Files
hyperframes/skills/remotion-to-hyperframes/scripts/frame_strip.sh
T
James 70e0b8bf87 feat(skills): remotion-to-hyperframes eval harness (2/7)
Adds the deterministic eval primitives the skill calls into:

  scripts/render_diff.sh    SSIM diff between two MP4s, JSON summary, configurable threshold
  scripts/frame_strip.sh    side-by-side comparison strip for visual debugging
  scripts/lint_source.py    pre-translation lint over Remotion source — blocks/warnings/infos

The harness is decoupled from the render pipeline: it accepts paths to
already-rendered MP4s. The skill orchestrator (PR 7) drives both renders
and feeds the outputs in. This keeps the harness usable in CI, in
sandboxes, and on any machine that has ffmpeg without needing the full
Remotion + HyperFrames toolchain.

Lint catches the patterns from the skill's out-of-scope list:
- useState / useReducer (state-machine driven animation)
- useEffect with deps (side effects)
- async calculateMetadata (Promise-returning composition metadata)
- @remotion/lambda imports
- third-party React UI libraries (MUI, Chakra, Mantine, antd, shadcn, Radix, NextUI)
- delayRender / useCallback / useMemo (warnings)
- staticFile / interpolateColors (info — translatable but flagged)

Smoke test (scripts/tests/smoke.sh) exercises all three scripts against
synthetic inputs: identical ffmpeg testsrc videos pass at threshold 0.99,
different ffmpeg testsrc videos fail at 0.99, frame_strip produces a
strip.png, lint produces 0 blockers on a clean fixture and >=3 blockers
on a fixture that uses useState + useEffect + MUI + async metadata.

Validated locally: smoke.sh exits 0.
2026-04-27 23:54:09 +00:00

108 lines
3.6 KiB
Bash
Executable File

#!/usr/bin/env bash
# frame_strip.sh — produce a side-by-side comparison strip from two videos.
#
# Used to debug failing render_diff.sh runs visually: pick a sample timestamp
# range, extract frames from both videos, lay them out as a grid for review.
#
# Usage:
# frame_strip.sh <baseline.mp4> <translated.mp4> [output-dir] [samples]
#
# Defaults: output-dir=./strip-out, samples=8 (evenly spaced across duration).
# Output:
# strip.png — single PNG with `samples` rows, each row is
# (baseline frame | translated frame) at one timestamp
# timestamps.txt — the timestamps sampled
set -euo pipefail
if [[ $# -lt 2 || $# -gt 4 ]]; then
echo "usage: $0 <baseline.mp4> <translated.mp4> [output-dir] [samples]" >&2
exit 2
fi
BASELINE="$1"
TRANSLATED="$2"
OUTDIR="${3:-./strip-out}"
SAMPLES="${4:-8}"
if ! command -v ffmpeg >/dev/null 2>&1 || ! command -v ffprobe >/dev/null 2>&1; then
echo "error: ffmpeg/ffprobe not on PATH" >&2
exit 2
fi
mkdir -p "$OUTDIR"
# Build the strip in a single ffmpeg invocation. Two inputs (baseline and
# translated) are sampled at N evenly-spaced timestamps via the `select`
# filter, then assembled with hstack (per-row pairs) + vstack (rows).
# Earlier versions spawned 3 ffmpeg calls per timestamp + a final vstack;
# this is one call regardless of N.
python3 - "$BASELINE" "$TRANSLATED" "$OUTDIR" "$SAMPLES" <<'PY'
import json
import shutil
import subprocess
import sys
from pathlib import Path
baseline, translated, outdir, samples = sys.argv[1], sys.argv[2], Path(sys.argv[3]), int(sys.argv[4])
# Read fps + duration from the baseline so we can map timestamps to frame
# indexes for the `select` filter (frame-accurate, doesn't depend on
# keyframe alignment).
probe = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=r_frame_rate,nb_read_frames,duration",
"-show_entries", "format=duration",
"-of", "json", "-count_frames", baseline],
check=True, capture_output=True, text=True,
)
data = json.loads(probe.stdout)
stream = data["streams"][0]
num, den = stream["r_frame_rate"].split("/")
fps = float(num) / float(den)
nb_frames = int(stream.get("nb_read_frames") or 0)
if nb_frames <= 0:
duration = float(stream.get("duration") or data["format"]["duration"])
nb_frames = int(duration * fps)
# Even-spaced sample frames in the 5%-95% window (skip fade-in/out noise).
start = max(0, int(nb_frames * 0.05))
end = max(start, int(nb_frames * 0.95) - 1)
if samples == 1:
frames = [start]
else:
step = (end - start) / (samples - 1)
frames = [int(start + i * step) for i in range(samples)]
(outdir / "timestamps.txt").write_text(
"\n".join(f"{f / fps:.3f}" for f in frames) + "\n"
)
# select='eq(n,F1)+eq(n,F2)+...' picks exactly the listed frames from each
# input. We then hstack per-frame pairs and vstack the result.
select_expr = "+".join(f"eq(n,{f})" for f in frames)
n = len(frames)
filter_parts = [
f"[0:v]select='{select_expr}',setpts=N/FRAME_RATE/TB,split={n}"
+ "".join(f"[b{i}]" for i in range(n)),
f"[1:v]select='{select_expr}',setpts=N/FRAME_RATE/TB,split={n}"
+ "".join(f"[t{i}]" for i in range(n)),
]
for i in range(n):
filter_parts.append(f"[b{i}][t{i}]hstack=inputs=2[row{i}]")
filter_parts.append(
"".join(f"[row{i}]" for i in range(n)) + f"vstack=inputs={n}[out]"
)
filter_graph = ";".join(filter_parts)
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-i", baseline, "-i", translated,
"-filter_complex", filter_graph,
"-map", "[out]", "-frames:v", "1",
str(outdir / "strip.png"),
]
subprocess.run(cmd, check=True)
print(f"wrote {outdir / 'strip.png'} ({n} samples)")
PY