Skip to content

Commit dda09c8

Browse files
authored
Merge pull request #2110 from heygen-com/fix/prompt-guide-pipeline-fixes
fix(skills): pipeline fixes from prompt-guide validation (BGM, caption accent, voice, PR version)
2 parents d4b3bca + 23c9d15 commit dda09c8

20 files changed

Lines changed: 218 additions & 42 deletions

File tree

skills-manifest.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"files": 144
77
},
88
"faceless-explainer": {
9-
"hash": "7f8f31695723756b",
9+
"hash": "09bc257e79dabebc",
1010
"files": 18
1111
},
1212
"figma": {
@@ -46,23 +46,23 @@
4646
"files": 10
4747
},
4848
"media-use": {
49-
"hash": "6c4aa8649e1eaf99",
50-
"files": 121
49+
"hash": "f6f3af6648b1bd81",
50+
"files": 122
5151
},
5252
"motion-graphics": {
5353
"hash": "0f1ac928e387a74c",
5454
"files": 23
5555
},
5656
"music-to-video": {
57-
"hash": "901a19d0680f8c1b",
57+
"hash": "5bb405421a7e19ba",
5858
"files": 132
5959
},
6060
"pr-to-video": {
61-
"hash": "800b4c11cda4658b",
61+
"hash": "a93fde2b33b26e75",
6262
"files": 22
6363
},
6464
"product-launch-video": {
65-
"hash": "937dcd6c581fb054",
65+
"hash": "14404973ef5d38a0",
6666
"files": 20
6767
},
6868
"remotion-to-hyperframes": {

skills/faceless-explainer/SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ Goal: Generate narration, word timings, music, and audio metadata from the appro
105105

106106
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.)
107107

108-
`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json &`
108+
**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.
109+
110+
`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --voice <voice-id> &`
109111

