Files
zooqueen 62bbc7766c skills: give each skill the directory its name declares
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.
2026-08-13 18:17:24 -07:00

6.1 KiB
Raw Permalink Blame History

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.