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