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 (
+
+
frame {frame}
+
+ );
+};
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(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ ctx?.fillRect(frame, frame, 10, 10);
+ }, [frame]);
+
+ return (
+
+
+
+ );
+};
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 = ({ text }) => {
+ const frame = useCurrentFrame();
+ return (
+
+
+ {text} · frame {frame}
+
+
+ );
+};
+
+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 (
+
+
+
+
+
+ );
+};
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 (
+
+
frame {frame}
+
+ );
+};
+
+// 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 (
+
+
frame {frame}
+
+ );
+};
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 (
+
+
fading in
+
+ );
+};
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([]);
+
+ useEffect(() => {
+ fetch("/api/items")
+ .then((r) => r.json())
+ .then(setItems);
+ }, [frame]);
+
+ const onClick = useCallback(() => {
+ setItems((prev) => [...prev, { id: String(prev.length), label: "new" }]);
+ }, []);
+
+ return (
+
+ {items.map((item) => (
+ {item.label}
+ ))}
+
+ );
+};
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