110112
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`.
111113

skills/faceless-explainer/scripts/assemble-index.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
// video, frames only). Durations come from STORYBOARD (audio sync-durations
2424
// writes them), NOT from here; this file carries only media PATHS, keyed by
2525
// frame number:
26-
// { "bgm": { "path": "assets/bgm/x.mp3", "volume": 0.8 } | null,
26+
// { "bgm": { "path": "assets/bgm/x.mp3", "volume": 0.12 } | null,
2727
// "voices":[ { "frame": 3, "path": "assets/voice/03.wav" } ],
2828
// "sfx": [ { "frame": 3, "file": "assets/sfx/x.mp3", "offset_s": 0,
2929
// "duration_s": 1.0, "volume": 0.35 } ] }
@@ -55,6 +55,7 @@ import { parseStoryboard } from "./lib/storyboard.mjs";
5555
import { parseFormat } from "./lib/dimensions.mjs";
5656
import { stageAssets } from "./lib/assets.mjs";
5757
import { parseColors, semanticColors } from "./lib/tokens.mjs";
58+
import { bgmDefaultVolume } from "../../media-use/audio/scripts/lib/bgm.mjs";
5859

5960
// ---------- argv ----------
6061
const argv = process.argv.slice(2);
@@ -388,7 +389,9 @@ if (audio.bgm?.path) {
388389
`bgm is ${cov.dur?.toFixed?.(1) ?? "?"}s (< ${TOTAL}s) and could not be extended (${cov.reason}) — the tail will be silent; install ffmpeg`,
389390
);
390391
}
391-
const vol = audio.bgm.volume != null ? audio.bgm.volume : voiceCount > 0 ? 0.8 : 0.9;
392+
// An explicit volume from audio_meta always wins; otherwise the shared
393+
// media-use default (bed ~ -18 dB under narration, forward for a silent film).
394+
const vol = audio.bgm.volume != null ? audio.bgm.volume : bgmDefaultVolume(voiceCount > 0);
392395
body.push(
393396
` <!-- BGM -->`,
394397
` <audio`,

skills/faceless-explainer/scripts/build-frame.mjs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
parseFonts,
3838
pickAccent,
3939
semanticColors,
40+
STATUS_ROLE_KEY,
4041
UA_DEFAULT_COLORS,
4142
} from "./lib/tokens.mjs";
4243

@@ -253,11 +254,7 @@ if (brandColors.length && presetColors.length) {
253254
let next;
254255
if (val === prDark) next = mapDark;
255256
else if (val === prLight) next = mapLight;
256-
else if (
257-
/(?:^|[-_])(?:positive|negative|success|error|warning|danger|good|bad|up|down)(?:[-_]|$)/i.test(
258-
key,
259-
)
260-
)
257+
else if (STATUS_ROLE_KEY.test(key))
261258
// semantic status colors (green/red …) — the HUE carries the meaning; never repaint.
262259
// MUST precede the accent checks: a preset's red "negative" is often its 2nd-most-chromatic
263260
// color and would otherwise be claimed as accent2 and recolored to the brand hue.

skills/faceless-explainer/scripts/lib/tokens.mjs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ export const UA_DEFAULT_COLORS = new Set(
4848
["#0000EE", "#0000FF", "#0000CC", "#1A0DAB", "#551A8B", "#EE0000"].map((c) => c.toUpperCase()),
4949
);
5050

51+
// Semantic STATUS roles (green "positive", red "negative"/"error", amber "warning" …). Their HUE
52+
// carries the meaning, so they are never a brand ACCENT — a status red is frequently the most
53+
// chromatic color in a palette (e.g. #dc2626 chroma 182 beats a deep-blue accent #1E40AF chroma
54+
// 145) and would otherwise win a pure chroma ranking, painting captions/highlights the error red.
55+
// build-frame.mjs uses this same key set to protect status colors during the preset→brand remix.
56+
export const STATUS_ROLE_KEY =
57+
/(?:^|[-_])(?:positive|negative|success|error|warning|danger|good|bad|up|down|info|neutral|alert|caution|critical)(?:[-_]|$)/i;
58+
5159
// Pick the brand ACCENT — never by raw chroma alone, never a UA-default link color.
5260
// Priority:
5361
// 1) with capture colorStats → the colorful color that RECURS across the UI. The brand
@@ -134,7 +142,8 @@ export function brandRolesFromStats(stats, colorsInOrder) {
134142
// Map a list of [key, value] colors to semantic roles. ink = a dark/ink-named
135143
// color (else darkest); canvas = a paper/cream/white-named color (else lightest);
136144
// accents = whatever's left, ranked by chroma (the loudest color is almost always
137-
// the brand accent) — UA-default link colors excluded so a stray <a> color never wins.
145+
// the brand accent) — UA-default link colors AND semantic status colors (positive/
146+
// negative/error…) excluded so neither a stray <a> color nor a status red ever wins.
138147
// For an unkeyed brand list, pass synthetic keys — name matching simply no-ops and it
139148
// falls back to luminance/chroma, which is what we want. NOTE: when capture colorStats
140149
// exist, prefer brandRolesFromStats() — it picks by function, not these proxies.
@@ -154,7 +163,13 @@ export function semanticColors(colors) {
154163
byLum[byLum.length - 1] ?? colors[colors.length - 1],
155164
);
156165
const accents = colors
157-
.filter(([, v]) => v !== ink && v !== canvas && !UA_DEFAULT_COLORS.has(String(v).toUpperCase()))
166+
.filter(
167+
([k, v]) =>
168+
v !== ink &&
169+
v !== canvas &&
170+
!UA_DEFAULT_COLORS.has(String(v).toUpperCase()) &&
171+
!STATUS_ROLE_KEY.test(k), // a status red/green carries meaning by hue — never an accent
172+
)
158173
.sort((a, b) => chroma(b[1]) - chroma(a[1]))
159174
.map(([, v]) => v);
160175
return { ink, canvas, accent: accents[0] ?? ink, accent2: accents[1] ?? accents[0] ?? ink };

skills/media-use/audio/references/bgm.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ One music bed per composition, produced by the shared audio engine (`scripts/aud
2222
```jsonc
2323
{
2424
"path": "assets/bgm/track.mp3",
25-
"volume": 0.8,
25+
"volume": 0.12,
2626
"mode": "retrieve",
2727
"query": "calm cinematic underscore",
2828
"duration_s": 42.0,
2929
}
3030
```
3131

