684 lines
22 KiB
Bash
Executable File
684 lines
22 KiB
Bash
Executable File
#!/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 "$@"
|