chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
run-report.json
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env bash
|
||||
# run.sh — corpus orchestrator. Runs every tier and prints a pass/fail summary.
|
||||
#
|
||||
# Tiers 1-3: render Remotion baseline + HF translation, run SSIM diff,
|
||||
# assert mean >= ssim_threshold from each fixture's expected.json.
|
||||
# Tier 4: runs cases/validate.sh which lints each case and asserts against
|
||||
# expected.json.
|
||||
#
|
||||
# Usage:
|
||||
# ./run.sh run all tiers
|
||||
# ./run.sh tier-1-title-card run a single tier
|
||||
#
|
||||
# Requirements:
|
||||
# - ffmpeg, ffprobe, python3 on PATH
|
||||
# - node 22 (for the HF CLI)
|
||||
# - npm (for Remotion installs)
|
||||
# - HF CLI built at packages/cli/dist/cli.js (run `bun run --filter @hyperframes/cli build`
|
||||
# in the repo root if missing)
|
||||
#
|
||||
# Output:
|
||||
# <fixture>/diff/summary.json per-fixture SSIM summary
|
||||
# <fixture>/strip/strip.png per-fixture comparison strip (only on fail)
|
||||
# ./run-report.json aggregate report
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
THIS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILL_DIR="$(cd "$THIS_DIR/../.." && pwd)"
|
||||
REPO_ROOT="$(cd "$SKILL_DIR/../.." && pwd)"
|
||||
|
||||
LINT="$SKILL_DIR/scripts/lint_source.py"
|
||||
DIFF="$SKILL_DIR/scripts/render_diff.sh"
|
||||
STRIP="$SKILL_DIR/scripts/frame_strip.sh"
|
||||
HF_CLI="$REPO_ROOT/packages/cli/dist/cli.js"
|
||||
REPORT="$THIS_DIR/run-report.json"
|
||||
|
||||
# Per-fixture results land here as one JSON file each, then the aggregator
|
||||
# globs them. This is safer than building JSON via bash string concatenation
|
||||
# (a fixture name containing a quote would break the previous approach).
|
||||
RESULTS_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$RESULTS_DIR"' EXIT
|
||||
|
||||
# T4 is lint-only — no ffmpeg or HF CLI needed. Defer the render-tier
|
||||
# toolchain checks until run_render_tier() actually runs, so
|
||||
# `./run.sh tier-4-escape-hatch` works on a clean checkout.
|
||||
require_render_tier_tools() {
|
||||
if [[ ! -f "$HF_CLI" ]]; then
|
||||
echo "error: HF CLI not built at $HF_CLI" >&2
|
||||
echo " Run 'bun run --filter @hyperframes/cli build' in $REPO_ROOT" >&2
|
||||
return 2
|
||||
fi
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "error: ffmpeg not on PATH" >&2
|
||||
return 2
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Write one fixture's result as a JSON file. Values are passed via argv so
|
||||
# bash string interpolation can't corrupt the JSON or inject Python source.
|
||||
write_result() {
|
||||
local fixture_name="$1"
|
||||
local status="$2"
|
||||
shift 2
|
||||
python3 - "$RESULTS_DIR/$fixture_name.json" "$fixture_name" "$status" "$@" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
out_path, fixture_name, status, *kvs = sys.argv[1:]
|
||||
result = {"fixture": fixture_name, "status": status}
|
||||
for i in range(0, len(kvs), 2):
|
||||
k, v = kvs[i], kvs[i + 1]
|
||||
try:
|
||||
result[k] = float(v) if "." in v or v.lstrip("-").isdigit() else v
|
||||
except ValueError:
|
||||
result[k] = v
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(result, f)
|
||||
PY
|
||||
}
|
||||
|
||||
# Read a top-level scalar value from a JSON file. Falls back to $3 if the
|
||||
# key is missing (used to default composition_id for older fixtures).
|
||||
read_json_value() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local default="${3:-}"
|
||||
python3 - "$file" "$key" "$default" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
path, key, default = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
val = data.get(key, default)
|
||||
print(val if val is not None else "")
|
||||
PY
|
||||
}
|
||||
|
||||
run_render_tier() {
|
||||
local fixture_dir="$1"
|
||||
local fixture_name
|
||||
fixture_name=$(basename "$fixture_dir")
|
||||
local expected="$fixture_dir/expected.json"
|
||||
|
||||
if ! require_render_tier_tools; then
|
||||
echo " ⚠ $fixture_name: render toolchain unavailable, skipping"
|
||||
write_result "$fixture_name" "skipped" reason "render toolchain unavailable"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local threshold composition_id
|
||||
threshold=$(read_json_value "$expected" "ssim_threshold")
|
||||
composition_id=$(read_json_value "$expected" "composition_id" "Composition")
|
||||
|
||||
echo " ▶ $fixture_name (threshold $threshold, composition $composition_id)"
|
||||
|
||||
if [[ -x "$fixture_dir/setup.sh" ]]; then
|
||||
"$fixture_dir/setup.sh" >/dev/null
|
||||
fi
|
||||
|
||||
if ! python3 "$LINT" "$fixture_dir/remotion-src/src/" >/dev/null; then
|
||||
echo " ✗ lint failed (blockers in Remotion source)"
|
||||
write_result "$fixture_name" "fail" stage "lint"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -d "$fixture_dir/remotion-src/node_modules" ]]; then
|
||||
echo " ⏳ npm install (first run)"
|
||||
(cd "$fixture_dir/remotion-src" && npm install --silent --no-progress >/dev/null 2>&1)
|
||||
fi
|
||||
|
||||
echo " ⏳ render Remotion baseline"
|
||||
if ! (cd "$fixture_dir/remotion-src" && \
|
||||
npx --no-install remotion render "$composition_id" out/baseline.mp4 >/dev/null 2>&1); then
|
||||
echo " ✗ Remotion render failed"
|
||||
write_result "$fixture_name" "fail" stage "remotion-render"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " ⏳ render HF translation"
|
||||
if ! (cd "$fixture_dir" && \
|
||||
node "$HF_CLI" render hf-src/ --output hf.mp4 --quiet >/dev/null 2>&1); then
|
||||
echo " ✗ HF render failed"
|
||||
write_result "$fixture_name" "fail" stage "hf-render"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if R2HF_SSIM_THRESHOLD="$threshold" "$DIFF" \
|
||||
"$fixture_dir/remotion-src/out/baseline.mp4" \
|
||||
"$fixture_dir/hf.mp4" \
|
||||
"$fixture_dir/diff" >/dev/null; then
|
||||
local mean
|
||||
mean=$(read_json_value "$fixture_dir/diff/summary.json" "mean")
|
||||
echo " ✓ pass (mean SSIM $mean, threshold $threshold)"
|
||||
write_result "$fixture_name" "pass" mean_ssim "$mean" threshold "$threshold"
|
||||
else
|
||||
local mean
|
||||
mean=$(read_json_value "$fixture_dir/diff/summary.json" "mean")
|
||||
echo " ✗ fail (mean SSIM $mean, threshold $threshold)"
|
||||
"$STRIP" \
|
||||
"$fixture_dir/remotion-src/out/baseline.mp4" \
|
||||
"$fixture_dir/hf.mp4" \
|
||||
"$fixture_dir/strip" 8 >/dev/null
|
||||
write_result "$fixture_name" "fail" stage "ssim" mean_ssim "$mean" threshold "$threshold"
|
||||
fi
|
||||
}
|
||||
|
||||
run_lint_tier() {
|
||||
local fixture_dir="$1"
|
||||
local fixture_name
|
||||
fixture_name=$(basename "$fixture_dir")
|
||||
|
||||
echo " ▶ $fixture_name (lint-only)"
|
||||
if "$fixture_dir/validate.sh" >/dev/null 2>&1; then
|
||||
echo " ✓ pass (8/8 cases)"
|
||||
write_result "$fixture_name" "pass" mode "lint"
|
||||
else
|
||||
echo " ✗ fail (some cases mismatched expected.json)"
|
||||
write_result "$fixture_name" "fail" mode "lint"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "remotion-to-hyperframes corpus run"
|
||||
echo "=================================="
|
||||
|
||||
for tier in tier-1-title-card tier-2-multi-scene tier-3-data-driven; do
|
||||
if [[ -n "${1:-}" && "$1" != "$tier" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ -d "$THIS_DIR/$tier" ]]; then
|
||||
run_render_tier "$THIS_DIR/$tier"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "${1:-}" || "$1" == "tier-4-escape-hatch" ]]; then
|
||||
if [[ -d "$THIS_DIR/tier-4-escape-hatch" ]]; then
|
||||
run_lint_tier "$THIS_DIR/tier-4-escape-hatch"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Aggregate the per-fixture JSON files into one report.
|
||||
#
|
||||
# Skipped fixtures are *not* a pass — they mean a tier didn't run because
|
||||
# tooling or fixtures were unavailable. The orchestrator exits non-zero on
|
||||
# any skip so a clean checkout that lacks the HF CLI doesn't accidentally
|
||||
# report "passed 1/4" (T4 alone) and look like the corpus is healthy.
|
||||
#
|
||||
# Single-tier mode (`./run.sh tier-N`) only writes a result file for the
|
||||
# selected tier; tiers that weren't run aren't counted as skips.
|
||||
python3 - "$RESULTS_DIR" "$REPORT" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
results_dir, out_path = Path(sys.argv[1]), Path(sys.argv[2])
|
||||
results = sorted(
|
||||
(json.loads(p.read_text()) for p in results_dir.glob("*.json")),
|
||||
key=lambda r: r["fixture"],
|
||||
)
|
||||
|
||||
total = len(results)
|
||||
passed = sum(1 for r in results if r["status"] == "pass")
|
||||
failed = sum(1 for r in results if r["status"] == "fail")
|
||||
skipped = sum(1 for r in results if r["status"] == "skipped")
|
||||
report = {
|
||||
"total": total,
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"skipped": skipped,
|
||||
"results": results,
|
||||
}
|
||||
out_path.write_text(json.dumps(report, indent=2))
|
||||
|
||||
print()
|
||||
print("=" * 50)
|
||||
print(f" passed {passed}/{total}, failed {failed}, skipped {skipped}")
|
||||
print(f" report → {out_path}")
|
||||
if skipped > 0:
|
||||
skipped_fixtures = [r["fixture"] for r in results if r["status"] == "skipped"]
|
||||
skipped_reasons = sorted({r.get("reason", "unknown") for r in results if r["status"] == "skipped"})
|
||||
print()
|
||||
print(f" ⚠ {skipped} skipped: {', '.join(skipped_fixtures)}")
|
||||
for reason in skipped_reasons:
|
||||
print(f" reason: {reason}")
|
||||
print(" Skipped fixtures count as failures for the aggregate.")
|
||||
print("=" * 50)
|
||||
sys.exit(0 if failed == 0 and skipped == 0 else 1)
|
||||
PY
|
||||
@@ -0,0 +1,10 @@
|
||||
# Render output
|
||||
remotion-src/out/
|
||||
hf-src/out/
|
||||
hf.mp4
|
||||
diff/
|
||||
strip/
|
||||
|
||||
# Remotion / HF dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,41 @@
|
||||
# Tier 1 — title-card-fade
|
||||
|
||||
## What it tests
|
||||
|
||||
The simplest non-trivial Remotion → HyperFrames translation. A single text
|
||||
element fades in over the first 0.5 s, holds for 2.0 s, and fades out over
|
||||
the last 0.5 s. No audio, no media, no custom components.
|
||||
|
||||
If a translation can't pass T1, it's broken on table-stakes basics:
|
||||
`AbsoluteFill`, `useCurrentFrame`, `interpolate` with multi-segment input,
|
||||
and the timing offset between Remotion's frame-based driver and HF's
|
||||
paused-GSAP driver.
|
||||
|
||||
## Translation walk-through
|
||||
|
||||
| Remotion | HyperFrames |
|
||||
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `<AbsoluteFill style={{ backgroundColor: "#0a0a0a" }}>` | `<body style="background: #0a0a0a">` + a positioned root div |
|
||||
| `useCurrentFrame()` | dropped — HF seeks the timeline |
|
||||
| `interpolate(frame, [0, 15, 75, 90], [0, 1, 1, 0])` at fps=30 | `gsap.timeline({ paused: true })` with three `.to()` calls at offsets 0s/0.5s/2.5s, each `ease: "none"` |
|
||||
| `<div style={{ opacity }}>HELLO</div>` | static markup; opacity is animated by the timeline |
|
||||
|
||||
The Remotion→HF time conversion is `time = frame / fps`. So
|
||||
`[0, 15, 75, 90]` at 30 fps becomes `[0, 0.5, 2.5, 3.0]` seconds.
|
||||
|
||||
## How to render and evaluate
|
||||
|
||||
```bash
|
||||
# Render Remotion baseline
|
||||
cd remotion-src && npm install && npm run render
|
||||
# Renders to remotion-src/out/baseline.mp4
|
||||
|
||||
# Render HyperFrames translation
|
||||
cd ../hf-src && npx hyperframes render --output ../hf.mp4
|
||||
|
||||
# Compare with the eval harness (from skill scripts/)
|
||||
../../../scripts/render_diff.sh ./remotion-src/out/baseline.mp4 ./hf.mp4 ./diff
|
||||
```
|
||||
|
||||
`expected.json` documents the SSIM threshold (0.95) for this fixture; the
|
||||
calibrated mean against Remotion @ 4.0 with PNG/BT.709 output is 0.974.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"tier": 1,
|
||||
"name": "title-card-fade",
|
||||
"composition_id": "TitleCard",
|
||||
"description": "Solid black background, single 'HELLO' element fades in 0-0.5s, holds 0.5-2.5s, fades out 2.5-3.0s. Tests the most basic Remotion → HyperFrames translation: a single AbsoluteFill, a single useCurrentFrame-driven interpolate, no audio, no media, no custom React components.",
|
||||
"duration_seconds": 3,
|
||||
"fps": 30,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"ssim_threshold": 0.95,
|
||||
"validation": {
|
||||
"measured_mean_ssim": 0.974,
|
||||
"measured_min_ssim": 0.972,
|
||||
"measured_p05_ssim": 0.972,
|
||||
"measured_p95_ssim": 0.983,
|
||||
"measured_at": "2026-04-27",
|
||||
"measured_against": "remotion@4.0 (PNG output, BT.709) vs hyperframes@0.4.15-alpha.1"
|
||||
},
|
||||
"translation_notes": [
|
||||
"Remotion: AbsoluteFill → HF: position:absolute;inset:0 div",
|
||||
"Remotion: interpolate(frame, [0,15,75,90], [0,1,1,0]) at fps=30 → HF: paused GSAP timeline with three keyframed tweens at 0s, 0.5s, 2.5s with ease:'none' (linear matches Remotion's default linear interpolation)",
|
||||
"No fonts loaded; both renderers use system Helvetica/Arial fallback. The Linux fallback diverges between Remotion's bundled Chromium and HyperFrames' chrome-headless-shell — same fontWeight:800 renders perceptibly bolder in HF. This costs ~0.025 mean SSIM and is the dominant non-translation noise floor.",
|
||||
"Remotion config must use setVideoImageFormat('png') + setColorSpace('bt709'); the JPEG default writes yuvj420p (full-range) which costs ~0.05 SSIM vs HF's yuv420p (limited-range)."
|
||||
],
|
||||
"rationale": "Threshold 0.95 sits ~0.02 below measured p05. A real translation regression (wrong easing, wrong durations) drops mean SSIM by 0.05+. Encoder/font drift between CI runs is bounded at ~0.01."
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>tier-1-title-card</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
}
|
||||
.title {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 160px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="stage"
|
||||
data-composition-id="tier-1-title-card"
|
||||
data-start="0"
|
||||
data-width="1280"
|
||||
data-height="720"
|
||||
data-duration="3"
|
||||
data-fps="30"
|
||||
>
|
||||
<div id="title" class="clip" data-start="0" data-duration="3" data-track-index="0">
|
||||
<div class="title">HELLO</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Translation of Remotion's
|
||||
// interpolate(frame, [0, 15, 75, 90], [0, 1, 1, 0]) at fps=30
|
||||
// into a paused GSAP timeline keyed in seconds.
|
||||
// Frame ranges → time ranges: 0/30=0, 15/30=0.5, 75/30=2.5, 90/30=3.0
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const target = document.querySelector("#title .title");
|
||||
tl.to(target, { opacity: 1, duration: 0.5, ease: "none" }, 0);
|
||||
tl.to(target, { opacity: 1, duration: 2.0, ease: "none" }, 0.5);
|
||||
tl.to(target, { opacity: 0, duration: 0.5, ease: "none" }, 2.5);
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["tier-1-title-card"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "tier-1-title-card-remotion",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"render": "remotion render TitleCard out/baseline.mp4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "^4.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"remotion": "^4.0.0"
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
// Match HyperFrames' default render so SSIM diffs measure translation
|
||||
// fidelity, not encoder differences.
|
||||
//
|
||||
// setVideoImageFormat("png") avoids the JPEG limited-range/full-range
|
||||
// colorspace flag (yuvj420p vs yuv420p) that otherwise costs ~0.05 SSIM.
|
||||
//
|
||||
// setColorSpace("bt709") matches HF's BT.709 SDR output.
|
||||
Config.setVideoImageFormat("png");
|
||||
Config.setColorSpace("bt709");
|
||||
Config.setOverwriteOutput(true);
|
||||
Config.setConcurrency(1);
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Composition } from "remotion";
|
||||
import { TitleCard } from "./TitleCard";
|
||||
|
||||
export const RemotionRoot = () => (
|
||||
<Composition
|
||||
id="TitleCard"
|
||||
component={TitleCard}
|
||||
durationInFrames={90}
|
||||
fps={30}
|
||||
width={1280}
|
||||
height={720}
|
||||
/>
|
||||
);
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
|
||||
|
||||
export const TitleCard = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// Fade in 0-15, hold 15-75, fade out 75-90.
|
||||
const opacity = interpolate(frame, [0, 15, 75, 90], [0, 1, 1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: "#0a0a0a",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 160,
|
||||
fontWeight: 800,
|
||||
color: "#ffffff",
|
||||
opacity,
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
HELLO
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2018",
|
||||
"module": "ESNext",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Generated by setup.sh
|
||||
remotion-src/public/
|
||||
hf-src/assets/
|
||||
|
||||
# Remotion / HF dependencies
|
||||
remotion-src/node_modules/
|
||||
remotion-src/package-lock.json
|
||||
|
||||
# Render output
|
||||
remotion-src/out/
|
||||
hf-src/out/
|
||||
hf.mp4
|
||||
diff/
|
||||
strip/
|
||||
@@ -0,0 +1,54 @@
|
||||
# Tier 2 — title-image-outro
|
||||
|
||||
## What it tests
|
||||
|
||||
Three-scene composition. Each scene exercises a different Remotion idiom:
|
||||
|
||||
1. **Scene 1 (0–2 s)** — TitleScene with `spring({damping:12, stiffness:100, mass:1})`
|
||||
driving a `transform: scale()` on text. Tests the lossy `spring → GSAP ease` translation.
|
||||
2. **Scene 2 (2–4 s)** — ImageScene that fades in a `staticFile`-loaded image and
|
||||
linearly scales it from 0.8 → 1.0. Tests asset paths + linear `interpolate`.
|
||||
3. **Scene 3 (4–6 s)** — OutroScene with a 1-s linear fade-in. Sanity check after
|
||||
the harder scenes.
|
||||
|
||||
A silent 6-second WAV plays throughout at `volume={0.5}`. Tests `<Audio>` translation.
|
||||
|
||||
If a translation passes T2, the skill correctly handles `<Sequence>` boundaries,
|
||||
`<Audio>` / `<Img>` / `staticFile`, and the Remotion `spring → GSAP ease` heuristic.
|
||||
|
||||
## Translation walk-through
|
||||
|
||||
| Remotion | HyperFrames |
|
||||
| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `<Sequence from={0} durationInFrames={60}>` | `<div data-start="0" data-duration="2" data-track-index="0">` |
|
||||
| `spring({frame, fps, config: {damping:12, stiffness:100, mass:1}})` | `gsap.to(target, { scale: 1, duration: 0.7, ease: "back.out(1.4)" })` |
|
||||
| `<Audio src={staticFile("music.wav")} volume={0.5} />` | `<audio src="assets/music.wav" data-start="0" data-duration="6" data-volume="0.5" data-track-index="1">` |
|
||||
| `<Img src={staticFile("square.png")} />` | `<img src="assets/square.png">` (with setup.sh copying into both trees) |
|
||||
| `interpolate(frame, [0, 15], [0, 1])` at 30 fps | `gsap.to(target, { opacity: 1, duration: 0.5, ease: "none" })` |
|
||||
|
||||
The scene crossfading is a HyperFrames idiom, not a Remotion one: at scene boundaries
|
||||
we `gsap.set(scene, { opacity: 0 })` so the previous scene disappears at the
|
||||
right time. Remotion does this implicitly by virtue of `<Sequence>`'s durationInFrames.
|
||||
|
||||
## How to render and evaluate
|
||||
|
||||
```bash
|
||||
# 1. Generate the binary assets (PNG + WAV) via ffmpeg
|
||||
./setup.sh
|
||||
|
||||
# 2. Render Remotion baseline
|
||||
cd remotion-src && npm install && npm run render
|
||||
|
||||
# 3. Render HyperFrames translation
|
||||
cd ../hf-src && npx hyperframes render --output ../hf.mp4
|
||||
|
||||
# 4. Compare
|
||||
../../../scripts/render_diff.sh ./remotion-src/out/baseline.mp4 ./hf.mp4 ./diff
|
||||
```
|
||||
|
||||
## Why threshold 0.95?
|
||||
|
||||
Same threshold as T1 (`expected.json` codifies it for the orchestrator). Spring → `back.out(1.4)`
|
||||
came in cleaner than predicted during calibration — the validated mean is 0.985 against the
|
||||
0.95 gate. If the translation breaks anything else (spring overshoot wrong, stagger off,
|
||||
asset path drift), mean SSIM will fall well below 0.95 — that's the failure signal.
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"tier": 2,
|
||||
"name": "title-image-outro",
|
||||
"composition_id": "MultiScene",
|
||||
"description": "Three-scene composition exercising Sequence, spring, interpolate, Audio, Img, and staticFile. Title scene uses Remotion's spring (translated to GSAP back.out as an approximation). Image scene scales an Img (from staticFile) with linear interpolate. Outro scene fades text in linearly. A silent WAV plays throughout at volume 0.5.",
|
||||
"duration_seconds": 6,
|
||||
"fps": 30,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"ssim_threshold": 0.95,
|
||||
"validation": {
|
||||
"measured_mean_ssim": 0.985,
|
||||
"measured_min_ssim": 0.963,
|
||||
"measured_p05_ssim": 0.966,
|
||||
"measured_p95_ssim": 0.999,
|
||||
"measured_at": "2026-04-27",
|
||||
"measured_against": "remotion@4.0 (PNG output, BT.709) vs hyperframes@0.4.15-alpha.1",
|
||||
"notes": "Spring → back.out(1.4) translation came out cleaner than expected; mean 0.985 leaves substantial headroom over 0.95."
|
||||
},
|
||||
"translation_notes": [
|
||||
"spring({damping:12, stiffness:100, mass:1}) → back.out(1.4) over 0.7s. Spring overshoot+settle and back.out's overshoot+settle have similar shape; budget ~0.02 SSIM for the late-tail curvature mismatch (validated lower than predicted in spec; original notes overestimated drift).",
|
||||
"<Sequence from durationInFrames> → wrapping div with data-start/data-duration in seconds and explicit gsap.set(opacity, 0/1) at scene boundaries to crossfade in/out cleanly",
|
||||
"<Audio src volume> → <audio data-start data-duration data-volume>",
|
||||
"<Img src={staticFile('x')}> → <img src='assets/x'>; setup.sh copies the asset into both fixture trees",
|
||||
"interpolate with default linear easing → ease:'none' in GSAP",
|
||||
"Fonts again rely on system Helvetica/Arial; ~0.015 SSIM cost from AA differences"
|
||||
],
|
||||
"rationale": "Threshold 0.95 sits ~0.015 below measured p05 (0.966). T2 actually validated cleaner than T1 because the lower title fontWeight (140px vs T1's 160px) shows less of the system-font fallback divergence."
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>tier-2-multi-scene</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
}
|
||||
.scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
}
|
||||
.title {
|
||||
font-size: 140px;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.05em;
|
||||
transform: scale(0);
|
||||
}
|
||||
.square {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
.outro {
|
||||
font-size: 100px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="stage"
|
||||
data-composition-id="tier-2-multi-scene"
|
||||
data-start="0"
|
||||
data-width="1280"
|
||||
data-height="720"
|
||||
data-duration="6"
|
||||
data-fps="30"
|
||||
>
|
||||
<div id="scene-1" class="scene clip" data-start="0" data-duration="2" data-track-index="0">
|
||||
<div class="title">Welcome</div>
|
||||
</div>
|
||||
|
||||
<div id="scene-2" class="scene clip" data-start="2" data-duration="2" data-track-index="0">
|
||||
<img class="square" src="assets/square.png" alt="" />
|
||||
</div>
|
||||
|
||||
<div id="scene-3" class="scene clip" data-start="4" data-duration="2" data-track-index="0">
|
||||
<div class="outro">Goodbye</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
id="bg-music"
|
||||
data-start="0"
|
||||
data-duration="6"
|
||||
data-track-index="1"
|
||||
data-volume="0.5"
|
||||
src="assets/music.wav"
|
||||
></audio>
|
||||
|
||||
<script>
|
||||
// Translation of Remotion's 3-Sequence MultiScene at fps=30, total 6s.
|
||||
//
|
||||
// Scene 1 (0-2s): TitleScene — spring scale on "Welcome".
|
||||
// Remotion: spring({frame, fps, config: {damping:12, stiffness:100, mass:1}})
|
||||
// GSAP equivalent: back.out(1.4) over ~0.7s. Spring → ease translation
|
||||
// is approximate; expect minor differences in the tail of the curve.
|
||||
//
|
||||
// Scene 2 (2-4s): ImageScene — opacity 0→1 over 0-0.5s + scale 0.8→1.0 over 0-2.0s.
|
||||
// Both Remotion interpolates use linear (default), match with ease:"none".
|
||||
//
|
||||
// Scene 3 (4-6s): OutroScene — opacity 0→1 over 4-5s, linear.
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Scene 1 setup: opacity flick on so scene becomes visible at start
|
||||
const scene1 = document.querySelector("#scene-1");
|
||||
const title = scene1.querySelector(".title");
|
||||
tl.set(scene1, { opacity: 1 }, 0);
|
||||
tl.to(title, { scale: 1, duration: 0.7, ease: "back.out(1.4)" }, 0);
|
||||
tl.set(scene1, { opacity: 0 }, 2);
|
||||
|
||||
// Scene 2
|
||||
const scene2 = document.querySelector("#scene-2");
|
||||
const square = scene2.querySelector(".square");
|
||||
tl.set(scene2, { opacity: 1 }, 2);
|
||||
tl.to(square, { opacity: 1, duration: 0.5, ease: "none" }, 2);
|
||||
tl.to(square, { scale: 1.0, duration: 2.0, ease: "none" }, 2);
|
||||
tl.set(scene2, { opacity: 0 }, 4);
|
||||
|
||||
// Scene 3
|
||||
const scene3 = document.querySelector("#scene-3");
|
||||
const outro = scene3.querySelector(".outro");
|
||||
tl.set(scene3, { opacity: 1 }, 4);
|
||||
tl.fromTo(outro, { opacity: 0 }, { opacity: 1, duration: 1.0, ease: "none" }, 4);
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["tier-2-multi-scene"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "tier-2-multi-scene-remotion",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"render": "remotion render MultiScene out/baseline.mp4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "^4.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"remotion": "^4.0.0"
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
// Match HyperFrames' default render so SSIM diffs measure translation
|
||||
// fidelity, not encoder differences.
|
||||
//
|
||||
// setVideoImageFormat("png") avoids the JPEG limited-range/full-range
|
||||
// colorspace flag (yuvj420p vs yuv420p) that otherwise costs ~0.05 SSIM.
|
||||
//
|
||||
// setColorSpace("bt709") matches HF's BT.709 SDR output.
|
||||
Config.setVideoImageFormat("png");
|
||||
Config.setColorSpace("bt709");
|
||||
Config.setOverwriteOutput(true);
|
||||
Config.setConcurrency(1);
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Audio,
|
||||
Img,
|
||||
Sequence,
|
||||
interpolate,
|
||||
spring,
|
||||
staticFile,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
const TitleScene = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const scale = spring({ frame, fps, config: { damping: 12, stiffness: 100, mass: 1 } });
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: "#0a0a0a",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 140,
|
||||
fontWeight: 800,
|
||||
color: "#ffffff",
|
||||
transform: `scale(${scale})`,
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
Welcome
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const ImageScene = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
|
||||
const scale = interpolate(frame, [0, 60], [0.8, 1.0], { extrapolateRight: "clamp" });
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: "#0a0a0a",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={staticFile("square.png")}
|
||||
style={{
|
||||
width: 200,
|
||||
height: 200,
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const OutroScene = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" });
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: "#0a0a0a",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 100,
|
||||
fontWeight: 600,
|
||||
color: "#ffffff",
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
Goodbye
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const MultiScene = () => (
|
||||
<AbsoluteFill>
|
||||
<Sequence from={0} durationInFrames={60}>
|
||||
<TitleScene />
|
||||
</Sequence>
|
||||
<Sequence from={60} durationInFrames={60}>
|
||||
<ImageScene />
|
||||
</Sequence>
|
||||
<Sequence from={120} durationInFrames={60}>
|
||||
<OutroScene />
|
||||
</Sequence>
|
||||
<Audio src={staticFile("music.wav")} volume={0.5} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Composition } from "remotion";
|
||||
import { MultiScene } from "./MultiScene";
|
||||
|
||||
export const RemotionRoot = () => (
|
||||
<Composition
|
||||
id="MultiScene"
|
||||
component={MultiScene}
|
||||
durationInFrames={180}
|
||||
fps={30}
|
||||
width={1280}
|
||||
height={720}
|
||||
/>
|
||||
);
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2018",
|
||||
"module": "ESNext",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup.sh — generate the binary assets this fixture needs.
|
||||
#
|
||||
# Both Remotion and HyperFrames variants need a 200x200 blue PNG and a
|
||||
# 6-second silent WAV. Generating them via ffmpeg keeps binaries out of
|
||||
# the repo while still letting the fixture render reproducibly.
|
||||
#
|
||||
# Run from the fixture root: ./setup.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
THIS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "error: ffmpeg not on PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$THIS_DIR/remotion-src/public" "$THIS_DIR/hf-src/assets"
|
||||
|
||||
# 200x200 solid blue PNG, ~200 bytes.
|
||||
ffmpeg -y -hide_banner -loglevel error \
|
||||
-f lavfi -i "color=color=#3066be:size=200x200" -frames:v 1 \
|
||||
"$THIS_DIR/remotion-src/public/square.png"
|
||||
cp "$THIS_DIR/remotion-src/public/square.png" "$THIS_DIR/hf-src/assets/square.png"
|
||||
|
||||
# 6-second silent WAV at 8 kHz mono. ~96 KB if checked in, but it is generated.
|
||||
ffmpeg -y -hide_banner -loglevel error \
|
||||
-f lavfi -i "anullsrc=cl=mono:r=8000" -t 6 -acodec pcm_s16le \
|
||||
"$THIS_DIR/remotion-src/public/music.wav"
|
||||
cp "$THIS_DIR/remotion-src/public/music.wav" "$THIS_DIR/hf-src/assets/music.wav"
|
||||
|
||||
echo "generated:"
|
||||
ls -la "$THIS_DIR/remotion-src/public/" "$THIS_DIR/hf-src/assets/"
|
||||
@@ -0,0 +1,10 @@
|
||||
# Render output
|
||||
remotion-src/out/
|
||||
hf-src/out/
|
||||
hf.mp4
|
||||
diff/
|
||||
strip/
|
||||
|
||||
# Remotion / HF dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,85 @@
|
||||
# Tier 3 — stargazed-data-driven
|
||||
|
||||
## What it tests
|
||||
|
||||
A purpose-built data-driven fixture that exercises the realistic shape of a
|
||||
production Remotion composition without using the runtime adapter from
|
||||
PR #214. If a translation passes T3, the skill correctly handles:
|
||||
|
||||
- A `<Composition>` with a `z.object` schema and typed `defaultProps`
|
||||
- Custom React subcomponents reused with different props across scenes
|
||||
- A nested data structure (`stats[]`) materialized as repeated HTML with
|
||||
per-instance attributes
|
||||
- A frame-driven count-up animation (`AnimatedNumber` → GSAP `onUpdate`)
|
||||
- Two different `spring` configs translated to two different `back.out` overshoots
|
||||
- Per-instance delays via component props (`delayInFrames` → GSAP timeline offsets)
|
||||
|
||||
## Composition shape
|
||||
|
||||
```
|
||||
Stargazed (10 s @ 30 fps, 1280×720)
|
||||
├── Sequence 0–3 s TitleScene
|
||||
│ ├── title ← spring scale
|
||||
│ └── subtitle ← linear fade
|
||||
├── Sequence 3–7 s StatsScene
|
||||
│ ├── StatCard "Stars" 1247 #fbbf24 (delay 0 frames)
|
||||
│ ├── StatCard "Forks" 312 #60a5fa (delay 12 frames)
|
||||
│ └── StatCard "Issues" 48 #f87171 (delay 24 frames)
|
||||
└── Sequence 7–10 s OutroScene
|
||||
└── UnderlinedText "thanks for watching" ← scale-in underline
|
||||
```
|
||||
|
||||
Each `StatCard` is a custom subcomponent that internally uses `AnimatedNumber`
|
||||
to count from 0 to the target. `AnimatedNumber` itself derives the displayed
|
||||
value from `useCurrentFrame()` + a manual `1 - (1 - t)^3` ease.
|
||||
|
||||
## The lossy parts (and why threshold = 0.90)
|
||||
|
||||
1. **`spring → back.out(N)`**: two different spring configs in this composition.
|
||||
- `{ damping: 12, stiffness: 100, mass: 1 }` (title) → `back.out(1.4)`
|
||||
- `{ damping: 14, stiffness: 90, mass: 1 }` (stat card) → `back.out(1.2)`
|
||||
|
||||
Overshoot ratio (1.4 vs 1.2) approximates the damping difference. The
|
||||
late-tail curve of GSAP's back ease and Remotion's spring don't match
|
||||
exactly — costs ~0.03 mean SSIM per spring instance.
|
||||
|
||||
2. **Count-up easing**: `AnimatedNumber` uses `1 - (1 - t)^3` (cubic ease-out)
|
||||
manually computed in the component. GSAP's `power3.out` is the same curve
|
||||
shape — should match closely. The displayed integer is rounded each frame
|
||||
in both renderers; minor mismatches occur when the rounded value flips
|
||||
between two numbers on a sub-frame timing difference.
|
||||
|
||||
3. **Font rendering**: same caveat as T1/T2. System Helvetica/Arial fallback
|
||||
produces minor anti-aliasing differences between renderers. Affects the
|
||||
stat card numbers (large weight 800) most.
|
||||
|
||||
A mean SSIM below 0.90 in T3 indicates a _structural_ mismatch (wrong scene
|
||||
durations, wrong stagger timing, missing prop wiring), not approximation
|
||||
drift. That's the failure signal we care about. The calibrated mean against
|
||||
Remotion @ 4.0 with PNG/BT.709 output is 0.953.
|
||||
|
||||
## Translation walk-through (skill cheat sheet)
|
||||
|
||||
| Remotion | HyperFrames |
|
||||
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `<Composition schema={z.object({...})} defaultProps={...} />` | data-\* attributes on root `#stage` div |
|
||||
| nested array prop (`stats[]`) | repeated HTML markup with per-instance `data-*` attrs |
|
||||
| custom React subcomponent | inline repeated HTML using the component's prop interface as the template |
|
||||
| `<AnimatedNumber from={0} to={value} dur={45} />` (cubic ease-out count-up) | tween on `{ v: 0 }` object with `onUpdate` rewriting `textContent`, ease `power3.out` |
|
||||
| `spring({damping:12, stiffness:100})` | `back.out(1.4)` over ~0.7 s |
|
||||
| `spring({damping:14, stiffness:90})` | `back.out(1.2)` over ~0.7 s |
|
||||
| `delayInFrames={i * 12}` (per-instance) | GSAP timeline offset `(i * 0.4)` s |
|
||||
| `useVideoConfig()` to get `fps` | dropped — composition fps is in `data-fps` on `#stage` |
|
||||
|
||||
## How to render and evaluate
|
||||
|
||||
```bash
|
||||
# Render Remotion baseline (no setup.sh — no binary assets in this fixture)
|
||||
cd remotion-src && npm install && npm run render
|
||||
|
||||
# Render HyperFrames translation
|
||||
cd ../hf-src && npx hyperframes render --output ../hf.mp4
|
||||
|
||||
# Compare
|
||||
../../../scripts/render_diff.sh ./remotion-src/out/baseline.mp4 ./hf.mp4 ./diff
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"tier": 3,
|
||||
"name": "stargazed-data-driven",
|
||||
"composition_id": "Stargazed",
|
||||
"description": "Data-driven 10-second composition with three scenes, custom React subcomponents reused across scenes, a Zod schema with defaultProps, and a count-up number animation. Translates the realistic shape of a production Remotion composition into HF — without using the runtime adapter from PR #214.",
|
||||
"duration_seconds": 10,
|
||||
"fps": 30,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"ssim_threshold": 0.9,
|
||||
"validation": {
|
||||
"measured_mean_ssim": 0.953,
|
||||
"measured_min_ssim": 0.927,
|
||||
"measured_p05_ssim": 0.938,
|
||||
"measured_p95_ssim": 0.977,
|
||||
"measured_at": "2026-04-27",
|
||||
"measured_against": "remotion@4.0 (PNG output, BT.709) vs hyperframes@0.4.15-alpha.1",
|
||||
"notes": "Count-up timing in StatsScene shows a few-frame offset between Remotion's manual 1-(1-t)^3 and GSAP's power3.out — both formulas are identical, so the offset comes from sub-frame timing of when the seek + onUpdate fire. Final values converge correctly. Visible only as transient digit mismatches mid-animation; no SSIM impact above the noise floor."
|
||||
},
|
||||
"remotion_apis_exercised": [
|
||||
"Composition with z.object schema and typed defaultProps",
|
||||
"Sequence (3 nested with computed offsets)",
|
||||
"AbsoluteFill",
|
||||
"useCurrentFrame, useVideoConfig",
|
||||
"interpolate (single-segment, multi-segment, with extrapolation)",
|
||||
"spring (two configs: damping:12 and damping:14)",
|
||||
"Custom React subcomponents reused with different props (StatCard ×3)",
|
||||
"Custom React utility component (AnimatedNumber for count-up)",
|
||||
"Custom React utility component (UnderlinedText)",
|
||||
"Per-instance delay via prop (delayInFrames)"
|
||||
],
|
||||
"translation_notes": [
|
||||
"Zod schema + defaultProps → data-* attributes on the root #stage div. The skill emits one data attribute per scalar prop; nested arrays (stats[]) get materialized as repeated HTML markup with per-instance data attributes (data-stat-index, data-stat-value, --card-color).",
|
||||
"Custom React subcomponents inline as repeated HTML divs. The component prop interface becomes the repeated markup template. This is lossy for components with internal state — fine here because StatCard, AnimatedNumber, UnderlinedText all derive from props alone.",
|
||||
"AnimatedNumber's frame-driven count-up → a GSAP tween on a { v: 0 } counter object with onUpdate rewriting textContent. GSAP's power3.out is cubic easeOut, matching the Remotion 1-(1-t)^3 manual ease.",
|
||||
"Two different springs in this composition: damping:12 → back.out(1.4) (snappy), damping:14 → back.out(1.2) (calmer). The 1.4 vs 1.2 overshoot ratio approximates the damping difference.",
|
||||
"Per-instance stagger via delayInFrames prop translates to a GSAP timeline offset of (i * 0.4)s.",
|
||||
"Threshold 0.90 reflects: spring → back.out approximation (×2 different configs), the count-up easing curve match (very close but not identical due to sub-frame seek timing), font/AA differences on body text. SSIM well below 0.90 indicates a structural mismatch, not approximation drift."
|
||||
],
|
||||
"rationale": "Threshold 0.90 sits ~0.04 below measured p05 (0.938). The wider gap vs T1/T2 reflects T3's bigger approximation budget (2 spring instances + count-up timing + font fallback on multiple text sizes). Mean SSIM below 0.90 = structural mismatch (wrong durations, wrong stagger, missing prop wiring), not approximation drift."
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>tier-3-data-driven</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
color: #ffffff;
|
||||
}
|
||||
.scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Scene 1 — Title */
|
||||
.scene-1 {
|
||||
flex-direction: column;
|
||||
}
|
||||
.scene-1 .title {
|
||||
font-size: 160px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.05em;
|
||||
transform: scale(0);
|
||||
}
|
||||
.scene-1 .subtitle {
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
color: #9ca3af;
|
||||
margin-top: 24px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Scene 2 — Stats */
|
||||
.scene-2 {
|
||||
gap: 48px;
|
||||
}
|
||||
.stat-card {
|
||||
width: 280px;
|
||||
height: 220px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 16px;
|
||||
border: 2px solid var(--card-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
.stat-card .number {
|
||||
font-size: 72px;
|
||||
font-weight: 800;
|
||||
color: var(--card-color);
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-card .label {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
margin-top: 16px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* Scene 3 — Outro */
|
||||
.scene-3 .outro-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
}
|
||||
.scene-3 .outro-text {
|
||||
font-size: 80px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.scene-3 .outro-underline {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -8px;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #fbbf24;
|
||||
border-radius: 3px;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="stage"
|
||||
data-composition-id="tier-3-data-driven"
|
||||
data-start="0"
|
||||
data-width="1280"
|
||||
data-height="720"
|
||||
data-duration="10"
|
||||
data-fps="30"
|
||||
data-title="STARGAZED"
|
||||
data-subtitle="by HeyGen"
|
||||
data-outro="thanks for watching"
|
||||
>
|
||||
<!-- Scene 1: Title -->
|
||||
<div
|
||||
id="scene-1"
|
||||
class="scene scene-1 clip"
|
||||
data-start="0"
|
||||
data-duration="3"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="title">STARGAZED</div>
|
||||
<div class="subtitle">by HeyGen</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 2: Stats — three StatCards as repeated markup with per-instance data attrs -->
|
||||
<div
|
||||
id="scene-2"
|
||||
class="scene scene-2 clip"
|
||||
data-start="3"
|
||||
data-duration="4"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div
|
||||
class="stat-card"
|
||||
data-stat-index="0"
|
||||
data-stat-value="1247"
|
||||
style="--card-color: #fbbf24"
|
||||
>
|
||||
<div class="number">0</div>
|
||||
<div class="label">Stars</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card"
|
||||
data-stat-index="1"
|
||||
data-stat-value="312"
|
||||
style="--card-color: #60a5fa"
|
||||
>
|
||||
<div class="number">0</div>
|
||||
<div class="label">Forks</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card"
|
||||
data-stat-index="2"
|
||||
data-stat-value="48"
|
||||
style="--card-color: #f87171"
|
||||
>
|
||||
<div class="number">0</div>
|
||||
<div class="label">Issues</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 3: Outro -->
|
||||
<div
|
||||
id="scene-3"
|
||||
class="scene scene-3 clip"
|
||||
data-start="7"
|
||||
data-duration="3"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="outro-wrap">
|
||||
<div class="outro-text">thanks for watching</div>
|
||||
<div class="outro-underline"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Translation of Stargazed.tsx
|
||||
//
|
||||
// Remotion structure:
|
||||
// <Composition schema={zod} defaultProps={...} fps=30 dur=300>
|
||||
// <Sequence 0..90> <TitleScene title subtitle />
|
||||
// <Sequence 90..210> <StatsScene stats />
|
||||
// <Sequence 210..300><OutroScene text />
|
||||
//
|
||||
// HF structure:
|
||||
// - root #stage carries data-* with the same props (title, subtitle, outro)
|
||||
// - 3 scene divs with data-start/data-duration in seconds (frames/fps)
|
||||
// - Custom React subcomponents inline as repeated HTML; their per-instance
|
||||
// props become data-* attributes on the markup
|
||||
// - Animation lives in a single paused GSAP timeline keyed by composition seconds
|
||||
//
|
||||
// Frame → time:
|
||||
// Sequence(0,90) → 0s..3s (title)
|
||||
// Sequence(90,210) → 3s..7s (stats; 4s window)
|
||||
// Sequence(210,300) → 7s..10s (outro)
|
||||
// Stagger(i*12) → i*0.4s (12 frames at 30fps)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// ─── Scene 1: Title ─────────────────────────────────────────────────
|
||||
const scene1 = document.querySelector("#scene-1");
|
||||
const title = scene1.querySelector(".title");
|
||||
const subtitle = scene1.querySelector(".subtitle");
|
||||
|
||||
tl.set(scene1, { opacity: 1 }, 0);
|
||||
// spring({damping:12, stiffness:100, mass:1}) → back.out(1.4) ~0.7s
|
||||
tl.to(title, { scale: 1, duration: 0.7, ease: "back.out(1.4)" }, 0);
|
||||
// interpolate(frame, [20,40], [0,1]) at fps=30 → 0.667s..1.333s linear
|
||||
tl.fromTo(subtitle, { opacity: 0 }, { opacity: 1, duration: 0.667, ease: "none" }, 0.667);
|
||||
tl.set(scene1, { opacity: 0 }, 3);
|
||||
|
||||
// ─── Scene 2: Stats ─────────────────────────────────────────────────
|
||||
const scene2 = document.querySelector("#scene-2");
|
||||
const cards = scene2.querySelectorAll(".stat-card");
|
||||
|
||||
tl.set(scene2, { opacity: 1 }, 3);
|
||||
|
||||
cards.forEach((card, i) => {
|
||||
const stagger = i * 0.4; // i * 12 frames at 30 fps
|
||||
const start = 3 + stagger;
|
||||
const value = Number(card.dataset.statValue);
|
||||
const numberEl = card.querySelector(".number");
|
||||
|
||||
// StatCard entrance:
|
||||
// spring({damping:14, stiffness:90, mass:1}) → back.out(1.2) ~0.7s
|
||||
// interpolate(local, [0,12], [0,1]) → 0s..0.4s linear opacity
|
||||
tl.to(card, { scale: 1, duration: 0.7, ease: "back.out(1.2)" }, start);
|
||||
tl.to(card, { opacity: 1, duration: 0.4, ease: "none" }, start);
|
||||
|
||||
// AnimatedNumber: count from 0 → value with easeOutCubic over 45 frames (1.5s).
|
||||
// GSAP equivalent: a tween on a counter object with onUpdate rewriting textContent.
|
||||
// power3.out is GSAP's name for cubic easeOut.
|
||||
const counter = { v: 0 };
|
||||
tl.to(
|
||||
counter,
|
||||
{
|
||||
v: value,
|
||||
duration: 1.5,
|
||||
ease: "power3.out",
|
||||
onUpdate: () => {
|
||||
numberEl.textContent = Math.round(counter.v).toLocaleString();
|
||||
},
|
||||
},
|
||||
start,
|
||||
);
|
||||
});
|
||||
|
||||
tl.set(scene2, { opacity: 0 }, 7);
|
||||
|
||||
// ─── Scene 3: Outro ─────────────────────────────────────────────────
|
||||
const scene3 = document.querySelector("#scene-3");
|
||||
const outroWrap = scene3.querySelector(".outro-wrap");
|
||||
const underline = scene3.querySelector(".outro-underline");
|
||||
|
||||
tl.set(scene3, { opacity: 1 }, 7);
|
||||
// interpolate(frame, [0,12], [0,1]) → 0s..0.4s linear opacity
|
||||
tl.to(outroWrap, { opacity: 1, duration: 0.4, ease: "none" }, 7);
|
||||
// interpolate(frame, [10,40], [0,1]) for underline → starts at 7+0.333s, dur 1s
|
||||
tl.to(underline, { scaleX: 1, duration: 1.0, ease: "none" }, 7.333);
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["tier-3-data-driven"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "tier-3-data-driven-remotion",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"render": "remotion render Stargazed out/baseline.mp4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "^4.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"remotion": "^4.0.0",
|
||||
"zod": "^3.22.0"
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
// Match HyperFrames' default render so SSIM diffs measure translation
|
||||
// fidelity, not encoder differences.
|
||||
//
|
||||
// setVideoImageFormat("png") avoids the JPEG limited-range/full-range
|
||||
// colorspace flag (yuvj420p vs yuv420p) that otherwise costs ~0.05 SSIM.
|
||||
//
|
||||
// setColorSpace("bt709") matches HF's BT.709 SDR output.
|
||||
Config.setVideoImageFormat("png");
|
||||
Config.setColorSpace("bt709");
|
||||
Config.setOverwriteOutput(true);
|
||||
Config.setConcurrency(1);
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { Composition } from "remotion";
|
||||
import { z } from "zod";
|
||||
import { Stargazed, stargazedSchema } from "./Stargazed";
|
||||
|
||||
const defaultProps: z.infer<typeof stargazedSchema> = {
|
||||
title: "STARGAZED",
|
||||
subtitle: "by HeyGen",
|
||||
stats: [
|
||||
{ label: "Stars", value: 1247, color: "#fbbf24" },
|
||||
{ label: "Forks", value: 312, color: "#60a5fa" },
|
||||
{ label: "Issues", value: 48, color: "#f87171" },
|
||||
],
|
||||
outro: "thanks for watching",
|
||||
};
|
||||
|
||||
export const RemotionRoot = () => (
|
||||
<Composition
|
||||
id="Stargazed"
|
||||
component={Stargazed}
|
||||
schema={stargazedSchema}
|
||||
durationInFrames={300}
|
||||
fps={30}
|
||||
width={1280}
|
||||
height={720}
|
||||
defaultProps={defaultProps}
|
||||
/>
|
||||
);
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { AbsoluteFill, Sequence } from "remotion";
|
||||
import { z } from "zod";
|
||||
import { TitleScene } from "./scenes/TitleScene";
|
||||
import { StatsScene } from "./scenes/StatsScene";
|
||||
import { OutroScene } from "./scenes/OutroScene";
|
||||
|
||||
export const stargazedSchema = z.object({
|
||||
title: z.string(),
|
||||
subtitle: z.string(),
|
||||
stats: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
color: z.string(),
|
||||
}),
|
||||
),
|
||||
outro: z.string(),
|
||||
});
|
||||
|
||||
export const Stargazed: React.FC<z.infer<typeof stargazedSchema>> = ({
|
||||
title,
|
||||
subtitle,
|
||||
stats,
|
||||
outro,
|
||||
}) => (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0a0a0a" }}>
|
||||
<Sequence from={0} durationInFrames={90}>
|
||||
<TitleScene title={title} subtitle={subtitle} />
|
||||
</Sequence>
|
||||
<Sequence from={90} durationInFrames={120}>
|
||||
<StatsScene stats={stats} />
|
||||
</Sequence>
|
||||
<Sequence from={210} durationInFrames={90}>
|
||||
<OutroScene text={outro} />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { interpolate, useCurrentFrame } from "remotion";
|
||||
|
||||
interface Props {
|
||||
from: number;
|
||||
to: number;
|
||||
durationInFrames: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts from `from` to `to` over `durationInFrames` with easeOut.
|
||||
* Driven entirely by useCurrentFrame — deterministic.
|
||||
*/
|
||||
export const AnimatedNumber: React.FC<Props> = ({ from, to, durationInFrames }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const t = interpolate(frame, [0, durationInFrames], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
// Ease-out cubic — fast start, slow finish, matches the ramp on data dashboards.
|
||||
const eased = 1 - (1 - t) ** 3;
|
||||
const value = Math.round(from + (to - from) * eased);
|
||||
return <>{value.toLocaleString()}</>;
|
||||
};
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { AnimatedNumber } from "./AnimatedNumber";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
delayInFrames: number;
|
||||
}
|
||||
|
||||
export const StatCard: React.FC<Props> = ({ label, value, color, delayInFrames }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const local = frame - delayInFrames;
|
||||
|
||||
const scale = spring({
|
||||
frame: local,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 90, mass: 1 },
|
||||
});
|
||||
const opacity = interpolate(local, [0, 12], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 280,
|
||||
height: 220,
|
||||
background: "#1a1a1a",
|
||||
borderRadius: 16,
|
||||
border: `2px solid ${color}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 72, fontWeight: 800, color, lineHeight: 1 }}>
|
||||
{local >= 0 ? <AnimatedNumber from={0} to={value} durationInFrames={45} /> : 0}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 500,
|
||||
color: "#9ca3af",
|
||||
marginTop: 16,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.1em",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { interpolate, useCurrentFrame } from "remotion";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const UnderlinedText: React.FC<Props> = ({ text, color }) => {
|
||||
const frame = useCurrentFrame();
|
||||
// Underline scales from left over 0-30 frames.
|
||||
const underlineScaleX = interpolate(frame, [10, 40], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const opacity = interpolate(frame, [0, 12], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", display: "inline-block", opacity }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 80,
|
||||
fontWeight: 600,
|
||||
color: "#ffffff",
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
bottom: -8,
|
||||
width: "100%",
|
||||
height: 6,
|
||||
background: color,
|
||||
transform: `scaleX(${underlineScaleX})`,
|
||||
transformOrigin: "left center",
|
||||
borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { AbsoluteFill } from "remotion";
|
||||
import { UnderlinedText } from "../components/UnderlinedText";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const OutroScene: React.FC<Props> = ({ text }) => (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
<UnderlinedText text={text} color="#fbbf24" />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { AbsoluteFill } from "remotion";
|
||||
import { StatCard } from "../components/StatCard";
|
||||
|
||||
interface Stat {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
stats: Stat[];
|
||||
}
|
||||
|
||||
export const StatsScene: React.FC<Props> = ({ stats }) => (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 48,
|
||||
flexDirection: "row",
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
{stats.map((stat, i) => (
|
||||
<StatCard
|
||||
key={stat.label}
|
||||
label={stat.label}
|
||||
value={stat.value}
|
||||
color={stat.color}
|
||||
delayInFrames={i * 12}
|
||||
/>
|
||||
))}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export const TitleScene: React.FC<Props> = ({ title, subtitle }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const titleScale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 100, mass: 1 },
|
||||
});
|
||||
const subtitleOpacity = interpolate(frame, [20, 40], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 160,
|
||||
fontWeight: 900,
|
||||
color: "#ffffff",
|
||||
letterSpacing: "0.05em",
|
||||
transform: `scale(${titleScale})`,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 400,
|
||||
color: "#9ca3af",
|
||||
marginTop: 24,
|
||||
opacity: subtitleOpacity,
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2018",
|
||||
"module": "ESNext",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# Tier 4 — escape-hatch
|
||||
|
||||
## What it tests
|
||||
|
||||
T4 is the **lint-only** tier. There are no renders to diff — the skill is
|
||||
graded on whether it correctly _refuses_ to translate each case (and
|
||||
recommends the runtime interop pattern from PR #214 instead) or, where
|
||||
appropriate, translates after dropping warning-level decorations.
|
||||
|
||||
Each `cases/*.tsx` file is a minimal Remotion composition that
|
||||
demonstrates one specific pattern. The skill should:
|
||||
|
||||
1. Run `scripts/lint_source.py` over the source.
|
||||
2. Compare the JSON output to `expected.json` for that case.
|
||||
3. Take the documented `skill_action`:
|
||||
- `refuse_translation_recommend_interop` — print the rationale + link to
|
||||
the PR #214 interop guide; do not produce HF output.
|
||||
- `drop_lambda_code_translate_remainder_if_clean` — drop the
|
||||
`@remotion/lambda` code with a note; translate the rest only if no
|
||||
other blockers are present.
|
||||
- `translate_after_dropping_wrappers` — translate normally; drop
|
||||
`useCallback` / `useMemo` / `delayRender` wrappers.
|
||||
- `inline_hook_body_if_pure` — inline the custom hook's body if it's a
|
||||
pure derivation of `useCurrentFrame`; otherwise bow out.
|
||||
|
||||
## Cases
|
||||
|
||||
| # | File | Expected finding | Notes |
|
||||
| --- | -------------------------- | ----------------------------------- | ----------------------------------------------- |
|
||||
| 01 | `01-use-state.tsx` | blocker `r2hf/use-state` | useState driving animation |
|
||||
| 02 | `02-use-effect-deps.tsx` | blocker `r2hf/use-effect-deps` | useEffect/useLayoutEffect with non-empty deps |
|
||||
| 03 | `03-async-metadata.tsx` | blocker `r2hf/async-metadata` | calculateMetadata returns a Promise |
|
||||
| 04 | `04-third-party-react.tsx` | blocker `r2hf/third-party-react-ui` | imports `@mui/material` |
|
||||
| 05 | `05-lambda-config.tsx` | warning `r2hf/lambda-import` | imports `@remotion/lambda` — drops, translates |
|
||||
| 06 | `06-warnings-only.tsx` | warnings only | delayRender / useCallback / useMemo |
|
||||
| 07 | `07-custom-hook.tsx` | warning `r2hf/custom-hook` | locally-defined `useFadeIn` (export const form) |
|
||||
| 08 | `08-mixed.tsx` | 3 blockers + 1 warning | aggregate-findings test |
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
./validate.sh
|
||||
```
|
||||
|
||||
The script runs `lint_source.py` against each case and asserts:
|
||||
|
||||
- Each expected blocker rule fires with severity `blocker`.
|
||||
- Each expected warning rule fires with severity `warning` (or stronger).
|
||||
- `lint_source.py`'s exit code is 1 when blockers are expected, 0 otherwise.
|
||||
|
||||
T4 passes when every case matches its expected output. No renders involved.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
// T4 case 01 — useState drives animation.
|
||||
//
|
||||
// Should be detected by lint_source.py as blocker r2hf/use-state.
|
||||
// The skill should refuse to translate and recommend the runtime interop
|
||||
// pattern from PR #214.
|
||||
//
|
||||
// Why this is a blocker: useState is React's component-local mutable state.
|
||||
// HF's seek-driven model produces deterministic frames from a single time
|
||||
// value — there's no per-frame React render cycle to update state on.
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { AbsoluteFill, useCurrentFrame } from "remotion";
|
||||
|
||||
export const StateDriven: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const [hue, setHue] = useState(0);
|
||||
|
||||
// Even if this looks innocuous, the setHue call breaks determinism: HF
|
||||
// can't reproduce React state mutations across seeks.
|
||||
if (frame % 30 === 0 && hue < 360) {
|
||||
setHue((h) => h + 30);
|
||||
}
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: `hsl(${hue}, 80%, 50%)` }}>
|
||||
<div>frame {frame}</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
// T4 case 02 — useEffect with non-empty deps performs side effects per render.
|
||||
//
|
||||
// Should be detected by lint_source.py as blocker r2hf/use-effect-deps.
|
||||
// The skill should refuse to translate.
|
||||
//
|
||||
// Why this is a blocker: side effects (network, DOM mutation outside the
|
||||
// rendered tree, timers) don't translate to a seek-driven model. HF assumes
|
||||
// the page is fully rendered and pure between seeks.
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { AbsoluteFill, useCurrentFrame } from "remotion";
|
||||
|
||||
export const SideEffectDriven: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.fillRect(frame, frame, 10, 10);
|
||||
}, [frame]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<canvas ref={canvasRef} width={1280} height={720} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
// T4 case 03 — calculateMetadata returns a Promise.
|
||||
//
|
||||
// Should be detected by lint_source.py as blocker r2hf/async-metadata.
|
||||
// The skill should refuse to translate.
|
||||
//
|
||||
// Why this is a blocker: HF needs the composition's duration, dimensions,
|
||||
// and props known up-front to produce HTML and seed the timeline. Async
|
||||
// metadata fetched from a server at render time has no equivalent in HF —
|
||||
// the metadata would need to be resolved at build time before the HTML is
|
||||
// authored.
|
||||
|
||||
import React from "react";
|
||||
import { AbsoluteFill, useCurrentFrame } from "remotion";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const AsyncMetadataDriven: React.FC<Props> = ({ text }) => {
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<div>
|
||||
{text} · frame {frame}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const calculateMetadata = async ({ props }: { props: Props }) => {
|
||||
const response = await fetch(
|
||||
`https://api.example.com/duration?text=${encodeURIComponent(props.text)}`,
|
||||
);
|
||||
const { durationInFrames } = await response.json();
|
||||
return {
|
||||
durationInFrames,
|
||||
fps: 30,
|
||||
};
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// T4 case 04 — Imports from a third-party React UI library.
|
||||
//
|
||||
// Should be detected by lint_source.py as blocker r2hf/third-party-react-ui.
|
||||
// The skill should refuse to translate.
|
||||
//
|
||||
// Why this is a blocker: a Material-UI Button (or any React UI library
|
||||
// component) is a React-only abstraction with internal hooks, refs, and
|
||||
// theme provider context. Translating it to HTML+CSS would require
|
||||
// re-implementing the design system, which is out of scope for a video
|
||||
// translation skill. Use the runtime interop pattern from PR #214 to keep
|
||||
// these components rendering through Remotion's React tree.
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@mui/material";
|
||||
import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
|
||||
|
||||
export const MuiDriven: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ opacity }}>
|
||||
<Button variant="contained" color="primary">
|
||||
Click me · frame {frame}
|
||||
</Button>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
// T4 case 05 — Imports @remotion/lambda for distributed rendering config.
|
||||
//
|
||||
// Should be detected by lint_source.py as warning r2hf/lambda-import.
|
||||
// The skill drops the Lambda code with a note (HF runs single-machine
|
||||
// today) and translates the rest of the composition.
|
||||
//
|
||||
// Why this is a warning, not a blocker: @remotion/lambda config is
|
||||
// orthogonal to the rendered composition — it's deployment configuration,
|
||||
// not animation logic. Treating it as a hard blocker would refuse
|
||||
// translation for compositions that are otherwise clean. The skill drops
|
||||
// the Lambda calls in step 3 (Generate) and writes a TRANSLATION_NOTES.md
|
||||
// entry so the user knows to set up HF rendering separately.
|
||||
|
||||
import React from "react";
|
||||
import { renderMediaOnLambda } from "@remotion/lambda";
|
||||
import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
|
||||
|
||||
export const LambdaConfigured: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const opacity = interpolate(frame, [0, 30], [0, 1]);
|
||||
return (
|
||||
<AbsoluteFill style={{ opacity }}>
|
||||
<div>frame {frame}</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Rendered at scale via Lambda — no HF equivalent.
|
||||
export async function renderViaLambda() {
|
||||
return renderMediaOnLambda({
|
||||
region: "us-east-1",
|
||||
functionName: "remotion-render",
|
||||
composition: "LambdaConfigured",
|
||||
serveUrl: "https://example.com/bundle",
|
||||
inputProps: {},
|
||||
codec: "h264",
|
||||
});
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
// T4 case 06 — Patterns that warn but don't block.
|
||||
//
|
||||
// Should be detected by lint_source.py with:
|
||||
// - r2hf/delay-render (warning) — drop the call; HF handles asset readiness
|
||||
// - r2hf/use-callback (warning) — decorative, drop the wrapper
|
||||
// - r2hf/use-memo (warning) — decorative, drop the wrapper
|
||||
//
|
||||
// 0 blockers expected — the skill should still translate this composition
|
||||
// after dropping the wrappers. delayRender is paired with continueRender via
|
||||
// an empty-deps useEffect (mount-once side effect), which doesn't trip the
|
||||
// use-effect-deps blocker.
|
||||
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { AbsoluteFill, delayRender, continueRender, useCurrentFrame, interpolate } from "remotion";
|
||||
|
||||
const handle = delayRender();
|
||||
// Resolve the handle once at module load — no per-frame side effects.
|
||||
queueMicrotask(() => continueRender(handle));
|
||||
|
||||
export const WarningsOnly: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// useCallback / useMemo — decorative for render-perf in React, no equivalent
|
||||
// needed in the seek-driven HF model.
|
||||
const opacity = useMemo(
|
||||
() => interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" }),
|
||||
[frame],
|
||||
);
|
||||
const onMount = useCallback(() => {}, []);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ opacity }} onClick={onMount}>
|
||||
<div>frame {frame}</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
// T4 case 07 — Locally-defined custom hook.
|
||||
//
|
||||
// Should be detected by lint_source.py as warning r2hf/custom-hook.
|
||||
// 0 blockers expected — the skill can attempt translation if the hook body
|
||||
// is pure (derives from props/frame alone).
|
||||
//
|
||||
// Why this is a warning: custom hooks vary widely in what they do. Some are
|
||||
// pure derivations of useCurrentFrame (translatable — inline the body); some
|
||||
// wrap useState/useEffect (blocker — but those will be caught by the other
|
||||
// rules independently). The warning prompts the agent to inspect the body.
|
||||
|
||||
import React from "react";
|
||||
import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
|
||||
|
||||
// Custom hook — pure derivation from frame, no state. Translates fine.
|
||||
function useFadeIn(durationInFrames: number) {
|
||||
const frame = useCurrentFrame();
|
||||
return interpolate(frame, [0, durationInFrames], [0, 1], { extrapolateRight: "clamp" });
|
||||
}
|
||||
|
||||
export const CustomHookDriven: React.FC = () => {
|
||||
const opacity = useFadeIn(30);
|
||||
return (
|
||||
<AbsoluteFill style={{ opacity }}>
|
||||
<div>fading in</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
// T4 case 08 — Multiple blockers + multiple warnings in one file.
|
||||
//
|
||||
// Should report:
|
||||
// blockers: r2hf/use-state, r2hf/use-effect-deps, r2hf/third-party-react-ui
|
||||
// warnings: r2hf/use-callback (also r2hf/delay-render via the import chain
|
||||
// would only fire if delayRender is actually called)
|
||||
//
|
||||
// Tests that the linter aggregates findings correctly and does not stop at
|
||||
// the first blocker.
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { AbsoluteFill, useCurrentFrame } from "remotion";
|
||||
import { Card } from "@chakra-ui/react";
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const MixedBlockers: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/items")
|
||||
.then((r) => r.json())
|
||||
.then(setItems);
|
||||
}, [frame]);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
setItems((prev) => [...prev, { id: String(prev.length), label: "new" }]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AbsoluteFill onClick={onClick}>
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>{item.label}</Card>
|
||||
))}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"tier": 4,
|
||||
"name": "escape-hatch",
|
||||
"description": "Lint-only fixture set. Each case demonstrates a Remotion pattern the skill cannot or should not translate cleanly. The skill is graded on whether lint_source.py emits the right finding for each case — there are no renders to compare. T4 passes when every case triggers its expected rule and no others.",
|
||||
"cases": [
|
||||
{
|
||||
"file": "01-use-state.tsx",
|
||||
"expected": {
|
||||
"blockers": [{ "rule": "r2hf/use-state", "min_count": 1 }],
|
||||
"warnings": [],
|
||||
"skill_action": "refuse_translation_recommend_interop"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "02-use-effect-deps.tsx",
|
||||
"expected": {
|
||||
"blockers": [{ "rule": "r2hf/use-effect-deps", "min_count": 1 }],
|
||||
"warnings": [],
|
||||
"skill_action": "refuse_translation_recommend_interop"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "03-async-metadata.tsx",
|
||||
"expected": {
|
||||
"blockers": [{ "rule": "r2hf/async-metadata", "min_count": 1 }],
|
||||
"warnings": [],
|
||||
"skill_action": "refuse_translation_recommend_interop"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "04-third-party-react.tsx",
|
||||
"expected": {
|
||||
"blockers": [{ "rule": "r2hf/third-party-react-ui", "min_count": 1 }],
|
||||
"warnings": [],
|
||||
"skill_action": "refuse_translation_recommend_interop"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "05-lambda-config.tsx",
|
||||
"expected": {
|
||||
"blockers": [],
|
||||
"warnings": [{ "rule": "r2hf/lambda-import", "min_count": 1 }],
|
||||
"skill_action": "drop_lambda_code_translate_remainder_if_clean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "06-warnings-only.tsx",
|
||||
"expected": {
|
||||
"blockers": [],
|
||||
"warnings": [
|
||||
{ "rule": "r2hf/delay-render", "min_count": 1 },
|
||||
{ "rule": "r2hf/use-callback", "min_count": 1 },
|
||||
{ "rule": "r2hf/use-memo", "min_count": 1 }
|
||||
],
|
||||
"skill_action": "translate_after_dropping_wrappers"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "07-custom-hook.tsx",
|
||||
"expected": {
|
||||
"blockers": [],
|
||||
"warnings": [{ "rule": "r2hf/custom-hook", "min_count": 1 }],
|
||||
"skill_action": "inline_hook_body_if_pure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "08-mixed.tsx",
|
||||
"expected": {
|
||||
"blockers": [
|
||||
{ "rule": "r2hf/use-state", "min_count": 1 },
|
||||
{ "rule": "r2hf/use-effect-deps", "min_count": 1 },
|
||||
{ "rule": "r2hf/third-party-react-ui", "min_count": 1 }
|
||||
],
|
||||
"warnings": [{ "rule": "r2hf/use-callback", "min_count": 1 }],
|
||||
"skill_action": "refuse_translation_recommend_interop"
|
||||
}
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"expected_blocker_cases": 5,
|
||||
"expected_warning_only_cases": 3,
|
||||
"expected_total_blocker_findings_min": 7,
|
||||
"expected_total_warning_findings_min": 6
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# validate.sh — assert lint_source.py output matches expected.json for every T4 case.
|
||||
#
|
||||
# T4 has no renders to diff. The skill is graded on whether it correctly
|
||||
# refuses to translate each case (or drops only the lambda config in case 5,
|
||||
# or warns appropriately in cases 6 and 7).
|
||||
#
|
||||
# Usage:
|
||||
# ./validate.sh
|
||||
# Exit 0 on pass.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
THIS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SCRIPTS_DIR="$(cd "$THIS_DIR/../../../scripts" && pwd)"
|
||||
EXPECTED="$THIS_DIR/expected.json"
|
||||
|
||||
if [[ ! -f "$SCRIPTS_DIR/lint_source.py" ]]; then
|
||||
echo "error: lint_source.py not found at $SCRIPTS_DIR/lint_source.py" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "$EXPECTED" ]]; then
|
||||
echo "error: expected.json not found at $EXPECTED" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Drive lint_file() in-process so the per-case overhead is one Python startup,
|
||||
# not N (8 cases × ~80 ms forking python3 was the dominant cost).
|
||||
SCRIPTS_DIR="$SCRIPTS_DIR" \
|
||||
THIS_DIR="$THIS_DIR" \
|
||||
EXPECTED="$EXPECTED" \
|
||||
python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
scripts_dir = Path(os.environ["SCRIPTS_DIR"])
|
||||
this_dir = Path(os.environ["THIS_DIR"])
|
||||
expected_path = Path(os.environ["EXPECTED"])
|
||||
cases_dir = this_dir / "cases"
|
||||
|
||||
sys.path.insert(0, str(scripts_dir))
|
||||
from lint_source import BLOCKER, WARNING, lint_file # noqa: E402
|
||||
|
||||
expected = json.loads(expected_path.read_text())
|
||||
|
||||
fails: list[str] = []
|
||||
passes: list[str] = []
|
||||
|
||||
for case in expected["cases"]:
|
||||
file_name = case["file"]
|
||||
fixture = cases_dir / file_name
|
||||
if not fixture.exists():
|
||||
fails.append(f"{file_name}: fixture missing at {fixture}")
|
||||
continue
|
||||
|
||||
findings = lint_file(fixture)
|
||||
rule_counts: Counter[str] = Counter()
|
||||
severity_by_rule: dict[str, str] = {}
|
||||
for f in findings:
|
||||
rule_counts[f.rule] += 1
|
||||
severity_by_rule[f.rule] = f.severity
|
||||
|
||||
case_failed = False
|
||||
|
||||
def assert_rule(expected_entry, expected_severity_floor, kind):
|
||||
global case_failed
|
||||
rule = expected_entry["rule"]
|
||||
min_count = expected_entry["min_count"]
|
||||
actual = rule_counts[rule]
|
||||
actual_severity = severity_by_rule.get(rule)
|
||||
if actual < min_count:
|
||||
fails.append(f"{file_name}: expected >={min_count} {kind} findings of rule {rule}, got {actual}")
|
||||
case_failed = True
|
||||
elif actual_severity not in expected_severity_floor:
|
||||
fails.append(
|
||||
f"{file_name}: rule {rule} found but severity={actual_severity!r} (expected {kind})"
|
||||
)
|
||||
case_failed = True
|
||||
|
||||
for entry in case["expected"]["blockers"]:
|
||||
assert_rule(entry, {BLOCKER}, "blocker")
|
||||
for entry in case["expected"]["warnings"]:
|
||||
assert_rule(entry, {WARNING, BLOCKER}, "warning")
|
||||
|
||||
# Implied lint exit code: 1 when blockers are expected, 0 otherwise.
|
||||
has_blockers = any(f.severity == BLOCKER for f in findings)
|
||||
expected_has_blockers = bool(case["expected"]["blockers"])
|
||||
if has_blockers != expected_has_blockers:
|
||||
fails.append(
|
||||
f"{file_name}: implied lint exit {1 if has_blockers else 0}, "
|
||||
f"expected {1 if expected_has_blockers else 0} (blockers expected: {expected_has_blockers})"
|
||||
)
|
||||
case_failed = True
|
||||
|
||||
if not case_failed:
|
||||
passes.append(file_name)
|
||||
|
||||
print(f"Passed: {len(passes)}")
|
||||
for name in passes:
|
||||
print(f" ✓ {name}")
|
||||
if fails:
|
||||
print(f"Failed: {len(fails)}")
|
||||
for msg in fails:
|
||||
print(f" ✗ {msg}")
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
PY
|
||||
Reference in New Issue
Block a user