83 lines
3.5 KiB
Bash
Executable file
83 lines
3.5 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Extract a clip to a PNG sequence + audio + manifest for the take builder.
|
|
#
|
|
# Frames are pre-extracted rather than decoded in the page on purpose: browser
|
|
# video seeking by currentTime is approximate and requestVideoFrameCallback only
|
|
# delivers frames at playback speed, so neither gives a deterministic per-frame
|
|
# pass. A PNG sequence is exact, instantly seekable, and reproducible.
|
|
#
|
|
# Audio comes out alongside because the page uses it as the PLAYBACK CLOCK -
|
|
# frame = floor(audio.currentTime * fps). Detection sees every decoded source
|
|
# frame; a lower drawing rate is a later playback choice, never an extraction
|
|
# choice. This script accepts CFR footage because frame-index timing needs a
|
|
# single rate. VFR needs per-frame timestamps in the manifest first.
|
|
set -euo pipefail
|
|
|
|
src="${1:?usage: ./extract.sh CLIP [BUNDLE_DIR]}"
|
|
bundle="${2:-.}"
|
|
if [[ "$bundle" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
|
|
echo "The FPS argument was removed: extraction always keeps the source rate. Use a directory as argument 2." >&2
|
|
exit 2
|
|
fi
|
|
if [[ "$bundle" = /* || "$bundle" = *..* ]]; then
|
|
echo "BUNDLE_DIR must be a relative directory inside this repo" >&2
|
|
exit 2
|
|
fi
|
|
|
|
probe=$(ffprobe -v error -select_streams v:0 \
|
|
-show_entries stream=r_frame_rate,avg_frame_rate,nb_frames \
|
|
-of json "$src")
|
|
fps=$(python3 -c '
|
|
import json, sys
|
|
from fractions import Fraction
|
|
streams = json.load(sys.stdin).get("streams", [])
|
|
if not streams:
|
|
raise SystemExit("no video stream in source")
|
|
s = streams[0]
|
|
nominal = Fraction(s["r_frame_rate"])
|
|
average = Fraction(s["avg_frame_rate"])
|
|
if nominal <= 0 or average <= 0 or abs(float(nominal / average) - 1) > 0.001:
|
|
raise SystemExit("variable-frame-rate source needs timestamp-aware playback; refusing to guess its fps")
|
|
print(float(average))
|
|
' <<< "$probe")
|
|
|
|
if [[ "$bundle" = "." ]]; then
|
|
dir="frames"; audio_path="audio.wav"; manifest_path="manifest.json"
|
|
else
|
|
dir="${bundle%/}/frames"
|
|
audio_path="${bundle%/}/audio.wav"
|
|
manifest_path="${bundle%/}/manifest.json"
|
|
fi
|
|
|
|
rm -rf "$dir"; mkdir -p "$dir"
|
|
ffmpeg -hide_banner -loglevel warning -i "$src" -fps_mode passthrough "$dir/%04d.png"
|
|
count=$(find "$dir" -maxdepth 1 -name '*.png' -type f | wc -l | tr -d ' ')
|
|
|
|
expected=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["streams"][0].get("nb_frames", ""))' <<< "$probe")
|
|
if [[ "$expected" =~ ^[0-9]+$ && "$count" != "$expected" ]]; then
|
|
echo "decoded $count frames but source reports $expected; refusing an inaccurate manifest" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Mono is enough for judging sync and halves the file. A silent clock lets a
|
|
# mute source use the same audio-driven transport.
|
|
if ffprobe -v error -select_streams a:0 -show_entries stream=codec_type \
|
|
-of csv=p=0 "$src" 2>/dev/null | grep -q audio; then
|
|
ffmpeg -hide_banner -loglevel warning -y -i "$src" -vn -ac 1 -ar 44100 "$audio_path"
|
|
else
|
|
duration=$(python3 -c 'import sys; print(int(sys.argv[1]) / float(sys.argv[2]))' "$count" "$fps")
|
|
ffmpeg -hide_banner -loglevel warning -y -f lavfi -i anullsrc=r=44100:cl=mono \
|
|
-t "$duration" -c:a pcm_s16le "$audio_path"
|
|
fi
|
|
|
|
# JSON escaping belongs to a JSON writer, especially for source filenames.
|
|
python3 - "$fps" "$count" "$dir" "$audio_path" "$src" "$manifest_path" <<'PY'
|
|
import json, os, sys
|
|
fps, count, frames, audio, source, path = sys.argv[1:]
|
|
with open(path, "w") as out:
|
|
json.dump({"fps": float(fps), "frames": int(count), "dir": frames,
|
|
"audio": audio, "source": os.path.basename(source)}, out)
|
|
out.write("\n")
|
|
PY
|
|
|
|
echo "$count source frames at ${fps}fps -> $dir/ ($manifest_path written)"
|