.gitignore
eval-report.html
heygen-com/hyperframes · GitHub
Agent Media OS는 HyperFrames 프로젝트에서 필요한 모든 미디어 요구 사항을 해결해 주는 단일 스킬입니다. BGM, SFX, 이미지, 아이콘, 브랜드 로고, 음성, 컬러 그레이딩 또는 LUT를 고정된 로컬 파일이나 붙여넣기 가능한 블록 + 원장 기록으로 변환합니다(단일 동사, `resolve`); 카탈로그에 해당 항목이 없을 경우 TTS/음악/이미지 모델을 통해 생성할 수 있으며, 하나의 공유 오디오 엔진을 통해 보이스오버, 텍스트 변환, 자막, 배경 제거를 생성할 수 있습니다. 또한 미디어에 대한 조작(컷/재구도/변형)이 가능하며, 프로젝트 간에 자산을 재사용할 수 있습니다. 또한 실제 영상이 어둡거나, 평면적이거나, 지루해 보인다는 식의 모호한 피드백이나, 레트로/캠코더/인쇄물/ASCII 느낌을 줘야 한다거나, 프라이버시가 보장되어야 한다거나, 미디어 공개가 필요하다는 등의 요청에도 활용할 수 있습니다.
프로젝트 폴더에서 아래 명령어를 실행하고, 설치할 에이전트를 선택하세요.
npx skills add heygen-com/hyperframes --skill media-use설치 명령을 직접 실행해야 적용됩니다. 지원 에이전트와 필요한 권한·라이선스는 제작자의 안내를 확인하세요.
.gitignoreeval-report.html
audio/assets/sfx/CREDITS.md# SFX Credits All sound effects in this directory are sourced from [Pixabay](https://pixabay.com/sound-effects/) and used under the [Pixabay Content License](https://pixabay.com/service/license-summary/). The Pixabay license allows free use for commercial and non-commercial purposes without attribution, but attribution is appreciated and given here for transparency. ## Files The following `.mp3` files are bundled with this skill: - `chime.mp3` - `click.mp3` / `click-soft.mp3` - `error.mp3` - `glitch-1.mp3` / `glitch-2.mp3` / `glitch-3.mp3` - `impact-bass-1.mp3` / `impact-bass-2.mp3` - `key-press.mp3` - `notification.mp3` - `ping.mp3` - `pop.mp3` - `riser.mp3` - `sparkle.mp3` - `typing.mp3` - `whoosh.mp3` / `whoosh-short.mp3` / `whoosh-cinematic.mp3` See `manifest.json` for per-file metadata (duration, energy character, recommended use). ## License All files are distributed under the [Pixabay Content License](https://pixabay.com/service/license-summary/), which permits: - Commercial and non-commercial use - Modification and remixing - Redistribution as part of derivative works (such as videos rendered with HyperFrames) without any attribution requirement.
audio/assets/sfx/manifest.json{
"chime": {
"file": "chime.mp3",
"duration": 2.5,
"description": "Soft melodic chime — gentle positive beat: success/confirmation or a lighthearted transition. Sync to the visual moment."
},
"click-soft": {
"file": "click-soft.mp3",
"duration": 0.37,
"description": "Quiet short click — low-key UI tap / soft selection. Short accent, sync exactly to the on-screen action."
},
"click": {
"file": "click.mp3",
"duration": 0.37,
"description": "Crisp UI click — button press, toggle, selection. Short accent, sync exactly to the on-screen action."
},
"error": {
"file": "error.mp3",
"duration": 1.62,
"description": "Negative / error tone — failure state, a 'wrong' beat, or a glitchy interruption."
},
"glitch-1": {
"file": "glitch-1.mp3",
"duration": 2.64,
"description": "Punchy digital glitch — hard-cut accent or sudden reveal. Trigger on the hit; let the decay bleed into the next shot (J-cut)."
},
"glitch-2": {
"file": "glitch-2.mp3",
"duration": 3.5,
"description": "Harsh, longer glitch — chaotic / jarring transition or a distorted reveal."
},
"glitch-3": {
"file": "glitch-3.mp3",
"duration": 3.1,
"description": "Low-key glitch texture — subtle digital shift, minimal transition that sits under other audio."
},
"impact-bass-1": {
"file": "impact-bass-1.mp3",
"duration": 2.12,
"description": "Bass impact hit — logo/hero snap, headline slam. Trigger on the visual landing; decay carries into the next shot (J-cut)."
},
"impact-bass-2": {
"file": "impact-bass-2.mp3",
"duration": 2.59,
"description": "Bass impact with a short swell — brief anticipation then a deep hit. Place so the peak lands on the reveal."
},
"key-press": {
"file": "key-press.mp3",
"duration": 0.4,
"description": "Single key press — one keystroke / terminal-input beat. Short accent, sync to the typed character."
},
"notification": {
"file": "notification.mp3",
"duration": 2.46,
"description": "Notification chime — alert, message-in, toast/badge appears. Sync to the element entering."
},
"ping": {
"file": "ping.mp3",
"duration": 1.32,
"description": "Sharp electronic ping — punchy accent on a key reveal or data point. Sync to the beat."
},
"pop": {
"file": "pop.mp3",
"duration": 0.72,
"description": "Quick pop — element appear/spawn, chip/tag/badge in. Small precise accent, sync to the pop-in."
},
"riser": {
"file": "riser.mp3",
"duration": 10.03,
"description": "Long cinematic riser (~10s build, peak at the end). Trigger at (climax_time − 10.03s) so it crests exactly on the reveal."
},
"sparkle": {
"file": "sparkle.mp3",
"duration": 1.8,
"description": "Bright sparkle / shimmer — magical reveal or 'shine' highlight on a hero element. Sync to the highlight."
},
"typing": {
"file": "typing.mp3",
"duration": 1.5,
"description": "Typing burst (~1.5s of keys) — keyboard / code typing reveal, text-being-typed beat. Start as the text begins typing."
},
"whoosh-cinematic": {
"file": "whoosh-cinematic.mp3",
"duration": 5.54,
"description": "Cinematic whoosh build (~5.5s) — sweeping scene transition. Align so the swell peaks on the cut."
},
"whoosh-short": {
"file": "whoosh-short.mp3",
"duration": 0.57,
"description": "Short whoosh — quick swipe/slide accent, fast element move, snappy transition. Sync to the motion."
},
"whoosh": {
"file": "whoosh.mp3",
"duration": 0.57,
"description": "Punchy whoosh/impact — fast reveal or hard transition accent. Sync to the motion."
}
}
audio/references/bgm.md# Background music (BGM)
One music bed per composition, produced by the shared audio engine (`scripts/audio.mjs` → `scripts/lib/bgm.mjs`). Two routes, chosen by the engine's one switch — whether a HeyGen credential is present:
- **HeyGen retrieval — the default when credentialed.** Search HeyGen's music catalog by mood, download the top track. No generation; same `~/.heygen` / `$HEYGEN_API_KEY` credential as TTS.
- **Local generation (Lyria → MusicGen) — the fallback when there is no credential** (or when asked for explicitly). Generate a WAV from a mood prompt. There is **no `npx hyperframes bgm` command**; the engine spawns `scripts/lyria-recipe.py` or an inline MusicGen script directly.
> **Run the Preflight first — no credential is not a green light to silently generate locally.** Before generating, complete the sign-in **Preflight** (see `../SKILL.md` → Preflight): run `npx hyperframes auth status`, recommend signing in, and **STOP for the user's choice** (sign in for HeyGen's music library, or continue offline with local generation). This applies to a one-off "generate a BGM" request just as much as inside a full workflow.
## Driving it from the request
`audio_request.json` → `bgm: { mode?, query?, prompt? }`:
- **`mode`** — `retrieve | generate | none`. Omit for **auto** (retrieve when credentialed, else generate). An **explicit** `retrieve` is strict: no credential ⇒ skip, never a detached generate (so a caller with no `wait-bgm` step, e.g. product-launch, can't get a pending job it won't await).
- **`query`** — the mood, used for retrieval and as a fallback prompt seed (e.g. a storyboard's `music:` field, falling back to `message` → `arc` → `"calm cinematic underscore"`).
- **`prompt`** — an explicit full prompt for generation; omit and the engine infers one (see Mood inference). Optional `blob` / `archetype` / `arc` feed that inference.
## HeyGen retrieval (default)
`searchSounds(query, "music", { limit: 5 })` → `GET /audio/sounds?query=<mood>&type=music&limit=5`. Take the top result (ranked by `score`), download its presigned `audio_url` → `assets/bgm/track.mp3`. Synchronous. No match → skip (BGM is optional; never fail the render over it). Cue written to `audio_meta.json`:
```jsonc
{
"path": "assets/bgm/track.mp3",
"volume": 0.12,
"mode": "retrieve",
"query": "calm cinematic underscore",
"duration_s": 42.0,
}
```
`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.
For short launch videos, do not assume the beginning of the retrieved file is the best edit point. Check the opening against later five-second sections. If the track starts with a quiet build but a later section has a stronger, clean musical entrance, trim from that section and apply a short fade-in and longer fade-out. Repeat this check whenever the composition duration changes; the final music file must cover the full cut without a silent tail.
## Local generation (fallback) — Lyria → MusicGen
Spawned **detached** so voice work isn't blocked; `audio_meta.bgm_pending: true` and `bgm_pid` / `bgm_log` are set until it finishes. **Run `scripts/wait-bgm.mjs` before assembling** — it polls the output file / process / log, detects crashes, and writes `bgm_status.json` (`status: ready | failed | timeout | disabled`). A failed/absent track is simply omitted; it never blocks voice/SFX.
| Order | Provider | Env / deps | Speed | Quality |
| ----- | ------------------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------- |
| 1 | Google Lyria RealTime | `$GEMINI_API_KEY` or `$GOOGLE_API_KEY` + `google-genai` (auto-installed on demand) | Real-time stream (≈ requested duration) | Production-grade |
| 2 | MusicGen (`facebook/musicgen-small`) | Python `transformers + torch + soundfile + numpy` (~300 MB first run; auto-installed) | Slow on CPU; fast on Apple MPS / CUDA | Decent; prompt-only control |
Output → `assets/bgm/track.wav`, target = total voice duration. MusicGen generates **one** seed clip (≤28–30s, under the decoder's positional limit) then crossfade-loops it up to the target (or trims down if shorter), avoiding per-segment seams. Backend selection is by what can actually **run**: Lyria only when `import google.genai` succeeds, else MusicGen; if neither can be made to run, BGM is skipped (voice + SFX still render).
## Mood inference (the generate prompt)
`inferBgmPrompt()` in `scripts/lib/bgm.mjs`: an explicit `prompt` wins; otherwise industry-keyword **base** → narrative-**archetype** shape → emotional-**arc** tiebreaker.
| Match in `blob` / `query` | Base prompt | BPM |
| ------------------------------------------------------ | --------------------------------------------------------------------------- | --- |
| `crypto / nft / web3 / defi / token / blockchain` | atmospheric electronic, deep bass, futuristic synths, restrained percussion | 100 |
| `finance / fintech / bank / payment / invest / wealth` | calm cinematic, soft strings, subtle piano, restrained percussion | 92 |
| `creative / agency / design / studio / art / brand` | playful electronic, warm pads, light percussion | 115 |
| _(default: SaaS / tech / platform)_ | uplifting corporate tech, bright modern piano with synth pads | 108 |
Archetype then reshapes the arc — PAS → "MINOR to MAJOR" build; BAB / future-pacing → aspirational rising; feature-cascade → +10 BPM driving; demo-loop → −8 BPM minimal. The emotional arc breaks remaining ties (tension→relief, excitement, trust/reassurance).
## Lyria knobs (direct recipe use)
The engine bakes BPM / scale into the **prompt text** (via the inference above) and passes only `--output` / `--duration` / `--prompt` to the recipe. If you invoke `scripts/lyria-recipe.py` directly you can also set: `--bpm` (90–110 calm, 110–130 energetic), `--brightness` (0–1, ≥0.7 promotional), `--density` (0–1, higher = fuller), `--scale` (`MAJOR` / `MINOR` / `PENTATONIC` / …), `--negative-prompt` (styles to exclude). MusicGen ignores all of these — put the mood in the prompt.
## Failure modes
| Failure | Behavior |
| --------------------------------------------- | ---------------------------------------------------------------------------------------- |
| No music match (retrieve) | `bgm: null`, anomaly logged. Render proceeds without BGM. |
| Explicit `retrieve`, no credential | Skipped (no silent generate fallback). Use `mode: generate` or omit `mode` for auto. |
| Neither Lyria nor MusicGen can run (generate) | `bgm` disabled with a `pip install …` hint. Voice + SFX still render. |
| Generate still rendering at assemble time | `bgm_pending: true`; `wait-bgm.mjs` waits/checks and writes `bgm_status.json` first. |
| Generate crashed | `wait-bgm.mjs` → `bgm_status.json { status: "failed" }`; the `<audio>` track is omitted. |
BGM failure never blocks a render.
audio/references/captions/authoring.md# Captions
Before authoring: confirm the transcript came from the right Whisper model. CLI default `small.en` silently translates non-English audio — see [`../transcribe.md`](../transcribe.md) → "Language Rule" and [`transcript-handling.md`](transcript-handling.md) for the mandatory quality check.
Analyze spoken content to determine caption style. If user specifies a style, use that. Otherwise, detect tone from the transcript.
## Transcript Source
```json
[
{ "id": "w0", "text": "Hello", "start": 0.0, "end": 0.5 },
{ "id": "w1", "text": "world.", "start": 0.6, "end": 1.2 }
]
```
`id` (`w0`, `w1`, …) is the stable reference for per-word overrides and is added by `hyperframes transcribe`. It's optional for backwards compatibility with hand-authored transcripts. See [`../transcribe.md`](../transcribe.md) → "Output Shape" for how this is produced, and [`transcript-handling.md`](transcript-handling.md) for cleanup before consumption.
## Style Detection (When No Style Specified)
Read the full transcript before choosing. Four dimensions:
**1. Visual feel** — corporate→clean; energetic→bold; storytelling→elegant; technical→precise; social→playful.
**2. Color palette** — dark+bright for energy; muted for professional; high contrast for clarity; one accent color.
**3. Font mood** — heavy/condensed for impact; clean sans for modern; rounded for friendly; serif for elegance.
**4. Animation character** — scale-pop for punchy; gentle fade for calm; word-by-word for emphasis; typewriter for technical.
## Per-Word Styling
Scan for words deserving distinct treatment:
- **Brand/product names** — larger size, unique color
- **ALL CAPS** — scale boost, flash, accent color
- **Numbers/statistics** — bold weight, accent color
- **Emotional keywords** — exaggerated animation (overshoot, bounce)
- **Call-to-action** — highlight, underline, color pop
- **Marker highlight** — for beyond-color emphasis (highlight sweep, circle, burst, scribble, sketchout), see `hyperframes-animation/rules/css-marker-patterns.md`.
## Script-to-Style Mapping
| Tone | Font mood | Animation | Color | Size |
| ------------ | ------------------------ | ---------------------------------- | --------------------------- | ------- |
| Hype/launch | Heavy condensed, 800-900 | Scale-pop, back.out(1.7), 0.1-0.2s | Bright on dark | 72-96px |
| Corporate | Clean sans, 600-700 | Fade+slide, power3.out, 0.3s | White/neutral, muted accent | 56-72px |
| Tutorial | Mono/clean sans, 500-600 | Typewriter/fade, 0.4-0.5s | High contrast, minimal | 48-64px |
| Storytelling | Serif/elegant, 400-500 | Slow fade, power2.out, 0.5-0.6s | Warm muted tones | 44-56px |
| Social | Rounded sans, 700-800 | Bounce, elastic.out, word-by-word | Playful, colored pills | 56-80px |
## Word Grouping
- **High energy:** 2-3 words. Quick turnover.
- **Conversational:** 3-5 words. Natural phrases.
- **Measured/calm:** 4-6 words. Longer groups.
Break on sentence boundaries, 150ms+ pauses, or max word count.
## Positioning
- **Landscape (1920x1080):** Bottom 80-120px, centered
- **Portrait (1080x1920):** Lower middle ~600-700px from bottom, centered
- Never cover the subject's face
- `position: absolute` — never relative
- One caption group visible at a time
## Text Overflow Prevention
Use `window.__hyperframes.fitTextFontSize()`:
```js
var result = window.__hyperframes.fitTextFontSize(group.text.toUpperCase(), {
fontFamily: "Outfit",
fontWeight: 900,
maxWidth: 1600,
});
el.style.fontSize = result.fontSize + "px";
```
Options: `maxWidth` (1600 landscape, 900 portrait), `baseFontSize` (78), `minFontSize` (42), `fontWeight`, `fontFamily`, `step` (2).
CSS safety nets: `max-width` on container, `overflow: visible` (**not** `hidden` — hidden clips scaled emphasis words and glow effects), `position: absolute`, explicit `height`. When per-word styling uses `scale > 1.0`, compute `maxWidth = safeWidth / maxScale` to leave headroom.
**Container pattern:** Full-width absolute container, centered. Do **not** use `left: 50%; transform: translateX(-50%)` — causes clipping at composition edges.
## Caption Exit Guarantee
Every group **must** have a hard kill after exit animation:
```js
tl.to(groupEl, { opacity: 0, scale: 0.95, duration: 0.12, ease: "power2.in" }, group.end - 0.12);
// `tl.set` is an instant flip, not a tween — safe to set `visibility` here (core's "no animating
// visibility" rule applies to tweens, which can't smoothly interpolate non-numeric values anyway).
tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end);
```
Self-lint after building timeline — place **before** `window.__timelines[id] = tl` so it runs at composition init:
```js
GROUPS.forEach(function (group, gi) {
var el = document.getElementById("cg-" + gi);
if (!el) return;
tl.seek(group.end + 0.01);
var computed = window.getComputedStyle(el);
if (computed.opacity !== "0" && computed.visibility !== "hidden") {
console.warn(
"[caption-lint] group " + gi + " still visible at t=" + (group.end + 0.01).toFixed(2) + "s",
);
}
});
tl.seek(0);
```
## Pre-Built Caption Components
Before building caption styles from scratch, check the registry — 15 ready-to-use caption components cover the most common styles. Install with `npx hyperframes add <name>` and wire as a sub-composition via `data-composition-src` (see `hyperframes-registry`).
```bash
npx hyperframes catalog --tag caption-style # list all caption components
npx hyperframes add caption-highlight # install a specific one
```
| Style | Component | Best for |
| ------------------------- | ---------------------------- | ---------------------------- |
| TikTok-style highlight | `caption-highlight` | Social, high-energy |
| Karaoke pill | `caption-pill-karaoke` | Music, lyric videos |
| Cinematic editorial | `caption-editorial-emphasis` | Documentary, storytelling |
| Glitch / cyber | `caption-glitch-rgb` | Tech, gaming |
| Full-screen slam | `caption-kinetic-slam` | Hype, announcements |
| Neon glow | `caption-neon-glow` | Night, club, neon aesthetics |
| Neon accent (multi-color) | `caption-neon-accent` | Colorful, playful |
| Wipe reveal | `caption-clip-wipe` | Clean, modern |
| Gradient fill | `caption-gradient-fill` | Vibrant, eye-catching |
| Matrix decode | `caption-matrix-decode` | Sci-fi, tech reveals |
| Emoji pop | `caption-emoji-pop` | Social, casual |
| Parallax layers | `caption-parallax-layers` | Depth, cinematic |
| Particle burst | `caption-particle-burst` | Celebration, impact keywords |
| Lava texture | `caption-texture` | Bold, dramatic |
| Weight shift | `caption-weight-shift` | Elegant, typographic |
Related: `caption-blend-difference` (tagged `text` / `blend-mode`, not `caption-style`, so it won't appear under the filter above) auto-inverts text against any background via `mix-blend-mode: difference` — useful when the background is busy or unpredictable.
Browse all with previews: [hyperframes.heygen.com/catalog](https://hyperframes.heygen.com/catalog)
Caption components ship with transparent backgrounds — they're pure overlays. If the underlying video is bright or busy, add a contrast layer (e.g. a semi-transparent dark div) in the host composition beneath the caption sub-composition, not inside the component itself.
## Further References
- [`motion.md`](motion.md) — karaoke, marker effects, audio-reactive modulation, scatter exits.
- [`transcript-handling.md`](transcript-handling.md) — input formats, quality checks, cleaning, external API fallback.
- `hyperframes-animation/rules/css-marker-patterns.md` — marker highlighting (deterministic, fully seekable).
## Constraints
- Deterministic. No `Math.random()`, no `Date.now()`.
- Sync to transcript timestamps.
- One group visible at a time.
- Every group must have a hard `tl.set` kill at `group.end`.
- Fonts: the compiler auto-embeds only its **built-in mapped set** (Inter, Roboto, Montserrat, …) — for those, just declare `font-family` in CSS. Any **other** font (a brand/custom font like `TT Norms Pro`, or a non-Latin CJK/Devanagari family) is **not** auto-supplied: it needs an `@font-face` pointing at a real `.woff2` shipped with the project, or the text silently falls back to a generic font in the render. Don't assume a `font-family` you can see locally will render — the render machine is a clean headless Chrome with no installed fonts.
audio/references/captions/motion.md# Dynamic Caption Techniques
You are here because SKILL.md told you to read this file before writing animation code. Pick your technique combination from the table below based on the energy level you detected from the transcript, then implement using standard GSAP patterns.
## Technique Selection by Energy
| Energy level | Highlight | Exit | Cycle pattern |
| ------------ | ------------------------------------- | ------------------- | ----------------------------------------- |
| High | Karaoke with accent glow + scale pop | Scatter or drop | Alternate highlight styles every 2 groups |
| Medium-high | Karaoke with color pop | Scatter or collapse | Alternate every 3 groups |
| Medium | Karaoke (subtle, white only) | Fade + slide | Alternate every 3 groups |
| Medium-low | Karaoke (minimal scale change) | Fade | Single style, vary ease per group |
| Low | Karaoke (warm tones, slow transition) | Collapse | Alternate every 4 groups |
**All energy levels use karaoke highlight as the baseline.** The difference is intensity — high energy gets accent color + glow + 15% scale pop on active words, low energy gets a gentle white shift with 3% scale.
**Emphasis words always break the pattern.** When a word is flagged as emphasis (emotional keyword, ALL CAPS, brand name), give it a stronger animation than surrounding words (larger scale, accent color, overshoot ease). This creates contrast.
**Marker highlight modes add a visual layer on top of karaoke.** For emphasis words that need more than color/scale, add a marker-style effect: highlight sweep, circle, burst, scribble, or sketchout. See `hyperframes-animation/rules/css-marker-patterns.md` for implementation details. Match mode to energy: burst for hype, circle for key terms, highlight for standard, scribble for subtle.
## Audio-Reactive Captions (Mandatory for Music)
**If the source audio is music (vocals over instrumentation, beats, any musical content), you MUST extract audio data and add audio-reactive animations.** This is not optional — music without audio reactivity looks disconnected. Even low-energy ballads get subtle bass pulse and treble glow.
No special wiring is needed. The group loop already iterates over every caption group to build entrance, karaoke, and exit tweens. At that point, read the audio data for each group's time range and use it to modulate the group's animation intensity with regular GSAP tweens.
```js
// Load audio data inline (same pattern as TRANSCRIPT)
var AUDIO = JSON.parse(audioDataJson); // { fps, totalFrames, frames: [{ bands: [...] }] }
GROUPS.forEach(function (group, gi) {
var groupEl = document.getElementById("cg-" + gi);
if (!groupEl) return;
// Read peak energy for this group's time range
var startFrame = Math.floor(group.start * AUDIO.fps);
var endFrame = Math.min(Math.floor(group.end * AUDIO.fps), AUDIO.totalFrames - 1);
var peakBass = 0;
var peakTreble = 0;
for (var f = startFrame; f <= endFrame; f++) {
var frame = AUDIO.frames[f];
if (!frame) continue;
peakBass = Math.max(peakBass, frame.bands[0] || 0, frame.bands[1] || 0);
peakTreble = Math.max(peakTreble, frame.bands[6] || 0, frame.bands[7] || 0);
}
// Modulate entrance — louder groups enter bigger and glowier
tl.to(
groupEl,
{
scale: 1 + peakBass * 0.06,
textShadow:
"0 0 " + Math.round(peakTreble * 12) + "px rgba(255,255,255," + peakTreble * 0.4 + ")",
duration: 0.3,
ease: "power2.out",
},
group.start,
);
// Reset at exit so audio-driven values don't persist
tl.set(groupEl, { scale: 1, textShadow: "none" }, group.end - 0.15);
});
```
This shapes the animation at build time, not playback time — no per-frame callbacks, no `tl.call()` loops, no async fetch timing issues. Loud groups come in with more weight and glow; quiet groups come in soft. The audio data modulates _how much_, the content determines _what_.
Keep audio reactivity subtle — 3-6% scale variation and soft glow. Heavy pulsing makes text unreadable.
To generate the audio data file:
```bash
python3 skills/hyperframes-creative/scripts/extract-audio-data.py audio.mp3 --fps 30 --bands 8 -o audio-data.json
```
## Combining Techniques
Don't use the same highlight animation on every group — cycle through styles using the group index. Don't combine multiple competing animations on the same word at the same timestamp. Vary techniques across groups to match the content's pace changes.
**Marker highlight effects** layer well with karaoke — use karaoke for the word-by-word reveal, then add a marker effect on emphasis words only. For example: karaoke highlights each word in white, but brand names get a yellow highlight sweep and stats get a red circle. Cycle marker modes across groups for visual variety.
## Runtime Tools
Caption motion uses standard HyperFrames runtime APIs. Use the canonical sources:
- **GSAP timeline + tween syntax** — `hyperframes-animation/adapters/gsap.md` (eases, position parameter, performance)
- **`window.__hyperframes.fitTextFontSize` / `pretext`** — `hyperframes-core/references/determinism-rules.md` → Layout Contract (overflow prevention, per-frame text measurement)
- **Audio data extraction** — generate via `python3 skills/hyperframes-creative/scripts/extract-audio-data.py audio.mp3 --fps 30 --bands 8 -o audio-data.json`, then load inline as shown in "Audio-Reactive Captions" above
audio/references/captions/transcript-handling.md# Transcript Guide
For the `transcribe` CLI invocation, the `.en`-translates-non-English rule, and whisper model selection, see [`../transcribe.md`](../transcribe.md). This file covers what to do with the resulting transcript when authoring captions: input formats, mandatory quality checks, cleaning code, external-API fallbacks.
## Supported Input Formats
The CLI auto-detects and normalizes these formats:
| Format | Extension | Source | Word-level? |
| --------------------- | --------- | --------------------------------------------------------------------------- | ----------------- |
| whisper.cpp JSON | `.json` | `hyperframes init --video`, `hyperframes transcribe` | Yes |
| OpenAI Whisper API | `.json` | `openai.audio.transcriptions.create({ timestamp_granularities: ["word"] })` | Yes |
| SRT subtitles | `.srt` | Video editors, subtitle tools, YouTube | No (phrase-level) |
| VTT subtitles | `.vtt` | Web players, YouTube, transcription services | No (phrase-level) |
| Normalized word array | `.json` | Pre-processed by any tool | Yes |
**Word-level timestamps produce better captions.** SRT/VTT give phrase-level timing, which works but can't do per-word animation effects.
## Transcript Quality Check (Mandatory)
After every transcription, **read the transcript and check for quality issues before proceeding.** Bad transcripts produce nonsensical captions. Never skip this step.
### What to look for
| Signal | Example | Cause |
| ---------------------------- | -------------------------------------- | ---------------------------------------------------------------------------- |
| Music note tokens (`♪`, `�`) | `{ "text": "♪" }` or `{ "text": "�" }` | Whisper detected music, not speech |
| Garbled / nonsense words | "Do a chin", "Get so gay", "huh" | Model misheard lyrics or background noise |
| Long gaps with no words | 20+ seconds of only `♪` tokens | Instrumental section — expected, but high ratio means speech is being missed |
| Repeated filler | Many "huh", "uh", "oh" entries | Model is hallucinating on music |
| Very short word spans | Words with `end - start < 0.05` | Unreliable timestamp alignment |
### Automatic retry rules
**If more than 20% of entries are `♪`/`�` tokens, or the transcript contains obvious nonsense words, the transcription failed.** Do not proceed with the bad transcript. Instead:
1. **Retry with `medium.en`** if the original used `small.en` or smaller:
```bash
npx hyperframes transcribe audio.mp3 --model medium.en
```
2. **If `medium.en` also fails** (still >20% music tokens or garbled), tell the user the audio is too noisy for local transcription and suggest:
- Providing lyrics manually as an SRT/VTT file
- Using an external API (OpenAI or Groq Whisper — see below)
3. **Always clean the transcript** before building captions — filter out `♪`/`�` tokens and entries where `text` is a single non-word character. Only real words should reach the caption composition.
### Cleaning a transcript
After transcription (even with a good model), strip non-word entries:
```js
var raw = JSON.parse(transcriptJson);
var words = raw.filter(function (w) {
if (!w.text || w.text.trim().length === 0) return false;
if (/^[♪�\u266a\u266b\u266c\u266d\u266e\u266f]+$/.test(w.text)) return false;
if (/^(huh|uh|um|ah|oh)$/i.test(w.text) && w.end - w.start < 0.1) return false;
return true;
});
```
For model-selection guidance by content type, see [`../transcribe.md`](../transcribe.md) → "Picking a model by content type".
## Using External Transcription APIs
For the best accuracy, use an external API and import the result:
**OpenAI Whisper API** (recommended for quality):
```bash
# Generate with word timestamps, then import
curl https://api.openai.com/v1/audio/transcriptions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-F file=@audio.mp3 -F model=whisper-1 \
-F response_format=verbose_json \
-F "timestamp_granularities[]=word" \
-o transcript-openai.json
npx hyperframes transcribe transcript-openai.json
```
**Groq Whisper API** (fast, free tier available):
```bash
curl https://api.groq.com/openai/v1/audio/transcriptions \
-H "Authorization: Bearer $GROQ_API_KEY" \
-F file=@audio.mp3 -F model=whisper-large-v3 \
-F response_format=verbose_json \
-F "timestamp_granularities[]=word" \
-o transcript-groq.json
npx hyperframes transcribe transcript-groq.json
```
## If No Transcript Exists
1. Check the project root for `transcript.json`, `.srt`, or `.vtt` files.
2. If none found, run [`../transcribe.md`](../transcribe.md) — pick the starting model from "Picking a model by content type" there.
3. Run the quality check above. If it fails, retry with a larger model or fall back to manual lyrics / external API.
audio/references/remove-background.md# Background Removal
Make a transparent overlay (typical: a talking head over an arbitrary scene). Uses `u2net_human_seg` (MIT).
```bash
npx hyperframes remove-background subject.mp4 -o transparent.webm # default: VP9 + alpha
npx hyperframes remove-background subject.mp4 -o transparent.mov # ProRes 4444 (editing)
npx hyperframes remove-background portrait.jpg -o cutout.png # single-image cutout
npx hyperframes remove-background subject.mp4 -o subject.webm \
--background-output plate.webm # both layers, one pass
npx hyperframes remove-background subject.mp4 -o transparent.webm --device cpu
npx hyperframes remove-background --info # detected providers
```
## Output Format
- **`.webm` (VP9 alpha)** — default. Plug straight into `<video>` for Chrome-native transparent playback (~1 MB / 4s @ 1080p).
- **`.mov` (ProRes 4444)** — round-trip in editors (Premiere / Resolve / DaVinci). ~50 MB / 4s.
- **`.png`** — single-image cutout.
## Quality (`--quality`)
Controls VP9 encoder CRF only — segmentation quality is fixed. Higher quality keeps the cutout's RGB closer to the source MP4 (important when overlaying the cutout on its own source).
| Preset | CRF | When |
| ---------- | --- | --------------------------------------------- |
| `fast` | 30 | Iterating, smaller files, looser color match |
| `balanced` | 18 | **Default**; visually identical for most uses |
| `best` | 12 | Master / final delivery, tightest color match |
## Device (`--device`)
`auto` (default) picks CoreML on Apple Silicon, CUDA when available, otherwise CPU. Force with `--device cpu | coreml | cuda`. CUDA requires `HYPERFRAMES_CUDA=1` plus a GPU-enabled `onnxruntime-node` build. Use `--info` to inspect detected providers without rendering.
## Compositing patterns — pick the right one
The cutout WebM is a **re-encoded copy** of the source MP4's RGB. What sits behind it matters.
| Pattern | Behind the cutout | Result |
| -------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| **Cutout over a different scene** (most common) | Static image, gradient, unrelated video | Looks great. Single RGB source for the subject. |
| **Cutout over its own source mp4** (text-behind-subject) | Same mp4 the cutout came from | At `balanced` doubling is barely visible; at `fast` you'll see color shift / edge halo. Use `best` for masters. |
| **Cutout over a different take of the same person** | Footage of the same subject | **Two overlapping people. Don't do this.** |
## Text-behind-subject pattern (two non-obvious rules)
Putting a headline behind a presenter cutout:
```html
<video
src="presenter.mp4"
id="bg"
data-start="0"
data-duration="6"
data-track-index="0"
muted
playsinline
></video>
<h1 id="headline" style="z-index:2; ...">MAKE IT IN HYPERFRAMES</h1>
<div class="cutout-wrap" style="position:absolute; inset:0; z-index:3; opacity:0">
<video
src="presenter.webm"
data-start="0"
data-duration="6"
data-track-index="1"
muted
playsinline
></video>
</div>
```
```js
// Flip the wrapper's opacity at the cut, NOT the video's
tl.set(".cutout-wrap", { opacity: 1 }, 3.3);
```
Two rules that are easy to miss:
1. **Wrap the cutout `<video>` in a non-timed `<div>` and animate the wrapper's opacity, not the video element's.** The framework forces `opacity: 1` on active clips (any element with `data-start` / `data-duration`), so animating the video's opacity directly is silently overridden. The wrapper has no `data-*` attributes, so it's owned by your CSS / GSAP.
2. **Both videos use `data-start="0"` and `data-media-start="0"`** so the framework decodes them in sync from t=0. Late-mounting the cutout (`data-start=3.3`) introduces a seek + warm-up that lands a frame off the base mp4 — visible as one frame of misalignment at the cut.
## Layer separation (`--background-output`)
Emits a **second** transparent video alongside the cutout: same source RGB, alpha is `255 - mask` instead of `mask`. The cutout has the subject opaque; the plate has the surroundings opaque (with a transparent hole where the subject was). Use it when text / graphics need to live **between** the two layers.
| File | Alpha is… | Use it for |
| -------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------- |
| `-o subject.webm` | mask — subject opaque, background transparent | Foreground layer (top) |
| `--background-output plate.webm` | inverse mask — surroundings opaque, subject transparent | Bottom layer; place text / graphics between this and the subject |
Both share the same `--quality` and run from a single inference pass — only encode cost roughly doubles. Only valid for video inputs with `.webm` / `.mov` outputs.
**Hole-cut, not inpainted.** The subject region in `plate.webm` is fully transparent — composite something opaque under it to fill the hole.
**Single test for whether `--background-output` is the right tool:** _will anything ever be visible through the subject's silhouette where the subject used to be?_ If no, you don't need the plate — `subject.webm` alone over a different background is enough.
### Use case → right tool
| Use case | Right tool |
| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| Text/graphics between the cutout and the plate (this command's reason for existing) | **Hole-cut** (`--background-output`) |
| Subject onto an unrelated scene | Just `subject.webm`; ignore the plate |
| Show the room _without_ the person, alone over no other content | **Clean plate** — needs an inpainter (LaMa, ProPainter, E2FGVI). Not this command. |
| Replace the subject with a different subject | **Clean plate** — same as above |
### Canonical 3-layer template (plate + content + cutout)
Ship just the two transparent layers and let arbitrary content live between them — no original mp4 needed:
```html
<!-- z=1 plate: surroundings opaque, subject silhouette transparent -->
<video
src="plate.webm"
data-start="0"
data-duration="6"
data-track-index="0"
muted
playsinline
></video>
<!-- z=2 your content lives between the layers -->
<h1 id="headline" style="z-index:2; ...">MAKE IT IN HYPERFRAMES</h1>
<!-- z=3 cutout floats the subject back on top -->
<div class="cutout-wrap" style="position:absolute; inset:0; z-index:3">
<video
src="subject.webm"
data-start="0"
data-duration="6"
data-track-index="1"
muted
playsinline
></video>
</div>
```
Functionally equivalent to the text-behind-subject pattern above, but doesn't require shipping the original mp4 — the plate replaces it. Use this when delivering just the two transparent layers as a reusable asset.
## When `remove-background` is NOT the right tool
If a user asks for "the room **without** the person, displayed standalone" (no subject anywhere, no compositing on top), `--background-output` is wrong — its plate has a transparent hole, not a filled-in clean plate. They need an **inpainter**: LaMa, ProPainter, or E2FGVI. Tell them this command can't do it.
audio/references/requirements.md# Requirements & Caches ## Credential & key priority Run `npx hyperframes auth status` to see what's configured and which engines a workflow will use (see the skill's **Preflight** section). Keys resolve in this order — **first match wins**: | Provider | Resolution order (first non-empty wins) | Local deps when used | | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | **HeyGen** (TTS + BGM/SFX retrieval) | `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → `~/.heygen/credentials` (shared with heygen-cli; `$HEYGEN_CONFIG_DIR` overrides the dir; written by `hyperframes auth login`) | none (REST) | | **ElevenLabs** (TTS fallback) | `$ELEVENLABS_API_KEY` | `pip install elevenlabs` | | **Lyria** (BGM fallback) | `$GEMINI_API_KEY` → `$GOOGLE_API_KEY` | `pip install google-genai` | | **Kokoro** (TTS, no key) | always — final voice fallback | `pip install kokoro-onnx soundfile` | | **MusicGen** (BGM, no key) | always — final music fallback | `pip install transformers torch soundfile numpy` | `hyperframes auth login` (browser OAuth) is the recommended setup: one sign-in, every project, no per-repo `.env`. An OAuth login is sent as `Authorization: Bearer`; an API key as `X-Api-Key`; both are tagged with `X-HeyGen-Source: cli`. OAuth CLI users can consume the web-plan free allowance for HeyGen TTS (10 min/month); API keys follow the normal API billing path. With no HeyGen credential, voice/BGM run fully locally (Kokoro / MusicGen) — `hyperframes auth status` and `hyperframes doctor` both report whether those local deps are installed. ## Model caches & system dependencies Each command downloads its own model on first run and caches it under `~/.cache/hyperframes/`: - **TTS (HeyGen)** — no local deps; needs a HeyGen credential + `ffmpeg` on PATH (to transcode the mp3 response to `.wav`). Credential resolves like the CLI: `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → `~/.heygen/credentials` (shared with heygen-cli; run `npx hyperframes auth login`). An OAuth login is sent as `Authorization: Bearer`; an API key as `X-Api-Key`; both include `X-HeyGen-Source: cli` so the backend can apply CLI OAuth free usage. - **TTS (ElevenLabs)** — same as HeyGen: API key + `ffmpeg`. - **TTS (Kokoro)** — Kokoro-82M (~311 MB) + voices (~27 MB) in `tts/`. Requires Python 3.8+ with `kokoro-onnx` and `soundfile` (`pip install kokoro-onnx soundfile`). Non-English text also needs `espeak-ng` system-wide. - **BGM (Lyria)** — needs `$GEMINI_API_KEY` or `$GOOGLE_API_KEY` + `pip install google-genai`. No local model cache. - **BGM (MusicGen)** — `pip install transformers torch soundfile`. `facebook/musicgen-small` (~300 MB) cached under `~/.cache/huggingface/` on first run. - **Transcribe** — Whisper model size depending on choice (75 MB – 3.1 GB) in `whisper/`, downloaded from HuggingFace on first use. `whisper.cpp` itself is NOT bundled: the CLI resolves it from PATH, installs via Homebrew (macOS), or builds it from source with git+cmake on first use (`$HYPERFRAMES_WHISPER_PATH` overrides). - **Remove-background** — `u2net_human_seg` (~168 MB ONNX) in `background-removal/models/`. Peak inference RAM ~1.5 GB. Run `npx hyperframes doctor` if a command fails because of a missing dependency.
audio/references/sfx.md# Sound effects (SFX)
Named sound effects, produced by the shared audio engine (`scripts/audio.mjs` → `scripts/lib/sfx.mjs`). **Provider-gated** by the engine's one switch — whether a HeyGen credential is present, decided once (not per cue):
- **HeyGen credential present → retrieve every cue** from HeyGen's audio library (`/v3/audio/sounds`, `type=sound_effects`, `min_score=0.4`). Search-and-download, **not** generation. The bundled library is NOT consulted.
- **No credential → the bundled 21-file library** (`assets/sfx/` + `manifest.json`): match each cue name, copy the matched file into the project. Offline, deterministic, free.
There is no `npx hyperframes sfx` command. SFX is never generated — it is retrieved (online) or taken from the bundled library (offline).
## Cues — request → meta
Each line names the effects it wants: `lines[].sfx: ["whoosh", "ui click"]`. The engine flattens these into cues, resolves them per the switch, dedupes identical `(id, name)` pairs (the same effect named twice downloads/copies once), and writes `audio_meta.sfx[]`:
```jsonc
{
"id": "3", // joins the cue to the caller's model (frame / scene / segment)
"name": "whoosh",
"file": "assets/sfx/whoosh.mp3", // downloaded or copied, relative to project root
"source": "heygen" | "local", // which route resolved it
"offset_s": 0, // delay from the line's start
"duration_s": 0.57,
"volume": 0.35 // SFX sit UNDER voice + BGM
}
```
A cue that matches nothing is **skipped** (recorded as an anomaly); SFX never blocks a render.
## HeyGen retrieval (credentialed)
`searchSounds(name, "sound_effects", { limit: 3, minScore: 0.4 })` → top hit → `assets/sfx/<slug>.mp3`. Results are ranked by `score` (each carries a presigned `audio_url`, `duration`, `description`). The floor is **0.4** because good SFX hits score ~0.5–0.67 — below the API's default `0.7`, which would silently drop most named cues (only whoosh/swoosh-family clears 0.7). `duration_s` comes from the result (else 1.0). Name effects concretely (`glass shatter`, not `dramatic sound`); a vague query returns a poor match.
## Bundled library (no credential)
21 curated files in `assets/sfx/`, indexed by `manifest.json` — `{ file, duration, description }` per key (e.g. `whoosh`, `pop`, `click`, `chime`, `riser`, `impact-bass-1`, `glitch-1`, `typing`, …). A cue name resolves by **manifest key, file basename, or slug**, so `whoosh`, `whoosh.mp3`, or `"ui click"` (→ slug) all match. Matched files are copied into the project's `assets/sfx/`; `duration_s` comes from the manifest, so timing is known **offline** — e.g. `riser` is 10.03s, so trigger it at `climax − 10.03s`. The manifest's `description` field carries placement hints per effect; read `assets/sfx/manifest.json` for the full set and usage.
## Rules
- **Volume ~0.35.** SFX must sit under narration and BGM, not fight them.
- **No match → skip, don't fail.** A missing effect logs an anomaly and moves on; never a render blocker.
- **Retrieval (credentialed) or bundled library (offline) — never generation.** You search HeyGen by text, or match a name against the 21-file manifest.
- **One asset per distinct name.** Reuse across lines is deduped to a single download/copy, many cues.
- **The switch is global, not per cue.** With a credential, retrieval handles even the long tail (effects not in the 21); without one, only the 21 bundled names resolve.
audio/references/transcribe.md# Transcription
Create normalized word-level timestamps. **Always specify `--model` explicitly** — the CLI default is `small.en`, which silently translates non-English audio into English.
```bash
npx hyperframes transcribe audio.mp3 --model small.en # known English
npx hyperframes transcribe video.mp4 --model small --language es # known Spanish
npx hyperframes transcribe audio.mp3 --model small # unknown language (auto-detect)
npx hyperframes transcribe subtitles.srt # import existing
npx hyperframes transcribe subtitles.vtt
npx hyperframes transcribe openai-response.json
```
## Language Rule (Non-Negotiable)
`.en` models (`tiny.en` / `base.en` / `small.en` / `medium.en`) **translate** non-English audio into English. This silently destroys the original language.
1. **Known English** → `--model small.en` (or `medium.en` for music / noisy audio)
2. **Known non-English** → `--model small --language <iso-code>` (no `.en` suffix)
3. **Unknown language** → `--model small` (whisper auto-detects)
**CLI default is `small.en`** — do not rely on it; always pass `--model` to make the choice explicit. `--language` also filters out non-target-language segments from mixed-language audio.
## Model Sizes
| Model | Size | Speed | When |
| ---------- | ------ | -------- | ------------------------------------- |
| `tiny` | 75 MB | Fastest | Quick previews, smoke tests |
| `base` | 142 MB | Fast | Short clips, clear audio |
| `small` | 466 MB | Moderate | Default for most multilingual content |
| `medium` | 1.5 GB | Slow | Music with vocals, noisy audio |
| `large-v3` | 3.1 GB | Slowest | Production quality |
### Picking a model by content type
1. Speech over silence / light background → `small.en`
2. Speech over music, or music with vocals → start with `medium.en`
3. Produced music track (vocals + full instrumentation) → start with `medium.en`; expect to need manual lyrics or an external API ([`captions/transcript-handling.md`](captions/transcript-handling.md) → "Using External Transcription APIs")
4. Multilingual → `medium` or `large-v3` (no `.en` suffix), pair with `--language`
## Output Shape
Compositions consume a flat array of word objects. The `id` (`w0`, `w1`, …) is added during normalization for stable references in caption overrides; optional for backwards compatibility.
```json
[
{ "id": "w0", "text": "Hello", "start": 0.0, "end": 0.5 },
{ "id": "w1", "text": "world.", "start": 0.6, "end": 1.2 }
]
```
For mandatory caption-quality checks, retry rules, and the OpenAI/Groq Whisper API import path, see `captions/transcript-handling.md`.
audio/references/tts-to-captions.md# TTS → Captions
When no recorded voiceover exists, generate one and obtain word-level caption timing. Two paths depending on which TTS provider is in use:
## Path A — HeyGen (single call, no Whisper)
HeyGen returns word timestamps in the same response as the audio. Use the
bundled REST helper (the `hyperframes tts` command is Kokoro-only):
```bash
node skills/media-use/audio/scripts/heygen-tts.mjs \
script.txt --output narration.wav --words narration.words.json
```
`narration.words.json` is already in the `[{ id, text, start, end }]` shape the captions pipeline consumes — no separate transcribe pass.
## Path B — ElevenLabs / Kokoro (TTS → Whisper)
These providers don't return word data. Generate the audio, then transcribe:
```bash
npx hyperframes tts script.txt --voice af_heart --output narration.wav
npx hyperframes transcribe narration.wav --model small.en # voice af_heart is American English
```
Whisper extracts precise word boundaries from the generated audio, so caption timing matches delivery without hand-tuning. Match `--model` to the voice's language (use `small.en` for `a`/`b` prefixes, `small --language <code>` otherwise). Then consume `transcript.json` via the caption references in `captions/`.
audio/references/tts.md# Text To Speech
`npx hyperframes tts` synthesizes locally with Kokoro. It does not accept a
`--provider` or `--words` flag. For HeyGen audio plus word timestamps, use the
bundled `heygen-tts.mjs` script below.
> **Run the Preflight first — no credential is not a green light to silently use the local voice.** Before generating a voiceover, complete the sign-in **Preflight** (see `../SKILL.md` → Preflight): run `npx hyperframes auth status`, recommend signing in, and **STOP for the user's choice** (sign in for HeyGen voices, or continue offline with local Kokoro). This applies to a one-off "generate a voiceover" request just as much as inside a full workflow.
## Narrating a HyperFrames docs video
Anything that will sit on hyperframes.heygen.com uses one narrator, so the site
does not sound like two products.
| | |
| -------------- | ---------------------------------------------- |
| Voice | **River** — "Relaxed, Neutral, Informative" |
| Provider | ElevenLabs |
| `voice_id` | `SAz9YHcvj6GT2YYXdXww` |
| Model | `eleven_multilingual_v2` |
| Pace | 145–155 words per minute, with room to breathe |
| Music under it | about −31 LUFS, never masking the voice |
```bash
curl -s -X POST "https://api.elevenlabs.io/v1/text-to-speech/SAz9YHcvj6GT2YYXdXww" \
-H "xi-api-key: $ELEVENLABS_API_KEY" -H "Content-Type: application/json" \
-d '{"text":"...","model_id":"eleven_multilingual_v2"}' -o take.mp3
```
This is the voice every user-journey film on the docs site already uses. Falling
back to local Kokoro because a key was not to hand produces a film that sounds
wrong beside the others — three docs videos were built that way and had to be
re-voiced. If you cannot reach ElevenLabs, say so and stop rather than
substituting a different voice.
Use another voice only for a documented reason, and write the reason down.
## Available routes
| Order | Provider | Env trigger | Voice IDs | Word timestamps | Audio format |
| ----- | ----------------- | ------------------------------------------- | ------------------------------------------- | ----------------------------------------- | -------------------- |
| 1 | HeyGen (Starfish) | `$HEYGEN_API_KEY` / `~/.heygen/credentials` | UUIDs from `GET /v3/voices?engine=starfish` | **Yes** (`word_timestamps[]` in response) | mp3 → wav via ffmpeg |
| 2 | ElevenLabs | `$ELEVENLABS_API_KEY` | UUIDs from elevenlabs.io dashboard | No | mp3 → wav via ffmpeg |
| 3 | Kokoro-82M | always (local fallback) | `am_michael`, `af_heart`, … (54 voices) | No | wav direct |
```bash
# Local Kokoro CLI
npx hyperframes tts "Welcome to HyperFrames" -o narration.wav
```
## Self-contained HeyGen (no CLI) — `scripts/heygen-tts.mjs`
The published `hyperframes tts` CLI synthesizes locally with Kokoro only. When you
want HeyGen specifically — best quality **plus** word timestamps in one call — use
the skill's bundled script, which calls the HeyGen v3 REST API directly and needs
no CLI provider plumbing:
The script resolves a HeyGen credential the same way the CLI does — first source
wins: `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → a project `.env` (auto-loaded,
walks up ≤5 dirs) → `~/.heygen/credentials` (shared with heygen-cli;
`$HEYGEN_CONFIG_DIR` overrides the dir). An OAuth login is sent as
`Authorization: Bearer`; an API key as `X-Api-Key`; both include
`X-HeyGen-Source: cli`. OAuth CLI users can consume the web-plan free allowance
(10 min/month) before paid usage; API keys follow normal API billing. If the
only credential is an expired OAuth token it stops with a hint to run
`npx hyperframes auth refresh`.
```bash
# Only needed if you haven't run `npx hyperframes auth login`:
export HEYGEN_API_KEY=... # or put it in a project .env
# Synthesize + capture word timestamps in one call (skips a Whisper pass)
node skills/media-use/audio/scripts/heygen-tts.mjs \
"Welcome to HyperFrames." -o narration.wav --words narration.words.json
node skills/media-use/audio/scripts/heygen-tts.mjs ./script.txt -o narration.wav
node skills/media-use/audio/scripts/heygen-tts.mjs --list # public starfish voices
```
- **Voice:** `--voice <id>` must be a **starfish** voice_id (`--list`, or `GET /v3/voices?engine=starfish`). v2-catalog ids are rejected with HTTP 400. Omit `--voice` (English) and it defaults to **Marcia** (`05f19352e8f74b0392a8f411eba40de1`, a fixed default so the choice is deterministic). Non-English with no `--voice` falls back to the first matching catalog voice.
- **Output:** `.wav` → transcoded to 44.1k mono via ffmpeg; `.mp3` → raw bytes (no ffmpeg needed).
- **Words:** `--words <path>` writes the flat `[{id,text,start,end}]` shape below, drop-in for the captions pipeline. HeyGen's `<start>`/`<end>` boundary sentinels are filtered out and ids are re-contiguous.
- **Non-English:** `--lang <code>` (anything but `en`) is sent as the request `language`.
## When to use which provider
| Goal | Use |
| --------------------------------------------------------- | --------------------------------------------------- |
| Best voice quality + word timestamps in one call | **HeyGen** |
| Drop-in cloud TTS, big voice catalog | **ElevenLabs** |
| Offline, no API key, fast iteration | **Kokoro** |
| Non-English multilingual with deterministic phonemization | **Kokoro** (`ef_dora`, `jf_alpha`, `zf_xiaobei`, …) |
## ffmpeg requirement
HeyGen + ElevenLabs return mp3. The bundled HeyGen helper transcodes to wav
when `--output` ends in `.wav` (the default and what downstream `ffprobe` +
Whisper expect). If you'd rather skip the transcode, pass `-o file.mp3`.
Without `ffmpeg` on PATH, wav output from cloud providers fails; the local
Kokoro CLI writes wav directly.
## Voice selection (Kokoro)
Default `af_heart`. Curated picks:
| Content type | Voice |
| ----------------- | ---------------------- |
| Product demo | `af_heart`, `af_nova` |
| Tutorial / how-to | `am_adam`, `bf_emma` |
| Marketing / promo | `af_sky`, `am_michael` |
| Documentation | `bf_emma`, `bm_george` |
| Casual / social | `af_heart`, `af_sky` |
Run `npx hyperframes tts --list` for the bundled set.
## Multilingual (Kokoro voice prefix → language)
The first letter of a Kokoro voice ID picks the phonemizer language; `--lang` overrides auto-detection.
| Prefix | Language |
| ------ | -------------------- |
| `a` | American English |
| `b` | British English |
| `e` | Spanish |
| `f` | French |
| `h` | Hindi |
| `i` | Italian |
| `j` | Japanese |
| `p` | Brazilian Portuguese |
| `z` | Mandarin |
```bash
npx hyperframes tts "La reunión empieza a las nueve" --voice ef_dora
npx hyperframes tts "Today is a nice day" --voice af_heart
```
Valid `--lang` codes (only needed to override the voice's auto-detected language): `en-us`, `en-gb`, `es`, `fr-fr`, `hi`, `it`, `pt-br`, `ja`, `zh`.
Non-English phonemization requires `espeak-ng` system-wide (`brew install espeak-ng` / `apt-get install espeak-ng`).
## Speed
- `0.7-0.8` — tutorial, complex content, accessibility
- `1.0` — natural pace (default)
- `1.1-1.2` — intros, transitions, upbeat content
- `1.5+` — rarely appropriate, test carefully
The `hyperframes tts` command honors `--speed` for Kokoro. Provider-specific
helpers document their own pacing controls.
## Long scripts
Past a few paragraphs, write the text to a `.txt` file and pass the path. Inputs over ~5 minutes of speech may benefit from splitting into segments.
## HeyGen word-timestamp shape
When `--words <path>` is passed to a HeyGen call, the file is written in the same flat shape `transcribe` produces — drop-in compatible with the captions pipeline:
```json
[
{ "id": "w0", "text": "Hi", "start": 0.0, "end": 0.21 },
{ "id": "w1", "text": "there", "start": 0.22, "end": 0.55 }
]
```
For ElevenLabs / Kokoro, run `npx hyperframes transcribe narration.wav --model small.en` to get the same shape.
audio/scripts/audio.mjs#!/usr/bin/env node
// audio.mjs — the shared HyperFrames audio engine. ONE implementation of TTS +
// BGM + SFX for every video workflow (product-launch, general-video, pr-to-video,
// …). Workflows do NOT vendor a copy: they write a neutral `audio_request.json`
// (a tiny per-workflow adapter maps their storyboard/scenes into it) and call:
//
// node <MEDIA_DIR>/scripts/audio.mjs --request ./audio_request.json --hyperframes . --out ./audio_meta.json
//
// The three capabilities degrade on ONE switch — whether HeyGen is configured
// (credential present, NOT the CLI). This mirrors the table in ../SKILL.md:
//
// TTS : HeyGen REST → ElevenLabs → Kokoro (CLI)
// BGM : HeyGen retrieve → (no credential) Lyria/MusicGen generate
// SFX : HeyGen retrieve → (no credential) bundled 19-file library
//
// ── audio_request.json (input) ────────────────────────────────────────────────
// {
// "provider": "auto", // auto|heygen|elevenlabs|kokoro (override: --provider)
// "lang": "en", "speed": 1.0,
// "lines": [ // one TTS unit each; id joins back to the caller's model
// { "id": "01", "text": "...", "sfx": ["whoosh", "ui click"] }
// ],
// "bgm": { "mode": "retrieve", // retrieve|generate|none (override: --bgm-mode / --no-bgm)
// "query": "calm cinematic underscore", // mood for retrieval
// "prompt": null, // full prompt for generation (else inferred)
// "blob": "...", "archetype": "...", "arc": "..." } // optional mood-inference hints
// }
//
// ── audio_meta.json (output, id-keyed) ───────────────────────────────────────
// { tts_provider, voice_id,
// bgm: { path, volume, mode, query?, duration_s? } | null,
// bgm_pending, bgm_provider, bgm_pid, bgm_log, bgm_mode, bgm_target_duration_s, …,
// voices: [ { id, path, duration_s, words: [{id,text,start,end}] } ],
// sfx: [ { id, name, file, source, offset_s, duration_s, volume } ],
// total_duration_s }
//
// --only tts,bgm,sfx runs a subset and MERGES into an existing --out (so a
// workflow can do TTS+BGM early, then SFX later once cues exist). When BGM uses
// the generate path it is spawned detached (bgm_pending:true) — run wait-bgm.mjs
// before assembling.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { heygenAuthHeaders, heygenCredential, loadEnvFromDir } from "./lib/heygen.mjs";
import {
ffprobeDuration,
pickProvider,
resolveVoiceId,
synthesizeOne,
transcribeWav,
withWordIds,
} from "./lib/tts.mjs";
import { generateBgmDetached, inferBgmPrompt, retrieveBgm } from "./lib/bgm.mjs";
import { resolveSfx } from "./lib/sfx.mjs";
import { mapWithConcurrency } from "./lib/concurrency.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const flag = (name, def) => {
const i = argv.indexOf(`--${name}`);
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
};
const has = (name) => argv.includes(`--${name}`);
const die = (m) => {
console.error(`✗ audio engine: ${m}`);
process.exit(1);
};
const r3 = (x) => Number(x.toFixed(3));
// Two independent reports of an unbounded Promise.all over TTS lines
// overwhelming a machine: one OOM'd 12/13 concurrent Kokoro TTS +
// whisper-transcribe lines on a resource-constrained laptop, the other saw
// 7/8 lines fail on first run (concurrent cold-start model loads) and pass on
// retry once the model was cached. Kokoro/Whisper each load their own local
// model per subprocess, so firing every line at once multiplies that cost by
// the line count. mapWithConcurrency caps how many run at once — still
// parallel, just bounded.
const ttsConcurrency = Math.max(1, Number(process.env.HYPERFRAMES_TTS_CONCURRENCY) || 4);
const hyperframesDir = resolve(flag("hyperframes", "."));
const requestPath = resolve(flag("request", join(hyperframesDir, "audio_request.json")));
const outPath = resolve(flag("out", join(hyperframesDir, "audio_meta.json")));
const sfxLibDir = resolve(flag("sfx-lib", join(HERE, "..", "assets", "sfx")));
const lyriaRecipe = resolve(flag("lyria-recipe", join(HERE, "lyria-recipe.py")));
const onlyArg = flag("only", "tts,bgm,sfx");
const only = new Set(
onlyArg
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
const providerOverride = flag("provider", null);
const bgmModeOverride = flag("bgm-mode", null);
const noBgm = has("no-bgm");
const voiceOverride = flag("voice", null);
const speedOverride = flag("speed", null);
const langOverride = flag("lang", null);
const seedSeconds = Number(flag("seed-seconds", "28")) || 28;
if (!existsSync(requestPath)) die(`audio_request.json not found at ${requestPath}`);
let request;
try {
request = JSON.parse(readFileSync(requestPath, "utf8"));
} catch (e) {
die(`audio_request.json parse: ${e.message}`);
}
const lines = Array.isArray(request.lines) ? request.lines : [];
const lang = langOverride || request.lang || "en";
const speed = Number(speedOverride ?? request.speed ?? 1.0) || 1.0;
// ── env + HeyGen availability (the single switch) ─────────────────────────────
loadEnvFromDir(hyperframesDir);
const heygenOK = heygenCredential() !== null;
const headers = heygenOK ? heygenAuthHeaders() : null;
// ── merge base: preserve sections not selected by --only ──────────────────────
const prev = existsSync(outPath) ? JSON.parse(readFileSync(outPath, "utf8")) : {};
const anomalies = [];
// ── TTS ───────────────────────────────────────────────────────────────────────
let voices = prev.voices ?? [];
let ttsProvider = prev.tts_provider ?? null;
let voiceId = prev.voice_id ?? null;
if (only.has("tts") && lines.length) {
try {
ttsProvider = pickProvider(
providerOverride || (request.provider === "auto" ? null : request.provider),
);
} catch (e) {
die(e.message);
}
voiceId = await resolveVoiceId({
provider: ttsProvider,
userVoice: voiceOverride || request.voice,
lang,
});
console.error(`· tts: ${ttsProvider} · voice ${voiceId} · ${lines.length} line(s)`);
const synthLine = async (line) => {
const id = String(line.id);
const text = String(line.text ?? "").trim();
if (!text) {
anomalies.push(`line ${id}: empty text — skipped`);
return null;
}
const rel = `assets/voice/${id}.wav`;
const abs = join(hyperframesDir, rel);
const { ok, words, error } = await synthesizeOne({
provider: ttsProvider,
text,
voiceId,
lang,
speed,
wavAbs: abs,
hyperframesDir,
});
if (!ok) {
anomalies.push(`line ${id}: TTS failed — omitted${error ? ` (${error})` : ""}`);
return null;
}
let wordArr = words; // heygen: native; else transcribe
if (!wordArr) wordArr = await transcribeWav({ wavRel: rel, lang, hyperframesDir });
const dur = ffprobeDuration(abs);
if (!isFinite(dur) || dur <= 0) {
anomalies.push(`line ${id}: bad voice duration — omitted`);
return null;
}
return { id, path: rel, duration_s: r3(dur), words: withWordIds(wordArr) };
};
const results = await mapWithConcurrency(lines, ttsConcurrency, synthLine);
voices = results.filter(Boolean);
for (const v of voices)
console.error(` voice ${v.id}: ${v.path} (${v.duration_s}s, ${v.words.length} words)`);
}
const hasVoice = voices.length > 0;
const totalDuration = r3(voices.reduce((a, v) => a + (v.duration_s || 0), 0));
// ── BGM ─────────────────────────────────────────────────────────────────────
let bgm = prev.bgm ?? null;
const bgmFields = {
bgm_pending: prev.bgm_pending ?? false,
bgm_provider: prev.bgm_provider ?? null,
bgm_pid: prev.bgm_pid ?? null,
bgm_log: prev.bgm_log ?? null,
bgm_mode: prev.bgm_mode ?? null,
bgm_target_duration_s: prev.bgm_target_duration_s ?? null,
bgm_seed_duration_s: prev.bgm_seed_duration_s ?? null,
bgm_loop_count: prev.bgm_loop_count ?? null,
};
if (only.has("bgm")) {
bgm = null;
Object.keys(bgmFields).forEach((k) => (bgmFields[k] = k === "bgm_pending" ? false : null));
// Mode resolution. An EXPLICIT mode (flag or request.bgm.mode) is strict:
// "retrieve" means retrieve-or-nothing — it never silently starts a detached
// generate (a caller with no wait-bgm step, e.g. product-launch, must not get
// a pending job it can't await). Only the UNSET/auto default picks generate
// when HeyGen is absent.
const explicitMode = bgmModeOverride || request.bgm?.mode || null;
let mode = noBgm ? "none" : explicitMode || (heygenOK ? "retrieve" : "generate");
if (mode === "retrieve" && !heygenOK) {
anomalies.push(
"bgm: retrieve requires a HeyGen credential — skipped (no generate fallback for an explicit retrieve)",
);
mode = "none";
}
if (mode === "none") {
console.error(`· bgm: disabled`);
} else if (mode === "retrieve") {
try {
bgm = await retrieveBgm({ query: request.bgm?.query, headers, hyperframesDir, hasVoice });
if (bgm) {
bgmFields.bgm_provider = "heygen";
bgmFields.bgm_mode = "retrieve";
console.error(` bgm: ${bgm.path} (retrieve "${bgm.query}")`);
} else {
anomalies.push(`bgm: no music match for "${request.bgm?.query ?? ""}" — skipped`);
}
} catch (e) {
anomalies.push(`bgm retrieve failed: ${e.message} — skipped`);
}
} else {
// generate
const prompt = inferBgmPrompt({
userPrompt: request.bgm?.prompt,
blob: request.bgm?.blob || request.bgm?.query,
archetype: request.bgm?.archetype,
arc: request.bgm?.arc,
});
const gen = generateBgmDetached({
prompt,
durationS: totalDuration || 30,
hyperframesDir,
lyriaRecipe: existsSync(lyriaRecipe) ? lyriaRecipe : null,
seedSeconds,
hasVoice,
});
if (gen.disabled) {
anomalies.push(`bgm: ${gen.reason}`);
} else {
bgm = { path: gen.path, volume: gen.volume, mode: gen.mode, duration_s: null };
bgmFields.bgm_pending = true;
bgmFields.bgm_provider = gen.provider;
bgmFields.bgm_pid = gen.pid;
bgmFields.bgm_log = gen.log;
bgmFields.bgm_mode = gen.mode;
bgmFields.bgm_target_duration_s = gen.target_duration_s ?? null;
bgmFields.bgm_seed_duration_s = gen.seed_duration_s ?? null;
bgmFields.bgm_loop_count = gen.loop_count ?? null;
console.error(` bgm: launched ${gen.provider} (detached, pid ${gen.pid}) → ${gen.path}`);
}
}
}
// ── SFX ─────────────────────────────────────────────────────────────────────
let sfx = prev.sfx ?? [];
if (only.has("sfx")) {
const cues = lines.flatMap((l) =>
(Array.isArray(l.sfx) ? l.sfx : [])
.map((name) => ({ id: String(l.id), name: String(name).trim() }))
.filter((c) => c.name),
);
const res = await resolveSfx({ cues, heygenOK, headers, hyperframesDir, sfxLibDir });
sfx = res.sfx;
anomalies.push(...res.anomalies);
console.error(
`· sfx: ${sfx.length} cue(s) resolved (${heygenOK ? "heygen retrieval" : "bundled library"})`,
);
}
// ── write audio_meta.json ─────────────────────────────────────────────────────
const meta = {
tts_provider: ttsProvider,
voice_id: voiceId,
bgm,
...bgmFields,
voices,
sfx,
total_duration_s: totalDuration,
};
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, JSON.stringify(meta, null, 2));
console.log(`✓ audio engine → ${outPath}`);
console.log(` heygen: ${heygenOK ? "yes" : "no"} · ran: ${[...only].join(",")}`);
console.log(
` voices: ${voices.length} · bgm: ${bgm ? `${bgmFields.bgm_provider}${bgmFields.bgm_pending ? " (pending)" : ""}` : "none"} · sfx: ${sfx.length}`,
);
console.log(` total voice duration: ${totalDuration}s`);
if (anomalies.length) {
console.log(`\nanomalies (non-fatal):`);
for (const a of anomalies) console.log(` - ${a}`);
}
audio/scripts/audio.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { resolveSfx } from "./lib/sfx.mjs";
// Proves the relocated engine (skills/media-use/audio/) still resolves its
// bundled SFX library from the moved location — the path most likely to break
// on a subtree move. Offline (heygenOK:false), no network.
const HERE = dirname(fileURLToPath(import.meta.url));
const sfxLibDir = join(HERE, "..", "assets", "sfx"); // same offset the engine uses
test("bundled SFX library resolves from the relocated path", async () => {
assert.ok(existsSync(join(sfxLibDir, "manifest.json")), "moved manifest is present");
const dir = mkdtempSync(join(tmpdir(), "mu-audio-"));
try {
const { sfx, anomalies } = await resolveSfx({
cues: [{ id: "1", name: "whoosh" }],
heygenOK: false,
hyperframesDir: dir,
sfxLibDir,
});
assert.equal(sfx.length, 1, `expected 1 resolved cue, got anomalies: ${anomalies.join("; ")}`);
assert.equal(sfx[0].source, "local");
assert.match(sfx[0].file, /assets\/sfx\//);
assert.ok(existsSync(join(dir, sfx[0].file)), "matched SFX copied into the project");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("an unknown cue is reported, not fatal", async () => {
const dir = mkdtempSync(join(tmpdir(), "mu-audio-"));
try {
const { sfx, anomalies } = await resolveSfx({
cues: [{ id: "1", name: "definitely-not-a-real-sfx" }],
heygenOK: false,
hyperframesDir: dir,
sfxLibDir,
});
assert.equal(sfx.length, 0);
assert.ok(anomalies.some((a) => /not in bundled library/.test(a)));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
audio/scripts/heygen-tts.mjs#!/usr/bin/env node
// Self-contained HeyGen TTS — single text in → one wav (+ optional words JSON)
// out. A thin CLI over lib/tts.mjs (the same code the audio engine uses), so the
// HeyGen REST call, starfish voice pick, mp3→wav transcode, and word-timestamp
// filtering live in exactly one place. Bypasses the `hyperframes` CLI, which in
// the published build is Kokoro-only.
//
// Usage:
// node heygen-tts.mjs "Text to speak" -o narration.wav [--words narration.words.json]
// node heygen-tts.mjs ./script.txt -o narration.wav --words narration.words.json
// node heygen-tts.mjs "Bonjour" -o fr.wav --lang fr --voice <id>
// node heygen-tts.mjs --list # list starfish voices and exit
//
// Flags: -o/--output (.wav → ffmpeg transcode; .mp3 → raw bytes), --words,
// --voice (starfish id), --speed, --lang, --list.
// Requires: $HEYGEN_API_KEY / OAuth ~/.heygen credentials and ffmpeg for .wav output.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { heygenAuthHeaders, heygenJSON, loadEnvFromDir } from "./lib/heygen.mjs";
import { ffprobeDuration, resolveVoiceId, synthesizeOne, withWordIds } from "./lib/tts.mjs";
const argv = process.argv.slice(2);
function flag(name, def) {
const i = argv.indexOf(`--${name}`);
if (i < 0) return def;
if (i + 1 >= argv.length) return true;
const v = argv[i + 1];
return v.startsWith("--") ? true : v;
}
const die = (m) => {
console.error(`✗ heygen-tts: ${m}`);
process.exit(1);
};
// First arg that isn't a flag or the -o value is the text / .txt path.
const positional = (() => {
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith("--")) {
const next = argv[i + 1];
if (next && !next.startsWith("--")) i++;
continue;
}
if (a === "-o") {
i++;
continue;
}
return a;
}
return null;
})();
const output = resolve(
(typeof flag("output") === "string" && flag("output")) ||
(argv.includes("-o") && argv[argv.indexOf("-o") + 1]) ||
"narration.wav",
);
const wordsPath = typeof flag("words") === "string" ? resolve(flag("words")) : null;
const userVoice = typeof flag("voice") === "string" ? flag("voice") : null;
const speedRaw = typeof flag("speed") === "string" ? Number(flag("speed")) : 1.0;
const speed = isFinite(speedRaw) && speedRaw > 0 ? speedRaw : 1.0;
const lang = typeof flag("lang") === "string" ? flag("lang") : "en";
const listOnly = flag("list") === true;
loadEnvFromDir(process.cwd());
let authHeaders;
try {
authHeaders = heygenAuthHeaders();
} catch (e) {
die(e.message);
}
// ---------- --list ----------
if (listOnly) {
const payload = await heygenJSON(`/voices?engine=starfish&type=public&limit=50`, {
headers: authHeaders,
});
for (const v of payload.data ?? payload.voices ?? []) {
console.log(`${v.voice_id}\t${v.name}\t${v.language ?? ""}`);
}
process.exit(0);
}
// ---------- resolve text + voice ----------
if (!positional) die("no text given. Pass a string or a .txt path, or use --list.");
const text =
positional.endsWith(".txt") && existsSync(resolve(positional))
? readFileSync(resolve(positional), "utf8").trim()
: positional;
if (!text) die("input text is empty");
const voiceId = await resolveVoiceId({ provider: "heygen", userVoice, lang });
if (!userVoice) console.error(`· using voice ${voiceId}`);
// ---------- synthesize (shared engine code) ----------
const { ok, words } = await synthesizeOne({
provider: "heygen",
text,
voiceId,
lang,
speed,
wavAbs: output,
hyperframesDir: process.cwd(),
});
if (!ok) die("synthesis failed (HeyGen request/transcode error)");
let wordCount = 0;
if (wordsPath) {
if (words && words.length) {
mkdirSync(dirname(wordsPath), { recursive: true });
writeFileSync(wordsPath, JSON.stringify(withWordIds(words), null, 2));
wordCount = words.length;
} else {
console.error("⚠ no word_timestamps in response — run `hyperframes transcribe` instead");
}
}
const dur = ffprobeDuration(output);
const durStr = isFinite(dur) ? ` (${dur.toFixed(2)}s)` : "";
console.log(`✓ ${output}${durStr}${wordCount ? ` · ${wordsPath} (${wordCount} words)` : ""}`);
audio/scripts/lib/bgm.mjs// bgm.mjs — background music for the media audio engine. Two routes, gated the
// same way as TTS/SFX:
//
// retrieve (default when HeyGen is configured) — search HeyGen's music library
// by mood, download the top track. Synchronous. assets/bgm/track.mp3.
// generate (the alternative; the automatic choice when HeyGen is absent) —
// Lyria (cloud, $GEMINI_API_KEY/$GOOGLE_API_KEY + google-genai) preferred,
// else local MusicGen (facebook/musicgen-small via transformers). Spawned
// DETACHED so the engine can return while audio renders; the caller marks
// bgm_pending and runs wait-bgm.mjs before assembling. assets/bgm/track.wav.
//
// Missing/failed BGM never blocks a render.
import { spawn, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, openSync, closeSync } from "node:fs";
import { join } from "node:path";
import { downloadTo, searchSounds } from "./heygen.mjs";
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";
const LYRIA_PY_DEPS = ["google-genai", "python-dotenv"];
const LYRIA_PY_PROBE = "import google.genai";
function pyOk(probe) {
const { cmd, args } = pythonInvocation(["-c", probe]);
return spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
}
// `python -m pip`, not a bare `pip` binary: a Homebrew/system Python often
// exposes only `python3`/`pip3` on PATH, so a plain `pip` spawn silently
// no-ops (ENOENT) and the documented "auto-installed on demand" path never
// actually installs. `-m pip` also guarantees the packages land in the SAME
// interpreter pyOk() probes — a bare `pip`/`pip3` could resolve to a
// different Python installation than `python3` if more than one is on PATH.
function pipInstall(deps) {
const { cmd, args } = pythonInvocation(["-m", "pip", "install", "-q", ...deps]);
return spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
}
// ── retrieval (HeyGen music library) ──────────────────────────────────────────
export async function retrieveBgm({ query, headers, hyperframesDir, hasVoice }) {
const q = query || "calm cinematic underscore";
const results = await searchSounds(q, "music", headers, { limit: 5 });
if (!results.length) return null;
const top = results[0];
const rel = "assets/bgm/track.mp3";
await downloadTo(top.audio_url, join(hyperframesDir, rel));
return {
path: rel,
volume: bgmDefaultVolume(hasVoice),
query: q,
mode: "retrieve",
duration_s: typeof top.duration === "number" ? r3(top.duration) : null,
};
}
// ── mood inference (for the generate path's prompt) ──────────────────────────
// Industry base → archetype shape → emotional-arc tiebreaker. Exported so a
// workflow adapter can build a rich prompt from its own narrative metadata; the
// engine also calls it when generate has only a plain mood query.
export function inferBgmPrompt({ blob = "", archetype = "", arc = "", userPrompt = "" } = {}) {
if (userPrompt) return userPrompt;
const b = String(blob).toLowerCase();
let base;
let bpm;
if (/\b(crypto|nft|web3|defi|token|blockchain|exchange|wallet|dao)\b/.test(b)) {
base = "atmospheric electronic, deep bass, futuristic synths, restrained percussion";
bpm = 100;
} else if (/\b(finance|fintech|bank|payment|invest|wealth|insurance|treasury)\b/.test(b)) {
base = "calm cinematic, soft strings, subtle piano, restrained percussion";
bpm = 92;
} else if (/\b(creative|agency|design|studio|art|brand|marketing|content)\b/.test(b)) {
base = "playful electronic, warm pads, light percussion";
bpm = 115;
} else {
base = "uplifting corporate tech, bright modern piano with synth pads";
bpm = 108;
}
const at = String(archetype).toLowerCase();
const ar = String(arc).toLowerCase();
if (/\bpas\b|pain.agitate|pain.+solve/.test(at))
return `${base}, starts with subtle tension then builds to resolution, BPM ${bpm}, transitions from MINOR to MAJOR`;
if (/\bbab\b|before.after|future.pac|vision/.test(at))
return `${base}, cinematic and aspirational, steady build with rising energy, BPM ${bpm}, MAJOR`;
if (/cascade|feature.benefit/.test(at))
return `${base}, energetic and driving, consistent momentum, BPM ${Math.min(bpm + 10, 128)}, MAJOR`;
if (/demo.loop|question.+answer/.test(at))
return `${base}, clean and focused, minimal arrangement, BPM ${Math.max(bpm - 8, 88)}`;
if (/frustrat|anxiety|overwhelm|tension/.test(ar) && /relief|excite|triumph/.test(ar))
return `${base}, builds from understated tension to uplifting resolution, BPM ${bpm}, MINOR to MAJOR`;
if (/excit|awe|power|triumph/.test(ar))
return `${base}, energetic and confident, BPM ${bpm}, MAJOR`;
if (/trust|ease|clarity|reassur/.test(ar))
return `${base}, warm and reassuring, BPM ${Math.max(bpm - 5, 85)}`;
return `${base}, BPM ${bpm}, MAJOR`;
}
// ── generation (Lyria → MusicGen, detached) ──────────────────────────────────
// Returns a bgmMeta the caller folds into audio_meta:
// { path, mode, volume, provider, pid, log, target_duration_s, seed_duration_s,
// loop_count, pending:true } on success, or { disabled:true, reason }.
export function generateBgmDetached({
prompt,
durationS,
hyperframesDir,
lyriaRecipe,
seedSeconds = 28,
hasVoice,
}) {
const rel = "assets/bgm/track.wav";
const abs = join(hyperframesDir, rel);
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: bgmDefaultVolume(hasVoice), pending: true };
const lyriaConfigured = !!lyriaKey() && !!lyriaRecipe && existsSync(lyriaRecipe);
// Make a backend runnable: prefer Lyria when configured (install google-genai
// on demand), else ensure local MusicGen deps. Installs are synchronous here —
// generation itself is detached, so the engine still returns promptly.
if (lyriaConfigured && !pyOk(LYRIA_PY_PROBE)) pipInstall(LYRIA_PY_DEPS);
const useLyria = lyriaConfigured && pyOk(LYRIA_PY_PROBE);
if (!useLyria && !pyOk(BGM_PY_PROBE)) pipInstall(BGM_PY_DEPS);
const fd = openSync(log, "w");
if (useLyria) {
const { cmd, args } = pythonInvocation([
lyriaRecipe,
"--output",
abs,
"--duration",
String(targetS),
"--prompt",
prompt,
]);
const proc = spawn(cmd, args, { detached: true, stdio: ["ignore", fd, fd] });
proc.unref();
closeSync(fd);
return {
...baseMeta,
mode: "detached-single",
provider: "lyria",
pid: proc.pid,
log,
target_duration_s: r3(targetS),
};
}
if (pyOk(BGM_PY_PROBE)) {
const seedS = Math.min(Math.max(seedSeconds, 10), 30);
const loops = targetS > seedS ? Math.ceil(targetS / seedS) : 1;
const script = musicgenScript({ prompt, abs, targetS, seedS });
const { cmd, args } = pythonInvocation(["-c", script]);
const proc = spawn(cmd, args, { detached: true, stdio: ["ignore", fd, fd] });
proc.unref();
closeSync(fd);
return {
...baseMeta,
mode: targetS > seedS ? "detached-seed-loop" : "detached-seed-trim",
provider: "musicgen",
pid: proc.pid,
log,
target_duration_s: r3(targetS),
seed_duration_s: seedS,
loop_count: loops,
};
}
closeSync(fd);
return {
disabled: true,
reason: lyriaConfigured
? `Lyria configured but google-genai uninstallable, and local MusicGen unavailable (pip install ${BGM_PY_DEPS.join(" ")})`
: `no Lyria key/recipe and local MusicGen deps unavailable (pip install ${BGM_PY_DEPS.join(" ")})`,
};
}
// Inline MusicGen: generate ONE seed clip (≤30s to stay under the decoder's
// positional limit), then trim it down or crossfade-loop it up to the target.
function musicgenScript({ prompt, abs, targetS, seedS }) {
return `
import math, os, sys, traceback
from pathlib import Path
import numpy as np
import soundfile as sf
from transformers import MusicgenForConditionalGeneration, AutoProcessor
prompt = ${JSON.stringify(prompt)}
out_path = ${JSON.stringify(abs)}
target_s = float(${targetS.toFixed(3)})
seed_s = float(${seedS.toFixed(3)})
token_rate = 50
crossfade_s = 0.3
def apply_fade(arr, sr, fade_in_s=0.08, fade_out_s=0.5):
n_in = min(int(round(fade_in_s * sr)), arr.shape[0] // 2)
n_out = min(int(round(fade_out_s * sr)), arr.shape[0] // 2)
if n_in > 1: arr[:n_in] *= np.linspace(0.0, 1.0, n_in, dtype="float32")
if n_out > 1: arr[-n_out:] *= np.linspace(1.0, 0.0, n_out, dtype="float32")
return arr
def loop_crossfade(seed, target_len, xf):
if seed.shape[0] >= target_len: return seed[:target_len]
xf = min(xf, seed.shape[0] // 2)
if xf < 1:
reps = int(math.ceil(target_len / seed.shape[0]))
return np.tile(seed, reps)[:target_len]
t = np.linspace(0.0, 1.0, xf, dtype="float32")
fade_out = np.cos(t * (math.pi / 2)); fade_in = np.sin(t * (math.pi / 2))
out = seed.copy()
while out.shape[0] < target_len:
tail = out[-xf:] * fade_out; head = seed[:xf] * fade_in
out = np.concatenate([out[:-xf], tail + head, seed[xf:]])
return out[:target_len]
try:
Path(os.path.dirname(out_path)).mkdir(parents=True, exist_ok=True)
processor = AutoProcessor.from_pretrained("facebook/musicgen-small")
model = MusicgenForConditionalGeneration.from_pretrained("facebook/musicgen-small")
model.eval()
sr = int(model.config.audio_encoder.sampling_rate)
gen_s = min(seed_s, target_s)
tokens = max(1, int(math.ceil(gen_s * token_rate)))
print(f"[musicgen] seed dur={gen_s:.2f}s tokens={tokens}", flush=True)
inputs = processor(text=[prompt], padding=True, return_tensors="pt")
audio = model.generate(**inputs, max_new_tokens=tokens)
seed = audio[0, 0].detach().cpu().numpy().astype("float32")
peak = float(np.max(np.abs(seed)))
if peak > 1e-6: seed = seed * (0.89 / peak)
want = max(1, int(round(target_s * sr)))
if seed.shape[0] >= want:
final = seed[:want].copy()
else:
final = loop_crossfade(seed, want, int(round(crossfade_s * sr)))
if final.shape[0] < want: final = np.pad(final, (0, want - final.shape[0]))
else: final = final[:want]
final = apply_fade(final, sr)
peak = float(np.max(np.abs(final)))
if peak > 1.0: final = final / peak
sf.write(out_path, final, sr)
print(f"[musicgen] wrote {out_path} samples={final.shape[0]} sr={sr}", flush=True)
except Exception:
traceback.print_exc(); sys.exit(1)
`;
}
audio/scripts/lib/bgm.test.mjsimport { 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`,
);
});
audio/scripts/lib/concurrency.mjs// mapWithConcurrency — run `fn` over `items` with at most `limit` in flight at
// once. Preserves input order in the result array regardless of completion order.
export async function mapWithConcurrency(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
audio/scripts/lib/concurrency.test.mjsimport { test } from "node:test";
import assert from "node:assert/strict";
import { mapWithConcurrency } from "./concurrency.mjs";
// Regression: audio.mjs used a bare Promise.all(lines.map(synthLine)) to
// synthesize every TTS line at once, spawning one Kokoro/whisper model load
// per line concurrently. Two independent reports of this overwhelming a
// machine (OOM, and cold-start contention causing spurious failures).
// mapWithConcurrency is the extracted cap; test it in isolation since
// audio.mjs itself is a script (runs CLI/exit side effects on import).
test("processes every item and preserves input order regardless of completion order", async () => {
const order = [5, 1, 3, 2, 4];
const results = await mapWithConcurrency(order, 2, async (n) => {
await new Promise((r) => setTimeout(r, n));
return n * 10;
});
assert.deepEqual(results, [50, 10, 30, 20, 40]);
});
test("never runs more than `limit` at once", async () => {
let inFlight = 0;
let maxInFlight = 0;
const items = Array.from({ length: 10 }, (_, i) => i);
await mapWithConcurrency(items, 3, async () => {
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight--;
});
assert.equal(maxInFlight, 3);
});
test("limit larger than the item count runs everything without hanging", async () => {
const results = await mapWithConcurrency([1, 2], 10, async (n) => n * 2);
assert.deepEqual(results, [2, 4]);
});
test("empty input resolves to an empty array", async () => {
const results = await mapWithConcurrency([], 4, async (n) => n);
assert.deepEqual(results, []);
});
audio/scripts/lib/heygen.mjs// heygen.mjs — vendored HeyGen REST helpers (auth + transport) for the audio
// pipeline. The credential resolver matches the hyperframes CLI auth: first
// usable source wins — $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY → a nearby .env → ~/.heygen/
// credentials (oauth → Bearer, else api_key → X-Api-Key; $HEYGEN_CONFIG_DIR
// overrides the dir). Vendored so the skill ships standalone. Pure node.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
export const HEYGEN_BASE = "https://api.heygen.com/v3";
export const HEYGEN_CLI_SOURCE_HEADERS = { "X-HeyGen-Source": "cli" };
// Tool-attribution sent on EVERY media-use HeyGen call regardless of auth type, so
// the backend can isolate media-use consumption from other free TTS / avatar video.
// Unconditional — a paying user's media-use call is still media-use — unlike the
// OAuth-only cli-source header above, which also gates the free allowance.
export const HEYGEN_CLIENT_SOURCE_HEADERS = { "X-HeyGen-Client-Source": "media-use" };
// Walk up ≤5 dirs from startDir; load the first .env (shell env always wins).
export function loadEnvFromDir(startDir) {
let dir = resolve(startDir);
for (let i = 0; i < 5; i++) {
const envPath = join(dir, ".env");
if (existsSync(envPath)) {
for (const raw of readFileSync(envPath, "utf8").split("\n")) {
let line = raw.trim();
if (!line || line.startsWith("#")) continue;
if (line.startsWith("export ")) line = line.slice(7).trim();
const eq = line.indexOf("=");
if (eq < 1) continue;
const key = line.slice(0, eq).trim();
let val = line.slice(eq + 1).trim();
if (val.startsWith('"') || val.startsWith("'")) {
const q = val[0];
const end = val.indexOf(q, 1);
val = end > 0 ? val.slice(1, end) : val.slice(1);
}
if (!(key in process.env)) process.env[key] = val;
}
return;
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
}
// → { headers } | { expired: true } | null. Never throws.
export function heygenCredential() {
const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY;
if (envKey) return { headers: { "X-Api-Key": envKey } };
const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
if (!existsSync(file)) return null;
const raw = readFileSync(file, "utf8").trim();
if (!raw) return null;
if (!raw.startsWith("{")) return { headers: { "X-Api-Key": raw } };
// A malformed credentials file (partial write / wrong shape) must degrade to
// "no credential", not crash the engine at startup — this function never throws.
let cred;
try {
cred = JSON.parse(raw);
} catch {
return null;
}
const oauth = cred.oauth;
if (oauth?.access_token) {
const expired = oauth.expires_at && new Date(oauth.expires_at).getTime() - 60_000 < Date.now();
if (!expired) return { headers: { Authorization: `Bearer ${oauth.access_token}` } };
if (!cred.api_key) return { expired: true };
}
if (cred.api_key) return { headers: { "X-Api-Key": cred.api_key } };
return null;
}
// → "oauth" | "api_key" | null. Same oauth-vs-api-key check heygenAuthHeaders()
// makes internally, exposed on its own so callers that only need to *tag* the
// auth path (telemetry) don't have to parse headers back apart. Never throws:
// no credential (or an expired one) is just `null`, same as a fresh resolve
// with nothing to tag.
export function heygenAuthMethod() {
const cred = heygenCredential();
if (!cred?.headers) return null;
return "Authorization" in cred.headers ? "oauth" : "api_key";
}
// → auth headers object, or throw with a fix hint.
export function heygenAuthHeaders() {
const cred = heygenCredential();
if (cred?.headers) {
// Only tag OAuth (Bearer) traffic as cli-source — the backend uses it to
// grant the free allowance for OAuth requests and ignores it for API-key
// (X-Api-Key) traffic, where it's dead metadata.
const isOauth = "Authorization" in cred.headers;
return isOauth
? { ...cred.headers, ...HEYGEN_CLI_SOURCE_HEADERS, ...HEYGEN_CLIENT_SOURCE_HEADERS }
: { ...cred.headers, ...HEYGEN_CLIENT_SOURCE_HEADERS };
}
if (cred?.expired)
throw new Error(
"HeyGen OAuth token expired — run `npx hyperframes auth refresh` (or `npx hyperframes auth login`)",
);
throw new Error(
"no HeyGen credentials — set $HEYGEN_API_KEY, or run `npx hyperframes auth login` (writes ~/.heygen/credentials)",
);
}
// Authed JSON request against the v3 API; throws on a non-OK status.
export async function heygenJSON(path, { method = "GET", headers = {}, body } = {}) {
const opts = { method, headers: { ...HEYGEN_CLIENT_SOURCE_HEADERS, ...headers } };
if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
const res = await fetch(`${HEYGEN_BASE}${path}`, opts);
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(
`HeyGen ${method} ${path} → HTTP ${res.status}${detail ? `\n${detail.slice(0, 300)}` : ""}`,
);
}
return res.json();
}
// Download a (presigned) URL to destPath; returns byte length.
export async function downloadTo(url, destPath) {
const res = await fetch(url);
if (!res.ok) throw new Error(`download HTTP ${res.status}: ${String(url).slice(0, 80)}`);
const bytes = Buffer.from(await res.arrayBuffer());
mkdirSync(dirname(destPath), { recursive: true });
writeFileSync(destPath, bytes);
return bytes.length;
}
// Retrieval search over HeyGen's audio catalog (NOT generation). type =
// "music" | "sound_effects". Returns the ranked results array (best first); each
// item has a presigned `audio_url` (+ `duration`, `description`, `name`, `score`).
// `query` is required (≥1 char, empty → HTTP 400) and `limit` is capped at 50.
// `minScore`: omit to use the server default (0.7). That default is TOO HIGH for
// sound_effects — good SFX hits score ~0.5–0.67, so callers wanting SFX should
// pass a lower floor (~0.4); music scores high and is fine at the default.
export async function searchSounds(query, type, headers, { limit = 5, minScore } = {}) {
const params = new URLSearchParams({ query, type, limit: String(limit) });
if (minScore != null) params.set("min_score", String(minScore));
const payload = await heygenJSON(`/audio/sounds?${params.toString()}`, { headers });
// `data` comes back as a ranked array (best first). Older responses keyed it by
// numeric index ("0","1",…); normalize both shapes to an array (empty → []).
const data = payload?.data ?? payload;
if (Array.isArray(data)) return data;
if (data && typeof data === "object") return Object.values(data);
throw new Error(
`unexpected /audio/sounds shape — top keys: ${Object.keys(payload ?? {}).join(", ")}`,
);
}
audio/scripts/lib/heygen.test.mjsimport { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { heygenAuthHeaders, heygenAuthMethod } from "./heygen.mjs";
function withCleanHeygenEnv(fn) {
const previousApiKey = process.env.HEYGEN_API_KEY;
const previousHyperframesApiKey = process.env.HYPERFRAMES_API_KEY;
const previousConfigDir = process.env.HEYGEN_CONFIG_DIR;
try {
delete process.env.HEYGEN_API_KEY;
delete process.env.HYPERFRAMES_API_KEY;
delete process.env.HEYGEN_CONFIG_DIR;
return fn();
} finally {
if (previousApiKey === undefined) delete process.env.HEYGEN_API_KEY;
else process.env.HEYGEN_API_KEY = previousApiKey;
if (previousHyperframesApiKey === undefined) delete process.env.HYPERFRAMES_API_KEY;
else process.env.HYPERFRAMES_API_KEY = previousHyperframesApiKey;
if (previousConfigDir === undefined) delete process.env.HEYGEN_CONFIG_DIR;
else process.env.HEYGEN_CONFIG_DIR = previousConfigDir;
}
}
test("heygenAuthHeaders does not tag API-key requests as CLI traffic, but still carries the media-use tool tag", () => {
withCleanHeygenEnv(() => {
process.env.HEYGEN_API_KEY = "hg_test";
// API-key requests use normal billing; the backend ignores the cli-source
// header for them, so it's not sent. The tool-attribution header IS sent on
// every media-use call (any auth type) so the backend can isolate media-use.
assert.deepEqual(heygenAuthHeaders(), {
"X-Api-Key": "hg_test",
"X-HeyGen-Client-Source": "media-use",
});
});
});
test("heygenAuthHeaders tags OAuth requests as CLI traffic and with the media-use tool tag", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
writeFileSync(
join(dir, "credentials"),
JSON.stringify({
oauth: {
access_token: "at_test",
expires_at: "2099-01-01T00:00:00Z",
},
}),
);
assert.deepEqual(heygenAuthHeaders(), {
Authorization: "Bearer at_test",
"X-HeyGen-Source": "cli",
"X-HeyGen-Client-Source": "media-use",
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("heygenAuthMethod returns api_key for an env API key, without tagging headers", () => {
withCleanHeygenEnv(() => {
process.env.HEYGEN_API_KEY = "hg_test";
assert.equal(heygenAuthMethod(), "api_key");
});
});
test("heygenAuthMethod returns oauth for a live OAuth credential", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir;
writeFileSync(
join(dir, "credentials"),
JSON.stringify({
oauth: {
access_token: "at_test",
expires_at: "2099-01-01T00:00:00Z",
},
}),
);
assert.equal(heygenAuthMethod(), "oauth");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("heygenAuthMethod returns null with no credential at all", () => {
withCleanHeygenEnv(() => {
const dir = mkdtempSync(join(tmpdir(), "heygen-cred-"));
try {
process.env.HEYGEN_CONFIG_DIR = dir; // no credentials file written
assert.equal(heygenAuthMethod(), null);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
audio/scripts/lib/python.mjs// python.mjs — resolve which Python 3 executable to spawn, per platform.
//
// The audio engine (tts.mjs, bgm.mjs) shells out to `python3` for ElevenLabs
// TTS and the local Lyria/MusicGen BGM paths. `python3` is the right name on
// macOS/Linux, but on Windows the python.org installer only creates
// `python.exe` plus the `py` launcher — there is no `python3.exe` (only the
// Microsoft Store build adds one). So a bare `spawn("python3", …)` ENOENTs on a
// standard Windows Python install, silently disabling every Python-backed audio
// feature until the user hand-creates a `python3.exe` shim (reported twice).
//
// Resolve once, per process: probe the platform's candidates in order and take
// the first that actually runs. `py` is the launcher, so it needs a `-3` arg to
// select Python 3 — hence candidates are argv PREFIXES, not bare names.
import { spawnSync } from "node:child_process";
function defaultProbe(cmd, args) {
try {
return spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
} catch {
return false;
}
}
/**
* Pick the argv prefix that launches Python 3 on this platform.
* Returns e.g. `["python3"]`, `["python"]`, or `["py", "-3"]`.
*
* Pure except for `probe` (which runs `<cmd> … --version`); both `platform`
* and `probe` are injectable so every branch is unit-testable without spawning.
* If nothing probes OK, falls back to the canonical name for the platform so
* the eventual spawn fails loudly exactly as it did before — never worse.
*/
export function resolvePythonCommand(platform = process.platform, probe = defaultProbe) {
const candidates =
platform === "win32" ? [["python3"], ["python"], ["py", "-3"]] : [["python3"], ["python"]];
for (const prefix of candidates) {
if (probe(prefix[0], [...prefix.slice(1), "--version"])) return prefix;
}
return candidates[0];
}
let cached = null;
/** Cached `resolvePythonCommand()` — probing spawns, so resolve at most once. */
export function pythonCommand() {
if (!cached) cached = resolvePythonCommand();
return cached;
}
/**
* Build a `{ cmd, args }` for running Python 3 with `extraArgs`, using the
* resolved (or supplied) prefix. Keeps the launcher's `-3` (and any future
* prefix args) ahead of the caller's own arguments.
*/
export function pythonInvocation(extraArgs, prefix = pythonCommand()) {
return { cmd: prefix[0], args: [...prefix.slice(1), ...extraArgs] };
}
/** Test-only: clear the cached resolution so a test can re-probe. */
export function _resetPythonCommandCacheForTests() {
cached = null;
}
audio/scripts/lib/python.test.mjsimport { test } from "node:test";
import assert from "node:assert/strict";
import { resolvePythonCommand, pythonInvocation } from "./python.mjs";
// Regression: on Windows a standard python.org install has no `python3.exe`
// (only `python.exe` + the `py` launcher), so `spawn("python3", …)` ENOENTs and
// every Python-backed audio feature silently no-ops. resolvePythonCommand takes
// injectable platform/probe params so all branches are testable without
// spawning a real interpreter.
// probeFor(names): a probe that reports success only for the given argv-0 names.
function probeFor(...names) {
const ok = new Set(names);
return (cmd) => ok.has(cmd);
}
test("non-win32 uses python3 when it runs", () => {
assert.deepEqual(resolvePythonCommand("linux", probeFor("python3")), ["python3"]);
assert.deepEqual(resolvePythonCommand("darwin", probeFor("python3")), ["python3"]);
});
test("win32 prefers python3 when the Microsoft Store build provides it", () => {
assert.deepEqual(resolvePythonCommand("win32", probeFor("python3", "python", "py")), ["python3"]);
});
test("win32 falls back to python.exe when python3 is absent (python.org install)", () => {
// The exact reported scenario: no python3, but `python` exists.
assert.deepEqual(resolvePythonCommand("win32", probeFor("python", "py")), ["python"]);
});
test("win32 falls back to the py launcher with -3 when only py exists", () => {
assert.deepEqual(resolvePythonCommand("win32", probeFor("py")), ["py", "-3"]);
});
test("py launcher is probed as `py -3 --version`, not bare `py`", () => {
const seen = [];
const probe = (cmd, args) => {
seen.push([cmd, ...args]);
return cmd === "py";
};
resolvePythonCommand("win32", probe);
assert.deepEqual(seen.at(-1), ["py", "-3", "--version"]);
});
test("falls back to the canonical name (loud failure, unchanged) when nothing runs", () => {
// No interpreter anywhere — must not throw, and must return python3 so the
// eventual spawn fails exactly as it did before this fix, never worse.
assert.deepEqual(
resolvePythonCommand("win32", () => false),
["python3"],
);
assert.deepEqual(
resolvePythonCommand("linux", () => false),
["python3"],
);
});
test("pythonInvocation prepends the resolved prefix ahead of caller args", () => {
assert.deepEqual(pythonInvocation(["-c", "import x"], ["python"]), {
cmd: "python",
args: ["-c", "import x"],
});
// The py launcher's -3 must stay ahead of the caller's own arguments.
assert.deepEqual(pythonInvocation(["-c", "import x"], ["py", "-3"]), {
cmd: "py",
args: ["-3", "-c", "import x"],
});
});
audio/scripts/lib/sfx.mjs// sfx.mjs — sound effects for the media audio engine. Provider-gated (NOT a
// per-cue merge): the decision is made once, by whether HeyGen is configured —
// mirroring how TTS and BGM degrade.
//
// HeyGen credential present → retrieve EVERY cue from HeyGen's audio library
// (/v3/audio/sounds, type=sound_effects, min_score=0.4). The bundled
// library is NOT consulted.
// HeyGen credential absent → resolve cues against the bundled 21-file
// library (assets/sfx/manifest.json), copying matched files into the
// project. Offline, deterministic, free.
//
// A cue that matches nothing is skipped (recorded as an anomaly); SFX never
// blocks a render. Every cue sits at volume ~0.35, under voice + BGM.
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { downloadTo, searchSounds } from "./heygen.mjs";
const SFX_VOLUME = 0.35;
const slug = (s) =>
s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40) || "x";
const r3 = (x) => Number(x.toFixed(3));
// cues: [{ id, name }] (id = the line/frame/scene the cue fires in). Returns
// { sfx: [{ id, name, file, source, offset_s, duration_s, volume }], anomalies }.
export async function resolveSfx({ cues, heygenOK, headers, hyperframesDir, sfxLibDir }) {
const sfx = [];
const anomalies = [];
const destDir = join(hyperframesDir, "assets", "sfx");
// Dedupe identical (id,name) cues — the same effect named twice in one line
// downloads/copies once.
const seen = new Set();
const uniq = cues.filter((c) => {
const k = `${c.id}:${c.name}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
if (heygenOK) {
for (const { id, name } of uniq) {
try {
// SFX hits score low (~0.5–0.67), below the API's default 0.7 which
// silently drops most named cues — floor to 0.4. (BGM/music score high
// and keep the default.)
const results = await searchSounds(name, "sound_effects", headers, {
limit: 3,
minScore: 0.4,
});
if (!results.length) {
anomalies.push(`sfx "${name}" (id ${id}): no HeyGen match — skipped`);
continue;
}
const top = results[0];
const file = `assets/sfx/${slug(name)}.mp3`;
await downloadTo(top.audio_url, join(hyperframesDir, file));
sfx.push({
id,
name,
file,
source: "heygen",
offset_s: 0,
duration_s: typeof top.duration === "number" ? r3(top.duration) : 1.0,
volume: SFX_VOLUME,
});
} catch (e) {
anomalies.push(`sfx "${name}" (id ${id}): retrieval failed — ${e.message}`);
}
}
return { sfx, anomalies };
}
// ── offline: bundled library ──
const manifestPath = join(sfxLibDir, "manifest.json");
if (!existsSync(manifestPath)) {
if (uniq.length)
anomalies.push(`no HeyGen credential and no SFX library at ${sfxLibDir} — all cues dropped`);
return { sfx, anomalies };
}
let manifest;
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
} catch (e) {
anomalies.push(`SFX manifest parse failed (${e.message}) — all cues dropped`);
return { sfx, anomalies };
}
// Build lookups: by manifest key, by file basename, and by slug of either, so
// a cue can name "whoosh", "whoosh.mp3", or "ui click" (→ slug match).
const byKey = new Map();
for (const [key, entry] of Object.entries(manifest)) {
if (!entry?.file || !isFinite(entry.duration)) continue;
const rec = { key, file: entry.file, duration: entry.duration };
byKey.set(key, rec);
byKey.set(entry.file, rec);
byKey.set(slug(key), rec);
byKey.set(slug(entry.file.replace(/\.\w+$/, "")), rec);
}
mkdirSync(destDir, { recursive: true });
for (const { id, name } of uniq) {
const hit = byKey.get(name) ?? byKey.get(slug(name));
if (!hit) {
const known = [...new Set([...byKey.values()].map((v) => v.key))].slice(0, 8).join(", ");
anomalies.push(
`sfx "${name}" (id ${id}): not in bundled library — skipped (have: ${known}…)`,
);
continue;
}
const src = join(sfxLibDir, hit.file);
const destRel = `assets/sfx/${hit.file}`;
const dest = join(hyperframesDir, destRel);
// The bundled library may be incomplete: some installs of the skill ship
// manifest.json without the actual mp3s. Pushing an sfx entry that points at
// a file we never copied produces a dangling reference that silently drops
// downstream ("not on disk"). Surface it as a loud anomaly and skip the cue
// instead, so the audio_meta never references a missing file.
if (!existsSync(dest)) {
if (!existsSync(src)) {
anomalies.push(
`sfx "${name}" (id ${id}): bundled file ${hit.file} missing from the offline ` +
`library (${sfxLibDir}) — skipped. Reinstall the media-use skill to ` +
`restore assets/sfx/*.mp3, or configure a HeyGen credential for retrieval.`,
);
continue;
}
copyFileSync(src, dest);
}
sfx.push({
id,
name,
file: destRel,
source: "local",
offset_s: 0,
duration_s: r3(hit.duration),
volume: SFX_VOLUME,
});
}
return { sfx, anomalies };
}
audio/scripts/lib/sfx.test.mjsimport { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { resolveSfx } from "./sfx.mjs";
// Offline (no HeyGen) SFX resolution: the bundled library may ship manifest.json
// without the actual mp3s. The old code copied only when the source existed but
// pushed the sfx entry unconditionally — producing a dangling reference that
// silently dropped downstream ("not on disk"). These tests lock in the loud
// behavior: a present file is copied + referenced; a missing file yields an
// anomaly and NO dangling entry.
async function withDirs(fn) {
const root = mkdtempSync(join(tmpdir(), "hf-sfx-"));
const libDir = join(root, "lib");
const projDir = join(root, "proj");
mkdirSync(libDir, { recursive: true });
mkdirSync(projDir, { recursive: true });
try {
// `await` is load-bearing: without it the finally cleanup runs before the
// async test body resolves, deleting the temp dir mid-assertion.
return await fn({ libDir, projDir });
} finally {
rmSync(root, { recursive: true, force: true });
}
}
test("offline: copies and references a present bundled file", async () => {
await withDirs(async ({ libDir, projDir }) => {
writeFileSync(
join(libDir, "manifest.json"),
JSON.stringify({ whoosh: { file: "whoosh.mp3", duration: 0.8 } }),
);
writeFileSync(join(libDir, "whoosh.mp3"), "ID3-fake-bytes");
const { sfx, anomalies } = await resolveSfx({
cues: [{ id: "s1", name: "whoosh" }],
heygenOK: false,
hyperframesDir: projDir,
sfxLibDir: libDir,
});
assert.equal(sfx.length, 1);
assert.equal(sfx[0].file, "assets/sfx/whoosh.mp3");
assert.equal(sfx[0].source, "local");
assert.ok(existsSync(join(projDir, "assets/sfx/whoosh.mp3")), "mp3 copied into project");
assert.equal(anomalies.length, 0);
});
});
test("offline: a matched-but-missing bundled file yields an anomaly and NO dangling entry", async () => {
await withDirs(async ({ libDir, projDir }) => {
// Manifest names whoosh.mp3, but the mp3 was never shipped (the reported bug).
writeFileSync(
join(libDir, "manifest.json"),
JSON.stringify({ whoosh: { file: "whoosh.mp3", duration: 0.8 } }),
);
const { sfx, anomalies } = await resolveSfx({
cues: [{ id: "s1", name: "whoosh" }],
heygenOK: false,
hyperframesDir: projDir,
sfxLibDir: libDir,
});
assert.equal(sfx.length, 0, "no dangling entry for a file that was never copied");
assert.equal(anomalies.length, 1);
assert.match(anomalies[0], /missing from the offline library/);
assert.ok(!existsSync(join(projDir, "assets/sfx/whoosh.mp3")), "nothing copied");
});
});
audio/scripts/lib/tts.mjs// tts.mjs — multi-provider TTS for the media audio engine. The provider chain,
// auto-detected from env, is the one documented in ../SKILL.md:
//
// 1. HeyGen (Starfish) — $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY / ~/.heygen.
// Direct v3 REST (NOT `hyperframes tts`, which in the published build is
// Kokoro-only and silently ignores a HeyGen key). Returns word_timestamps
// in the same call, so no separate transcribe pass.
// 2. ElevenLabs — $ELEVENLABS_API_KEY + `pip install elevenlabs`. No
// word timings → caller chains transcribeWav().
// 3. Kokoro-82M (local) — always available, via the published `hyperframes tts`
// CLI. No word timings → caller chains transcribeWav().
//
// "HeyGen available" is decided by CREDENTIAL presence (heygenCredential), never
// by the CLI — see the note above.
import { spawn, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { heygenAuthHeaders, heygenCredential, heygenJSON } from "./heygen.mjs";
import { pythonInvocation } from "./python.mjs";
// ── provider detection ────────────────────────────────────────────────────────
export function heygenAvailable() {
return heygenCredential() !== null;
}
export function elevenlabsAvailable() {
if (!process.env.ELEVENLABS_API_KEY) return false;
const { cmd, args } = pythonInvocation(["-c", "import elevenlabs"]);
const r = spawnSync(cmd, args, {
stdio: "ignore",
});
return r.status === 0;
}
// First available provider wins; an explicit choice is honored (and validated).
export function pickProvider(userProvider) {
if (userProvider) {
if (!["heygen", "elevenlabs", "kokoro"].includes(userProvider))
throw new Error(`invalid provider "${userProvider}" (heygen | elevenlabs | kokoro)`);
if (userProvider === "heygen" && !heygenAvailable())
throw new Error(
"provider=heygen but no HeyGen credentials (set $HEYGEN_API_KEY or run `npx hyperframes auth login`)",
);
if (userProvider === "elevenlabs" && !process.env.ELEVENLABS_API_KEY)
throw new Error("provider=elevenlabs but $ELEVENLABS_API_KEY is not set");
return userProvider;
}
return heygenAvailable() ? "heygen" : elevenlabsAvailable() ? "elevenlabs" : "kokoro";
}
// ── voice resolution ──────────────────────────────────────────────────────────
// HeyGen /v3/voices/speech only accepts STARFISH voice_ids; auto-pick the first
// English public starfish voice when none is pinned. ElevenLabs/Kokoro have
// their own defaults.
export async function resolveVoiceId({ provider, userVoice, lang = "en" }) {
if (userVoice) return userVoice;
if (provider === "elevenlabs") return "21m00Tcm4TlvDq8ikWAM"; // Rachel
if (provider === "kokoro") {
if (lang === "en") return "am_michael";
throw new Error("Kokoro non-English needs an explicit --voice (see references/tts.md)");
}
// heygen — pin a fixed English default so the choice is deterministic. The old
// "first English voice the API returns" drifts whenever HeyGen re-sorts the
// public catalog. Marcia (mature, low female). Override with --voice / request.voice.
if (lang === "en") return "05f19352e8f74b0392a8f411eba40de1"; // Marcia · English · female
// Non-English: no fixed default — fall back to the first matching catalog voice.
const payload = await heygenJSON(`/voices?engine=starfish&type=public&limit=50`, {
headers: heygenAuthHeaders(),
});
const voices = payload.data ?? payload.voices ?? [];
const pick = voices.find((v) => v.language === "English") ?? voices[0];
if (!pick) throw new Error("no public starfish voice to default to — pass --voice");
return pick.voice_id;
}
// ── helpers ─────────────────────────────────────────────────────────────────
export function withWordIds(words) {
return (words ?? []).map((w, i) => ({
id: `w${i}`,
text: w.text,
start: w.start,
end: w.end,
}));
}
// `ffmpeg -i <file>` prints a `Duration: HH:MM:SS.ms` line to stderr even
// though it exits non-zero with no output requested. Parsing pulled out as
// a pure function so the ENOENT fallback below can be tested without
// depending on whether ffprobe/ffmpeg are actually installed on the
// machine running the tests.
export function parseFfmpegDurationBanner(stderrText) {
const match = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(stderrText ?? "");
if (!match) return NaN;
const [, hours, minutes, seconds] = match;
return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
}
// Some "essentials"-style ffmpeg distributions (common on Windows) ship
// ffmpeg.exe without ffprobe.exe. ffprobeDuration's caller (audio.mjs)
// otherwise reads a spurious NaN as "the WAV file is corrupt" and drops an
// already-successfully-synthesized TTS line, rather than "the tool for
// measuring it is missing".
function ffmpegDurationFallback(absPath) {
const r = spawnSync("ffmpeg", ["-i", absPath], { encoding: "utf8" });
return parseFfmpegDurationBanner(r.stderr);
}
export function ffprobeDuration(absPath) {
const r = spawnSync(
"ffprobe",
["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", "--", absPath],
{ encoding: "utf8" },
);
if (r.error?.code === "ENOENT") return ffmpegDurationFallback(absPath);
if (r.status !== 0) return NaN;
return parseFloat(String(r.stdout).trim());
}
export function resolveNpxCliFromNpmExecPath(
npmExecPath = process.env.npm_execpath,
pathExists = existsSync,
) {
if (!npmExecPath) return null;
const fileName = npmExecPath.replace(/\\/g, "/").split("/").pop()?.toLowerCase();
const npxCliPath =
fileName === "npx-cli.js" ? npmExecPath : join(dirname(npmExecPath), "npx-cli.js");
return pathExists(npxCliPath) ? npxCliPath : null;
}
export function resolveNpxCliPath(
npmExecPath = process.env.npm_execpath,
nodeExecPath = process.env.npm_node_execpath || process.execPath,
pathExists = existsSync,
) {
const fromNpm = resolveNpxCliFromNpmExecPath(npmExecPath, pathExists);
if (fromNpm) return fromNpm;
const besideNode = join(dirname(nodeExecPath), "node_modules", "npm", "bin", "npx-cli.js");
return pathExists(besideNode) ? besideNode : null;
}
export function resolveSpawnCommand(
cmd,
args,
opts = {},
platform = process.platform,
env = process.env,
pathExists = existsSync,
) {
if (cmd !== "npx" || platform !== "win32") {
return { cmd, args, opts: { stdio: "ignore", ...opts } };
}
// On Windows, npx resolves to npx.cmd, which Node cannot execute directly.
// Avoid `shell:true` and the .cmd shim entirely by invoking npm's JS CLI with
// node, preserving request-provided values as argv data instead of shell text.
const nodeExecPath = env.npm_node_execpath || process.execPath;
const npxCliPath = resolveNpxCliPath(env.npm_execpath, nodeExecPath, pathExists);
if (!npxCliPath) return null;
return {
cmd: nodeExecPath,
args: [npxCliPath, ...args.map((arg) => String(arg))],
opts: { stdio: "ignore", windowsHide: true, ...opts },
};
}
// `platform`/`spawnFn` params (default process.platform / the real spawn)
// exist so tests can exercise the win32 branch without mocking node:child_process
// (its ESM exports are non-configurable, so mock.method can't patch it).
// One-shot so a whole batch of TTS lines doesn't repeat the same diagnostic.
let _warnedNpxResolution = false;
/** Test-only: reset the one-shot npx-resolution warning latch. */
export function _resetNpxResolutionWarnForTests() {
_warnedNpxResolution = false;
}
export function spawnP(
cmd,
args,
opts = {},
platform = process.platform,
spawnFn = spawn,
env = process.env,
pathExists = existsSync,
) {
const resolved = resolveSpawnCommand(cmd, args, opts, platform, env, pathExists);
if (!resolved) {
// resolveSpawnCommand only returns null for the npx-on-win32 case where
// neither npm's configured CLI nor the beside-node fallback exists. Without
// this, every call silently returns status:-1 and stdio:"ignore" hides why.
if (!_warnedNpxResolution) {
_warnedNpxResolution = true;
const reason = env.npm_execpath
? `npm_execpath (${env.npm_execpath}) and the beside-node npm fallback could not be found`
: "npm_execpath is unset and the beside-node npm fallback could not be found";
console.error(
`[media-use] Cannot run "${cmd}" on Windows: ${reason}. ` +
`Every "${cmd}" call is being skipped. Install npm with Node, or run via ` +
`\`npx\`/\`npm run\` with a valid npm_execpath.`,
);
}
return Promise.resolve({ status: -1 });
}
return new Promise((resolve) => {
const p = spawnFn(resolved.cmd, resolved.args, resolved.opts);
p.on("exit", (code) => resolve({ status: code ?? -1 }));
p.on("error", () => resolve({ status: -1 }));
});
}
// mp3/whatever bytes → wav 44.1k mono at destWav (ffmpeg detects true format).
function transcodeToWav(bytes, destWav) {
const td = mkdtempSync(join(tmpdir(), "hf-tts-"));
const tmp = join(td, "a.mp3");
writeFileSync(tmp, bytes);
mkdirSync(dirname(destWav), { recursive: true });
const ff = spawnSync(
"ffmpeg",
["-y", "-loglevel", "error", "-i", tmp, "-ar", "44100", "-ac", "1", destWav],
{ stdio: "ignore" },
);
rmSync(td, { recursive: true, force: true });
return ff.status === 0 && existsSync(destWav);
}
const ELEVENLABS_PY = `
import os, sys
from elevenlabs.client import ElevenLabs
from elevenlabs import save
client = ElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])
text = open(sys.argv[1]).read()
audio = client.text_to_speech.convert(
text=text, voice_id=sys.argv[2],
model_id="eleven_multilingual_v2", output_format="mp3_44100_128",
)
save(audio, sys.argv[3])
`;
// ── synthesize one line ───────────────────────────────────────────────────────
// Writes wav at wavAbs. Returns { ok, words, error } — words is the raw
// [{text,start,end}] array for HeyGen (native), or null for ElevenLabs/Kokoro
// (caller must transcribeWav). Never throws; failures return { ok:false, error }
// where `error` states WHY (so the caller can surface it, not a bare "TTS failed").
export async function synthesizeOne({
provider,
text,
voiceId,
lang = "en",
speed = 1.0,
wavAbs,
hyperframesDir,
}) {
if (provider === "heygen") return synthesizeHeygen({ text, voiceId, lang, speed, wavAbs });
if (provider === "elevenlabs") {
// The Python helper writes straight to wavAbs; unlike heygen (transcodeToWav)
// and kokoro (the `hyperframes tts` CLI), it does NOT create the parent dir,
// so on a fresh project (no assets/voice/ yet) the save fails and the line is
// silently dropped as "TTS failed - omitted". Create it first, like the other
// providers do. Guarded so a mkdir failure (EACCES/EROFS) returns
// { ok:false } like the rest of this branch rather than throwing (the
// function's contract is "never throws; failures return { ok:false }").
try {
mkdirSync(dirname(wavAbs), { recursive: true });
} catch {
return { ok: false, words: null };
}
const { cmd, args } = pythonInvocation([
"-c",
ELEVENLABS_PY,
writeTmpText(text),
voiceId,
wavAbs,
]);
const r = await spawnP(cmd, args, {});
return synthResult(r, wavAbs, "elevenlabs (python)");
}
// kokoro — via the published CLI; --output is relative to the project dir.
const wavRel = relTo(hyperframesDir, wavAbs);
const args = ["hyperframes", "tts", writeTmpText(text), "--voice", voiceId, "--output", wavRel];
if (lang !== "en") args.push("--lang", lang);
const r = await spawnP("npx", args, { cwd: hyperframesDir });
return synthResult(r, wavAbs, "kokoro (npx hyperframes tts)");
}
// Shape a spawn result into { ok, words, error }, naming why on failure so the
// caller surfaces it instead of a bare "TTS failed".
export function synthResult(r, wavAbs, label) {
if (r.status === 0 && existsSync(wavAbs)) return { ok: true, words: null };
const why =
r.status !== 0 ? `${label} exited with status ${r.status}` : `${label} produced no wav file`;
return { ok: false, words: null, error: why };
}
// `deps` is injectable for tests; production uses the real network/ffmpeg impls.
// Every failure path returns an `error` string so the caller can surface WHY a
// line was dropped instead of the bare "TTS failed" that hid the real cause
// (e.g. an HTTP 402 plan_upgrade_required thrown by heygenJSON was swallowed).
export async function synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }, deps = {}) {
const requestJSON = deps.heygenJSON ?? heygenJSON;
const authHeaders = deps.heygenAuthHeaders ?? heygenAuthHeaders;
const fetchImpl = deps.fetch ?? fetch;
const transcode = deps.transcodeToWav ?? transcodeToWav;
try {
const body = { text, voice_id: voiceId, speed };
if (lang !== "en") body.language = lang;
const payload = await requestJSON(`/voices/speech`, {
method: "POST",
headers: authHeaders(),
body,
});
const inner = payload.data ?? payload;
if (!inner.audio_url) {
return { ok: false, words: null, error: "HeyGen /voices/speech returned no audio_url" };
}
const res = await fetchImpl(inner.audio_url);
if (!res.ok) {
return { ok: false, words: null, error: `audio_url fetch failed: HTTP ${res.status}` };
}
const bytes = Buffer.from(await res.arrayBuffer());
// .wav output → transcode to 44.1k mono; .mp3 → raw bytes (no ffmpeg). The
// engine always asks for .wav; the standalone heygen-tts CLI may ask for .mp3.
if (wavAbs.endsWith(".wav")) {
if (!transcode(bytes, wavAbs)) {
return {
ok: false,
words: null,
error: "wav transcode failed (ffmpeg)",
};
}
} else {
mkdirSync(dirname(wavAbs), { recursive: true });
writeFileSync(wavAbs, bytes);
}
const words = Array.isArray(inner.word_timestamps)
? inner.word_timestamps
.filter((w) => w && typeof w.word === "string" && isFinite(w.start) && isFinite(w.end))
.filter((w) => !/^<.*>$/.test(w.word.trim())) // drop <start>/<end> sentinels
.map((w) => ({ text: w.word, start: w.start, end: w.end }))
: [];
return { ok: true, words };
} catch (e) {
return { ok: false, words: null, error: e?.message ? String(e.message) : String(e) };
}
}
// ElevenLabs/Kokoro have no word timings — run Whisper over the wav. Returns the
// flat [{id,text,start,end}] word array, or null. Each call uses a throwaway
// --dir so parallel scenes don't collide on transcript.json.
export async function transcribeWav({ wavRel, lang = "en", hyperframesDir }) {
const model = lang === "en" ? "small.en" : "small";
const td = mkdtempSync(join(tmpdir(), "hf-trans-"));
const args = ["hyperframes", "transcribe", wavRel, "--model", model, "--dir", td];
if (lang !== "en") args.push("--language", lang);
const r = await spawnP("npx", args, { cwd: hyperframesDir });
let words = null;
if (r.status === 0) {
const src = join(td, "transcript.json");
if (existsSync(src)) {
try {
const arr = JSON.parse(readFileSync(src, "utf8"));
if (Array.isArray(arr) && arr.length) words = arr;
} catch {}
}
}
rmSync(td, { recursive: true, force: true });
return words;
}
// ── tiny local utils ──────────────────────────────────────────────────────────
function writeTmpText(text) {
const td = mkdtempSync(join(tmpdir(), "hf-txt-"));
const p = join(td, "line.txt");
writeFileSync(p, text);
return p;
}
function relTo(base, abs) {
return abs.startsWith(base + "/") ? abs.slice(base.length + 1) : abs;
}
audio/scripts/lib/tts.spawn.test.mjsimport { test } from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import {
resolveNpxCliFromNpmExecPath,
resolveNpxCliPath,
resolveSpawnCommand,
spawnP,
_resetNpxResolutionWarnForTests,
} from "./tts.mjs";
// Regression: on Windows, npx resolves to npx.cmd, which spawn() cannot exec
// without shell:true — it fails ENOENT, silently swallowed as ok:false by the
// caller. spawnP takes injectable platform/spawnFn params so this doesn't
// need to touch the real process.platform or mock node:child_process (whose
// ESM exports are non-configurable).
function fakeSpawn(captured) {
return (cmd, args, opts) => {
captured.push({ cmd, args, opts });
const p = new EventEmitter();
setImmediate(() => p.emit("exit", 0));
return p;
};
}
const envWithNpxCli = {
npm_execpath: "/opt/node/lib/node_modules/npm/bin/npm-cli.js",
npm_node_execpath: "/opt/node/bin/node",
};
const npxCliPath = "/opt/node/lib/node_modules/npm/bin/npx-cli.js";
const pathExists = (path) => path === npxCliPath;
test("resolveNpxCliFromNpmExecPath finds npx-cli next to npm-cli", () => {
assert.equal(resolveNpxCliFromNpmExecPath(envWithNpxCli.npm_execpath, pathExists), npxCliPath);
});
test("resolveNpxCliPath finds npx-cli beside node when npm_execpath is unset", () => {
const node = "C:/Program Files/nodejs/node.exe";
const expected = "C:/Program Files/nodejs/node_modules/npm/bin/npx-cli.js";
assert.equal(
resolveNpxCliPath(undefined, node, (path) => path === expected),
expected,
);
});
test("resolveSpawnCommand routes npx through node+npx-cli on win32 without shell:true", () => {
const resolved = resolveSpawnCommand(
"npx",
["hyperframes", "tts", "C:\\Users\\Test User\\line.txt", "--voice", "am_michael"],
{},
"win32",
envWithNpxCli,
pathExists,
);
assert.ok(resolved);
assert.equal(resolved.cmd, envWithNpxCli.npm_node_execpath);
assert.deepEqual(resolved.args, [
npxCliPath,
"hyperframes",
"tts",
"C:\\Users\\Test User\\line.txt",
"--voice",
"am_michael",
]);
assert.equal(resolved.opts.shell, undefined);
});
test("resolveSpawnCommand preserves Windows npx shell metacharacters as argv data", () => {
const resolved = resolveSpawnCommand(
"npx",
["hyperframes", "tts", "hello & calc"],
{},
"win32",
envWithNpxCli,
pathExists,
);
assert.ok(resolved);
assert.deepEqual(resolved.args, [npxCliPath, "hyperframes", "tts", "hello & calc"]);
});
test("spawnP uses the resolved node+npx-cli command for npx on win32", async () => {
const captured = [];
await spawnP(
"npx",
["hyperframes", "tts"],
{},
"win32",
fakeSpawn(captured),
envWithNpxCli,
pathExists,
);
assert.equal(captured.length, 1);
assert.equal(captured[0].cmd, envWithNpxCli.npm_node_execpath);
assert.deepEqual(captured[0].args, [npxCliPath, "hyperframes", "tts"]);
assert.equal(captured[0].opts.shell, undefined);
});
test("spawnP does not enable shell for npx on darwin/linux", async () => {
const captured = [];
await spawnP("npx", ["hyperframes", "tts"], {}, "darwin", fakeSpawn(captured));
assert.equal(captured[0].cmd, "npx");
assert.deepEqual(captured[0].args, ["hyperframes", "tts"]);
assert.equal(captured[0].opts.shell, undefined);
});
test("spawnP does not enable shell for non-npx commands even on win32", async () => {
const captured = [];
await spawnP("python3", ["-c", "pass"], {}, "win32", fakeSpawn(captured));
assert.equal(captured[0].cmd, "python3");
assert.deepEqual(captured[0].args, ["-c", "pass"]);
assert.equal(captured[0].opts.shell, undefined);
});
test("spawnP resolves npx beside node when npm_execpath is unset on win32", async () => {
_resetNpxResolutionWarnForTests();
const captured = [];
const node = "C:/Program Files/nodejs/node.exe";
const npxCli = "C:/Program Files/nodejs/node_modules/npm/bin/npx-cli.js";
const result = await spawnP(
"npx",
["hyperframes", "tts"],
{},
"win32",
fakeSpawn(captured),
{ npm_node_execpath: node },
(path) => path === npxCli,
);
assert.equal(result.status, 0);
assert.equal(captured.length, 1);
assert.equal(captured[0].cmd, node);
assert.deepEqual(captured[0].args, [npxCli, "hyperframes", "tts"]);
});
test("spawnP warns once with an accurate diagnostic when neither npx path exists", async () => {
_resetNpxResolutionWarnForTests();
const errors = [];
const originalError = console.error;
console.error = (message) => errors.push(String(message));
try {
const env = { npm_execpath: "C:/missing/npm-cli.js", npm_node_execpath: "C:/node/node.exe" };
const missing = () => false;
assert.equal(
(await spawnP("npx", ["hyperframes", "tts"], {}, "win32", fakeSpawn([]), env, missing))
.status,
-1,
);
assert.equal(
(await spawnP("npx", ["hyperframes", "tts"], {}, "win32", fakeSpawn([]), env, missing))
.status,
-1,
);
} finally {
console.error = originalError;
}
assert.equal(errors.length, 1);
assert.match(errors[0], /npm_execpath \(C:\/missing\/npm-cli\.js\)/);
assert.doesNotMatch(errors[0], /npm_execpath is not set/);
});
audio/scripts/lib/tts.test.mjsimport { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, chmodSync, rmSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import {
parseFfmpegDurationBanner,
ffprobeDuration,
synthesizeOne,
synthesizeHeygen,
synthResult,
} from "./tts.mjs";
test("parseFfmpegDurationBanner reads ffmpeg's stderr Duration line", () => {
const stderr = [
"ffmpeg version 6.0",
"Input #0, wav, from 'a.wav':",
" Duration: 00:00:03.42, bitrate: 705 kb/s",
"At least one output file must be specified",
].join("\n");
assert.equal(parseFfmpegDurationBanner(stderr), 3.42);
});
test("parseFfmpegDurationBanner handles an hours component", () => {
const stderr = " Duration: 01:02:03.50, start: 0.000000, bitrate: 128 kb/s";
assert.equal(parseFfmpegDurationBanner(stderr), 3723.5);
});
test("parseFfmpegDurationBanner returns NaN when there is no Duration line", () => {
assert.ok(Number.isNaN(parseFfmpegDurationBanner("ffmpeg: command not found")));
assert.ok(Number.isNaN(parseFfmpegDurationBanner("")));
assert.ok(Number.isNaN(parseFfmpegDurationBanner(undefined)));
});
// Regression for the actual bug: ffprobeDuration used to collapse "ffprobe
// binary is missing" (ENOENT — the "essentials"-style Windows ffmpeg build
// with no ffprobe.exe) and "file is genuinely unreadable" into the same NaN,
// giving audio.mjs no way to tell "measure differently" from "give up".
//
// Builds an isolated PATH containing only a fake `ffmpeg` stub (no `ffprobe`
// at all) so ffprobeDuration's spawnSync("ffprobe", ...) call ENOENTs for
// real, then verifies it recovers the duration via the ffmpeg fallback
// instead of returning NaN.
test("ffprobeDuration falls back to ffmpeg when the ffprobe binary itself is missing", () => {
const dir = mkdtempSync(join(tmpdir(), "tts-ffprobe-fallback-"));
const fakeFfmpeg = join(dir, "ffmpeg");
writeFileSync(
fakeFfmpeg,
"#!/bin/sh\necho 'Duration: 00:00:02.50, start: 0.000000, bitrate: 128 kb/s' 1>&2\nexit 1\n",
);
chmodSync(fakeFfmpeg, 0o755);
const originalPath = process.env.PATH;
try {
process.env.PATH = dir; // only the fake ffmpeg resolves; no real ffprobe on this PATH
assert.equal(ffprobeDuration("/does/not/matter.wav"), 2.5);
} finally {
process.env.PATH = originalPath;
rmSync(dir, { recursive: true, force: true });
}
});
test("ffprobeDuration returns NaN when neither ffprobe nor ffmpeg resolve", () => {
const dir = mkdtempSync(join(tmpdir(), "tts-no-binaries-"));
const originalPath = process.env.PATH;
try {
process.env.PATH = dir; // empty directory — nothing resolves
assert.ok(Number.isNaN(ffprobeDuration("/does/not/matter.wav")));
} finally {
process.env.PATH = originalPath;
rmSync(dir, { recursive: true, force: true });
}
});
test("synthesizeOne(elevenlabs) creates the output dir before writing", async () => {
const dir = mkdtempSync(join(tmpdir(), "tts-el-mkdir-"));
const wavAbs = join(dir, "assets", "voice", "line-0.wav"); // nested, not yet created
const savedKey = process.env.ELEVENLABS_API_KEY;
try {
// Unset the key so the Python side fails fast — the mkdir must run before
// the spawn regardless, which is what this guards.
delete process.env.ELEVENLABS_API_KEY;
await synthesizeOne({
provider: "elevenlabs",
text: "hi",
voiceId: "v",
wavAbs,
hyperframesDir: dir,
});
assert.ok(existsSync(dirname(wavAbs)), "output directory should be created");
} finally {
if (savedKey === undefined) delete process.env.ELEVENLABS_API_KEY;
else process.env.ELEVENLABS_API_KEY = savedKey;
rmSync(dir, { recursive: true, force: true });
}
});
test("synthesizeHeygen surfaces a thrown HTTP error (e.g. 402) instead of swallowing it", async () => {
const res = await synthesizeHeygen(
{ text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: "/tmp/x.wav" },
{
heygenAuthHeaders: () => ({}),
heygenJSON: async () => {
throw new Error("HeyGen POST /voices/speech → HTTP 402\nplan_upgrade_required");
},
},
);
assert.equal(res.ok, false);
assert.match(res.error, /402/);
assert.match(res.error, /plan_upgrade_required/);
});
test("synthesizeHeygen surfaces a failed audio_url fetch with its status", async () => {
const res = await synthesizeHeygen(
{ text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: "/tmp/x.wav" },
{
heygenAuthHeaders: () => ({}),
heygenJSON: async () => ({ data: { audio_url: "http://audio.example/x" } }),
fetch: async () => ({ ok: false, status: 403 }),
},
);
assert.equal(res.ok, false);
assert.match(res.error, /HTTP 403/);
});
test("synthesizeHeygen reports a missing audio_url", async () => {
const res = await synthesizeHeygen(
{ text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: "/tmp/x.wav" },
{ heygenAuthHeaders: () => ({}), heygenJSON: async () => ({}) },
);
assert.equal(res.ok, false);
assert.match(res.error, /no audio_url/);
});
test("synthesizeHeygen reports wav transcode failures", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-tts-test-"));
try {
const res = await synthesizeHeygen(
{ text: "hi", voiceId: "v1", lang: "en", speed: 1, wavAbs: join(dir, "voice.wav") },
{
heygenAuthHeaders: () => ({}),
heygenJSON: async () => ({ data: { audio_url: "http://audio.example/x" } }),
fetch: async () => ({ ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) }),
transcodeToWav: () => false,
},
);
assert.equal(res.ok, false);
assert.equal(res.error, "wav transcode failed (ffmpeg)");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("synthResult names a non-zero subprocess exit", () => {
const res = synthResult({ status: 2 }, "/tmp/none.wav", "kokoro (npx hyperframes tts)");
assert.equal(res.ok, false);
assert.match(res.error, /kokoro .* exited with status 2/);
});
audio/scripts/lyria-recipe.py#!/usr/bin/env python3
"""Generate BGM using Google Lyria RealTime API.
Usage:
python lyria-recipe.py --output <path> --duration <seconds> [tuning flags]
Requires:
$GOOGLE_API_KEY or $GEMINI_API_KEY environment variable (treated as aliases).
pip install google-genai python-dotenv. audio.mjs Step 4b installs these on
demand when a key is set but google.genai is not importable; if that install
fails it falls back to local MusicGen rather than leaving the video with no BGM.
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
import wave
from pathlib import Path
# Windows sizes stdio to the ANSI code page (cp1252). These scripts emit UTF-8 on
# every platform; say so rather than depending on the console's code page. Carry
# `errors` across: reconfigure() resets it to "strict", and CPython deliberately gives
# stderr "backslashreplace" so the diagnostic path can never itself raise.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors=_stream.errors)
DEFAULT_PROMPT = "Uplifting corporate tech, bright and modern, gentle piano with synth pads"
SAMPLE_RATE = 48000
CHANNELS = 2
SAMPLE_WIDTH = 2 # 16-bit
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Generate BGM via Google Lyria RealTime.")
p.add_argument("--output", required=True, help="Output WAV path.")
p.add_argument("--duration", type=float, required=True, help="Target duration in seconds.")
p.add_argument("--prompt", default=DEFAULT_PROMPT, help="Mood / instrumentation prompt.")
p.add_argument("--negative-prompt", default=None, help="Styles to exclude (optional).")
p.add_argument("--bpm", type=int, default=110)
p.add_argument("--brightness", type=float, default=0.8, help="0-1, higher = brighter mood.")
p.add_argument("--density", type=float, default=0.5, help="0-1, higher = fuller mix.")
p.add_argument(
"--scale",
default="MAJOR",
help="MAJOR / MINOR / PENTATONIC / etc. — see google.genai.types.Scale. Pass empty string for none.",
)
return p.parse_args()
async def generate_bgm(args: argparse.Namespace) -> dict:
from google import genai
from google.genai import types
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY") or ""
if not api_key:
raise RuntimeError("Neither GOOGLE_API_KEY nor GEMINI_API_KEY is set.")
client = genai.Client(
api_key=api_key,
http_options={"api_version": "v1alpha"},
)
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
target_bytes = int(args.duration * SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH)
cfg: dict = {"bpm": args.bpm, "temperature": 1.0}
if args.density is not None:
cfg["density"] = args.density
if args.brightness is not None:
cfg["brightness"] = args.brightness
if args.scale:
scale_enum = getattr(types.Scale, args.scale, None)
if scale_enum:
cfg["scale"] = scale_enum
prompts = [types.WeightedPrompt(text=args.prompt, weight=1.0)]
if args.negative_prompt:
prompts.append(types.WeightedPrompt(text=args.negative_prompt, weight=-1.0))
buf = bytearray()
timeout = args.duration + 8
async with client.aio.live.music.connect(
model="models/lyria-realtime-exp",
) as session:
await session.set_weighted_prompts(prompts=prompts)
await session.set_music_generation_config(
config=types.LiveMusicGenerationConfig(**cfg),
)
await session.play()
async def collect():
while len(buf) < target_bytes:
async for msg in session.receive():
sc = msg.server_content
if sc and sc.audio_chunks:
for chunk in sc.audio_chunks:
buf.extend(chunk.data)
if len(buf) >= target_bytes:
return
await asyncio.sleep(1e-6)
try:
await asyncio.wait_for(collect(), timeout=timeout)
except TimeoutError:
print(f"Timeout after {timeout:.0f}s, collected {len(buf)} bytes", file=sys.stderr)
audio = bytes(buf[:target_bytes])
with wave.open(str(out_path), "wb") as wf:
wf.setnchannels(CHANNELS)
wf.setsampwidth(SAMPLE_WIDTH)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(audio)
actual_duration = len(audio) / (SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH)
print(f"BGM: {out_path} ({actual_duration:.2f}s)")
return {"file": str(out_path), "duration_sec": round(actual_duration, 2)}
def main() -> None:
args = parse_args()
try:
asyncio.run(generate_bgm(args))
except RuntimeError as exc:
print(f"BGM generation failed: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
audio/scripts/wait-bgm.mjs#!/usr/bin/env node
// Phase 4c pre-assemble helper — wait for detached BGM, then write status.
//
// audio.mjs may launch Lyria / MusicGen in a detached process so voice work can
// keep moving. Before assemble-index.mjs decides whether to emit the BGM audio
// track, this script gives the background renderer a bounded chance to finish
// and converts log/process state into a small bgm_status.json file.
//
// Always exits 0 for normal pipeline use: missing/failed BGM should not block a
// voice/captions/SFX render. Structural invocation errors still exit 1.
//
// Usage:
// node wait-bgm.mjs --audio-meta ./audio_meta.json --hyperframes . \
// [--timeout-ms 120000] [--interval-ms 2000] [--out ./bgm_status.json]
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
const argv = process.argv.slice(2);
const flag = (name, def) => {
const i = argv.indexOf(`--${name}`);
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
};
function die(msg) {
console.error(`✗ wait-bgm.mjs: ${msg}`);
process.exit(1);
}
const audioMetaPath = resolve(flag("audio-meta", "./audio_meta.json"));
const hyperframesDir = resolve(flag("hyperframes", "."));
const outPath = resolve(flag("out", join(hyperframesDir, "bgm_status.json")));
const timeoutMs = Math.max(0, Number(flag("timeout-ms", "120000")) || 0);
const intervalMs = Math.max(250, Number(flag("interval-ms", "2000")) || 2000);
function sleep(ms) {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}
function isProcessAlive(pid) {
if (!pid || !Number.isFinite(Number(pid))) return false;
try {
process.kill(Number(pid), 0);
return true;
} catch {
return false;
}
}
function readTail(path, maxChars = 6000) {
if (!path || !existsSync(path)) return "";
const s = statSync(path);
const txt = readFileSync(path, "utf8");
return txt.slice(Math.max(0, txt.length - Math.min(maxChars, s.size)));
}
function detectFailure(logTail) {
if (!logTail) return "";
const lines = logTail.split("\n");
// Bare "out of range" over-matched benign BGM-renderer logs (e.g. a "sample rate
// out of range, resampling" notice), mislabelling a healthy track as failed and
// silently dropping the music. Anchor to the actual crash strings instead:
// Python "(list) index out of range" and torch "index … out of bounds".
const idx = lines.findIndex((line) =>
/(Traceback|IndexError|RuntimeError|Exception|Killed|No space left|Cannot allocate|index out of range|out of bounds)/i.test(
line,
),
);
if (idx < 0) return "";
return lines.slice(idx).join("\n").trim();
}
function writeStatus(status) {
const payload = {
generated_at: new Date().toISOString(),
...status,
};
writeFileSync(outPath, JSON.stringify(payload, null, 2) + "\n");
return payload;
}
if (!existsSync(audioMetaPath)) die(`audio_meta.json missing at ${audioMetaPath}`);
const audioMeta = JSON.parse(readFileSync(audioMetaPath, "utf8"));
const bgmPath = audioMeta.bgm?.path || "";
const bgmAbsPath = bgmPath ? join(hyperframesDir, bgmPath) : "";
const logPath = audioMeta.bgm_log || "";
const pid = audioMeta.bgm_pid || null;
const base = {
enabled: Boolean(audioMeta.bgm_pending && bgmPath),
provider: audioMeta.bgm_provider || null,
mode: audioMeta.bgm_mode || null,
path: bgmPath || null,
log: logPath || null,
pid,
target_duration_s: audioMeta.bgm_target_duration_s || null,
seed_duration_s: audioMeta.bgm_seed_duration_s || null,
loop_count: audioMeta.bgm_loop_count || null,
timeout_ms: timeoutMs,
};
if (!base.enabled) {
const status = writeStatus({
...base,
status: "disabled",
ready: false,
waited_ms: 0,
message: "BGM not requested or disabled in audio_meta.json.",
});
console.log(`✓ bgm: ${status.status} (${status.message})`);
process.exit(0);
}
const started = Date.now();
let lastFailure = "";
let lastTail = "";
while (Date.now() - started <= timeoutMs) {
if (existsSync(bgmAbsPath)) {
const size = statSync(bgmAbsPath).size;
writeStatus({
...base,
status: "ready",
ready: true,
waited_ms: Date.now() - started,
size_bytes: size,
message: `BGM ready at ${bgmPath}.`,
});
console.log(`✓ bgm: ready (${bgmPath}, ${size}B)`);
process.exit(0);
}
lastTail = readTail(logPath);
lastFailure = detectFailure(lastTail);
const alive = isProcessAlive(pid);
if (lastFailure || (!alive && logPath && existsSync(logPath))) {
const message = lastFailure
? `BGM renderer failed; see ${logPath}.`
: `BGM renderer exited without writing ${bgmPath}; see ${logPath}.`;
const status = writeStatus({
...base,
status: "failed",
ready: false,
waited_ms: Date.now() - started,
process_alive: alive,
message,
error_tail: lastFailure || lastTail.slice(-2000),
});
console.log(`! bgm: failed (${status.message})`);
process.exit(0);
}
if (timeoutMs === 0) break;
await sleep(Math.min(intervalMs, Math.max(0, timeoutMs - (Date.now() - started))));
}
const status = writeStatus({
...base,
status: "timeout",
ready: false,
waited_ms: Date.now() - started,
process_alive: isProcessAlive(pid),
message: `Timed out waiting for ${bgmPath}; assemble-index will skip BGM if still absent.`,
log_tail: lastTail.slice(-2000),
});
console.log(`! bgm: timeout after ${status.waited_ms}ms (${bgmPath})`);
luts/index.json{
"notes": "Look entries resolve on-demand from a CDN .cube `url` (downloaded + frozen, like bgm/image); `params` is a deterministic buildCube fallback used offline (--local-only) or if the download/validation fails. No .cube bodies are committed. See README.md to author or host a new look.",
"looks": [
{
"id": "teal-orange-blockbuster",
"description": "Teal shadows and warm orange highlights for blockbuster-style cinematic footage.",
"tags": ["teal", "orange", "blockbuster", "cinematic", "split tone", "movie"],
"intensity": 0.85,
"url": "https://static.heygen.ai/luts/teal-orange-blockbuster.cube",
"params": {
"contrast": 0.18,
"saturation": 0.08,
"vibrance": 0.12,
"splitTone": {
"intensity": 0.62,
"balance": 0.52,
"shadows": [-0.04, 0.05, 0.09],
"highlights": [0.1, 0.04, -0.03]
}
}
},
{
"id": "bleach-bypass",
"description": "High-contrast desaturated bleach bypass look with strong blacks.",
"tags": ["bleach", "bypass", "desaturated", "high contrast", "gritty", "film"],
"intensity": 0.8,
"url": "https://static.heygen.ai/luts/bleach-bypass.cube",
"params": {
"blacks": 0.04,
"shadows": -0.08,
"highlights": 0.08,
"whites": 0.18,
"contrast": 0.55,
"temperature": -0.02,
"saturation": -0.72,
"vibrance": -0.25
}
},
{
"id": "film-fade",
"description": "Soft faded film wash with lifted blacks and warm highlights.",
"tags": ["film", "fade", "faded", "wash", "warm", "vintage"],
"intensity": 0.75,
"url": "https://static.heygen.ai/luts/film-fade.cube",
"params": {
"blacks": 0.35,
"shadows": 0.18,
"highlights": 0.02,
"whites": -0.03,
"contrast": -0.28,
"temperature": 0.16,
"saturation": -0.12,
"vibrance": -0.08
}
}
]
}
luts/README.md# LUT library (authoring)
`index.json` is the agent-consumed catalog of color-grade looks. Each entry resolves
on demand — no `.cube` bodies are committed to the repo.
Each look has:
- `id`, `description`, `tags`, `intensity` — matching + application metadata.
- `url` (optional) — a hosted `.cube` downloaded, validated, and frozen at resolve
time, exactly like bgm/image assets.
- `params` (optional) — a deterministic `buildCube` spec used offline (`--local-only`)
or as a fallback if the `url` download/validation fails.
An entry needs at least one of `url` or `params`; prefer both (CDN url with a params
fallback) so resolution is never blocked on the network.
## Hosting a new look (operators)
1. Generate the `.cube` (e.g. `resolve -t lut --params '{...}'` or a graded export).
2. Upload it to the public CDN origin bucket:
```
aws s3 cp <id>.cube s3://heygen-public/luts/<id>.cube
```
It is then served at `https://static.heygen.ai/luts/<id>.cube` (CloudFront).
3. Add an entry to `index.json` with that `url` (and ideally a `params` fallback).
references/audio.md# Audio engine — voiceover, music, SFX, captions, transcription
For a full audio pass (TTS voiceover + background music + sound effects in one
shot), use the shared engine at `audio/scripts/audio.mjs`. It takes a neutral
`audio_request.json` and writes `audio_meta.json` plus assets under
`.media/audio/{voice,bgm,sfx}`:
```bash
node <SKILL_DIR>/audio/scripts/audio.mjs --request ./audio_request.json --out ./audio_meta.json
```
- **Request** `{ provider?, lang?, speed?, lines: [{ id, text, sfx?: [names] }], bgm: { mode?, query?, prompt? } }`: `id` joins each line back to your model; `bgm.mode` = `retrieve | generate | none` (omit for auto). `--only tts,bgm,sfx` runs a subset and merges into an existing `--out`.
- **Output** `audio_meta.json` (id-keyed): `voices[].{path,duration_s,words[]}` (word timestamps for captions), `sfx[]`, `bgm`, `total_duration_s`.
- **HeyGen free-usage path**: HeyGen CLI auth unlocks TTS plus music/SFX retrieval. Local/provider-specific generators are explicit alternatives where installed; run `node <SKILL_DIR>/scripts/resolve.mjs --doctor` before assuming retrieval or TTS will work.
- If BGM took the generate path (`bgm_pending: true`), run `audio/scripts/wait-bgm.mjs` before final render.
Single-shot helpers: `audio/scripts/heygen-tts.mjs` (one voice file). Transcription / background removal / captions use the `hyperframes` CLI (`transcribe`, `remove-background`), see the per-topic guides in `audio/references/` (`tts.md`, `bgm.md`, `sfx.md`, `transcribe.md`, `remove-background.md`, `captions/`).
Transcription defaults to Parakeet (better than whisper.cpp: 6.05% vs 7.44% WER, 5-10x faster) via `scripts/transcribe.mjs`, with whisper.cpp auto-fallback (see `references/operations.md`).
references/grading.md# Color grading — grade blocks and LUTs
Use `grade` when you need a canonical HyperFrames grading/effects payload for
an `<img>` or `<video>`. Core presets and params-backed LUT entries resolve
locally; future CDN-backed LUT entries require network unless already
frozen. Persist a decided payload with the CLI rather than editing HTML by
hand:
For a vague but explicit polish request, do not jump directly from intent to a
preset name. Read `media-treatments.md`, choose a treatment whose subject and
avoid rules match the actual media, apply its conservative base with only
justified bounded tuning, then complete its visual verification steps. A named
owned treatment uses the exact preset/payload in its recipe; do not run the
generic grade/LUT resolver first.
Stop here and use that treatment workflow for requests such as retro, old home
video, camcorder, film, print, ASCII, glitch, privacy, or a media reveal. Do not
assemble those from a generic LUT plus handmade CSS vignette/grain/opacity.
**Never `cat`/read a `.cube` file into context.** A 3D LUT is ~size^3 lines of raw numbers (33^3 ≈ 36k lines at the default size). It bloats context and carries zero human/agent-legible signal. To understand or choose a LUT, use `hyperframes grade-compare` to see it rendered, or `cube-validate.mjs` for a one-line `{ok,size}` check. Read `.media/index.md` or `luts/index.json` for the description. Never read the LUT body itself.
```bash
node <SKILL_DIR>/scripts/resolve.mjs --type grade --intent "warm daylight" --project . --json
```
Preset-first output uses the core runtime vocabulary and does not freeze a file:
```json
{
"preset": "warm-daylight",
"intensity": 1
}
```
Apply that payload to one unambiguous real media element:
```bash
hyperframes media-treatment --project . --file index.html \
--selector '#hero' \
--grading '{"preset":"warm-daylight","intensity":1}' --apply --json
```
Use `--dry-run` before writing when scope is uncertain and `--clear` to remove
the treatment. The low-level persisted result is still normal HTML:
```html
<video
class="clip"
src="./media/scene.mp4"
data-color-grading='{"preset":"warm-daylight","intensity":1}'
></video>
```
Direct attribute authoring is a fallback for environments where the CLI is not
available, not the primary agent workflow.
To build a treatment that is not already represented by a recipe, inspect the
canonical toolbox first:
```bash
hyperframes media-treatment --capabilities --json
```
It reports a concise family map. Read `--capability grading` for the processing
order, then request only the focused family needed to get its legal controls
and ranges from Core. Compose one nested payload and pass it back through
`hyperframes media-treatment`; the command rejects unknown keys before
mutation. Do not generate or hand-edit a LUT merely to combine controls already
owned by the realtime shader.
For seek-safe effect motion, animate only the runtime-supported CSS properties
on that same real media element with its registered paused GSAP timeline:
| CSS property | Range |
| ---------------------------------- | ------- |
| `--hf-color-grading-intensity` | 0 to 1 |
| `--hf-color-grading-lut-intensity` | 0 to 1 |
| `--hf-color-grading-exposure` | -2 to 2 |
| `--hf-color-grading-blur` | 0 to 1 |
| `--hf-color-grading-bloom` | 0 to 3 |
| `--hf-color-grading-kuwahara` | 0 to 1 |
| `--hf-color-grading-pixelate` | 0 to 1 |
| `--hf-color-grading-ascii` | 0 to 1 |
| `--hf-color-grading-dither` | 0 to 1 |
Author the initial value directly in the media element's inline `style`, then
use finite `tl.to()` keyframes. Do not use a frame-zero `tl.set()`, CSS
animation clocks, timers, random values, or `onUpdate` callbacks. The static
`data-color-grading` payload remains the fallback and source of the other
controls.
For a reusable color transform beyond the preset vocabulary, freeze a validated
`.cube` under `.media/luts/` and return a block that references it:
```bash
node <SKILL_DIR>/scripts/resolve.mjs --type grade --intent "teal orange blockbuster" --project . --json
```
```json
{
"intensity": 1,
"lut": { "src": ".media/luts/grade_001.cube", "intensity": 0.85 }
}
```
Use `lut` when you only need the reusable `.cube` file:
```bash
node <SKILL_DIR>/scripts/resolve.mjs --type lut --intent "teal orange blockbuster" --project .
```
For a describable technical look, author an explicit parametric LUT with `--params`:
```bash
node <SKILL_DIR>/scripts/resolve.mjs --type lut --params '{"contrast":0.2,"temperature":-0.3}' --project .
node <SKILL_DIR>/scripts/resolve.mjs --type grade --params '{"exposure":0.2}' --project . --json
```
For a LUT generated by your own script, ingest it with `--from`; media-use validates it before registration and rejects invalid or oversized cubes:
```bash
node <SKILL_DIR>/scripts/resolve.mjs --type lut --from custom.cube --project .
```
Parametric math (`buildCube`) cannot reproduce real film stocks or emulsion
transforms. Use a CDN-backed scanned `.cube` entry or ingest a real scanned
`.cube` for those.
For visual selection, list reusable LUT candidates with
`resolve --type grade --candidates`, write the promising entries to a
`grades.json`, run
`hyperframes grade-compare --for <frame> --grades grades.json`, then commit the
winner with `resolve -t grade` as the final `data-color-grading` block.
For media already selected in a composition, use `media-treatment --analyze`
when you need side-effect-free `ffmpeg`/`ffprobe` signalstats evidence. It
returns source metadata, HDR/unknown-LOG warnings, and a bounded `adjust`
suggestion without modifying the composition. The suggestion is a starting
point for visual review, not an automatic neutralization of intentional color.
```bash
hyperframes media-treatment --project . --file index.html \
--selector '#hero' --analyze --json
```
For an unbound source file, `resolve --type grade --for ... --analyze` remains
available. Without `--analyze`, that resolver records a grade candidate in
`.media`; use that form only when you intend to keep the candidate.
Library LUT entries live in `luts/index.json`. Each entry keeps `id`,
`description`, `tags`, and `intensity`, then supplies either compact `params`
for on-demand `buildCube(params)` generation or a direct CDN `url` for future
scanned `.cube` files. Do not commit generated `.cube` bodies; resolve
validates generated or downloaded cubes as it freezes them under
`.media/luts/`.
```bash
node skills/media-use/scripts/resolve.mjs --type lut --intent "teal orange blockbuster" --project . --json
node skills/media-use/scripts/lib/cube-validate.mjs .media/luts/lut_001.cube
```
references/media-treatment-recipes.md# Media treatment recipes
These are optional tested seeds, not the complete capability surface. Read the
shared policy and choose one relevant section through `media-treatments.md`.
Agents may modify or combine a seed with compatible canonical controls after
inspecting the media, or assemble a bespoke payload from
`hyperframes media-treatment --capabilities --json` when no seed fits.
## Natural Portrait
Use for a talking head, interview, presenter, or people-focused photo whose
intended result is natural, polished, and restrained.
Do not use when the face is incidental or tiny, the source is intentionally
neon/monochrome/strongly stylized, or the requested result is beauty retouching.
This treatment changes the whole frame; it is not a face mask or skin-smoothing
effect.
Inspect face exposure, highlight retention, shadow detail, white balance, and
whether the existing look is intentional. Signalstats do not detect faces or
creative intent.
### Base payload
Start here, then tune only when the sampled frames justify it:
```json
{ "preset": "skin-soft", "intensity": 0.6 }
```
`skin-soft` is a global tonal/color preset whose vibrance math is reduced for
skin-like colors. It does not blur, retouch, segment, or track a face.
### Bounded tuning
Adjustment values are absolute values in the final payload, not deltas added to
the preset. Keep changes inside these conservative ranges unless the user asks
for a stylized result:
| Property | Natural Portrait range |
| ----------- | ---------------------- |
| intensity | 0.45 to 0.75 |
| exposure | -0.06 to 0.14 |
| contrast | -0.05 to 0.08 |
| highlights | -0.18 to -0.04 |
| shadows | 0.04 to 0.18 |
| whites | -0.10 to 0.04 |
| blacks | -0.06 to 0.06 |
| temperature | -0.05 to 0.10 |
| tint | -0.03 to 0.05 |
| vibrance | 0 to 0.06 |
| saturation | -0.04 to 0.06 |
Leave grain, blur, and pixelate at zero. A vignette is optional at `0` to
`0.05` only when it improves subject focus without looking like an effect.
Manual controls must stay inside their schema section; they are never
top-level keys. A tuned Natural Portrait payload looks like this:
```json
{
"preset": "skin-soft",
"intensity": 0.58,
"adjust": {
"highlights": -0.08,
"shadows": 0.08,
"temperature": 0.02,
"vibrance": 0.02
},
"details": { "vignette": 0.03 }
}
```
Use the same nested shape in `grade-compare` candidate files. `adjust` owns
tonal/color controls, `details` owns vignette/grain, and `effects` owns blur,
pixelate, chroma bleed, and the advanced treatment primitives below.
During the common comparison, reject any result that makes skin implausible,
loses highlight detail, flattens or desaturates dark skin, or casts clothing and
background colors accidentally.
## Product Polish
Use for photographed or filmed physical products when the goal is clean,
accurate, dimensional presentation. Protect product color, material texture,
label readability, specular highlights, and intentional lighting.
Do not use this treatment for literal app/site screenshots or screen captures;
follow UI Fidelity below. Do not neutralize a lifestyle scene's deliberate
ambient color, and do not infer exact brand-color correction without a neutral
reference or known product color.
Inspect the product separately from its background. Check white balance, label
legibility, surface texture, highlight clipping, shadow detail, white point,
and black point. Statistics cannot identify a white package, metallic
highlight, amber glass, or intentional warm light.
### Base payload
Compare this restrained correction against the untouched source:
```json
{
"intensity": 0.7,
"adjust": {
"exposure": 0.01,
"contrast": 0.06,
"highlights": -0.1,
"shadows": 0.04,
"whites": 0.02,
"blacks": -0.03,
"vibrance": 0.03,
"saturation": 0.02
}
}
```
This is a comparison starting point, not an instruction to change an already
finished source. If the original has accurate color, clean endpoints, and good
texture, leave the pixels unchanged and polish through framing or motion.
### Bounded tuning
| Property | Product Polish range |
| ----------- | -------------------- |
| intensity | 0.45 to 0.8 |
| exposure | -0.08 to 0.1 |
| contrast | 0 to 0.1 |
| highlights | -0.16 to 0 |
| shadows | 0 to 0.12 |
| whites | -0.08 to 0.05 |
| blacks | -0.06 to 0.04 |
| temperature | -0.05 to 0.05 |
| tint | -0.03 to 0.03 |
| vibrance | 0 to 0.06 |
| saturation | -0.04 to 0.05 |
Temperature and tint stay at zero unless the frames show a plausible cast.
Leave grain, vignette, blur, and pixelate at zero for catalog/e-commerce media.
For a lifestyle product shot, a vignette up to `0.04` is acceptable only when
it improves focus without changing the product itself.
During the common comparison, reject any result that clips white packaging,
muddies black products, shifts a known brand color, hides texture, or makes
labels harder to read. Report when preserving the original was the deliberate
decision.
## UI Fidelity
Use for literal app, website, dashboard, terminal, slide, or screen-recording
pixels whose colors and readability are part of the product being shown.
The default payload is **none**: do not add `data-color-grading`. Global color
changes affect brand colors, status colors, charts, screenshots, and tiny text
together, so even a tasteful photographic look can make the demonstration less
truthful.
Polish UI footage with crop, scale, pacing, cursor emphasis, surrounding DOM
overlays, or seek-safe motion outside the captured pixels. If the user
explicitly asks for a stylized UI look, preview it against the original and
state that exact UI color is no longer preserved. If a camera filmed a screen,
correct only a demonstrated capture cast or exposure issue and still verify
text and brand colors across representative frames.
## Film Memory
Use when the story explicitly calls for a warm memory, restrained flashback,
personal archive, or film-like recollection. This is not the default meaning of
"cinematic", and it is not scanned-film-stock emulation.
Do not use for literal UI, product catalog media, technical demonstrations, or
footage whose accurate current-day color is important. Use a separate camcorder
treatment for VHS/REC language. Do not add dust, scratches, light leaks, film
burns, or halation unless an owned component is available and the requested
story actually benefits from it.
Check that the source has enough highlight and shadow detail to tolerate a
faded treatment, and confirm nostalgia or temporal separation belongs in the
story. Compare the full moving treatment, not only a still preset card.
### Static pixel base
Start with this owned shader recipe:
```json
{
"preset": "vintage-wash",
"intensity": 0.6,
"details": {
"vignette": 0.12,
"grain": 0.12,
"grainSize": 0.2,
"grainRoughness": 0.6
}
}
```
Keep the static values inside these ranges:
| Property | Film Memory range |
| -------------- | ----------------- |
| intensity | 0.5 to 0.75 |
| vignette | 0.08 to 0.16 |
| grain | 0.08 to 0.16 |
| grainSize | 0.16 to 0.24 |
| grainRoughness | 0.5 to 0.7 |
The stronger end can flatten dark skin, black clothing, or already-faded
footage. Compare against the source and lower strength when it does.
### Temporal character
Use the existing registered paused GSAP timeline on the same media element:
- author `--hf-color-grading-exposure: 0` in the media element's inline
`style`;
- move it through a finite irregular sequence within `-0.03` to `0.03`, using
gentle `sine.inOut` segments around `0.45` to `0.8` seconds;
- for gate weave, keep `x`/`y` within `0.15%` of the shorter composition edge,
rotation within `0.03` degrees, and scale between `1.005` and `1.01` to
protect the frame edges;
- return close to the starting exposure and transform at the treatment end.
Do not use randomness, infinite CSS keyframes, timers, or `onUpdate`. Flicker
is a gentle exposure pulse, not a flash. Weave is slight mechanical drift, not
handheld shake.
Also run focused keyframe diagnostics and seek directly to the final-minus-frame
position. Reject brightness pumping, distracting drift, clipped edges, or
skin/detail loss. Report the motion ranges and describe this as an HF
film-memory treatment, not camera-stock emulation. If motion reads as an effect
before it reads as a memory, reduce or remove it.
## Creator Camcorder
Use when the story explicitly calls for a creator-camera recording, consumer
camcorder memory, or restrained digital-video character. This treatment is a
modern camcorder language, not VHS restoration, CRT simulation, surveillance,
or a promise to reproduce a specific camera model.
Do not apply it to literal UI, product catalog media, tiny media tiles, or
already compressed footage that has distracting color bleed. Do not add a REC
HUD merely because the source contains a person talking; the camera-device
language must support the story or the user's requested style.
Check skin, saturated edges, fine text, source compression, and whether the
source already has a deliberate camera look. Reject softened chroma that
damages labels, graphics, or identifying product color. Judge chroma softness
and grain in motion, not one still.
### Static pixel base
Start with the proven shader payload below, then tune only inside the bounded
ranges when representative frames justify it:
```json
{
"intensity": 0.72,
"adjust": {
"contrast": 0.08,
"highlights": -0.05,
"shadows": 0.02,
"whites": 0.03,
"blacks": -0.04,
"temperature": -0.03,
"tint": -0.015,
"vibrance": -0.03,
"saturation": -0.06
},
"details": {
"vignette": 0.06,
"grain": 0.08,
"grainSize": 0.18,
"grainRoughness": 0.58
},
"effects": { "chromaBleed": 0.55 }
}
```
| Property | Creator Camcorder range |
| -------------- | ----------------------- |
| intensity | 0.55 to 0.8 |
| contrast | 0.03 to 0.1 |
| highlights | -0.1 to 0 |
| shadows | 0 to 0.06 |
| whites | 0 to 0.05 |
| blacks | -0.08 to -0.01 |
| temperature | -0.06 to 0.04 |
| tint | -0.03 to 0.02 |
| vibrance | -0.06 to 0.02 |
| saturation | -0.12 to -0.02 |
| vignette | 0.03 to 0.1 |
| grain | 0.04 to 0.12 |
| grainSize | 0.14 to 0.24 |
| grainRoughness | 0.45 to 0.7 |
| chromaBleed | 0.35 to 0.7 |
Leave blur and pixelate at zero. Square pixels, scanlines, RGB splitting, and
tracking noise are different visual languages and are not defaults for this
treatment.
### Optional camera HUD
When the narrative benefits from explicit recording-device language, install
the Registry overlay block:
```bash
npx hyperframes add camcorder-hud --no-clipboard
```
Insert the printed `data-composition-src` host over the intended media range.
Edit the displayed date/time/mode/counter in
`compositions/camcorder-hud.html`. The block's paused GSAP timeline derives
its counter and REC blink from composition time, so play, scrub, and render
agree. Keep the HUD finite and scoped to the shot.
The HUD is an optional authored overlay. The pixel payload remains useful
without it, and the HUD alone is not evidence that the footage was treated.
### Optional source-to-camera reveal
Global grading intensity fades only primary correction and LUT output; it does
not fade the independent camcorder effects. For a visible source-to-camera
mode change, use two synchronized media layers and a finite opacity crossfade
from untreated to treated footage. Fade the HUD in on that same paused GSAP
timeline. Do not animate shader state with callbacks or an independent clock.
Also verify HUD placement and framing in each aspect ratio the project supports.
Report whether the HUD was used and describe this as an HF camcorder treatment,
not camera/VHS emulation. If an effect artifact is more noticeable than the
subject, reduce chroma bleed/grain or keep the source unchanged.
## VHS Playback
Use when the story explicitly calls for analog home-video tape, a dated archive,
or a visibly degraded VHS playback. This treatment is not Creator Camcorder,
generic pixelation, CRT display simulation, or a default retro look.
Do not use for literal UI, product catalog media, small text, clean modern
creator footage, or any source whose identifying color/detail must remain exact.
Inspect high-contrast vertical edges, faces, saturated objects, and the bottom
of the frame in motion. Analog damage must support the story without making the
subject hard to read.
### Pixel payload
Start with the complete proven combination, not `tapeDamage` alone:
```json
{
"intensity": 1,
"adjust": { "contrast": -0.04, "saturation": -0.08 },
"details": {
"grain": 0.16,
"grainSize": 0.12,
"grainRoughness": 0.72
},
"effects": {
"tapeDamage": 0.82,
"tapeTracking": 0.85,
"tapeNoise": 0.3,
"tapeSpeed": 0.5,
"chromaBleed": 0.5,
"chromaticAberration": 0.18,
"chromaticAngle": 0,
"scanlines": 0.35,
"scanlineCount": 0.17,
"scanlineSoftness": 1,
"digitalGlitch": 0.32,
"digitalGlitchColorSplit": 0,
"digitalGlitchLineTear": 0.08,
"digitalGlitchPixelate": 0,
"digitalGlitchBlockAmount": 0,
"digitalGlitchBlockDisplacement": 0,
"digitalGlitchBlockOpacity": 0,
"digitalGlitchSpeed": 0.5
}
}
```
| Property | VHS Playback range |
| --------------------- | ------------------ |
| intensity | 0.75 to 1 |
| contrast | -0.1 to 0 |
| saturation | -0.16 to 0 |
| grain | 0.08 to 0.18 |
| grainSize | 0.08 to 0.18 |
| grainRoughness | 0.55 to 0.8 |
| tapeDamage | 0.65 to 0.9 |
| tapeTracking | 0.5 to 0.9 |
| tapeNoise | 0.15 to 0.45 |
| tapeSpeed | 0.35 to 0.65 |
| chromaBleed | 0.35 to 0.65 |
| chromaticAberration | 0.08 to 0.22 |
| scanlines | 0.2 to 0.4 |
| scanlineCount | 0.14 to 0.2 |
| digitalGlitch | 0.2 to 0.4 |
| digitalGlitchLineTear | 0.04 to 0.1 |
`tapeDamage` owns deterministic horizontal line jitter, slow time-base wobble,
bottom-edge head switching, luma bandwidth loss, restrained ghosting, noise,
and sparse dropouts. Its subordinate tracking/noise/speed controls add bounded
moving tape tears and control their signal character without introducing a new
clock. `chromaBleed` separately reduces horizontal chroma detail. The restrained
scanline and chromatic settings supply the remaining tape-playback character.
The digital stage is used only for rare horizontal row tears: keep its color
split, pixelation, block displacement, block opacity, and corruption values at
zero. Leave blur, CRT curvature, generic pixelation, and a camera HUD off.
These values are an original HyperFrames recipe calibrated on the same public
Orange Cat source used for the external visual reference. They are not copied
shader code or a claim of pixel-identical output from the external reference. The scanline count is
mapped to the reference's approximately 127-cycle primary line pattern; the HF
tracking math stays bounded in media pixels and uses the composition clock.
The shader damage evolves from the existing deterministic media time, so it
needs no CSS loop or private timeline. Global grading intensity does not fade
tape damage or other independent effects. If the story requires a finite
source-to-tape reveal, crossfade synchronized untreated and treated media layers
on the host's paused GSAP timeline. During the common workflow, inspect dense
consecutive frames and reject hard edge tearing, face
smearing, frozen noise, square blocks, blank borders, or a bottom disturbance
that competes with the subject.
## 8mm Home Movie
Use for personal archive, family-memory, childhood, travel-memory, or explicit
small-gauge home-movie language. This is stronger and more materially film-like
than Film Memory, but it is still an owned HyperFrames treatment rather than a
claim to reproduce a named film stock, camera, or laboratory process.
Do not use for literal UI, technical demonstrations, catalog products, clean
interviews, or footage where dust/scratches would imply false provenance. Check
skin, highlights, dark clothing, and frame edges before applying it.
### Pixel payload
```json
{
"preset": "vintage-wash",
"intensity": 0.72,
"details": {
"vignette": 0.28,
"vignetteMidpoint": 0.54,
"vignetteFeather": 0.72,
"grain": 0.34,
"grainSize": 0.18,
"grainRoughness": 0.72
},
"effects": { "filmArtifacts": 0.62 }
}
```
| Property | 8mm Home Movie range |
| -------------- | -------------------- |
| intensity | 0.6 to 0.8 |
| vignette | 0.18 to 0.34 |
| grain | 0.22 to 0.42 |
| grainSize | 0.12 to 0.24 |
| grainRoughness | 0.6 to 0.8 |
| filmArtifacts | 0.35 to 0.7 |
`filmArtifacts` owns only deterministic sparse dust and short scratches. The
existing preset/details own color, vignette, and grain; the host's paused GSAP
timeline owns optional gate weave. Keep weave within `0.15%` of the shorter
composition edge, rotation within `0.03` degrees, and scale between `1.005` and
`1.015`. Use finite `sine.inOut` segments around `0.6` to `1` second, return
near the starting transform, and never use randomness, timers, `onUpdate`, or
an infinite CSS animation.
Reject a result when dust is constantly visible, scratches persist unnaturally,
the frame pumps, weave exposes an edge, highlights turn muddy, or the material
artifacts are more noticeable than the memory. For a subtler nostalgic result,
use Film Memory instead.
## Editorial Halftone
Use for print/editorial transitions, poster frames, comic/newsprint language,
stylized product or portrait beats, and graphic sequences where visible ink
screening is the point. This is a real four-angle CMYK raster treatment, not a
dotted DOM overlay.
Do not use on literal UI, dense text, tiny labels, footage that must remain
photorealistic, or a long talking-head segment unless the user explicitly asks
for strong print stylization. Preserve text/captions as ungraded DOM above the
media whenever they must stay readable.
### Pixel payload
```json
{
"intensity": 1,
"adjust": { "contrast": 0.04, "saturation": 0.04 },
"effects": { "halftone": 0.94, "halftoneSize": 0.36 }
}
```
| Property | Editorial Halftone range |
| ------------ | ------------------------ |
| intensity | 0.8 to 1 |
| contrast | -0.02 to 0.08 |
| saturation | -0.04 to 0.08 |
| halftone | 0.75 to 1 |
| halftoneSize | 0.15 to 0.55 |
The shader uses fixed C/M/Y/K screen angles of 15/75/0/45 degrees, separate ink
coverage, a warm paper base, and resolution-aware dot-cell sizing. Keep those
screen semantics fixed; tune only amount and size unless a future visual proof
justifies a broader schema. Judge the result at final output resolution because
browser zoom can misrepresent the screen. Reject unstable moire, unreadable
subjects, clipped ink detail, excessive dot size, or any treatment that looks
like a transparent dot texture laid over unchanged footage.
## Two-Ink Editorial Print
Use for poster frames, editorial portraits, music/social cutaways, zine
graphics, and bold print-led transitions where two visible spot inks are more
appropriate than photographic color. This is a fixed original HyperFrames
vermilion/teal treatment, not a claim to emulate a named printer, ink set, or
commercial print process.
Do not use for literal UI, brand-color-critical products, small labels, natural
talking heads, or media that must remain photorealistic. Keep captions and
graphics as normal DOM above the treated media.
### Pixel payload
```json
{
"intensity": 1,
"adjust": { "contrast": 0.08, "highlights": -0.06, "shadows": 0.04 },
"effects": { "twoInkPrint": 1, "twoInkPrintSize": 0.42 }
}
```
| Property | Two-Ink range |
| --------------- | ------------- |
| intensity | 0.8 to 1 |
| contrast | 0.02 to 0.1 |
| highlights | -0.1 to 0 |
| shadows | 0 to 0.08 |
| twoInkPrint | 0.8 to 1 |
| twoInkPrintSize | 0.18 to 0.55 |
The shader maps warm midtones to vermilion, deep/cool shadows to teal, and
shared dark coverage to a dark overprint on warm paper. It uses separate
15/75-degree screens, a subtle fixed registration offset, deterministic paper
texture, and resolution-aware dot sizing. Do not combine it with `halftone` or
a duotone LUT: that re-separates the result and defeats the two-ink contract.
Judge it at output resolution and across multiple frames. Reject missing second
ink, crushed faces, unstable moire, illegible silhouettes, or a result that
reads as a red tint with dots rather than two screened inks.
## Monochrome Screen Print
Use for graphic portrait beats, posterized social inserts, newspaper-like
screens, or a finite transition into visible monochrome cells. Keep captions
and typography as normal DOM above the treated media.
```json
{
"intensity": 1,
"effects": {
"monoScreen": 1,
"monoScreenSize": 0.35,
"monoScreenAngle": 0.25,
"monoScreenSpread": 0.3,
"monoScreenShape": 0,
"monoScreenInvert": 0
},
"palette": ["#111319", "#f2ecdc"]
}
```
Use `monoScreenShape` `0..4` for circle, square, diamond, triangle, or line.
Keep cell size within `0.15..0.55` and spread within `0.15..0.55`. Reject faces
that lose their silhouette, unstable moire, or cells too small to survive the
final encoded resolution.
## Engraved Illustration
Use for editorial portraits, historical/technical illustration, title-card
cutaways, or a source-to-line-art reveal. It is not routine correction and
should not be applied to literal UI or brand-color-critical product footage.
```json
{
"intensity": 1,
"effects": {
"engraving": 1,
"engravingSpacing": 0.4118,
"engravingMinThickness": 0.2,
"engravingMaxThickness": 0.4571,
"engravingAngle": 0.25,
"engravingContrast": 0.4667,
"engravingSharpness": 0.59,
"engravingWave": 0.2,
"engravingWaveFrequency": 0.2222
},
"palette": ["#101216", "#f3eddf"]
}
```
Preserve the calibrated base first. Tune spacing within `0.25..0.6`, contrast
within `0.3..0.65`, and wave within `0..0.35`. Reject squeezed framing, broken
contours, noisy flat backgrounds, or lines that flicker across moving frames.
## Crosshatched Sketch
Use for hand-rendered editorial beats, comic/documentary cutaways, and short
illustrative transformations where multiple line directions should preserve
the subject contour.
```json
{
"intensity": 1,
"effects": {
"crosshatch": 1,
"crosshatchSpacing": 0.28,
"crosshatchThickness": 0.25,
"crosshatchAngle": 0.25,
"crosshatchContrast": 0.3333,
"crosshatchEdges": 0.5,
"crosshatchLineWeight": 0,
"crosshatchWave": 0.33,
"crosshatchWaveFrequency": 0.2222
},
"palette": ["#101216", "#f3eddf"]
}
```
Tune spacing within `0.18..0.5`, edge detail within `0.3..0.7`, and wave within
`0.1..0.45`. Reject distorted aspect ratio, dense black fill that hides the
subject, or temporal shimmer stronger than the intended sketch language.
## CRT Display
Use when the media is intentionally shown as an older monitor, terminal, game
screen, or broadcast display. Curvature alone is geometry, not a complete CRT
treatment, so pair it with restrained scanlines and only slight channel
separation.
```json
{
"intensity": 1,
"effects": {
"crtCurvature": 0.2,
"scanlines": 0.35,
"scanlineCount": 0.17,
"scanlineSoftness": 1,
"chromaticAberration": 0.08,
"chromaticAngle": 0
}
}
```
Keep curvature within `0.08..0.28`, scanlines within `0.18..0.45`, and channel
separation within `0..0.12`. Reject excessive black corners, unreadable UI,
large color fringes, or applying the display language to ordinary footage when
the user only asked for correction.
## Procedural ASCII
Use for a deliberate terminal, code, data, surveillance, editorial, or
source-to-character reveal. This is a real shader-generated 5x7 glyph field,
not monospace text placed over unchanged footage.
Do not use as routine talking-head polish, on literal UI or dense text, or when
recognizing a face/product precisely matters. Keep captions and graphics as
normal DOM above the treated media.
Choose one of these proven starting points:
```json
{
"effects": { "ascii": 1, "asciiSize": 0.08, "asciiInvert": 1 },
"palette": ["#020605", "#38ff78"]
}
```
The first is **Terminal ASCII**: dark field, bright green glyphs, appropriate
for code/data/device language. For a warmer print-like **Editorial ASCII**, use:
```json
{
"effects": { "ascii": 1, "asciiSize": 0.066, "asciiInvert": 0 },
"palette": ["#0b0d0d", "#eee9db"]
}
```
Keep `ascii` between `0.75` and `1` for a fully readable treatment and
`asciiSize` between `0.04` and `0.15`. A finite reveal may author
`--hf-color-grading-ascii: 0` inline and tween it to `1` with the registered
paused GSAP timeline. Reject unstable cells, lost silhouette/face structure,
unreadable composition, or a palette that conflicts with the project.
## Ordered Palette Dither
Use for posterized social beats, music/editorial cutaways, pixel-art language,
or a finite source-to-palette reveal. The shader uses a stable 4x4 Bayer
threshold matrix and an explicit dark-to-light palette. Do not describe it as
Floyd-Steinberg, Atkinson, or another sequential error-diffusion process.
Do not use on literal UI, brand-color-critical products, tiny labels, or long
photorealistic sections. Start with one of these original palettes:
```json
{
"effects": { "dither": 1, "ditherSize": 0.25 },
"palette": ["#17121a", "#824c50", "#e09873", "#f7ddb1"]
}
```
The four-color option is **Warm Print**. For a louder social/music beat, use
the six-color **Electric Ink** palette:
```json
{
"effects": { "dither": 1, "ditherSize": 0.4 },
"palette": ["#080717", "#3c185f", "#7e2278", "#d9339f", "#ff6b66", "#aafae0"]
}
```
HyperFrames also owns these named ramps. The name is an authoring shortcut;
persist the listed colors through the existing `palette` array:
| Group | Palette ID | Ordered colors |
| ----------- | ---------------- | ---------------------------------------------------------------- |
| Classic | `noir` | `#000000`, `#ffffff` |
| Classic | `ink-paper` | `#1a1a2e`, `#f5f5dc` |
| Classic | `terminal` | `#001100`, `#00ff00` |
| Classic | `amber-glow` | `#1a0f00`, `#ffcc00` |
| Classic | `handheld-green` | `#0f380f`, `#306230`, `#8bac0f`, `#9bbc0f` |
| Mood | `golden-hour` | `#1a1205`, `#4a3510`, `#8b6914`, `#d4a017`, `#fff8dc` |
| Mood | `deep-sea` | `#0a1628`, `#1a3a5c`, `#2d6187`, `#5ba4c9`, `#a8dce8` |
| Mood | `arctic-night` | `#0a0a14`, `#1a2a4a`, `#3a5a8a`, `#6a9aca`, `#cae8ff` |
| Mood | `synthwave` | `#120458`, `#7b2cbf`, `#e040fb`, `#ff6ec7`, `#fff59d` |
| Mood | `vaporwave` | `#1a0a2e`, `#3d1a5c`, `#ff71ce`, `#01cdfe`, `#fffb96` |
| Mood | `forest` | `#1a2e1a`, `#2d4a2d`, `#4a7c4a`, `#7ab37a`, `#c8e6c8` |
| Mono | `sepia` | `#1a1610`, `#3d3020`, `#6b5a40`, `#a89070`, `#e8dcc8` |
| Mono | `blueprint` | `#001830`, `#003060`, `#0050a0`, `#0080e0`, `#e0f0ff` |
| HyperFrames | `warm-print` | `#17121a`, `#824c50`, `#e09873`, `#f7ddb1` |
| HyperFrames | `electric-ink` | `#080717`, `#3c185f`, `#7e2278`, `#d9339f`, `#ff6b66`, `#aafae0` |
Choose by inspected source and project language, not by palette name alone.
For example, `terminal` fits device/code language, `warm-print` fits editorial
print, and `synthwave` is an intentional stylization rather than generic polish.
`palette` must contain two to six exact `#RRGGBB` colors in authored order. Use
dark-to-light order for this treatment; the runtime validates colors but does
not reorder them, so reversing the array intentionally inverts the mapping.
Keep `dither` between `0.7` and `1` and `ditherSize` between `0.1` and `0.5`.
A finite reveal may author `--hf-color-grading-dither: 0` inline and tween it
to the chosen amount with GSAP. Judge the moving result at output resolution;
reject shimmer, lost subject structure, accidental muddy intermediate colors,
or a palette chosen without regard to the project's design language.
## Cached Error Diffusion
Use exact error diffusion for a deliberate 1-bit Macintosh, newspaper/print,
limited-palette game, or crunchy editorial treatment. It bakes a new image or
MP4 because every processed block depends on error from earlier blocks; it is
not a realtime shader setting.
Choose the algorithm by visible intent:
- `floyd-steinberg`: balanced default with organic fine texture.
- `atkinson`: higher-contrast, more open and distinctly early-Macintosh.
- `jarvis-judice-ninke`: smoother gradients with a wider 12-neighbor field.
- `stucki`: smooth, slightly sharper alternative to JJN.
- `burkes`: compact two-row texture.
- `sierra`, `sierra-lite`, `two-row-sierra`: progressively different
speed/texture tradeoffs; use only after comparing frames.
Run the exact processor and register its output through the existing media
ledger/cache:
```bash
node <SKILL_DIR>/scripts/dither.mjs \
--input .media/videos/video_001.mp4 \
--out .media/generated/video_001.atkinson.mp4 \
--algorithm atkinson \
--palette '#17121a,#824c50,#e09873,#f7ddb1' \
--point-size 3
node <SKILL_DIR>/scripts/resolve.mjs \
--from .media/generated/video_001.atkinson.mp4 --type video --project .
```
Use the registered output path on a real `<img>` or `<video>`. Keep text,
captions, logos, and interface graphics outside the processed media. For a
finite reveal, overlap the original and processed media with identical framing
and crossfade or wipe them using the registered paused GSAP timeline. Do not
label the realtime Bayer shader as Floyd-Steinberg/Atkinson, and do not process
PQ/HLG footage without an explicit SDR tone-map decision.
## Organic Light Leak
Use for one motivated memory beat, time shift, warm scene handoff, or tactile
transition. It is a finite deterministic CSS/GSAP overlay, not a looping
texture, generic flash, or film-stock emulation.
Install the Registry overlay block:
```bash
npx hyperframes add organic-light-leak-overlay --no-clipboard
```
Insert the printed `data-composition-src` host at the intended beat and keep
its duration finite. Its paused timeline owns one rise, peak, and complete
recovery and scales those phases to the placed duration. Inspect the source
before, at the brightest frame, and after recovery. Reject clipped faces, an
unmotivated warm wash, visible black from incorrect blend mode, or a leak that
conceals the subject longer than the transition needs.
## Freeze-Frame Cutout
Use for a social introduction, speaker emphasis, chapter punctuation, sports
or creator beat, or a scrapbook/editorial hold. This requires a real alpha
matte; decoration may not conceal a poor subject edge.
Extract the exact deterministic source frame first, then remove its background:
```bash
ffmpeg -ss <seconds> -i <source-video> -frames:v 1 -y .media/generated/freeze-source.png
npx hyperframes remove-background .media/generated/freeze-source.png \
-o .media/generated/freeze-cutout.png --json
npx hyperframes add freeze-frame-dressing --no-clipboard
```
Add the transparent result as a direct-root timed media layer and insert the
printed overlay block above the same time range. The block owns the paper,
tape, and flash; the host timeline only animates the real cutout:
```html
<img
id="hf-freeze-cutout"
class="clip"
src="./.media/generated/freeze-cutout.png"
alt=""
data-start="6"
data-duration="3"
data-track-index="20"
/>
```
```js
tl.fromTo(
"#hf-freeze-cutout",
{ y: 42, scale: 0.86, rotation: -2 },
{ y: 0, scale: 1, rotation: 0.4, duration: 0.5, ease: "back.out(1.35)" },
freezeAt,
);
```
Inspect the matte over both light and dark temporary plates before styling it.
Reject missing hair/fingers, background halos, a cutout that changes identity,
overly thick outline, exposed frame edges, or a flash that obscures the reveal.
If the matte is not acceptable, choose another frame or keep the original media.
## Social Flash / Editorial Reveal
Use this treatment for one meaningful high-energy cut, creator reveal, product
beat, or before/after handoff. It is not a default transition for every scene.
Avoid it for calm long-form footage, accessibility-sensitive contexts, already
clipped highlights, literal UI that must remain readable through the cut, or
any request for repeated strobing.
Inspect representative frames on both sides of the cut first. Grade each media
layer for its own subject using the appropriate contract above; the flash is
not a substitute for correction. For people, a restrained `skin-soft` payload
is a safe starting point. For literal UI, preserve the pixels and use only the
authored light/motion layers when they do not obscure required information.
Install the Registry overlay block:
```bash
npx hyperframes add editorial-flash-overlay --no-clipboard
```
Insert the printed `data-composition-src` host so the block's midpoint lands
on the cut. Its own paused timeline drives the finite flash. The host timeline
may coordinate outgoing and incoming media motion without reaching into the
block:
```js
tl.to(
"#outgoing-media",
{
scale: 1.035,
"--hf-color-grading-exposure": 0.82,
duration: 0.12,
ease: "power3.in",
},
cutAt - 0.16,
);
tl.fromTo(
"#incoming-media",
{ scale: 1.1 },
{ scale: 1, duration: 0.42, ease: "power3.out" },
cutAt,
);
tl.to(
"#incoming-media",
{
"--hf-color-grading-exposure": 0,
"--hf-color-grading-intensity": 0.58,
duration: 0.24,
ease: "power2.out",
},
cutAt,
);
```
When the shader steps are used, author
`--hf-color-grading-exposure: 0.72` and
`--hf-color-grading-intensity: 0` inline on the incoming media so a fresh seek
has the correct start state. Set the final intensity to the source-approved
value instead of copying `0.58` blindly. Skip the shader intensity step when
the incoming source should remain ungraded.
Keep the rise between roughly `0.035` and `0.055` seconds and the recovery
between `0.24` and `0.38` seconds. Default to one neutral/warm flash event,
never saturated red, never a looping strobe, and never more than one authored
flash inside a one-second treatment window. Verify frames immediately before,
at, and after the cut, then inspect moving playback and a rendered draft. The
peak must hide the cut; the recovery must reveal a correctly framed source with
no retained prior canvas, clipped face, or unexpected highlight damage.
references/media-treatments.md# Media treatments
A media treatment is a source-aware plan that composes existing HyperFrames
color, effect, timeline, and Registry primitives. It is not a second runtime
schema. Use this file to choose a primary direction. A matching recipe is an
optional tested seed; bespoke requests may assemble a validated treatment from
the canonical capability catalog.
## Permission and scope
- An explicit request such as "polish this", "make it look better", or "make it
fit the topic" delegates a conservative treatment. Apply it, verify it, and
report what changed.
- During an unsolicited opportunity scan, show or suggest the treatment first.
- Target meaningful photographic media. Skip text, SVG, logos, icons, UI
chrome, and intentionally stylized footage unless the user asks.
- Realtime grading and effects apply to the entire selected real `<img>` or
`<video>`. They do not isolate or track a face, plate, address, or other
region. For region-only work, first create a separate cropped/masked media
layer or use an external segmentation/tracking tool; never imply that a
whole-media Blur or Pixelate performed region isolation.
- The realtime treatment path is Rec.709/SDR. Do not silently process HDR, HLG,
PQ, or camera LOG sources through it.
## Classify the request
Choose the smallest lane that satisfies the request before choosing a recipe
or assembling a custom treatment:
| User intent | Lane |
| --------------------------------------------------- | ------------------------------------------ |
| too dark, flat, too warm, too many shadows | correction |
| shape shadows/highlights or selected colors | wheels, curves, or HSL secondary |
| polished, premium, warm, cinematic, fit the topic | preset or custom treatment |
| retro, print, ASCII, glitch, camcorder | shader effect or effect-bearing preset |
| obscure the whole selected media | privacy Blur or Pixelate |
| hide one face, plate, address, or screen region | separate crop/mask/asset or external tool |
| draw attention to media without changing its pixels | framing, motion, or optional overlay |
| reveal, focus, depixelate, fade the treatment | finite seek-safe treatment keyframes |
| REC HUD, light leak, flash, freeze-frame cutout | Registry overlay plus any justified pixels |
The lane identifies the primary reason for the change; it is not a one-feature
limit. A final treatment may combine correction, a preset, finishing, multiple
compatible shader effects, finite keyframed values, and optional overlays when
the inspected media and user intent justify the complete combination. Keep one
primary intent as the creative anchor so the result remains coherent and
deterministic.
Do not add a stylized Effect when correction solves the complaint. Do not
change color when the request is only temporal, and do not install an overlay
when the selected media alone communicates the result.
Match strength to intent. When the user explicitly names a bold look such as
VHS, glitch, ASCII, halftone, camcorder, print, or engraving, apply its
signature effects strongly enough to read unmistakably. The guard against
unrequested additions does not mean under-delivering an effect the user asked
for. Correction and polish stay restrained; named stylization must be obvious
in the after-frame.
### Translate vague feedback conservatively
| User feedback | First action | Add only when the frames justify it | Never infer |
| ------------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------- |
| too many shadows and a bit boring | lift shadows/protect highlights, then compare one restrained source-appropriate preset | mild contrast or vibrance | retro texture, HUD, or palette effect |
| make the product footage feel premium | protect product color and labels, compare Product Polish | restrained vignette on lifestyle footage | a cinematic LUT or crushed blacks |
| make this reveal cooler | preserve color and animate one supported effect or treatment value | a short owned overlay block | unrelated whole-clip styling |
| make it feel like an old home video | compare 8mm and VHS language against the source | finite weave/flicker or justified HUD | that every old-video request means VHS |
| hide this face | explain that realtime effects are whole-media; isolate the region first or use an external tool | whole-media Blur/Pixelate only when the user accepts that scope | face tracking or masking that was not performed |
| keep the brand colors exact | leave UI/logo pixels unchanged; use framing and motion | demonstrated exposure-only correction | stylized preset, palette, or LUT |
When more than one lane could fit, generate at most two candidates and choose
from inspected before/after evidence. Ordinary correction or polish starts with
one candidate; a second candidate is an escalation, not the default. Do not
stack effects merely to make the answer look more sophisticated.
## Seed or assemble
Use the table when a tested recipe directly fits. Read only that recipe
section. Recipes are optional macros, not a closed list of allowed results.
| Intent or source | Recipe heading to read |
| -------------------------------------------------------- | ------------------------------------------------- |
| Talking head, interview, presenter, people-focused photo | `Natural Portrait` |
| Product footage, lifestyle footage, clean social polish | `Product Polish` |
| Screen capture, dashboard, website, app UI | `UI Fidelity` |
| Warm memory, restrained nostalgia | `Film Memory` |
| Creator/UGC handheld camera character | `Creator Camcorder` |
| Analog tape playback | `VHS Playback` |
| Small-gauge home-movie character | `8mm Home Movie` |
| Editorial dots or ink print | `Editorial Halftone` or `Two-Ink Editorial Print` |
| Monochrome dot/line screen print | `Monochrome Screen Print` |
| Engraved or hand-hatched illustration | `Engraved Illustration` or `Crosshatched Sketch` |
| Curved scanlined display | `CRT Display` |
| Glyph-based art | `Procedural ASCII` |
| Realtime palette quantization | `Ordered Palette Dither` |
| Exact historical error diffusion | `Cached Error Diffusion` |
| Finite warm flare layer | `Organic Light Leak` |
| Held-frame graphic interruption | `Freeze-Frame Cutout` |
| Short exposure-flash transition | `Social Flash / Editorial Reveal` |
Use `rg -n '^## <heading>$' <SKILL_DIR>/references/media-treatment-recipes.md`,
then read only from that heading to the next `##`. Do not load the entire
cookbook for one request.
When source intent is unclear, inspect the concise capability overview:
```bash
hyperframes media-treatment --capabilities --json
```
It lists the complete surface by family with one-line descriptions. Then load
only the family, effect, preset, or palette relevant to the inspected source:
```bash
hyperframes media-treatment --capability <id> --json
```
The focused result provides legal controls, recommended apply values, render
cost, palette support, and the exact animation contract when supported. Use
`--all` only for tooling/tests or a genuinely exhaustive audit. Compose one
nested payload from these existing parts. Recipes and catalog-built payloads
use the same renderer and persistence contract.
Treat `renderLane: "multipass"` as a cost signal. Blur, Bloom, and Kuwahara are
bounded but more expensive than single-pass effects; avoid stacking several of
them across many simultaneous media elements unless the composition needs it,
then verify playback and a draft render.
Cost follows treated pixel area as well as element count. More than two
simultaneously visible full-frame multipass media layers requires a continuous
playback check on the target machine; simplify or pre-render the stack if it
drops frames. Do not impose or claim a universal hard cap from one machine.
## Common workflow
1. Confirm the target is a real `<img>` or `<video>` and inspect source color
metadata.
2. For an image, read it once. For video, capture early/middle/late output as
one labeled sheet and read that one image:
```bash
hyperframes snapshot <project> --frames 3 --no-end --describe false \
--output snapshots/treatment-before
```
Read `snapshots/treatment-before/contact-sheet.jpg`; do not spend separate
model turns reading each frame unless the sheet exposes a specific problem.
Do not infer semantics from signal statistics alone.
3. Choose one primary lane. Use one matching recipe as a tested seed, or read
the overview and one focused capability detail when the request is bespoke.
A seed may be changed or combined with compatible catalog controls when the
contact sheet justifies it. Do not invent keys, exceed reported ranges, or
stack effects without a visual reason. Do not run the generic grade/LUT
resolver first; it adds irrelevant candidates and may download an unused
LUT. Use `media-treatment --selector "#hero" --analyze --json` only when
correction needs measured signal evidence.
4. Persist pixel settings with `hyperframes media-treatment`; it validates and
merges a patch into the existing nested `data-color-grading` contract. Use registered
GSAP only for supported animated values and Registry overlay blocks only
for authored dressing.
```bash
hyperframes media-treatment --selector "<unique selector>" \
--grading '<nested JSON patch>' --apply --json
```
For a temporal reveal, use the focused capability result's `animation`
contract. If it is `null`, the capability is static. Author the starting CSS
property inline on the real media element and return temporary treatment
values to neutral so the finished shot preserves its existing pixels.
Prefer that bounded media animation first; if an overlay is justified,
install the owned Registry block instead of recreating it with bespoke
overlay markup.
For correction and ordinary polish, keep those values as editable
preset/adjustment JSON; do not generate a LUT for controls the realtime shader
already owns.
Use the canonical `details`/`effects` fields for vignette, grain, blur,
pixelate, and related primitives. Do not duplicate them with CSS filters,
SVG turbulence, opacity, or decorative DOM overlays.
5. When the treatment calls for an overlay, install that named block with
`hyperframes add <name> --dir <project> --no-clipboard --json`, inspect its
returned `data-composition-src` host, and place it once using the block's
timing contract. Check for the installed file and host element
ID before insertion; never duplicate an existing overlay block. This is one
treatment workflow: do not make the user discover Catalog or separately ask
for the recipe's justified overlay.
6. For ordinary correction/polish, capture one after-sheet with the same three
timestamps under `snapshots/treatment-after`, compare it to the before-sheet,
and stop when the result is clearly better. Run the normal project check;
do not encode a draft solely to prove a static correction.
7. Escalate only when evidence requires it. Read individual frames to diagnose a
specific visual problem. Preview and render moving evidence when judging
treatment keyframes, glitch/tape motion, overlays, playback smoothness, LUT
timing, or any other temporal behavior. HDR/LOG, privacy, and brand-sensitive
work also require the existing explicit caveats and stronger verification.
If the treatment is not clearly better, keep the source unchanged.
8. Report the selected media, primary intent, recipe seed if used, final
composed controls, optional overlays, and the frames/render that were
actually checked. Do not report visual quality from command success alone.
`media-treatment --analyze` provides deterministic clipping and signal
evidence for local composition media, not subject recognition or automatic
taste. It reports HDR/metadata caveats and a bounded primary-correction patch;
it does not invent wheels, curves, or HSL selections from statistics.
references/memory.md# User memory — preferences and recipes ## Preferences — remembered defaults The lightweight tier of user memory: confirmed brief answers (destination, aspect, language, flow, storyboard, voice, style preset) persisted on the same two-tier split as assets — project `.media/preferences.json` (committed, the team inherits it) and personal `~/.media/preferences.json`. A value earns the personal tier by being confirmed in **two different projects**, so a one-off choice never pollutes the global defaults. ```bash node <SKILL_DIR>/scripts/prefs.mjs get --hyperframes . --json # merged view (project overrides user) node <SKILL_DIR>/scripts/prefs.mjs record --hyperframes . --key destination --value x-feed node <SKILL_DIR>/scripts/prefs.mjs record --hyperframes . --key style_preset --value pin-and-paper --workflow faceless-explainer ``` Only what the user actually confirmed gets recorded — never an inferred or defaulted value. How workflows consume these (a remembered value becomes the recommended default with a receipt, and never skips a question) is the brief contract's rule: `hyperframes-core/references/brief-contract.md` § 2, Remembered defaults. ## Recipes — frozen video bundles The heavyweight tier of user memory: one approved run frozen as a named, versioned bundle — `frame.md`, the storyboard skeleton (structure kept, content blanked to per-frame fill-ins), the brief skeleton (from `BRIEF.md` when the project has one — reusable frontmatter kept, run-shape and prose blanked), and the confirmed brief values. Same two tiers: project `.media/recipes/<name>/` (committed) and `~/.media/recipes/<name>/` (a freeze is already a confirmed bundle, so it promotes immediately — no two-project rule). Re-freezing a name bumps `version` and archives the old folder as `<name>@v<N>`. ```bash node <SKILL_DIR>/scripts/recipe.mjs freeze --hyperframes . --name weekly-promo # workflow read from BRIEF.md (--workflow only for briefless projects) node <SKILL_DIR>/scripts/recipe.mjs list --hyperframes . --workflow product-launch-video node <SKILL_DIR>/scripts/recipe.mjs use --hyperframes . --name weekly-promo # also: resolve.mjs --type recipe --entity weekly-promo ``` The freeze is offered once after the final approval (`hyperframes-core/references/review-loop.md` § 4), and the intent layer (`/hyperframes` → `references/intent-interview.md`, step 1) checks for a match before its first question. Adopting a recipe fills the brief, the design spec, and the storyboard skeleton — and unlike preferences it may skip the questions it answers: the bundle was approved as a whole, and adoption itself is the question. ## Files - `.media/manifest.jsonl`: machine SSOT, one JSON record per line - `.media/index.md`: agent-readable table (id, type, dur, dims, path, description) - `.media/preferences.json`: the project's remembered defaults (committed) - `~/.media/`: global cross-project reuse cache (content-addressed, SHA-256) - `~/.media/preferences.json`: personal remembered defaults (promoted after two projects) - `.media/recipes/<name>/`: frozen video bundles — recipe.json + frame.md + storyboard skeleton (committed) - `~/.media/recipes/<name>/`: personal recipe tier (promoted on freeze) - `~/.media/misses.jsonl`: local-only resolve misses, including intent text for `--stats`
references/meta.md# Ownership matrix, usage stats, telemetry, privacy Maintainer-facing reference. Nothing here changes how you resolve or operate on media. ## What it owns (the gaps HyperFrames leaves) HyperFrames owns media _playback_; media-use owns everything else. Each row is enforced by `scripts/lib/coverage.test.mjs` so the claim can't rot. | HyperFrames gap | media-use owns it via | | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Audio-only, no image/icon | `resolve --type image\|icon` (heygen asset search) | | No third-party brand logos | `resolve --type logo` (svgl → simple-icons → GitHub org avatar → domain favicon) | | No voice / audio generation | `resolve --type voice` (HeyGen TTS free-usage path; optional local Kokoro) + the audio engine (`audio/scripts/audio.mjs`) | | Scattered/duplicated audio engine | one consolidated engine under `audio/` (hyperframes-media retired) | | No agent media-ops (cut/reframe/transform) | `references/operations.md` + `resolve --from` to register outputs | | No transcript-driven cutting | `scripts/transcript-cut.mjs` compiles word-timestamp edits into cut lists | | No auto-duck / publish loudness | `scripts/audio-duck.mjs` + `references/operations.md` loudnorm/sidechain recipes | | No cross-project memory | global content-addressed cache + auto-promote (`~/.media`) | | Grade recipes and LUT freezing | `resolve --type grade` emits a paste-ready recipe and `resolve --type lut` freezes validated `.cube` files; direct element analysis/authoring lives in `hyperframes media-treatment` | | No image generation | RAM-graded local mflux (FLUX) via `scripts/lib/mflux-provider.mjs`, codex `image_gen` upsell (`scripts/lib/codex-provider.mjs`) | | No video generation | `resolve --type video` — HeyGen avatar video first (free-usage path, sign-in nudge on auth failure), local LTX fallback (`videogen` in `scripts/lib/local-models.mjs`); image-to-video, photo-avatar, dub/translate remain manual `heygen` CLI recipes (`references/operations.md`) | | Weak local-model defaults | HeyGen free-usage path via the `heygen` CLI; local open-source tools only as opt-in alternatives (`scripts/lib/local-run.mjs`) | ## Usage stats Use `resolve --stats` for a local, shareable report over the current project's `.media/` manifest, the global `~/.media/` cache, and local resolve misses. Human output is compact; add `--json` for a single machine-readable object, and `--days N` to window timestamped records. ```bash node <SKILL_DIR>/scripts/resolve.mjs --stats --project . --days 7 # media-use stats # total resolves: 12 # misses: 2 # hit rate: 86% ``` ## Telemetry `resolve` and the edit tools (transcribe / transcript-cut / audio-duck) send an anonymous usage event to PostHog (`scripts/lib/telemetry.mjs`), so we can see which capabilities are actually used. It records only the media TYPE, the resolution SOURCE, and the winning PROVIDER: never the intent text, file names, or paths, and `$ip:null` so no IP is stored. Best-effort and non-blocking (a resolve never waits on or fails from telemetry). Opt out with `DO_NOT_TRACK=1` or `HYPERFRAMES_NO_TELEMETRY=1` (also off in CI and dev). Same public PostHog project key and opt-outs as the `hyperframes` CLI. HeyGen request tagging: every generating `heygen` call (TTS, avatar video, catalog search) carries the allowlisted `X-HeyGen-Client-Source: media-use` header, sourced from one shared constant (`HEYGEN_CLIENT_SOURCE_ARGV` in `scripts/lib/heygen-cli.mjs`) so a future call site can't silently ship untagged. Read-only discovery calls (`voice list`, `avatar list`) are intentionally left untagged. ## Privacy media-use uses the same shared install id as the `hyperframes` CLI/studio (`~/.hyperframes/config.json`). When you are signed in to HeyGen, usage is linked to your account email, or username when email is unavailable, matching the CLI behavior. The events stay coarse: media type, source, provider, and small counts only; intent text and paths stay local. Disable telemetry with `HYPERFRAMES_NO_TELEMETRY=1` or `DO_NOT_TRACK=1`.
references/operations.md# Media operations: agent guidance
media-use resolves and remembers assets. For **operating** on them: cutting,
reframing, stitching, transforming, it does not wrap every action as a bespoke
command. Instead it points you at the right local tool (decision OP1). Run the
tool, then register the output with `resolve --from <output> --type <type>` so the
result lands in the ledger and the global cache like any other asset.
All tools below are local and free. ffmpeg is assumed present (it backs the
engine already).
## Cut / trim: keep a slice
```bash
ffmpeg -i in.mp4 -ss 00:00:12 -to 00:00:20 -c copy out.mp4 # 0:12–0:20, no re-encode
```
In-composition trimming usually needs **no new file**: a clip plays a sub-window
via `data-media-start` + `data-duration` (see hyperframes-core). Only cut a
physical file when exporting/assembling outside the composition.
## Reframe / crop: change aspect ratio
```bash
# 16:9 -> 9:16, crop centered
ffmpeg -i in.mp4 -vf "crop=ih*9/16:ih,scale=1080:1920" out.mp4
```
For a non-destructive crop, set a `clip-path` on the element in the composition
itself (render-time, source file untouched) instead of re-encoding with ffmpeg.
## Montage / stitch: join clips
```bash
printf "file '%s'\n" a.mp4 b.mp4 c.mp4 > list.txt
ffmpeg -f concat -safe 0 -i list.txt -c copy out.mp4
```
## Silence-cut / highlight: trim dead air, grab the best moment
```bash
auto-editor in.mp4 --edit audio:threshold=4% -o tight.mp4 # pip install auto-editor
scenedetect -i in.mp4 detect-adaptive list-scenes # pip install scenedetect
```
## Transforms with a quality choice (process)
These have a local option AND a higher-quality HeyGen-CLI option. Run the local
one for free/offline; use the HeyGen CLI when quality matters. Showing the user
a **side-by-side** (local vs HeyGen) is the honest way to let them choose.
| Op | Local (free) | HeyGen CLI (quality) |
| ------------------ | -------------------------------------------------- | --------------------------- |
| Background removal | `hyperframes remove-background in.png` (u2net) | `heygen background-removal` |
| Upscale | `realesrgan-ncnn-vulkan -i in.png -o out.png -s 4` | n/a |
| Lipsync (dub) | n/a | `heygen lipsync` |
| Translate | n/a | `heygen video-translate` |
After any op: `resolve --from out.ext --type <type>` to register the derived
asset (it records provenance and auto-promotes to the global cache).
> ponytail: media-use doesn't re-wrap ffmpeg/heygen here, that's deliberate
> (OP1). The value it adds is the ledger + global reuse on the _output_, via
> `--from`. Add a thin `process` verb only if agents repeatedly fumble these
> recipes.
## Exact error-diffusion dither
Use the local processor when the requested look specifically calls for
Floyd-Steinberg, Atkinson/Macintosh, Jarvis-Judice-Ninke, Stucki, Burkes, or a
Sierra variant. These are sequential error-diffusion algorithms, not the
realtime Bayer `effects.dither` shader.
```bash
node <SKILL_DIR>/scripts/dither.mjs \
--input source.mp4 \
--out source.atkinson.mp4 \
--algorithm atkinson \
--palette '#0f380f,#306230,#8bac0f,#9bbc0f' \
--point-size 3
node <SKILL_DIR>/scripts/resolve.mjs \
--from source.atkinson.mp4 --type video --project .
```
Available algorithms: `floyd-steinberg`, `atkinson`,
`jarvis-judice-ninke`, `stucki`, `burkes`, `sierra`, `sierra-lite`, and
`two-row-sierra`. The default is balanced Floyd-Steinberg with a black/white
palette. Palettes contain 2-6 `#rrggbb` colors in authored dark-to-light order;
reversing the order intentionally inverts the mapping. `--point-size` controls
1-20px blocks; `--brightness` and `--contrast` accept 0.5-2; `--detail` accepts
0.1-1.
The processor supports ordinary SDR images and MP4 video, preserves video
audio, and emits BT.709 MP4. It rejects tagged PQ/HLG input rather than silently
tone-mapping it. To animate the transformation, keep the original and processed
files as two real media layers and use the seek-safe GSAP timeline to reveal or
crossfade between them. Use the realtime Bayer shader instead when the dither
amount itself must animate continuously.
## Transcription (default: Parakeet, better than whisper.cpp)
`transcribe.mjs` is the default local transcription path. It runs **NVIDIA
Parakeet-TDT via parakeet-mlx**, which beats whisper.cpp on the Open ASR
Leaderboard (avg WER ~6.05% vs 7.44%; on NOISY audio 4.73% vs 5.96%, where
whisper-large-v3 hallucinated to 308% WER on meetings) and is 5-10x faster.
It emits `{ text, words:[{text,start,end}] }` with word timestamps (merged from
Parakeet's sub-word tokens), feeding transcript-cut, captions, and the audio
engine directly.
```bash
# install once: uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx
node <SKILL_DIR>/scripts/transcribe.mjs --input talk.mp4 --out talk.transcribe.json
# equivalently, the hyperframes CLI has Parakeet built in (auto-detects it, whisper fallback):
npx hyperframes transcribe talk.mp4 --engine parakeet # or --engine auto (default)
```
VERIFIED on 24GB: accurate, ~3s (cached) for 8s audio. Parakeet covers English +
25 European languages. For other languages, or when parakeet-mlx is not
installed, transcribe.mjs auto-falls-back to whisper.cpp (99 languages) via
`hyperframes transcribe`. `--engine parakeet|whisper` forces one. (Cohere
Transcribe tops the leaderboard on paper but its mlx-audio quants produced
garbage and ran 40-70x slower on a Mac in testing, so it is not wired in.)
## Text-based editing (transcript cut)
`transcript-cut.mjs` is a compiler, not a wrapper: it turns word timestamps and
agent cut decisions into exact kept segments. It is provided even though the rest
of this file is guidance-only.
```bash
node <SKILL_DIR>/scripts/transcript-cut.mjs \
--input talk.mp4 \
--transcript talk.transcribe.json \
--remove "12.41-15.02,88.3-91.7" \
--remove-fillers "um,uh,like" \
--cut-silence 0.8 \
--out talk.cut.mp4
resolve --from talk.cut.mp4 --type video
```
Use `--plan` first when you want to inspect the kept segment JSON before encoding.
## Ducking (declare in-composition / bake for export)
B1, declare ducking in the composition. `audio-duck.mjs` emits GSAP volume
keyframes. Paste them into the composition timeline, the source file stays
untouched.
```bash
node <SKILL_DIR>/scripts/audio-duck.mjs \
--meta audio_meta.json \
--target "#bgm" \
--composition index.html
```
```js
// auto-duck: #bgm under narration (generated; base volume 0.6)
tl.to("#bgm", { volume: 0.15, duration: 0.15 }, 3.42);
tl.to("#bgm", { volume: 0.6, duration: 0.4 }, 9.87);
```
B2, bake ducking only for exported or standalone files.
```bash
ffmpeg -i bgm.mp3 -i voice.wav \
-filter_complex "[0][1]sidechaincompress=threshold=0.03:ratio=8:attack=200:release=400[ducked]" \
-map "[ducked]" bgm.ducked.wav
```
Declare inside compositions. Bake only for assets leaving the hyperframes
pipeline.
## Publish loudness
Two-pass `loudnorm` measures first, then applies the measured values with the
target LUFS baked in.
Socials target, -14 LUFS:
```bash
ffmpeg -i mix.wav \
-af loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json \
-f null -
ffmpeg -i mix.wav \
-af loudnorm=I=-14:TP=-1.5:LRA=11:measured_I=<input_i>:measured_TP=<input_tp>:measured_LRA=<input_lra>:measured_thresh=<input_thresh>:offset=<target_offset>:linear=true:print_format=summary \
mix.social.wav
```
Podcast target, -16 LUFS:
```bash
ffmpeg -i mix.wav \
-af loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json \
-f null -
ffmpeg -i mix.wav \
-af loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=<input_i>:measured_TP=<input_tp>:measured_LRA=<input_lra>:measured_thresh=<input_thresh>:offset=<target_offset>:linear=true:print_format=summary \
mix.podcast.wav
```
## Generate: images (local first, cloud upsell)
`resolve --type image` retrieves from the HeyGen catalog first; on a miss it
GENERATES. Two paths, best-for-the-machine picked automatically:
1. **Local (default, free, private): mflux** (FLUX-on-MLX). `resolve` spec-checks
AVAILABLE RAM and runs the best FLUX-class model that fits, via
`scripts/lib/local-models.mjs` (`imagegen` ladder) + `mflux-provider.mjs`.
The RAM ladder (agent sees it via `describeModelLadder("imagegen", specs)`):
| Tier | Model | Needs (available RAM) | Notes |
| ------ | -------------------- | --------------------- | ----------------------------------- |
| medium | FLUX.1 schnell int4 | ~8GB (`--low-ram`) | ~20s/512px on 24GB. VERIFIED. Fast. |
| large | FLUX.2 Klein 4B int4 | ~32GB | higher quality, full-resident |
| xlarge | Qwen-Image | ~64GB | top quality, 64GB+ Macs only |
Gotchas baked into the table: the official FLUX repos are HF-gated, so it
points at non-gated community 4-bit re-uploads; and `--low-ram` is MANDATORY
at the medium tier (without it a 768x512 run swap-thrashed to 90 minutes on
24GB; with it, 20 seconds).
2. **Cloud upsell (better quality): the `codex` CLI** `image_gen` tool, on the
user's ChatGPT subscription (codex owns auth, no key here, no per-call
charge). It is the automatic fallback when no local model fits AND the
explicit "make it better" choice on any machine. Users who just want codex
can ask for it directly. Verified: prompt -> raster -> frozen + ledgered.
`--local-only` keeps mflux (once cached) and skips codex (network).
## Generate: video (`resolve --type video`, HeyGen avatar first)
`resolve --type video "<intent>"` is the default path. It generates a
script-driven HeyGen avatar video first (the free-usage allowance — OAuth
sessions ride the web-plan free avatar-video quota where eligible, API keys
follow normal API billing), falling back to local generative LTX only when
HeyGen is unavailable, uncredentialed, or `--local-only` is passed. The two
are non-substitutable outputs (a real presenter vs. a generic generative
clip), so treat the fallback as "HeyGen wasn't reachable," not "upgrade the
quality":
- **HeyGen avatar video (default, free for new API users):**
`heygenVideoGenerate` (`scripts/lib/heygen-video-provider.mjs`) shells the
`heygen` CLI — never the raw API — auto-picking a public avatar and a
starfish voice (override with `--avatar-id`/`--voice-id`, threaded through
as `ctx.avatarId`/`ctx.voiceId`). If the CLI reports `not_authenticated`,
the provider prints an onboarding recommendation (avatar video is free for
new API users — sign in) to stderr and falls through to LTX instead of
hard-failing.
- **Local fallback: LTX 2.3 on MLX** via `dgrauet/ltx-2-mlx`, the `videogen`
ladder in `local-models.mjs` (`ltx-video-provider.mjs`). Generative clips
(t2v), spec-gated to RAM. Verified on 24GB: 512x320 x 33f with audio.
Every generating `heygen` call from media-use — TTS, avatar video, and
catalog search — sends the allowlisted `X-HeyGen-Client-Source: media-use`
header (persistent flag, works on every subcommand) via the shared
`HEYGEN_CLIENT_SOURCE_ARGV` constant (`scripts/lib/heygen-cli.mjs`), so usage
tags correctly in billing/resource meta and shows up in the API dashboards.
Read-only discovery (`avatar list`, `voice list`) doesn't need it.
For structured bodies `resolve --type video` doesn't expose yet (a specific
`avatar_id`/`voice_id` combination beyond the ctx overrides, or a
pre-recorded `audio_url` instead of a script), the raw `heygen video create`
recipe below remains the escape hatch:
```bash
# discover an avatar + a starfish voice, then create + wait
heygen avatar list --ownership public --limit 5
heygen voice list --engine starfish --limit 5
heygen video create --headers "X-HeyGen-Client-Source: media-use" --wait -d '{
"type": "avatar",
"avatar_id": "<avatar-id>",
"script": "Your narration here.",
"voice_id": "<voice-id>"
}'
```
Avatar videos are deterministic + script-driven (lip-sync from a script or a
pre-recorded `audio_url`), distinct from the generative LTX clips. After a
manual recipe renders, `resolve --from <downloaded.mp4> --type video` to
ledger it (not needed when generating via `resolve --type video` directly —
that already ledgers the result).
### Image-to-video (animate any still into a talking clip)
Not wired into `resolve --type video` (deferred — the `avatar` type covers
the default script-driven case). `heygen video create` takes the raw
`POST /v3/videos` body, so switching `type`
from `avatar` to `image` animates **any image of a person** into a lip-synced
talking video, with no avatar/photo-avatar creation step first. Point `image` at a
public URL or an uploaded `asset_id`, and drive speech with a `script`+`voice_id`
or a pre-recorded `audio_url`:
```bash
heygen video create --headers "X-HeyGen-Client-Source: media-use" --wait -d '{
"type": "image",
"image": { "type": "url", "url": "https://example.com/person.jpg" },
"script": "Your narration here.",
"voice_id": "<voice-id>"
}'
```
Common optional fields: `title`, `resolution` (`4k`/`1080p`/`720p`),
`aspect_ratio`, `remove_background`, `background`, `voice_settings`,
`motion_prompt` + `expressiveness` (photo-avatar animation), and
`callback_url`/`callback_id` for webhooks. Don't hardcode these from memory: the
CLI self-documents the full, current body with
`heygen video create --request-schema` (a discriminated union keyed on `type`),
so read the schema rather than trusting a stale field list. For a still you'll
reuse across many scripts, create a reusable **Photo Avatar** once instead
(`heygen avatar create`). Ledger the result with
`resolve --from <downloaded.mp4> --type video`. Docs:
<https://developers.heygen.com/image-to-video>.
## HEVC / H.265 sources
HEVC/H.265 sources need no conversion for **render** (FFmpeg pre-decodes all
input video) or for **preview** (auto-proxy transcodes and caches an H.264
copy on first use, disable with `--no-proxy` or `media.autoProxy: false` in
hyperframes.json). A manual H.264 proxy via `ffmpeg -i in.mp4 -c:v libx264
-crf 18 proxy.mp4`, registered with `resolve --from`, remains available for
edge cases (e.g. auto-proxy disabled, or ffmpeg unavailable at preview time).
references/resolve.md# Resolve — command, flags, reuse, adopt, inventory
```bash
node <SKILL_DIR>/scripts/resolve.mjs --type <type> --intent "<description>" --project <dir>
```
Returns one line: `resolved <id> → <path> (<type>, <metadata>)`
## Types
| Type | What it finds | Provider / cascade |
| ------- | -------------------------------- | ------------------------------------------------------------ |
| `bgm` | Background music | HeyGen audio catalog (10k+ tracks) |
| `sfx` | Sound effects | Bundled 19-file library + HeyGen catalog |
| `image` | Photos, backgrounds | HeyGen asset search (75k+ vectors) |
| `icon` | Icons, symbols | HeyGen asset search (type=icon) |
| `logo` | Official brand marks | svgl → simple-icons → GitHub org avatar → domain favicon |
| `voice` | TTS voiceover | HeyGen TTS free-usage path; optional local Kokoro |
| `grade` | HyperFrames color-grading blocks | Core preset → look index params/CDN LUT → deterministic cube |
| `lut` | Reusable `.cube` LUT files | Look index params/CDN LUT → deterministic cube |
## Examples
```bash
# Background music
node <SKILL_DIR>/scripts/resolve.mjs --type bgm --intent "upbeat tech launch" --project .
# → resolved bgm_001 → .media/audio/bgm/bgm_001.mp3 (bgm, 25s)
# Sound effect
node <SKILL_DIR>/scripts/resolve.mjs --type sfx --intent "whoosh" --project .
# → resolved sfx_001 → .media/audio/sfx/sfx_001.mp3 (sfx, 0.57s)
# Image
node <SKILL_DIR>/scripts/resolve.mjs --type image --intent "gradient tech background" --project .
# → resolved image_001 → .media/images/image_001.jpg (image)
# Icon
node <SKILL_DIR>/scripts/resolve.mjs --type icon --intent "rocket" --project .
# → resolved icon_001 → .media/images/icon_001.png (icon, transparent)
# Brand logo (official mark — never redrawn by hand)
node <SKILL_DIR>/scripts/resolve.mjs --type logo --entity linkedin --intent "LinkedIn logo" --project .
# → resolved logo_001 → .media/images/logo_001.svg (logo, official mark)
# Color grade block
node <SKILL_DIR>/scripts/resolve.mjs --type grade --intent "warm daylight" --project . --json
# → {"ok":true,"preset":"warm-daylight","grading":{"preset":"warm-daylight","intensity":1},...}
# LUT file
node <SKILL_DIR>/scripts/resolve.mjs --type lut --intent "teal orange blockbuster" --project .
# → resolved lut_001 → .media/luts/lut_001.cube (lut)
```
## Flags
| Flag | Description |
| --------------- | ------------------------------------------------------------------------------------ |
| `--type, -t` | Media type: bgm, sfx, image, icon, logo, voice, grade, lut |
| `--intent, -i` | What you need (natural language) |
| `--entity, -e` | Entity name for cache matching (optional) |
| `--project, -p` | Project directory (default: .) |
| `--candidates` | List reusable assets (project + global cache) for `--type`; no download, no mutation |
| `--reuse <sha>` | Import a specific global-cache asset (by content sha/prefix, from `--candidates`) |
| `--from` | Freeze a local file or direct public URL (ingest) |
| `--for` | Analyze a local image/video and add measured adjust suggestions (`grade` only) |
| `--local-only` | Offline: skip every network provider (cache + local only) |
| `--provider` | Force one generator (e.g. `codex`, `mflux`, `kokoro`, `heygen`) |
| `--adopt` | Bulk-import existing assets/ into manifest |
| `--doctor` | Check local CLI dependencies; no manifest changes |
| `--stats` | Print local usage stats from `.media/` and `~/.media`; no manifest changes |
| `--days N` | Limit `--stats` to timestamped records/misses from the last N days |
| `--json` | Output JSON instead of one-line result |
## Reuse before you resolve
Before resolving bgm/sfx/image/icon/logo/grade/lut, **check what already exists and reuse it when it fits.** media-use does not semantically match for you — you are the judge. It surfaces candidates; you decide.
```bash
node <SKILL_DIR>/scripts/resolve.mjs --type bgm --intent "upbeat tech launch" --candidates --project .
# [project] upbeat tech launch (25s, heygen.audio.sounds)
# .media/audio/bgm/bgm_001.wav
# [global] energetic tech intro (22s, heygen.audio.sounds)
# --reuse 06e052c075fd2b80
```
Read the list and judge semantic fit yourself — "upbeat tech launch" ≈ "energetic tech intro" is a call only you can make from the descriptions. Then:
- **A project candidate fits** → just reference its path in your composition. Nothing else to run.
- **A global candidate fits** → `resolve --type bgm --reuse <sha>` copies it into this project (self-contained render) and records it.
- **Nothing fits** → resolve fresh (`--type ... --intent ...`).
**Trust guardrail — when unsure, resolve fresh.** A redundant download is cheap; shipping the wrong asset is not. Judge fit from description + prompt + type + duration/dims. For **brand/entity** assets, reuse a _global_ candidate only when the entity matches exactly — the global cache aggregates every project you have worked on, so a `--candidates` list can surface another client's brand mark and its prompt text. Never reuse a cross-project brand asset on a loose match.
The deterministic floor still runs automatically: an identical (case/whitespace-insensitive) repeat auto-reuses with no `--candidates` step. `--candidates` is only for the semantic layer above that floor — and a fuzzy match is **never** auto-applied; reuse is always your explicit call. On a resolve that misses the floor and is about to fetch, media-use prints a one-line stderr hint when similar cached assets exist, pointing you back here.
## How it works
`resolve` runs an automatic floor, then falls through to fetching:
1. Check project `.media/manifest.jsonl` for a prompt match (case- and whitespace-insensitive) — auto-reuse
2. Scan existing `assets/` directory for unregistered files that share a word with the need
3. Check global cache `~/.media/` for a reusable asset matched on the same normalized prompt — auto-reuse
4. Search via provider (HeyGen audio catalog, HeyGen asset search), or resolve color locally
5. Freeze file to `.media/<type>/`, register in manifest, regenerate `index.md`, auto-promote to `~/.media/`
Steps 1 and 3 are the **deterministic floor**: they only auto-reuse an exact-normalized match, never a fuzzy one. Semantic reuse ("close enough") is the agent's explicit call via [Reuse before you resolve](#reuse-before-you-resolve) — it never happens automatically. The agent gets back **one line**; candidates, scores, provenance stay on disk.
## Adopt existing projects
Most HyperFrames projects already have assets in `assets/`. media-use adopts them:
```bash
node <SKILL_DIR>/scripts/resolve.mjs --adopt --project .
# → adopted 9 assets from assets/
# bgm_001 → assets/bgm/mango-fizz.mp3 (bgm, 146.6s)
# image_001 → assets/images/avatar.jpg (image, 400×400)
```
`ffprobe` extracts real duration and dimensions. During resolve, unregistered files in `assets/` matching the intent are adopted on the fly.
## Reading the inventory
After resolve or adopt, read `.media/index.md` for the full inventory:
```
# .media · 4 assets
id type dur dims path description
bgm_001 bgm 25s - .media/audio/bgm/bgm_001.mp3 upbeat tech launch
sfx_001 sfx 0.6s - .media/audio/sfx/sfx_001.mp3 whoosh
image_001 image - 1920×1080 .media/images/image_001.jpg gradient tech background
icon_001 icon - 200×200 .media/images/icon_001.png rocket
```
## Cross-project reuse
Assets are cached automatically on resolve. Every resolved/ingested asset is auto-promoted to the global cache at `~/.media/`, so subsequent resolves for the same (or near-identical) prompt, in any project, hit the cache with no re-download and no provider call.
For a _semantically_ similar (not identical) need in another project, the exact-match floor won't fire — use [Reuse before you resolve](#reuse-before-you-resolve): `--candidates` lists the global assets, and `--reuse <sha>` imports the one you pick. This is how a track resolved in one project gets reused in the next when the wording differs.
references/setup-providers.md# Setup and providers — install, auth, RAM ladders, forcing a provider ## Setup — install heygen first (free-usage path) Install the HeyGen CLI through its [verified release instructions](https://developers.heygen.com/cli), then run: ```bash heygen update # free usage needs the OAuth-capable CLI (v0.3.0+) heygen auth login --oauth # OAuth = free subscription credits; --api-key bills API credits ``` This unlocks the FREE path for bgm/sfx/image/icon catalog search, TTS (voice), and avatar videos. Sign in with `--oauth` — the free allowance rides on the OAuth session (an API key bills API credits instead). **media-use requires heygen >= v0.3.0 uniformly** (the OAuth free-usage path needs it), so `--doctor` nudges older CLIs to update even for API-key-only use. Before resolving anything, verify setup with: ```bash node <SKILL_DIR>/scripts/resolve.mjs --doctor ``` ## Providers media-use holds no keys; every external tool owns its auth. Generation is centered on the HeyGen CLI free-usage path. Install and authenticate `heygen` before resolving bgm/sfx/image/icon/voice/avatar-video. Local tools are opt-in alternatives where they exist: mflux for image, Kokoro for voice, Parakeet for transcription, and LTX for local video generation. `resolve` spec-checks AVAILABLE RAM for those local ladders (`describeModelLadder`); the agent can see the ladder and override. | Type | Provider / path | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | bgm/sfx | heygen catalog free-usage path | | image | heygen search free-usage path; optional local mflux; codex `image_gen` upsell | | voice | heygen tts free-usage path; optional local **Kokoro** (free, on-device) | | icon | heygen asset search free-usage path | | logo | svgl, then simple-icons, then GitHub org avatar, then domain favicon (all free) | | grade/lut | local core-preset map, params/CDN look index, deterministic `buildCube` fallback | | video | heygen avatar video free-usage path (sign-in nudge on auth failure); optional local LTX (`videogen` ladder). Image-to-video / photo-avatar / dub stay manual `heygen` recipes | Local Kokoro (voice), mflux (image), and LTX (video) run on-device (free, private, offline once cached). The `codex` CLI remains the ChatGPT-sub image upsell. Cost rule (X4): the agent confirms before an agent-initiated paid call; a user-requested one just runs — `heygen.video` is flagged paid (metered free allowance) so an agent-initiated `resolve --type video` confirms first. To force a specific generator (e.g. a user says "make this image with codex"), pass `--provider codex`: it pins resolution to that provider and skips the free-usage default. See `references/operations.md` for the RAM ladders and provider recipes. `--local-only` skips every network provider, including the free HeyGen ones, leaving the project + global cache and any installed local provider. For HeyGen-only types, that means no fresh resolve. ## CLI tools used (what to run, and how to enable each) `resolve` auto-cascades; each provider shells one CLI. HeyGen is the free-usage path for bgm/sfx/image/icon catalog search, TTS (voice), and avatar video, so those capabilities need `heygen` installed and authenticated. Local tools are OPT-IN alternatives where they exist; install one to unlock its free, private, on-device path instead of or ahead of HeyGen for that type. Only `ffmpeg`/`ffprobe` are strictly required for the tool to run at all. | Tool | Serves | Install | | ------------------ | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) | | `heygen` | catalog (bgm/sfx/image/icon) + TTS (voice) + avatar video — the free-usage path | install through [verified HeyGen release instructions](https://developers.heygen.com/cli), then `heygen auth login --oauth` (needs >= v0.3.0) | | `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` | | `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) | | `parakeet-mlx` | local transcription (default ASR, best) | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` | | `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` | | `npx hyperframes` | Kokoro TTS (voice), whisper.cpp (transcribe fallback), remove-background | via the hyperframes CLI; whisper.cpp is built on first use (Homebrew on macOS, else git+cmake), models download from HuggingFace | The RAM-graded local-model shortlist + exact per-tier install/invoke lives in `scripts/lib/local-models.mjs` (the agent can read `describeModelLadder(cap, specs)` to see which model fits this machine). Without a tool on PATH, its provider prints a one-line diagnostic to stderr and resolve falls through where another provider exists (e.g. no `mflux` -> codex image upsell; no `parakeet-mlx` -> whisper.cpp). `heygen asset search` is a pre-launch command hidden from `heygen --help`, but it runs; providers tag requests with the allowlisted `X-HeyGen-Client-Source` header (v0.3.0+).
references/telemetry-dashboard.md# media-use usage dashboard Reproducible definition of the media-use usage dashboard. The dashboard answers "how much is media-use used, for what, is reuse working, and what can't it satisfy" from the telemetry `scripts/lib/telemetry.mjs` already emits. Build it in an authorized HyperFrames analytics project; this doc is the source of truth so it can be recreated. Local complement: `resolve --stats` (same questions, from `.media/` + `~/.media`, no dashboard access needed). ## Identity (see `scripts/lib/telemetry.mjs`) Events attribute to the **same person as the hyperframes CLI and studio** — the shared install id in `~/.hyperframes/config.json` (`anonymousId`), stitched to the HeyGen account (`$identify`, `distinct_id` = email/username) on sign-in. Not fully anonymous by design; pseudonymous before sign-in, account-linked after. `$ip:null`. Opt-out: `HYPERFRAMES_NO_TELEMETRY=1` / `DO_NOT_TRACK=1` (also CI, dev). ## Event catalog (verified present in-project) Every event carries `surface: "media-use"`. Event **properties are coarse** — never intent text, file names, or paths. | Event | Fires on | Key properties | | ---------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------- | | `media_use_resolve` | a resolve that produced/returned an asset | `type`, `source`, `provider`, `via`, `local_only`, `provider_override` | | `media_use_resolve_miss` | a resolve that found nothing | `type`, `local_only`, `provider_override` (no intent) | | `media_use_candidates` | `--candidates` / `--dry-run` listing | `type`, counts | | `media_use_doctor_run` | `--doctor` | `ok`, `checks_failed`, `failed[]` | | `media_use_compare` | `grade-compare` / `compare` | `command`, `cells`, `truncated`, `total`, `render_ready_timed_out` | | `media_use_transcribe` · `media_use_duck` · `media_use_transcript_cut` | audio-engine ops | op-specific | ## Dashboard tiles 1. **Invocation volume** — `query-trends`, count of `media_use_resolve` over time (daily). "How much." 2. **By media type** — `media_use_resolve` broken down by `type` (bgm/sfx/image/icon/logo/voice/grade/lut). "For what." 3. **Resolve hit-rate** — trends formula: `A / (A + B)` where A = `media_use_resolve`, B = `media_use_resolve_miss`. "Is the catalog covering needs." 4. **Provider mix** — `media_use_resolve` broken down by `provider`; a second tile by `via` (`url` / `params-fallback` / `params`) to catch CDN→params LUT downgrades. 5. **Top misses** — `media_use_resolve_miss` broken down by `type` (the tuning signal — pair with local `resolve --stats`, which also shows the missed _intents_ that telemetry deliberately omits). 6. **Doctor health** — `media_use_doctor_run` broken down by `failed[]` (which dependency check fails most) + `checks_failed` distribution. 7. **Compare cost** — `media_use_compare` by `command`, plus `truncated` / `render_ready_timed_out` rates (observe before lifting the 16-cell cap). 8. **Adoption (optional)** — if the `first_run` property ships (plan U5), segment `media_use_resolve` first-run vs repeat. ## Recreate in an analytics dashboard For each tile, confirm the event/property schema, build its trend or breakdown, then add it to a dashboard. Keep names prefixed `media-use:` so the dashboard is greppable. Cross-surface note: because identity is shared with CLI/studio, you can also break these down by the same person across `cli_command*` and `studio:*` events.
scripts/audio-duck.mjs#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { parseArgs } from "node:util";
import { duckKeyframes, speechSpans } from "./lib/duck.mjs";
import { track } from "./lib/telemetry.mjs";
const { values: args } = parseArgs({
options: {
meta: { type: "string" },
target: { type: "string" },
duck: { type: "string", default: "0.25" },
attack: { type: "string", default: "0.15" },
release: { type: "string", default: "0.4" },
"merge-gap": { type: "string", default: "0.6" },
sequential: { type: "boolean", default: false },
gap: { type: "string", default: "0" },
offsets: { type: "string" },
composition: { type: "string" },
json: { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
},
strict: true,
});
if (args.help) {
console.log(`media-use audio-duck — generate GSAP volume ducking keyframes
Usage:
node audio-duck.mjs --meta audio_meta.json --target "#bgm"
Options:
--meta audio_meta.json or JSON word transcript
--target GSAP selector for the background audio element
--duck Duck multiplier (default: 0.25)
--attack Duck-in duration seconds (default: 0.15)
--release Restore duration seconds (default: 0.4)
--merge-gap Bridge speech gaps smaller than this many seconds (default: 0.6)
--sequential Place multi-line meta back to back at composition time
--gap Extra seconds between sequential lines (default: 0)
--offsets Explicit placement, "l1=0,l2=3.4" (voice id = start seconds)
--composition Read target data-volume from this HTML file
--json Output { spans, keyframes }
--help, -h Show this help`);
process.exit(0);
}
try {
run();
await track("media_use_duck", { sequential: !!args.sequential });
} catch (err) {
if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
else console.error(`error: ${err.message}`);
process.exit(1);
}
function run() {
if (!args.meta || !args.target) throw new Error("--meta and --target are required");
const meta = JSON.parse(readFileSync(resolve(args.meta), "utf8"));
const target = args.target;
const baseVolume = readBaseVolume(args.composition, target);
const offsets = args.offsets
? Object.fromEntries(
args.offsets.split(",").map((pair) => {
const [id, t] = pair.split("=");
return [id.trim(), Number(t)];
}),
)
: undefined;
const spans = speechSpans(meta, {
mergeGap: Number(args["merge-gap"]),
sequential: args.sequential,
gap: Number(args.gap),
offsets,
});
const keyframes = duckKeyframes(spans, {
duck: Number(args.duck),
attack: Number(args.attack),
release: Number(args.release),
baseVolume,
});
if (args.json) {
console.log(JSON.stringify({ spans, keyframes }));
return;
}
console.log(
`// auto-duck: ${target} under narration (generated; base volume ${fmt(baseVolume)})`,
);
for (const keyframe of keyframes) {
console.log(
`tl.to(${JSON.stringify(target)}, { volume: ${fmt(keyframe.volume)}, duration: ${fmt(
keyframe.duration,
)} }, ${fmt(keyframe.time)});`,
);
}
}
function readBaseVolume(composition, target) {
if (!composition || !target.startsWith("#")) return 1;
const id = target.slice(1);
const html = readFileSync(resolve(composition), "utf8");
// ponytail: regex is enough here because this only reads one attribute from
// one user-authored composition element, not arbitrary HTML.
const tag = html.match(new RegExp(`<[^>]*\\bid=["']${escapeRegExp(id)}["'][^>]*>`, "i"))?.[0];
const raw = tag?.match(/\bdata-volume=["']([^"']+)["']/i)?.[1];
const volume = Number(raw);
return Number.isFinite(volume) ? volume : 1;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function fmt(n) {
return Number(n)
.toFixed(3)
.replace(/\.?0+$/, "");
}
scripts/dither.mjs#!/usr/bin/env node
import { execFileSync, spawn } from "node:child_process";
import { once } from "node:events";
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
import { dirname, extname, resolve } from "node:path";
import { text } from "node:stream/consumers";
import { parseArgs } from "node:util";
import {
ERROR_DIFFUSION_ALGORITHMS,
applyErrorDiffusionRgba,
errorDiffusionBufferLength,
} from "./lib/error-diffusion.mjs";
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"]);
const OUTPUT_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
const { values: args } = parseArgs({
options: {
input: { type: "string", short: "i" },
out: { type: "string", short: "o" },
algorithm: { type: "string", short: "a", default: "floyd-steinberg" },
palette: { type: "string", default: "#000000,#ffffff" },
"point-size": { type: "string", default: "3" },
brightness: { type: "string", default: "1" },
contrast: { type: "string", default: "1.2" },
detail: { type: "string", default: "1" },
json: { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
},
strict: true,
});
if (args.help) {
console.log(`media-use dither — exact cached error-diffusion for image or MP4 media
Usage:
node dither.mjs --input in.mp4 --out out.mp4 [options]
Options:
--algorithm, -a ${Object.keys(ERROR_DIFFUSION_ALGORITHMS).join(" | ")}
--palette 2-6 authored-order #rrggbb colors, comma-separated
--point-size Block size in pixels, 1-20 (default: 3)
--brightness 0.5-2 (default: 1)
--contrast 0.5-2 (default: 1.2)
--detail Diffused-error strength, 0.1-1 (default: 1)
--json Output JSON status
--help, -h Show this help
Video output uses the source average frame rate as CFR; VFR cadence is normalized.
After processing, register the output with:
node resolve.mjs --from <output> --type image|video`);
process.exit(0);
}
try {
const result = await run();
if (args.json) console.log(JSON.stringify({ ok: true, ...result }));
else {
console.log(`dithered ${result.input} -> ${result.out} (${result.algorithm})`);
console.log(`next: resolve --from ${result.out} --type ${result.type}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (args.json) console.log(JSON.stringify({ ok: false, error: message }));
else console.error(`error: ${message}`);
process.exit(1);
}
async function run() {
if (!args.input || !args.out) throw new Error("--input and --out are required");
const inputPath = resolve(args.input);
const outPath = resolve(args.out);
if (!existsSync(inputPath)) throw new Error(`input file not found: ${inputPath}`);
if (inputPath === outPath) throw new Error("--out must differ from --input");
const metadata = probe(inputPath);
if (metadata.colorTransfer === "smpte2084" || metadata.colorTransfer === "arib-std-b67") {
throw new Error(
`HDR ${metadata.colorTransfer} input is not supported by the 8-bit SDR dither processor; tone-map to Rec.709 first`,
);
}
const options = {
algorithm: args.algorithm,
palette: args.palette.split(",").map((color) => color.trim()),
pointSize: Number(args["point-size"]),
brightness: Number(args.brightness),
contrast: Number(args.contrast),
detail: Number(args.detail),
};
// Validate before starting FFmpeg or creating an output file.
applyErrorDiffusionRgba(new Uint8ClampedArray(4), 1, 1, options, new Float32Array(3));
mkdirSync(dirname(outPath), { recursive: true });
const inputIsImage = IMAGE_EXTENSIONS.has(extname(inputPath).toLowerCase());
if (inputIsImage) {
if (!OUTPUT_IMAGE_EXTENSIONS.has(extname(outPath).toLowerCase())) {
throw new Error("image output must use .png, .jpg, .jpeg, or .webp");
}
processImage(inputPath, outPath, metadata, options);
} else {
if (extname(outPath).toLowerCase() !== ".mp4") throw new Error("video output must use .mp4");
await processVideo(inputPath, outPath, metadata, options);
}
return {
input: inputPath,
out: outPath,
type: inputIsImage ? "image" : "video",
algorithm: options.algorithm,
palette: options.palette,
point_size: options.pointSize,
brightness: options.brightness,
contrast: options.contrast,
detail: options.detail,
};
}
function probe(filePath) {
const raw = execFileSync(
"ffprobe",
["-v", "error", "-print_format", "json", "-show_streams", "-show_format", "--", filePath],
{ encoding: "utf8", timeout: 10_000 },
);
const parsed = JSON.parse(raw);
const video = parsed.streams?.find((stream) => stream.codec_type === "video");
if (!video?.width || !video?.height)
throw new Error(`no readable video/image stream: ${filePath}`);
const fps = usableFrameRate(video.avg_frame_rate) ?? usableFrameRate(video.r_frame_rate) ?? "30";
return {
width: video.width,
height: video.height,
fps,
colorTransfer: video.color_transfer || "",
};
}
function processImage(inputPath, outPath, metadata, options) {
const frameBytes = metadata.width * metadata.height * 4;
const rgba = execFileSync(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-i",
inputPath,
"-frames:v",
"1",
"-f",
"rawvideo",
"-pix_fmt",
"rgba",
"-",
],
{ maxBuffer: frameBytes + 1024 },
);
if (rgba.length !== frameBytes)
throw new Error(`decoded ${rgba.length} bytes; expected ${frameBytes}`);
applyErrorDiffusionRgba(rgba, metadata.width, metadata.height, options);
const temporary = temporaryOutput(outPath);
try {
execFileSync(
"ffmpeg",
[
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"rawvideo",
"-pix_fmt",
"rgba",
"-s:v",
`${metadata.width}x${metadata.height}`,
"-i",
"-",
"-frames:v",
"1",
temporary,
],
{ input: rgba, maxBuffer: frameBytes + 1024 },
);
renameSync(temporary, outPath);
} finally {
rmSync(temporary, { force: true });
}
}
async function processVideo(inputPath, outPath, metadata, options) {
const temporary = temporaryOutput(outPath);
const frameBytes = metadata.width * metadata.height * 4;
const keyframeInterval = String(Math.max(1, Math.round(frameRateNumber(metadata.fps))));
const errors = new Float32Array(
errorDiffusionBufferLength(metadata.width, metadata.height, options.pointSize),
);
const decoder = spawn("ffmpeg", [
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-i",
inputPath,
"-map",
"0:v:0",
"-f",
"rawvideo",
"-pix_fmt",
"rgba",
"-",
]);
const encoder = spawn("ffmpeg", [
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"rawvideo",
"-pix_fmt",
"rgba",
"-s:v",
`${metadata.width}x${metadata.height}`,
"-r",
metadata.fps,
"-i",
"-",
"-i",
inputPath,
"-map",
"0:v:0",
"-map",
"1:a?",
"-map_metadata",
"1",
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"18",
"-g",
keyframeInterval,
"-keyint_min",
keyframeInterval,
"-sc_threshold",
"0",
"-pix_fmt",
"yuv420p",
"-x264-params",
"colorprim=bt709:transfer=bt709:colormatrix=bt709",
"-color_primaries:v",
"bt709",
"-color_trc:v",
"bt709",
"-colorspace:v",
"bt709",
"-color_range",
"tv",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
"-movflags",
"+faststart",
temporary,
]);
const decoderError = text(decoder.stderr);
const encoderError = text(encoder.stderr);
const decoderDone = once(decoder, "close").then(([code]) => code ?? 1);
const encoderDone = once(encoder, "close").then(([code]) => code ?? 1);
try {
const frame = Buffer.allocUnsafe(frameBytes);
let frameOffset = 0;
for await (const chunk of decoder.stdout) {
let chunkOffset = 0;
while (chunkOffset < chunk.length) {
const length = Math.min(frameBytes - frameOffset, chunk.length - chunkOffset);
chunk.copy(frame, frameOffset, chunkOffset, chunkOffset + length);
chunkOffset += length;
frameOffset += length;
if (frameOffset !== frameBytes) continue;
applyErrorDiffusionRgba(frame, metadata.width, metadata.height, options, errors);
await writeFrame(encoder.stdin, frame);
frameOffset = 0;
}
}
if (frameOffset)
throw new Error(`decoder returned a partial RGBA frame (${frameOffset} bytes)`);
encoder.stdin.end();
const [decoderCode, encoderCode] = await Promise.all([decoderDone, encoderDone]);
if (decoderCode !== 0) throw new Error(`FFmpeg decode failed: ${(await decoderError).trim()}`);
if (encoderCode !== 0) throw new Error(`FFmpeg encode failed: ${(await encoderError).trim()}`);
renameSync(temporary, outPath);
} catch (error) {
decoder.kill("SIGKILL");
encoder.kill("SIGKILL");
throw error;
} finally {
rmSync(temporary, { force: true });
}
}
function writeFrame(stream, frame) {
return new Promise((resolveWrite, reject) => {
stream.write(frame, (error) => (error ? reject(error) : resolveWrite()));
});
}
function usableFrameRate(value) {
if (!value || value === "0/0") return null;
const number = frameRateNumber(value);
return Number.isFinite(number) && number > 0 ? value : null;
}
function frameRateNumber(value) {
const [numerator, denominator = "1"] = value.split("/");
return Number(numerator) / Number(denominator);
}
function temporaryOutput(outPath) {
const extension = extname(outPath);
return `${outPath.slice(0, -extension.length)}.part-${process.pid}${extension}`;
}
scripts/dither.test.mjsimport assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const SCRIPT = fileURLToPath(new URL("./dither.mjs", import.meta.url));
const HAS_FFMPEG =
spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0 &&
spawnSync("ffprobe", ["-version"], { stdio: "ignore" }).status === 0;
function fixture() {
const dir = mkdtempSync(join(tmpdir(), "media-use-dither-"));
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
}
function ffmpeg(args) {
execFileSync("ffmpeg", ["-y", "-hide_banner", "-loglevel", "error", ...args]);
}
function run(args) {
return spawnSync(process.execPath, [SCRIPT, ...args, "--json"], { encoding: "utf8" });
}
test("processes an image into the requested ordered palette", { skip: !HAS_FFMPEG }, (t) => {
const { dir, cleanup } = fixture();
t.after(cleanup);
const input = join(dir, "source.png");
const output = join(dir, "dithered.png");
ffmpeg(["-f", "lavfi", "-i", "testsrc2=size=16x12:rate=1", "-frames:v", "1", input]);
const result = run([
"--input",
input,
"--out",
output,
"--algorithm",
"floyd-steinberg",
"--palette",
"#000000,#ffffff",
"--point-size",
"3",
]);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(JSON.parse(result.stdout).type, "image");
const rgb = execFileSync("ffmpeg", [
"-hide_banner",
"-loglevel",
"error",
"-i",
output,
"-frames:v",
"1",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-",
]);
for (let index = 0; index < rgb.length; index += 3) {
const value = rgb[index];
assert.ok(value === 0 || value === 255);
assert.equal(rgb[index + 1], value);
assert.equal(rgb[index + 2], value);
}
});
test("processes moving MP4 frames, audio, and BT.709 metadata", { skip: !HAS_FFMPEG }, (t) => {
const { dir, cleanup } = fixture();
t.after(cleanup);
const input = join(dir, "source.mp4");
const output = join(dir, "dithered.mp4");
ffmpeg([
"-f",
"lavfi",
"-i",
"testsrc2=size=16x12:rate=3:duration=2",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=3",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
input,
]);
const result = run([
"--input",
input,
"--out",
output,
"--algorithm",
"atkinson",
"--palette",
"#0f380f,#306230,#8bac0f,#9bbc0f",
]);
assert.equal(result.status, 0, result.stderr || result.stdout);
const probe = JSON.parse(
execFileSync(
"ffprobe",
["-v", "error", "-print_format", "json", "-show_streams", "-show_format", "--", output],
{
encoding: "utf8",
},
),
);
const video = probe.streams.find((stream) => stream.codec_type === "video");
const audio = probe.streams.find((stream) => stream.codec_type === "audio");
assert.equal(video.width, 16);
assert.equal(video.height, 12);
assert.equal(video.nb_frames, "6");
assert.equal(video.color_space, "bt709");
assert.equal(video.color_transfer, "bt709");
assert.equal(video.color_primaries, "bt709");
assert.equal(video.color_range, "tv");
assert.equal(audio.codec_name, "aac");
assert.ok(Number(probe.format.duration) < 2.5, "audio must not outlive processed video");
const keyframes = execFileSync(
"ffprobe",
[
"-v",
"error",
"-select_streams",
"v:0",
"-skip_frame",
"nokey",
"-show_entries",
"frame=best_effort_timestamp_time",
"-of",
"csv=p=0",
"--",
output,
],
{ encoding: "utf8" },
)
.trim()
.split(/\s+/)
.map((value) => Number.parseFloat(value));
assert.deepEqual(keyframes, [0, 1]);
});
test("rejects tagged PQ or HLG instead of silently producing SDR", { skip: !HAS_FFMPEG }, (t) => {
const { dir, cleanup } = fixture();
t.after(cleanup);
const input = join(dir, "hlg.mp4");
ffmpeg([
"-f",
"lavfi",
"-i",
"color=c=white:size=16x12:rate=1:duration=1",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p10le",
"-x264-params",
"colorprim=bt2020:transfer=arib-std-b67:colormatrix=bt2020nc",
"-color_primaries:v",
"bt2020",
"-color_trc:v",
"arib-std-b67",
"-colorspace:v",
"bt2020nc",
input,
]);
const result = run(["--input", input, "--out", join(dir, "wrong.mp4")]);
assert.equal(result.status, 1);
assert.match(JSON.parse(result.stdout).error, /HDR arib-std-b67 input is not supported/);
});
scripts/eval.mjs#!/usr/bin/env node
/**
* media-use eval — compare baseline (no media-use) vs. with media-use
* on real registry blocks. Produces an HTML report.
*/
import {
mkdtempSync,
cpSync,
rmSync,
readFileSync,
readdirSync,
existsSync,
writeFileSync,
} from "node:fs";
import { join, basename, resolve, dirname } from "node:path";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, "..", "..", "..");
const RESOLVE_SCRIPT = join(SCRIPT_DIR, "resolve.mjs");
const TEST_BLOCKS = [
"registry/blocks/nyc-paris-flight",
"registry/blocks/macos-tahoe-liquid-glass",
"registry/blocks/blue-sweater-intro-video",
"registry/blocks/vpn-youtube-spot",
"registry/blocks/apple-money-count",
"registry/blocks/liquid-glass-notification",
"registry/blocks/instagram-follow",
];
// Run resolve.mjs with args as a literal argv array (no shell), so values
// interpolated from manifest metadata (--intent prompt, --type) can't inject
// shell. Mirrors the execFileSync fix in probe.mjs / heygen-search.mjs.
function run(args, opts = {}) {
try {
return {
ok: true,
output: execFileSync(process.execPath, [RESOLVE_SCRIPT, ...args], {
encoding: "utf8",
timeout: 15000,
stdio: "pipe",
...opts,
}).trim(),
};
} catch (err) {
return { ok: false, output: (err.stdout || "") + (err.stderr || ""), code: err.status };
}
}
function countAssetFiles(dir) {
const assetsDir = join(dir, "assets");
if (!existsSync(assetsDir)) return { count: 0, files: [] };
const files = [];
function walk(d, base = "") {
for (const e of readdirSync(d, { withFileTypes: true })) {
const rel = base ? `${base}/${e.name}` : e.name;
if (e.isDirectory()) walk(join(d, e.name), rel);
else files.push(rel);
}
}
walk(assetsDir);
return { count: files.length, files };
}
function evalBlock(blockPath) {
const fullPath = join(REPO_ROOT, blockPath);
if (!existsSync(fullPath)) return null;
const name = basename(blockPath);
const tmp = mkdtempSync(join(tmpdir(), `mu-eval-${name}-`));
try {
cpSync(fullPath, tmp, { recursive: true });
// baseline: what the agent sees WITHOUT media-use
const baseline = countAssetFiles(tmp);
const htmlFiles = readdirSync(tmp).filter((f) => f.endsWith(".html"));
// parse compositions for asset references
const assetRefs = [];
for (const hf of htmlFiles) {
const html = readFileSync(join(tmp, hf), "utf8");
const srcMatches = html.matchAll(/src=["']([^"']+?)["']/g);
for (const m of srcMatches) {
const ref = m[1];
if (ref.startsWith("data:") || ref.startsWith("http")) continue;
assetRefs.push({ composition: hf, ref });
}
const urlMatches = html.matchAll(/url\(["']?([^"')]+?)["']?\)/g);
for (const m of urlMatches) {
const ref = m[1];
if (ref.startsWith("data:") || ref.startsWith("http") || ref.startsWith("#")) continue;
assetRefs.push({ composition: hf, ref });
}
}
// with media-use: run --adopt
const adoptResult = run(["--adopt", "--project", tmp, "--json"]);
let adopted = { ok: false, adopted: 0, assets: [] };
if (adoptResult.ok) {
try {
adopted = JSON.parse(adoptResult.output);
} catch {
/* */
}
}
// read the generated index
const indexPath = join(tmp, ".media", "index.md");
const indexContent = existsSync(indexPath)
? readFileSync(indexPath, "utf8")
: "(no index generated)";
// read manifest for detail
const manifestPath = join(tmp, ".media", "manifest.jsonl");
const manifest = existsSync(manifestPath)
? readFileSync(manifestPath, "utf8")
.trim()
.split("\n")
.map((l) => {
try {
return JSON.parse(l);
} catch {
return null;
}
})
.filter(Boolean)
: [];
// test resolve cache hit: try resolving something that was adopted
let resolveTest = null;
if (manifest.length > 0) {
const first = manifest[0];
const prompt = first.provenance?.prompt || first.description;
const r = run(["--type", first.type, "--intent", prompt, "--project", tmp, "--json"]);
if (r.ok) {
try {
resolveTest = JSON.parse(r.output);
} catch {
/* */
}
}
}
// test resolve miss: try resolving something that doesn't exist
const missResult = run([
"--type",
"bgm",
"--intent",
"nonexistent query xyz",
"--project",
tmp,
"--json",
]);
let resolveMiss = null;
if (!missResult.ok) {
try {
resolveMiss = JSON.parse(missResult.output);
} catch {
/* */
}
}
// coverage: which composition refs are covered by the manifest
const manifestPaths = new Set(manifest.map((m) => m.path));
const coverage = assetRefs.map((r) => ({
...r,
covered: manifestPaths.has(r.ref),
}));
return {
name,
baseline: { fileCount: baseline.count, files: baseline.files, htmlCount: htmlFiles.length },
compositions: htmlFiles,
assetRefs: coverage,
adopted: { count: adopted.adopted, assets: adopted.assets || [] },
index: indexContent,
manifest,
resolveTest,
resolveMiss,
};
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
function generateReport(results) {
const all = results.filter(Boolean);
const passed = all.filter((r) => r.adopted.count > 0);
const rows = results
.filter(Boolean)
.map((r) => {
const hasMetadata = r.manifest.some((m) => m.duration || m.width);
const cacheHit = r.resolveTest?._source === "cached";
const missHandled = r.resolveMiss?.ok === false;
return `<tr>
<td><strong>${r.name}</strong></td>
<td>${r.baseline.fileCount} files, ${r.baseline.htmlCount} comp${r.baseline.htmlCount === 1 ? "" : "s"}</td>
<td>${r.adopted.count} adopted</td>
<td>${hasMetadata ? "<span class='pass'>with metadata</span>" : "<span class='warn'>no metadata</span>"}</td>
<td>${cacheHit ? "<span class='pass'>cache hit</span>" : "<span class='warn'>no hit</span>"}</td>
<td>${missHandled ? "<span class='pass'>handled</span>" : "<span class='fail'>unexpected</span>"}</td>
</tr>`;
})
.join("\n");
const details = results
.filter(Boolean)
.filter((r) => r.adopted.count > 0)
.map((r) => {
const assetRows = r.manifest
.map((m) => {
const dur = m.duration != null ? `${m.duration}s` : "—";
const dims = m.width && m.height ? `${m.width}×${m.height}` : "—";
return `<tr><td>${m.id}</td><td>${m.type}</td><td>${dur}</td><td>${dims}</td><td class="path">${m.path}</td><td>${m.description || ""}</td></tr>`;
})
.join("\n");
const coveredCount = r.assetRefs.filter((c) => c.covered).length;
const totalRefs = r.assetRefs.length;
const coveragePct = totalRefs > 0 ? Math.round((coveredCount / totalRefs) * 100) : 100;
const refRows = r.assetRefs
.map(
(c) =>
`<tr><td class="path">${c.composition}</td><td class="path">${c.ref}</td><td>${c.covered ? "<span class='pass'>covered</span>" : "<span class='warn'>not in manifest</span>"}</td></tr>`,
)
.join("\n");
return `<div class="block-detail">
<h3>${r.name}</h3>
<p style="font-size:13px;color:var(--muted)">${r.compositions.length} composition${r.compositions.length === 1 ? "" : "s"}: ${r.compositions.join(", ")}</p>
<div class="comparison">
<div class="col">
<h4>Baseline (no media-use)</h4>
<p>Agent sees: ${r.baseline.fileCount} raw files in assets/<br>No metadata, no type info, no relationship to compositions.</p>
<pre class="file-list">${r.baseline.files.join("\n") || "(no assets)"}</pre>
</div>
<div class="col">
<h4>With media-use (after --adopt)</h4>
<p>Agent reads index.md — structured, typed, with metadata:</p>
<pre class="index">${escapeHtml(r.index)}</pre>
</div>
</div>
${
totalRefs > 0
? `<h4>Composition → asset coverage <span class="${coveragePct === 100 ? "pass" : "warn"}">${coveragePct}%</span> (${coveredCount}/${totalRefs} refs)</h4>
<table class="manifest">
<thead><tr><th>composition</th><th>asset reference</th><th>in manifest?</th></tr></thead>
<tbody>${refRows}</tbody>
</table>`
: ""
}
<h4>Manifest records</h4>
<table class="manifest">
<thead><tr><th>id</th><th>type</th><th>dur</th><th>dims</th><th>path</th><th>description</th></tr></thead>
<tbody>${assetRows}</tbody>
</table>
</div>`;
})
.join("\n");
return `<title>media-use eval report</title>
<style>
:root { --bg: #fafaf7; --text: #1b1b18; --muted: #7a756a; --accent: #0d7377; --good: #1a7a3a; --warn: #b45309; --fail: #dc2626; --border: #e8e5df; --surface: #fff; --mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; --sans: system-ui, -apple-system, sans-serif; --serif: Georgia, serif }
* { box-sizing: border-box; margin: 0 } body { background: var(--bg); color: var(--text); font-family: var(--serif); line-height: 1.6; font-size: 15px; padding: 40px 24px }
.wrap { max-width: 1100px; margin: 0 auto }
h1 { font-family: var(--sans); font-size: 28px; font-weight: 700; margin-bottom: 8px; letter-spacing: -.02em }
h2 { font-family: var(--sans); font-size: 20px; font-weight: 650; margin: 32px 0 12px; letter-spacing: -.01em }
h3 { font-family: var(--sans); font-size: 17px; font-weight: 650; margin: 24px 0 8px }
h4 { font-family: var(--sans); font-size: 14px; font-weight: 600; margin: 16px 0 6px; color: var(--muted) }
p { margin-bottom: 10px }
.meta { font-family: var(--mono); font-size: 12px; color: var(--muted); margin-bottom: 24px }
.summary { display: flex; gap: 16px; margin: 16px 0; flex-wrap: wrap }
.stat { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 18px; flex: 1; min-width: 140px }
.stat .num { font-family: var(--sans); font-size: 28px; font-weight: 700; color: var(--accent) }
.stat .label { font-family: var(--mono); font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .1em }
table { width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--sans); margin: 8px 0 }
th { text-align: left; font-family: var(--mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); border-bottom: 2px solid var(--border); padding: 6px 8px; font-weight: 700 }
td { border-bottom: 1px solid var(--border); padding: 7px 8px; vertical-align: top }
td.path { font-family: var(--mono); font-size: 12px; color: var(--muted); max-width: 300px; overflow: hidden; text-overflow: ellipsis }
.pass { color: var(--good); font-weight: 600 } .warn { color: var(--warn); font-weight: 600 } .fail { color: var(--fail); font-weight: 600 }
.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 12px 0 }
@media(max-width:700px) { .comparison { grid-template-columns: 1fr } }
.col { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px }
.col h4 { margin-top: 0 }
pre { font-family: var(--mono); font-size: 12px; background: #1b1b18; color: #d4d0c8; border-radius: 6px; padding: 12px 14px; overflow-x: auto; margin: 6px 0; line-height: 1.5 }
pre.file-list { background: var(--bg); color: var(--muted); border: 1px solid var(--border) }
pre.index { white-space: pre; }
.block-detail { border-top: 1px solid var(--border); padding-top: 20px; margin-top: 20px }
.verdict { margin-top: 24px; padding: 16px 20px; border-radius: 8px; font-family: var(--sans); font-size: 15px }
.verdict.ship { background: #edfbf0; border: 1px solid #1a7a3a; color: #1a7a3a }
.verdict.wait { background: #fff3ec; border: 1px solid #d94f04; color: #d94f04 }
</style>
<div class="wrap">
<h1>media-use eval report</h1>
<p class="meta">${new Date().toISOString().slice(0, 10)} · ${all.length} blocks evaluated · baseline vs. media-use --adopt</p>
<div class="summary">
<div class="stat"><div class="num">${all.length}</div><div class="label">blocks tested</div></div>
<div class="stat"><div class="num">${passed.length}</div><div class="label">with assets</div></div>
<div class="stat"><div class="num">${all.reduce((s, r) => s + r.adopted.count, 0)}</div><div class="label">assets adopted</div></div>
<div class="stat"><div class="num">${all.filter((r) => r.manifest.some((m) => m.duration || m.width)).length}</div><div class="label">with ffprobe metadata</div></div>
<div class="stat"><div class="num">${(() => {
const refs = all.flatMap((r) => r.assetRefs);
const covered = refs.filter((c) => c.covered).length;
return refs.length > 0 ? Math.round((covered / refs.length) * 100) + "%" : "—";
})()}</div><div class="label">composition coverage</div></div>
</div>
<h2>Results matrix</h2>
<table>
<thead><tr><th>Block</th><th>Baseline</th><th>Adopted</th><th>Metadata</th><th>Cache hit</th><th>Miss handling</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<h2>Before / after comparisons</h2>
${details}
<div class="verdict ${passed.length >= 3 ? "ship" : "wait"}">
${
passed.length >= 3
? `<strong>Ship it.</strong> ${passed.length}/${all.length} blocks adopted successfully with metadata. Resolve cache hits work. Miss handling is clean.`
: `<strong>Needs work.</strong> Only ${passed.length} blocks adopted. Check the failures above.`
}
</div>
</div>`;
}
function escapeHtml(str) {
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
console.log("media-use eval · running against registry blocks...\n");
const results = [];
for (const block of TEST_BLOCKS) {
const fullPath = join(REPO_ROOT, block);
if (!existsSync(fullPath)) {
console.log(` skip ${basename(block)} (not found)`);
results.push(null);
continue;
}
process.stdout.write(` ${basename(block)}...`);
const result = evalBlock(block);
if (result) {
console.log(
` ${result.adopted.count} adopted, ${result.manifest.filter((m) => m.duration || m.width).length} with metadata`,
);
} else {
console.log(" failed");
}
results.push(result);
}
const report = generateReport(results);
const outPath = join(SCRIPT_DIR, "..", "eval-report.html");
writeFileSync(outPath, report);
console.log(`\nReport: ${outPath}`);
scripts/lib/adopt.mjsimport { readdirSync, statSync, existsSync } from "node:fs";
import { join, extname, basename } from "node:path";
import { readManifest, appendRecord, nextId } from "./manifest.mjs";
import { regenerateIndex } from "./index-gen.mjs";
import { probe } from "./probe.mjs";
import { matchTokens } from "./match.mjs";
const AUDIO_EXT = new Set([".mp3", ".wav", ".ogg", ".m4a", ".aac"]);
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico"]);
const VIDEO_EXT = new Set([".mp4", ".webm", ".mov"]);
function inferType(filePath) {
const ext = extname(filePath).toLowerCase();
if (AUDIO_EXT.has(ext)) {
const lower = filePath.toLowerCase();
if (lower.includes("/bgm/") || lower.includes("/music/") || lower.startsWith("bgm/"))
return "bgm";
if (lower.includes("/sfx/") || lower.includes("/sound") || lower.startsWith("sfx/"))
return "sfx";
if (lower.includes("/voice/") || lower.includes("/narrat") || lower.startsWith("voice/"))
return "voice";
return "bgm";
}
if (IMAGE_EXT.has(ext)) {
if (ext === ".svg" || ext === ".ico") return "icon";
return "image";
}
if (VIDEO_EXT.has(ext)) return "video";
return null;
}
function walkDir(dir, base = "") {
const files = [];
if (!existsSync(dir)) return files;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const rel = base ? `${base}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
files.push(...walkDir(join(dir, entry.name), rel));
} else {
files.push(rel);
}
}
return files;
}
export function scanExistingAssets(projectDir) {
const assetsDir = join(projectDir, "assets");
if (!existsSync(assetsDir)) return [];
const files = walkDir(assetsDir);
const found = [];
for (const rel of files) {
const type = inferType(rel);
if (!type) continue;
const fullPath = join(assetsDir, rel);
const stat = statSync(fullPath);
if (stat.size === 0) {
// A 0-byte asset would register clean but fail at render — skip it loudly
// rather than adopt a broken file.
console.error(`media-use: skipping 0-byte asset assets/${rel}`);
continue;
}
const meta = probe(fullPath);
found.push({
relativePath: `assets/${rel}`,
type,
size: stat.size,
name: basename(rel, extname(rel)),
...meta,
});
}
return found;
}
export function adoptExistingAssets(projectDir) {
const existing = scanExistingAssets(projectDir);
if (existing.length === 0) return [];
const manifest = readManifest(projectDir);
const knownPaths = new Set(manifest.map((r) => r.path));
const adopted = [];
for (const asset of existing) {
if (knownPaths.has(asset.relativePath)) continue;
const id = nextId(projectDir, asset.type);
const record = {
id,
type: asset.type,
path: asset.relativePath,
source: "existing",
description: asset.name.replace(/[-_]/g, " "),
...(asset.duration != null && { duration: asset.duration }),
...(asset.width != null && { width: asset.width }),
...(asset.height != null && { height: asset.height }),
provenance: { provider: "local", adopted: true },
};
appendRecord(projectDir, record);
adopted.push(record);
}
if (adopted.length > 0) regenerateIndex(projectDir);
return adopted;
}
// Adopt a pre-existing assets/ file only when it shares a meaningful word with
// the intent. The old test — `name.includes(intent) || intent.includes(name)` —
// silently returned the WRONG file: "whoosh" grabbed a stray who.mp3, and a
// one-letter filename matched every intent. A false negative just falls through
// to a catalog search (safe); a false positive ships the wrong asset. So bias to
// precision: require a shared token, don't guess from substrings.
export function findExistingAsset(projectDir, intent, type) {
const assetsDir = join(projectDir, "assets");
if (!existsSync(assetsDir)) return null;
const intentTokens = matchTokens(intent);
if (intentTokens.size === 0) return null;
for (const rel of walkDir(assetsDir)) {
const t = inferType(rel);
if (!t || (type && t !== type)) continue;
const stem = basename(rel, extname(rel));
for (const tok of matchTokens(stem)) {
if (intentTokens.has(tok)) {
return { relativePath: `assets/${rel}`, type: t, name: stem };
}
}
}
return null;
}
scripts/lib/adopt.test.mjsimport { strict as assert } from "node:assert";
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { findExistingAsset, adoptExistingAssets } from "./adopt.mjs";
let tmp;
function setup() {
tmp = mkdtempSync(join(tmpdir(), "mu-adopt-test-"));
}
function cleanup() {
if (tmp) rmSync(tmp, { recursive: true, force: true });
}
function drop(rel) {
const full = join(tmp, "assets", rel);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, "x");
}
function runTests() {
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
test("does NOT false-match a short filename stem (who.mp3 vs 'whoosh')", () => {
setup();
drop("sfx/who.mp3");
assert.equal(findExistingAsset(tmp, "whoosh", "sfx"), null);
cleanup();
});
test("does NOT match a one-letter filename against any intent", () => {
setup();
drop("images/a.jpg");
assert.equal(findExistingAsset(tmp, "gradient tech background", "image"), null);
cleanup();
});
test("matches on a shared meaningful word", () => {
setup();
drop("images/hero-shot.jpg");
const hit = findExistingAsset(tmp, "hero image", "image");
assert.ok(hit);
assert.equal(hit.relativePath, "assets/images/hero-shot.jpg");
cleanup();
});
test("matches multi-word overlap", () => {
setup();
drop("images/gradient-tech-bg.jpg");
assert.ok(findExistingAsset(tmp, "gradient tech background", "image"));
cleanup();
});
test("a shared stopword alone does not match", () => {
setup();
drop("video/the-video.mp4");
assert.equal(findExistingAsset(tmp, "the rocket", null), null);
cleanup();
});
test("respects the type filter", () => {
setup();
drop("sfx/rocket.mp3");
assert.equal(findExistingAsset(tmp, "rocket", "image"), null, "wrong type is skipped");
assert.ok(findExistingAsset(tmp, "rocket", "sfx"), "right type matches");
cleanup();
});
test("adoptExistingAssets still imports every typed file (unaffected by match rule)", () => {
setup();
drop("sfx/who.mp3");
drop("images/a.jpg");
assert.equal(adoptExistingAssets(tmp).length, 2);
cleanup();
});
let passed = 0;
let failed = 0;
for (const { name, fn } of tests) {
try {
fn();
passed++;
console.log(` \x1b[32m✓\x1b[0m ${name}`);
} catch (err) {
failed++;
console.log(` \x1b[31m✗\x1b[0m ${name}`);
console.log(` ${err.message}`);
}
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
console.log("media-use · adopt / findExistingAsset tests\n");
runTests();
scripts/lib/bgm-provider.mjsimport { heygenSearch } from "./heygen-search.mjs";
export const bgmProvider = {
async search(intent) {
const results = heygenSearch("audio sounds list", intent, { type: "music" });
if (!results) return null;
const best = results[0];
return {
url: best.audio_url,
source: "search",
// ext derived from audio_url by resolve.mjs — catalog tracks are .mp3 or .wav
metadata: {
description: best.description || intent,
duration: best.duration || null,
provider: "heygen.audio.sounds",
provenance: { track_id: best.id, score: best.score, query: intent },
},
};
},
};
scripts/lib/brand-provider.mjsimport { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
function findDesignSpec(projectDir) {
for (const name of ["frame.md", "design.md", "DESIGN.md"]) {
const p = join(projectDir, name);
if (existsSync(p)) return { path: p, name };
}
return null;
}
function parseFrontmatter(content) {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return null;
const yaml = match[1];
const tokens = {};
for (const line of yaml.split("\n")) {
const m = line.match(/^\s*(\w[\w-]*):\s*(.+)/);
if (m) tokens[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
}
return tokens;
}
function extractColors(tokens) {
const colors = [];
for (const [k, v] of Object.entries(tokens)) {
if (typeof v === "string" && /^#[0-9a-fA-F]{3,8}$/.test(v)) {
colors.push({ name: k, hex: v });
}
}
return colors;
}
export const brandProvider = {
async search(intent, { projectDir } = {}) {
if (!projectDir) return null;
const spec = findDesignSpec(projectDir);
if (!spec) return null;
const content = readFileSync(spec.path, "utf8");
const tokens = parseFrontmatter(content);
if (!tokens) return null;
const colors = extractColors(tokens);
return {
localPath: spec.path,
source: "local",
ext: ".md",
metadata: {
description: "Brand tokens from " + spec.name,
provider: "design_spec",
provenance: {
file: spec.name,
colors,
font: tokens.font || tokens.typography || null,
logo: tokens.logo || null,
},
},
};
},
};
scripts/lib/bundled-sfx-provider.mjsimport { existsSync, readFileSync } from "node:fs";
import { extname, join } from "node:path";
const LIB_DIR =
process.env.HYPERFRAMES_MEDIA_USE_SFX_DIR ||
join(import.meta.dirname, "..", "..", "audio", "assets", "sfx");
export const BUNDLED_SFX_RECOVERY_COMMAND = "npx hyperframes skills update media-use";
export class BundledSfxAssetsError extends Error {
constructor(health) {
super(
`bundled SFX assets are missing or incomplete (${health.detail}). Repair the installed media-use skill: ${health.fix}`,
);
this.name = "BundledSfxAssetsError";
this.code = health.code;
this.fix = health.fix;
}
}
function unhealthy(detail) {
return {
ok: false,
code: "bundled_sfx_assets_missing",
detail,
fix: BUNDLED_SFX_RECOVERY_COMMAND,
};
}
export function inspectBundledSfxAssets(libraryDir = LIB_DIR) {
const manifestPath = join(libraryDir, "manifest.json");
if (!existsSync(manifestPath)) return unhealthy(`manifest not found: ${manifestPath}`);
let manifest;
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
} catch {
return unhealthy(`manifest is not valid JSON: ${manifestPath}`);
}
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
return unhealthy(`manifest must contain an object: ${manifestPath}`);
}
const entries = Object.entries(manifest);
if (entries.length === 0) return unhealthy(`manifest contains no SFX entries: ${manifestPath}`);
for (const [key, entry] of entries) {
if (!entry?.file || typeof entry.file !== "string") {
return unhealthy(`manifest entry "${key}" has no file`);
}
const assetPath = join(libraryDir, entry.file);
if (!existsSync(assetPath)) return unhealthy(`asset not found: ${assetPath}`);
}
return {
ok: true,
count: entries.length,
detail: `${entries.length} bundled SFX asset${entries.length === 1 ? "" : "s"} available`,
fix: "",
};
}
const normalize = (value) =>
String(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
export function extensionForBundledSfxFile(filename) {
return extname(filename) || ".mp3";
}
function score(intent, key, entry) {
const query = normalize(intent);
const name = normalize(key);
if (query === name) return 100;
if (query.includes(name) || name.includes(query)) return 50;
const haystack = new Set(normalize(`${key} ${entry.description || ""}`).split(/\s+/));
return query.split(/\s+/).filter((token) => token && haystack.has(token)).length;
}
export const bundledSfxProvider = {
async search(intent, ctx = {}) {
const libraryDir = ctx.libraryDir || LIB_DIR;
const health = inspectBundledSfxAssets(libraryDir);
if (!health.ok) throw new BundledSfxAssetsError(health);
const manifest = JSON.parse(readFileSync(join(libraryDir, "manifest.json"), "utf8"));
const ranked = Object.entries(manifest)
.map(([key, entry]) => ({ key, entry, score: score(intent, key, entry) }))
.filter(({ entry, score }) => entry?.file && score > 0)
.sort((a, b) => b.score - a.score || a.key.localeCompare(b.key));
const best = ranked[0];
if (!best) return null;
const localPath = join(libraryDir, best.entry.file);
return {
localPath,
ext: extensionForBundledSfxFile(best.entry.file),
source: "bundled",
metadata: {
description: best.entry.description || best.key,
duration: best.entry.duration ?? null,
provider: "bundled.sfx",
provenance: { library_key: best.key },
},
};
},
};
scripts/lib/bundled-sfx-provider.test.mjsimport { strict as assert } from "node:assert";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import {
BUNDLED_SFX_RECOVERY_COMMAND,
BundledSfxAssetsError,
bundledSfxProvider,
extensionForBundledSfxFile,
inspectBundledSfxAssets,
} from "./bundled-sfx-provider.mjs";
test("derives bundled SFX extension from the manifest filename", () => {
assert.equal(extensionForBundledSfxFile("impact.wav"), ".wav");
assert.equal(extensionForBundledSfxFile("whoosh.ogg"), ".ogg");
assert.equal(extensionForBundledSfxFile("extensionless"), ".mp3");
});
test("reports an agent-friendly recovery when the bundled SFX manifest is absent", () => {
const libraryDir = mkdtempSync(join(tmpdir(), "media-use-sfx-missing-"));
try {
const health = inspectBundledSfxAssets(libraryDir);
assert.equal(health.ok, false);
assert.equal(health.code, "bundled_sfx_assets_missing");
assert.match(health.detail, /manifest\.json/);
assert.match(health.fix, /hyperframes skills update media-use/);
assert.equal(health.fix, BUNDLED_SFX_RECOVERY_COMMAND);
} finally {
rmSync(libraryDir, { recursive: true, force: true });
}
});
test("reports the exact missing file from an incomplete bundled SFX install", () => {
const libraryDir = mkdtempSync(join(tmpdir(), "media-use-sfx-incomplete-"));
try {
writeFileSync(
join(libraryDir, "manifest.json"),
JSON.stringify({ whoosh: { file: "whoosh.mp3", description: "transition" } }),
);
const health = inspectBundledSfxAssets(libraryDir);
assert.equal(health.ok, false);
assert.equal(health.code, "bundled_sfx_assets_missing");
assert.match(health.detail, /whoosh\.mp3/);
} finally {
rmSync(libraryDir, { recursive: true, force: true });
}
});
test("bundled provider raises a typed install error instead of a generic catalog miss", async () => {
const libraryDir = mkdtempSync(join(tmpdir(), "media-use-sfx-provider-"));
try {
await assert.rejects(
() => bundledSfxProvider.search("whoosh", { libraryDir }),
(error) => {
assert.ok(error instanceof BundledSfxAssetsError);
assert.equal(error.code, "bundled_sfx_assets_missing");
assert.match(error.message, /hyperframes skills update media-use/);
return true;
},
);
} finally {
rmSync(libraryDir, { recursive: true, force: true });
}
});
test("accepts a complete bundled SFX library", () => {
const libraryDir = mkdtempSync(join(tmpdir(), "media-use-sfx-complete-"));
try {
mkdirSync(libraryDir, { recursive: true });
writeFileSync(
join(libraryDir, "manifest.json"),
JSON.stringify({ whoosh: { file: "whoosh.mp3", description: "transition" } }),
);
writeFileSync(join(libraryDir, "whoosh.mp3"), "audio");
assert.deepEqual(inspectBundledSfxAssets(libraryDir), {
ok: true,
count: 1,
detail: "1 bundled SFX asset available",
fix: "",
});
} finally {
rmSync(libraryDir, { recursive: true, force: true });
}
});
scripts/lib/cache.mjsimport { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
import { join, basename } from "node:path";
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { readManifest, appendRecord, normalizePrompt } from "./manifest.mjs";
const SCHEMA_PREFIX = "mu-v1-";
const KEY_HEX_CHARS = 16;
const COMPLETE_SENTINEL = ".hf-complete";
export function globalMediaDir() {
return join(homedir(), ".media");
}
export function contentHash(filePath) {
const bytes = readFileSync(filePath);
return createHash("sha256").update(bytes).digest("hex");
}
function cacheEntryDir(rootDir, sha) {
return join(rootDir, SCHEMA_PREFIX + sha.slice(0, KEY_HEX_CHARS));
}
function isComplete(entryDir) {
return existsSync(join(entryDir, COMPLETE_SENTINEL));
}
function markComplete(entryDir) {
writeFileSync(join(entryDir, COMPLETE_SENTINEL), "", "utf8");
}
// The manifest helpers append their own ".media" to the dir they get, so the
// global manifest must be addressed by HOME, not by globalMediaDir() — passing
// the latter nested it at ~/.media/.media/manifest.jsonl, invisible to the
// Studio /api/assets/global route (which reads the documented flat path).
export function readGlobalManifest() {
return readManifest(homedir());
}
// Resolve a content-sha (full or unambiguous prefix) to a reusable global-cache
// record, for `resolve --reuse <sha>`. Returns null on no match, or
// { ambiguous: true, count } when a prefix matches multiple distinct entries.
// Completeness (the .hf-complete sentinel) is left to importFromCache so the
// caller can surface an "incomplete cache entry" error distinctly from a miss.
export function findGlobalBySha(shaPrefix) {
const p = String(shaPrefix || "")
.toLowerCase()
.trim();
if (!p) return null;
const matches = readGlobalManifest().filter(
(r) => r.reusable && typeof r.sha === "string" && r.sha.startsWith(p),
);
if (matches.length === 0) return null;
if (matches.length > 1) {
const exact = matches.find((r) => r.sha === p);
if (exact) return exact;
return { ambiguous: true, count: matches.length };
}
return matches[0];
}
function validateCacheHit(match) {
if (!match?.sha) return null;
return isComplete(cacheEntryDir(globalMediaDir(), match.sha)) ? match : null;
}
export function cacheGet(prompt, type) {
const key = normalizePrompt(prompt);
if (!key) return null;
return validateCacheHit(
readGlobalManifest().find(
(r) =>
r.reusable &&
normalizePrompt(r.provenance?.prompt) === key &&
(type == null || r.type === type),
),
);
}
export function cacheGetByEntity(entity) {
const lower = entity.toLowerCase();
return validateCacheHit(
readGlobalManifest().find((r) => r.reusable && r.entity && r.entity.toLowerCase() === lower),
);
}
export function cachePut(filePath, record) {
const sha = contentHash(filePath);
// Idempotent: same content already promoted -> don't duplicate the global
// record. ponytail: skips usage_count bump; add it when the metric is needed.
const existing = readGlobalManifest().find((r) => r.sha === sha);
if (existing) return { sha, cached_path: existing.cached_path, deduped: true };
const dir = globalMediaDir();
const entryDir = cacheEntryDir(dir, sha);
mkdirSync(entryDir, { recursive: true });
const dest = join(entryDir, basename(filePath));
copyFileSync(filePath, dest);
markComplete(entryDir);
const globalRecord = {
...record,
sha,
reusable: true,
cached_path: dest,
};
appendRecord(homedir(), globalRecord);
return { sha, cached_path: dest };
}
export function importFromCache(cacheRecord, projectDir, localId, localPath) {
const sha = cacheRecord.sha;
const entryDir = cacheEntryDir(globalMediaDir(), sha);
if (!isComplete(entryDir)) return null;
const cachedFile = cacheRecord.cached_path;
if (!cachedFile || !existsSync(cachedFile)) return null;
mkdirSync(join(projectDir, ".media"), { recursive: true });
const fullDest = join(projectDir, localPath);
mkdirSync(join(fullDest, ".."), { recursive: true });
copyFileSync(cachedFile, fullDest);
const projectRecord = {
...cacheRecord,
id: localId,
path: localPath,
provenance: {
...cacheRecord.provenance,
imported_from: sha,
},
};
delete projectRecord.sha;
delete projectRecord.reusable;
delete projectRecord.cached_path;
return projectRecord;
}
export function promote(projectDir, id) {
const records = readManifest(projectDir);
const record = records.find((r) => r.id === id);
if (!record) throw new Error(`asset not found in project manifest: ${id}`);
const filePath = join(projectDir, record.path);
if (!existsSync(filePath)) throw new Error(`asset file not found: ${filePath}`);
return cachePut(filePath, record);
}
scripts/lib/candidates.mjs// Reuse candidates: a side-effect-free view of assets already available to this
// project (its own manifest) and across every project (the global ~/.media
// cache), so the calling agent can judge semantic fit itself. No download, no
// provider, no mutation. The ranker only *surfaces* — it orders by lexical
// overlap but never filters a candidate out on zero overlap (that would
// pre-empt the agent's judgment); the agent does the semantic call.
import { readManifest } from "./manifest.mjs";
import { readGlobalManifest } from "./cache.mjs";
import { tokenOverlap, typesMatch } from "./match.mjs";
export const CANDIDATE_CAP = 8;
function shape(record, scope, intent) {
const description = record.description || record.provenance?.prompt || "";
const prompt = record.provenance?.prompt || null;
return {
id: record.id,
type: record.type,
scope,
description,
prompt,
provider: record.provenance?.provider || null,
duration: record.duration ?? null,
width: record.width ?? null,
height: record.height ?? null,
// Only global records carry a content sha — it is the stable reuse handle
// for `resolve --reuse <sha>`. Project assets are reused by referencing
// their path directly, so they need no handle.
sha: scope === "global" ? record.sha || null : null,
path: scope === "project" ? record.path : null,
score: intent ? tokenOverlap(intent, `${description} ${prompt || ""}`) : 0,
};
}
// Rank one scope: type-matched (icon<->image aware), newest-first within equal
// overlap, ordered by overlap desc. Returns the full ranked list (uncapped).
function rankScope(records, scope, type, intent) {
return records
.filter((r) => typesMatch(r.type, type))
.reverse() // manifest is append-order (oldest first); newest-first at equal score
.map((r) => shape(r, scope, intent))
.sort((a, b) => b.score - a.score); // Array.sort is stable → recency preserved
}
// List reuse candidates for `type`, capped per scope. Returns:
// candidates: capped project candidates followed by capped global candidates
// truncated: true if either scope had more than `cap`
// total: { project, global } counts before the cap (machine-readable)
// similar: count of candidates with lexical overlap > 0 (drives the nudge)
export function listCandidates({ projectDir, type, intent = "", cap = CANDIDATE_CAP }) {
const project = rankScope(readManifest(projectDir), "project", type, intent);
const global = rankScope(readGlobalManifest(), "global", type, intent);
const candidates = [...project.slice(0, cap), ...global.slice(0, cap)];
return {
candidates,
truncated: project.length > cap || global.length > cap,
total: { project: project.length, global: global.length },
similar: [...project, ...global].filter((c) => c.score > 0).length,
};
}
function meta(c) {
const parts = [];
if (c.duration != null) parts.push(`${c.duration}s`);
if (c.width && c.height) parts.push(`${c.width}x${c.height}`);
if (c.provider) parts.push(c.provider);
return parts.join(", ");
}
// Human-readable listing. The agent can read this directly; --json is for
// programmatic use. Reuse handle differs by scope: path for project, sha for
// global.
export function formatCandidates(candidates, { truncated, total } = {}) {
if (candidates.length === 0) return "no reuse candidates found (project or global cache)";
const lines = [`${candidates.length} reuse candidate${candidates.length === 1 ? "" : "s"}:`, ""];
for (const c of candidates) {
const handle =
c.scope === "global" ? `--reuse ${String(c.sha).slice(0, 16)}` : c.path || `manifest:${c.id}`;
const m = meta(c);
lines.push(` [${c.scope}] ${c.description}${m ? ` (${m})` : ""}`);
lines.push(` ${handle}`);
}
if (truncated && total) {
lines.push("");
lines.push(
` (showing top ${CANDIDATE_CAP} per scope; ${total.project} project / ${total.global} global total — refine --intent to narrow)`,
);
}
return lines.join("\n");
}
scripts/lib/candidates.test.mjsimport { test } from "node:test";
import { strict as assert } from "node:assert";
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { listCandidates, formatCandidates, CANDIDATE_CAP } from "./candidates.mjs";
import { findGlobalBySha } from "./cache.mjs";
// candidates + findGlobalBySha are offline (no heygen), so we can override HOME
// to a temp dir and seed a fake global ~/.media manifest deterministically.
function sandbox() {
const root = mkdtempSync(join(tmpdir(), "mu-cand-"));
const project = join(root, "proj");
const home = join(root, "home");
process.env.HOME = home;
return { root, project, home };
}
function seedManifest(dir, records) {
const md = join(dir, ".media");
mkdirSync(md, { recursive: true });
writeFileSync(md + "/manifest.jsonl", records.map((r) => JSON.stringify(r)).join("\n") + "\n");
}
function proj(id, type, description, prompt) {
return { id, type, path: `.media/audio/bgm/${id}.wav`, description, provenance: { prompt } };
}
function glob(id, type, description, prompt, sha) {
return {
id,
type,
sha,
reusable: true,
cached_path: `/x/${sha}/${id}.wav`,
description,
provenance: { prompt, provider: "heygen.audio.sounds" },
};
}
test("ranks project + global by overlap, tags scope", () => {
const { root, project, home } = sandbox();
try {
seedManifest(project, [proj("bgm_001", "bgm", "calm ambient piano", "calm ambient piano")]);
seedManifest(home, [
glob("bgm_009", "bgm", "energetic tech launch", "energetic tech launch", "a".repeat(64)),
glob("bgm_010", "bgm", "sad corporate piano", "sad corporate piano", "b".repeat(64)),
]);
const { candidates } = listCandidates({
projectDir: project,
type: "bgm",
intent: "tech launch",
});
assert.equal(candidates[0].scope, "project"); // project listed first
const g = candidates.filter((c) => c.scope === "global");
assert.equal(g[0].description, "energetic tech launch"); // higher overlap ranks first
assert.ok(g[0].score >= g[1].score);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("zero-overlap intent still lists candidates (no hard filter)", () => {
const { root, project, home } = sandbox();
try {
seedManifest(project, []);
seedManifest(home, [glob("bgm_009", "bgm", "driving synth", "driving synth", "c".repeat(64))]);
const { candidates, similar } = listCandidates({
projectDir: project,
type: "bgm",
intent: "totally unrelated words xyz",
});
assert.equal(candidates.length, 1, "listed despite zero overlap");
assert.equal(candidates[0].score, 0);
assert.equal(similar, 0, "similar counts only overlap>0");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("caps per scope and reports truncation + totals", () => {
const { root, project, home } = sandbox();
try {
const many = Array.from({ length: CANDIDATE_CAP + 3 }, (_, i) =>
glob(`bgm_${i}`, "bgm", `track ${i}`, `track ${i}`, String(i).padStart(64, "0")),
);
seedManifest(project, []);
seedManifest(home, many);
const { candidates, truncated, total } = listCandidates({ projectDir: project, type: "bgm" });
assert.equal(candidates.length, CANDIDATE_CAP);
assert.equal(truncated, true);
assert.equal(total.global, CANDIDATE_CAP + 3);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("honors icon<->image adjacency", () => {
const { root, project, home } = sandbox();
try {
seedManifest(project, []);
seedManifest(home, [glob("image_1", "image", "rocket logo", "rocket logo", "d".repeat(64))]);
const { candidates } = listCandidates({ projectDir: project, type: "icon", intent: "rocket" });
assert.equal(candidates.length, 1, "image asset surfaces for icon request");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("sha only on global, path only on project", () => {
const { root, project, home } = sandbox();
try {
seedManifest(project, [proj("bgm_001", "bgm", "x", "x")]);
seedManifest(home, [glob("bgm_009", "bgm", "y", "y", "e".repeat(64))]);
const { candidates } = listCandidates({ projectDir: project, type: "bgm" });
const p = candidates.find((c) => c.scope === "project");
const g = candidates.find((c) => c.scope === "global");
assert.ok(p.path && !p.sha);
assert.ok(g.sha && !g.path);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("findGlobalBySha resolves unique prefix, flags ambiguity, misses cleanly", () => {
const { root, project, home } = sandbox();
try {
seedManifest(project, []);
seedManifest(home, [
glob("bgm_1", "bgm", "a", "a", "abc" + "0".repeat(61)),
glob("bgm_2", "bgm", "b", "b", "abd" + "0".repeat(61)),
glob("bgm_3", "bgm", "c", "c", "fff" + "0".repeat(61)),
]);
assert.equal(findGlobalBySha("fff").id, "bgm_3", "unique prefix resolves");
assert.deepEqual(
{ ambiguous: findGlobalBySha("ab").ambiguous, count: findGlobalBySha("ab").count },
{ ambiguous: true, count: 2 },
"ambiguous prefix flagged",
);
assert.equal(findGlobalBySha("zzz"), null, "miss returns null");
assert.equal(findGlobalBySha(""), null, "empty returns null");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("formatCandidates shows reuse handles by scope; empty message", () => {
const { candidates } = {
candidates: [
{ scope: "project", description: "p", path: ".media/audio/bgm/bgm_001.wav" },
{ scope: "global", description: "g", sha: "f".repeat(64) },
],
};
const out = formatCandidates(candidates, {});
assert.match(out, /\.media\/audio\/bgm\/bgm_001\.wav/);
assert.match(out, /--reuse ffffffffffffffff/);
assert.match(formatCandidates([], {}), /no reuse candidates/);
});
scripts/lib/codex-provider.mjsimport { execFileSync } from "node:child_process";
import { copyFileSync, existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
// Image generation via the OpenAI Codex CLI's built-in image tool (gpt-image-2)
// on the user's ChatGPT subscription: the codex CLI owns auth, media-use holds
// no key (CLI-only). The image UPSELL behind local mflux; skipped by --local-only.
//
// Retrieval mirrors illo-skill rather than trusting the model to save a file:
// `--enable image_generation` makes the built-in tool drop the rendered artifact
// into $CODEX_HOME/generated_images/, and we fetch the freshest file that
// postdates this run. The save-to-path instruction is only a best-effort
// verify-first.
//
// The flag was called `imagegenext` until Codex CLI ~0.145, which renamed it to
// `image_generation` and dropped the old row from `codex features list`. Both
// the gate below and the --enable arg track the new name, with a fallback to
// the old one so older CLIs keep working.
const TIMEOUT_MS = 600000; // codex exec round-trips the sub; first-run tool spin-up is slow
const MTIME_SKEW_MS = 2000; // tolerate mtime granularity / clock skew (illo uses 2s)
function codexGeneratedDir() {
// Codex relocates CODEX_HOME on some hosts, so resolve it at run time.
return join(process.env.CODEX_HOME || join(homedir(), ".codex"), "generated_images");
}
// Newest artifact that postdates `sinceMs` (minus skew), so a stale prior render
// or a concurrent session's file can't be mistaken for this run's output.
function freshestGeneratedImage(sinceMs) {
const dir = codexGeneratedDir();
if (!existsSync(dir)) return null;
const floor = sinceMs - MTIME_SKEW_MS;
let best = null;
for (const name of readdirSync(dir)) {
let st;
try {
st = statSync(join(dir, name));
} catch {
continue;
}
if (!st.isFile() || st.mtimeMs < floor) continue;
if (!best || st.mtimeMs > best.mtimeMs) best = { path: join(dir, name), mtimeMs: st.mtimeMs };
}
return best?.path ?? null;
}
// Short `codex` subcommand → combined stdout+stderr, or null if it can't run.
function codexRun(args) {
try {
return execFileSync("codex", args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 10000,
});
} catch (err) {
return `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}` || null;
}
}
// Name of the feature flag this CLI uses for the extended image tool, or null if
// it exposes neither. Newer CLIs list only `image_generation`; older ones list
// `imagegenext` alongside it and reject the new name on --enable.
export function imageGenFlag(feats) {
if (/\bimagegenext\b/.test(feats)) return "imagegenext";
if (/\bimage_generation\b/.test(feats)) return "image_generation";
return null;
}
// Fail-fast host check (mirrors illo): don't burn a minutes-long exec when Codex
// isn't usable. Returns { flag } when ready, else { reason }. The flag ships
// default-disabled, so we check the ROW is present (the capability signal) — the
// exec enables it per-render with --enable.
function codexUnavailableReason() {
try {
const which = process.platform === "win32" ? "where" : "which";
execFileSync(which, ["codex"], { stdio: ["ignore", "ignore", "ignore"], timeout: 5000 });
} catch {
// A shell alias (e.g. `codex → /Applications/Codex.app/...`) is NOT enough:
// aliases live only in the interactive shell, so a spawned subprocess's PATH
// lookup can't see them. Symlink the real binary onto PATH.
return {
reason:
'codex CLI not reachable on PATH (a shell alias won\'t work — spawned processes can\'t see aliases; symlink the real binary onto PATH, e.g. ln -s "$(readlink -f "$(command -v codex)")" ~/.local/bin/codex)',
};
}
// Auth marker: presence of the credentials file, NOT `codex login status`.
// That command prints "Logged in using ChatGPT" only to a human stream
// (stderr / TTY) and exits 0, so its piped stdout — how media-use spawns it —
// is empty, and the gate falsely reported "not logged in", blocking codex
// image gen in every headless / CI / agent run even when fully authed.
// auth.json is the durable, TTY-independent signal; token validity is proven
// by the exec itself, which fails cleanly if the login is stale.
const authPath = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "auth.json");
if (!existsSync(authPath)) return { reason: "codex not logged in (run: codex login)" };
const feats = codexRun(["features", "list"]);
if (feats == null) return { reason: "could not read `codex features list`" };
const flag = imageGenFlag(feats);
if (!flag) return { reason: "codex image generation feature unavailable (upgrade Codex CLI)" };
return { flag };
}
export async function codexImageGenerate(intent) {
const { reason, flag } = codexUnavailableReason();
if (reason) {
console.error(`media-use: codex image upsell unavailable: ${reason}`);
return null;
}
const outPath = join(tmpdir(), `media-use-codex-${process.pid}-${Date.now()}.png`);
const prompt =
`${intent}\n\n` +
`Use your built-in image generation tool to render this, then save the image ` +
`to ${outPath} (overwrite if it exists). Do not ask for confirmation. ` +
`If you have no built-in image tool, do nothing (no PIL/matplotlib/SVG substitute).`;
try {
unlinkSync(outPath); // clear any prior file so verify-first can't accept a stale render
} catch {
/* no prior file */
}
const started = Date.now();
try {
execFileSync(
"codex",
[
"exec",
"--cd",
tmpdir(),
"-s",
"workspace-write",
"--skip-git-repo-check",
"--enable",
flag,
"-",
],
{ input: prompt, encoding: "utf8", timeout: TIMEOUT_MS, stdio: ["pipe", "pipe", "pipe"] },
);
} catch (err) {
console.error(
`media-use: \`codex exec\` image generation failed: ${err.stderr?.toString().trim().slice(-200) || err.message}`,
);
return null;
}
// Verify-first (save-to-path may have worked), else fetch the image-tool artifact.
const produced =
existsSync(outPath) && statSync(outPath).size > 0 ? outPath : freshestGeneratedImage(started);
if (!produced) return null;
if (produced !== outPath) {
try {
copyFileSync(produced, outPath);
} catch {
return null;
}
}
return {
localPath: outPath,
ext: ".png",
source: "generated",
metadata: { description: intent, provider: "codex.image_gen", provenance: { prompt: intent } },
};
}
scripts/lib/codex-provider.test.mjsimport test from "node:test";
import assert from "node:assert";
import { imageGenFlag } from "./codex-provider.mjs";
// Real `codex features list` output is three space-padded columns:
// <name> <stage> <enabled>
const OLD_CLI = [
"image_detail_original removed false",
"image_generation stable true",
"imagegenext under development false",
].join("\n");
const NEW_CLI = [
"image_detail_original removed false",
"image_generation stable true",
].join("\n");
test("older CLIs keep the old flag — they reject --enable image_generation", () => {
assert.equal(imageGenFlag(OLD_CLI), "imagegenext");
});
test("Codex CLI >=0.145 dropped the imagegenext row; fall back to its new name", () => {
// The regression this guards: gating on `imagegenext` alone made the provider
// report "unavailable" on every up-to-date CLI, without attempting a render.
assert.equal(imageGenFlag(NEW_CLI), "image_generation");
});
test("no image feature at all is unavailable, not a silent default", () => {
assert.equal(imageGenFlag("apps stable true\nhooks stable true"), null);
});
test("substring lookalikes do not count as the feature row", () => {
assert.equal(imageGenFlag("image_generation_v2 stable true"), null);
});
scripts/lib/coverage.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import { existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { listTypes, getProviders } from "./registry.mjs";
import { CAPABILITIES, listModels } from "./local-models.mjs";
// Capstone: media-use must actually OWN each hyperframes media weakness. This
// test enforces the weakness→owner matrix in references/meta.md so a claim can't rot — if
// a capability's entrypoint disappears, this fails.
const SKILL = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
test("weakness: audio-only → media-use resolves image + icon", () => {
for (const t of ["image", "icon"]) {
assert.ok(getProviders(t).length > 0, `no provider for ${t}`);
}
});
test("weakness: no third-party brand logos → media-use resolves logo", () => {
assert.ok(listTypes().includes("logo"), "logo type missing");
assert.ok(getProviders("logo").length >= 4, "logo cascade incomplete");
});
test("weakness: no voice/audio gen → media-use exposes voice + the audio engine", () => {
assert.ok(listTypes().includes("voice"), "voice type missing");
assert.ok(getProviders("voice").length > 0, "no enabled voice provider (Bin approved)");
assert.ok(existsSync(join(SKILL, "audio", "scripts", "audio.mjs")), "audio engine missing");
});
test("weakness: scattered audio engine → consolidated under media-use (hyperframes-media gone)", () => {
assert.ok(existsSync(join(SKILL, "audio", "scripts", "lib", "tts.mjs")), "tts engine missing");
assert.ok(
existsSync(join(SKILL, "audio", "assets", "sfx", "manifest.json")),
"bundled SFX missing",
);
});
test("weakness: no media-ops → ops guidance reference exists", () => {
assert.ok(existsSync(join(SKILL, "references", "operations.md")), "operations.md missing");
});
test("weakness: no transcript-driven cutting → cut compiler entrypoints exist", async () => {
assert.ok(existsSync(join(SKILL, "scripts", "transcript-cut.mjs")), "transcript-cut missing");
assert.ok(existsSync(join(SKILL, "scripts", "lib", "cutlist.mjs")), "cutlist lib missing");
const cutlist = await import("./cutlist.mjs");
assert.equal(typeof cutlist.compileCutList, "function");
});
test("weakness: whisper.cpp is weak → better local ASR (Parakeet) entrypoint exists", async () => {
assert.ok(existsSync(join(SKILL, "scripts", "transcribe.mjs")), "transcribe.mjs missing");
const pw = await import("./parakeet-words.mjs");
assert.equal(typeof pw.mergeTokensToWords, "function", "token->word merge missing");
const lm = await import("./local-models.mjs");
const asr = lm.listModels("asr");
const parakeet = asr.find((m) => m.id === "parakeet-mlx");
assert.ok(parakeet && parakeet.rank === 0, "Parakeet must be the rank-0 preferred ASR");
});
test("weakness: no auto-duck/loudness → duck compiler and recipes exist", async () => {
assert.ok(existsSync(join(SKILL, "scripts", "audio-duck.mjs")), "audio-duck missing");
assert.ok(existsSync(join(SKILL, "scripts", "lib", "duck.mjs")), "duck lib missing");
assert.ok(existsSync(join(SKILL, "references", "operations.md")), "operations.md missing");
const duck = await import("./duck.mjs");
assert.equal(typeof duck.speechSpans, "function");
assert.equal(typeof duck.duckKeyframes, "function");
});
test("weakness: no cross-project memory → global cache + ingest entrypoints exist", async () => {
const cache = await import("./cache.mjs");
assert.equal(typeof cache.cachePut, "function");
assert.equal(typeof cache.promote, "function");
assert.equal(typeof cache.globalMediaDir, "function");
const freeze = await import("./freeze.mjs");
assert.equal(typeof freeze.isDirectMediaUrl, "function", "ingest URL guard missing");
});
// Wenbo (06-29): heygen free-usage is the default; local models are the opt-out
// fallback ("if user no, then local"). We still assert the fallback table is
// populated so the opt-out path stays real.
test("weakness: weak local defaults → local models exist as the opt-out fallback (tts/asr/upscale)", () => {
for (const cap of ["tts", "asr", "upscale"]) {
assert.ok(CAPABILITIES.includes(cap), `capability ${cap} missing`);
assert.ok(listModels(cap).length > 0, `no local models for ${cap}`);
}
});
test("weakness: no image generation → local mflux (RAM-graded) + codex upsell", async () => {
const ps = getProviders("image");
assert.ok(
ps.some((p) => p.name === "mflux.local" && typeof p.generate === "function"),
"local image gen missing",
);
assert.ok(
ps.some((p) => p.name === "codex.image_gen" && typeof p.generate === "function"),
"codex image upsell missing",
);
const lm = await import("./local-models.mjs");
assert.ok(lm.CAPABILITIES.includes("imagegen"), "imagegen capability missing");
assert.ok(lm.listModels("imagegen").length >= 3, "imagegen RAM ladder too small");
assert.equal(typeof lm.describeModelLadder, "function", "agent-facing ladder missing");
});
test("weakness: no video generation → local videogen ladder + heygen avatar upsell", async () => {
const lm = await import("./local-models.mjs");
assert.ok(lm.CAPABILITIES.includes("videogen"), "videogen capability missing");
assert.ok(lm.listModels("videogen").length >= 2, "videogen ladder too small");
const ops = existsSync(join(SKILL, "references", "operations.md"));
assert.ok(ops, "operations.md (avatar-upsell recipe) missing");
});
test("every resolve type has at least one enabled provider", () => {
for (const t of listTypes()) {
assert.ok(getProviders(t).length > 0, `type ${t} has no enabled provider`);
}
});
scripts/lib/cube-build.mjsconst DEFAULT_SIZE = 33;
const MAX_SIZE = 64;
function clamp(value, min, max) {
if (!Number.isFinite(value)) return 0;
return Math.min(max, Math.max(min, value));
}
function clampUnit(value) {
return clamp(value, 0, 1);
}
function readParam(params, key, min, max) {
return clamp(Number(params?.[key] ?? 0), min, max);
}
function luma([r, g, b]) {
// Rec.709 luma weightings (matches the color space the grading runtime uses).
return r * 0.2126 + g * 0.7152 + b * 0.0722;
}
function smoothstep(edge0, edge1, value) {
const t = clampUnit((value - edge0) / (edge1 - edge0));
return t * t * (3 - 2 * t);
}
function applyLiftGain(color, params) {
const y = luma(color);
const blacks = readParam(params, "blacks", -1, 1);
const shadows = readParam(params, "shadows", -1, 1);
const highlights = readParam(params, "highlights", -1, 1);
const whites = readParam(params, "whites", -1, 1);
const shadowMask = 1 - smoothstep(0.18, 0.62, y);
const highlightMask = smoothstep(0.38, 0.82, y);
const offset =
blacks * 0.08 + shadows * 0.12 * shadowMask + highlights * 0.12 * highlightMask + whites * 0.08;
return color.map((channel) => clampUnit(channel + offset));
}
function applyExposure(color, params) {
const exposure = readParam(params, "exposure", -2, 2);
const gain = 2 ** exposure;
const lift = Math.max(0, exposure) * 0.015;
return color.map((channel) => clampUnit(channel * gain + lift));
}
function applyContrast(color, params) {
const contrast = readParam(params, "contrast", -1, 1);
if (contrast === 0) return color;
const factor = 1 + contrast * 1.2;
return color.map((channel) => clampUnit(0.5 + (channel - 0.5) * factor));
}
function applyWhiteBalance(color, params) {
const temperature = readParam(params, "temperature", -1, 1);
const tint = readParam(params, "tint", -1, 1);
const redScale = 1 + temperature * 0.28 + tint * 0.08;
const greenScale = 1 - Math.abs(tint) * 0.1 - tint * 0.08;
const blueScale = 1 - temperature * 0.28 + tint * 0.08;
return [
clampUnit(color[0] * redScale),
clampUnit(color[1] * greenScale),
clampUnit(color[2] * blueScale),
];
}
function applySplitTone(color, params) {
const split = params?.splitTone;
if (!split) return color;
const intensity = clampUnit(Number(split.intensity ?? 0));
if (intensity === 0) return color;
const balance = clampUnit(Number(split.balance ?? 0.5));
const y = luma(color);
const shadowMask = 1 - smoothstep(balance - 0.25, balance + 0.2, y);
const highlightMask = smoothstep(balance - 0.2, balance + 0.25, y);
const shadows = Array.isArray(split.shadows) ? split.shadows : [0, 0, 0];
const highlights = Array.isArray(split.highlights) ? split.highlights : [0, 0, 0];
return color.map((channel, i) =>
clampUnit(
channel +
Number(shadows[i] ?? 0) * shadowMask * intensity +
Number(highlights[i] ?? 0) * highlightMask * intensity,
),
);
}
function applySaturation(color, params) {
const saturation = readParam(params, "saturation", -1, 1);
const vibrance = readParam(params, "vibrance", -1, 1);
if (saturation === 0 && vibrance === 0) return color;
const y = luma(color);
const currentSat = Math.max(
Math.abs(color[0] - y),
Math.abs(color[1] - y),
Math.abs(color[2] - y),
);
const vibranceWeight = 1 - clampUnit(currentSat * 2);
const factor = clamp(1 + saturation + vibrance * vibranceWeight, 0, 2.5);
return color.map((channel) => clampUnit(y + (channel - y) * factor));
}
function applyParams(color, params) {
let out = applyLiftGain(color, params);
out = applyExposure(out, params);
out = applyContrast(out, params);
out = applyWhiteBalance(out, params);
out = applySplitTone(out, params);
out = applySaturation(out, params);
return out;
}
function formatNumber(value) {
return clampUnit(value).toFixed(6);
}
export function buildCube(params = {}, size = DEFAULT_SIZE) {
if (!Number.isInteger(size) || size < 2 || size > MAX_SIZE) {
throw new Error(`LUT size must be an integer from 2 to ${MAX_SIZE}`);
}
const lines = [
`TITLE "media-use parametric grade"`,
"DOMAIN_MIN 0 0 0",
"DOMAIN_MAX 1 1 1",
`LUT_3D_SIZE ${size}`,
];
const denom = size - 1;
for (let b = 0; b < size; b++) {
for (let g = 0; g < size; g++) {
for (let r = 0; r < size; r++) {
const out = applyParams([r / denom, g / denom, b / denom], params);
lines.push(`${formatNumber(out[0])} ${formatNumber(out[1])} ${formatNumber(out[2])}`);
}
}
}
return `${lines.join("\n")}\n`;
}
export function paramsFromIntent(intent) {
const text = String(intent ?? "").toLowerCase();
const params = {};
let matched = false;
if (/\b(warm|golden|sunlit|sunny)\b/.test(text)) {
params.temperature = 0.18;
matched = true;
} else if (/\b(cool|blue|icy|crisp)\b/.test(text)) {
params.temperature = -0.16;
matched = true;
}
if (/\b(cinematic|film|movie)\b/.test(text)) {
params.contrast = 0.08;
params.saturation = 0.04;
matched = true;
}
if (/\b(punchy|contrast|dramatic|bold)\b/.test(text)) {
params.contrast = Math.max(params.contrast ?? 0, 0.22);
matched = true;
}
if (/\b(bright|airy|lift)\b/.test(text)) {
params.exposure = 0.16;
params.shadows = 0.08;
matched = true;
}
if (/\b(dark|moody|low-key)\b/.test(text)) {
params.exposure = -0.12;
params.contrast = Math.max(params.contrast ?? 0, 0.12);
matched = true;
}
if (/\b(vibrant|saturated|colorful)\b/.test(text)) {
params.saturation = Math.max(params.saturation ?? 0, 0.16);
params.vibrance = 0.12;
matched = true;
}
if (/\b(muted|desaturated|washed)\b/.test(text)) {
params.saturation = Math.min(params.saturation ?? 0, -0.16);
matched = true;
}
return matched ? params : null;
}
scripts/lib/cube-build.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import { buildCube, paramsFromIntent } from "./cube-build.mjs";
import { validateCube } from "./cube-validate.mjs";
function rows(cube) {
return cube
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => /^[+-]?(?:\d|\.\d)/.test(line))
.map((line) => line.split(/\s+/).map(Number));
}
function rowAt(cubeRows, size, r, g, b) {
return cubeRows[(b * size + g) * size + r];
}
function luma(row) {
return row[0] * 0.2126 + row[1] * 0.7152 + row[2] * 0.0722;
}
test("all-zero params produce a near-identity LUT", () => {
const cube = buildCube({}, 3);
assert.equal(validateCube(cube).ok, true);
const parsed = rows(cube);
for (let b = 0; b < 3; b++) {
for (let g = 0; g < 3; g++) {
for (let r = 0; r < 3; r++) {
const row = rowAt(parsed, 3, r, g, b);
assert.ok(Math.abs(row[0] - r / 2) < 0.000001);
assert.ok(Math.abs(row[1] - g / 2) < 0.000001);
assert.ok(Math.abs(row[2] - b / 2) < 0.000001);
}
}
}
});
test("positive exposure increases unclipped output luma", () => {
const identity = rows(buildCube({}, 5));
const exposed = rows(buildCube({ exposure: 0.3 }, 5));
for (let i = 0; i < identity.length; i++) {
const before = luma(identity[i]);
if (before > 0.02 && before < 0.95) {
assert.ok(luma(exposed[i]) > before, `row ${i} should brighten`);
}
}
});
test("positive temperature warms mid-gray", () => {
const parsed = rows(buildCube({ temperature: 0.2 }, 3));
const mid = rowAt(parsed, 3, 1, 1, 1);
assert.ok(mid[0] > 0.5, "red channel should rise");
assert.ok(mid[2] < 0.5, "blue channel should fall");
});
test("positive contrast darkens shadows and brightens highlights", () => {
const parsed = rows(buildCube({ contrast: 0.3 }, 5));
const shadow = rowAt(parsed, 5, 1, 1, 1);
const highlight = rowAt(parsed, 5, 3, 3, 3);
assert.ok(luma(shadow) < 0.25, "below-mid gray should darken");
assert.ok(luma(highlight) > 0.75, "above-mid gray should brighten");
});
test("outputs validate at the default size and are deterministic", () => {
const params = { exposure: 0.15, contrast: 0.2, temperature: -0.1, saturation: 0.12 };
const a = buildCube(params);
const b = buildCube(params);
assert.equal(a, b);
assert.equal(validateCube(a).ok, true);
assert.equal(validateCube(a).size, 33);
});
test("paramsFromIntent declines zero-overlap prompts and maps technical words", () => {
assert.equal(paramsFromIntent("zqxv imaginary neutron look"), null);
assert.deepEqual(paramsFromIntent("warm cinematic"), {
temperature: 0.18,
contrast: 0.08,
saturation: 0.04,
});
});
scripts/lib/cube-validate.mjs#!/usr/bin/env node
// Standalone mirror of packages/core/src/colorLuts.ts. media-use cannot import
// the TypeScript source at runtime, so cube-validate.test.mjs mirrors core
// parser cases to catch drift in accepted .cube files before freezing them.
import { readFileSync } from "node:fs";
import { resolve as resolvePath } from "node:path";
import { fileURLToPath } from "node:url";
export const DEFAULT_MAX_CUBE_LUT_SIZE = 64;
const DEFAULT_DOMAIN_MIN = [0, 0, 0];
const DEFAULT_DOMAIN_MAX = [1, 1, 1];
class CubeValidateError extends Error {
constructor(message, lineNumber = null) {
super(lineNumber == null ? message : `${message} at line ${lineNumber}`);
this.name = "CubeValidateError";
this.lineNumber = lineNumber;
}
}
function stripComment(line) {
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') inQuote = !inQuote;
if (char === "#" && !inQuote) return line.slice(0, i);
}
return line;
}
function parseFiniteNumber(value, lineNumber) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new CubeValidateError(`Invalid number "${value}"`, lineNumber);
}
return parsed;
}
function parseVec3(parts, keyword, lineNumber) {
if (parts.length !== 3) {
throw new CubeValidateError(`${keyword} expects three numbers`, lineNumber);
}
return [
parseFiniteNumber(parts[0], lineNumber),
parseFiniteNumber(parts[1], lineNumber),
parseFiniteNumber(parts[2], lineNumber),
];
}
function parseSize(value, keyword, lineNumber) {
if (!value) throw new CubeValidateError(`${keyword} expects a size`, lineNumber);
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 2) {
throw new CubeValidateError(`${keyword} must be an integer greater than 1`, lineNumber);
}
return parsed;
}
function validateDomain(domainMin, domainMax) {
if (
domainMax[0] <= domainMin[0] ||
domainMax[1] <= domainMin[1] ||
domainMax[2] <= domainMin[2]
) {
throw new CubeValidateError("DOMAIN_MAX values must be greater than DOMAIN_MIN values");
}
}
function isNumericDataLine(token) {
return /^[+-]?(?:\d|\.\d)/.test(token);
}
function parseCube(input, options = {}) {
const maxSize = options.maxSize ?? DEFAULT_MAX_CUBE_LUT_SIZE;
let domainMin = DEFAULT_DOMAIN_MIN;
let domainMax = DEFAULT_DOMAIN_MAX;
let lut1dSize = null;
let lut3dSize = null;
let rows = 0;
const lines = String(input)
.replace(/^\uFEFF/, "")
.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const lineNumber = i + 1;
const line = stripComment(lines[i] ?? "").trim();
if (!line) continue;
const parts = line.split(/\s+/);
const keyword = (parts[0] ?? "").toUpperCase();
const rest = parts.slice(1);
if (keyword === "TITLE") continue;
if (keyword === "DOMAIN_MIN") {
domainMin = parseVec3(rest, keyword, lineNumber);
continue;
}
if (keyword === "DOMAIN_MAX") {
domainMax = parseVec3(rest, keyword, lineNumber);
continue;
}
if (keyword === "LUT_3D_INPUT_RANGE") {
if (rest.length !== 2) {
throw new CubeValidateError(`${keyword} expects two numbers`, lineNumber);
}
const min = parseFiniteNumber(rest[0], lineNumber);
const max = parseFiniteNumber(rest[1], lineNumber);
if (max <= min) {
throw new CubeValidateError("LUT_3D_INPUT_RANGE max must exceed min", lineNumber);
}
domainMin = [min, min, min];
domainMax = [max, max, max];
continue;
}
if (keyword === "LUT_1D_SIZE") {
lut1dSize = parseSize(rest[0], keyword, lineNumber);
continue;
}
if (keyword === "LUT_3D_SIZE") {
lut3dSize = parseSize(rest[0], keyword, lineNumber);
if (lut3dSize > maxSize) {
throw new CubeValidateError(`LUT_3D_SIZE ${lut3dSize} exceeds max ${maxSize}`, lineNumber);
}
continue;
}
if (!isNumericDataLine(keyword)) {
if (keyword.startsWith("LUT_")) {
throw new CubeValidateError(`Unsupported cube keyword ${keyword}`, lineNumber);
}
continue;
}
if (!lut3dSize) {
if (lut1dSize) {
throw new CubeValidateError("1D cube LUTs are not supported yet", lineNumber);
}
throw new CubeValidateError("LUT data appears before LUT_3D_SIZE", lineNumber);
}
if (parts.length !== 3) {
throw new CubeValidateError("LUT data rows must contain three numbers", lineNumber);
}
parseFiniteNumber(parts[0], lineNumber);
parseFiniteNumber(parts[1], lineNumber);
parseFiniteNumber(parts[2], lineNumber);
rows++;
}
if (lut1dSize && lut3dSize) {
throw new CubeValidateError("Mixed 1D and 3D cube LUTs are not supported yet");
}
if (!lut3dSize) {
if (lut1dSize) throw new CubeValidateError("1D cube LUTs are not supported yet");
throw new CubeValidateError("Missing LUT_3D_SIZE");
}
validateDomain(domainMin, domainMax);
const expectedRows = lut3dSize * lut3dSize * lut3dSize;
if (rows !== expectedRows) {
throw new CubeValidateError(
`Expected ${expectedRows} LUT rows for size ${lut3dSize}, found ${rows}`,
);
}
return { size: lut3dSize };
}
export function validateCube(input, options = {}) {
try {
const parsed = parseCube(input, options);
return { ok: true, size: parsed.size };
} catch (err) {
return { ok: false, error: err.message };
}
}
export function validateCubeFile(filePath, options = {}) {
return validateCube(readFileSync(filePath, "utf8"), options);
}
function main(argv) {
const file = argv[2];
if (!file) {
console.error("usage: cube-validate.mjs <file.cube>");
process.exit(2);
}
const result = validateCubeFile(file);
if (!result.ok) {
console.error(`error: ${result.error}`);
process.exit(1);
}
console.log(`ok: LUT_3D_SIZE ${result.size}`);
}
if (process.argv[1] && resolvePath(process.argv[1]) === fileURLToPath(import.meta.url)) {
main(process.argv);
}
scripts/lib/cube-validate.test.mjsimport { strict as assert } from "node:assert";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { validateCube } from "./cube-validate.mjs";
const IDENTITY_2 = `
# comment
TITLE "Identity 2"
DOMAIN_MIN 0 0 0
DOMAIN_MAX 1 1 1
LUT_3D_SIZE 2
0 0 0
1 0 0
0 1 0
1 1 0
0 0 1
1 0 1
0 1 1
1 1 1
`;
test("accepts a valid minimal 3D cube LUT", () => {
const result = validateCube(IDENTITY_2);
assert.deepEqual(result, { ok: true, size: 2 });
});
test("rejects oversize LUTs with the core parser message", () => {
const result = validateCube("LUT_3D_SIZE 65", { maxSize: 64 });
assert.equal(result.ok, false);
assert.match(result.error, /LUT_3D_SIZE 65 exceeds max 64/);
});
test("rejects data rows before LUT_3D_SIZE", () => {
const result = validateCube("0 0 0\nLUT_3D_SIZE 2");
assert.equal(result.ok, false);
assert.match(result.error, /LUT data appears before LUT_3D_SIZE/);
});
test("rejects missing LUT_3D_SIZE", () => {
const result = validateCube('TITLE "No Size"');
assert.equal(result.ok, false);
assert.match(result.error, /Missing LUT_3D_SIZE/);
});
test("rejects row count mismatches with the core parser message", () => {
const result = validateCube("LUT_3D_SIZE 2\n0 0 0");
assert.equal(result.ok, false);
assert.match(result.error, /Expected 8 LUT rows/);
});
test("rejects inverted domains", () => {
const result = validateCube(`
DOMAIN_MIN 0 0 0
DOMAIN_MAX 1 0 1
LUT_3D_SIZE 2
0 0 0
1 0 0
0 1 0
1 1 0
0 0 1
1 0 1
0 1 1
1 1 1
`);
assert.equal(result.ok, false);
assert.match(result.error, /DOMAIN_MAX values must be greater than DOMAIN_MIN values/);
});
test("rejects unsupported 1D and mixed cube LUTs", () => {
const oneD = validateCube("LUT_1D_SIZE 2\n0 0 0\n1 1 1");
assert.equal(oneD.ok, false);
assert.match(oneD.error, /1D cube LUTs are not supported yet/);
const mixed = validateCube(`
LUT_1D_SIZE 2
LUT_3D_SIZE 2
0 0 0
1 0 0
0 1 0
1 1 0
0 0 1
1 0 1
0 1 1
1 1 1
`);
assert.equal(mixed.ok, false);
assert.match(mixed.error, /Mixed 1D and 3D cube LUTs are not supported yet/);
});
test("CLI exits zero for valid files and non-zero for invalid files", () => {
const dir = mkdtempSync(join(tmpdir(), "mu-cube-validate-"));
try {
const valid = join(dir, "valid.cube");
const invalid = join(dir, "invalid.cube");
writeFileSync(valid, IDENTITY_2);
writeFileSync(invalid, "LUT_3D_SIZE 65");
const out = execFileSync(
process.execPath,
[new URL("./cube-validate.mjs", import.meta.url).pathname, valid],
{
encoding: "utf8",
},
);
assert.match(out, /ok: LUT_3D_SIZE 2/);
assert.throws(
() =>
execFileSync(
process.execPath,
[new URL("./cube-validate.mjs", import.meta.url).pathname, invalid],
{
encoding: "utf8",
stdio: "pipe",
},
),
(err) => err.status === 1 && String(err.stderr).includes("exceeds max 64"),
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
scripts/lib/cutlist.mjsimport { normalizeWords } from "./words.mjs";
const MIN_SEGMENT_SECONDS = 0.2;
const SILENCE_PAD_SECONDS = 0.15;
export function compileCutList(transcript, opts = {}) {
const words = normalizeWords(transcript);
if (opts.keep != null && hasRemovalSource(opts)) {
throw new Error("--keep is mutually exclusive with removal options");
}
if (opts.keep != null) {
const duration = durationFrom(words, opts);
const ranges = parseTimeRanges(opts.keep);
return finalizeKept(duration != null ? clampRanges(ranges, duration) : ranges);
}
const duration = durationFrom(words, opts);
if (!duration) return [];
const removals = [
...parseTimeRanges(opts.remove),
...wordIndexRanges(words, opts.removeWords),
...fillerRanges(words, opts.removeFillers),
...silenceRanges(words, opts.cutSilence),
];
const mergedRemovals = mergeRanges(clampRanges(removals, duration));
return finalizeKept(invertRanges(mergedRemovals, duration));
}
function hasRemovalSource(opts) {
return (
opts.remove != null ||
opts.removeWords != null ||
opts.removeFillers != null ||
opts.cutSilence != null
);
}
function durationFrom(words, opts) {
const explicit = Number(opts.duration ?? opts.totalDuration);
if (Number.isFinite(explicit) && explicit > 0) return explicit;
const last = words.at(-1);
return last && Number.isFinite(last.end) && last.end > 0 ? last.end : null;
}
function parseTimeRanges(value) {
if (value == null || value === false || value === "") return [];
if (typeof value === "string") {
return value
.split(",")
.map((part) => part.trim())
.filter(Boolean)
.map(parseRangeString);
}
if (!Array.isArray(value)) throw new Error("range list must be a string or array");
return value.map((range) => {
if (Array.isArray(range)) return cleanRange(Number(range[0]), Number(range[1]));
return cleanRange(Number(range?.start), Number(range?.end));
});
}
function parseRangeString(value) {
const match = value.match(/^([0-9]*\.?[0-9]+)\s*-\s*([0-9]*\.?[0-9]+)$/);
if (!match) throw new Error(`invalid range: ${value}`);
return cleanRange(Number(match[1]), Number(match[2]));
}
function cleanRange(start, end) {
if (!Number.isFinite(start) || !Number.isFinite(end)) {
throw new Error("range start/end must be finite numbers");
}
if (end < start) throw new Error(`range end ${end} is before start ${start}`);
return { start, end };
}
function wordIndexRanges(words, value) {
if (value == null || value === false || value === "") return [];
const ranges = typeof value === "string" ? value.split(",") : value;
if (!Array.isArray(ranges)) throw new Error("--remove-words must be a string or array");
return ranges
.map((range) => (typeof range === "string" ? range.trim() : range))
.filter(Boolean)
.map((range) => {
const [first, last = first] =
typeof range === "string" ? range.split("-").map((n) => n.trim()) : range;
const startIndex = Number(first);
const endIndex = Number(last);
if (!Number.isInteger(startIndex) || !Number.isInteger(endIndex)) {
throw new Error(`invalid word range: ${range}`);
}
if (startIndex < 0 || endIndex < startIndex || endIndex >= words.length) {
throw new Error(`word range out of bounds: ${range}`);
}
return { start: words[startIndex].start, end: words[endIndex].end };
});
}
function fillerRanges(words, value) {
if (value == null || value === false || value === "") return [];
const fillers = Array.isArray(value)
? value
: String(value)
.split(",")
.map((s) => s.trim());
const set = new Set(fillers.filter(Boolean).map(bareToken));
if (set.size === 0) return [];
// Whisper emits words with attached punctuation and arbitrary case
// ("UM," / "Um."), so compare bare tokens.
return words
.filter((word) => set.has(bareToken(word.text)))
.map((word) => ({ start: word.start, end: word.end }));
}
function bareToken(text) {
return String(text)
.toLowerCase()
.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
}
function silenceRanges(words, value) {
if (value == null || value === false || value === "") return [];
const threshold = Number(value);
if (!Number.isFinite(threshold) || threshold <= 0) {
throw new Error("--cut-silence must be a positive number");
}
const ranges = [];
for (let i = 0; i < words.length - 1; i++) {
const current = words[i];
const next = words[i + 1];
const gap = next.start - current.end;
if (gap <= threshold) continue;
const start = current.end + SILENCE_PAD_SECONDS;
const end = next.start - SILENCE_PAD_SECONDS;
if (end > start) ranges.push({ start, end });
}
return ranges;
}
function clampRanges(ranges, duration) {
return ranges
.map((range) => ({
start: Math.max(0, Math.min(duration, range.start)),
end: Math.max(0, Math.min(duration, range.end)),
}))
.filter((range) => range.end > range.start);
}
function mergeRanges(ranges) {
const sorted = ranges
.map((range) => ({ start: round3(range.start), end: round3(range.end) }))
.sort((a, b) => a.start - b.start || a.end - b.end);
const merged = [];
for (const range of sorted) {
const prev = merged.at(-1);
if (prev && range.start <= prev.end) {
prev.end = Math.max(prev.end, range.end);
} else {
merged.push({ ...range });
}
}
return merged;
}
function invertRanges(removals, duration) {
const kept = [];
let cursor = 0;
for (const range of removals) {
if (range.start > cursor) kept.push({ start: cursor, end: range.start });
cursor = Math.max(cursor, range.end);
}
if (cursor < duration) kept.push({ start: cursor, end: duration });
return kept;
}
function finalizeKept(ranges) {
return mergeRanges(ranges)
.map((range) => ({ start: round3(range.start), end: round3(range.end) }))
.filter((range) => round3(range.end - range.start) >= MIN_SEGMENT_SECONDS);
}
function round3(n) {
return Math.round(Number(n) * 1000) / 1000;
}
scripts/lib/cutlist.test.mjsimport { strict as assert } from "node:assert";
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { compileCutList } from "./cutlist.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const SCRIPT = join(HERE, "..", "transcript-cut.mjs");
test("explicit --remove ranges invert to kept segments", () => {
const transcript = [
word("w0", "alpha", 0, 1),
word("w1", "beta", 1.2, 2),
word("w2", "gamma", 2.2, 5),
];
assert.deepEqual(compileCutList(transcript, { remove: "1-2.5" }), [
{ start: 0, end: 1 },
{ start: 2.5, end: 5 },
]);
});
test("--remove-words resolves inclusive word-index ranges to time ranges", () => {
const transcript = [
word("w0", "zero", 0, 0.5),
word("w1", "one", 0.6, 1),
word("w2", "two", 1.1, 1.5),
word("w3", "three", 2, 3),
];
assert.deepEqual(compileCutList(transcript, { removeWords: "1-2" }), [
{ start: 0, end: 0.6 },
{ start: 1.5, end: 3 },
]);
});
test("--remove-fillers drops case-insensitive matching words", () => {
const transcript = [
word("w0", "Hello", 0, 0.5),
word("w1", "Um", 0.5, 0.7),
word("w2", "world", 0.8, 1.2),
word("w3", "LIKE", 1.3, 1.5),
word("w4", "done", 1.6, 2),
];
assert.deepEqual(compileCutList(transcript, { removeFillers: "um,like" }), [
{ start: 0, end: 0.5 },
{ start: 0.7, end: 1.3 },
{ start: 1.5, end: 2 },
]);
});
test("--cut-silence removes only the center of long inter-word gaps", () => {
const transcript = [word("w0", "a", 0, 0.5), word("w1", "b", 2, 2.5), word("w2", "c", 2.7, 3)];
assert.deepEqual(compileCutList(transcript, { cutSilence: 0.8 }), [
{ start: 0, end: 0.65 },
{ start: 1.85, end: 3 },
]);
});
test("overlapping removal sources merge before inversion", () => {
const transcript = [
word("w0", "start", 0, 0.5),
word("w1", "um", 0.9, 1.1),
word("w2", "middle", 2.5, 2.8),
word("w3", "more", 3.1, 3.4),
word("w4", "end", 5.5, 6),
];
assert.deepEqual(
compileCutList(transcript, {
remove: "1-2.7",
removeWords: "2-3",
removeFillers: "um",
}),
[
{ start: 0, end: 0.9 },
{ start: 3.4, end: 6 },
],
);
});
test("kept slivers shorter than 0.2s are dropped", () => {
const transcript = [word("w0", "start", 0, 0.5), word("w1", "end", 2.5, 3)];
assert.deepEqual(compileCutList(transcript, { remove: "0.1-2.95" }), []);
});
test("--keep is inverse mode and coalesces direct kept ranges", () => {
const transcript = [word("w0", "start", 0, 0.5), word("w1", "end", 4.5, 5)];
assert.deepEqual(compileCutList(transcript, { keep: "3-4,1-2,1.5-2.5,4.1-4.2" }), [
{ start: 1, end: 2.5 },
{ start: 3, end: 4 },
]);
});
test("--plan on a fixture transcript prints the exact segment JSON", () => {
const dir = mkdtempSync(join(tmpdir(), "media-use-cutlist-"));
try {
const transcriptPath = join(dir, "fixture.json");
writeFileSync(
transcriptPath,
JSON.stringify([
word("w0", "hello", 0, 0.4),
word("w1", "um", 0.5, 0.65),
word("w2", "there", 0.7, 1),
word("w3", "pause", 2.2, 2.5),
word("w4", "end", 2.7, 3.2),
]),
);
const out = execFileSync(
process.execPath,
[
SCRIPT,
"--input",
"ignored.mp4",
"--transcript",
transcriptPath,
"--remove",
"0.9-1.2",
"--remove-fillers",
"um",
"--cut-silence",
"0.8",
"--plan",
],
{ encoding: "utf8" },
);
assert.deepEqual(JSON.parse(out), [
{ start: 0, end: 0.5 },
{ start: 0.65, end: 0.9 },
{ start: 2.05, end: 3.2 },
]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
function word(id, text, start, end) {
return { id, text, start, end };
}
scripts/lib/duck.mjsimport { wordListsFromMediaMeta } from "./words.mjs";
/**
* Speech spans from word timestamps.
*
* audio_meta.json word times are relative to EACH LINE'S OWN FILE, not to the
* composition. Without placement info, multiple lines would overlap at t=0 and
* merge into one bogus span. Placement options:
* offsets: { [voiceId]: startSeconds } explicit composition placement
* sequential: stack lines back to back (plus `gap` seconds between lines)
* A single word list (bare transcript) needs neither.
*/
export function speechSpans(meta, { mergeGap = 0.6, offsets, sequential = false, gap = 0 } = {}) {
const merge = Number(mergeGap);
const lists = wordListsFromMediaMeta(meta);
const voices = Array.isArray(meta?.voices) ? meta.voices : [];
if (lists.length > 1 && !offsets && !sequential) {
throw new Error(
"audio_meta has multiple voice lines with file-relative times; pass --sequential or --offsets so spans land at composition time",
);
}
const intervals = [];
let cursor = 0;
for (let i = 0; i < lists.length; i++) {
const voice = voices[i];
let offset = 0;
if (offsets) {
const id = voice?.id ?? String(i);
if (!(id in offsets)) throw new Error(`--offsets is missing voice "${id}"`);
offset = Number(offsets[id]) || 0;
} else if (sequential) {
offset = cursor;
const lineDuration = Number(voice?.duration_s) || Math.max(...lists[i].map((w) => w.end), 0);
cursor += lineDuration + (Number(gap) || 0);
}
for (const word of lists[i]) {
if (word.end > word.start)
intervals.push({ start: word.start + offset, end: word.end + offset });
}
}
return mergeIntervals(intervals, Number.isFinite(merge) && merge >= 0 ? merge : 0.6);
}
export function duckKeyframes(
spans,
{ duck = 0.25, attack = 0.15, release = 0.4, baseVolume = 1 } = {},
) {
const base = finiteOr(baseVolume, 1);
const ducked = round3(base * finiteOr(duck, 0.25));
const keyframes = [];
for (const span of spans) {
keyframes.push({
time: round3(Math.max(0, finiteOr(span.start, 0))),
volume: ducked,
duration: round3(finiteOr(attack, 0.15)),
});
keyframes.push({
time: round3(Math.max(0, finiteOr(span.end, 0))),
volume: round3(base),
duration: round3(finiteOr(release, 0.4)),
});
}
return keyframes.sort((a, b) => a.time - b.time);
}
function mergeIntervals(intervals, mergeGap) {
const sorted = intervals
.map((range) => ({ start: round3(range.start), end: round3(range.end) }))
.sort((a, b) => a.start - b.start || a.end - b.end);
const merged = [];
for (const range of sorted) {
const prev = merged.at(-1);
if (prev && (range.start <= prev.end || range.start - prev.end < mergeGap)) {
prev.end = Math.max(prev.end, range.end);
} else {
merged.push({ ...range });
}
}
return merged;
}
function finiteOr(value, fallback) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function round3(n) {
return Math.round(Number(n) * 1000) / 1000;
}
scripts/lib/duck.test.mjsimport { strict as assert } from "node:assert";
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { duckKeyframes, speechSpans } from "./duck.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const SCRIPT = join(HERE, "..", "audio-duck.mjs");
test("speechSpans bridges gaps smaller than mergeGap", () => {
const meta = {
words: [word("w0", "one", 0, 0.5), word("w1", "two", 0.8, 1), word("w2", "three", 2, 2.2)],
};
assert.deepEqual(speechSpans(meta, { mergeGap: 0.4 }), [
{ start: 0, end: 1 },
{ start: 2, end: 2.2 },
]);
});
test("speechSpans refuses multi-line meta without placement (file-relative times)", () => {
const meta = {
voices: [
{ id: "a", words: [word("w0", "one", 0, 1)] },
{ id: "b", words: [word("w1", "two", 0, 1)] },
],
};
assert.throws(() => speechSpans(meta, { mergeGap: 0.2 }), /--sequential or --offsets/);
});
test("speechSpans sequential stacks lines by duration plus gap", () => {
const meta = {
voices: [
{ id: "a", duration_s: 2, words: [word("w0", "one", 0.1, 1.9)] },
{ id: "b", duration_s: 1, words: [word("w1", "two", 0.1, 0.9)] },
],
};
assert.deepEqual(speechSpans(meta, { mergeGap: 0.2, sequential: true, gap: 0.5 }), [
{ start: 0.1, end: 1.9 },
{ start: 2.6, end: 3.4 },
]);
});
test("speechSpans explicit offsets place each line at composition time", () => {
const meta = {
voices: [
{ id: "a", words: [word("w0", "one", 0, 1)] },
{ id: "b", words: [word("w1", "two", 0, 1)] },
],
};
assert.deepEqual(speechSpans(meta, { mergeGap: 0.2, offsets: { a: 0, b: 4 } }), [
{ start: 0, end: 1 },
{ start: 4, end: 5 },
]);
assert.throws(() => speechSpans(meta, { offsets: { a: 0 } }), /missing voice "b"/);
});
test("speechSpans returns empty spans for empty input", () => {
assert.deepEqual(speechSpans({ voices: [] }, { mergeGap: 0.6 }), []);
});
test("duckKeyframes shapes attack and release from base volume", () => {
assert.deepEqual(
duckKeyframes([{ start: 3, end: 5 }], {
duck: 0.25,
attack: 0.15,
release: 0.4,
baseVolume: 0.6,
}),
[
{ time: 3, volume: 0.15, duration: 0.15 },
{ time: 5, volume: 0.6, duration: 0.4 },
],
);
});
test("--json spans match --merge-gap semantics exactly", () => {
const dir = mkdtempSync(join(tmpdir(), "media-use-duck-"));
try {
const metaPath = join(dir, "audio_meta.json");
writeFileSync(
metaPath,
JSON.stringify({
voices: [
{
id: "narration",
words: [
word("w0", "one", 0, 0.4),
word("w1", "two", 0.9, 1.2),
word("w2", "three", 1.8, 2.1),
],
},
],
}),
);
const out = execFileSync(
process.execPath,
[SCRIPT, "--meta", metaPath, "--target", "#bgm", "--merge-gap", "0.6", "--json"],
{ encoding: "utf8" },
);
const parsed = JSON.parse(out);
assert.deepEqual(parsed.spans, [
{ start: 0, end: 1.2 },
{ start: 1.8, end: 2.1 },
]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
function word(id, text, start, end) {
return { id, text, start, end };
}
scripts/lib/error-diffusion.mjsexport const ERROR_DIFFUSION_ALGORITHMS = {
"floyd-steinberg": {
kernel: [
[1, 0, 7],
[-1, 1, 3],
[0, 1, 5],
[1, 1, 1],
],
divisor: 16,
},
atkinson: {
kernel: [
[1, 0, 1],
[2, 0, 1],
[-1, 1, 1],
[0, 1, 1],
[1, 1, 1],
[0, 2, 1],
],
divisor: 8,
},
"jarvis-judice-ninke": {
kernel: [
[1, 0, 7],
[2, 0, 5],
[-2, 1, 3],
[-1, 1, 5],
[0, 1, 7],
[1, 1, 5],
[2, 1, 3],
[-2, 2, 1],
[-1, 2, 3],
[0, 2, 5],
[1, 2, 3],
[2, 2, 1],
],
divisor: 48,
},
stucki: {
kernel: [
[1, 0, 8],
[2, 0, 4],
[-2, 1, 2],
[-1, 1, 4],
[0, 1, 8],
[1, 1, 4],
[2, 1, 2],
[-2, 2, 1],
[-1, 2, 2],
[0, 2, 4],
[1, 2, 2],
[2, 2, 1],
],
divisor: 42,
},
burkes: {
kernel: [
[1, 0, 8],
[2, 0, 4],
[-2, 1, 2],
[-1, 1, 4],
[0, 1, 8],
[1, 1, 4],
[2, 1, 2],
],
divisor: 32,
},
sierra: {
kernel: [
[1, 0, 5],
[2, 0, 3],
[-2, 1, 2],
[-1, 1, 4],
[0, 1, 5],
[1, 1, 4],
[2, 1, 2],
[-1, 2, 2],
[0, 2, 3],
[1, 2, 2],
],
divisor: 32,
},
"sierra-lite": {
kernel: [
[1, 0, 2],
[-1, 1, 1],
[0, 1, 1],
],
divisor: 4,
},
"two-row-sierra": {
kernel: [
[1, 0, 4],
[2, 0, 3],
[-2, 1, 1],
[-1, 1, 2],
[0, 1, 3],
[1, 1, 2],
[2, 1, 1],
],
divisor: 16,
},
};
const DEFAULTS = {
algorithm: "floyd-steinberg",
brightness: 1,
contrast: 1.2,
detail: 1,
palette: ["#000000", "#ffffff"],
pointSize: 3,
};
export function errorDiffusionBufferLength(width, height, pointSize) {
return Math.ceil(width / pointSize) * Math.ceil(height / pointSize) * 3;
}
export function applyErrorDiffusionRgba(data, width, height, options = {}, errorBuffer) {
if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) {
throw new Error("width and height must be positive integers");
}
if (!data || data.length !== width * height * 4) {
throw new Error(`RGBA data must contain ${width * height * 4} bytes`);
}
const algorithm = options.algorithm ?? DEFAULTS.algorithm;
const diffusion = ERROR_DIFFUSION_ALGORITHMS[algorithm];
if (!diffusion) throw new Error(`unknown error-diffusion algorithm: ${algorithm}`);
const pointSize = integerInRange(options.pointSize ?? DEFAULTS.pointSize, 1, 20, "pointSize");
const brightness = numberInRange(options.brightness ?? DEFAULTS.brightness, 0.5, 2, "brightness");
const contrast = numberInRange(options.contrast ?? DEFAULTS.contrast, 0.5, 2, "contrast");
const detail = numberInRange(options.detail ?? DEFAULTS.detail, 0.1, 1, "detail");
const palette = parsePalette(options.palette ?? DEFAULTS.palette);
const blockColumns = Math.ceil(width / pointSize);
const blockRows = Math.ceil(height / pointSize);
const errorLength = errorDiffusionBufferLength(width, height, pointSize);
const errors = errorBuffer ?? new Float32Array(errorLength);
if (!(errors instanceof Float32Array) || errors.length !== errorLength) {
throw new Error(`errorBuffer must be a Float32Array of length ${errorLength}`);
}
errors.fill(0);
const centerOffset = Math.floor(pointSize / 2);
for (let blockRow = 0; blockRow < blockRows; blockRow++) {
const blockY = blockRow * pointSize;
for (let blockColumn = 0; blockColumn < blockColumns; blockColumn++) {
const blockX = blockColumn * pointSize;
const centerX = Math.min(blockX + centerOffset, width - 1);
const centerY = Math.min(blockY + centerOffset, height - 1);
const rgbaIndex = (centerY * width + centerX) * 4;
const errorIndex = (blockRow * blockColumns + blockColumn) * 3;
const red = correctedChannel(data[rgbaIndex], errors[errorIndex], brightness, contrast);
const green = correctedChannel(
data[rgbaIndex + 1],
errors[errorIndex + 1],
brightness,
contrast,
);
const blue = correctedChannel(
data[rgbaIndex + 2],
errors[errorIndex + 2],
brightness,
contrast,
);
const luminance = 0.299 * red + 0.587 * green + 0.114 * blue;
const output = palette[Math.min(palette.length - 1, Math.floor(luminance * palette.length))];
for (let y = blockY; y < Math.min(blockY + pointSize, height); y++) {
for (let x = blockX; x < Math.min(blockX + pointSize, width); x++) {
const outputIndex = (y * width + x) * 4;
data[outputIndex] = Math.round(output[0] * 255);
data[outputIndex + 1] = Math.round(output[1] * 255);
data[outputIndex + 2] = Math.round(output[2] * 255);
}
}
for (const [dx, dy, weight] of diffusion.kernel) {
const targetColumn = blockColumn + dx;
const targetRow = blockRow + dy;
if (
targetColumn < 0 ||
targetColumn >= blockColumns ||
targetRow < 0 ||
targetRow >= blockRows
) {
continue;
}
const target = (targetRow * blockColumns + targetColumn) * 3;
const scale = (weight / diffusion.divisor) * detail;
errors[target] += (red - output[0]) * scale;
errors[target + 1] += (green - output[1]) * scale;
errors[target + 2] += (blue - output[2]) * scale;
}
}
}
return data;
}
function correctedChannel(byte, error, brightness, contrast) {
return Math.min(1, Math.max(0, ((byte / 255 - 0.5) * contrast + 0.5) * brightness + error));
}
function parsePalette(colors) {
if (!Array.isArray(colors) || colors.length < 2 || colors.length > 6) {
throw new Error("palette must contain 2 to 6 colors");
}
return colors.map((color) => {
const match = /^#([0-9a-f]{6})$/i.exec(color);
if (!match) throw new Error(`palette color must use #rrggbb: ${color}`);
const value = Number.parseInt(match[1], 16);
return [(value >> 16) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];
});
}
function numberInRange(value, min, max, name) {
const number = Number(value);
if (!Number.isFinite(number) || number < min || number > max) {
throw new Error(`${name} must be between ${min} and ${max}`);
}
return number;
}
function integerInRange(value, min, max, name) {
const number = Number(value);
if (!Number.isInteger(number) || number < min || number > max) {
throw new Error(`${name} must be an integer between ${min} and ${max}`);
}
return number;
}
scripts/lib/error-diffusion.test.mjsimport assert from "node:assert/strict";
import test from "node:test";
import { ERROR_DIFFUSION_ALGORITHMS, applyErrorDiffusionRgba } from "./error-diffusion.mjs";
const EXPECTED_GRADIENTS = {
"floyd-steinberg": "00000101/00010101/00100101/00010111/01010101/01011011",
atkinson: "00000011/00001100/00010011/00010111/01001101/00111011",
"jarvis-judice-ninke": "00000011/00001011/00011001/00101111/00100111/01011011",
stucki: "00000011/00010101/00010110/00101011/00101101/01010111",
burkes: "00000101/00010011/00010110/00101011/01010111/00101011",
sierra: "00000011/00010101/00010110/00100111/00110111/00101101",
"sierra-lite": "00000101/00010101/00100101/00010110/01010111/01010101",
"two-row-sierra": "00000101/00010011/00010110/00101011/00101101/01011011",
};
test("exposes the eight article error-diffusion algorithms", () => {
assert.deepEqual(Object.keys(ERROR_DIFFUSION_ALGORITHMS), Object.keys(EXPECTED_GRADIENTS));
});
test("matches deterministic golden patterns for every diffusion kernel", () => {
const width = 8;
const height = 6;
const source = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const value = Math.round((255 * (x + y * 0.7)) / (width - 1 + (height - 1) * 0.7));
const offset = (y * width + x) * 4;
source[offset] = value;
source[offset + 1] = Math.round(value * 0.8);
source[offset + 2] = Math.round(value * 0.55);
source[offset + 3] = 17 + x + y;
}
}
for (const [algorithm, expected] of Object.entries(EXPECTED_GRADIENTS)) {
const output = source.slice();
applyErrorDiffusionRgba(output, width, height, {
algorithm,
brightness: 1,
contrast: 1,
detail: 1,
palette: ["#000000", "#ffffff"],
pointSize: 1,
});
const rows = [];
for (let y = 0; y < height; y++) {
let row = "";
for (let x = 0; x < width; x++) row += output[(y * width + x) * 4] ? "1" : "0";
rows.push(row);
}
assert.equal(rows.join("/"), expected, algorithm);
for (let i = 0; i < width * height; i++) assert.equal(output[i * 4 + 3], source[i * 4 + 3]);
}
});
test("fills point-size blocks from their center sample", () => {
const data = new Uint8ClampedArray([
0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 4, 0, 0, 0, 5, 255, 255, 255, 6, 0, 0, 0, 7, 255,
255, 255, 8,
]);
applyErrorDiffusionRgba(
data,
4,
2,
{
algorithm: "floyd-steinberg",
brightness: 1,
contrast: 1,
detail: 1,
palette: ["#000000", "#ffffff"],
pointSize: 2,
},
new Float32Array(6),
);
assert.deepEqual(
[...data],
[
255, 255, 255, 1, 255, 255, 255, 2, 255, 255, 255, 3, 255, 255, 255, 4, 255, 255, 255, 5, 255,
255, 255, 6, 255, 255, 255, 7, 255, 255, 255, 8,
],
);
});
test("preserves authored palette order and validates the public contract", () => {
const reversed = new Uint8ClampedArray([0, 0, 0, 255]);
applyErrorDiffusionRgba(reversed, 1, 1, {
palette: ["#ffffff", "#000000"],
});
assert.deepEqual([...reversed], [255, 255, 255, 255]);
assert.throws(
() => applyErrorDiffusionRgba(new Uint8ClampedArray(4), 1, 1, { palette: ["#000000"] }),
/2 to 6 colors/,
);
assert.throws(
() =>
applyErrorDiffusionRgba(new Uint8ClampedArray(4), 1, 1, {
algorithm: "ordered-bayer",
}),
/unknown error-diffusion algorithm/,
);
});
scripts/lib/freeze.mjsimport { writeFileSync, copyFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
// ponytail: bound the download so a hostile/runaway URL can't fill the disk.
// 256MB covers any real media asset; raise if 4K video sources ever exceed it.
const MAX_FREEZE_BYTES = 256 * 1024 * 1024;
export async function freezeUrl(url, destPath) {
const where = String(url).slice(0, 80);
const res = await fetch(url);
if (!res.ok) throw new Error(`freeze failed: HTTP ${res.status} for ${where}`);
// Fail fast on an advertised oversize body before reading a single byte.
const declared = Number(res.headers.get("content-length"));
if (declared > MAX_FREEZE_BYTES)
throw new Error(
`freeze failed: ${declared} bytes exceeds ${MAX_FREEZE_BYTES} cap for ${where}`,
);
// Stream and abort once the cap is crossed, so a lying/chunked hostile URL
// can't buffer the whole payload into memory before the check (M1).
const chunks = [];
let total = 0;
for await (const chunk of res.body) {
total += chunk.length;
if (total > MAX_FREEZE_BYTES)
throw new Error(`freeze failed: stream exceeds ${MAX_FREEZE_BYTES} cap for ${where}`);
chunks.push(chunk);
}
if (total === 0) throw new Error(`freeze failed: empty response for ${where}`);
mkdirSync(dirname(destPath), { recursive: true });
writeFileSync(destPath, Buffer.concat(chunks, total));
return total;
}
export function freezeLocalFile(srcPath, destPath) {
mkdirSync(dirname(destPath), { recursive: true });
copyFileSync(srcPath, destPath);
}
// Ingest accepts a DIRECT public media URL only — not a platform page. yt-dlp is
// deliberately out (cloud IPs get blocked, and it's brittle); the supported case
// is "user points at their own file or a direct asset link". A direct URL is a
// non-platform host whose path ends in a known media extension.
const PLATFORM_HOSTS =
/(^|\.)(youtube\.com|youtu\.be|vimeo\.com|tiktok\.com|instagram\.com|twitter\.com|x\.com|facebook\.com|dailymotion\.com)$/i;
const MEDIA_EXT = /\.(mp3|wav|m4a|aac|ogg|flac|mp4|mov|webm|mkv|png|jpe?g|webp|gif|svg|avif)$/i;
// SSRF guard (m11): a user-supplied --from URL must not point at the local host
// or a private network. Blocks loopback/localhost, RFC1918, link-local, and the
// IPv6 equivalents on the literal hostname.
// ponytail: literal-host check only; a DNS name that *resolves* to a private IP
// (rebinding) still passes — add resolve-then-check if --from ever fetches from
// untrusted hostnames at scale.
const PRIVATE_HOST =
/^(localhost|.*\.local|.*\.internal|127\.|10\.|0\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|\[?(::1|::ffff:127\.|f[cd][0-9a-f]{2}:|fe80:))/i;
export function isDirectMediaUrl(u) {
let url;
try {
url = new URL(u);
} catch {
return false;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
if (PLATFORM_HOSTS.test(url.hostname)) return false;
if (PRIVATE_HOST.test(url.hostname)) return false;
return MEDIA_EXT.test(url.pathname);
}
scripts/lib/freeze.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import { isDirectMediaUrl } from "./freeze.mjs";
test("accepts direct public media URLs", () => {
assert.equal(isDirectMediaUrl("https://cdn.example.com/clip.mp4"), true);
assert.equal(isDirectMediaUrl("https://example.com/a/b/track.mp3"), true);
assert.equal(isDirectMediaUrl("http://example.com/logo.svg"), true);
});
test("rejects platform pages (no yt-dlp)", () => {
assert.equal(isDirectMediaUrl("https://www.youtube.com/watch?v=abc"), false);
assert.equal(isDirectMediaUrl("https://youtu.be/abc"), false);
assert.equal(isDirectMediaUrl("https://vimeo.com/12345"), false);
assert.equal(isDirectMediaUrl("https://x.com/u/status/1"), false);
});
test("rejects non-direct / non-media URLs", () => {
assert.equal(isDirectMediaUrl("https://example.com/page"), false, "no media extension");
assert.equal(isDirectMediaUrl("ftp://example.com/a.mp4"), false, "non-http(s)");
assert.equal(isDirectMediaUrl("not a url"), false);
});
test("rejects local / private hosts (SSRF guard, m11)", () => {
for (const u of [
"http://localhost/a.mp4",
"http://127.0.0.1/a.mp4",
"http://127.1.2.3/a.mp4",
"http://0.0.0.0/a.mp4",
"http://10.0.0.5/a.mp4",
"http://192.168.1.1/a.mp4",
"http://172.16.0.1/a.mp4",
"http://172.31.255.255/a.mp4",
"http://169.254.169.254/a.mp4", // cloud metadata endpoint
"http://printer.local/a.mp4",
"http://svc.internal/a.mp4",
"http://[::1]/a.mp4",
"http://[fe80::1]/a.mp4",
"http://[fd00::1]/a.mp4",
]) {
assert.equal(isDirectMediaUrl(u), false, `should block ${u}`);
}
// A public host that merely starts with similar digits is still allowed.
assert.equal(isDirectMediaUrl("https://172.40.0.1/a.mp4"), true, "172.40 is public");
assert.equal(isDirectMediaUrl("https://11.example.com/a.mp4"), true);
});
scripts/lib/grade-analyzer.mjs// Vendored plain-JS copy of packages/core/src/mediaGradeAnalyzer.ts.
// packages/core/src/mediaGradeAnalyzer.vendoredParity.test.ts guards behavior drift.
import { execFileSync } from "node:child_process";
import { basename, extname } from "node:path";
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff"]);
const SAMPLE_FRAMES = 5;
const DEFAULT_TIMEOUT_MS = 15_000;
const ADJUST_LIMITS = {
exposure: { min: -2, max: 2 },
contrast: { min: -1, max: 1 },
whites: { min: -1, max: 1 },
blacks: { min: -1, max: 1 },
temperature: { min: -1, max: 1 },
tint: { min: -1, max: 1 },
};
function clamp(value, key) {
const limit = ADJUST_LIMITS[key];
if (!Number.isFinite(value)) return 0;
return Math.min(limit.max, Math.max(limit.min, value));
}
function round(value) {
const rounded = Math.round(value * 1000) / 1000;
return Object.is(rounded, -0) ? 0 : rounded;
}
function average(values) {
return values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length);
}
function probeMedia(mediaPath, ffprobePath) {
try {
const raw = execFileSync(
ffprobePath,
[
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=color_space,color_transfer,color_primaries,pix_fmt,duration:format=duration",
"-of",
"json",
"--",
mediaPath,
],
{ encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "pipe"] },
);
const parsed = JSON.parse(raw);
const stream = Array.isArray(parsed.streams) ? (parsed.streams[0] ?? {}) : {};
const duration = Number(stream.duration ?? parsed.format?.duration);
const text = (key) =>
typeof stream[key] === "string" && stream[key] ? stream[key] : "unknown";
return {
duration: Number.isFinite(duration) && duration > 0 ? duration : null,
colorSpace: text("color_space"),
transfer: text("color_transfer"),
primaries: text("color_primaries"),
pixelFormat: text("pix_fmt"),
};
} catch {
return {
duration: null,
colorSpace: "unknown",
transfer: "unknown",
primaries: "unknown",
pixelFormat: "unknown",
};
}
}
export function parseMediaTreatmentSignalStats(raw) {
const frames = [];
let current = null;
for (const line of String(raw).split(/\r?\n/)) {
// One digit is enough: .* consumes the rest without overlapping repetitions.
const frame = line.match(/^frame:\d.*pts_time:([+-]?(?:\d+(?:\.\d+)?|\.\d+))/);
if (frame) {
if (current) frames.push(current);
current = { ptsTime: Number(frame[1]) };
continue;
}
const stat = line.match(/lavfi\.signalstats\.([A-Z]+)=([+-]?(?:\d+(?:\.\d+)?|\.\d+))/);
if (!stat) continue;
current ??= {};
current[stat[1]] = Number(stat[2]);
}
if (current) frames.push(current);
return frames.filter(
(frame) =>
Number.isFinite(frame.YMIN) &&
Number.isFinite(frame.YLOW) &&
Number.isFinite(frame.YAVG) &&
Number.isFinite(frame.YHIGH) &&
Number.isFinite(frame.YMAX) &&
Number.isFinite(frame.UAVG) &&
Number.isFinite(frame.VAVG),
);
}
function summarizeFrames(frames) {
if (frames.length === 0) throw new Error("FFmpeg returned no analyzable video frames");
return {
frames: frames.length,
yMin: Math.min(...frames.map((frame) => frame.YMIN)),
yLow: average(frames.map((frame) => frame.YLOW)),
yAvg: average(frames.map((frame) => frame.YAVG)),
yHigh: average(frames.map((frame) => frame.YHIGH)),
yMax: Math.max(...frames.map((frame) => frame.YMAX)),
uAvg: average(frames.map((frame) => frame.UAVG)),
vAvg: average(frames.map((frame) => frame.VAVG)),
satAvg: average(frames.map((frame) => frame.SATAVG ?? 0)),
shadowClipRisk: average(frames.map((frame) => (frame.YLOW <= 16 ? 1 : 0))),
highlightClipRisk: average(frames.map((frame) => (frame.YHIGH >= 235 ? 1 : 0))),
};
}
function suggestedExposure(normalizedAverage, yLow, yHigh) {
if (normalizedAverage < 0.28 && yHigh / 255 < 0.65) {
return clamp((0.32 - normalizedAverage) * 1.2, "exposure");
}
if (normalizedAverage > 0.72 && yLow / 255 > 0.3) {
return clamp((0.68 - normalizedAverage) * 1.2, "exposure");
}
return 0;
}
function suggestedTemperature(uAverage, vAverage) {
const chromaWarmth = (vAverage - 128 + (128 - uAverage)) / 128;
return Math.abs(chromaWarmth) >= 0.08 ? clamp(-chromaWarmth * 0.25, "temperature") : 0;
}
function suggestedTint(uAverage, vAverage) {
const cast = uAverage + vAverage - 256;
return Math.abs(cast) >= 10 ? clamp(-cast / 512, "tint") : 0;
}
export function statsToAdjust(stats) {
const yMin = Number(stats.yMin);
const yLow = Number(stats.yLow ?? stats.yMin);
const yMax = Number(stats.yMax);
const yHigh = Number(stats.yHigh ?? stats.yMax);
const yAvg = Number(stats.yAvg);
const uAvg = Number(stats.uAvg);
const vAvg = Number(stats.vAvg);
const shadowClipRisk = Number(stats.shadowClipRisk ?? (yLow <= 16 ? 1 : 0));
const highlightClipRisk = Number(stats.highlightClipRisk ?? (yHigh >= 235 ? 1 : 0));
const percentileSpread = (yHigh - yLow) / 255;
const normalizedAverage = yAvg / 255;
const contrast = percentileSpread < 0.35 ? clamp((0.35 - percentileSpread) * 0.4, "contrast") : 0;
return {
adjust: {
exposure: round(suggestedExposure(normalizedAverage, yLow, yHigh)),
contrast: round(contrast),
blacks: round(clamp(shadowClipRisk * 0.08, "blacks")),
whites: round(clamp(-highlightClipRisk * 0.08, "whites")),
temperature: round(suggestedTemperature(uAvg, vAvg)),
tint: round(suggestedTint(uAvg, vAvg)),
},
measured: {
frames: Number(stats.frames ?? 1),
yMin: round(yMin),
yLow: round(yLow),
yAvg: round(yAvg),
yHigh: round(yHigh),
yMax: round(yMax),
uAvg: round(uAvg),
vAvg: round(vAvg),
satAvg: round(Number(stats.satAvg ?? 0)),
shadowClipRisk: round(shadowClipRisk),
highlightClipRisk: round(highlightClipRisk),
},
};
}
export function summarizeMediaTreatmentAnalysis(probe, frames) {
const result = statsToAdjust(summarizeFrames(frames));
const hdr = ["smpte2084", "arib-std-b67"].includes(probe.transfer);
const diagnosis = [];
if (result.measured.shadowClipRisk > 0) {
diagnosis.push("sampled frames contain deep or clipped shadows");
}
if (result.measured.highlightClipRisk > 0) {
diagnosis.push("sampled frames contain bright or clipped highlights");
}
if (diagnosis.length === 0) diagnosis.push("no obvious technical imbalance in sampled frames");
const warnings = [];
if (hdr) warnings.push("HDR transfer detected; the realtime treatment path is SDR/Rec.709.");
if (probe.colorSpace === "unknown" || probe.transfer === "unknown") {
warnings.push(
"Source color metadata is incomplete; camera LOG cannot be identified reliably from container metadata alone.",
);
}
return {
...result,
source: {
colorSpace: probe.colorSpace,
transfer: probe.transfer,
primaries: probe.primaries,
pixelFormat: probe.pixelFormat,
hdr,
log: "unknown",
},
diagnosis,
warnings,
};
}
export function analyzeMediaGrade(
mediaPath,
{ ffmpegPath = "ffmpeg", ffprobePath = "ffprobe" } = {},
) {
try {
const probe = probeMedia(mediaPath, ffprobePath);
const isImage = IMAGE_EXT.has(extname(mediaPath).toLowerCase());
const fps =
!isImage && probe.duration
? Math.max(0.1, Math.min(2, SAMPLE_FRAMES / probe.duration))
: null;
const filters = [
fps ? `fps=${fps.toFixed(4)}` : null,
"format=yuv444p",
"signalstats",
"metadata=print:file=-",
]
.filter(Boolean)
.join(",");
const raw = execFileSync(
ffmpegPath,
[
"-hide_banner",
"-nostdin",
"-v",
"error",
"-i",
mediaPath,
"-vf",
filters,
"-frames:v",
String(SAMPLE_FRAMES),
"-f",
"null",
"-",
],
{
encoding: "utf8",
timeout: Number(process.env.HYPERFRAMES_ANALYZE_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS,
stdio: ["ignore", "pipe", "pipe"],
},
);
return summarizeMediaTreatmentAnalysis(probe, parseMediaTreatmentSignalStats(raw));
} catch (error) {
throw new Error(`grade analysis failed for ${mediaPath}: ${error.message}`);
}
}
export function formatMeasuredNote(mediaPath, measured) {
return `media-use: measured ${basename(mediaPath)}: frames=${measured.frames}, YMIN=${measured.yMin}, YLOW=${measured.yLow}, YAVG=${measured.yAvg}, YHIGH=${measured.yHigh}, YMAX=${measured.yMax}, UAVG=${measured.uAvg}, VAVG=${measured.vAvg}; adjust is a starting suggestion`;
}
scripts/lib/grade-analyzer.test.mjsimport { strict as assert } from "node:assert";
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import {
analyzeMediaGrade,
formatMeasuredNote,
parseMediaTreatmentSignalStats,
statsToAdjust,
summarizeMediaTreatmentAnalysis,
} from "./grade-analyzer.mjs";
const SIGNALSTATS = `frame:0 pts:0 pts_time:0
lavfi.signalstats.YMIN=0
lavfi.signalstats.YLOW=8
lavfi.signalstats.YAVG=100
lavfi.signalstats.YHIGH=240
lavfi.signalstats.YMAX=255
lavfi.signalstats.UAVG=120
lavfi.signalstats.VAVG=140
lavfi.signalstats.SATAVG=40
frame:1 pts:2000 pts_time:2
lavfi.signalstats.YMIN=10
lavfi.signalstats.YLOW=20
lavfi.signalstats.YAVG=130
lavfi.signalstats.YHIGH=220
lavfi.signalstats.YMAX=245
lavfi.signalstats.UAVG=130
lavfi.signalstats.VAVG=125
lavfi.signalstats.SATAVG=60`;
// The "Test: skills" CI job runs bare `node --test` with no ffmpeg on PATH (by
// design — skills tests are meant to be node-builtin-only). Tests that shell to
// ffmpeg skip there and run wherever ffmpeg is present (locally, dev).
const FFMPEG_SKIP =
spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0
? false
: "ffmpeg not on PATH";
const ADJUST_LIMITS = {
exposure: { min: -2, max: 2 },
contrast: { min: -1, max: 1 },
highlights: { min: -1, max: 1 },
shadows: { min: -1, max: 1 },
whites: { min: -1, max: 1 },
blacks: { min: -1, max: 1 },
temperature: { min: -1, max: 1 },
tint: { min: -1, max: 1 },
vibrance: { min: -1, max: 1 },
saturation: { min: -1, max: 1 },
};
function makeFrame(dir, name, color) {
const out = join(dir, name);
execFileSync(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
`color=c=${color}:s=64x64`,
"-frames:v",
"1",
"-y",
out,
],
{ stdio: "pipe" },
);
return out;
}
function assertWithinLimits(adjust) {
for (const [key, value] of Object.entries(adjust)) {
const limit = ADJUST_LIMITS[key];
assert.ok(limit, `unexpected adjust key ${key}`);
assert.ok(value >= limit.min && value <= limit.max, `${key} out of range: ${value}`);
}
}
test("under-exposed synthetic frame suggests positive exposure", { skip: FFMPEG_SKIP }, () => {
const dir = mkdtempSync(join(tmpdir(), "mu-grade-under-"));
try {
const file = makeFrame(dir, "under.png", "0x202020");
const { adjust, measured } = analyzeMediaGrade(file);
assert.ok(measured.frames >= 1);
assert.ok(adjust.exposure > 0, `expected positive exposure, got ${adjust.exposure}`);
assertWithinLimits(adjust);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test(
"over-exposed synthetic frame pulls exposure down without inventing clipping",
{ skip: FFMPEG_SKIP },
() => {
const dir = mkdtempSync(join(tmpdir(), "mu-grade-over-"));
try {
const file = makeFrame(dir, "over.png", "white");
const { adjust } = analyzeMediaGrade(file);
assert.ok(adjust.exposure < 0, `expected negative exposure, got ${adjust.exposure}`);
assert.ok(adjust.whites <= 0, `expected non-positive whites, got ${adjust.whites}`);
assertWithinLimits(adjust);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
test(
"warm-cast synthetic frame suggests negative temperature correction",
{ skip: FFMPEG_SKIP },
() => {
const dir = mkdtempSync(join(tmpdir(), "mu-grade-warm-"));
try {
const file = makeFrame(dir, "warm.png", "orange");
const { adjust } = analyzeMediaGrade(file);
assert.ok(adjust.temperature < 0, `expected cooling correction, got ${adjust.temperature}`);
assertWithinLimits(adjust);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
test("low-spread stats suggest positive contrast", () => {
const { adjust } = statsToAdjust({
frames: 1,
yMin: 104,
yMax: 116,
yAvg: 110,
uAvg: 128,
vAvg: 128,
});
assert.ok(adjust.contrast > 0, `expected positive contrast, got ${adjust.contrast}`);
assertWithinLimits(adjust);
});
test("malformed media fails cleanly", () => {
assert.throws(
() => analyzeMediaGrade(join(tmpdir(), "does-not-exist.png")),
/grade analysis failed/,
);
});
test(
"media path with shell metacharacters is passed as argv, not a shell string",
{
skip: FFMPEG_SKIP,
},
() => {
const dir = mkdtempSync(join(tmpdir(), "mu-grade-shell-"));
const sentinel = join(process.cwd(), "mu-grade-shell-sentinel.png");
try {
if (existsSync(sentinel)) unlinkSync(sentinel);
const file = makeFrame(dir, "frame; touch mu-grade-shell-sentinel.png", "orange");
const result = analyzeMediaGrade(file);
assert.ok(result.measured.frames >= 1);
assert.equal(existsSync(sentinel), false);
} finally {
if (existsSync(sentinel)) unlinkSync(sentinel);
rmSync(dir, { recursive: true, force: true });
}
},
);
test("measured note is a stderr-safe single-line summary", () => {
const note = formatMeasuredNote("/tmp/frame.png", {
frames: 1,
yMin: 10,
yMax: 240,
yAvg: 80,
uAvg: 120,
vAvg: 140,
});
assert.match(note, /^media-use: measured /);
assert.match(note, /YMIN=10/);
assert.match(note, /YMAX=240/);
assert.match(note, /YAVG=80/);
assert.equal(note.includes("\n"), false);
});
test("parses deterministic FFmpeg signalstats frames", () => {
const frames = parseMediaTreatmentSignalStats(SIGNALSTATS);
assert.equal(frames.length, 2);
assert.deepEqual(
{
ptsTime: frames[0]?.ptsTime,
YLOW: frames[0]?.YLOW,
YAVG: frames[0]?.YAVG,
YHIGH: frames[0]?.YHIGH,
SATAVG: frames[0]?.SATAVG,
},
{ ptsTime: 0, YLOW: 8, YAVG: 100, YHIGH: 240, SATAVG: 40 },
);
});
test("keeps adjust output while adding HDR metadata and warnings", () => {
const result = summarizeMediaTreatmentAnalysis(
{
duration: 4,
colorSpace: "bt2020nc",
transfer: "smpte2084",
primaries: "bt2020",
pixelFormat: "yuv420p10le",
},
parseMediaTreatmentSignalStats(SIGNALSTATS),
);
assert.deepEqual(
{
blacks: result.adjust.blacks,
whites: result.adjust.whites,
frames: result.measured.frames,
yLow: result.measured.yLow,
yHigh: result.measured.yHigh,
satAvg: result.measured.satAvg,
shadowClipRisk: result.measured.shadowClipRisk,
highlightClipRisk: result.measured.highlightClipRisk,
},
{
blacks: 0.04,
whites: -0.04,
frames: 2,
yLow: 14,
yHigh: 230,
satAvg: 50,
shadowClipRisk: 0.5,
highlightClipRisk: 0.5,
},
);
assert.equal(result.source.hdr, true);
assert.equal(result.source.log, "unknown");
assert.match(result.warnings[0] ?? "", /HDR/);
});
test("does not force middle-gray exposure onto healthy intentional contrast", () => {
const result = summarizeMediaTreatmentAnalysis(
{
duration: 4,
colorSpace: "bt709",
transfer: "bt709",
primaries: "bt709",
pixelFormat: "yuv420p",
},
[
{
YMIN: 20,
YLOW: 42,
YAVG: 122,
YHIGH: 190,
YMAX: 220,
UAVG: 128,
VAVG: 128,
SATAVG: 40,
},
{
YMIN: 18,
YLOW: 38,
YAVG: 118,
YHIGH: 196,
YMAX: 225,
UAVG: 128,
VAVG: 128,
SATAVG: 42,
},
],
);
assert.deepEqual(
{
exposure: result.adjust.exposure,
blacks: result.adjust.blacks,
whites: result.adjust.whites,
temperature: result.adjust.temperature,
tint: result.adjust.tint,
},
{ exposure: 0, blacks: 0, whites: 0, temperature: 0, tint: 0 },
);
});
scripts/lib/heygen-cli.mjsimport { execFileSync } from "node:child_process";
import { track } from "./telemetry.mjs";
// v0.3.0 is the first CLI that can use an OAuth session; v0.1.x/0.2.x reject it
// ("heygen-cli can't use OAuth yet"), and OAuth is what the free-usage path
// needs — so anything below this can't authenticate for free usage at all.
export const HEYGEN_MIN_VERSION = "0.3.0";
// Free-usage path is OAuth (`--oauth` → subscription/free credits); `--api-key`
// bills API credits, so the onboarding steers to OAuth. Keep pipe-to-shell
// installer text out of the runtime module; the docs are the safer source of
// truth for platform-specific setup.
export const HEYGEN_INSTALL_COMMAND =
"Install the CLI from https://developers.heygen.com/cli, then run: heygen auth login --oauth";
export const HEYGEN_AUTH_COMMAND = "heygen auth login --oauth";
export const HEYGEN_UPDATE_COMMAND = "heygen update";
export const HEYGEN_CLIENT_SOURCE_ARGV = ["--headers", "X-HeyGen-Client-Source: media-use"];
export const HEYGEN_NOT_FOUND_MESSAGE = `media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. ${HEYGEN_INSTALL_COMMAND}`;
export const HEYGEN_NOT_AUTHENTICATED_MESSAGE = `media-use: heygen CLI not authenticated (free usage) — run: ${HEYGEN_AUTH_COMMAND}`;
export const HEYGEN_OUTDATED_MESSAGE = `media-use: heygen CLI is outdated — run: ${HEYGEN_UPDATE_COMMAND} (need >= v${HEYGEN_MIN_VERSION})`;
const ACTIONABLE_MESSAGES = new Set([
HEYGEN_NOT_FOUND_MESSAGE,
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
HEYGEN_OUTDATED_MESSAGE,
]);
export function classifyHeygenError(err) {
return classifyHeygenErrorResult(err).message;
}
export function classifyHeygenErrorCode(err) {
return classifyHeygenErrorResult(err).code;
}
function classifyHeygenErrorResult(err) {
const detail = heygenErrorDetail(err);
const text = [err?.stderr, err?.stdout, err?.message, detail]
.map((value) => textOf(value))
.filter(Boolean)
.join("\n");
const lower = text.toLowerCase();
// Only ENOENT (spawn of a missing binary) or a shell's "command not found"
// mean the CLI itself is absent. A bare "not found" would misfire on the CLI's
// own resource errors (e.g. a stale voiceId → "voice not found"), whose message
// embeds the `heygen ...` command line — sending users to reinstall a CLI they
// just ran successfully. Keep this narrow.
if (err?.code === "ENOENT" || lower.includes("command not found")) {
return { code: "not_found", message: HEYGEN_NOT_FOUND_MESSAGE };
}
if (
lower.includes("unauthorized") ||
lower.includes("unauthenticated") ||
// \b401\b, not a bare "401" substring — otherwise request IDs (req-401abc),
// URLs, and retry-after headers would misclassify as an auth failure.
/\b401\b/.test(lower) ||
lower.includes("not logged in") ||
lower.includes("no api key") ||
lower.includes("missing api key") ||
lower.includes("invalid api key") ||
lower.includes("login required") ||
lower.includes("auth required") ||
lower.includes("authentication required")
) {
return { code: "not_authenticated", message: HEYGEN_NOT_AUTHENTICATED_MESSAGE };
}
const version = firstSemver(text);
if (version && versionLessThan(version, HEYGEN_MIN_VERSION)) {
return { code: "outdated", message: HEYGEN_OUTDATED_MESSAGE };
}
if (
lower.includes("rate limit") ||
lower.includes("quota") ||
lower.includes("insufficient credit") ||
lower.includes("too many requests") ||
lower.includes("throttled") ||
/\b429\b/.test(lower)
) {
return { code: "rate_limited", message: detail };
}
return { code: "other", message: detail };
}
// reportHeygenFailure's callers (voice-provider.mjs, heygen-search.mjs) are
// synchronous and several layers below the CLI's process.exit() calls, so
// they can't await this tracking call themselves. Stash each attempt's
// promise here so a caller closer to exit (resolve.mjs) can join it first —
// same "awaited so a short-lived run flushes it" discipline telemetry.mjs's
// track() already documents, just reachable from a sync call site.
const pendingFailureTracking = new Set();
// resolve.mjs is a single-shot CLI (one resolve per process), so one shared
// consume-once slot is sufficient. If resolve becomes an in-process/concurrent
// API, move this state into a per-resolve context before reusing that path.
let pendingRemediation = null;
export function consumeHeygenRemediation() {
const remediation = pendingRemediation;
pendingRemediation = null;
return remediation;
}
export function reportHeygenFailure(err, context, trackEvent = track) {
const { code, message } = classifyHeygenErrorResult(err);
if (code === "not_found" || code === "outdated") {
pendingRemediation = { code, message };
}
if (ACTIONABLE_MESSAGES.has(message)) {
console.error(message);
} else {
console.error(`media-use: \`${context}\` failed: ${message}`);
}
try {
const tracked = Promise.resolve(
trackEvent("media_use_provider_error", { provider: "heygen", reason: code }),
).catch(() => {});
pendingFailureTracking.add(tracked);
void tracked.finally(() => pendingFailureTracking.delete(tracked));
return tracked;
} catch {
// Telemetry must never affect the provider failure path.
return Promise.resolve();
}
}
// Awaits every provider-error track fired since the last flush, so a caller
// about to process.exit() doesn't orphan one mid-request (both are separate,
// non-keepalive HTTP connections with no ordering guarantee otherwise).
// Never rejects: each tracked promise already swallows its own failure.
export async function flushHeygenFailureTracking() {
if (pendingFailureTracking.size === 0) return;
await Promise.all(pendingFailureTracking);
}
// Shared discovery/generation helper for the CLI-shelling providers (voice,
// avatar-video): run a heygen JSON subcommand, report+classify on failure,
// and hand the caller the classified reason via onError (used by callers that
// need to distinguish e.g. not_authenticated from other failures).
export function runHeygenJson(bin, argv, label, onError) {
let out;
try {
out = execFileSync(bin, argv, {
encoding: "utf8",
timeout: 120000,
stdio: ["pipe", "pipe", "pipe"],
});
} catch (err) {
reportHeygenFailure(err, `${bin} ${label}`);
onError?.(classifyHeygenErrorCode(err));
return null;
}
try {
return JSON.parse(out);
} catch {
console.error(`media-use: \`${bin} ${label}\` returned non-JSON output`);
return null;
}
}
export function firstSemver(text) {
const match = String(text || "").match(/\bv?(\d+)\.(\d+)\.(\d+)\b/);
return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
}
export function versionLessThan(version, minimum) {
const left = versionParts(version);
const right = versionParts(minimum);
if (!left || !right) return false;
for (let i = 0; i < 3; i++) {
if (left[i] < right[i]) return true;
if (left[i] > right[i]) return false;
}
return false;
}
function heygenErrorDetail(err) {
return textOf(err?.stderr) || textOf(err?.stdout) || err?.message || String(err);
}
function textOf(value) {
return value == null ? "" : String(value).trim();
}
function versionParts(version) {
const match = String(version || "").match(/^v?(\d+)\.(\d+)\.(\d+)$/);
return match ? match.slice(1).map((part) => Number.parseInt(part, 10)) : null;
}
scripts/lib/heygen-cli.test.mjsimport { strict as assert } from "node:assert";
import { spawnSync } from "node:child_process";
import { test } from "node:test";
import {
classifyHeygenError,
classifyHeygenErrorCode,
consumeHeygenRemediation,
flushHeygenFailureTracking,
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
HEYGEN_NOT_FOUND_MESSAGE,
HEYGEN_OUTDATED_MESSAGE,
reportHeygenFailure,
} from "./heygen-cli.mjs";
function captureFailureReport(err, context, trackEvent) {
const originalError = console.error;
const stderrCalls = [];
console.error = (...args) => stderrCalls.push(args);
try {
if (trackEvent) {
reportHeygenFailure(err, context, trackEvent);
} else {
reportHeygenFailure(err, context);
}
} finally {
console.error = originalError;
}
return stderrCalls;
}
test("classifies ENOENT-style missing heygen errors with install instructions", () => {
const message = classifyHeygenError({ code: "ENOENT", message: "spawn heygen ENOENT" });
assert.equal(message, HEYGEN_NOT_FOUND_MESSAGE);
});
test("classifies auth failures with login instructions", () => {
const message = classifyHeygenError({ stderr: Buffer.from("Error: not logged in") });
assert.equal(message, HEYGEN_NOT_AUTHENTICATED_MESSAGE);
});
test("classifies a real 401 as auth, but not a bare 401 substring in prose", () => {
assert.equal(
classifyHeygenError({ stderr: Buffer.from("HTTP 401 Unauthorized") }),
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
);
// A request id that merely contains "401" must NOT read as an auth failure.
const noise = classifyHeygenError({ stderr: Buffer.from("upload failed (request req-401abc)") });
assert.notEqual(noise, HEYGEN_NOT_AUTHENTICATED_MESSAGE);
});
test("classifies old heygen versions with update instructions", () => {
const message = classifyHeygenError({
stderr: Buffer.from("heygen v0.1.5 does not support --headers"),
});
assert.equal(message, HEYGEN_OUTDATED_MESSAGE);
});
test("does not misclassify a resource 'not found' error as a missing CLI", () => {
// A stale voiceId makes `heygen voice speech create` fail with "voice not
// found"; the error message embeds the `heygen ...` command line. This must
// pass through as detail, not send the user to reinstall a working CLI.
const message = classifyHeygenError({
stderr: Buffer.from("Error: voice not found (id: stale-123)"),
message: "Command failed: heygen voice speech create --voice stale-123",
});
assert.notEqual(message, HEYGEN_NOT_FOUND_MESSAGE);
assert.equal(message, "Error: voice not found (id: stale-123)");
});
test("classifies a shell 'command not found' as a missing CLI", () => {
const message = classifyHeygenError({ stderr: Buffer.from("bash: heygen: command not found") });
assert.equal(message, HEYGEN_NOT_FOUND_MESSAGE);
});
test("passes through unrelated errors", () => {
const message = classifyHeygenError({
stderr: Buffer.from("rate limit exceeded"),
message: "Command failed",
});
assert.equal(message, "rate limit exceeded");
});
test("classifies existing HeyGen failures with stable reason codes", () => {
assert.equal(classifyHeygenErrorCode({ code: "ENOENT" }), "not_found");
assert.equal(
classifyHeygenErrorCode({ stderr: Buffer.from("HTTP 401 Unauthorized") }),
"not_authenticated",
);
assert.equal(
classifyHeygenErrorCode({ stderr: Buffer.from("heygen v0.1.5 is unsupported") }),
"outdated",
);
assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from("provider unavailable") }), "other");
});
test("classifies rate-limit text case-insensitively", () => {
assert.equal(
classifyHeygenErrorCode({ stderr: Buffer.from("RATE LIMIT exceeded") }),
"rate_limited",
);
});
test("classifies quota and insufficient-credit errors as rate limited", () => {
for (const detail of ["Quota exhausted", "INSUFFICIENT CREDIT remaining"]) {
assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from(detail) }), "rate_limited");
}
});
test("classifies the literal 429 reason phrase and throttling language as rate limited", () => {
for (const detail of ["Too Many Requests", "Error: throttled by upstream, retry later"]) {
assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from(detail) }), "rate_limited");
}
});
test("does not misclassify unrelated errors that share a word with the new phrasing", () => {
// Shares "too many" with "too many requests" but is a distinct failure (fd
// exhaustion, not a rate limit) — the match must require the full phrase.
assert.equal(
classifyHeygenErrorCode({ stderr: Buffer.from("Too many open file descriptors") }),
"other",
);
});
test("classifies a bare 429 as rate limited without matching request IDs", () => {
assert.equal(
classifyHeygenErrorCode({ stderr: Buffer.from("HTTP 429 Too Many Requests") }),
"rate_limited",
);
assert.equal(
classifyHeygenErrorCode({ stderr: Buffer.from("request req-429abc failed") }),
"other",
);
});
test("tracks not-found failures without changing actionable output", () => {
const trackingCalls = [];
const stderrCalls = captureFailureReport({ code: "ENOENT" }, "heygen asset search", (...args) =>
trackingCalls.push(args),
);
assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]);
assert.deepEqual(trackingCalls, [
["media_use_provider_error", { provider: "heygen", reason: "not_found" }],
]);
});
test("records missing and outdated CLI remediation once", () => {
consumeHeygenRemediation();
captureFailureReport({ code: "ENOENT" }, "heygen audio sounds list", () => {});
assert.deepEqual(consumeHeygenRemediation(), {
code: "not_found",
message: HEYGEN_NOT_FOUND_MESSAGE,
});
assert.equal(consumeHeygenRemediation(), null);
captureFailureReport(
{ stderr: Buffer.from("heygen v0.1.5 does not support --headers") },
"heygen audio sounds list",
() => {},
);
assert.deepEqual(consumeHeygenRemediation(), {
code: "outdated",
message: HEYGEN_OUTDATED_MESSAGE,
});
assert.equal(consumeHeygenRemediation(), null);
});
test("does not record non-install remediation", () => {
consumeHeygenRemediation();
for (const error of [
{ stderr: Buffer.from("HTTP 401 Unauthorized") },
{ stderr: Buffer.from("quota exhausted") },
{ stderr: Buffer.from("provider unavailable") },
]) {
captureFailureReport(error, "heygen audio sounds list", () => {});
assert.equal(consumeHeygenRemediation(), null);
}
});
test("tracks generic failures without including raw detail", () => {
const trackingCalls = [];
const stderrCalls = captureFailureReport(
{ stderr: Buffer.from("private provider detail") },
"heygen asset search",
(...args) => trackingCalls.push(args),
);
assert.deepEqual(stderrCalls, [
["media-use: `heygen asset search` failed: private provider detail"],
]);
assert.deepEqual(trackingCalls, [
["media_use_provider_error", { provider: "heygen", reason: "other" }],
]);
});
test("keeps failure output observable when telemetry is opted out", () => {
const previousOptOut = process.env.HYPERFRAMES_NO_TELEMETRY;
process.env.HYPERFRAMES_NO_TELEMETRY = "1";
try {
const stderrCalls = captureFailureReport({ code: "ENOENT" }, "heygen asset search");
assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]);
} finally {
if (previousOptOut === undefined) {
delete process.env.HYPERFRAMES_NO_TELEMETRY;
} else {
process.env.HYPERFRAMES_NO_TELEMETRY = previousOptOut;
}
}
});
test("keeps failure output observable when tracking throws synchronously", () => {
const originalError = console.error;
const stderrCalls = [];
let thrown;
console.error = (...args) => stderrCalls.push(args);
try {
try {
reportHeygenFailure({ code: "ENOENT" }, "heygen asset search", () => {
throw new Error("tracking failed");
});
} catch (err) {
thrown = err;
}
} finally {
console.error = originalError;
}
assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]);
assert.equal(thrown, undefined);
});
test("does not leave rejected tracking promises unhandled", () => {
const moduleUrl = new URL("./heygen-cli.mjs", import.meta.url).href;
const script = `
import { reportHeygenFailure } from ${JSON.stringify(moduleUrl)};
reportHeygenFailure(
{ stderr: "provider unavailable" },
"heygen asset search",
() => Promise.reject(new Error("tracking failed")),
);
await new Promise((resolve) => setImmediate(resolve));
`;
const child = spawnSync(
process.execPath,
["--unhandled-rejections=strict", "--input-type=module", "--eval", script],
{ encoding: "utf8", timeout: 5000 },
);
assert.equal(child.error, undefined);
assert.equal(child.signal, null);
assert.equal(child.status, 0, child.stderr);
assert.equal(child.stderr, "media-use: `heygen asset search` failed: provider unavailable\n");
});
test("flushHeygenFailureTracking waits for a pending report before resolving", async () => {
const events = [];
let releaseTrack;
const gate = new Promise((resolve) => {
releaseTrack = resolve;
});
// Mirrors the real call sites (voice-provider.mjs, heygen-search.mjs):
// fire-and-forget, the return value is never awaited by the caller.
reportHeygenFailure({ code: "ENOENT" }, "heygen voice speech", () =>
gate.then(() => {
events.push("track-settled");
}),
);
const flushed = flushHeygenFailureTracking().then(() => {
events.push("flush-resolved");
});
// Let several pending microtasks drain before releasing the gate, so this
// proves flush is genuinely still waiting on the tracked promise -- not
// merely that it hasn't had a tick yet.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(events, [], "flush must not resolve while the tracked promise is still pending");
releaseTrack();
await flushed;
assert.deepEqual(
events,
["track-settled", "flush-resolved"],
"flush must resolve only after the pending track settles, in that order",
);
});
test("flushHeygenFailureTracking resolves immediately when nothing is pending", async () => {
await flushHeygenFailureTracking();
});
scripts/lib/heygen-search.mjsimport { execFileSync } from "node:child_process";
import { HEYGEN_CLIENT_SOURCE_ARGV, reportHeygenFailure } from "./heygen-cli.mjs";
export function heygenSearch(subcommand, query, { type, limit = 5, minScore } = {}) {
// execFileSync with an argv array (no shell), so query/type/etc. are passed as
// literal arguments — no quoting tricks, no command injection. subcommand is a
// hardcoded multi-word string (e.g. "audio sounds list"), split into tokens.
// Tag the caller via the CLI's allowlisted attribution header (heygen >= v0.3.0).
const args = [...HEYGEN_CLIENT_SOURCE_ARGV, ...subcommand.split(" "), "--query", query];
if (type) args.push("--type", type);
args.push("--limit", String(limit));
// Server-side score floor. Honored by `audio sounds list`; the `asset search`
// backend rejects it, so only audio providers pass minScore (see image-provider).
if (minScore != null) args.push("--min-score", String(minScore));
let out;
try {
out = execFileSync("heygen", args, {
encoding: "utf8",
timeout: 15000,
stdio: ["pipe", "pipe", "pipe"],
});
} catch (err) {
// Don't swallow a broken command / auth failure as "no results" — that turns
// a typo or expired key into a silent dead end. Surface it, then give up.
reportHeygenFailure(err, `heygen ${subcommand}`);
return null;
}
let parsed;
try {
parsed = JSON.parse(out);
} catch {
console.error(`media-use: \`heygen ${subcommand}\` returned non-JSON output`);
return null;
}
if (parsed?.error) {
const e = parsed.error;
console.error(`media-use: \`heygen ${subcommand}\` error: ${e.message ?? JSON.stringify(e)}`);
return null;
}
const data = parsed?.data;
return Array.isArray(data) && data.length > 0 ? data : null;
}
scripts/lib/heygen-search.test.mjsimport { strict as assert } from "node:assert";
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { HEYGEN_CLIENT_SOURCE_HEADERS } from "../../audio/scripts/lib/heygen.mjs";
import { HEYGEN_CLIENT_SOURCE_ARGV } from "./heygen-cli.mjs";
import { heygenSearch } from "./heygen-search.mjs";
test("tags HeyGen searches with the shared media-use client source", () => {
const dir = mkdtempSync(join(tmpdir(), "media-use-heygen-search-"));
const capturePath = join(dir, "argv.log");
const heygenPath = join(dir, "heygen");
const previousPath = process.env.PATH;
const previousCapturePath = process.env.HEYGEN_CAPTURE_PATH;
writeFileSync(
heygenPath,
`#!/bin/sh
printf '%s\\n' "$*" >> "$HEYGEN_CAPTURE_PATH"
printf '%s\\n' '{"data":[{"id":"x"}]}'
`,
);
chmodSync(heygenPath, 0o755);
process.env.PATH = `${dir}:${previousPath ?? ""}`;
process.env.HEYGEN_CAPTURE_PATH = capturePath;
try {
const result = heygenSearch("audio sounds list", "ocean", { limit: 1 });
const argv = readFileSync(capturePath, "utf8").trim();
assert.deepEqual(result, [{ id: "x" }]);
assert.match(argv, /X-HeyGen-Client-Source: media-use/);
} finally {
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
if (previousCapturePath === undefined) delete process.env.HEYGEN_CAPTURE_PATH;
else process.env.HEYGEN_CAPTURE_PATH = previousCapturePath;
rmSync(dir, { recursive: true, force: true });
}
});
test("keeps CLI and REST media-use client source headers in lockstep", () => {
const [entry] = Object.entries(HEYGEN_CLIENT_SOURCE_HEADERS);
assert.ok(entry);
const [key, value] = entry;
assert.equal(HEYGEN_CLIENT_SOURCE_ARGV[1], `${key}: ${value}`);
});
scripts/lib/heygen-video-provider.mjsimport { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { freezeUrl } from "./freeze.mjs";
import {
classifyHeygenErrorCode,
HEYGEN_AUTH_COMMAND,
HEYGEN_CLIENT_SOURCE_ARGV,
reportHeygenFailure,
runHeygenJson,
} from "./heygen-cli.mjs";
export const AVATAR_VIDEO_SIGNIN_MESSAGE = `media-use: avatar video is free for new API users — sign in: ${HEYGEN_AUTH_COMMAND}`;
// Cache only a truthy id -- a transient discovery failure must not poison the
// cache with `null` and permanently disable heygen.video for the rest of the
// process. `onError` lets the caller distinguish not_authenticated (and nudge
// onboarding) from any other discovery failure.
let cachedAvatarId;
function defaultAvatarId(onError) {
if (cachedAvatarId) return cachedAvatarId;
const j = runHeygenJson(
"heygen",
["avatar", "list", "--ownership", "public", "--limit", "1"],
"avatar list",
onError,
);
cachedAvatarId = j?.data?.[0]?.avatar_id || null;
return cachedAvatarId;
}
let cachedStarfishVoiceId;
function defaultStarfishVoiceId(onError) {
if (cachedStarfishVoiceId) return cachedStarfishVoiceId;
const j = runHeygenJson(
"heygen",
["voice", "list", "--engine", "starfish", "--limit", "1"],
"voice list",
onError,
);
cachedStarfishVoiceId = j?.data?.[0]?.voice_id || null;
return cachedStarfishVoiceId;
}
export async function heygenVideoGenerate(intent, ctx) {
let discoveryFailureReason = null;
const captureReason = (reason) => {
discoveryFailureReason ??= reason;
};
// Short-circuit: once one discovery call fails, the result is null either
// way, so don't attempt the second -- that would double-fire the onboarding
// message and the provider-error telemetry ping for what's really one failure.
const avatarId = ctx?.avatarId || defaultAvatarId(captureReason);
if (!avatarId) {
if (discoveryFailureReason === "not_authenticated") console.error(AVATAR_VIDEO_SIGNIN_MESSAGE);
return null;
}
const voiceId = ctx?.voiceId || defaultStarfishVoiceId(captureReason);
if (!voiceId) {
if (discoveryFailureReason === "not_authenticated") console.error(AVATAR_VIDEO_SIGNIN_MESSAGE);
return null;
}
let out;
try {
out = execFileSync(
"heygen",
[
...HEYGEN_CLIENT_SOURCE_ARGV,
"video",
"create",
"--wait",
"-d",
JSON.stringify({
type: "avatar",
avatar_id: avatarId,
script: intent,
voice_id: voiceId,
}),
],
{
encoding: "utf8",
timeout: 300000,
stdio: ["pipe", "pipe", "pipe"],
},
);
} catch (err) {
if (classifyHeygenErrorCode(err) === "not_authenticated") {
console.error(AVATAR_VIDEO_SIGNIN_MESSAGE);
}
reportHeygenFailure(err, "heygen video create");
return null;
}
let parsed;
try {
parsed = JSON.parse(out);
} catch {
console.error("media-use: `heygen video create` returned invalid JSON");
return null;
}
const videoUrl = parsed?.data?.video_url;
if (typeof videoUrl !== "string" || !videoUrl) {
console.error("media-use: `heygen video create` returned no video URL");
return null;
}
const tmpPath = join(tmpdir(), `media-use-heygen-video-${process.pid}-${Date.now()}.mp4`);
try {
await freezeUrl(videoUrl, tmpPath);
} catch (err) {
console.error(`media-use: heygen video download failed: ${err.message}`);
return null;
}
return {
localPath: tmpPath,
ext: ".mp4",
source: "generated",
metadata: {
description: intent,
provider: "heygen.video",
provenance: { prompt: intent },
},
};
}
scripts/lib/heygen-video-provider.test.mjsimport { strict as assert } from "node:assert";
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import http from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { HEYGEN_NOT_AUTHENTICATED_MESSAGE } from "./heygen-cli.mjs";
import { AVATAR_VIDEO_SIGNIN_MESSAGE } from "./heygen-video-provider.mjs";
const VIDEO_FIXTURE = Buffer.from("tiny heygen video fixture");
let importCount = 0;
async function freshGenerate() {
importCount += 1;
const module = await import(`./heygen-video-provider.mjs?test=${importCount}`);
return module.heygenVideoGenerate;
}
async function listenVideoServer() {
const server = http.createServer((req, res) => {
if (req.url !== "/video.mp4") {
res.writeHead(404).end();
return;
}
res.writeHead(200, {
"content-length": VIDEO_FIXTURE.length,
"content-type": "video/mp4",
});
res.end(VIDEO_FIXTURE);
});
await new Promise((resolve) => server.listen(0, resolve));
const address = server.address();
assert.ok(address && typeof address !== "string");
return {
server,
url: `http://127.0.0.1:${address.port}/video.mp4`,
};
}
async function listenFailingVideoServer() {
const server = http.createServer((req, res) => {
res.writeHead(500).end();
});
await new Promise((resolve) => server.listen(0, resolve));
const address = server.address();
assert.ok(address && typeof address !== "string");
return {
server,
url: `http://127.0.0.1:${address.port}/video.mp4`,
};
}
function closeServer(server) {
return new Promise((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
}
async function withFakeHeygen(options, run) {
const dir = mkdtempSync(join(tmpdir(), "media-use-heygen-video-provider-"));
const capturePath = join(dir, "argv.log");
const heygenPath = join(dir, "heygen");
const previousEnv = {
PATH: process.env.PATH,
HEYGEN_CAPTURE_PATH: process.env.HEYGEN_CAPTURE_PATH,
HEYGEN_VIDEO_MODE: process.env.HEYGEN_VIDEO_MODE,
HEYGEN_VIDEO_RESPONSE: process.env.HEYGEN_VIDEO_RESPONSE,
HEYGEN_DISCOVERY_MODE: process.env.HEYGEN_DISCOVERY_MODE,
HYPERFRAMES_NO_TELEMETRY: process.env.HYPERFRAMES_NO_TELEMETRY,
};
writeFileSync(
heygenPath,
`#!/bin/sh
printf '%s\\n' "$*" >> "$HEYGEN_CAPTURE_PATH"
case "$*" in
*"avatar list"*)
case "$HEYGEN_DISCOVERY_MODE" in
auth) printf '%s\\n' 'HTTP 401 Unauthorized' >&2; exit 1 ;;
*) printf '%s\\n' '{"data":[{"avatar_id":"avatar-public-1"}]}' ;;
esac
;;
*"voice list"*)
case "$HEYGEN_DISCOVERY_MODE" in
auth) printf '%s\\n' 'HTTP 401 Unauthorized' >&2; exit 1 ;;
*) printf '%s\\n' '{"data":[{"voice_id":"voice-starfish-1"}]}' ;;
esac
;;
*"video create"*)
case "$HEYGEN_VIDEO_MODE" in
auth) printf '%s\\n' 'HTTP 401 Unauthorized' >&2; exit 1 ;;
other) printf '%s\\n' 'provider unavailable' >&2; exit 1 ;;
*) printf '%s\\n' "$HEYGEN_VIDEO_RESPONSE" ;;
esac
;;
esac
`,
);
chmodSync(heygenPath, 0o755);
process.env.PATH = `${dir}:${previousEnv.PATH ?? ""}`;
process.env.HEYGEN_CAPTURE_PATH = capturePath;
process.env.HEYGEN_VIDEO_MODE = options.mode ?? "success";
process.env.HEYGEN_VIDEO_RESPONSE = options.response ?? "";
process.env.HEYGEN_DISCOVERY_MODE = options.discoveryMode ?? "";
process.env.HYPERFRAMES_NO_TELEMETRY = "1";
try {
return await run({
invocations: () => readFileSync(capturePath, "utf8").trim().split("\n"),
});
} finally {
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
rmSync(dir, { recursive: true, force: true });
}
}
function bodyFromInvocation(invocation) {
const marker = " -d ";
const start = invocation.indexOf(marker);
assert.notEqual(start, -1);
return JSON.parse(invocation.slice(start + marker.length));
}
test("downloads a generated avatar video and returns the generated MP4 result", async () => {
const { server, url } = await listenVideoServer();
let localPath;
try {
await withFakeHeygen(
{ response: JSON.stringify({ data: { video_url: url } }) },
async ({ invocations }) => {
const heygenVideoGenerate = await freshGenerate();
const intent = "Welcome to the HyperFrames launch";
const result = await heygenVideoGenerate(intent, {});
localPath = result?.localPath;
const calls = invocations();
const create = calls.find((call) => call.includes("video create"));
assert.ok(create);
assert.match(create, /--headers X-HeyGen-Client-Source: media-use/);
assert.deepEqual(bodyFromInvocation(create), {
type: "avatar",
avatar_id: "avatar-public-1",
script: intent,
voice_id: "voice-starfish-1",
});
assert.ok(result);
assert.equal(join(tmpdir(), result.localPath.slice(tmpdir().length + 1)), result.localPath);
assert.match(result.localPath, /media-use-heygen-video-\d+-\d+\.mp4$/);
assert.deepEqual(result, {
localPath: result.localPath,
ext: ".mp4",
source: "generated",
metadata: {
description: intent,
provider: "heygen.video",
provenance: { prompt: intent },
},
});
assert.deepEqual(readFileSync(result.localPath), VIDEO_FIXTURE);
},
);
} finally {
if (localPath) rmSync(localPath, { force: true });
await closeServer(server);
}
});
test("tags video creation but not avatar or voice discovery", async () => {
const { server, url } = await listenVideoServer();
let localPath;
try {
await withFakeHeygen(
{ response: JSON.stringify({ data: { video_url: url } }) },
async ({ invocations }) => {
const heygenVideoGenerate = await freshGenerate();
const result = await heygenVideoGenerate("Header regression guard", {});
localPath = result?.localPath;
const calls = invocations();
assert.equal(calls.length, 3);
assert.match(calls[0], /^avatar list --ownership public --limit 1$/);
assert.doesNotMatch(calls[0], /X-HeyGen-Client-Source/);
assert.match(calls[1], /^voice list --engine starfish --limit 1$/);
assert.doesNotMatch(calls[1], /X-HeyGen-Client-Source/);
assert.match(calls[2], /video create/);
assert.match(calls[2], /X-HeyGen-Client-Source: media-use/);
},
);
} finally {
if (localPath) rmSync(localPath, { force: true });
await closeServer(server);
}
});
test("uses explicit avatar and voice overrides without discovery", async () => {
const { server, url } = await listenVideoServer();
let localPath;
try {
await withFakeHeygen(
{ response: JSON.stringify({ data: { video_url: url } }) },
async ({ invocations }) => {
const heygenVideoGenerate = await freshGenerate();
const result = await heygenVideoGenerate("Use my presenter", {
avatarId: "avatar-override",
voiceId: "voice-override",
});
localPath = result?.localPath;
const calls = invocations();
assert.equal(calls.length, 1);
assert.match(calls[0], /video create/);
assert.deepEqual(bodyFromInvocation(calls[0]), {
type: "avatar",
avatar_id: "avatar-override",
script: "Use my presenter",
voice_id: "voice-override",
});
},
);
} finally {
if (localPath) rmSync(localPath, { force: true });
await closeServer(server);
}
});
test("caches discovered avatar and voice IDs for the process", async () => {
const { server, url } = await listenVideoServer();
const localPaths = new Set();
try {
await withFakeHeygen(
{ response: JSON.stringify({ data: { video_url: url } }) },
async ({ invocations }) => {
const heygenVideoGenerate = await freshGenerate();
for (const intent of ["First avatar video", "Second avatar video"]) {
const result = await heygenVideoGenerate(intent, {});
if (result) localPaths.add(result.localPath);
}
const calls = invocations();
assert.equal(calls.filter((call) => call.startsWith("avatar list ")).length, 1);
assert.equal(calls.filter((call) => call.startsWith("voice list ")).length, 1);
assert.equal(calls.filter((call) => call.includes("video create")).length, 2);
},
);
} finally {
for (const localPath of localPaths) rmSync(localPath, { force: true });
await closeServer(server);
}
});
test("prints auth onboarding and reports an unauthenticated create failure", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
await withFakeHeygen({ mode: "auth" }, async () => {
const heygenVideoGenerate = await freshGenerate();
const result = await heygenVideoGenerate("Sign-in failure", {
avatarId: "avatar-override",
voiceId: "voice-override",
});
assert.equal(result, null);
assert.ok(errors.includes(AVATAR_VIDEO_SIGNIN_MESSAGE));
assert.ok(errors.includes(HEYGEN_NOT_AUTHENTICATED_MESSAGE));
});
});
test("reports other create failures without auth onboarding", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
await withFakeHeygen({ mode: "other" }, async () => {
const heygenVideoGenerate = await freshGenerate();
const result = await heygenVideoGenerate("Provider failure", {
avatarId: "avatar-override",
voiceId: "voice-override",
});
assert.equal(result, null);
assert.ok(!errors.includes(AVATAR_VIDEO_SIGNIN_MESSAGE));
assert.ok(errors.includes("media-use: `heygen video create` failed: provider unavailable"));
});
});
test("falls through on non-JSON and error responses", async (t) => {
t.mock.method(console, "error", () => {});
for (const response of ["not JSON", '{"error":{"message":"render failed"}}']) {
await withFakeHeygen({ response }, async () => {
const heygenVideoGenerate = await freshGenerate();
const result = await heygenVideoGenerate("Unusable response", {
avatarId: "avatar-override",
voiceId: "voice-override",
});
assert.equal(result, null);
});
}
});
test("onboards and returns null when avatar/voice discovery itself is unauthenticated", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
// Both avatar list AND voice list would fail unauthenticated (discoveryMode
// "auth" applies to both in the fake CLI) -- the short-circuit after the
// first failure must mean only one is ever attempted, so the onboarding
// message and the provider-error telemetry ping each fire exactly once
// instead of double-firing for what's really one auth failure.
await withFakeHeygen({ discoveryMode: "auth" }, async ({ invocations }) => {
const heygenVideoGenerate = await freshGenerate();
const result = await heygenVideoGenerate("Discovery auth failure", {});
assert.equal(result, null);
assert.equal(
errors.filter((message) => message === AVATAR_VIDEO_SIGNIN_MESSAGE).length,
1,
"onboarding message must fire exactly once, not once per failed discovery call",
);
const calls = invocations();
assert.equal(calls.length, 1, "must short-circuit after the first discovery failure");
assert.match(calls[0], /^avatar list /);
});
});
test("download failure after a successful create returns null and logs a diagnostic", async () => {
const { server, url } = await listenFailingVideoServer();
try {
await withFakeHeygen({ response: JSON.stringify({ data: { video_url: url } }) }, async () => {
const heygenVideoGenerate = await freshGenerate();
const result = await heygenVideoGenerate("Download failure", {
avatarId: "avatar-override",
voiceId: "voice-override",
});
assert.equal(result, null);
});
} finally {
await closeServer(server);
}
});
scripts/lib/image-provider.mjsimport { heygenSearch } from "./heygen-search.mjs";
export const imageProvider = {
async search(intent) {
const results = heygenSearch("asset search", intent, { type: "image" });
if (!results) return null;
const best = results[0];
return {
url: best.url,
source: "search",
// ext derived from the asset URL by resolve.mjs (.jpg/.png/.webp)
metadata: {
description: intent,
width: best.width || null,
height: best.height || null,
transparent: best.is_transparent || false,
provider: "heygen.asset.search",
provenance: { asset_id: best.id, score: best.score },
},
};
},
};
export const iconProvider = {
async search(intent) {
// No minScore: the `asset search` backend rejects --min-score and returns no score field.
const results = heygenSearch("asset search", intent, { type: "icon" });
if (!results) return null;
const best = results[0];
return {
url: best.url,
source: "search",
// ext derived from the asset URL by resolve.mjs — catalog icons are .png, not .svg
metadata: {
description: intent,
width: best.width || null,
height: best.height || null,
transparent: best.is_transparent ?? true,
provider: "heygen.asset.search",
provenance: { asset_id: best.id, score: best.score, type: "icon" },
},
};
},
};
scripts/lib/index-gen.mjsimport { writeFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { readManifest, indexPath } from "./manifest.mjs";
function pad(str, len) {
return String(str ?? "").padEnd(len);
}
function formatDur(record) {
if (record.duration == null) return "—";
return `${record.duration}s`;
}
function formatDims(record) {
if (record.width && record.height) return `${record.width}×${record.height}`;
if (record.type === "icon" && record.transparent) return "svg";
return "—";
}
export function generateIndexContent(records) {
const count = records.length;
const header = `# .media · ${count} asset${count === 1 ? "" : "s"}\n`;
if (count === 0) return header;
const cols = { id: 4, type: 5, dur: 4, dims: 5, path: 5, desc: 11 };
for (const r of records) {
cols.id = Math.max(cols.id, (r.id ?? "").length);
cols.type = Math.max(cols.type, (r.type ?? "").length);
cols.dur = Math.max(cols.dur, formatDur(r).length);
cols.dims = Math.max(cols.dims, formatDims(r).length);
cols.path = Math.max(cols.path, (r.path ?? "").length);
}
const heading =
pad("id", cols.id + 2) +
pad("type", cols.type + 2) +
pad("dur", cols.dur + 2) +
pad("dims", cols.dims + 2) +
pad("path", cols.path + 2) +
"description";
const lines = [header, heading];
for (const r of records) {
lines.push(
pad(r.id, cols.id + 2) +
pad(r.type, cols.type + 2) +
pad(formatDur(r), cols.dur + 2) +
pad(formatDims(r), cols.dims + 2) +
pad(r.path, cols.path + 2) +
(r.description ?? ""),
);
}
return lines.join("\n") + "\n";
}
export function regenerateIndex(projectDir) {
const records = readManifest(projectDir);
const content = generateIndexContent(records);
const p = indexPath(projectDir);
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, content);
return content;
}
scripts/lib/local-models.mjs// Declarative table of USER-INSTALLED local models, for the spec-gated fallback.
//
// These models run on the user's own machine for their own use; media-use
// recommends, spec-checks, and assists install; it does not bundle, redistribute,
// or sell them. Because nothing is redistributed, selection is purely by
// quality / size / spec-fit / word-timestamp support (there is deliberately NO
// license field gating availability).
//
// Tiers (`small`|`medium`|`large`|`xlarge`) are human labels; `needs.ramMB` is
// what selection actually gates on. selectModel() returns the best model that
// fits the machine's AVAILABLE RAM, best-first: by explicit `rank` when set
// (quality that is NOT size, e.g. ASR), else by RAM footprint (the quality
// proxy for generation). No fit -> recommend the CLI/cloud path.
//
// selectModelLadder() returns EVERY fitting model in that same order. Callers
// that can retry walk it so ONE unusable entry (gated weights, a missing
// binary, an OOM) demotes to the next tier instead of killing the local path.
//
// Picks reflect the 2026 research pass, verified live where noted.
export const CAPABILITIES = ["tts", "asr", "upscale", "videogen", "imagegen"];
const MODELS = {
tts: [
{
id: "kokoro",
tier: "medium",
sizeMB: 330,
needs: { ramMB: 2048, gpu: false },
wordTimestamps: "native",
install: "pip install kokoro",
invoke: "python -m kokoro --text {text} --voice {voice} --out {out}",
notes: "CPU, faster-than-realtime, native per-word timestamps. Default floor.",
},
{
id: "fish-speech",
tier: "large",
sizeMB: 1100,
needs: { ramMB: 16000, gpu: true, vramMB: 12000 },
wordTimestamps: "whisperx", // needs forced alignment (run ASR over output)
install: "pip install fish-speech",
invoke: "fish-speech synth --text {text} --ref {ref} --out {out}",
notes: "Expressive zero-shot voice cloning; meeting pick. WhisperX for word timing.",
},
],
asr: [
// Parakeet is BETTER than Whisper yet SMALLER (0.6B vs 1.5B), so quality is
// not size here: `rank` pins it ahead of whisper regardless of footprint.
// Open ASR Leaderboard avg WER: Parakeet ~6.05% vs whisper-large-v3 7.44%
// (~19% better); on NOISY test-other 4.73% vs 5.96%, and whisper-v3
// hallucinated to 308% WER on meetings where Parakeet held. 5-10x faster.
//
// Cohere Transcribe 2B tops the leaderboard (5.42%) and is nominally the most
// accurate, but its mlx-audio community MLX quants (4bit AND 8bit, with and
// without --language en) produced multilingual token-soup garbage AND ran
// 40-70x slower than Parakeet on a 24GB Mac (live-tested 2026-07). Excluded
// until the mlx-audio Cohere decoder stabilizes; Parakeet is the default.
{
id: "parakeet-mlx",
tier: "small",
rank: 0,
sizeMB: 2400,
needs: { ramMB: 4000, gpu: true },
wordTimestamps: "tokens", // sub-word tokens; merged to words by parakeet-words.mjs
repo: "mlx-community/parakeet-tdt-0.6b-v3",
install:
"uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx",
invoke:
"parakeet-mlx {audio} --model mlx-community/parakeet-tdt-0.6b-v3 --output-format json --output-dir {outdir}",
notes:
"NVIDIA Parakeet-TDT 0.6B via parakeet-mlx. VERIFIED on 24GB: accurate transcript, ~3s (cached model) for 8s audio, word timestamps drive transcript-cut. English + 25 European languages. Beats whisper.cpp on accuracy (6.05% vs 7.44% WER) AND speed (5-10x).",
},
{
id: "whisperx",
tier: "medium",
rank: 1,
sizeMB: 1500,
needs: { ramMB: 4096, gpu: false },
wordTimestamps: "native", // faster-whisper + wav2vec2 forced alignment
install: "pip install whisperx",
invoke: "whisperx {audio} --output_format json --out {out}",
notes:
"CPU-only fallback (no GPU): faster-whisper + wav2vec2 forced alignment, native word timestamps. The packaged `hyperframes transcribe` (whisper.cpp) is the zero-setup baseline below this.",
},
],
upscale: [
{
id: "real-esrgan",
tier: "medium",
sizeMB: 70,
needs: { ramMB: 2048, gpu: false },
wordTimestamps: false,
install: "brew install real-esrgan-ncnn-vulkan # or download the ncnn binary",
invoke: "realesrgan-ncnn-vulkan -i {in} -o {out} -s 4",
notes: "ncnn-vulkan binary, CPU-capable. GFPGAN for faces.",
},
{
id: "seedvr2",
tier: "large",
sizeMB: 6000,
needs: { ramMB: 24000, gpu: true, vramMB: 16000 },
wordTimestamps: false,
install: "pip install seedvr2",
invoke: "seedvr2 upscale --in {in} --out {out}",
notes: "Diffusion upscaler, GPU-only. Video2X for video.",
},
],
videogen: [
// 2026-07 X research pass + live verification on a 24GB M-series Mac -
// which reaches the q4 tier only: a 24GB machine cannot select the 32GB
// entry below it, so that tier's claims stay unverified until someone
// runs it on a 32GB+ machine.
// The Mac-local video story is LTX 2.3 on MLX via dgrauet/ltx-2-mlx (the
// pipeline these weights were converted for; also powers Phosphene).
// Wan 2.x MLX exists only as A14B conversions (too large for consumer
// unified memory); revisit when a 5B Wan MLX conversion lands.
// IMPORTANT: sizeMB below is the FULL repo, because that is what a run
// actually downloads. Both invokes pass a repo id to `--model`, and
// upstream resolve_model_dir() (ltx_pipelines_mlx/utils/_orchestration.py)
// calls snapshot_download(repo) with no allow_patterns - so the whole repo
// lands regardless of what you pre-fetched. A targeted `hf download
// --include` subset used to be documented here; it was removed because it
// is both ineffective (the runner refetches the rest at generate time) and
// insufficient (--two-stage needs transformer-dev AND transformer-distilled
// AND the x2 spatial upscaler; --distilled needs an upscaler too). The q4
// tier verified below only worked BECAUSE the download is unfiltered.
{
id: "ltx-2.3-mlx-q4",
tier: "medium",
sizeMB: 59700, // full repo, measured 59.69GB; gemma-3-12b-4bit text encoder adds ~7GB
needs: { ramMB: 16384, gpu: true },
wordTimestamps: false,
install:
'git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras && export PATH="$PWD/.venv/bin:$PATH"',
invoke:
"ltx-2-mlx generate --prompt {prompt} --distilled --low-ram --model dgrauet/ltx-2.3-mlx-q4 --width {w} --height {h} --frames {frames} --frame-rate 24 --output {out}",
notes:
"LTX 2.3 int4 on MLX. Verified on 24GB unified: 512x320 x 33 frames in ~19 min cold (incl. text-encoder download), t2v with audio. Dims must be multiples of 64. i2v, retake/extend, keyframe interpolation supported.",
},
{
id: "ltx-2.3-mlx-q8",
tier: "large",
sizeMB: 87500, // full repo, measured 87.51GB
needs: { ramMB: 32768, gpu: true },
wordTimestamps: false,
install:
'git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras && export PATH="$PWD/.venv/bin:$PATH"',
invoke:
"ltx-2-mlx generate --prompt {prompt} --two-stage --low-ram --model dgrauet/ltx-2.3-mlx-q8 --width {w} --height {h} --frames {frames} --frame-rate 24 --output {out}",
notes:
"LTX 2.3 int8 on MLX, two-stage (upstream production default; higher quality than the q4 distilled tier). Replaced dgrauet/ltx-2.3-mlx-bf16, which is gated (HTTP 401) and cannot be downloaded at all. Costs an 87.5GB download against q4's 59.7GB - a real tradeoff, not a rounding difference. --two-stage is dev model + CFG at half-res, upscale, then distilled LoRA refine (upstream's own help text), so it needs transformer-dev + transformer-distilled + spatial_upscaler_x2; the full snapshot carries all three. --low-ram matches this tier's 32GB floor (block streaming); 64-128GB Macs for long/HD runs. NOT live-verified on a 32GB+ machine - the q4 tier below is the verified one.",
},
],
imagegen: [
// 2026-07 X research + live verification on a 24GB M-series Mac. mflux
// (FLUX-on-MLX) is the Mac-native runner; FLUX is the quality leader. Two
// hard-won findings baked into `needs.ramMB`:
// 1. The OFFICIAL FLUX repos are HF-gated (license wall). Point --path at a
// non-gated community 4-bit re-upload (self-contained, incl. VAE).
// 2. Without --low-ram, FLUX's T5-XXL text encoder + transformer blow past
// 24GB into swap: a 768x512 run took 90 MINUTES. With --low-ram (streams
// components from disk) the SAME machine did 512x512 in ~20s at 7.6GB
// free. So the medium tier's needs.ramMB is the streamed floor, not the
// resident footprint; the large tiers are the no-streaming thresholds.
// The runner resolves `repo` to a local snapshot (hf download) before --path;
// a bare repo id in --path breaks mlx unflatten.
{
id: "flux-schnell-mflux-q4",
tier: "medium",
sizeMB: 8700,
needs: { ramMB: 8000, gpu: true },
repo: "dhairyashil/FLUX.1-schnell-mflux-4bit",
wordTimestamps: false,
install: "uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6",
invoke:
"mflux-generate --model schnell --path {model_path} --low-ram --steps 4 --prompt {prompt} --width {w} --height {h} --seed {seed} --output {out}",
notes:
"FLUX.1 schnell int4. VERIFIED on 24GB (7.6GB free): --low-ram 512x512 in ~20s, photoreal. --low-ram is MANDATORY at this tier (streams to avoid swap). Few-step, fast.",
},
{
id: "flux2-klein-mflux-q4",
tier: "large",
sizeMB: 12000,
needs: { ramMB: 32000, gpu: true },
repo: "Runpod/FLUX.2-klein-4B-mflux-4bit",
wordTimestamps: false,
install: "uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux",
invoke:
"mflux-generate --base-model flux2-klein-4b --path {model_path} --steps 8 --prompt {prompt} --width {w} --height {h} --seed {seed} --output {out}",
notes:
"FLUX.2 Klein 4B int4 (most-downloaded mflux community repo). Newer, higher quality than schnell; full-resident (no streaming) so needs 32GB+ to stay fast. Needs mflux >= 0.18 for the flux2-klein base model.",
},
{
id: "qwen-image-mflux",
tier: "xlarge",
sizeMB: 40000,
needs: { ramMB: 64000, gpu: true },
repo: "Qwen/Qwen-Image",
wordTimestamps: false,
install: "uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux",
invoke:
"mflux-generate --base-model qwen --steps 20 --prompt {prompt} --width {w} --height {h} --seed {seed} --output {out}",
notes:
"Qwen-Image, top-tier quality. Heavy: 'several minutes' even on 128GB M4 Max, 'almost fried' a 32GB M4 Pro. 64GB+ only. Below that, the cloud upsell (codex) is faster and better.",
},
],
};
function tableFor(capability) {
const t = MODELS[capability];
if (!t) throw new Error(`unknown local-model capability: ${capability}`);
return t;
}
/** All local models for a capability. */
export function listModels(capability) {
return tableFor(capability).slice();
}
// Tokenize an `invoke` template on whitespace first, then substitute each
// token, so a `{prompt}`/`{model_path}` value with spaces stays a single argv
// entry. Shared by every local-model provider (mflux, LTX) that builds argv
// from a MODELS[...].invoke template.
export function buildArgv(template, vars) {
return template
.trim()
.split(/\s+/)
.map((tok) => tok.replace(/\{(\w+)\}/g, (_, k) => (k in vars ? String(vars[k]) : `{${k}}`)));
}
/** Does this machine meet a model's needs? Apple Silicon unified memory counts as VRAM. */
export function meetsSpecs(model, specs) {
const n = model.needs || {};
// Gate on AVAILABLE RAM when the probe reported it (the real budget with the
// OS + open apps resident); fall back to total RAM otherwise. Older specs
// objects (and unit fixtures) that only set ramMB keep working unchanged.
const budget = specs.availableRamMB ?? specs.ramMB;
if (n.ramMB && budget < n.ramMB) return false;
if (n.gpu && !specs.gpu?.present) return false;
if (n.vramMB) {
const vram = specs.gpu?.vramMB ?? 0;
if (vram < n.vramMB) return false;
}
return true;
}
// "Best model the machine can run" == best-first among those that fit. Ordering:
// 1. explicit `rank` (lower = better) when a model declares it. Needed where
// quality is NOT size: Parakeet-0.6B beats Whisper-large-1.5B at ASR, so
// footprint would pick the wrong one.
// 2. otherwise RAM footprint descending, the quality proxy for generation
// (a 40GB image model out-renders a 12GB one).
function rankedByPreference(table) {
return [...table].sort((a, b) => {
const ra = a.rank ?? Infinity;
const rb = b.rank ?? Infinity;
if (ra !== rb) return ra - rb;
return (b.needs?.ramMB ?? 0) - (a.needs?.ramMB ?? 0);
});
}
/**
* Every local model for a capability this machine can actually run, best-first
* (same ordering as selectModel, whose pick is this list's head).
*
* Callers that can retry should walk the whole list: a table entry can be
* unusable for reasons no spec check can see - weights pulled or gated behind a
* login, the runner missing from PATH, an OOM at a tier that nominally fits. On
* a single-select call any one of those fails the entire local path, because the
* cascade cannot tell "this model is broken" from "nothing here fits you".
* Demoting to the next fitting tier is almost always what the user wanted.
*/
export function selectModelLadder(capability, specs, { preferTier } = {}) {
const table = tableFor(capability);
const pool = preferTier ? table.filter((m) => m.tier === preferTier) : table;
return rankedByPreference(pool).filter((model) => meetsSpecs(model, specs));
}
/**
* Pick the best local model the machine can run for a capability: the
* highest-footprint model that fits the available-RAM budget (and GPU/VRAM).
* `preferTier` pins the search to one tier (e.g. force a smaller/faster model).
* Returns `{ model, tier }`, or `{ recommend: "cli", reason }` when nothing fits.
*/
export function selectModel(capability, specs, { preferTier } = {}) {
const table = tableFor(capability);
const [model] = selectModelLadder(capability, specs, { preferTier });
if (model) return { model, tier: model.tier };
const smallest = table.reduce((a, b) => (a.sizeMB <= b.sizeMB ? a : b));
return {
recommend: "cli",
reason: `machine does not meet specs for any local ${capability} model (smallest needs ~${smallest.needs.ramMB}MB RAM${smallest.needs.gpu ? " + GPU" : ""}); use the CLI path instead`,
};
}
/**
* Agent-facing ladder: every model for a capability, best-first, each flagged
* with whether it fits this machine and why. Lets the agent see the RAM-graded
* options and choose (e.g. trade the auto-picked best for a smaller/faster one,
* or step up to a cloud upsell) rather than only getting one auto-selection.
*/
export function describeModelLadder(capability, specs) {
const budget = specs.availableRamMB ?? specs.ramMB;
return rankedByPreference(tableFor(capability)).map((model) => {
const fits = meetsSpecs(model, specs);
return {
id: model.id,
tier: model.tier,
needsRamMB: model.needs?.ramMB ?? 0,
sizeMB: model.sizeMB,
fits,
reason: fits
? `fits (needs ~${model.needs?.ramMB}MB, ${budget}MB available)`
: `too big (needs ~${model.needs?.ramMB}MB${model.needs?.gpu ? " + GPU" : ""}, ${budget}MB available)`,
notes: model.notes,
};
});
}
scripts/lib/local-models.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import {
listModels,
meetsSpecs,
selectModel,
selectModelLadder,
describeModelLadder,
CAPABILITIES,
} from "./local-models.mjs";
const TIERS = ["small", "medium", "large", "xlarge"];
const strongGpu = {
ramMB: 64000,
gpu: { present: true, kind: "nvidia", vramMB: 24000 },
appleSilicon: false,
};
const cpuOnly = { ramMB: 16000, gpu: { present: false, vramMB: 0 }, appleSilicon: false };
const tiny = { ramMB: 1024, gpu: { present: false, vramMB: 0 }, appleSilicon: false };
test("every capability table is non-empty and well-formed", () => {
for (const cap of CAPABILITIES) {
const models = listModels(cap);
assert.ok(models.length > 0, `no models for ${cap}`);
for (const m of models) {
assert.ok(m.id && m.tier && m.needs, `${cap}/${m.id} missing fields`);
assert.ok(TIERS.includes(m.tier), `${cap}/${m.id} bad tier: ${m.tier}`);
assert.equal(typeof m.install, "string", `${cap}/${m.id} needs an install command`);
assert.equal(typeof m.invoke, "string", `${cap}/${m.id} needs an invoke command`);
// user-installed, local-use-only: there is NO license gate on selection
assert.equal("license" in m, false, `${cap}/${m.id} must not carry a license gate`);
}
}
});
test("meetsSpecs enforces RAM, GPU presence, and VRAM", () => {
const gpuModel = { needs: { ramMB: 8000, gpu: true, vramMB: 12000 } };
assert.equal(meetsSpecs(gpuModel, strongGpu), true);
assert.equal(meetsSpecs(gpuModel, cpuOnly), false, "no GPU -> fails a GPU model");
const cpuModel = { needs: { ramMB: 2000, gpu: false } };
assert.equal(meetsSpecs(cpuModel, cpuOnly), true);
assert.equal(meetsSpecs(cpuModel, tiny), false, "too little RAM");
});
test("Apple Silicon unified memory counts as VRAM", () => {
const apple = {
ramMB: 24000,
appleSilicon: true,
gpu: { present: true, kind: "apple", vramMB: 24000 },
};
const gpuModel = { needs: { ramMB: 8000, gpu: true, vramMB: 16000 } };
assert.equal(meetsSpecs(gpuModel, apple), true);
});
test("selectModel picks the large tier on a strong machine", () => {
const r = selectModel("tts", strongGpu);
assert.equal(r.tier, "large");
assert.ok(r.model.id);
});
test("selectModel falls back to medium on a CPU-only machine", () => {
const r = selectModel("tts", cpuOnly);
assert.equal(r.tier, "medium");
assert.equal(r.model.id, "kokoro", "Kokoro is the CPU/medium default (native word timestamps)");
});
test("selectModel recommends the CLI path when no tier fits", () => {
const r = selectModel("tts", tiny);
assert.equal(r.recommend, "cli");
assert.ok(r.reason && /spec/i.test(r.reason));
assert.equal(r.model, undefined);
});
test("preferTier:'medium' avoids the large model even on a strong machine", () => {
const r = selectModel("tts", strongGpu, { preferTier: "medium" });
assert.equal(r.tier, "medium");
});
test("selectModel gates on AVAILABLE RAM, not total, when both are present", () => {
// 64GB total but only 6GB free right now -> the large tier must not be chosen.
const busy = {
ramMB: 64000,
availableRamMB: 6000,
appleSilicon: true,
gpu: { present: true, kind: "apple", vramMB: 64000 },
};
const r = selectModel("tts", busy);
assert.equal(r.tier, "medium", "available RAM (6GB) rules out the 16GB large tier");
});
test("imagegen is a RAM-graduated ladder; agent picks the best that fits", () => {
const ladder = describeModelLadder("imagegen", {
ramMB: 24000,
availableRamMB: 12000,
appleSilicon: true,
gpu: { present: true, kind: "apple", vramMB: 24000 },
});
// best-first order, each flagged with fit
assert.ok(ladder.length >= 3, "imagegen offers multiple RAM tiers");
assert.ok(
ladder[0].needsRamMB >= ladder[ladder.length - 1].needsRamMB,
"ladder is ordered best (biggest) first",
);
// on 24GB / 12GB-free the schnell --low-ram tier fits, the 32GB+ tiers do not
const fitting = ladder.filter((m) => m.fits);
assert.ok(fitting.length >= 1, "at least the low-ram tier fits a 24GB Mac");
assert.ok(
fitting.every((m) => m.needsRamMB <= 12000),
"only sub-budget models flagged as fitting",
);
const pick = selectModel("imagegen", {
ramMB: 24000,
availableRamMB: 12000,
gpu: { present: true, vramMB: 24000 },
});
assert.equal(
pick.model.id,
"flux-schnell-mflux-q4",
"best fit on 24GB is the low-ram schnell tier",
);
});
test("imagegen on a 64GB Mac steps up to the higher-quality tier", () => {
const pick = selectModel("imagegen", {
ramMB: 96000,
availableRamMB: 80000,
gpu: { present: true, vramMB: 96000 },
});
assert.equal(pick.tier, "xlarge", "80GB free unlocks the top-quality model");
});
test("ASR prefers Parakeet by rank even though it is smaller than whisper", () => {
// quality != size for ASR: Parakeet 0.6B beats whisper-1.5B, so `rank` wins
// over footprint. On a capable machine both fit; Parakeet must be chosen.
const capable = {
ramMB: 24000,
availableRamMB: 12000,
appleSilicon: true,
gpu: { present: true, kind: "apple", vramMB: 24000 },
};
const pick = selectModel("asr", capable);
assert.equal(pick.model.id, "parakeet-mlx", "Parakeet is the rank-0 preferred ASR");
// whisperx (rank 1, CPU-only) is the fallback when no GPU
const cpu = { ramMB: 16000, availableRamMB: 12000, gpu: { present: false, vramMB: 0 } };
assert.equal(selectModel("asr", cpu).model.id, "whisperx", "CPU-only falls back to whisperx");
});
test("ASR offers word-timestamp-capable models (better than plain whisper)", () => {
const asr = listModels("asr");
assert.ok(
asr.every((m) => m.wordTimestamps),
"every ASR model must support word timestamps",
);
});
// A machine that clears BOTH videogen tiers (the 32GB entry and the 16GB one).
// The existing fixtures deliberately sit under the large tier's floor, which is
// exactly how a dead 32GB entry stayed invisible: nothing could select it.
const bothVideogenTiers = { availableRamMB: 40000, gpu: { present: true } };
test("selectModelLadder returns every fitting model, best-first", () => {
const ladder = selectModelLadder("videogen", bothVideogenTiers);
assert.deepEqual(
ladder.map((m) => m.tier),
["large", "medium"],
"both tiers fit 40GB, biggest first",
);
assert.equal(
selectModel("videogen", bothVideogenTiers).model.id,
ladder[0].id,
"selectModel's pick is the ladder's head",
);
});
test("selectModelLadder drops what the machine cannot run", () => {
const oneTier = selectModelLadder("videogen", { availableRamMB: 20000, gpu: { present: true } });
assert.deepEqual(
oneTier.map((m) => m.tier),
["medium"],
"20GB cannot reach the 32GB tier",
);
assert.deepEqual(
selectModelLadder("videogen", { availableRamMB: 100, gpu: { present: true } }),
[],
"nothing fits -> empty ladder, and selectModel recommends the CLI",
);
assert.equal(
selectModel("videogen", { availableRamMB: 100, gpu: { present: true } }).recommend,
"cli",
);
});
test("selectModelLadder honours preferTier", () => {
const pinned = selectModelLadder("videogen", bothVideogenTiers, { preferTier: "medium" });
assert.deepEqual(
pinned.map((m) => m.tier),
["medium"],
"preferTier pins the ladder to one tier",
);
});
test("an invoke that names an owner/repo model agrees with the entry id", () => {
// Guards a half-done repoint: moving an entry to different weights means
// changing BOTH the id and the --model argument. Change one and the table
// selects one model while the runner downloads another.
let checked = 0;
for (const cap of CAPABILITIES) {
for (const m of listModels(cap)) {
if (m.repo) continue; // entries with an explicit repo resolve through it
const named = /--model\s+(\S+)/.exec(m.invoke);
if (!named) continue;
const [, name] = named[1].split("/");
if (!name) continue; // a bare model name, not an owner/repo id
assert.equal(name, m.id, `${cap}/${m.id}: invoke runs ${named[1]}`);
checked += 1;
}
}
assert.ok(checked > 0, "no entry pins an owner/repo model - guard would be vacuous");
});
scripts/lib/local-run.mjsimport { execFileSync } from "node:child_process";
import { selectModel, selectModelLadder } from "./local-models.mjs";
import { probeSpecs } from "./specs.mjs";
// Run a USER-INSTALLED local model for a capability (tts/asr/upscale).
// Walks the tiers the machine supports best-first (selectModelLadder), checking
// the tool is on PATH, filling the model's invoke template, and running it. A
// tier whose tool is missing or whose run fails demotes to the next fitting
// tier, so one unusable entry does not fail the capability. Returns:
// { model, tier, out } on success
// { recommend:"install", model, sizeMB, command, reason } tool isn't installed
// { recommend:"cli", reason } when no tier fits the machine
// `exec` / `which` are injectable for tests.
//
// ponytail: "installed" = the invoke's first token is on PATH (e.g. `whisperx`,
// `realesrgan-ncnn-vulkan`). For `python -m kokoro` this only proves python
// exists; good enough to gate — the recommend.command names the real package.
// Upgrade to a per-tool probe if a "python present but package missing" run ever
// produces a confusing error instead of a clean recommend.
function defaultWhich(bin) {
execFileSync("command", ["-v", bin], { stdio: "ignore", shell: true });
}
function defaultExec(cmd) {
execFileSync(cmd, { stdio: ["ignore", "pipe", "pipe"], shell: true, timeout: 600000 });
}
const fill = (tpl, vars) =>
tpl.replace(/\{(\w+)\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : ""));
export function runLocalModel(capability, opts = {}) {
const {
specs = probeSpecs(),
exec = defaultExec,
which = defaultWhich,
vars = {},
preferTier,
} = opts;
const ladder = selectModelLadder(capability, specs, { preferTier });
// no tier fits at all -> recommend the CLI path (selectModel words the reason)
if (!ladder.length) return selectModel(capability, specs, { preferTier });
// Best tier first, demoting past any tier that cannot run here: a missing
// tool or a failed run at the top tier must not hide a lower tier that works
// (fish-speech absent should still get you Kokoro). The last tier's failure is
// what gets reported, since by then nothing local ran.
let lastFailure = null;
for (const model of ladder) {
const bin = model.invoke.split(/\s+/)[0];
try {
which(bin);
} catch {
lastFailure = {
recommend: "install",
model: model.id,
sizeMB: model.sizeMB,
command: model.install,
reason: `${model.id} not installed (~${(model.sizeMB / 1000).toFixed(1)}GB to download once it is)`,
};
continue;
}
try {
exec(fill(model.invoke, vars));
} catch (e) {
lastFailure = {
recommend: "install",
model: model.id,
sizeMB: model.sizeMB,
command: model.install,
reason: e.message || String(e),
};
continue;
}
return { model: model.id, tier: model.tier, out: vars.out };
}
return lastFailure;
}
scripts/lib/local-run.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import { runLocalModel } from "./local-run.mjs";
const strongCpu = { ramMB: 16000, gpu: { present: false, vramMB: 0 }, appleSilicon: false };
const tiny = { ramMB: 512, gpu: { present: false, vramMB: 0 }, appleSilicon: false };
const ok = () => {}; // which/exec that succeed
test("recommends the CLI path when no local tier fits the machine", () => {
const r = runLocalModel("tts", { specs: tiny, which: ok, exec: ok });
assert.equal(r.recommend, "cli");
});
test("recommends install when the tool is not on PATH", () => {
const r = runLocalModel("tts", {
specs: strongCpu,
which: () => {
throw new Error("not found");
},
exec: ok,
vars: { text: "hi", out: "/tmp/v.wav" },
});
assert.equal(r.recommend, "install");
assert.equal(r.model, "kokoro");
assert.match(r.command, /pip install kokoro/);
});
test("runs the model and returns the output path when installed", () => {
let ran = "";
const r = runLocalModel("tts", {
specs: strongCpu,
which: ok,
exec: (cmd) => {
ran = cmd;
},
vars: { text: "hello world", voice: "af_heart", out: "/tmp/v.wav" },
});
assert.equal(r.model, "kokoro");
assert.equal(r.out, "/tmp/v.wav");
assert.match(ran, /hello world/, "invoke template filled with vars");
assert.match(ran, /\/tmp\/v\.wav/);
});
test("a failing run degrades to an install recommendation, never throws", () => {
const r = runLocalModel("upscale", {
specs: strongCpu,
which: ok,
exec: () => {
throw new Error("boom");
},
vars: { in: "a.png", out: "b.png" },
});
assert.equal(r.recommend, "install");
});
test("a tier whose tool is missing demotes to the next tier that fits", () => {
// 64GB + GPU fits BOTH tts tiers, so the ladder has two rungs: fish-speech
// (its own binary) above Kokoro (`python -m kokoro`). fish-speech absent must
// not cost the user Kokoro.
const strongGpu = { ramMB: 64000, gpu: { present: true, vramMB: 24000 } };
let ran = "";
const r = runLocalModel("tts", {
specs: strongGpu,
which: (bin) => {
if (bin === "fish-speech") throw new Error("not found");
},
exec: (cmd) => {
ran = cmd;
},
vars: { text: "hello", voice: "af_heart", out: "/tmp/v.wav" },
});
assert.equal(r.model, "kokoro", "demoted past the missing fish-speech binary");
assert.equal(r.tier, "medium");
assert.match(ran, /kokoro/);
});
test("every fitting tier failing reports the last tier's install command", () => {
const strongGpu = { ramMB: 64000, gpu: { present: true, vramMB: 24000 } };
const r = runLocalModel("tts", {
specs: strongGpu,
which: ok,
exec: () => {
throw new Error("boom");
},
vars: { text: "hi", out: "/tmp/v.wav" },
});
assert.equal(r.recommend, "install");
assert.equal(r.model, "kokoro", "the smallest fitting tier is the actionable one");
});
test("the install recommendation states the download size", () => {
// nothing today tells the user what they are agreeing to before a tool
// starts pulling weights, so the size travels in the payload AND in the
// text a caller shows them
const r = runLocalModel("tts", {
specs: strongCpu,
which: () => {
throw new Error("not found");
},
});
assert.equal(r.recommend, "install");
assert.equal(typeof r.sizeMB, "number");
assert.ok(r.sizeMB > 0);
assert.match(r.reason, /GB to download/);
});
test("a failed run still reports the tier's size", () => {
const r = runLocalModel("tts", {
specs: strongCpu,
which: ok,
exec: () => {
throw new Error("boom");
},
});
assert.equal(r.recommend, "install");
assert.equal(typeof r.sizeMB, "number");
});
scripts/lib/logo-provider.mjs// Official brand marks — the `logo` type's provider tiers, tried in registry
// order. Every tier was verified against a 54-brand stress test (2026-07,
// 100% cascade hit). Hit counts below are a snapshot of that run — they
// drift as the alias/org maps grow; re-run the stress test to refresh them.
//
// 1. svgl — official full-color vector SVGs (+ wordmark variants);
// 40/54 first-hits. Search is substring-based, so
// entities go through alias normalization first
// ("nextjs" never matches "Next.js" raw).
// 2. simple-icons — monochrome official glyphs; caught the long tail the
// others miss (nike, visa, toyota, wechat, bytedance).
// Pinned CDN build for determinism.
// 3. github avatar — the org's official logo for brands with a GitHub
// presence. Known orgs only: guessing a login risks a
// same-named personal account.
// 4. domain favicon — small-raster last resort (DuckDuckGo ip3). Responses
// under ~500B are DDG's globe placeholder, not a hit.
//
// HeyGen asset search is deliberately absent: for brand queries it returns
// generic look-alike icons (0/3 in testing) — worse than a miss. A total miss
// falls through to resolve's normal failure path (`no provider could resolve
// logo`, exit 1).
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const SVGL_API = "https://api.svgl.app";
const SIMPLE_ICONS_CDN = "https://cdn.jsdelivr.net/npm/simple-icons@16.25.0/icons";
const FAVICON_MIN_BYTES = 500;
// svgl search queries per entity, tried in order after the raw entity.
const SVGL_ALIASES = {
nextjs: ["next.js", "next"],
aws: ["amazon web services"],
huggingface: ["hugging face"],
cocacola: ["coca-cola"],
mcdonalds: ["mcdonald's"],
};
// simple-icons slugs that differ from the normalized entity.
const SIMPLE_ICON_SLUGS = {
nextjs: "nextdotjs",
aws: "amazonwebservices",
};
// Known GitHub orgs. Only mapped entities resolve at this tier — a brand name
// is NOT a GitHub login, and guessing hits same-named personal accounts.
const GITHUB_ORGS = {
slack: "slackhq",
meta: "facebook",
google: "google",
microsoft: "microsoft",
aws: "aws",
vercel: "vercel",
nextjs: "vercel",
alibaba: "alibaba",
heygen: "heygen-com",
};
// Favicon domains that aren't `<entity>.com`.
const FAVICON_DOMAINS = {
cocacola: "coca-cola.com",
aws: "aws.amazon.com",
nextjs: "nextjs.org",
};
const norm = (s) =>
String(s)
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
/** The brand entity for a query: --entity wins; else the intent minus filler. */
export function entityFrom(intent, entity) {
if (entity) return entity.toLowerCase().trim();
return String(intent)
.toLowerCase()
.replace(/\b(logo|logos|icon|brand|official|mark)\b/g, "")
.trim()
.replace(/\s+/g, " ");
}
/** Exact match after stripping case/spacing/punctuation — "Next.js" ≡ "nextjs". */
export function titleMatches(title, entity) {
return norm(title) === norm(entity);
}
export function svglQueriesFor(entity) {
return [entity, ...(SVGL_ALIASES[norm(entity)] || [])];
}
export function simpleIconSlugsFor(entity) {
const slugs = [norm(entity)];
const alias = SIMPLE_ICON_SLUGS[norm(entity)];
if (alias) slugs.push(alias);
return slugs;
}
export function githubOrgFor(entity) {
return GITHUB_ORGS[norm(entity)] || null;
}
export function faviconDomainFor(entity) {
return FAVICON_DOMAINS[norm(entity)] || `${norm(entity)}.com`;
}
async function fetchJson(url) {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) return null;
return res.json();
}
async function urlExists(url) {
const res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(10_000) });
return res.ok;
}
export async function svglSearch(intent, ctx = {}) {
const entity = entityFrom(intent, ctx.entity);
for (const q of svglQueriesFor(entity)) {
let items;
try {
items = await fetchJson(`${SVGL_API}?search=${encodeURIComponent(q)}`);
} catch {
return null; // network down — let the next tier try its own host
}
if (!Array.isArray(items)) continue;
const hit = items.find((it) => titleMatches(it.title, q) || titleMatches(it.title, entity));
if (!hit) continue;
const route = typeof hit.route === "string" ? hit.route : hit.route?.light;
if (!route) continue;
return {
url: route,
ext: ".svg",
source: "search",
metadata: {
description: `${hit.title} logo (official mark)`,
provider: "svgl",
provenance: { entity, query: q, route, wordmark: Boolean(hit.wordmark) },
},
};
}
return null;
}
export async function simpleIconsSearch(intent, ctx = {}) {
const entity = entityFrom(intent, ctx.entity);
for (const slug of simpleIconSlugsFor(entity)) {
const url = `${SIMPLE_ICONS_CDN}/${slug}.svg`;
let ok;
try {
ok = await urlExists(url);
} catch {
return null;
}
if (!ok) continue;
return {
url,
ext: ".svg",
source: "search",
metadata: {
description: `${entity} logo (official monochrome glyph)`,
provider: "simple-icons",
provenance: { entity, slug, pinned: "simple-icons@16.25.0" },
},
};
}
return null;
}
export async function githubAvatarSearch(intent, ctx = {}) {
const entity = entityFrom(intent, ctx.entity);
const org = githubOrgFor(entity);
if (!org) return null;
const url = `https://github.com/${org}.png?size=460`;
try {
if (!(await urlExists(url))) return null;
} catch {
return null;
}
return {
url,
ext: ".png",
source: "search",
metadata: {
description: `${entity} logo (GitHub org avatar)`,
provider: "github.avatar",
provenance: { entity, org },
},
};
}
export async function faviconSearch(intent, ctx = {}) {
const entity = entityFrom(intent, ctx.entity);
const domain = faviconDomainFor(entity);
const url = `https://icons.duckduckgo.com/ip3/${domain}.ico`;
let body;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) return null;
body = Buffer.from(await res.arrayBuffer());
} catch {
return null;
}
if (body.byteLength < FAVICON_MIN_BYTES) return null; // DDG placeholder, not a logo
// Hand the verified bytes over as a local file: the freeze step copies it
// instead of re-downloading, so the size check is authoritative over what
// gets frozen and the favicon tier costs one network round-trip, not two.
const bytes = body.byteLength;
const tmp = join(mkdtempSync(join(tmpdir(), "media-use-logo-")), `${domain}.ico`);
writeFileSync(tmp, body);
return {
localPath: tmp,
ext: ".ico",
source: "search",
metadata: {
description: `${entity} favicon (small raster — chip-size use only)`,
provider: "favicon.ddg",
provenance: { entity, domain, bytes, low_res: true },
},
};
}
scripts/lib/logo-provider.test.mjsimport test from "node:test";
import assert from "node:assert";
import { readFileSync } from "node:fs";
import {
entityFrom,
titleMatches,
svglQueriesFor,
simpleIconSlugsFor,
githubOrgFor,
faviconDomainFor,
svglSearch,
simpleIconsSearch,
githubAvatarSearch,
faviconSearch,
} from "./logo-provider.mjs";
import { getProviders, runProviders } from "./registry.mjs";
test("entityFrom strips filler words from the intent; --entity wins", () => {
assert.equal(entityFrom("LinkedIn logo"), "linkedin");
assert.equal(entityFrom("official Slack brand mark"), "slack");
assert.equal(entityFrom("anything", "Notion"), "notion");
});
test("titleMatches ignores case, spacing, punctuation — and rejects lookalikes", () => {
assert.ok(titleMatches("Next.js", "nextjs"));
assert.ok(titleMatches("Coca-Cola", "coca cola"));
assert.ok(!titleMatches("Slackware", "slack"));
});
test("svgl queries include the alias forms the raw entity can't match", () => {
assert.ok(svglQueriesFor("nextjs").includes("next.js"));
assert.ok(svglQueriesFor("aws").includes("amazon web services"));
assert.deepEqual(svglQueriesFor("figma"), ["figma"]);
});
test("simple-icons slugs cover the renamed entries", () => {
assert.ok(simpleIconSlugsFor("nextjs").includes("nextdotjs"));
assert.ok(simpleIconSlugsFor("aws").includes("amazonwebservices"));
assert.deepEqual(simpleIconSlugsFor("nike"), ["nike"]);
});
test("github avatar tier never guesses an org", () => {
assert.equal(githubOrgFor("slack"), "slackhq");
assert.equal(githubOrgFor("heygen"), "heygen-com");
assert.equal(githubOrgFor("some-random-startup"), null);
});
test("favicon domain defaults to <entity>.com with explicit overrides", () => {
assert.equal(faviconDomainFor("cocacola"), "coca-cola.com");
assert.equal(faviconDomainFor("stripe"), "stripe.com");
});
// --- async tiers, network mocked -------------------------------------------
// The 54-brand stress test is a manual snapshot; these pin the same behavior
// as CI gates: descriptor shape, alias retry, error→null fallthrough, the
// placeholder filter, and the real cascade order under a mocked network.
const json = (data) => new Response(JSON.stringify(data), { status: 200 });
const status = (code) => new Response(null, { status: code });
const bin = (n) => new Response(new Uint8Array(n), { status: 200 });
test("svglSearch returns the descriptor shape on an exact title hit", async (t) => {
t.mock.method(globalThis, "fetch", async () =>
json([{ title: "Figma", route: "https://svgl.app/library/figma.svg" }]),
);
const res = await svglSearch("Figma logo", {});
assert.equal(res.url, "https://svgl.app/library/figma.svg");
assert.equal(res.ext, ".svg");
assert.equal(res.metadata.provider, "svgl");
});
test("svglSearch skips a non-array payload and retries with the alias query", async (t) => {
const seen = [];
t.mock.method(globalThis, "fetch", async (url) => {
seen.push(decodeURIComponent(String(url)));
return seen.length === 1
? json({ error: "unexpected shape" })
: json([{ title: "Next.js", route: "https://svgl.app/library/nextjs.svg" }]);
});
const res = await svglSearch("nextjs logo", {});
assert.equal(res.metadata.provenance.query, "next.js", "hit came from the alias query");
assert.ok(seen.length >= 2, "raw query then alias");
});
test("svglSearch returns null when the network is down — the cascade falls through", async (t) => {
t.mock.method(globalThis, "fetch", async () => {
throw new Error("network down");
});
assert.equal(await svglSearch("figma logo", {}), null);
});
test("simpleIconsSearch falls to the next slug on a 404", async (t) => {
const seen = [];
t.mock.method(globalThis, "fetch", async (url) => {
seen.push(String(url));
return String(url).includes("amazonwebservices") ? status(200) : status(404);
});
const res = await simpleIconsSearch("aws logo", {});
assert.ok(res.url.endsWith("amazonwebservices.svg"));
assert.equal(seen.length, 2, "plain slug 404s first, alias slug hits");
});
test("faviconSearch rejects DDG's sub-500B placeholder with null", async (t) => {
t.mock.method(globalThis, "fetch", async () => bin(120));
assert.equal(await faviconSearch("someco logo", {}), null);
});
test("faviconSearch hands verified bytes over as a local file — one fetch, no re-download", async (t) => {
const fetchMock = t.mock.method(globalThis, "fetch", async () => bin(600));
const res = await faviconSearch("someco logo", {});
assert.ok(res.localPath, "returns a localPath, not a url");
assert.equal(readFileSync(res.localPath).byteLength, 600, "frozen bytes are the verified bytes");
assert.equal(fetchMock.mock.callCount(), 1, "single network round-trip");
assert.equal(res.metadata.provenance.low_res, true);
});
test("githubAvatarSearch never touches the network for an unmapped entity", async (t) => {
const fetchMock = t.mock.method(globalThis, "fetch", async () => status(200));
assert.equal(await githubAvatarSearch("some-random-startup logo", {}), null);
assert.equal(fetchMock.mock.callCount(), 0);
});
test("the real logo cascade falls through tier by tier to the first hit", async (t) => {
t.mock.method(globalThis, "fetch", async (url) => {
const u = String(url);
if (u.includes("api.svgl.app")) return json([]); // tier 1: no hit
if (u.includes("jsdelivr")) return status(404); // tier 2: no such slug
// tier 3 (github) is never called: entity is unmapped
if (u.includes("duckduckgo")) return bin(600); // tier 4: real favicon
throw new Error(`unexpected fetch: ${u}`);
});
const res = await runProviders(getProviders("logo"), "search", "zzzbrand logo", {
entity: "zzzbrand",
});
assert.ok(res, "cascade must land on the favicon tier");
assert.equal(res.metadata.provider, "favicon.ddg");
});
scripts/lib/ltx-video-provider.mjsimport { execFileSync } from "node:child_process";
import { existsSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describeDownload, probeSpecs } from "./specs.mjs";
import { buildArgv, selectModel, selectModelLadder } from "./local-models.mjs";
export async function ltxVideoGenerate(
intent,
ctx,
execFn = execFileSync,
pathExists = existsSync,
unlinkFn = unlinkSync,
) {
const specs = ctx?.specs || probeSpecs();
const ladder = selectModelLadder("videogen", specs, { preferTier: ctx?.preferTier });
if (!ladder.length) {
const { reason } = selectModel("videogen", specs, { preferTier: ctx?.preferTier });
console.error(
`media-use: local video gen not enabled (${reason}). Enable a fitting free on-device LTX model to use this provider.`,
);
return null;
}
// Each attempt mints its own timestamped output path, so a partial artifact
// from a failed tier is orphaned rather than overwritten - and a lower tier
// then succeeding hides it. Discard it before demoting. Best-effort: a
// partial we cannot remove must never mask the real failure.
const discardPartial = (path) => {
try {
if (pathExists(path)) unlinkFn(path);
} catch {
// nothing actionable: the generate failure below is the real story
}
};
// Walk the whole ladder, best tier first. A tier that cannot run on this
// machine for a reason no spec check sees (runner off PATH, gated weights, an
// OOM) demotes to the next fitting tier instead of failing local video gen
// outright. Every demotion is reported: a silent drop to a smaller model
// leaves the caller wondering why the output looks the way it does.
for (const model of ladder) {
const bin = model.invoke.trim().split(/\s+/)[0];
try {
execFn("which", [bin], { stdio: ["ignore", "ignore", "ignore"] });
} catch {
console.error(
`media-use: local video gen not enabled (\`${bin}\` not on PATH). Install for free on-device LTX: ${model.install}. Heads up: ${model.id} ${describeDownload(model.sizeMB)}.`,
);
continue;
}
const outPath = join(tmpdir(), `media-use-ltx-${process.pid}-${Date.now()}.mp4`);
const argv = buildArgv(model.invoke, {
prompt: intent,
w: ctx?.width || 512,
h: ctx?.height || 320,
frames: ctx?.frames || 33,
out: outPath,
});
argv.shift();
try {
execFn(bin, argv, {
encoding: "utf8",
timeout: 1_800_000,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
discardPartial(outPath);
console.error(
`media-use: local video gen (${model.id}) failed: ${err.stderr?.toString().trim().slice(-200) || err.message}`,
);
continue;
}
if (!pathExists(outPath)) {
console.error(
`media-use: local video gen (${model.id}) exited cleanly but wrote no output file`,
);
continue;
}
return {
localPath: outPath,
ext: ".mp4",
source: "generated",
metadata: {
description: intent,
provider: "ltx.local",
provenance: { prompt: intent },
},
};
}
return null;
}
scripts/lib/ltx-video-provider.test.mjsimport { test } from "node:test";
import assert from "node:assert/strict";
import { dirname } from "node:path";
import { tmpdir } from "node:os";
import { ltxVideoGenerate } from "./ltx-video-provider.mjs";
const fittingSpecs = { availableRamMB: 20000, gpu: { present: true } };
test("no fitting local model: falls through without checking for a binary", async (t) => {
t.mock.method(console, "error", () => {});
const calls = [];
const result = await ltxVideoGenerate(
"a calm ocean wave at sunset",
{ specs: { availableRamMB: 100, gpu: { present: true } } },
(...call) => calls.push(call),
() => true,
);
assert.equal(result, null);
assert.deepEqual(calls, []);
});
test("binary missing from PATH: prints the model install hint and falls through", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
const calls = [];
const fakeExec = (...call) => {
calls.push(call);
throw new Error("not found");
};
const result = await ltxVideoGenerate(
"a calm ocean wave at sunset",
{ specs: fittingSpecs },
fakeExec,
);
assert.equal(result, null);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0].slice(0, 2), ["which", ["ltx-2-mlx"]]);
assert.equal(errors.length, 1);
assert.match(errors[0], /git clone https:\/\/github\.com\/dgrauet\/ltx-2-mlx/);
// the install hint is the accept moment: say what the pull costs
assert.match(errors[0], /GB of weights to/);
});
test("generate argv substitutes a spaced prompt after tokenizing and uses verified defaults", async () => {
const calls = [];
const checkedPaths = [];
const fakeExec = (...call) => calls.push(call);
const pathExists = (path) => {
checkedPaths.push(path);
return false;
};
const intent = "a calm ocean wave at sunset";
const result = await ltxVideoGenerate(intent, { specs: fittingSpecs }, fakeExec, pathExists);
assert.equal(result, null);
assert.equal(calls.length, 2);
const [bin, argv, opts] = calls[1];
assert.equal(bin, "ltx-2-mlx");
assert.equal(opts.timeout, 1_800_000);
const expectedPairs = [
["--prompt", intent],
["--width", "512"],
["--height", "320"],
["--frames", "33"],
["--output", checkedPaths[0]],
];
let previousIndex = -1;
for (const [flag, value] of expectedPairs) {
const index = argv.indexOf(flag);
assert.ok(index > previousIndex, `${flag} should follow the previous required option`);
assert.equal(argv[index + 1], value);
previousIndex = index;
}
assert.equal(argv.filter((arg) => arg === intent).length, 1);
});
test("successful generation returns the generated MP4 result", async () => {
const calls = [];
const fakeExec = (...call) => calls.push(call);
const intent = "a calm ocean wave at sunset";
const result = await ltxVideoGenerate(intent, { specs: fittingSpecs }, fakeExec, () => true);
assert.ok(result);
assert.equal(calls.length, 2);
assert.equal(dirname(result.localPath), tmpdir());
assert.match(result.localPath, /media-use-ltx-\d+-\d+\.mp4$/);
assert.deepEqual(result, {
localPath: result.localPath,
ext: ".mp4",
source: "generated",
metadata: {
description: intent,
provider: "ltx.local",
provenance: { prompt: intent },
},
});
});
test("generate failure returns null instead of throwing", async (t) => {
t.mock.method(console, "error", () => {});
let calls = 0;
const fakeExec = () => {
calls += 1;
if (calls === 2) {
const error = new Error("generation failed");
error.stderr = "LTX failed";
throw error;
}
};
const result = await ltxVideoGenerate(
"storm clouds",
{ specs: fittingSpecs },
fakeExec,
() => true,
);
assert.equal(result, null);
assert.equal(calls, 2);
});
test("missing generated output returns null and says so", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
const result = await ltxVideoGenerate(
"storm clouds",
{ specs: fittingSpecs },
() => {},
() => false,
);
assert.equal(result, null);
assert.equal(errors.length, 1);
assert.match(errors[0], /wrote no output file/);
});
// 40GB clears BOTH videogen tiers, so the ladder has two rungs. `fittingSpecs`
// above sits under the large tier's floor on purpose: every other test in this
// file exercises the medium tier alone, which is precisely why a broken large
// tier could sit in the table unnoticed.
const bothTiersSpecs = { availableRamMB: 40000, gpu: { present: true } };
const isGenerate = (call) => call[0] !== "which";
test("a top tier that cannot run demotes to the next fitting tier", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
const calls = [];
// The runner is installed, but the large tier's weights are gated: the
// download 401s and `generate` exits non-zero. The medium tier then works.
const fakeExec = (...call) => {
calls.push(call);
if (isGenerate(call) && call[1].includes("dgrauet/ltx-2.3-mlx-q8")) {
const err = new Error("exit 1");
err.stderr = "401 Client Error: Unauthorized for url: .../ltx-2.3-mlx-q8";
throw err;
}
};
const result = await ltxVideoGenerate(
"storm clouds",
{ specs: bothTiersSpecs },
fakeExec,
() => true,
);
assert.ok(result, "the medium tier still produced a video");
const generated = calls.filter(isGenerate).map((call) => call[1].join(" "));
assert.equal(generated.length, 2, "large attempted first, then medium");
assert.match(generated[0], /dgrauet\/ltx-2\.3-mlx-q8/);
assert.match(generated[1], /dgrauet\/ltx-2\.3-mlx-q4/);
// the demotion is reported, never silent: a smaller model changes the output
assert.equal(errors.length, 1);
assert.match(errors[0], /ltx-2\.3-mlx-q8\) failed/);
assert.match(errors[0], /401/);
});
test("every fitting tier failing returns null, one reason per tier", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
const fakeExec = (...call) => {
if (isGenerate(call)) throw new Error("mlx out of memory");
};
const result = await ltxVideoGenerate(
"storm clouds",
{ specs: bothTiersSpecs },
fakeExec,
() => true,
);
assert.equal(result, null);
assert.equal(errors.length, 2, "both tiers tried, both reported");
assert.match(errors[0], /ltx-2\.3-mlx-q8/);
assert.match(errors[1], /ltx-2\.3-mlx-q4/);
});
test("preferTier pins the attempt to one tier instead of demoting", async (t) => {
t.mock.method(console, "error", () => {});
const calls = [];
const fakeExec = (...call) => {
calls.push(call);
if (isGenerate(call)) throw new Error("boom");
};
const result = await ltxVideoGenerate(
"storm clouds",
{ specs: bothTiersSpecs, preferTier: "large" },
fakeExec,
() => true,
);
assert.equal(result, null);
const generated = calls.filter(isGenerate).map((call) => call[1].join(" "));
assert.equal(generated.length, 1, "pinned to large: no demotion to medium");
assert.match(generated[0], /dgrauet\/ltx-2\.3-mlx-q8/);
});
// A failed attempt's temp path is minted per attempt (it carries a timestamp),
// so without cleanup a partial mp4 from a failed tier is orphaned rather than
// overwritten - and a lower tier then succeeding hides it. Partial video files
// are the expensive case, which is why this is pinned.
const outputOf = (argv) => argv[argv.indexOf("--output") + 1];
test("a failed attempt's partial output is discarded before demoting", async (t) => {
t.mock.method(console, "error", () => {});
const unlinked = [];
const attempted = [];
const fakeExec = (...call) => {
if (!isGenerate(call)) return;
attempted.push(outputOf(call[1]));
if (call[1].includes("dgrauet/ltx-2.3-mlx-q8")) {
// OOM mid-write is one of the advertised demotion cases
const err = new Error("exit 1");
err.stderr = "mlx.core.metal: out of memory";
throw err;
}
};
const result = await ltxVideoGenerate(
"storm clouds",
{ specs: bothTiersSpecs },
fakeExec,
() => true,
(path) => unlinked.push(path),
);
assert.ok(result, "the medium tier still produced a video");
assert.deepEqual(unlinked, [attempted[0]], "the failed large-tier partial is removed");
});
test("every tier failing discards every partial, one per attempt", async (t) => {
t.mock.method(console, "error", () => {});
const unlinked = [];
const attempted = [];
const fakeExec = (...call) => {
if (!isGenerate(call)) return;
attempted.push(outputOf(call[1]));
throw new Error("mlx out of memory");
};
const result = await ltxVideoGenerate(
"storm clouds",
{ specs: bothTiersSpecs },
fakeExec,
() => true,
(path) => unlinked.push(path),
);
assert.equal(result, null);
assert.equal(attempted.length, 2, "both tiers attempted");
assert.deepEqual(unlinked, attempted, "nothing is left behind on the all-fail path");
});
test("a successful generation is never discarded", async () => {
const unlinked = [];
const result = await ltxVideoGenerate(
"storm clouds",
{ specs: bothTiersSpecs },
() => {},
() => true,
(path) => unlinked.push(path),
);
assert.ok(result);
assert.deepEqual(unlinked, [], "the returned artifact must survive");
});
test("an unremovable partial does not mask the generate failure", async (t) => {
t.mock.method(console, "error", () => {});
const fakeExec = (...call) => {
if (isGenerate(call)) throw new Error("mlx out of memory");
};
const result = await ltxVideoGenerate(
"storm clouds",
{ specs: bothTiersSpecs },
fakeExec,
() => true,
() => {
throw new Error("EPERM: operation not permitted");
},
);
// cleanup is best-effort: a partial we cannot delete must not become the error
assert.equal(result, null);
});
scripts/lib/lut-preset-provider.mjsimport { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { withReservedFile, withReservedFileSync } from "./manifest.mjs";
import { freezeUrl } from "./freeze.mjs";
import { tokenOverlap } from "./match.mjs";
import { buildCube } from "./cube-build.mjs";
import { validateCube, validateCubeFile } from "./cube-validate.mjs";
const SKILL_DIR = join(import.meta.dirname, "..", "..");
const LUT_DIR = join(SKILL_DIR, "luts");
const LUT_INDEX = join(LUT_DIR, "index.json");
export const LIBRARY_LUT_OFFLINE_CODE = "MEDIA_USE_LIBRARY_LUT_OFFLINE";
// Presets this released resolver can safely emit at this stack layer.
// Effect-backed treatment presets join after their runtime support lands.
export const RESOLVABLE_PRESET_IDS = [
"neutral",
"warm-daylight",
"clean-studio",
"skin-soft",
"food-pop",
"night-lift",
"muted-editorial",
"vintage-wash",
"mono-clean",
"mono-fade",
"soft-boost",
"bright-pop",
"deep-contrast",
"creator-camcorder",
"vhs-playback",
"home-movie-8mm",
"editorial-halftone",
"two-ink-print",
];
const PRESET_SYNONYMS = {
neutral: ["neutral", "identity", "none", "ungraded", "natural base"],
"warm-daylight": [
"warm daylight",
"warm natural light",
"golden daylight",
"sunlit",
"warm sunny",
"warm clean",
"clean warm",
"warm product",
],
"clean-studio": [
"clean studio",
"studio clean",
"cool studio",
"product studio",
"cool clean",
"clean cool",
"cool crisp",
],
"skin-soft": ["skin soft", "soft skin", "portrait soft", "beauty skin"],
"food-pop": ["food pop", "food vibrant", "appetizing", "restaurant color"],
"night-lift": ["night lift", "night", "low light lift", "city night"],
"muted-editorial": ["muted editorial", "editorial muted", "magazine muted"],
"vintage-wash": ["vintage wash", "vintage", "retro wash", "aged film"],
"mono-clean": ["mono clean", "black white clean", "monochrome clean"],
"mono-fade": ["mono fade", "black white fade", "faded monochrome"],
"soft-boost": [
"soft boost",
"soft bright",
"gentle boost",
"natural lift",
"natural light",
"gentle lift",
"soft natural",
],
"bright-pop": [
"bright pop",
"bright punchy",
"vivid bright",
"fresh pop",
"fresh",
"bright fresh",
"clean colorful",
],
"deep-contrast": ["deep contrast", "high contrast punchy", "punchy contrast", "bold contrast"],
"creator-camcorder": ["creator camcorder", "creator video", "ugc camera", "handheld creator"],
"vhs-playback": ["vhs playback", "vhs tape", "analog tape", "degraded tape"],
"home-movie-8mm": ["8mm home movie", "8mm film", "family film", "small gauge film"],
"editorial-halftone": ["editorial halftone", "halftone", "print dots", "newsprint"],
"two-ink-print": ["two ink print", "two ink editorial", "duotone print", "poster print"],
};
function presetCandidates() {
return RESOLVABLE_PRESET_IDS.map((id) => ({
kind: "preset",
preset: id,
synonyms: PRESET_SYNONYMS[id] ?? [],
text: [id, ...(PRESET_SYNONYMS[id] ?? [])].join(" "),
}));
}
export function readBundledLutIndex() {
if (!existsSync(LUT_INDEX)) return [];
const parsed = JSON.parse(readFileSync(LUT_INDEX, "utf8"));
const entries = Array.isArray(parsed) ? parsed : parsed.looks;
if (!Array.isArray(entries)) return [];
return entries.map((entry) => {
const params =
entry.params && typeof entry.params === "object" && !Array.isArray(entry.params)
? entry.params
: null;
const url = typeof entry.url === "string" && entry.url.trim() ? entry.url.trim() : null;
return {
id: String(entry.id),
description: String(entry.description ?? entry.id),
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
intensity: Number.isFinite(Number(entry.intensity)) ? Number(entry.intensity) : 1,
...(params && { params }),
...(url && { url }),
};
});
}
function libraryCandidates() {
return readBundledLutIndex().map((entry) => ({
kind: "library",
...entry,
text: [entry.id, entry.description, ...entry.tags].join(" "),
}));
}
export function matchColorLook(intent) {
const normalized = String(intent ?? "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
if (RESOLVABLE_PRESET_IDS.includes(normalized)) {
return { kind: "preset", preset: normalized, score: 99 };
}
const candidates = [...presetCandidates(), ...libraryCandidates()]
.map((candidate, index) => ({
...candidate,
index,
score: tokenOverlap(intent, candidate.text),
}))
.filter((candidate) => candidate.score >= 2)
.sort((a, b) => b.score - a.score || a.index - b.index);
if (candidates.length === 0) return null;
const best = candidates[0];
if (best.kind === "preset") {
return { kind: "preset", preset: best.preset, score: best.score };
}
return {
kind: "library",
id: best.id,
description: best.description,
tags: best.tags,
intensity: best.intensity,
...(best.params && { params: best.params }),
...(best.url && { url: best.url }),
score: best.score,
};
}
export function isLibraryLutOfflineMiss(err) {
return err?.code === LIBRARY_LUT_OFFLINE_CODE;
}
function libraryRecord(match, { id, localPath, fullPath, via }) {
return {
id,
localPath,
fullPath,
lut: { src: localPath, intensity: match.intensity },
source: "library",
description: match.description,
metadata: {
provider: "cube_lut.library",
provenance: {
look_id: match.id,
tags: match.tags,
via,
},
},
};
}
function assertValidCubeText(cube, label) {
const check = validateCube(cube);
if (!check.ok) throw new Error(`${label}: ${check.error}`);
}
function assertValidCubeFile(path, label) {
const check = validateCubeFile(path);
if (!check.ok) throw new Error(`${label}: ${check.error}`);
}
function offlineLibraryMiss(match) {
const err = new Error(`library LUT "${match.id}" is CDN-only and --local-only is set`);
err.code = LIBRARY_LUT_OFFLINE_CODE;
return err;
}
export async function freezeLibraryLut(match, { projectDir, type, localOnly = false }) {
if (!match || match.kind !== "library") {
throw new Error("freezeLibraryLut requires a library match");
}
// Prefer the CDN url so looks download on-demand (like bgm/image). Fall back
// to deterministic buildCube params when offline (--local-only) or if the
// download/validation fails, so resolution is never blocked on the network.
if (match.url && !localOnly) {
try {
return await withReservedFile(
projectDir,
type,
".cube",
async ({ id, localPath, fullPath }) => {
const tmpPath = `${fullPath}.tmp`;
try {
// Download + validate at a .tmp path, then atomically rename. A crash
// (SIGKILL/OOM) between write and validate can't orphan an invalid .cube
// at the final path — only a validated cube is ever renamed into place.
await freezeUrl(match.url, tmpPath);
assertValidCubeFile(tmpPath, `downloaded library LUT ${match.id} failed validation`);
renameSync(tmpPath, fullPath);
return libraryRecord(match, { id, localPath, fullPath, via: "url" });
} finally {
rmSync(tmpPath, { force: true });
}
},
);
} catch (err) {
if (!match.params) {
throw new Error(`failed to freeze library LUT ${match.id}: ${err.message}`);
}
// else: fall through to the params fallback below
}
}
if (match.params) {
return withReservedFileSync(projectDir, type, ".cube", ({ id, localPath, fullPath }) => {
const tmpPath = `${fullPath}.tmp`;
try {
const cube = buildCube(match.params);
assertValidCubeText(cube, `invalid library LUT ${match.id}`);
// Write + validate at .tmp, then atomic rename — same no-orphan guarantee
// as the url path above.
writeFileSync(tmpPath, cube);
assertValidCubeFile(tmpPath, `invalid frozen LUT ${localPath}`);
renameSync(tmpPath, fullPath);
return libraryRecord(match, {
id,
localPath,
fullPath,
via: match.url ? "params-fallback" : "params",
});
} finally {
rmSync(tmpPath, { force: true });
}
});
}
if (match.url) throw offlineLibraryMiss(match); // url-only entry, offline
throw new Error(`misconfigured library LUT "${match.id}": expected params or url`);
}
scripts/lib/lut-preset-provider.test.mjsimport { strict as assert } from "node:assert";
import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import {
LIBRARY_LUT_OFFLINE_CODE,
RESOLVABLE_PRESET_IDS,
freezeLibraryLut,
matchColorLook,
readBundledLutIndex,
} from "./lut-preset-provider.mjs";
import { buildCube } from "./cube-build.mjs";
import { validateCube, validateCubeFile } from "./cube-validate.mjs";
const REPO_ROOT = join(import.meta.dirname, "..", "..", "..", "..");
function corePresetIdsFromSource() {
const src = readFileSync(join(REPO_ROOT, "packages/core/src/colorGrading.ts"), "utf8");
const match = src.match(/export type HfColorGradingPresetId =([\s\S]*?);/);
assert.ok(match, "core preset union should be readable");
return [...match[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
}
test("warm daylight and warm natural light resolve to the core warm-daylight preset", () => {
assert.deepEqual(matchColorLook("warm daylight"), {
kind: "preset",
preset: "warm-daylight",
score: 2,
});
assert.equal(matchColorLook("warm natural light").preset, "warm-daylight");
});
test("high contrast punchy resolves to deep-contrast", () => {
assert.equal(matchColorLook("high contrast punchy").preset, "deep-contrast");
});
test("removed preset phrases resolve to surviving looks", () => {
assert.equal(matchColorLook("natural lift").preset, "soft-boost");
assert.equal(matchColorLook("fresh pop").preset, "bright-pop");
assert.equal(matchColorLook("warm clean").preset, "warm-daylight");
assert.equal(matchColorLook("cool clean").preset, "clean-studio");
});
test("complete-filter intent aliases resolve deterministically", () => {
assert.equal(matchColorLook("analog tape").preset, "vhs-playback");
assert.equal(matchColorLook("creator video").preset, "creator-camcorder");
});
test("library look freezes a validated cube from params offline (--local-only)", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "mu-lut-provider-"));
try {
const match = matchColorLook("teal orange blockbuster");
assert.equal(match.kind, "library");
// localOnly forces the deterministic params path (no network); online, the
// same look downloads its .cube from the CDN url (via "url").
const frozen = await freezeLibraryLut(match, { projectDir, type: "grade", localOnly: true });
assert.match(frozen.localPath, /^\.media\/luts\/grade_001\.cube$/);
assert.ok(existsSync(join(projectDir, frozen.localPath)));
assert.equal(validateCubeFile(join(projectDir, frozen.localPath)).ok, true);
assert.equal(frozen.lut.src, frozen.localPath);
assert.equal(frozen.metadata.provenance.via, "params-fallback");
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
test("every resolver preset exists in packages/core/src/colorGrading.ts", () => {
const corePresetIds = corePresetIdsFromSource();
assert.deepEqual(
RESOLVABLE_PRESET_IDS.filter((id) => !corePresetIds.includes(id)),
[],
);
for (const id of RESOLVABLE_PRESET_IDS) {
const match = matchColorLook(id);
assert.equal(match.kind, "preset");
assert.equal(match.preset, id);
}
});
test("zero-overlap intent returns no preset or library match", () => {
assert.equal(matchColorLook("zqxv imaginary neutron look"), null);
});
test("bundled LUT index entries resolve from params or url", () => {
for (const entry of readBundledLutIndex()) {
assert.ok(entry.id);
assert.ok(entry.description);
assert.ok(entry.params || entry.url, `${entry.id} should define params or url`);
if (entry.params) {
assert.equal(typeof entry.params, "object");
assert.equal(validateCube(buildCube(entry.params)).ok, true, `${entry.id} params validate`);
}
if (entry.url) assert.equal(typeof entry.url, "string");
}
});
test("url library entries respect localOnly and freeze through fetch", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "mu-lut-url-provider-"));
const match = {
kind: "library",
id: "cdn-look",
description: "CDN-hosted look",
tags: ["cdn"],
intensity: 0.7,
url: "https://example.invalid/look.cube",
};
const originalFetch = globalThis.fetch;
let fetchCalls = 0;
try {
globalThis.fetch = async () => {
fetchCalls++;
throw new Error("network should be skipped under localOnly");
};
await assert.rejects(
freezeLibraryLut(match, { projectDir, type: "lut", localOnly: true }),
(err) => {
assert.equal(err.code, LIBRARY_LUT_OFFLINE_CODE);
assert.match(err.message, /--local-only/);
return true;
},
);
assert.equal(fetchCalls, 0);
const cube = buildCube({ contrast: 0.1 });
const body = Buffer.from(cube);
globalThis.fetch = async (url) => {
fetchCalls++;
assert.equal(url, match.url);
return {
ok: true,
headers: { get: () => String(body.length) },
body: [body],
};
};
const frozen = await freezeLibraryLut(match, { projectDir, type: "lut" });
assert.equal(fetchCalls, 1);
assert.match(frozen.localPath, /^\.media\/luts\/lut_001\.cube$/);
assert.equal(validateCubeFile(join(projectDir, frozen.localPath)).ok, true);
assert.equal(frozen.metadata.provenance.via, "url");
} finally {
globalThis.fetch = originalFetch;
rmSync(projectDir, { recursive: true, force: true });
}
});
test("failed URL library freeze releases its reservation", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "mu-lut-failure-"));
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = async () => ({
ok: true,
headers: { get: () => "12" },
body: [Buffer.from("not a cube\n")],
});
await assert.rejects(
freezeLibraryLut(
{
kind: "library",
id: "broken-cdn-look",
description: "Broken CDN look",
tags: ["broken"],
intensity: 1,
url: "https://example.com/broken.cube",
},
{ projectDir, type: "lut" },
),
/failed to freeze library LUT/,
);
assert.deepStrictEqual(readdirSync(join(projectDir, ".media/luts")), []);
} finally {
globalThis.fetch = originalFetch;
rmSync(projectDir, { recursive: true, force: true });
}
});
scripts/lib/manifest.mjsimport {
readFileSync,
appendFileSync,
mkdirSync,
existsSync,
readdirSync,
openSync,
closeSync,
writeFileSync,
rmSync,
statSync,
} from "node:fs";
import { join } from "node:path";
const MANIFEST_FILE = "manifest.jsonl";
const INDEX_FILE = "index.md";
const TYPE_DIRS = {
bgm: "audio/bgm",
sfx: "audio/sfx",
voice: "audio/voice",
image: "images",
icon: "images",
logo: "images",
brand: "images",
video: "video",
grade: "luts",
lut: "luts",
recipe: "recipes",
};
export function mediaDir(projectDir) {
return join(projectDir, ".media");
}
export function manifestPath(projectDir) {
return join(mediaDir(projectDir), MANIFEST_FILE);
}
export function indexPath(projectDir) {
return join(mediaDir(projectDir), INDEX_FILE);
}
export function typeSubdir(type) {
const sub = TYPE_DIRS[type];
if (!sub) throw new Error(`unknown media type: ${type}`);
return sub;
}
export function typeDirPath(projectDir, type) {
return join(mediaDir(projectDir), typeSubdir(type));
}
export function readManifest(projectDir) {
const p = manifestPath(projectDir);
if (!existsSync(p)) return [];
const raw = readFileSync(p, "utf8");
const records = [];
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
records.push(JSON.parse(trimmed));
} catch {
// ponytail: skip malformed lines, don't crash
}
}
return records;
}
export function appendRecord(projectDir, record) {
const dir = mediaDir(projectDir);
mkdirSync(dir, { recursive: true });
const typeDir = typeDirPath(projectDir, record.type);
mkdirSync(typeDir, { recursive: true });
const p = manifestPath(projectDir);
const line = JSON.stringify(record) + "\n";
appendFileSync(p, line);
}
// Match prompts forgivingly. Agents rarely re-emit a byte-identical intent, so
// keying cache lookups on exact equality meant "Calm piano" and "calm piano"
// re-searched and re-downloaded. Normalize (trim, lowercase, collapse internal
// whitespace) on both sides; the raw prompt is still stored for audit.
export function normalizePrompt(prompt) {
return String(prompt ?? "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
export function findByPrompt(projectDir, prompt, type) {
const key = normalizePrompt(prompt);
if (!key) return null;
const records = readManifest(projectDir);
return (
records.find(
(r) => normalizePrompt(r.provenance?.prompt) === key && (type == null || r.type === type),
) || null
);
}
export function findByEntity(projectDir, entity) {
const lower = entity.toLowerCase();
const records = readManifest(projectDir);
return records.find((r) => r.entity && r.entity.toLowerCase() === lower) || null;
}
export function nextId(projectDir, type) {
const records = readManifest(projectDir);
const prefix = type;
let max = 0;
for (const r of records) {
if (r.type !== type) continue;
const m = r.id?.match(new RegExp(`^${prefix}_(\\d+)$`));
if (m) max = Math.max(max, parseInt(m[1], 10));
}
return `${prefix}_${String(max + 1).padStart(3, "0")}`;
}
// Sync sleep (no busy-spin) for the allocation lock retry.
function sleepMs(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
// Coarse per-project lock so concurrent resolves don't race on id allocation.
// ponytail: one lock file with a 15s stale-steal (a crashed holder can't wedge
// the project); fine for agent-scale concurrency — revisit if throughput needs
// finer locking. Date.now() is available here (a normal Node CLI, not a
// workflow DSL), so mtime-based staleness is safe.
const LOCK_STALE_MS = 15000;
const LOCK_TIMEOUT_MS = 20000;
function withLock(dir, fn) {
const lock = join(dir, ".lock");
const start = Date.now();
for (;;) {
try {
closeSync(openSync(lock, "wx")); // O_EXCL: atomic acquire
break;
} catch (err) {
if (err.code !== "EEXIST") throw err;
try {
if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
rmSync(lock, { force: true }); // steal a stale lock from a dead holder
continue;
}
} catch {
continue; // lock vanished between check and stat — retry the acquire
}
if (Date.now() - start > LOCK_TIMEOUT_MS) {
throw new Error("media-use: timed out acquiring .media/.lock");
}
sleepMs(25);
}
}
try {
return fn();
} finally {
rmSync(lock, { force: true });
}
}
// Atomically allocate the next free id for `type` AND reserve its file, so a
// slow download/copy between allocation and appendRecord can't let a concurrent
// caller grab the same id (the MU-23 clobber). Under the lock we take the max id
// across BOTH the manifest and any already-reserved files in the type dir, then
// O_EXCL-create an empty placeholder at the target path; freeze/copy overwrites
// it. Returns { id, localPath }.
export function allocateId(projectDir, type, ext) {
mkdirSync(mediaDir(projectDir), { recursive: true });
const typeDir = typeDirPath(projectDir, type);
mkdirSync(typeDir, { recursive: true });
return withLock(mediaDir(projectDir), () => {
const re = new RegExp(`^${type}_(\\d+)`);
let max = 0;
for (const r of readManifest(projectDir)) {
if (r.type !== type) continue;
const m = r.id?.match(re);
if (m) max = Math.max(max, parseInt(m[1], 10));
}
for (const f of readdirSync(typeDir)) {
const m = f.match(re);
if (m) max = Math.max(max, parseInt(m[1], 10)); // skip ids reserved but not yet appended
}
const id = `${type}_${String(max + 1).padStart(3, "0")}`;
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
writeFileSync(join(projectDir, localPath), "", { flag: "wx" }); // durable reservation
return { id, localPath };
});
}
function reservedFile(projectDir, type, ext) {
const allocation = allocateId(projectDir, type, ext);
return { ...allocation, fullPath: join(projectDir, allocation.localPath) };
}
function rollbackReservation(reservation) {
rmSync(reservation.fullPath, { force: true });
}
// A reservation is committed only when populate returns a non-null value.
// Throwing/rejecting or returning null means no usable asset was produced, so
// the placeholder must be released. Keeping this transaction beside allocateId
// prevents individual provider/cache/LUT paths from forgetting the rollback.
export function withReservedFileSync(projectDir, type, ext, populate) {
const reservation = reservedFile(projectDir, type, ext);
try {
const result = populate(reservation);
if (result == null) rollbackReservation(reservation);
return result;
} catch (error) {
rollbackReservation(reservation);
throw error;
}
}
export async function withReservedFile(projectDir, type, ext, populate) {
const reservation = reservedFile(projectDir, type, ext);
try {
const result = await populate(reservation);
if (result == null) rollbackReservation(reservation);
return result;
} catch (error) {
rollbackReservation(reservation);
throw error;
}
}
scripts/lib/manifest.test.mjsimport { strict as assert } from "node:assert";
import {
mkdtempSync,
rmSync,
readFileSync,
writeFileSync,
mkdirSync,
existsSync,
readdirSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
readManifest,
appendRecord,
findByPrompt,
findByEntity,
nextId,
allocateId,
withReservedFileSync,
normalizePrompt,
manifestPath,
mediaDir,
typeDirPath,
typeSubdir,
} from "./manifest.mjs";
import { regenerateIndex, generateIndexContent } from "./index-gen.mjs";
import {
contentHash,
cachePut,
cacheGet,
cacheGetByEntity,
importFromCache,
promote,
} from "./cache.mjs";
let tmp;
function setup() {
tmp = mkdtempSync(join(tmpdir(), "mu-test-"));
}
function cleanup() {
if (tmp) rmSync(tmp, { recursive: true, force: true });
}
function makeRecord(overrides = {}) {
return {
id: "bgm_001",
type: "bgm",
path: ".media/audio/bgm/bgm_001.wav",
source: "search",
description: "soft minimal ambient",
duration: 11,
provenance: { provider: "heygen.audio.sounds", prompt: "subtle tech" },
...overrides,
};
}
function runTests() {
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
// --- manifest.mjs ---
test("readManifest returns empty array when no manifest exists", () => {
setup();
const result = readManifest(tmp);
assert.deepStrictEqual(result, []);
cleanup();
});
test("appendRecord writes valid JSONL and readManifest parses it back", () => {
setup();
const record = makeRecord();
appendRecord(tmp, record);
const records = readManifest(tmp);
assert.equal(records.length, 1);
assert.deepStrictEqual(records[0], record);
cleanup();
});
test("appendRecord creates .media/ and type subdirs on first write", () => {
setup();
appendRecord(tmp, makeRecord());
assert.ok(existsSync(mediaDir(tmp)));
assert.ok(existsSync(typeDirPath(tmp, "bgm")));
cleanup();
});
test("lut and grade artifacts use the shared .media/luts subdir", () => {
assert.equal(typeSubdir("lut"), "luts");
assert.equal(typeSubdir("grade"), "luts");
setup();
const allocated = allocateId(tmp, "lut", ".cube");
assert.equal(allocated.localPath, ".media/luts/lut_001.cube");
assert.ok(existsSync(join(tmp, allocated.localPath)));
cleanup();
});
test("appendRecord appends multiple records", () => {
setup();
appendRecord(tmp, makeRecord({ id: "bgm_001" }));
appendRecord(tmp, makeRecord({ id: "bgm_002", provenance: { prompt: "energetic" } }));
const records = readManifest(tmp);
assert.equal(records.length, 2);
assert.equal(records[0].id, "bgm_001");
assert.equal(records[1].id, "bgm_002");
cleanup();
});
test("findByPrompt returns exact-match record", () => {
setup();
appendRecord(tmp, makeRecord());
const found = findByPrompt(tmp, "subtle tech", "bgm");
assert.ok(found);
assert.equal(found.id, "bgm_001");
cleanup();
});
test("findByPrompt returns null on miss", () => {
setup();
appendRecord(tmp, makeRecord());
assert.equal(findByPrompt(tmp, "nonexistent", "bgm"), null);
cleanup();
});
test("findByPrompt filters by type", () => {
setup();
appendRecord(tmp, makeRecord({ type: "sfx" }));
assert.equal(findByPrompt(tmp, "subtle tech", "bgm"), null);
assert.ok(findByPrompt(tmp, "subtle tech", "sfx"));
cleanup();
});
test("findByPrompt matches across case and whitespace variants", () => {
setup();
appendRecord(tmp, makeRecord({ provenance: { provider: "x", prompt: "calm ambient piano" } }));
assert.ok(findByPrompt(tmp, "Calm Ambient Piano", "bgm"), "case-insensitive");
assert.ok(findByPrompt(tmp, " calm ambient piano ", "bgm"), "whitespace-insensitive");
assert.equal(findByPrompt(tmp, "calm ambient guitar", "bgm"), null, "still a real miss");
cleanup();
});
test("normalizePrompt trims, lowercases, collapses whitespace", () => {
assert.equal(normalizePrompt(" Upbeat Tech Launch "), "upbeat tech launch");
assert.equal(normalizePrompt(null), "");
});
test("allocateId reserves the id on disk so a pre-append caller can't reuse it (MU-23)", () => {
setup();
const a = allocateId(tmp, "bgm", ".wav");
assert.equal(a.id, "bgm_001");
assert.ok(existsSync(join(tmp, a.localPath)), "placeholder reserved on disk");
// Second allocation BEFORE any manifest append (the download window) must not
// hand back bgm_001 again, even with a different extension.
const b = allocateId(tmp, "bgm", ".mp3");
assert.equal(b.id, "bgm_002");
assert.notEqual(a.localPath, b.localPath);
// Lock file is released (not left behind).
assert.ok(!existsSync(join(tmp, ".media", ".lock")), "lock released");
cleanup();
});
test("allocateId continues past the highest manifest id", () => {
setup();
appendRecord(tmp, makeRecord({ id: "bgm_005" }));
assert.equal(allocateId(tmp, "bgm", ".wav").id, "bgm_006");
cleanup();
});
test("failed reservation rollback preserves another completed reservation", () => {
setup();
const kept = withReservedFileSync(tmp, "bgm", ".wav", (reservation) => {
writeFileSync(reservation.fullPath, "completed asset");
return reservation;
});
assert.throws(
() =>
withReservedFileSync(tmp, "bgm", ".wav", () => {
throw new Error("populate failed");
}),
/populate failed/,
);
assert.equal(kept.id, "bgm_001");
assert.equal(readFileSync(kept.fullPath, "utf8"), "completed asset");
assert.deepStrictEqual(readdirSync(typeDirPath(tmp, "bgm")), ["bgm_001.wav"]);
assert.equal(allocateId(tmp, "bgm", ".wav").id, "bgm_002");
cleanup();
});
test("empty reservation result releases the placeholder", () => {
setup();
assert.equal(
withReservedFileSync(tmp, "image", ".jpg", () => null),
null,
);
assert.deepStrictEqual(readdirSync(typeDirPath(tmp, "image")), []);
cleanup();
});
test("findByEntity matches case-insensitively", () => {
setup();
appendRecord(tmp, makeRecord({ entity: "GitHub", type: "icon" }));
assert.ok(findByEntity(tmp, "github"));
assert.ok(findByEntity(tmp, "GITHUB"));
assert.equal(findByEntity(tmp, "gitlab"), null);
cleanup();
});
test("nextId generates sequential ids", () => {
setup();
assert.equal(nextId(tmp, "bgm"), "bgm_001");
appendRecord(tmp, makeRecord({ id: "bgm_001" }));
assert.equal(nextId(tmp, "bgm"), "bgm_002");
appendRecord(tmp, makeRecord({ id: "bgm_002" }));
assert.equal(nextId(tmp, "bgm"), "bgm_003");
cleanup();
});
// --- index-gen.mjs ---
test("regenerateIndex produces plain-column table", () => {
setup();
appendRecord(tmp, makeRecord());
regenerateIndex(tmp);
const content = readFileSync(join(tmp, ".media", "index.md"), "utf8");
assert.ok(content.includes("# .media · 1 asset"));
assert.ok(content.includes("bgm_001"));
assert.ok(content.includes("soft minimal ambient"));
assert.ok(content.includes("11s"));
cleanup();
});
test("regenerateIndex handles empty manifest", () => {
setup();
mkdirSync(join(tmp, ".media"), { recursive: true });
writeFileSync(manifestPath(tmp), "");
regenerateIndex(tmp);
const content = readFileSync(join(tmp, ".media", "index.md"), "utf8");
assert.ok(content.includes("# .media · 0 assets"));
cleanup();
});
test("generateIndexContent includes dims for images", () => {
const records = [
makeRecord({ id: "img_001", type: "image", width: 1920, height: 1080, duration: null }),
];
const content = generateIndexContent(records);
assert.ok(content.includes("1920×1080"));
assert.ok(content.includes("img_001"));
});
test("regenerateIndex matches manifest content after multiple writes", () => {
setup();
appendRecord(tmp, makeRecord({ id: "bgm_001" }));
appendRecord(
tmp,
makeRecord({ id: "sfx_001", type: "sfx", description: "whoosh", duration: 3 }),
);
regenerateIndex(tmp);
const content = readFileSync(join(tmp, ".media", "index.md"), "utf8");
assert.ok(content.includes("# .media · 2 assets"));
assert.ok(content.includes("bgm_001"));
assert.ok(content.includes("sfx_001"));
assert.ok(content.includes("whoosh"));
cleanup();
});
// --- cache.mjs ---
test("cacheGet returns null when cache is empty", () => {
const result = cacheGet("nonexistent prompt", "bgm");
assert.equal(result, null);
});
test("cachePut + cacheGet round-trip", () => {
setup();
const filePath = join(tmp, "test.wav");
writeFileSync(filePath, "fake audio bytes for testing");
const record = makeRecord({ provenance: { prompt: "cache test" } });
const { sha } = cachePut(filePath, record);
assert.ok(sha);
assert.equal(sha.length, 64);
const found = cacheGet("cache test", "bgm");
assert.ok(found);
assert.equal(found.reusable, true);
assert.equal(found.sha, sha);
// cross-project reuse must survive trivial prompt variation, not just
// byte-identical intents (the whole point of normalizePrompt).
assert.ok(cacheGet(" Cache Test ", "bgm"), "cacheGet is case/whitespace-insensitive");
cleanup();
});
test("cacheGetByEntity finds cached asset", () => {
setup();
const filePath = join(tmp, "logo.png");
writeFileSync(filePath, "fake png bytes");
const record = makeRecord({
type: "icon",
entity: "TestCorp",
provenance: { prompt: "TestCorp logo" },
});
cachePut(filePath, record);
const found = cacheGetByEntity("testcorp");
assert.ok(found);
assert.equal(found.entity, "TestCorp");
cleanup();
});
test("contentHash is deterministic", () => {
setup();
const filePath = join(tmp, "det.bin");
writeFileSync(filePath, "deterministic content");
const h1 = contentHash(filePath);
const h2 = contentHash(filePath);
assert.equal(h1, h2);
cleanup();
});
test("promote copies project asset to global cache", () => {
setup();
const record = makeRecord();
appendRecord(tmp, record);
const filePath = join(tmp, record.path);
mkdirSync(join(filePath, ".."), { recursive: true });
writeFileSync(filePath, "promotable audio data");
const { sha } = promote(tmp, "bgm_001");
assert.ok(sha);
const cached = cacheGet("subtle tech", "bgm");
assert.ok(cached);
assert.equal(cached.sha, sha);
cleanup();
});
test("importFromCache copies cached file into project", () => {
setup();
const filePath = join(tmp, "source.wav");
writeFileSync(filePath, "importable audio");
const record = makeRecord({ provenance: { prompt: "import test" } });
const { sha } = cachePut(filePath, record);
const cached = cacheGet("import test", "bgm");
const projectDir = mkdtempSync(join(tmpdir(), "mu-import-"));
const imported = importFromCache(cached, projectDir, "bgm_001", ".media/audio/bgm/bgm_001.wav");
assert.ok(imported);
assert.equal(imported.id, "bgm_001");
assert.equal(imported.provenance.imported_from, sha);
assert.ok(existsSync(join(projectDir, ".media/audio/bgm/bgm_001.wav")));
rmSync(projectDir, { recursive: true, force: true });
cleanup();
});
// --- run ---
let passed = 0;
let failed = 0;
for (const { name, fn } of tests) {
try {
fn();
passed++;
console.log(` \x1b[32m✓\x1b[0m ${name}`);
} catch (err) {
failed++;
console.log(` \x1b[31m✗\x1b[0m ${name}`);
console.log(` ${err.message}`);
}
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
console.log("media-use · manifest/index/cache tests\n");
runTests();
scripts/lib/match.mjs// Shared lexical-matching helpers used by both the assets/ scan (adopt.mjs) and
// the reuse-candidate ranker (candidates.mjs), and the type-equivalence check
// used by resolve.mjs and candidates.mjs. Kept in one place so the icon<->image
// equivalence and the token rules can't drift between the "do" path (resolve)
// and the "look" path (candidates).
// Common filler words that should never, on their own, make two strings match.
const MATCH_STOPWORDS = new Set([
"the",
"and",
"for",
"with",
"from",
"this",
"that",
"your",
"our",
]);
// Split into lowercased word tokens of length >= 3, minus stopwords.
export function matchTokens(text) {
return new Set(
String(text)
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((t) => t.length >= 3 && !MATCH_STOPWORDS.has(t)),
);
}
// Count of shared meaningful word tokens between two strings. 0 = no lexical
// overlap (the candidate ranker still surfaces these, ordered after overlaps).
export function tokenOverlap(a, b) {
const ta = matchTokens(a);
const tb = matchTokens(b);
let n = 0;
for (const t of ta) if (tb.has(t)) n++;
return n;
}
// icon, image, and logo are interchangeable: all live in images/, and
// figma-imported brand marks are recorded as type image while agents ask for
// logos as icon or logo.
export function typesMatch(a, b) {
if (a === b) return true;
const visual = new Set(["icon", "image", "logo"]);
return visual.has(a) && visual.has(b);
}
scripts/lib/mflux-provider.mjsimport { execFileSync } from "node:child_process";
import { existsSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describeDownload, probeSpecs } from "./specs.mjs";
import { buildArgv, selectModelLadder } from "./local-models.mjs";
// Local image generation via mflux (FLUX-on-MLX), the Mac-native runner.
// Spec-gated: selectModelLadder("imagegen", specs) returns every FLUX-class
// model the machine's AVAILABLE RAM can actually run (medium FLUX-schnell
// --low-ram on ~24GB, up to Qwen-Image on 64GB+), best first. When nothing
// local fits, or no fitting tier can actually run here, this returns null so
// the registry falls through to the codex image upsell.
//
// The official FLUX repos are HF-gated, so the model entries point --path at
// non-gated community 4-bit re-uploads; the repo is resolved to a local snapshot
// (hf download, idempotent) because a bare repo id breaks mlx unflatten.
// Resolve an HF repo to its local snapshot dir. `hf download` is idempotent and
// prints the snapshot path as its last line.
function resolveSnapshot(repo, execFn, pathExists) {
const out = execFn("hf", ["download", repo], {
encoding: "utf8",
timeout: 1_800_000,
stdio: ["ignore", "pipe", "pipe"],
});
const path = out?.trim().split(/\r?\n/).pop()?.trim();
return path && pathExists(path) ? path : null;
}
export async function mfluxImageGenerate(
intent,
ctx,
execFn = execFileSync,
pathExists = existsSync,
unlinkFn = unlinkSync,
) {
const specs = ctx?.specs || probeSpecs();
const ladder = selectModelLadder("imagegen", specs, { preferTier: ctx?.preferTier });
if (!ladder.length) return null; // no local model fits -> codex upsell/fallback
// Each attempt mints its own timestamped output path, so a partial artifact
// from a failed tier is orphaned rather than overwritten - and a lower tier
// then succeeding hides it. Discard it before demoting. Best-effort: a
// partial we cannot remove must never mask the real failure.
const discardPartial = (path) => {
try {
if (pathExists(path)) unlinkFn(path);
} catch {
// nothing actionable: the generate failure below is the real story
}
};
// Best tier first, demoting past any tier that cannot run here (runner off
// PATH, a snapshot that won't download, an OOM) rather than failing local
// image gen outright. Every demotion is reported, so a quietly smaller model
// is never mistaken for the tier the machine nominally qualified for.
for (const model of ladder) {
const bin = model.invoke.trim().split(/\s+/)[0];
// Not installed? Surface the exact enable-command (before the model
// download) so the agent learns the free local path is available instead of
// silently taking the codex upsell.
try {
execFn("which", [bin], { stdio: ["ignore", "ignore", "ignore"] });
} catch {
console.error(
`media-use: local image gen not enabled (\`${bin}\` not on PATH). Install for free on-device FLUX: ${model.install}. Heads up: ${model.id} ${describeDownload(model.sizeMB)}.`,
);
continue;
}
const outPath = join(tmpdir(), `media-use-mflux-${process.pid}-${Date.now()}.png`);
const vars = {
prompt: intent,
w: ctx?.width || 512,
h: ctx?.height || 512,
seed: ctx?.seed ?? 42,
out: outPath,
};
if (model.repo && model.invoke.includes("{model_path}")) {
let snap = null;
let why = `could not resolve a local snapshot of ${model.repo}`;
try {
snap = resolveSnapshot(model.repo, execFn, pathExists);
} catch (err) {
why = `hf download failed: ${err.stderr?.toString().trim().slice(-200) || err.message}`;
}
if (!snap) {
console.error(`media-use: local image gen (${model.id}): ${why}`);
continue;
}
vars.model_path = snap;
}
const argv = buildArgv(model.invoke, vars);
argv.shift(); // drop the bin (already validated)
try {
execFn(bin, argv, {
encoding: "utf8",
timeout: 1_800_000,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
discardPartial(outPath);
console.error(
`media-use: local image gen (${model.id}) failed: ${err.stderr?.toString().trim().slice(-200) || err.message}`,
);
continue;
}
if (!pathExists(outPath)) {
console.error(
`media-use: local image gen (${model.id}) exited cleanly but wrote no output file`,
);
continue;
}
return {
localPath: outPath,
ext: ".png",
source: "generated",
metadata: {
description: intent,
provider: `mflux.${model.id}`,
provenance: { model: model.id, tier: model.tier, prompt: intent },
},
};
}
return null;
}
scripts/lib/mflux-provider.test.mjsimport { test } from "node:test";
import assert from "node:assert/strict";
import { mfluxImageGenerate } from "./mflux-provider.mjs";
// 40GB clears the 32GB klein tier and the 8GB schnell tier below it; the 64GB
// qwen tier stays out of reach. Two rungs is what makes demotion observable.
const bothTiersSpecs = { availableRamMB: 40000, gpu: { present: true } };
const SNAPSHOT = "/tmp/hf-snapshot";
// exec stub covering all three shells-out mflux does: the PATH probe, the
// idempotent `hf download`, and the generate itself.
function stubExec({ failGenerateFor = [], failWhichFor = [] } = {}) {
const calls = [];
const exec = (...call) => {
calls.push(call);
const [bin, argv] = call;
if (bin === "which") {
if (failWhichFor.includes(argv[0])) throw new Error("not found");
return "";
}
if (bin === "hf") return `Fetching 6 files...\n${SNAPSHOT}\n`;
if (failGenerateFor.some((id) => argv.join(" ").includes(id))) {
const err = new Error("exit 1");
err.stderr = "mlx.core.metal: out of memory";
throw err;
}
return "";
};
return { calls, exec };
}
const generateCalls = (calls) =>
calls.filter(([bin]) => bin !== "which" && bin !== "hf").map(([, argv]) => argv.join(" "));
test("no local model fits: falls through to the upsell without shelling out", async () => {
const { calls, exec } = stubExec();
const result = await mfluxImageGenerate(
"a red bicycle",
{ specs: { availableRamMB: 100, gpu: { present: true } } },
exec,
() => true,
);
assert.equal(result, null);
assert.deepEqual(calls, []);
});
test("a top tier that cannot run demotes to the next fitting tier", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
const { calls, exec } = stubExec({ failGenerateFor: ["flux2-klein-4b"] });
const result = await mfluxImageGenerate(
"a red bicycle",
{ specs: bothTiersSpecs },
exec,
() => true,
);
assert.ok(result, "the schnell tier still produced an image");
assert.equal(result.metadata.provider, "mflux.flux-schnell-mflux-q4");
const generated = generateCalls(calls);
assert.equal(generated.length, 2, "klein attempted first, then schnell");
assert.match(generated[0], /flux2-klein-4b/);
assert.match(generated[1], /--model schnell/);
assert.equal(errors.length, 1, "the demotion is reported, not silent");
assert.match(errors[0], /flux2-klein-mflux-q4\) failed/);
});
test("a snapshot that will not resolve demotes rather than failing outright", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
const calls = [];
const exec = (...call) => {
calls.push(call);
const [bin, argv] = call;
if (bin === "which") return "";
// klein's weights won't download; schnell's do
if (bin === "hf") {
if (argv[1].includes("FLUX.2-klein")) throw new Error("403 Forbidden");
return `${SNAPSHOT}\n`;
}
return "";
};
const result = await mfluxImageGenerate(
"a red bicycle",
{ specs: bothTiersSpecs },
exec,
() => true,
);
assert.ok(result, "demoted past the ungettable weights");
assert.equal(result.metadata.provider, "mflux.flux-schnell-mflux-q4");
assert.equal(errors.length, 1);
assert.match(errors[0], /hf download failed/);
});
test("runner missing from PATH reports the install hint per tier and returns null", async (t) => {
const errors = [];
t.mock.method(console, "error", (message) => errors.push(message));
const { exec } = stubExec({ failWhichFor: ["mflux-generate"] });
const result = await mfluxImageGenerate(
"a red bicycle",
{ specs: bothTiersSpecs },
exec,
() => true,
);
assert.equal(result, null);
assert.equal(errors.length, 2, "both fitting tiers reported");
assert.match(errors[0], /uv pip install mflux/);
// each hint states that tier's download cost before the user commits
assert.match(errors[0], /GB of weights to/);
assert.match(errors[1], /GB of weights to/);
});
// Same per-attempt temp path, same orphaning risk as the LTX provider: a
// partial png from a failed tier must not survive a lower tier succeeding.
const outputOf = (argv) => argv[argv.indexOf("--output") + 1];
const attemptedOutputs = (calls) =>
calls.filter(([bin]) => bin !== "which" && bin !== "hf").map(([, argv]) => outputOf(argv));
test("a failed attempt's partial output is discarded before demoting", async (t) => {
t.mock.method(console, "error", () => {});
const unlinked = [];
const { calls, exec } = stubExec({ failGenerateFor: ["flux2-klein-4b"] });
const result = await mfluxImageGenerate(
"a red bicycle",
{ specs: bothTiersSpecs },
exec,
() => true,
(path) => unlinked.push(path),
);
assert.ok(result, "the schnell tier still produced an image");
assert.deepEqual(unlinked, [attemptedOutputs(calls)[0]], "the failed klein partial is removed");
});
test("every tier failing discards every partial, one per attempt", async (t) => {
t.mock.method(console, "error", () => {});
const unlinked = [];
const { calls, exec } = stubExec({ failGenerateFor: ["flux2-klein-4b", "schnell"] });
const result = await mfluxImageGenerate(
"a red bicycle",
{ specs: bothTiersSpecs },
exec,
() => true,
(path) => unlinked.push(path),
);
assert.equal(result, null);
assert.deepEqual(unlinked, attemptedOutputs(calls), "nothing is left behind");
});
test("a successful generation is never discarded", async () => {
const unlinked = [];
const { exec } = stubExec();
const result = await mfluxImageGenerate(
"a red bicycle",
{ specs: bothTiersSpecs },
exec,
() => true,
(path) => unlinked.push(path),
);
assert.ok(result);
assert.deepEqual(unlinked, [], "the returned artifact must survive");
});
scripts/lib/misses.mjsimport { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
const MISSES_FILE = "misses.jsonl";
function missesPath() {
return join(homedir(), ".media", MISSES_FILE);
}
export function recordMiss({ type, intent, provider_override, local_only }) {
try {
const dir = join(homedir(), ".media");
mkdirSync(dir, { recursive: true });
appendFileSync(
join(dir, MISSES_FILE),
JSON.stringify({
ts: new Date().toISOString(),
type,
intent,
provider_override: !!provider_override,
local_only: !!local_only,
}) + "\n",
);
} catch {
// local miss logging is best-effort; never surface into resolve
}
}
export function readMisses() {
const p = missesPath();
try {
if (!existsSync(p)) return [];
const raw = readFileSync(p, "utf8");
const records = [];
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
records.push(JSON.parse(trimmed));
} catch {
// skip malformed local lines, don't crash stats
}
}
return records;
} catch {
return [];
}
}
scripts/lib/misses.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readMisses, recordMiss } from "./misses.mjs";
function sandbox() {
const root = mkdtempSync(join(tmpdir(), "mu-misses-"));
const home = join(root, "home");
mkdirSync(home, { recursive: true });
process.env.HOME = home;
return { root, home };
}
function restoreEnv(saved) {
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
Object.assign(process.env, saved);
}
test("recordMiss appends a well-formed local miss", () => {
const savedEnv = { ...process.env };
const { root, home } = sandbox();
try {
recordMiss({ type: "bgm", intent: "moody synth pulse", provider_override: true });
const misses = readMisses();
assert.equal(misses.length, 1);
assert.equal(misses[0].type, "bgm");
assert.equal(misses[0].intent, "moody synth pulse");
assert.equal(misses[0].provider_override, true);
assert.equal(misses[0].local_only, false);
assert.ok(!Number.isNaN(Date.parse(misses[0].ts)));
const raw = readFileSync(join(home, ".media/misses.jsonl"), "utf8");
assert.match(raw, /moody synth pulse/);
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
}
});
test("recordMiss swallows filesystem failures", () => {
const savedEnv = { ...process.env };
const { root, home } = sandbox();
try {
writeFileSync(join(home, ".media"), "not a directory");
assert.doesNotThrow(() =>
recordMiss({ type: "image", intent: "unwritable", local_only: true }),
);
assert.equal(existsSync(join(home, ".media/misses.jsonl")), false);
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
}
});
test("readMisses skips corrupt lines", () => {
const savedEnv = { ...process.env };
const { root, home } = sandbox();
try {
mkdirSync(join(home, ".media"), { recursive: true });
writeFileSync(
join(home, ".media/misses.jsonl"),
[
JSON.stringify({ ts: "2026-07-09T00:00:00.000Z", type: "bgm", intent: "one" }),
"{not json",
JSON.stringify({ ts: "2026-07-09T00:00:01.000Z", type: "sfx", intent: "two" }),
].join("\n"),
);
assert.deepEqual(
readMisses().map((miss) => miss.intent),
["one", "two"],
);
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
}
});
scripts/lib/npx-sync.mjsimport { existsSync } from "node:fs";
import { resolveSpawnCommand } from "../../audio/scripts/lib/tts.mjs";
// Sync-spawn analog of the audio engine's spawnP, for execFileSync call sites
// that must hard-fail (rather than fall through to another provider) when npx
// cannot be resolved. On Windows a bare "npx" is npx.cmd, which
// execFileSync/spawnSync cannot exec (spawnSync npx ENOENT) —
// resolveSpawnCommand reroutes it through node + npx-cli.js, no shell:true.
//
// `platform`/`env`/`pathExists` params (defaulting to the real values) exist
// so tests can exercise the win32 branch without mocking node:child_process
// (its ESM exports are non-configurable) — same idiom as spawnP and
// localTtsGenerate.
export function resolveNpxInvocation(
argv,
opts,
platform = process.platform,
env = process.env,
pathExists = existsSync,
) {
const resolved = resolveSpawnCommand("npx", argv, opts, platform, env, pathExists);
if (!resolved) {
// npx-on-win32 with no resolvable npx-cli.js — same terminal condition
// spawnP warns about, surfaced as a throw for callers with no fallback.
throw new Error(
"cannot run npx on Windows: npm's npx-cli.js was not found " +
"(install npm with Node, or run via npx/npm run so npm_execpath is set)",
);
}
return resolved;
}
scripts/lib/npx-sync.test.mjsimport { test } from "node:test";
import assert from "node:assert/strict";
import { resolveNpxInvocation } from "./npx-sync.mjs";
// Coverage parity with tts-local-provider.test.mjs, for the whisper.cpp
// fallback's call-site shape (transcribe.mjs runWhisper): the same three
// branches, but with the hard-fail contract — no fallback provider exists,
// so an unresolvable npx must throw actionably instead of returning null.
const envWithNpxCli = {
npm_execpath: "C:/Program Files/nodejs/node_modules/npm/bin/npm-cli.js",
npm_node_execpath: "C:/Program Files/nodejs/node.exe",
};
const npxCliPath = "C:/Program Files/nodejs/node_modules/npm/bin/npx-cli.js";
const pathExists = (path) => path === npxCliPath;
const WHISPER_ARGV = ["hyperframes", "transcribe", "C:\\media\\input.wav", "--dir", "C:\\work"];
const WHISPER_OPTS = { stdio: ["ignore", "pipe", "pipe"], timeout: 1_800_000 };
test("win32: routes the whisper fallback through node + npx-cli, never bare npx", () => {
const resolved = resolveNpxInvocation(
WHISPER_ARGV,
WHISPER_OPTS,
"win32",
envWithNpxCli,
pathExists,
);
assert.equal(resolved.cmd, envWithNpxCli.npm_node_execpath);
assert.equal(resolved.args[0], npxCliPath);
assert.deepEqual(resolved.args.slice(1, 3), ["hyperframes", "transcribe"]);
// execFileSync options survive the rerouting (the timeout bounds the
// whisper build/model download; the pipes surface its errors).
assert.deepEqual(resolved.opts.stdio, ["ignore", "pipe", "pipe"]);
assert.equal(resolved.opts.timeout, 1_800_000);
});
test("win32 without a resolvable npx-cli: throws the actionable install hint", () => {
assert.throws(
() => resolveNpxInvocation(WHISPER_ARGV, WHISPER_OPTS, "win32", {}, () => false),
/npx-cli\.js was not found/,
);
});
test("non-win32: spawns plain npx unchanged", () => {
const resolved = resolveNpxInvocation(WHISPER_ARGV, WHISPER_OPTS, "darwin", {}, () => false);
assert.equal(resolved.cmd, "npx");
assert.deepEqual(resolved.args, WHISPER_ARGV);
});
scripts/lib/parakeet-words.mjs// Merge Parakeet-MLX token timestamps into word timestamps.
//
// parakeet-mlx JSON emits SUB-WORD tokens (" H", "ello", ...) with per-token
// start/end. Captions + transcript-cut need WORD timestamps, so join tokens
// into words on the space boundary: a token whose text starts with a space
// (or the very first token) begins a new word; the rest append. Output matches
// the { words: [{ text, start, end }] } shape the rest of media-use consumes
// (see words.mjs / cutlist.mjs).
export function mergeTokensToWords(parakeet) {
const sentences = Array.isArray(parakeet?.sentences) ? parakeet.sentences : [];
const words = [];
for (const s of sentences) {
for (const t of s.tokens ?? []) {
const raw = typeof t.text === "string" ? t.text : "";
const startsWord = raw.startsWith(" ") || words.length === 0;
if (startsWord) {
words.push({ text: raw.trim(), start: t.start, end: t.end });
} else {
const w = words[words.length - 1];
w.text += raw;
w.end = t.end;
}
}
}
return { text: (parakeet?.text ?? "").trim(), words: words.filter((w) => w.text.length > 0) };
}
scripts/lib/parakeet-words.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import { mergeTokensToWords } from "./parakeet-words.mjs";
test("mergeTokensToWords joins sub-word tokens on the space boundary", () => {
const parakeet = {
text: "Hello everyone. Um,",
sentences: [
{
tokens: [
{ text: " H", start: 0.0, end: 0.24 },
{ text: "ello", start: 0.24, end: 0.48 },
{ text: " everyone.", start: 0.48, end: 1.28 },
{ text: " Um,", start: 1.28, end: 1.92 },
],
},
],
};
const { words } = mergeTokensToWords(parakeet);
assert.deepEqual(words, [
{ text: "Hello", start: 0.0, end: 0.48 },
{ text: "everyone.", start: 0.48, end: 1.28 },
{ text: "Um,", start: 1.28, end: 1.92 },
]);
});
test("mergeTokensToWords spans multiple sentences and drops empties", () => {
const parakeet = {
text: "Hi there",
sentences: [
{ tokens: [{ text: "Hi", start: 0, end: 0.2 }] },
{ tokens: [{ text: " there", start: 0.5, end: 0.9 }] },
],
};
const { words } = mergeTokensToWords(parakeet);
assert.equal(words.length, 2);
assert.equal(words[1].text, "there");
assert.equal(words[1].start, 0.5);
});
test("mergeTokensToWords tolerates missing sentences/tokens", () => {
assert.deepEqual(mergeTokensToWords({}).words, []);
assert.deepEqual(mergeTokensToWords({ sentences: [{}] }).words, []);
});
scripts/lib/prefs-store.mjsimport { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
/**
* Remembered defaults — the lightweight tier of HyperFrames user memory.
*
* Two files, same shape as the rest of media-use's storage split:
* - project `.media/preferences.json` — committed with the repo, so the whole
* team inherits it; written every time a brief answer is confirmed.
* - user `~/.media/preferences.json` — personal, cross-repo. A key is promoted
* here only once the same value has been confirmed in two different projects
* (`PROMOTE_AT`), so a one-off choice never pollutes the global defaults.
* Pre-promotion evidence accumulates in the user file's `sightings` ledger —
* project files can't see each other, so the cross-project count has to live
* user-side.
*
* Consumption contract (brief-contract § 2, Remembered defaults): a remembered
* value becomes the recommended option with a receipt naming its source — it
* never skips a question, and explicit request content always wins.
*/
const PREFS_FILE = "preferences.json";
/** Keys the brief contract records; `style_preset` is stored per workflow. */
export const PREFERENCE_KEYS = [
"destination",
"aspect",
"language",
"flow",
"storyboard",
"voice",
"style_preset",
];
/** A value must be confirmed in this many distinct projects to go user-tier. */
export const PROMOTE_AT = 2;
export function projectPrefsPath(projectDir) {
return join(resolve(projectDir), ".media", PREFS_FILE);
}
export function userPrefsPath() {
return join(homedir(), ".media", PREFS_FILE);
}
function emptyFile() {
return { version: 1, preferences: {}, sightings: {} };
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Tolerant read — a missing or malformed file counts as empty. */
function readPrefsFile(path) {
try {
if (!existsSync(path)) return emptyFile();
const parsed = JSON.parse(readFileSync(path, "utf8"));
if (!isRecord(parsed)) return emptyFile();
return {
version: 1,
preferences: isRecord(parsed.preferences) ? parsed.preferences : {},
sightings: isRecord(parsed.sightings) ? parsed.sightings : {},
};
} catch {
return emptyFile();
}
}
/** Atomic write (tmp + rename) so a crash never leaves a torn file. */
function writePrefsFile(path, file) {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`);
renameSync(tmp, path);
}
/** `style_preset` entries are stored per workflow as `style_preset.<workflow>`. */
export function preferenceKeyFor(key, workflow) {
return key === "style_preset" && workflow ? `style_preset.${workflow}` : key;
}
function validEntry(entry) {
return isRecord(entry) && typeof entry.value === "string" && entry.value.length > 0;
}
/**
* The merged view the brief reads: user-tier promoted entries first, project
* entries on top (project wins). Each entry carries `source` plus the receipt
* material (`confirmed_in`, `updated_at`).
*/
export function mergedPreferences(projectDir) {
const user = readPrefsFile(userPrefsPath());
const project = readPrefsFile(projectPrefsPath(projectDir));
const merged = {};
for (const [key, entry] of Object.entries(user.preferences)) {
if (validEntry(entry)) merged[key] = { ...entry, source: "user" };
}
for (const [key, entry] of Object.entries(project.preferences)) {
if (validEntry(entry)) merged[key] = { ...entry, source: "project" };
}
return merged;
}
function dedupe(list) {
return [...new Set(list)];
}
/**
* Project tier: same value accumulates confirmations; a changed value starts
* provenance over (the old confirmations vouched for the old value).
*/
function recordProjectTier(projectDir, fullKey, value, projectName, now) {
const path = projectPrefsPath(projectDir);
const file = readPrefsFile(path);
const previous = file.preferences[fullKey];
const keepProvenance = validEntry(previous) && previous.value === value;
const confirmedIn = keepProvenance
? dedupe([...(Array.isArray(previous.confirmed_in) ? previous.confirmed_in : []), projectName])
: [projectName];
file.preferences[fullKey] = { value, confirmed_in: confirmedIn, updated_at: now };
writePrefsFile(path, file);
return confirmedIn;
}
/**
* User tier: accumulate this sighting in the ledger, and promote the key once
* the same value has been confirmed in PROMOTE_AT distinct projects.
*/
function recordUserSighting(fullKey, value, projectName, now) {
const path = userPrefsPath();
const file = readPrefsFile(path);
const keySightings = isRecord(file.sightings[fullKey]) ? file.sightings[fullKey] : {};
const seenIn = dedupe([
...(Array.isArray(keySightings[value]) ? keySightings[value] : []),
projectName,
]);
keySightings[value] = seenIn;
file.sightings[fullKey] = keySightings;
const promoted = seenIn.length >= PROMOTE_AT;
if (promoted) {
file.preferences[fullKey] = { value, confirmed_in: seenIn, updated_at: now };
}
writePrefsFile(path, file);
return promoted;
}
/**
* Record one confirmed brief answer. Always writes the project tier; feeds the
* user tier's sightings ledger and promotes once the same value has been
* confirmed in PROMOTE_AT distinct projects. Idempotent per project.
*/
export function recordPreference({ projectDir, key, value, workflow }) {
if (!PREFERENCE_KEYS.includes(key)) {
throw new Error(`unknown preference key: "${key}" (known: ${PREFERENCE_KEYS.join(", ")})`);
}
if (typeof value !== "string" || !value.trim()) {
throw new Error("a preference needs a non-empty string value");
}
if (key === "style_preset" && (!workflow || !String(workflow).trim())) {
throw new Error("style_preset is stored per workflow — pass --workflow <w>");
}
const fullKey = preferenceKeyFor(key, workflow);
const projectName = basename(resolve(projectDir));
const trimmed = value.trim();
const now = new Date().toISOString();
const confirmedIn = recordProjectTier(projectDir, fullKey, trimmed, projectName, now);
// Best-effort — a read-only home directory must never fail a brief.
let promoted = false;
try {
promoted = recordUserSighting(fullKey, trimmed, projectName, now);
} catch {
// The project record already landed; promotion just waits for next time.
}
return { key: fullKey, value: trimmed, confirmed_in: confirmedIn, promoted };
}
scripts/lib/prefs-store.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
mergedPreferences,
preferenceKeyFor,
projectPrefsPath,
recordPreference,
userPrefsPath,
} from "./prefs-store.mjs";
function sandbox() {
const root = mkdtempSync(join(tmpdir(), "mu-prefs-"));
const home = join(root, "home");
const projectA = join(root, "proj-a");
const projectB = join(root, "proj-b");
mkdirSync(home, { recursive: true });
mkdirSync(projectA, { recursive: true });
mkdirSync(projectB, { recursive: true });
process.env.HOME = home;
return { root, home, projectA, projectB };
}
function restoreEnv(saved) {
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
Object.assign(process.env, saved);
}
function withSandbox(fn) {
const savedEnv = { ...process.env };
const box = sandbox();
try {
fn(box);
} finally {
restoreEnv(savedEnv);
rmSync(box.root, { recursive: true, force: true });
}
}
test("record writes the project tier and get merges it as source=project", () => {
withSandbox(({ projectA }) => {
const result = recordPreference({ projectDir: projectA, key: "destination", value: "x-feed" });
assert.equal(result.promoted, false);
assert.deepEqual(result.confirmed_in, ["proj-a"]);
const merged = mergedPreferences(projectA);
assert.equal(merged.destination.value, "x-feed");
assert.equal(merged.destination.source, "project");
const onDisk = JSON.parse(readFileSync(projectPrefsPath(projectA), "utf8"));
assert.equal(onDisk.preferences.destination.value, "x-feed");
});
});
test("one project never promotes: a fresh project sees nothing user-tier", () => {
withSandbox(({ projectA, projectB }) => {
recordPreference({ projectDir: projectA, key: "destination", value: "x-feed" });
// proj-b has no project entry, and the value was only confirmed once.
assert.equal(mergedPreferences(projectB).destination, undefined);
});
});
test("the same value confirmed in two projects promotes to the user tier", () => {
withSandbox(({ projectA, projectB }) => {
recordPreference({ projectDir: projectA, key: "destination", value: "x-feed" });
const second = recordPreference({ projectDir: projectB, key: "destination", value: "x-feed" });
assert.equal(second.promoted, true);
const userFile = JSON.parse(readFileSync(userPrefsPath(), "utf8"));
assert.deepEqual(userFile.preferences.destination.confirmed_in.sort(), ["proj-a", "proj-b"]);
// A third project with no local entry now inherits the promoted default.
const projectC = join(projectA, "..", "proj-c");
mkdirSync(projectC, { recursive: true });
const merged = mergedPreferences(projectC);
assert.equal(merged.destination.value, "x-feed");
assert.equal(merged.destination.source, "user");
});
});
test("record is idempotent per project — confirmed_in stays deduped", () => {
withSandbox(({ projectA }) => {
recordPreference({ projectDir: projectA, key: "language", value: "zh" });
const again = recordPreference({ projectDir: projectA, key: "language", value: "zh" });
assert.deepEqual(again.confirmed_in, ["proj-a"]);
assert.equal(again.promoted, false);
});
});
test("a changed value restarts provenance instead of inheriting confirmations", () => {
withSandbox(({ projectA, projectB }) => {
recordPreference({ projectDir: projectA, key: "destination", value: "x-feed" });
recordPreference({ projectDir: projectB, key: "destination", value: "x-feed" });
const switched = recordPreference({
projectDir: projectA,
key: "destination",
value: "youtube",
});
assert.deepEqual(switched.confirmed_in, ["proj-a"]);
assert.equal(switched.promoted, false);
// The previously promoted value keeps serving other projects until youtube
// earns its own two confirmations.
const userFile = JSON.parse(readFileSync(userPrefsPath(), "utf8"));
assert.equal(userFile.preferences.destination.value, "x-feed");
});
});
test("project tier overrides a promoted user-tier value", () => {
withSandbox(({ projectA, projectB }) => {
recordPreference({ projectDir: projectA, key: "storyboard", value: "yes" });
recordPreference({ projectDir: projectB, key: "storyboard", value: "yes" });
recordPreference({ projectDir: projectA, key: "storyboard", value: "no" });
const merged = mergedPreferences(projectA);
assert.equal(merged.storyboard.value, "no");
assert.equal(merged.storyboard.source, "project");
});
});
test("the retired mode key is rejected", () => {
withSandbox(({ projectA }) => {
assert.throws(
() => recordPreference({ projectDir: projectA, key: "mode", value: "collaborative" }),
/unknown preference key/,
);
});
});
test("the run-shape keys record and promote like any field", () => {
withSandbox(({ projectA, projectB }) => {
recordPreference({ projectDir: projectA, key: "flow", value: "automation" });
const promoted = recordPreference({ projectDir: projectB, key: "flow", value: "automation" });
assert.equal(promoted.promoted, true);
recordPreference({ projectDir: projectA, key: "storyboard", value: "yes" });
const merged = mergedPreferences(projectA);
assert.equal(merged.flow.value, "automation");
assert.equal(merged.storyboard.value, "yes");
assert.equal(merged.storyboard.source, "project");
});
});
test("style_preset is keyed per workflow", () => {
withSandbox(({ projectA }) => {
assert.equal(
preferenceKeyFor("style_preset", "faceless-explainer"),
"style_preset.faceless-explainer",
);
assert.equal(preferenceKeyFor("destination", "faceless-explainer"), "destination");
recordPreference({
projectDir: projectA,
key: "style_preset",
value: "pin-and-paper",
workflow: "faceless-explainer",
});
const merged = mergedPreferences(projectA);
assert.equal(merged["style_preset.faceless-explainer"].value, "pin-and-paper");
assert.equal(merged["style_preset"], undefined);
});
});
test("style_preset without a workflow is rejected, never stored bare", () => {
withSandbox(({ projectA }) => {
assert.throws(
() => recordPreference({ projectDir: projectA, key: "style_preset", value: "pin-and-paper" }),
/pass --workflow/,
);
assert.deepEqual(mergedPreferences(projectA), {});
});
});
test("unknown keys and empty values are rejected", () => {
withSandbox(({ projectA }) => {
assert.throws(() => recordPreference({ projectDir: projectA, key: "vibe", value: "x" }));
assert.throws(() => recordPreference({ projectDir: projectA, key: "voice", value: " " }));
});
});
test("corrupt files are treated as empty instead of crashing", () => {
withSandbox(({ projectA, home }) => {
mkdirSync(join(projectA, ".media"), { recursive: true });
writeFileSync(projectPrefsPath(projectA), "not json");
mkdirSync(join(home, ".media"), { recursive: true });
writeFileSync(userPrefsPath(), '{"preferences": 42}');
assert.deepEqual(mergedPreferences(projectA), {});
const result = recordPreference({ projectDir: projectA, key: "language", value: "zh" });
assert.equal(result.value, "zh");
});
});
scripts/lib/probe.mjsimport { execFileSync } from "node:child_process";
import { extname } from "node:path";
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico"]);
export function probe(filePath) {
const ext = extname(filePath).toLowerCase();
if (ext === ".svg") return { width: null, height: null, duration: null, codec: "svg" };
try {
// execFileSync (no shell) so a hostile filename like `"; rm -rf ~; ".png`
// can't break out of the quoting — filePath is passed as a literal argv entry.
const raw = execFileSync(
"ffprobe",
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "--", filePath],
{ encoding: "utf8", timeout: 5000 },
);
const info = JSON.parse(raw);
const stream = info.streams?.[0];
const format = info.format;
const isImage = IMAGE_EXT.has(ext);
const duration = isImage
? null
: parseFloat(format?.duration) || parseFloat(stream?.duration) || null;
const width = parseInt(stream?.width, 10) || null;
const height = parseInt(stream?.height, 10) || null;
const codec = stream?.codec_name || null;
return {
duration: duration != null ? Math.round(duration * 10) / 10 : null,
width,
height,
codec,
};
} catch {
return { duration: null, width: null, height: null, codec: null };
}
}
scripts/lib/probe.test.mjsimport { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { probe } from "./probe.mjs";
// Regression for the shell-injection fix: probe() must pass the path as a literal
// argv entry, never through a shell. A filename containing shell metacharacters
// must NOT execute. Under the old execSync(`ffprobe ... "${path}"`) the embedded
// `touch` ran and created the marker; under execFileSync it cannot, regardless of
// whether ffprobe is installed (the injected command never reaches a shell).
test("probe does not execute shell metacharacters in a filename", () => {
const dir = mkdtempSync(join(tmpdir(), "probe-inject-"));
const marker = join(dir, "INJECTED");
// Slash-free basename (a real on-disk filename) that breaks out of the old
// double-quoted interpolation and would `touch INJECTED` in the cwd.
const evil = join(dir, `clip"; touch INJECTED; echo ".mp4`);
const prevCwd = process.cwd();
try {
writeFileSync(evil, "not real media");
process.chdir(dir); // so a leaked `touch INJECTED` would land next to `marker`
const meta = probe(evil);
assert.equal(existsSync(marker), false, "injected `touch` must not have run");
// Bogus/unreadable media still returns the null-shaped result, never throws.
assert.deepEqual(Object.keys(meta).sort(), ["codec", "duration", "height", "width"]);
} finally {
process.chdir(prevCwd);
rmSync(dir, { recursive: true, force: true });
}
});
scripts/lib/providers.mjs// Back-compat surface for the v1 provider API. The ordered, capability-based
// registry now lives in registry.mjs; this re-exports the v1 helpers so existing
// callers keep working. New code should import from registry.mjs directly
// (getProviders / runCapability).
export { getProvider, listTypes } from "./registry.mjs";
scripts/lib/recipe-store.mjsimport {
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, join, resolve } from "node:path";
import { appendRecord, mediaDir, nextId } from "./manifest.mjs";
import { regenerateIndex } from "./index-gen.mjs";
import { mergedPreferences } from "./prefs-store.mjs";
/**
* Recipes — the heavyweight tier of HyperFrames user memory.
*
* A recipe is the full confirmed bundle for one video type: the frozen design
* spec (`frame.md`), the storyboard skeleton (structure with the content
* blanked), and the confirmed brief values — frozen after the run's final
* approval, reused to start the next video of the same type from everything
* already approved.
*
* Storage is **named folders**, not content-addressed cache entries: a recipe
* is an evolving bundle with a `version`, so re-freezing the same name bumps
* the version and archives the old folder as `<name>@v<N>`. Two tiers, same
* split as everything else in media-use: project `.media/recipes/<name>/`
* (committed) and user `~/.media/recipes/<name>/` (a freeze is already a
* confirmed bundle, so it promotes immediately — no two-project rule here).
*/
/** Frontmatter keys that describe THIS video, not the reusable type. */
const FRONTMATTER_CONTENT_KEYS = new Set(["message", "audience", "mode"]);
/** BRIEF.md frontmatter keys that describe this run, not the reusable type —
* a recipe never locks the run's shape, so the intent layer always re-asks. */
const BRIEF_CONTENT_KEYS = new Set(["flow", "storyboard", "message", "audience"]);
/** Per-frame metadata that is content, not structure. */
const FRAME_CONTENT_KEYS = new Set([
"voiceover",
"vo",
"voice_over",
"narration",
"scene",
"description",
"summary",
"caption",
"asset_candidates",
]);
const FRAME_HEADING_RE = /^(#{2,3})\s+(?:frame|beat|scene)\s+\d+/i;
export function projectRecipesDir(projectDir) {
return join(mediaDir(projectDir), "recipes");
}
export function userRecipesDir() {
return join(homedir(), ".media", "recipes");
}
export function slugifyRecipeName(name) {
const slug = String(name ?? "")
.trim()
.toLowerCase()
.replace(/[\s_]+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
if (!slug) throw new Error(`recipe name "${name}" has no usable characters`);
return slug;
}
function frameTitle(headingLine) {
const dash = headingLine.split(/\s+—\s+/)[1];
if (dash && dash.trim()) return dash.trim();
return headingLine.replace(/^#+\s*/, "").trim();
}
/** Frontmatter: drop the content keys, keep structure/style keys verbatim. */
function skeletonFrontmatter(lines, out, contentKeys = FRONTMATTER_CONTENT_KEYS) {
if (lines[0]?.trim() !== "---") return 0;
out.push(lines[0]);
let i = 1;
while (i < lines.length && lines[i].trim() !== "---") {
const key = lines[i].match(/^(\w+)\s*:/)?.[1]?.toLowerCase();
if (!key || !contentKeys.has(key)) out.push(lines[i]);
i++;
}
if (i < lines.length) {
out.push(lines[i]); // closing ---
i++;
}
return i;
}
/** One line inside a frame section — returns the replacement lines (may be none). */
function skeletonFrameLine(line, state, out) {
const bulletKey = line.match(/^-\s+(\w+)\s*:/)?.[1]?.toLowerCase();
if (bulletKey) {
if (bulletKey === "status") out.push("- status: outline");
else if (!FRAME_CONTENT_KEYS.has(bulletKey)) out.push(line);
return;
}
if (!line.trim()) {
out.push(line);
return;
}
// Frame prose: one placeholder per frame in place of the narrative.
if (!state.proseReplaced) {
out.push(
`<fill in: this video's content for the "${state.title}" beat — keep the layout role, replace the words.>`,
);
state.proseReplaced = true;
}
}
/**
* Skeletonize a STORYBOARD.md: keep the reusable structure (frame count,
* durations, transitions, src paths, the Video direction block, style-ish
* frontmatter), reset every status to `outline`, and blank the content
* (message/audience, narration guides, per-frame prose) down to a fill-in
* placeholder that names the frame's role.
*/
/**
* Skeletonize a BRIEF.md: keep the frontmatter's reusable keys (workflow,
* destination, aspect, language, length, angle…), drop the run-shape and
* content keys (flow, storyboard, message, audience), and blank each body
* section down to a fill-in placeholder under its kept heading.
*/
export function skeletonizeBrief(source) {
const lines = String(source ?? "").split(/\r?\n/);
const out = [];
let i = skeletonFrontmatter(lines, out, BRIEF_CONTENT_KEYS);
for (; i < lines.length; i++) {
const heading = lines[i].match(/^##\s+(.+)$/);
if (heading) {
out.push(lines[i], "");
out.push(
`<fill in: this video's ${heading[1].trim().toLowerCase()} — the recipe keeps the shape, this run supplies the specifics.>`,
);
out.push("");
}
}
return out.join("\n").replace(/\n{3,}/g, "\n\n");
}
export function skeletonizeStoryboard(source) {
const lines = String(source ?? "").split(/\r?\n/);
const out = [];
const state = { inFrame: false, proseReplaced: false, title: "" };
for (let i = skeletonFrontmatter(lines, out); i < lines.length; i++) {
const line = lines[i];
if (/^#{2,3}\s/.test(line)) {
state.inFrame = FRAME_HEADING_RE.test(line);
state.proseReplaced = false;
state.title = state.inFrame ? frameTitle(line) : "";
out.push(line);
} else if (!state.inFrame) {
out.push(line);
} else {
skeletonFrameLine(line, state, out);
}
}
return out.join("\n").replace(/\n{3,}/g, "\n\n");
}
/** The run's workflow as BRIEF.md records it — the source of truth a freeze
* must not contradict. Undefined when no BRIEF.md (or no `workflow:`) exists. */
function briefWorkflow(root) {
const brief = join(root, "BRIEF.md");
if (!existsSync(brief)) return undefined;
const lines = readFileSync(brief, "utf8").split(/\r?\n/);
if (lines[0]?.trim() !== "---") return undefined;
for (let i = 1; i < lines.length && lines[i].trim() !== "---"; i++) {
const match = lines[i].match(/^workflow\s*:\s*(.+?)\s*$/);
if (match) return match[1].replace(/^["']|["']$/g, "") || undefined;
}
return undefined;
}
function readRecipeJson(dir) {
try {
const parsed = JSON.parse(readFileSync(join(dir, "recipe.json"), "utf8"));
if (typeof parsed !== "object" || parsed === null || typeof parsed.name !== "string") {
return null;
}
return parsed;
} catch {
return null;
}
}
function prefValue(prefs, key) {
return prefs[key]?.value;
}
/**
* Freeze the current project's approved run as a named recipe. Writes the
* project-tier folder + a manifest record, then copies to the user tier (a
* freeze is already confirmed — it promotes immediately).
*/
export function freezeRecipe({ projectDir, name, workflow, blocks }) {
const slug = slugifyRecipeName(name);
const root = resolve(projectDir);
const fromBrief = briefWorkflow(root);
const fromFlag = workflow && String(workflow).trim() ? String(workflow).trim() : undefined;
// BRIEF.md decides; the flag only covers projects briefed before it existed.
const resolvedWorkflow = fromBrief ?? fromFlag;
if (!resolvedWorkflow) {
throw new Error("no workflow found — BRIEF.md names none and no --workflow was given");
}
const frameSpec = join(root, "frame.md");
const storyboard = join(root, "STORYBOARD.md");
if (!existsSync(frameSpec)) throw new Error("no frame.md to freeze — run the design step first");
if (!existsSync(storyboard)) throw new Error("no STORYBOARD.md to freeze");
const dir = join(projectRecipesDir(root), slug);
let version = 1;
const previous = existsSync(dir) ? readRecipeJson(dir) : null;
if (previous) {
version = (Number.isInteger(previous.version) ? previous.version : 1) + 1;
const archive = `${dir}@v${previous.version ?? 1}`;
rmSync(archive, { recursive: true, force: true });
renameSync(dir, archive);
}
mkdirSync(dir, { recursive: true });
const prefs = mergedPreferences(root);
const recipe = {
version,
name: slug,
workflow: resolvedWorkflow,
approved_at: new Date().toISOString(),
source_project: basename(root),
destination: prefValue(prefs, "destination"),
aspect: prefValue(prefs, "aspect"),
language: prefValue(prefs, "language"),
voice: prefValue(prefs, "voice"),
// The bare-key fallback tolerates records made before the store required
// style_preset to be workflow-scoped.
style_preset:
prefValue(prefs, `style_preset.${resolvedWorkflow}`) ?? prefValue(prefs, "style_preset"),
blocks: Array.isArray(blocks) && blocks.length > 0 ? blocks : undefined,
};
writeFileSync(join(dir, "recipe.json"), `${JSON.stringify(recipe, null, 2)}\n`);
cpSync(frameSpec, join(dir, "frame.md"));
writeFileSync(
join(dir, "storyboard-skeleton.md"),
`${skeletonizeStoryboard(readFileSync(storyboard, "utf8")).trimEnd()}\n`,
);
// Best-effort fourth artifact — projects briefed before BRIEF.md existed
// (or by workflows that don't write one) freeze fine without it.
const brief = join(root, "BRIEF.md");
const briefSkeleton = existsSync(brief);
if (briefSkeleton) {
writeFileSync(
join(dir, "brief-skeleton.md"),
`${skeletonizeBrief(readFileSync(brief, "utf8")).trimEnd()}\n`,
);
}
const id = nextId(root, "recipe");
appendRecord(root, {
id,
type: "recipe",
path: `.media/recipes/${slug}/recipe.json`,
entity: slug,
description: `recipe: ${slug} (${recipe.workflow}, v${version})`,
provenance: { provider: "recipe.freeze", version, source_project: recipe.source_project },
});
regenerateIndex(root);
// User tier — best-effort, like every other promotion.
try {
const userDir = join(userRecipesDir(), slug);
mkdirSync(userDir, { recursive: true });
cpSync(dir, userDir, { recursive: true, force: true });
} catch {
// The project-tier freeze already landed.
}
return {
id,
slug,
version,
dir,
briefSkeleton,
workflow: resolvedWorkflow,
workflowOverridden: Boolean(fromBrief && fromFlag && fromBrief !== fromFlag),
};
}
function scanRecipesDir(dir, source) {
if (!existsSync(dir)) return [];
const found = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.includes("@v")) continue;
const recipe = readRecipeJson(join(dir, entry.name));
if (recipe) found.push({ ...recipe, source, dir: join(dir, entry.name) });
}
return found;
}
/** Two-tier merged listing (project wins), newest approval first. */
export function listRecipes({ projectDir, workflow }) {
const merged = new Map();
for (const recipe of scanRecipesDir(userRecipesDir(), "user")) merged.set(recipe.name, recipe);
for (const recipe of scanRecipesDir(projectRecipesDir(resolve(projectDir)), "project")) {
merged.set(recipe.name, recipe);
}
let list = [...merged.values()];
if (workflow) list = list.filter((r) => r.workflow === workflow);
return list.sort((a, b) =>
String(b.approved_at ?? "").localeCompare(String(a.approved_at ?? "")),
);
}
/**
* Adopt a recipe into the current project: import the folder from the user
* tier when the project doesn't have it, copy its frame.md over the project's,
* and hand back the values + the skeleton path for the storyboard draft.
*/
export function useRecipe({ projectDir, name }) {
const slug = slugifyRecipeName(name);
const root = resolve(projectDir);
let dir = join(projectRecipesDir(root), slug);
if (!readRecipeJson(dir)) {
const userDir = join(userRecipesDir(), slug);
if (!readRecipeJson(userDir)) {
const known = listRecipes({ projectDir: root }).map((r) => r.name);
throw new Error(
`no recipe named "${slug}"${known.length ? ` (known: ${known.join(", ")})` : ""}`,
);
}
mkdirSync(dir, { recursive: true });
cpSync(userDir, dir, { recursive: true, force: true });
const imported = readRecipeJson(dir);
appendRecord(root, {
id: nextId(root, "recipe"),
type: "recipe",
path: `.media/recipes/${slug}/recipe.json`,
entity: slug,
description: `recipe: ${slug} (${imported.workflow}, v${imported.version})`,
provenance: { provider: "recipe.local", imported_from: "user-tier" },
});
regenerateIndex(root);
}
const recipe = readRecipeJson(dir);
cpSync(join(dir, "frame.md"), join(root, "frame.md"));
return {
recipe,
dir,
frameSpecPath: "frame.md",
skeletonPath: `.media/recipes/${slug}/storyboard-skeleton.md`,
// Recipes frozen before BRIEF.md existed have no brief skeleton — degrade.
briefSkeletonPath: existsSync(join(dir, "brief-skeleton.md"))
? `.media/recipes/${slug}/brief-skeleton.md`
: undefined,
};
}
scripts/lib/recipe-store.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
freezeRecipe,
listRecipes,
skeletonizeBrief,
skeletonizeStoryboard,
slugifyRecipeName,
useRecipe,
} from "./recipe-store.mjs";
import { recordPreference } from "./prefs-store.mjs";
const STORYBOARD = `---
format: 1080x1080
message: "Ship a launch video in an afternoon"
arc: Hook → Problem → Solution
audience: indie devs on X
mode: collaborative
music: upbeat tech
---
## Frame 1 — Hook
- scene: Big type punches in
- duration: 3s
- transition_in: cut
- status: animated
- voiceover: "Ship a launch video in an afternoon."
- src: compositions/frames/01-hook.html
Open cold on the promise. This is the thesis.
## Frame 2 — Proof
- duration: 4s
- transition_in: crossfade
- status: animated
- asset_candidates: dashboard.png
- src: compositions/frames/02-proof.html
Real dashboard, real numbers.
Second prose line to collapse.
## Video direction
Punchy, one accent color, hard cuts on the beat.
`;
const BRIEF = `---
workflow: product-launch-video
flow: automation
storyboard: yes
message: "Ship a launch video in an afternoon"
audience: indie devs on X
destination: x-feed
aspect: 1080x1080
language: en
length: 60s
angle: feature-reveal
---
## Intent
Sell the afternoon-launch promise to indie devs.
## Assets
- public/dashboard.png — the real dashboard, proof beat.
## Notes
- No stock-photo aesthetics.
`;
function sandbox() {
const root = mkdtempSync(join(tmpdir(), "mu-recipes-"));
const home = join(root, "home");
const project = join(root, "my-launch");
mkdirSync(home, { recursive: true });
mkdirSync(project, { recursive: true });
writeFileSync(join(project, "frame.md"), "# Frame spec\nbackground: #101014\n");
writeFileSync(join(project, "STORYBOARD.md"), STORYBOARD);
process.env.HOME = home;
return { root, home, project };
}
function restoreEnv(saved) {
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
Object.assign(process.env, saved);
}
function withSandbox(fn) {
const savedEnv = { ...process.env };
const box = sandbox();
try {
fn(box);
} finally {
restoreEnv(savedEnv);
rmSync(box.root, { recursive: true, force: true });
}
}
test("slugifyRecipeName normalizes and rejects empty", () => {
assert.equal(slugifyRecipeName("Weekly Changelog!"), "weekly-changelog");
assert.equal(slugifyRecipeName(" pr_reveal "), "pr-reveal");
assert.throws(() => slugifyRecipeName("!!!"));
});
test("skeletonize keeps structure, resets status, blanks content", () => {
const skeleton = skeletonizeStoryboard(STORYBOARD);
// Structure kept.
assert.match(skeleton, /format: 1080x1080/);
assert.match(skeleton, /arc: Hook → Problem → Solution/);
assert.match(skeleton, /music: upbeat tech/);
assert.match(skeleton, /- duration: 3s/);
assert.match(skeleton, /- src: compositions\/frames\/01-hook\.html/);
assert.match(skeleton, /## Video direction/);
assert.match(skeleton, /hard cuts on the beat/);
// Statuses reset.
assert.equal((skeleton.match(/- status: outline/g) ?? []).length, 2);
assert.doesNotMatch(skeleton, /animated/);
// Content blanked.
assert.doesNotMatch(skeleton, /message:/);
assert.doesNotMatch(skeleton, /audience:/);
assert.doesNotMatch(skeleton, /mode:/);
assert.doesNotMatch(skeleton, /voiceover/);
assert.doesNotMatch(skeleton, /asset_candidates/);
assert.doesNotMatch(skeleton, /thesis/);
assert.doesNotMatch(skeleton, /Real dashboard/);
assert.match(skeleton, /<fill in: this video's content for the "Hook" beat/);
assert.match(skeleton, /<fill in: this video's content for the "Proof" beat/);
});
test("skeletonizeBrief keeps reusable keys, drops run-shape, blanks body sections", () => {
const skeleton = skeletonizeBrief(BRIEF);
// Reusable frontmatter kept.
assert.match(skeleton, /workflow: product-launch-video/);
assert.match(skeleton, /destination: x-feed/);
assert.match(skeleton, /aspect: 1080x1080/);
assert.match(skeleton, /length: 60s/);
assert.match(skeleton, /angle: feature-reveal/);
// Run-shape and content keys dropped.
assert.doesNotMatch(skeleton, /^flow:/m);
assert.doesNotMatch(skeleton, /^storyboard:/m);
assert.doesNotMatch(skeleton, /^message:/m);
assert.doesNotMatch(skeleton, /^audience:/m);
// Body sections kept as headings, prose blanked to placeholders.
assert.match(skeleton, /## Intent/);
assert.match(skeleton, /## Assets/);
assert.match(skeleton, /<fill in: this video's intent/);
assert.match(skeleton, /<fill in: this video's assets/);
assert.doesNotMatch(skeleton, /afternoon-launch promise/);
assert.doesNotMatch(skeleton, /dashboard\.png/);
});
test("freeze writes the folder, manifest record, and user-tier copy", () => {
withSandbox(({ project, home }) => {
recordPreference({ projectDir: project, key: "destination", value: "x-feed" });
recordPreference({
projectDir: project,
key: "style_preset",
value: "pin-and-paper",
workflow: "product-launch-video",
});
const frozen = freezeRecipe({
projectDir: project,
name: "Weekly Launch",
workflow: "product-launch-video",
blocks: ["data-chart"],
});
assert.equal(frozen.slug, "weekly-launch");
assert.equal(frozen.version, 1);
const dir = join(project, ".media/recipes/weekly-launch");
const recipe = JSON.parse(readFileSync(join(dir, "recipe.json"), "utf8"));
assert.equal(recipe.workflow, "product-launch-video");
assert.equal(recipe.destination, "x-feed");
assert.equal(recipe.style_preset, "pin-and-paper");
assert.deepEqual(recipe.blocks, ["data-chart"]);
assert.ok(existsSync(join(dir, "frame.md")));
assert.match(readFileSync(join(dir, "storyboard-skeleton.md"), "utf8"), /- status: outline/);
const manifest = readFileSync(join(project, ".media/manifest.jsonl"), "utf8");
assert.match(manifest, /"type":"recipe"/);
assert.match(manifest, /"entity":"weekly-launch"/);
assert.ok(existsSync(join(home, ".media/recipes/weekly-launch/recipe.json")));
});
});
test("freeze with a BRIEF.md carries the brief skeleton; use hands its path back", () => {
withSandbox(({ project, root }) => {
writeFileSync(join(project, "BRIEF.md"), BRIEF);
const frozen = freezeRecipe({
projectDir: project,
name: "promo",
workflow: "product-launch-video",
});
assert.equal(frozen.briefSkeleton, true);
const skeleton = readFileSync(join(project, ".media/recipes/promo/brief-skeleton.md"), "utf8");
assert.match(skeleton, /destination: x-feed/);
assert.doesNotMatch(skeleton, /^flow:/m);
const fresh = join(root, "fresh-project");
mkdirSync(fresh, { recursive: true });
const used = useRecipe({ projectDir: fresh, name: "promo" });
assert.equal(used.briefSkeletonPath, ".media/recipes/promo/brief-skeleton.md");
assert.ok(existsSync(join(fresh, ".media/recipes/promo/brief-skeleton.md")));
});
});
test("freeze without a BRIEF.md degrades: no skeleton, use returns no path", () => {
withSandbox(({ project, root }) => {
const frozen = freezeRecipe({
projectDir: project,
name: "promo",
workflow: "product-launch-video",
});
assert.equal(frozen.briefSkeleton, false);
assert.ok(!existsSync(join(project, ".media/recipes/promo/brief-skeleton.md")));
const fresh = join(root, "fresh-project");
mkdirSync(fresh, { recursive: true });
const used = useRecipe({ projectDir: fresh, name: "promo" });
assert.equal(used.briefSkeletonPath, undefined);
});
});
test("freeze takes the workflow from BRIEF.md over a contradicting flag", () => {
withSandbox(({ project }) => {
writeFileSync(
join(project, "BRIEF.md"),
BRIEF.replace("workflow: product-launch-video", "workflow: general-video"),
);
const frozen = freezeRecipe({
projectDir: project,
name: "promo",
workflow: "faceless-explainer",
});
assert.equal(frozen.workflow, "general-video");
assert.equal(frozen.workflowOverridden, true);
const recipe = JSON.parse(
readFileSync(join(project, ".media/recipes/promo/recipe.json"), "utf8"),
);
assert.equal(recipe.workflow, "general-video");
});
});
test("freeze without BRIEF.md falls back to --workflow; with neither it refuses", () => {
withSandbox(({ project }) => {
const frozen = freezeRecipe({
projectDir: project,
name: "promo",
workflow: "product-launch-video",
});
assert.equal(frozen.workflow, "product-launch-video");
assert.equal(frozen.workflowOverridden, false);
assert.throws(() => freezeRecipe({ projectDir: project, name: "other" }), /no workflow found/);
});
});
test("freeze finds a legacy bare style_preset record; the scoped key wins over it", () => {
withSandbox(({ project }) => {
writeFileSync(join(project, "BRIEF.md"), BRIEF);
// A record made before the store required workflow scoping.
mkdirSync(join(project, ".media"), { recursive: true });
writeFileSync(
join(project, ".media/preferences.json"),
JSON.stringify({
version: 1,
preferences: {
style_preset: {
value: "source-paper-flowchart",
confirmed_in: ["my-launch"],
updated_at: "2026-07-15T00:00:00.000Z",
},
},
sightings: {},
}),
);
freezeRecipe({ projectDir: project, name: "promo" });
const legacy = JSON.parse(
readFileSync(join(project, ".media/recipes/promo/recipe.json"), "utf8"),
);
assert.equal(legacy.style_preset, "source-paper-flowchart");
recordPreference({
projectDir: project,
key: "style_preset",
value: "pin-and-paper",
workflow: "product-launch-video",
});
freezeRecipe({ projectDir: project, name: "promo" });
const scoped = JSON.parse(
readFileSync(join(project, ".media/recipes/promo/recipe.json"), "utf8"),
);
assert.equal(scoped.style_preset, "pin-and-paper");
});
});
test("re-freezing bumps the version and archives the old folder", () => {
withSandbox(({ project }) => {
freezeRecipe({ projectDir: project, name: "promo", workflow: "product-launch-video" });
const again = freezeRecipe({
projectDir: project,
name: "promo",
workflow: "product-launch-video",
});
assert.equal(again.version, 2);
assert.ok(existsSync(join(project, ".media/recipes/promo@v1/recipe.json")));
const current = JSON.parse(
readFileSync(join(project, ".media/recipes/promo/recipe.json"), "utf8"),
);
assert.equal(current.version, 2);
});
});
test("list merges tiers (project wins), filters by workflow, skips archives", () => {
withSandbox(({ project, root }) => {
freezeRecipe({ projectDir: project, name: "promo", workflow: "product-launch-video" });
freezeRecipe({ projectDir: project, name: "promo", workflow: "product-launch-video" });
freezeRecipe({ projectDir: project, name: "explainer", workflow: "faceless-explainer" });
const fresh = join(root, "fresh-project");
mkdirSync(fresh, { recursive: true });
const all = listRecipes({ projectDir: fresh });
assert.deepEqual(all.map((r) => r.source).sort(), ["user", "user"]);
assert.equal(all.find((r) => r.name === "promo").version, 2);
const filtered = listRecipes({ projectDir: fresh, workflow: "faceless-explainer" });
assert.deepEqual(
filtered.map((r) => r.name),
["explainer"],
);
});
});
test("use imports from the user tier into a fresh project and copies frame.md", () => {
withSandbox(({ project, root }) => {
freezeRecipe({ projectDir: project, name: "promo", workflow: "product-launch-video" });
const fresh = join(root, "fresh-project");
mkdirSync(fresh, { recursive: true });
const used = useRecipe({ projectDir: fresh, name: "Promo" });
assert.equal(used.recipe.name, "promo");
assert.equal(used.skeletonPath, ".media/recipes/promo/storyboard-skeleton.md");
assert.ok(existsSync(join(fresh, "frame.md")));
assert.ok(existsSync(join(fresh, ".media/recipes/promo/storyboard-skeleton.md")));
assert.match(
readFileSync(join(fresh, ".media/manifest.jsonl"), "utf8"),
/"imported_from":"user-tier"/,
);
});
});
test("use with an unknown name lists what exists", () => {
withSandbox(({ project }) => {
freezeRecipe({ projectDir: project, name: "promo", workflow: "product-launch-video" });
assert.throws(
() => useRecipe({ projectDir: project, name: "nope" }),
/no recipe named "nope" \(known: promo\)/,
);
});
});
scripts/lib/registry.mjs// Provider registry — the v2 contract.
//
// Each media type maps to an ORDERED list of provider entries. Providers are
// tried in order; the first to return a non-null result wins, which keeps
// resolution deterministic (same request -> same provider -> same file ->
// reproducible renders). heygen-CLI is always first for the types it serves.
//
// An entry exposes any of three capability methods — search / generate /
// process — plus { name }. media-use holds no keys; each external tool owns its
// own auth. Providers, by type:
// - heygen CLI: catalog + TTS, first for every type it serves (OAuth free
// allowance first, then the user's HeyGen billing path)
// - mflux: local FLUX-class image gen, spec-selected to the machine's RAM
// (free, private, offline once cached)
// - codex CLI: image gen on the user's ChatGPT sub — the better-quality upsell
// and the fallback when no local model fits
// - Kokoro (via the hyperframes CLI): local voiceover, free/private fallback
// when HeyGen credentials are absent or --local-only is requested
//
// Generation is local-first, cloud-upsell. `ctx.provider` forces one provider
// (e.g. "make an image with codex").
import { bgmProvider } from "./bgm-provider.mjs";
import { sfxProvider } from "./sfx-provider.mjs";
import { bundledSfxProvider } from "./bundled-sfx-provider.mjs";
import { imageProvider, iconProvider } from "./image-provider.mjs";
import { brandProvider } from "./brand-provider.mjs";
import {
svglSearch,
simpleIconsSearch,
githubAvatarSearch,
faviconSearch,
} from "./logo-provider.mjs";
import { heygenTtsGenerate } from "./voice-provider.mjs";
import { heygenVideoGenerate } from "./heygen-video-provider.mjs";
import { ltxVideoGenerate } from "./ltx-video-provider.mjs";
import { localTtsGenerate } from "./tts-local-provider.mjs";
import { codexImageGenerate } from "./codex-provider.mjs";
import { mfluxImageGenerate } from "./mflux-provider.mjs";
// Provider markers: `network` = hits a remote service (skipped by --local-only).
// `paid` = may cost wallet credits after any OAuth/web-plan free allowance
// (documentation for the agent's cost judgment, X4: agent-initiated paid should
// confirm). HeyGen catalog SEARCH is free; HeyGen TTS is free for eligible
// OAuth CLI users up to the monthly allowance, then follows the user's billing.
const A = (name, caps) => ({ name, ...caps }); // local, free
const N = (name, caps) => ({ name, network: true, ...caps }); // remote, free
const P = (name, caps) => ({ name, network: true, paid: true, ...caps }); // remote, paid
// heygen-CLI first. All remote providers are skipped by --local-only.
const REGISTRY = {
bgm: [N("heygen.audio.sounds", { search: bgmProvider.search })],
sfx: [
N("heygen.audio.sounds", { search: sfxProvider.search }),
A("bundled.sfx", { search: bundledSfxProvider.search }),
],
image: [
N("heygen.asset.search", { search: imageProvider.search }),
// Catalog miss -> generate. Local first (best FLUX-class model the machine's
// RAM can run, spec-selected; free, private, kept under --local-only), then
// the codex CLI on the user's ChatGPT sub as the better-quality upsell and
// the fallback when no local model fits.
A("mflux.local", { generate: mfluxImageGenerate }),
N("codex.image_gen", { generate: codexImageGenerate }),
],
icon: [N("heygen.asset.search", { search: iconProvider.search })],
logo: [
// Official brand marks. Tiers verified by a 54-brand stress test (100%
// cascade hit); HeyGen asset search is deliberately absent — it returns
// generic look-alike icons for brand queries. All free, all network →
// --local-only leaves only the cache rungs.
N("svgl", { search: svglSearch }),
N("simple-icons", { search: simpleIconsSearch }),
N("github.avatar", { search: githubAvatarSearch }),
N("favicon.ddg", { search: faviconSearch }),
],
voice: [
// HeyGen TTS first when credentialed so CLI/OAuth users consume the free
// web-plan allowance (10 min/month) before any paid path. --local-only skips
// it and keeps Kokoro as the private/offline fallback.
// Deliberately kept `paid` (X4 confirm-before-call) even though the first
// 10 min/month are free: the client can't know the remaining allowance, so
// confirming is safer than risking a silent charge once it's spent. (A
// tri-state "quota-first, paid after" would need backend quota state.)
P("heygen.tts", { generate: heygenTtsGenerate }),
A("kokoro.local", { generate: localTtsGenerate }),
],
video: [
// HeyGen avatar video first when credentialed; --local-only skips it and
// keeps LTX as the local fallback.
P("heygen.video", { generate: heygenVideoGenerate }),
A("ltx.local", { generate: ltxVideoGenerate }),
],
brand: [
// Local design spec, not heygen — reads frame.md / design.md tokens.
A("design_spec", { search: brandProvider.search }),
],
grade: [
// Local deterministic cascade handled by resolve.mjs so grade records can
// carry an inline block as well as an optional frozen .cube file.
A("color_grade.local", { search: async () => null, generate: async () => null }),
],
lut: [
// Lower-level local LUT generation/freezing path handled by resolve.mjs.
A("cube_lut.local", { search: async () => null, generate: async () => null }),
],
};
function listFor(type) {
const list = REGISTRY[type];
if (!list) throw new Error(`unknown media type: ${type}`);
return list;
}
/** Ordered providers for a type. */
export function getProviders(type) {
return listFor(type);
}
/** All declared media types. */
export function listTypes() {
return Object.keys(REGISTRY);
}
/** Provider names available for a type, in cascade order (for --provider validation). */
export function providerNamesFor(type) {
return listFor(type).map((p) => p.name);
}
/**
* name -> cost tier ("local" | "network_free" | "network_paid") over a collection
* of ordered provider lists, i.e. the A / N / P distinction the constructors above
* already declare. Exported so the conflict rule below is testable against a
* fixture; production reads the REGISTRY-wide index built from it.
*
* A name declared under two media types must carry the same tier in both. If it
* didn't, "did this resolve cost credit" would depend on which type happened to
* serve it, and the telemetry property would mean nothing — so this throws at
* import rather than silently picking one.
*/
export function buildProviderTierIndex(providerLists) {
const tiers = new Map();
for (const list of providerLists) {
for (const p of list) {
const tier = p.paid ? "network_paid" : p.network ? "network_free" : "local";
const prior = tiers.get(p.name);
if (prior && prior !== tier)
throw new Error(
`provider "${p.name}" is declared ${prior} under one media type and ${tier} under another`,
);
tiers.set(p.name, tier);
}
}
return tiers;
}
const PROVIDER_TIERS = buildProviderTierIndex(Object.values(REGISTRY));
/**
* Cost tier of a provider by name, or undefined for a name the registry doesn't
* declare. The registry stays the single owner of "does this cost credit", so
* dashboards and callers never re-derive it from provider-name string matching.
*/
export function providerTierFor(name) {
return PROVIDER_TIERS.get(name);
}
/**
* Does an override token (full name like "codex.image_gen" or a prefix like
* "codex") match any provider declared for the type? Same match rule as
* runProviders, so validation and dispatch never disagree.
*/
export function providerMatches(type, want) {
return providerNamesFor(type).some((n) => n === want || n.startsWith(`${want}.`));
}
/**
* Back-compat shim for the v1 single-provider API. Returns the first declared
* provider for the type (tagged with `type`); throws for an unknown type.
* Kept for v1 callers only — new code should use getProviders/runCapability.
*/
export function getProvider(type) {
const first = listFor(type)[0] || {};
return { ...first, type };
}
/**
* Run a capability across an explicit ordered provider list. Tries each in
* order, returns the first non-null result, skips providers that don't expose
* the capability. Pure over its input — the unit-testable core of the cascade.
*
* Offline guard: a `network` provider is skipped when `ctx.localOnly` is set —
* unconditionally, even under a `ctx.provider` override. --local-only is a hard
* safety flag: it must never make a network call. Forcing a network provider
* while offline yields a clean miss (the caller explains the conflict), never a
* silent network request.
* Provider override: `ctx.provider` (a full name like "codex.image_gen" or a
* prefix like "codex") pins resolution to matching providers only — this is how
* a user "make an image WITH codex" forces the upsell instead of taking the
* free-first default.
*/
export async function runProviders(providers, capability, intent, ctx) {
const want = ctx?.provider;
for (const p of providers) {
if (want && p.name !== want && !p.name.startsWith(`${want}.`)) continue;
if (p.network && ctx?.localOnly) continue; // --local-only wins, even over --provider
const fn = p[capability];
if (typeof fn !== "function") continue;
const res = await fn(intent, ctx);
if (res) return res;
}
return null;
}
/** Run a capability over the providers for a type (deterministic, heygen-first). */
export async function runCapability(type, capability, intent, ctx) {
return runProviders(getProviders(type), capability, intent, ctx);
}
scripts/lib/registry.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import {
getProviders,
getProvider,
listTypes,
providerMatches,
providerNamesFor,
runProviders,
runCapability,
providerTierFor,
buildProviderTierIndex,
} from "./registry.mjs";
// --- registry shape -------------------------------------------------------
test("listTypes exposes the v2 media types", () => {
const types = listTypes();
for (const t of [
"bgm",
"sfx",
"image",
"icon",
"logo",
"voice",
"video",
"brand",
"grade",
"lut",
]) {
assert.ok(types.includes(t), `missing type: ${t}`);
}
});
test("heygen provider is first for every type it serves", () => {
for (const t of ["bgm", "sfx", "image", "icon"]) {
const first = getProviders(t)[0];
assert.ok(first, `no enabled provider for ${t}`);
assert.match(first.name, /^heygen/, `${t} first provider is ${first.name}`);
}
});
test("sanctioned providers only: heygen, local mflux/kokoro/ltx, codex, design spec, logo tiers", () => {
const allowed =
/^heygen|^bundled\.sfx$|^mflux\.local$|^kokoro\.local$|^ltx\.local$|^codex\.image_gen$|^design_spec$|^svgl$|^simple-icons$|^github\.avatar$|^favicon\.ddg$|^color_grade\.local$|^cube_lut\.local$/;
for (const t of listTypes()) {
for (const p of getProviders(t)) {
assert.ok(allowed.test(p.name), `${t} lists unsanctioned provider: ${p.name}`);
}
}
});
test("image cascade: heygen catalog, then local mflux, then the codex upsell", () => {
const ps = getProviders("image");
assert.match(ps[0].name, /^heygen/, "heygen catalog first");
const names = ps.map((p) => p.name);
const mflux = ps.find((p) => p.name === "mflux.local");
const codex = ps.find((p) => p.name === "codex.image_gen");
assert.ok(mflux && typeof mflux.generate === "function", "local mflux registered");
assert.ok(codex && typeof codex.generate === "function", "codex upsell registered");
assert.ok(names.indexOf("mflux.local") < names.indexOf("codex.image_gen"), "local before codex");
assert.ok(!mflux.network, "local mflux is kept under --local-only");
assert.ok(codex.network, "codex is network (skipped under --local-only)");
});
test("voice cascade: HeyGen TTS first, Kokoro remains the local fallback", () => {
const ps = getProviders("voice");
assert.equal(ps[0].name, "heygen.tts", "HeyGen TTS is first when credentials exist");
assert.ok(ps[0].network, "HeyGen TTS is network (skipped under --local-only)");
assert.ok(ps[0].paid, "HeyGen TTS may bill after the OAuth free allowance");
assert.equal(ps[1].name, "kokoro.local", "local Kokoro is the offline fallback");
assert.ok(!ps[1].network, "local Kokoro kept under --local-only");
assert.ok(!ps[1].paid, "local Kokoro is free");
});
test("video cascade: HeyGen first, LTX local fallback, generate-only", async () => {
assert.deepEqual(providerNamesFor("video"), ["heygen.video", "ltx.local"]);
assert.equal(providerMatches("video", "ltx.local"), true);
const ps = getProviders("video");
assert.ok(ps[0].network, "HeyGen video is network (skipped under --local-only)");
assert.ok(ps[0].paid, "HeyGen video may bill after the OAuth free allowance");
assert.ok(!ps[1].network, "local LTX is kept under --local-only");
assert.equal(await runCapability("video", "search", "x", {}), null);
});
test("sfx cascade: HeyGen catalog first, bundled library remains the local fallback", () => {
const ps = getProviders("sfx");
assert.equal(ps[0].name, "heygen.audio.sounds");
assert.ok(ps[0].network, "HeyGen SFX catalog is network-only");
assert.equal(ps[1].name, "bundled.sfx");
assert.equal(typeof ps[1].search, "function");
assert.ok(!ps[1].network, "bundled SFX remain available offline");
});
test("ctx.provider forces one generator (e.g. 'make an image WITH codex')", async () => {
const providers = [
{ name: "heygen.asset.search", network: true, search: async () => null },
{ name: "mflux.local", generate: async () => ({ hit: "local" }) },
{ name: "codex.image_gen", network: true, generate: async () => ({ hit: "codex" }) },
];
// no override: local wins (first generate to return non-null)
assert.deepEqual(await runProviders(providers, "generate", "x", {}), { hit: "local" });
// override to codex: skip local, use codex even though local would have worked
assert.deepEqual(await runProviders(providers, "generate", "x", { provider: "codex" }), {
hit: "codex",
});
// override matches the full name too
assert.deepEqual(
await runProviders(providers, "generate", "x", { provider: "codex.image_gen" }),
{ hit: "codex" },
);
// --local-only wins even over a forced network provider: no network call,
// clean miss (the caller surfaces the conflict). A forced LOCAL provider under
// --local-only still runs.
assert.equal(
await runProviders(providers, "generate", "x", { provider: "codex", localOnly: true }),
null,
);
assert.deepEqual(
await runProviders(providers, "generate", "x", { provider: "mflux", localOnly: true }),
{ hit: "local" },
);
});
test("getProvider returns the first provider with its type, throws for unknown", () => {
const p = getProvider("bgm");
assert.equal(p.type, "bgm");
assert.equal(typeof p.search, "function");
assert.throws(() => getProvider("unknown_type"), /unknown media type/);
});
test("getProviders throws for unknown type", () => {
assert.throws(() => getProviders("nope"), /unknown media type/);
});
// --- deterministic capability execution (runProviders core) ---------------
test("runProviders calls providers in order and returns the first non-null", async () => {
const calls = [];
const providers = [
{
name: "a",
enabled: true,
search: async () => {
calls.push("a");
return null;
},
},
{
name: "b",
enabled: true,
search: async () => {
calls.push("b");
return { hit: "b" };
},
},
{
name: "c",
enabled: true,
search: async () => {
calls.push("c");
return { hit: "c" };
},
},
];
const res = await runProviders(providers, "search", "x", {});
assert.deepEqual(res, { hit: "b" });
assert.deepEqual(calls, ["a", "b"], "must stop at first non-null, never call c");
});
test("runProviders skips providers missing the requested capability", async () => {
const providers = [
{ name: "a", enabled: true /* no search */ },
{ name: "b", enabled: true, search: async () => ({ hit: "b" }) },
];
const res = await runProviders(providers, "search", "x", {});
assert.deepEqual(res, { hit: "b" });
});
test("runProviders returns null when no provider yields a result", async () => {
const providers = [{ name: "a", enabled: true, search: async () => null }];
assert.equal(await runProviders(providers, "search", "x", {}), null);
});
test("runCapability('bgm','process') is null — process slot is graceful when unfilled", async () => {
assert.equal(await runCapability("bgm", "process", "x", {}), null);
});
// --- provider cost tier (telemetry) ---------------------------------------
test("providerTierFor reports the registry's own A/N/P declaration", () => {
assert.equal(providerTierFor("heygen.tts"), "network_paid");
assert.equal(providerTierFor("heygen.video"), "network_paid");
assert.equal(providerTierFor("heygen.audio.sounds"), "network_free");
assert.equal(providerTierFor("heygen.asset.search"), "network_free");
assert.equal(providerTierFor("codex.image_gen"), "network_free");
assert.equal(providerTierFor("bundled.sfx"), "local");
assert.equal(providerTierFor("kokoro.local"), "local");
});
test("providerTierFor agrees across every type that declares the same name", () => {
// heygen.audio.sounds serves both bgm and sfx; heygen.asset.search serves both
// image and icon. A name whose tier depended on the media type would make the
// telemetry property meaningless.
const byName = new Map();
for (const type of listTypes()) {
for (const name of providerNamesFor(type)) {
const tier = providerTierFor(name);
assert.ok(tier, `every declared provider has a tier (${type}/${name})`);
const prior = byName.get(name);
if (prior) assert.equal(tier, prior, `${name} must carry one tier across types`);
byName.set(name, tier);
}
}
});
test("providerTierFor returns undefined for a name the registry does not declare", () => {
assert.equal(providerTierFor("does.not.exist"), undefined);
assert.equal(providerTierFor(undefined), undefined);
assert.equal(providerTierFor(null), undefined);
assert.equal(providerTierFor(""), undefined);
});
test("buildProviderTierIndex throws when one name carries two tiers", () => {
assert.throws(
() =>
buildProviderTierIndex([
[{ name: "dual", network: true }],
[{ name: "dual", network: true, paid: true }],
]),
/declared network_free under one media type and network_paid under another/,
);
});
test("buildProviderTierIndex accepts the same name repeated at the same tier", () => {
const index = buildProviderTierIndex([
[{ name: "same", network: true }],
[{ name: "same", network: true }],
]);
assert.equal(index.get("same"), "network_free");
});
test("--local-only skips every network provider (even free remote ones)", async () => {
let remoteRan = false;
const providers = [
{
name: "heygen",
network: true,
search: async () => {
remoteRan = true;
return { hit: "net" };
},
},
{ name: "local", search: async () => ({ hit: "local" }) },
];
assert.deepEqual(await runProviders(providers, "search", "x", { localOnly: true }), {
hit: "local",
});
assert.equal(remoteRan, false, "the remote provider must not be called offline");
});
scripts/lib/search.mjs// Paginated text search over a manifest (project or global) by id / description
// / entity. Plain substring match — no vector DB (B4): the agent's own file
// search covers semantics; this just keeps result pages small enough to not
// blow the context window.
const PAGE = 10;
export function searchRecords(records, query, { page = 1, pageSize = PAGE } = {}) {
const q = String(query || "")
.trim()
.toLowerCase();
const matched = q
? records.filter((r) =>
[r.id, r.description, r.entity, r.type].some(
(f) => f && String(f).toLowerCase().includes(q),
),
)
: records.slice();
const total = matched.length;
const pages = Math.max(1, Math.ceil(total / pageSize));
const p = Math.min(Math.max(1, page), pages);
const start = (p - 1) * pageSize;
return { results: matched.slice(start, start + pageSize), total, page: p, pages, pageSize };
}
scripts/lib/search.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import { searchRecords } from "./search.mjs";
const recs = Array.from({ length: 23 }, (_, i) => ({
id: `bgm_${i}`,
type: i % 2 ? "bgm" : "sfx",
description: i === 5 ? "energetic tech whoosh" : `track ${i}`,
}));
test("matches id / description / type, case-insensitively", () => {
assert.equal(searchRecords(recs, "ENERGETIC").results.length, 1);
assert.equal(searchRecords(recs, "energetic").results[0].id, "bgm_5");
assert.ok(searchRecords(recs, "sfx").total > 0);
});
test("caps page size and reports pagination", () => {
const r = searchRecords(recs, "", { pageSize: 10 });
assert.equal(r.results.length, 10, "never returns more than a page");
assert.equal(r.total, 23);
assert.equal(r.pages, 3);
assert.equal(r.page, 1);
});
test("page navigation returns the right slice, clamps out-of-range", () => {
assert.equal(searchRecords(recs, "", { page: 3, pageSize: 10 }).results.length, 3);
assert.equal(searchRecords(recs, "", { page: 99, pageSize: 10 }).page, 3, "clamped to last page");
});
test("empty query returns everything (paginated)", () => {
assert.equal(searchRecords(recs, "").total, 23);
});
scripts/lib/sfx-provider.mjsimport { heygenSearch } from "./heygen-search.mjs";
export const sfxProvider = {
async search(intent) {
const results = heygenSearch("audio sounds list", intent, {
type: "sound_effects",
minScore: 0.4,
});
if (!results) return null;
const best = results[0];
return {
url: best.audio_url,
source: "search",
// ext derived from audio_url by resolve.mjs — catalog SFX are .mp3 or .wav
metadata: {
description: best.description || best.name || intent,
duration: best.duration || null,
provider: "heygen.audio.sounds",
provenance: { track_id: best.id, score: best.score, query: intent },
},
};
},
};
scripts/lib/specs.mjs// Machine-capability probe for the spec-gated local-model fallback.
//
// Local models are USER-INSTALLED and local-use-only — media-use recommends,
// spec-checks, and assists install, but never bundles or runs them as a service.
// This probe answers "what tier can this machine actually run?" so selection can
// offer a medium/large local model, or fall back to recommending the CLI path.
//
// `osMod` and `exec` are injectable for tests. `exec(cmd)` returns the command's
// stdout as a string, or throws / returns null on failure.
import os from "node:os";
import { statfsSync } from "node:fs";
import { dirname, join } from "node:path";
import { execSync } from "node:child_process";
function defaultExec(cmd) {
return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 });
}
// Available (not just total) RAM — the real budget for "will this model fit
// alongside the OS + open apps". On unified-memory Macs the model shares system
// RAM, so a big model on a busy machine OOMs/thrashes even if total RAM looks
// ample. macOS: vm_stat free + inactive + speculative + purgeable pages (all
// reclaimable). Linux: /proc/meminfo MemAvailable. Fallback: total (optimistic
// but stable when the probe is unavailable, e.g. in tests).
function availableRamMB(platform, exec, totalMB) {
try {
if (platform === "darwin") {
const out = String(exec("vm_stat"));
const pageSize = parseInt((out.match(/page size of (\d+)/) || [])[1] || "16384", 10);
const pages = (name) => {
const m = out.match(new RegExp(`${name}:\\s+(\\d+)`));
return m ? parseInt(m[1], 10) : 0;
};
const reclaimable =
pages("Pages free") +
pages("Pages inactive") +
pages("Pages speculative") +
pages("Pages purgeable");
const mb = Math.round((reclaimable * pageSize) / (1024 * 1024));
if (mb > 0) return mb;
} else if (platform === "linux") {
const out = String(exec("cat /proc/meminfo"));
const m = out.match(/MemAvailable:\s+(\d+)\s+kB/);
if (m) return Math.round(parseInt(m[1], 10) / 1024);
}
} catch {
// probe unavailable — fall through to the total-RAM estimate
}
return totalMB;
}
function detectGpu(platform, arch, ramMB, exec) {
// Apple Silicon: Metal GPU with unified memory — VRAM tracks system RAM.
if (platform === "darwin" && arch === "arm64") {
return { present: true, kind: "apple", vramMB: ramMB };
}
// NVIDIA: query total VRAM. Any failure (no driver, no GPU) -> no GPU.
try {
const out = exec("nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits");
const mb = parseInt(String(out).trim().split(/\r?\n/)[0], 10);
if (Number.isFinite(mb) && mb > 0) return { present: true, kind: "nvidia", vramMB: mb };
} catch {
// fall through — no usable GPU
}
return { present: false, kind: null, vramMB: 0 };
}
export function probeSpecs({ osMod = os, exec = defaultExec } = {}) {
const platform = osMod.platform();
const arch = osMod.arch();
const cpuCores = osMod.cpus().length;
const ramMB = Math.round(osMod.totalmem() / (1024 * 1024));
return {
platform,
arch,
cpuCores,
ramMB,
availableRamMB: availableRamMB(platform, exec, ramMB),
appleSilicon: platform === "darwin" && arch === "arm64",
gpu: detectGpu(platform, arch, ramMB, exec),
};
}
// Where Hugging Face actually puts downloaded weights. A free-space check
// against cwd measures the wrong filesystem, so the disk question has to be
// asked about this directory. Precedence follows huggingface_hub's own order.
export function weightsCacheDir({ env = process.env, osMod = os } = {}) {
if (env.HF_HUB_CACHE) return env.HF_HUB_CACHE;
if (env.HUGGINGFACE_HUB_CACHE) return env.HUGGINGFACE_HUB_CACHE;
if (env.HF_HOME) return join(env.HF_HOME, "hub");
return join(osMod.homedir(), ".cache", "huggingface", "hub");
}
// Free space on the filesystem that will hold the weights. The cache dir
// usually does not exist until the first download and statfs throws on a
// missing path, so walk up to the deepest ancestor that does exist. Returns
// null when even the root cannot be read, so callers can say "unknown" instead
// of implying zero and scaring someone off a download that would have worked.
export function freeSpaceMB(dir, statfsFn = statfsSync) {
let path = dir;
for (;;) {
try {
const { bavail, bsize } = statfsFn(path);
return (bavail * bsize) / 1e6;
} catch {
const parent = dirname(path);
if (parent === path) return null;
path = parent;
}
}
}
// One line the user reads BEFORE agreeing to a pull that can be tens of GB.
// Always names the size and where it lands. When it will not fit we say so
// plainly rather than withholding the tier: a machine that could free up space
// should still be told the tier exists. `statfsFn` / `env` / `osMod` are
// injectable for tests.
export function describeDownload(
sizeMB,
{ statfsFn = statfsSync, env = process.env, osMod = os } = {},
) {
const dir = weightsCacheDir({ env, osMod });
const gb = (mb) => (mb / 1000).toFixed(1);
const head = `downloads ~${gb(sizeMB)}GB of weights to ${dir}`;
const free = freeSpaceMB(dir, statfsFn);
if (free == null) return `${head} (free space unknown)`;
if (free < sizeMB) {
return `${head}, but only ${gb(free)}GB is free there, so it will NOT fit as-is`;
}
return `${head} (${gb(free)}GB free there)`;
}
scripts/lib/specs.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import { describeDownload, freeSpaceMB, probeSpecs, weightsCacheDir } from "./specs.mjs";
// Fake os module + exec so the probe is deterministic across CI machines.
const fakeOs = (over = {}) => ({
platform: () => over.platform ?? "linux",
arch: () => over.arch ?? "x64",
cpus: () => Array.from({ length: over.cores ?? 8 }),
totalmem: () => (over.ramMB ?? 16384) * 1024 * 1024,
});
test("probeSpecs reports structured caps", () => {
const s = probeSpecs({ osMod: fakeOs({ cores: 12, ramMB: 32768 }), exec: () => null });
assert.equal(s.cpuCores, 12);
assert.equal(s.ramMB, 32768);
assert.equal(s.platform, "linux");
assert.equal(s.gpu.present, false);
// probe unavailable (exec returns null) -> availableRamMB falls back to total
assert.equal(s.availableRamMB, 32768);
});
test("availableRamMB is read from /proc/meminfo on Linux", () => {
const exec = (cmd) =>
cmd.includes("meminfo") ? "MemTotal: 33554432 kB\nMemAvailable: 8388608 kB\n" : null;
const s = probeSpecs({ osMod: fakeOs({ platform: "linux", ramMB: 32768 }), exec });
assert.equal(s.availableRamMB, 8192, "8388608 kB -> 8192 MB");
});
test("availableRamMB is summed from reclaimable vm_stat pages on macOS", () => {
const vmStat =
"Mach Virtual Memory Statistics: (page size of 16384 bytes)\n" +
"Pages free: 100000.\n" +
"Pages inactive: 200000.\n" +
"Pages speculative: 50000.\n" +
"Pages purgeable: 10000.\n";
const exec = (cmd) => (cmd.includes("vm_stat") ? vmStat : null);
const s = probeSpecs({
osMod: fakeOs({ platform: "darwin", arch: "arm64", ramMB: 24576 }),
exec,
});
// (100000+200000+50000+10000) pages * 16384 B / 1MiB = 5625 MB
assert.equal(s.availableRamMB, 5625);
});
test("Apple Silicon is detected as a unified-memory GPU", () => {
const s = probeSpecs({
osMod: fakeOs({ platform: "darwin", arch: "arm64", ramMB: 24576 }),
exec: () => null,
});
assert.equal(s.appleSilicon, true);
assert.equal(s.gpu.present, true);
assert.equal(s.gpu.kind, "apple");
// unified memory: VRAM tracks system RAM
assert.equal(s.gpu.vramMB, 24576);
});
test("NVIDIA GPU is detected via nvidia-smi VRAM query", () => {
const exec = (cmd) => (cmd.includes("nvidia-smi") ? "24564" : null);
const s = probeSpecs({ osMod: fakeOs({ platform: "linux" }), exec });
assert.equal(s.gpu.present, true);
assert.equal(s.gpu.kind, "nvidia");
assert.equal(s.gpu.vramMB, 24564);
});
test("no GPU when nvidia-smi is absent / fails", () => {
const s = probeSpecs({
osMod: fakeOs({ platform: "linux" }),
exec: () => {
throw new Error("command not found");
},
});
assert.equal(s.gpu.present, false);
assert.equal(s.gpu.vramMB, 0);
});
// --- download disclosure: what the user is agreeing to before the pull ---
const fakeHome = { homedir: () => "/home/tester" };
// statfs reports blocks, not bytes: bavail * bsize. 1 MB blocks keep the sums
// readable, and mirror the real struct's shape.
const fakeStatfs = (freeMB, existsOnly) => (path) => {
if (existsOnly && path !== existsOnly) throw new Error(`ENOENT: ${path}`);
return { bavail: freeMB, bsize: 1e6 };
};
test("weightsCacheDir follows huggingface_hub's precedence", () => {
assert.equal(
weightsCacheDir({ env: {}, osMod: fakeHome }),
"/home/tester/.cache/huggingface/hub",
);
assert.equal(weightsCacheDir({ env: { HF_HOME: "/data/hf" }, osMod: fakeHome }), "/data/hf/hub");
assert.equal(
weightsCacheDir({ env: { HF_HOME: "/data/hf", HUGGINGFACE_HUB_CACHE: "/c" }, osMod: fakeHome }),
"/c",
"HUGGINGFACE_HUB_CACHE outranks HF_HOME",
);
assert.equal(
weightsCacheDir({ env: { HF_HUB_CACHE: "/a", HUGGINGFACE_HUB_CACHE: "/c" }, osMod: fakeHome }),
"/a",
"HF_HUB_CACHE wins outright",
);
});
test("freeSpaceMB walks up to the deepest existing ancestor", () => {
// the cache dir does not exist until the first download, and statfs throws
// on a missing path - so the answer has to come from an ancestor
const statfs = fakeStatfs(4096, "/home");
assert.equal(freeSpaceMB("/home/tester/.cache/huggingface/hub", statfs), 4096);
});
test("freeSpaceMB reports null rather than zero when nothing can be read", () => {
assert.equal(
freeSpaceMB("/home/tester/.cache", () => {
throw new Error("EACCES");
}),
null,
"unknown must not be reported as no-space",
);
});
test("describeDownload names the size and where it lands", () => {
const msg = describeDownload(87500, {
statfsFn: fakeStatfs(200000),
env: {},
osMod: fakeHome,
});
assert.match(msg, /~87\.5GB/);
assert.match(msg, /\/home\/tester\/\.cache\/huggingface\/hub/);
assert.match(msg, /200\.0GB free/);
assert.equal(/NOT fit/.test(msg), false, "it fits, so no warning");
});
test("describeDownload says plainly when the weights will not fit", () => {
const msg = describeDownload(87500, {
statfsFn: fakeStatfs(14000),
env: {},
osMod: fakeHome,
});
assert.match(msg, /only 14\.0GB is free there/);
assert.match(msg, /will NOT fit as-is/);
// the tier is still described, not withheld: a machine that could free up
// space should know the tier exists
assert.match(msg, /~87\.5GB/);
});
test("describeDownload admits when free space is unknown", () => {
const msg = describeDownload(87500, {
statfsFn: () => {
throw new Error("EACCES");
},
env: {},
osMod: fakeHome,
});
assert.match(msg, /free space unknown/);
assert.equal(/NOT fit/.test(msg), false, "unknown is not a refusal");
});
scripts/lib/stats.mjsimport { statSync } from "node:fs";
import { readGlobalManifest } from "./cache.mjs";
import { readManifest } from "./manifest.mjs";
import { readMisses } from "./misses.mjs";
const TOP_MISSES = 5;
function emptyReport() {
return {
total_resolves: 0,
by_type: {},
by_source: {},
by_provider: {},
by_via: {},
misses: 0,
hit_rate: null,
top_missed_intents: {},
global_cache_assets: 0,
global_cache_disk_bytes: 0,
cross_project_reuse: 0,
};
}
function increment(map, key) {
if (!key) return;
map[key] = (map[key] || 0) + 1;
}
function timestampOf(record) {
return record?.ts || record?.timestamp || record?.created_at || record?.createdAt || null;
}
function inWindow(record, cutoff) {
if (!cutoff) return true;
const ts = timestampOf(record);
// Older manifest records may not carry a timestamp; keep them in the report
// because --days can only window records/misses that carry a ts/timestamp.
if (!ts) return true;
const time = Date.parse(ts);
return Number.isNaN(time) ? true : time >= cutoff;
}
function sourceOf(record) {
return record?._source || record?.source || record?.provenance?.source || "unknown";
}
function normalizeIntent(intent) {
return String(intent ?? "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
function topMissedIntents(misses) {
const grouped = {};
for (const miss of misses) {
const type = miss?.type || "unknown";
const intent = normalizeIntent(miss?.intent);
if (!intent) continue;
grouped[type] ||= {};
grouped[type][intent] = (grouped[type][intent] || 0) + 1;
}
const out = {};
for (const [type, intents] of Object.entries(grouped)) {
out[type] = Object.entries(intents)
.map(([intent, count]) => ({ intent, count }))
.sort((a, b) => b.count - a.count || a.intent.localeCompare(b.intent))
.slice(0, TOP_MISSES);
}
return out;
}
function diskBytes(records) {
let total = 0;
for (const record of records) {
const p = record?.cached_path || record?.path;
if (!p) continue;
try {
total += statSync(p).size;
} catch {
// cache entries can outlive files; stats skips missing files
}
}
return total;
}
export function buildStats({ projectDir, days, now = Date.now() } = {}) {
// Only a positive finite --days windows the report; null / NaN / <= 0 mean
// "all time" rather than silently excluding everything (a negative cutoff
// would land in the future and drop every record). The reads below are each
// best-effort (they return [] / skip on IO errors), so there is no top-level
// catch masking a real logic bug as an all-zero "no usage" report.
const n = Number(days);
const cutoff = Number.isFinite(n) && n > 0 ? Number(now) - n * 24 * 60 * 60 * 1000 : null;
const records = (projectDir ? readManifest(projectDir) : []).filter((r) => inWindow(r, cutoff));
const misses = readMisses().filter((miss) => inWindow(miss, cutoff));
const globalRecords = readGlobalManifest();
const report = emptyReport();
report.total_resolves = records.length;
report.misses = misses.length;
for (const record of records) {
increment(report.by_type, record?.type || "unknown");
increment(report.by_source, sourceOf(record));
increment(report.by_provider, record?.provenance?.provider);
increment(report.by_via, record?.provenance?.via);
}
const attempts = report.total_resolves + report.misses;
report.hit_rate = attempts === 0 ? null : report.total_resolves / attempts;
report.top_missed_intents = topMissedIntents(misses);
report.global_cache_assets = globalRecords.length;
report.global_cache_disk_bytes = diskBytes(globalRecords);
report.cross_project_reuse = globalRecords.filter((r) => r?.provenance?.reused_by).length;
return report;
}
scripts/lib/stats.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildStats } from "./stats.mjs";
function sandbox() {
const root = mkdtempSync(join(tmpdir(), "mu-stats-"));
const home = join(root, "home");
const projectDir = join(root, "project");
mkdirSync(home, { recursive: true });
mkdirSync(projectDir, { recursive: true });
process.env.HOME = home;
return { root, home, projectDir };
}
function restoreEnv(saved) {
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
Object.assign(process.env, saved);
}
function seedManifest(dir, records) {
mkdirSync(join(dir, ".media"), { recursive: true });
writeFileSync(
join(dir, ".media/manifest.jsonl"),
records.map((record) => JSON.stringify(record)).join("\n") + "\n",
);
}
function isoDaysAgo(days) {
return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
}
test("buildStats aggregates resolves, misses, providers, sources, and reuse", () => {
const savedEnv = { ...process.env };
const { root, home, projectDir } = sandbox();
try {
seedManifest(projectDir, [
{
id: "bgm_001",
type: "bgm",
source: "search",
ts: isoDaysAgo(0),
provenance: { provider: "heygen.audio.sounds", prompt: "upbeat", via: "url" },
},
{
id: "image_001",
type: "image",
_source: "reused-explicit",
timestamp: isoDaysAgo(0),
provenance: { provider: "local", reused_by: "agent" },
},
{
id: "bgm_002",
type: "bgm",
source: "generated",
provenance: { provider: "codex" },
},
]);
const cachedFile = join(home, ".media/mu-v1-cache/asset.wav");
mkdirSync(join(cachedFile, ".."), { recursive: true });
writeFileSync(cachedFile, "12345");
seedManifest(home, [
{
id: "bgm_009",
type: "bgm",
reusable: true,
cached_path: cachedFile,
provenance: { provider: "heygen.audio.sounds", reused_by: "agent" },
},
]);
writeFileSync(
join(home, ".media/misses.jsonl"),
JSON.stringify({
ts: isoDaysAgo(0),
type: "bgm",
intent: "dark cinematic riser",
}) + "\n",
{ flag: "a" },
);
const stats = buildStats({ projectDir });
assert.equal(stats.total_resolves, 3);
assert.deepEqual(stats.by_type, { bgm: 2, image: 1 });
assert.deepEqual(stats.by_source, { search: 1, "reused-explicit": 1, generated: 1 });
assert.equal(stats.by_provider["heygen.audio.sounds"], 1);
assert.equal(stats.by_via.url, 1);
assert.equal(stats.misses, 1);
assert.equal(stats.hit_rate, 0.75);
assert.deepEqual(stats.top_missed_intents.bgm[0], {
intent: "dark cinematic riser",
count: 1,
});
assert.equal(stats.global_cache_assets, 1);
assert.equal(stats.global_cache_disk_bytes, 5);
assert.equal(stats.cross_project_reuse, 1);
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
}
});
test("buildStats returns a zeroed report when nothing exists", () => {
const savedEnv = { ...process.env };
const { root, projectDir } = sandbox();
try {
const stats = buildStats({ projectDir });
assert.equal(stats.total_resolves, 0);
assert.deepEqual(stats.by_type, {});
assert.deepEqual(stats.by_source, {});
assert.deepEqual(stats.by_provider, {});
assert.deepEqual(stats.by_via, {});
assert.equal(stats.misses, 0);
assert.equal(stats.hit_rate, null);
assert.deepEqual(stats.top_missed_intents, {});
assert.equal(stats.global_cache_assets, 0);
assert.equal(stats.global_cache_disk_bytes, 0);
assert.equal(stats.cross_project_reuse, 0);
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
}
});
test("buildStats returns JSON-safe output", () => {
const savedEnv = { ...process.env };
const { root, projectDir } = sandbox();
try {
const stats = buildStats({ projectDir });
assert.deepEqual(JSON.parse(JSON.stringify(stats)), stats);
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
}
});
test("buildStats applies days window to timestamped records and misses", () => {
const savedEnv = { ...process.env };
const { root, home, projectDir } = sandbox();
try {
seedManifest(projectDir, [
{ id: "bgm_old", type: "bgm", ts: isoDaysAgo(100), provenance: { provider: "old" } },
{ id: "bgm_new", type: "bgm", ts: isoDaysAgo(1), provenance: { provider: "new" } },
{ id: "image_untimed", type: "image", provenance: { provider: "none" } },
]);
mkdirSync(join(home, ".media"), { recursive: true });
writeFileSync(
join(home, ".media/misses.jsonl"),
[
JSON.stringify({ ts: isoDaysAgo(100), type: "bgm", intent: "old miss" }),
JSON.stringify({ ts: isoDaysAgo(1), type: "bgm", intent: "new miss" }),
].join("\n"),
);
const stats = buildStats({ projectDir, days: 7 });
assert.equal(stats.total_resolves, 2);
assert.deepEqual(stats.by_type, { bgm: 1, image: 1 });
assert.equal(stats.misses, 1);
assert.equal(stats.top_missed_intents.bgm[0].intent, "new miss");
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
}
});
scripts/lib/telemetry.mjs// Opt-out usage tracking for media-use, sharing the hyperframes CLI/studio
// identity (packages/cli/src/telemetry): the same install id from
// ~/.hyperframes/config.json, plus a $identify to the HeyGen account on sign-in,
// so a person is one PostHog profile across surfaces — not a fresh id per tool.
// Not fully anonymous by design (it must dedupe): pseudonymous before sign-in,
// account-linked after. Event PROPERTIES stay coarse — media TYPE, resolution
// SOURCE, winning PROVIDER — never the intent text, file names, or paths.
//
// Same public PostHog project key as the CLI (a write-only ingestion key, safe
// to ship), same opt-outs (DO_NOT_TRACK / HYPERFRAMES_NO_TELEMETRY / CI / dev),
// and $ip:null so no IP is recorded. Fire-and-forget: telemetry never blocks a
// resolve and never throws into it.
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
const POSTHOG_HOST = "https://us.i.posthog.com";
const TIMEOUT_MS = 1500;
let identifiedAccount = false;
let warnedNonDefaultHost = false;
// Same CI/test signals the test suite itself sets (resolve.test.mjs's U7 test
// sets NODE_ENV=test and clears CI to prove the interception seam works) —
// reused here, not a new heuristic, so that deliberate test usage never
// triggers the warning below.
function isTestOrCiContext() {
return (
process.env.CI === "true" ||
process.env.CI === "1" ||
process.env.NODE_ENV === "test" ||
process.env.NODE_ENV === "development"
);
}
// Test-only interception seam: a real HTTP destination a test can point at,
// so a spawned-child test (resolve.test.mjs) can prove track() never reaches
// production rather than trusting DO_NOT_TRACK alone (a future call site or
// test could forget to set that env var). Falls back to the real production
// host whenever unset — production behavior is unchanged.
//
// Safety net: if this ever leaks into a real user's shell, track() would
// silently redirect to a likely-dead host and postBatch()'s catch{} would
// swallow the failure with zero signal. Surface one stderr warning outside
// test/CI contexts so a real user gets some indication instead of silence.
function posthogHost() {
const override = process.env.MEDIA_USE_TELEMETRY_HOST;
if (override && !warnedNonDefaultHost && !isTestOrCiContext()) {
warnedNonDefaultHost = true;
console.error(
`media-use: telemetry is redirected to a non-default host via MEDIA_USE_TELEMETRY_HOST (${override}) — unset it unless this is intentional.`,
);
}
return override || POSTHOG_HOST;
}
/** True when telemetry must NOT be sent (opt-out envs, CI, dev). */
export function optedOut() {
return (
process.env.HYPERFRAMES_NO_TELEMETRY === "1" ||
process.env.DO_NOT_TRACK === "1" ||
process.env.CI === "true" ||
process.env.CI === "1" ||
process.env.NODE_ENV === "development"
);
}
// CLI + studio share one install identity in ~/.hyperframes/config.json
// (packages/cli/src/telemetry/config.ts — same path, same `anonymousId` /
// `telemetryNoticeShown` fields). Read and write that same file so media-use is
// the same PostHog person and shows the notice once per person, not per tool.
// Computed per call (not a module const) so it honors HOME at runtime — tests
// sandbox HOME, and os.homedir() re-reads it each call.
function sharedConfigPath() {
return join(homedir(), ".hyperframes", "config.json");
}
function readSharedConfig() {
try {
const file = sharedConfigPath();
if (existsSync(file)) {
const parsed = JSON.parse(readFileSync(file, "utf8"));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
}
} catch {
// unreadable config → treat as empty; never throw
}
return {};
}
function writeSharedConfig(config) {
const dir = join(homedir(), ".hyperframes");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n");
}
// Adopt a pre-existing media-use-only id (~/.media/anon-id from before this
// change) so upgraders keep their PostHog persona instead of resetting to a new
// one — otherwise cross-surface continuity would start over on upgrade.
function legacyMediaAnonId() {
try {
const file = join(homedir(), ".media", "anon-id");
if (existsSync(file)) {
const id = readFileSync(file, "utf8").trim();
if (id) return id;
}
} catch {
// ignore
}
return null;
}
// Stable per-machine id from the shared config; seeds it (adopting a legacy
// media-use id when present) if absent.
function anonymousId() {
try {
const config = readSharedConfig();
if (typeof config.anonymousId === "string" && config.anonymousId.trim()) {
return config.anonymousId.trim();
}
const id = legacyMediaAnonId() || randomUUID();
writeSharedConfig({ ...config, anonymousId: id });
return id;
} catch {
return "anon"; // best-effort; a shared bucket is fine if the fs is read-only
}
}
function heygenAccountDistinctId() {
const file = join(process.env.HEYGEN_CONFIG_DIR || join(homedir(), ".heygen"), "credentials");
try {
if (!existsSync(file)) return null;
const raw = readFileSync(file, "utf8").trim();
if (!raw.startsWith("{")) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const user = parsed.user;
if (!user || typeof user !== "object" || Array.isArray(user)) return null;
const id = typeof user.email === "string" && user.email.trim() ? user.email : user.username;
// Lowercased so this joins with the CLI's own identify call regardless of
// the account's stored email casing — two different-case distinct ids
// would otherwise split one person across two PostHog profiles.
return typeof id === "string" && id.trim() ? id.trim().toLowerCase() : null;
} catch {
return null;
}
}
function showTelemetryNotice() {
if (optedOut()) return;
try {
const config = readSharedConfig();
// Shared with the CLI (config.telemetryNoticeShown): shown once per person
// across surfaces, not once per tool.
if (config.telemetryNoticeShown === true) return;
console.error(
[
"media-use sends usage telemetry: media type, resolution source, and provider; never intent text, file names, or paths.",
"If you sign in to HeyGen, usage links to your account email or username. Opt out with HYPERFRAMES_NO_TELEMETRY=1 or DO_NOT_TRACK=1.",
].join("\n"),
);
writeSharedConfig({ ...config, telemetryNoticeShown: true });
} catch {
// notice is best-effort; never surface into the command
}
}
async function postBatch(batch) {
try {
await fetch(`${posthogHost()}/batch/`, {
method: "POST",
headers: { "Content-Type": "application/json", Connection: "close" },
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
} catch {
// telemetry is best-effort; never surface into the command
}
}
async function postEvent(event, properties, distinctId) {
await postBatch([
{
event,
properties: { ...properties, surface: "media-use", $ip: null },
distinct_id: distinctId,
timestamp: new Date().toISOString(),
},
]);
}
async function identifyAccount(anonId) {
if (optedOut() || identifiedAccount) return;
const distinctId = heygenAccountDistinctId();
if (!distinctId) return;
identifiedAccount = true;
await postEvent("$identify", { $anon_distinct_id: anonId }, distinctId);
}
/**
* Fire-and-forget a single event to PostHog. Best-effort: awaited with a short
* timeout so a short-lived script flushes before exit, but any failure (offline,
* opted out) is swallowed. `properties` must be non-PII (no intent/paths).
*/
export async function track(event, properties = {}) {
if (optedOut()) return;
showTelemetryNotice();
const anonId = anonymousId();
await identifyAccount(anonId);
await postEvent(event, properties, anonId);
}
export function __anonymousIdForTest() {
return anonymousId();
}
export function __resetTelemetryForTest() {
identifiedAccount = false;
warnedNonDefaultHost = false;
}
scripts/lib/telemetry.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
chmodSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { __anonymousIdForTest, __resetTelemetryForTest, optedOut, track } from "./telemetry.mjs";
function sandbox() {
const root = mkdtempSync(join(tmpdir(), "mu-telemetry-"));
const home = join(root, "home");
mkdirSync(home, { recursive: true });
process.env.HOME = home;
return { root, home };
}
function restoreEnv(saved) {
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
Object.assign(process.env, saved);
}
function withoutTelemetryOptOut() {
for (const k of ["DO_NOT_TRACK", "HYPERFRAMES_NO_TELEMETRY", "CI", "NODE_ENV"])
delete process.env[k];
}
function parseFetchBodies(calls) {
return calls.flatMap((call) => JSON.parse(call.options.body).batch);
}
test("optedOut respects DO_NOT_TRACK / HYPERFRAMES_NO_TELEMETRY / CI", () => {
const saved = { ...process.env };
try {
for (const k of ["DO_NOT_TRACK", "HYPERFRAMES_NO_TELEMETRY", "CI", "NODE_ENV"])
delete process.env[k];
assert.equal(optedOut(), false, "default: tracking allowed");
process.env.DO_NOT_TRACK = "1";
assert.equal(optedOut(), true, "DO_NOT_TRACK opts out");
delete process.env.DO_NOT_TRACK;
process.env.HYPERFRAMES_NO_TELEMETRY = "1";
assert.equal(optedOut(), true, "HYPERFRAMES_NO_TELEMETRY opts out");
delete process.env.HYPERFRAMES_NO_TELEMETRY;
process.env.CI = "true";
assert.equal(optedOut(), true, "CI opts out");
} finally {
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
Object.assign(process.env, saved);
}
});
test("track is a no-op (no network, resolves) when opted out", async () => {
const savedEnv = { ...process.env };
const originalFetch = globalThis.fetch;
const { root, home } = sandbox();
const calls = [];
globalThis.fetch = async (...args) => {
calls.push(args);
return { ok: true };
};
process.env.DO_NOT_TRACK = "1";
try {
// must resolve immediately without throwing or hitting the network
await track("media_use_resolve", { type: "bgm", source: "search" });
assert.equal(calls.length, 0);
assert.equal(existsSync(join(home, ".hyperframes/config.json")), false);
assert.equal(existsSync(join(home, ".media/telemetry-notice-shown")), false);
} finally {
globalThis.fetch = originalFetch;
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
__resetTelemetryForTest();
}
});
test("anonymous id uses the shared hyperframes config", () => {
const savedEnv = { ...process.env };
const { root, home } = sandbox();
try {
withoutTelemetryOptOut();
mkdirSync(join(home, ".hyperframes"), { recursive: true });
writeFileSync(
join(home, ".hyperframes/config.json"),
JSON.stringify({ anonymousId: "shared-install-id", keep: true }),
);
mkdirSync(join(home, ".media"), { recursive: true });
writeFileSync(join(home, ".media/anon-id"), "old-media-id");
assert.equal(__anonymousIdForTest(), "shared-install-id");
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
__resetTelemetryForTest();
}
});
test("anonymous id seeds missing config once and reuses it", () => {
const savedEnv = { ...process.env };
const { root, home } = sandbox();
try {
withoutTelemetryOptOut();
const first = __anonymousIdForTest();
const configPath = join(home, ".hyperframes/config.json");
assert.ok(existsSync(configPath));
assert.match(
first,
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
const second = __anonymousIdForTest();
assert.equal(second, first);
assert.equal(JSON.parse(readFileSync(configPath, "utf8")).anonymousId, first);
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
__resetTelemetryForTest();
}
});
test("anonymous id adopts a legacy ~/.media/anon-id on upgrade (persona continuity)", () => {
const savedEnv = { ...process.env };
const { root, home } = sandbox();
try {
withoutTelemetryOptOut();
mkdirSync(join(home, ".media"), { recursive: true });
writeFileSync(join(home, ".media/anon-id"), "legacy-media-id");
// no ~/.hyperframes/config.json yet — the old media-use-only id must carry over
assert.equal(__anonymousIdForTest(), "legacy-media-id");
// and it is persisted into the shared config so CLI/studio see the same id
assert.equal(
JSON.parse(readFileSync(join(home, ".hyperframes/config.json"), "utf8")).anonymousId,
"legacy-media-id",
);
} finally {
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
__resetTelemetryForTest();
}
});
test("track identifies a signed-in HeyGen account once and still sends events", async () => {
const savedEnv = { ...process.env };
const originalFetch = globalThis.fetch;
const { root, home } = sandbox();
const calls = [];
globalThis.fetch = async (url, options) => {
calls.push({ url, options });
return { ok: true };
};
try {
withoutTelemetryOptOut();
mkdirSync(join(home, ".hyperframes"), { recursive: true });
writeFileSync(
join(home, ".hyperframes/config.json"),
JSON.stringify({ anonymousId: "anon-1" }),
);
mkdirSync(join(home, ".heygen"), { recursive: true });
writeFileSync(
join(home, ".heygen/credentials"),
JSON.stringify({ user: { email: "alice@example.com", username: "alice" } }),
);
await track("media_use_resolve", { type: "bgm", source: "search" });
await track("media_use_resolve", { type: "image", source: "generated" });
const batch = parseFetchBodies(calls);
const identify = batch.filter((item) => item.event === "$identify");
assert.equal(identify.length, 1);
assert.equal(identify[0].distinct_id, "alice@example.com");
assert.equal(identify[0].properties.$anon_distinct_id, "anon-1");
assert.equal(batch.filter((item) => item.event === "media_use_resolve").length, 2);
} finally {
globalThis.fetch = originalFetch;
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
__resetTelemetryForTest();
}
});
test("track identifies with a lowercased email regardless of stored casing", async () => {
const savedEnv = { ...process.env };
const originalFetch = globalThis.fetch;
const { root, home } = sandbox();
const calls = [];
globalThis.fetch = async (url, options) => {
calls.push({ url, options });
return { ok: true };
};
try {
withoutTelemetryOptOut();
mkdirSync(join(home, ".hyperframes"), { recursive: true });
writeFileSync(
join(home, ".hyperframes/config.json"),
JSON.stringify({ anonymousId: "anon-3" }),
);
mkdirSync(join(home, ".heygen"), { recursive: true });
writeFileSync(
join(home, ".heygen/credentials"),
JSON.stringify({ user: { email: "Alice@Example.com", username: "alice" } }),
);
await track("media_use_resolve", { type: "bgm", source: "search" });
const batch = parseFetchBodies(calls);
const identify = batch.filter((item) => item.event === "$identify");
assert.equal(identify.length, 1);
// Lowercased so this joins with heygen-cli's own identify call regardless
// of the account's stored email casing -- otherwise the same person could
// split into two PostHog profiles by email case alone.
assert.equal(identify[0].distinct_id, "alice@example.com");
} finally {
globalThis.fetch = originalFetch;
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
__resetTelemetryForTest();
}
});
test("track does not identify when signed out", async () => {
const savedEnv = { ...process.env };
const originalFetch = globalThis.fetch;
const { root, home } = sandbox();
const calls = [];
globalThis.fetch = async (url, options) => {
calls.push({ url, options });
return { ok: true };
};
try {
withoutTelemetryOptOut();
mkdirSync(join(home, ".hyperframes"), { recursive: true });
writeFileSync(
join(home, ".hyperframes/config.json"),
JSON.stringify({ anonymousId: "anon-2" }),
);
await track("media_use_resolve", { type: "bgm", source: "search" });
const batch = parseFetchBodies(calls);
assert.equal(
batch.some((item) => item.event === "$identify"),
false,
);
assert.equal(batch[0].event, "media_use_resolve");
assert.equal(batch[0].distinct_id, "anon-2");
} finally {
globalThis.fetch = originalFetch;
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
__resetTelemetryForTest();
}
});
test("first run notice prints to stderr once and never stdout", async () => {
const savedEnv = { ...process.env };
const originalFetch = globalThis.fetch;
const originalError = console.error;
const originalLog = console.log;
const { root, home } = sandbox();
const stderr = [];
const stdout = [];
globalThis.fetch = async () => ({ ok: true });
console.error = (...args) => stderr.push(args.join(" "));
console.log = (...args) => stdout.push(args.join(" "));
try {
withoutTelemetryOptOut();
await track("media_use_resolve", { type: "bgm" });
await track("media_use_resolve", { type: "sfx" });
assert.equal(stderr.length, 1);
assert.match(stderr[0], /media-use sends usage telemetry/);
assert.equal(stdout.length, 0);
// notice-shown lives in the shared config (config.telemetryNoticeShown), so
// the CLI and media-use show it once per person — not a media-use-only marker.
assert.equal(
JSON.parse(readFileSync(join(home, ".hyperframes/config.json"), "utf8")).telemetryNoticeShown,
true,
);
} finally {
globalThis.fetch = originalFetch;
console.error = originalError;
console.log = originalLog;
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
__resetTelemetryForTest();
}
});
test("warns once on stderr when MEDIA_USE_TELEMETRY_HOST is set outside a test/CI context", async () => {
const savedEnv = { ...process.env };
const originalFetch = globalThis.fetch;
const originalError = console.error;
const { root } = sandbox();
const stderr = [];
globalThis.fetch = async () => ({ ok: true });
console.error = (...args) => stderr.push(args.join(" "));
try {
// No opt-out, no CI/NODE_ENV signal — mirrors a real user's shell where
// this test-only var leaked in by accident and tracking is not disabled.
withoutTelemetryOptOut();
process.env.MEDIA_USE_TELEMETRY_HOST = "http://127.0.0.1:1"; // nothing listens; irrelevant here
await track("media_use_resolve", { type: "bgm" });
await track("media_use_resolve", { type: "bgm" });
const warnings = stderr.filter((line) => line.includes("MEDIA_USE_TELEMETRY_HOST"));
assert.equal(warnings.length, 1, "expected exactly one warning across repeated calls");
} finally {
globalThis.fetch = originalFetch;
console.error = originalError;
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
__resetTelemetryForTest();
}
});
test("does not warn when MEDIA_USE_TELEMETRY_HOST is set the way the U7 interception test sets it", async () => {
const savedEnv = { ...process.env };
const originalFetch = globalThis.fetch;
const originalError = console.error;
const { root } = sandbox();
const stderr = [];
globalThis.fetch = async () => ({ ok: true });
console.error = (...args) => stderr.push(args.join(" "));
try {
withoutTelemetryOptOut();
// Same env shape resolve.test.mjs's U7 test uses to allow tracking through:
// DO_NOT_TRACK=0, CI unset/empty, NODE_ENV=test.
process.env.DO_NOT_TRACK = "0";
process.env.CI = "";
process.env.NODE_ENV = "test";
process.env.MEDIA_USE_TELEMETRY_HOST = "http://127.0.0.1:1";
await track("media_use_resolve", { type: "bgm" });
assert.equal(
stderr.some((line) => line.includes("MEDIA_USE_TELEMETRY_HOST")),
false,
"the U7 test's own use of the override must not print a spurious warning",
);
} finally {
globalThis.fetch = originalFetch;
console.error = originalError;
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
__resetTelemetryForTest();
}
});
test("read-only telemetry state degrades without throwing", async () => {
const savedEnv = { ...process.env };
const originalFetch = globalThis.fetch;
const { root, home } = sandbox();
globalThis.fetch = async () => ({ ok: true });
try {
withoutTelemetryOptOut();
writeFileSync(join(home, ".hyperframes"), "not a directory");
writeFileSync(join(home, ".media"), "not a directory");
await track("media_use_resolve", { type: "bgm" });
} finally {
globalThis.fetch = originalFetch;
try {
chmodSync(join(home, ".hyperframes"), 0o600);
} catch {
// best effort for cleanup on platforms with different chmod behavior
}
restoreEnv(savedEnv);
rmSync(root, { recursive: true, force: true });
__resetTelemetryForTest();
}
});
scripts/lib/tts-local-provider.mjsimport { execFileSync } from "node:child_process";
import { existsSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveSpawnCommand } from "../../audio/scripts/lib/tts.mjs";
// Local voiceover via the packaged Kokoro-82M TTS (the `hyperframes tts` CLI),
// the free/private default now that HeyGen TTS costs wallet credits. Kokoro runs
// on-device (CPU, faster-than-realtime, bundled voices, native word timestamps),
// so no key and no per-call charge. When Kokoro is not set up, this returns null
// and the registry falls through to the HeyGen TTS upsell.
//
// Delegated to the hyperframes CLI (same as transcribe / remove-background), not
// re-implemented here. ffprobe reads the duration back for the ledger.
function probeDurationSeconds(file) {
try {
const out = execFileSync(
"ffprobe",
["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", "--", file],
{ encoding: "utf8", timeout: 15000 },
);
const d = parseFloat(String(out).trim());
return Number.isFinite(d) ? d : undefined;
} catch {
return undefined;
}
}
// `platform`/`execFn`/`env`/`pathExists` params (defaulting to the real
// values) exist so tests can exercise the win32 branch without mocking
// node:child_process (its ESM exports are non-configurable) — same idiom as
// spawnP in ../../audio/scripts/lib/tts.mjs.
export async function localTtsGenerate(
intent,
ctx,
platform = process.platform,
execFn = execFileSync,
env = process.env,
pathExists = existsSync,
) {
const outPath = join(tmpdir(), `media-use-kokoro-${process.pid}-${Date.now()}.wav`);
const argv = ["hyperframes", "tts", intent, "--output", outPath];
if (ctx?.voice) argv.push("--voice", ctx.voice);
if (ctx?.lang && ctx.lang !== "en") argv.push("--lang", ctx.lang);
// On Windows a bare "npx" is npx.cmd, which execFileSync cannot exec
// (spawnSync npx ENOENT) — resolveSpawnCommand reroutes it through
// node + npx-cli.js, same as the audio engine's TTS spawns.
const resolved = resolveSpawnCommand(
"npx",
argv,
{ encoding: "utf8", timeout: 300000, stdio: ["ignore", "pipe", "pipe"] },
platform,
env,
pathExists,
);
if (!resolved) {
// npx-on-win32 with no resolvable npx-cli.js — same terminal condition
// spawnP warns about. Fall through to the next provider rather than crash.
console.error(
"media-use: local voice not enabled (kokoro). Cannot run npx on Windows: " +
"npm's npx-cli.js was not found (install npm with Node, or run via npx/npm run so npm_execpath is set).",
);
return null;
}
try {
execFn(resolved.cmd, resolved.args, resolved.opts);
} catch (err) {
// `hyperframes tts` prints its "kokoro-onnx not installed" hint to stdout
// (clack UI), so read both streams and surface the actionable enable-command
// rather than a bare "Command failed": otherwise resolve silently falls
// through to the PAID HeyGen TTS upsell when free local voice was one pip away.
const out = `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}`.trim();
const hint = /not installed|pip install kokoro/i.test(out)
? "install for free on-device voice: pip install kokoro-onnx soundfile (or set HYPERFRAMES_PYTHON to a venv that has it)"
: out.slice(-200) || err.message;
console.error(`media-use: local voice not enabled (kokoro). ${hint}`);
return null;
}
if (!existsSync(outPath) || statSync(outPath).size === 0) return null;
return {
localPath: outPath,
ext: ".wav",
source: "generated",
metadata: {
description: intent,
provider: "kokoro.local",
duration: probeDurationSeconds(outPath),
provenance: { engine: "kokoro-82m", prompt: intent },
},
};
}
scripts/lib/tts-local-provider.test.mjsimport { test } from "node:test";
import assert from "node:assert/strict";
import { localTtsGenerate } from "./tts-local-provider.mjs";
// Regression: on Windows, a bare "npx" is npx.cmd, which execFileSync cannot
// exec — the Kokoro delegation failed with `spawnSync npx ENOENT` instead of
// synthesizing (or cleanly falling through to the next provider). The spawn
// must route through node + npx-cli.js on win32, same as the audio engine's
// TTS spawns (see ../../audio/scripts/lib/tts.spawn.test.mjs).
const envWithNpxCli = {
npm_execpath: "C:/Program Files/nodejs/node_modules/npm/bin/npm-cli.js",
npm_node_execpath: "C:/Program Files/nodejs/node.exe",
};
const npxCliPath = "C:/Program Files/nodejs/node_modules/npm/bin/npx-cli.js";
const pathExists = (path) => path === npxCliPath;
test("win32: routes the hyperframes tts call through node + npx-cli, never bare npx", async () => {
const captured = [];
const fakeExec = (cmd, args, opts) => {
captured.push({ cmd, args, opts });
// Synthesize nothing — the provider then returns null via the
// missing-output check, which is fine: we only assert the spawn shape.
};
await localTtsGenerate(
"hello there",
{ voice: "am_michael" },
"win32",
fakeExec,
envWithNpxCli,
pathExists,
);
assert.equal(captured.length, 1);
assert.equal(captured[0].cmd, envWithNpxCli.npm_node_execpath);
assert.equal(captured[0].args[0], npxCliPath);
assert.deepEqual(captured[0].args.slice(1, 4), ["hyperframes", "tts", "hello there"]);
assert.ok(captured[0].args.includes("--voice"));
// execFileSync options survive the rerouting (pipes are what let the caller
// read the "kokoro-onnx not installed" hint back out).
assert.deepEqual(captured[0].opts.stdio, ["ignore", "pipe", "pipe"]);
});
test("win32 without a resolvable npx-cli: falls through to the next provider (null), no spawn", async () => {
const captured = [];
const fakeExec = (...call) => captured.push(call);
const result = await localTtsGenerate(
"hello",
{},
"win32",
fakeExec,
{}, // no npm_execpath, and pathExists finds nothing
() => false,
);
assert.equal(result, null);
assert.equal(captured.length, 0);
});
test("non-win32: spawns plain npx unchanged", async () => {
const captured = [];
const fakeExec = (cmd, args) => captured.push({ cmd, args });
await localTtsGenerate("hola", { lang: "es" }, "darwin", fakeExec, {}, () => false);
assert.equal(captured.length, 1);
assert.equal(captured[0].cmd, "npx");
assert.deepEqual(captured[0].args.slice(0, 3), ["hyperframes", "tts", "hola"]);
assert.ok(captured[0].args.includes("--lang"));
});
scripts/lib/usage.mjsimport { readFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
// Asset in-use detection: which manifest assets are actually referenced by the
// project's compositions. Answers "is this safe to prune?" from the CLI. (The
// Studio Asset tab computes its in-use filter separately from the live timeline;
// this is the skill-side equivalent for headless use.)
//
// ponytail: substring match of each asset's filename against the .html text
// (covers src= / href= / url() / data-* without parsing HTML). False positives
// only if a filename literally appears in prose; fine for a filter. Upgrade to
// attribute parsing if that ever bites.
function compositionHtml(projectDir) {
let files = [];
try {
files = readdirSync(projectDir).filter((f) => f.endsWith(".html"));
} catch {
return "";
}
const sub = join(projectDir, "compositions");
if (existsSync(sub)) {
try {
for (const f of readdirSync(sub))
if (f.endsWith(".html")) files.push(join("compositions", f));
} catch {
// ignore unreadable subdir
}
}
return files
.map((f) => {
try {
return readFileSync(join(projectDir, f), "utf8");
} catch {
return "";
}
})
.join("\n");
}
/** Tag each record with `inUse` (referenced by some composition). */
export function tagUsage(records, projectDir) {
const html = compositionHtml(projectDir);
return records.map((r) => {
const file = r.path ? r.path.split("/").pop() : r.id;
return { ...r, inUse: Boolean(file) && html.includes(file) };
});
}
/** Split records into { used, unused } for the filter. */
export function partitionUsage(records, projectDir) {
const tagged = tagUsage(records, projectDir);
return {
used: tagged.filter((r) => r.inUse),
unused: tagged.filter((r) => !r.inUse),
};
}
scripts/lib/usage.test.mjsimport { strict as assert } from "node:assert";
import { test } from "node:test";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { tagUsage, partitionUsage } from "./usage.mjs";
function project(html) {
const dir = mkdtempSync(join(tmpdir(), "mu-usage-"));
writeFileSync(join(dir, "index.html"), html);
return dir;
}
const records = [
{ id: "bgm_001", path: ".media/audio/bgm/bgm_001.wav", description: "used track" },
{ id: "bgm_002", path: ".media/audio/bgm/bgm_002.wav", description: "orphan track" },
];
test("an asset referenced by a composition is marked in-use", () => {
const dir = project(`<audio src="assets/bgm_001.wav"></audio>`);
try {
const tagged = tagUsage(records, dir);
assert.equal(tagged.find((r) => r.id === "bgm_001").inUse, true);
assert.equal(tagged.find((r) => r.id === "bgm_002").inUse, false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("partitionUsage splits used vs unused for the filter", () => {
const dir = project(`<img src="bgm_001.wav">`);
try {
const { used, unused } = partitionUsage(records, dir);
assert.deepEqual(
used.map((r) => r.id),
["bgm_001"],
);
assert.deepEqual(
unused.map((r) => r.id),
["bgm_002"],
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("no compositions -> everything reads as unused (safe default)", () => {
const dir = mkdtempSync(join(tmpdir(), "mu-usage-"));
try {
assert.equal(partitionUsage(records, dir).used.length, 0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
scripts/lib/voice-provider.mjsimport { HEYGEN_CLIENT_SOURCE_ARGV, runHeygenJson } from "./heygen-cli.mjs";
// Voice / TTS generation via the HeyGen CLI — the only external CLI media-use
// shells (CLI-only invariant: media-use holds no keys; the CLI owns auth).
// Flags verified against `heygen voice speech create --help` (v0.3.0).
function result(url, duration, provider, intent) {
if (!url) return null;
return {
url,
source: "generated",
metadata: {
description: intent,
provider,
...(duration != null && { duration }),
provenance: { prompt: intent },
},
};
}
// HeyGen TTS requires a starfish-engine voice. Default to the first one the
// catalog returns (deterministic order); pass ctx.voiceId to override.
// ponytail: listed once per process; the resolved asset is frozen + cached after
// first use, so the network list only happens on a cache miss. Cache only a
// truthy id -- a transient list failure must not poison the cache with `null`
// and permanently disable TTS for the rest of the process.
let cachedVoiceId;
function defaultVoiceId() {
if (cachedVoiceId) return cachedVoiceId;
const j = runHeygenJson(
"heygen",
["voice", "list", "--engine", "starfish", "--limit", "1"],
"voice list",
);
cachedVoiceId = j?.data?.[0]?.voice_id || null;
return cachedVoiceId;
}
export async function heygenTtsGenerate(intent, ctx) {
const voiceId = ctx?.voiceId || defaultVoiceId();
if (!voiceId) return null;
const p = runHeygenJson(
"heygen",
[
...HEYGEN_CLIENT_SOURCE_ARGV,
"voice",
"speech",
"create",
"--text",
intent,
"--voice-id",
voiceId,
],
"tts",
);
return result(p?.data?.audio_url, p?.data?.duration, "heygen.tts", intent);
}
scripts/lib/voice-provider.test.mjsimport { strict as assert } from "node:assert";
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { heygenTtsGenerate } from "./voice-provider.mjs";
test("tags TTS generation but not voice discovery with the media-use client source", async () => {
const dir = mkdtempSync(join(tmpdir(), "media-use-voice-provider-"));
const capturePath = join(dir, "argv.log");
const heygenPath = join(dir, "heygen");
const previousPath = process.env.PATH;
const previousCapturePath = process.env.HEYGEN_CAPTURE_PATH;
writeFileSync(
heygenPath,
`#!/bin/sh
printf '%s\\n' "$*" >> "$HEYGEN_CAPTURE_PATH"
case "$*" in
*"voice list"*) printf '%s\\n' '{"data":[{"voice_id":"voice-123"}]}' ;;
*"voice speech create"*) printf '%s\\n' '{"data":{"audio_url":"https://example.com/voice.mp3","duration":1.5}}' ;;
esac
`,
);
chmodSync(heygenPath, 0o755);
process.env.PATH = `${dir}:${previousPath ?? ""}`;
process.env.HEYGEN_CAPTURE_PATH = capturePath;
try {
const result = await heygenTtsGenerate("Hello from media-use", {});
const invocations = readFileSync(capturePath, "utf8").trim().split("\n");
assert.equal(invocations.length, 2);
assert.match(invocations[0], /^voice list /);
assert.doesNotMatch(invocations[0], /X-HeyGen-Client-Source/);
assert.match(invocations[1], /voice speech create/);
assert.match(invocations[1], /X-HeyGen-Client-Source: media-use/);
assert.equal(result?.url, "https://example.com/voice.mp3");
} finally {
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
if (previousCapturePath === undefined) delete process.env.HEYGEN_CAPTURE_PATH;
else process.env.HEYGEN_CAPTURE_PATH = previousCapturePath;
rmSync(dir, { recursive: true, force: true });
}
});
scripts/lib/words.mjsexport function normalizeWords(input) {
const raw = Array.isArray(input) ? input : Array.isArray(input?.words) ? input.words : [];
return raw
.map((w, index) => {
const text = String(w?.text ?? w?.word ?? "").trim();
const start = Number(w?.start);
const end = Number(w?.end);
if (!text || !Number.isFinite(start) || !Number.isFinite(end)) return null;
return { id: w?.id ?? `w${index}`, text, start, end };
})
.filter(Boolean);
}
export function wordListsFromMediaMeta(input) {
if (Array.isArray(input) || Array.isArray(input?.words)) return [normalizeWords(input)];
if (!Array.isArray(input?.voices)) return [];
return input.voices.map((voice) => normalizeWords(voice)).filter((words) => words.length > 0);
}
scripts/prefs.mjs#!/usr/bin/env node
/**
* Remembered defaults CLI — the lightweight tier of HyperFrames user memory.
*
* node prefs.mjs get --hyperframes . [--json]
* Print the merged view (project `.media/preferences.json` over user
* `~/.media/preferences.json`), each key with its source and the receipt
* material (confirmed_in, updated_at).
*
* node prefs.mjs record --hyperframes . --key destination --value x-feed [--workflow <w>]
* Record one confirmed brief answer into the project tier; the same value
* confirmed in two different projects promotes the key to the user tier.
*
* Consumption rules live in hyperframes-core/references/brief-contract.md § 2
* (Remembered defaults): a remembered value becomes the recommended option
* with a receipt — it never skips a question.
*/
import { parseArgs } from "node:util";
import { mergedPreferences, recordPreference } from "./lib/prefs-store.mjs";
const { values: args, positionals } = parseArgs({
options: {
hyperframes: { type: "string", default: "." },
key: { type: "string" },
value: { type: "string" },
workflow: { type: "string" },
json: { type: "boolean", default: false },
},
allowPositionals: true,
});
const verb = positionals[0];
function fail(message) {
console.error(message);
process.exit(1);
}
if (verb === "get") {
const merged = mergedPreferences(args.hyperframes);
if (args.json) {
console.log(JSON.stringify(merged, null, 2));
} else if (Object.keys(merged).length === 0) {
console.log("no remembered preferences yet");
} else {
for (const [key, entry] of Object.entries(merged)) {
console.log(
`${key} = ${entry.value} (${entry.source}; confirmed in ${entry.confirmed_in.join(", ")})`,
);
}
}
} else if (verb === "record") {
if (!args.key || !args.value) fail("record needs --key and --value");
try {
const result = recordPreference({
projectDir: args.hyperframes,
key: args.key,
value: args.value,
workflow: args.workflow,
});
const promotion = result.promoted ? "; promoted to user tier" : "";
console.log(`recorded ${result.key} = ${result.value} (project${promotion})`);
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
}
} else {
fail(
"usage: prefs.mjs <get|record> --hyperframes . [--key <k> --value <v> --workflow <w>] [--json]",
);
}
scripts/recipe.mjs#!/usr/bin/env node
/**
* Recipes CLI — the heavyweight tier of HyperFrames user memory.
*
* node recipe.mjs freeze --hyperframes . --name <n> [--workflow <w>] [--blocks a,b,c]
* Freeze the current approved run as a named recipe: frame.md + the
* storyboard skeleton (structure kept, content blanked) + the brief
* skeleton (when BRIEF.md exists) + the confirmed brief values.
* The workflow is read from BRIEF.md; --workflow only covers projects
* briefed before BRIEF.md existed. Re-freezing the same name bumps the
* version and archives the old folder as <name>@v<N>. Promotes to
* ~/.media/recipes/ immediately.
*
* node recipe.mjs list --hyperframes . [--workflow <w>] [--json]
* Two-tier merged listing (project wins), newest approval first.
*
* node recipe.mjs use --hyperframes . --name <n> [--json]
* Adopt a recipe: import it from the user tier if needed, copy its
* frame.md over the project's, and print the brief values + skeleton path.
*
* When recipes are offered/consumed is the review loop's and the intent
* layer's business — see hyperframes-core/references/review-loop.md § 4 and
* the intent layer's recipe check (hyperframes SKILL.md § 4).
*/
import { parseArgs } from "node:util";
import { freezeRecipe, listRecipes, useRecipe } from "./lib/recipe-store.mjs";
const { values: args, positionals } = parseArgs({
options: {
hyperframes: { type: "string", default: "." },
name: { type: "string" },
workflow: { type: "string" },
blocks: { type: "string" },
json: { type: "boolean", default: false },
},
allowPositionals: true,
});
const verb = positionals[0];
function fail(message) {
console.error(message);
process.exit(1);
}
try {
if (verb === "freeze") {
if (!args.name) fail("freeze needs --name");
const frozen = freezeRecipe({
projectDir: args.hyperframes,
name: args.name,
workflow: args.workflow,
blocks: args.blocks
? args.blocks
.split(",")
.map((b) => b.trim())
.filter(Boolean)
: undefined,
});
if (args.json) console.log(JSON.stringify({ ok: true, ...frozen }));
else {
console.log(
`froze recipe ${frozen.slug} (v${frozen.version}, ${frozen.workflow}) → ${frozen.dir}`,
);
if (frozen.workflowOverridden)
console.log(` (BRIEF.md says "${frozen.workflow}" — the --workflow flag was ignored)`);
if (!frozen.briefSkeleton)
console.log(" (no BRIEF.md in the project — brief skeleton skipped)");
}
} else if (verb === "list") {
const list = listRecipes({ projectDir: args.hyperframes, workflow: args.workflow });
if (args.json) console.log(JSON.stringify(list));
else if (list.length === 0) console.log("no recipes yet");
else {
for (const r of list) {
console.log(
`${r.name} v${r.version} ${r.workflow} (${r.source}; approved ${String(r.approved_at ?? "?").slice(0, 10)})`,
);
}
}
} else if (verb === "use") {
if (!args.name) fail("use needs --name");
const used = useRecipe({ projectDir: args.hyperframes, name: args.name });
if (args.json) console.log(JSON.stringify({ ok: true, ...used }));
else {
console.log(
`using recipe ${used.recipe.name} (v${used.recipe.version}, ${used.recipe.workflow})`,
);
console.log(` frame spec → ${used.frameSpecPath} (copied over)`);
console.log(` storyboard skeleton → ${used.skeletonPath}`);
if (used.briefSkeletonPath) console.log(` brief skeleton → ${used.briefSkeletonPath}`);
for (const key of ["destination", "aspect", "language", "voice", "style_preset"]) {
if (used.recipe[key]) console.log(` ${key}: ${used.recipe[key]}`);
}
}
} else {
fail(
"usage: recipe.mjs <freeze|list|use> --hyperframes . [--name <n>] [--workflow <w>] [--blocks a,b] [--json]",
);
}
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
}
scripts/resolve.mjs#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { existsSync, statSync, writeFileSync, renameSync, rmSync } from "node:fs";
import { resolve, join, extname, basename } from "node:path";
import { parseArgs } from "node:util";
import {
appendRecord,
findByPrompt,
findByEntity,
nextId,
withReservedFile,
withReservedFileSync,
} from "./lib/manifest.mjs";
import { regenerateIndex } from "./lib/index-gen.mjs";
import { cacheGet, cacheGetByEntity, importFromCache, cachePut } from "./lib/cache.mjs";
import {
runCapability,
listTypes,
providerMatches,
providerNamesFor,
providerTierFor,
} from "./lib/registry.mjs";
import { freezeUrl, freezeLocalFile, isDirectMediaUrl } from "./lib/freeze.mjs";
import { findExistingAsset } from "./lib/adopt.mjs";
import { track } from "./lib/telemetry.mjs";
import { recordMiss } from "./lib/misses.mjs";
import { buildStats } from "./lib/stats.mjs";
import { typesMatch } from "./lib/match.mjs";
import { listCandidates, formatCandidates, CANDIDATE_CAP } from "./lib/candidates.mjs";
import { findGlobalBySha } from "./lib/cache.mjs";
import { heygenAuthMethod } from "../audio/scripts/lib/heygen.mjs";
import { buildCube, paramsFromIntent } from "./lib/cube-build.mjs";
import { validateCubeFile } from "./lib/cube-validate.mjs";
import { analyzeMediaGrade, formatMeasuredNote } from "./lib/grade-analyzer.mjs";
import {
freezeLibraryLut,
isLibraryLutOfflineMiss,
matchColorLook,
} from "./lib/lut-preset-provider.mjs";
import {
HEYGEN_AUTH_COMMAND,
HEYGEN_INSTALL_COMMAND,
HEYGEN_MIN_VERSION,
HEYGEN_UPDATE_COMMAND,
consumeHeygenRemediation,
firstSemver,
flushHeygenFailureTracking,
versionLessThan,
} from "./lib/heygen-cli.mjs";
import { BundledSfxAssetsError, inspectBundledSfxAssets } from "./lib/bundled-sfx-provider.mjs";
const INGEST_TYPES = listTypes();
const DEFAULT_EXT = {
bgm: ".wav",
sfx: ".mp3",
voice: ".wav",
image: ".jpg",
icon: ".svg",
logo: ".svg",
brand: ".png",
video: ".mp4",
grade: ".cube",
lut: ".cube",
};
// resolve shells `fetch`/`freezeUrl` and modern ESM; 18 is the floor where those
// exist without flags. Named so the --doctor node check verifies something real
// (O2). Declared before the top-level `--doctor` branch that calls runDoctor().
const MIN_NODE_VERSION = "18.0.0";
const { values: args } = parseArgs({
options: {
type: { type: "string", short: "t" },
intent: { type: "string", short: "i" },
entity: { type: "string", short: "e" },
project: { type: "string", short: "p", default: "." },
adopt: { type: "boolean", default: false },
candidates: { type: "boolean", default: false },
doctor: { type: "boolean", default: false },
stats: { type: "boolean", default: false },
days: { type: "string" },
"dry-run": { type: "boolean", default: false },
reuse: { type: "string" },
from: { type: "string" },
params: { type: "string" },
for: { type: "string" },
analyze: { type: "boolean", default: false },
"local-only": { type: "boolean", default: false },
provider: { type: "string" },
"avatar-id": { type: "string" },
"voice-id": { type: "string" },
json: { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
},
strict: true,
});
if (args.help) {
console.log(`media-use resolve — turn a media need into a frozen local file
Usage:
node resolve.mjs --type <type> --intent "<description>" [--project <dir>]
Types: ${listTypes().join(", ")}
Options:
--type, -t Media type (required)
--intent, -i What you need (required)
--entity, -e Entity name for cache matching (optional)
--project, -p Project directory (default: .)
--adopt Adopt all existing assets/ files into the manifest
--candidates List reusable assets (project + global cache) for --type; no
download, no mutation. Read them and decide reuse yourself.
--doctor Check local CLI dependencies; no manifest changes.
--stats Print local usage stats from .media and ~/.media; no mutation.
--days <N> Limit --stats to records/misses from the last N days when
timestamps are available.
--reuse <sha> Import a specific global-cache asset (by content sha/prefix,
from --candidates) into this project
--from <file> Freeze a local file or direct public URL (ingest)
--params <json> Build an explicit parametric LUT (lut/grade only)
--for <media> Analyze a local image/video and add measured grade adjust
suggestions (grade only)
--analyze Return --for grade evidence without recording a candidate
--local-only Offline: skip every network provider
--provider Force one generator (e.g. codex, mflux, kokoro, heygen)
--avatar-id Override the default avatar for heygen.video generation
--voice-id Override the default voice for voice/heygen.video generation
--json Output JSON instead of one-line result
--help, -h Show this help`);
process.exit(0);
}
const projectDir = resolve(args.project);
const type = args.type;
const intent = args.intent;
const entity = args.entity || null;
if (args.adopt) {
const { adoptExistingAssets } = await import("./lib/adopt.mjs");
const adopted = adoptExistingAssets(projectDir);
if (args.json) {
console.log(JSON.stringify({ ok: true, adopted: adopted.length, assets: adopted }));
} else if (adopted.length === 0) {
console.log("no new assets to adopt (assets/ empty or already registered)");
} else {
console.log(`adopted ${adopted.length} asset${adopted.length === 1 ? "" : "s"} from assets/`);
for (const r of adopted) console.log(` ${r.id} → ${r.path} (${r.type})`);
}
process.exit(0);
}
// Candidates: side-effect-free listing of reusable assets (project + global
// cache) for --type. No download, no provider, no mutation. The agent reads
// these and decides semantic fit itself.
if (args.candidates || args["dry-run"]) {
await showCandidates();
process.exit(0);
}
if (args.doctor) {
const doctor = runDoctor();
const failed = doctor.checks.filter((check) => !check.ok);
// Non-PII: instrument the exact question the feature exists to answer — how
// often is --doctor run and which check fails most. Awaited so a short-lived
// run flushes before exit.
await track("media_use_doctor_run", {
ok: doctor.ok,
checks_failed: failed.length,
failed: failed.map((check) => check.name),
});
if (args.json) {
console.log(JSON.stringify({ ok: doctor.ok, checks: doctor.checks }));
} else {
printDoctor(doctor.checks);
}
process.exit(doctor.ok ? 0 : 1);
}
if (args.stats) {
const report = buildStats({
projectDir,
days: args.days ? Number(args.days) : undefined,
});
if (args.json) {
console.log(JSON.stringify(report));
} else {
printStats(report);
}
process.exit(0);
}
// Reuse: import a specific global-cache asset (by content sha/prefix, taken
// from --candidates) into this project. `!== undefined` so an empty --reuse ""
// still routes here (and gets a clear empty-sha error) instead of falling
// through to the misleading "--type and --intent are required".
if (args.reuse !== undefined) {
await reuseGlobal(args.reuse);
process.exit(0);
}
// Ingest: freeze a user-supplied local file or direct public URL (no search).
if (args.from) {
await ingest(args.from);
process.exit(0);
}
if (args.analyze) {
if (type !== "grade" || !args.for) {
console.error("error: --analyze requires --type grade and --for <media>");
process.exit(2);
}
const mediaPath = resolve(args.for);
if (!existsSync(mediaPath)) {
console.error(`error: --for file not found: ${mediaPath}`);
process.exit(2);
}
const analysis = analyzeMediaGrade(mediaPath);
if (args.json) {
console.log(JSON.stringify({ ok: true, type: "grade-analysis", ...analysis }));
} else {
console.log(formatMeasuredNote(mediaPath, analysis.measured));
console.log(`suggested adjust: ${JSON.stringify(analysis.adjust)}`);
}
process.exit(0);
}
// Recipes: folder-based named bundles resolved by entity name — no providers,
// no content hashing (an evolving versioned bundle, not an immutable file).
// Delegates to lib/recipe-store.mjs the way grade/lut delegate to resolveColor;
// freeze/list live in scripts/recipe.mjs.
if (type === "recipe") {
const { useRecipe } = await import("./lib/recipe-store.mjs");
const name = (entity || intent || "").trim();
if (!name) exitError("--type recipe needs --entity <name> (or --intent <name>)", 2);
try {
const used = useRecipe({ projectDir, name });
if (args.json) {
console.log(JSON.stringify({ ok: true, ...used }));
} else {
console.log(
`resolved recipe ${used.recipe.name} (v${used.recipe.version}, ${used.recipe.workflow})`,
);
console.log(` frame spec → ${used.frameSpecPath} (copied over)`);
console.log(` storyboard skeleton → ${used.skeletonPath}`);
if (used.briefSkeletonPath) console.log(` brief skeleton → ${used.briefSkeletonPath}`);
}
process.exit(0);
} catch (err) {
exitError(err.message, 1);
}
}
if (args.params !== undefined) {
if (type !== "lut" && type !== "grade") {
exitError(
type
? `--params only supports --type lut or grade (got ${type})`
: "--params requires --type lut or grade",
2,
);
}
try {
await runParams();
process.exit(0);
} catch (err) {
exitError(err.message, 1);
}
}
if (!args.type || !args.intent || !args.intent.trim()) {
console.error("error: --type and a non-empty --intent are required");
process.exit(2);
}
if (!listTypes().includes(args.type)) {
console.error(`error: unknown media type: ${args.type} (known: ${listTypes().join(", ")})`);
process.exit(2);
}
// Forced-provider validation: reject an unknown/unavailable provider name up
// front so a typo reads as a typo, not a catalog miss (`no provider could
// resolve`). Match rule mirrors runProviders (full name or dotted prefix).
if (args.provider && !providerMatches(args.type, args.provider)) {
console.error(
`error: unknown provider "${args.provider}" for type ${args.type} (available: ${providerNamesFor(args.type).join(", ")})`,
);
process.exit(2);
}
function recordAvailable(projectDir, record) {
if (!record) return false;
if (record.path) return existsSync(join(projectDir, record.path));
return record.type === "grade" && record.grading;
}
// Sparse `{ authMethod }` for a heygen-family provider name (e.g. "heygen.tts"),
// else `{}` — keeps auth_method telemetry absent for every non-heygen resolve
// instead of implying an auth method that doesn't apply.
function heygenAuthMethodFor(provider) {
if (!provider || !provider.startsWith("heygen.")) return {};
const authMethod = heygenAuthMethod();
return authMethod ? { authMethod } : {};
}
function localizeImportedRecord(record, localPath) {
if (record?.type === "grade" && record.grading?.lut) {
record.grading = {
...record.grading,
lut: { ...record.grading.lut, src: localPath },
};
}
return record;
}
async function run() {
// A forced --provider means "(re)generate with THIS provider" — it bypasses
// every reuse rung (project/entity/assets/global cache) so it can't silently
// hand back an asset from a different provider. The floor only applies to the
// default (unforced) cascade.
const forced = !!args.provider;
// 1. project manifest — exact-prompt match
const projectHit = forced ? null : findByPrompt(projectDir, intent, type);
if (recordAvailable(projectDir, projectHit)) {
return result(projectHit, "cached");
}
// 1b. entity match in project. icon and image are interchangeable for
// entity hits — both live in images/, and figma-imported brand marks are
// always recorded as type image while agents ask for logos as type icon.
if (!forced && entity) {
const entityHit = findByEntity(projectDir, entity);
if (entityHit && typesMatch(entityHit.type, type) && recordAvailable(projectDir, entityHit)) {
return result(entityHit, "cached");
}
}
// 1c. scan existing assets/ directory for unregistered matches
const existingAsset =
forced || type === "grade" || type === "lut"
? null
: findExistingAsset(projectDir, intent, type);
if (existingAsset) {
const id = nextId(projectDir, type);
const record = {
id,
type: existingAsset.type,
path: existingAsset.relativePath,
source: "existing",
description: existingAsset.name.replace(/[-_]/g, " "),
provenance: { provider: "local", adopted: true, prompt: intent },
};
appendRecord(projectDir, record);
regenerateIndex(projectDir);
return result(record, "existing");
}
// 2. global cache — exact-prompt or entity match
const cacheHit = forced ? null : cacheGet(intent, type);
if (cacheHit) {
const ext = extname(cacheHit.cached_path);
const imported = withReservedFileSync(projectDir, type, ext, ({ id, localPath }) =>
localizeImportedRecord(importFromCache(cacheHit, projectDir, id, localPath), localPath),
);
if (imported) {
appendRecord(projectDir, imported);
regenerateIndex(projectDir);
return result(imported, "reused");
}
}
if (!forced && entity) {
const entityCacheHit = cacheGetByEntity(entity);
if (entityCacheHit && typesMatch(entityCacheHit.type, type)) {
const ext = extname(entityCacheHit.cached_path);
const imported = withReservedFileSync(projectDir, type, ext, ({ id, localPath }) =>
localizeImportedRecord(
importFromCache(entityCacheHit, projectDir, id, localPath),
localPath,
),
);
if (imported) {
appendRecord(projectDir, imported);
regenerateIndex(projectDir);
return result(imported, "reused");
}
}
}
// Offline guard: --local-only skips every remote provider (HeyGen catalog),
// leaving the project + global cache and any local provider.
const localOnly = args["local-only"];
const ctx = {
entity,
projectDir,
localOnly,
provider: args.provider,
avatarId: args["avatar-id"],
voiceId: args["voice-id"],
};
// Adherence nudge (offline, no auto-reuse): the exact-cache floor missed and
// we're about to fetch/generate. If lexically-similar assets already exist,
// point the agent at --candidates so it can reuse instead of fetching. Only a
// fuzzy match ever reaches the agent this way — never auto-applied. Goes to
// stderr so it reaches --json callers without corrupting stdout. Best-effort.
try {
const { similar } = listCandidates({ projectDir, type, intent, cap: CANDIDATE_CAP });
if (similar > 0) {
console.error(
`media-use: ${similar} similar cached asset${similar === 1 ? "" : "s"} already ${similar === 1 ? "exists" : "exist"} — run \`resolve --candidates --type ${type} --intent "${intent}"\` to review and reuse instead of fetching.`,
);
}
} catch {
// hint is best-effort; never block a resolve
}
if (type === "grade" || type === "lut") {
return resolveColor(type, intent, { projectDir });
}
// 3. provider search — registry tries providers in order (heygen-CLI first)
let searchResult = null;
let providerFailure = null;
try {
searchResult = await runCapability(type, "search", intent, ctx);
} catch (error) {
providerFailure = error;
// search failed, try generate
}
// 4. generate fallback — same ordered cascade for the generate capability
if (!searchResult) {
try {
searchResult = await runCapability(type, "generate", intent, ctx);
} catch (error) {
providerFailure ??= error;
// generate failed too
}
}
// A search/generate attempt against heygen may have fired a fire-and-forget
// media_use_provider_error track (reportHeygenFailure — heygen-search.mjs /
// voice-provider.mjs are sync call sites several layers below here and can't
// await it themselves). Join it now, before any process.exit() below can
// race it: both it and the miss/success telemetry below are separate,
// non-keepalive HTTP connections with no ordering guarantee otherwise.
await flushHeygenFailureTracking();
if (!searchResult) {
await track("media_use_resolve_miss", {
type,
local_only: !!localOnly,
provider_override: !!args.provider,
});
recordMiss({
type,
intent,
provider_override: !!args.provider,
local_only: !!args["local-only"],
});
// brand stays local: no frame.md/design.md -> upsell the HyperFrames design
// flow rather than reporting a generic miss (B5).
const msg =
providerFailure instanceof BundledSfxAssetsError
? providerFailure.message
: type === "brand"
? "no brand spec found — add a frame.md or design.md (colors/font/logo) to this project. Run the HyperFrames design flow to create one; brand tokens are read locally for deterministic rendering."
: args.provider
? `provider "${args.provider}" could not resolve ${type}: "${intent}"${localOnly ? " (--local-only skips network providers; drop it or the --provider override)" : ""}`
: `no provider could resolve ${type}: "${intent}"`;
if (args.json) {
console.log(
JSON.stringify({
ok: false,
...(providerFailure instanceof BundledSfxAssetsError
? { code: providerFailure.code, fix: providerFailure.fix }
: {}),
error: msg,
}),
);
} else {
console.error(`error: ${msg}`);
}
process.exit(1);
}
// 5. freeze + register (atomic id+file reservation so concurrent resolves
// can't collide on an id during the download — MU-23)
const ext = searchResult.ext || extFromUrl(searchResult.url || "") || defaultExt(type);
const { id, localPath, fullPath } = await withReservedFile(
projectDir,
type,
ext,
async (reservation) => {
if (searchResult.localPath) {
freezeLocalFile(searchResult.localPath, reservation.fullPath);
} else if (searchResult.url) {
await freezeUrl(searchResult.url, reservation.fullPath);
} else {
throw new Error("provider returned no url or localPath");
}
return reservation;
},
);
const record = {
id,
type,
path: localPath,
source: searchResult.source || "search",
description: searchResult.metadata?.description || intent,
...(searchResult.metadata?.duration != null && {
duration: Math.round(searchResult.metadata.duration * 10) / 10, // round to 0.1s like probe (voice bypassed it)
}),
...(searchResult.metadata?.width != null && { width: searchResult.metadata.width }),
...(searchResult.metadata?.height != null && { height: searchResult.metadata.height }),
...(searchResult.metadata?.transparent != null && {
transparent: searchResult.metadata.transparent,
}),
...(entity && { entity }),
provenance: {
provider: searchResult.metadata?.provider || "unknown",
prompt: intent,
// heygenAuthMethodFor spreads first so an explicit authMethod on a
// future provider's own metadata.provenance can still override it below
// -- safe today (no provider sets authMethod itself), but keep this
// ordering if that ever changes.
...heygenAuthMethodFor(searchResult.metadata?.provider),
...searchResult.metadata?.provenance,
},
};
const heygenRemediation = consumeHeygenRemediation();
if (
searchResult.metadata?.provider === "bundled.sfx" &&
!localOnly &&
!args.provider &&
heygenRemediation
) {
record.advisory = heygenRemediation;
}
appendRecord(projectDir, record);
regenerateIndex(projectDir);
// Auto-promote: surface every fetched asset in the global cache so it's
// reusable across all hyperframes projects (B3). Non-fatal; dedup by sha.
// ponytail: promotes search/generate/ingest assets (the ones media-use
// fetched), not bulk --adopt imports — add those if cross-project reuse of
// pre-existing project assets is wanted.
try {
cachePut(fullPath, record);
} catch {
// promotion is best-effort; a resolve still succeeds locally
}
return result(record, searchResult.source || "search");
}
function mergeSmartAdjust(block) {
if (!args.for) return block;
const mediaPath = resolve(args.for);
// Clear upfront error beats an ffmpeg "No such file" stack on a typo'd path.
if (!existsSync(mediaPath)) throw new Error(`--for file not found: ${mediaPath}`);
const analysis = analyzeMediaGrade(mediaPath);
console.error(formatMeasuredNote(mediaPath, analysis.measured));
return {
...block,
adjust: {
...(block.adjust || {}),
...analysis.adjust,
},
};
}
function freezeGeneratedLut(
params,
{
projectDir,
type,
description = "parametric color grade",
validationErrorPrefix = "generated LUT failed validation",
},
) {
return withReservedFileSync(projectDir, type, ".cube", ({ id, localPath, fullPath }) => {
const tmpPath = `${fullPath}.tmp`;
try {
// Write + validate at .tmp, then atomic rename, so a crash between write and
// validate can't leave an invalid .cube at the final path.
writeFileSync(tmpPath, buildCube(params));
const check = validateCubeFile(tmpPath);
if (!check.ok) throw new Error(check.error);
renameSync(tmpPath, fullPath);
} catch (err) {
rmSync(tmpPath, { force: true });
throw new Error(`${validationErrorPrefix}: ${err.message}`);
}
return {
id,
localPath,
fullPath,
lut: { src: localPath, intensity: 1 },
source: "generated",
description,
metadata: {
provider: "cube_lut.builder",
provenance: { params },
},
};
});
}
function exitError(message, status = 1) {
if (args.json) {
console.log(JSON.stringify({ ok: false, error: message }));
} else {
console.error(`error: ${message}`);
}
process.exit(status);
}
function parseExplicitParams() {
try {
return JSON.parse(args.params);
} catch (err) {
throw new Error(`invalid --params JSON: ${err.message}`);
}
}
async function runParams() {
if (type === "lut" && args.for) {
throw new Error("--for is only supported with --type grade");
}
const params = parseExplicitParams();
const description =
typeof intent === "string" && intent.trim()
? intent.trim()
: `custom parametric ${type === "lut" ? "lut" : "grade"}`;
const frozen = freezeGeneratedLut(params, {
projectDir,
type,
description,
validationErrorPrefix: "--params produced an invalid LUT",
});
const record = {
id: frozen.id,
type,
path: frozen.localPath,
source: frozen.source,
description: frozen.description,
...(type === "grade" && { grading: mergeSmartAdjust({ intensity: 1, lut: frozen.lut }) }),
provenance: {
provider: frozen.metadata.provider,
...frozen.metadata.provenance,
},
};
return finalizeColorRecord(record, frozen.source, frozen.fullPath);
}
async function finalizeColorRecord(record, source, fullPath = null) {
appendRecord(projectDir, record);
regenerateIndex(projectDir);
if (fullPath) {
try {
cachePut(fullPath, record);
} catch {
// promotion is best-effort
}
}
return result(record, source);
}
async function colorMiss(type, intent) {
await track("media_use_resolve_miss", {
type,
local_only: !!args["local-only"],
provider_override: !!args.provider,
});
recordMiss({
type,
intent,
provider_override: !!args.provider,
local_only: !!args["local-only"],
});
const msg = `no local color grade could resolve ${type}: "${intent}"`;
if (args.json) {
console.log(JSON.stringify({ ok: false, error: msg }));
} else {
console.error(`error: ${msg}`);
}
process.exit(1);
}
async function resolveGrade(intent, { projectDir }) {
const match = matchColorLook(intent);
if (match?.kind === "preset") {
const id = nextId(projectDir, "grade");
const grading = mergeSmartAdjust({ preset: match.preset, intensity: 1 });
const record = {
id,
type: "grade",
source: "preset",
description: intent,
grading,
provenance: {
provider: "color_grade.local",
prompt: intent,
preset: match.preset,
},
};
return finalizeColorRecord(record, "preset");
}
if (match?.kind === "library") {
let frozen;
try {
frozen = await freezeLibraryLut(match, {
projectDir,
type: "grade",
localOnly: args["local-only"],
});
} catch (err) {
if (isLibraryLutOfflineMiss(err)) return colorMiss("grade", intent);
throw err;
}
const grading = mergeSmartAdjust({ intensity: 1, lut: frozen.lut });
const record = {
id: frozen.id,
type: "grade",
path: frozen.localPath,
source: frozen.source,
description: frozen.description,
grading,
provenance: {
provider: frozen.metadata.provider,
prompt: intent,
...frozen.metadata.provenance,
},
};
return finalizeColorRecord(record, frozen.source, frozen.fullPath);
}
const params = paramsFromIntent(intent);
if (!params) {
// No creative look matched. With --for, the measured adjust block is a
// valid grade on its own (footage auto-correction); only a true miss
// (no look AND no analysis) aborts.
if (args.for) {
const grading = mergeSmartAdjust({ intensity: 1 });
const record = {
id: nextId(projectDir, "grade"),
type: "grade",
source: "measured",
description: intent,
grading,
provenance: { provider: "color_grade.local", prompt: intent, measured: true },
};
return finalizeColorRecord(record, "measured");
}
return colorMiss("grade", intent);
}
const frozen = freezeGeneratedLut(params, { projectDir, type: "grade" });
const grading = mergeSmartAdjust({ intensity: 1, lut: frozen.lut });
const record = {
id: frozen.id,
type: "grade",
path: frozen.localPath,
source: frozen.source,
description: intent,
grading,
provenance: {
provider: frozen.metadata.provider,
prompt: intent,
...frozen.metadata.provenance,
},
};
return finalizeColorRecord(record, frozen.source, frozen.fullPath);
}
async function resolveLut(intent, { projectDir }) {
if (args.for) {
throw new Error("--for is only supported with --type grade");
}
const match = matchColorLook(intent);
if (match?.kind === "library") {
let frozen;
try {
frozen = await freezeLibraryLut(match, {
projectDir,
type: "lut",
localOnly: args["local-only"],
});
} catch (err) {
if (isLibraryLutOfflineMiss(err)) return colorMiss("lut", intent);
throw err;
}
const record = {
id: frozen.id,
type: "lut",
path: frozen.localPath,
source: frozen.source,
description: frozen.description,
provenance: {
provider: frozen.metadata.provider,
prompt: intent,
...frozen.metadata.provenance,
},
};
return finalizeColorRecord(record, frozen.source, frozen.fullPath);
}
const params = paramsFromIntent(intent);
if (!params) return colorMiss("lut", intent);
const frozen = freezeGeneratedLut(params, { projectDir, type: "lut" });
const record = {
id: frozen.id,
type: "lut",
path: frozen.localPath,
source: frozen.source,
description: intent,
provenance: {
provider: frozen.metadata.provider,
prompt: intent,
...frozen.metadata.provenance,
},
};
return finalizeColorRecord(record, frozen.source, frozen.fullPath);
}
async function resolveColor(type, intent, options) {
if (type === "grade") return resolveGrade(intent, options);
return resolveLut(intent, options);
}
async function ingest(src) {
if (!type || !INGEST_TYPES.includes(type)) {
console.error(`error: --from requires --type (one of: ${INGEST_TYPES.join(", ")})`);
process.exit(2);
}
const isUrl = /^https?:\/\//i.test(src);
if (isUrl && !isDirectMediaUrl(src)) {
console.error(
`error: --from takes a direct public media URL or a local file; "${src}" is not a direct media link (no platform pages / yt-dlp)`,
);
process.exit(2);
}
if (!isUrl && !existsSync(resolve(src))) {
console.error(`error: file not found: ${src}`);
process.exit(2);
}
// Refuse 0-byte input: an empty asset would register clean but fail at render
// (freezeUrl already rejects empty responses; this covers local files).
if (!isUrl && statSync(resolve(src)).size === 0) {
console.error(`error: refusing to ingest a 0-byte file: ${src}`);
process.exit(2);
}
const ext = extname(isUrl ? new URL(src).pathname : src) || defaultExt(type);
const { id, localPath, fullPath } = await withReservedFile(
projectDir,
type,
ext,
async (reservation) => {
if (isUrl) await freezeUrl(src, reservation.fullPath);
else freezeLocalFile(resolve(src), reservation.fullPath);
return reservation;
},
);
if (type === "lut" || type === "grade") {
try {
const check = validateCubeFile(fullPath);
if (!check.ok) throw new Error(check.error);
} catch (err) {
rmSync(fullPath, { force: true });
exitError(`ingested LUT is invalid: ${err.message}`, 1);
}
}
const record = {
id,
type,
path: localPath,
source: "ingested",
description: basename(src.split("?")[0]),
provenance: { provider: "local", from: src },
};
appendRecord(projectDir, record);
regenerateIndex(projectDir);
try {
cachePut(fullPath, record); // surface ingested assets globally too (B3)
} catch {
// best-effort
}
await result(record, "ingested");
}
async function showCandidates() {
const projectDir = resolve(args.project);
const type = args.type;
if (!type || !listTypes().includes(type)) {
console.error(`error: --candidates requires --type (one of: ${listTypes().join(", ")})`);
process.exit(2);
}
const intent = args.intent || "";
const { candidates, truncated, total, similar } = listCandidates({
projectDir,
type,
intent,
cap: CANDIDATE_CAP,
});
await track("media_use_candidates", {
type,
project_n: total.project,
global_n: total.global,
local_only: !!args["local-only"],
});
if (args.json) {
console.log(JSON.stringify({ ok: true, candidates, truncated, total, similar }));
} else {
console.log(formatCandidates(candidates, { truncated, total }));
}
}
// Best-effort latest stable CLI tag from the CDN (the install script's source of
// truth). null on any failure (offline, no curl) — treated as "unknown", never fatal.
function latestHeygenStable() {
const probe = runCommand("curl", [
"-fsSL",
"--max-time",
"4",
"https://static.heygen.ai/cli/stable",
]);
return probe.status === 0 ? firstSemver(commandText(probe)) : null;
}
function heygenAuthCheck() {
// `heygen auth status` already emits JSON by default (only `--human` opts out
// to a table) — there is no `--json`/`--output` flag; passing one errors with
// "unknown flag". emailFromAuthStatus parses that default JSON.
// NOTE: JSON-by-default is a v0.3.0 behavior — this probe assumes it, which
// HEYGEN_MIN_VERSION >= 0.3.0 (+ the version gate above) guarantees. If that
// floor is ever lowered, auth detection on an older CLI would silently break.
const authProbe = runCommand("heygen", ["auth", "status"]);
// spawnSync sets .error/.signal on a timeout or spawn failure (status then
// null). A stalled auth endpoint (transient network/DNS) must not be reported
// as an authoritative "not authenticated" with a re-login fix.
const timedOut = authProbe.error?.code === "ETIMEDOUT" || authProbe.signal != null;
const email = authProbe.status === 0 ? emailFromAuthStatus(commandText(authProbe)) : null;
return {
name: "heygen authenticated",
ok: !!email,
detail: email
? `heygen authenticated as ${email}`
: timedOut
? "heygen auth status timed out — possible network issue, not proof of sign-out"
: "heygen not authenticated",
fix: email ? "" : timedOut ? "check network, then re-run --doctor" : HEYGEN_AUTH_COMMAND,
};
}
function runDoctor() {
const checks = [];
const bundledSfx = inspectBundledSfxAssets();
checks.push({
name: "bundled SFX assets",
ok: bundledSfx.ok,
detail: bundledSfx.detail,
fix: bundledSfx.fix,
});
const heygenVersionProbe = runCommand("heygen", ["--version"]);
const heygenOnPath = heygenVersionProbe.status === 0;
const heygenVersionText = commandText(heygenVersionProbe);
const heygenVersion = firstSemver(heygenVersionText);
checks.push({
name: "heygen on PATH",
ok: heygenOnPath,
// Just "is the binary here" — the version row below owns the version string,
// so this row must not also render `heygen v0.3.0` (two byte-identical lines).
detail: heygenOnPath ? "heygen found on PATH" : "heygen not found",
fix: heygenOnPath ? "" : HEYGEN_INSTALL_COMMAND,
});
if (!heygenOnPath) {
checks.push({
name: "heygen version",
ok: false,
detail: "heygen version unavailable",
fix: HEYGEN_INSTALL_COMMAND,
});
checks.push({
name: "heygen authenticated",
ok: false,
detail: "heygen auth status unavailable",
fix: HEYGEN_INSTALL_COMMAND,
});
} else if (heygenVersion) {
const versionOk = !versionLessThan(heygenVersion, HEYGEN_MIN_VERSION);
// Keep it latest: even when the installed version clears the floor, nudge
// `heygen update` if a newer stable exists. Best-effort — silently skipped
// when the CDN is unreachable, so it never blocks the check.
const latest = versionOk ? latestHeygenStable() : null;
const behind = latest && versionLessThan(heygenVersion, latest);
checks.push({
name: "heygen version",
ok: versionOk,
detail: versionOk
? `heygen v${heygenVersion}${behind ? ` (latest v${latest} available)` : ""}`
: `heygen v${heygenVersion} (need >= v${HEYGEN_MIN_VERSION})`,
fix: versionOk ? (behind ? HEYGEN_UPDATE_COMMAND : "") : HEYGEN_UPDATE_COMMAND,
});
// Below the OAuth-capable floor the auth probe fails for the SAME root cause
// (an old CLI can't OAuth and doesn't emit JSON auth status), which would
// read as a confusing second "not authenticated" error. Skip it — one root
// cause, one fix.
checks.push(
versionOk
? heygenAuthCheck()
: {
name: "heygen authenticated",
ok: false,
detail: "skipped — update heygen first",
fix: HEYGEN_UPDATE_COMMAND,
},
);
} else {
// Fail-open: heygen ran but printed no semver (dev/stripped build). We can't
// verify the version, so we don't block on it — but say so rather than a bare
// green check that implies a real version comparison happened.
checks.push({
name: "heygen version",
ok: true,
detail: "heygen present; version unverifiable (no semver in --version output)",
fix: "",
});
checks.push(heygenAuthCheck());
}
const ffmpegProbe = runCommand("ffmpeg", ["-version"]);
checks.push({
name: "ffmpeg on PATH",
ok: ffmpegProbe.status === 0,
detail: ffmpegProbe.status === 0 ? firstLine(ffmpegProbe.stdout) : "ffmpeg not found",
fix: ffmpegProbe.status === 0 ? "" : "brew install ffmpeg",
});
const ffprobeProbe = runCommand("ffprobe", ["-version"]);
checks.push({
name: "ffprobe on PATH",
ok: ffprobeProbe.status === 0,
detail: ffprobeProbe.status === 0 ? firstLine(ffprobeProbe.stdout) : "ffprobe not found",
fix: ffprobeProbe.status === 0 ? "" : "brew install ffmpeg",
});
const nodeOk = !versionLessThan(process.versions.node, MIN_NODE_VERSION);
checks.push({
name: "node version",
ok: nodeOk,
detail: `${process.version} (need >= v${MIN_NODE_VERSION})`,
fix: nodeOk ? "" : `upgrade Node to >= v${MIN_NODE_VERSION}`,
});
// ffmpeg AND ffprobe are both strictly required (see references/setup-providers.md); the exit code
// must reflect that so a script gating on `--doctor` doesn't pass with ffprobe
// missing and then break at the first probe call.
const ffmpeg = checks.find((check) => check.name === "ffmpeg on PATH");
const ffprobe = checks.find((check) => check.name === "ffprobe on PATH");
return { ok: bundledSfx.ok && !!ffmpeg?.ok && !!ffprobe?.ok, checks };
}
function printDoctor(checks) {
const heygenChecks = new Set(["heygen on PATH", "heygen version", "heygen authenticated"]);
for (const check of checks) {
const prefix = check.ok ? "✓" : "✗";
const freePath = heygenChecks.has(check.name)
? " — free-usage path: bgm/image/voice/avatar-video"
: "";
const fix = check.ok || !check.fix ? "" : ` — fix: ${check.fix}`;
console.log(`${prefix} ${check.detail}${freePath}${fix}`);
}
}
function printStats(report) {
console.log("media-use stats");
console.log(`total resolves: ${report.total_resolves}`);
console.log(`misses: ${report.misses}`);
console.log(
`hit rate: ${report.hit_rate == null ? "n/a" : `${Math.round(report.hit_rate * 100)}%`}`,
);
printMap("by type", report.by_type);
printMap("by source", report.by_source);
printMap("by provider", report.by_provider);
printMap("by via", report.by_via);
console.log(`global cache assets: ${report.global_cache_assets}`);
console.log(`global cache disk: ${report.global_cache_disk_bytes} bytes`);
console.log(`cross-project reuse: ${report.cross_project_reuse}`);
console.log("top missed intents:");
const entries = Object.entries(report.top_missed_intents);
if (entries.length === 0) {
console.log(" none");
return;
}
for (const [type, misses] of entries) {
console.log(` ${type}:`);
for (const miss of misses) console.log(` ${miss.count} ${miss.intent}`);
}
}
function printMap(label, values) {
const entries = Object.entries(values);
console.log(`${label}:`);
if (entries.length === 0) {
console.log(" none");
return;
}
for (const [key, value] of entries) console.log(` ${key}: ${value}`);
}
function runCommand(bin, argv) {
return spawnSync(bin, argv, {
encoding: "utf8",
timeout: 15000,
});
}
function commandText(result) {
return [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
}
function firstLine(text) {
return (
String(text || "")
.trim()
.split(/\r?\n/)[0] || ""
);
}
function emailFromAuthStatus(text) {
// JSON only (auth status emits JSON by default). No prose regex fallback: a
// human-format body like "Session expired. Contact support@heygen.ai" would
// otherwise report the user as authenticated as support@heygen.ai.
const trimmed = String(text || "").trim();
if (!trimmed.startsWith("{")) return null;
try {
const parsed = JSON.parse(trimmed);
return parsed?.data?.email || parsed?.email || null;
} catch {
return null;
}
}
async function reuseGlobal(shaArg) {
const projectDir = resolve(args.project);
const type = args.type;
if (!type || !listTypes().includes(type)) {
console.error(`error: --reuse requires --type (one of: ${listTypes().join(", ")})`);
process.exit(2);
}
if (!shaArg || !shaArg.trim()) {
console.error("error: --reuse needs a content sha/prefix (from `resolve --candidates`)");
process.exit(2);
}
const rec = findGlobalBySha(shaArg);
if (rec && rec.ambiguous) {
console.error(
`error: sha prefix "${shaArg}" is ambiguous (${rec.count} matches) — use more characters`,
);
process.exit(2);
}
if (!rec) {
console.error(`error: no reusable global asset matches sha "${shaArg}"`);
process.exit(1);
}
// Type guard: don't import a bgm asset as an image (audio under images/).
// icon<->image are interchangeable; everything else must match --type.
if (!typesMatch(rec.type, type)) {
console.error(`error: sha "${shaArg}" is a ${rec.type} asset, not ${type}`);
process.exit(2);
}
const ext = extname(rec.cached_path || "") || defaultExt(type);
const imported = withReservedFileSync(projectDir, type, ext, ({ id, localPath }) =>
localizeImportedRecord(importFromCache(rec, projectDir, id, localPath), localPath),
);
if (!imported) {
console.error(`error: cache entry for "${shaArg}" is incomplete or missing on disk`);
process.exit(1);
}
// Distinguish an explicit agent reuse from an automatic normalize-exact hit.
imported.source = "reused-explicit";
imported.provenance = { ...imported.provenance, reused_by: "agent" };
appendRecord(projectDir, imported);
regenerateIndex(projectDir);
await result(imported, "reused-explicit");
}
async function result(record, source) {
// Non-PII usage event: which media type, how it resolved, which provider won.
// Never the intent text or paths. Awaited so a short-lived run flushes it.
await track("media_use_resolve", {
type: record.type,
source,
provider: record.provenance?.provider,
// How a library LUT resolved: "url" (CDN), "params-fallback" (CDN failed →
// parametric), or "params" (offline). Surfaces silent CDN→params downgrades
// in prod, which --doctor can't (it only answers "reachable now?").
via: record.provenance?.via,
// Free (OAuth) vs. paid (API-key) heygen path — sparse: absent for every
// non-heygen provider (see heygenAuthMethodFor at construction time). On a
// cache/reuse hit this reports how the asset was ORIGINALLY fetched, not
// this resolve's own credential state — intentional: it's a conversion
// signal about the fetch that actually consumed a heygen credit, not
// about the (free, no-credential) act of copying a cached file.
auth_method: record.provenance?.authMethod,
// "local" / "network_free" / "network_paid", straight from the registry's own
// A/N/P declaration — so a dashboard can separate free lookups from calls that
// spend credit without hardcoding provider names. Sparse: absent when the
// record carries no provider (cache and reuse hits) or the name is unknown.
provider_tier: providerTierFor(record.provenance?.provider),
local_only: !!args["local-only"],
provider_override: !!args.provider,
});
if (args.json) {
const grading = record.type === "grade" && record.grading ? record.grading : null;
console.log(
JSON.stringify({
ok: true,
...record,
...(grading || {}),
...(grading && { grading }),
_source: source,
}),
);
} else {
const meta = formatMeta(record, source);
console.log(`resolved ${record.id} → ${record.path || "inline"} (${meta})`);
}
}
function formatMeta(record, source) {
const parts = [record.type];
if (record.grading?.preset) parts.push(`preset ${record.grading.preset}`);
if (record.grading?.lut) parts.push("lut");
if (record.duration != null) parts.push(`${record.duration}s`);
if (record.width && record.height) parts.push(`${record.width}×${record.height}`);
if (record.transparent) parts.push("transparent");
if (source === "reused" || source === "reused-explicit") parts.push("reused");
if (source === "generated") parts.push("generated");
return parts.join(", ");
}
function extFromUrl(url) {
try {
return extname(new URL(url).pathname) || null;
} catch {
return null;
}
}
function defaultExt(type) {
return DEFAULT_EXT[type] || ".bin";
}
run().catch((err) => {
if (args.json) {
console.log(JSON.stringify({ ok: false, error: err.message }));
} else {
console.error(`error: ${err.message}`);
}
process.exit(1);
});
scripts/resolve.test.mjsimport { strict as assert } from "node:assert";
import {
mkdtempSync,
rmSync,
writeFileSync,
readFileSync,
mkdirSync,
existsSync,
readdirSync,
chmodSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createServer } from "node:http";
import { execFileSync, spawn, spawnSync } from "node:child_process";
import { appendRecord, readManifest } from "./lib/manifest.mjs";
import { regenerateIndex } from "./lib/index-gen.mjs";
import { getProvider } from "./lib/providers.mjs";
import { HEYGEN_NOT_FOUND_MESSAGE } from "./lib/heygen-cli.mjs";
import { freezeLocalFile } from "./lib/freeze.mjs";
import { cachePut, cacheGet, importFromCache } from "./lib/cache.mjs";
import { validateCubeFile } from "./lib/cube-validate.mjs";
const REPO_ROOT = join(import.meta.dirname, "..", "..", "..");
const RESOLVE_CLI = join(import.meta.dirname, "resolve.mjs");
// The "Test: skills" CI job has no ffmpeg on PATH (by design). The smart-grade
// test shells to ffmpeg, so it's skipped there and runs where ffmpeg exists.
const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0;
// The core-conformance test imports core's TypeScript via tsx. The dependency-free
// "Test: skills" CI job has neither tsx nor installed deps, so skip it there; it
// runs wherever the workspace is installed (locally, the main Test job).
const CAN_TSX =
spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", "0"], {
stdio: "ignore",
}).status === 0;
let tmp;
function setup() {
tmp = mkdtempSync(join(tmpdir(), "mu-resolve-test-"));
}
function cleanup() {
if (tmp) rmSync(tmp, { recursive: true, force: true });
}
function makeRecord(overrides = {}) {
return {
id: "bgm_001",
type: "bgm",
path: ".media/audio/bgm/bgm_001.wav",
source: "search",
description: "soft minimal ambient",
duration: 11,
provenance: { provider: "test", prompt: "test prompt" },
...overrides,
};
}
// Run resolve.mjs with argv passed as a literal array (no shell). Each token is
// a separate argv entry, so a value with spaces or shell metacharacters can't
// break out — never build a command string and hand it to a shell.
function runResolve(args, opts = {}) {
const { env, ...rest } = opts;
return execFileSync(process.execPath, [RESOLVE_CLI, ...args], {
cwd: REPO_ROOT,
encoding: "utf8",
env: { ...process.env, DO_NOT_TRACK: "1", ...env },
...rest,
});
}
function spawnResolve(args, opts = {}) {
const { env, ...rest } = opts;
return spawnSync(process.execPath, [RESOLVE_CLI, ...args], {
cwd: REPO_ROOT,
encoding: "utf8",
env: { ...process.env, DO_NOT_TRACK: "1", ...env },
...rest,
});
}
function spawnResolveAsync(args, opts = {}) {
const { env, ...rest } = opts;
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [RESOLVE_CLI, ...args], {
cwd: REPO_ROOT,
env: { ...process.env, DO_NOT_TRACK: "1", ...env },
stdio: ["ignore", "pipe", "pipe"],
...rest,
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => (stdout += chunk));
child.stderr.on("data", (chunk) => (stderr += chunk));
child.once("error", reject);
child.once("close", (status, signal) => resolve({ status, signal, stdout, stderr }));
});
}
function makeFrame(dir, name, color) {
const out = join(dir, name);
execFileSync(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
`color=c=${color}:s=64x64`,
"-frames:v",
"1",
"-y",
out,
],
{ stdio: "pipe" },
);
return out;
}
function normalizeWithCoreSource(grading) {
const sourcePath = join(REPO_ROOT, "packages/core/src/colorGrading.ts");
const code = `
import { normalizeHfColorGrading } from ${JSON.stringify(sourcePath)};
const grading = JSON.parse(process.env.HF_GRADING_JSON);
const normalized = normalizeHfColorGrading(grading);
if (!normalized) process.exit(2);
console.log(JSON.stringify({
preset: normalized.preset,
intensity: normalized.intensity,
adjust: normalized.adjust,
lut: normalized.lut,
colorSpace: normalized.colorSpace
}));
`;
return JSON.parse(
execFileSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", code], {
cwd: REPO_ROOT,
encoding: "utf8",
env: { ...process.env, HF_GRADING_JSON: JSON.stringify(grading) },
}),
);
}
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
// --- manifest cache hit ---
test("bundled SFX resolve without HeyGen on PATH", () => {
setup();
const result = spawnResolve(["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--json"], {
env: { HOME: tmp, PATH: tmp },
});
assert.equal(result.status, 0, result.stderr);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.ok, true);
assert.equal(parsed.provenance.provider, "bundled.sfx");
assert.equal(parsed.advisory?.message, HEYGEN_NOT_FOUND_MESSAGE);
assert.equal(parsed.advisory.message.includes("| bash"), false);
assert.ok(existsSync(join(tmp, parsed.path)));
cleanup();
});
test("missing bundled SFX install returns a typed recovery command", () => {
setup();
const missingLibrary = join(tmp, "missing-sfx-library");
const result = spawnResolve(
["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--local-only", "--json"],
{
env: {
HOME: tmp,
PATH: tmp,
HYPERFRAMES_MEDIA_USE_SFX_DIR: missingLibrary,
},
},
);
assert.equal(result.status, 1, result.stderr);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.ok, false);
assert.equal(parsed.code, "bundled_sfx_assets_missing");
assert.equal(parsed.fix, "npx hyperframes skills update media-use");
assert.match(parsed.error, /bundled SFX assets are missing or incomplete/);
assert.match(parsed.error, /manifest not found/);
cleanup();
});
function writeFakeHeygen(body, exitCode = 0) {
const binDir = join(tmp, "bin");
mkdirSync(binDir, { recursive: true });
const command = join(binDir, "heygen");
writeFileSync(command, `#!/bin/sh\n${body}\nexit ${exitCode}\n`);
chmodSync(command, 0o755);
return binDir;
}
test("bundled SFX advises update when the HeyGen CLI is outdated", () => {
setup();
const binDir = writeFakeHeygen('echo "heygen v0.1.5 does not support --headers" >&2', 1);
const result = spawnResolve(["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--json"], {
env: { HOME: tmp, PATH: binDir },
});
assert.equal(result.status, 0, result.stderr);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.provenance.provider, "bundled.sfx");
assert.match(parsed.advisory?.message ?? "", /heygen update/);
cleanup();
});
test("bundled SFX does not advise installation after a healthy catalog miss", () => {
setup();
const binDir = writeFakeHeygen(`echo '{"data":[]}'`);
const result = spawnResolve(["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--json"], {
env: { HOME: tmp, PATH: binDir },
});
assert.equal(result.status, 0, result.stderr);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.provenance.provider, "bundled.sfx");
assert.equal(parsed.advisory, undefined);
cleanup();
});
test("explicit local bundled SFX resolution does not advise installation", () => {
for (const extraArgs of [["--local-only"], ["--provider", "bundled.sfx"]]) {
setup();
const result = spawnResolve(
["--type", "sfx", "--intent", "whoosh", "--project", tmp, "--json", ...extraArgs],
{ env: { HOME: tmp, PATH: tmp } },
);
assert.equal(result.status, 0, result.stderr);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.provenance.provider, "bundled.sfx");
assert.equal(parsed.advisory, undefined);
cleanup();
}
});
test("human bundled fallback prints the install hint once", () => {
setup();
const result = spawnResolve(["--type", "sfx", "--intent", "whoosh", "--project", tmp], {
env: { HOME: tmp, PATH: tmp },
});
assert.equal(result.status, 0, result.stderr);
assert.equal(
result.stderr.match(/Install the CLI from https:\/\/developers\.heygen\.com\/cli/g)?.length,
1,
);
assert.match(result.stdout, /resolved sfx_001/);
cleanup();
});
test("project manifest hit skips providers", () => {
setup();
const record = makeRecord({ provenance: { prompt: "cached query", provider: "test" } });
appendRecord(tmp, record);
const filePath = join(tmp, record.path);
mkdirSync(join(filePath, ".."), { recursive: true });
writeFileSync(filePath, "cached audio");
const out = runResolve(["--type", "bgm", "--intent", "cached query", "--project", tmp, "--json"]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.id, "bgm_001");
assert.equal(parsed._source, "cached");
cleanup();
});
test("entity hit matches across icon/image (figma-imported brand marks)", () => {
setup();
const record = makeRecord({
id: "image_001",
type: "image",
path: ".media/images/image_001.svg",
description: "Acme logo",
entity: "Acme logo",
provenance: { source: "figma", fileKey: "KEY", nodeId: "1:2", version: "1", format: "svg" },
});
delete record.duration;
appendRecord(tmp, record);
const filePath = join(tmp, record.path);
mkdirSync(join(filePath, ".."), { recursive: true });
writeFileSync(filePath, "<svg/>");
const out = runResolve([
"--type",
"icon",
"--intent",
"acme brand mark",
"--entity",
"Acme logo",
"--project",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.id, "image_001");
assert.equal(parsed._source, "cached");
cleanup();
});
// --- auth_method provenance (U6) ---
test("manifest hit for an OAuth-credentialed heygen resolve surfaces authMethod: oauth", () => {
setup();
const record = makeRecord({
id: "voice_001",
type: "voice",
path: ".media/audio/voice/voice_001.wav",
provenance: { provider: "heygen.tts", authMethod: "oauth", prompt: "oauth voice" },
});
appendRecord(tmp, record);
const filePath = join(tmp, record.path);
mkdirSync(join(filePath, ".."), { recursive: true });
writeFileSync(filePath, "cached voice");
const out = runResolve([
"--type",
"voice",
"--intent",
"oauth voice",
"--project",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.provenance.authMethod, "oauth");
cleanup();
});
test("manifest hit for an API-key-credentialed heygen resolve surfaces authMethod: api_key", () => {
setup();
const record = makeRecord({
id: "voice_001",
type: "voice",
path: ".media/audio/voice/voice_001.wav",
provenance: { provider: "heygen.tts", authMethod: "api_key", prompt: "api key voice" },
});
appendRecord(tmp, record);
const filePath = join(tmp, record.path);
mkdirSync(join(filePath, ".."), { recursive: true });
writeFileSync(filePath, "cached voice");
const out = runResolve([
"--type",
"voice",
"--intent",
"api key voice",
"--project",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.provenance.authMethod, "api_key");
cleanup();
});
test("manifest hit for a non-heygen provider omits authMethod entirely", () => {
setup();
const record = makeRecord({
id: "logo_001",
type: "logo",
path: ".media/images/logo_001.svg",
provenance: { provider: "svgl", prompt: "acme logo" },
});
appendRecord(tmp, record);
const filePath = join(tmp, record.path);
mkdirSync(join(filePath, ".."), { recursive: true });
writeFileSync(filePath, "<svg/>");
const out = runResolve(["--type", "logo", "--intent", "acme logo", "--project", tmp, "--json"]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal("authMethod" in parsed.provenance, false);
cleanup();
});
// --- global cache hit ---
test("global cache hit copies to project and registers", () => {
setup();
const sourceFile = join(tmp, "source.wav");
writeFileSync(sourceFile, "cached globally for resolve");
const record = makeRecord({ provenance: { prompt: "global resolve test" } });
cachePut(sourceFile, record);
const cached = cacheGet("global resolve test", "bgm");
assert.ok(cached);
const projectDir = mkdtempSync(join(tmpdir(), "mu-resolve-proj-"));
const imported = importFromCache(cached, projectDir, "bgm_001", ".media/audio/bgm/bgm_001.wav");
assert.ok(imported);
assert.ok(existsSync(join(projectDir, ".media/audio/bgm/bgm_001.wav")));
appendRecord(projectDir, imported);
regenerateIndex(projectDir);
const manifest = readManifest(projectDir);
assert.equal(manifest.length, 1);
assert.equal(manifest[0].provenance.imported_from, cached.sha);
rmSync(projectDir, { recursive: true, force: true });
cleanup();
});
// --- provider interface ---
test("getProvider returns provider with type", () => {
const p = getProvider("bgm");
assert.equal(p.type, "bgm");
assert.ok(typeof p.search === "function");
});
test("getProvider throws for unknown type", () => {
assert.throws(() => getProvider("unknown_type"), /unknown media type/);
});
// --- freeze ---
test("freezeLocalFile creates parent dirs and copies", () => {
setup();
const src = join(tmp, "src.bin");
writeFileSync(src, "freeze test data");
const dest = join(tmp, "deep/nested/dir/file.bin");
freezeLocalFile(src, dest);
assert.ok(existsSync(dest));
assert.equal(readFileSync(dest, "utf8"), "freeze test data");
cleanup();
});
test("failed remote freeze removes its reserved placeholder", async () => {
setup();
const server = createServer((_req, res) => {
res.writeHead(503);
res.end("unavailable");
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
const binDir = writeFakeHeygen(
`printf '%s\\n' '{"data":[{"id":"asset.jpg","url":"http://127.0.0.1:${port}/asset.jpg"}]}'`,
);
try {
const result = await spawnResolveAsync(
[
"--type",
"image",
"--intent",
"download failure",
"--provider",
"heygen",
"--project",
tmp,
"--json",
],
{ env: { HOME: tmp, PATH: binDir } },
);
assert.equal(result.status, 1, result.stderr);
assert.deepStrictEqual(readdirSync(join(tmp, ".media/images")), []);
assert.deepStrictEqual(readManifest(tmp), []);
} finally {
await new Promise((resolve) => server.close(resolve));
cleanup();
}
});
test("failed URL ingest removes its reserved placeholder", () => {
setup();
const result = spawnResolve([
"--from",
"https://example.invalid/unavailable.jpg",
"--type",
"image",
"--project",
tmp,
"--json",
]);
assert.equal(result.status, 1, result.stderr);
assert.deepStrictEqual(readdirSync(join(tmp, ".media/images")), []);
assert.deepStrictEqual(readManifest(tmp), []);
cleanup();
});
// --- adopt existing assets ---
test("--adopt registers existing assets/ files", () => {
setup();
mkdirSync(join(tmp, "assets/bgm"), { recursive: true });
mkdirSync(join(tmp, "assets/icons"), { recursive: true });
writeFileSync(join(tmp, "assets/bgm/track.mp3"), "fake mp3");
writeFileSync(join(tmp, "assets/icons/logo.svg"), "fake svg");
const out = runResolve(["--adopt", "--project", tmp, "--json"]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.adopted, 2);
assert.ok(parsed.assets.some((a) => a.path === "assets/bgm/track.mp3"));
assert.ok(parsed.assets.some((a) => a.path === "assets/icons/logo.svg"));
const manifest = readManifest(tmp);
assert.equal(manifest.length, 2);
cleanup();
});
test("--adopt skips already-registered assets", () => {
setup();
mkdirSync(join(tmp, "assets/bgm"), { recursive: true });
writeFileSync(join(tmp, "assets/bgm/track.mp3"), "fake mp3");
runResolve(["--adopt", "--project", tmp, "--json"]);
const out = runResolve(["--adopt", "--project", tmp, "--json"]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.adopted, 0);
const manifest = readManifest(tmp);
assert.equal(manifest.length, 1);
cleanup();
});
test("resolve finds existing unregistered asset before hitting providers", () => {
setup();
mkdirSync(join(tmp, "assets/bgm"), { recursive: true });
writeFileSync(join(tmp, "assets/bgm/ambient-track.mp3"), "existing bgm");
const out = runResolve([
"--type",
"bgm",
"--intent",
"ambient track",
"--project",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.path, "assets/bgm/ambient-track.mp3");
assert.equal(parsed._source, "existing");
cleanup();
});
// --- CLI interface ---
test("--help exits 0", () => {
const out = runResolve(["--help"]);
assert.ok(out.includes("media-use resolve"));
assert.ok(out.includes("--type"));
assert.ok(out.includes("--for"));
assert.ok(out.includes("--analyze"));
assert.ok(out.includes("--from"));
assert.ok(out.includes("--local-only"));
assert.ok(out.includes("--stats"));
});
test("--from registers a derived video as documented", () => {
setup();
const source = join(tmp, "derived.mp4");
writeFileSync(source, "derived video bytes");
const out = runResolve(["--from", source, "--type", "video", "--project", tmp, "--json"]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.type, "video");
assert.match(parsed.path, /^\.media\/video\/video_001\.mp4$/);
assert.equal(readManifest(tmp)[0]?.type, "video");
cleanup();
});
test("--from type error lists video exactly once", () => {
const result = spawnResolve(["--from", "missing.mp4"]);
assert.equal(result.status, 2);
assert.match(result.stderr, /--from requires --type \(one of:/);
assert.equal(result.stderr.match(/\bvideo\b/g)?.length, 1);
});
test("--from uses .mp4 as the default video extension", () => {
setup();
const source = join(tmp, "extensionless-video");
writeFileSync(source, "video bytes");
const out = runResolve(["--from", source, "--type", "video", "--project", tmp, "--json"]);
const parsed = JSON.parse(out.trim());
assert.match(parsed.path, /^\.media\/video\/video_001\.mp4$/);
cleanup();
});
test("--avatar-id/--voice-id parse as real CLI flags (regression guard: docs promise them, parseArgs must not reject them)", () => {
setup();
const result = spawnResolve(
[
"--type",
"video",
"--intent",
"regression guard",
"--local-only",
"--avatar-id",
"avatar-override",
"--voice-id",
"voice-override",
"--project",
tmp,
],
{ stdio: "pipe" },
);
assert.doesNotMatch(result.stderr || "", /ERR_PARSE_ARGS_UNKNOWN_OPTION/);
cleanup();
});
test("unknown type error lists grade and lut", () => {
try {
runResolve(["--type", "bogus", "--intent", "x"], { stdio: "pipe" });
assert.fail("should have exited");
} catch (err) {
assert.equal(err.status, 2);
assert.match(String(err.stderr), /known: .*grade.*lut/);
}
});
test("missing required args exits 2", () => {
try {
runResolve([], { stdio: "pipe" });
assert.fail("should have exited");
} catch (err) {
assert.equal(err.status, 2);
}
});
test("--json returns error JSON on stub provider failure", () => {
setup();
try {
runResolve(["--type", "bgm", "--intent", "stub fail", "--project", tmp, "--json"], {
stdio: "pipe",
});
assert.fail("should have exited");
} catch (err) {
const output = err.stdout || "";
const parsed = JSON.parse(output.trim());
assert.equal(parsed.ok, false);
assert.ok(parsed.error.includes("no provider"));
}
cleanup();
});
test("--doctor --json reports dependency checks and top-level ok requires ffmpeg and ffprobe", () => {
const result = spawnResolve(["--doctor", "--json"]);
assert.match(result.stdout.trim(), /^\{/);
assert.equal(result.stderr, "");
assert.ok(result.status === 0 || result.status === 1);
const parsed = JSON.parse(result.stdout.trim());
assert.ok(Array.isArray(parsed.checks));
const expected = [
"bundled SFX assets",
"heygen on PATH",
"heygen version",
"heygen authenticated",
"ffmpeg on PATH",
"ffprobe on PATH",
"node version",
];
const byName = new Map(parsed.checks.map((check) => [check.name, check]));
for (const name of expected) {
assert.ok(byName.has(name), `missing check: ${name}`);
const check = byName.get(name);
assert.equal(typeof check.ok, "boolean", `${name}.ok`);
assert.equal(typeof check.detail, "string", `${name}.detail`);
assert.ok("fix" in check, `${name}.fix`);
}
const ffmpeg = byName.get("ffmpeg on PATH");
const ffprobe = byName.get("ffprobe on PATH");
const bundledSfx = byName.get("bundled SFX assets");
assert.match(bundledSfx.detail, /bundled SFX assets available/);
const strictOk = bundledSfx.ok && ffmpeg.ok && ffprobe.ok;
assert.equal(parsed.ok, strictOk);
assert.equal(result.status, strictOk ? 0 : 1);
});
test("one-line output format matches contract", () => {
setup();
const record = makeRecord({ provenance: { prompt: "format test", provider: "test" } });
appendRecord(tmp, record);
const filePath = join(tmp, record.path);
mkdirSync(join(filePath, ".."), { recursive: true });
writeFileSync(filePath, "format check");
const out = runResolve(["--type", "bgm", "--intent", "format test", "--project", tmp]);
assert.match(out.trim(), /^resolved bgm_001 → .media\/audio\/bgm\/bgm_001\.wav \(bgm/);
cleanup();
});
// --- color grading ---
test("grade resolves a preset-only look with no cube file", () => {
setup();
const out = runResolve([
"--type",
"grade",
"--intent",
"warm daylight",
"--project",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.type, "grade");
assert.equal(parsed.grading.preset, "warm-daylight");
assert.equal(parsed.grading.lut, undefined);
assert.equal(parsed.path, undefined);
assert.equal(readManifest(tmp).length, 1);
cleanup();
});
test("grade resolves a library LUT look and freezes a validated cube", () => {
setup();
const out = runResolve([
"--type",
"grade",
"--intent",
"teal orange blockbuster",
"--project",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.match(parsed.grading.lut.src, /^\.media\/luts\/grade_001\.cube$/);
assert.equal(parsed.path, parsed.grading.lut.src);
assert.ok(existsSync(join(tmp, parsed.grading.lut.src)));
assert.equal(validateCubeFile(join(tmp, parsed.grading.lut.src)).ok, true);
cleanup();
});
test("smart grade merges measured adjust and keeps stdout valid JSON", () => {
if (!HAS_FFMPEG) {
console.log(" (skipped: ffmpeg not on PATH)");
return;
}
setup();
const frame = makeFrame(tmp, "under.png", "0x202020");
const proc = spawnResolve([
"--type",
"grade",
"--intent",
"warm cinematic",
"--for",
frame,
"--project",
tmp,
"--json",
]);
assert.equal(proc.status, 0, proc.stderr);
const parsed = JSON.parse(proc.stdout);
assert.equal(parsed.ok, true);
assert.ok(parsed.grading.adjust.exposure > 0, "under-exposed frame should suggest lift");
assert.match(proc.stderr, /media-use: measured/);
cleanup();
});
test("grade analysis returns evidence without recording a candidate", () => {
if (!HAS_FFMPEG) {
console.log(" (skipped: ffmpeg not on PATH)");
return;
}
setup();
const frame = makeFrame(tmp, "under.png", "0x202020");
const proc = spawnResolve([
"--type",
"grade",
"--for",
frame,
"--analyze",
"--project",
tmp,
"--json",
]);
assert.equal(proc.status, 0, proc.stderr);
const parsed = JSON.parse(proc.stdout);
assert.equal(parsed.ok, true);
assert.equal(parsed.type, "grade-analysis");
assert.ok(parsed.adjust.exposure > 0, "under-exposed frame should suggest lift");
assert.ok(parsed.measured.frames > 0);
assert.equal(readManifest(tmp).length, 0);
cleanup();
});
test("emitted grading block survives the core normalizeHfColorGrading contract", () => {
if (!CAN_TSX) {
console.log(" (skipped: tsx / core source unavailable)");
return;
}
setup();
const out = runResolve([
"--type",
"grade",
"--intent",
"teal orange blockbuster",
"--project",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
const normalized = normalizeWithCoreSource(parsed.grading);
assert.equal(normalized.lut.src, parsed.grading.lut.src);
assert.equal(normalized.lut.intensity, parsed.grading.lut.intensity);
assert.equal(normalized.colorSpace, "rec709");
cleanup();
});
test("lut resolves only the frozen cube path", () => {
setup();
const out = runResolve([
"--type",
"lut",
"--intent",
"teal orange blockbuster",
"--project",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.type, "lut");
assert.match(parsed.path, /^\.media\/luts\/lut_001\.cube$/);
assert.equal(parsed.grading, undefined);
assert.equal(validateCubeFile(join(tmp, parsed.path)).ok, true);
cleanup();
});
test("lut --params builds, validates, and freezes a cube", () => {
setup();
const params = { contrast: 0.2, temperature: -0.3 };
const out = runResolve(["-t", "lut", "--params", JSON.stringify(params), "-p", tmp, "--json"]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.type, "lut");
assert.match(parsed.path, /^\.media\/luts\/lut_001\.cube$/);
assert.equal(parsed.description, "custom parametric lut");
assert.equal(parsed.provenance.provider, "cube_lut.builder");
assert.deepEqual(parsed.provenance.params, params);
assert.ok(existsSync(join(tmp, parsed.path)));
assert.equal(validateCubeFile(join(tmp, parsed.path)).ok, true);
cleanup();
});
test("grade --params returns a grading block with a frozen valid cube", () => {
setup();
const out = runResolve([
"-t",
"grade",
"--params",
JSON.stringify({ exposure: 0.2 }),
"-p",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.type, "grade");
assert.equal(parsed.grading.intensity, 1);
assert.match(parsed.grading.lut.src, /^\.media\/luts\/grade_001\.cube$/);
assert.equal(parsed.lut.src, parsed.grading.lut.src);
assert.equal(parsed.path, parsed.grading.lut.src);
assert.equal(validateCubeFile(join(tmp, parsed.grading.lut.src)).ok, true);
cleanup();
});
test("--params malformed JSON errors cleanly without freezing a cube", () => {
setup();
const proc = spawnResolve(["-t", "lut", "--params", "{not json", "-p", tmp, "--json"]);
assert.equal(proc.status, 1, proc.stderr);
const parsed = JSON.parse(proc.stdout);
assert.equal(parsed.ok, false);
assert.match(parsed.error, /^invalid --params JSON:/);
assert.equal(readManifest(tmp).length, 0);
assert.equal(existsSync(join(tmp, ".media/luts")), false);
cleanup();
});
// buildCube clamps every accepted parameter and resolve.mjs does not expose
// the size argument, so there is no CLI input that can make --params emit a
// structurally invalid cube. Invalid cube cleanup is covered through --from.
test("--from rejects invalid lut cube without registering or leaving a frozen file", () => {
setup();
const broken = join(tmp, "broken.cube");
writeFileSync(broken, "LUT_3D_SIZE 999\n");
const proc = spawnResolve(["--from", broken, "-t", "lut", "-p", tmp, "--json"]);
assert.equal(proc.status, 1, proc.stderr);
const parsed = JSON.parse(proc.stdout);
assert.equal(parsed.ok, false);
assert.match(parsed.error, /^ingested LUT is invalid: LUT_3D_SIZE 999 exceeds max 64/);
assert.equal(readManifest(tmp).length, 0);
const lutDir = join(tmp, ".media/luts");
assert.deepEqual(existsSync(lutDir) ? readdirSync(lutDir) : [], []);
cleanup();
});
test("grade miss exits explicitly with no partial file", () => {
setup();
const missIntent = `zqxv imaginary neutron ${process.pid}`;
try {
runResolve(["--type", "grade", "--intent", missIntent, "--project", tmp, "--json"]);
assert.fail("should have exited");
} catch (err) {
assert.equal(err.status, 1);
const parsed = JSON.parse(String(err.stdout));
assert.equal(parsed.ok, false);
assert.match(parsed.error, /no local color grade could resolve/);
assert.equal(readManifest(tmp).length, 0);
assert.equal(existsSync(join(tmp, ".media/luts")), false);
}
cleanup();
});
test("identical grade resolve hits the project cache without re-freezing", () => {
setup();
const first = JSON.parse(
runResolve([
"--type",
"grade",
"--intent",
"teal orange blockbuster",
"--project",
tmp,
"--json",
]),
);
const second = JSON.parse(
runResolve([
"--type",
"grade",
"--intent",
"teal orange blockbuster",
"--project",
tmp,
"--json",
]),
);
assert.equal(second._source, "cached");
assert.equal(second.id, first.id);
assert.equal(second.path, first.path);
assert.equal(readManifest(tmp).length, 1);
cleanup();
});
// --- telemetry isolation (U7) ---
// Every other test relies on runResolve/spawnResolve's default DO_NOT_TRACK:
// "1" to keep track() a no-op. That default is fragile on its own (a future
// call site or test could forget to set it), so telemetry.mjs also exposes a
// MEDIA_USE_TELEMETRY_HOST override read at the point the POST URL is built.
// This test proves that seam actually intercepts a real event end to end: a
// resolve that reaches track("media_use_resolve", ...) with tracking allowed
// posts to a local HTTP server instead of production, and the server actually
// receives it (not just "nothing happened because nothing was listening").
// Spawns a real resolve that hits the manifest for `provider`, intercepts the
// telemetry POST it makes, and hands back the media_use_resolve event actually
// sent. Nothing is stubbed: the CLI runs as its own process, telemetry.mjs builds
// the URL, and a local server reads the payload off the wire.
async function captureResolveEvent({ provider, type = "bgm", intent }) {
const received = [];
const server = createServer((req, res) => {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
try {
received.push(JSON.parse(body));
} catch {
// ignore malformed body; callers assert on empty `received`
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end("{}");
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
const sandboxHome = mkdtempSync(join(tmpdir(), "mu-resolve-telemetry-home-"));
try {
// The record's type must match the --type below, otherwise the manifest
// never matches, the cascade calls a live provider, and the run fails for
// reasons that have nothing to do with the tier.
const record = makeRecord({
id: `${type}_tier_001`,
type,
path: `.media/audio/${type}/${type}_tier_001.wav`,
provenance: { prompt: intent, provider },
});
appendRecord(tmp, record);
const filePath = join(tmp, record.path);
mkdirSync(join(filePath, ".."), { recursive: true });
writeFileSync(filePath, "telemetry seam audio");
// Override this one invocation's env only: allow tracking (DO_NOT_TRACK
// default flipped off), sandbox HOME so anonymousId()/showTelemetryNotice()
// never touch the real developer machine, and point the host at the local
// server. HEYGEN_CONFIG_DIR is sandboxed too -- runResolve's env is
// {...process.env, ...env}, so a developer with that var set to a real
// credentials dir would otherwise have heygenAccountDistinctId() read
// their real email into this test's local-server payload despite HOME
// being sandboxed (HEYGEN_CONFIG_DIR, not HOME, resolves the credentials
// path). Every other test in this file keeps its untouched default env.
runResolve(["--type", type, "--intent", intent, "--project", tmp, "--json"], {
env: {
DO_NOT_TRACK: "0",
HYPERFRAMES_NO_TELEMETRY: "0",
CI: "",
NODE_ENV: "test",
HOME: sandboxHome,
HEYGEN_CONFIG_DIR: join(sandboxHome, ".heygen"),
MEDIA_USE_TELEMETRY_HOST: `http://127.0.0.1:${port}`,
},
});
// runResolve blocks synchronously (execFileSync) until the child exits, which
// pauses this process's own event loop for that whole span -- the child's
// request to our local server sits accepted-but-unprocessed in the kernel
// backlog until control returns here. Poll briefly to let the event loop
// drain it rather than asserting before the server has had a turn to run.
for (let i = 0; i < 100 && received.length === 0; i++) {
await new Promise((resolve) => setTimeout(resolve, 20));
}
} finally {
await new Promise((resolve) => server.close(resolve));
rmSync(sandboxHome, { recursive: true, force: true });
}
assert.ok(received.length > 0, "expected the local telemetry server to receive a POST");
const event = received[0].batch.find((e) => e.event === "media_use_resolve");
assert.ok(event, "expected a media_use_resolve event in the intercepted batch");
return event;
}
test("track() posts to MEDIA_USE_TELEMETRY_HOST when set, proving real interception", async () => {
setup();
try {
const event = await captureResolveEvent({ provider: "test", intent: "telemetry seam test" });
assert.equal(event.properties.provider, "test");
assert.equal(event.properties.type, "bgm");
// "test" is not a declared registry provider, so the tier is absent rather
// than guessed, the same sparseness rule auth_method follows.
assert.equal(
"provider_tier" in event.properties && event.properties.provider_tier !== undefined,
false,
"an undeclared provider must not be assigned a cost tier",
);
} finally {
cleanup();
}
});
// The registry-derived tier has to survive the whole path -- registry lookup,
// result(), track(), JSON body -- not just a unit call to providerTierFor. Each
// case names a provider the registry declares at a different tier and asserts the
// tier that actually reaches the wire.
for (const [provider, type, expected] of [
["heygen.tts", "voice", "network_paid"],
["heygen.audio.sounds", "bgm", "network_free"],
["bundled.sfx", "sfx", "local"],
]) {
test(`a resolve won by ${provider} sends provider_tier: ${expected}`, async () => {
setup();
try {
const event = await captureResolveEvent({
provider,
type,
intent: `tier seam ${provider}`,
});
assert.equal(event.properties.provider, provider);
assert.equal(
event.properties.provider_tier,
expected,
`${provider} must reach the wire as ${expected}`,
);
// The tier is derived from the registry and the auth method from the
// credential state; they must not become entangled. A non-heygen provider
// carries a tier and no auth method, whatever credentials exist locally.
if (!provider.startsWith("heygen."))
assert.equal(
event.properties.auth_method,
undefined,
"a non-heygen provider must carry a tier without an auth method",
);
} finally {
cleanup();
}
});
}
// --- run ---
async function main() {
console.log("media-use · resolve engine tests\n");
let passed = 0;
let failed = 0;
for (const { name, fn } of tests) {
try {
await fn();
passed++;
console.log(` \x1b[32m✓\x1b[0m ${name}`);
} catch (err) {
failed++;
console.log(` \x1b[31m✗\x1b[0m ${name}`);
console.log(` ${err.message}`);
}
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main();
scripts/transcribe.mjs#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import {
existsSync,
readFileSync,
writeFileSync,
copyFileSync,
renameSync,
mkdtempSync,
rmSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { basename, extname, join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { mergeTokensToWords } from "./lib/parakeet-words.mjs";
import { track } from "./lib/telemetry.mjs";
import { resolveNpxInvocation } from "./lib/npx-sync.mjs";
// The DEFAULT local transcription path. Prefers NVIDIA Parakeet-TDT via
// parakeet-mlx, which beats whisper.cpp on the Open ASR Leaderboard (~6.05% vs
// 7.44% avg WER, and 4.73% vs 5.96% on noisy test-other) and is 5-10x faster
// with native punctuation. Emits { text, words:[{text,start,end}] } (word
// timestamps merged from Parakeet's sub-word tokens) for transcript-cut /
// captions / the audio engine.
//
// Parakeet v3 covers English + 25 European languages. For other languages, or
// when parakeet-mlx is not installed, it falls back to whisper.cpp via
// `hyperframes transcribe` (99 languages; the CLI resolves/builds whisper.cpp
// on first use — it is not bundled). `--engine` forces one.
const { values: args } = parseArgs({
options: {
input: { type: "string", short: "i" },
out: { type: "string", short: "o" },
engine: { type: "string", default: "auto" }, // auto | parakeet | whisper
model: { type: "string", default: "mlx-community/parakeet-tdt-0.6b-v3" },
json: { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
},
strict: true,
});
if (args.help) {
console.log(`media-use transcribe: better-than-whisper local ASR (Parakeet), whisper.cpp fallback
Usage:
node transcribe.mjs --input audio.wav [--out audio.transcribe.json] [--engine auto|parakeet|whisper]
Parakeet (default) beats whisper.cpp on accuracy + speed for English/European
languages; whisper.cpp (99 languages) is the fallback. Install Parakeet once:
uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx`);
process.exit(0);
}
if (!args.input) {
console.error("error: --input is required");
process.exit(2);
}
const inputPath = resolve(args.input);
if (!existsSync(inputPath)) {
console.error(`error: input not found: ${inputPath}`);
process.exit(2);
}
const outPath = resolve(
args.out || `${inputPath.slice(0, -extname(inputPath).length)}.transcribe.json`,
);
// Locate the parakeet-mlx runner the same way the CLI does: env override, then
// the documented ~/.venvs/parakeet install, then PATH. Checking the venv (not
// just PATH) is what keeps a user who followed the install docs verbatim from
// silently falling through to whisper. Returns the runner path, or null.
function resolveParakeet() {
for (const p of [
process.env.HYPERFRAMES_PARAKEET,
join(homedir(), ".venvs", "parakeet", "bin", "parakeet-mlx"),
]) {
if (p && existsSync(p)) return p;
}
try {
execFileSync("parakeet-mlx", ["--help"], {
stdio: ["ignore", "ignore", "ignore"],
timeout: 20000,
});
return "parakeet-mlx";
} catch {
return null;
}
}
// Write via a sibling temp + atomic rename so a SIGKILL mid-write can't leave a
// truncated transcript at outPath (downstream reads it as valid JSON).
function atomicWrite(target, data) {
const tmp = `${target}.tmp-${process.pid}`;
writeFileSync(tmp, data);
renameSync(tmp, target);
}
function report(engine, wordCount) {
if (args.json) console.log(JSON.stringify({ ok: true, out: outPath, engine, words: wordCount }));
else
console.log(
`transcribed ${basename(inputPath)} -> ${outPath}${wordCount != null ? ` (${wordCount} words,` : " ("}${engine})`,
);
}
function runParakeet(runner) {
const workDir = mkdtempSync(join(tmpdir(), "media-use-asr-"));
try {
execFileSync(
runner,
[inputPath, "--model", args.model, "--output-format", "json", "--output-dir", workDir],
{ stdio: ["ignore", "pipe", "pipe"], timeout: 1_800_000 },
);
const jsonPath = join(workDir, `${basename(inputPath, extname(inputPath))}.json`);
if (!existsSync(jsonPath)) throw new Error("parakeet produced no JSON");
const merged = mergeTokensToWords(JSON.parse(readFileSync(jsonPath, "utf8")));
atomicWrite(outPath, JSON.stringify(merged, null, 2));
report("parakeet", merged.words.length);
} finally {
rmSync(workDir, { recursive: true, force: true });
}
}
// whisper.cpp via the hyperframes CLI (fetched/built on first use — see
// SKILL.md): writes transcript.json into --dir; relocate to --out.
function runWhisper() {
const workDir = mkdtempSync(join(tmpdir(), "media-use-whisper-"));
try {
// On Windows a bare "npx" is npx.cmd, which execFileSync cannot exec
// (spawnSync npx ENOENT) — resolveNpxInvocation reroutes it through
// node + npx-cli.js (and throws actionably when it can't), same
// mechanism as the audio engine's TTS spawns.
const resolved = resolveNpxInvocation(
["hyperframes", "transcribe", inputPath, "--dir", workDir],
{ stdio: ["ignore", "pipe", "pipe"], timeout: 1_800_000 },
);
execFileSync(resolved.cmd, resolved.args, resolved.opts);
const produced = join(workDir, "transcript.json");
if (!existsSync(produced)) throw new Error("whisper produced no transcript.json");
const tmp = `${outPath}.tmp-${process.pid}`;
copyFileSync(produced, tmp);
renameSync(tmp, outPath); // atomic publish
let words;
try {
const t = JSON.parse(readFileSync(outPath, "utf8"));
words = Array.isArray(t?.words) ? t.words.length : undefined;
} catch {
/* leave undefined */
}
report("whisper", words);
} finally {
rmSync(workDir, { recursive: true, force: true });
}
}
try {
const parakeetBin = resolveParakeet();
const engine =
args.engine === "parakeet" || args.engine === "whisper"
? args.engine
: parakeetBin
? "parakeet"
: "whisper";
if (engine === "parakeet") {
if (!parakeetBin) {
throw new Error(
"parakeet-mlx not found (checked $HYPERFRAMES_PARAKEET, ~/.venvs/parakeet, and PATH). Install: uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx (or use --engine whisper)",
);
}
runParakeet(parakeetBin);
} else {
runWhisper();
}
await track("media_use_transcribe", { engine });
} catch (err) {
if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
else console.error(`error: transcription failed: ${err.message}`);
process.exit(1);
}
scripts/transcript-cut.mjs#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, extname, join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { compileCutList } from "./lib/cutlist.mjs";
import { track } from "./lib/telemetry.mjs";
const { values: args } = parseArgs({
options: {
input: { type: "string" },
transcript: { type: "string" },
remove: { type: "string" },
"remove-words": { type: "string" },
"remove-fillers": { type: "string" },
"cut-silence": { type: "string" },
keep: { type: "string" },
copy: { type: "boolean", default: false },
plan: { type: "boolean", default: false },
out: { type: "string" },
json: { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
},
strict: true,
});
if (args.help) {
console.log(`media-use transcript-cut — compile transcript edits into video cuts
Usage:
node transcript-cut.mjs --input in.mp4 --transcript transcript.json --remove "12-15" --out out.mp4
Options:
--input Source video/audio file
--transcript JSON word transcript, array or { words: [...] }
--remove Time ranges to remove, seconds: a-b,c-d
--remove-words Word-index ranges to remove: 12-18,40-41
--remove-fillers Comma list of filler words to remove
--cut-silence Remove inter-word gaps longer than this many seconds
--keep Inverse mode: direct kept ranges, mutually exclusive with removal
--copy Use stream copy for faster, keyframe-snapped cuts
--plan Print kept segment JSON and exit without ffmpeg
--out Output file
--json Output JSON status
--help, -h Show this help`);
process.exit(0);
}
try {
run();
await track("media_use_transcript_cut", {
mode: args.plan ? "plan" : "encode",
remove_fillers: !!args["remove-fillers"],
cut_silence: !!args["cut-silence"],
ranges: !!args.remove,
keep: !!args.keep,
});
} catch (err) {
if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
else console.error(`error: ${err.message}`);
process.exit(1);
}
function run() {
if (!args.transcript) throw new Error("--transcript is required");
const transcript = JSON.parse(readFileSync(resolve(args.transcript), "utf8"));
const segments = compileCutList(transcript, {
remove: args.remove,
removeWords: args["remove-words"],
removeFillers: args["remove-fillers"],
cutSilence: args["cut-silence"],
keep: args.keep,
});
if (args.plan) {
console.log(JSON.stringify(segments));
return;
}
if (!args.input || !args.out)
throw new Error("--input and --out are required unless --plan is set");
if (segments.length === 0) throw new Error("cut list has no kept segments");
const inputPath = resolve(args.input);
const outPath = resolve(args.out);
mkdirSync(dirname(outPath), { recursive: true });
const tmpDir = mkdtempSync(join(tmpdir(), "media-use-cut-"));
const keptSeconds = sumDurations(segments);
const totalSeconds = probeDuration(inputPath);
try {
const parts = segments.map((segment, index) => {
const out = join(
tmpDir,
`segment-${String(index).padStart(4, "0")}${extname(outPath) || ".mp4"}`,
);
cutSegment(inputPath, segment, out, Boolean(args.copy));
return out;
});
const listPath = join(tmpDir, "list.txt");
writeFileSync(
listPath,
parts.map((part) => `file '${escapeConcatPath(part)}'`).join("\n") + "\n",
);
// Encode to a sibling temp (same extension so ffmpeg picks the right muxer),
// then atomic-rename so a SIGKILL mid-encode can't leave a truncated outPath.
const tmpOut = `${outPath}.part${extname(outPath) || ".mp4"}`;
execFileSync(
"ffmpeg",
["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", tmpOut],
{
stdio: "ignore",
},
);
renameSync(tmpOut, outPath);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
// Stream copy can only cut on keyframes; on sparse-keyframe footage the snap
// can silently swallow the whole cut. Compare, then surface the drift in BOTH
// the stderr warning (human) and the --json result (pipelines).
let copyDrift = null;
if (args.copy) {
const outSeconds = probeDuration(outPath);
if (Math.abs(outSeconds - keptSeconds) > 1) {
copyDrift = { produced_s: round3(outSeconds), expected_s: round3(keptSeconds) };
if (!args.json) {
console.error(
`warning: --copy keyframe snapping produced ${round3(outSeconds)}s instead of ${round3(keptSeconds)}s kept; drop --copy for frame-accurate cuts`,
);
}
}
}
if (args.json) {
console.log(
JSON.stringify({
ok: true,
input: inputPath,
out: outPath,
segments,
kept_s: round3(keptSeconds),
total_s: round3(totalSeconds),
...(copyDrift && { copy_drift: copyDrift }),
}),
);
return;
}
console.log(
`cut ${inputPath} -> ${outPath} (${segments.length} segments, ${fmt(keptSeconds)}s kept of ${fmt(
totalSeconds,
)}s)`,
);
console.log(`next: resolve --from ${outPath} --type <type>`);
}
function cutSegment(inputPath, segment, outPath, copy) {
const argv = [
"-y",
"-nostdin",
"-ss",
fmt(segment.start),
"-i",
inputPath,
"-to",
fmt(segment.end - segment.start),
];
if (copy) {
argv.push("-c", "copy", "-avoid_negative_ts", "make_zero");
} else {
argv.push(...encodeArgsFor(extname(outPath).toLowerCase()));
}
argv.push(outPath);
execFileSync("ffmpeg", argv, { stdio: "ignore" });
}
// Codec set per output container. Audio-only outputs must not get the
// video-centric aac/x264 set (aac inside .wav breaks timing entirely).
function encodeArgsFor(ext) {
if (ext === ".wav") return ["-c:a", "pcm_s16le"];
if (ext === ".mp3") return ["-c:a", "libmp3lame", "-q:a", "2"];
if (ext === ".m4a" || ext === ".aac") return ["-c:a", "aac"];
if (ext === ".flac") return ["-c:a", "flac"];
return [
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"18",
"-c:a",
"aac",
"-movflags",
"+faststart",
];
}
function probeDuration(filePath) {
const raw = execFileSync(
"ffprobe",
[
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
"--",
filePath,
],
{ encoding: "utf8" },
);
const duration = Number(raw.trim());
if (!Number.isFinite(duration) || duration <= 0)
throw new Error(`could not probe duration: ${filePath}`);
return duration;
}
function escapeConcatPath(filePath) {
return filePath.replace(/'/g, "'\\''");
}
function sumDurations(segments) {
return segments.reduce((sum, segment) => sum + (segment.end - segment.start), 0);
}
function fmt(n) {
return round3(n)
.toFixed(3)
.replace(/\.?0+$/, "");
}
function round3(n) {
return Math.round(Number(n) * 1000) / 1000;
}
SKILL.md--- name: media-use description: Agent Media OS, the single skill for every media need in a HyperFrames project. Resolve BGM, SFX, image, icon, brand logo, voice, color grade, or LUT into a frozen local file or paste-ready block + ledger record (one verb, `resolve`); generate via TTS / music / image models when the catalog misses; produce voiceover, transcription, captions, and background removal through one shared audio engine; operate on media (cut / reframe / transform); and reuse assets across projects. Also use for vague feedback that real footage looks dark, flat, boring, should feel retro/camcorder/print/ASCII, needs privacy, or needs a media reveal. --- # media-use The media OS for HyperFrames: resolve · generate · operate · remember — every media type, one skill, zero context noise. First run: install and sign in to the `heygen` CLI (the free-usage path), then verify with `node <SKILL_DIR>/scripts/resolve.mjs --doctor`. Setup and providers: `references/setup-providers.md`. ## Resolve — the one verb ```bash node <SKILL_DIR>/scripts/resolve.mjs --type <type> --intent "<description>" --project <dir> ``` Returns one line: `resolved <id> → <path> (<type>, <metadata>)`. All search noise stays on disk. | Type | One-line intent | | ------- | ----------------------------------------------------------------------------------- | | `bgm` | background music (HeyGen catalog, 10k+ tracks) | | `sfx` | sound effects (bundled 19-file library + catalog) | | `image` | photos, backgrounds (HeyGen asset search, 75k+ vectors) | | `icon` | icons, symbols (transparent) | | `logo` | official brand marks (svgl → simple-icons → GitHub avatar → favicon; never redrawn) | | `voice` | TTS voiceover (HeyGen free-usage path; optional local Kokoro) | | `grade` | measured correction candidate; broad polish/stylization follows Media Treatments | | `lut` | user-provided or explicitly chosen reusable validated `.cube` file | Before resolving fresh, list reusable candidates with `--candidates` and judge fit yourself — reuse rules, all flags, ingest (`--from`), and adopt are in `references/resolve.md`. ## Treat broad visual feedback as media intent When a user explicitly asks to fix, polish, stylize, obscure, emphasize, or reveal photographic media, read `references/media-treatments.md` even if they do not name color grading or an effect. Inspect the real `<img>`/`<video>`, choose one primary intent, then use deterministic persistence and verification. Use a matching recipe as an optional tested seed, or inspect `hyperframes media-treatment --capabilities --json`, then request one relevant family/effect with `--capability <id>` and assemble a custom treatment from canonical controls. Never load `--all` for ordinary authoring. A treatment may compose correction, a preset, finishing, compatible shader effects, supported keyframes, and optional Registry overlays. Add only source-justified bounded tuning and compatible parts, never effects merely to make the result look more sophisticated. Persist the final combined payload with `hyperframes media-treatment`. Use one progressively escalating workflow. For video, inspect one labeled early/middle/late contact sheet rather than reading frames separately. Apply one candidate and inspect one after-sheet for ordinary correction or polish. Escalate to individual frames or moving draft evidence only when the result is ambiguous, temporal, stylized, LUT-based, HDR/LOG-sensitive, private, or brand-critical. For ordinary correction or polish, persist the final treatment's preset/adjustment JSON. Do not generate a `.cube` LUT merely to encode exposure, shadows, contrast, or warmth. Use a LUT only when the user supplies one or the selected treatment explicitly owns one. `resolve --type grade --for ... --analyze` is measurement evidence, not permission to replace the chosen treatment with a generated LUT. Do not recreate supported vignette, grain, blur, pixelate, color, or treatment effects with CSS/SVG overlays; that bypasses Studio controls and the canonical preview/render shader path. ## Be proactive — run a media opportunity pass The human usually can't tell which media would lift the piece. You can. When you build or review a composition, do **one** grounded scan and then **ask once** — don't silently add, and don't nag per asset. Surface an opportunity only when a concrete signal is present: | Signal detected | Offer | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | On-screen text / a script with no voiceover | TTS voiceover (audio engine) | | Emoji or a `<div>` styled as an icon | resolve real `icon`s | | Image that is a placeholder, tiny, or upscaled-looking | a better `image` (and/or upscale — see `references/operations.md`) | | Hard scene cuts / transitions with no sound | transition `sfx` | | A piece over ~10s with no music bed | `bgm` | | Footage that reads under/over-exposed or color-cast | a corrective grade (inspect it with `hyperframes media-treatment --selector '#hero' --analyze --json`) | | Photographic media that feels visually flat or off-topic | one specific source-appropriate preset or custom treatment, with the intended target named | | A meaningful media entrance/reveal that feels static | one supported seek-safe treatment animation; preserve color unless the request also justifies a preset | Rules that keep this a help, not nagware: **grounded, not generic** (no signal → no suggestion); **opinionated + concrete** (propose the specific fix with defaults chosen — the human approves **all / some / none**); **once per project** (one consolidated ask; respect "leave it"); **surface, never silently mutate** (color grades especially: propose and preview — a gray-world "correction" ruins an intentional sunset or neon look). ## Where to look — read only the file your task needs | Task | Read | | ------------------------------------------------------------------------- | -------------------------------- | | resolve / reuse / adopt / ingest, flags, cascade, inventory | `references/resolve.md` | | color grading, LUTs, smart grade (`--for`), grade-compare | `references/grading.md` | | voiceover / TTS, music, SFX, captions, transcription (audio engine) | `references/audio.md` | | cut / reframe / transform existing media, exact error diffusion, HEVC | `references/operations.md` | | source-aware creative treatments, realtime effects, overlays, reveals | `references/media-treatments.md` | | install + auth, provider table, RAM ladders, `--local-only`, `--provider` | `references/setup-providers.md` | | remembered preferences + frozen recipes (user memory) | `references/memory.md` | | ownership matrix, usage stats, telemetry, privacy (maintainer-facing) | `references/meta.md` |