storyshot 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -152,7 +152,7 @@ Rendering is free and runs locally. Only the voiceover can cost money:
152
152
  | voice | cost | notes |
153
153
  |---|---|---|
154
154
  | none | free | captions only |
155
- | `kokoro` | free | local, English only; downloads a ~90 MB model on first use |
155
+ | `kokoro` | free | local, English and Mandarin Chinese (`zf_001`); downloads a ~90 MB model on first use (Chinese: ~130 MB, and needs Python 3.10–3.12) |
156
156
  | ElevenLabs | your credits | the most natural voice; many languages including Chinese. Put `ELEVENLABS_API_KEY` in `.env`. |
157
157
  | OpenAI | your credits | `OPENAI_API_KEY` |
158
158
 
@@ -217,7 +217,7 @@ Everything the agent does is also a command:
217
217
  | `storyshot record videos/acme-promo.yaml` | Only record. |
218
218
  | `storyshot guide` | Print the playbook and the reference. |
219
219
  | `storyshot setup <agent>` (`cursor`, `claude`, `codex`, `gemini`, `vscode`, `windsurf`, `cline`) | Set up the MCP server and the skill in this project, and install or check what Storyshot needs. |
220
- | `storyshot doctor` | Check Node, ffmpeg, Chromium and voices; install Chromium if it's missing. |
220
+ | `storyshot doctor` | Check Node, ffmpeg (and the filters and encoders Storyshot uses), Chromium and voices; install Chromium if it's missing. |
221
221
  | `storyshot mcp` | Run the MCP server. |
222
222
 
223
223
  Recordings, previews and voice lines are cached in `.storyshot/` next to the
@@ -247,7 +247,7 @@ viewport: { width: 1600, height: 900 } # CSS px the site is laid out at
247
247
  output: { width: 1920, height: 1080, fps: 30 } # same aspect ratio; use 1080x1920 with a 900x1600 viewport for vertical
248
248
  theme: { accent: "#d9f75b", ink: "#0f172a", font: Inter }
249
249
  music: { bpm: 120, volume: 1 } # or { file: track.mp3 }, or false
250
- sfx: true # click / whoosh / riser sounds
250
+ sfx: true # click / whoosh / riser sounds; clicks = no whooshes on cards and page changes (tutorial and course default); false = none
251
251
  voice: { provider: elevenlabs, voice: hpp4J3VqNfWAUOO0d1Us } # optional voiceover, see below
252
252
  storageState: auth.json # optional Playwright login state
253
253
  ignoreHTTPSErrors: true # internal site with a self-signed HTTPS certificate (plain http:// needs nothing)
@@ -256,7 +256,7 @@ captions: true # false keeps caption pills off
256
256
  purpose: pitch # pitch | tutorial | course | docs (sets the defaults below)
257
257
  highlight: false # spotlight targets before click/type (per step or per action too)
258
258
  stepNumbers: false # numbered caption badges
259
- subtitles: false # write <out>.srt (narration, else captions); `burn` also burns the narration into the video, each word lighting up as it is spoken (captions move to the top)
259
+ subtitles: false # write <out>.srt (narration, else captions); `burn` also burns the narration into the video, each word lighting up as it is spoken (captions move to the top); uses ffmpeg's libass when it has it, else draws the words itself
260
260
  pace: slow # fast (pitch) | normal (docs) | slow (tutorial, course): cursor and typing speed, a beat after each click, hold after narration, voice speed, how much of a wait is shown
261
261
  follow: true # tutorial/course default: the camera zooms in on what each step touches and pans between steps (a step's own zoom wins; follow: false on a step skips it); or { scale: 1.8 }
262
262
  article: { title: Export a drawing, intro: … } # docs: heading and first paragraph of <out>-steps/README.md
@@ -393,10 +393,21 @@ mix is normalized to -14 LUFS.
393
393
 
394
394
  | provider | key (env or `.env`) | notes |
395
395
  |---|---|---|
396
- | `kokoro` (default) | none | Local and free; English voices only (`af_heart`, `am_michael`, `bf_emma`, …). Needs `npm i kokoro-js`; the first run downloads an ~90 MB model. |
396
+ | `kokoro` (default) | none | Local and free. English voices (`af_heart`, `am_michael`, `bf_emma`, …) and Mandarin Chinese ones (`zf_001`, see below). Needs `npm i kokoro-js`; the first run downloads an ~90 MB model. |
397
397
  | `elevenlabs` | `ELEVENLABS_API_KEY` | Most natural, many languages including Chinese. `voice` is a voice ID; `model` defaults to `eleven_multilingual_v2`. The key needs Text to Speech access, plus Voices → Read to list voices. |
398
398
  | `openai` | `OPENAI_API_KEY` | `voice: alloy`, `model: gpt-4o-mini-tts`; optional `instructions` for tone. |
399
- | `say` | none | macOS built-in voices, robotic; fine for drafts. |
399
+ | `say` | none | macOS built-in voices, robotic; fine for drafts. For Chinese, use `kokoro` with `zf_001` rather than `Tingting`. |
400
+
401
+ **Chinese narration.** `voice: { provider: kokoro, voice: zf_001 }` speaks Mandarin with Kokoro
402
+ v1.1-zh, free and local, and is what `kokoro` uses when the `say:` lines are Chinese and no voice is
403
+ named. It sounds far more natural than macOS `say -v Tingting`. Other voices: `zf_002` (female),
404
+ `zm_010`, `zm_011` (male), and others between `zf_001`…`zf_099` and `zm_009`…`zm_100` (not every
405
+ number exists). Its text-to-phoneme step (misaki) needs Python 3.10–3.12: on first use Storyshot
406
+ creates its own venv in `~/.cache/storyshot/zh-venv` (about 250 MB, under a minute; nothing is installed globally;
407
+ `STORYSHOT_PYTHON` picks the interpreter) and downloads a ~130 MB model. `storyshot doctor` says
408
+ whether it's ready. English words and letters in a line (`GeoGebra`, `y 等于 m x 加 b`) are spoken in
409
+ English, a 2 before a measure word is read 两 (`2 个单位`), and `=`, `+`, `×`, `÷`, `²` are spoken;
410
+ subtitles keep the line as written. Known issue: with every Kokoro voice, 逗号 can sound like 度号.
400
411
 
