Setting the file. One moment.
Frame Strip · Remotion To Hyperframes · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page (opens in a new tab)
scripts/ frame_strip.sh
Shell · 107 lines · 4 KB
15
16 set -euo pipefail
17
18 if [[ $# -lt 2 || $# -gt 4 ]]; then
19 echo "usage: $0 <baseline.mp4> <translated.mp4> [output-dir] [samples]" >&2
20 exit 2
21 fi
22
23 BASELINE = " $1 "
24 TRANSLATED = " $2 "
25 OUTDIR = " ${3 :- . / strip-out } "
26 SAMPLES = " ${4 :- 8} "
27
28 if ! command -v ffmpeg > /dev/null 2>&1 || ! command -v ffprobe > /dev/null 2>&1 ; then
29 echo "error: ffmpeg/ffprobe not on PATH" >&2
30 exit 2
31 fi
32
33 mkdir -p " $OUTDIR "
34
35 # Build the strip in a single ffmpeg invocation. Two inputs (baseline and
36 # translated) are sampled at N evenly-spaced timestamps via the `select`
37 # filter, then assembled with hstack (per-row pairs) + vstack (rows).
38 # Earlier versions spawned 3 ffmpeg calls per timestamp + a final vstack;
39 # this is one call regardless of N.
40 python3 - " $BASELINE " " $TRANSLATED " " $OUTDIR " " $SAMPLES " << 'PY'
41 import json
42 import shutil
43 import subprocess
44 import sys
45 from pathlib import Path
46
47 baseline, translated, outdir, samples = sys.argv[1], sys.argv[2], Path(sys.argv[3]), int(sys.argv[4])
48
49 # Read fps + duration from the baseline so we can map timestamps to frame
50 # indexes for the `select` filter (frame-accurate, doesn't depend on
51 # keyframe alignment).
52 probe = subprocess.run(
53 ["ffprobe", "-v", "error", "-select_streams", "v:0",
54 "-show_entries", "stream=r_frame_rate,nb_read_frames,duration",
55 "-show_entries", "format=duration",
56 "-of", "json", "-count_frames", "--", baseline],
57 check=True, capture_output=True, text=True,
58 )
59 data = json.loads(probe.stdout)
60 stream = data["streams"][0]
61 num, den = stream["r_frame_rate"].split("/")
62 fps = float(num) / float(den)
63 nb_frames = int(stream.get("nb_read_frames") or 0)
64 if nb_frames <= 0:
65 duration = float(stream.get("duration") or data["format"]["duration"])
66 nb_frames = int(duration * fps)
67
68 # Even-spaced sample frames in the 5%-95% window (skip fade-in/out noise).
69 start = max(0, int(nb_frames * 0.05))
70 end = max(start, int(nb_frames * 0.95) - 1)
71 if samples == 1:
72 frames = [start]
73 else:
74 step = (end - start) / (samples - 1)
75 frames = [int(start + i * step) for i in range(samples)]
76
77 (outdir / "timestamps.txt").write_text(
78 "\n".join(f"{f / fps:.3f}" for f in frames) + "\n"
79 )
80
81 # select='eq(n,F1)+eq(n,F2)+...' picks exactly the listed frames from each
82 # input. We then hstack per-frame pairs and vstack the result.
83 select_expr = "+".join(f"eq(n,{f})" for f in frames)
84 n = len(frames)
85 filter_parts = [
86 f"[0:v]select='{select_expr}',setpts=N/FRAME_RATE/TB,split={n}"
87 + "".join(f"[b{i}]" for i in range(n)),
88 f"[1:v]select='{select_expr}',setpts=N/FRAME_RATE/TB,split={n}"
89 + "".join(f"[t{i}]" for i in range(n)),
90 ]
91 for i in range(n):
92 filter_parts.append(f"[b{i}][t{i}]hstack=inputs=2[row{i}]")
93 filter_parts.append(
94 "".join(f"[row{i}]" for i in range(n)) + f"vstack=inputs={n}[out]"
95 )
96 filter_graph = ";".join(filter_parts)
97
98 cmd = [
99 "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
100 "-i", baseline, "-i", translated,
101 "-filter_complex", filter_graph,
102 "-map", "[out]", "-frames:v", "1",
103 str(outdir / "strip.png"),
104 ]
105 subprocess.run(cmd, check=True)
106 print(f"wrote {outdir / 'strip.png'} ({n} samples)")
107 PY