Files
hyperframes/.claude/skills/cut-the-curve/examples/gsap-implementation.md
T
James RussoandJake Moran e96ebd74de feat(skills): add changelog-video skill for repo-native CC + Codex discovery (#2552)
Packages Jake Moran's changelog-video pipeline (v1, validated end-to-end
by Home on the Jun 23-29 range) as a repo-native skill set that Claude
Code (.claude/skills/) and Codex CLI (.agents/skills/) auto-discover the
moment the repo is opened. No install step; run the skill against a
changelog markdown for a given git range and it produces a lint-clean,
seam-gate-green 1080x1080 MP4 (~45-60s, Annie VO, mock-UI visualizations,
caption rail) end-to-end.

Six skills added byte-identical in both mirror dirs:
- changelog-video (pipeline entry point)
- motion-doctrine (carries seam-stamp.mjs + seam-gate.mjs)
- cut-the-curve, captions-overlay, seam-craft, oversized-cursor

Layout:
- .claude/skills/  - Claude Code project-local auto-discover
- .agents/skills/  - Codex CLI project-local auto-discover (verified via
                     Magi's clean-home Codex 0.144.3 repro; NOT .codex/skills/)

Fonts, animated background (12 MB), house BGM (5 MB), lexicon, and
align-captions ship inside the skill dirs. .gitattributes routes only
.claude/skills/**/*.{mp4,mp3} + .agents/skills/**/*.{mp4,mp3} through
LFS — narrowly scoped so unrelated Player, Studio, registry, and
marketplace media stay put. HeyGen CLI auth is the one credential the
skill needs; Node >= 22, ffmpeg, and headless Chrome are documented
alongside in both READMEs.

.gitignore: rewrites .claude/ and .agents/ blocks to keep agent-installed
skill hygiene while re-including the six repo-native skill dirs plus
README.md.

CI:
- Extends changes.skills filter to match .claude/skills/**,
  .agents/skills/**, scripts/lint-skills.ts, and scripts/check-skill-mirror.mjs.
- New 'Skills: project-native lint + mirror' job runs the extended
  lint-skills.ts (schema-driven; required { name, description } + optional
  { license, allowed-tools, metadata }, name pattern check, description
  length check) plus a new check-skill-mirror.mjs byte-integrity script
  (24 mirrored files must match; README.md deliberately per-CLI).
- Wired into 'bun run lint' locally.

Frontmatter validator:
- Rejects unsupported top-level keys (catches category:-style drift).
- Requires name + description.
- Validates name pattern (^[a-z][a-z0-9-]{0,63}$) and description shape
  (non-empty, <=1024 chars).
- Missing frontmatter block itself is a first-class error.

Also strips unsupported top-level 'category:' frontmatter from Jake's
motion-doctrine and cut-the-curve SKILL.mds (both mirrors), rewrites the
TTS invocation from ~/.claude/skills/media-use/... to the tracked
skills/hyperframes-media/scripts/heygen-tts.mjs, swaps npx hyperframes@latest
for the repo-local CLI in the gate step, and fixes a lint issue in Jake's
seam-gate.mjs (ternary-for-side-effect -> if/else).

Validated end-to-end by Home on Jun 23-29 (MP4 posted in C0ACCNHLG3U
thread 1784181166.041319). Independently reviewed R1/R2/R3 by Magi.

Co-authored-by: Jake Moran <jake@heygen.com>
2026-07-16 17:29:19 -04:00

9.8 KiB
Raw Blame History

Cut the Curve — GSAP code templates

Canonical implementations for every variant. Parameters and rules live in ../SKILL.md; copy code from here. Worker-authored versions tween in-scene elements; registry gsap_templates are injector-stamped onto the two clip wrappers (__OLD__ / __NEW__ / __T__ / __DUR__ tokens — see seam-craft for the token table).

1. Zoom-Through (forward)

Worker version (within-scene wrapper swap)

var EXIT_START = /* when readable text starts leaving */;
var CUT = EXIT_START + 0.2;

// Phase 1: Exit — scale/blur accelerate, opacity fades linearly (separate tween)
tl.to(".text-a-wrapper", {
  scale: 1.2,
  filter: "blur(10px)",   // text-scale: 10px
  duration: 0.2,
  ease: "power3.in",
  overwrite: "auto"
}, EXIT_START);
tl.to(".text-a-wrapper", { opacity: 0.15, duration: 0.2, ease: "none" }, EXIT_START);

// Phase 2: Hard cut — matched properties
tl.set(".text-a-wrapper", { opacity: 0 }, CUT);
tl.set(".text-b-wrapper", { opacity: 0.15, scale: 0.75, filter: "blur(10px)" }, CUT);

// Phase 3: Entry — fast initial velocity, long settle
tl.to(".text-b-wrapper", {
  scale: 1, filter: "blur(0px)", opacity: 1,
  duration: 0.5, ease: "expo.out"
}, CUT);

Registry gsap_template

tl.to(
  __OLD__,
  { scale: 2.5, opacity: 0, filter: "blur(8px)", duration: __DUR__, ease: "power3.in" },
  __T__,
);
tl.fromTo(
  __NEW__,
  { scale: 0.5, opacity: 0, filter: "blur(8px)" },
  { scale: 1, opacity: 1, filter: "blur(0px)", duration: __DUR__, ease: "power3.out" },
  __T__,
);

2. Inverse Zoom-Through

Registry gsap_template

tl.set(__NEW__, { opacity: 0 }, __T__);
tl.to(
  __OLD__,
  { scale: 0.8, filter: "blur(10px)", duration: __DUR__ * 0.3, ease: "power3.in" },
  __T__,
);
tl.to(__OLD__, { opacity: 0.15, duration: __DUR__ * 0.3, ease: "none" }, __T__);
tl.set(__OLD__, { opacity: 0 }, __T__ + __DUR__ * 0.3);
tl.fromTo(
  __NEW__,
  { opacity: 0.15, scale: 1.25, filter: "blur(10px)" },
  {
    opacity: 1,
    scale: 1,
    filter: "blur(0px)",
    duration: __DUR__ * 0.7,
    ease: "expo.out",
    immediateRender: false,
  },
  __T__ + __DUR__ * 0.3,
);

Worker version: same phases as zoom-through with the scale values flipped (exit 1 → 0.8, cut-in at 1.25, entry 1.25 → 1).

3. Cut the Curve

Worker version (scene layers)

var CUT_TIME = /* scene transition point */;

// Scene A: hero accelerates leftward (partial travel ~12% of frame)
tl.to(".scene-a-layer", { opacity: 0, duration: 0.33, ease: "power2.in" }, CUT_TIME - 0.33);
tl.to(".hero-a-wrapper", {
  x: -230, filter: "blur(8px)",
  duration: 0.33, ease: "power4.in",   // mirrored half of power4.inOut
  overwrite: "auto"
}, CUT_TIME - 0.33);

// Hard cut
tl.set(".scene-b-layer", { opacity: 1 }, CUT_TIME);
tl.set(".hero-b-wrapper", { x: 230, filter: "blur(8px)" }, CUT_TIME);

// Scene B: hero decelerates leftward
tl.to(".hero-b-wrapper", {
  x: 0, filter: "blur(0px)",
  duration: 0.33, ease: "power4.out"   // matched velocity at the cut
}, CUT_TIME);

Registry gsap_template

__DX__ = -1920 (LEFT) / 1920 (RIGHT); __DY__ = -1080 (UP) / 1080 (DOWN). The * 0.12 / * 0.21 factors yield the ~12% partial travel.

// horizontal
tl.set(__NEW__, { opacity: 0 }, __T__);
tl.to(__OLD__, { x: __DX__ * 0.12, duration: __DUR__ * 0.5, ease: "power4.in" }, __T__);
tl.to(__OLD__, { opacity: 0, duration: __DUR__ * 0.47, ease: "power2.in" }, __T__ + __DUR__ * 0.03);
tl.fromTo(
  __NEW__,
  { x: __DXIN__ * 0.12, opacity: 0.35 },
  { x: 0, opacity: 1, duration: __DUR__ * 0.5, ease: "power4.out", immediateRender: false },
  __T__ + __DUR__ * 0.5,
);

// vertical
tl.set(__NEW__, { opacity: 0 }, __T__);
tl.to(__OLD__, { y: __DY__ * 0.21, duration: __DUR__ * 0.5, ease: "power4.in" }, __T__);
tl.to(__OLD__, { opacity: 0, duration: __DUR__ * 0.47, ease: "power2.in" }, __T__ + __DUR__ * 0.03);
tl.fromTo(
  __NEW__,
  { y: __DYIN__ * 0.21, opacity: 0.35 },
  { y: 0, opacity: 1, duration: __DUR__ * 0.5, ease: "power4.out", immediateRender: false },
  __T__ + __DUR__ * 0.5,
);

Combined cut-the-curve + zoom

The scale component obeys the Z sign rule: both sides SHRINK (exit 1 → 0.92, entry 1.08 → 1) — a consistent mild pull layered on the lateral cut. Never pair a shrinking exit with a grow-from-small entry here.

// Scene A: hero slides left + mild pull + blur
tl.to(
  ".hero-a-wrapper",
  {
    x: -230,
    scale: 0.92,
    filter: "blur(8px)",
    duration: 0.33,
    ease: "power4.in",
  },
  CUT_TIME - 0.33,
);

// Cut + Scene B: continues leftward, arrives slightly oversized and retracts
tl.set(".hero-b-wrapper", { x: 230, scale: 1.08, filter: "blur(8px)" }, CUT_TIME);
tl.to(
  ".hero-b-wrapper",
  {
    x: 0,
    scale: 1,
    filter: "blur(0px)",
    duration: 0.42,
    ease: "power4.out",
  },
  CUT_TIME,
);

4. Waterfall Cut

DOM + CSS

<div class="beat" id="b1"><div class="line">And until now</div></div>
<div class="beat" id="b2">
  <div class="line">
    <span class="w">that</span> <span class="w">changes</span> <span class="w">today.</span>
  </div>
</div>
.beat {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  will-change: transform, opacity;
}
.w {
  display: inline-block;
  will-change: transform, opacity;
}

Timeline

// Pre-set at build time — immediateRender:false alone leaves un-started words visible.
gsap.set([...w1, ...w2], { x: 230, opacity: 0 });

function wordExit(words, C) {
  // C = cut time on the comp timeline
  let s = C - 0.32; // exits START before the cut
  words.forEach((el) => {
    tl.to(el, { x: -230, duration: 0.34, ease: "power4.in" }, s);
    tl.to(el, { opacity: 0, duration: 0.18, ease: "power1.in" }, s); // fade ends ~25-30% into travel
    s += 0.022; // reading-order stagger
  });
}

function wordEnter(words, C) {
  let off = 0,
    gap = 0.05;
  words.forEach((el) => {
    tl.fromTo(
      el,
      { x: 230, opacity: 0.35 }, // ignites MID-PATH, already moving
      { x: 0, opacity: 1, duration: 0.3, ease: "power4.out", immediateRender: false },
      C + off,
    );
    off += gap;
    gap *= 0.84; // shrinking gaps — the cascade accelerates
  });
}

const C = 2.3;
wordExit(w1, C);
tl.set(b1, { opacity: 0 }, C); // hard layer swap AT the cut
tl.set(b2, { opacity: 1 }, C);
wordEnter(w2, C);

Whole-line first beat: exit as one element — x: -230, 0.34s, power4.in with the opacity fade running nearly the whole ramp (ends ~0.02s before the cut).

5. Rack-Focus Blur-Cut

For a Z dolly, drop the x-offset and use scale 0.92 → / → 1.08.

var T = /* transition start */;
// Phase 1: outgoing pans + blurs, held fully opaque
tl.to("#scene-out", { x: -80, scale: 1.06, filter: "blur(12px)", duration: 0.3, ease: "power2.in" }, T);
// Phase 2: hard cut at peak blur
tl.set("#scene-out", { opacity: 0 }, T + 0.3);
tl.fromTo("#scene-in",
  { opacity: 1, x: 80, scale: 1.06, filter: "blur(12px)" },
  { x: 0, scale: 1, filter: "blur(0px)", duration: 0.35, ease: "power2.out" },
  T + 0.3);

Tuning ranges

Zoom-Through / Inverse Zoom

Parameter Default Range
Exit scale 1.2 (fwd) / 0.8 (inv) ±0.1
Entry scale 0.75 (fwd) / 1.25 (inv) ±0.1
Blur at cut 10px text / 1820px full-frame
Opacity at cut 0.15 0.10.2
Exit duration 0.2s 0.150.3s
Entry duration 0.5s 0.40.6s

Cut the Curve

Parameter Default Range
Travel 230px (~12% frame) 150300px
Blur at cut 8px 610px
Exit duration 0.33s 0.20.4s
Entry duration 0.330.42s ≥ exit

6. Waterfall Entry

Each element: tl.set (instant reveal + offset) then tl.to (whip to rest). nextStart = prevStart + prevDuration (overlapFrames × F); +overlap = cascade, overlap = deliberate gap. CSS: elements start opacity: 0; display: inline-block.

var F = 1 / 60;
var t0 = 0.1;
// anchor (heaviest): biggest travel, longest settle
tl.set("#el-1", { opacity: 1, y: 80 }, t0);
tl.to("#el-1", { y: 0, duration: 0.18, ease: "power4.out" }, t0);
// normal word: 2 frames after the anchor finishes
var t1 = t0 + 0.18 + 2 * F;
tl.set("#el-2", { opacity: 1, y: 45 }, t1);
tl.to("#el-2", { y: 0, duration: 0.15, ease: "power4.out" }, t1);
// light word: 1 frame BEFORE the previous finishes (overlap)
var t2 = t1 + 0.15 - F;
tl.set("#el-3", { opacity: 1, y: 40 }, t2);
tl.to("#el-3", { y: 0, duration: 0.14, ease: "power4.out" }, t2);
// split final-word fragments: tightest overlap, extra travel (lighter)
var t3 = t2 + 0.14 - F;
tl.set("#frag-a", { opacity: 1, y: 70 }, t3);
tl.to("#frag-a", { y: 0, duration: 0.16, ease: "power4.out" }, t3);
var t4 = t3 + 0.14 - F;
tl.set("#frag-b", { opacity: 1, y: 70 }, t4);
tl.to("#frag-b", { y: 0, duration: 0.15, ease: "power4.out" }, t4);
// punctuation: lightest, fastest
var t5 = t4 + 0.13 - 2 * F;
tl.set("#dot", { opacity: 1, y: 48 }, t5);
tl.to("#dot", { y: 0, duration: 0.12, ease: "power4.out" }, t5);

7. Nudge Curve

Reference values for a 270px leftward slide (0.57s total). Scale distances proportionally for other travels; preserve the TIME ratios; tail ≥3× ramp-in.

var t = /* start after content settles */;
tl.to(".text-row", { x: -30,  duration: 0.12, ease: "power3.in"  }, t);          // ramp-in: 11% dist / 21% time
tl.to(".text-row", { x: -210, duration: 0.10, ease: "none"       }, t + 0.12);   // burst:   67% dist / 18% time
tl.to(".text-row", { x: -270, duration: 0.35, ease: "power4.out" }, t + 0.22);   // tail:    22% dist / 61% time
// vertical: same ratios on y. 150px variant: -15 / -115 / -150 at the same times.