#!/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 "$@"