mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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)
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"files": 144
|
"files": 144
|
||||||
},
|
},
|
||||||
"faceless-explainer": {
|
"faceless-explainer": {
|
||||||
"hash": "7f8f31695723756b",
|
"hash": "09bc257e79dabebc",
|
||||||
"files": 18
|
"files": 18
|
||||||
},
|
},
|
||||||
"figma": {
|
"figma": {
|
||||||
@@ -46,23 +46,23 @@
|
|||||||
"files": 10
|
"files": 10
|
||||||
},
|
},
|
||||||
"media-use": {
|
"media-use": {
|
||||||
"hash": "6c4aa8649e1eaf99",
|
"hash": "f6f3af6648b1bd81",
|
||||||
"files": 121
|
"files": 122
|
||||||
},
|
},
|
||||||
"motion-graphics": {
|
"motion-graphics": {
|
||||||
"hash": "0f1ac928e387a74c",
|
"hash": "0f1ac928e387a74c",
|
||||||
"files": 23
|
"files": 23
|
||||||
},
|
},
|
||||||
"music-to-video": {
|
"music-to-video": {
|
||||||
"hash": "901a19d0680f8c1b",
|
"hash": "5bb405421a7e19ba",
|
||||||
"files": 132
|
"files": 132
|
||||||
},
|
},
|
||||||
"pr-to-video": {
|
"pr-to-video": {
|
||||||
"hash": "800b4c11cda4658b",
|
"hash": "a93fde2b33b26e75",
|
||||||
"files": 22
|
"files": 22
|
||||||
},
|
},
|
||||||
"product-launch-video": {
|
"product-launch-video": {
|
||||||
"hash": "937dcd6c581fb054",
|
"hash": "14404973ef5d38a0",
|
||||||
"files": 20
|
"files": 20
|
||||||
},
|
},
|
||||||
"remotion-to-hyperframes": {
|
"remotion-to-hyperframes": {
|
||||||
|
|||||||
@@ -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.)
|
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`.
|
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
|
// video, frames only). Durations come from STORYBOARD (audio sync-durations
|
||||||
// writes them), NOT from here; this file carries only media PATHS, keyed by
|
// writes them), NOT from here; this file carries only media PATHS, keyed by
|
||||||
// frame number:
|
// 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" } ],
|
// "voices":[ { "frame": 3, "path": "assets/voice/03.wav" } ],
|
||||||
// "sfx": [ { "frame": 3, "file": "assets/sfx/x.mp3", "offset_s": 0,
|
// "sfx": [ { "frame": 3, "file": "assets/sfx/x.mp3", "offset_s": 0,
|
||||||
// "duration_s": 1.0, "volume": 0.35 } ] }
|
// "duration_s": 1.0, "volume": 0.35 } ] }
|
||||||
@@ -55,6 +55,7 @@ import { parseStoryboard } from "./lib/storyboard.mjs";
|
|||||||
import { parseFormat } from "./lib/dimensions.mjs";
|
import { parseFormat } from "./lib/dimensions.mjs";
|
||||||
import { stageAssets } from "./lib/assets.mjs";
|
import { stageAssets } from "./lib/assets.mjs";
|
||||||
import { parseColors, semanticColors } from "./lib/tokens.mjs";
|
import { parseColors, semanticColors } from "./lib/tokens.mjs";
|
||||||
|
import { bgmDefaultVolume } from "../../media-use/audio/scripts/lib/bgm.mjs";
|
||||||
|
|
||||||
// ---------- argv ----------
|
// ---------- argv ----------
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
@@ -388,7 +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`,
|
`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 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(
|
body.push(
|
||||||
` <!-- BGM -->`,
|
` <!-- BGM -->`,
|
||||||
` <audio`,
|
` <audio`,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
parseFonts,
|
parseFonts,
|
||||||
pickAccent,
|
pickAccent,
|
||||||
semanticColors,
|
semanticColors,
|
||||||
|
STATUS_ROLE_KEY,
|
||||||
UA_DEFAULT_COLORS,
|
UA_DEFAULT_COLORS,
|
||||||
} from "./lib/tokens.mjs";
|
} from "./lib/tokens.mjs";
|
||||||
|
|
||||||
@@ -253,11 +254,7 @@ if (brandColors.length && presetColors.length) {
|
|||||||
let next;
|
let next;
|
||||||
if (val === prDark) next = mapDark;
|
if (val === prDark) next = mapDark;
|
||||||
else if (val === prLight) next = mapLight;
|
else if (val === prLight) next = mapLight;
|
||||||
else if (
|
else if (STATUS_ROLE_KEY.test(key))
|
||||||
/(?:^|[-_])(?:positive|negative|success|error|warning|danger|good|bad|up|down)(?:[-_]|$)/i.test(
|
|
||||||
key,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
// semantic status colors (green/red …) — the HUE carries the meaning; never repaint.
|
// 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
|
// 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.
|
// 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()),
|
["#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|info|neutral|alert|caution|critical)(?:[-_]|$)/i;
|
||||||
|
|
||||||
// Pick the brand ACCENT — never by raw chroma alone, never a UA-default link color.
|
// Pick the brand ACCENT — never by raw chroma alone, never a UA-default link color.
|
||||||
// Priority:
|
// Priority:
|
||||||
// 1) with capture colorStats → the colorful color that RECURS across the UI. The brand
|
// 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
|
// 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);
|
// 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
|
// 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
|
// 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
|
// falls back to luminance/chroma, which is what we want. NOTE: when capture colorStats
|
||||||
// exist, prefer brandRolesFromStats() — it picks by function, not these proxies.
|
// 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],
|
byLum[byLum.length - 1] ?? colors[colors.length - 1],
|
||||||
);
|
);
|
||||||
const accents = colors
|
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]))
|
.sort((a, b) => chroma(b[1]) - chroma(a[1]))
|
||||||
.map(([, v]) => v);
|
.map(([, v]) => v);
|
||||||
return { ink, canvas, accent: accents[0] ?? ink, accent2: accents[1] ?? accents[0] ?? ink };
|
return { ink, canvas, accent: accents[0] ?? ink, accent2: accents[1] ?? accents[0] ?? ink };
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ One music bed per composition, produced by the shared audio engine (`scripts/aud
|
|||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"path": "assets/bgm/track.mp3",
|
"path": "assets/bgm/track.mp3",
|
||||||
"volume": 0.8,
|
"volume": 0.12,
|
||||||
"mode": "retrieve",
|
"mode": "retrieve",
|
||||||
"query": "calm cinematic underscore",
|
"query": "calm cinematic underscore",
|
||||||
"duration_s": 42.0,
|
"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` 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.
|
||||||
|
|
||||||
## Local generation (fallback) — Lyria → MusicGen
|
## Local generation (fallback) — Lyria → MusicGen
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ import { pythonInvocation } from "./python.mjs";
|
|||||||
const r3 = (x) => Number(x.toFixed(3));
|
const r3 = (x) => Number(x.toFixed(3));
|
||||||
const lyriaKey = () => process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
|
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_DEPS = ["transformers", "torch", "soundfile", "numpy"];
|
||||||
const BGM_PY_PROBE =
|
const BGM_PY_PROBE =
|
||||||
"import transformers, soundfile, torch, numpy; from transformers import MusicgenForConditionalGeneration";
|
"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));
|
await downloadTo(top.audio_url, join(hyperframesDir, rel));
|
||||||
return {
|
return {
|
||||||
path: rel,
|
path: rel,
|
||||||
volume: hasVoice ? 0.8 : 0.9,
|
volume: bgmDefaultVolume(hasVoice),
|
||||||
query: q,
|
query: q,
|
||||||
mode: "retrieve",
|
mode: "retrieve",
|
||||||
duration_s: typeof top.duration === "number" ? r3(top.duration) : null,
|
duration_s: typeof top.duration === "number" ? r3(top.duration) : null,
|
||||||
@@ -116,7 +123,7 @@ export function generateBgmDetached({
|
|||||||
mkdirSync(join(hyperframesDir, "assets", "bgm"), { recursive: true });
|
mkdirSync(join(hyperframesDir, "assets", "bgm"), { recursive: true });
|
||||||
const log = join(hyperframesDir, "assets", "bgm", `bgm-${Date.now()}.log`);
|
const log = join(hyperframesDir, "assets", "bgm", `bgm-${Date.now()}.log`);
|
||||||
const targetS = Math.max(1, durationS);
|
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);
|
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`,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -153,7 +153,9 @@ for (const m of mounted) {
|
|||||||
body.push("");
|
body.push("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// BGM (track 11) — full duration; duck slightly when VO present
|
// BGM (track 11) — full duration. Here the music IS the content (music-first
|
||||||
|
// skill), so it never drops to the explainer pipelines' narration-bed default
|
||||||
|
// (bgmDefaultVolume() 0.12 ≈ -18 dB): an incidental VO ducks it only slightly.
|
||||||
let bgmEmitted = false;
|
let bgmEmitted = false;
|
||||||
if (existsSync(join(hyperframesDir, bgmRel))) {
|
if (existsSync(join(hyperframesDir, bgmRel))) {
|
||||||
const vol = voiceCount > 0 ? 0.8 : 0.9;
|
const vol = voiceCount > 0 ? 0.8 : 0.9;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ Initialize only if `hyperframes.json` is missing. Name `<project>` from the PR i
|
|||||||
|
|
||||||
## Step 1: Ingest the PR (no capture)
|
## 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
|
```bash
|
||||||
PR="<url | owner/repo#N | N>"
|
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.
|
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`.
|
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).
|
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
|
## 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:
|
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
|
// video, frames only). Durations come from STORYBOARD (audio sync-durations
|
||||||
// writes them), NOT from here; this file carries only media PATHS, keyed by
|
// writes them), NOT from here; this file carries only media PATHS, keyed by
|
||||||
// frame number:
|
// 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" } ],
|
// "voices":[ { "frame": 3, "path": "assets/voice/03.wav" } ],
|
||||||
// "sfx": [ { "frame": 3, "file": "assets/sfx/x.mp3", "offset_s": 0,
|
// "sfx": [ { "frame": 3, "file": "assets/sfx/x.mp3", "offset_s": 0,
|
||||||
// "duration_s": 1.0, "volume": 0.35 } ] }
|
// "duration_s": 1.0, "volume": 0.35 } ] }
|
||||||
@@ -55,6 +55,7 @@ import { parseStoryboard } from "./lib/storyboard.mjs";
|
|||||||
import { parseFormat } from "./lib/dimensions.mjs";
|
import { parseFormat } from "./lib/dimensions.mjs";
|
||||||
import { stageAssets } from "./lib/assets.mjs";
|
import { stageAssets } from "./lib/assets.mjs";
|
||||||
import { parseColors, semanticColors } from "./lib/tokens.mjs";
|
import { parseColors, semanticColors } from "./lib/tokens.mjs";
|
||||||
|
import { bgmDefaultVolume } from "../../media-use/audio/scripts/lib/bgm.mjs";
|
||||||
|
|
||||||
// ---------- argv ----------
|
// ---------- argv ----------
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
@@ -388,7 +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`,
|
`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 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(
|
body.push(
|
||||||
` <!-- BGM -->`,
|
` <!-- BGM -->`,
|
||||||
` <audio`,
|
` <audio`,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
parseFonts,
|
parseFonts,
|
||||||
pickAccent,
|
pickAccent,
|
||||||
semanticColors,
|
semanticColors,
|
||||||
|
STATUS_ROLE_KEY,
|
||||||
UA_DEFAULT_COLORS,
|
UA_DEFAULT_COLORS,
|
||||||
} from "./lib/tokens.mjs";
|
} from "./lib/tokens.mjs";
|
||||||
|
|
||||||
@@ -253,11 +254,7 @@ if (brandColors.length && presetColors.length) {
|
|||||||
let next;
|
let next;
|
||||||
if (val === prDark) next = mapDark;
|
if (val === prDark) next = mapDark;
|
||||||
else if (val === prLight) next = mapLight;
|
else if (val === prLight) next = mapLight;
|
||||||
else if (
|
else if (STATUS_ROLE_KEY.test(key))
|
||||||
/(?:^|[-_])(?:positive|negative|success|error|warning|danger|good|bad|up|down)(?:[-_]|$)/i.test(
|
|
||||||
key,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
// semantic status colors (green/red …) — the HUE carries the meaning; never repaint.
|
// 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
|
// 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.
|
// color and would otherwise be claimed as accent2 and recolored to the brand hue.
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
// call — `gh pr view --json files` truncates at ~100 files, so a
|
// 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
|
// big PR would otherwise lose the tail. Commits keep gh pr view's
|
||||||
// rich `authors[]` (co-authors) — only `files` needs the override.
|
// 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`).
|
// 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
|
// gh runs HERE so auth / not-found / private-repo errors surface with gh's own stderr
|
||||||
@@ -82,6 +84,8 @@ const FIELDS = [
|
|||||||
"assignees",
|
"assignees",
|
||||||
"reviewDecision",
|
"reviewDecision",
|
||||||
"mergedBy",
|
"mergedBy",
|
||||||
|
"state",
|
||||||
|
"mergedAt",
|
||||||
].join(",");
|
].join(",");
|
||||||
|
|
||||||
const view = ghTry(["pr", "view", prRef, "--json", FIELDS]);
|
const view = ghTry(["pr", "view", prRef, "--json", FIELDS]);
|
||||||
@@ -136,6 +140,71 @@ if (owner && repo && number != null) {
|
|||||||
console.error(" (warn: could not parse owner/repo from PR url — keeping pr view's files)");
|
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 ────────────────────────────
|
// ── 3. write capture/pr.json + capture/diff.patch ────────────────────────────
|
||||||
mkdirSync(outDir, { recursive: true });
|
mkdirSync(outDir, { recursive: true });
|
||||||
const prJsonPath = join(outDir, "pr.json");
|
const prJsonPath = join(outDir, "pr.json");
|
||||||
@@ -159,6 +228,7 @@ console.log(
|
|||||||
[
|
[
|
||||||
`✓ fetch-pr: ${repoLabel} PR #${number ?? "?"} — "${(pr.title || "").slice(0, 72)}"`,
|
`✓ fetch-pr: ${repoLabel} PR #${number ?? "?"} — "${(pr.title || "").slice(0, 72)}"`,
|
||||||
` files: ${filesNote}; diff: ${diff.ok ? `${diff.stdout.length} chars` : "MISSING"}`,
|
` 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}` : ""}`,
|
` wrote ${prJsonPath}${diff.ok ? ` + ${diffPath}` : ""}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
// --pr-json <path> gh pr view --json number,title,body,author,url,baseRefName,
|
// --pr-json <path> gh pr view --json number,title,body,author,url,baseRefName,
|
||||||
// headRefName,commits,files,additions,deletions,changedFiles,labels,
|
// headRefName,commits,files,additions,deletions,changedFiles,labels,
|
||||||
// reviews,latestReviews,comments,assignees,reviewDecision,mergedBy
|
// 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]
|
// --diff <path> gh pr diff (raw unified diff) [optional — brief still builds without it]
|
||||||
// Writes (under --out-dir, default ./capture/extracted):
|
// Writes (under --out-dir, default ./capture/extracted):
|
||||||
// tokens.json synthetic design tokens (colors:[] → claude native palette)
|
// tokens.json synthetic design tokens (colors:[] → claude native palette)
|
||||||
@@ -253,6 +254,12 @@ const people = [...peopleMap.values()]
|
|||||||
const reviewDecision = pr.reviewDecision || null;
|
const reviewDecision = pr.reviewDecision || null;
|
||||||
const mergedByLogin = pr.mergedBy?.login || 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 ----------
|
// ---------- clean body ----------
|
||||||
function cleanBody(raw) {
|
function cleanBody(raw) {
|
||||||
if (!raw || typeof raw !== "string") return "";
|
if (!raw || typeof raw !== "string") return "";
|
||||||
@@ -406,6 +413,8 @@ lines.push(
|
|||||||
);
|
);
|
||||||
if (labels.length) lines.push(`Labels: ${labels.join(", ")}`);
|
if (labels.length) lines.push(`Labels: ${labels.join(", ")}`);
|
||||||
if (url) lines.push(`URL: ${url}`);
|
if (url) lines.push(`URL: ${url}`);
|
||||||
|
if (shippedVersion)
|
||||||
|
lines.push(`Shipped in: ${shippedVersion}${versionSource ? ` (${versionSource})` : ""}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
// People & reviews — human context for an optional credits / shipped-by close.
|
// People & reviews — human context for an optional credits / shipped-by close.
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ export const UA_DEFAULT_COLORS = new Set(
|
|||||||
["#0000EE", "#0000FF", "#0000CC", "#1A0DAB", "#551A8B", "#EE0000"].map((c) => c.toUpperCase()),
|
["#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|info|neutral|alert|caution|critical)(?:[-_]|$)/i;
|
||||||
|
|
||||||
// Pick the brand ACCENT — never by raw chroma alone, never a UA-default link color.
|
// Pick the brand ACCENT — never by raw chroma alone, never a UA-default link color.
|
||||||
// Priority:
|
// Priority:
|
||||||
// 1) with capture colorStats → the colorful color that RECURS across the UI. The brand
|
// 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
|
// 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);
|
// 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
|
// 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
|
// 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
|
// falls back to luminance/chroma, which is what we want. NOTE: when capture colorStats
|
||||||
// exist, prefer brandRolesFromStats() — it picks by function, not these proxies.
|
// 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],
|
byLum[byLum.length - 1] ?? colors[colors.length - 1],
|
||||||
);
|
);
|
||||||
const accents = colors
|
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]))
|
.sort((a, b) => chroma(b[1]) - chroma(a[1]))
|
||||||
.map(([, v]) => v);
|
.map(([, v]) => v);
|
||||||
return { ink, canvas, accent: accents[0] ?? ink, accent2: accents[1] ?? accents[0] ?? ink };
|
return { ink, canvas, accent: accents[0] ?? ink, accent2: accents[1] ?? accents[0] ?? ink };
|
||||||
|
|||||||
@@ -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.
|
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`.
|
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
|
// video, frames only). Durations come from STORYBOARD (audio sync-durations
|
||||||
// writes them), NOT from here; this file carries only media PATHS, keyed by
|
// writes them), NOT from here; this file carries only media PATHS, keyed by
|
||||||
// frame number:
|
// 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" } ],
|
// "voices":[ { "frame": 3, "path": "assets/voice/03.wav" } ],
|
||||||
// "sfx": [ { "frame": 3, "file": "assets/sfx/x.mp3", "offset_s": 0,
|
// "sfx": [ { "frame": 3, "file": "assets/sfx/x.mp3", "offset_s": 0,
|
||||||
// "duration_s": 1.0, "volume": 0.35 } ] }
|
// "duration_s": 1.0, "volume": 0.35 } ] }
|
||||||
@@ -55,6 +55,7 @@ import { parseStoryboard } from "./lib/storyboard.mjs";
|
|||||||
import { parseFormat } from "./lib/dimensions.mjs";
|
import { parseFormat } from "./lib/dimensions.mjs";
|
||||||
import { stageAssets } from "./lib/assets.mjs";
|
import { stageAssets } from "./lib/assets.mjs";
|
||||||
import { parseColors, semanticColors } from "./lib/tokens.mjs";
|
import { parseColors, semanticColors } from "./lib/tokens.mjs";
|
||||||
|
import { bgmDefaultVolume } from "../../media-use/audio/scripts/lib/bgm.mjs";
|
||||||
|
|
||||||
// ---------- argv ----------
|
// ---------- argv ----------
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
@@ -388,7 +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`,
|
`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 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(
|
body.push(
|
||||||
` <!-- BGM -->`,
|
` <!-- BGM -->`,
|
||||||
` <audio`,
|
` <audio`,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
parseFonts,
|
parseFonts,
|
||||||
pickAccent,
|
pickAccent,
|
||||||
semanticColors,
|
semanticColors,
|
||||||
|
STATUS_ROLE_KEY,
|
||||||
UA_DEFAULT_COLORS,
|
UA_DEFAULT_COLORS,
|
||||||
} from "./lib/tokens.mjs";
|
} from "./lib/tokens.mjs";
|
||||||
|
|
||||||
@@ -253,11 +254,7 @@ if (brandColors.length && presetColors.length) {
|
|||||||
let next;
|
let next;
|
||||||
if (val === prDark) next = mapDark;
|
if (val === prDark) next = mapDark;
|
||||||
else if (val === prLight) next = mapLight;
|
else if (val === prLight) next = mapLight;
|
||||||
else if (
|
else if (STATUS_ROLE_KEY.test(key))
|
||||||
/(?:^|[-_])(?:positive|negative|success|error|warning|danger|good|bad|up|down)(?:[-_]|$)/i.test(
|
|
||||||
key,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
// semantic status colors (green/red …) — the HUE carries the meaning; never repaint.
|
// 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
|
// 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.
|
// 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()),
|
["#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|info|neutral|alert|caution|critical)(?:[-_]|$)/i;
|
||||||
|
|
||||||
// Pick the brand ACCENT — never by raw chroma alone, never a UA-default link color.
|
// Pick the brand ACCENT — never by raw chroma alone, never a UA-default link color.
|
||||||
// Priority:
|
// Priority:
|
||||||
// 1) with capture colorStats → the colorful color that RECURS across the UI. The brand
|
// 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
|
// 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);
|
// 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
|
// 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
|
// 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
|
// falls back to luminance/chroma, which is what we want. NOTE: when capture colorStats
|
||||||
// exist, prefer brandRolesFromStats() — it picks by function, not these proxies.
|
// 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],
|
byLum[byLum.length - 1] ?? colors[colors.length - 1],
|
||||||
);
|
);
|
||||||
const accents = colors
|
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]))
|
.sort((a, b) => chroma(b[1]) - chroma(a[1]))
|
||||||
.map(([, v]) => v);
|
.map(([, v]) => v);
|
||||||
return { ink, canvas, accent: accents[0] ?? ink, accent2: accents[1] ?? accents[0] ?? ink };
|
return { ink, canvas, accent: accents[0] ?? ink, accent2: accents[1] ?? accents[0] ?? ink };
|
||||||
|
|||||||
Reference in New Issue
Block a user