Skip to content

Commit afe9d23

Browse files
committed
feat(skills): remotion-to-hyperframes corpus T4 (5/7)
Adds the escape-hatch tier — lint-only fixtures that test the skill's ability to refuse translation cleanly when it sees patterns that don't map to HF's seek-driven model. Cases (8 total): 01-use-state.tsx blocker: r2hf/use-state 02-use-effect-deps.tsx blocker: r2hf/use-effect-deps (multi-line body with internal commas — regression target for the regex bug fix in PR 2) 03-async-metadata.tsx blocker: r2hf/async-metadata 04-third-party-react.tsx blocker: r2hf/third-party-react-ui (@mui/material) 05-lambda-config.tsx blocker: r2hf/lambda-import 06-warnings-only.tsx warnings: delayRender / useCallback / useMemo (no blockers — translates after dropping wrappers) 07-custom-hook.tsx warning: r2hf/custom-hook (pure useFadeIn) 08-mixed.tsx multiple blockers + warnings (aggregate test) Each case documents: - The Remotion pattern it demonstrates - Why it's a blocker / warning / info - What the skill should do (refuse / drop-and-translate / translate-as-is) Validation harness (validate.sh): Runs lint_source.py against each case, asserts: - Each expected blocker rule fires with severity="blocker" - Each expected warning rule fires with severity="warning" - lint_source.py exit code is 1 when blockers expected, 0 otherwise T4 has no renders to diff. The skill is graded on lint correctness — that's the gate that decides whether to translate or recommend the runtime interop pattern from PR #214. Result: 8/8 cases pass.
1 parent 0697f35 commit afe9d23

11 files changed

