storyshot 0.7.0 → 0.7.3

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
@@ -15,6 +15,12 @@ second or two on screen.
15
15
  The agent plans, writes and fixes the storyboard. Storyshot does the
16
16
  recording and editing, and the same storyboard always renders the same video.
17
17
 
18
+ https://github.com/user-attachments/assets/05dd9a73-2edc-46af-9b2b-a8e33c7005b4
19
+
20
+ A 68-second pitch for Storyshot, made with [HyperFrames](https://hyperframes.heygen.com) and a free
21
+ local voice (Kokoro). The request, storyboard, recording, contact sheet and render log in it, and the
22
+ 16 videos on the wall, are real Storyshot output.
23
+
18
24
  ## Gallery
19
25
 
20
26
  **[boheling.github.io/storyshot-gallery](https://boheling.github.io/storyshot-gallery/)**: promos,
@@ -211,7 +217,7 @@ Everything the agent does is also a command:
211
217
  | `storyshot init <url> [--purpose tutorial] [--topic export-pdf] [--storage videos/<site>-login.json]` | Write `videos/<site>-<kind>[-<topic>].yaml` with verified targets; the video gets the same name. |
212
218
  | `storyshot login <url> [--save videos/<site>-login.json]` | Log in once in a browser window and save the session. |
213
219
  | `storyshot inspect <url> [--storage auth.json]` | List verified targets and save screenshots. |
214
- | `storyshot inspect videos/acme-promo.yaml --after 3` | The same, for the page after steps 1–3. |
220
+ | `storyshot inspect videos/acme-promo.yaml --after 3` | The same, for the page after steps 1–3. What step 3 opened is marked ★ and listed first. |
215
221
  | `storyshot check videos/acme-promo.yaml` | Validate without opening a browser. |
216
222
  | `storyshot render videos/acme-promo.yaml [--draft]` | Record, then compose the video and soundtrack. |
217
223
  | `storyshot compose videos/acme-promo.yaml [--draft]` | Rebuild from the last recording, in seconds. |
@@ -257,7 +263,7 @@ captions: true # false keeps caption pills off
257
263
  purpose: pitch # pitch | tutorial | course | docs (sets the defaults below)
258
264
  highlight: false # spotlight targets before click/type (per step or per action too)
259
265
  stepNumbers: false # numbered caption badges
260
- 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
266
+ 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; in a vertical video the lines sit above the bottom fifth, which short-video apps cover, and the cards keep above them); uses ffmpeg's libass when it has it, else draws the words itself
261
267
  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
262
268
  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 }
263
269
  article: { title: Export a drawing, intro: … } # docs: heading and first paragraph of <out>-steps/README.md
@@ -326,7 +332,7 @@ You can add `exact: true`, `last: true` or `nth: 2` to any of them.
326
332
  | `move` / `hover` | `move: { text: "$0" }` or `move: { x: 0.5, y: 0.4, ms: 1200 }` |
327
333
  | `type` | `type: { target: { label: Email }, text: hi@acme.com, delay: 60 }`, or `type: hello` to type into whatever has focus. `clear: true` empties a pre-filled field first |
328
334
  | `drag` | `drag: { from: { css: ".slider" }, by: [{ x: -300 }, { x: 450 }] }` (several moves while held), or `to: <target or point>` |
329
- | `press` | `press: Enter`, `press: "^"`, `press: { key: Meta+K, show: true }`. When highlighting, shortcuts and special keys show an on-screen badge; plain characters don't. `show: false` hides it. |
335
+ | `press` | `press: Enter`, `press: "^"`, `press: { key: Meta+K, show: true }`. When highlighting, shortcuts and special keys show an on-screen badge next to the cursor; plain characters don't. `show: false` hides it. |
330
336
  | `pause` | `pause: 700` (ms) |
331
337
  | `waitFor` | `waitFor: { role: button, name: Done, timeout: 120000 }` |
332
338
  | `waitForUrl` | `waitForUrl: "**/dashboard"` |
@@ -336,9 +342,9 @@ You can add `exact: true`, `last: true` or `nth: 2` to any of them.
336
342
  | `scroll` | `scroll: 600` |
337
343
  | `dismiss` | `dismiss: { role: button, name: Close }` (clicks only if visible) |
338
344
  | `hide` | `hide: '[id*="QSI"], .chat-widget'` hides pop-ups, survey prompts, feedback tabs and chat widgets for the whole video, including ones that appear late (use it in `setup`) |
