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
+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`,
);
});