From c992a136bf74cd9e87ba23fc824448f1a9e5c94c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 11:51:44 -0700 Subject: [PATCH 1/2] 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 ; 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 --- skills-manifest.json | 10 +-- skills/faceless-explainer/SKILL.md | 4 +- .../scripts/assemble-index.mjs | 6 +- .../scripts/build-frame.mjs | 7 +- .../faceless-explainer/scripts/lib/tokens.mjs | 19 ++++- skills/media-use/audio/references/bgm.md | 4 +- skills/media-use/audio/scripts/lib/bgm.mjs | 11 ++- .../media-use/audio/scripts/lib/bgm.test.mjs | 30 ++++++++ skills/pr-to-video/SKILL.md | 6 +- skills/pr-to-video/references/story-design.md | 7 ++ skills/pr-to-video/scripts/assemble-index.mjs | 6 +- skills/pr-to-video/scripts/build-frame.mjs | 7 +- skills/pr-to-video/scripts/fetch-pr.mjs | 71 +++++++++++++++++++ skills/pr-to-video/scripts/ingest.mjs | 9 +++ skills/pr-to-video/scripts/lib/tokens.mjs | 19 ++++- skills/product-launch-video/SKILL.md | 4 +- .../scripts/assemble-index.mjs | 6 +- .../scripts/build-frame.mjs | 7 +- .../scripts/lib/tokens.mjs | 19 ++++- 19 files changed, 212 insertions(+), 40 deletions(-) create mode 100644 skills/media-use/audio/scripts/lib/bgm.test.mjs diff --git a/skills-manifest.json b/skills-manifest.json index 707b5c5f76..fb62ea64dc 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -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": { diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index 35f02db3fb..2f965990e8 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -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 /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 `. 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 /scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --voice &` 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`. diff --git a/skills/faceless-explainer/scripts/assemble-index.mjs b/skills/faceless-explainer/scripts/assemble-index.mjs index 0648a39135..06b910b386 100644 --- a/skills/faceless-explainer/scripts/assemble-index.mjs +++ b/skills/faceless-explainer/scripts/assemble-index.mjs @@ -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( ` `, ` 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 color never wins. +// the brand accent) — UA-default link colors AND semantic status colors (positive/ +// negative/error…) excluded so neither a stray 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 }; diff --git a/skills/media-use/audio/references/bgm.md b/skills/media-use/audio/references/bgm.md index 277626ca93..4e220f50d7 100644 --- a/skills/media-use/audio/references/bgm.md +++ b/skills/media-use/audio/references/bgm.md @@ -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 diff --git a/skills/media-use/audio/scripts/lib/bgm.mjs b/skills/media-use/audio/scripts/lib/bgm.mjs index d4db76e880..4c91d005f2 100644 --- a/skills/media-use/audio/scripts/lib/bgm.mjs +++ b/skills/media-use/audio/scripts/lib/bgm.mjs @@ -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); diff --git a/skills/media-use/audio/scripts/lib/bgm.test.mjs b/skills/media-use/audio/scripts/lib/bgm.test.mjs new file mode 100644 index 0000000000..46967605c7 --- /dev/null +++ b/skills/media-use/audio/scripts/lib/bgm.test.mjs @@ -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`, + ); +}); diff --git a/skills/pr-to-video/SKILL.md b/skills/pr-to-video/SKILL.md index 8d2d3494bb..4d6063c05c 100644 --- a/skills/pr-to-video/SKILL.md +++ b/skills/pr-to-video/SKILL.md @@ -70,7 +70,7 @@ Initialize only if `hyperframes.json` is missing. Name `` 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="" @@ -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 /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 `. 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 /scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --voice &` 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`. diff --git a/skills/pr-to-video/references/story-design.md b/skills/pr-to-video/references/story-design.md index ea92a0e68c..dfc3e02432 100644 --- a/skills/pr-to-video/references/story-design.md +++ b/skills/pr-to-video/references/story-design.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: ()` 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: diff --git a/skills/pr-to-video/scripts/assemble-index.mjs b/skills/pr-to-video/scripts/assemble-index.mjs index 0648a39135..06b910b386 100644 --- a/skills/pr-to-video/scripts/assemble-index.mjs +++ b/skills/pr-to-video/scripts/assemble-index.mjs @@ -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( ` `, ` 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 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. diff --git a/skills/pr-to-video/scripts/lib/tokens.mjs b/skills/pr-to-video/scripts/lib/tokens.mjs index 02b1ceda7b..5b58fc2151 100644 --- a/skills/pr-to-video/scripts/lib/tokens.mjs +++ b/skills/pr-to-video/scripts/lib/tokens.mjs @@ -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 color never wins. +// the brand accent) — UA-default link colors AND semantic status colors (positive/ +// negative/error…) excluded so neither a stray 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 }; diff --git a/skills/product-launch-video/SKILL.md b/skills/product-launch-video/SKILL.md index 50ef6c44cd..e6956b7d91 100644 --- a/skills/product-launch-video/SKILL.md +++ b/skills/product-launch-video/SKILL.md @@ -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 /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 `. 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 /scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --voice &` 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`. diff --git a/skills/product-launch-video/scripts/assemble-index.mjs b/skills/product-launch-video/scripts/assemble-index.mjs index c1b68e6caa..75e9652757 100644 --- a/skills/product-launch-video/scripts/assemble-index.mjs +++ b/skills/product-launch-video/scripts/assemble-index.mjs @@ -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( ` `, ` 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 color never wins. +// the brand accent) — UA-default link colors AND semantic status colors (positive/ +// negative/error…) excluded so neither a stray 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 }; From 23c9d15b6998fd118112cb1c5eb9fb7bdc87037a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 17:38:00 -0700 Subject: [PATCH 2/2] fix(skills): address PR #2110 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSOT: the three assemble-index.mjs BGM fallbacks now import bgmDefaultVolume() from media-use's bgm.mjs instead of duplicating the 0.12/0.9 literals (both reviewers). The cross-skill relative import matches the existing dependency (each workflow's audio.mjs adapter already resolves ../../media-use/audio/scripts/audio.mjs). - STATUS_ROLE_KEY: extended with info|neutral|alert|caution|critical — same hue-carries-meaning class as the original set (all 3 copies). - bgm.md: phrase the default as bgmDefaultVolume()/BGM_BED_VOLUME with "currently 0.12" so the prose survives future tuning. - fetch-pr.mjs: drop dead mergeCommit field from the gh pr view FIELDS list (version resolution uses mergedAt only). - music-to-video assemble-index.mjs: comment documenting why its BGM stays at 0.8 under VO — music is the content there, not a narration bed, so the explainer pipelines' 0.12 default deliberately does not apply. Not changed: pickAccent's chroma fallback — both call sites pass keyless capture palettes (tokens.json hex lists), so no status-role keys exist to filter on; the keyed preset path goes through semanticColors and the build-frame remix, which this PR already fixed. Co-Authored-By: Claude Fable 5 --- skills-manifest.json | 10 +++++----- skills/faceless-explainer/scripts/assemble-index.mjs | 7 ++++--- skills/faceless-explainer/scripts/lib/tokens.mjs | 2 +- skills/media-use/audio/references/bgm.md | 2 +- skills/music-to-video/scripts/assemble-index.mjs | 4 +++- skills/pr-to-video/scripts/assemble-index.mjs | 7 ++++--- skills/pr-to-video/scripts/fetch-pr.mjs | 1 - skills/pr-to-video/scripts/lib/tokens.mjs | 2 +- skills/product-launch-video/scripts/assemble-index.mjs | 7 ++++--- skills/product-launch-video/scripts/lib/tokens.mjs | 2 +- 10 files changed, 24 insertions(+), 20 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index fb62ea64dc..82e22e1c56 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,7 +6,7 @@ "files": 144 }, "faceless-explainer": { - "hash": "a8f964f864ee3a14", + "hash": "09bc257e79dabebc", "files": 18 }, "figma": { @@ -46,7 +46,7 @@ "files": 10 }, "media-use": { - "hash": "74d80350ba0bccd9", + "hash": "f6f3af6648b1bd81", "files": 122 }, "motion-graphics": { @@ -54,15 +54,15 @@ "files": 23 }, "music-to-video": { - "hash": "901a19d0680f8c1b", + "hash": "5bb405421a7e19ba", "files": 132 }, "pr-to-video": { - "hash": "da8e3cbebcd4b674", + "hash": "a93fde2b33b26e75", "files": 22 }, "product-launch-video": { - "hash": "a88df1784155ace4", + "hash": "14404973ef5d38a0", "files": 20 }, "remotion-to-hyperframes": { diff --git a/skills/faceless-explainer/scripts/assemble-index.mjs b/skills/faceless-explainer/scripts/assemble-index.mjs index 06b910b386..1bdfca1d15 100644 --- a/skills/faceless-explainer/scripts/assemble-index.mjs +++ b/skills/faceless-explainer/scripts/assemble-index.mjs @@ -55,6 +55,7 @@ import { parseStoryboard } from "./lib/storyboard.mjs"; import { parseFormat } from "./lib/dimensions.mjs"; import { stageAssets } from "./lib/assets.mjs"; import { parseColors, semanticColors } from "./lib/tokens.mjs"; +import { bgmDefaultVolume } from "../../media-use/audio/scripts/lib/bgm.mjs"; // ---------- argv ---------- const argv = process.argv.slice(2); @@ -388,9 +389,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`, ); } - // 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; + // An explicit volume from audio_meta always wins; otherwise the shared + // media-use default (bed ~ -18 dB under narration, forward for a silent film). + const vol = audio.bgm.volume != null ? audio.bgm.volume : bgmDefaultVolume(voiceCount > 0); body.push( ` `, ` 0 ? 0.8 : 0.9; diff --git a/skills/pr-to-video/scripts/assemble-index.mjs b/skills/pr-to-video/scripts/assemble-index.mjs index 06b910b386..1bdfca1d15 100644 --- a/skills/pr-to-video/scripts/assemble-index.mjs +++ b/skills/pr-to-video/scripts/assemble-index.mjs @@ -55,6 +55,7 @@ import { parseStoryboard } from "./lib/storyboard.mjs"; import { parseFormat } from "./lib/dimensions.mjs"; import { stageAssets } from "./lib/assets.mjs"; import { parseColors, semanticColors } from "./lib/tokens.mjs"; +import { bgmDefaultVolume } from "../../media-use/audio/scripts/lib/bgm.mjs"; // ---------- argv ---------- const argv = process.argv.slice(2); @@ -388,9 +389,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`, ); } - // 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; + // An explicit volume from audio_meta always wins; otherwise the shared + // media-use default (bed ~ -18 dB under narration, forward for a silent film). + const vol = audio.bgm.volume != null ? audio.bgm.volume : bgmDefaultVolume(voiceCount > 0); body.push( ` `, ` 0 ? 0.12 : 0.9; + // An explicit volume from audio_meta always wins; otherwise the shared + // media-use default (bed ~ -18 dB under narration, forward for a silent film). + const vol = audio.bgm.volume != null ? audio.bgm.volume : bgmDefaultVolume(voiceCount > 0); body.push( ` `, `