fix(skills): pipeline fixes from prompt-guide validation (BGM, caption accent, voice, PR version)

Behavior fixes surfaced by the prompt-guide validation campaign (Tier 1+2
of the upstream bug list; Tier 3 tracked in #2107). Split out from the
doc-only updates, which follow in a separate PR.

- BGM level: default bed volume under narration was 0.8 linear (~-2 dB,
  ~16 dB too hot vs voice). Now 0.12 (~-18 dB) via shared
  bgmDefaultVolume() in media-use bgm.mjs + assemble-index fallbacks in
  faceless-explainer / pr-to-video / product-launch-video. Explicit
  volume still wins; silent-film 0.9 and music-to-video unchanged.
  Adds bgm.test.mjs (3 cases); bgm.md reference updated to match.
- Caption accent: semanticColors() ranked accents purely by chroma, so a
  preserved status red (#dc2626) outranked the brand accent and captions
  highlighted in error-red. Status-keyed colors now excluded via shared
  STATUS_ROLE_KEY regex consumed by both tokens.mjs and build-frame.mjs
  (all three skill copies kept in sync).
- Voice threading: workflow SKILL.md Step 3.1 blocks now instruct
  choosing the narration voice from the user's ask and passing
  --voice <id>; previously "a male voice" was silently ignored and the
  default (Marcia/am_michael) always won.
- fetch-pr shipping version: MERGED PRs get best-effort shipped_version
  + version_source in pr.json (first release published at/after merge,
  else default-branch package.json marked unreleased); ingest surfaces
  it as a 'Shipped in:' brief line; story-design.md forbids inventing
  versions when absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-09 22:12:52 -07:00
co-authored by Claude Fable 5
parent d4b3bcaba3
commit c992a136bf
19 changed files with 212 additions and 40 deletions
+5 -5
View File
@@ -6,7 +6,7 @@
"files": 144
},
"faceless-explainer": {
"hash": "7f8f31695723756b",
"hash": "a8f964f864ee3a14",
"files": 18
},
"figma": {
@@ -46,8 +46,8 @@
"files": 10
},
"media-use": {
"hash": "6c4aa8649e1eaf99",
"files": 121
"hash": "74d80350ba0bccd9",
"files": 122
},
"motion-graphics": {
"hash": "0f1ac928e387a74c",
@@ -58,11 +58,11 @@
"files": 132
},
"pr-to-video": {
"hash": "800b4c11cda4658b",
"hash": "da8e3cbebcd4b674",
"files": 22
},
"product-launch-video": {
"hash": "937dcd6c581fb054",
"hash": "a88df1784155ace4",
"files": 20
},
"remotion-to-hyperframes": {
+3 -1
View File
@@ -105,7 +105,9 @@ Goal: Generate narration, word timings, music, and audio metadata from the appro
Start audio after Step 3 approval. Run it in the background, then continue to Step 4. (Sign-in status was already shown in Step 0; the engine falls back automatically.)
`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json &`
**Choose the narration voice from the user's ask before invoking.** If the request named a voice, gender, or tone, pick a matching voice id and pass it with `--voice <id>`. The pipeline default is otherwise **Marcia (female)** on HeyGen / `am_michael` on Kokoro — so a request like "a male voice" is silently ignored unless you pass the flag. Voice ids are provider-specific; resolve against whichever provider Step 0's sign-in status selected: **HeyGen** (signed in) via `node ../media-use/audio/scripts/heygen-tts.mjs --list` (or `GET /v3/voices?engine=starfish`); **Kokoro** (offline) via the voice table in `../media-use/audio/references/tts.md` (prefixes `am_`/`bm_` male, `af_`/`bf_` female). Omit `--voice` only when the user expressed no preference.
`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --voice <voice-id> &`
The audio script handles narration, word timings, BGM lookup from HeyGen's music library, and timing metadata. BGM mood comes from the storyboard's `music:` field. This uses the HeyGen Audio API for retrieval, not generation, and the same `~/.heygen` credential as TTS. For provider details, read `../media-use/audio/references/tts.md`.
@@ -23,7 +23,7 @@
// video, frames only). Durations come from STORYBOARD (audio sync-durations
// writes them), NOT from here; this file carries only media PATHS, keyed by
// frame number:
// { "bgm": { "path": "assets/bgm/x.mp3", "volume": 0.8 } | null,
// { "bgm": { "path": "assets/bgm/x.mp3", "volume": 0.12 } | null,
// "voices":[ { "frame": 3, "path": "assets/voice/03.wav" } ],
// "sfx": [ { "frame": 3, "file": "assets/sfx/x.mp3", "offset_s": 0,
// "duration_s": 1.0, "volume": 0.35 } ] }
@@ -388,7 +388,9 @@ if (audio.bgm?.path) {
`bgm is ${cov.dur?.toFixed?.(1) ?? "?"}s (< ${TOTAL}s) and could not be extended (${cov.reason}) — the tail will be silent; install ffmpeg`,
);
}
const vol = audio.bgm.volume != null ? audio.bgm.volume : voiceCount > 0 ? 0.8 : 0.9;
// An explicit volume from audio_meta always wins. Otherwise BGM under
// narration is a bed (0.12 ≈ -18 dB); a silent film sits it forward at 0.9.
const vol = audio.bgm.volume != null ? audio.bgm.volume : voiceCount > 0 ? 0.12 : 0.9;
body.push(
` <!-- BGM -->`,
` <audio`,
@@ -37,6 +37,7 @@ import {
parseFonts,
pickAccent,
semanticColors,
STATUS_ROLE_KEY,
UA_DEFAULT_COLORS,
} from "./lib/tokens.mjs";
@@ -253,11 +254,7 @@ if (brandColors.length && presetColors.length) {
let next;
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,
)
)
else if (STATUS_ROLE_KEY.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.
@@ -48,6 +48,14 @@ export const UA_DEFAULT_COLORS = new Set(
["#0000EE", "#0000FF", "#0000CC", "#1A0DAB", "#551A8B", "#EE0000"].map((c) => c.toUpperCase()),
);
// Semantic STATUS roles (green "positive", red "negative"/"error", amber "warning" …). Their HUE
// carries the meaning, so they are never a brand ACCENT — a status red is frequently the most
// chromatic color in a palette (e.g. #dc2626 chroma 182 beats a deep-blue accent #1E40AF chroma
// 145) and would otherwise win a pure chroma ranking, painting captions/highlights the error red.
// build-frame.mjs uses this same key set to protect status colors during the preset→brand remix.
export const STATUS_ROLE_KEY =
/(?:^|[-_])(?:positive|negative|success|error|warning|danger|good|bad|up|down)(?:[-_]|$)/i;
// Pick the brand ACCENT — never by raw chroma alone, never a UA-default link color.
// Priority:
// 1) with capture colorStats → the colorful color that RECURS across the UI. The brand
@@ -134,7 +142,8 @@ export function brandRolesFromStats(stats, colorsInOrder) {
// 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.
// the brand accent) — UA-default link colors AND semantic status colors (positive/
// negative/error…) excluded so neither a stray <a> color nor a status red ever 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.
@@ -154,7 +163,13 @@ export function semanticColors(colors) {
byLum[byLum.length - 1] ?? colors[colors.length - 1],
);
const accents = colors
.filter(([, v]) => v !== ink && v !== canvas && !UA_DEFAULT_COLORS.has(String(v).toUpperCase()))
.filter(
([k, v]) =>
v !== ink &&
v !== canvas &&
!UA_DEFAULT_COLORS.has(String(v).toUpperCase()) &&
!STATUS_ROLE_KEY.test(k), // a status red/green carries meaning by hue — never an accent
)
.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 };
+2 -2
View File
@@ -22,14 +22,14 @@ One music bed per composition, produced by the shared audio engine (`scripts/aud
```jsonc
{
"path": "assets/bgm/track.mp3",
"volume": 0.8,
"volume": 0.12,
"mode": "retrieve",
"query": "calm cinematic underscore",
"duration_s": 42.0,
}
```
`volume` is 0.8 under narration, 0.9 for a silent film (no voice). `bgm_pending` is `false` — the file is on disk when the engine returns.
`volume` is 0.12 (≈ -18 dB — a bed under the voice) under narration, 0.9 for a silent film (no voice). An explicit `volume` in `audio_meta.json` always overrides this default. `bgm_pending` is `false` — the file is on disk when the engine returns.
## Local generation (fallback) — Lyria → MusicGen
+9 -2
View File
@@ -20,6 +20,13 @@ import { pythonInvocation } from "./python.mjs";
const r3 = (x) => Number(x.toFixed(3));
const lyriaKey = () => process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
// Default BGM level. Under narration music is a bed that must stay under the
// voice — 0.12 linear ≈ -18 dB. A silent film (no voice) has no voice to duck
// beneath, so BGM sits forward at 0.9. Callers may override per composition.
export const BGM_BED_VOLUME = 0.12;
export const BGM_SILENT_VOLUME = 0.9;
export const bgmDefaultVolume = (hasVoice) => (hasVoice ? BGM_BED_VOLUME : BGM_SILENT_VOLUME);
const BGM_PY_DEPS = ["transformers", "torch", "soundfile", "numpy"];
const BGM_PY_PROBE =
"import transformers, soundfile, torch, numpy; from transformers import MusicgenForConditionalGeneration";
@@ -51,7 +58,7 @@ export async function retrieveBgm({ query, headers, hyperframesDir, hasVoice })
await downloadTo(top.audio_url, join(hyperframesDir, rel));
return {
path: rel,
volume: hasVoice ? 0.8 : 0.9,
volume: bgmDefaultVolume(hasVoice),
query: q,
mode: "retrieve",
duration_s: typeof top.duration === "number" ? r3(top.duration) : null,
@@ -116,7 +123,7 @@ export function generateBgmDetached({
mkdirSync(join(hyperframesDir, "assets", "bgm"), { recursive: true });
const log = join(hyperframesDir, "assets", "bgm", `bgm-${Date.now()}.log`);
const targetS = Math.max(1, durationS);
const baseMeta = { path: rel, mode: null, volume: hasVoice ? 0.8 : 0.9, pending: true };
const baseMeta = { path: rel, mode: null, volume: bgmDefaultVolume(hasVoice), pending: true };
const lyriaConfigured = !!lyriaKey() && !!lyriaRecipe && existsSync(lyriaRecipe);
@@ -0,0 +1,30 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { BGM_BED_VOLUME, BGM_SILENT_VOLUME, bgmDefaultVolume } from "./bgm.mjs";
// Regression: narrated pipelines used to ship BGM at 0.8 (≈ -2 dB), ~16 dB
// hotter than a music bed under a voice should be. The default under narration
// must be a proper bed (≈ -18 dB); a silent film keeps the louder default.
const dbfs = (linear) => 20 * Math.log10(linear);
test("BGM under narration is a bed near -18 dB", () => {
assert.equal(bgmDefaultVolume(true), BGM_BED_VOLUME);
assert.equal(BGM_BED_VOLUME, 0.12);
const db = dbfs(BGM_BED_VOLUME);
assert.ok(db < -17 && db > -19, `bed should be ≈ -18 dB, got ${db.toFixed(1)} dB`);
});
test("a silent film (no voice) keeps BGM forward", () => {
assert.equal(bgmDefaultVolume(false), BGM_SILENT_VOLUME);
assert.equal(BGM_SILENT_VOLUME, 0.9);
});
test("the narrated default is well below the voice (≈ 0 dBFS)", () => {
// Voice sits at data-volume="1" (0 dBFS); the bed must be ~16+ dB under it.
const separation = dbfs(1) - dbfs(bgmDefaultVolume(true));
assert.ok(
separation >= 16,
`bed should sit ≥16 dB under the voice, got ${separation.toFixed(1)} dB`,
);
});
+4 -2
View File
@@ -70,7 +70,7 @@ Initialize only if `hyperframes.json` is missing. Name `<project>` from the PR i
## Step 1: Ingest the PR (no capture)
Goal: Fetch the PR's facts and fold them into the project as the source of information. There is **no website capture**. `fetch-pr.mjs` runs `gh` deterministically — completing the files list via paginated `gh api` so a large PR doesn't truncate at ~100 files, and writing only `capture/pr.json` + `capture/diff.patch` (no scratch dir). Then `ingest.mjs` folds that into the synthetic capture package offline.
Goal: Fetch the PR's facts and fold them into the project as the source of information. There is **no website capture**. `fetch-pr.mjs` runs `gh` deterministically — completing the files list via paginated `gh api` so a large PR doesn't truncate at ~100 files, and writing only `capture/pr.json` + `capture/diff.patch` (no scratch dir). For MERGED PRs it also resolves a best-effort `shipped_version` (+ `version_source`) into `pr.json`, so the end card can cite a real version instead of inventing one. Then `ingest.mjs` folds that into the synthetic capture package offline.
```bash
PR="<url | owner/repo#N | N>"
@@ -133,7 +133,9 @@ Goal: Generate narration, word timings, music, and audio metadata from the appro
Start audio after Step 3 approval. Run it in the background, then continue to Step 4.
`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json &`
**Choose the narration voice from the user's ask before invoking.** If the request named a voice, gender, or tone, pick a matching voice id and pass it with `--voice <id>`. The pipeline default is otherwise **Marcia (female)** on HeyGen / `am_michael` on Kokoro — so a request like "a male voice" is silently ignored unless you pass the flag. Voice ids are provider-specific; resolve against whichever provider Step 0's sign-in status selected: **HeyGen** (signed in) via `node ../media-use/audio/scripts/heygen-tts.mjs --list` (or `GET /v3/voices?engine=starfish`); **Kokoro** (offline) via the voice table in `../media-use/audio/references/tts.md` (prefixes `am_`/`bm_` male, `af_`/`bf_` female). Omit `--voice` only when the user expressed no preference.
`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --voice <voice-id> &`
The audio script handles narration, word timings, BGM lookup from HeyGen's music library, and timing metadata. BGM mood comes from the storyboard's `music:` field. This uses the HeyGen Audio API for retrieval, not generation, and the same `~/.heygen` credential as TTS. For provider details, read `../media-use/audio/references/tts.md`.
@@ -161,6 +161,13 @@ The `credits` frame is an avatar row with names + roles + an "approved" check. O
Every other frame has **no** `asset_candidates` (the visuals are invented downstream from `scene` + the diff).
### Versions on the end card (cta / changelog)
A `cta` ("upgrade to vN", "npm i pkg@N") or a changelog "what's new in vN" wants a real version — and **a version is the one fact you must never invent.** A PR carries no shipping version, so Step 1 resolves a best-effort one for MERGED PRs and writes it into `capture/extracted/visible-text.txt` as a `Shipped in: <version> (<source>)` meta line (mirrored in `capture/pr.json` as `shipped_version` / `version_source`). Use it:
- **`Shipped in:` present** → use that exact version on the end card. A `version_source` of `unreleased` means the change is on the default branch but not yet in a tagged release — say "shipping in the next release" rather than pinning a tag.
- **No `Shipped in:` line** (open PR, or no version resolvable) → **state the repo / PR URL only** ("read the PR at github.com/…", "pull it") and do **not** name or guess a version number.
## Per-frame length budget — ≤ 9 s, word count is the real measurement
The largest quality bug in PR videos is **scripts that talk too long**. TTS runs at **~2.2 words/second**, so a 45-word "7-second" script is really 20 seconds, and the visual phase has to pad the tail with idle drift (the video reads as "shimmering"). Budget by word count:
@@ -23,7 +23,7 @@
// video, frames only). Durations come from STORYBOARD (audio sync-durations
// writes them), NOT from here; this file carries only media PATHS, keyed by
// frame number:
// { "bgm": { "path": "assets/bgm/x.mp3", "volume": 0.8 } | null,
// { "bgm": { "path": "assets/bgm/x.mp3", "volume": 0.12 } | null,
// "voices":[ { "frame": 3, "path": "assets/voice/03.wav" } ],
// "sfx": [ { "frame": 3, "file": "assets/sfx/x.mp3", "offset_s": 0,
// "duration_s": 1.0, "volume": 0.35 } ] }
@@ -388,7 +388,9 @@ if (audio.bgm?.path) {
`bgm is ${cov.dur?.toFixed?.(1) ?? "?"}s (< ${TOTAL}s) and could not be extended (${cov.reason}) — the tail will be silent; install ffmpeg`,
);
}
const vol = audio.bgm.volume != null ? audio.bgm.volume : voiceCount > 0 ? 0.8 : 0.9;
// An explicit volume from audio_meta always wins. Otherwise BGM under
// narration is a bed (0.12 ≈ -18 dB); a silent film sits it forward at 0.9.
const vol = audio.bgm.volume != null ? audio.bgm.volume : voiceCount > 0 ? 0.12 : 0.9;
body.push(
` <!-- BGM -->`,
` <audio`,
+2 -5
View File
@@ -37,6 +37,7 @@ import {
parseFonts,
pickAccent,
semanticColors,
STATUS_ROLE_KEY,
UA_DEFAULT_COLORS,
} from "./lib/tokens.mjs";
@@ -253,11 +254,7 @@ if (brandColors.length && presetColors.length) {
let next;
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,
)
)
else if (STATUS_ROLE_KEY.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.
+71
View File
@@ -9,6 +9,8 @@
// call — `gh pr view --json files` truncates at ~100 files, so a
// big PR would otherwise lose the tail. Commits keep gh pr view's
// rich `authors[]` (co-authors) — only `files` needs the override.
// For MERGED PRs it also stamps a best-effort `shipped_version`
// (+ `version_source`) so the end card / cta doesn't invent one.
// capture/diff.patch the full unified diff (`gh pr diff`).
//
// gh runs HERE so auth / not-found / private-repo errors surface with gh's own stderr
@@ -82,6 +84,9 @@ const FIELDS = [
"assignees",
"reviewDecision",
"mergedBy",
"state",
"mergedAt",
"mergeCommit",
].join(",");
const view = ghTry(["pr", "view", prRef, "--json", FIELDS]);
@@ -136,6 +141,71 @@ if (owner && repo && number != null) {
console.error(" (warn: could not parse owner/repo from PR url — keeping pr view's files)");
}
// ── 2.5 best-effort shipping version (MERGED PRs only) ───────────────────────
// The end card / cta ("upgrade to vN", "what's new in vN") wants a real version;
// a PR carries none, so the agent would otherwise guess. We resolve one here and
// stamp it onto pr.json as `shipped_version` (+ a `version_source` note that keeps
// it honest). `git tag --contains` isn't available on a remote-only fetch, so we
// use gh api proxies: the first release published at/after the merge is the first
// tag that can contain the merge commit; failing that, the default branch's
// package manifest version (unreleased); else null. Always best-effort — a lookup
// failure just leaves the fields null (the skill then falls back to the repo URL).
pr.shipped_version = null;
pr.version_source = null;
if (pr.state === "MERGED") {
const mergedAt = pr.mergedAt ? Date.parse(pr.mergedAt) : NaN;
// (a) earliest non-draft release published on/after the merge.
if (owner && repo && !Number.isNaN(mergedAt)) {
const rel = ghTry([
"api",
"--paginate",
`repos/${owner}/${repo}/releases`,
"--jq",
".[] | select(.draft == false) | {tag: .tag_name, published: .published_at}",
]);
if (rel.ok) {
let best = null;
for (const line of rel.stdout.split("\n").filter(Boolean)) {
let r;
try {
r = JSON.parse(line);
} catch {
continue;
}
if (!r?.tag || !r?.published) continue;
const t = Date.parse(r.published);
if (Number.isNaN(t) || t < mergedAt) continue;
if (!best || t < best.t) best = { tag: r.tag, t };
}
if (best) {
pr.shipped_version = best.tag;
pr.version_source = "first release published at/after merge";
}
} else {
console.error(` (warn: gh api releases failed: ${rel.stderr.split("\n")[0]})`);
}
}
// (b) fallback — default branch's package manifest version (change merged but not
// yet in a tagged release). Marked as unreleased so the skill doesn't present
// it as a shipped tag.
if (pr.shipped_version == null && owner && repo) {
const pkg = ghTry(["api", `repos/${owner}/${repo}/contents/package.json`, "--jq", ".content"]);
if (pkg.ok && pkg.stdout.trim()) {
try {
const manifest = JSON.parse(Buffer.from(pkg.stdout.trim(), "base64").toString("utf8"));
if (manifest?.version) {
pr.shipped_version = String(manifest.version);
pr.version_source = "default-branch package.json (unreleased)";
}
} catch {
/* not JSON / no version — leave null */
}
}
}
}
// ── 3. write capture/pr.json + capture/diff.patch ────────────────────────────
mkdirSync(outDir, { recursive: true });
const prJsonPath = join(outDir, "pr.json");
@@ -159,6 +229,7 @@ console.log(
[
`✓ fetch-pr: ${repoLabel} PR #${number ?? "?"} — "${(pr.title || "").slice(0, 72)}"`,
` files: ${filesNote}; diff: ${diff.ok ? `${diff.stdout.length} chars` : "MISSING"}`,
` shipped_version: ${pr.shipped_version ?? "null"}${pr.version_source ? ` (${pr.version_source})` : ""}`,
` wrote ${prJsonPath}${diff.ok ? ` + ${diffPath}` : ""}`,
].join("\n"),
);
+9
View File
@@ -13,6 +13,7 @@
// --pr-json <path> gh pr view --json number,title,body,author,url,baseRefName,
// headRefName,commits,files,additions,deletions,changedFiles,labels,
// reviews,latestReviews,comments,assignees,reviewDecision,mergedBy
// + fetch-pr.mjs's best-effort shipped_version / version_source
// --diff <path> gh pr diff (raw unified diff) [optional — brief still builds without it]
// Writes (under --out-dir, default ./capture/extracted):
// tokens.json synthetic design tokens (colors:[] → claude native palette)
@@ -253,6 +254,12 @@ const people = [...peopleMap.values()]
const reviewDecision = pr.reviewDecision || null;
const mergedByLogin = pr.mergedBy?.login || null;
// Best-effort shipping version stamped by fetch-pr.mjs (MERGED PRs only). Surfaced
// in the brief so the end card / cta cites a real version instead of inventing one;
// null means "no version known — the close names the repo URL only" (see story-design.md).
const shippedVersion = typeof pr.shipped_version === "string" ? pr.shipped_version : null;
const versionSource = typeof pr.version_source === "string" ? pr.version_source : null;
// ---------- clean body ----------
function cleanBody(raw) {
if (!raw || typeof raw !== "string") return "";
@@ -406,6 +413,8 @@ lines.push(
);
if (labels.length) lines.push(`Labels: ${labels.join(", ")}`);
if (url) lines.push(`URL: ${url}`);
if (shippedVersion)
lines.push(`Shipped in: ${shippedVersion}${versionSource ? ` (${versionSource})` : ""}`);
lines.push("");
// People & reviews — human context for an optional credits / shipped-by close.
+17 -2
View File
@@ -48,6 +48,14 @@ export const UA_DEFAULT_COLORS = new Set(
["#0000EE", "#0000FF", "#0000CC", "#1A0DAB", "#551A8B", "#EE0000"].map((c) => c.toUpperCase()),
);
// Semantic STATUS roles (green "positive", red "negative"/"error", amber "warning" …). Their HUE
// carries the meaning, so they are never a brand ACCENT — a status red is frequently the most
// chromatic color in a palette (e.g. #dc2626 chroma 182 beats a deep-blue accent #1E40AF chroma
// 145) and would otherwise win a pure chroma ranking, painting captions/highlights the error red.
// build-frame.mjs uses this same key set to protect status colors during the preset→brand remix.
export const STATUS_ROLE_KEY =
/(?:^|[-_])(?:positive|negative|success|error|warning|danger|good|bad|up|down)(?:[-_]|$)/i;
// Pick the brand ACCENT — never by raw chroma alone, never a UA-default link color.
// Priority:
// 1) with capture colorStats → the colorful color that RECURS across the UI. The brand
@@ -134,7 +142,8 @@ export function brandRolesFromStats(stats, colorsInOrder) {
// 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.
// the brand accent) — UA-default link colors AND semantic status colors (positive/
// negative/error…) excluded so neither a stray <a> color nor a status red ever 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.
@@ -154,7 +163,13 @@ export function semanticColors(colors) {
byLum[byLum.length - 1] ?? colors[colors.length - 1],
);
const accents = colors
.filter(([, v]) => v !== ink && v !== canvas && !UA_DEFAULT_COLORS.has(String(v).toUpperCase()))
.filter(
([k, v]) =>
v !== ink &&
v !== canvas &&
!UA_DEFAULT_COLORS.has(String(v).toUpperCase()) &&
!STATUS_ROLE_KEY.test(k), // a status red/green carries meaning by hue — never an accent
)
.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 };
+3 -1
View File
@@ -106,7 +106,9 @@ Goal: Generate narration, word timings, music, and audio metadata from the appro
Start audio after Step 3 approval. Run it in the background, then continue to Step 4.
`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json &`
**Choose the narration voice from the user's ask before invoking.** If the request named a voice, gender, or tone, pick a matching voice id and pass it with `--voice <id>`. The pipeline default is otherwise **Marcia (female)** on HeyGen / `am_michael` on Kokoro — so a request like "a male voice" is silently ignored unless you pass the flag. Voice ids are provider-specific; resolve against whichever provider Step 0's sign-in status selected: **HeyGen** (signed in) via `node ../media-use/audio/scripts/heygen-tts.mjs --list` (or `GET /v3/voices?engine=starfish`); **Kokoro** (offline) via the voice table in `../media-use/audio/references/tts.md` (prefixes `am_`/`bm_` male, `af_`/`bf_` female). Omit `--voice` only when the user expressed no preference.
`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --voice <voice-id> &`
The audio script handles narration, word timings, BGM lookup from HeyGen's music library, and timing metadata. BGM mood comes from the storyboard's `music:` field. This uses the HeyGen Audio API for retrieval, not generation, and uses the same `~/.heygen` credential as TTS. For provider details, read `../media-use/audio/references/tts.md`.
@@ -23,7 +23,7 @@
// video, frames only). Durations come from STORYBOARD (audio sync-durations
// writes them), NOT from here; this file carries only media PATHS, keyed by
// frame number:
// { "bgm": { "path": "assets/bgm/x.mp3", "volume": 0.8 } | null,
// { "bgm": { "path": "assets/bgm/x.mp3", "volume": 0.12 } | null,
// "voices":[ { "frame": 3, "path": "assets/voice/03.wav" } ],
// "sfx": [ { "frame": 3, "file": "assets/sfx/x.mp3", "offset_s": 0,
// "duration_s": 1.0, "volume": 0.35 } ] }
@@ -388,7 +388,9 @@ if (audio.bgm?.path) {
`bgm is ${cov.dur?.toFixed?.(1) ?? "?"}s (< ${TOTAL}s) and could not be extended (${cov.reason}) — the tail will be silent; install ffmpeg`,
);
}
const vol = audio.bgm.volume != null ? audio.bgm.volume : voiceCount > 0 ? 0.8 : 0.9;
// An explicit volume from audio_meta always wins. Otherwise BGM under
// narration is a bed (0.12 ≈ -18 dB); a silent film sits it forward at 0.9.
const vol = audio.bgm.volume != null ? audio.bgm.volume : voiceCount > 0 ? 0.12 : 0.9;
body.push(
` <!-- BGM -->`,
` <audio`,
@@ -37,6 +37,7 @@ import {
parseFonts,
pickAccent,
semanticColors,
STATUS_ROLE_KEY,
UA_DEFAULT_COLORS,
} from "./lib/tokens.mjs";
@@ -253,11 +254,7 @@ if (brandColors.length && presetColors.length) {
let next;
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,
)
)
else if (STATUS_ROLE_KEY.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.
@@ -48,6 +48,14 @@ export const UA_DEFAULT_COLORS = new Set(
["#0000EE", "#0000FF", "#0000CC", "#1A0DAB", "#551A8B", "#EE0000"].map((c) => c.toUpperCase()),
);
// Semantic STATUS roles (green "positive", red "negative"/"error", amber "warning" …). Their HUE
// carries the meaning, so they are never a brand ACCENT — a status red is frequently the most
// chromatic color in a palette (e.g. #dc2626 chroma 182 beats a deep-blue accent #1E40AF chroma
// 145) and would otherwise win a pure chroma ranking, painting captions/highlights the error red.
// build-frame.mjs uses this same key set to protect status colors during the preset→brand remix.
export const STATUS_ROLE_KEY =
/(?:^|[-_])(?:positive|negative|success|error|warning|danger|good|bad|up|down)(?:[-_]|$)/i;
// Pick the brand ACCENT — never by raw chroma alone, never a UA-default link color.
// Priority:
// 1) with capture colorStats → the colorful color that RECURS across the UI. The brand
@@ -134,7 +142,8 @@ export function brandRolesFromStats(stats, colorsInOrder) {
// 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.
// the brand accent) — UA-default link colors AND semantic status colors (positive/
// negative/error…) excluded so neither a stray <a> color nor a status red ever 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.
@@ -154,7 +163,13 @@ export function semanticColors(colors) {
byLum[byLum.length - 1] ?? colors[colors.length - 1],
);
const accents = colors
.filter(([, v]) => v !== ink && v !== canvas && !UA_DEFAULT_COLORS.has(String(v).toUpperCase()))
.filter(
([k, v]) =>
v !== ink &&
v !== canvas &&
!UA_DEFAULT_COLORS.has(String(v).toUpperCase()) &&
!STATUS_ROLE_KEY.test(k), // a status red/green carries meaning by hue — never an accent
)
.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 };