339
- | `mark` | `mark: "automatic zoom, captions"` sweeps a highlighter over that phrase, even inside a paragraph (scrolling to it only if it's off screen, and below a sticky header). `mark: { css: "table tr:nth-child(3)" }` marks any target with a box. Options: `ms` (sweep time, default 700), `color` (default: theme accent), `box: true/false`. Marks stay until the step ends. |
345
+ | `mark` | `mark: "automatic zoom, captions"` sweeps a highlighter over that phrase, even inside a paragraph (scrolling to it only if it's off screen, and below a sticky header). `mark: { css: "table tr:nth-child(3)" }` marks any target with a box. Options: `ms` (sweep time, default 700), `color` (default: theme accent), `box: true/false`. Only text the viewer can see counts: a copy that is hidden, transparent, collapsed or covered is skipped. Marks stay until the step ends, and a mark whose text scrolls out of view (out of its panel, under a header) disappears with it. |
340
346
  | `unmark` | `unmark: true` clears the marks (and notes) earlier |
341
- | `note` | `note: { target: { label: Database }, text: Curated proteins only }` puts a short label with an arrow next to the element, on the side with the most room (or `side: left/right/top/bottom`). It stays until the step ends and gets reading time (about 3 words a second, or `ms`). |
347
+ | `note` | `note: { target: { label: Database }, text: Curated proteins only }` puts a short label with an arrow next to the element, on the side with the most room (or `side: left/right/top/bottom`). It stays until the step ends and gets reading time (about 3 words or 5 Chinese/Japanese/Korean characters a second, or `ms`). |
342
348
  | `paste` | `paste: { target: { label: JSON }, text: '{"a": 1}' }` pastes text in one go through the real clipboard (with a ⌘V badge), so editors react as to a real paste; `paste: "..."` pastes into whatever has focus |
343
349
  | `slide` | `slide: { target: { label: Quality }, to: 0.2 }` drags a slider to a position along its track (0 = start, 1 = end), found from the element, not pixels. `value: 3` drags it to that value instead, when the slider reports its range (an `<input type=range>`, or an ARIA slider such as Desmos's; `inspect` shows the range). The target can be the slider or its knob |
344
350
  | `select` | `select: { target: { label: Database }, option: "UniProtKB/Swiss-Prot" }` picks an option in a dropdown (by its visible text, or part of it). `inspect` lists each dropdown's options. |
@@ -386,8 +392,10 @@ rather than cut off.
386
392
  ### Voiceover
387
393
 
388
394
  Add a `voice` block, then give steps a `say:` line (and optionally
389
- `end_card.say`). Each line starts as its step begins (`sayAt` shifts it) and may
390
- run until the next line. A line that is up to 20% too long is sped up slightly.
395
+ `end_card.say`). Each line starts as its step begins (`sayAt: 0.5` starts it
396
+ half a second in; `sayAt: after` starts it, and the step's caption, once the
397
+ step's actions are done, for a line about their result) and may run until the
398
+ next line. A line that is up to 20% too long is sped up slightly.
391
399
  A line longer than that is queued after the previous line, and you get a
392
400
  warning telling you to shorten it. Music ducks under the voice, and the final
393
401
  mix is normalized to -14 LUFS.
@@ -398,7 +406,7 @@ mix is normalized to -14 LUFS.
398
406
  | `qwen` | none | Local and free, Apple Silicon Macs only: Qwen3-TTS, the most natural free Chinese voice here (`dylan`, `serena`, see below). |
399
407
  | `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. |
400
408
  | `openai` | `OPENAI_API_KEY` | `voice: alloy`, `model: gpt-4o-mini-tts`; optional `instructions` for tone. |
401
- | `say` | none | macOS built-in voices, robotic; fine for drafts. For Chinese, use `qwen` (Apple Silicon) or `kokoro` with `zf_001` rather than `Tingting`. |
409
+ | `say` | none | macOS built-in voices, robotic; for drafts only: Apple's license allows them for personal, non-commercial use, and not in anything you publish or share. For Chinese, use `qwen` (Apple Silicon) or `kokoro` with `zf_001` rather than `Tingting`. |
402
410
 
403
411
  **Chinese narration.** `voice: { provider: kokoro, voice: zf_001 }` speaks Mandarin with Kokoro
404
412
  v1.1-zh, free and local, and is what `kokoro` uses when the `say:` lines are Chinese and no voice is
@@ -426,7 +434,9 @@ first use) and compares what it hears with the line: Chinese by sound, so a homo
426
434
  numbers digit by digit. A take that comes out wrong, or far too long or short, is made again with another
427
435
  seed (up to 4 tries), and Storyshot warns, quoting what it heard, if a line still doesn't come out right.
428
436
  This adds about 0.2 s per line. A scientific number like `2e-105` is spelled out in Chinese, because it sets the
429
- model babbling. On other machines the same storyboard renders with `kokoro` (`zf_001`, or `draftVoice`),
437
+ model babbling. Lines in Latin letters are read in the storyboard's `lang` when Qwen3-TTS knows it
438
+ (`en`, `es`, `fr`, `de`, `it`, `pt`), so a Spanish lesson with `lang: es` gets a Spanish reading, checked
439
+ by ear in Spanish. On other machines the same storyboard renders with `kokoro` (`zf_001`, or `draftVoice`),
430
440
  with a note.
431
441
 
432
442
  Other fields: `speed` (default 1), `volume` (default 1), `pause` (the shortest pause between the
@@ -453,7 +463,7 @@ usually shouldn't.
453
463
 
454
464
  | field | meaning |
455
465
  |---|---|
456
- | `logo` | A target on the page (captured as an image, e.g. `{ alt: Acme logo }`) or a path to an image file. If the logo image already contains the wordmark, leave out `title`. |
466
+ | `logo` | A target on the page (captured as an image, e.g. `{ alt: Acme logo }`; an inline SVG logo is `{ css: "header a svg" }` or similar) or a path to a PNG, JPG or SVG file. With a `title` too, the title shows under the logo; if the logo already contains the wordmark, leave out `title`. |
457
467
  | `title` | Large product name next to the logo. |
458
468
  | `kicker` | Small label above the title, e.g. `Part 2`. |
459
469
  | `subtitle` | One line; `**bold**` gets an accent highlight. |
@@ -463,7 +473,7 @@ usually shouldn't.
463
473
  | `cta` | Dark call-to-action pill, e.g. `Start free at acme.com`. |
464
474
  | `duration` | Seconds. Default: fits the `say` line, else 1.0 (end card) or 2.2 (intro). |
465
475
  | `say` | Voiceover line played over the end card. |
466
- | `emblem` | With `hyperframes`: one to three short monospace lines over the title (an alignment, a formula, a line of code); `false` for none. |
476
+ | `emblem` | With `hyperframes`: one to three short monospace lines over the title (an alignment, a formula, a line of code); `false` for none. On HyperFrames cards, text too long for the frame (a title, subtitle, emblem or call to action) wraps and then shrinks until it fits. |
467
477
 
468
478
  ### HyperFrames cards
469
479
 
@@ -500,6 +510,29 @@ each). Any card that can't be rendered falls back to the built-in one with a
500
510
  warning. The Virgil font is © Ellinor Rapp, SIL Open Font License 1.1
501
511
  (`src/fonts/Virgil-LICENSE.md`).
502
512
 
513
+ ## Licenses of what Storyshot uses
514
+
515
+ Storyshot is MIT. What it installs and downloads:
516
+
517
+ - **npm packages** are MIT, BSD, ISC, Apache-2.0 or BlueOak, with two exceptions, both under the optional
518
+ `kokoro-js` (the `kokoro` voice):
519
+ - `phonemizer` says Apache-2.0, but it bundles [eSpeak NG](https://github.com/espeak-ng/espeak-ng),
520
+ which is GPL-3.0-or-later, compiled to WebAssembly. If you ship Storyshot inside a closed-source
521
+ product, account for that, or install without it (`npm install --omit=optional`) and narrate with
522
+ `qwen` or a cloud voice.
523
+ - `sharp` loads libvips (LGPL-3.0) as a separate library.
524
+ - **Downloaded on first use:** Chromium (through Playwright), HyperFrames (Apache-2.0), the voice and
525
+ speech models (Kokoro-82M, Qwen3-TTS, Qwen3-ASR: all Apache-2.0) and their Python packages
526
+ (mlx-audio: MIT, pypinyin: MIT). ffmpeg is your own install; Storyshot runs it as a separate program.
527
+ - **The cards** animate with the browser's own Web Animations API. The Virgil font is under the SIL Open
528
+ Font License 1.1. The music and sound effects are synthesized by Storyshot.
529
+ - **Your videos:** narration made with `kokoro` or `qwen` comes from Apache-2.0 models, so you can use it
530
+ in commercial work. The macOS `say` voices are for drafts only: Apple's license allows them for
531
+ personal, non-commercial use, and not in anything published or shared. ElevenLabs and OpenAI voices
532
+ follow those services' terms.
533
+
534
+ This is a summary, not legal advice.
535
+
503
536
  ## License
504
537
 
505
538
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyshot",
3
- "version": "0.7.0",
3
+ "version": "0.7.3",
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": {
@@ -39,7 +39,6 @@
39
39
  "zod": "^4.6.5"
40
40
  },
41
41
  "optionalDependencies": {
42
- "gsap": "^3.14.2",
43
42
  "kokoro-js": "^1.2.1",
44
43
  "roughjs": "^4.6.6"
45
44
  }
@@ -96,8 +96,11 @@ frames or stock footage presented as a recording of their app.
96
96
  models on first use. Storyshot listens to each take and makes it again if
97
97
  it comes out garbled; a `⚠ qwen: … sounds like "…"` warning means a line
98
98
  still came out wrong after 4 tries: rephrase it (spell out a number, say
99
- `一格` for `1 格`). Elsewhere a `qwen` storyboard renders with kokoro. For other
100
- languages, use ElevenLabs.
99
+ `一格` for `1 格`). Elsewhere a `qwen` storyboard renders with kokoro.
100
+ `qwen` also reads Spanish, French, German, Italian and Portuguese: set the
101
+ storyboard's `lang` (`lang: es`). For other languages, use ElevenLabs.
102
+ Never publish a video narrated with macOS `say`: Apple's license allows its
103
+ voices only for personal, non-commercial use, so keep it for drafts.
101
104
  3. **Login.** If the flow needs a logged-in page:
102
105
  - Tell the user a browser window will open and that they should log in
103
106
  there, preferably with a demo account (the video shows whatever is on
@@ -127,7 +130,13 @@ frames or stock footage presented as a recording of their app.
127
130
  3. For states you can only reach by clicking (an editor after an upload, an
128
131
  open menu, a modal), write the steps up to that point, then call
129
132
  `inspect_page` with `storyboard` + `after_step: N`. It runs steps 1..N fast
130
- (about 10 s) and lists the targets on the page at that moment.
133
+ (about 10 s) and lists the targets on the page at that moment. What step N
134
+ opened (a menu, a dialog, a panel) is marked ★ and listed first. If a step
135
+ fails, the result lists the targets on the page at that moment.
136
+ - Inspect lists up to 150 targets. When it says "… and N more not listed",
137
+ open the panel you need with a step and inspect after that step.
138
+ - `(covered by something on top right now)` means a dialog or banner sits over
139
+ that element: a click lands on the cover. Close or `hide` it first.
131
140
  4. Copy targets exactly as printed: `{ role: "button", name: "Convert", exact: true }`.
132
141
  - `name` is the accessible name. It can differ from the label on screen;
133
142
  inspect shows `(on screen: "Export Image")` when it does. Never write a
@@ -161,6 +170,12 @@ small controls are readable. Your job is the structure:
161
170
  its own `say:` explaining **what** it does and **why this value**. Never
162
171
  "fill in the form and submit" in one line. `check` warns when a tutorial
163
172
  step does more than two things.
173
+ - **Say it when it happens.** A line starts with its step, so the viewer hears
174
+ "now the line is flat" before the drag has flattened it. When a line is about
175
+ what the step's actions produce, give that step `sayAt: after`: its line and
176
+ caption start once the actions are done (needs `duration: auto`, the default
177
+ for tutorials and lessons). Lines that say what you're about to do ("click
178
+ Send") stay as they are.
164
179
  - **Show, then explain the result.** After an action that produces output
165
180
  (a results table, a number, a plot, a status), add a step that only looks:
166
181
  `mark` the value or row you're talking about (`mark: "E value"`,
package/src/check.mjs CHANGED
@@ -98,6 +98,8 @@ export function check(file, { draft = false } = {}) {
98
98
  else target(s.zoom.target, `${where} zoom.target`);
99
99
  }
100
100
  if (s.hidden && (s.caption || s.say)) warnings.push(`${where}: hidden steps are cut from the video, so caption/say are ignored`);
101
+ if (s.sayAt != null && s.sayAt !== 'after' && !(typeof s.sayAt === 'number' && s.sayAt >= 0)) errors.push(`${where}: sayAt must be seconds into the step (e.g. 0.5) or "after" (once the step's actions are done)`);
102
+ if (s.sayAt === 'after' && typeof (s.duration ?? raw.duration) === 'number') warnings.push(`${where}: sayAt: after needs duration: auto (a fixed-length step has no time after its actions)`);
101
103
  if (!Array.isArray(s.actions ?? [])) errors.push(`${where}: actions must be a list`);
102
104
  for (const [j, a] of (s.actions ?? []).entries()) checkAction(a, `${where} action ${j + 1}`);
103
105
  if (s.card) unknown(s.card, END, `${where} card`);
@@ -181,7 +183,7 @@ export function check(file, { draft = false } = {}) {
181
183
  const nextK = vis.findIndex((v, j) => j > k && v.s.say);
182
184
  const span = vis.slice(k, nextK < 0 ? undefined : nextK).map((v) => v.s.duration);
183
185
  if (!span.every((d) => typeof d === 'number')) continue;
184
- const room = span.reduce((a, d) => a + d, 0) - (s.sayAt ?? 0.15) + (nextK < 0 && cfg.endCard ? 2.5 : 0);
186
+ const room = span.reduce((a, d) => a + d, 0) - (typeof s.sayAt === 'number' ? s.sayAt : 0.15) + (nextK < 0 && cfg.endCard ? 2.5 : 0);
185
187
  const need = secs(s.say);
186
188
  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)`);
187
189
  }
package/src/compose.mjs CHANGED
@@ -25,7 +25,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
25
25
  const cardLen = (c, voice, min) => (c.duration === 'auto' || c.duration == null ? Math.max(min, (voice ?? 0) + 0.9, readTime(c)) + (c.think ?? 0) : c.duration);
26
26
  // the end card carries the call to action: long enough to read it (and for a command to type itself out)
27
27
  const ctaTime = (c) => (c?.cta ? 0.8 + String(c.cta).length * 0.045 + 1.6 : 0);
28
- const END = !ec0 ? 0 : ec0.duration === 'auto' ? Math.max(2.5, (voiceLens.endCard ?? 0) + 0.6, readTime(ec0), ctaTime(ec0)) : ec0.duration;
28
+ const END = !ec0 ? 0 : ec0.duration === 'auto' ? Math.max(2.5, (voiceLens.endCard ?? 0) + 0.6 + cfg.pace.tail * 0.6, readTime(ec0), ctaTime(ec0)) : ec0.duration;
29
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`);
30
30
  const in0 = cfg.intro;
31
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;
@@ -56,21 +56,27 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
56
56
  const M = parts.filter((p) => !p[2]).reduce((x, p) => x + p[1] - p[0], 0);
57
57
  const Wt = parts.filter((p) => p[2]).reduce((x, p) => x + p[1] - p[0], 0);
58
58
  let D, waitBudget, motionBudget, hold = 0;
59
+ // when the line starts: `sayAt: after` once the step's actions are done (a line about their result:
60
+ // "now the line is flat"), else this many seconds into the step
61
+ let sayAt = typeof s.sayAt === 'number' ? s.sayAt : 0.15;
59
62
  if (s.duration === 'auto') {
60
- const voiceNeed = voiceLens.steps[i] != null ? voiceLens.steps[i] + (s.sayAt ?? 0.15) + cfg.pace.tail : 0;
61
63
  waitBudget = Math.min(Wt, cfg.maxWait);
62
64
  motionBudget = M;
65
+ if (s.sayAt === 'after') sayAt = M + waitBudget + 0.2;
66
+ const voiceNeed = voiceLens.steps[i] != null ? voiceLens.steps[i] + sayAt + cfg.pace.tail : 0;
63
67
  // a silent step holds its result too (docs GIFs: long enough to see it before the loop restarts)
64
68
  const quiet = s.say ? 0 : cfg.clips.length ? 1.5 : cfg.pace.tail;
65
69
  D = Math.max(M + waitBudget + quiet, voiceNeed, 0.8);
66
70
  let extra = D - M - waitBudget;
67
- const stretch = Math.min(extra, Wt - waitBudget);
71
+ // more of the waiting first, then hold the last frame (after the line about the result: only hold)
72
+ const stretch = s.sayAt === 'after' ? 0 : Math.min(extra, Wt - waitBudget);
68
73
  waitBudget += stretch; extra -= stretch;
69
74
  hold = extra;
70
75
  } else {
71
76
  D = s.duration;
72
77
  waitBudget = M + Wt <= D ? Wt * D / (M + Wt) : M === 0 ? D : Math.min(Wt, Math.max(D - M, 0.15 * D));
73
78
  motionBudget = D - waitBudget;
79
+ if (s.sayAt === 'after') warn(`step ${i + 1}: sayAt: after needs duration: auto (a fixed-length step has no time after its actions) — the line starts with the step`);
74
80
  }
75
81
  if (s.card) {
76
82
  // a section card plays over the step's first frame, before the step
@@ -83,7 +89,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
83
89
  cards.push({ i, t0: acc, t1: acc + C, card: s.card, thinkAt: C - (s.card.think ?? 0) - 0.3 });
84
90
  acc += C;
85
91
  }
86
- const g = { i, step: s, o0: acc, o1: acc + D, zoomBox: r.zoomBox, afterHidden, hold,
92
+ const g = { i, step: s, o0: acc, o1: acc + D, sayAt, zoomBox: r.zoomBox, afterHidden, hold,
87
93
  motionSpeed: M && motionBudget ? M / motionBudget : 1, waitSpeed: Wt && waitBudget ? Wt / waitBudget : 1 };
88
94
  for (const [a, b, w] of parts) {
89
95
  const len = (b - a) / (w ? g.waitSpeed : g.motionSpeed);
@@ -173,7 +179,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
173
179
  const spoken = [
174
180
  ...(in0 ? [[0, INTRO]] : []), // cards are their own pictures (the frames under them don't show)
175
181
  ...cards.map((c) => [c.t0, c.t1]),
176
- ...segs.filter((g) => voiceLens.steps[g.i] != null).map((g) => [g.o0 + (g.step.sayAt ?? 0.15), g.o0 + (g.step.sayAt ?? 0.15) + voiceLens.steps[g.i]]),
182
+ ...segs.filter((g) => voiceLens.steps[g.i] != null).map((g) => [g.o0 + g.sayAt, g.o0 + g.sayAt + voiceLens.steps[g.i]]),
177
183
  ];
178
184
  let from = null;
179
185
  const report = (to) => {
@@ -360,7 +366,8 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
360
366
  warn(`step ${g.i + 1}: the ${want} caption would cover ${hit.what}, so it was moved to the ${other}` + (explicit ? ` (despite captionPosition: ${explicit})` : '') + ` — set captionPosition: ${other} to make this explicit`);
361
367
  } else if (hit) warn(`step ${g.i + 1}: the caption covers ${hit.what} at both top and bottom — shorten the caption, or change the zoom`);
362
368
  if (prev && n === prev.n && prev.pos === pos) prev.t[1] = g.o1;
363
- else captions.push({ text: g.step.caption, t: [g.o0, g.o1], pos, n, h });
369
+ // a line about the step's result (sayAt: after) shows its caption with it, not before the result is there
370
+ else captions.push({ text: g.step.caption, t: [g.step.sayAt === 'after' ? g.o0 + Math.max(0, g.sayAt - 0.1) : g.o0, g.o1], pos, n, h });
364
371
  }
365
372
  for (const [i, c] of (cfg.captions ? captions : []).entries()) await shot(bubble(c.text, c.n, c.pos), path.join(cfg.work, `cap${i}.png`), true);
366
373
  // Intro and end cards are short animations (logo pops, title rises, the highlight sweeps, a command
@@ -488,10 +495,22 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
488
495
  return f;
489
496
  };
490
497
  const sizeOf = (f) => {
498
+ if (/\.svg$/i.test(f)) {
499
+ // ffprobe can't read SVG: its shape from width/height or the viewBox, as large as the cards draw a logo
500
+ const tag = fs.readFileSync(f, 'utf8').match(/<svg\b[^>]*>/i)?.[0] ?? '';
501
+ const attr = (a) => parseFloat(tag.match(new RegExp(`\\s${a}\\s*=\\s*["']?([\\d.]+)(px)?["'\\s>]`, 'i'))?.[1]);
502
+ const vb = tag.match(/viewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)/i);
503
+ const [w, h] = attr('width') > 0 && attr('height') > 0 ? [attr('width'), attr('height')] : vb ? [+vb[1], +vb[2]] : [0, 0];
504
+ if (!(w > 0 && h > 0)) { warn(`the logo ${path.basename(f)} has no width/height or viewBox, so the cards leave it out — add a viewBox, or use a PNG`); return null; }
505
+ const k = Math.min((W * 0.42) / w, (H * 0.17) / h) / 3.2;
506
+ return { w: w * k, h: h * k };
507
+ }
491
508
  try {
492
509
  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);
493
- return w && h ? { w, h } : null;
494
- } catch { return null; }
510
+ if (w && h) return { w, h };
511
+ } catch {}
512
+ warn(`could not read the size of the logo ${path.basename(f)}, so the cards leave it out — use a PNG, JPG or SVG`);
513
+ return null;
495
514
  };
