commit 33d1f89930c76d7ef03ace2f1b382ded200f97c6 Author: jared Date: Mon Jan 19 22:45:04 2026 -0500 Initial commit Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03d00aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Virtual environment +.venv/ +venv/ +env/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so + +# macOS +.DS_Store + +# Media/test files +media/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Distribution +dist/ +build/ +*.egg-info/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..557f353 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# ffsuper.sh — FFmpeg helper + +`ffsuper.sh` is a Swiss-army FFmpeg helper that wraps common batch operations: resize presets, custom resizes, rotation, trimming, speedup, merge, lossless cut, 60fps interpolation, H.265 transcode, and simple finders. + +## Quick start +```bash +chmod +x ffsuper.sh +./ffsuper.sh help +./ffsuper.sh examples +``` + +## Subcommands +- `preset ` (or call the preset name directly): `medium|medium60|medium5994|large|large60|small60|resize60|default|noaudio|noaudio-small|169to43|43to169`. +- `resize [opts] [file]`: Custom resize with audio; omit `file` to batch all `.mp4` in the directory. +- `small [opts] [file]`: Alias for `resize` (defaults to 960x540@29.97). +- `rotate <90cw|90ccw|180> [resize opts] [file]`: Rotate then resize; omit `file` to batch all `.mp4`. +- `trim [end] `: Trim then (optionally) resize; outputs `basename_START-END.mp4` (same naming as `cut`) at source size by default. +- `speedup <2x|4x> [file]`: Speed video/audio; outputs `*_2x.mp4` or `*_4x.mp4`. No `file` means batch all `.mp4`. +- `merge [out]`: Concat all `*.mp4` into `merged.mp4` (or custom name); always processes all `.mp4` in the directory. +- `cut [end] `: Lossless segment copy (no re-encode). +- `interpolate60 [file]`: Motion interpolation to 60fps; omit `file` to batch all `.mp4`; outputs `*_60fps.mp4`. +- `h265 [file]`: Transcode to H.265, keep resolution, copy audio, safe subtitles; omit `file` to batch all `.mp4`. +- `find `: Probe current dir and list matching files. +- `help`, `examples`. + +## Common examples +- Default resize (960x540@29.97): `./ffsuper.sh resize` (options below) +- `./ffsuper.sh medium` → `*_medium.mp4` (1280x720@29.97). +- `./ffsuper.sh medium60` → `*_medium_60fps.mp4` (1280x720@60). +- `./ffsuper.sh resize60` → `*_60fps.mp4` (960x540@60). +- `./ffsuper.sh large` → `*_large.mp4` (1920x1080@29.97). +- `./ffsuper.sh large60` → `*_large_60fps.mp4` (1920x1080@60). +- `./ffsuper.sh small` → `*_resized.mp4` (960x540@29.97; alias of resize). +- `./ffsuper.sh small60` → `*_60fps.mp4` (960x540@59.94, ~7M). +- `./ffsuper.sh 169to43` → `*_aspectfixed.mp4` (force 4:3). +- `./ffsuper.sh 43to169` → `*_aspectfixed.mp4` (force 16:9). +- `./ffsuper.sh noaudio input.mp4` → `*_noaudio.mp4` (drop audio only, copy video, keep resolution). +- `./ffsuper.sh resize --no-audio` → `*_noaudio.mp4`. +- `./ffsuper.sh rotate 90cw` → `*_rotated.mp4`. +- `./ffsuper.sh rotate 180` → `*_rotated.mp4` (180° flip). +- `./ffsuper.sh merge myjoined.mp4` → concat all mp4s. +- `./ffsuper.sh cut 00:01:00 00:02:00 input.mp4` → lossless slice. +- `./ffsuper.sh h265 input.mp4` → H.265 output, same resolution. + +## Resize options (used by resize/rotate and presets) +- `--width N` (default 960) +- `--height N` (default 540) +- `--fps F` (default `30000/1001`; empty keeps source) +- `--bitrate BR` (default 2500k for HW encode) +- `--codec NAME` (`libx264` default, or `h264_videotoolbox`) +- `--audio MODE` (`aac` default, `copy`, or `none`) +- `--audio-br BR` (default 160k) +- `--suffix STR` (default `_resized`, overridden by some commands/presets) +- `--overwrite` (clobber outputs) +- `--dry-run` (print commands only) +- `--force-aspect R` (e.g., 4:3 or 16:9, applies setdar) +- `--no-audio` sets `RES_AUDIO=none` and default suffix `_noaudio`. + +## Notes and behaviors +- Rotation defaults to suffix `_rotated`. +- Trim keeps source resolution unless you pass explicit `--width/--height`. +- Trim output naming matches `cut`: `basename_START-END.mp4`. +- `noaudio` stream-copies video and drops audio without resizing. +- Presets set their own suffixes as noted above. +- Finder uses `ffprobe` to avoid brittle grep parsing. +- Except for `merge`, `trim`, `cut`, and `find`, providing a single mp4 limits processing to that file; omit it to process all `.mp4` in the directory. +- Existing outputs prompt for overwrite; `--overwrite` (where available) auto-accepts. + +## Dependencies +- FFmpeg/ffprobe in PATH (`brew install ffmpeg` on macOS). diff --git a/ffsuper.sh b/ffsuper.sh new file mode 100755 index 0000000..7abec76 --- /dev/null +++ b/ffsuper.sh @@ -0,0 +1,683 @@ +#!/usr/bin/env bash +# Swiss-army FFmpeg helper +# Supports batch resize presets, custom resizes, rotation, speedup, +# merge/concat helpers, finders, cuts, H.265 re-encode, and 60fps +# motion interpolation. +# +# Usage examples: +# ./ffsuper.sh help +# ./ffsuper.sh resize # default 960x540@29.97 +# ./ffsuper.sh resize --width 960 --height 540 --fps 30000/1001 \ +# --bitrate 2500k --codec h264_videotoolbox --audio aac \ +# --audio-br 160k --suffix _resized +# ./ffsuper.sh resize --no-audio # mute output, saves *_noaudio.mp4 +# ./ffsuper.sh medium # 1280x720@29.97fps to *_medium.mp4 +# ./ffsuper.sh medium60 # 1280x720@60fps to *_medium_60fps.mp4 +# ./ffsuper.sh resize60 # 960x540@60fps to *_60fps.mp4 +# ./ffsuper.sh large # 1920x1080@29.97fps to *_large.mp4 +# ./ffsuper.sh large60 # 1920x1080@60fps to *_large_60fps.mp4 +# ./ffsuper.sh small # 960x540@29.97fps to *_resized.mp4 +# ./ffsuper.sh small60 # 960x540@59.94fps, 7M to *_60fps.mp4 +# ./ffsuper.sh 43to169 # pillarbox 4:3 into 16:9 frame, *_aspectfixed.mp4 +# ./ffsuper.sh half # resize to 50% of original dimensions, *_half.mp4 +# ./ffsuper.sh rotate 90cw # rotate right, saves *_rotated.mp4 +# ./ffsuper.sh rotate 90ccw --width 1280 --height 720 # rotate left + resize +# ./ffsuper.sh rotate 180 # rotate 180°, saves *_rotated.mp4 +# ./ffsuper.sh trim 00:00:05 00:00:12 input.mp4 # trim segment, saves *_trimmed.mp4 +# ./ffsuper.sh noaudio input.mp4 # drop audio only, keeps resolution, saves *_noaudio.mp4 +# ./ffsuper.sh speedup 2x # make *_2x.mp4 +# ./ffsuper.sh speedup 4x # make *_4x.mp4 +# ./ffsuper.sh merge [out.mp4] # concat all mp4s -> merged.mp4 +# ./ffsuper.sh cut 00:01:00 00:02:00 input.mp4 # lossless cut (stream copy) +# ./ffsuper.sh interpolate60 input.mp4 # motion interpolation to 60fps +# ./ffsuper.sh h265 input.mp4 # transcode to h265, keep resolution +# ./ffsuper.sh find hevc|60fps|large|medium # list files matching probe + +set -Eeuo pipefail + +DEFAULT_SUFFIX="_resized" +declare -a TARGETS=() +RES_OVERWRITE=0 +PREFIX_FILTERS="" +SCALE_FIT_PAD=0 +SCALE_HALF=0 + +log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; } +die() { printf 'Error: %s\n' "$*" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } +quote() { printf '%q' "$1"; } + +require_ffmpeg() { have ffmpeg || die "ffmpeg not found. Install via brew install ffmpeg"; } + +help_text() { + cat <<'EOF' +ffsuper.sh — consolidated FFmpeg helper + +Subcommands: + resize [opts] [file] Custom resize (with audio), default 960x540@29.97. See options below. + small [opts] [file] Alias for resize (default 960x540@29.97). + help Show this help. + examples Show the usage examples listed in this file. + preset [file] Run a named preset (or call the preset name directly): + medium|medium60|medium5994|large|large60|small60|resize60| + default|noaudio|noaudio-small|43to169|half. + rotate <90cw|90ccw|180> [resize opts] [file] + Rotate then resize using resize options/presets. + trim [end] Trim then resize; outputs basename_START-END.mp4. + speedup <2x|4x> [file] Speed video (and audio) by 2x or 4x. + merge [out] Concat all *.mp4 into merged.mp4 (or custom name). + cut [end] Cut a segment without re-encoding (stream copy). + interpolate60 [file] Motion-interpolate to 60fps (single input or all *.mp4). + h265 [file] Re-encode to H.265 (single input or all *.mp4). + find Probe directory and list matches. + +Resize options (for resize/rotate commands): + --width N Max width (default 960) + --height N Max height (default 540) + --fps F Target fps (default 30000/1001 ≈29.97). Empty keeps source. + --bitrate BR Video bitrate (default 2500k for HW encode) + --codec NAME libx264 (default) or h264_videotoolbox + --audio MODE aac (default), copy, or none + --audio-br BR AAC bitrate (default 160k) + --suffix STR Output suffix before .mp4 (default "_resized") + --overwrite Overwrite existing outputs + --dry-run Print commands only + --force-aspect R Set display aspect (e.g., 4:3 or 16:9) before scaling + +Notes: + - You can call presets directly (e.g., "medium60") or via "preset medium60". + - Existing outputs trigger an overwrite prompt; --overwrite auto-accepts where supported. + - merge always processes all *.mp4 in the current directory. + - trim and cut always expect a single input file. + - All other processing commands accept an optional mp4; omit it to batch all *.mp4 in the directory. +EOF +} + +show_examples() { + local file="${BASH_SOURCE[0]:-$0}" + [[ -r "$file" ]] || die "Cannot read examples from $file" + echo "Usage examples:" + awk ' + /^# Usage examples:/ { inblock=1; next } + inblock && /^#/ { + line=$0 + sub(/^# ?/, "", line) + if (line ~ /^[[:space:]]*$/) next + print line + next + } + inblock { exit } + ' "$file" +} + +parse_resize_opts() { + # defaults + RES_OVERWRITE=0 + if [[ "${KEEP_SOURCE_SIZE:-0}" -eq 1 ]]; then + RES_W=""; RES_H="" + else + RES_W=960; RES_H=540 + fi + RES_FPS="30000/1001"; RES_BR="2500k" + RES_CODEC="libx264"; RES_AUDIO="aac"; RES_ABR="160k" + RES_SUFFIX="${DEFAULT_SUFFIX:-_resized}"; RES_OVERWRITE=0; RES_DRYRUN=0; RES_FORCE_ASPECT="" + local suffix_explicit=0 + while [[ $# -gt 0 ]]; do + case "$1" in + --width) RES_W="${2:?}"; shift 2 ;; + --height) RES_H="${2:?}"; shift 2 ;; + --fps) RES_FPS="${2:-}"; shift 2 ;; + --bitrate) RES_BR="${2:?}"; shift 2 ;; + --codec) RES_CODEC="${2:?}"; shift 2 ;; + --audio) RES_AUDIO="${2:?}"; shift 2 ;; + --audio-br) RES_ABR="${2:?}"; shift 2 ;; + --suffix) RES_SUFFIX="${2:?}"; suffix_explicit=1; shift 2 ;; + --overwrite) RES_OVERWRITE=1; shift ;; + --dry-run) RES_DRYRUN=1; shift ;; + --force-aspect) RES_FORCE_ASPECT="${2:?}"; shift 2 ;; + "\\") shift ;; # tolerate stray line-continuation backslash + --no-audio) + RES_AUDIO="none" + [[ $suffix_explicit -eq 0 ]] && RES_SUFFIX="_noaudio" + shift + ;; + *) break ;; + esac + done + REMAINDER=("$@") +} + +collect_mp4_targets() { + local explicit="${1:-}" + TARGETS=() + if [[ -n "$explicit" ]]; then + [[ "${explicit,,}" == *.mp4 ]] || die "Input must be an mp4 file: $explicit" + [[ -f "$explicit" ]] || die "File not found: $explicit" + TARGETS+=("$explicit") + return + fi + + while IFS= read -r -d '' f; do + f="${f#./}" + TARGETS+=("$f") + done < <(find . -maxdepth 1 -type f -iname "*.mp4" -print0) + + [[ ${#TARGETS[@]} -gt 0 ]] || die "No mp4 files found to process in $(pwd)" +} + +confirm_overwrite() { + local target="$1" + local allow="${RES_OVERWRITE:-0}" + if [[ -e "$target" && $allow -eq 0 ]]; then + read -r -p "Overwrite $target? [y/N] " reply + if [[ ! "$reply" =~ ^[Yy]$ ]]; then + log "SKIP (exists): $target" + return 1 + fi + fi + return 0 +} + +resize_one() { + local in="$1" rotation="$2" prefix_filters="$3" + local base="${in%.*}" + local out="${base}${RES_SUFFIX}.mp4" + + if [[ "$in" == *"${RES_SUFFIX}.mp4" ]]; then + log "SKIP (already has suffix): $in" + return + fi + if ! confirm_overwrite "$out"; then + return 0 + fi + + local vf="" + if [[ "${SCALE_HALF:-0}" -eq 1 ]]; then + # Scale to half of original dimensions. + vf="scale=iw/2:ih/2:flags=bicubic,setsar=1" + elif [[ "${SCALE_FIT_PAD:-0}" -eq 1 ]]; then + # Scale preserving aspect ratio, then pad to target dimensions (pillarbox/letterbox). + vf="scale=${RES_W}:${RES_H}:force_original_aspect_ratio=decrease,pad=${RES_W}:${RES_H}:(ow-iw)/2:(oh-ih)/2,setsar=1" + elif [[ -n "${RES_W:-}" && -n "${RES_H:-}" ]]; then + # Hard scale to exact dimensions by default, no aspect preservation or padding. + vf="scale=${RES_W}:${RES_H}:flags=bicubic,setsar=1" + fi + if [[ -n "$rotation" ]]; then + case "$rotation" in + 90cw) vf="${vf:+${vf},}transpose=1" ;; + 90ccw) vf="${vf:+${vf},}transpose=2" ;; + 180) vf="${vf:+${vf},}transpose=2,transpose=2" ;; + esac + fi + [[ -n "$prefix_filters" ]] && vf="${prefix_filters},${vf}" + if [[ -n "$RES_FORCE_ASPECT" ]]; then + vf="${vf},setdar=${RES_FORCE_ASPECT}" + fi + + local copy_video=0 + if [[ -z "$vf" && -z "$rotation" && -z "$prefix_filters" && -z "${RES_FORCE_ASPECT:-}" && -z "${RES_W:-}" && -z "${RES_H:-}" ]]; then + copy_video=1 + fi + + local vcodec=() + if [[ $copy_video -eq 1 ]]; then + vcodec=(-c:v copy) + elif [[ "$RES_CODEC" == "h264_videotoolbox" ]]; then + vcodec=(-c:v h264_videotoolbox -b:v "$RES_BR" -pix_fmt yuv420p) + else + vcodec=(-c:v libx264 -preset slow -crf 23 -pix_fmt yuv420p) + fi + + local acodec=() + case "$RES_AUDIO" in + none) acodec=(-an) ;; + copy) acodec=(-c:a copy) ;; + aac|*) acodec=(-c:a aac -b:a "$RES_ABR") ;; + esac + + local fps_opts=() + if [[ $copy_video -eq 0 && -n "$RES_FPS" ]]; then + fps_opts=(-r "$RES_FPS" -vsync cfr) + fi + + log "IN : $in" + log "OUT: $out" + [[ -n "$vf" ]] && log "VF : $vf" || log "VF : (none)" + + if [[ $RES_DRYRUN -eq 1 ]]; then + if [[ -n "$vf" ]]; then + echo "ffmpeg -i $(quote "$in") -map 0:v:0 -map 0:a? -vf \"$vf\" ${vcodec[*]} ${acodec[*]} -movflags +faststart -threads 0 ${fps_opts[*]} -y $(quote "$out")" + else + echo "ffmpeg -i $(quote "$in") -map 0:v:0 -map 0:a? ${vcodec[*]} ${acodec[*]} -movflags +faststart -threads 0 ${fps_opts[*]} -y $(quote "$out")" + fi + return + fi + + if [[ -n "$vf" ]]; then + nice -n 20 ffmpeg -hide_banner -loglevel error -stats \ + -i "$in" -map 0:v:0 -map 0:a? \ + -vf "$vf" \ + "${vcodec[@]}" "${acodec[@]}" \ + -movflags +faststart -threads 0 \ + "${fps_opts[@]}" \ + -y "$out" + else + nice -n 20 ffmpeg -hide_banner -loglevel error -stats \ + -i "$in" -map 0:v:0 -map 0:a? \ + "${vcodec[@]}" "${acodec[@]}" \ + -movflags +faststart -threads 0 \ + "${fps_opts[@]}" \ + -y "$out" + fi + + log "DONE: $out" +} + +run_resize_batch() { + require_ffmpeg + local rotation="" + if [[ $# -gt 0 && ( "$1" == 90cw || "$1" == 90ccw || "$1" == 180 ) ]]; then + rotation="$1" + shift + fi + if [[ -n "$rotation" ]]; then + DEFAULT_SUFFIX="_rotated" + else + DEFAULT_SUFFIX="_resized" + fi + parse_resize_opts "$@" + set -- "${REMAINDER[@]}" + local explicit_input="" + if [[ $# -gt 1 ]]; then + die "Only one input mp4 may be specified" + elif [[ $# -eq 1 ]]; then + explicit_input="$1" + fi + + collect_mp4_targets "$explicit_input" + local prefix_filters="${PREFIX_FILTERS:-}" + PREFIX_FILTERS="" + for f in "${TARGETS[@]}"; do + [[ "$f" == *"${RES_SUFFIX}.mp4" ]] && { log "SKIP (already has suffix): $f"; continue; } + resize_one "$f" "$rotation" "$prefix_filters" + done +} + +preset_resize() { + local name="$1" + shift || true + case "$name" in + medium) + run_resize_batch --width 1280 --height 720 --fps 30000/1001 --bitrate 2700k --suffix _medium "$@" + ;; + medium60) + run_resize_batch --width 1280 --height 720 --fps 60 --bitrate 5400k --suffix _medium_60fps "$@" + ;; + medium5994) + run_resize_batch --width 1280 --height 720 --fps 59.94 --bitrate 7000k --suffix _mobile "$@" + ;; + large) + run_resize_batch --width 1920 --height 1080 --fps 30000/1001 --bitrate 6000k --suffix _large "$@" + ;; + large60) + run_resize_batch --width 1920 --height 1080 --fps 60 --bitrate 10000k --suffix _large_60fps "$@" + ;; + resize60) + run_resize_batch --width 960 --height 540 --fps 60 --bitrate 4000k --suffix _60fps "$@" + ;; + small60) + run_resize_batch --width 960 --height 540 --fps 59.94 --bitrate 7000k --suffix _60fps "$@" + ;; + default) + run_resize_batch "$@" + ;; + half) + SCALE_HALF=1 + run_resize_batch --suffix _half "$@" + SCALE_HALF=0 + ;; + 43to169) + SCALE_FIT_PAD=1 + run_resize_batch --width 960 --height 540 --fps 29 --bitrate 1500k --suffix _aspectfixed "$@" + SCALE_FIT_PAD=0 + ;; + noaudio) + local prev_keep="${KEEP_SOURCE_SIZE:-0}" + KEEP_SOURCE_SIZE=1 + run_resize_batch --no-audio --suffix _noaudio "$@" + KEEP_SOURCE_SIZE="$prev_keep" + ;; + noaudio-small) + run_resize_batch --no-audio --width 720 --height 480 --fps 29 --bitrate 500k --suffix _mobile "$@" + ;; + *) + die "Unknown preset: $name" + ;; + esac +} + +trim_and_resize() { + [[ $# -lt 2 ]] && die "Usage: trim START [END] FILE" + local start="$1"; shift + local end="" file="" + local prev_keep="${KEEP_SOURCE_SIZE:-0}" + KEEP_SOURCE_SIZE=1 + if [[ $# -eq 1 ]]; then + file="$1" + else + end="$1"; file="$2" + fi + [[ -f "$file" ]] || die "File not found: $file" + [[ "${file,,}" == *.mp4 ]] || die "Trim expects an mp4 input: $file" + require_ffmpeg + parse_resize_opts + KEEP_SOURCE_SIZE="$prev_keep" + local base="${file%.*}" ext="${file##*.}" + local clean_start="${start//:/}" + local out + if [[ -n "$end" ]]; then + local clean_end="${end//:/}" + out="${base}_${clean_start}-${clean_end}.${ext}" + else + out="${base}_${clean_start}-end.${ext}" + fi + if ! confirm_overwrite "$out"; then + return 0 + fi + + local vf + if [[ -n "$RES_W" && -n "$RES_H" ]]; then + vf="scale=${RES_W}:${RES_H}:force_original_aspect_ratio=decrease,pad=${RES_W}:${RES_H}:(ow-iw)/2:(oh-ih)/2,setsar=1" + else + vf="setsar=1" + fi + if [[ -n "$RES_FORCE_ASPECT" ]]; then + vf="${vf},setdar=${RES_FORCE_ASPECT}" + fi + local acodec=() + case "$RES_AUDIO" in + none) acodec=(-an) ;; + copy) acodec=(-c:a copy) ;; + *) acodec=(-c:a aac -b:a "$RES_ABR") ;; + esac + local fps_opts=() + [[ -n "$RES_FPS" ]] && fps_opts=(-r "$RES_FPS" -vsync cfr) + local t_opts=("-ss" "$start") + [[ -n "$end" ]] && t_opts+=("-to" "$end") + + log "Trim+resize $file -> $out" + nice -n 20 ffmpeg -hide_banner -loglevel error -stats \ + "${t_opts[@]}" -i "$file" \ + -map 0:v:0 -map 0:a? \ + -vf "$vf" \ + -c:v libx264 -b:v "$RES_BR" -pix_fmt yuv420p \ + "${acodec[@]}" \ + -movflags +faststart -threads 0 \ + "${fps_opts[@]}" \ + -y "$out" +} + +speedup() { + local factor="$1"; shift + [[ "$factor" == "2x" || "$factor" == "4x" ]] || die "Use speedup 2x|4x" + require_ffmpeg + local explicit_input="" + if [[ $# -gt 1 ]]; then + die "Only one input mp4 may be specified" + elif [[ $# -eq 1 ]]; then + explicit_input="$1" + fi + + collect_mp4_targets "$explicit_input" + for f in "${TARGETS[@]}"; do + local base="${f%.*}" + local out="${base}_${factor}.mp4" + confirm_overwrite "$out" || continue + log "Speed $factor: $f" + if [[ "$factor" == "2x" ]]; then + nice -n 20 ffmpeg -hide_banner -loglevel error -stats \ + -i "$f" -r 60 \ + -filter:v "setpts=0.5*PTS" -filter:a "atempo=2.0" \ + "$out" + else + nice -n 20 ffmpeg -hide_banner -loglevel error -stats \ + -i "$f" \ + -filter_complex "[0:v]setpts=0.25*PTS[v];[0:a]atempo=2.0,atempo=2.0[a]" \ + -map "[v]" -map "[a]" \ + "$out" + fi + done +} + +merge_all() { + require_ffmpeg + local out="${1:-merged.mp4}" + local listfile + + # Create temp file safely + listfile=$(mktemp -t ffsuper-merge) || die "mktemp failed" + + # SAFETY: Ensure temp file is deleted when the function returns or script exits + trap 'rm -f "$listfile"' RETURN + + # COMPATIBILITY CHECK: + # Check if the 'sort' command available to THIS script supports -V. + # macOS /usr/bin/sort usually does not, causing the script to crash under 'set -e'. + local sort_opt="" + if sort -V /dev/null 2>&1; then + sort_opt="-V" + fi + + # Find files, sort them using the safe option, and write to list + local count=0 + while IFS= read -r f; do + # Strip leading ./ for cleaner path construction + local clean_path="${f#./}" + + # Format absolute path for ffmpeg concat safe file + # Handles single quotes in filenames by escaping them + printf "file '%s/%s'\n" "$PWD" "${clean_path//\'/\047}" >>"$listfile" + ((count++)) + done < <(find . -maxdepth 1 -iname "*.mp4" -not -name ".*" | sort $sort_opt) + + if [[ $count -eq 0 ]]; then + die "No .mp4 files found in $PWD to merge." + fi + + if ! confirm_overwrite "$out"; then + return 0 + fi + + log "Merging $count files..." + ffmpeg -hide_banner -loglevel error -f concat -safe 0 -i "$listfile" -c copy -y "$out" + log "Created: $out" +} + +cut_segment() { + [[ $# -lt 2 ]] && die "Usage: cut START [END] FILE" + local start="$1"; shift + local end="" file="" + if [[ $# -eq 1 ]]; then + file="$1" + else + end="$1"; file="$2" + fi + [[ -f "$file" ]] || die "File not found: $file" + [[ "${file,,}" == *.mp4 ]] || die "Cut expects an mp4 input: $file" + require_ffmpeg + local base="${file%.*}" ext="${file##*.}" + local clean_start="${start//:/}" + local out + if [[ -n "$end" ]]; then + local clean_end="${end//:/}" + out="${base}_${clean_start}-${clean_end}.${ext}" + if ! confirm_overwrite "$out"; then + return 0 + fi + ffmpeg -hide_banner -loglevel error -ss "$start" -to "$end" -i "$file" -c copy -y "$out" + else + out="${base}_${clean_start}-end.${ext}" + if ! confirm_overwrite "$out"; then + return 0 + fi + ffmpeg -hide_banner -loglevel error -ss "$start" -i "$file" -c copy -y "$out" + fi + log "Created: $out" +} + +interpolate60() { + [[ $# -le 1 ]] || die "Usage: interpolate60 [INPUT]" + local explicit_input="${1:-}" + require_ffmpeg + collect_mp4_targets "$explicit_input" + for in in "${TARGETS[@]}"; do + local base="${in%.*}" + local dir + dir="$(dirname -- "$in")" + local out="${dir}/${base##*/}_60fps.mp4" + confirm_overwrite "$out" || continue + log "Interpolating to 60fps: $in -> $out" + ffmpeg -hide_banner -loglevel info \ + -i "$in" \ + -vf "minterpolate=fps=60:mi_mode=mci:me=epzs" \ + -c:v libx264 -crf 18 -preset slow -pix_fmt yuv420p \ + -c:a copy \ + "$out" + done +} + +h265_encode() { + [[ $# -le 1 ]] || die "Usage: h265 [INPUT]" + local explicit_input="${1:-}" + require_ffmpeg + have ffprobe || die "ffprobe required for subtitle compatibility check" + + collect_mp4_targets "$explicit_input" + for in in "${TARGETS[@]}"; do + local filename="$(basename -- "$in")" + local ext="${filename##*.}" + local base="${filename%.*}" + local out="${base}_h265.${ext}" + confirm_overwrite "$out" || continue + + local sub_codec="copy" + local subs + subs="$(ffprobe -v error -select_streams s -show_entries stream=codec_name -of csv=p=0 "$in" 2>/dev/null | sort -u)" + if [[ -n "$subs" && "$ext" =~ ^(mp4|MP4)$ ]]; then + if echo "$subs" | grep -qv '^mov_text$'; then + sub_codec="mov_text" + log "Non-mov_text subtitles detected ($subs); converting to mov_text." + fi + fi + + ffmpeg -hide_banner -ss 0 -i "$in" \ + -map 0 \ + -c:v libx265 -preset ultrafast \ + -x265-params "crf=20:qcomp=0.8:aq-mode=1:aq_strength=1.0:qg-size=16:psy-rd=0.7:psy-rdoq=5.0:rdoq-level=1:merange=44" \ + -c:a copy \ + -c:s "$sub_codec" \ + -y "$out" + log "Created: $out" + done +} + +finder() { + local mode="$1" + require_ffmpeg + case "$mode" in + hevc) + echo "h265 media:" + for i in *; do + [[ -f "$i" ]] || continue + [[ "${i,,}" == *.mp4 ]] || continue + if ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of csv=p=0 "$i" 2>/dev/null \ + | grep -qi 'hevc'; then + echo "$i" + fi + done + ;; + 60fps) + echo "60fps media:" + for i in *; do + [[ -f "$i" ]] || continue + [[ "${i,,}" == *.mp4 ]] || continue + local rate + rate="$(ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 "$i" 2>/dev/null || true)" + [[ -z "$rate" ]] && continue + if awk -v r="$rate" 'BEGIN{split(r,a,"/"); v=(length(a)==2)?a[1]/a[2]:a[1]; if(v>=50) exit 0; exit 1;}'; then + echo "$i" + fi + done + ;; + large) + echo "Large 1440|1852|1920|2030|2048 media:" + for i in *; do + [[ -f "$i" ]] || continue + [[ "${i,,}" == *.mp4 ]] || continue + local width + width="$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "$i" 2>/dev/null || true)" + case "$width" in + 1440|1852|1920|2030|2048) echo "$i" ;; + esac + done + ;; + medium) + echo "Medium 1280|1440 media:" + for i in *; do + [[ -f "$i" ]] || continue + [[ "${i,,}" == *.mp4 ]] || continue + local width + width="$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "$i" 2>/dev/null || true)" + case "$width" in + 1280|1440) echo "$i" ;; + esac + done + ;; + *) die "Unknown find mode: $mode" ;; + esac +} + +is_preset_name() { + case "$1" in + medium|medium60|medium5994|large|large60|resize60|default|noaudio|noaudio-small|43to169|small60|half) + return 0 ;; + *) return 1 ;; + esac +} + +main() { + local argc=$# + local cmd="${1:-help}" + shift || true + if is_preset_name "$cmd"; then + preset_resize "$cmd" "$@" + return + fi + case "$cmd" in + help|-h|--help) + help_text + if [[ $argc -eq 0 ]]; then + echo + show_examples + fi + ;; + examples) show_examples ;; + preset) [[ $# -ge 1 ]] || die "preset name required"; preset_resize "$@" ;; + resize) run_resize_batch "$@" ;; + small) run_resize_batch "$@" ;; + rotate) run_resize_batch "$1" "${@:2}" ;; + trim) trim_and_resize "$@" ;; + speedup) [[ $# -ge 1 ]] || die "speedup needs 2x|4x"; speedup "$@" ;; + merge) + local out="${1:-merged.mp4}" + merge_all "$out" + ;; + cut) cut_segment "$@" ;; + interpolate60) interpolate60 "$@" ;; + h265) h265_encode "$@" ;; + find) [[ $# -ge 1 ]] || die "find mode required"; finder "$1" ;; + *) die "Unknown command: $cmd" ;; + esac +} + +main "$@" diff --git a/screen_resolutions.txt b/screen_resolutions.txt new file mode 100644 index 0000000..cfe434b --- /dev/null +++ b/screen_resolutions.txt @@ -0,0 +1,16 @@ +16:9 Resolutions + • 720×480 + • 960×540 + • 1280×720 + • 1600×900 + • 1920×1080 + +4:3 Resolutions + • 320×240 + • 480×360 + • 640×480 + • 720×480 + • 800×600 + • 1024×768 + • 1280×960 + • 1920×1440