mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 23:29:50 +00:00
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>
This commit is contained in:
co-authored by
Jake Moran
parent
172f2757f0
commit
e96ebd74de
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
// seam-stamp.mjs — generate master-timeline seam code FROM ledger.json (motion-doctrine).
|
||||
// The generation half of the Seam Gate: stamped seams pass seam-gate.mjs by construction.
|
||||
//
|
||||
// node seam-stamp.mjs --ledger ledger.json # print the seam block
|
||||
// node seam-stamp.mjs --ledger ledger.json --write index.html
|
||||
//
|
||||
// --write replaces the block between "// <seams:auto>" and "// </seams:auto>" markers
|
||||
// (adds them before the final pad tween if absent). Tier-A morphs / match-cuts get
|
||||
// visibility sets only — author the carrier handoff by hand.
|
||||
//
|
||||
// Per-seam ledger options (all optional):
|
||||
// exit.dur / entry.dur — override durations (defaults below)
|
||||
// entry.travel — xPercent/yPercent entry offset (default 10; "soft" look = 8)
|
||||
// blur — Z-seam blur px (default 18 full-frame; use 10 for text-scale)
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const flag = (n, d) => {
|
||||
const i = argv.indexOf("--" + n);
|
||||
return i >= 0 ? argv[i + 1] : d;
|
||||
};
|
||||
const ledger = JSON.parse(readFileSync(flag("ledger", "ledger.json"), "utf8"));
|
||||
|
||||
const round = (n) => +n.toFixed(3);
|
||||
const lines = [];
|
||||
const emit = (s) => lines.push(" " + s);
|
||||
|
||||
// ---------- scene inventory (order of appearance) + base states ----------
|
||||
const scenes = [];
|
||||
const zEntries = new Map(); // selector -> {scale, blur} preset for Z arrivals
|
||||
for (const seam of ledger.seams) {
|
||||
for (const sel of [seam.exit?.selector, seam.entry?.selector]) {
|
||||
if (sel && !scenes.includes(sel)) scenes.push(sel);
|
||||
}
|
||||
if (seam.entry?.axis === "z") {
|
||||
const blur = seam.blur ?? 18;
|
||||
zEntries.set(
|
||||
seam.entry.selector,
|
||||
seam.entry.dir === -1
|
||||
? { scale: 1.25, blur } // pull: arrives oversized
|
||||
: { scale: 0.78, blur },
|
||||
); // push: arrives small, growing
|
||||
}
|
||||
}
|
||||
|
||||
emit(`// <seams:auto> — generated by seam-stamp.mjs from ledger.json; do not hand-edit.`);
|
||||
emit(
|
||||
`// Regenerate: node <motion-doctrine>/scripts/seam-stamp.mjs --ledger ledger.json --write index.html`,
|
||||
);
|
||||
if (scenes.length) {
|
||||
const first = scenes[0];
|
||||
const rest = scenes.slice(1).filter((s) => !zEntries.has(s));
|
||||
emit(
|
||||
`gsap.set("${first}", { autoAlpha: 1, xPercent: 0, yPercent: 0, scale: 1, filter: "blur(0px)", transformOrigin: "50% 50%" });`,
|
||||
);
|
||||
if (rest.length)
|
||||
emit(
|
||||
`gsap.set([${rest.map((s) => `"${s}"`).join(",")}], { autoAlpha: 0, xPercent: 0, yPercent: 0, scale: 1, filter: "blur(0px)", transformOrigin: "50% 50%" });`,
|
||||
);
|
||||
for (const [sel, p] of zEntries)
|
||||
emit(
|
||||
`gsap.set("${sel}", { autoAlpha: 0, scale: ${p.scale}, filter: "blur(${p.blur}px)", xPercent: 0, yPercent: 0, transformOrigin: "50% 50%" });`,
|
||||
);
|
||||
}
|
||||
emit(``);
|
||||
|
||||
// ---------- per-seam stamping ----------
|
||||
for (const seam of ledger.seams) {
|
||||
const cut = seam.cut,
|
||||
type = seam.type || "cut";
|
||||
emit(`// SEAM — ${seam.id} : ${seam.technique || type} (cut @${cut})`);
|
||||
|
||||
if (type !== "cut") {
|
||||
if (seam.exit?.selector) emit(`tl.set("${seam.exit.selector}", { autoAlpha: 0 }, ${cut});`);
|
||||
if (seam.entry?.selector) emit(`tl.set("${seam.entry.selector}", { autoAlpha: 1 }, ${cut});`);
|
||||
emit(
|
||||
`// ${type}: carrier handoff is Tier-A — author it by hand and keep the carrier row in ledger.json`,
|
||||
);
|
||||
emit(``);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ex = seam.exit,
|
||||
en = seam.entry;
|
||||
if (ex.axis !== en.axis || ex.dir !== en.dir)
|
||||
throw new Error(
|
||||
`ledger row "${seam.id}" mismatched (${ex.axis}${ex.dir} vs ${en.axis}${en.dir}) — fix the PLAN, not the stamp`,
|
||||
);
|
||||
|
||||
if (ex.axis === "z") {
|
||||
const blur = seam.blur ?? 18;
|
||||
const exDur = ex.dur ?? 0.21,
|
||||
enDur = en.dur ?? 0.5;
|
||||
const exScale = ex.dir === -1 ? 0.8 : 1.18;
|
||||
const enFrom = ex.dir === -1 ? 1.25 : 0.78;
|
||||
emit(
|
||||
`tl.to("${ex.selector}", { scale: ${exScale}, filter: "blur(${blur}px)", duration: ${exDur}, ease: "power3.in" }, ${round(cut - exDur)});`,
|
||||
);
|
||||
emit(
|
||||
`tl.to("${ex.selector}", { autoAlpha: 0, duration: ${exDur}, ease: "none" }, ${round(cut - exDur)});`,
|
||||
);
|
||||
emit(`tl.set("${ex.selector}", { autoAlpha: 0 }, ${cut});`);
|
||||
emit(
|
||||
`tl.fromTo("${en.selector}", { autoAlpha: 0.15, scale: ${enFrom}, filter: "blur(${blur}px)" }, { autoAlpha: 1, scale: 1.0, filter: "blur(0px)", duration: ${enDur}, ease: "expo.out", immediateRender: false }, ${cut});`,
|
||||
);
|
||||
} else {
|
||||
const prop = ex.axis === "x" ? "xPercent" : "yPercent";
|
||||
const exDur = ex.dur ?? 0.34,
|
||||
enDur = en.dur ?? 0.42;
|
||||
const travel = en.travel ?? 10;
|
||||
emit(
|
||||
`tl.to("${ex.selector}", { ${prop}: ${12 * ex.dir}, autoAlpha: 0, duration: ${exDur}, ease: "power3.in" }, ${round(cut - exDur)});`,
|
||||
);
|
||||
emit(`tl.set("${ex.selector}", { autoAlpha: 0 }, ${cut});`);
|
||||
emit(
|
||||
`tl.fromTo("${en.selector}", { ${prop}: ${-travel * en.dir}, autoAlpha: 0.35 }, { ${prop}: 0, autoAlpha: 1, duration: ${enDur}, ease: "power4.out", immediateRender: false }, ${cut});`,
|
||||
);
|
||||
}
|
||||
emit(``);
|
||||
}
|
||||
emit(`// </seams:auto>`);
|
||||
|
||||
const block = lines.join("\n");
|
||||
const target = flag("write", null);
|
||||
if (!target) {
|
||||
console.log(block);
|
||||
} else {
|
||||
let html = readFileSync(target, "utf8");
|
||||
const re = /[ \t]*\/\/ <seams:auto>[\s\S]*?\/\/ <\/seams:auto>/;
|
||||
if (re.test(html)) {
|
||||
html = html.replace(re, block);
|
||||
} else {
|
||||
// insert after the master timeline registration line
|
||||
const anchor = /(window\.__timelines\["main"\]\s*=\s*tl;\s*\n)/;
|
||||
if (!anchor.test(html))
|
||||
throw new Error('no <seams:auto> markers and no window.__timelines["main"] anchor found');
|
||||
html = html.replace(anchor, `$1\n${block}\n`);
|
||||
}
|
||||
writeFileSync(target, html);
|
||||
console.log(`stamped ${ledger.seams.length} seams into ${target}`);
|
||||
}
|
||||
Reference in New Issue
Block a user