496
515
  const voice = (text, start, len) => (text && len ? { text, start, len } : null);
497
516
  const jobs = [];
@@ -519,7 +538,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
519
538
  // doesn't render) the built-in ones. A launch outro is drawn with transparency over the last frame,
520
539
  // which the ffmpeg pass below zooms into the page.
521
540
  const hf = cfg.hyperframes ? await renderHyperframesCards(hfJobs(), {
522
- W, H, FPS, dark: darkCards, accent, ink, font, lang: cfg.lang, labels: cfg.labels, opts: cfg.hyperframes, work: cfg.work,
541
+ W, H, FPS, dark: darkCards, accent, ink, font, lang: cfg.lang, labels: cfg.labels, opts: cfg.hyperframes, work: cfg.work, subs: cfg.subtitles === 'burn',
523
542
  frame: { FX, FY, FW, FH, ...Object.fromEntries([['frameBg', 'frame-bg.png'], ['chrome', 'frame-chrome.png'], ['url', 'frame-url0.png']]
524
543
  .map(([k, f]) => [k, cfg.frame && fs.existsSync(path.join(cfg.work, f)) ? path.join(cfg.work, f) : null])) },
525
544
  }, { log, warn }) : {};
@@ -675,7 +694,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
675
694
  voice: [
676
695
  ...(in0?.say ? [{ text: in0.say, start: 0.15, where: 'intro' }] : []),
677
696
  ...cards.filter((c) => c.card.say).map((c) => ({ text: c.card.say, start: c.t0 + 0.15, where: `step ${c.i + 1} card` })),
678
- ...segs.filter((g) => g.step.say).map((g) => ({ text: g.step.say, start: g.o0 + (g.step.sayAt ?? 0.15), where: `step ${g.i + 1}` })),
697
+ ...segs.filter((g) => g.step.say).map((g) => ({ text: g.step.say, start: g.o0 + g.sayAt, where: `step ${g.i + 1}` })),
679
698
  ...(ec?.say ? [{ text: ec.say, start: VIDEO + 0.05, where: 'end card' }] : []),
680
699
  ],
