feat(skills): product-launch-video skill + consolidate motion knowledge into hyperframes-animation (#1745)

* feat(skills): product-launch-video + consolidate motion knowledge into hyperframes-animation

- Add the product-launch-video skill: shot-sequence architecture where each
  visual frame is a time-coded shot sequence picked from a blueprint menu and
  paced to the voiceover (anti-PowerPoint). Includes the frame-worker sub-agent,
  story/visual/motion-design references, and audio/captions/transitions/
  stage-assets/assemble-index scripts.
- Consolidate motion knowledge in hyperframes-animation as the single source of
  truth: promote the updated atomic rules (31 -> 36) and rename product-launch-
  video's archetypes into hyperframes-animation blueprints (13 -> 15, replacing
  the old set). product-launch-video, faceless-explainer, and pr-to-video now
  reference them via ../hyperframes-animation/{rules-index,blueprints-index}.md
  and the rules/blueprints dirs. Fixes the discrete-text-sequence broken links;
  blueprints no longer ship per-id runnable examples, so example references in
  the consumers were dropped.
- Default HeyGen TTS voice to Marcia (deterministic; was the API's first English
  voice, which drifts on catalog re-sort). Override with --voice.
- assemble-index pre-assembly frame guards: auto-repair a sub-comp root missing
  canvas dims; hard-fail on <video>/<audio> inside a sub-comp; hard-fail on a
  timed non-root element missing class="clip" or overlapping same-track clips.
- Lint/CLI: lint media inside sub-compositions as an error; stop false-positive
  caption layout/lint findings; contrast/layout-audit skip elements hidden by an
  invisible ancestor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(skills): clear CodeQL alerts in assemble-index.mjs

- script/style blanking regex now matches closing tags with trailing
  whitespace (</script >, </style >) — js/bad-tag-filter (high).
- drop the existsSync precheck before reading/repairing a frame file; read
  directly and handle ENOENT, removing the check->write TOCTOU window —
  js/file-system-race (high).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
