Every SKILL.md already declared the new name — name: frames, frames-cli, frames-core, and so on — but the directories were never moved, so the tree carried frames-* in content and hyperframes-* on disk. Everything that resolves a skill by path missed: the core-skill list, the plugin marketplace pin, and the docs the CLI reads at runtime. Eight tests failed on main because the files they open do not exist. The directories now match what the files say they are, and the guide the claude-design contract reads takes the name that contract already used.
6.1 KiB
Timing translation: interpolate, spring, easing
The single highest-leverage reference. Easings and timings are what readers notice; getting them wrong costs more SSIM than any other translation choice. Empirically validated against tiers T1–T3.
Conversion: frames → seconds
HF's timeline is in seconds. Remotion is frame-based. Always:
time_seconds = frame / fps
So at fps=30:
- frame 15 → 0.5 s
- frame 30 → 1.0 s
- frame 90 → 3.0 s
Do this conversion once when translating, not at runtime.
interpolate — linear
const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" });
Translates to:
gsap.to(target, { opacity: 1, duration: 1.0, ease: "none" }, 0);
// fromTo if the property starts at 0 and CSS doesn't already set it
gsap.fromTo(target, { opacity: 0 }, { opacity: 1, duration: 1.0, ease: "none" }, 0);
ease: "none" matches Remotion's default linear interpolation. CSS sets the
from value if your initial state is in CSS; otherwise use fromTo.
extrapolateLeft/extrapolateRight defaults to "extend" in Remotion but
"clamp" is what the agent will see most often. GSAP doesn't extend — values
hold at the start and end of the tween. So for clamp, GSAP matches; for
extend, you'd need to extend the input range manually before emitting.
interpolate — multi-segment
const opacity = interpolate(frame, [0, 15, 75, 90], [0, 1, 1, 0]);
Three keyframed tweens at offsets [0]/fps, [1]/fps, [2]/fps:
const tl = gsap.timeline({ paused: true });
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);
Validated in T1 — mean SSIM 0.974 against Remotion baseline.
spring → GSAP back.out
Remotion's spring() is the most lossy translation. The mapping is approximate
but close enough that real-world compositions hold ≥ 0.92 SSIM (T2: 0.985, T3: 0.953).
Remotion spring config |
GSAP equivalent | Validated in |
|---|---|---|
{damping: 12, stiffness: 100, mass: 1} (snappy) |
back.out(1.4) over ~0.7 s |
T2, T3 (TitleScene) |
{damping: 14, stiffness: 90, mass: 1} (calmer) |
back.out(1.2) over ~0.7 s |
T3 (StatCard) |
{damping: 8, stiffness: 200} (very bouncy) |
back.out(2.0) or elastic.out(1, 0.5) over ~0.6 s |
not validated; budget ~0.05 SSIM |
{overshootClamping: true} |
power3.out over ~0.6 s (no overshoot) |
not validated |
Rule of thumb: back.out(N) overshoot ratio ≈ (stiffness / damping^2) * 1.4. For
damping:12, stiffness:100 that gives 1.4 * 100/144 = 0.97, which is close to
the validated 1.4 (the formula is rough; tune by visual). Default duration is
~0.7 s for the typical config.
When the spring's delay/from/to are non-default, scale the duration
proportionally.
interpolate with custom easing
import { Easing } from "remotion";
interpolate(frame, [0, 30], [0, 1], { easing: Easing.out(Easing.cubic) });
| Remotion | GSAP |
|---|---|
Easing.in(Easing.linear) |
ease: "none" |
Easing.out(Easing.cubic) |
ease: "power3.out" |
Easing.inOut(Easing.cubic) |
ease: "power3.inOut" |
Easing.out(Easing.poly(N)) |
ease: "power<N>.out" (N=2 quad, 3 cubic, 4 quart, 5 quint) |
Easing.bezier(a,b,c,d) |
CustomEase.create("c", "M0,0 C${a},${b} ${c},${d} 1,1") (requires CustomEase plugin) |
Easing.elastic(bounciness) |
ease: "elastic.out(${bounciness}, 0.3)" |
Easing.bounce |
ease: "bounce.out" |
Easing.back(overshoot) |
ease: "back.out(${overshoot * 1.7})" (Remotion's overshoot scale differs) |
interpolate driving non-numeric properties
const color = interpolateColors(frame, [0, 30], ["#ff0000", "#0000ff"]);
GSAP does color tweens natively:
gsap.to(target, { color: "#0000ff", duration: 1.0, ease: "none" }, 0);
Same for backgroundColor, borderColor. The from value is read from CSS
or the inline style.
Custom count-up / number tweens
When Remotion uses a frame-driven number ramp (Math.round(value * eased)):
const t = interpolate(frame, [0, 45], [0, 1]);
const eased = 1 - (1 - t) ** 3; // cubic ease-out
const value = Math.round(target * eased);
return <div>{value.toLocaleString()}</div>;
GSAP equivalent — tween a counter object, write textContent on update:
const counter = { v: 0 };
tl.to(
counter,
{
v: target,
duration: 1.5,
ease: "power3.out",
onUpdate: () => {
el.textContent = Math.round(counter.v).toLocaleString();
},
},
0,
);
power3.out matches 1 - (1-t)^3 exactly. Validated in T3 (mean SSIM 0.953).
Per-frame digit mismatches occur on sub-frame timing offsets but final values
converge — no SSIM impact above the noise floor.
Stagger via per-instance prop
When custom subcomponents take a delayInFrames prop:
<StatCard delayInFrames={i * 12} value={...} />
Translate to GSAP timeline offsets:
cards.forEach((card, i) => {
const start = base + i * (12 / fps); // i * 0.4s at fps=30
tl.to(card, { ... }, start);
});
Validated in T3 — three StatCards staggered at 0.0/0.4/0.8 s.