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 `` 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 | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `` | 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 | +| `` (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 @@ + + + + + tier-3-data-driven + + + + +
+ +
+
STARGAZED
+
by HeyGen
+
+ + +
+
+
0
+
Stars
+
+
+
0
+
Forks
+
+
+
0
+
Issues
+
+
+ + +
+
+
thanks for watching
+
+
+
+ + +
+ + 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 = { + 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 = () => ( + +); 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> = ({ + title, + subtitle, + stats, + outro, +}) => ( + + + + + + + + + + + +); 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 = ({ 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 = ({ 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 ( +
+
+ {local >= 0 ? : 0} +
+
+ {label} +
+
+ ); +}; 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 = ({ 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 ( +
+
+ {text} +
+
+
+ ); +}; 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 = ({ text }) => ( + + + +); 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 = ({ stats }) => ( + + {stats.map((stat, i) => ( + + ))} + +); 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 = ({ 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 ( + +
+ {title} +
+
+ {subtitle} +
+
+ ); +}; 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"] +}