401
412
  Other fields: `speed` (default 1), `volume` (default 1), and `pronounce`, a
402
413
  map of word → spoken form used only for TTS, e.g.
@@ -430,6 +441,41 @@ usually shouldn't.
430
441
  | `cta` | Dark call-to-action pill, e.g. `Start free at acme.com`. |
431
442
  | `duration` | Seconds. Default: fits the `say` line, else 1.0 (end card) or 2.2 (intro). |
432
443
  | `say` | Voiceover line played over the end card. |
444
+ | `emblem` | With `hyperframes`: one to three short monospace lines over the title (an alignment, a formula, a line of code); `false` for none. |
445
+
446
+ ### HyperFrames cards (optional)
447
+
448
+ `hyperframes: true` renders the intro, section and end cards with
449
+ [HyperFrames](https://hyperframes.heygen.com) (HeyGen's open-source HTML-to-video
450
+ renderer, Apache-2.0) for more motion. Two templates, picked by `purpose`:
451
+
452
+ - **lesson** (tutorials, courses, docs): the title lands word by word over a
453
+ slowly drifting, blurred view of the page the section leads into; section
454
+ cards show a progress bar of the parts; the intro's steps and the end card's
455
+ recap points light up as the narration names them.
456
+ - **launch** (promos): hand-drawn accents (a box round the logo, an underlined
457
+ highlight, an arrow to the call to action). The intro's paper turns into the
458
+ browser window and its logo lands on the app's own logo in the first frame;
459
+ the outro zooms from the last frame into the page and draws the call to
460
+ action over it.
461
+
462
+ ```yaml
463
+ hyperframes:
464
+ template: lesson # or launch (default: launch for pitch, lesson otherwise)
465
+ band: MVHLTPEEKSAVTALWGKV… # optional: text drifting behind the titles (a sequence, a formula)
466
+ bandColors: aminoacid # aminoacid | nucleotide | accent (default)
467
+ emblem: [LHCDKLHVDPENFRLL, "||||||||||||+|||", LHCDKLHVDPENFKLL] # optional, over the titles
468
+ hand: true # launch: hand lettering (Virgil) for the subtitle and call to action
469
+ backdrop: false # lesson: plain background instead of the blurred page
470
+ ```
471
+
472
+ It needs Node 22 or newer. On first use storyshot installs the pinned
473
+ HyperFrames version into `~/.cache/storyshot/hyperframes/` (a local install
474
+ of the `hyperframes` package wins); its telemetry is switched off. Cards are
475
+ cached, so `compose` only re-renders the ones that changed (about 10–25 s
476
+ each). Any card that can't be rendered falls back to the built-in one with a
477
+ warning. The Virgil font is © Ellinor Rapp, SIL Open Font License 1.1
478
+ (`src/fonts/Virgil-LICENSE.md`).
433
479
 
434
480
  ## License
435
481
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyshot",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Turn a short YAML storyboard into a polished product demo video of your web app — smooth cursor, auto zoom, captions, music.",
5
5
  "author": "boheling <boheling@gmail.com>",
6
6
  "repository": {
@@ -12,6 +12,9 @@
12
12
  "bin": {
13
13
  "storyshot": "bin/storyshot.mjs"
14
14
  },
15
+ "scripts": {
16
+ "test": "node --test"
17
+ },
15
18
  "files": [
16
19
  "bin",
17
20
  "src",
@@ -36,6 +39,8 @@
36
39
  "zod": "^4.6.5"
37
40
  },
38
41
  "optionalDependencies": {
39
- "kokoro-js": "^1.2.1"
42
+ "gsap": "^3.14.2",
43
+ "kokoro-js": "^1.2.1",
44
+ "roughjs": "^4.6.6"
40
45
  }
41
46
  }
@@ -65,8 +65,8 @@ frames or stock footage presented as a recording of their app.
65
65
  narrated how-to; course = narrated lesson; docs = silent step-by-step GIFs)
66
66
  2. Length: about 20 s, 6 shots (it follows from the flow below)
67
67
  3. Flow: hero → Examples → Try It → AI result → pricing → end card
68
- 4. Voice: none (other options: free local English voice; ElevenLabs, best
69
- quality and Chinese support, key found in .env)
68
+ 4. Voice: none (other options: free local voice, English or Chinese;
69
+ ElevenLabs, best quality, key found in .env)
70
70
  5. Music: upbeat · Captions: English · Subtitle file (.srt): no
71
71
  6. Format: 16:9, 1080p (other option: 9:16 vertical)
72
72
  7. Login: not needed (or: Examples needs a login, so I'll open a browser
@@ -85,7 +85,11 @@ frames or stock footage presented as a recording of their app.
85
85
  | `docs` | one clip per step, each just long enough to see its result | none | none | step numbers, spotlight, cropped GIFs + PNGs, README.md article |
86
86
 
87
87
  Captions match the site's language. `kokoro` (free, local) speaks English
88
- only; for other languages, use ElevenLabs.
88
+ and Mandarin Chinese: for Chinese lines use `voice: { provider: kokoro,
89
+ voice: zf_001 }` (the default for Chinese `say:` lines), not macOS `say -v
90
+ Tingting`, which sounds robotic. It needs Python 3.10–3.12 and sets up its
91
+ own venv on first use (`doctor` reports it). For other languages, use
92
+ ElevenLabs.
89
93
  3. **Login.** If the flow needs a logged-in page:
90
94
  - Tell the user a browser window will open and that they should log in
91
95
  there, preferably with a demo account (the video shows whatever is on
@@ -175,6 +179,14 @@ small controls are readable. Your job is the structure:
175
179
  `note` the field the reader must not miss. Set the article's heading and
176
180
  first paragraph with `article: { title: …, intro: … }`. Downloads show a
177
181
  "Downloaded" bubble; `paste` and copy-to-clipboard work.
182
+ - **More motion on the cards** (when the user asks for livelier titles, or for
183
+ a launch video): `hyperframes: true` renders the intro, section and end
184
+ cards with HyperFrames (needs Node 22+; installs itself on first use). For a
185
+ subject with its own material, give the titles a backdrop from it:
186
+ `hyperframes: { band: <a sequence or formula>, emblem: [<1–3 short lines>] }`.
187
+ Promos get hand-drawn accents; `hand: true` uses hand lettering. Write the
188
+ intro's `bullets` and the end card's recap with the same words the `say`
189
+ line uses: each point lights up when the narration names it.
178
190
  - **End with a recap**: `end_card` with `bullets` listing the steps or rules
179
191
  to remember, and a `say` that repeats them.
180
192
  - **Narrating real results.** You can't know the numbers (scores, counts,
package/src/audio.mjs CHANGED
@@ -101,7 +101,7 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
101
101
  return press + release;
102
102
  }, 0.55);
