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 +37 -10
- package/package.json +1 -2
- package/skills/storyshot/SKILL.md +18 -3
- package/src/check.mjs +3 -1
- package/src/compose.mjs +28 -9
- package/src/config.mjs +1 -1
- package/src/hyperframes.mjs +155 -35
- package/src/init.mjs +3 -2
- package/src/inspect.mjs +51 -15
- package/src/qwen-tts.mjs +10 -6
- package/src/qwen_tts.py +24 -9
- package/src/record.mjs +110 -32
- package/src/voice.mjs +5 -5
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`
|
|
390
|
-
|
|
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;
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
100
|
-
|
|
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
|
|
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
|
-
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
494
|
-
} catch {
|
|
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 +
|
|
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);
|