Files
hyperframes/skills/faceless-explainer/scripts/build-frame.mjs
T
WaterrrForeverandClaude Opus 4.8 54cab331d0 feat(cli): shared TTS/BGM auth preflight + caption and skill-workflow fixes (#1697)
* fix: handle caption skin workflow

* docs(skills): simplify the finalize step across video workflows

- Drop --strict-layout; all skills use plain `hyperframes inspect`
- Add the caption text_box_overflow false-positive note to faceless-explainer
- On a failed check, the orchestrator makes the cheapest safe edit itself
  (no worker re-dispatch / Step 3 backtrack language)
- Snapshot: glance at the stitched contact-sheet.jpg and move on

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

* feat(auth): onboarding-first `auth status` + shared TTS/BGM preflight

When no HeyGen credential is configured, `hyperframes auth status` now
prints registration-first guidance instead of a terse error:

- Interactive / agent-driven sessions get sign-in guidance led by
  `hyperframes auth login` (the OAuth step that also creates an account
  and is shared with heygen-cli), and never steer users to a per-repo
  `.env`. CI / non-interactive runs get a terse note. Exit 1 is kept so
  the "am I logged in?" `$?` contract still holds.
- It probes which local engine voice/music will fall back to (Kokoro /
  MusicGen, mirroring the skill resolution order) and whether their
  Python deps are installed, with a pip hint when missing. `--json`
  exposes `recommended_action` + `offline_engines` for skills to branch.
- `doctor` gains matching "TTS (Kokoro)" / "BGM (MusicGen)" checks via
  the same shared probe (findPython/hasPythonModules extracted to
  tts/python.ts; provider resolution in audio/providers.ts).

Every TTS/BGM workflow now relays this at Step 0 (setup) instead of
improvising its own "missing key" prompt: pr-to-video, product-launch-
video, faceless-explainer, website-to-video, music-to-video. The
canonical behavior + key-priority table live once in hyperframes-media.

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

* feat(pr-to-video): scale recommended video length to PR change size

Step 0 led with a fixed ~60-90s length default. Now the recommended
length is derived from the PR's diff stat (lines added+deleted, nudged
by file count) on a tier scale (trivial ~20-40s → large ~110-180s, hard
cap ~3 min), reusing the same PR peek already done to infer the angle.
The agent states the basis when proposing it, and a huge PR with one
headline change still stays tight. User can always override.

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

* fix(captions): embed brand fonts whose files use separators

brandFontFaces() matched font files by stripping only whitespace, so an
underscore/hyphen-named file (TT_Norms_Pro_Bold.woff2) never matched the
family key "ttnormspro" — captions shipped with no @font-face, the
font_family_without_font_face bug. Now both family and filename normalize
away all non-alphanumerics; families match longest-key-first so a parent
family can't swallow a more specific one's files (TT Norms Pro vs Mono);
each file is claimed once; "demibold" ranks before "bold"; and when
nothing matches it warns loudly at build time instead of returning "".

Also: parseFonts() falls back to h1/h2/title/hero display roles, and the
frame-worker + caption authoring docs spell out that only shipped font
files render — no system CJK/Devanagari families on the headless renderer.

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

* fix(hyperframes-media): enforce sign-in preflight on standalone BGM/TTS

A one-off "generate me a BGM" request went straight to local MusicGen
without recommending sign-in: bgm.md/tts.md framed the no-credential path
as an automatic fallback, so the generation path bypassed the Preflight
stop, and the preflight used a bare `hyperframes auth status` that isn't
on PATH in a fresh `npx skills` project.

- Preflight now applies to one-off generation as well as workflows, uses
  `npx hyperframes auth status`, and says: if the CLI can't run, still
  recommend signing in and STOP — never treat "no credential" as a silent
  green light for local generation.
- bgm.md and tts.md point at the Preflight before generating, reframing
  local generation as the fallback the user opts into, not a default.

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

* docs(auth): add Authentication & API keys guide

Document signing in, the keys each capability (voice, music, capture)
uses, their resolution priority, and the fully local fallback. Add the
guide to the nav and cross-link it from the cloud deploy note and the
CLI env-var reference.

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

* fix(lint): strip HTML comments in a fixpoint loop (CodeQL)

Single-pass <!-- --> removal can re-form a complete comment from
adjacent markers (e.g. `<<!-- -->!-- ... -->`), letting a decoy
<template> survive and hijack the template-boundary match. Loop to a
fixpoint, mirroring the captions.mjs precedent; add a regression test
that fails on single-pass (2 root findings) and passes on the loop.

Also wrap the build-frame.mjs node:fs imports to satisfy oxfmt — the
new copyFileSync import pushed the line past the width limit, which
was the sole cause of the Format / Preflight CI failures.

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

* fix(lint): strip HTML comments with a linear scan (CodeQL ReDoS)

