Merge pull request #515 from heygen-com/skill/r2hf-corpus-t4

feat(skills): remotion-to-hyperframes corpus T4 (5/7)
This commit is contained in:
James Russo
2026-04-27 22:18:14 -07:00
committed by GitHub
11 changed files with 516 additions and 0 deletions
@@ -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.
@@ -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>
);
};
@@ -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>
);
};
@@ -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,
};
};
@@ -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>
);
};
@@ -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",
});
}
@@ -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>
);
};
@@ -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>
);
};
@@ -0,0 +1,41 @@
// T4 case 08 — Multiple blockers + multiple warnings in one file.
//
// Should report:
// blockers: r2hf/use-state, r2hf/use-effect-deps, r2hf/third-party-react-ui
// warnings: r2hf/use-callback (also r2hf/delay-render via the import chain
// would only fire if delayRender is actually called)
//
// Tests that the linter aggregates findings correctly and does not stop at
// the first blocker.
import React, { useState, useEffect, useCallback } from "react";
import { AbsoluteFill, useCurrentFrame } from "remotion";
import { Card } from "@chakra-ui/react";
interface Item {
id: string;
label: string;
}
export const MixedBlockers: React.FC = () => {
const frame = useCurrentFrame();
const [items, setItems] = useState<Item[]>([]);
useEffect(() => {
fetch("/api/items")
.then((r) => r.json())
.then(setItems);
}, [frame]);
const onClick = useCallback(() => {
setItems((prev) => [...prev, { id: String(prev.length), label: "new" }]);
}, []);
return (
<AbsoluteFill onClick={onClick}>
{items.map((item) => (
<Card key={item.id}>{item.label}</Card>
))}
</AbsoluteFill>
);
};
@@ -0,0 +1,85 @@
{
"tier": 4,
"name": "escape-hatch",
"description": "Lint-only fixture set. Each case demonstrates a Remotion pattern the skill cannot or should not translate cleanly. The skill is graded on whether lint_source.py emits the right finding for each case — there are no renders to compare. T4 passes when every case triggers its expected rule and no others.",
"cases": [
{
"file": "01-use-state.tsx",
"expected": {
"blockers": [{ "rule": "r2hf/use-state", "min_count": 1 }],
"warnings": [],
"skill_action": "refuse_translation_recommend_interop"
}
},
{
"file": "02-use-effect-deps.tsx",
"expected": {
"blockers": [{ "rule": "r2hf/use-effect-deps", "min_count": 1 }],
"warnings": [],
"skill_action": "refuse_translation_recommend_interop"
}
},
{
"file": "03-async-metadata.tsx",
"expected": {
"blockers": [{ "rule": "r2hf/async-metadata", "min_count": 1 }],
"warnings": [],
"skill_action": "refuse_translation_recommend_interop"
}
},
{
"file": "04-third-party-react.tsx",
"expected": {
"blockers": [{ "rule": "r2hf/third-party-react-ui", "min_count": 1 }],
"warnings": [],
"skill_action": "refuse_translation_recommend_interop"
}
},
{
"file": "05-lambda-config.tsx",
"expected": {
"blockers": [],
"warnings": [{ "rule": "r2hf/lambda-import", "min_count": 1 }],
"skill_action": "drop_lambda_code_translate_remainder_if_clean"
}
},
{
"file": "06-warnings-only.tsx",
"expected": {
"blockers": [],
"warnings": [
{ "rule": "r2hf/delay-render", "min_count": 1 },
{ "rule": "r2hf/use-callback", "min_count": 1 },
{ "rule": "r2hf/use-memo", "min_count": 1 }
],
"skill_action": "translate_after_dropping_wrappers"
}
},
{
"file": "07-custom-hook.tsx",
"expected": {
"blockers": [],
"warnings": [{ "rule": "r2hf/custom-hook", "min_count": 1 }],
"skill_action": "inline_hook_body_if_pure"
}
},
{
"file": "08-mixed.tsx",
"expected": {
"blockers": [
{ "rule": "r2hf/use-state", "min_count": 1 },
{ "rule": "r2hf/use-effect-deps", "min_count": 1 },
{ "rule": "r2hf/third-party-react-ui", "min_count": 1 }
],
"warnings": [{ "rule": "r2hf/use-callback", "min_count": 1 }],
"skill_action": "refuse_translation_recommend_interop"
}
}
],
"totals": {
"expected_blocker_cases": 5,
"expected_warning_only_cases": 3,
"expected_total_blocker_findings_min": 7,
"expected_total_warning_findings_min": 6
}
}
@@ -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