681
700
  };
package/src/config.mjs CHANGED
@@ -154,7 +154,7 @@ export function loadConfig(file, { draft = false } = {}) {
154
154
  // kokoro's and qwen's default voices follow the narration's language (zf_001 / serena for Chinese)
155
155
  const kokoroVoice = kokoroVoiceFor(raw);
156
156
  const byLang = { kokoro: kokoroVoice, qwen: qwenVoiceFor(raw) }[provider];
157
- voice = { speed: pace0.voiceSpeed, volume: 1, pause: pace0.sentencePause, ...VOICE_DEFAULTS[provider], ...(byLang ? { voice: byLang } : {}), ...raw.voice, provider };
157
+ voice = { speed: pace0.voiceSpeed, volume: 1, pause: pace0.sentencePause, ...VOICE_DEFAULTS[provider], ...(byLang ? { voice: byLang } : {}), ...(raw.lang ? { lang: raw.lang } : {}), ...raw.voice, provider };
158
158
  if (!(typeof voice.pause === 'number' && voice.pause >= 0 && voice.pause <= 3)) throw new Error('voice.pause is the shortest pause between the sentences of a line, in seconds, 0–3 (0 leaves the voice as it is)');
159
159
  if (provider === 'qwen') {
160
160
  const v = qwenVoice(voice.voice);