fix(skills): teach and ship the remotion translation as sub-compositions (#4141)

* fix(skills): teach and ship the remotion translation as sub-compositions

The converter reference fixtures now emit one sub-composition per scene
under a mounting root, and the api map, sequencing guide and tier-2 README
teach that shape instead of the flat data-start div. SSIM per tier is
unchanged (t1 0.974415, t2 0.985993, t3 0.953275 to 0.953281).

* docs(skills): make every remotion translation doc teach the sub-composition shape

SKILL.md step 3, the sequencing guide (nested, series, crossfade), the api map
asset rows, and the parameters guide now match the shipped fixtures.

* docs(skills): say where scene cuts versus cross-scene fades live
This commit is contained in:
Miguel Ángel
2026-09-18 21:01:59 -04:00
committed by GitHub
parent 4bbcf650ae
commit ae0e8fcae1
19 changed files with 516 additions and 373 deletions
+2 -2
View File
@@ -70,8 +70,8 @@
"files": 30
},
"remotion-to-hyperframes": {
"hash": "13ade041e6416e5b",
"files": 70
"hash": "65222f2d894e8776",
"files": 77
},
"slideshow": {
"hash": "71174bff2d869f18",
+3 -4
View File
@@ -74,10 +74,9 @@ Don't load all of them — load only what the specific source needs.
Emit `index.html` with:
- Root `<div id="stage">` carrying the composition's `data-composition-id`, `data-start="0"`, `data-duration` (in seconds), `data-fps`, `data-width`, `data-height`, plus one `data-*` per scalar prop.
- A flat list of scene divs with `data-start` / `data-duration` / `data-track-index`.
- Inline `<style>` for layout; CSS sets the `from` state of every animated property.
- A single `<script>` tag at the bottom containing one paused `gsap.timeline({paused: true})`. Every Remotion `useCurrentFrame()` derivation becomes a tween on this timeline at the right offset.
- `window.__timelines["<composition-id>"] = tl;` registers the timeline with HF's runtime.
- One host `<div>` per scene with `data-composition-src="compositions/<scene>.html"` and `data-start` / `data-duration` / `data-track-index`. The root holds no nested layout.
- One `compositions/<scene>.html` per scene (a `<template>` sub-composition): its inline `<style>` for layout (CSS sets the `from` state of every animated property), its markup, and one paused `gsap.timeline({paused: true})` in the scene's local time. Every Remotion `useCurrentFrame()` derivation becomes a tween on that timeline at the offset within the scene.
- `window.__timelines["<scene-id>"] = tl;` in each scene file, and `window.__timelines["<composition-id>"]` for the root's own (possibly empty) timeline.
Custom React subcomponents inline as repeated HTML using the prop interface as the template (see [`parameters.md`](references/parameters.md) for the per-instance `data-*` pattern).
@@ -10,11 +10,11 @@
"ssim_threshold": 0.95,
"validation": {
"measured_mean_ssim": 0.974,
"measured_min_ssim": 0.972,
"measured_p05_ssim": 0.972,
"measured_p95_ssim": 0.983,
"measured_at": "2026-04-27",
"measured_against": "remotion@4.0 (PNG output, BT.709) vs hyperframes@0.4.15-alpha.1"
"measured_min_ssim": 0.973,
"measured_p05_ssim": 0.973,
"measured_p95_ssim": 0.984,
"measured_at": "2026-09-18",
"measured_against": "remotion@4.0 (PNG output, BT.709) vs hyperframes@0.7.71 (sub-composition fixtures)"
},
"translation_notes": [
"Remotion: AbsoluteFill → HF: position:absolute;inset:0 div",
@@ -0,0 +1,31 @@
<template id="title-scene-template">
<div data-composition-id="title-scene" data-width="1280" data-height="720" data-duration="3">
<style>
.title {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 160px;
font-weight: 800;
letter-spacing: 0.05em;
opacity: 0;
}
</style>
<div class="title">HELLO</div>
<script>
// Translation of Remotion's
// interpolate(frame, [0, 15, 75, 90], [0, 1, 1, 0]) at fps=30
// into a paused GSAP timeline keyed in seconds.
const tl = gsap.timeline({ paused: true });
const target = document.querySelector(".title");
tl.to(target, { opacity: 1, duration: 0.5, ease: "none" }, 0);
tl.to(target, { opacity: 1, duration: 2.0, ease: "none" }, 0.5);
tl.to(target, { opacity: 0, duration: 0.5, ease: "none" }, 2.5);
window.__timelines = window.__timelines || {};
window.__timelines["title-scene"] = tl;
</script>
</div>
</template>
@@ -15,18 +15,6 @@
background: #0a0a0a;
font-family: Helvetica, Arial, sans-serif;
}
.title {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 160px;
font-weight: 800;
letter-spacing: 0.05em;
opacity: 0;
}
</style>
</head>
<body>
@@ -39,22 +27,19 @@
data-duration="3"
data-fps="30"
>
<div id="title" class="clip" data-start="0" data-duration="3" data-track-index="0">
<div class="title">HELLO</div>
</div>
<div
id="title"
class="clip"
data-composition-id="title-scene"
data-composition-src="compositions/title.html"
data-start="0"
data-duration="3"
data-track-index="0"
></div>
<script>
// Translation of Remotion's
// interpolate(frame, [0, 15, 75, 90], [0, 1, 1, 0]) at fps=30
// into a paused GSAP timeline keyed in seconds.
// Frame ranges → time ranges: 0/30=0, 15/30=0.5, 75/30=2.5, 90/30=3.0
const tl = gsap.timeline({ paused: true });
const target = document.querySelector("#title .title");
tl.to(target, { opacity: 1, duration: 0.5, ease: "none" }, 0);
tl.to(target, { opacity: 1, duration: 2.0, ease: "none" }, 0.5);
tl.to(target, { opacity: 0, duration: 0.5, ease: "none" }, 2.5);
window.__timelines = window.__timelines || {};
window.__timelines["tier-1-title-card"] = tl;
window.__timelines["tier-1-title-card"] = gsap.timeline({ paused: true });
</script>
</div>
</body>
@@ -18,16 +18,16 @@ If a translation passes T2, the skill correctly handles `<Sequence>` boundaries,
## Translation walk-through
| Remotion | HyperFrames |
| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `<Sequence from={0} durationInFrames={60}>` | `<div data-start="0" data-duration="2" data-track-index="0">` |
| `spring({frame, fps, config: {damping:12, stiffness:100, mass:1}})` | `gsap.to(target, { scale: 1, duration: 0.7, ease: "back.out(1.4)" })` |
| `<Audio src={staticFile("music.wav")} volume={0.5} />` | `<audio src="assets/music.wav" data-start="0" data-duration="6" data-volume="0.5" data-track-index="1">` |
| `<Img src={staticFile("square.png")} />` | `<img src="assets/square.png">` (with setup.sh copying into both trees) |
| `interpolate(frame, [0, 15], [0, 1])` at 30 fps | `gsap.to(target, { opacity: 1, duration: 0.5, ease: "none" })` |
| Remotion | HyperFrames |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `<Sequence from={0} durationInFrames={60}>` | host `<div data-composition-src="compositions/scene-1.html" data-start="0" data-duration="2" data-track-index="0">` |
| `spring({frame, fps, config: {damping:12, stiffness:100, mass:1}})` | `gsap.to(target, { scale: 1, duration: 0.7, ease: "back.out(1.4)" })` |
| `<Audio src={staticFile("music.wav")} volume={0.5} />` | `<audio src="assets/music.wav" data-start="0" data-duration="6" data-volume="0.5" data-track-index="1">` |
| `<Img src={staticFile("square.png")} />` | `<img src="../assets/square.png">` inside `compositions/scene-2.html` (with setup.sh copying into both trees) |
| `interpolate(frame, [0, 15], [0, 1])` at 30 fps | `gsap.to(target, { opacity: 1, duration: 0.5, ease: "none" })` |
The scene crossfading is a HyperFrames idiom, not a Remotion one: at scene boundaries
we `gsap.set(scene, { opacity: 0 })` so the previous scene disappears at the
each scene sub-composition sets its own `gsap.set(scene, { opacity: 0 })` at its local end so the scene disappears at the
right time. Remotion does this implicitly by virtue of `<Sequence>`'s durationInFrames.
## How to render and evaluate
@@ -9,19 +9,19 @@
"height": 720,
"ssim_threshold": 0.95,
"validation": {
"measured_mean_ssim": 0.985,
"measured_min_ssim": 0.963,
"measured_p05_ssim": 0.966,
"measured_p95_ssim": 0.999,
"measured_at": "2026-04-27",
"measured_against": "remotion@4.0 (PNG output, BT.709) vs hyperframes@0.4.15-alpha.1",
"notes": "Spring → back.out(1.4) translation came out cleaner than expected; mean 0.985 leaves substantial headroom over 0.95."
"measured_mean_ssim": 0.986,
"measured_min_ssim": 0.964,
"measured_p05_ssim": 0.967,
"measured_p95_ssim": 1,
"measured_at": "2026-09-18",
"measured_against": "remotion@4.0 (PNG output, BT.709) vs hyperframes@0.7.71 (sub-composition fixtures)",
"notes": "Spring → back.out(1.4) translation came out cleaner than expected; mean 0.986 leaves substantial headroom over 0.95."
},
"translation_notes": [
"spring({damping:12, stiffness:100, mass:1}) → back.out(1.4) over 0.7s. Spring overshoot+settle and back.out's overshoot+settle have similar shape; budget ~0.02 SSIM for the late-tail curvature mismatch (validated lower than predicted in spec; original notes overestimated drift).",
"<Sequence from durationInFrames> → wrapping div with data-start/data-duration in seconds and explicit gsap.set(opacity, 0/1) at scene boundaries to crossfade in/out cleanly",
"<Sequence from durationInFrames> → a sub-composition host with data-start/data-duration in seconds; the scene's own file uses local time and explicit gsap.set(opacity, 0/1) at its boundaries to crossfade in/out cleanly",
"<Audio src volume> → <audio data-start data-duration data-volume>",
"<Img src={staticFile('x')}> → <img src='assets/x'>; setup.sh copies the asset into both fixture trees",
"<Img src={staticFile('x')}> → <img src='assets/x'> (`../assets/x` inside the scene sub-composition file); setup.sh copies the asset into both fixture trees",
"interpolate with default linear easing → ease:'none' in GSAP",
"Fonts again rely on system Helvetica/Arial; ~0.015 SSIM cost from AA differences"
],
@@ -0,0 +1,35 @@
<template id="scene-1-template">
<div data-composition-id="scene-1" data-width="1280" data-height="720" data-duration="2">
<style>
.scene {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.title {
font-size: 140px;
font-weight: 800;
color: #ffffff;
letter-spacing: 0.05em;
transform: scale(0);
}
</style>
<div class="scene">
<div class="title">Welcome</div>
</div>
<script>
// Scene 1 (0-2s): spring scale on "Welcome". Remotion:
// spring({frame, fps, config: {damping:12, stiffness:100, mass:1}}) -> back.out(1.4) over ~0.7s.
const tl = gsap.timeline({ paused: true });
const scene = document.querySelector(".scene");
tl.set(scene, { opacity: 1 }, 0);
tl.to(scene.querySelector(".title"), { scale: 1, duration: 0.7, ease: "back.out(1.4)" }, 0);
tl.set(scene, { opacity: 0 }, 2);
window.__timelines = window.__timelines || {};
window.__timelines["scene-1"] = tl;
</script>
</div>
</template>
@@ -0,0 +1,35 @@
<template id="scene-2-template">
<div data-composition-id="scene-2" data-width="1280" data-height="720" data-duration="2">
<style>
.scene {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.square {
width: 200px;
height: 200px;
opacity: 0;
transform: scale(0.8);
}
</style>
<div class="scene">
<img class="square" src="../assets/square.png" alt="" />
</div>
<script>
// Scene 2 (2-4s, local 0-2s): opacity 0->1 over 0.5s, scale 0.8->1.0 over 2s, both linear.
const tl = gsap.timeline({ paused: true });
const scene = document.querySelector(".scene");
const square = scene.querySelector(".square");
tl.set(scene, { opacity: 1 }, 0);
tl.to(square, { opacity: 1, duration: 0.5, ease: "none" }, 0);
tl.to(square, { scale: 1.0, duration: 2.0, ease: "none" }, 0);
tl.set(scene, { opacity: 0 }, 2);
window.__timelines = window.__timelines || {};
window.__timelines["scene-2"] = tl;
</script>
</div>
</template>
@@ -0,0 +1,36 @@
<template id="scene-3-template">
<div data-composition-id="scene-3" data-width="1280" data-height="720" data-duration="2">
<style>
.scene {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.outro {
font-size: 100px;
font-weight: 600;
color: #ffffff;
}
</style>
<div class="scene">
<div class="outro">Goodbye</div>
</div>
<script>
// Scene 3 (4-6s, local 0-2s): opacity 0->1 over 1s, linear.
const tl = gsap.timeline({ paused: true });
const scene = document.querySelector(".scene");
tl.set(scene, { opacity: 1 }, 0);
tl.fromTo(
scene.querySelector(".outro"),
{ opacity: 0 },
{ opacity: 1, duration: 1.0, ease: "none" },
0,
);
window.__timelines = window.__timelines || {};
window.__timelines["scene-3"] = tl;
</script>
</div>
</template>
@@ -15,32 +15,6 @@
background: #0a0a0a;
font-family: Helvetica, Arial, sans-serif;
}
.scene {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.title {
font-size: 140px;
font-weight: 800;
color: #ffffff;
letter-spacing: 0.05em;
transform: scale(0);
}
.square {
width: 200px;
height: 200px;
opacity: 0;
transform: scale(0.8);
}
.outro {
font-size: 100px;
font-weight: 600;
color: #ffffff;
}
</style>
</head>
<body>
@@ -53,17 +27,35 @@
data-duration="6"
data-fps="30"
>
<div id="scene-1" class="scene clip" data-start="0" data-duration="2" data-track-index="0">
<div class="title">Welcome</div>
</div>
<div
id="scene-1"
class="clip"
data-composition-id="scene-1"
data-composition-src="compositions/scene-1.html"
data-start="0"
data-duration="2"
data-track-index="0"
></div>
<div id="scene-2" class="scene clip" data-start="2" data-duration="2" data-track-index="0">
<img class="square" src="assets/square.png" alt="" />
</div>
<div
id="scene-2"
class="clip"
data-composition-id="scene-2"
data-composition-src="compositions/scene-2.html"
data-start="2"
data-duration="2"
data-track-index="0"
></div>
<div id="scene-3" class="scene clip" data-start="4" data-duration="2" data-track-index="0">
<div class="outro">Goodbye</div>
</div>
<div
id="scene-3"
class="clip"
data-composition-id="scene-3"
data-composition-src="compositions/scene-3.html"
data-start="4"
data-duration="2"
data-track-index="0"
></div>
<audio
id="bg-music"
@@ -75,43 +67,8 @@
></audio>
<script>
// Translation of Remotion's 3-Sequence MultiScene at fps=30, total 6s.
//
// Scene 1 (0-2s): TitleScene — spring scale on "Welcome".
// Remotion: spring({frame, fps, config: {damping:12, stiffness:100, mass:1}})
// GSAP equivalent: back.out(1.4) over ~0.7s. Spring → ease translation
// is approximate; expect minor differences in the tail of the curve.
//
// Scene 2 (2-4s): ImageScene — opacity 0→1 over 0-0.5s + scale 0.8→1.0 over 0-2.0s.
// Both Remotion interpolates use linear (default), match with ease:"none".
//
// Scene 3 (4-6s): OutroScene — opacity 0→1 over 4-5s, linear.
const tl = gsap.timeline({ paused: true });
// Scene 1 setup: opacity flick on so scene becomes visible at start
const scene1 = document.querySelector("#scene-1");
const title = scene1.querySelector(".title");
tl.set(scene1, { opacity: 1 }, 0);
tl.to(title, { scale: 1, duration: 0.7, ease: "back.out(1.4)" }, 0);
tl.set(scene1, { opacity: 0 }, 2);
// Scene 2
const scene2 = document.querySelector("#scene-2");
const square = scene2.querySelector(".square");
tl.set(scene2, { opacity: 1 }, 2);
tl.to(square, { opacity: 1, duration: 0.5, ease: "none" }, 2);
tl.to(square, { scale: 1.0, duration: 2.0, ease: "none" }, 2);
tl.set(scene2, { opacity: 0 }, 4);
// Scene 3
const scene3 = document.querySelector("#scene-3");
const outro = scene3.querySelector(".outro");
tl.set(scene3, { opacity: 1 }, 4);
tl.fromTo(outro, { opacity: 0 }, { opacity: 1, duration: 1.0, ease: "none" }, 4);
window.__timelines = window.__timelines || {};
window.__timelines["tier-2-multi-scene"] = tl;
window.__timelines["tier-2-multi-scene"] = gsap.timeline({ paused: true });
</script>
</div>
</body>
@@ -12,9 +12,9 @@
"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",
"measured_p95_ssim": 0.978,
"measured_at": "2026-09-18",
"measured_against": "remotion@4.0 (PNG output, BT.709) vs hyperframes@0.7.71 (sub-composition fixtures)",
"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": [
@@ -0,0 +1,52 @@
<template id="scene-1-template">
<div data-composition-id="scene-1" data-width="1280" data-height="720" data-duration="3">
<style>
.scene {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.scene-1 {
flex-direction: column;
}
.scene-1 .title {
font-size: 160px;
font-weight: 900;
letter-spacing: 0.05em;
transform: scale(0);
}
.scene-1 .subtitle {
font-size: 36px;
font-weight: 400;
color: #9ca3af;
margin-top: 24px;
opacity: 0;
}
</style>
<div class="scene scene-1">
<div class="title">STARGAZED</div>
<div class="subtitle">by HeyGen</div>
</div>
<script>
// Remotion: <Sequence 0..90> TitleScene. Frame -> time at 30 fps.
const tl = gsap.timeline({ paused: true });
const scene = document.querySelector(".scene-1");
tl.set(scene, { opacity: 1 }, 0);
// spring({damping:12, stiffness:100, mass:1}) -> back.out(1.4) ~0.7s
tl.to(scene.querySelector(".title"), { scale: 1, duration: 0.7, ease: "back.out(1.4)" }, 0);
// interpolate(frame, [20,40], [0,1]) -> 0.667s..1.333s linear
tl.fromTo(
scene.querySelector(".subtitle"),
{ opacity: 0 },
{ opacity: 1, duration: 0.667, ease: "none" },
0.667,
);
tl.set(scene, { opacity: 0 }, 3);
window.__timelines = window.__timelines || {};
window.__timelines["scene-1"] = tl;
</script>
</div>
</template>
@@ -0,0 +1,99 @@
<template id="scene-2-template">
<div data-composition-id="scene-2" data-width="1280" data-height="720" data-duration="4">
<style>
.scene {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.scene-2 {
gap: 48px;
}
.stat-card {
width: 280px;
height: 220px;
background: #1a1a1a;
border-radius: 16px;
border: 2px solid var(--card-color);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
opacity: 0;
transform: scale(0);
}
.stat-card .number {
font-size: 72px;
font-weight: 800;
color: var(--card-color);
line-height: 1;
}
.stat-card .label {
font-size: 24px;
font-weight: 500;
color: #9ca3af;
margin-top: 16px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
</style>
<div class="scene scene-2">
<div
class="stat-card"
data-stat-index="0"
data-stat-value="1247"
style="--card-color: #fbbf24"
>
<div class="number">0</div>
<div class="label">Stars</div>
</div>
<div
class="stat-card"
data-stat-index="1"
data-stat-value="312"
style="--card-color: #60a5fa"
>
<div class="number">0</div>
<div class="label">Forks</div>
</div>
<div class="stat-card" data-stat-index="2" data-stat-value="48" style="--card-color: #f87171">
<div class="number">0</div>
<div class="label">Issues</div>
</div>
</div>
<script>
// Remotion: <Sequence 90..210> StatsScene; StatCard stagger i*12 frames = i*0.4s.
const tl = gsap.timeline({ paused: true });
const scene = document.querySelector(".scene-2");
tl.set(scene, { opacity: 1 }, 0);
scene.querySelectorAll(".stat-card").forEach((card, i) => {
const start = i * 0.4;
const value = Number(card.dataset.statValue);
const numberEl = card.querySelector(".number");
// spring({damping:14, stiffness:90, mass:1}) -> back.out(1.2) ~0.7s; opacity 0..0.4s linear
tl.to(card, { scale: 1, duration: 0.7, ease: "back.out(1.2)" }, start);
tl.to(card, { opacity: 1, duration: 0.4, ease: "none" }, start);
// AnimatedNumber: easeOutCubic over 45 frames (1.5s) is GSAP power3.out on a counter.
const counter = { v: 0 };
tl.to(
counter,
{
v: value,
duration: 1.5,
ease: "power3.out",
onUpdate: () => {
numberEl.textContent = Math.round(counter.v).toLocaleString();
},
},
start,
);
});
tl.set(scene, { opacity: 0 }, 4);
window.__timelines = window.__timelines || {};
window.__timelines["scene-2"] = tl;
</script>
</div>
</template>
@@ -0,0 +1,55 @@
<template id="scene-3-template">
<div data-composition-id="scene-3" data-width="1280" data-height="720" data-duration="3">
<style>
.scene {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.scene-3 .outro-wrap {
position: relative;
display: inline-block;
opacity: 0;
}
.scene-3 .outro-text {
font-size: 80px;
font-weight: 600;
}
.scene-3 .outro-underline {
position: absolute;
left: 0;
bottom: -8px;
width: 100%;
height: 6px;
background: #fbbf24;
border-radius: 3px;
transform: scaleX(0);
transform-origin: left center;
}
</style>
<div class="scene scene-3">
<div class="outro-wrap">
<div class="outro-text">thanks for watching</div>
<div class="outro-underline"></div>
</div>
</div>
<script>
// Remotion: <Sequence 210..300> OutroScene.
const tl = gsap.timeline({ paused: true });
const scene = document.querySelector(".scene-3");
tl.set(scene, { opacity: 1 }, 0);
tl.to(scene.querySelector(".outro-wrap"), { opacity: 1, duration: 0.4, ease: "none" }, 0);
// interpolate(frame, [10,40], [0,1]) for the underline -> starts 0.333s, 1s long
tl.to(
scene.querySelector(".outro-underline"),
{ scaleX: 1, duration: 1.0, ease: "none" },
0.333,
);
window.__timelines = window.__timelines || {};
window.__timelines["scene-3"] = tl;
</script>
</div>
</template>
@@ -16,86 +16,6 @@
font-family: Helvetica, Arial, sans-serif;
color: #ffffff;
}
.scene {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* Scene 1 — Title */
.scene-1 {
flex-direction: column;
}
.scene-1 .title {
font-size: 160px;
font-weight: 900;
letter-spacing: 0.05em;
transform: scale(0);
}
.scene-1 .subtitle {
font-size: 36px;
font-weight: 400;
color: #9ca3af;
margin-top: 24px;
opacity: 0;
}
/* Scene 2 — Stats */
.scene-2 {
gap: 48px;
}
.stat-card {
width: 280px;
height: 220px;
background: #1a1a1a;
border-radius: 16px;
border: 2px solid var(--card-color);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
opacity: 0;
transform: scale(0);
}
.stat-card .number {
font-size: 72px;
font-weight: 800;
color: var(--card-color);
line-height: 1;
}
.stat-card .label {
font-size: 24px;
font-weight: 500;
color: #9ca3af;
margin-top: 16px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
/* Scene 3 — Outro */
.scene-3 .outro-wrap {
position: relative;
display: inline-block;
opacity: 0;
}
.scene-3 .outro-text {
font-size: 80px;
font-weight: 600;
}
.scene-3 .outro-underline {
position: absolute;
left: 0;
bottom: -8px;
width: 100%;
height: 6px;
background: #fbbf24;
border-radius: 3px;
transform: scaleX(0);
transform-origin: left center;
}
</style>
</head>
<body>
@@ -111,158 +31,39 @@
data-subtitle="by HeyGen"
data-outro="thanks for watching"
>
<!-- Scene 1: Title -->
<div
id="scene-1"
class="scene scene-1 clip"
class="clip"
data-composition-id="scene-1"
data-composition-src="compositions/scene-1.html"
data-start="0"
data-duration="3"
data-track-index="0"
>
<div class="title">STARGAZED</div>
<div class="subtitle">by HeyGen</div>
</div>
></div>
<!-- Scene 2: Stats — three StatCards as repeated markup with per-instance data attrs -->
<div
id="scene-2"
class="scene scene-2 clip"
class="clip"
data-composition-id="scene-2"
data-composition-src="compositions/scene-2.html"
data-start="3"
data-duration="4"
data-track-index="0"
>
<div
class="stat-card"
data-stat-index="0"
data-stat-value="1247"
style="--card-color: #fbbf24"
>
<div class="number">0</div>
<div class="label">Stars</div>
</div>
<div
class="stat-card"
data-stat-index="1"
data-stat-value="312"
style="--card-color: #60a5fa"
>
<div class="number">0</div>
<div class="label">Forks</div>
</div>
<div
class="stat-card"
data-stat-index="2"
data-stat-value="48"
style="--card-color: #f87171"
>
<div class="number">0</div>
<div class="label">Issues</div>
</div>
</div>
></div>
<!-- Scene 3: Outro -->
<div
id="scene-3"
class="scene scene-3 clip"
class="clip"
data-composition-id="scene-3"
data-composition-src="compositions/scene-3.html"
data-start="7"
data-duration="3"
data-track-index="0"
>
<div class="outro-wrap">
<div class="outro-text">thanks for watching</div>
<div class="outro-underline"></div>
</div>
</div>
></div>
<script>
// ────────────────────────────────────────────────────────────────────
// Translation of Stargazed.tsx
//
// Remotion structure:
// <Composition schema={zod} defaultProps={...} fps=30 dur=300>
// <Sequence 0..90> <TitleScene title subtitle />
// <Sequence 90..210> <StatsScene stats />
// <Sequence 210..300><OutroScene text />
//
// HF structure:
// - root #stage carries data-* with the same props (title, subtitle, outro)
// - 3 scene divs with data-start/data-duration in seconds (frames/fps)
// - Custom React subcomponents inline as repeated HTML; their per-instance
// props become data-* attributes on the markup
// - Animation lives in a single paused GSAP timeline keyed by composition seconds
//
// Frame → time:
// Sequence(0,90) → 0s..3s (title)
// Sequence(90,210) → 3s..7s (stats; 4s window)
// Sequence(210,300) → 7s..10s (outro)
// Stagger(i*12) → i*0.4s (12 frames at 30fps)
// ────────────────────────────────────────────────────────────────────
const tl = gsap.timeline({ paused: true });
// ─── Scene 1: Title ─────────────────────────────────────────────────
const scene1 = document.querySelector("#scene-1");
const title = scene1.querySelector(".title");
const subtitle = scene1.querySelector(".subtitle");
tl.set(scene1, { opacity: 1 }, 0);
// spring({damping:12, stiffness:100, mass:1}) → back.out(1.4) ~0.7s
tl.to(title, { scale: 1, duration: 0.7, ease: "back.out(1.4)" }, 0);
// interpolate(frame, [20,40], [0,1]) at fps=30 → 0.667s..1.333s linear
tl.fromTo(subtitle, { opacity: 0 }, { opacity: 1, duration: 0.667, ease: "none" }, 0.667);
tl.set(scene1, { opacity: 0 }, 3);
// ─── Scene 2: Stats ─────────────────────────────────────────────────
const scene2 = document.querySelector("#scene-2");
const cards = scene2.querySelectorAll(".stat-card");
tl.set(scene2, { opacity: 1 }, 3);
cards.forEach((card, i) => {
const stagger = i * 0.4; // i * 12 frames at 30 fps
const start = 3 + stagger;
const value = Number(card.dataset.statValue);
const numberEl = card.querySelector(".number");
// StatCard entrance:
// spring({damping:14, stiffness:90, mass:1}) → back.out(1.2) ~0.7s
// interpolate(local, [0,12], [0,1]) → 0s..0.4s linear opacity
tl.to(card, { scale: 1, duration: 0.7, ease: "back.out(1.2)" }, start);
tl.to(card, { opacity: 1, duration: 0.4, ease: "none" }, start);
// AnimatedNumber: count from 0 → value with easeOutCubic over 45 frames (1.5s).
// GSAP equivalent: a tween on a counter object with onUpdate rewriting textContent.
// power3.out is GSAP's name for cubic easeOut.
const counter = { v: 0 };
tl.to(
counter,
{
v: value,
duration: 1.5,
ease: "power3.out",
onUpdate: () => {
numberEl.textContent = Math.round(counter.v).toLocaleString();
},
},
start,
);
});
tl.set(scene2, { opacity: 0 }, 7);
// ─── Scene 3: Outro ─────────────────────────────────────────────────
const scene3 = document.querySelector("#scene-3");
const outroWrap = scene3.querySelector(".outro-wrap");
const underline = scene3.querySelector(".outro-underline");
tl.set(scene3, { opacity: 1 }, 7);
// interpolate(frame, [0,12], [0,1]) → 0s..0.4s linear opacity
tl.to(outroWrap, { opacity: 1, duration: 0.4, ease: "none" }, 7);
// interpolate(frame, [10,40], [0,1]) for underline → starts at 7+0.333s, dur 1s
tl.to(underline, { scaleX: 1, duration: 1.0, ease: "none" }, 7.333);
window.__timelines = window.__timelines || {};
window.__timelines["tier-3-data-driven"] = tl;
window.__timelines["tier-3-data-driven"] = gsap.timeline({ paused: true });
</script>
</div>
</body>
@@ -27,12 +27,12 @@ details (timing, transitions, etc.).
See [sequencing.md](sequencing.md) for nesting and stagger details.
| Remotion | HyperFrames |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `<Sequence from={F} durationInFrames={D}>` | `<div data-start="<F/fps>" data-duration="<D/fps>" data-track-index="N">` |
| `<Series>` + `<Series.Sequence>` | siblings with sequential `data-start` values |
| `<Loop durationInFrames={D}>` | not a primitive — emit a bounded GSAP repeat from the available duration |
| `<Freeze frame={F}>` | drop the wrapper; HF doesn't have running animation outside the seek-driven timeline so freeze is a no-op |
| Remotion | HyperFrames |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `<Sequence from={F} durationInFrames={D}>` | a host `<div data-composition-src="compositions/<name>.html" data-start="<F/fps>" data-duration="<D/fps>" data-track-index="N">` in the root, its content in that sub-composition file (see [sequencing.md](sequencing.md)) |
| `<Series>` + `<Series.Sequence>` | sibling scene hosts with sequential `data-start` values |
| `<Loop durationInFrames={D}>` | not a primitive — emit a bounded GSAP repeat from the available duration |
| `<Freeze frame={F}>` | drop the wrapper; HF doesn't have running animation outside the seek-driven timeline so freeze is a no-op |
## Timing
@@ -53,16 +53,16 @@ See [timing.md](timing.md) — this is the highest-leverage section.
See [media.md](media.md) for trim, volume ramps, and decoder notes.
| Remotion | HyperFrames |
| -------------------------------------- | --------------------------------------------------------------------------- |
| `<Audio src volume>` | `<audio data-start data-duration data-track-index data-volume src>` |
| `<Audio playbackRate startFrom endAt>` | `data-playback-rate`, `data-trim-start`, `data-trim-end` |
| `<Video src>` | `<video muted playsinline data-start data-duration data-track-index src>` |
| `<OffthreadVideo>` | `<video>` — HF doesn't need the off-thread variant (uses headless Chrome) |
| `<Img src>` | `<img>` |
| `<IFrame src>` | `<iframe>` — HF auto-falls back to screenshot mode for nested iframes |
| `staticFile("x.png")` | `"assets/x.png"` — copy the file into `hf-src/assets/` next to `index.html` |
| `delayRender()` / `continueRender()` | drop — HF waits on asset readiness via the Frame Adapter pattern |
| Remotion | HyperFrames |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `<Audio src volume>` | `<audio data-start data-duration data-track-index data-volume src>` |
| `<Audio playbackRate startFrom endAt>` | `data-playback-rate`, `data-trim-start`, `data-trim-end` |
| `<Video src>` | `<video muted playsinline data-start data-duration data-track-index src>` |
| `<OffthreadVideo>` | `<video>` — HF doesn't need the off-thread variant (uses headless Chrome) |
| `<Img src>` | `<img>` |
| `<IFrame src>` | `<iframe>` — HF auto-falls back to screenshot mode for nested iframes |
| `staticFile("x.png")` | `"assets/x.png"` in the root, `"../assets/x.png"` inside a `compositions/` scene file — copy the file into `hf-src/assets/` next to `index.html` |
| `delayRender()` / `continueRender()` | drop — HF waits on asset readiness via the Frame Adapter pattern |
## Transitions
@@ -37,6 +37,11 @@ The `data-title` attribute carries the value through. Code that originally
read `props.title` reads `document.getElementById("stage").dataset.title`
in HF.
Inside a scene sub-composition file that lookup does not reach the root
`#stage`. Pass a prop to a scene with `data-variable-values` on its host and
read it there with `window.__hyperframes.getVariables()` (declare defaults with
`data-composition-variables` on the scene's composition element).
## Async calculateMetadata (NOT translatable)
```tsx
@@ -1,7 +1,7 @@
# Sequencing translation: Sequence, Series, Composition root
How Remotion's nested `Sequence` tree maps to HF's flat `data-start` /
`data-duration` markup with a single paused GSAP timeline.
How Remotion's nested `Sequence` tree maps to one sub-composition host per
scene in the root, each with its own paused GSAP timeline in local time.
## The core idea
@@ -11,8 +11,9 @@ component to the window `[F, F+D]`. HF doesn't have a per-element
"current frame" — there's a single composition seek time and the
runtime hides/shows elements based on their `data-start` / `data-duration`.
Result: the nested tree flattens into a list of siblings on the same
parent, each with their own time window.
Result: the nested tree flattens into a list of scene hosts on the same
parent, each with its own time window; each scene's content lives in its
own sub-composition file.
## `<Composition>` → root `#stage`
@@ -57,7 +58,7 @@ playback; missing it triggers a lint warning).
`AbsoluteFill` is just a styled div in Remotion. Translate to a div with
`position:absolute; inset:0` and copy through any other style props.
## `<Sequence>` → time-windowed div
## `<Sequence>` → sub-composition host
```tsx
<Sequence from={0} durationInFrames={90}>
@@ -65,15 +66,42 @@ playback; missing it triggers a lint warning).
</Sequence>
```
The root `#stage` only holds a host per scene; the scene's markup, styles
and timeline live in their own sub-composition file, so the Studio timeline
gets one readable row per scene and the lint has no nested structure to flag.
```html
<div data-start="0" data-duration="3" data-track-index="0">
<!-- TitleCard children inlined -->
</div>
<!-- index.html -->
<div
id="scene-1"
class="clip"
data-composition-id="scene-1"
data-composition-src="compositions/scene-1.html"
data-start="0"
data-duration="3"
data-track-index="0"
></div>
```
```html
<!-- compositions/scene-1.html: TitleCard children inlined, timeline keyed "scene-1", local time starts at 0 -->
<template id="scene-1-template">
<div data-composition-id="scene-1" data-width="1280" data-height="720" data-duration="3">
<!-- TitleCard children -->
<script>
const tl = gsap.timeline({ paused: true });
window.__timelines["scene-1"] = tl;
</script>
</div>
</template>
```
Convert frames to seconds: `from/fps`, `durationInFrames/fps`. Pick a
`data-track-index` per parallel rendering layer (background = 0,
overlays = 1, audio = 2, etc.). Sequential scenes can share an index.
Inside the sub-composition, time is local: a tween that started at `F/fps`
in the root starts at `0` here. Asset paths are relative to the
sub-composition file (`../assets/x.png`).
## Nested `<Sequence>` flattens
@@ -89,13 +117,17 @@ Remotion adds offsets when sequences nest:
The inner sequence's effective window is `[60+30, 60+30+60] = [90, 150]`.
Translate by computing the sum and emitting one HF div with the resolved
window:
Translate by computing the sum and emitting one host with the resolved
window; the scene's children go in its sub-composition file:
```html
<div data-start="3" data-duration="2" data-track-index="0">
<!-- ImageScene children -->
</div>
<div
data-composition-id="image-scene"
data-composition-src="compositions/image-scene.html"
data-start="3"
data-duration="2"
data-track-index="0"
></div>
```
## `<Series>` → siblings with sequential offsets
@@ -115,19 +147,40 @@ window:
```
Each `Sequence.Sequence` lives in the next time slot. Emit siblings
with `data-start` accumulating:
with `data-start` accumulating (each host mounts its scene file):
```html
<div data-start="0" data-duration="2" data-track-index="0">A</div>
<div data-start="2" data-duration="4" data-track-index="0">B</div>
<div data-start="6" data-duration="3" data-track-index="0">C</div>
<div
data-composition-id="a"
data-composition-src="compositions/a.html"
data-start="0"
data-duration="2"
data-track-index="0"
></div>
<div
data-composition-id="b"
data-composition-src="compositions/b.html"
data-start="2"
data-duration="4"
data-track-index="0"
></div>
<div
data-composition-id="c"
data-composition-src="compositions/c.html"
data-start="6"
data-duration="3"
data-track-index="0"
></div>
```
## Crossfading scene boundaries
Remotion `<Sequence>` shows/hides at hard boundaries by default. HF does
the same — but if your composition needs a smooth fade between scenes,
you have to drive opacity explicitly with GSAP at the boundary:
you have to drive opacity explicitly with GSAP at the boundary. A scene can
hard-cut itself with `gsap.set` at its local end (the corpus fixtures do). A
fade between two scenes goes on the scene hosts from the root timeline, since
a scene's own timeline only sees its own file:
```js
const tl = gsap.timeline({ paused: true });