Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"files": 144
},
"faceless-explainer": {
"hash": "7f8f31695723756b",
"hash": "09bc257e79dabebc",
"files": 18
},
"figma": {
Expand Down Expand Up @@ -46,23 +46,23 @@
"files": 10
},
"media-use": {
"hash": "6c4aa8649e1eaf99",
"files": 121
"hash": "f6f3af6648b1bd81",
"files": 122
},
"motion-graphics": {
"hash": "0f1ac928e387a74c",
"files": 23
},
"music-to-video": {
"hash": "901a19d0680f8c1b",
"hash": "5bb405421a7e19ba",
"files": 132
},
"pr-to-video": {
"hash": "800b4c11cda4658b",
"hash": "a93fde2b33b26e75",
"files": 22
},
"product-launch-video": {
"hash": "937dcd6c581fb054",
"hash": "14404973ef5d38a0",
"files": 20
},
"remotion-to-hyperframes": {
Expand Down
4 changes: 3 additions & 1 deletion skills/faceless-explainer/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ Goal: Generate narration, word timings, music, and audio metadata from the appro

Start audio after Step 3 approval. Run it in the background, then continue to Step 4. (Sign-in status was already shown in Step 0; the engine falls back automatically.)

`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json &`
**Choose the narration voice from the user's ask before invoking.** If the request named a voice, gender, or tone, pick a matching voice id and pass it with `--voice <id>`. The pipeline default is otherwise **Marcia (female)** on HeyGen / `am_michael` on Kokoro — so a request like "a male voice" is silently ignored unless you pass the flag. Voice ids are provider-specific; resolve against whichever provider Step 0's sign-in status selected: **HeyGen** (signed in) via `node ../media-use/audio/scripts/heygen-tts.mjs --list` (or `GET /v3/voices?engine=starfish`); **Kokoro** (offline) via the voice table in `../media-use/audio/references/tts.md` (prefixes `am_`/`bm_` male, `af_`/`bf_` female). Omit `--voice` only when the user expressed no preference.

`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --voice <voice-id> &`

The audio script handles narration, word timings, BGM lookup from HeyGen's music library, and timing metadata. BGM mood comes from the storyboard's `music:` field. This uses the HeyGen Audio API for retrieval, not generation, and the same `~/.heygen` credential as TTS. For provider details, read `../media-use/audio/references/tts.md`.

Expand Down
7 changes: 5 additions & 2 deletions skills/faceless-explainer/scripts/assemble-index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } ] }
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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`,
);
}
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(
` <!-- BGM -->`,
` <audio`,
Expand Down
7 changes: 2 additions & 5 deletions skills/faceless-explainer/scripts/build-frame.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
parseFonts,
pickAccent,
semanticColors,
STATUS_ROLE_KEY,
UA_DEFAULT_COLORS,
} from "./lib/tokens.mjs";

Expand Down Expand Up @@ -253,11 +254,7 @@ if (brandColors.length && presetColors.length) {
let next;
if (val === prDark) next = mapDark;
else if (val === prLight) next = mapLight;
else if (
/(?:^|[-_])(?:positive|negative|success|error|warning|danger|good|bad|up|down)(?:[-_]|$)/i.test(
key,
)
)
else if (STATUS_ROLE_KEY.test(key))
// semantic status colors (green/red …) — the HUE carries the meaning; never repaint.
// MUST precede the accent checks: a preset's red "negative" is often its 2nd-most-chromatic
// color and would otherwise be claimed as accent2 and recolored to the brand hue.
Expand Down
19 changes: 17 additions & 2 deletions skills/faceless-explainer/scripts/lib/tokens.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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|info|neutral|alert|caution|critical)(?:[-_]|$)/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
Expand Down Expand Up @@ -134,7 +142,8 @@ export function brandRolesFromStats(stats, colorsInOrder) {
// Map a list of [key, value] colors to semantic roles. ink = a dark/ink-named
// color (else darkest); canvas = a paper/cream/white-named color (else lightest);
// accents = whatever's left, ranked by chroma (the loudest color is almost always
// the brand accent) — UA-default link colors excluded so a stray <a> color never wins.
// the brand accent) — UA-default link colors AND semantic status colors (positive/
// negative/error…) excluded so neither a stray <a> color nor a status red ever wins.
// For an unkeyed brand list, pass synthetic keys — name matching simply no-ops and it
// falls back to luminance/chroma, which is what we want. NOTE: when capture colorStats
// exist, prefer brandRolesFromStats() — it picks by function, not these proxies.
Expand All @@ -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 };
Expand Down
4 changes: 2 additions & 2 deletions skills/media-use/audio/references/bgm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 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

Expand Down
11 changes: 9 additions & 2 deletions skills/media-use/audio/scripts/lib/bgm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down
30 changes: 30 additions & 0 deletions skills/media-use/audio/scripts/lib/bgm.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { BGM_BED_VOLUME, BGM_SILENT_VOLUME, bgmDefaultVolume } from "./bgm.mjs";

// Regression: narrated pipelines used to ship BGM at 0.8 (≈ -2 dB), ~16 dB
// hotter than a music bed under a voice should be. The default under narration
// must be a proper bed (≈ -18 dB); a silent film keeps the louder default.

const dbfs = (linear) => 20 * Math.log10(linear);

test("BGM under narration is a bed near -18 dB", () => {
assert.equal(bgmDefaultVolume(true), BGM_BED_VOLUME);
assert.equal(BGM_BED_VOLUME, 0.12);
const db = dbfs(BGM_BED_VOLUME);
assert.ok(db < -17 && db > -19, `bed should be ≈ -18 dB, got ${db.toFixed(1)} dB`);
});

test("a silent film (no voice) keeps BGM forward", () => {
assert.equal(bgmDefaultVolume(false), BGM_SILENT_VOLUME);
assert.equal(BGM_SILENT_VOLUME, 0.9);
});

test("the narrated default is well below the voice (≈ 0 dBFS)", () => {
// Voice sits at data-volume="1" (0 dBFS); the bed must be ~16+ dB under it.
const separation = dbfs(1) - dbfs(bgmDefaultVolume(true));
assert.ok(
separation >= 16,
`bed should sit ≥16 dB under the voice, got ${separation.toFixed(1)} dB`,
);
});
4 changes: 3 additions & 1 deletion skills/music-to-video/scripts/assemble-index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ for (const m of mounted) {
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;
if (existsSync(join(hyperframesDir, bgmRel))) {
const vol = voiceCount > 0 ? 0.8 : 0.9;
Expand Down
6 changes: 4 additions & 2 deletions skills/pr-to-video/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Initialize only if `hyperframes.json` is missing. Name `<project>` from the PR i

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

Goal: Fetch the PR's facts and fold them into the project as the source of information. There is **no website capture**. `fetch-pr.mjs` runs `gh` deterministically — completing the files list via paginated `gh api` so a large PR doesn't truncate at ~100 files, and writing only `capture/pr.json` + `capture/diff.patch` (no scratch dir). Then `ingest.mjs` folds that into the synthetic capture package offline.
Goal: Fetch the PR's facts and fold them into the project as the source of information. There is **no website capture**. `fetch-pr.mjs` runs `gh` deterministically — completing the files list via paginated `gh api` so a large PR doesn't truncate at ~100 files, and writing only `capture/pr.json` + `capture/diff.patch` (no scratch dir). For MERGED PRs it also resolves a best-effort `shipped_version` (+ `version_source`) into `pr.json`, so the end card can cite a real version instead of inventing one. Then `ingest.mjs` folds that into the synthetic capture package offline.

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

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

`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json &`
**Choose the narration voice from the user's ask before invoking.** If the request named a voice, gender, or tone, pick a matching voice id and pass it with `--voice <id>`. The pipeline default is otherwise **Marcia (female)** on HeyGen / `am_michael` on Kokoro — so a request like "a male voice" is silently ignored unless you pass the flag. Voice ids are provider-specific; resolve against whichever provider Step 0's sign-in status selected: **HeyGen** (signed in) via `node ../media-use/audio/scripts/heygen-tts.mjs --list` (or `GET /v3/voices?engine=starfish`); **Kokoro** (offline) via the voice table in `../media-use/audio/references/tts.md` (prefixes `am_`/`bm_` male, `af_`/`bf_` female). Omit `--voice` only when the user expressed no preference.

`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --voice <voice-id> &`

The audio script handles narration, word timings, BGM lookup from HeyGen's music library, and timing metadata. BGM mood comes from the storyboard's `music:` field. This uses the HeyGen Audio API for retrieval, not generation, and the same `~/.heygen` credential as TTS. For provider details, read `../media-use/audio/references/tts.md`.

Expand Down
7 changes: 7 additions & 0 deletions skills/pr-to-video/references/story-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ The `credits` frame is an avatar row with names + roles + an "approved" check. O

Every other frame has **no** `asset_candidates` (the visuals are invented downstream from `scene` + the diff).

### Versions on the end card (cta / changelog)

A `cta` ("upgrade to vN", "npm i pkg@N") or a changelog "what's new in vN" wants a real version — and **a version is the one fact you must never invent.** A PR carries no shipping version, so Step 1 resolves a best-effort one for MERGED PRs and writes it into `capture/extracted/visible-text.txt` as a `Shipped in: <version> (<source>)` meta line (mirrored in `capture/pr.json` as `shipped_version` / `version_source`). Use it:

- **`Shipped in:` present** → use that exact version on the end card. A `version_source` of `unreleased` means the change is on the default branch but not yet in a tagged release — say "shipping in the next release" rather than pinning a tag.
- **No `Shipped in:` line** (open PR, or no version resolvable) → **state the repo / PR URL only** ("read the PR at github.com/…", "pull it") and do **not** name or guess a version number.

## Per-frame length budget — ≤ 9 s, word count is the real measurement

The largest quality bug in PR videos is **scripts that talk too long**. TTS runs at **~2.2 words/second**, so a 45-word "7-second" script is really 20 seconds, and the visual phase has to pad the tail with idle drift (the video reads as "shimmering"). Budget by word count:
Expand Down
7 changes: 5 additions & 2 deletions skills/pr-to-video/scripts/assemble-index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } ] }
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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`,
);
}
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(
` <!-- BGM -->`,
` <audio`,
Expand Down
7 changes: 2 additions & 5 deletions skills/pr-to-video/scripts/build-frame.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
parseFonts,
pickAccent,
semanticColors,
STATUS_ROLE_KEY,
UA_DEFAULT_COLORS,
} from "./lib/tokens.mjs";

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