Files
hyperframes/skills/faceless-explainer/scripts/lib/tokens.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

175 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tokens.mjs — shared brand-token parsing + semantic role mapping for frame.md /
// FRAME.md. Used by build-frame.mjs (remix a preset onto brand tokens) and
// captions.mjs (derive caption colors from frame.md). One mapping → frames and
// captions stay consistent. Pure node.
// Collect `key: value` pairs under the top-level `colors:` block (until dedent).
export function parseColors(md) {
const out = [];
let inBlock = false;
for (const line of md.split(/\r?\n/)) {
if (/^colors:\s*$/.test(line)) {
inBlock = true;
continue;
}
if (!inBlock) continue;
if (/^\S/.test(line)) break; // dedent to a top-level key → end of block
const m = line.match(
/^\s+([\w-]+):\s*(?:"([^"]+)"|'([^']+)'|(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|[^#\s][^#\n]*?))\s*(?:#.*)?$/,
);
if (m) out.push([m[1], (m[2] ?? m[3] ?? m[4]).trim()]);
}
return out;
}
// relative luminance of a #rrggbb (null for non-hex like rgba()).
export function lum(v) {
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(v).trim());
if (!m) return null;
const n = parseInt(m[1], 16);
return 0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255);
}
// chroma (maxmin channel) of a #rrggbb — a cheap "how colorful" proxy; 1 for non-hex.
export function chroma(v) {
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(v).trim());
if (!m) return -1;
const n = parseInt(m[1], 16);
const r = (n >> 16) & 255,
g = (n >> 8) & 255,
b = n & 255;
return Math.max(r, g, b) - Math.min(r, g, b);
}
// Browser user-agent default colors for links / visited links. These leak into a
// capture from any UNSTYLED <a> and are NOT brand colors — but being pure & saturated
// they beat a real accent on chroma alone. Never let one become the accent.
export const UA_DEFAULT_COLORS = new Set(
["#0000EE", "#0000FF", "#0000CC", "#1A0DAB", "#551A8B", "#EE0000"].map((c) => c.toUpperCase()),
);
// Pick the brand ACCENT — never by raw chroma alone, never a UA-default link color.
// Priority:
// 1) with capture colorStats → the colorful color used MOST as an interactive
// background (buttons / pills). That is, by definition, the brand action color.
// 2) no stats → most chromatic color AFTER removing UA defaults + `exclude`.
// A stray default link color (e.g. #0000EE) can win under neither path.
export function pickAccent(stats, colors, exclude = []) {
const ban = new Set([...exclude, ...UA_DEFAULT_COLORS].map((c) => String(c).toUpperCase()));
const ok = (h) => /^#[0-9a-fA-F]{6}$/.test(String(h)) && !ban.has(String(h).toUpperCase());
if (Array.isArray(stats) && stats.length) {
const a = stats
.filter((s) => ok(s?.hex) && (s.interactiveBg || 0) > 0 && chroma(s.hex) > 40)
.sort(
(x, y) => (y.interactiveBg || 0) - (x.interactiveBg || 0) || chroma(y.hex) - chroma(x.hex),
);
if (a.length) return a[0].hex;
}
const c = (colors ?? [])
.map(String)
.filter(ok)
.sort((x, y) => chroma(y) - chroma(x));
return c[0];
}
// Derive brand roles from rich capture colorStats (areaBg / interactiveBg / textCount /
// maxArea) — by semantic FUNCTION, not luminance/chroma proxies. Returns null when stats
// are unusable, so the caller can fall back. canvas = the color painting the most real
// background area (the page ground, dark or light); ink = the dominant text color that
// actually contrasts with the canvas; accent via pickAccent.
export function brandRolesFromStats(stats) {
if (!Array.isArray(stats) || !stats.length) return null;
const v = stats.filter((s) => /^#[0-9a-fA-F]{6}$/.test(s?.hex || ""));
if (!v.length) return null;
const canvas = [...v].sort(
(a, b) =>
(b.areaBg || 0) - (a.areaBg || 0) ||
(b.maxArea || 0) - (a.maxArea || 0) ||
(b.bgCount || 0) - (a.bgCount || 0),
)[0]?.hex;
const accent = pickAccent(
v,
v.map((s) => s.hex),
[canvas],
);
if (!canvas || !accent) return null;
const cl = lum(canvas) ?? 0;
const ink =
[...v]
.filter((s) => s.hex !== canvas && s.hex !== accent)
.sort((a, b) => (b.textCount || 0) - (a.textCount || 0))
.find((s) => Math.abs((lum(s.hex) ?? 0) - cl) > 64)?.hex ??
(cl > 128 ? "#000000" : "#FFFFFF");
const accent2 =
v
.filter(
(s) =>
![canvas, ink, accent].includes(s.hex) &&
(s.interactiveBg || 0) > 0 &&
chroma(s.hex) > 40 &&
!UA_DEFAULT_COLORS.has(s.hex.toUpperCase()),
)
.sort((a, b) => (b.interactiveBg || 0) - (a.interactiveBg || 0))[0]?.hex ?? accent;
return { ink, canvas, accent, accent2 };
}
// Map a list of [key, value] colors to semantic roles. ink = a dark/ink-named
// color (else darkest); canvas = a paper/cream/white-named color (else lightest);
// accents = whatever's left, ranked by chroma (the loudest color is almost always
// the brand accent) — UA-default link colors excluded so a stray <a> color never wins.
// For an unkeyed brand list, pass synthetic keys — name matching simply no-ops and it
// falls back to luminance/chroma, which is what we want. NOTE: when capture colorStats
// exist, prefer brandRolesFromStats() — it picks by function, not these proxies.
export function semanticColors(colors) {
if (!colors.length) return {};
const named = (re) => colors.find(([k]) => re.test(k));
const hexes = colors.filter(([, v]) => lum(v) != null);
const byLum = [...hexes].sort((a, b) => (lum(a[1]) ?? 1e9) - (lum(b[1]) ?? 1e9));
const pick = (m, fallback) => (m ? m[1] : fallback ? fallback[1] : undefined);
// "ink" must be a whole word-segment so "soft-pink"/"pink" don't match it.
const ink = pick(
named(/(?:^|[-_])ink(?:[-_]|$)|black|charcoal|^text(?:-dark)?$|outline|noir/i),
byLum[0] ?? colors[0],
);
const canvas = pick(
named(/cream|paper|canvas|white|bg|ground|surface|base|sand|parchment|off-?white|bone/i),
byLum[byLum.length - 1] ?? colors[colors.length - 1],
);
const accents = colors
.filter(([, v]) => v !== ink && v !== canvas && !UA_DEFAULT_COLORS.has(String(v).toUpperCase()))
.sort((a, b) => chroma(b[1]) - chroma(a[1]))
.map(([, v]) => v);
return { ink, canvas, accent: accents[0] ?? ink, accent2: accents[1] ?? accents[0] ?? ink };
}
// Collect role→fontFamily under the top-level `typography:` block; pick a display
// + body family from the usual role names. Returns quoted families (or null).
export function parseFonts(md) {
const roles = {};
let inBlock = false;
for (const line of md.split(/\r?\n/)) {
if (/^typography:\s*$/.test(line)) {
inBlock = true;
continue;
}
if (!inBlock) continue;
if (/^\S/.test(line)) break;
const m = line.match(/^\s+([\w-]+):\s*\{[^}]*fontFamily:\s*"([^"]+)"/);
if (m) roles[m[1]] = m[2];
}
const q = (s) => (s ? `"${s}"` : null);
const body = roles.body ?? roles.subtitle ?? Object.values(roles)[0];
const display =
roles.display ??
roles.headline ??
roles["card-headline"] ??
roles["section-headline"] ??
roles["quote-display"] ??
roles.h1 ??
roles.h2 ??
roles.title ??
roles.hero ??
body;
return { display: q(display), body: q(body) };
}