The fixpoint loop still ran a /<!--[\s\S]*?-->/ regex per pass, which
backtracks O(n^2) on inputs with many unterminated "<!--" — CodeQL
js/polynomial-redos (high). Looping the same regex (the prescribed
fix) never addressed this; only the regex itself does.

Replace it with an indexOf-based linear strip in utils.ts
(stripHtmlComments), kept in a fixpoint loop so markers that re-form
when a comment is removed are still stripped. 200k unterminated
"<!--" now strips in ~3ms instead of quadratic time; behavior is
otherwise unchanged — unterminated comments are kept verbatim, as the
old regex left them. The re-forming regression test still guards it.

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

* fix(auth): make TTS/BGM sign-in guidance accurate and runnable

From team review of the not-signed-in onboarding:

- OAuth is a `hyperframes auth login` feature only. The separate `heygen`
  CLI is API-key-only — `heygen auth login` stores a pasted key, it is not
  OAuth and does not create an account. Stop presenting the two CLIs as the
  same OAuth/sign-up step.
- Use `npx hyperframes` in every imperative and runtime hint. Bare
  `hyperframes` is not on PATH on a fresh machine (command not found); only
  `npx hyperframes` is guaranteed. Also updates the JSON recommended_action.
- Drop `heygen auth login` from the terminal/skill onboarding: it needs its
  own install and there is no `npx heygen`, so it was a command-not-found
  trap. The shared-credential fact stays in the reference docs.

Covers the `auth status` guidance + tests, the Authentication docs, the
shared hyperframes-media preflight (SKILL, requirements, tts, error hints),
and the `npx hyperframes auth status` preflight in every TTS/BGM workflow
(pr-to-video, product-launch-video, faceless-explainer, website-to-video,
music-to-video).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:20:39 +08:00

292 lines
12 KiB
JavaScript