103
103
  }
104
- for (const c of events.whooshes) {
104
+ for (const c of cfg.sfx.transitions ? events.whooshes : []) {
105
105
  const filt = svf(), len = 0.45;
106
106
  add(sfx, c - len * 0.55, len, (t) => {
107
107
  const p = t / len;
@@ -137,8 +137,14 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
137
137
  let dur = pcm.length / 2 / SR;
138
138
  const start = Math.max(line.start, prevEnd + 0.12), room = line.until - start - 0.1;
139
139
  let sped = 1;
140
- if (dur > room && dur / room <= 1.2) { sped = dur / room; pcm = tempo(pcm, sped); dur = pcm.length / 2 / SR; }
141
- else if (dur > room) warn(`${line.where}: narration is ${dur.toFixed(1)}s but only ${Math.max(0, room).toFixed(1)}s until ${line === events.voice.at(-1) ? 'the video ends' : 'the next line'} — shorten "${line.text}" or give the step more duration`);
140
+ // a line pushed past its window by the one before has no room left (room <= 0): it plays late, at normal speed
141
+ if (dur > room && room > 0 && dur / room <= 1.2) { sped = dur / room; pcm = tempo(pcm, sped); dur = pcm.length / 2 / SR; }
142
+ else if (dur > room) warn(room > 0
143
+ ? `${line.where}: narration is ${dur.toFixed(1)}s but only ${room.toFixed(1)}s until ${line === events.voice.at(-1) ? 'the video ends' : 'the next line'} — shorten "${line.text}" or give the step more duration`
144
+ : `${line.where}: the narration before it runs ${(start - line.start).toFixed(1)}s past where this line should start, so "${line.text}" plays late — shorten the earlier lines or give their steps more duration`);
145
+ if (start + dur > events.total) warn(start >= events.total
146
+ ? `${line.where}: "${line.text}" is cut off: the narration before it pushes it past the end of the video (${events.total.toFixed(1)}s) — shorten the lines or give the steps more duration`
147
+ : `${line.where}: the last ${(start + dur - events.total).toFixed(1)}s of "${line.text}" is cut off at the end of the video — shorten it or give the steps more duration`);
142
148
  log(` voice ${start.toFixed(2)}–${(start + dur).toFixed(2)}s ${line.where}${sped > 1 ? ` (sped up x${sped.toFixed(2)})` : ''} "${line.text}"`);
143
149
  let pk = 0;
144
150
  for (const v of pcm) pk = Math.max(pk, Math.abs(v));
@@ -187,7 +193,7 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
187
193
  // so re-encoding by video sites doesn't clip
188
194
  execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', silentVideo, '-i', wavFile,
189
195
  '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', `loudnorm=I=-14:TP=-1.5:LRA=11,aresample=${SR},alimiter=limit=0.7:level=false:attack=1:release=50`, '-ar', `${SR}`, '-c:a', 'aac', '-b:a', '192k', '-shortest', '-movflags', '+faststart', cfg.out]);
190
- log(`soundtrack: ${cfg.music ? (cfg.music.file ? 'music file' : `synth ${cfg.music.bpm} bpm`) : 'no music'}, ${events.clicks.length} clicks, ${events.whooshes.length} whooshes` +
196
+ log(`soundtrack: ${cfg.music ? (cfg.music.file ? 'music file' : `synth ${cfg.music.bpm} bpm`) : 'no music'}, ${events.clicks.length} clicks, ${cfg.sfx?.transitions ? events.whooshes.length : 0} whooshes` +
191
197
  (cfg.voice ? `, ${events.voice.length} narration lines (${cfg.voice.provider}/${cfg.voice.voice})` : ''));
192
198
  return { out: cfg.out, warnings, narration };
193
199
  }
package/src/build.mjs CHANGED
@@ -12,7 +12,7 @@ export async function build(cfg, mode = 'render', { log = console.log } = {}) {
12
12
  const voiceLens = await measureVoice(cfg, { log });
13
13
  const { silent, clean, events, preview, warnings, captions, cards, segs } = await compose(cfg, { log, voiceLens });
14
14
  const audio = await soundtrack(cfg, events, silent, { log });
15
- if (cfg.subtitles === 'burn') burnSubtitles(cfg, audio.narration ?? [], { log });
15
+ if (cfg.subtitles === 'burn') await burnSubtitles(cfg, audio.narration ?? [], { log });
16
16
  const extras = await writeOutputs(cfg, { captions, cards, segs, clean, narration: audio.narration, total: events.total }, { log });
17
17
  return { out: audio.out, extras, preview, warnings: [...warnings, ...audio.warnings], duration: events.total, seconds: Math.round((Date.now() - t0) / 1000) };
18
18
  }
package/src/check.mjs CHANGED
@@ -3,12 +3,14 @@ import fs from 'node:fs';
3
3
  import YAML from 'yaml';
4
4
  import { loadConfig } from './config.mjs';
5
5
  import { gitIgnored } from './auth.mjs';
6
+ import { isZhVoice, zhVoiceStatus } from './kokoro-zh.mjs';
6
7
 
7
8
  const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'intro', 'frame', 'pace', 'follow', 'article', 'cards', 'lang', 'colorScheme',
8
- 'scrollOffset', 'captionPosition', 'captions', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card'];
9
+ 'scrollOffset', 'captionPosition', 'captions', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card', 'hyperframes'];
9
10
  const STEP = ['caption', 'say', 'sayAt', 'duration', 'zoom', 'actions', 'hidden', 'sfx', 'highlight', 'captionPosition', 'card', 'follow', 'doc'];
10
11
  const ZOOM = ['target', 'scale', 'start', 'end', 'ramp', 'offset'];
11
- const END = ['logo', 'title', 'subtitle', 'cta', 'duration', 'say', 'kicker', 'bullets', 'image', 'think'];
12
+ const END = ['logo', 'title', 'subtitle', 'cta', 'duration', 'say', 'kicker', 'bullets', 'image', 'think', 'emblem'];
13
+ const HYPERFRAMES = ['template', 'band', 'bandColors', 'emblem', 'hand', 'backdrop'];
12
14
  const TARGET = ['role', 'name', 'text', 'label', 'placeholder', 'alt', 'testId', 'css', 'exact', 'last', 'nth'];
13
15
  const OPTS = ['ms', 'offset', 'timeout', 'highlight', 'color', 'box'];
14
16
  const ACTIONS = {
@@ -84,6 +86,7 @@ export function check(file, { draft = false } = {}) {
84
86
  }
85
87
  if (raw.end_card) unknown(raw.end_card, END, 'end_card');
86
88
  if (raw.intro) unknown(raw.intro, END, 'intro');
89
+ if (raw.hyperframes && typeof raw.hyperframes === 'object') unknown(raw.hyperframes, HYPERFRAMES, 'hyperframes');
87
90
  if (raw.article) unknown(raw.article, ['title', 'intro'], 'article');
88
91
  if (raw.frame && typeof raw.frame === 'object') unknown(raw.frame, ['background', 'style', 'window', 'title', 'icon'], 'frame');
89
92
  const todo = (fs.readFileSync(file, 'utf8').split('\n').map((l, i) => [l.replace(/#.*$/, ''), i + 1]).filter(([l]) => /\bTODO\b/.test(l)));
@@ -138,6 +141,28 @@ export function check(file, { draft = false } = {}) {
138
141
  const says = visible.filter((s) => s.say).length + (cfg.endCard?.say ? 1 : 0) + (cfg.intro?.say ? 1 : 0);
139
142
  if (cfg.voice && !says) warnings.push('voice is set but no step has a `say:` line');
140
143
  if (!cfg.voice && says) warnings.push('steps have `say:` lines but there is no `voice:` block, so they will not be spoken');
144
+ // fixed-length steps: a line that clearly can't be spoken before the next one starts (a rough
145
+ // estimate from its length; the render measures the real voice)
146
+ if (cfg.voice) {
147
+ const words = (t) => { const x = String(t).replace(/\*\*/g, ''); const c = (x.match(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/g) ?? []).length; return { c, w: x.replace(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/g, ' ').split(/\s+/).filter(Boolean).length }; };
148
+ const secs = (t) => { const { c, w } = words(t); return (c / 4.5 + w / 2.8) / (cfg.voice.speed ?? 1); };
149
+ const vis = visible.map((s) => ({ s, i: cfg.steps.indexOf(s) }));
150
+ for (const [k, { s, i }] of vis.entries()) {
151
+ if (!s.say) continue;
152
+ const nextK = vis.findIndex((v, j) => j > k && v.s.say);
153
+ const span = vis.slice(k, nextK < 0 ? undefined : nextK).map((v) => v.s.duration);
154
+ if (!span.every((d) => typeof d === 'number')) continue;
155
+ const room = span.reduce((a, d) => a + d, 0) - (s.sayAt ?? 0.15) + (nextK < 0 && cfg.endCard ? 2.5 : 0);
156
+ const need = secs(s.say);
157
+ if (need > room * 1.4 && need - room > 1) warnings.push(`step ${i + 1}: the say: line takes about ${need.toFixed(1)}s to speak but has ${room.toFixed(1)}s before ${nextK < 0 ? 'the video ends' : 'the next line'} — shorten it or raise duration (the lines after it would play late)`);
158
+ }
159
+ }
160
+ // the Chinese Kokoro voice needs a Python for its G2P: say so now, not after the recording
161
+ const v = draft && cfg.voice ? loadConfig(file, { draft }).voice : cfg.voice;
162
+ if (v?.provider === 'kokoro' && isZhVoice(v.voice)) {
163
+ const zh = zhVoiceStatus();
164
+ if (zh.problem) errors.push(zh.problem);
165
+ }
141
166
  if (!draft && cfg.voice && ['elevenlabs', 'openai'].includes(cfg.voice.provider)) {
142
167
  const env = cfg.voice.provider === 'elevenlabs' ? 'ELEVENLABS_API_KEY' : 'OPENAI_API_KEY';
143
168
  if (!process.env[env]) errors.push(`voice provider ${cfg.voice.provider} needs ${env} (environment or a .env file next to the storyboard) — or render with draft mode to use the free local voice`);
package/src/compose.mjs CHANGED
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
+ import { renderHyperframesCards } from './hyperframes.mjs';
7
8
 
8
9
  const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
9
10
  const md = (s, accent) => esc(s).replace(/\*\*(.+?)\*\*/g, `<b style="color:${accent};font-weight:700">$1</b>`);
@@ -27,7 +28,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
27
28
  const END = !ec0 ? 0 : ec0.duration === 'auto' ? Math.max(2.5, (voiceLens.endCard ?? 0) + 0.6, readTime(ec0), ctaTime(ec0)) : ec0.duration;
28
29
  if (ec0 && END < 2.5) warn(`the end card shows for only ${END.toFixed(1)}s: too short to read its call to action — use duration: auto, or at least 2.5`);
29
30
  const in0 = cfg.intro;
30
- const INTRO = !in0 ? 0 : in0.duration === 'auto' ? Math.max(1.8, (voiceLens.intro ?? 0) + 0.8, readTime(in0)) : in0.duration;
31
+ const INTRO = !in0 ? 0 : in0.duration === 'auto' ? Math.max(cfg.hyperframes?.template === 'launch' ? 2.8 : 1.8, (voiceLens.intro ?? 0) + 0.8, readTime(in0)) : in0.duration;
31
32
  if (INTRO > 8) warn(`the intro card plays ${INTRO.toFixed(1)}s before anything happens: viewers leave during long intros — shorten its say or bullets (aim for 6–8s)`);
32
33
  const cards = []; // section cards: { i, t0, t1, card }
33
34
 
@@ -74,7 +75,11 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
74
75
  if (s.card) {
75
76
  // a section card plays over the step's first frame, before the step
76
77
  const C = cardLen(s.card, voiceLens.cards?.[i], 2.4);
77
- pieces.push({ raw: [r.start, r.start], o0: acc, o1: acc + C });
78
+ // while the card fades in, the page under it is still the last one shown (else the new step's
79
+ // page flashes through the fade); it changes once the card covers the screen
80
+ const prev = pieces.at(-1)?.raw[1];
81
+ if (prev != null) pieces.push({ raw: [prev, prev], o0: acc, o1: acc + 0.25 });
82
+ pieces.push({ raw: [r.start, r.start], o0: acc + (prev != null ? 0.25 : 0), o1: acc + C });
78
83
  cards.push({ i, t0: acc, t1: acc + C, card: s.card, thinkAt: C - (s.card.think ?? 0) - 0.3 });
79
84
  acc += C;
80
85
  }
@@ -419,9 +424,6 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
419
424
  }
420
425
  };
421
426
  const ec = cfg.endCard;
422
- if (ec) await animate(card(ec, ec.logoFile ?? path.join(cfg.work, 'logo.png')), END + 0.5, path.join(cfg.work, 'endcard'));
423
- for (const [k, c] of cards.entries()) await animate(card(c.card, c.card.logoFile ?? '', { thinkAt: c.thinkAt }), c.t1 - c.t0, path.join(cfg.work, `card${k}`));
424
- if (in0) await animate(card(in0, in0.logoFile ?? path.join(cfg.work, 'intro-logo.png')), INTRO, path.join(cfg.work, 'intro'));
425
427
  if (cfg.frame) {
426
428
  // three layers: the backdrop (a picture, or the page itself blurred), the window chrome (shadow and
427
429
  // title bar), and the page with rounded bottom corners
@@ -467,6 +469,74 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
467
469
  await pg.screenshot({ path: path.join(cfg.work, 'frame-mask.png') });
468
470
  await pg.setViewportSize({ width: W, height: H });
469
471
  }
472
+
473
+ // what each HyperFrames card is about: its text, when its narration plays (card seconds), and
474
+ // pictures from the recording (the page it leads into, blurred, as a backdrop; for a launch intro, the
475
+ // first page sharp, and where the app shows the intro's logo, so the logo can land there)
476
+ function hfJobs() {
477
+ const launch = cfg.hyperframes.template === 'launch';
478
+ const seqAt = (t) => path.join(SEQ, `${String(Math.min(nFrames - 1, Math.max(0, Math.round(t * FPS)))).padStart(5, '0')}.jpg`);
479
+ const blurred = (t, name) => {
480
+ if (cfg.hyperframes.backdrop === false || !nFrames) return null;
481
+ const f = path.join(cfg.work, `${name}-backdrop.jpg`);
482
+ execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', seqAt(t), '-vf', `scale=${even(W / 4)}:-2,gblur=sigma=6`, '-q:v', '4', f]);
483
+ return f;
484
+ };
485
+ const sizeOf = (f) => {
486
+ try {
487
+ const [w, h] = execFileSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', f]).toString().trim().split(',').map(Number);
488
+ return w && h ? { w, h } : null;
489
+ } catch { return null; }
490
+ };
491
+ const voice = (text, start, len) => (text && len ? { text, start, len } : null);
492
+ const jobs = [];
493
+ if (in0) {
494
+ const logo = [in0.logoFile, path.join(cfg.work, 'intro-logo.png')].find((f) => f && fs.existsSync(f)) ?? null;
495
+ const target = launch && logo && tl.introLogo ? onScreen(tl.introLogo, INTRO) : null;
496
+ jobs.push({ name: 'intro', kind: 'intro', seconds: INTRO, card: in0, voice: voice(in0.say, 0.15, voiceLens.intro), image: in0.imageFile,
497
+ logo, logoSize: target ? { w: target.w, h: target.h } : logo && sizeOf(logo), logoTarget: target,
498
+ page: launch && nFrames ? seqAt(INTRO) : null, backdrop: launch ? null : blurred(INTRO + 0.05, 'intro') });
499
+ }
500
+ for (const [k, c] of cards.entries()) {
501
+ jobs.push({ name: `card${k}`, kind: 'section', seconds: c.t1 - c.t0, card: c.card, voice: voice(c.card.say, 0.15, voiceLens.cards?.[c.i]),
502
+ index: k, of: cards.length, thinkAt: c.thinkAt, image: c.card.imageFile, backdrop: blurred(c.t1 + 0.05, `card${k}`) });
503
+ }
504
+ if (ec) {
505
+ const logo = [ec.logoFile, path.join(cfg.work, 'logo.png')].find((f) => f && fs.existsSync(f)) ?? null;
506
+ // the end card starts as the video ends (launch: over it, at VIDEO; lesson: a beat before)
507
+ jobs.push({ name: 'endcard', kind: 'end', seconds: launch ? END + 0.05 : END + 0.5, card: ec, voice: voice(ec.say, launch ? 0.05 : 0.3, voiceLens.endCard),
508
+ image: ec.imageFile, logo, logoSize: logo && sizeOf(logo), backdrop: launch ? null : blurred(VIDEO - 0.3, 'endcard') });
509
+ }
510
+ return jobs;
511
+ }
512
+
513
+ // the cards: HyperFrames compositions when asked for (`hyperframes:`), else (or for any card that
514
+ // doesn't render) the built-in ones. A launch outro is drawn with transparency over the last frame,
515
+ // which the ffmpeg pass below zooms into the page.
516
+ const hf = cfg.hyperframes ? await renderHyperframesCards(hfJobs(), {
517
+ W, H, FPS, dark: darkCards, accent, ink, font, lang: cfg.lang, labels: cfg.labels, opts: cfg.hyperframes, work: cfg.work,
518
+ frame: { FX, FY, FW, FH, ...Object.fromEntries([['frameBg', 'frame-bg.png'], ['chrome', 'frame-chrome.png'], ['url', 'frame-url0.png']]
519
+ .map(([k, f]) => [k, cfg.frame && fs.existsSync(path.join(cfg.work, f)) ? path.join(cfg.work, f) : null])) },
520
+ }, { log, warn }) : {};
521
+ const place = (name, seconds) => {
522
+ const dir = path.join(cfg.work, name), src = fs.readdirSync(hf[name].dir).filter((f) => f.endsWith(`.${hf[name].ext}`)).sort();
523
+ fs.rmSync(dir, { recursive: true, force: true });
524
+ fs.mkdirSync(dir, { recursive: true });
525
+ for (let k = 0, n = Math.ceil(seconds * FPS) + 1; k < n; k++) fs.copyFileSync(path.join(hf[name].dir, src[Math.min(k, src.length - 1)]), path.join(dir, `${String(k).padStart(4, '0')}.${hf[name].ext}`));
526
+ };
527
+ const ecAlpha = !!hf.endcard?.alpha;
528
+ if (ec) {
529
+ if (hf.endcard) place('endcard', ecAlpha ? END + 0.05 : END + 0.5);
530
+ else await animate(card(ec, ec.logoFile ?? path.join(cfg.work, 'logo.png')), END + 0.5, path.join(cfg.work, 'endcard'));
531
+ }
532
+ for (const [k, c] of cards.entries()) {
533
+ if (hf[`card${k}`]) place(`card${k}`, c.t1 - c.t0);
534
+ else await animate(card(c.card, c.card.logoFile ?? '', { thinkAt: c.thinkAt }), c.t1 - c.t0, path.join(cfg.work, `card${k}`));
535
+ }
536
+ if (in0) {
537
+ if (hf.intro) place('intro', INTRO);
538
+ else await animate(card(in0, in0.logoFile ?? path.join(cfg.work, 'intro-logo.png')), INTRO, path.join(cfg.work, 'intro'));
539
+ }
470
540
  await browser.close();
471
541
 
472
542
  // --- 5. ffmpeg --------------------------------------------------------------------------------
@@ -508,12 +578,28 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
508
578
  last = `v${idx++}`;
509
579
  }
510
580
  for (const [k, c] of cards.entries()) {
581
+ // a card right after the intro is already in place (its first frame held) while the intro fades
582
+ // out, so the intro dissolves into it instead of showing the page for a moment in between
583
+ const lead = in0 && Math.abs(c.t0 - INTRO) < 0.02 ? Math.min(0.2, INTRO) : 0, s0 = c.t0 - lead;
511
584
  inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, `card${k}`, '%04d.jpg'));
512
- graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${c.t0.toFixed(3)}/TB,fade=in:st=${c.t0.toFixed(3)}:d=0.15:alpha=1,fade=out:st=${Math.max(c.t0, c.t1 - 0.15).toFixed(3)}:d=0.15:alpha=1[sc${k}];\n` +
513
- `[${last}][sc${k}]overlay=0:0:eof_action=pass:enable='between(t,${c.t0.toFixed(3)},${c.t1.toFixed(3)})'[v${++idx}];\n`;
585
+ graph += `[${idx}:v]format=rgba,${lead ? `tpad=start_duration=${lead.toFixed(3)}:start_mode=clone,` : ''}setpts=PTS-STARTPTS+${s0.toFixed(3)}/TB,` +
586
+ `${lead ? '' : `fade=in:st=${c.t0.toFixed(3)}:d=0.15:alpha=1,`}fade=out:st=${Math.max(c.t0, c.t1 - 0.15).toFixed(3)}:d=0.15:alpha=1[sc${k}];\n` +
587
+ `[${last}][sc${k}]overlay=0:0:eof_action=pass:enable='between(t,${s0.toFixed(3)},${c.t1.toFixed(3)})'[v${++idx}];\n`;
514
588
  last = `v${idx}`;
515
589
  }
516
- if (ec) {
590
+ if (ec && ecAlpha) {
591
+ // launch outro: the last frame zooms into the page (an upscaled copy, so the slow zoom doesn't
592
+ // jitter), then the card draws itself over it
593
+ const q = `clip((on/${FPS}-0.05)/0.6,0,1)`, E = `(${q})*(${q})*(3-2*(${q}))`, Z = 2;
594
+ const zoom = cfg.frame
595
+ ? `z='${W}/(${W}-${E}*${W - FW})':x='${E}*${FX * Z}':y='${E}*${FY * Z}'`
596
+ : `z='1+0.06*${E}':x='(iw-iw/zoom)/2':y='(ih-ih/zoom)/2'`;
597
+ inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, 'endcard', '%04d.png'));
598
+ graph += `[${last}]split[pre][post];[pre]trim=end=${VIDEO.toFixed(3)},setpts=PTS-STARTPTS,format=yuv420p,setsar=1[pre1];\n` +
599
+ `[post]trim=start=${VIDEO.toFixed(3)},setpts=PTS-STARTPTS,scale=${W * Z}:${H * Z}:flags=lanczos,zoompan=${zoom}:d=1:fps=${FPS}:s=${W}x${H}[post1];\n` +
600
+ `[${idx}:v]format=rgba,setpts=PTS-STARTPTS[ec];[post1][ec]overlay=0:0:eof_action=repeat,format=yuv420p,setsar=1[post2];[pre1][post2]concat=n=2:v=1:a=0[v${++idx}];\n`;
601
+ last = `v${idx}`;
602
+ } else if (ec) {
517
603
  const st = VIDEO - 0.25;
518
604
  inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, 'endcard', '%04d.jpg'));
519
605
  graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${st.toFixed(3)}/TB,fade=in:st=${st.toFixed(3)}:d=0.3:alpha=1[ec];\n` +
package/src/config.mjs CHANGED
@@ -2,17 +2,17 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import YAML from 'yaml';
5
- import { VOICE_DEFAULTS } from './voice.mjs';
5
+ import { VOICE_DEFAULTS, kokoroVoiceFor } from './voice.mjs';
6
6
 
7
7
  // Built-in labels drawn into the video, by `lang` (e.g. es, es-MX); other languages get English
8
8
  export const LABELS = {
9
- en: { think: 'Pause and think', intro: 'Introduction', downloaded: 'Downloaded' },
10
- zh: { think: '先想一想', intro: '开场', downloaded: '已下载' },
11
- es: { think: 'Piénsalo un momento', intro: 'Introducción', downloaded: 'Descargado' },
12
- pt: { think: 'Pense um pouco', intro: 'Introdução', downloaded: 'Baixado' },
13
- fr: { think: 'Réfléchissez', intro: 'Introduction', downloaded: 'Téléchargé' },
14
- de: { think: 'Kurz nachdenken', intro: 'Einführung', downloaded: 'Heruntergeladen' },
15
- ja: { think: '考えてみよう', intro: 'はじめに', downloaded: 'ダウンロード済み' },
9
+ en: { think: 'Pause and think', intro: 'Introduction', downloaded: 'Downloaded', tryIt: 'try it' },
10
+ zh: { think: '先想一想', intro: '开场', downloaded: '已下载', tryIt: '试试看' },
11
+ es: { think: 'Piénsalo un momento', intro: 'Introducción', downloaded: 'Descargado', tryIt: 'pruébalo' },
12
+ pt: { think: 'Pense um pouco', intro: 'Introdução', downloaded: 'Baixado', tryIt: 'experimente' },
13
+ fr: { think: 'Réfléchissez', intro: 'Introduction', downloaded: 'Téléchargé', tryIt: 'essayez' },
14
+ de: { think: 'Kurz nachdenken', intro: 'Einführung', downloaded: 'Heruntergeladen', tryIt: 'ausprobieren' },
15
+ ja: { think: '考えてみよう', intro: 'はじめに', downloaded: 'ダウンロード済み', tryIt: '試してみて' },
16
16
  };
17
17
 
18
18
  // Load API keys (ELEVENLABS_API_KEY, OPENAI_API_KEY) from .env next to the storyboard
@@ -31,8 +31,8 @@ export function loadEnv(dir) {
31
31
  // What the video is for sets sensible defaults; anything in the YAML overrides them.
32
32
  export const PURPOSES = {
33
33
  pitch: { duration: null, music: { bpm: 120, volume: 1 }, sfx: true, highlight: false, stepNumbers: false, subtitles: false, chapters: false, clips: null, frame: 'aurora', pace: 'fast', follow: false },
34
- tutorial: { duration: 'auto', music: { bpm: 96, volume: 0.35 }, sfx: true, highlight: true, stepNumbers: true, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
35
- course: { duration: 'auto', music: false, sfx: true, highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
34
+ tutorial: { duration: 'auto', music: { bpm: 96, volume: 0.35 }, sfx: 'clicks', highlight: true, stepNumbers: true, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
35
+ course: { duration: 'auto', music: false, sfx: 'clicks', highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
36
36
  docs: { duration: 'auto', music: false, sfx: false, highlight: true, stepNumbers: true, subtitles: false, chapters: false, clips: ['gif', 'png'], frame: false, pace: 'normal', follow: false },
37
37
  };
38
38
 
@@ -114,7 +114,9 @@ export function loadConfig(file, { draft = false } = {}) {
114
114
  if (endCard) endCard = { duration: 'auto', ...endCard };
115
115
  // An intro title card (promos; optional for tutorials and lessons). Same fields as the end card.
116
116
  let intro = raw.intro === false ? null : raw.intro ?? null;
117
- if (intro) intro = { duration: intro.say ? 'auto' : 2.2, ...intro };
117
+ // (a HyperFrames launch intro also turns into the app window at the end: it needs a little longer)
118
+ const launchCards = raw.hyperframes && (raw.hyperframes.template ?? (purpose === 'pitch' ? 'launch' : 'lesson')) === 'launch';
119
+ if (intro) intro = { duration: intro.say ? 'auto' : launchCards ? 3.0 : 2.2, ...intro };
118
120
  for (const card of [endCard, intro]) {
119
121
  if (card?.image) card.imageFile = path.resolve(dir, card.image);
120
122
  if (card?.logo && typeof card.logo === 'string' && /\.(png|jpe?g|svg|webp)$/i.test(card.logo)) card.logoFile = path.resolve(dir, card.logo);
@@ -134,14 +136,23 @@ export function loadConfig(file, { draft = false } = {}) {
134
136
  if (frame && !['browser', 'app'].includes(frame.window)) throw new Error('frame.window must be browser or app');
135
137
  if (frame?.icon) frame.iconFile = path.resolve(dir, frame.icon);
136
138
 
139
+ // hyperframes: true | { template: lesson | launch, band, bandColors, emblem, hand, backdrop } — cards
140
+ // rendered by HyperFrames (see hyperframes.mjs); promos get the launch template, the rest the lesson one
141
+ const hf0 = raw.hyperframes;
142
+ const hyperframes = hf0 ? { template: purpose === 'pitch' ? 'launch' : 'lesson', backdrop: true, ...(typeof hf0 === 'object' ? hf0 : {}) } : null;
143
+ if (hyperframes && !['lesson', 'launch'].includes(hyperframes.template)) throw new Error('hyperframes.template must be lesson or launch');
144
+ if (hyperframes?.bandColors && !['aminoacid', 'nucleotide', 'accent'].includes(hyperframes.bandColors)) throw new Error('hyperframes.bandColors must be aminoacid, nucleotide or accent');
145
+
137
146
  let voice = null;
138
147
  if (raw.voice) {
139
148
  const provider = raw.voice.provider ?? 'kokoro';
140
149
  if (!VOICE_DEFAULTS[provider]) throw new Error(`voice.provider must be one of ${Object.keys(VOICE_DEFAULTS).join(', ')}`);
141
150
  const pace0 = PACES[pick('pace')] ?? PACES.normal;
142
- voice = { speed: pace0.voiceSpeed, volume: 1, ...VOICE_DEFAULTS[provider], ...raw.voice, provider };
151
+ // kokoro's default voice follows the narration's language (zf_001 for Chinese, af_heart otherwise)
152
+ const kokoroVoice = kokoroVoiceFor(raw);
153
+ voice = { speed: pace0.voiceSpeed, volume: 1, ...VOICE_DEFAULTS[provider], ...(provider === 'kokoro' ? { voice: kokoroVoice } : {}), ...raw.voice, provider };
143
154
  if (draft && provider !== 'kokoro') {
144
- voice = { ...voice, provider: 'kokoro', voice: raw.voice.draftVoice ?? VOICE_DEFAULTS.kokoro.voice, model: undefined };
155
+ voice = { ...voice, provider: 'kokoro', voice: raw.voice.draftVoice ?? kokoroVoice, model: undefined };
145
156
  }
146
157
  }
147
158
 
@@ -186,10 +197,13 @@ export function loadConfig(file, { draft = false } = {}) {
186
197
  lang: raw.lang ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? 'zh' : 'en'),
187
198
  labels: LABELS[String(raw.lang ?? '').toLowerCase().split(/[-_]/)[0]] ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? LABELS.zh : LABELS.en),
188
199
  frame,
200
+ hyperframes,
189
201
  follow,
190
202
  music,
191
203
  voice,
192
- sfx: pick('sfx') !== false,
204
+ // sfx: true (clicks and transition whooshes), 'clicks' (no whooshes: tutorials and lessons, where a
205
+ // sound at every card soon grates), or false
206
+ sfx: pick('sfx') === false ? false : { clicks: true, transitions: pick('sfx') !== 'clicks' },
193
207
  draft,
194
208
  out: path.resolve(dir, raw.out ?? `${name}.mp4`).replace(/(\.[^./]+)?$/, (ext) => (draft ? `.draft${ext || '.mp4'}` : ext)),
195
209
  work: path.resolve(dir, '.storyshot', name),
package/src/doctor.mjs CHANGED
@@ -5,6 +5,9 @@ import { chromium } from 'playwright';
5
5
  import { execFileSync } from 'node:child_process';
6
6
  import { createRequire } from 'node:module';
7
7
  import path from 'node:path';
8
+ import { hasFilter, hasEncoder, NEEDED, OPTIONAL } from './ffmpeg.mjs';
9
+ import { zhVoiceStatus } from './kokoro-zh.mjs';
10
+ import { hyperframesCli, HF_VERSION } from './hyperframes.mjs';
8
11
 
9
12
  const has = (cmd, args = ['--version']) => { try { execFileSync(cmd, args, { stdio: 'ignore' }); return true; } catch { return false; } };
10
13
 
@@ -47,7 +50,15 @@ export async function doctor({ log = console.log } = {}) {
47
50
 
48
51
  try {
49
52
  const v = execFileSync('ffmpeg', ['-version']).toString().match(/ffmpeg version (\S+)/)?.[1];
50
- lines.push(`✔ ffmpeg ${v ?? ''}`.trim());
53
+ // a build can lack pieces Storyshot uses: say which, and what that affects
54
+ const missing = [...NEEDED.filters.filter((f) => !hasFilter(f)), ...NEEDED.encoders.filter((e) => !hasEncoder(e))];
55
+ if (missing.length) {
56
+ lines.push(`✖ ffmpeg ${v ?? ''} is missing ${missing.join(', ')}`);
57
+ todo.push(`install an ffmpeg build with ${missing.join(', ')} (${process.platform === 'darwin' ? 'e.g. brew install ffmpeg-full, then put "$(brew --prefix ffmpeg-full)/bin" first on your PATH, as it is keg-only; or a static build from https://ffmpeg.org/download.html' : 'e.g. a static build from https://ffmpeg.org/download.html'})`);
58
+ } else lines.push(`✔ ffmpeg ${v ?? ''}`.trim());
59
+ for (const o of OPTIONAL) {
60
+ if (o.filter ? !hasFilter(o.filter) : !hasEncoder(o.encoder)) lines.push(`· ffmpeg has no ${o.filter ? `${o.filter} filter` : `${o.encoder} encoder`} — ${o.for}`);
61
+ }
51
62
  } catch {
52
63
  lines.push('✖ ffmpeg not found');
53
64
  todo.push(ffmpegInstallCommand());
@@ -62,6 +73,15 @@ export async function doctor({ log = console.log } = {}) {
62
73
  }
63
74
 
64
75
  try { await import('kokoro-js'); lines.push('✔ free local voice (kokoro)'); } catch { lines.push('· free local voice (kokoro) not installed — optional: npm i kokoro-js'); }
76
+ // Mandarin (kokoro zf_001…): its G2P runs in a Python 3.10–3.12 venv that storyshot creates on first use
77
+ const zh = zhVoiceStatus();
78
+ lines.push(zh.ready ? `✔ Chinese voice (kokoro zf_001): Python ${zh.python?.version ?? '?'} venv in ${zh.venv}`
79
+ : zh.python ? `· Chinese voice (kokoro zf_001): Python ${zh.python.version} found; storyshot sets up its venv in ${zh.venv} on first use`
80
+ : '· Chinese voice (kokoro zf_001) needs Python 3.10–3.12 — optional: brew install python@3.12, or uv python install 3.12 (or set STORYSHOT_PYTHON)');
81
+ const hf = hyperframesCli();
82
+ lines.push(hf.error ? `· HyperFrames cards (hyperframes: true) unavailable — optional: ${hf.error}`
83
+ : hf.missing ? `· HyperFrames cards (hyperframes: true): ${HF_VERSION} installs itself on first use`
84
+ : `✔ HyperFrames cards (${hf.how})`);
65
85
  lines.push(process.env.ELEVENLABS_API_KEY ? '✔ ELEVENLABS_API_KEY set' : '· ELEVENLABS_API_KEY not set — optional, for the best voice (put it in .env)');
66
86
  return { ok: !todo.length, report: lines.join('\n'), todo };
67
87
  }
package/src/ffmpeg.mjs ADDED
@@ -0,0 +1,27 @@
1
+ // What the installed ffmpeg can do: builds differ (Homebrew's default ffmpeg has no libass, so no
2
+ // `ass` filter for burned-in subtitles).
3
+ import { execFileSync } from 'node:child_process';
4
+
5
+ const cache = {};
6
+ const list = (kind) => {
7
+ if (!cache[kind]) {
8
+ try {
9
+ const out = execFileSync('ffmpeg', ['-hide_banner', `-${kind}`], { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
10
+ // " .. zoompan V->V ..." (filters) or " V....D libx264 ..." (encoders): the name is the second column
11
+ cache[kind] = new Set(out.split('\n').map((l) => l.trim().split(/\s+/)[1]).filter(Boolean));
12
+ } catch { cache[kind] = new Set(); }
13
+ }
14
+ return cache[kind];
15
+ };
16
+ export const hasFilter = (name) => list('filters').has(name);
17
+ export const hasEncoder = (name) => list('encoders').has(name);
18
+
19
+ // what Storyshot needs from ffmpeg, and what each optional piece is for
20
+ export const NEEDED = {
21
+ filters: ['zoompan', 'overlay', 'alphamerge', 'gblur', 'fade', 'tpad', 'loudnorm', 'alimiter', 'aresample', 'atempo', 'silenceremove', 'areverse', 'palettegen', 'paletteuse'],
22
+ encoders: ['libx264', 'aac'],
23
+ };
24
+ export const OPTIONAL = [
25
+ { filter: 'ass', for: '`subtitles: burn` uses its own renderer instead (the word highlight steps word by word rather than sweeping)' },
26
+ { encoder: 'libvpx-vp9', for: 'webm clips (clips: webm)' },
27
+ ];