storyshot 0.7.0 → 0.7.2

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
@@ -211,7 +211,7 @@ Everything the agent does is also a command:
211
211
  | `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
212
  | `storyshot login <url> [--save videos/<site>-login.json]` | Log in once in a browser window and save the session. |
213
213
  | `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. |
214
+ | `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
215
  | `storyshot check videos/acme-promo.yaml` | Validate without opening a browser. |
216
216
  | `storyshot render videos/acme-promo.yaml [--draft]` | Record, then compose the video and soundtrack. |
217
217
  | `storyshot compose videos/acme-promo.yaml [--draft]` | Rebuild from the last recording, in seconds. |
@@ -326,7 +326,7 @@ You can add `exact: true`, `last: true` or `nth: 2` to any of them.
326
326
  | `move` / `hover` | `move: { text: "$0" }` or `move: { x: 0.5, y: 0.4, ms: 1200 }` |
327
327
  | `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
328
  | `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. |
329
+ | `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
330
  | `pause` | `pause: 700` (ms) |
331
331
  | `waitFor` | `waitFor: { role: button, name: Done, timeout: 120000 }` |
332
332
  | `waitForUrl` | `waitForUrl: "**/dashboard"` |
@@ -336,9 +336,9 @@ You can add `exact: true`, `last: true` or `nth: 2` to any of them.
336
336
  | `scroll` | `scroll: 600` |
337
337
  | `dismiss` | `dismiss: { role: button, name: Close }` (clicks only if visible) |
338
338
  | `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. |
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`. 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
340
  | `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`). |
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 or 5 Chinese/Japanese/Korean characters a second, or `ms`). |
342
342
  | `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
343
  | `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
344
  | `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 +386,10 @@ rather than cut off.
386
386
  ### Voiceover
387
387
 
388
388
  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.
389
+ `end_card.say`). Each line starts as its step begins (`sayAt: 0.5` starts it
390
+ half a second in; `sayAt: after` starts it, and the step's caption, once the
391
+ step's actions are done, for a line about their result) and may run until the
392
+ next line. A line that is up to 20% too long is sped up slightly.
391
393
  A line longer than that is queued after the previous line, and you get a
392
394
  warning telling you to shorten it. Music ducks under the voice, and the final
393
395
  mix is normalized to -14 LUFS.
@@ -398,7 +400,7 @@ mix is normalized to -14 LUFS.
398
400
  | `qwen` | none | Local and free, Apple Silicon Macs only: Qwen3-TTS, the most natural free Chinese voice here (`dylan`, `serena`, see below). |
399
401
  | `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
402
  | `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`. |
403
+ | `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
404
 
403
405
  **Chinese narration.** `voice: { provider: kokoro, voice: zf_001 }` speaks Mandarin with Kokoro
404
406
  v1.1-zh, free and local, and is what `kokoro` uses when the `say:` lines are Chinese and no voice is
@@ -426,7 +428,9 @@ first use) and compares what it hears with the line: Chinese by sound, so a homo
426
428
  numbers digit by digit. A take that comes out wrong, or far too long or short, is made again with another
427
429
  seed (up to 4 tries), and Storyshot warns, quoting what it heard, if a line still doesn't come out right.
428
430
  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`),
431
+ model babbling. Lines in Latin letters are read in the storyboard's `lang` when Qwen3-TTS knows it
432
+ (`en`, `es`, `fr`, `de`, `it`, `pt`), so a Spanish lesson with `lang: es` gets a Spanish reading, checked
433
+ by ear in Spanish. On other machines the same storyboard renders with `kokoro` (`zf_001`, or `draftVoice`),
430
434
  with a note.
431
435
 
432
436
  Other fields: `speed` (default 1), `volume` (default 1), `pause` (the shortest pause between the
@@ -453,7 +457,7 @@ usually shouldn't.
453
457
 
454
458
  | field | meaning |
455
459
  |---|---|
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`. |
460
+ | `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
461
  | `title` | Large product name next to the logo. |
458
462
  | `kicker` | Small label above the title, e.g. `Part 2`. |
459
463
  | `subtitle` | One line; `**bold**` gets an accent highlight. |
@@ -463,7 +467,7 @@ usually shouldn't.
463
467
  | `cta` | Dark call-to-action pill, e.g. `Start free at acme.com`. |
464
468
  | `duration` | Seconds. Default: fits the `say` line, else 1.0 (end card) or 2.2 (intro). |
465
469
  | `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. |
470
+ | `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
471
 
468
472
  ### HyperFrames cards
469
473
 
@@ -500,6 +504,29 @@ each). Any card that can't be rendered falls back to the built-in one with a
500
504
  warning. The Virgil font is © Ellinor Rapp, SIL Open Font License 1.1
501
505
  (`src/fonts/Virgil-LICENSE.md`).
502
506
 
507
+ ## Licenses of what Storyshot uses
508
+
509
+ Storyshot is MIT. What it installs and downloads:
510
+
511
+ - **npm packages** are MIT, BSD, ISC, Apache-2.0 or BlueOak, with two exceptions, both under the optional
512
+ `kokoro-js` (the `kokoro` voice):
513
+ - `phonemizer` says Apache-2.0, but it bundles [eSpeak NG](https://github.com/espeak-ng/espeak-ng),
514
+ which is GPL-3.0-or-later, compiled to WebAssembly. If you ship Storyshot inside a closed-source
515
+ product, account for that, or install without it (`npm install --omit=optional`) and narrate with
516
+ `qwen` or a cloud voice.
517
+ - `sharp` loads libvips (LGPL-3.0) as a separate library.
518
+ - **Downloaded on first use:** Chromium (through Playwright), HyperFrames (Apache-2.0), the voice and
519
+ speech models (Kokoro-82M, Qwen3-TTS, Qwen3-ASR: all Apache-2.0) and their Python packages
520
+ (mlx-audio: MIT, pypinyin: MIT). ffmpeg is your own install; Storyshot runs it as a separate program.
521
+ - **The cards** animate with the browser's own Web Animations API. The Virgil font is under the SIL Open
522
+ Font License 1.1. The music and sound effects are synthesized by Storyshot.
523
+ - **Your videos:** narration made with `kokoro` or `qwen` comes from Apache-2.0 models, so you can use it
524
+ in commercial work. The macOS `say` voices are for drafts only: Apple's license allows them for
525
+ personal, non-commercial use, and not in anything published or shared. ElevenLabs and OpenAI voices
526
+ follow those services' terms.
527
+
528
+ This is a summary, not legal advice.
529
+
503
530
  ## License
504
531
 
505
532
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyshot",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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 = [];
@@ -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);