#!/usr/bin/env node
// build-frame.mjs — Step 2 design system in ONE command. The LLM only chooses a
// preset; this does the deterministic rest: copy the preset's FRAME.md → frame.md,
// remix its colors/typography onto the project's brand tokens, copy the preset's
// caption-skin.html, and self-validate. "Strict on brand" is deterministic, so it's
// a script, not LLM hand-editing (which mis-copies hex / breaks keys).
//
// node build-frame.mjs --preset capsule --hyperframes .
// [--tokens capture/extracted/tokens.json] [--preset-dir <abs path to frame-presets>]
//
// Remix rule — ONLY `colors:` values and `typography:` fontFamily change; keys,
// structure, geometry, and components are untouched:
// colors — map brand tokens onto the preset's keys BY ROLE: the ink-role key takes
// the brand ink (darkest/ink-named), the canvas-role key takes the brand
// canvas (lightest), and every other color is repainted with the nearest
// brand accent's hue+saturation while KEEPING its own lightness, so tint
// families (sun / sun-soft / haze) stay a family. Empty brand colors → the
// preset palette is kept (it is already a complete, good design).
// fonts — the preset's display family → the brand display font, its body family →
// the brand body font, wherever they appear. Empty brand fonts → kept.
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
brandRolesFromStats,
chroma,
lum,
parseColors,
parseFonts,
pickAccent,
semanticColors,
UA_DEFAULT_COLORS,
} from "./lib/tokens.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const flag = (name, def) => {
const i = argv.indexOf(`--${name}`);
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
};
const die = (m) => {
console.error(`✗ build-frame: ${m}`);
process.exit(1);
};
const presetName = flag("preset", null);
const hyperframesDir = resolve(flag("hyperframes", "."));
const presetDir = resolve(
flag("preset-dir", join(__dirname, "../../hyperframes-creative/frame-presets")),
);
const tokensPath = resolve(flag("tokens", join(hyperframesDir, "capture/extracted/tokens.json")));
if (!presetName) die("--preset <name> is required");
const presetFrame = join(presetDir, presetName, "FRAME.md");
if (!existsSync(presetFrame)) {
const avail = existsSync(presetDir)
? readdirSync(presetDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
: [];
die(
`no FRAME.md for preset "${presetName}" under ${presetDir}\n available: ${avail.join(", ")}`,
);
}
// ── HSL helpers (recolor = brand hue+sat, original lightness) ──────────────────
function hexToHsl(hex) {
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex).trim());
if (!m) return null;
const n = parseInt(m[1], 16);
const r = ((n >> 16) & 255) / 255,
g = ((n >> 8) & 255) / 255,
b = (n & 255) / 255;
const max = Math.max(r, g, b),
min = Math.min(r, g, b),
d = max - min;
let h = 0;
const l = (max + min) / 2;
const s = d === 0 ? 0 : l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (d !== 0) {
h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
h *= 60;
}
return { h, s, l };
}
function hslToHex(h, s, l) {
h = (((h % 360) + 360) % 360) / 360;
const hue = (p, q, t) => {
t = (t + 1) % 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue(p, q, h + 1 / 3);
g = hue(p, q, h);
b = hue(p, q, h - 1 / 3);
}
const to = (x) =>
Math.round(x * 255)
.toString(16)
.padStart(2, "0")
.toUpperCase();
return `#${to(r)}${to(g)}${to(b)}`;
}
const hueDist = (a, b) => {
const d = Math.abs(a - b) % 360;
return d > 180 ? 360 - d : d;
};
// ── brand tokens ──────────────────────────────────────────────────────────────
let brandColors = [];
let brandFonts = [];
let brandColorStats = []; // rich per-color usage stats (areaBg / interactiveBg / textCount …)
if (existsSync(tokensPath)) {
try {
const t = JSON.parse(readFileSync(tokensPath, "utf8"));
brandColors = (t.colors ?? [])
.map((c) => (typeof c === "string" ? c : (c?.hex ?? c?.value ?? "")))
.map((c) => String(c).trim())
.filter((c) => /^#?[0-9a-fA-F]{6}$/.test(c))
.map((c) => (c.startsWith("#") ? c : `#${c}`));
brandFonts = (t.fonts ?? [])
.map((f) => (typeof f === "string" ? f : (f?.family ?? f?.name ?? "")))
.map((f) => String(f).split(",")[0].replace(/['"]/g, "").trim())
.filter(Boolean);
brandColorStats = Array.isArray(t.colorStats) ? t.colorStats : [];
} catch (e) {
die(`tokens.json parse: ${e.message}`);
}
}
let md = readFileSync(presetFrame, "utf8");
const presetColors = parseColors(md);
const summary = [];
// ── color remix ───────────────────────────────────────────────────────────────
if (brandColors.length && presetColors.length) {
const pr = semanticColors(presetColors);
// Brand roles: prefer the function-based reading of capture colorStats (canvas =
// largest background, accent = top interactive bg, ink = dominant contrasting text).
// 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) ??
(() => {
// 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()));
const s = semanticColors(clean.map((h, i) => [`c${i}`, h]));
return {
ink: s.ink,
canvas: s.canvas,
accent: pickAccent(brandColorStats, clean, [s.ink, s.canvas]) ?? s.accent,
accent2: s.accent2,
};
})();
if (!br.accent) die("accent 选取失败:品牌色里没有可用的强调色");
if (chroma(br.accent) <= 40) {
console.warn(
` ⚠ 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.
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 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;
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.
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;
}
if (next !== val) newByKey.set(key, next);
}
// rewrite only the value of each colors: line; everything else byte-identical.
let inBlock = false;
md = md
.split(/\r?\n/)
.map((line) => {
if (/^colors:\s*$/.test(line)) {
inBlock = true;
return line;
}
if (inBlock && /^\S/.test(line)) inBlock = false;
if (!inBlock) return line;
const m = line.match(
/^(\s+)([\w-]+):\s*(?:"[^"]*"|'[^']*'|#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|[^#\n]*?)(\s+#.*)?$/,
);
if (m && newByKey.has(m[2])) return `${m[1]}${m[2]}: "${newByKey.get(m[2])}"${m[3] ?? ""}`;
return line;
})
.join("\n");
summary.push(
`colors: dark ${prDark}${brDark}, light ${prLight}${brLight}, accent ${pr.accent}${br.accent}` +
` (${newByKey.size}/${presetColors.length} keys repainted${brandColorStats.length ? ", via colorStats" : ""})`,
);
} else {
summary.push(
brandColors.length
? "colors: preset has no parseable colors — kept"
: "colors: no brand colors — preset palette kept",
);
}
// ── font remix ────────────────────────────────────────────────────────────────
if (brandFonts.length) {
const pf = parseFonts(md);
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}`);
} else {
summary.push("fonts: no brand fonts — preset fonts kept");
}
// ── write frame.md ────────────────────────────────────────────────────────────
const framePath = join(hyperframesDir, "frame.md");
writeFileSync(framePath, md);
// ── copy caption-skin.html ────────────────────────────────────────────────────
const presetSkin = join(presetDir, presetName, "caption-skin.html");
let skinCopied = false;
if (existsSync(presetSkin)) {
const skinDir = join(hyperframesDir, ".hyperframes");
mkdirSync(skinDir, { recursive: true });
copyFileSync(presetSkin, join(skinDir, "caption-skin.html"));
skinCopied = true;
}
// ── self-validate ─────────────────────────────────────────────────────────────
const outColors = parseColors(md);
if (outColors.length !== presetColors.length) {
die(`color keys changed (${presetColors.length}${outColors.length}) — keys must be preserved`);
}
const outRoles = semanticColors(outColors);
const li = lum(outRoles.ink),
lc = lum(outRoles.canvas);
if (li != null && lc != null && li >= lc) {
die(
`ink (${outRoles.ink}, lum ${li.toFixed(0)}) is not darker than canvas (${outRoles.canvas}, lum ${lc.toFixed(0)}) — bad brand mapping`,
);
}
console.log(`✓ build-frame: ${presetName}${framePath}`);
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 ✓`);