feature: H.264 script
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
# dji264 - convert DJI Action 4 clips (10-bit HEVC) to 8-bit H.264 for DaVinci Resolve.
|
||||
#
|
||||
# Free Resolve on Windows cannot decode 10-bit HEVC, so Action 4 footage imports with
|
||||
# audio but no video. This re-encodes to 8-bit H.264, which Resolve decodes natively.
|
||||
#
|
||||
# dji264 convert every .MP4 in the current directory
|
||||
# dji264 clip.MP4 convert one file
|
||||
# dji264 /path/to/shoot convert every .MP4 in that directory
|
||||
# dji264 -o D:\out *.MP4 choose the output directory
|
||||
# dji264 -b 90M clip.MP4 override the video bitrate
|
||||
# dji264 -f clip.MP4 re-convert even if a good output already exists
|
||||
#
|
||||
# Output lands in an H264/ subdirectory beside each source unless -o is given.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
BITRATE=${BITRATE:-70M} # VBR target
|
||||
MAXRATE=${MAXRATE:-95M} # ceiling; keep ~1.35x the target
|
||||
BUFSIZE=${BUFSIZE:-140M}
|
||||
OUTDIR=""
|
||||
FORCE=0
|
||||
|
||||
die() { echo "dji264: $*" >&2; exit 1; }
|
||||
|
||||
usage() { sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0; }
|
||||
|
||||
while getopts ":o:b:m:fh" opt; do
|
||||
case $opt in
|
||||
o) OUTDIR=$OPTARG ;;
|
||||
b) BITRATE=$OPTARG ;;
|
||||
m) MAXRATE=$OPTARG ;;
|
||||
f) FORCE=1 ;;
|
||||
h) usage ;;
|
||||
\?) die "unknown option -$OPTARG (try -h)" ;;
|
||||
:) die "-$OPTARG needs a value" ;;
|
||||
esac
|
||||
done
|
||||
shift $((OPTIND - 1))
|
||||
|
||||
command -v ffmpeg >/dev/null || die "ffmpeg not found on PATH"
|
||||
command -v ffprobe >/dev/null || die "ffprobe not found on PATH"
|
||||
|
||||
# Collect inputs: files as given, directories expanded to their .MP4 contents.
|
||||
inputs=()
|
||||
if [ $# -eq 0 ]; then set -- .; fi
|
||||
for arg in "$@"; do
|
||||
if [ -d "$arg" ]; then
|
||||
while IFS= read -r f; do inputs+=("$f"); done \
|
||||
< <(find "$arg" -maxdepth 1 -type f \( -iname '*.mp4' -o -iname '*.mov' \) | sort)
|
||||
elif [ -f "$arg" ]; then
|
||||
inputs+=("$arg")
|
||||
else
|
||||
echo " skip (not found): $arg" >&2
|
||||
fi
|
||||
done
|
||||
[ ${#inputs[@]} -gt 0 ] || die "no input files found"
|
||||
|
||||
probe() { ffprobe -v error -select_streams v:0 -show_entries "$2" -of default=nw=1:nk=1 "$1" 2>/dev/null; }
|
||||
|
||||
ok=0; failed=0; skipped=0
|
||||
started=$(date +%s)
|
||||
|
||||
for src in "${inputs[@]}"; do
|
||||
base=$(basename "$src"); stem=${base%.*}
|
||||
dest_dir=${OUTDIR:-"$(dirname "$src")/H264"}
|
||||
mkdir -p "$dest_dir" || die "cannot create $dest_dir"
|
||||
out="$dest_dir/$stem.mp4"
|
||||
|
||||
# Source facts we need: frame count (to verify against), fps (to size the GOP),
|
||||
# timecode (to carry across), and pixel format (to spot footage that needs no work).
|
||||
src_frames=$(probe "$src" stream=nb_frames)
|
||||
rfr=$(probe "$src" stream=r_frame_rate)
|
||||
pixfmt=$(probe "$src" stream=pix_fmt)
|
||||
tc=$(ffprobe -v error -select_streams v:0 -show_entries stream_tags=timecode \
|
||||
-of default=nw=1:nk=1 "$src" 2>/dev/null | head -1)
|
||||
|
||||
[ -n "$rfr" ] || { echo " skip (no video stream): $base"; skipped=$((skipped+1)); continue; }
|
||||
|
||||
# DJI records a keyframe roughly every 0.5s. NVENC would otherwise default to a
|
||||
# 250-frame GOP, which makes the result far worse to scrub than the source for no
|
||||
# saving - at this bitrate the extra keyframes are free.
|
||||
gop=$(awk -v r="$rfr" 'BEGIN{split(r,a,"/"); d=(a[2]?a[2]:1); f=a[1]/d;
|
||||
g=int(f/2+0.5); if(g<1)g=1; print g}')
|
||||
|
||||
case $pixfmt in
|
||||
yuv420p) echo " note: $base is already 8-bit - Resolve can read it as-is" ;;
|
||||
esac
|
||||
|
||||
# Idempotent: leave a previous good conversion alone unless -f.
|
||||
if [ $FORCE -eq 0 ] && [ -f "$out" ]; then
|
||||
if [ -n "$src_frames" ] && [ "$(probe "$out" stream=nb_frames)" = "$src_frames" ]; then
|
||||
echo " skip (already converted): $base"; skipped=$((skipped+1)); continue
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "==> $base (${rfr} fps, GOP $gop, $BITRATE VBR)"
|
||||
ffmpeg -y -v error -stats -hwaccel cuda -i "$src" \
|
||||
-map 0:v:0 -map 0:a:0 \
|
||||
-c:v h264_nvenc -preset slow -rc vbr \
|
||||
-b:v "$BITRATE" -maxrate "$MAXRATE" -bufsize "$BUFSIZE" \
|
||||
-pix_fmt yuv420p -profile:v high -g "$gop" -bf 3 -spatial-aq 1 \
|
||||
-color_primaries bt709 -color_trc bt709 -colorspace bt709 \
|
||||
${tc:+-timecode "$tc"} \
|
||||
-c:a copy \
|
||||
"$out" 2>&1 | tr '\r' '\n' | tail -1
|
||||
rc=${PIPESTATUS[0]}
|
||||
|
||||
# Verify rather than trust. A dropped USB drive mid-encode leaves a *playable* but
|
||||
# truncated file and ffmpeg still tears down cleanly, so the frame count is the
|
||||
# check that actually catches it.
|
||||
out_frames=$(probe "$out" stream=nb_frames)
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo " FAILED ffmpeg exit $rc"; failed=$((failed+1))
|
||||
elif [ -n "$src_frames" ] && [ "$out_frames" != "$src_frames" ]; then
|
||||
echo " FAILED truncated: $out_frames of $src_frames frames"; failed=$((failed+1))
|
||||
else
|
||||
sz=$(stat -c%s "$out" 2>/dev/null || echo 0)
|
||||
echo " ok $out_frames frames $(awk -v s="$sz" 'BEGIN{printf "%.2f GB", s/1073741824}')"
|
||||
ok=$((ok+1))
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n%d converted, %d skipped, %d failed (%dm%02ds)\n' \
|
||||
"$ok" "$skipped" "$failed" $(( ($(date +%s)-started)/60 )) $(( ($(date +%s)-started)%60 ))
|
||||
[ "$failed" -eq 0 ]
|
||||
Reference in New Issue
Block a user