WaterrrForever
2026-06-27 02:51:35 +08:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 413d8187fd
commit 05af482f22
67 changed files with 3838 additions and 3006 deletions
@@ -121,11 +121,51 @@ const hueDist = (a, b) => {
const d = Math.abs(a - b) % 360;
return d > 180 ? 360 - d : d;
};
function hexToRgb(hex) {
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex).trim());
if (!m) return null;
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const rgbToHsl = (r, g, b) =>
hexToHsl("#" + [r, g, b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join(""));
// Repaint a chromatic rgba()/rgb() tint with the brand accent's RGB, keeping its alpha.
// A near-neutral rgb (shadow / scrim overlay) is left untouched; a non-rgba string → null.
function remapRgbaToAccent(val, brAccent, brAccent2, prAccentHsl, prAccent2Hsl) {
const m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+%?))?\s*\)$/i.exec(
String(val).trim(),
);
if (!m) return null;
const r = +m[1],
g = +m[2],
b = +m[3],
a = m[4];
if (Math.max(r, g, b) - Math.min(r, g, b) < 16) return null; // neutral overlay — keep as-is
const src = rgbToHsl(r, g, b);
const useSecond =
brAccent2 &&
prAccentHsl &&
prAccent2Hsl &&
src &&
hueDist(src.h, prAccent2Hsl.h) < hueDist(src.h, prAccentHsl.h);
const t = hexToRgb(useSecond ? brAccent2 : brAccent);
if (!t) return null;
return a !== undefined
? `rgba(${t[0]}, ${t[1]}, ${t[2]}, ${a})`
: `rgb(${t[0]}, ${t[1]}, ${t[2]})`;
}
// ── brand tokens ──────────────────────────────────────────────────────────────
let brandColors = [];
let brandFonts = [];
let brandFontWeights = []; // weights the brand text font actually ships (tokens fonts[].weights)
let brandColorStats = []; // rich per-color usage stats (areaBg / interactiveBg / textCount …)
// Icon/glyph fonts capture surfaces as "fonts" — they are never the brand text face
// (webflow-icons, Font Awesome, icomoon …) and must not become display/body or contribute weights.
const isIconFont = (name) =>
/(?:^|[\s_-])icons?(?:[\s_-]|$)|icomoon|font\s*-?awesome|glyphicons?|material\s*icons|feather\s*icons/i.test(
String(name),
);
if (existsSync(tokensPath)) {
try {
const t = JSON.parse(readFileSync(tokensPath, "utf8"));
@@ -137,7 +177,19 @@ if (existsSync(tokensPath)) {
brandFonts = (t.fonts ?? [])
.map((f) => (typeof f === "string" ? f : (f?.family ?? f?.name ?? "")))
.map((f) => String(f).split(",")[0].replace(/['"]/g, "").trim())
.filter(Boolean);
.filter(Boolean)
.filter((f) => !isIconFont(f));
// Union of the (non-icon) brand fonts' available weights — used to clamp the preset's
// type ramp so a font shipping only 400/500 never faux-bolds a 600/700 heading.
brandFontWeights = [
...new Set(
(t.fonts ?? [])
.filter((f) => f && typeof f === "object" && !isIconFont(f.family ?? f.name ?? ""))
.flatMap((f) => (Array.isArray(f.weights) ? f.weights : []))
.map((w) => parseInt(w, 10))
.filter((w) => Number.isFinite(w)),
),
].sort((a, b) => a - b);
brandColorStats = Array.isArray(t.colorStats) ? t.colorStats : [];
} catch (e) {
die(`tokens.json parse: ${e.message}`);
@@ -156,7 +208,7 @@ if (brandColors.length && presetColors.length) {
// Fall back to the legacy luminance/chroma heuristic only when stats are absent —
// but pick the accent via pickAccent either way so a UA-default link color never wins.
const br =
brandRolesFromStats(brandColorStats) ??
brandRolesFromStats(brandColorStats, brandColors) ??
(() => {
// strip UA-default link colors so a stray <a> color can't become ink/canvas/accent
const clean = brandColors.filter((h) => !UA_DEFAULT_COLORS.has(h.toUpperCase()));
@@ -174,39 +226,66 @@ if (brandColors.length && presetColors.length) {
` ⚠ accent ${br.accent} 彩度很低 (${chroma(br.accent)}) — 确认这是品牌色而非中性/默认色`,
);
}
// Map by LUMINANCE POLARITY, not by role name: the preset's darker neutral takes the
// brand's darker neutral, the lighter takes the lighter. So a dark-ground preset stays
// dark and a light-ground preset stays light — both land on the brand's real values,
// even when the brand's canvas is dark (dark-mode brand) and ink is light.
// Map by LUMINANCE POLARITY. The preset's darker value takes the brand's darker value and
// the lighter takes the lighter — UNLESS the brand's GROUND polarity differs from the
// preset's. Every shipped preset is light-ground; a dark-mode brand (Linear, Vercel,
// Raycast…) has its canvas darker than its ink (colorStats already resolved the real
// ground as the largest-area background). On a polarity MISMATCH we INVERT the mapping so a
// light preset becomes the dark brand (canvas↔ink swap) instead of forcing the brand onto
// an off-brand light video; neutral/tint lightness is then mirrored (L→1L) so the whole
// palette flips to the brand's ground. Same-polarity (the common case) is unchanged.
const darker = (a, b) => ((lum(a) ?? 0) <= (lum(b) ?? 0) ? a : b);
const prDark = darker(pr.ink, pr.canvas);
const prLight = prDark === pr.ink ? pr.canvas : pr.ink;
const brDark = darker(br.ink, br.canvas);
const brLight = brDark === br.ink ? br.canvas : br.ink;
const presetGroundDark = (lum(pr.canvas) ?? 255) < (lum(pr.ink) ?? 0);
const brandGroundDark = (lum(br.canvas) ?? 255) < (lum(br.ink) ?? 0);
const invert = presetGroundDark !== brandGroundDark;
const mapDark = invert ? brLight : brDark; // preset's dark value → this brand value
const mapLight = invert ? brDark : brLight; // preset's light value → this brand value
const flipL = (l) => (invert ? 1 - l : l); // mirror tint/neutral lightness when flipping
const prAccentHsl = hexToHsl(pr.accent);
const prAccent2Hsl = hexToHsl(pr.accent2);
const newByKey = new Map();
for (const [key, val] of presetColors) {
const ph = hexToHsl(val);
let next;
if (val === prDark) next = brDark;
else if (val === prLight) next = brLight;
if (val === prDark) next = mapDark;
else if (val === prLight) next = mapLight;
else if (
/(?:^|[-_])(?:positive|negative|success|error|warning|danger|good|bad|up|down)(?:[-_]|$)/i.test(
key,
)
)
// semantic status colors (green/red …) — the HUE carries the meaning; never repaint.
// MUST precede the accent checks: a preset's red "negative" is often its 2nd-most-chromatic
// color and would otherwise be claimed as accent2 and recolored to the brand hue.
next = val;
else if (val === pr.accent)
next = br.accent; // primary accent → the EXACT brand color
else if (pr.accent2 !== pr.accent && val === pr.accent2)
next = br.accent2; // exact 2nd accent
else if (!ph)
next = val; // non-hex (rgba) → leave as-is
else {
// repaint the remaining tints: pick the brand accent whose preset counterpart is
// nearest in hue, then keep THIS color's own lightness so tint families stay families.
else if (!ph) {
// rgba()/rgb() tint → repaint its rgb with the brand accent, keep alpha (a neutral
// overlay is kept). A non-color non-hex value (var(), named) falls through unchanged.
next = remapRgbaToAccent(val, br.accent, br.accent2, prAccentHsl, prAccent2Hsl) ?? val;
} else if (chroma(val) < 16) {
// NEUTRAL source (grey text-ladder, hairline borders) → keep it NEUTRAL. Apply at most a
// whisper of the brand hue (sat ≤ 0.06); never the accent's full saturation — that is what
// turned the grey ladder into saturated blue.
const bh = hexToHsl(br.accent);
next = bh ? hslToHex(bh.h, Math.min(ph.s, 0.06), flipL(ph.l)) : val;
} else {
// chromatic tint → repaint with the nearest brand accent's hue+sat, keep THIS color's
// lightness so tint families stay families.
const useSecond =
pr.accent !== pr.accent2 &&
prAccentHsl &&
prAccent2Hsl &&
hueDist(ph.h, prAccent2Hsl.h) < hueDist(ph.h, prAccentHsl.h);
const bh = hexToHsl(useSecond ? br.accent2 : br.accent);
next = bh ? hslToHex(bh.h, bh.s, ph.l) : val;
next = bh ? hslToHex(bh.h, bh.s, flipL(ph.l)) : val;
}
if (next !== val) newByKey.set(key, next);
}
@@ -229,7 +308,7 @@ if (brandColors.length && presetColors.length) {
})
.join("\n");
summary.push(
`colors: dark ${prDark}${brDark}, light ${prLight}${brLight}, accent ${pr.accent}${br.accent}` +
`colors: ${invert ? "INVERTED (dark-mode brand on light preset) · " : ""}dark ${prDark}${mapDark}, light ${prLight}${mapLight}, accent ${pr.accent}${br.accent}` +
` (${newByKey.size}/${presetColors.length} keys repainted${brandColorStats.length ? ", via colorStats" : ""})`,
);
} else {
@@ -246,15 +325,178 @@ if (brandFonts.length) {
const strip = (q) => (q ? q.replace(/^"|"$/g, "") : null);
const pDisplay = strip(pf.display);
const pBody = strip(pf.body);
const bDisplay = brandFonts[0];
const bBody = brandFonts[1] ?? brandFonts[0];
if (pDisplay && bDisplay) md = md.split(`"${pDisplay}"`).join(`"${bDisplay}"`);
if (pBody && pBody !== pDisplay && bBody) md = md.split(`"${pBody}"`).join(`"${bBody}"`);
summary.push(`fonts: display ${pDisplay}${bDisplay}, body ${pBody}${bBody}`);
const pMono = strip(pf.mono);
// A monospace brand face is for code / labels / chrome — never the reading display or body.
// Split the brand fonts: the primary NON-mono family carries display AND body (the common
// single-sans case, e.g. Inter for everything), and a captured mono (Berkeley Mono,
// JetBrains Mono…) is routed onto the preset's mono role instead of turning the body
// monospace. (Distinct display/body brands still resolve to a clean sans; hand-tune the
// display in frame.md if a separate display face is wanted.)
const isMonoFont = (n) =>
/(?:^|[\s_-])mono(?:[\s_-]|$)|monospace|consol|courier|menlo|monaco|jetbrains|berkeley|space\s*mono|ibm\s*plex\s*mono|sf\s*mono|roboto\s*mono|source\s*code|fira\s*code|geist\s*mono|dm\s*mono/i.test(
String(n),
);
const nonMono = brandFonts.filter((f) => !isMonoFont(f));
const monoFonts = brandFonts.filter(isMonoFont);
const bDisplay = nonMono[0] ?? brandFonts[0];
const bBody = nonMono[0] ?? brandFonts[0];
const bMono = monoFonts[0] ?? null;
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Replace the preset family as a WHOLE WORD/PHRASE everywhere — frontmatter values,
// component strings like "Space Grotesk 600", AND prose — case-sensitive with word
// boundaries so a single-word family ("Inter") can never corrupt a substring
// ("interactive"). Quote-exact replace alone missed names baked into longer strings + prose.
const swapFamily = (from, to) => {
if (from && to && from !== to) md = md.replace(new RegExp(`\\b${escRe(from)}\\b`, "g"), to);
};
swapFamily(pDisplay, bDisplay);
if (pBody !== pDisplay) swapFamily(pBody, bBody);
// route the brand mono onto the preset's mono role (only if the preset has a DISTINCT mono
// family — never collapse body/display into mono)
if (bMono && pMono && pMono !== pBody && pMono !== pDisplay) swapFamily(pMono, bMono);
summary.push(
`fonts: display ${pDisplay}${bDisplay}, body ${pBody}${bBody}` +
(bMono && pMono && pMono !== pBody && pMono !== pDisplay ? `, mono ${pMono}${bMono}` : ""),
);
} else {
summary.push("fonts: no brand fonts — preset fonts kept");
}
// ── cap type weights to the brand font's available faces ──────────────────────
// The remix swaps the font FAMILY but keeps the preset's weights; a brand font that ships
// only e.g. 400/500 would faux-bold every 600/700 heading. Clamp each `typography:` weight
// to the NEAREST weight the brand font actually provides (tokens.json fonts[].weights).
if (brandFonts.length && brandFontWeights.length) {
const avail = brandFontWeights;
const nearest = (n) =>
avail.reduce((best, w) => {
const dw = Math.abs(w - n),
db = Math.abs(best - n);
return dw < db || (dw === db && w > best) ? w : best;
}, avail[0]);
let capped = 0;
const cap = (num) => {
const n = parseInt(num, 10);
if (avail.includes(n)) return String(n);
const c = nearest(n);
if (c !== n) capped++;
return String(c);
};
let inType = false;
md = md
.split(/\r?\n/)
.map((line) => {
if (/^typography:\s*$/.test(line)) {
inType = true;
return line;
}
if (inType && /^\S/.test(line)) inType = false;
let out = line;
// (a) structured `weight: NNN` in the typography ramp
if (inType) out = out.replace(/(\bweight:\s*)(\d{3})\b/g, (m, pfx, num) => pfx + cap(num));
// (b) a weight baked into a quoted `typography:` component value, e.g.
// cta-button → typography: "Basier Square 600" (NNN not followed by a unit like px)
out = out.replace(
/(typography:\s*"[^"]*?\b)(\d{3})\b(?![a-z%])/gi,
(m, pfx, num) => pfx + cap(num),
);
return out;
})
.join("\n");
if (capped)
summary.push(`fonts: capped ${capped} type weight(s) to brand faces {${avail.join(", ")}}`);
}
// ── brand-adaptation note ─────────────────────────────────────────────────────
// The remix fixes the NORMATIVE frontmatter, but the preset's PROSE still carries its
// original weight ranges / color-names. Prepend a short "frontmatter is truth" header so a
// reader (or frame worker) interprets any lingering preset prose THROUGH the brand values —
// instead of fragile per-sentence prose surgery.
if (brandFonts.length || (brandColors.length && presetColors.length)) {
const bD = brandFonts[0];
const bB = brandFonts[1] ?? brandFonts[0];
const note =
`## Brand adaptation (READ FIRST — the frontmatter is the source of truth)\n\n` +
`This is the **${presetName}** preset remixed onto the captured brand. The YAML frontmatter above ` +
`(colors · typography · components) is **normative and already correct — use it verbatim.** The prose ` +
`below is the ORIGINAL preset's intent; read it THROUGH the frontmatter:\n\n` +
(brandFonts.length
? `- **Fonts** — already set to **${bD}** (display) / **${bB}** (body); ignore any preset font name lingering in prose.\n`
: "") +
(brandFontWeights.length
? `- **Weights** — the brand font ships \`{${brandFontWeights.join(", ")}}\` only; every weight is clamped to these — ignore higher preset weights (e.g. 600/700) in prose.\n`
: "") +
`- **Colors** — use the frontmatter hex; preset color NAMES in prose (e.g. "cobalt", "cream") mean the remapped brand values.\n`;
if (/^# .*$/m.test(md)) md = md.replace(/^# .*$/m, (m) => `${m}\n\n${note}`);
else md = `${note}\n${md}`;
summary.push("brand-adaptation note prepended");
}
// ── stage brand font files + emit @font-face ──────────────────────────────────
// A brand font is rarely a Google font, so renaming the family in frame.md is not enough:
// nothing loads the actual face. If the capture downloaded font files, copy them to
// assets/fonts/ under CLEAN, weight-named names (so captions.mjs' family-prefix matcher
// finds them too) and append a ready-to-paste, ROOT-RELATIVE @font-face block to frame.md.
if (brandFonts.length) {
const norm = (s) =>
String(s)
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
const extOf = (f) => (f.match(/\.(woff2|woff|ttf|otf)$/i)?.[1] ?? "").toLowerCase();
const FMT = { woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype" };
const weightInfo = (name) => {
const s = name.toLowerCase();
if (/black|heavy|ultra|extrabold/.test(s)) return { n: 800, w: "ExtraBold" };
if (/semibold|demibold/.test(s)) return { n: 600, w: "SemiBold" };
if (/bold/.test(s)) return { n: 700, w: "Bold" };
if (/medium/.test(s)) return { n: 500, w: "Medium" };
if (/light|thin/.test(s)) return { n: 300, w: "Light" };
return { n: 400, w: "Regular" };
};
const fams = [...new Set(brandFonts)];
const srcDirs = [
join(hyperframesDir, "capture/assets/fonts"),
join(hyperframesDir, "assets/fonts"),
].filter((d) => existsSync(d));
const files = [];
for (const d of srcDirs)
for (const f of readdirSync(d).sort()) if (extOf(f)) files.push({ d, f });
// Single family → all font files belong to it (the common captured case, hash-named files
// included). Multiple families → assign each file to the longest family key its name contains.
const ranked = [...fams].sort((a, b) => norm(b).length - norm(a).length);
const famOf = (f) =>
fams.length === 1 ? fams[0] : ranked.find((x) => norm(f).includes(norm(x)));
const outDir = join(hyperframesDir, "assets/fonts");
const faces = [];
const stagedNames = new Set();
for (const { d, f } of files) {
const fam = famOf(f);
if (!fam) continue;
const { n, w } = weightInfo(f);
const clean = `${fam.replace(/[^A-Za-z0-9]/g, "")}-${w}.${extOf(f)}`;
if (stagedNames.has(clean)) continue;
mkdirSync(outDir, { recursive: true });
if (!existsSync(join(outDir, clean))) copyFileSync(join(d, f), join(outDir, clean));
stagedNames.add(clean);
faces.push(
`@font-face{font-family:"${fam}";font-weight:${n};font-style:normal;font-display:block;src:url("assets/fonts/${clean}") format("${FMT[extOf(f)]}");}`,
);
}
if (faces.length) {
md +=
`\n\n## Font loading (auto-generated)\n\n` +
`The brand font ships as local files in \`assets/fonts/\` — do NOT link Google Fonts for it. ` +
`Paste this \`<style>\` into every frame's \`<head>\`/\`<template>\` (captions use the same files) ` +
`so \`font-family\` resolves in preview, snapshot, and render alike:\n\n` +
"```html\n<style>\n" +
faces.join("\n") +
"\n</style>\n```\n";
summary.push(
`fonts: staged ${stagedNames.size} face(s) → assets/fonts/ + @font-face in frame.md`,
);
}
}
// ── write frame.md ────────────────────────────────────────────────────────────
const framePath = join(hyperframesDir, "frame.md");
writeFileSync(framePath, md);
@@ -277,9 +519,12 @@ if (outColors.length !== presetColors.length) {
const outRoles = semanticColors(outColors);
const li = lum(outRoles.ink),
lc = lum(outRoles.canvas);
if (li != null && lc != null && li >= lc) {
// ink (type) and canvas (ground) must differ enough to READ — in EITHER direction. A
// light-mode spec has ink darker than canvas; a dark-mode spec (the polarity flip above)
// the reverse. Assert luminance SEPARATION, not a fixed polarity.
if (li != null && lc != null && Math.abs(li - lc) < 40) {
die(
`ink (${outRoles.ink}, lum ${li.toFixed(0)}) is not darker than canvas (${outRoles.canvas}, lum ${lc.toFixed(0)}) — bad brand mapping`,
`ink (${outRoles.ink}, lum ${li.toFixed(0)}) and canvas (${outRoles.canvas}, lum ${lc.toFixed(0)}) lack contrast — bad brand mapping`,
);
}
@@ -288,4 +533,4 @@ for (const s of summary) console.log(` ${s}`);
console.log(
` .hyperframes/caption-skin.html: ${skinCopied ? "copied" : "preset ships none — captions will use the default pill"}`,
);
console.log(` self-check: keys preserved, ink darker than canvas`);
console.log(` self-check: keys preserved, ink/canvas contrast ok`);