Initial commit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 22:45:04 -05:00
commit 33d1f89930
4 changed files with 796 additions and 0 deletions

27
.gitignore vendored Normal file
View File

@@ -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/

70
README.md Normal file
View File

@@ -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 <name>` (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 <start> [end] <file>`: 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 <start> [end] <file>`: 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 <hevc|60fps|large|medium>`: 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).

683
ffsuper.sh Executable file
View File

@@ -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 <name> [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 <start> [end] <file> 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 <start> [end] <file> 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 <hevc|60fps|large|medium> 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 >/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 "$@"

16
screen_resolutions.txt Normal file
View File

@@ -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