Lines changed: 536 additions & 0 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Tier 4 — escape-hatch
2+
3+
## What it tests
4+
5+
T4 is the **lint-only** tier. There are no renders to diff — the skill is
6+
graded on whether it correctly _refuses_ to translate each case (and
7+
recommends the runtime interop pattern from PR #214 instead) or, where
8+
appropriate, translates after dropping warning-level decorations.
9+
10+
Each `cases/*.tsx` file is a minimal Remotion composition that
11+
demonstrates one specific pattern. The skill should:
12+
13+
1. Run `scripts/lint_source.py` over the source.
14+
2. Compare the JSON output to `expected.json` for that case.
15+
3. Take the documented `skill_action`:
16+
- `refuse_translation_recommend_interop` — print the rationale + link to
17+
the PR #214 interop guide; do not produce HF output.
18+
- `drop_lambda_code_translate_remainder_if_clean` — drop the
19+
`@remotion/lambda` code with a note; translate the rest only if no
20+
other blockers are present.
21+
- `translate_after_dropping_wrappers` — translate normally; drop
22+
`useCallback` / `useMemo` / `delayRender` wrappers.
23+
- `inline_hook_body_if_pure` — inline the custom hook's body if it's a
24+
pure derivation of `useCurrentFrame`; otherwise bow out.
25+
26+
## Cases
27+
28+
| # | File | Expected blocker | Notes |
29+
| --- | -------------------------- | ----------------------------- | ------------------------------------------- |
30+
| 01 | `01-use-state.tsx` | `r2hf/use-state` | useState driving animation |
31+
| 02 | `02-use-effect-deps.tsx` | `r2hf/use-effect-deps` | useEffect with non-empty deps + side effect |
32+
| 03 | `03-async-metadata.tsx` | `r2hf/async-metadata` | calculateMetadata returns a Promise |
33+
| 04 | `04-third-party-react.tsx` | `r2hf/third-party-react-ui` | imports `@mui/material` |
34+
| 05 | `05-lambda-config.tsx` | `r2hf/lambda-import` | imports `@remotion/lambda` |
35+
| 06 | `06-warnings-only.tsx` | (warnings only) | delayRender / useCallback / useMemo |
36+
| 07 | `07-custom-hook.tsx` | (warnings only) | locally-defined `useFadeIn` |
37+
| 08 | `08-mixed.tsx` | useState + useEffect + chakra | aggregate-findings test |
38+
39+
## Validation
40+
41+
```bash
42+
./validate.sh
43+
```
44+
45+
The script runs `lint_source.py` against each case and asserts:
46+
47+
- Each expected blocker rule fires with severity `blocker`.
48+
- Each expected warning rule fires with severity `warning` (or stronger).
49+
- `lint_source.py`'s exit code is 1 when blockers are expected, 0 otherwise.
50+
51+
T4 passes when every case matches its expected output. No renders involved.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// T4 case 01 — useState drives animation.
2+
//
3+
// Should be detected by lint_source.py as blocker r2hf/use-state.
4+
// The skill should refuse to translate and recommend the runtime interop
5+
// pattern from PR #214.
6+
//
7+
// Why this is a blocker: useState is React's component-local mutable state.
8+
// HF's seek-driven model produces deterministic frames from a single time
9+
// value — there's no per-frame React render cycle to update state on.
10+
11+
import React, { useState } from "react";
12+
import { AbsoluteFill, useCurrentFrame } from "remotion";
13+
14+
export const StateDriven: React.FC = () => {
15+
const frame = useCurrentFrame();
16+
const [hue, setHue] = useState(0);
17+
18+
// Even if this looks innocuous, the setHue call breaks determinism: HF
19+
// can't reproduce React state mutations across seeks.
20+
if (frame % 30 === 0 && hue < 360) {
21+
setHue((h) => h + 30);
22+
}
23+
24+
return (
25+
<AbsoluteFill style={{ background: `hsl(${hue}, 80%, 50%)` }}>
26+
<div>frame {frame}</div>
27+
</AbsoluteFill>
28+
);
29+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// T4 case 02 — useEffect with non-empty deps performs side effects per render.
2+
//
3+
// Should be detected by lint_source.py as blocker r2hf/use-effect-deps.
4+
// The skill should refuse to translate.
5+
//
6+
// Why this is a blocker: side effects (network, DOM mutation outside the
7+
// rendered tree, timers) don't translate to a seek-driven model. HF assumes
8+
// the page is fully rendered and pure between seeks.
9+
10+
import React, { useEffect, useRef } from "react";
11+
import { AbsoluteFill, useCurrentFrame } from "remotion";
12+
13+
export const SideEffectDriven: React.FC = () => {
14+
const frame = useCurrentFrame();
15+
const canvasRef = useRef<HTMLCanvasElement>(null);
16+
17+
useEffect(() => {
18+
const canvas = canvasRef.current;
19+
if (!canvas) return;
20+
const ctx = canvas.getContext("2d");
21+
ctx?.fillRect(frame, frame, 10, 10);
22+
}, [frame]);
23+
24+
return (
25+
<AbsoluteFill>
26+
<canvas ref={canvasRef} width={1280} height={720} />
27+
</AbsoluteFill>
28+
);
29+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// T4 case 03 — calculateMetadata returns a Promise.
2+
//
3+
// Should be detected by lint_source.py as blocker r2hf/async-metadata.
4+
// The skill should refuse to translate.
5+
//
6+
// Why this is a blocker: HF needs the composition's duration, dimensions,
7+
// and props known up-front to produce HTML and seed the timeline. Async
8+
// metadata fetched from a server at render time has no equivalent in HF —
9+
// the metadata would need to be resolved at build time before the HTML is
10+
// authored.
11+
12+
import React from "react";
13+
import { AbsoluteFill, useCurrentFrame } from "remotion";
14+
15+
interface Props {
16+
text: string;
17+
}
18+
19+
export const AsyncMetadataDriven: React.FC<Props> = ({ text }) => {
20+
const frame = useCurrentFrame();
21+
return (
22+
<AbsoluteFill>
23+
<div>
24+
{text} · frame {frame}
25+
</div>
26+
</AbsoluteFill>
27+
);
28+
};
29+
30+
export const calculateMetadata = async ({ props }: { props: Props }) => {
31+
const response = await fetch(
32+
`https://api.example.com/duration?text=${encodeURIComponent(props.text)}`,
33+
);
34+
const { durationInFrames } = await response.json();
35+
return {
36+
durationInFrames,
37+
fps: 30,
38+
};
39+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// T4 case 04 — Imports from a third-party React UI library.
2+
//
3+
// Should be detected by lint_source.py as blocker r2hf/third-party-react-ui.
4+
// The skill should refuse to translate.
5+
//
6+
// Why this is a blocker: a Material-UI Button (or any React UI library
7+
// component) is a React-only abstraction with internal hooks, refs, and
8+
// theme provider context. Translating it to HTML+CSS would require
9+
// re-implementing the design system, which is out of scope for a video
10+
// translation skill. Use the runtime interop pattern from PR #214 to keep
11+
// these components rendering through Remotion's React tree.
12+
13+
import React from "react";
14+
import { Button } from "@mui/material";
15+
import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
16+
17+
export const MuiDriven: React.FC = () => {
18+
const frame = useCurrentFrame();
19+
const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" });
20+
21+
return (
22+
<AbsoluteFill style={{ alignItems: "center", justifyContent: "center" }}>
23+
<div style={{ opacity }}>
24+
<Button variant="contained" color="primary">
25+
Click me · frame {frame}
26+
</Button>
27+
</div>
28+
</AbsoluteFill>
29+
);
30+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// T4 case 05 — Imports @remotion/lambda for distributed rendering config.
2+
//
3+
// Should be detected by lint_source.py as blocker r2hf/lambda-import.
4+
// The skill should drop the Lambda code with a note (HF runs single-machine
5+
// today) and translate the rest of the composition only if no other blockers
6+
// are present.
7+
//
8+
// Why this is a blocker: @remotion/lambda is Remotion's AWS-Lambda-based
9+
// distributed renderer. HF doesn't have an equivalent — render is
10+
// single-machine. The skill cannot translate this configuration.
11+
12+
import React from "react";
13+
import { renderMediaOnLambda } from "@remotion/lambda";
14+
import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
15+
16+
export const LambdaConfigured: React.FC = () => {
17+
const frame = useCurrentFrame();
18+
const opacity = interpolate(frame, [0, 30], [0, 1]);
19+
return (
20+
<AbsoluteFill style={{ opacity }}>
21+
<div>frame {frame}</div>
22+
</AbsoluteFill>
23+
);
24+
};
25+
26+
// Rendered at scale via Lambda — no HF equivalent.
27+
export async function renderViaLambda() {
28+
return renderMediaOnLambda({
29+
region: "us-east-1",
30+
functionName: "remotion-render",
31+
composition: "LambdaConfigured",
32+
serveUrl: "https://example.com/bundle",
33+
inputProps: {},
34+
codec: "h264",
35+
});
36+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// T4 case 06 — Patterns that warn but don't block.
2+
//
3+
// Should be detected by lint_source.py with:
4+
// - r2hf/delay-render (warning) — drop the call; HF handles asset readiness
5+
// - r2hf/use-callback (warning) — decorative, drop the wrapper
6+
// - r2hf/use-memo (warning) — decorative, drop the wrapper
7+
//
8+
// 0 blockers expected — the skill should still translate this composition
9+
// after dropping the wrappers. delayRender is paired with continueRender via
10+
// an empty-deps useEffect (mount-once side effect), which doesn't trip the
11+
// use-effect-deps blocker.
12+
13+
import React, { useCallback, useMemo } from "react";
14+
import { AbsoluteFill, delayRender, continueRender, useCurrentFrame, interpolate } from "remotion";
15+
16+
const handle = delayRender();
17+
// Resolve the handle once at module load — no per-frame side effects.
18+
queueMicrotask(() => continueRender(handle));
19+
20+
export const WarningsOnly: React.FC = () => {
21+
const frame = useCurrentFrame();
22+
23+
// useCallback / useMemo — decorative for render-perf in React, no equivalent
24+
// needed in the seek-driven HF model.
25+
const opacity = useMemo(
26+
() => interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" }),
27+
[frame],
28+
);
29+
const onMount = useCallback(() => {}, []);
30+
31+
return (
32+
<AbsoluteFill style={{ opacity }} onClick={onMount}>
33+
<div>frame {frame}</div>
34+
</AbsoluteFill>
35+
);
36+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// T4 case 07 — Locally-defined custom hook.
2+
//
3+
// Should be detected by lint_source.py as warning r2hf/custom-hook.
4+
// 0 blockers expected — the skill can attempt translation if the hook body
5+
// is pure (derives from props/frame alone).
6+
//
7+
// Why this is a warning: custom hooks vary widely in what they do. Some are
8+
// pure derivations of useCurrentFrame (translatable — inline the body); some
9+
// wrap useState/useEffect (blocker — but those will be caught by the other
10+
// rules independently). The warning prompts the agent to inspect the body.
11+
12+
import React from "react";
13+
import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
14+
15+
// Custom hook — pure derivation from frame, no state. Translates fine.
16+
function useFadeIn(durationInFrames: number) {
17+
const frame = useCurrentFrame();
18+
return interpolate(frame, [0, durationInFrames], [0, 1], { extrapolateRight: "clamp" });
19+
}
20+
21+
export const CustomHookDriven: React.FC = () => {
22+
const opacity = useFadeIn(30);
23+
return (
24+
<AbsoluteFill style={{ opacity }}>
25+
<div>fading in</div>
26+
</AbsoluteFill>
27+
);
28+
};
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// T4 case 08 — Multiple blockers + multiple warnings in one file.
2+
//
3+
// Should report:
4+
// blockers: r2hf/use-state, r2hf/use-effect-deps, r2hf/third-party-react-ui
5+
// warnings: r2hf/use-callback (also r2hf/delay-render via the import chain
6+
// would only fire if delayRender is actually called)
7+
//
8+
// Tests that the linter aggregates findings correctly and does not stop at
9+
// the first blocker.
10+
11+
import React, { useState, useEffect, useCallback } from "react";
12+
import { AbsoluteFill, useCurrentFrame } from "remotion";
13+
import { Card } from "@chakra-ui/react";
14+
15+
interface Item {
16+
id: string;
17+
label: string;
18+
}
19+
20+
export const MixedBlockers: React.FC = () => {
21+
const frame = useCurrentFrame();
22+
const [items, setItems] = useState<Item[]>([]);
23+
24+
useEffect(() => {
25+
fetch("/api/items")
26+
.then((r) => r.json())
27+
.then(setItems);
28+
}, [frame]);
29+
30+
const onClick = useCallback(() => {
31+
setItems((prev) => [...prev, { id: String(prev.length), label: "new" }]);
32+
}, []);
33+
34+
return (
35+
<AbsoluteFill onClick={onClick}>
36+
{items.map((item) => (
37+
<Card key={item.id}>{item.label}</Card>
38+
))}
39+
</AbsoluteFill>
40+
);
41+
};

0 commit comments

Comments
 (0)