mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
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>
128 lines
4.5 KiB
JavaScript
128 lines
4.5 KiB
JavaScript
#!/usr/bin/env node
|
|
// align-captions.mjs — map SPOKEN-layer word timestamps back onto DISPLAY tokens.
|
|
//
|
|
// node align-captions.mjs --tokens script-tokens.json --words vo-words.json \
|
|
// --out captions.json [--tail 0.6]
|
|
//
|
|
// tokens: { lines: [{ id, tokens: [ "word" | {display, spoken} ] }] }
|
|
// words: [ { text, start, end } ] — timestamps of the spoken text (heygen-tts --words)
|
|
// out: { lines: [{ id, end, w: [[display, start], ...] }] } — caption-rail input
|
|
//
|
|
// Each display token consumes the spoken words of its `spoken` form (one display
|
|
// token may be several spoken words: "C L I" = 3). The display word's time = its
|
|
// FIRST spoken word's start. Line end = next line's first word start (last line:
|
|
// last spoken end + tail). Fuzzy matching absorbs TTS/timestamp quirks; anything
|
|
// it can't absorb prints MISMATCH — resolve every one before trusting captions.
|
|
|
|
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 die = (m) => {
|
|
console.error("align-captions:", m);
|
|
process.exit(2);
|
|
};
|
|
|
|
const tokensFile = flag("tokens", null) ?? die("--tokens required");
|
|
const wordsFile = flag("words", null) ?? die("--words required");
|
|
const outFile = flag("out", "captions.json");
|
|
const tail = parseFloat(flag("tail", "0.6"));
|
|
|
|
const script = JSON.parse(readFileSync(tokensFile, "utf8"));
|
|
const stream = JSON.parse(readFileSync(wordsFile, "utf8"));
|
|
if (!script.lines?.length) die("tokens file has no lines[]");
|
|
if (!stream.length) die("words file is empty");
|
|
|
|
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
const lev = (a, b) => {
|
|
if (a === b) return 0;
|
|
const m = a.length,
|
|
n = b.length;
|
|
if (!m || !n) return Math.max(m, n);
|
|
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
for (let i = 1; i <= m; i++) {
|
|
const cur = [i];
|
|
for (let j = 1; j <= n; j++)
|
|
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
prev = cur;
|
|
}
|
|
return prev[n];
|
|
};
|
|
const close = (a, b) => {
|
|
if (!a || !b) return false;
|
|
if (a === b || a.startsWith(b) || b.startsWith(a)) return true;
|
|
return lev(a, b) <= Math.max(1, Math.floor(Math.min(a.length, b.length) / 3));
|
|
};
|
|
|
|
let si = 0; // stream cursor
|
|
let mismatches = 0;
|
|
const outLines = [];
|
|
|
|
// Greedily consume stream words from `from` whose concatenated norm builds the
|
|
// token's full spoken norm ("hey-jen" may arrive as one word or several; "C L I"
|
|
// as three). Returns { start, next } or null.
|
|
function consume(from, spokenNorm) {
|
|
let acc = "",
|
|
start = null,
|
|
k = from;
|
|
while (k < stream.length) {
|
|
const wn = norm(stream[k].text);
|
|
if (!wn) {
|
|
k++;
|
|
continue;
|
|
}
|
|
const cand = acc + wn;
|
|
if (spokenNorm.startsWith(cand) || close(cand, spokenNorm)) {
|
|
if (start === null) start = stream[k].start;
|
|
acc = cand;
|
|
k++;
|
|
if (close(acc, spokenNorm)) return { start, next: k };
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
return acc && close(acc, spokenNorm) ? { start, next: k } : null;
|
|
}
|
|
|
|
for (const line of script.lines) {
|
|
const w = [];
|
|
for (const tok of line.tokens) {
|
|
const display = typeof tok === "string" ? tok : tok.display;
|
|
const spoken = typeof tok === "string" ? tok : tok.spoken;
|
|
const spokenNorm = norm(spoken);
|
|
if (!spokenNorm) {
|
|
w.push([display, si < stream.length ? stream[si].start : 0]);
|
|
continue;
|
|
}
|
|
// try at the cursor, then resync up to 4 words ahead
|
|
let hit = null;
|
|
for (let off = 0; off <= 4 && !hit; off++) hit = consume(si + off, spokenNorm);
|
|
if (!hit) {
|
|
console.error(
|
|
`MISMATCH line=${line.id} display="${display}" expected~"${spoken}" heard="${stream[si]?.text ?? "<eof>"}" @${stream[si]?.start?.toFixed(2) ?? "?"}s`,
|
|
);
|
|
mismatches++;
|
|
w.push([display, si < stream.length ? stream[si].start : stream.at(-1).end]);
|
|
continue;
|
|
}
|
|
si = hit.next;
|
|
w.push([display, +hit.start.toFixed(2)]);
|
|
}
|
|
outLines.push({ id: line.id, w });
|
|
}
|
|
|
|
for (let i = 0; i < outLines.length; i++) {
|
|
outLines[i].end =
|
|
i + 1 < outLines.length ? outLines[i + 1].w[0][1] : +(stream.at(-1).end + tail).toFixed(2);
|
|
}
|
|
|
|
writeFileSync(outFile, JSON.stringify({ lines: outLines }, null, 1));
|
|
const status = mismatches ? `${mismatches} MISMATCH(ES) — resolve before building` : "clean";
|
|
console.log(
|
|
`aligned ${outLines.length} lines / ${stream.length} spoken words → ${outFile} (${status})`,
|
|
);
|
|
process.exit(mismatches ? 1 : 0);
|