SKILL.md
---
name: short-editing
description: Edits a talking-head take end to end into a paper-style motion design short (1080x1920, also 1:1 and 16:9) with its own render engine (Node + Chrome + ffmpeg, free, offline). Use when the user says edit, cut, captions, subtitles, motion design, montage, "make it a short", render, or gives a recording from video-agent/takes/. Transcribes locally with word timings (whisper.cpp, or ElevenLabs when a key is set), cuts every silence, lays the boards on a textured paper above the camera card (counters, terminals, logos, trees, real screens, HTML motion components), word-by-word captions, a calm music bed, a few designed hits, -14 LUFS, and writes the mp4, an .srt and the post copy.
argument-hint: "<take file or slug> [9:16 | 1:1 | 16:9] [style <name>] [brief]"
allowed-tools: Bash(node *) Bash(ffmpeg *) Bash(ffprobe *) Bash(open video-agent/edits/*)
---
# Short Editing
One raw take in, one finished video out: `video-agent/edits/short-<slug>/output/short-<slug>.mp4`.
The engine ships in this skill folder (`<skill>` below): `<skill>/engine/short.mjs`. It is a deterministic canvas renderer driven by ONE file per short, `source/spec.js`. The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp.
Read before writing a spec:
- [references/shorts-codes.md](references/shorts-codes.md): format, safe zones, hook and pace
- [references/design-system.md](references/design-system.md): palette, motion, paper, what is forbidden
- [references/scene-library.md](references/scene-library.md): every scene and item, every parameter, the time keys
- [references/music.md](references/music.md): the 8 beds, which fits which hook, where to start it
- [references/style.md](references/style.md): the house rules of the edit
- [examples/](examples/README.md): real shorts made with this engine (spec, components, contact sheets). Open the one closest to the request before planning: it is the quality bar.
## How to run it
- Talk in the creator's language (`creator.lang` of the style, else `video-agent/voice.md`, else English). Say in one line what each long step does and how long it takes.
- `<slug>`: lowercase, dashes, no dots or spaces.
- Every command runs from the project root (the folder that holds `video-agent/`).
## 0. Setup (first time only, then a 1 s check)
```bash
node <skill>/engine/setup.mjs --check || node <skill>/engine/setup.mjs
```
It installs what is missing: the engine packages (15 MB), a Chromium when no Chrome is installed (100 MB), whisper.cpp and its speech model (0.5 GB, once), the 8 music beds and the icons (generated on this computer). Tell them the first run takes 3 to 6 minutes. ffmpeg missing: it prints the command to install it (`brew install ffmpeg`, `winget install Gyan.FFmpeg`); run it with their ok, then setup again.
## 1. The take
Use the file they name, else the newest file in `video-agent/takes/`. A URL (YouTube, X, Instagram, Loom, a direct mp4): `yt-dlp -f "bv*+ba/b" --merge-output-format mp4 -o "video-agent/takes/<slug>.%(ext)s" "<url>"` first.
```bash
ffprobe -v error -show_entries format=duration:stream=codec_type,width,height -of compact "<take>"
```
No audio stream: stop and say so. Then create the project:
```bash
node <skill>/engine/short.mjs init video-agent/edits/short-<slug> --source "<take>" [--style <name>]
```
- The style: `style <name>` in the request = `video-agent/styles/<name>/style.json`, passed as `--style <name>` (only when that folder exists: an unknown name stops `init`, so list `video-agent/styles/` instead). Without it, `init` takes the default one (`video-agent/style.json` names it). No style at all: first write `video-agent/styles/default/style.json` = `{"version": 1, "name": "default"}` plus the `style` of the `default` look of `<skill>/assets/looks.json` (system font, camera full width, mono caps captions on a black band), and `video-agent/style.json` = `{"default": "default"}`, then say once that `/studio-setup style` makes it theirs in 2 minutes.
- `init` makes `assets/ files/ source/ output/ stills/`, copies the take to `assets/source.mp4`, frames `cam` on the face, writes the starter `source/spec.js` and snapshots the style into `source/style.json` (engine defaults < style < spec: the spec only sets what differs for this one short). The style's `assets/` (logos, images) are copied into the project.
- Format: `9:16` by default. `1:1` = `format: 'square'` (X feed), `16:9` = `format: 'wide'` in the spec.
## 2. Words
```bash
node <skill>/engine/short.mjs words video-agent/edits/short-<slug> [lang]
```
whisper.cpp on this computer, about real time (a 60 s take, about a minute), free. With an ElevenLabs key (`ELEVENLABS_API_KEY=` or `ELEVEN_KEY=` in `video-agent/.env`, the project `.env` or the environment), ElevenLabs Scribe instead, on its own: it hears every retake and false start that whisper smooths over, and names; a few cents a take. Recommend it once when a take has retakes. Never ask for a key in chat, never print one.
Then `prepare` (step 4) once: it writes `files/words.md`, every word with its SOURCE time and the SHORT time it lands on after the cuts. Fix misheard words from the script with `captions.replace: [['clics cloud', 'skills Claude']]`, never by editing the times.
## 3. The spec
Author `source/spec.js` from `files/words.md`. All times are SOURCE seconds; the engine maps them onto the tightened cut. Start from the starter spec `init` wrote, and from the closest example.
Plan the beats first, in a comment block at the top of the spec:
- **Hook** (first 2 s): readable on the first frame, never blank paper at t=0. It adds to the spoken hook, never repeats it.
- **Something changes every 2 to 4 s**: a new board, a counter that counts, a terminal that types, a logo that lands, a line struck. Each on the exact word it illustrates (`at` = that word's source time).
- **Pick the item from what is said** (all in scene-library): a figure = `counter` (`countFrom`/`countTo`, `strikeAt` when it dies); a command or a prompt = `term` (`typeFrom`, `typeTo`, `out` lines); a folder or a list of parts = `tree`; a tool named = its real `logo`; a result = `check`; a real screen = `screen` (zoom keys + rings on the number they read); a tweet = `tweet`; before / after = two boards or `bars`; the big payoff = `stat` or a board with one huge `text` and an `emph` word.
- **Camera**: `layout.keys: [[t, seam]]`, seam 1160 = split (paper above, camera card below), 1920 = paper alone, 0 = full camera. Full camera only for the direct lines (a story, a reaction, the CTA) and for 5 s or more of SHORT time; a style with a fixed `layout.seam` keeps that one size all along.
- **Every beat is an action**, never an image sitting there. A software idea (source code, privacy, price, speed) is played in a recreated UI: code that scrolls then blacks out line by line, a lock that closes, a price that drops to 0 under a stamp. A platform named with a number is its real interface recreated and animated (the GitHub repo page, the stars curve settling on the real figure). Never a screenshot pasted as an image (even zoomed or circled), a lone logo, a text card or a still illustration: a screenshot is only a reference to rebuild in HTML.
- **The hook gets the most work**: a 2 or 3 act mini story with a reversal, several layers moving together, an impact (stamp + thud) on each keyword, the payoff as a badge. For example: the big apps crush the viewer's app under a REPLACED stamp; on the turning word ("now") it flips, the viewer's app becomes the giant. A hook that places one object and slides it is not enough: rebuild it richer, never tweak it.
- **Motion components**: what the library cannot draw well (a real app in its own UI, an Instagram comment sheet, a notification, a GitHub repo page, a software idea, the hook's mini story, an illustration that acts) becomes an HTML page with `window.setT(t)`, captured into a clip (step 3b). The examples hold a dozen of them to copy.
- **Captions**: on by default, on the seam. `captions.emph` = the words in the accent color, `captions.hide: [[a, b]]` over a board that carries the same words.
- **Music**: one bed from [music.md](references/music.md) picked by the hook's emotion, `music: { bed: '<id>', offset }`. Never the same bed as the previous short.
- **Sound**: `sfx: { swish: 0, accents: 0, extra: [[t, 'thud', -6]] }`: no movement noise, a `thud` or `pop` only on a real payoff. Bigger moments (the hook cut, the payoff, a shatter) take a bundled file from `assets/sfx/` (`zhhh`, `impact-boom`, `riser`, `glass-shatter`...), all in [references/sfx.md](references/sfx.md). Never generate a sound with ElevenLabs unless the creator asks for one the library lacks.
- **Real logos, always**: every tool or app named on screen shows its real icon in `assets/logos/<name>.png` (the App Store icon, the official site or repo), never a letter glyph, never an emoji, never a look-alike. Download it into the project and say from where.
- No em-dashes in any on-screen text.
### 3b. Motion components
One HTML file per component in `source/<name>.html`, 1080 wide (or the card size), every animation a pure function of `t` set by `window.setT(t)` (no CSS animation, no timers), transparent background for an overlay. See [references/sub-agents.md](references/sub-agents.md) for the page contract and the prompt to give a sub-agent (one per component, in parallel, when the harness has sub-agents; else build them one after the other). Capture:
```bash
node <skill>/engine/short.mjs capture video-agent/edits/short-<slug> source/<name>.html --dur 4.5 --alpha --out assets/<name>.mov
```
Read its `stills/<name>-sheet.jpg`, then wire it: `media: { x: { src: 'assets/<name>.mov', width: 1080, alpha: true } }` plus an `overlay` scene (or an actor on the board), see `examples/thinking-orbs/source/spec.js`.
## 4. Prepare
```bash
node <skill>/engine/short.mjs prepare video-agent/edits/short-<slug>
```
Cuts every micro silence, applies the speed (1.15 by default), splices the voice, extracts the camera and media frames (cached), writes `assets/timeline.json` and `files/words.md`. Rerun it after a change to `cut`, `cam` or `media`. `cut: [[a, b]]` (source time) drops a retake or a false start: walk the script, keep the last complete attempt of each line.
## 5. Stills, then critique (the main loop)
```bash
node <skill>/engine/short.mjs stills video-agent/edits/short-<slug> 0.3 2.9s 6.4s 9s 12.9s
```
Plain numbers = SHORT time, an `s` suffix = SOURCE time. Take one still per beat (the moment it is complete), plus the hook and the last second. Read `stills/contact.jpg`, then zoom the full-size stills, and check the quality bar below. Fix the spec, stills again. Stills cost seconds, renders cost minutes: iterate here.
## 6. Render, sound, final
```bash
node <skill>/engine/short.mjs all video-agent/edits/short-<slug>
```
Render + audio (ducked voice, music bed, SFX, mixed beside the render) + final (mux, poster, QC), all at once. On an Apple Silicon Mac a 36 s short takes about 5 s (the render alone about 4 s, 250 fps, WebCodecs H.264 inside Chrome with 2 workers); slower machines take longer, so run it in the background and say how long. After a sound change, rerun only `audio` and `final`. The QC line must read `h264 1080 1920` (or `1080 1080`, `1920 1080`), a duration equal to the timeline, about -14 LUFS and a true peak at or under -1 dBTP (else `mix: { ceil: -2.2 }` in the spec, then `audio` and `final` again). Draft: `render ... --scale .5`.
## Quality bar (every contact sheet)
- The hook reads on the first frame.
- Every beat acts. On each beat's still, ask « is something happening, or is it an image sitting there? ». A sitting image (a screenshot, a still illustration, a logo next to a counter, a text card) is a component to redo before rendering.
- Every board fills the paper: big mobile text (nothing under 34 px, captions about 84 px), 1 to 3 lines, nothing cut at the edges, nothing in the safe zones of [shorts-codes.md](references/shorts-codes.md).
- Captions never overlap a card, a CTA or a chat composer (`captions.hide`).
- One accent color per moment; accent words only from `captions.emph` and `emph`.
- Cards never change size; the camera card never resizes. A full-camera stretch lasts 5 s or more.
- No ghost of the old beat under the new one, no counter parked on 0, no two values stacked in one slot.
- Camera colors natural: bright warm skin, never grey or greenish (`stills/grade.jpg`).
- Every named tool shows its real logo.
- Sound: the voice on top, the bed breathing between sentences, hits only on real payoffs, no whooshes.
## 7. Deliver
In `video-agent/edits/short-<slug>/output/`: `short-<slug>.mp4` and its poster. Then write next to them:
- `short-<slug>.srt`: from `files/words.md` (the SHORT times), up to 7 words per cue, a new cue after each sentence.
- `post.md`: the post copy (below).
Open the video so they watch it right away: `open <mp4>` (macOS), `start "" "<mp4>"` (Windows), `xdg-open <mp4>` (Linux). Say the length, what was cut, the bed and why, and offer one change ("another hook? a board on another word? full camera on the story?"). A change = edit `spec.js`, stills, `all` again. A change they want on every video (accent, font, captions, pace) goes in their style (`video-agent/styles/<name>/style.json`, schema in [style-schema.md](references/style-schema.md)), not only in this spec. Next: `/publish short-<slug>`.
On their first finished video only (the only folder in `video-agent/edits/`), end with one line in their language: a one-minute review of the free pack helps make it better, https://codelynx.dev/editing/feedbacks. Never again after that.
## Post copy
`post.md`, from the script and voice.md, in the language of the video:
- **Title** (YouTube Shorts): under 60 characters, the topic in the first 3 words.
- **Caption**: the first 125 characters work on their own. Then 1 or 2 short lines and one ask (the CTA of the style).
- **Search words**: the 2 or 3 words their viewer would type, written plainly in the title or the first line.
- **Hashtags**: 3 to 5 about the topic. No #fyp or #viral.
- Nothing the video doesn't say.
Run the humanize pass of `studio-script` (its `references/humanize.md`) on it when that skill is installed.
## CapCut or Premiere
Some creators prefer their editor. Do steps 0 to 4 (the spec with no scenes), then give them `files/words.md` (the cut, word by word) and the plain cut: `node <skill>/engine/short.mjs rough video-agent/edits/short-<slug>` writes `output/short-<slug>-rough.mp4` (every silence cut, full frame, no motion design) to import, with the `.srt` of step 7.
## Paper-only (no take)
`silent: true` and `duration: <s>` in the spec, `init` without `--source`, skip step 2. No voice, camera or captions; the music and the on-screen text carry it, same pace rule.
## Honest limits
- whisper.cpp mishears names and jargon: fix them with `captions.replace`, check numbers by eye.
- A cut can clip a breath or a soft last syllable: watch the result once before posting.
- Claude cannot hear the take: it trusts the timings. Bad audio (music, echo, two voices) makes them worse.
- Face framing is exact on macOS (Apple Vision); on Windows and Linux it uses MediaPipe in Chrome, check `cam` on the first still.
- The review studio, the Recorder, the B-roll library, the faceless mascot and the thumbnails come with the paid kit, Editing IA Pro.
assets/icons/build-icons.mjs
// House icon set = Google Noto Emoji (Apache 2.0, github.com/googlefonts/noto-emoji), the default icon library everywhere
// (the creator 2026-10-15, after a side-by-side test of Fluent 3D, Noto and Phosphor on the Editing IA Pro VSL).
// Bare color emoji on transparent, never on a tile: <name>.png and glyph/<name>.png are the same 512 px image
// (both paths kept so older specs keep working), svg/<name>.svg is the source.
// The names stay the house names, so specs that use assets/icons/<name>.png or glyph/<name>.png keep working.
// Add an icon: one line in ICONS (house name: Noto name, search https://icon-sets.iconify.design/noto/), then run:
// cd ../../engine && npm install && node ../assets/icons/build-icons.mjs
// The SVGs come from the Iconify API in one request (it rate-limits per-icon calls) and stay in svg/; delete svg/<name>.svg to fetch it again.
import { chromium } from '../../engine/node_modules/playwright-core/index.mjs';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
// house name: Noto name
const ICONS = {
warning: 'warning', skull: 'skull', cross: 'cross-mark', fear: 'fearful-face', tomb: 'headstone', locked: 'locked',
refund: 'receipt', check: 'check-mark-button', crown: 'crown', rocket: 'rocket', trophy: 'trophy',
unlocked: 'unlocked', sunrise: 'sunrise', bolt: 'high-voltage', gift: 'wrapped-gift', play: 'clapper-board',
money: 'euro-banknote', connected: 'electric-plug', arrowdown: 'backhand-index-pointing-down', 'arrow-down': 'backhand-index-pointing-down',
ant: 'ant', brain: 'brain', bread: 'bread', bulb: 'light-bulb', calendar: 'spiral-calendar', camera: 'movie-camera',
chat: 'speech-balloon', clock: 'alarm-clock', collision: 'collision', cool: 'smiling-face-with-sunglasses',
coupon: 'admission-tickets', cursor: 'computer-mouse', elephant: 'elephant', eye: 'eyes', factory: 'factory',
globe: 'globe-showing-europe-africa', goldfish: 'fish', hook: 'hook', hourglass: 'hourglass-not-done', laptop: 'laptop',
megaphone: 'megaphone', mic: 'studio-microphone', mountain: 'snow-capped-mountain', palette: 'artist-palette',
pencil: 'pencil', phone: 'mobile-phone', question: 'red-question-mark', robot: 'robot', scissors: 'scissors',
script: 'scroll', shield: 'shield', tag: 'label', users: 'busts-in-silhouette', search: 'magnifying-glass-tilted-left',
folder: 'open-file-folder', file: 'page-facing-up', envelope: 'envelope', heart: 'red-heart', star: 'star',
timer: 'timer-clock', copy: 'clipboard', code: 'keyboard', ruler: 'straight-ruler', bookmark: 'bookmark',
wrench: 'wrench', car: 'automobile', tyre: 'wheel',
house: 'house', server: 'desktop-computer', support: 'technologist', repeat: 'counterclockwise-arrows-button', pin: 'pushpin', link: 'link',
image: 'framed-picture', music: 'musical-notes', upload: 'outbox-tray', film: 'film-frames', waveform: 'speaker-high-volume',
// podcast ép. 4 v3 (the creator 2026-10-16: « on utilise les émojis Google maintenant, que ce soit plus clean »): house name = Noto name
fire: 'fire', 'money-bag': 'money-bag', 'money-with-wings': 'money-with-wings', 'chart-increasing': 'chart-increasing', 'chart-decreasing': 'chart-decreasing', 'bar-chart': 'bar-chart',
handshake: 'handshake', 'thumbs-up': 'thumbs-up', 'thumbs-down': 'thumbs-down', 'clapping-hands': 'clapping-hands', 'raising-hands': 'raising-hands', 'flexed-biceps': 'flexed-biceps',
'face-with-tears-of-joy': 'face-with-tears-of-joy', 'rolling-on-the-floor-laughing': 'rolling-on-the-floor-laughing', 'exploding-head': 'exploding-head', 'face-screaming-in-fear': 'face-screaming-in-fear', 'thinking-face': 'thinking-face', 'face-with-monocle': 'face-with-monocle',
'face-with-symbols-on-mouth': 'face-with-symbols-on-mouth', 'smiling-face-with-heart-eyes': 'smiling-face-with-heart-eyes', 'star-struck': 'star-struck', 'partying-face': 'partying-face', 'pile-of-poo': 'pile-of-poo', ghost: 'ghost',
alien: 'alien', 'clown-face': 'clown-face', 'nerd-face': 'nerd-face', 'sleeping-face': 'sleeping-face', 'yawning-face': 'yawning-face', 'face-with-rolling-eyes': 'face-with-rolling-eyes',
'grimacing-face': 'grimacing-face', 'see-no-evil-monkey': 'see-no-evil-monkey', 'shushing-face': 'shushing-face', 'zipper-mouth-face': 'zipper-mouth-face', 'hot-face': 'hot-face', 'cold-face': 'cold-face',
'woozy-face': 'woozy-face', 'crying-face': 'crying-face', 'loudly-crying-face': 'loudly-crying-face', 'skull-and-crossbones': 'skull-and-crossbones', bomb: 'bomb', sparkles: 'sparkles',
'hundred-points': 'hundred-points', 'dollar-banknote': 'dollar-banknote', coin: 'coin', 'credit-card': 'credit-card', 'gem-stone': 'gem-stone', 'shopping-cart': 'shopping-cart',
package: 'package', 'office-building': 'office-building', school: 'school', bank: 'bank', 'classical-building': 'classical-building', briefcase: 'briefcase',
'crystal-ball': 'crystal-ball', 'magic-wand': 'magic-wand', 'game-die': 'game-die', 'slot-machine': 'slot-machine', 'puzzle-piece': 'puzzle-piece', 'teddy-bear': 'teddy-bear',
'cat-face': 'cat-face', 'dog-face': 'dog-face', 'pig-face': 'pig-face', 'cow-face': 'cow-face', tractor: 'tractor', seedling: 'seedling',
sun: 'sun', 'cloud-with-lightning-and-rain': 'cloud-with-lightning-and-rain', tornado: 'tornado', rainbow: 'rainbow', snowflake: 'snowflake', droplet: 'droplet',
volcano: 'volcano', airplane: 'airplane', 'racing-car': 'racing-car', ambulance: 'ambulance', 'police-car-light': 'police-car-light', construction: 'construction',
'stop-sign': 'stop-sign', 'no-entry': 'no-entry', prohibited: 'prohibited', gear: 'gear', hammer: 'hammer', 'hammer-and-wrench': 'hammer-and-wrench',
toolbox: 'toolbox', magnet: 'magnet', 'test-tube': 'test-tube', microscope: 'microscope', 'satellite-antenna': 'satellite-antenna', battery: 'battery',
'low-battery': 'low-battery', 'floppy-disk': 'floppy-disk', television: 'television', 'video-game': 'video-game', joystick: 'joystick', headphone: 'headphone',
bell: 'bell', 'bell-with-slash': 'bell-with-slash', loudspeaker: 'loudspeaker', 'muted-speaker': 'muted-speaker', key: 'key', 'old-key': 'old-key',
door: 'door', bed: 'bed', pill: 'pill', syringe: 'syringe', ear: 'ear', 'man-technologist': 'man-technologist',
ninja: 'ninja', superhero: 'superhero', detective: 'detective', vampire: 'vampire', mage: 'mage', genie: 'genie',
ring: 'ring', 'graduation-cap': 'graduation-cap', backpack: 'backpack', books: 'books', notebook: 'notebook', memo: 'memo',
newspaper: 'newspaper', paperclip: 'paperclip', wastebasket: 'wastebasket', 'file-cabinet': 'file-cabinet', 'inbox-tray': 'inbox-tray', 'e-mail': 'e-mail',
'incoming-envelope': 'incoming-envelope', 'ballot-box-with-ballot': 'ballot-box-with-ballot', stopwatch: 'stopwatch', 'hourglass-done': 'hourglass-done', 'balance-scale': 'balance-scale', 'sports-medal': 'sports-medal',
'1st-place-medal': '1st-place-medal', 'flying-saucer': 'flying-saucer', 'crossed-swords': 'crossed-swords', dagger: 'dagger', axe: 'axe', chains: 'chains',
'red-exclamation-mark': 'red-exclamation-mark', 'double-exclamation-mark': 'double-exclamation-mark', 'exclamation-question-mark': 'exclamation-question-mark', 'thought-balloon': 'thought-balloon', 'right-anger-bubble': 'right-anger-bubble', 'waving-hand': 'waving-hand',
'ok-hand': 'ok-hand', 'pinched-fingers': 'pinched-fingers', 'victory-hand': 'victory-hand', 'crossed-fingers': 'crossed-fingers', 'index-pointing-up': 'index-pointing-up', 'backhand-index-pointing-right': 'backhand-index-pointing-right',
'writing-hand': 'writing-hand', 'folded-hands': 'folded-hands', 'palms-up-together': 'palms-up-together', 'person-shrugging': 'person-shrugging', 'person-facepalming': 'person-facepalming', 'person-gesturing-no': 'person-gesturing-no',
'person-running': 'person-running', 'person-lifting-weights': 'person-lifting-weights', 'speaking-head': 'speaking-head', 'bust-in-silhouette': 'bust-in-silhouette', pizza: 'pizza', hamburger: 'hamburger',
'french-fries': 'french-fries', sushi: 'sushi', spaghetti: 'spaghetti', croissant: 'croissant', 'birthday-cake': 'birthday-cake', cookie: 'cookie',
'hot-beverage': 'hot-beverage', 'beer-mug': 'beer-mug', 'clinking-beer-mugs': 'clinking-beer-mugs', 'bottle-with-popping-cork': 'bottle-with-popping-cork', popcorn: 'popcorn', carrot: 'carrot',
banana: 'banana', 'red-apple': 'red-apple', lemon: 'lemon', 'hot-pepper': 'hot-pepper', 'honey-pot': 'honey-pot', soap: 'soap',
broom: 'broom', basket: 'basket', bucket: 'bucket', 'fire-extinguisher': 'fire-extinguisher', 'shopping-bags': 'shopping-bags', balloon: 'balloon',
'party-popper': 'party-popper', fireworks: 'fireworks', 'money-mouth-face': 'money-mouth-face', 'nauseated-face': 'nauseated-face', 'face-with-head-bandage': 'face-with-head-bandage', 'face-with-thermometer': 'face-with-thermometer',
'face-with-medical-mask': 'face-with-medical-mask', 'cowboy-hat-face': 'cowboy-hat-face', 'disguised-face': 'disguised-face', 'smiling-face-with-horns': 'smiling-face-with-horns', 'angry-face-with-horns': 'angry-face-with-horns', 'anatomical-heart': 'anatomical-heart',
bone: 'bone', tooth: 'tooth', 'chart-increasing-with-yen': 'chart-increasing-with-yen', goblin: 'goblin', cat: 'cat', dog: 'dog',
mouse: 'mouse', rat: 'rat', snail: 'snail', turtle: 'turtle', snake: 'snake', dragon: 'dragon',
unicorn: 'unicorn', monkey: 'monkey', gorilla: 'gorilla', shark: 'shark', octopus: 'octopus', butterfly: 'butterfly',
spider: 'spider', 't-rex': 't-rex', sauropod: 'sauropod', chicken: 'chicken', 'baby-chick': 'baby-chick', egg: 'egg',
cooking: 'cooking', moai: 'moai', 'wilted-flower': 'wilted-flower', sunflower: 'sunflower', 'four-leaf-clover': 'four-leaf-clover', cactus: 'cactus',
'deciduous-tree': 'deciduous-tree', 'evergreen-tree': 'evergreen-tree', 'desert-island': 'desert-island', 'world-map': 'world-map', compass: 'compass', stethoscope: 'stethoscope',
'adhesive-bandage': 'adhesive-bandage', 'drop-of-blood': 'drop-of-blood', 'broken-heart': 'broken-heart', 'heart-on-fire': 'heart-on-fire', 'sparkling-heart': 'sparkling-heart', lollipop: 'lollipop',
'chocolate-bar': 'chocolate-bar', doughnut: 'doughnut', ice: 'ice', 'yo-yo': 'yo-yo', kite: 'kite', boomerang: 'boomerang',
bullseye: 'bullseye', bowling: 'bowling', 'video-camera': 'video-camera', 'camera-with-flash': 'camera-with-flash', 'film-projector': 'film-projector', 'level-slider': 'level-slider',
'control-knobs': 'control-knobs', radio: 'radio', trumpet: 'trumpet', guitar: 'guitar', drum: 'drum', saxophone: 'saxophone',
violin: 'violin',
};
for (const d of ['svg', 'glyph']) mkdirSync(join(here, d), { recursive: true });
const svgFile = name => join(here, 'svg', name + '.svg');
const missing = Object.entries(ICONS).filter(([name, noto]) => !existsSync(svgFile(name)) || !readFileSync(svgFile(name), 'utf8').includes(`<!-- noto:${noto} -->`));
if (missing.length) {
const res = await fetch(`https://api.iconify.design/noto.json?icons=${[...new Set(missing.map(([, n]) => n))].join(',')}`);
if (!res.ok) throw new Error(`Iconify answered ${res.status}, try again in a few minutes`);
const set = await res.json();
for (const [name, noto] of missing) {
const icon = set.icons?.[noto];
if (!icon) throw new Error(`no Noto icon "${noto}" for ${name}`);
const w = icon.width ?? set.width ?? 128, h = icon.height ?? set.height ?? 128;
writeFileSync(svgFile(name), `<!-- noto:${noto} -->\n<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 ${w} ${h}">${icon.body}</svg>`);
}
}
// installed Chrome (macOS, Windows, Linux) unless CHROME_PATH points at another Chromium
const browser = await chromium.launch(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : { channel: 'chrome' });
const page = await browser.newPage({ viewport: { width: 512, height: 512 } });
for (const name of Object.keys(ICONS)) {
const svg = readFileSync(join(here, 'svg', name + '.svg'), 'utf8');
await page.setContent(`<html><body style="margin:0;background:transparent"><div class="g" style="width:512px;height:512px">${svg}</div></body></html>`);
await page.locator('.g').screenshot({ path: join(here, name + '.png'), omitBackground: true });
writeFileSync(join(here, 'glyph', name + '.png'), readFileSync(join(here, name + '.png')));
}
await browser.close();
console.log(Object.keys(ICONS).length, 'Noto icons ->', here);
engine/audio.mjs
// Audio for a short: cleaned voice + music bed ducked under it + spec-driven paper SFX -> assets/mix.wav (48k, 24-bit).
// Voice: ffmpeg high-pass 80 Hz + gentle compression, then normalised to -16 LUFS. Bed and SFX are auto-levelled
// to LUFS targets relative to that (spec.mix = {music: -28, sfx: -29.5, duck: .5}), master limited to -14 LUFS / -1.5 dBTP.
// The SFX level is measured on the event hits only (pops, ticks, taps, bells...). Noise strokes (slides, whooshes,
// highlights) ride at the same gain but stay out of the measurement, so thinning or softening them makes the short
// quieter instead of pushing every other hit up.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { mapper, mapSpec } from './lib/timeline.js';
import { SR, studio, freeverb, lufs, limit, clamp } from './lib/dsp.js';
import { sfxCues, kitHits, score } from './lib/cues.js';
import { FFMPEG } from './platform.mjs';
const db = x => Math.pow(10, x / 20);
// spec.music.bed: a bed of the skill's library by id (assets/music/<id>.mp3, see references/music.md).
// spec.music.src: any track file instead (mp3, wav, m4a...; ~, absolute or project-relative path). Either replaces the
// generated score. Read from music.offset s into the file, laid from music.start to music.end (SHORT time), looped
// when the track is shorter, faded in .4 s / out 1.2 s. The mixer still levels it to mix.music and ducks it.
function trackBed(M, src, P, T, X) {
const f = src.replace(/^~/, os.homedir()), file = path.isAbsolute(f) ? f : P(f);
if (!fs.existsSync(file)) throw new Error(`music.src not found: ${file}`);
const i0 = Math.max(0, Math.round((M.start ?? 0) * SR)), i1 = Math.min(X.N, Math.round(Math.min(T, M.end ?? T) * SR)), fi = (M.fi ?? .4) * SR, fo = (M.fo ?? 1.2) * SR;
// decode only the stretch laid (+1 s): the library beds run 150 s, a short uses 30
const pcm = execFileSync(FFMPEG, ['-v', 'error', ...(M.offset ? ['-ss', String(M.offset)] : []), '-i', file, '-t', String(Math.max(0, i1 - i0) / SR + 1), '-ac', '2', '-ar', String(SR), '-f', 'f32le', '-'], { maxBuffer: 1 << 30 });
const a = new Float32Array(pcm.buffer, pcm.byteOffset, pcm.length / 4), L = a.length >> 1; if (!L) throw new Error(`music.src decoded empty: ${file}`);
for (let i = i0; i < i1; i++) {
const k = (i - i0) % L, e = Math.min(1, (i - i0) / fi, (i1 - i) / fo);
X.MUS[0][i] += a[2 * k] * e; X.MUS[1][i] += a[2 * k + 1] * e;
}
}
export async function buildAudio(proj, raw) {
const P = (...a) => path.join(proj, ...a), t0 = Date.now();
const tl = JSON.parse(fs.readFileSync(P('assets/timeline.json'), 'utf8')), m = mapper(tl), T = tl.duration;
const spec = mapSpec(raw, m); spec._map = m; spec._jumps = (raw.cut || []).map(([a]) => m(a));
const mm = v => v != null ? m(v) : undefined;
if (spec.music) spec.music = { ...spec.music, breaks: (raw.music?.breaks || []).map(([a, b]) => [m(a), m(b)]), end: mm(raw.music?.end), start: mm(raw.music?.start), drops: (raw.music?.drops || []).map(m) };
const mixCfg = { music: -28, sfx: -29.5, duck: .5, ...(spec.mix || {}) };
// ---- voice
const X = studio(T), N = X.N, V = new Float32Array(N);
if (!raw.silent) { // spec.silent: paper-only piece, music + SFX only
const pcm = execFileSync(FFMPEG, ['-v', 'error', '-i', P('assets/voice.wav'), '-af',
'highpass=f=80,lowpass=f=15000,acompressor=threshold=0.125:ratio=3:attack=5:release=90:makeup=2',
'-ac', '1', '-ar', String(SR), '-f', 'f32le', '-'], { maxBuffer: 1 << 30 });
const vsrc = new Float32Array(pcm.buffer, pcm.byteOffset, pcm.length / 4);
V.set(vsrc.subarray(0, Math.min(N, vsrc.length)));
const vg = db(-16 - lufs([V, V])); for (let i = 0; i < N; i++) V[i] *= vg;
}
// inserted narrator (spec.pause): same chain, own -16 LUFS normalisation, dropped into the silent gap
if (tl.pause && raw.pause?.voice) {
const np = execFileSync(FFMPEG, ['-v', 'error', '-i', P(raw.pause.voice), '-af', 'highpass=f=90,lowpass=f=15000,acompressor=threshold=0.125:ratio=3:attack=5:release=90:makeup=2',
'-ac', '1', '-ar', String(SR), '-f', 'f32le', '-'], { maxBuffer: 1 << 30 });
const nv = new Float32Array(np.buffer, np.byteOffset, np.length / 4), ng = db(-16 - lufs([nv, nv])) * db(raw.pause.gain ?? 0), i0 = Math.round(tl.pause.t0 * SR);
for (let i = 0; i < nv.length && i0 + i < N; i++) V[i0 + i] += nv[i] * ng;
}
// spec.inserts: each clip in its own gap (tl.inserts), same chain, fixed RMS gain like spec.voices (too short to gate)
(raw.inserts || []).forEach((v, k) => {
const g = tl.inserts?.[k]; if (!g) return;
const np = execFileSync(FFMPEG, ['-v', 'error', '-i', P(v.voice), '-af', 'highpass=f=90,lowpass=f=15000,acompressor=threshold=0.125:ratio=3:attack=5:release=90:makeup=2',
'-ac', '1', '-ar', String(SR), '-f', 'f32le', '-'], { maxBuffer: 1 << 30 });
const nv = new Float32Array(np.buffer, np.byteOffset, np.length / 4), l = lufs([nv, nv]);
let rms = 0; for (let i = 0; i < nv.length; i++) rms += nv[i] * nv[i]; rms = Math.sqrt(rms / nv.length);
const ng = (l > -70 ? db(-16 - l) : db(-19) / Math.max(1e-4, rms)) * db(v.gain ?? 0), i0 = Math.round(g.t0 * SR);
for (let i = 0; i < nv.length && i0 + i < N; i++) V[i0 + i] += nv[i] * ng;
});
// spec.voices: [{at, src, gain}] extra voice clips (an AI answering on camera) laid into held gaps at 1x speed.
// Each clip is normalised on its own: 1 s clips are too short to gate, so they use a fixed RMS-based gain.
(spec.voices || []).forEach((v, k) => {
const np = execFileSync(FFMPEG, ['-v', 'error', '-i', P(raw.voices[k].src), '-af', 'highpass=f=90,lowpass=f=15000,acompressor=threshold=0.125:ratio=3:attack=5:release=90:makeup=2',
'-ac', '1', '-ar', String(SR), '-f', 'f32le', '-'], { maxBuffer: 1 << 30 });
const nv = new Float32Array(np.buffer, np.byteOffset, np.length / 4), l = lufs([nv, nv]);
let rms = 0; for (let i = 0; i < nv.length; i++) rms += nv[i] * nv[i]; rms = Math.sqrt(rms / nv.length);
const ng = (l > -70 ? db(-16 - l) : db(-19) / Math.max(1e-4, rms)) * db(v.gain ?? 0), i0 = Math.round(v.at * SR);
for (let i = 0; i < nv.length && i0 + i < N; i++) V[i0 + i] += nv[i] * ng;
});
// spec.bleep: [[a, b]] SOURCE ranges censored. The voice is fully cut there (6 ms crossfade) and a 1 kHz tone at about
// -18 LUFS (just under the voice) takes its place, so nothing of the word leaks. Cover the whole word, its tail included.
for (const [a, b] of spec.bleep || []) {
const i0 = Math.max(0, Math.round(a * SR)), i1 = Math.min(N, Math.round(b * SR)), e = Math.round(.006 * SR), A = db(-17.3);
for (let i = i0; i < i1; i++) { const f = Math.min(1, (i - i0) / e, (i1 - i) / e); V[i] = V[i] * (1 - f) + A * f * Math.sin(2 * Math.PI * 1000 * (i - i0) / SR); }
}
// spec.thump: [[a, b, dB]] SOURCE ranges where a mic plosive ("p", "b") thumps: the voice is high-passed at 240 Hz there
// (two biquads, 24 dB/oct, 12 ms crossfades) and moved by dB (default -3). Cover the burst only: read it on the 80-160 Hz band.
for (const [a, b, g = -3] of spec.thump || []) {
const e = Math.round(.012 * SR), i0 = Math.max(0, Math.round(a * SR)), i1 = Math.min(N, Math.round(b * SR)), G = db(g);
const w = 2 * Math.PI * 240 / SR, al = Math.sin(w) / Math.SQRT2, c = Math.cos(w), a0 = 1 + al;
const b0 = (1 + c) / 2 / a0, b1 = -(1 + c) / a0, a1 = -2 * c / a0, a2 = (1 - al) / a0, st = new Float64Array(8);
for (let i = Math.max(0, i0 - 4 * e); i < Math.min(N, i1 + e); i++) {
let x = V[i];
for (let o = 0; o < 8; o += 4) { const y = b0 * x + b1 * st[o] + b0 * st[o + 1] - a1 * st[o + 2] - a2 * st[o + 3]; st[o + 1] = st[o]; st[o] = x; st[o + 3] = st[o + 2]; st[o + 2] = y; x = y; }
const f = clamp(Math.min((i - i0 + e) / e, (i1 + e - i) / e)); V[i] = V[i] * (1 - f) + x * G * f;
}
}
// ---- ducking envelope: fast attack, slow release, so the bed breathes back in between sentences
const DK = new Float32Array(N), aA = Math.exp(-1 / (.012 * SR)), aR = Math.exp(-1 / (.35 * SR)), thr = db(-30);
let env = 0, sm = 0; const aS = Math.exp(-1 / (.06 * SR));
for (let i = 0; i < N; i++) { const x = Math.abs(V[i]); env = x > env ? x + (env - x) * aA : x + (env - x) * aR; const d = clamp(env / thr); sm = d + (sm - d) * aS; DK[i] = 1 - mixCfg.duck * sm; }
// ---- score + sfx
const lib = path.join(path.dirname(fileURLToPath(import.meta.url)), '../assets/music'), bed = raw.music?.bed && path.join(lib, `${raw.music.bed}.mp3`);
// free pack: the beds are not in git, each one is generated in code the first time it is used (about 30 s)
if (bed && !fs.existsSync(bed) && JSON.parse(fs.readFileSync(path.join(lib, 'tracks.json'), 'utf8')).some(x => x.id === raw.music.bed)) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bed-'));
console.log(`[audio] music: generating the "${raw.music.bed}" bed once (about 30 s)`);
execFileSync(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), 'music.mjs'), raw.music.bed, '--out', tmp], { stdio: 'inherit' });
if (fs.existsSync(path.join(tmp, `${raw.music.bed}.mp3`))) fs.copyFileSync(path.join(tmp, `${raw.music.bed}.mp3`), bed);
}
if (bed && !fs.existsSync(bed)) throw new Error(`music.bed "${raw.music.bed}" not in ${lib} (ids: ${fs.readdirSync(lib).filter(f => f.endsWith('.mp3')).map(f => f.slice(0, -4)).join(', ')})`);
// music.chapters: [[at, bedId], ...] (SOURCE s): long-form, one library bed per chapter, 2 s crossfade on each change
const ch = !spec.music?.mute && raw.music?.chapters;
if (ch?.length) ch.forEach(([at, id], k) => {
const f = path.join(lib, `${id}.mp3`); if (!fs.existsSync(f)) throw new Error(`music.chapters bed "${id}" not in ${lib}`);
const a = k ? m(at) - 1 : 0, b = k < ch.length - 1 ? m(ch[k + 1][0]) + 1 : T;
trackBed({ start: a, end: b, fi: k ? 2 : .4, fo: k < ch.length - 1 ? 2 : 1.2 }, f, P, T, X);
});
else if ((bed || spec.music?.src) && !spec.music.mute) trackBed(spec.music, bed || raw.music.src, P, T, X);
else if (!spec.music?.mute) score(spec, tl, X);
sfxCues(spec, tl, X);
// nothing sent to the reverb (no synth SFX): its output is all zeros, skip the 8 combs x 2 over the whole short
const RV = X.REV.some(c => c.some(v => v !== 0)) ? freeverb(X.REV[0], X.REV[1], N) : [new Float32Array(N), new Float32Array(N)];
const BED = [new Float32Array(N), new Float32Array(N)], FX = [new Float32Array(N), new Float32Array(N)], EV = [new Float32Array(N), new Float32Array(N)];
for (let c = 0; c < 2; c++) for (let i = 0; i < N; i++) { BED[c][i] = X.MUS[c][i] + X.DRM[c][i] * .9 + RV[c][i] * .5; EV[c][i] = X.SFX[c][i]; FX[c][i] = X.SFX[c][i] + X.NZ[c][i] + RV[c][i] * .5; }
// soft sfx (spec.sfx.appear = 'slide'): the slides live on the noise bus, so normalize the whole FX bus
const lb = lufs(BED), le = lufs(EV), lf = le > -90 && spec.sfx?.appear !== 'slide' ? le : lufs(FX), gb = lb > -90 ? db(mixCfg.music - lb) : 0, gf = lf > -90 ? db(mixCfg.sfx - lf) : 0;
// spec.sfx.files: [{at | endAt, src, gain -10, pan 0, peak, offset, len}] designed sound files (an ElevenLabs impact, a riser).
// Each is levelled on its own to (-16 + gain) LUFS, i.e. gain dB under the voice, and stays out of the SFX bus measurement.
// at = where the file starts (SOURCE time); peak: true puts its loudest sample on `at` instead (impacts, whoosh hits);
// endAt = where it ends (a riser that lands on the payoff). offset / len trim it (s), with a short fade out.
const SF = [new Float32Array(N), new Float32Array(N)], kit = kitHits(spec, T), decoded = new Map();
[...(spec.sfx?.files || []), ...kit.files].forEach(f => {
const file = P(f.src); if (!fs.existsSync(file)) throw new Error(`sfx.${f.kit ? 'kit.' + f.kit : 'files'} src not found: ${file}`);
const ck = [file, f.offset, f.len].join('|'), pcm = decoded.get(ck) || execFileSync(FFMPEG, ['-v', 'error', ...(f.offset ? ['-ss', String(f.offset)] : []), ...(f.len ? ['-t', String(f.len)] : []), '-i', file, '-ac', '2', '-ar', String(SR), '-f', 'f32le', '-'], { maxBuffer: 1 << 30 });
decoded.set(ck, pcm);
const a = new Float32Array(pcm.buffer, pcm.byteOffset, pcm.length / 4), L = a.length >> 1; if (!L) return;
const ch = [new Float32Array(L), new Float32Array(L)]; let pk = 0, pi = 0, rms = 0;
for (let i = 0; i < L; i++) { ch[0][i] = a[2 * i]; ch[1][i] = a[2 * i + 1]; const v = Math.abs(a[2 * i]) + Math.abs(a[2 * i + 1]); if (v > pk) { pk = v; pi = i; } rms += a[2 * i] ** 2 + a[2 * i + 1] ** 2; }
rms = Math.sqrt(rms / (2 * L));
const l = lufs(ch), g = (l > -70 ? db(-16 + (f.gain ?? -10) - l) : db(-19 + (f.gain ?? -10)) / Math.max(1e-4, rms));
const pan = clamp(f.pan ?? 0, -1, 1), gl = g * Math.cos((pan + 1) * Math.PI / 4) * Math.SQRT2, gr = g * Math.sin((pan + 1) * Math.PI / 4) * Math.SQRT2;
const i0 = f.endAt != null ? Math.round(f.endAt * SR) - L : Math.round((f.at ?? 0) * SR) - (f.peak ? pi : 0), fo = Math.round(.04 * SR);
for (let i = 0; i < L; i++) { const j = i0 + i; if (j < 0 || j >= N) continue; const e = f.len ? Math.min(1, (L - i) / fo) : 1; SF[0][j] += ch[0][i] * gl * e; SF[1][j] += ch[1][i] * gr * e; }
});
// music.drops: [t] SOURCE s where the bed cuts out for music.dropLen s (default .3) and slams back, as on a punch-in.
// music.dropOn: 'punch' adds one on every sfx.kit punch (instant zoom), 'cut' on every kit cut too.
const DR = new Float32Array(N).fill(1), dl = spec.music?.dropLen ?? .3, dd = spec.music?.dropDepth ?? .06, ed = Math.round(.008 * SR);
const dOn = [].concat(spec.music?.dropOn || []), drops = [...(spec.music?.drops || []), ...kit.files.filter(f => dOn.includes(f.kit)).map(f => f.at)];
for (const a of drops) { const i0 = Math.round(a * SR), i1 = i0 + Math.round(dl * SR);
for (let i = Math.max(0, i0 - ed); i < Math.min(N, i1 + ed); i++) DR[i] = Math.min(DR[i], dd + (1 - dd) * (1 - clamp(Math.min((i - i0 + ed) / ed, (i1 + ed - i) / ed)))); }
// ---- sum + master
const fadeIn = Math.round(.02 * SR), fadeOut = Math.round(.35 * SR), Mx = [new Float32Array(N), new Float32Array(N)];
for (let c = 0; c < 2; c++) for (let i = 0; i < N; i++) {
const f = Math.min(1, i / fadeIn, (N - i) / fadeOut);
Mx[c][i] = (V[i] + BED[c][i] * gb * DK[i] * DR[i] + FX[c][i] * gf + SF[c][i]) * f;
}
// sample-peak ceiling; AAC can add ~1.5 dB of overs on sharp transients, so lower it (spec.mix.ceil, dBFS) if QC fails
let gain = 1, Y, I; const ceil = db(spec.mix?.ceil ?? -1.5);
for (let it = 0; it < 6; it++) { Y = limit(Mx, gain, ceil); I = lufs(Y); if (Math.abs(I + 14) < .05) break; gain *= db(-14 - I); }
let peak = 0; for (let c = 0; c < 2; c++) for (let i = 0; i < N; i++) { if (!Number.isFinite(Y[c][i])) throw new Error('NaN in mix at ' + (i / SR).toFixed(3)); peak = Math.max(peak, Math.abs(Y[c][i])); }
const buf = Buffer.alloc(44 + N * 6);
buf.write('RIFF', 0); buf.writeUInt32LE(36 + N * 6, 4); buf.write('WAVEfmt ', 8); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(2, 22);
buf.writeUInt32LE(SR, 24); buf.writeUInt32LE(SR * 6, 28); buf.writeUInt16LE(6, 32); buf.writeUInt16LE(24, 34); buf.write('data', 36); buf.writeUInt32LE(N * 6, 40);
for (let i = 0; i < N; i++) for (let c = 0; c < 2; c++) { // 24-bit little endian by hand (faster than writeIntLE)
const v = Math.max(-8388608, Math.min(8388607, Math.round(Y[c][i] * 8388607))), o = 44 + i * 6 + c * 3;
buf[o] = v & 255; buf[o + 1] = v >> 8 & 255; buf[o + 2] = v >> 16 & 255;
}
fs.writeFileSync(P('assets/mix.wav'), buf);
// spec.mix.stem: also write assets/fx.wav = music + SFX without the voice, pre-master, levelled against a -16 LUFS voice.
// The youtube-editing mix route lays it over the Tella master's own voice (scripts/mix_splice.mjs).
if (spec.mix?.stem) {
const sb = Buffer.alloc(44 + N * 8);
sb.write('RIFF', 0); sb.writeUInt32LE(36 + N * 8, 4); sb.write('WAVEfmt ', 8); sb.writeUInt32LE(16, 16); sb.writeUInt16LE(3, 20); sb.writeUInt16LE(2, 22);
sb.writeUInt32LE(SR, 24); sb.writeUInt32LE(SR * 8, 28); sb.writeUInt16LE(8, 32); sb.writeUInt16LE(32, 34); sb.write('data', 36); sb.writeUInt32LE(N * 8, 40);
for (let i = 0; i < N; i++) for (let c = 0; c < 2; c++) sb.writeFloatLE(BED[c][i] * gb * DK[i] * DR[i] + FX[c][i] * gf + SF[c][i], 44 + i * 8 + c * 4);
fs.writeFileSync(P('assets/fx.wav'), sb);
}
console.log(`[short] mix.wav: ${T.toFixed(2)}s, voice -16 LUFS, ${lb > -90 ? `bed ${mixCfg.music} LUFS (duck ${Math.round(mixCfg.duck * 100)}%)` : 'no music'}, ${lf > -90 ? `sfx ${mixCfg.sfx} LUFS` : 'no synth sfx'}${(spec.sfx?.files?.length || 0) + kit.files.length ? `, ${(spec.sfx?.files?.length || 0) + kit.files.length} sfx files` : ''} -> master ${I.toFixed(2)} LUFS, peak ${(20 * Math.log10(peak)).toFixed(2)} dBFS (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
}
assets/looks.json
{
"_": "Starting looks for the onboarding: a partial style.json each, merged over the engine template. preview.mjs --variants '[{\"label\": \"...\", \"look\": \"<key>\", \"style\": {<patch>}}]'. Never personal: no creator, folders, sfx kit or music file.",
"default": {
"label": "Minimal",
"says": "Le style par défaut : papier texturé, police système (celle du Mac), caméra pleine largeur, sous-titres en capitales mono, blancs sur un bandeau noir posé sur la jointure.",
"style": {"font": {"family": "system-ui, -apple-system, Segoe UI, Helvetica Neue, Arial, sans-serif"}, "display": {"font": "system-ui, -apple-system, Segoe UI, Helvetica Neue, Arial, sans-serif", "weight": 800, "text": "system-ui, -apple-system, Segoe UI, Helvetica Neue, Arial, sans-serif", "title": "ink2", "flat": true}, "captions": {"style": "band", "font": "JetBrains Mono", "weight": 800, "size": 60, "maxWords": 3, "upper": true, "bg": "#111111", "color": "#FFFFFF", "radius": 0.2, "shadow": true, "tracking": -0.02, "straddle": true}, "layout": {"frame": {"bleed": true, "r": 0}, "seam": 1160}}
},
"classique": {
"label": "Papier classique",
"says": "Le style par défaut : papier texturé, sous-titres stickers, accent bleu.",
"style": {}
},
"papier": {
"label": "Papier signature",
"says": "Papier texturé avec des ombres profondes, caméra en photo encadrée, sous-titres « rise » : le mot dit apparaît sur un bloc de couleur. Tech, explicatif, premium.",
"style": {
"captions": { "style": "rise", "size": 82, "straddle": true },
"paper": { "grain": 1.2, "tooth": 1, "depth": 1.7, "logos": "flat" },
"layout": { "frame": true, "seam": 1160 },
"display": { "font": "Bricolage Grotesque", "weight": 800, "text": "Bricolage Grotesque", "title": "ink2", "flat": true },
"font": { "family": "Bricolage Grotesque" },
"grade": true
}
},
"pop": {
"label": "Pop jaune",
"says": "Blanc cassé, noir et un jaune vif, gros sous-titres « hard » à ombre dure. Podcast, opinion, énergie.",
"style": {
"colors": { "paper": "#F7F7F5", "ink": "#111111", "accent": "#FFD21F", "pop": "#111111" },
"captions": { "style": "hard", "size": 96, "straddle": true },
"paper": { "grain": 1.2, "tooth": 1, "depth": 1.7, "logos": "flat", "shadow": "20,20,20" },
"layout": { "frame": true, "seam": 1010 },
"display": { "font": "Bricolage Grotesque", "weight": 800, "text": "Bricolage Grotesque", "title": "ink2", "flat": true },
"font": { "family": "Bricolage Grotesque" }
}
},
"poster": {
"label": "Poster orange",
"says": "Fond orange uni, coupes franches, le mot clé énorme en majuscules condensées sur la caméra. Rythmé, viral, très lisible.",
"style": {
"pace": { "speed": 1.12 },
"colors": { "paper": "#F0640A", "ink": "#111111", "accent": "#F0640A", "sheet": "#FFFFFF", "onAccent": "#FFFFFF" },
"captions": { "style": "stack", "size": 52, "straddle": false, "font": "Inter", "weight": 800, "maxWords": 4, "big": 3.2, "stretch": "extra-condensed", "smallW": 380, "stagger": 70, "fullY": 1250, "paperY": 820, "labelFont": "Inter", "labelWeight": 500, "labelSize": 80, "outline": { "color": "#F0640A", "width": 0.07 }, "zoomDrop": 320, "tall": 1.5, "smallDisplay": true, "snap": true, "margin": 60, "paperBig": 4.6 },
"paper": { "grain": 0, "tooth": 0, "depth": 0.4, "logos": "flat", "bg": "flat" },
"layout": { "frame": false, "seam": 1000 },
"display": { "font": "Archivo", "weight": 900, "text": "Inter", "title": "ink2", "flat": true },
"font": { "family": "Inter" },
"grade": "eq=contrast=1.08:saturation=0.95:gamma=0.97,vignette=angle=PI/3",
"zoom": { "drift": 0.008 },
"cuts": "hard"
}
}
}
engine/bin/darwin/faces
���� !� @ �� ���� * x � ! H __PAGEZERO � __TEXT � � __text __TEXT 0 jX 0 � __stubs __TEXT �k � �k � __stub_helper __TEXT <n 6 <n � __cstring __TEXT �s � �s __const __TEXT `x 2 `x __swift5_typeref__TEXT �z � �z __objc_methname __TEXT |{ � |{ __swift5_entry __TEXT H| H| __constg_swiftt __TEXT P| 8 P| __swift5_builtin__TEXT �| �| __swift5_reflstr__TEXT �| # �| __swift5_fieldmd__TEXT �| �| __swift5_assocty__TEXT �| 0 �| __swift5_proto __TEXT } } __swift5_types __TEXT $} $} __unwind_info __TEXT (} � (} __eh_frame __TEXT �~ h �~ h 8 __DATA_CONST � � __got __DATA_CONST � @ � p __const __DATA_CONST @� � @� __objc_imageinfo__DATA_CONST � � � __DATA � � __la_symbol_ptr __DATA � p � � __objc_selrefs __DATA p� ` p� __objc_classrefs__DATA Г 8 Г __s_async_hook __DATA � � � __swift56_hooks __DATA �� � �� __data __DATA H� H� __common __DATA H� p __bss __DATA �� � H __LINKEDIT � � � Ё "