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>
9.8 KiB
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 / 18–20px full-frame | — |
| Opacity at cut | 0.15 | 0.1–0.2 |
| Exit duration | 0.2s | 0.15–0.3s |
| Entry duration | 0.5s | 0.4–0.6s |
Cut the Curve
| Parameter | Default | Range |
|---|---|---|
| Travel | 230px (~12% frame) | 150–300px |
| Blur at cut | 8px | 6–10px |
| Exit duration | 0.33s | 0.2–0.4s |
| Entry duration | 0.33–0.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.