diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/.gitignore b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/.gitignore
new file mode 100644
index 000000000..d45877158
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/.gitignore
@@ -0,0 +1,10 @@
+# Render output
+remotion-src/out/
+hf-src/out/
+hf.mp4
+diff/
+strip/
+
+# Remotion / HF dependencies
+node_modules/
+package-lock.json
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/README.md b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/README.md
new file mode 100644
index 000000000..7b496809c
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/README.md
@@ -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.
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/expected.json b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/expected.json
new file mode 100644
index 000000000..bccec0a59
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/expected.json
@@ -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."
+}
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/hf-src/index.html b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/hf-src/index.html
new file mode 100644
index 000000000..df2570b8f
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/hf-src/index.html
@@ -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>
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/package.json b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/package.json
new file mode 100644
index 000000000..1c33b63b4
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/package.json
@@ -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"
+  }
+}
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/remotion.config.ts b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/remotion.config.ts
new file mode 100644
index 000000000..a22cafc5e
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/remotion.config.ts
@@ -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);
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/src/Root.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/src/Root.tsx
new file mode 100644
index 000000000..f8dd72770
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/src/Root.tsx
@@ -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}
+  />
+);
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/src/TitleCard.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/src/TitleCard.tsx
new file mode 100644
index 000000000..924557fff
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/src/TitleCard.tsx
@@ -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>
+  );
+};
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/src/index.ts b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/src/index.ts
new file mode 100644
index 000000000..f31c790ed
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/src/index.ts
@@ -0,0 +1,4 @@
+import { registerRoot } from "remotion";
+import { RemotionRoot } from "./Root";
+
+registerRoot(RemotionRoot);
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/tsconfig.json b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/tsconfig.json
new file mode 100644
index 000000000..eb6f0f806
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-1-title-card/remotion-src/tsconfig.json
@@ -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"]
+}
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/.gitignore b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/.gitignore
new file mode 100644
index 000000000..def72ea01
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/.gitignore
@@ -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/
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/README.md b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/README.md
new file mode 100644
index 000000000..17659a683
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/README.md
@@ -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.
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/expected.json b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/expected.json
new file mode 100644
index 000000000..ed5c477df
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/expected.json
@@ -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."
+}
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/hf-src/index.html b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/hf-src/index.html
new file mode 100644
index 000000000..9a6635305
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/hf-src/index.html
@@ -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>
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/package.json b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/package.json
new file mode 100644
index 000000000..88b08833a
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/package.json
@@ -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"
+  }
+}
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/remotion.config.ts b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/remotion.config.ts
new file mode 100644
index 000000000..a22cafc5e
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/remotion.config.ts
@@ -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);
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/src/MultiScene.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/src/MultiScene.tsx
new file mode 100644
index 000000000..362d967fa
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/src/MultiScene.tsx
@@ -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>
+);
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/src/Root.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/src/Root.tsx
new file mode 100644
index 000000000..37703e9cd
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/src/Root.tsx
@@ -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}
+  />
+);
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/src/index.ts b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/src/index.ts
new file mode 100644
index 000000000..f31c790ed
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/src/index.ts
@@ -0,0 +1,4 @@
+import { registerRoot } from "remotion";
+import { RemotionRoot } from "./Root";
+
+registerRoot(RemotionRoot);
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/tsconfig.json b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/tsconfig.json
new file mode 100644
index 000000000..eb6f0f806
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/remotion-src/tsconfig.json
@@ -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"]
+}
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/setup.sh b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/setup.sh
new file mode 100755
index 000000000..918212337
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-2-multi-scene/setup.sh
@@ -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/"
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/.gitignore b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/.gitignore
new file mode 100644
index 000000000..d45877158
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/.gitignore
@@ -0,0 +1,10 @@
+# Render output
+remotion-src/out/
+hf-src/out/
+hf.mp4
+diff/
+strip/
+
+# Remotion / HF dependencies
+node_modules/
+package-lock.json
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/README.md b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/README.md
new file mode 100644
index 000000000..711733364
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/README.md
@@ -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
+```
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/expected.json b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/expected.json
new file mode 100644
index 000000000..82c0a7de7
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/expected.json
@@ -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."
+}
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/hf-src/index.html b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/hf-src/index.html
new file mode 100644
index 000000000..e4253f204
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/hf-src/index.html
@@ -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>
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/package.json b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/package.json
new file mode 100644
index 000000000..d5acfc24d
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/package.json
@@ -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"
+  }
+}
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/remotion.config.ts b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/remotion.config.ts
new file mode 100644
index 000000000..a22cafc5e
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/remotion.config.ts
@@ -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);
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/Root.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/Root.tsx
new file mode 100644
index 000000000..5932bcd45
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/Root.tsx
@@ -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}
+  />
+);
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/Stargazed.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/Stargazed.tsx
new file mode 100644
index 000000000..4579c6d9a
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/Stargazed.tsx
@@ -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>
+);
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/components/AnimatedNumber.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/components/AnimatedNumber.tsx
new file mode 100644
index 000000000..d1a03f5e7
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/components/AnimatedNumber.tsx
@@ -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()}</>;
+};
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/components/StatCard.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/components/StatCard.tsx
new file mode 100644
index 000000000..7de79fc43
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/components/StatCard.tsx
@@ -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>
+  );
+};
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/components/UnderlinedText.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/components/UnderlinedText.tsx
new file mode 100644
index 000000000..38800f42c
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/components/UnderlinedText.tsx
@@ -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>
+  );
+};
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/index.ts b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/index.ts
new file mode 100644
index 000000000..f31c790ed
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/index.ts
@@ -0,0 +1,4 @@
+import { registerRoot } from "remotion";
+import { RemotionRoot } from "./Root";
+
+registerRoot(RemotionRoot);
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/scenes/OutroScene.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/scenes/OutroScene.tsx
new file mode 100644
index 000000000..3121b49dd
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/scenes/OutroScene.tsx
@@ -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>
+);
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/scenes/StatsScene.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/scenes/StatsScene.tsx
new file mode 100644
index 000000000..aeb6878a4
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/scenes/StatsScene.tsx
@@ -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>
+);
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/scenes/TitleScene.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/scenes/TitleScene.tsx
new file mode 100644
index 000000000..12d000388
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/src/scenes/TitleScene.tsx
@@ -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>
+  );
+};
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/tsconfig.json b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/tsconfig.json
new file mode 100644
index 000000000..eb6f0f806
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-3-data-driven/remotion-src/tsconfig.json
@@ -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"]
+}
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/README.md b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/README.md
new file mode 100644
index 000000000..ac36e230d
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/README.md
@@ -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.
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/01-use-state.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/01-use-state.tsx
new file mode 100644
index 000000000..d022bb277
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/01-use-state.tsx
@@ -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>
+  );
+};
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/02-use-effect-deps.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/02-use-effect-deps.tsx
new file mode 100644
index 000000000..270a65fa6
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/02-use-effect-deps.tsx
@@ -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>
+  );
+};
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/03-async-metadata.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/03-async-metadata.tsx
new file mode 100644
index 000000000..c1ecbbbc8
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/03-async-metadata.tsx
@@ -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,
+  };
+};
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/04-third-party-react.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/04-third-party-react.tsx
new file mode 100644
index 000000000..5787c4891
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/04-third-party-react.tsx
@@ -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>
+  );
+};
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/05-lambda-config.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/05-lambda-config.tsx
new file mode 100644
index 000000000..e4cdb2c00
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/05-lambda-config.tsx
@@ -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",
+  });
+}
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/06-warnings-only.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/06-warnings-only.tsx
new file mode 100644
index 000000000..eecf83261
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/06-warnings-only.tsx
@@ -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>
+  );
+};
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/07-custom-hook.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/07-custom-hook.tsx
new file mode 100644
index 000000000..7ebcf9cf5
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/07-custom-hook.tsx
@@ -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>
+  );
+};
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/08-mixed.tsx b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/08-mixed.tsx
new file mode 100644
index 000000000..68d918280
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/cases/08-mixed.tsx
@@ -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>
+  );
+};
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/expected.json b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/expected.json
new file mode 100644
index 000000000..94a494e6c
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/expected.json
@@ -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
+  }
+}
diff --git a/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/validate.sh b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/validate.sh
new file mode 100755
index 000000000..e7180f48c
--- /dev/null
+++ b/skills/remotion-to-hyperframes/assets/test-corpus/tier-4-escape-hatch/validate.sh
@@ -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
diff --git a/skills/remotion-to-hyperframes/references/.gitkeep b/skills/remotion-to-hyperframes/references/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/skills/remotion-to-hyperframes/references/api-map.md b/skills/remotion-to-hyperframes/references/api-map.md
new file mode 100644
index 000000000..906d7957e
--- /dev/null
+++ b/skills/remotion-to-hyperframes/references/api-map.md
@@ -0,0 +1,142 @@
+# Remotion → HyperFrames API Map
+
+Authoritative translation table. Load this reference when starting a translation
+to know the high-level mapping; load the per-topic references for fragile
+details (timing, transitions, etc.).
+
+## Reading this table
+
+- **`drop`** = remove from output entirely. The HF runtime handles it.
+- **`see references/X.md`** = the mapping is non-trivial; read the linked file.
+- **`refuse + interop`** = the skill bows out and recommends the runtime adapter
+  pattern from [PR #214](https://github.com/heygen-com/hyperframes/pull/214).
+
+## Composition root
+
+| Remotion                                             | HyperFrames                                                                                                          |
+| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| `<Composition id durationInFrames fps width height>` | root `<div id="stage" data-composition-id data-start="0" data-duration="<dur/fps>" data-fps data-width data-height>` |
+| `defaultProps={...}`                                 | `data-*` attributes on `#stage` (one per scalar prop). Nested objects/arrays — see [parameters.md](parameters.md)    |
+| `schema={z.object(...)}`                             | not represented in HTML; the schema lives in the agent's translation step only                                       |
+| `calculateMetadata` (sync)                           | resolve at translation time, write concrete values into `data-*`                                                     |
+| `calculateMetadata` (async)                          | **refuse + interop** — see [escape-hatch.md](escape-hatch.md)                                                        |
+| `registerRoot(RemotionRoot)`                         | drop                                                                                                                 |
+| `<AbsoluteFill style>`                               | `<div style="position:absolute;inset:0;{style}">`                                                                    |
+
+## Sequencing
+
+See [sequencing.md](sequencing.md) for nesting and stagger details.
+
+| Remotion                                   | HyperFrames                                                                                               |
+| ------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
+| `<Sequence from={F} durationInFrames={D}>` | `<div data-start="<F/fps>" data-duration="<D/fps>" data-track-index="N">`                                 |
+| `<Series>` + `<Series.Sequence>`           | siblings with sequential `data-start` values                                                              |
+| `<Loop durationInFrames={D}>`              | not a primitive — emit a custom GSAP `repeat: -1` loop with manual offset math                            |
+| `<Freeze frame={F}>`                       | drop the wrapper; HF doesn't have running animation outside the seek-driven timeline so freeze is a no-op |
+
+## Timing
+
+See [timing.md](timing.md) — this is the highest-leverage section.
+
+| Remotion                                                   | HyperFrames                                                                                                        |
+| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
+| `useCurrentFrame()`                                        | drop — HF seeks the timeline. The math derived from `frame` becomes an animatable property of a paused GSAP tween. |
+| `useVideoConfig()` for `fps` / `durationInFrames`          | drop — read from `data-fps` / `data-duration` on `#stage`                                                          |
+| `interpolate(frame, [a,b], [x,y])` (linear)                | `gsap.fromTo(t, {p:x}, {p:y, duration:(b-a)/fps, ease:"none"})` at offset `a/fps`                                  |
+| `interpolate(frame, [a,b,c,d], [x,y,y,z])` (multi-segment) | three `gsap.to` calls at offsets `a/fps`, `b/fps`, `c/fps`                                                         |
+| `interpolate(..., {easing: Easing.bezier})`                | GSAP `CustomEase.create("c", "M0,0 C${a},${b} ${c},${d} 1,1")`                                                     |
+| `spring({frame, fps, config: {damping, stiffness, mass}})` | GSAP `back.out(N)` — see [timing.md](timing.md) for damping → overshoot table                                      |
+| `interpolateColors(frame, range, colors)`                  | `gsap.to({...}, { backgroundColor, color, duration, ease })` — GSAP handles color tweens natively                  |
+| `Easing.in / .out / .inOut(power)`                         | GSAP `power<N>.in` / `power<N>.out` / `power<N>.inOut`                                                             |
+
+## Media
+
+See [media.md](media.md) for trim, volume ramps, and decoder notes.
+
+| Remotion                               | HyperFrames                                                                 |
+| -------------------------------------- | --------------------------------------------------------------------------- |
+| `<Audio src volume>`                   | `<audio data-start data-duration data-track-index data-volume src>`         |
+| `<Audio playbackRate startFrom endAt>` | `data-playback-rate`, `data-trim-start`, `data-trim-end`                    |
+| `<Video src>`                          | `<video muted playsinline data-start data-duration data-track-index src>`   |
+| `<OffthreadVideo>`                     | `<video>` — HF doesn't need the off-thread variant (uses headless Chrome)   |
+| `<Img src>`                            | `<img>`                                                                     |
+| `<IFrame src>`                         | `<iframe>` — HF auto-falls back to screenshot mode for nested iframes       |
+| `staticFile("x.png")`                  | `"assets/x.png"` — copy the file into `hf-src/assets/` next to `index.html` |
+| `delayRender()` / `continueRender()`   | drop — HF waits on asset readiness via the Frame Adapter pattern            |
+
+## Transitions
+
+See [transitions.md](transitions.md).
+
+| Remotion                                                                       | HyperFrames                                                                                               |
+| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
+| `<TransitionSeries>` + `<TransitionSeries.Transition presentation={fade()} />` | manual `gsap.to(scene, {opacity: 0/1, duration})` crossfade at the boundary                               |
+| `slide()`, `wipe()`, `clockWipe()`, `fade()`                                   | HF [shader-transitions](https://hyperframes.heygen.com/catalog/blocks) package presets — pick the closest |
+| `linearTiming({durationInFrames})`                                             | duration in seconds (`/fps`)                                                                              |
+| `springTiming({config})`                                                       | duration in seconds, ease `back.out` — see [timing.md](timing.md)                                         |
+
+## Lottie
+
+See [lottie.md](lottie.md).
+
+| Remotion                        | HyperFrames                                                                                               |
+| ------------------------------- | --------------------------------------------------------------------------------------------------------- |
+| `<Lottie animationData={data}>` | `<div id="lottie-N">` + `<script>lottie.loadAnimation(...).then(a => window.__hfLottie.push(a))</script>` |
+| `loop` / `playbackRate` props   | translate to `loop` / lottie playback options; HF adapter seeks via `goToAndStop`                         |
+| `@remotion/lottie` runtime      | `lottie-web` from CDN — drop the React wrapper                                                            |
+
+## Fonts
+
+See [fonts.md](fonts.md).
+
+| Remotion                                            | HyperFrames                                                                                 |
+| --------------------------------------------------- | ------------------------------------------------------------------------------------------- |
+| `loadFont()` from `@remotion/google-fonts/<Family>` | `@font-face` rule referencing the Google Fonts CSS, OR `<link>` to Google Fonts in `<head>` |
+| Local font via `@font-face`                         | same — paste the rule into `<style>`                                                        |
+| System font fallback                                | document the font-fallback divergence cost (see [eval.md](eval.md))                         |
+
+## Parameters
+
+See [parameters.md](parameters.md).
+
+| Remotion                      | HyperFrames                                                                                   |
+| ----------------------------- | --------------------------------------------------------------------------------------------- |
+| `z.object({foo: z.string()})` | `data-foo` on `#stage` (the schema is implicit in HTML structure)                             |
+| nested array prop (`stats[]`) | repeated HTML markup with per-instance `data-*` attrs                                         |
+| Zod default values            | bake defaults into the HTML directly                                                          |
+| Zod runtime validation        | not represented; if validation matters, validate in the translation step before emitting HTML |
+
+## React patterns
+
+| Remotion                                           | HyperFrames                                                      |
+| -------------------------------------------------- | ---------------------------------------------------------------- |
+| Custom React subcomponent (pure, prop-driven)      | inline as repeated HTML using the prop interface as the template |
+| `useState` driving animation                       | **refuse + interop**                                             |
+| `useReducer` driving animation                     | **refuse + interop**                                             |
+| `useEffect(fn, [deps])` (non-empty deps)           | **refuse + interop**                                             |
+| `useEffect(fn, [])` (mount-once side effect)       | drop the effect; use `queueMicrotask` if startup work is needed  |
+| `useCallback`, `useMemo`                           | drop the wrappers — decorative                                   |
+| Custom hook (pure derivation of `useCurrentFrame`) | inline the body                                                  |
+| Custom hook with state/effects                     | refuse + interop                                                 |
+
+## Distributed rendering
+
+`@remotion/lambda` and `@remotion/cloudrun` are deployment configuration —
+orthogonal to the rendered composition itself. The skill emits these as
+**warnings** (not blockers) and drops them in step 3 (Generate) with a note
+in `TRANSLATION_NOTES.md`. HF is single-machine today; document the gap.
+
+| Remotion                   | HyperFrames                                            |
+| -------------------------- | ------------------------------------------------------ |
+| `@remotion/lambda` import  | drop the import (warning `r2hf/lambda-import`)         |
+| `renderMediaOnLambda(...)` | drop the call; note in `TRANSLATION_NOTES.md`          |
+| `@remotion/cloudrun`       | drop the import + call; note in `TRANSLATION_NOTES.md` |
+
+## When to bow out entirely
+
+If any blocker pattern is present, recommend the runtime interop pattern from
+[PR #214](https://github.com/heygen-com/hyperframes/pull/214) instead of
+attempting translation. See [escape-hatch.md](escape-hatch.md).
+
+The blockers are documented in [`scripts/lint_source.py`](../scripts/lint_source.py)
+and tested by [tier-4-escape-hatch](../assets/test-corpus/tier-4-escape-hatch/).
diff --git a/skills/remotion-to-hyperframes/references/escape-hatch.md b/skills/remotion-to-hyperframes/references/escape-hatch.md
new file mode 100644
index 000000000..9dd0fa445
--- /dev/null
+++ b/skills/remotion-to-hyperframes/references/escape-hatch.md
@@ -0,0 +1,115 @@
+# When to bow out: the runtime interop pattern
+
+Some Remotion compositions can't be translated cleanly. The skill should
+recognize them upfront and recommend the **runtime interop pattern** from
+[PR #214](https://github.com/heygen-com/hyperframes/pull/214) instead of
+producing broken HTML.
+
+## When to recommend interop
+
+Run `scripts/lint_source.py` first. If it returns any blocker, recommend
+interop. The blockers are:
+
+| Rule                        | What it catches                                                |
+| --------------------------- | -------------------------------------------------------------- |
+| `r2hf/use-state`            | useState driving animation                                     |
+| `r2hf/use-reducer`          | useReducer driving animation                                   |
+| `r2hf/use-effect-deps`      | useEffect/useLayoutEffect with non-empty deps (side effects)   |
+| `r2hf/async-metadata`       | calculateMetadata returns a Promise                            |
+| `r2hf/third-party-react-ui` | Imports from MUI, Chakra, Mantine, antd, shadcn, Radix, NextUI |
+
+Each of these breaks the seek-driven, deterministic-frame model that HF
+relies on. Translating them produces silently-wrong output.
+
+## What the interop pattern actually does
+
+Per [PR #214](https://github.com/heygen-com/hyperframes/pull/214), the
+runtime adapter:
+
+1. Bundles the user's Remotion code with React + `@remotion/player` via esbuild.
+2. Mounts a Remotion `<Player>` inside an HF composition's HTML.
+3. Pauses the player on mount.
+4. Registers the player on `window.__hfRemotion` with `seekTo(frame)`,
+   `pause()`, `durationInFrames`, `fps`.
+5. HF's render loop seeks the player frame-by-frame via `seekTo(frame)`.
+
+Result: Remotion's React tree renders at HF's deterministic frame ticks.
+Custom hooks, useState, useEffect, MUI components — all work because
+Remotion's React reconciler is doing the rendering.
+
+## The recommendation message
+
+When the skill detects a blocker, output something like:
+
+> The Remotion source uses `useState` (and others), which can't be
+> translated to HF's seek-driven HTML model. The recommended path is the
+> **runtime interop pattern**: bundle your Remotion code with `@remotion/player`
+> and let HF drive it frame-by-frame.
+>
+> See https://github.com/heygen-com/hyperframes/pull/214 for the full
+> implementation. Quick summary:
+>
+> 1. Bundle `entry.tsx` with esbuild: `npx esbuild entry.tsx --bundle --outfile=dist/bundle.js --format=iife --jsx=automatic`
+> 2. Mount the Player and register on `window.__hfRemotion`:
+>
+>    ```tsx
+>    const playerRef = useRef<PlayerRef>(null);
+>    useEffect(() => {
+>      playerRef.current?.pause();
+>      window.__hfRemotion = window.__hfRemotion || [];
+>      window.__hfRemotion.push({
+>        seekTo: (f) => playerRef.current?.seekTo(f),
+>        pause: () => playerRef.current?.pause(),
+>        durationInFrames,
+>        fps,
+>      });
+>    }, []);
+>    ```
+>
+> 3. Reference the bundle from your HF `index.html` and render normally:
+>    `<script src="dist/bundle.js"></script>`
+
+## The lint output already includes recommendations
+
+`lint_source.py` emits a `recommendation` field per finding. Surface those
+verbatim — they're tuned per blocker rule:
+
+```json
+{
+  "rule": "r2hf/use-state",
+  "message": "useState detected — Remotion compositions that drive animation via React state are not deterministic frame-capture targets in HyperFrames",
+  "recommendation": "Use the runtime interop pattern from PR #214 instead of attempting a translation"
+}
+```
+
+## When NOT to bow out: warnings only
+
+Some patterns produce warnings, not blockers — translate after dropping
+the wrappers:
+
+| Rule                      | Action                                                              |
+| ------------------------- | ------------------------------------------------------------------- |
+| `r2hf/lambda-import`      | drop the `@remotion/lambda` config; HF runs single-machine, log gap |
+| `r2hf/delay-render`       | drop the call; HF handles asset readiness                           |
+| `r2hf/use-callback`       | drop the wrapper, inline the function                               |
+| `r2hf/use-memo`           | drop the wrapper, compute inline                                    |
+| `r2hf/custom-hook` (pure) | inline the hook body if it's a derivation of `useCurrentFrame`      |
+| `r2hf/static-file`        | replace `staticFile("x")` with `"assets/x"`                         |
+| `r2hf/interpolate-colors` | translate to GSAP color tween (see [timing.md](timing.md))          |
+
+These are documented in T4 cases 05–07.
+
+`r2hf/lambda-import` is a warning — not a blocker — because Lambda
+configuration is orthogonal to the rendered composition. Translating an
+otherwise-clean Remotion comp shouldn't fail just because the author also
+configured AWS Lambda for distributed rendering. The skill drops the
+`@remotion/lambda` imports and `renderMediaOnLambda(...)` calls in step 3
+(Generate) and writes a `TRANSLATION_NOTES.md` entry so the user knows to
+set up HF rendering separately.
+
+## When the source has BOTH blockers AND warnings
+
+Bow out. The presence of a single blocker means the skill shouldn't
+attempt translation — even if the rest of the composition is clean.
+The user should use interop for the whole thing OR refactor the
+blocker patterns out of their Remotion source first.
diff --git a/skills/remotion-to-hyperframes/references/eval.md b/skills/remotion-to-hyperframes/references/eval.md
new file mode 100644
index 000000000..edb951c3a
--- /dev/null
+++ b/skills/remotion-to-hyperframes/references/eval.md
@@ -0,0 +1,140 @@
+# Eval: how to validate a translation end-to-end
+
+Every translation should be measured. The skill ships three scripts and
+a tiered test corpus that, together, gate translation quality.
+
+## The three scripts
+
+| Script                   | Input                       | Output                                                              |
+| ------------------------ | --------------------------- | ------------------------------------------------------------------- |
+| `scripts/lint_source.py` | Remotion source dir or file | JSON findings + exit code (0 clean, 1 has blockers)                 |
+| `scripts/render_diff.sh` | two MP4 paths               | per-frame SSIM + JSON summary (`mean`, `min`, `p05`, `p95`, `pass`) |
+| `scripts/frame_strip.sh` | two MP4 paths               | side-by-side comparison strip PNG for visual debugging              |
+
+Run them in this order: **lint → render → diff → (if fail) strip**.
+
+## Per-fixture flow
+
+```bash
+# 1. Lint the source — blockers mean stop
+python3 ../../scripts/lint_source.py ./remotion-src/src/
+
+# 2. Generate any binary assets (T2+T3 only)
+[ -f setup.sh ] && ./setup.sh
+
+# 3. Render Remotion baseline
+cd remotion-src && npm install && npm run render
+# -> remotion-src/out/baseline.mp4
+
+# 4. Render HF translation
+cd .. && node ../../../packages/cli/dist/cli.js render hf-src/ --output hf.mp4
+# -> hf.mp4
+
+# 5. SSIM diff
+../../scripts/render_diff.sh ./remotion-src/out/baseline.mp4 ./hf.mp4 ./diff
+# -> diff/summary.json
+
+# 6. If diff fails, generate frame strip for visual inspection
+../../scripts/frame_strip.sh ./remotion-src/out/baseline.mp4 ./hf.mp4 ./strip 8
+# -> strip/strip.png
+```
+
+## Reading `diff/summary.json`
+
+```json
+{
+  "frame_count": 90,
+  "mean": 0.974,
+  "min": 0.972,
+  "max": 0.999,
+  "p05": 0.972,
+  "p95": 0.983,
+  "threshold": 0.95,
+  "pass": true
+}
+```
+
+| Field         | What it tells you                                                           |
+| ------------- | --------------------------------------------------------------------------- |
+| `mean`        | average SSIM across all frames; the headline number                         |
+| `min`         | worst frame; below threshold means at least one frame is structurally wrong |
+| `p05` / `p95` | 5th / 95th percentile — most frames sit between these                       |
+| `threshold`   | from `R2HF_SSIM_THRESHOLD` env var (default 0.85)                           |
+| `pass`        | whether `mean >= threshold`                                                 |
+
+## Validated tier thresholds
+
+Calibrated against actual Remotion + HF renders:
+
+| Tier | Composition shape                           | Mean  | Threshold | Margin |
+| ---- | ------------------------------------------- | ----- | --------- | ------ |
+| T1   | single-element fade-in                      | 0.974 | 0.95      | +0.022 |
+| T2   | multi-scene + spring + audio + image        | 0.985 | 0.95      | +0.016 |
+| T3   | data-driven, custom subcomponents, count-up | 0.953 | 0.90      | +0.038 |
+
+Each fixture's `expected.json` carries:
+
+- `ssim_threshold` — the gate for `pass`
+- `validation` — the actual measured numbers from the calibration run
+- `translation_notes` — what's lossy and why
+
+## Critical: encoder config
+
+Both Remotion and HF must output the same pixel format for SSIM to be
+meaningful. Remotion's default JPEG output writes `yuvj420p` (full-range);
+HF outputs `yuv420p` (limited-range). The mismatch costs ~0.05 SSIM.
+
+Every fixture's `remotion.config.ts` sets:
+
+```ts
+Config.setVideoImageFormat("png");
+Config.setColorSpace("bt709");
+```
+
+If the user's source doesn't have these, add them in the translation
+step — otherwise the diff measures encoder differences, not translation
+fidelity.
+
+## What the noise floor looks like
+
+The dominant non-translation noise is **system font fallback divergence**.
+Remotion's bundled Chromium and HF's `chrome-headless-shell` interpret
+`font-weight: 800` differently when there's no real font installed:
+
+- Remotion HELLO at 160px: medium-weight stroke
+- HF HELLO at 160px: heavy-weight stroke
+
+This costs ~0.025 mean SSIM. Visible in T1's frame strip.
+[fonts.md](fonts.md) covers how to mitigate (use Inter, load explicit
+Google Fonts).
+
+## Threshold rule of thumb
+
+Set the threshold ~0.02 below measured `p05`:
+
+- Real translation regressions drop mean by 0.05+ — caught.
+- Encoder/font drift between CI runs is bounded at ~0.01 — not caught.
+
+If a calibration run's measured mean is far above your initial threshold
+guess, _don't_ tighten the threshold to fit. Leave headroom — fixtures
+re-rendered on different hardware will drift.
+
+## When the diff fails
+
+1. **Look at `frame_strip.sh` output first.** A side-by-side strip at 6–10
+   evenly-spaced timestamps shows whether the failure is structural
+   (wrong scene durations, missing element) or cosmetic (different font
+   weight, slight timing skew).
+2. **Check `diff/ssim.log`.** Per-frame SSIM tells you _which_ frames
+   failed. Cluster of bad frames in the middle of a scene = animation
+   problem; bad frames at scene boundaries = sequencing problem.
+3. **Re-read the relevant reference.** [timing.md](timing.md) for
+   spring/easing issues, [sequencing.md](sequencing.md) for scene
+   boundary issues, [media.md](media.md) for asset loading issues.
+
+## CI integration
+
+The fixtures are not yet wired into CI (`packages/producer/tests/` runs
+inside Docker; the skill corpus needs the same). PR 7 of the stack adds
+the orchestrator that runs all four tiers and emits an aggregated pass
+report. For now, evaluate by hand per fixture.
diff --git a/skills/remotion-to-hyperframes/references/fonts.md b/skills/remotion-to-hyperframes/references/fonts.md
new file mode 100644
index 000000000..56c92983b
--- /dev/null
+++ b/skills/remotion-to-hyperframes/references/fonts.md
@@ -0,0 +1,112 @@
+# Font translation
+
+Fonts are the dominant non-translation noise floor. Same `font-weight: 800`
+renders perceptibly bolder on HF's `chrome-headless-shell` than on
+Remotion's bundled Chromium when there's no real font installed. Validation
+showed this costs ~0.025 mean SSIM at the noise floor.
+
+## Pattern: `@remotion/google-fonts/<Family>`
+
+```tsx
+import { loadFont } from "@remotion/google-fonts/Inter";
+loadFont("normal", { weights: ["400", "800"] });
+```
+
+Translate to a `<link>` tag in `<head>`:
+
+```html
+<head>
+  <link rel="preconnect" href="https://fonts.googleapis.com" />
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+  <link
+    href="https://fonts.googleapis.com/css2?family=Inter:wght@400;800&display=swap"
+    rel="stylesheet"
+  />
+  <style>
+    body {
+      font-family: Inter, sans-serif;
+    }
+  </style>
+</head>
+```
+
+Pull the family name and weights from the import path and `loadFont`
+arguments. HF's compiler inlines the Google Fonts CSS at render time, so
+you don't pay a network round-trip per render.
+
+## Pattern: local fonts via `@font-face`
+
+```tsx
+import { Font } from "remotion";
+
+Font.loadFont("/MyFont.woff2", "MyFont");
+```
+
+Translate to a `@font-face` rule:
+
+```html
+<style>
+  @font-face {
+    font-family: "MyFont";
+    src: url("assets/MyFont.woff2") format("woff2");
+    font-weight: 400;
+    font-style: normal;
+  }
+</style>
+```
+
+Copy the font file into `hf-src/assets/` next to the HTML.
+
+## Pattern: system font fallback (no font load)
+
+```tsx
+<div style={{ fontFamily: "Helvetica, Arial, sans-serif" }}>...</div>
+```
+
+Same string in HF — but be aware: on Linux without a real Helvetica
+installed (typical CI environment), Remotion and HF fall back to
+_different_ sans-serif system fonts because they bundle different
+Chromium versions. This is the noise floor: ~0.025 mean SSIM cost,
+visible as different stroke widths at large font weights (800+).
+
+If matching the Remotion render exactly matters for a specific
+fixture, load the same font explicitly — don't rely on system
+fallback.
+
+## When in doubt: use Inter
+
+Inter renders identically across Chromium versions and is free.
+Translate any "system sans-serif" Remotion comp to Inter when you
+need to minimize font drift in the validation harness.
+
+## Font loading and `delayRender`
+
+Remotion uses `delayRender()` to defer the first frame until fonts
+load. HF's compiler inlines Google Fonts at compile time and waits
+on `@font-face` readiness via the Frame Adapter pattern — the
+`delayRender` call drops in translation. See [media.md](media.md).
+
+## Multi-weight loading
+
+When Remotion loads multiple weights:
+
+```tsx
+loadFont("normal", { weights: ["400", "500", "700", "800"] });
+```
+
+Inline all weights in the Google Fonts URL:
+
+```
+?family=Inter:wght@400;500;700;800&display=swap
+```
+
+Translation rule: enumerate every distinct `font-weight` value
+that appears in the composition's CSS (`font-weight: 800` →
+weight 800 must be loaded). If the Remotion source loads weights
+that aren't actually used, drop them.
+
+## Font subsetting
+
+Remotion's `loadFont` doesn't subset; HF's compiler doesn't either
+(yet). Don't try to optimize this in translation — it's lossless to
+keep the same weight set as the Remotion source.
diff --git a/skills/remotion-to-hyperframes/references/limitations.md b/skills/remotion-to-hyperframes/references/limitations.md
new file mode 100644
index 000000000..e01980652
--- /dev/null
+++ b/skills/remotion-to-hyperframes/references/limitations.md
@@ -0,0 +1,136 @@
+# Translation limitations
+
+What the skill explicitly cannot translate, separated from the
+blocker-list (which is enforced by `lint_source.py`). These are
+_known_ gaps — surface them to the user as translation notes
+when translating the surrounding composition.
+
+## React patterns the skill refuses
+
+See [escape-hatch.md](escape-hatch.md). Any of these triggers
+a bow-out:
+
+- `useState`, `useReducer` driving animation
+- `useEffect` / `useLayoutEffect` with non-empty deps (side effects)
+- async `calculateMetadata`
+- Third-party React UI libraries (MUI, Chakra, Mantine, antd, shadcn, Radix, NextUI)
+
+`@remotion/lambda` is no longer in this list — it's a warning, not a
+blocker, because Lambda config is orthogonal to composition rendering.
+The skill drops the imports and `renderMediaOnLambda(...)` calls and
+writes a `TRANSLATION_NOTES.md` entry. See
+[escape-hatch.md](escape-hatch.md).
+
+## Patterns that work with caveats
+
+### Volume ramps on `<Audio>`
+
+Remotion accepts a function for `volume`:
+
+```tsx
+<Audio src={...} volume={(f) => interpolate(f, [0, 30], [0, 1])} />
+```
+
+HF supports static `data-volume` only. Translation: bake the ramp into
+the audio file at translation time using `ffmpeg afade`, OR drop the
+ramp with a note. The dropped-ramp path produces audibly different
+output but visually-identical video, so SSIM passes — just flag it.
+
+### `<Loop>` with stateful children
+
+```tsx
+<Loop durationInFrames={30}>
+  <CounterThatIncrementsViaUseRef />
+</Loop>
+```
+
+Loop with `repeat: -1` works for _visual_ repetition. If the looped
+child has cross-iteration state (a counter, a randomness seed), HF
+won't reproduce it identically per iteration. Bow out unless the
+child is fully deterministic per-iteration.
+
+### Remotion's `<Img>` with crossOrigin
+
+```tsx
+<Img src="https://other-domain.com/x.png" crossOrigin="anonymous" />
+```
+
+HF's renderer doesn't enforce CORS the same way Remotion does. Most
+public images work; private images served with auth headers won't.
+If the source uses `crossOrigin="use-credentials"`, the asset needs
+to be downloaded and inlined at translation time.
+
+### Custom `presentation` in `<TransitionSeries>`
+
+```tsx
+const customPresentation: PresentationComponent = ({ children, presentationProgress }) => {
+  return <div style={{ filter: `blur(${(1 - presentationProgress) * 20}px)` }}>{children}</div>;
+};
+```
+
+Pure presentations (transform/filter/opacity computed from progress)
+translate to GSAP tweens cleanly. Presentations that read
+`useCurrentFrame()` internally or have stateful children don't —
+bow out.
+
+### Code-split components (`React.lazy`)
+
+```tsx
+const HeavyChart = React.lazy(() => import("./HeavyChart"));
+```
+
+`React.lazy` is async and doesn't fit the deterministic-render model.
+Translate to a regular import; the resulting HF composition will
+just include all the code upfront.
+
+## Patterns that always work
+
+- `<AbsoluteFill>` and `<Sequence>` (any nesting)
+- `useCurrentFrame()` derivations: `interpolate`, `spring`, `Easing`,
+  `interpolateColors`, manual math
+- `<Audio>`, `<Video>`, `<Img>`, `<IFrame>` with simple props
+- `staticFile()` references
+- Custom React subcomponents that are pure functions of props
+- Custom hooks that are pure derivations of `useCurrentFrame`
+- `@remotion/lottie` (translates to HF's Lottie adapter)
+- `@remotion/google-fonts/<Family>` (translates to `<link>` or `@font-face`)
+- Sync `calculateMetadata` (resolved at translation time)
+- `<TransitionSeries>` with built-in presentations (`fade`, `slide`,
+  `wipe`, `clockWipe`, `flip`, `iris`)
+
+## What the skill never tries to translate
+
+These are out-of-scope by design:
+
+- **HDR rendering** — HF supports HDR but Remotion doesn't, so there's
+  nothing to translate from.
+- **Variable frame rate** — both tools assume constant fps.
+- **Multi-composition `<Composition>` lists** — translate one at a
+  time. The skill prompts the user to choose which composition.
+- **Remotion Studio props panel** — visual prop editing in HF Studio
+  needs different infrastructure; out of scope.
+
+## Reporting gaps to the user
+
+When translation produces _something_ but the something has gaps, write
+a `TRANSLATION_NOTES.md` next to the output:
+
+```markdown
+# Translation notes
+
+The following Remotion patterns were translated with caveats:
+
+- `<Audio volume={(f) => ...}>` (line 15): volume ramp dropped — added
+  static `data-volume="0.5"`. To preserve the ramp, run
+  `ffmpeg -i music.wav -af "afade=t=in:st=0:d=1" music.faded.wav` and
+  swap the source file.
+- `<HeavyChart>` (line 30): translated as inline HTML. The original
+  React.lazy boundary was dropped — bundle size unchanged because HF
+  serves a single HTML file.
+
+If any of these caveats matter, consider the runtime interop pattern
+instead.
+```
+
+This file is also generated by the skill alongside the HF output, not
+held in the corpus.
diff --git a/skills/remotion-to-hyperframes/references/lottie.md b/skills/remotion-to-hyperframes/references/lottie.md
new file mode 100644
index 000000000..22c103520
--- /dev/null
+++ b/skills/remotion-to-hyperframes/references/lottie.md
@@ -0,0 +1,119 @@
+# Lottie translation: @remotion/lottie → HF lottie adapter
+
+Lottie animations are a clean translation case — HF has a built-in
+[Lottie adapter](https://github.com/heygen-com/hyperframes/blob/main/packages/core/src/runtime/adapters/lottie.ts)
+that supports both `lottie-web` and `@lottiefiles/dotlottie-web`. The
+adapter auto-discovers animations registered on `window.__hfLottie`
+and seeks them per-frame via `goToAndStop`.
+
+## Pattern
+
+```tsx
+import { Lottie } from "@remotion/lottie";
+import animationData from "./hello.json";
+
+export const MyComp = () => (
+  <AbsoluteFill>
+    <Lottie animationData={animationData} loop={false} />
+  </AbsoluteFill>
+);
+```
+
+Translates to:
+
+```html
+<div id="stage" ...>
+  <div id="lottie-anim" style="width:100%;height:100%"></div>
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.12.2/lottie.min.js"></script>
+  <script>
+    const anim = lottie.loadAnimation({
+      container: document.getElementById("lottie-anim"),
+      renderer: "svg",
+      loop: false,
+      autoplay: false,
+      path: "assets/hello.json",
+    });
+    window.__hfLottie = window.__hfLottie || [];
+    window.__hfLottie.push(anim);
+  </script>
+</div>
+```
+
+Key differences from a typical Lottie embed:
+
+- `autoplay: false` — HF drives playback by seeking
+- `loop: false` typically (unless Remotion's `loop={true}`)
+- `window.__hfLottie.push(anim)` is what hooks the animation into HF's
+  per-frame seek
+
+## Asset handling
+
+Remotion bundles the animation JSON via webpack import. HF needs the JSON
+on disk under `assets/` and references it via path:
+
+1. Copy `hello.json` from the Remotion project into `hf-src/assets/`.
+2. Reference as `path: "assets/hello.json"` in `loadAnimation`.
+
+For dotlottie (binary) format, swap in `@lottiefiles/dotlottie-web`:
+
+```html
+<script src="https://unpkg.com/@lottiefiles/dotlottie-web"></script>
+<canvas id="anim" style="width:100%;height:100%"></canvas>
+<script>
+  const player = new DotLottie({
+    canvas: document.getElementById("anim"),
+    src: "assets/hello.lottie",
+    autoplay: false,
+  });
+  window.__hfLottie = window.__hfLottie || [];
+  window.__hfLottie.push(player);
+</script>
+```
+
+The HF adapter handles both player APIs (it duck-types `goToAndStop`
+vs `setCurrentRawFrameValue` / `seek`).
+
+## Multiple Lottie animations
+
+Multiple `<Lottie>` instances in one composition work — push each one
+onto `window.__hfLottie` and the adapter will seek all of them in sync:
+
+```js
+window.__hfLottie.push(anim1);
+window.__hfLottie.push(anim2);
+window.__hfLottie.push(anim3);
+```
+
+## Lottie source isn't actually translation-blocking
+
+Lottie animations encode their own deterministic timeline. They're the
+_easiest_ part of a Remotion composition to translate because the
+animation logic is already self-contained — neither Remotion nor HF
+"animate" them, both just seek them. Translation cost is near-zero.
+
+## After Effects → Lottie limitations
+
+Lottie supports a subset of After Effects features. Expressions, most
+Effects (drop shadow, color overlay), all blend modes beyond Normal/Add/
+Multiply, luma mattes, and most 3D parameters are not supported. If the
+Remotion composition uses a Lottie file that depends on these, the
+animation will break in BOTH Remotion and HF — this isn't a translation
+problem, it's a Lottie limitation. See
+[airbnb/lottie/after-effects.md](https://github.com/airbnb/lottie/blob/master/after-effects.md)
+for the full supported feature list.
+
+## Loop behavior
+
+Remotion's `loop={true}` plays the animation continuously. Translate to
+the lottie-web `loop: true` setting AND rely on the adapter's natural
+seek behavior (it'll seek modulo the animation's duration). For
+non-default playback rates, set `playbackRate` in `loadAnimation` and HF
+will respect it during seek.
+
+## Performance note
+
+Per the [Lottie adapter](https://github.com/heygen-com/hyperframes/blob/main/packages/core/src/runtime/adapters/lottie.ts)
+docs: lottie-web's `goToAndStop(time, isFrame=false)` takes time in ms;
+the adapter passes `time * 1000` for precision. This is more accurate
+than passing frame numbers (especially for animations whose internal
+fps doesn't match the HF render fps).
diff --git a/skills/remotion-to-hyperframes/references/media.md b/skills/remotion-to-hyperframes/references/media.md
new file mode 100644
index 000000000..1c7b77dde
--- /dev/null
+++ b/skills/remotion-to-hyperframes/references/media.md
@@ -0,0 +1,149 @@
+# Media translation: Audio, Video, Img, IFrame, staticFile
+
+## Asset paths
+
+Remotion's `staticFile("x.png")` resolves to the project's `public/` directory.
+HF uses relative paths from the composition's `index.html`, conventionally
+`assets/`:
+
+```tsx
+<Img src={staticFile("logo.png")} />
+```
+
+```html
+<img src="assets/logo.png" />
+```
+
+When translating, copy the asset from `remotion-src/public/x` to
+`hf-src/assets/x`. Multiple files can be batched with a setup script;
+see T2's `setup.sh` for an example pattern.
+
+## `<Audio>`
+
+```tsx
+<Audio src={staticFile("music.wav")} volume={0.5} />
+```
+
+```html
+<audio
+  data-start="0"
+  data-duration="6"
+  data-track-index="2"
+  data-volume="0.5"
+  src="assets/music.wav"
+></audio>
+```
+
+`data-start` and `data-duration` are required — the runtime needs them to
+schedule the audio. Default to the composition's full duration if Remotion
+didn't specify trim.
+
+### Volume ramps
+
+```tsx
+<Audio src={staticFile("music.wav")} volume={(f) => interpolate(f, [0, 30], [0, 1])} />
+```
+
+HF supports static `data-volume` only for now. Volume ramps need to be
+applied to the audio file at translation time (with ffmpeg `afade`) or the
+ramp is dropped with a translation note.
+
+### Trim / playbackRate
+
+```tsx
+<Audio src={staticFile("music.wav")} startFrom={60} endAt={180} playbackRate={1.5} />
+```
+
+```html
+<audio
+  data-start="0"
+  data-duration="<resolved from trim>"
+  data-trim-start="2"
+  data-trim-end="6"
+  data-playback-rate="1.5"
+  src="assets/music.wav"
+></audio>
+```
+
+`startFrom` / `endAt` are frame indexes; convert to seconds.
+
+## `<Video>` and `<OffthreadVideo>`
+
+```tsx
+<Video src={staticFile("intro.mp4")} muted playsInline />
+<OffthreadVideo src={staticFile("intro.mp4")} muted />
+```
+
+```html
+<video
+  muted
+  playsinline
+  data-start="0"
+  data-duration="5"
+  data-track-index="0"
+  src="assets/intro.mp4"
+></video>
+```
+
+`<OffthreadVideo>` is a Remotion-specific optimization for headless
+rendering. HF runs in headless Chrome already, so the off-thread variant
+collapses to a regular `<video>`.
+
+`muted` and `playsinline` are required for the runtime to autoplay
+(browser policy). Always emit them.
+
+## `<Img>`
+
+```tsx
+<Img src={staticFile("logo.png")} style={{ width: 200, height: 200 }} />
+```
+
+```html
+<img src="assets/logo.png" style="width: 200px; height: 200px;" />
+```
+
+Width/height get rounded to integer px. If the original style has
+animated dimensions, the GSAP tween animates them — see [timing.md](timing.md).
+
+## `<IFrame>`
+
+```tsx
+<IFrame src="https://example.com" />
+```
+
+```html
+<iframe src="https://example.com"></iframe>
+```
+
+When HF detects a nested iframe in a composition, it auto-falls back to
+**screenshot mode** rather than the deterministic BeginFrame mode. This
+costs render performance but produces visibly-correct output. See
+[hyperframes-vs-remotion.mdx](https://github.com/heygen-com/hyperframes/blob/main/docs/guides/hyperframes-vs-remotion.mdx)
+for details.
+
+## `delayRender()` / `continueRender()`
+
+```tsx
+const handle = delayRender();
+useEffect(() => {
+  loadAsset().then(() => continueRender(handle));
+}, []);
+```
+
+Drop. HF waits on asset readiness via the [Frame Adapter pattern](https://hyperframes.heygen.com/concepts/frame-adapters)
+— images, videos, fonts, and Lottie animations all signal load
+completion natively. There's nothing to do at the application level.
+
+## When the asset isn't a file
+
+If Remotion's media source is a Buffer, dataURL, or URL.createObjectURL,
+the asset doesn't exist on disk and can't be copied via setup.sh. Two
+options:
+
+1. Materialize the asset at translation time — write the buffer to a file
+   in `hf-src/assets/`.
+2. Embed as a data URL directly in the HTML (`src="data:image/png;base64,..."`)
+   for small assets (< 100 KB).
+
+For audio/video Buffers, option 1 is preferred — base64-encoded media
+bloats the HTML and slows the renderer.
diff --git a/skills/remotion-to-hyperframes/references/parameters.md b/skills/remotion-to-hyperframes/references/parameters.md
new file mode 100644
index 000000000..56fde3ecf
--- /dev/null
+++ b/skills/remotion-to-hyperframes/references/parameters.md
@@ -0,0 +1,167 @@
+# Parameter translation: Zod schemas, defaultProps, calculateMetadata
+
+How a typed Remotion `<Composition schema={...} defaultProps={...} />`
+turns into a parameterized HF composition.
+
+## Sync calculateMetadata (translatable)
+
+```tsx
+<Composition
+  id="MyVideo"
+  component={MyVideo}
+  schema={z.object({ title: z.string(), duration: z.number() })}
+  defaultProps={{ title: "Hello", duration: 90 }}
+  calculateMetadata={({ props }) => ({
+    durationInFrames: props.duration,
+    fps: 30,
+  })}
+/>
+```
+
+When `calculateMetadata` is synchronous and only uses `props`, **resolve
+it at translation time** — call it with `defaultProps` (or whatever the
+caller specifies) and write the concrete result into the HTML:
+
+```html
+<div
+  id="stage"
+  data-composition-id="MyVideo"
+  data-start="0"
+  data-duration="3"          <!-- 90/30 -->
+  data-fps="30"
+  data-title="Hello"
+></div>
+```
+
+The `data-title` attribute carries the value through. Code that originally
+read `props.title` reads `document.getElementById("stage").dataset.title`
+in HF.
+
+## Async calculateMetadata (NOT translatable)
+
+```tsx
+<Composition
+  calculateMetadata={async ({ props }) => {
+    const res = await fetch(...);
+    return { durationInFrames: res.duration };
+  }}
+/>
+```
+
+**Refuse + interop**. HF needs composition metadata up-front to seed the
+HTML. Resolving network calls at translation time defeats the purpose
+of having dynamic metadata. See [escape-hatch.md](escape-hatch.md).
+
+The lint rule `r2hf/async-metadata` catches this. T4 case 03 tests it.
+
+## Default props
+
+```tsx
+defaultProps={{
+  title: "Hello",
+  subtitle: "World",
+  count: 42,
+}}
+```
+
+Translate to `data-*` attributes on the root `#stage` div:
+
+```html
+<div id="stage" data-title="Hello" data-subtitle="World" data-count="42">...</div>
+```
+
+Convention: `propName` → `data-prop-name` (kebab-case). Inside the GSAP
+script, read via `document.getElementById("stage").dataset.propName`.
+
+## Nested object / array props
+
+```tsx
+defaultProps={{
+  stats: [
+    { label: "Stars", value: 1247, color: "#fbbf24" },
+    { label: "Forks", value: 312, color: "#60a5fa" },
+  ],
+}}
+```
+
+Don't try to encode the array as a JSON `data-` attribute — HF's runtime
+doesn't parse those. Materialize the array as **repeated HTML markup**:
+
+```html
+<div id="scene-stats">
+  <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>
+```
+
+The component template (`StatCard.tsx`) becomes the markup template;
+each instance gets its scalar props rendered as `data-*` and CSS
+custom properties.
+
+Validated in T3 — three StatCards reused with different props,
+mean SSIM 0.953.
+
+## Numeric props that need typed parsing
+
+`document.getElementById("stage").dataset.count` is a string. Convert at
+read time:
+
+```js
+const count = Number(stage.dataset.count);
+```
+
+Or inline values directly into the GSAP script when the data is known
+at translation time and doesn't need to vary per render.
+
+## Boolean props
+
+```tsx
+defaultProps={{ darkMode: true }}
+```
+
+Two conventions:
+
+- `data-dark-mode="true"` — read as string, compare `=== "true"`
+- `data-dark-mode` (presence/absence) — `<div data-dark-mode>` for true, omit for false
+
+Pick one and be consistent. The presence/absence form is HTML-idiomatic
+and pairs well with CSS attribute selectors:
+
+```css
+[data-dark-mode] .scene {
+  background: #000;
+}
+```
+
+## Zod runtime validation
+
+Remotion's `schema` validates props at composition load. HF doesn't have
+an equivalent — by the time the HTML is in the renderer, the schema is
+already gone.
+
+Validate at translation time instead. If the user passes invalid data,
+fail with a translation error before emitting HTML. This matches Zod's
+"fail loud" intent without requiring the runtime dependency.
+
+## When the composition uses props for computed prop derivation
+
+```tsx
+const Composition: React.FC<Props> = ({ stats }) => {
+  const total = stats.reduce((acc, s) => acc + s.value, 0);
+  return <div>{total}</div>;
+};
+```
+
+Compute the derived value at translation time and bake it into the HTML
+or a `data-` attribute. Don't try to express the computation in JS in the
+HF composition — that adds runtime overhead and makes the HTML stateful
+in ways that complicate human editing.
+
+If the derivation is non-trivial (involves the array itself, not just
+scalars), materialize it as static text in the HTML.
diff --git a/skills/remotion-to-hyperframes/references/sequencing.md b/skills/remotion-to-hyperframes/references/sequencing.md
new file mode 100644
index 000000000..c0de381c6
--- /dev/null
+++ b/skills/remotion-to-hyperframes/references/sequencing.md
@@ -0,0 +1,195 @@
+# Sequencing translation: Sequence, Series, Composition root
+
+How Remotion's nested `Sequence` tree maps to HF's flat `data-start` /
+`data-duration` markup with a single paused GSAP timeline.
+
+## The core idea
+
+Remotion's `<Sequence from={F} durationInFrames={D}>` is a coordinate
+transform: it shifts `useCurrentFrame()` by `F` and clips the child
+component to the window `[F, F+D]`. HF doesn't have a per-element
+"current frame" — there's a single composition seek time and the
+runtime hides/shows elements based on their `data-start` / `data-duration`.
+
+Result: the nested tree flattens into a list of siblings on the same
+parent, each with their own time window.
+
+## `<Composition>` → root `#stage`
+
+```tsx
+<Composition
+  id="MyVideo"
+  component={MyVideo}
+  durationInFrames={300}
+  fps={30}
+  width={1280}
+  height={720}
+/>
+```
+
+```html
+<div
+  id="stage"
+  data-composition-id="MyVideo"
+  data-start="0"
+  data-duration="10"      <!-- 300/30 -->
+  data-fps="30"
+  data-width="1280"
+  data-height="720"
+>
+  <!-- composition content -->
+</div>
+```
+
+`data-start="0"` is required on `#stage` (the runtime needs it to anchor
+playback; missing it triggers a lint warning).
+
+## `<AbsoluteFill>` → positioned div
+
+```tsx
+<AbsoluteFill style={{ backgroundColor: "#0a0a0a" }}>...children...</AbsoluteFill>
+```
+
+```html
+<div style="position:absolute;inset:0;background-color:#0a0a0a;">...children...</div>
+```
+
+`AbsoluteFill` is just a styled div in Remotion. Translate to a div with
+`position:absolute; inset:0` and copy through any other style props.
+
+## `<Sequence>` → time-windowed div
+
+```tsx
+<Sequence from={0} durationInFrames={90}>
+  <TitleCard />
+</Sequence>
+```
+
+```html
+<div data-start="0" data-duration="3" data-track-index="0">
+  <!-- TitleCard children inlined -->
+</div>
+```
+
+Convert frames to seconds: `from/fps`, `durationInFrames/fps`. Pick a
+`data-track-index` per parallel rendering layer (background = 0,
+overlays = 1, audio = 2, etc.). Sequential scenes can share an index.
+
+## Nested `<Sequence>` flattens
+
+Remotion adds offsets when sequences nest:
+
+```tsx
+<Sequence from={60} durationInFrames={120}>
+  <Sequence from={30} durationInFrames={60}>
+    <ImageScene />
+  </Sequence>
+</Sequence>
+```
+
+The inner sequence's effective window is `[60+30, 60+30+60] = [90, 150]`.
+
+Translate by computing the sum and emitting one HF div with the resolved
+window:
+
+```html
+<div data-start="3" data-duration="2" data-track-index="0">
+  <!-- ImageScene children -->
+</div>
+```
+
+## `<Series>` → siblings with sequential offsets
+
+```tsx
+<Series>
+  <Series.Sequence durationInFrames={60}>
+    <A />
+  </Series.Sequence>
+  <Series.Sequence durationInFrames={120}>
+    <B />
+  </Series.Sequence>
+  <Series.Sequence durationInFrames={90}>
+    <C />
+  </Series.Sequence>
+</Series>
+```
+
+Each `Sequence.Sequence` lives in the next time slot. Emit siblings
+with `data-start` accumulating:
+
+```html
+<div data-start="0" data-duration="2" data-track-index="0">A</div>
+<div data-start="2" data-duration="4" data-track-index="0">B</div>
+<div data-start="6" data-duration="3" data-track-index="0">C</div>
+```
+
+## Crossfading scene boundaries
+
+Remotion `<Sequence>` shows/hides at hard boundaries by default. HF does
+the same — but if your composition needs a smooth fade between scenes,
+you have to drive opacity explicitly with GSAP at the boundary:
+
+```js
+const tl = gsap.timeline({ paused: true });
+tl.set(scene1, { opacity: 1 }, 0);
+tl.set(scene1, { opacity: 0 }, 2); // hard cut at 2s
+tl.set(scene2, { opacity: 1 }, 2);
+```
+
+For a 0.5 s crossfade:
+
+```js
+tl.to(scene1, { opacity: 0, duration: 0.5 }, 1.5);
+tl.to(scene2, { opacity: 1, duration: 0.5 }, 1.5);
+```
+
+For Remotion `<TransitionSeries>` translations see [transitions.md](transitions.md).
+
+## `<Loop>`
+
+```tsx
+<Loop durationInFrames={30}>
+  <Spinner />
+</Loop>
+```
+
+HF doesn't have a `<Loop>` primitive. Translate to a GSAP timeline with
+`repeat: -1`:
+
+```js
+const spinTl = gsap.timeline({ paused: true, repeat: -1, repeatRefresh: false });
+spinTl.to(spinner, { rotate: 360, duration: 1.0, ease: "none" });
+// Embed in the main composition timeline at the right offset:
+mainTl.add(spinTl, 3);
+```
+
+This is fragile — Remotion's `<Loop>` resets internal state every iteration,
+which GSAP repeat does too, but if the looped child has its own animation,
+you need to be careful that GSAP's `repeatRefresh` is on or off as needed.
+For most simple "spin forever" cases this is fine.
+
+## `<Freeze>`
+
+```tsx
+<Freeze frame={30}>
+  <Animated />
+</Freeze>
+```
+
+Drop the wrapper. `<Freeze>` pins `useCurrentFrame()` at a constant for
+the children — but in HF, the children's animation is already driven by
+explicit GSAP tweens, so freeze translates to "don't tween this element".
+
+## Multiple parallel tracks
+
+When you have a background video + overlay text + audio playing
+simultaneously, use distinct `data-track-index` values:
+
+```html
+<div data-track-index="0">background video</div>
+<div data-track-index="1">overlay text</div>
+<audio data-track-index="2" ...></audio>
+```
+
+The runtime picks track ordering from the index. See [media.md](media.md)
+for media-specific track conventions.
diff --git a/skills/remotion-to-hyperframes/references/timing.md b/skills/remotion-to-hyperframes/references/timing.md
new file mode 100644
index 000000000..dee32aee7
--- /dev/null
+++ b/skills/remotion-to-hyperframes/references/timing.md
@@ -0,0 +1,165 @@
+# Timing translation: interpolate, spring, easing
+
+The single highest-leverage reference. Easings and timings are what readers
+notice; getting them wrong costs more SSIM than any other translation choice.
+Empirically validated against tiers T1–T3.
+
+## Conversion: frames → seconds
+
+HF's timeline is in seconds. Remotion is frame-based. Always:
+
+```
+time_seconds = frame / fps
+```
+
+So at fps=30:
+
+- frame 15 → 0.5 s
+- frame 30 → 1.0 s
+- frame 90 → 3.0 s
+
+Do this conversion once when translating, not at runtime.
+
+## interpolate — linear
+
+```tsx
+const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" });
+```
+
+Translates to:
+
+```js
+gsap.to(target, { opacity: 1, duration: 1.0, ease: "none" }, 0);
+// fromTo if the property starts at 0 and CSS doesn't already set it
+gsap.fromTo(target, { opacity: 0 }, { opacity: 1, duration: 1.0, ease: "none" }, 0);
+```
+
+`ease: "none"` matches Remotion's default linear interpolation. CSS sets the
+`from` value if your initial state is in CSS; otherwise use `fromTo`.
+
+`extrapolateLeft`/`extrapolateRight` defaults to `"extend"` in Remotion but
+`"clamp"` is what the agent will see most often. GSAP doesn't extend — values
+hold at the start and end of the tween. So for `clamp`, GSAP matches; for
+`extend`, you'd need to extend the input range manually before emitting.
+
+## interpolate — multi-segment
+
+```tsx
+const opacity = interpolate(frame, [0, 15, 75, 90], [0, 1, 1, 0]);
+```
+
+Three keyframed tweens at offsets `[0]/fps`, `[1]/fps`, `[2]/fps`:
+
+```js
+const tl = gsap.timeline({ paused: true });
+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);
+```
+
+Validated in T1 — mean SSIM 0.974 against Remotion baseline.
+
+## spring → GSAP back.out
+
+Remotion's `spring()` is the most lossy translation. The mapping is approximate
+but close enough that real-world compositions hold ≥ 0.92 SSIM (T2: 0.985, T3: 0.953).
+
+| Remotion `spring` config                          | GSAP equivalent                                      | Validated in                     |
+| ------------------------------------------------- | ---------------------------------------------------- | -------------------------------- |
+| `{damping: 12, stiffness: 100, mass: 1}` (snappy) | `back.out(1.4)` over ~0.7 s                          | T2, T3 (TitleScene)              |
+| `{damping: 14, stiffness: 90, mass: 1}` (calmer)  | `back.out(1.2)` over ~0.7 s                          | T3 (StatCard)                    |
+| `{damping: 8, stiffness: 200}` (very bouncy)      | `back.out(2.0)` or `elastic.out(1, 0.5)` over ~0.6 s | not validated; budget ~0.05 SSIM |
+| `{overshootClamping: true}`                       | `power3.out` over ~0.6 s (no overshoot)              | not validated                    |
+
+**Rule of thumb**: `back.out(N)` overshoot ratio ≈ `(stiffness / damping^2) * 1.4`. For
+`damping:12, stiffness:100` that gives `1.4 * 100/144 = 0.97`, which is close to
+the validated 1.4 (the formula is rough; tune by visual). Default duration is
+~0.7 s for the typical config.
+
+When the spring's `delay`/`from`/`to` are non-default, scale the duration
+proportionally.
+
+## interpolate with custom easing
+
+```tsx
+import { Easing } from "remotion";
+interpolate(frame, [0, 30], [0, 1], { easing: Easing.out(Easing.cubic) });
+```
+
+| Remotion                     | GSAP                                                                                   |
+| ---------------------------- | -------------------------------------------------------------------------------------- |
+| `Easing.in(Easing.linear)`   | `ease: "none"`                                                                         |
+| `Easing.out(Easing.cubic)`   | `ease: "power3.out"`                                                                   |
+| `Easing.inOut(Easing.cubic)` | `ease: "power3.inOut"`                                                                 |
+| `Easing.out(Easing.poly(N))` | `ease: "power<N>.out"` (N=2 quad, 3 cubic, 4 quart, 5 quint)                           |
+| `Easing.bezier(a,b,c,d)`     | `CustomEase.create("c", "M0,0 C${a},${b} ${c},${d} 1,1")` (requires CustomEase plugin) |
+| `Easing.elastic(bounciness)` | `ease: "elastic.out(${bounciness}, 0.3)"`                                              |
+| `Easing.bounce`              | `ease: "bounce.out"`                                                                   |
+| `Easing.back(overshoot)`     | `ease: "back.out(${overshoot * 1.7})"` (Remotion's overshoot scale differs)            |
+
+## interpolate driving non-numeric properties
+
+```tsx
+const color = interpolateColors(frame, [0, 30], ["#ff0000", "#0000ff"]);
+```
+
+GSAP does color tweens natively:
+
+```js
+gsap.to(target, { color: "#0000ff", duration: 1.0, ease: "none" }, 0);
+```
+
+Same for `backgroundColor`, `borderColor`. The `from` value is read from CSS
+or the inline style.
+
+## Custom count-up / number tweens
+
+When Remotion uses a frame-driven number ramp (`Math.round(value * eased)`):
+
+```tsx
+const t = interpolate(frame, [0, 45], [0, 1]);
+const eased = 1 - (1 - t) ** 3; // cubic ease-out
+const value = Math.round(target * eased);
+return <div>{value.toLocaleString()}</div>;
+```
+
+GSAP equivalent — tween a counter object, write `textContent` on update:
+
+```js
+const counter = { v: 0 };
+tl.to(
+  counter,
+  {
+    v: target,
+    duration: 1.5,
+    ease: "power3.out",
+    onUpdate: () => {
+      el.textContent = Math.round(counter.v).toLocaleString();
+    },
+  },
+  0,
+);
+```
+
+`power3.out` matches `1 - (1-t)^3` exactly. Validated in T3 (mean SSIM 0.953).
+Per-frame digit mismatches occur on sub-frame timing offsets but final values
+converge — no SSIM impact above the noise floor.
+
+## Stagger via per-instance prop
+
+When custom subcomponents take a `delayInFrames` prop:
+
+```tsx
+<StatCard delayInFrames={i * 12} value={...} />
+```
+
+Translate to GSAP timeline offsets:
+
+```js
+cards.forEach((card, i) => {
+  const start = base + i * (12 / fps); // i * 0.4s at fps=30
+  tl.to(card, { ... }, start);
+});
+```
+
+Validated in T3 — three StatCards staggered at 0.0/0.4/0.8 s.
diff --git a/skills/remotion-to-hyperframes/references/transitions.md b/skills/remotion-to-hyperframes/references/transitions.md
new file mode 100644
index 000000000..223cc7ff1
--- /dev/null
+++ b/skills/remotion-to-hyperframes/references/transitions.md
@@ -0,0 +1,114 @@
+# Transitions translation: @remotion/transitions → HF crossfades / shader-transitions
+
+The `@remotion/transitions` package is Remotion's library of pre-built
+scene-to-scene transitions. HF has two paths to translate them:
+
+1. **Manual GSAP crossfade** — for simple opacity/transform transitions. Free, no extra package.
+2. **HF shader-transitions package** — for visually-rich transitions that match the @remotion/transitions presets.
+
+## Pattern: `<TransitionSeries>` is `<Series>` with overlap
+
+```tsx
+<TransitionSeries>
+  <TransitionSeries.Sequence durationInFrames={60}>
+    <SceneA />
+  </TransitionSeries.Sequence>
+  <TransitionSeries.Transition
+    presentation={fade()}
+    timing={linearTiming({ durationInFrames: 15 })}
+  />
+  <TransitionSeries.Sequence durationInFrames={60}>
+    <SceneB />
+  </TransitionSeries.Sequence>
+</TransitionSeries>
+```
+
+Translates to scenes that overlap by the transition duration:
+
+- SceneA: [0, 60] = `data-start="0" data-duration="2"`
+- SceneB: [60-15, 60-15+60] = `data-start="1.5" data-duration="2"` (the transition window overlaps the end of A and start of B)
+
+Then drive the transition with GSAP:
+
+```js
+// Manual fade (presentation={fade()})
+tl.to(sceneA, { opacity: 0, duration: 0.5, ease: "none" }, 1.5);
+tl.fromTo(sceneB, { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "none" }, 1.5);
+```
+
+## Presentation table
+
+| Remotion `presentation`            | HF translation                                                                            |
+| ---------------------------------- | ----------------------------------------------------------------------------------------- |
+| `fade()`                           | manual `gsap.to(opacity)` crossfade                                                       |
+| `slide({direction: "from-right"})` | `gsap.fromTo(translateX: "100%" → 0)` on incoming + `to(translateX: "-100%")` on outgoing |
+| `wipe({direction: "from-left"})`   | `gsap.fromTo(clip-path: inset(0 100% 0 0) → inset(0 0 0 0))` on incoming                  |
+| `clockWipe()`                      | use HF's `sdf-iris` shader-transition (`npx hyperframes add sdf-iris`)                    |
+| `flip()`                           | `gsap.to(rotateY)` 180° split between scenes                                              |
+| `cube()`                           | use HF's `cinematic-zoom` or build manually with `rotateY` + `transform-origin`           |
+| `iris()`                           | use HF's `sdf-iris` shader-transition                                                     |
+| `none()`                           | no transition; hard cut at the boundary                                                   |
+
+## Timing translations
+
+```tsx
+linearTiming({durationInFrames: 15})              → ease: "none"
+linearTiming({durationInFrames: 15, easing: ...}) → ease per the easing table in timing.md
+springTiming({config: {damping: 12}})             → ease: "back.out(1.4)" (~0.7 s)
+```
+
+Convert `durationInFrames` to seconds (`/fps`).
+
+## When to use HF shader-transitions
+
+For transitions Remotion presets that have visually-rich GLSL equivalents
+(iris, ripple, zoom, glitch), use HF's [shader-transitions](https://hyperframes.heygen.com/catalog/blocks)
+package. They produce richer output than manual GSAP transforms.
+
+```bash
+npx hyperframes add sdf-iris
+```
+
+Then in the composition:
+
+```html
+<div id="iris-transition" class="hf-shader-transition" data-start="1.5" data-duration="0.5">
+  <!-- bound scenes via the shader-transition's data-from / data-to -->
+</div>
+```
+
+Each shader-transition has its own data attributes; see the catalog page
+for the specific block.
+
+## When the source uses a custom Presentation
+
+Remotion supports custom `presentation` implementations:
+
+```tsx
+const customPresentation: PresentationComponent = ({
+  children,
+  presentationProgress,
+  presentationDirection,
+}) => {
+  return (
+    <div
+      style={
+        {
+          /* compute transform from progress */
+        }
+      }
+    >
+      {children}
+    </div>
+  );
+};
+```
+
+Translation: extract the math from the `style={...}` block and emit
+equivalent GSAP tweens. Specifically the transform formula maps directly
+to a `gsap.to(target, { transform: ... })` parameterized by `progress`.
+
+If the custom presentation uses `useCurrentFrame()` internally to
+animate something _outside_ the simple progress curve, treat the source
+as untranslatable and bow out to the runtime interop pattern (see
+[escape-hatch.md](escape-hatch.md)).
diff --git a/skills/remotion-to-hyperframes/scripts/frame_strip.sh b/skills/remotion-to-hyperframes/scripts/frame_strip.sh
new file mode 100755
index 000000000..468d50d5e
--- /dev/null
+++ b/skills/remotion-to-hyperframes/scripts/frame_strip.sh
@@ -0,0 +1,107 @@
+#!/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
diff --git a/skills/remotion-to-hyperframes/scripts/lint_source.py b/skills/remotion-to-hyperframes/scripts/lint_source.py
new file mode 100755
index 000000000..e3a8ecd5c
--- /dev/null
+++ b/skills/remotion-to-hyperframes/scripts/lint_source.py
@@ -0,0 +1,358 @@
+#!/usr/bin/env python3
+"""Lint a Remotion project for patterns that don't translate cleanly to HyperFrames.
+
+The skill should run this *before* attempting a translation. If any blocker
+findings come back, the recommendation is to use the runtime interop pattern
+from PR #214 instead of producing broken HTML.
+
+Usage:
+    lint_source.py <path-to-remotion-src> [--json]
+
+Output (default human-readable, --json for machine-readable):
+    For each .ts/.tsx file, a list of findings with:
+      - severity: blocker | warning | info
+      - line, column
+      - rule id
+      - message
+      - recommendation
+
+Blockers (skill should refuse to translate):
+  - r2hf/use-state            React state machine drives animation
+  - r2hf/use-effect-deps      useEffect/useLayoutEffect with non-empty deps (side effects)
+  - r2hf/use-reducer          useReducer drives animation
+  - r2hf/async-metadata       calculateMetadata returns a Promise
+  - r2hf/third-party-react-ui Imports a React UI library (shadcn, mui, antd, mantine, chakra)
+
+Warnings (translate but flag — drop the construct, keep the rest):
+  - r2hf/lambda-import        @remotion/lambda configuration — drop, HF is single-machine
+  - r2hf/delay-render         delayRender() — HF handles asset loading differently
+  - r2hf/use-callback         useCallback — usually decorative, drop
+  - r2hf/use-memo             useMemo — usually decorative, drop
+  - r2hf/custom-hook          Custom hook (use*) defined locally; may need manual rewrite
+
+Info (translate and document):
+  - r2hf/static-file          staticFile("x") — convert to relative path
+  - r2hf/interpolate-colors   interpolateColors — translate to GSAP color tween
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from collections.abc import Iterable
+from dataclasses import dataclass, asdict
+from pathlib import Path
+from typing import Callable
+
+BLOCKER = "blocker"
+WARNING = "warning"
+INFO = "info"
+
+THIRD_PARTY_UI_PACKAGES = {
+    "@mui/material",
+    "@mui/icons-material",
+    "@chakra-ui/react",
+    "@mantine/core",
+    "antd",
+    "@shadcn/ui",
+    "@radix-ui",
+    "@nextui-org/react",
+}
+
+
+@dataclass
+class Finding:
+    file: str
+    line: int
+    column: int
+    severity: str
+    rule: str
+    message: str
+    recommendation: str
+
+
+@dataclass
+class Rule:
+    """A lint rule: a matcher that yields hits, plus the metadata each hit gets.
+
+    A matcher is a function `src -> Iterable[(offset, override_message)]`. If
+    `override_message` is None, the rule's default `message` is used; matchers
+    that need to embed the matched text (custom-hook name, third-party package
+    name) return the customized message instead.
+    """
+
+    rule_id: str
+    severity: str
+    matcher: Callable[[str], Iterable[tuple[int, str | None]]]
+    message: str
+    recommendation: str
+
+
+def _regex_matcher(pattern: re.Pattern[str]) -> Callable[[str], Iterable[tuple[int, str | None]]]:
+    def _match(src: str) -> Iterable[tuple[int, str | None]]:
+        for m in pattern.finditer(src):
+            yield m.start(), None
+
+    return _match
+
+
+def _use_effect_with_deps(src: str) -> Iterable[tuple[int, str | None]]:
+    # Find use(Layout)?Effect(, walk to its matching ), and check if the call
+    # ends with `, [<non-empty>])`. Empty `[]` is mount-only, allowed.
+    for m in re.finditer(r"\buse(?:Layout)?Effect\s*\(", src):
+        end = _find_matching_paren(src, m.end() - 1)
+        if end is None:
+            continue
+        call = src[m.start() : end + 1]
+        m2 = re.search(r",\s*\[([^\]]*)\]\s*$", call[:-1])
+        if m2 and m2.group(1).strip():
+            yield m.start(), None
+
+
+_CUSTOM_HOOK_DECL = re.compile(
+    r"^\s*(?:export\s+(?:default\s+)?)?(?:function|const|let|var)\s+(use[A-Z]\w+)\b",
+    re.MULTILINE,
+)
+_REMOTION_BUILTIN_HOOKS = {"useCurrentFrame", "useVideoConfig"}
+
+
+def _custom_hook(src: str) -> Iterable[tuple[int, str | None]]:
+    for m in _CUSTOM_HOOK_DECL.finditer(src):
+        name = m.group(1)
+        if name in _REMOTION_BUILTIN_HOOKS:
+            continue
+        yield m.start(), f"Custom hook `{name}` defined locally — may need manual rewrite"
+
+
+_IMPORT_FROM = re.compile(r"from\s+['\"]([^'\"]+)['\"]")
+
+
+def _third_party_react_ui(src: str) -> Iterable[tuple[int, str | None]]:
+    for m in _IMPORT_FROM.finditer(src):
+        pkg = m.group(1)
+        if any(pkg.startswith(p) for p in THIRD_PARTY_UI_PACKAGES):
+            yield m.start(), f"Imports `{pkg}` — third-party React UI library has no HF equivalent"
+
+
+RULES: list[Rule] = [
+    Rule(
+        "r2hf/use-state",
+        BLOCKER,
+        _regex_matcher(re.compile(r"\buseState\s*[(<]")),
+        "useState detected — Remotion compositions that drive animation via React state are not deterministic frame-capture targets in HyperFrames",
+        "Use the runtime interop pattern from PR #214 instead of attempting a translation",
+    ),
+    Rule(
+        "r2hf/use-reducer",
+        BLOCKER,
+        _regex_matcher(re.compile(r"\buseReducer\s*[(<]")),
+        "useReducer detected — same issue as useState",
+        "Use the runtime interop pattern from PR #214",
+    ),
+    Rule(
+        "r2hf/use-effect-deps",
+        BLOCKER,
+        _use_effect_with_deps,
+        "useEffect/useLayoutEffect with non-empty deps — side effects don't translate to HF's seek-driven model",
+        "Move the side-effect work into a build step, or use the runtime interop pattern",
+    ),
+    Rule(
+        "r2hf/async-metadata",
+        BLOCKER,
+        _regex_matcher(
+            re.compile(
+                r"calculateMetadata[^=]*=\s*async\b|async\s+calculateMetadata\b|calculateMetadata\s*:\s*async"
+            )
+        ),
+        "calculateMetadata returns a Promise — HF needs composition metadata up front",
+        "Resolve metadata at build time and pass concrete values, or use runtime interop",
+    ),
+    Rule(
+        "r2hf/third-party-react-ui",
+        BLOCKER,
+        _third_party_react_ui,
+        "Imports a third-party React UI library — no HF equivalent",
+        "Use runtime interop, or rewrite the affected components as HTML+CSS",
+    ),
+    # Lambda is a warning, not a blocker: it's deployment config, orthogonal
+    # to the rendered composition. The skill drops the import and translates
+    # the rest. See references/escape-hatch.md.
+    Rule(
+        "r2hf/lambda-import",
+        WARNING,
+        _regex_matcher(re.compile(r"from\s+['\"]@remotion/lambda['\"]")),
+        "@remotion/lambda is Remotion-specific distributed rendering — no HF equivalent today",
+        "Drop the Lambda config; HF runs single-machine. Document the gap in TRANSLATION_NOTES.md.",
+    ),
+    Rule(
+        "r2hf/delay-render",
+        WARNING,
+        _regex_matcher(re.compile(r"\bdelayRender\s*\(")),
+        "delayRender() — HF waits on asset readiness via the Frame Adapter pattern",
+        "Drop the call; HF handles this transparently",
+    ),
+    Rule(
+        "r2hf/use-callback",
+        WARNING,
+        _regex_matcher(re.compile(r"\buseCallback\s*\(")),
+        "useCallback — typically decorative for render performance, no HF equivalent needed",
+        "Drop the wrapper, inline the function",
+    ),
+    Rule(
+        "r2hf/use-memo",
+        WARNING,
+        _regex_matcher(re.compile(r"\buseMemo\s*\(")),
+        "useMemo — typically decorative, no HF equivalent needed",
+        "Drop the wrapper, compute inline",
+    ),
+    Rule(
+        "r2hf/custom-hook",
+        WARNING,
+        _custom_hook,
+        "Custom hook defined locally — may need manual rewrite",
+        "Inline the hook body if pure; bow out to runtime interop if it uses useState/useEffect",
+    ),
+    Rule(
+        "r2hf/static-file",
+        INFO,
+        _regex_matcher(re.compile(r"\bstaticFile\s*\(")),
+        "staticFile() reference — convert to a relative path in the HF composition",
+        "Replace `staticFile(\"x.png\")` with `\"x.png\"` and copy the asset alongside the HTML",
+    ),
+    Rule(
+        "r2hf/interpolate-colors",
+        INFO,
+        _regex_matcher(re.compile(r"\binterpolateColors\s*\(")),
+        "interpolateColors() — translate to a GSAP color tween",
+        "See references/timing.md for the GSAP equivalent",
+    ),
+]
+
+
+def _find_matching_paren(src: str, open_idx: int) -> int | None:
+    """Given the index of an open `(`, return the index of its matching `)`.
+
+    Skips parens that appear inside `'...'`, `"..."`, or `` `...` `` string
+    literals. Returns None if no matching close paren is found.
+
+    This is good enough for hand-written Remotion source. It does not handle
+    template-literal interpolations `${...}` recursively or comments — both
+    are uncommon in Remotion code we expect to lint and would only matter
+    if the unbalanced paren landed inside such a region.
+    """
+    if open_idx >= len(src) or src[open_idx] != "(":
+        return None
+    depth = 0
+    i = open_idx
+    in_str: str | None = None
+    while i < len(src):
+        c = src[i]
+        if in_str is not None:
+            if c == "\\":
+                i += 2
+                continue
+            if c == in_str:
+                in_str = None
+            i += 1
+            continue
+        if c in ("'", '"', "`"):
+            in_str = c
+            i += 1
+            continue
+        if c == "(":
+            depth += 1
+        elif c == ")":
+            depth -= 1
+            if depth == 0:
+                return i
+        i += 1
+    return None
+
+
+def lint_file(path: Path) -> list[Finding]:
+    src = path.read_text()
+    findings: list[Finding] = []
+
+    def loc(offset: int) -> tuple[int, int]:
+        line = src.count("\n", 0, offset) + 1
+        col = offset - (src.rfind("\n", 0, offset) + 1) + 1
+        return line, col
+
+    for rule in RULES:
+        for offset, override_message in rule.matcher(src):
+            line, col = loc(offset)
+            findings.append(
+                Finding(
+                    str(path),
+                    line,
+                    col,
+                    rule.severity,
+                    rule.rule_id,
+                    override_message or rule.message,
+                    rule.recommendation,
+                )
+            )
+
+    findings.sort(key=lambda f: (f.file, f.line, f.column))
+    return findings
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument("path", type=Path, help="Directory or file to lint")
+    ap.add_argument("--json", action="store_true", help="Emit JSON instead of human-readable output")
+    args = ap.parse_args()
+
+    if not args.path.exists():
+        print(f"error: {args.path} does not exist", file=sys.stderr)
+        return 2
+
+    files: list[Path]
+    if args.path.is_file():
+        files = [args.path]
+    else:
+        files = sorted(
+            p
+            for p in args.path.rglob("*")
+            if p.is_file()
+            and p.suffix in {".ts", ".tsx", ".jsx", ".js"}
+            and "node_modules" not in p.parts
+        )
+
+    all_findings: list[Finding] = []
+    for f in files:
+        all_findings.extend(lint_file(f))
+
+    blockers = sum(1 for f in all_findings if f.severity == BLOCKER)
+    warnings = sum(1 for f in all_findings if f.severity == WARNING)
+    infos = sum(1 for f in all_findings if f.severity == INFO)
+
+    if args.json:
+        json.dump(
+            {
+                "files_scanned": len(files),
+                "blockers": blockers,
+                "warnings": warnings,
+                "infos": infos,
+                "findings": [asdict(f) for f in all_findings],
+            },
+            sys.stdout,
+            indent=2,
+        )
+        sys.stdout.write("\n")
+    else:
+        for f in all_findings:
+            print(f"{f.file}:{f.line}:{f.column} [{f.severity}] {f.rule}: {f.message}")
+            print(f"    -> {f.recommendation}")
+        print()
+        print(f"{len(files)} files scanned · {blockers} blocker · {warnings} warning · {infos} info")
+        if blockers:
+            print("RECOMMENDATION: do not attempt translation. Use the runtime interop pattern from PR #214.")
+
+    return 1 if blockers else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/skills/remotion-to-hyperframes/scripts/render_diff.sh b/skills/remotion-to-hyperframes/scripts/render_diff.sh
new file mode 100755
index 000000000..dc349fd11
--- /dev/null
+++ b/skills/remotion-to-hyperframes/scripts/render_diff.sh
@@ -0,0 +1,103 @@
+#!/usr/bin/env bash
+# render_diff.sh — compute per-frame SSIM between two video files.
+#
+# The eval primitive for the remotion-to-hyperframes skill: given a Remotion
+# render and a HyperFrames render of the same composition, report whether the
+# translation is visually equivalent.
+#
+# Usage:
+#   render_diff.sh <baseline.mp4> <translated.mp4> [output-dir]
+#
+# Output (in output-dir, defaults to ./diff-out):
+#   ssim.log          — per-frame SSIM lines from ffmpeg
+#   summary.json      — { mean, min, p05, p95, frame_count, pass, threshold }
+#
+# Exit codes:
+#   0  — pass (mean SSIM >= threshold)
+#   1  — fail (mean SSIM <  threshold)
+#   2  — usage / setup error
+#
+# Threshold defaults to 0.85 (loose; tier-specific thresholds are applied by
+# the orchestrator). Override with R2HF_SSIM_THRESHOLD=0.95 in the environment.
+
+set -euo pipefail
+
+THRESHOLD="${R2HF_SSIM_THRESHOLD:-0.85}"
+
+if [[ $# -lt 2 || $# -gt 3 ]]; then
+  echo "usage: $0 <baseline.mp4> <translated.mp4> [output-dir]" >&2
+  exit 2
+fi
+
+BASELINE="$1"
+TRANSLATED="$2"
+OUTDIR="${3:-./diff-out}"
+
+if [[ ! -f "$BASELINE" ]]; then
+  echo "error: baseline not found: $BASELINE" >&2
+  exit 2
+fi
+if [[ ! -f "$TRANSLATED" ]]; then
+  echo "error: translated not found: $TRANSLATED" >&2
+  exit 2
+fi
+if ! command -v ffmpeg >/dev/null 2>&1; then
+  echo "error: ffmpeg not on PATH" >&2
+  exit 2
+fi
+
+mkdir -p "$OUTDIR"
+SSIM_LOG="$OUTDIR/ssim.log"
+SUMMARY="$OUTDIR/summary.json"
+
+# ffmpeg's ssim filter writes one line per frame to stats_file and a single
+# Mean SSIM line to stderr. We capture both — per-frame for distribution
+# stats, and the mean for the headline number.
+ffmpeg -hide_banner -nostats -loglevel info \
+  -i "$BASELINE" -i "$TRANSLATED" \
+  -lavfi "[0:v]scale=iw:ih[ref];[1:v]scale=iw:ih[main];[main][ref]ssim=stats_file=$SSIM_LOG" \
+  -f null - 2>"$OUTDIR/ffmpeg.stderr"
+
+# Parse: each line in ssim.log looks like
+#   n:1 Y:0.987655 U:0.992345 V:0.991234 All:0.989012 (19.512345)
+# We want the All:N column.
+python3 - "$SSIM_LOG" "$SUMMARY" "$THRESHOLD" <<'PY'
+import json, math, re, sys
+from pathlib import Path
+
+log_path = Path(sys.argv[1])
+out_path = Path(sys.argv[2])
+threshold = float(sys.argv[3])
+
+values = []
+pattern = re.compile(r"All:([\d.]+)")
+for line in log_path.read_text().splitlines():
+    m = pattern.search(line)
+    if m:
+        try:
+            values.append(float(m.group(1)))
+        except ValueError:
+            pass
+
+if not values:
+    print(f"error: no SSIM samples parsed from {log_path}", file=sys.stderr)
+    sys.exit(2)
+
+values.sort()
+n = len(values)
+mean = sum(values) / n
+p_idx = lambda p: min(n - 1, max(0, int(math.floor(p * n))))
+summary = {
+    "frame_count": n,
+    "mean": round(mean, 6),
+    "min": round(values[0], 6),
+    "max": round(values[-1], 6),
+    "p05": round(values[p_idx(0.05)], 6),
+    "p95": round(values[p_idx(0.95)], 6),
+    "threshold": threshold,
+    "pass": bool(mean >= threshold),
+}
+out_path.write_text(json.dumps(summary, indent=2) + "\n")
+print(json.dumps(summary, indent=2))
+sys.exit(0 if summary["pass"] else 1)
+PY
diff --git a/skills/remotion-to-hyperframes/scripts/tests/fixtures/blocker.tsx b/skills/remotion-to-hyperframes/scripts/tests/fixtures/blocker.tsx
new file mode 100644
index 000000000..f03636631
--- /dev/null
+++ b/skills/remotion-to-hyperframes/scripts/tests/fixtures/blocker.tsx
@@ -0,0 +1,50 @@
+import React, { useState, useEffect, useLayoutEffect } from "react";
+import { useCurrentFrame, AbsoluteFill, delayRender, continueRender } from "remotion";
+import { Button } from "@mui/material";
+
+// Custom hook in `export const useFoo = ...` form — earlier custom-hook
+// regex anchored to `^\s*(?:function|const|let)` and missed the `export`
+// prefix. This covers the regression.
+export const useFadeMixed = (n: number) => {
+  const f = useCurrentFrame();
+  return f / n;
+};
+
+export const BadComposition: React.FC = () => {
+  const frame = useCurrentFrame();
+  const [data, setData] = useState<string | null>(null);
+  const [handle] = useState(() => delayRender());
+
+  // Multi-line useEffect body with commas inside (fillRect args) — regression
+  // coverage for r2hf/use-effect-deps. An earlier regex `[^,]+` would stop at
+  // the first comma inside the body and miss the deps array entirely.
+  useEffect(() => {
+    fetch("/api/data")
+      .then((r) => r.json())
+      .then((d) => {
+        const ctx = document.createElement("canvas").getContext("2d");
+        ctx?.fillRect(0, 0, 100, 100);
+        setData(d.text);
+        continueRender(handle);
+      });
+  }, [handle]);
+
+  // Expression-bodied useEffect — the form `useEffect(() => fetch(...), [deps])`
+  // has no closing `}`, which an earlier regex anchored on. This and the
+  // useLayoutEffect below cover the false-negative cases Miguel surfaced.
+  useEffect(() => fetch("/api/heartbeat"), [frame]);
+  useLayoutEffect(() => (document.title = `frame ${frame}`), [frame]);
+
+  return (
+    <AbsoluteFill>
+      <Button>{data ?? "loading"}</Button>
+      <span>{frame}</span>
+    </AbsoluteFill>
+  );
+};
+
+export const calculateMetadata = async () => {
+  const res = await fetch("/api/duration");
+  const { duration } = await res.json();
+  return { durationInFrames: duration };
+};
diff --git a/skills/remotion-to-hyperframes/scripts/tests/fixtures/clean.tsx b/skills/remotion-to-hyperframes/scripts/tests/fixtures/clean.tsx
new file mode 100644
index 000000000..73c9b60c2
--- /dev/null
+++ b/skills/remotion-to-hyperframes/scripts/tests/fixtures/clean.tsx
@@ -0,0 +1,46 @@
+import React, { useEffect } from "react";
+import {
+  useCurrentFrame,
+  useVideoConfig,
+  AbsoluteFill,
+  interpolate,
+  spring,
+  Sequence,
+  staticFile,
+  Audio,
+  Img,
+} from "remotion";
+
+// Mount-only useEffect with empty deps + a later expression containing a
+// non-empty array — regression coverage for the over-match Miguel reported:
+// the earlier regex spanned past `[]` and matched `[frame]` from `pick(...)`,
+// falsely flagging this clean fixture as having a blocker.
+function pick<T>(_key: string, items: T[]): T {
+  return items[0];
+}
+
+const TitleCard: React.FC = () => {
+  const frame = useCurrentFrame();
+  const { fps } = useVideoConfig();
+  useEffect(() => {
+    console.log("mounted");
+  }, []);
+  const _picked = pick("x", [frame]);
+  const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
+  const scale = spring({ frame, fps, config: { damping: 12 } });
+  return (
+    <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
+      <div style={{ fontSize: 72, opacity, transform: `scale(${scale})` }}>Hello</div>
+      <Img src={staticFile("logo.png")} />
+    </AbsoluteFill>
+  );
+};
+
+export const MyComposition: React.FC = () => (
+  <AbsoluteFill>
+    <Sequence from={0} durationInFrames={90}>
+      <TitleCard />
+    </Sequence>
+    <Audio src={staticFile("music.mp3")} volume={0.5} />
+  </AbsoluteFill>
+);
diff --git a/skills/remotion-to-hyperframes/scripts/tests/smoke.sh b/skills/remotion-to-hyperframes/scripts/tests/smoke.sh
new file mode 100755
index 000000000..3a13c2804
--- /dev/null
+++ b/skills/remotion-to-hyperframes/scripts/tests/smoke.sh
@@ -0,0 +1,90 @@
+#!/usr/bin/env bash
+# smoke.sh — exercise the eval harness scripts against synthetic inputs.
+#
+# Generates two synthetic videos with ffmpeg's testsrc filter, runs render_diff
+# and frame_strip against them, and runs lint_source against fixture .tsx files.
+# Asserts the harness produces sensible output without depending on a real
+# Remotion or HyperFrames render pipeline being installed.
+#
+# Usage: ./smoke.sh
+# Exit 0 on pass.
+
+set -euo pipefail
+
+THIS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SCRIPTS_DIR="$(cd "$THIS_DIR/.." && pwd)"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+
+echo "==> smoke: render_diff.sh against identical inputs"
+# Generate the same test pattern twice. Identical inputs → SSIM should be ~1.0.
+ffmpeg -y -hide_banner -loglevel error \
+  -f lavfi -i "testsrc=duration=2:size=320x240:rate=30" \
+  -pix_fmt yuv420p "$WORK/baseline.mp4"
+cp "$WORK/baseline.mp4" "$WORK/translated.mp4"
+
+R2HF_SSIM_THRESHOLD=0.99 "$SCRIPTS_DIR/render_diff.sh" \
+  "$WORK/baseline.mp4" "$WORK/translated.mp4" "$WORK/diff" >/dev/null
+
+MEAN=$(python3 -c "import json,sys; print(json.load(open('$WORK/diff/summary.json'))['mean'])")
+PASS=$(python3 -c "import json,sys; print(json.load(open('$WORK/diff/summary.json'))['pass'])")
+if [[ "$PASS" != "True" ]]; then
+  echo "FAIL: identical inputs failed pass check (mean=$MEAN)"
+  exit 1
+fi
+echo "    identical inputs → mean SSIM=$MEAN (pass=True)"
+
+echo "==> smoke: render_diff.sh against different inputs"
+# Different test pattern → SSIM should be lower. With a high threshold it should fail.
+ffmpeg -y -hide_banner -loglevel error \
+  -f lavfi -i "testsrc2=duration=2:size=320x240:rate=30" \
+  -pix_fmt yuv420p "$WORK/different.mp4"
+
+set +e
+R2HF_SSIM_THRESHOLD=0.99 "$SCRIPTS_DIR/render_diff.sh" \
+  "$WORK/baseline.mp4" "$WORK/different.mp4" "$WORK/diff2" >/dev/null
+RC=$?
+set -e
+if [[ "$RC" -eq 0 ]]; then
+  echo "FAIL: different inputs unexpectedly passed at threshold 0.99"
+  exit 1
+fi
+DIFF_MEAN=$(python3 -c "import json; print(json.load(open('$WORK/diff2/summary.json'))['mean'])")
+echo "    different inputs → mean SSIM=$DIFF_MEAN (correctly failed at 0.99)"
+
+echo "==> smoke: frame_strip.sh produces a strip"
+"$SCRIPTS_DIR/frame_strip.sh" "$WORK/baseline.mp4" "$WORK/different.mp4" "$WORK/strip" 4 >/dev/null
+if [[ ! -f "$WORK/strip/strip.png" ]]; then
+  echo "FAIL: frame_strip.sh did not produce strip.png"
+  exit 1
+fi
+echo "    strip.png written ($(stat -c%s "$WORK/strip/strip.png" 2>/dev/null || stat -f%z "$WORK/strip/strip.png") bytes)"
+
+echo "==> smoke: lint_source.py on clean fixture (expect exit 0)"
+set +e
+python3 "$SCRIPTS_DIR/lint_source.py" "$THIS_DIR/fixtures/clean.tsx" --json >"$WORK/clean.json"
+RC=$?
+set -e
+BLOCKERS=$(python3 -c "import json; print(json.load(open('$WORK/clean.json'))['blockers'])")
+if [[ "$RC" -ne 0 || "$BLOCKERS" -ne 0 ]]; then
+  echo "FAIL: clean fixture reported $BLOCKERS blockers (rc=$RC)"
+  exit 1
+fi
+INFOS=$(python3 -c "import json; print(json.load(open('$WORK/clean.json'))['infos'])")
+echo "    clean.tsx → 0 blockers, $INFOS info findings"
+
+echo "==> smoke: lint_source.py on blocker fixture (expect exit 1)"
+set +e
+python3 "$SCRIPTS_DIR/lint_source.py" "$THIS_DIR/fixtures/blocker.tsx" --json >"$WORK/blocker.json"
+RC=$?
+set -e
+BLOCKERS=$(python3 -c "import json; print(json.load(open('$WORK/blocker.json'))['blockers'])")
+if [[ "$RC" -eq 0 || "$BLOCKERS" -lt 3 ]]; then
+  echo "FAIL: blocker fixture reported $BLOCKERS blockers, expected >=3 (rc=$RC)"
+  cat "$WORK/blocker.json"
+  exit 1
+fi
+echo "    blocker.tsx → $BLOCKERS blockers detected (correctly refused)"
+
+echo
+echo "✅ smoke tests passed"
