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