32-
`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.
32+
`volume` comes from the engine's `bgmDefaultVolume()`: `BGM_BED_VOLUME` (currently `0.12` ≈ -18 dB — a bed under the voice) under narration, `BGM_SILENT_VOLUME` (currently `0.9`) for a silent film (no voice). Tune those constants in `scripts/lib/bgm.mjs`, not call sites. An explicit `volume` in `audio_meta.json` always overrides this default. `bgm_pending` is `false` — the file is on disk when the engine returns.
3333

3434
## Local generation (fallback) — Lyria → MusicGen
3535

skills/media-use/audio/scripts/lib/bgm.mjs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ import { pythonInvocation } from "./python.mjs";
2020
const r3 = (x) => Number(x.toFixed(3));
2121
const lyriaKey = () => process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
2222

23+
// Default BGM level. Under narration music is a bed that must stay under the
24+
// voice — 0.12 linear ≈ -18 dB. A silent film (no voice) has no voice to duck
25+
// beneath, so BGM sits forward at 0.9. Callers may override per composition.
26+
export const BGM_BED_VOLUME = 0.12;
27+
export const BGM_SILENT_VOLUME = 0.9;
28+
export const bgmDefaultVolume = (hasVoice) => (hasVoice ? BGM_BED_VOLUME : BGM_SILENT_VOLUME);
29+
2330
const BGM_PY_DEPS = ["transformers", "torch", "soundfile", "numpy"];
2431
const BGM_PY_PROBE =
2532
"import transformers, soundfile, torch, numpy; from transformers import MusicgenForConditionalGeneration";
@@ -51,7 +58,7 @@ export async function retrieveBgm({ query, headers, hyperframesDir, hasVoice })
5158
await downloadTo(top.audio_url, join(hyperframesDir, rel));
5259
return {
5360
path: rel,
54-
volume: hasVoice ? 0.8 : 0.9,
61+
volume: bgmDefaultVolume(hasVoice),
5562
query: q,
5663
mode: "retrieve",
5764
duration_s: typeof top.duration === "number" ? r3(top.duration) : null,
@@ -116,7 +123,7 @@ export function generateBgmDetached({
116123
mkdirSync(join(hyperframesDir, "assets", "bgm"), { recursive: true });
117124
const log = join(hyperframesDir, "assets", "bgm", `bgm-${Date.now()}.log`);
118125
const targetS = Math.max(1, durationS);
119-
const baseMeta = { path: rel, mode: null, volume: hasVoice ? 0.8 : 0.9, pending: true };
126+
const baseMeta = { path: rel, mode: null, volume: bgmDefaultVolume(hasVoice), pending: true };
120127

121128
const lyriaConfigured = !!lyriaKey() && !!lyriaRecipe && existsSync(lyriaRecipe);
122129

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { BGM_BED_VOLUME, BGM_SILENT_VOLUME, bgmDefaultVolume } from "./bgm.mjs";
4+
5+
// Regression: narrated pipelines used to ship BGM at 0.8 (≈ -2 dB), ~16 dB
6+
// hotter than a music bed under a voice should be. The default under narration
7+
// must be a proper bed (≈ -18 dB); a silent film keeps the louder default.
8+
9+
const dbfs = (linear) => 20 * Math.log10(linear);
10+
11+
test("BGM under narration is a bed near -18 dB", () => {
12+
assert.equal(bgmDefaultVolume(true), BGM_BED_VOLUME);
13+
assert.equal(BGM_BED_VOLUME, 0.12);
14+
const db = dbfs(BGM_BED_VOLUME);
15+
assert.ok(db < -17 && db > -19, `bed should be ≈ -18 dB, got ${db.toFixed(1)} dB`);
16+
});
17+
18+
test("a silent film (no voice) keeps BGM forward", () => {
19+
assert.equal(bgmDefaultVolume(false), BGM_SILENT_VOLUME);
20+
assert.equal(BGM_SILENT_VOLUME, 0.9);
21+
});
22+
23+
test("the narrated default is well below the voice (≈ 0 dBFS)", () => {
24+
// Voice sits at data-volume="1" (0 dBFS); the bed must be ~16+ dB under it.
25+
const separation = dbfs(1) - dbfs(bgmDefaultVolume(true));
26+
assert.ok(
27+
separation >= 16,
28+
`bed should sit ≥16 dB under the voice, got ${separation.toFixed(1)} dB`,
29+
);
30+
});

skills/music-to-video/scripts/assemble-index.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,9 @@ for (const m of mounted) {
153153
body.push("");
154154
}
155155

156-
// BGM (track 11) — full duration; duck slightly when VO present
156+
// BGM (track 11) — full duration. Here the music IS the content (music-first
157+
// skill), so it never drops to the explainer pipelines' narration-bed default
158+
// (bgmDefaultVolume() 0.12 ≈ -18 dB): an incidental VO ducks it only slightly.
157159
let bgmEmitted = false;
158160
if (existsSync(join(hyperframesDir, bgmRel))) {
159161
const vol = voiceCount > 0 ? 0.8 : 0.9;

skills/pr-to-video/SKILL.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Initialize only if `hyperframes.json` is missing. Name `<project>` from the PR i
7070

7171
## Step 1: Ingest the PR (no capture)
7272

73-
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.
73+
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.
7474

7575
```bash
7676
PR="<url | owner/repo#N | N>"
@@ -133,7 +133,9 @@ Goal: Generate narration, word timings, music, and audio metadata from the appro
133133

134134
Start audio after Step 3 approval. Run it in the background, then continue to Step 4.
135135

136-
`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json &`
136+
**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.
137+
138+
`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --voice <voice-id> &`
137139

138140
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`.
139141

0 commit comments

Comments
 (0)