storyshot 0.5.0 → 0.7.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 +37 -14
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +81 -12
- package/src/audio.mjs +9 -4
- package/src/check.mjs +46 -2
- package/src/compose.mjs +5 -0
- package/src/config.mjs +31 -15
- package/src/doctor.mjs +6 -0
- package/src/init.mjs +10 -2
- package/src/inspect.mjs +13 -5
- package/src/qwen-tts.mjs +155 -0
- package/src/qwen_tts.py +186 -0
- package/src/record.mjs +99 -33
- package/src/voice.mjs +139 -22
package/README.md
CHANGED
|
@@ -153,6 +153,7 @@ Rendering is free and runs locally. Only the voiceover can cost money:
|
|
|
153
153
|
|---|---|---|
|
|
154
154
|
| none | free | captions only |
|
|
155
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
|
+
| `qwen` | free | local, Apple Silicon Macs only; the most natural free Chinese voice (`dylan`, `serena`); every take is checked by a speech recognizer and made again if it comes out wrong; downloads ~3.3 GB of models and needs Python 3.10–3.12 |
|
|
156
157
|
| ElevenLabs | your credits | the most natural voice; many languages including Chinese. Put `ELEVENLABS_API_KEY` in `.env`. |
|
|
157
158
|
| OpenAI | your credits | `OPENAI_API_KEY` |
|
|
158
159
|
|
|
@@ -190,7 +191,7 @@ from `storyshot guide`.
|
|
|
190
191
|
| `inspect_page` | Verified click targets and a screenshot, for a URL or for the state after step N of a storyboard. Reports login walls. |
|
|
191
192
|
| `login` | Opens a browser window where the **user** logs in, and saves the session. |
|
|
192
193
|
| `init_storyboard` | Writes a starter storyboard with verified targets. |
|
|
193
|
-
| `check_storyboard` | Validates in about a second: typos with "did you mean", action and target shapes, `TODO`s, keys, the session file. |
|
|
194
|
+
| `check_storyboard` | Validates in about a second: typos with "did you mean", action and target shapes, `TODO`s, keys, the session file, and narration that sounds machine-written ("not just X, but Y", "seamless", 赋能…) or runs on without a breath. |
|
|
194
195
|
| `render_video` / `render_status` | Renders in the background and returns the MP4 path, warnings, a per-step speed log and the preview image. If the render isn't done within 45 s, it returns a `job_id`, so client tool timeouts never kill a render. |
|
|
195
196
|
|
|
196
197
|
Any other MCP client: run `npx -y storyshot mcp` (stdio), and point the agent at the playbook (`storyshot guide`). When run from a git
|
|
@@ -247,7 +248,7 @@ viewport: { width: 1600, height: 900 } # CSS px the site is laid out at
|
|
|
247
248
|
output: { width: 1920, height: 1080, fps: 30 } # same aspect ratio; use 1080x1920 with a 900x1600 viewport for vertical
|
|
248
249
|
theme: { accent: "#d9f75b", ink: "#0f172a", font: Inter }
|
|
249
250
|
music: { bpm: 120, volume: 1 } # or { file: track.mp3 }, or false
|
|
250
|
-
sfx: true # click / whoosh / riser sounds; clicks = no whooshes on cards and page changes (tutorial and course default); false = none
|
|
251
|
+
sfx: true # click / whoosh / riser sounds; clicks = no whooshes on cards and page changes, and no rising sound under a riser step, only its chime (tutorial and course default); false = none
|
|
251
252
|
voice: { provider: elevenlabs, voice: hpp4J3VqNfWAUOO0d1Us } # optional voiceover, see below
|
|
252
253
|
storageState: auth.json # optional Playwright login state
|
|
253
254
|
ignoreHTTPSErrors: true # internal site with a self-signed HTTPS certificate (plain http:// needs nothing)
|
|
@@ -286,7 +287,7 @@ steps:
|
|
|
286
287
|
- click: { role: button, name: Try it }
|
|
287
288
|
- caption: AI does the work
|
|
288
289
|
duration: 1.3
|
|
289
|
-
sfx: riser # rising sound + chime (or `chime`)
|
|
290
|
+
sfx: riser # rising sound + chime (or `chime`); a tutorial or lesson plays only the chime
|
|
290
291
|
actions:
|
|
291
292
|
- waitFor: { text: Done, timeout: 120000 } # waiting is compressed automatically
|
|
292
293
|
- card: { kicker: Part 2, title: Read the results } # a section card before this step (tutorials); becomes a chapter
|
|
@@ -331,15 +332,15 @@ You can add `exact: true`, `last: true` or `nth: 2` to any of them.
|
|
|
331
332
|
| `waitForUrl` | `waitForUrl: "**/dashboard"` |
|
|
332
333
|
| `networkIdle` | `networkIdle: true` |
|
|
333
334
|
| `goto` | `goto: /pricing` |
|
|
334
|
-
| `scrollTo` | `scrollTo: { css: "#pricing", ms: 1200 }` |
|
|
335
|
+
| `scrollTo` | `scrollTo: { css: "#pricing", ms: 1200 }` scrolls the target to the top of the window, below a sticky header |
|
|
335
336
|
| `scroll` | `scroll: 600` |
|
|
336
337
|
| `dismiss` | `dismiss: { role: button, name: Close }` (clicks only if visible) |
|
|
337
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`) |
|
|
338
|
-
| `mark` | `mark: "automatic zoom, captions"` sweeps a highlighter over that phrase, even inside a paragraph (scrolling to it if
|
|
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
340
|
| `unmark` | `unmark: true` clears the marks (and notes) earlier |
|
|
340
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
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 |
|
|
342
|
-
| `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 |
|
|
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 |
|
|
343
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. |
|
|
344
345
|
|
|
345
346
|
`ms` sets how long the cursor takes to travel to the target (`dragMs` for each
|
|
@@ -374,7 +375,7 @@ In-page anchor links (`href="#..."`) scroll smoothly instead of jumping.
|
|
|
374
375
|
|---|---|
|
|
375
376
|
| `target` | What to center on: a target, or a point `{ x: 0.5, y: 0.3 }`. If a step action used the same target, its position at that moment is used (so you can zoom on a button that disappears after the click). Otherwise it is measured at the start of the step when the zoom begins in the first half, or at the end of the step otherwise. |
|
|
376
377
|
| `scale` | Zoom factor (default 1.3). 1.2–1.5 works well. |
|
|
377
|
-
| `start`, `end` | Seconds from the start of the step. The camera eases in from 1× at `start` and is back at 1× at `end` (default: the step's end). `end`
|
|
378
|
+
| `start`, `end` | Seconds from the start of the step. The camera eases in from 1× at `start` and is back at 1× at `end` (default: the step's end). A zoom belongs to its step: an `end` past the step's end stops there, with a warning. |
|
|
378
379
|
| `ramp` | Ease-in / ease-out seconds: `0.5` or `[in, out]`. |
|
|
379
380
|
| `offset` | Moves the camera center from the target's center, in viewport CSS px. `{ y: -120 }` centers 120 px above the target. |
|
|
380
381
|
|
|
@@ -394,9 +395,10 @@ mix is normalized to -14 LUFS.
|
|
|
394
395
|
| provider | key (env or `.env`) | notes |
|
|
395
396
|
|---|---|---|
|
|
396
397
|
| `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. |
|
|
398
|
+
| `qwen` | none | Local and free, Apple Silicon Macs only: Qwen3-TTS, the most natural free Chinese voice here (`dylan`, `serena`, see below). |
|
|
397
399
|
| `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
400
|
| `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. For Chinese, use `kokoro` with `zf_001` rather than `Tingting`. |
|
|
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`. |
|
|
400
402
|
|
|
401
403
|
**Chinese narration.** `voice: { provider: kokoro, voice: zf_001 }` speaks Mandarin with Kokoro
|
|
402
404
|
v1.1-zh, free and local, and is what `kokoro` uses when the `say:` lines are Chinese and no voice is
|
|
@@ -409,8 +411,28 @@ whether it's ready. English words and letters in a line (`GeoGebra`, `y 等于 m
|
|
|
409
411
|
English, a 2 before a measure word is read 两 (`2 个单位`), and `=`, `+`, `×`, `÷`, `²` are spoken;
|
|
410
412
|
subtitles keep the line as written. Known issue: with every Kokoro voice, 逗号 can sound like 度号.
|
|
411
413
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
+
**Chinese on an Apple Silicon Mac: Qwen3-TTS.** `voice: { provider: qwen }` speaks with
|
|
415
|
+
[Qwen3-TTS 0.6B CustomVoice](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice) (Apache-2.0)
|
|
416
|
+
through [mlx-audio](https://github.com/Blaizzy/mlx-audio). It reads English words, letters and numbers
|
|
417
|
+
in a Chinese line more reliably than `zf_001`. Voices: `dylan` (male, Beijing accent; the default
|
|
418
|
+
for Chinese tutorials, lessons and docs), `serena` (female, warm; the default for Chinese promos),
|
|
419
|
+
`vivian` (female, bright), `uncle_fu` (male, mellow), `eric` (male, Sichuan accent), and `ryan`, `aiden` (English), `ono_anna` (Japanese), `sohee` (Korean). On
|
|
420
|
+
first use Storyshot creates a venv in `~/.cache/storyshot/qwen-venv` (Python 3.10–3.12, about 400 MB)
|
|
421
|
+
and downloads the ~2.3 GB model into the Hugging Face cache. It speaks at about 2x real time on an M4 Pro
|
|
422
|
+
and peaks at about 8 GB of memory. The model samples its speech, so a take now and then garbles a few
|
|
423
|
+
words, drops one, runs on into invented words or stops short. Storyshot listens to every take with
|
|
424
|
+
[Qwen3-ASR 0.6B](https://huggingface.co/Qwen/Qwen3-ASR-0.6B) (Apache-2.0, a ~1 GB model downloaded on
|
|
425
|
+
first use) and compares what it hears with the line: Chinese by sound, so a homophone isn't a mistake, and
|
|
426
|
+
numbers digit by digit. A take that comes out wrong, or far too long or short, is made again with another
|
|
427
|
+
seed (up to 4 tries), and Storyshot warns, quoting what it heard, if a line still doesn't come out right.
|
|
428
|
+
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`),
|
|
430
|
+
with a note.
|
|
431
|
+
|
|
432
|
+
Other fields: `speed` (default 1), `volume` (default 1), `pause` (the shortest pause between the
|
|
433
|
+
sentences of a line, in seconds: 0.3 for promos, 0.6 for tutorials and lessons; a voice's own longer pause
|
|
434
|
+
stays, and `0` leaves the voice as it is),
|
|
435
|
+
and `pronounce`, a map of word → spoken form used only for TTS, e.g.
|
|
414
436
|
`pronounce: { pxGenius: P X Genius, PPTX: P P T X }`. Subtitles keep the real
|
|
415
437
|
spelling. Synthesized lines
|
|
416
438
|
are cached in `.storyshot/tts-cache/`, so `compose` only pays for lines you
|
|
@@ -443,11 +465,12 @@ usually shouldn't.
|
|
|
443
465
|
| `say` | Voiceover line played over the end card. |
|
|
444
466
|
| `emblem` | With `hyperframes`: one to three short monospace lines over the title (an alignment, a formula, a line of code); `false` for none. |
|
|
445
467
|
|
|
446
|
-
### HyperFrames cards
|
|
468
|
+
### HyperFrames cards
|
|
447
469
|
|
|
448
|
-
|
|
470
|
+
Tutorials and lessons render their intro, section and end cards with
|
|
449
471
|
[HyperFrames](https://hyperframes.heygen.com) (HeyGen's open-source HTML-to-video
|
|
450
|
-
renderer, Apache-2.0) for more motion
|
|
472
|
+
renderer, Apache-2.0) for more motion; `hyperframes: false` keeps the built-in
|
|
473
|
+
cards, and `hyperframes: true` turns them on for a promo. Two templates, picked by `purpose`:
|
|
451
474
|
|
|
452
475
|
- **lesson** (tutorials, courses, docs): the title lands word by word over a
|
|
453
476
|
slowly drifting, blurred view of the page the section leads into; section
|
|
@@ -469,7 +492,7 @@ hyperframes:
|
|
|
469
492
|
backdrop: false # lesson: plain background instead of the blurred page
|
|
470
493
|
```
|
|
471
494
|
|
|
472
|
-
It needs Node 22 or newer. On first use storyshot installs the pinned
|
|
495
|
+
It needs Node 22 or newer; on older Node, tutorials and lessons use the built-in cards. On first use storyshot installs the pinned
|
|
473
496
|
HyperFrames version into `~/.cache/storyshot/hyperframes/` (a local install
|
|
474
497
|
of the `hyperframes` package wins); its telemetry is switched off. Cards are
|
|
475
498
|
cached, so `compose` only re-renders the ones that changed (about 10–25 s
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storyshot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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": {
|
|
@@ -88,8 +88,16 @@ frames or stock footage presented as a recording of their app.
|
|
|
88
88
|
and Mandarin Chinese: for Chinese lines use `voice: { provider: kokoro,
|
|
89
89
|
voice: zf_001 }` (the default for Chinese `say:` lines), not macOS `say -v
|
|
90
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).
|
|
92
|
-
|
|
91
|
+
own venv on first use (`doctor` reports it). On an Apple Silicon Mac, the
|
|
92
|
+
best free Chinese voice is `voice: { provider: qwen }` (Qwen3-TTS: `dylan`,
|
|
93
|
+
a male teacher's voice, for tutorials and lessons; `serena`, female, for
|
|
94
|
+
promos; those are the defaults): clearer than
|
|
95
|
+
zf_001 with English words and letters, about 2x real time, ~3.3 GB of
|
|
96
|
+
models on first use. Storyshot listens to each take and makes it again if
|
|
97
|
+
it comes out garbled; a `⚠ qwen: … sounds like "…"` warning means a line
|
|
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.
|
|
93
101
|
3. **Login.** If the flow needs a logged-in page:
|
|
94
102
|
- Tell the user a browser window will open and that they should log in
|
|
95
103
|
there, preferably with a demo account (the video shows whatever is on
|
|
@@ -179,14 +187,18 @@ small controls are readable. Your job is the structure:
|
|
|
179
187
|
`note` the field the reader must not miss. Set the article's heading and
|
|
180
188
|
first paragraph with `article: { title: …, intro: … }`. Downloads show a
|
|
181
189
|
"Downloaded" bubble; `paste` and copy-to-clipboard work.
|
|
182
|
-
- **
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
190
|
+
- **Animated cards that help explain.** Tutorials and lessons render their
|
|
191
|
+
cards with HyperFrames by default (Node 22+; it installs itself on first
|
|
192
|
+
use): titles land word by word, and each bullet lights up when the `say`
|
|
193
|
+
line names it, so write the bullets in the narration's own words. Use them:
|
|
194
|
+
a concept card for every idea that isn't on screen (the idea as the title,
|
|
195
|
+
its 2–4 steps as bullets, a formula or a line of code as `emblem`), a
|
|
196
|
+
question card for each check, and a recap end card. For a subject with its
|
|
197
|
+
own material, give every title a backdrop from it:
|
|
186
198
|
`hyperframes: { band: <a sequence or formula>, emblem: [<1–3 short lines>] }`.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
199
|
+
For a promo, `hyperframes: true` gives the launch template with hand-drawn
|
|
200
|
+
accents (`hand: true` for hand lettering). `hyperframes: false` keeps the
|
|
201
|
+
built-in cards.
|
|
190
202
|
- **End with a recap**: `end_card` with `bullets` listing the steps or rules
|
|
191
203
|
to remember, and a `say` that repeats them.
|
|
192
204
|
- **Narrating real results.** You can't know the numbers (scores, counts,
|
|
@@ -201,6 +213,59 @@ small controls are readable. Your job is the structure:
|
|
|
201
213
|
don't pad: if a part is long, split it into more parts rather than
|
|
202
214
|
speeding it up.
|
|
203
215
|
|
|
216
|
+
### Writing the narration
|
|
217
|
+
|
|
218
|
+
Viewers notice three things first: lines that run together, phrasing nobody
|
|
219
|
+
says out loud, and an explanation that jumps around. Write the `say:` lines as
|
|
220
|
+
a script before the steps around them, then read them in order on their own.
|
|
221
|
+
They should sound like one person talking a friend through it.
|
|
222
|
+
|
|
223
|
+
**Say it like a person.**
|
|
224
|
+
- Short sentences, one idea each: about 15 words at most in English, 25
|
|
225
|
+
characters in Chinese. If you would run out of breath, split it.
|
|
226
|
+
- Spoken words: contractions ("you'll", "it's"), "you", present tense, active
|
|
227
|
+
voice. In Chinese, 口语 rather than 书面语: "点一下这里", not "进行点击操作".
|
|
228
|
+
- Use the exact labels on screen, and the same term every time (no synonyms
|
|
229
|
+
for variety).
|
|
230
|
+
- Each sentence gets a breath after it: Storyshot widens the pause between
|
|
231
|
+
sentences to at least `voice.pause` (0.3 s for `pitch`, 0.6 s for tutorials
|
|
232
|
+
and lessons). Commas get no extra pause, so split long lines into sentences
|
|
233
|
+
rather than chaining clauses with commas.
|
|
234
|
+
- For fixed durations, budget about 2.5 words a second in English and 4
|
|
235
|
+
characters a second in Chinese, and leave silence while the screen is busy.
|
|
236
|
+
|
|
237
|
+
**Cut the phrases that mark text as machine-written** (`check` flags the
|
|
238
|
+
common ones):
|
|
239
|
+
- Contrasts that only add weight: "not just X, but Y", "it's not X, it's Y".
|
|
240
|
+
- Inflated words: seamless, effortless, powerful, robust, unlock, elevate,
|
|
241
|
+
supercharge, revolutionize, leverage, cutting-edge, game-changer, delve;
|
|
242
|
+
"in today's fast-paced world". In Chinese: 赋能, 助力, 一站式, 全方位, 极致,
|
|
243
|
+
无缝, 轻松搞定, 不仅……更……, 让我们一起.
|
|
244
|
+
- Openers and closers that say nothing: "Let's dive in", "Ever wondered…?",
|
|
245
|
+
"And that's it!", "Simple as that."
|
|
246
|
+
- Three adjectives in a row, line after line ("fast, simple and powerful").
|
|
247
|
+
- Filler that talks down to a stuck viewer: basically, simply, just, easily.
|
|
248
|
+
- Dashes (—) in `say:` lines. Voices read them as a run-on; use a period.
|
|
249
|
+
|
|
250
|
+
**Explain in an order the viewer can follow** (tutorials and lessons):
|
|
251
|
+
1. The question or the goal ("How does b move the line?").
|
|
252
|
+
2. A concrete case on screen first: change the value, show the result.
|
|
253
|
+
3. Then name the idea, in plain words, the first time it appears.
|
|
254
|
+
4. Why it works, in one or two sentences, with a concept card if it isn't on
|
|
255
|
+
screen (its bullets light up as you name them; a formula goes in `emblem`).
|
|
256
|
+
5. A check: a question card, then the step that reveals the answer.
|
|
257
|
+
6. The recap, in the same words as before.
|
|
258
|
+
|
|
259
|
+
Every step answers the question the step before it raised. Link them out loud:
|
|
260
|
+
"Now that b has moved the line up, let's see what m does." When the camera
|
|
261
|
+
has to go somewhere else on the page, say why first ("The results are further
|
|
262
|
+
down, in the table."). If a line only makes sense with the video, or the
|
|
263
|
+
script reads as a list of disconnected facts, reorder the steps before you
|
|
264
|
+
render.
|
|
265
|
+
|
|
266
|
+
(These rules borrow from [humanizer](https://github.com/blader/humanizer) and
|
|
267
|
+
[stop-slop](https://github.com/hardikpandya/stop-slop), both MIT.)
|
|
268
|
+
|
|
204
269
|
Rules of thumb from real renders (promos and tutorials):
|
|
205
270
|
|
|
206
271
|
- **One idea per step.**
|
|
@@ -220,11 +285,15 @@ Rules of thumb from real renders (promos and tutorials):
|
|
|
220
285
|
`voice.pronounce` (e.g. `pxGenius: P X Genius`, `AI: A I`) so subtitles stay
|
|
221
286
|
correct. For fixed durations, budget about 2.5 words per second.
|
|
222
287
|
- **Isolate long waits** (AI processing, uploads) in their own step: a
|
|
223
|
-
`waitFor` the finished state
|
|
224
|
-
`duration: 1.0–1.5`.
|
|
288
|
+
`waitFor` the finished state. In `pitch` mode add `sfx: riser` and
|
|
289
|
+
`duration: 1.0–1.5`. In tutorials and lessons, say what is happening
|
|
290
|
+
instead; at most `sfx: chime` when the result arrives (a riser there plays
|
|
291
|
+
only its chime). Waiting is compressed automatically.
|
|
225
292
|
- **Zoom sparingly**, on 2–4 payoff moments (headline, clicked button, result,
|
|
226
293
|
price), with `scale: 1.2–1.5`.
|
|
227
|
-
- **Show the signature interaction.** Use `
|
|
294
|
+
- **Show the signature interaction.** Use `slide` for sliders:
|
|
295
|
+
`slide: { target: <the slider inspect lists>, value: 3 }` lands on that value
|
|
296
|
+
(`to: 0..1` for sliders that don't report a range). Use `drag` for before/after
|
|
228
297
|
views (`drag: { from: { x: 0.57, y: 0.5 }, by: [{ x: -300 }, { x: 400 }] }`;
|
|
229
298
|
`from` can be a target or a point). Use `type` for inputs: `type: y=x^2`
|
|
230
299
|
types into whatever has focus (for editors and canvases with hidden inputs),
|
package/src/audio.mjs
CHANGED
|
@@ -108,7 +108,8 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
|
|
|
108
108
|
return filt(rand(), 400 + 5000 * Math.sin(Math.PI * p), 1.6).bp * Math.pow(Math.sin(Math.PI * p), 2);
|
|
109
109
|
}, 0.5);
|
|
110
110
|
}
|
|
111
|
-
|
|
111
|
+
// the rising sound under a wait is for promos; tutorials and lessons (sfx: clicks) keep only its chime
|
|
112
|
+
for (const [a, b] of cfg.sfx.transitions ? events.risers : []) {
|
|
112
113
|
const len = b - a, filt = svf();
|
|
113
114
|
let ph = 0;
|
|
114
115
|
add(sfx, a, len, (t) => {
|
|
@@ -133,12 +134,15 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
|
|
|
133
134
|
if (cfg.voice && events.voice?.length) {
|
|
134
135
|
let prevEnd = 0;
|
|
135
136
|
for (const line of events.voice) {
|
|
136
|
-
let pcm = await speak(cfg, line.text, { log });
|
|
137
|
+
let { pcm, parts } = await speak(cfg, line.text, { log });
|
|
137
138
|
let dur = pcm.length / 2 / SR;
|
|
138
139
|
const start = Math.max(line.start, prevEnd + 0.12), room = line.until - start - 0.1;
|
|
139
140
|
let sped = 1;
|
|
140
141
|
// 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) {
|
|
142
|
+
if (dur > room && room > 0 && dur / room <= 1.2) {
|
|
143
|
+
sped = dur / room; pcm = tempo(pcm, sped); dur = pcm.length / 2 / SR;
|
|
144
|
+
parts = parts.map((p) => ({ ...p, start: p.start / sped, end: p.end / sped }));
|
|
145
|
+
}
|
|
142
146
|
else if (dur > room) warn(room > 0
|
|
143
147
|
? `${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
148
|
: `${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`);
|
|
@@ -154,7 +158,8 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
|
|
|
154
158
|
voiceOn[s0 + i] = 1;
|
|
155
159
|
}
|
|
156
160
|
prevEnd = start + dur;
|
|
157
|
-
|
|
161
|
+
// subtitles per sentence, so the pauses between them don't throw off the word timing
|
|
162
|
+
for (const p of parts) narration.push({ start: start + p.start, end: Math.min(start + p.end, start + dur), text: p.text });
|
|
158
163
|
}
|
|
159
164
|
}
|
|
160
165
|
|
package/src/check.mjs
CHANGED
|
@@ -4,6 +4,35 @@ import YAML from 'yaml';
|
|
|
4
4
|
import { loadConfig } from './config.mjs';
|
|
5
5
|
import { gitIgnored } from './auth.mjs';
|
|
6
6
|
import { isZhVoice, zhVoiceStatus } from './kokoro-zh.mjs';
|
|
7
|
+
import { qwenStatus } from './qwen-tts.mjs';
|
|
8
|
+
import { sentences } from './voice.mjs';
|
|
9
|
+
|
|
10
|
+
// Phrases that mark narration as machine-written, and sentences too long to say in one breath.
|
|
11
|
+
const TELLS = [
|
|
12
|
+
/\bnot (?:just|only|merely) [^.,;!?]{1,40}?,? but(?: also)?\b/i, // "not just X, but Y"
|
|
13
|
+
/\bit'?s not (?:about )?[^.,;!?]{1,30}[,;—–-]+ it'?s\b/i, // "it's not X, it's Y"
|
|
14
|
+
/\b(?:seamless(?:ly)?|effortless(?:ly)?|supercharg\w*|revolutioni[sz]\w*|game[- ]chang\w*|cutting[- ]edge|elevat(?:e|es|ed|ing)|leverag(?:e|es|ed|ing)|delv(?:e|es|ing))\b/i,
|
|
15
|
+
/\bin today'?s (?:fast-paced|digital|modern)\b/i,
|
|
16
|
+
/\blet'?s dive in\b|\bever wondered\b|\bsimple as that\b|\band that'?s it\b/i,
|
|
17
|
+
/\b(?:basically|simply)\b/i, // ("easily" is too often literal: "binds more easily")
|
|
18
|
+
/[—–]/,
|
|
19
|
+
/赋能|助力|一站式|全方位|极致|无缝|轻松搞定|让我们一起|不仅[^。!?]{1,20}更/,
|
|
20
|
+
];
|
|
21
|
+
export function narrationLint(text) {
|
|
22
|
+
const t = String(text).replace(/\*\*/g, ''), tells = [];
|
|
23
|
+
for (const re of TELLS) {
|
|
24
|
+
const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
|
|
25
|
+
for (const m of t.matchAll(g)) if (!tells.includes(m[0].trim())) tells.push(m[0].trim());
|
|
26
|
+
}
|
|
27
|
+
let long = null;
|
|
28
|
+
for (const s of sentences(t)) {
|
|
29
|
+
const cjk = (s.match(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/g) ?? []).length;
|
|
30
|
+
const words = s.replace(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/g, ' ').split(/\s+/).filter((w) => /\w/.test(w)).length;
|
|
31
|
+
if (cjk > 40 && !long) long = `${cjk}-character`;
|
|
32
|
+
else if (!cjk && words > 25 && !long) long = `${words}-word`;
|
|
33
|
+
}
|
|
34
|
+
return { tells, long };
|
|
35
|
+
}
|
|
7
36
|
|
|
8
37
|
const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'intro', 'frame', 'pace', 'follow', 'article', 'cards', 'lang', 'colorScheme',
|
|
9
38
|
'scrollOffset', 'captionPosition', 'captions', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card', 'hyperframes'];
|
|
@@ -106,7 +135,7 @@ export function check(file, { draft = false } = {}) {
|
|
|
106
135
|
if (type === 'target') target(v, `${where} (${kind})`);
|
|
107
136
|
if (type === 'mark' && typeof v !== 'string') target(v, `${where} (mark)`); // a string is a phrase to highlight
|
|
108
137
|
if (type === 'slide') {
|
|
109
|
-
if (!v?.target || typeof v?.to !== 'number' || v.to < 0 || v.to > 1) errors.push(`${where}: slide needs { target: <slider>, to: 0..1 } (0 = start of the track, 1 = end)`);
|
|
138
|
+
if (!v?.target || (typeof v?.value !== 'number' && (typeof v?.to !== 'number' || v.to < 0 || v.to > 1))) errors.push(`${where}: slide needs { target: <slider>, to: 0..1 } (0 = start of the track, 1 = end) or { target: <slider>, value: <number> }`);
|
|
110
139
|
else target(v.target, `${where} (slide.target)`);
|
|
111
140
|
}
|
|
112
141
|
if (type === 'select') {
|
|
@@ -145,7 +174,7 @@ export function check(file, { draft = false } = {}) {
|
|
|
145
174
|
// estimate from its length; the render measures the real voice)
|
|
146
175
|
if (cfg.voice) {
|
|
147
176
|
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); };
|
|
177
|
+
const secs = (t) => { const { c, w } = words(t); return (c / 4.5 + w / 2.8) / (cfg.voice.speed ?? 1) + (sentences(t).length - 1) * (cfg.voice.pause ?? 0); };
|
|
149
178
|
const vis = visible.map((s) => ({ s, i: cfg.steps.indexOf(s) }));
|
|
150
179
|
for (const [k, { s, i }] of vis.entries()) {
|
|
151
180
|
if (!s.say) continue;
|
|
@@ -157,12 +186,27 @@ export function check(file, { draft = false } = {}) {
|
|
|
157
186
|
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
187
|
}
|
|
159
188
|
}
|
|
189
|
+
// spoken lines that read as machine-written, or run on without a breath (the skill's "Writing the narration")
|
|
190
|
+
if (cfg.voice) {
|
|
191
|
+
const lines = [...visible.flatMap((s) => [[s.say, `step ${cfg.steps.indexOf(s) + 1}`], [s.card?.say, `step ${cfg.steps.indexOf(s) + 1} card`]]),
|
|
192
|
+
[cfg.intro?.say, 'intro'], [cfg.endCard?.say, 'end card']].filter(([t]) => t);
|
|
193
|
+
for (const [text, where] of lines) {
|
|
194
|
+
const { tells, long } = narrationLint(text);
|
|
195
|
+
if (tells.length) warnings.push(`${where}: the narration sounds machine-written (${tells.map((x) => `"${x}"`).join(', ')}) — say it plainly, as you would out loud`);
|
|
196
|
+
if (long) warnings.push(`${where}: a ${long} sentence — split it, so the voice gets a breath between the parts`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
160
199
|
// the Chinese Kokoro voice needs a Python for its G2P: say so now, not after the recording
|
|
161
200
|
const v = draft && cfg.voice ? loadConfig(file, { draft }).voice : cfg.voice;
|
|
162
201
|
if (v?.provider === 'kokoro' && isZhVoice(v.voice)) {
|
|
163
202
|
const zh = zhVoiceStatus();
|
|
164
203
|
if (zh.problem) errors.push(zh.problem);
|
|
165
204
|
}
|
|
205
|
+
if (v?.provider === 'qwen') {
|
|
206
|
+
const q = qwenStatus();
|
|
207
|
+
if (q.problem) errors.push(q.problem);
|
|
208
|
+
}
|
|
209
|
+
if (v?.fallback) warnings.push(v.fallback);
|
|
166
210
|
if (!draft && cfg.voice && ['elevenlabs', 'openai'].includes(cfg.voice.provider)) {
|
|
167
211
|
const env = cfg.voice.provider === 'elevenlabs' ? 'ELEVENLABS_API_KEY' : 'OPENAI_API_KEY';
|
|
168
212
|
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
|
@@ -235,6 +235,11 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
235
235
|
g.zoomBox = null;
|
|
236
236
|
} else if (z && !g.zoomBox) warn(`step ${g.i + 1}: zoom target not found on the page, zoom skipped`);
|
|
237
237
|
if (!z || !g.zoomBox) continue;
|
|
238
|
+
{
|
|
239
|
+
// measured off screen (e.g. before the step scrolled to it): the camera can only frame what's in the window
|
|
240
|
+
const b = g.zoomBox, cx = b.x + b.width / 2, cy = b.y + b.height / 2, { width: VW, height: VH } = cfg.viewport;
|
|
241
|
+
if (cx < 0 || cx > VW || cy < 0 || cy > VH) warn(`step ${g.i + 1}: the zoom target was off screen when it was measured (${cy > VH ? `${Math.round(cy - VH)}px below` : cy < 0 ? `${Math.round(-cy)}px above` : 'beside'} the window), so the zoom frames the edge of the page instead — zoom on something an action in this step moves to, or use a point target`);
|
|
242
|
+
}
|
|
238
243
|
g.zoomPeak = g.o0 + (z.start ?? 0) + (Array.isArray(z.ramp) ? z.ramp[0] : z.ramp ?? 0.5) + 0.1;
|
|
239
244
|
const b = g.zoomBox, off = z.offset ?? {};
|
|
240
245
|
const c = [(b.x + b.width / 2 + (off.x ?? 0)) * K, (b.y + b.height / 2 + (off.y ?? 0)) * K];
|
package/src/config.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import YAML from 'yaml';
|
|
5
|
-
import { VOICE_DEFAULTS, kokoroVoiceFor } from './voice.mjs';
|
|
5
|
+
import { VOICE_DEFAULTS, kokoroVoiceFor, qwenVoiceFor } from './voice.mjs';
|
|
6
|
+
import { QWEN_VOICES, qwenSupported, qwenVoice } from './qwen-tts.mjs';
|
|
6
7
|
|
|
7
8
|
// Built-in labels drawn into the video, by `lang` (e.g. es, es-MX); other languages get English
|
|
8
9
|
export const LABELS = {
|
|
@@ -30,10 +31,10 @@ export function loadEnv(dir) {
|
|
|
30
31
|
|
|
31
32
|
// What the video is for sets sensible defaults; anything in the YAML overrides them.
|
|
32
33
|
export const PURPOSES = {
|
|
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: '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
|
-
docs: { duration: 'auto', music: false, sfx: false, highlight: true, stepNumbers: true, subtitles: false, chapters: false, clips: ['gif', 'png'], frame: false, pace: 'normal', follow: false },
|
|
34
|
+
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, hyperframes: false },
|
|
35
|
+
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, hyperframes: true },
|
|
36
|
+
course: { duration: 'auto', music: false, sfx: 'clicks', highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true, hyperframes: true },
|
|
37
|
+
docs: { duration: 'auto', music: false, sfx: false, highlight: true, stepNumbers: true, subtitles: false, chapters: false, clips: ['gif', 'png'], frame: false, pace: 'normal', follow: false, hyperframes: false },
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
// Where storyboards and their videos go, and how they're named: <site>-<kind>[-<topic>].yaml →
|
|
@@ -42,9 +43,10 @@ export const PURPOSES = {
|
|
|
42
43
|
// follow each action and to read its result before the next one starts.
|
|
43
44
|
export const PACES = {
|
|
44
45
|
// cursor ms typing ms/char beat after a click (ms) hold after narration (s) waits shown up to (s) voice speed
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
// shortest pause between the sentences of one line (s)
|
|
47
|
+
fast: { cursor: 700, type: 55, beat: 0, tail: 0.4, maxWait: 1.0, voiceSpeed: 1, sentencePause: 0.3 },
|
|
48
|
+
normal: { cursor: 900, type: 70, beat: 350, tail: 0.8, maxWait: 1.5, voiceSpeed: 1, sentencePause: 0.45 },
|
|
49
|
+
slow: { cursor: 1200, type: 95, beat: 800, tail: 1.6, maxWait: 3.0, voiceSpeed: 0.92, sentencePause: 0.6 },
|
|
48
50
|
};
|
|
49
51
|
|
|
50
52
|
export const FRAME_STYLES = ['aurora', 'studio', 'dark', 'pastel', 'blur'];
|
|
@@ -137,8 +139,9 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
137
139
|
if (frame?.icon) frame.iconFile = path.resolve(dir, frame.icon);
|
|
138
140
|
|
|
139
141
|
// 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
|
-
|
|
142
|
+
// rendered by HyperFrames (see hyperframes.mjs); promos get the launch template, the rest the lesson one.
|
|
143
|
+
// On by default for tutorials and lessons, where Node can run it (22+); else the built-in cards
|
|
144
|
+
const hf0 = raw.hyperframes !== undefined ? raw.hyperframes : P.hyperframes && Number(process.versions.node.split('.')[0]) >= 22;
|
|
142
145
|
const hyperframes = hf0 ? { template: purpose === 'pitch' ? 'launch' : 'lesson', backdrop: true, ...(typeof hf0 === 'object' ? hf0 : {}) } : null;
|
|
143
146
|
if (hyperframes && !['lesson', 'launch'].includes(hyperframes.template)) throw new Error('hyperframes.template must be lesson or launch');
|
|
144
147
|
if (hyperframes?.bandColors && !['aminoacid', 'nucleotide', 'accent'].includes(hyperframes.bandColors)) throw new Error('hyperframes.bandColors must be aminoacid, nucleotide or accent');
|
|
@@ -148,10 +151,23 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
148
151
|
const provider = raw.voice.provider ?? 'kokoro';
|
|
149
152
|
if (!VOICE_DEFAULTS[provider]) throw new Error(`voice.provider must be one of ${Object.keys(VOICE_DEFAULTS).join(', ')}`);
|
|
150
153
|
const pace0 = PACES[pick('pace')] ?? PACES.normal;
|
|
151
|
-
// kokoro's default
|
|
154
|
+
// kokoro's and qwen's default voices follow the narration's language (zf_001 / serena for Chinese)
|
|
152
155
|
const kokoroVoice = kokoroVoiceFor(raw);
|
|
153
|
-
|
|
154
|
-
|
|
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 };
|
|
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
|
+
if (provider === 'qwen') {
|
|
160
|
+
const v = qwenVoice(voice.voice);
|
|
161
|
+
if (!v) throw new Error(`voice: qwen has no voice "${voice.voice}" (one of ${QWEN_VOICES.join(', ')})`);
|
|
162
|
+
voice.voice = v;
|
|
163
|
+
// MLX needs Apple Silicon: anywhere else the same storyboard renders with kokoro
|
|
164
|
+
if (!qwenSupported()) {
|
|
165
|
+
const k = raw.voice.draftVoice ?? kokoroVoice;
|
|
166
|
+
voice = { ...voice, provider: 'kokoro', voice: k, fallback: `the qwen voice needs an Apple Silicon Mac; using kokoro ${k} instead` };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// free local voices stay; a paid one is swapped for kokoro
|
|
170
|
+
if (draft && !['kokoro', 'qwen'].includes(voice.provider)) {
|
|
155
171
|
voice = { ...voice, provider: 'kokoro', voice: raw.voice.draftVoice ?? kokoroVoice, model: undefined };
|
|
156
172
|
}
|
|
157
173
|
}
|
|
@@ -201,8 +217,8 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
201
217
|
follow,
|
|
202
218
|
music,
|
|
203
219
|
voice,
|
|
204
|
-
// sfx: true (clicks
|
|
205
|
-
// sound at every card soon grates), or false
|
|
220
|
+
// sfx: true (clicks, transition whooshes and risers), 'clicks' (no whooshes or risers: tutorials and
|
|
221
|
+
// lessons, where a sound at every card soon grates), or false
|
|
206
222
|
sfx: pick('sfx') === false ? false : { clicks: true, transitions: pick('sfx') !== 'clicks' },
|
|
207
223
|
draft,
|
|
208
224
|
out: path.resolve(dir, raw.out ?? `${name}.mp4`).replace(/(\.[^./]+)?$/, (ext) => (draft ? `.draft${ext || '.mp4'}` : ext)),
|
package/src/doctor.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { createRequire } from 'node:module';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { hasFilter, hasEncoder, NEEDED, OPTIONAL } from './ffmpeg.mjs';
|
|
9
9
|
import { zhVoiceStatus } from './kokoro-zh.mjs';
|
|
10
|
+
import { qwenStatus } from './qwen-tts.mjs';
|
|
10
11
|
import { hyperframesCli, HF_VERSION } from './hyperframes.mjs';
|
|
11
12
|
|
|
12
13
|
const has = (cmd, args = ['--version']) => { try { execFileSync(cmd, args, { stdio: 'ignore' }); return true; } catch { return false; } };
|
|
@@ -78,6 +79,11 @@ export async function doctor({ log = console.log } = {}) {
|
|
|
78
79
|
lines.push(zh.ready ? `✔ Chinese voice (kokoro zf_001): Python ${zh.python?.version ?? '?'} venv in ${zh.venv}`
|
|
79
80
|
: zh.python ? `· Chinese voice (kokoro zf_001): Python ${zh.python.version} found; storyshot sets up its venv in ${zh.venv} on first use`
|
|
80
81
|
: '· 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)');
|
|
82
|
+
const q = qwenStatus();
|
|
83
|
+
lines.push(!q.supported ? '· Qwen3-TTS voice (provider: qwen) needs an Apple Silicon Mac; storyboards that use it fall back to kokoro here'
|
|
84
|
+
: q.ready ? `✔ Qwen3-TTS voice (provider: qwen): venv in ${q.venv}${q.model ? ', models downloaded' : '; the models (~3.3 GB: the voice, and a speech recognizer that checks each take) download on first use'}`
|
|
85
|
+
: q.python ? `· Qwen3-TTS voice (provider: qwen): Python ${q.python.version} found; storyshot sets up its venv in ${q.venv} and downloads the models (~3.3 GB) on first use`
|
|
86
|
+
: '· Qwen3-TTS voice (provider: qwen) needs Python 3.10–3.12 — optional: brew install python@3.12, or uv python install 3.12 (or set STORYSHOT_PYTHON)');
|
|
81
87
|
const hf = hyperframesCli();
|
|
82
88
|
lines.push(hf.error ? `· HyperFrames cards (hyperframes: true) unavailable — optional: ${hf.error}`
|
|
83
89
|
: hf.missing ? `· HyperFrames cards (hyperframes: true): ${HF_VERSION} installs itself on first use`
|
package/src/init.mjs
CHANGED
|
@@ -7,6 +7,14 @@ import { PURPOSES, defaultStoryboard } from './config.mjs';
|
|
|
7
7
|
import { loginAdvice } from './auth.mjs';
|
|
8
8
|
|
|
9
9
|
const q = (s) => JSON.stringify(String(s));
|
|
10
|
+
// The product's name from the page title: its first part ("Squoosh | Image compressor" → "Squoosh"),
|
|
11
|
+
// or the repo on a code host ("GitHub - boheling/storyshot: Ask…" → "storyshot"). Only spaced
|
|
12
|
+
// separators split, so a hyphenated name (Tic-Tac-Toe) stays whole.
|
|
13
|
+
export function productName(title, host) {
|
|
14
|
+
const segs = String(title ?? '').split(/\s+[|—–·-]\s+|:\s+/).map((s) => s.trim()).filter(Boolean);
|
|
15
|
+
const repo = segs.find((s) => /^[\w.-]+ ?\/ ?[\w.-]+$/.test(s));
|
|
16
|
+
return repo ? repo.split('/')[1].trim() : segs[0] ?? host;
|
|
17
|
+
}
|
|
10
18
|
const tgt = (t) => (typeof t === 'string' ? q(t) : `{ ${Object.entries(t).map(([k, v]) => `${k}: ${typeof v === 'string' ? q(v) : v}`).join(', ')} }`);
|
|
11
19
|
|
|
12
20
|
export async function initStoryboard(url, { purpose = 'pitch', topic, file, storageState, ignoreHTTPSErrors = false, viewport = { width: 1600, height: 900 } } = {}) {
|
|
@@ -41,7 +49,7 @@ out: ${name}.mp4
|
|
|
41
49
|
${storageState ? `storageState: ${path.relative(path.dirname(path.resolve(file)), path.resolve(storageState)).split(path.sep).join('/')} # saved login (never commit it)\n` : ''}${ignoreHTTPSErrors ? 'ignoreHTTPSErrors: true # internal site with a self-signed certificate\n' : ''}${narrated ? 'voice: { provider: kokoro } # free, local, English. For the final: { provider: elevenlabs, voice: <voice id> } + ELEVENLABS_API_KEY' : '# voice: { provider: kokoro } # optional voiceover; add `say:` lines to steps'}
|
|
42
50
|
|
|
43
51
|
${purpose === 'pitch' ? `intro: # title card before the recording (promos; optional for tutorials/lessons)
|
|
44
|
-
title: ${q(page.title
|
|
52
|
+
title: ${q(productName(page.title, host))}
|
|
45
53
|
subtitle: TODO the one-line promise, with the **key words** in bold
|
|
46
54
|
${PURPOSES[purpose]?.frame ? '# frame: false # the recording sits in a browser window on a backdrop; false = full frame\n' : ''}
|
|
47
55
|
` : purpose === 'tutorial' || purpose === 'course' ? `# intro: { title: TODO, subtitle: TODO } # optional title card (e.g. "How to export a report")
|
|
@@ -65,7 +73,7 @@ steps:
|
|
|
65
73
|
# run \`storyshot inspect ${file} --after <step>\` to list verified targets at that point.
|
|
66
74
|
|
|
67
75
|
end_card:
|
|
68
|
-
title: ${q(page.title
|
|
76
|
+
title: ${q(productName(page.title, host))}
|
|
69
77
|
cta: ${q(`Try it at ${host}`)}${narrated ? `\n say: ${q(`Try it at ${host}.`)}` : ''}
|
|
70
78
|
|
|
71
79
|
# ── Verified targets on ${page.url} (each matches exactly one visible element) ──
|
package/src/inspect.mjs
CHANGED
|
@@ -44,7 +44,8 @@ function collect() {
|
|
|
44
44
|
const clean = (s) => (s || '').replace(/\s+/g, ' ').trim();
|
|
45
45
|
// a short CSS selector (tag + classes, or #id) — the last-resort target when names don't resolve
|
|
46
46
|
const cssOf = (el) => {
|
|
47
|
-
|
|
47
|
+
const id = el.getAttribute('id'); // not el.id: a form with an <input name=id> has that input as its .id
|
|
48
|
+
if (id && !/^[0-9:]|:/.test(id)) return `#${CSS.escape(id)}`;
|
|
48
49
|
const cls = [...el.classList].filter((c) => !/^(css|sc|jsx|svelte|tw)-|\d{3,}|__/.test(c)).slice(0, 2).map((c) => `.${CSS.escape(c)}`).join('');
|
|
49
50
|
return cls ? `${el.tagName.toLowerCase()}${cls}` : null;
|
|
50
51
|
};
|
|
@@ -68,10 +69,17 @@ function collect() {
|
|
|
68
69
|
// a label's own text, without the links/buttons inside it (e.g. a "Clear" link)
|
|
69
70
|
const labelText = (lab) => { if (!lab) return null; const c = lab.cloneNode(true); c.querySelectorAll('a,button,select,input,textarea').forEach((x) => x.remove()); return clean(c.innerText) || null; };
|
|
70
71
|
// styled checkboxes and radios hide the real input and draw their label instead: list them where the label is
|
|
72
|
+
// a slider's range and value, when it says (an <input type=range>, or aria-valuemin/max/now)
|
|
73
|
+
const sliderRange = (el) => {
|
|
74
|
+
const n = (a) => (el.getAttribute(a) == null ? NaN : Number(el.getAttribute(a)));
|
|
75
|
+
const [lo, hi, now] = el.type === 'range' ? [Number(el.min || 0), Number(el.max || 100), Number(el.value)] : [n('aria-valuemin'), n('aria-valuemax'), n('aria-valuenow')];
|
|
76
|
+
return [lo, hi, now].every(Number.isFinite) ? { lo, hi, now } : null;
|
|
77
|
+
};
|
|
71
78
|
const shownBy = (el) => (el.tagName === 'INPUT' && ['checkbox', 'radio'].includes(el.type) && !vis(el) ? [...(el.labels ?? [])].find(vis) : null);
|
|
72
79
|
const interactive = [...document.querySelectorAll(NATIVE)]
|
|
73
80
|
.filter((el) => (vis(el) || shownBy(el)) && el.tagName !== 'LABEL' && !isAd(el) && !(el.isContentEditable && el.parentElement?.isContentEditable))
|
|
74
81
|
.map((el) => ({ kind: 'role', role: roleOf(el), name: (el.isContentEditable || el.tagName === 'TEXTAREA' ? editorName(el) : nameOf(el)).slice(0, 80), shows: clean(el.innerText).slice(0, 60), alt: clean(el.querySelector('img[alt]')?.alt) || null, href: el.getAttribute('href'), label: labelText(el.labels?.[0]),
|
|
82
|
+
range: roleOf(el) === 'slider' ? sliderRange(el) : null,
|
|
75
83
|
options: el.tagName === 'SELECT' ? [...el.options].map((o) => ({ text: clean(o.text), on: o.selected })).filter((o) => o.text).slice(0, 12) : null, ...pos(shownBy(el) ?? el) }));
|
|
76
84
|
// custom clickables: outermost cursor:pointer elements that aren't native controls
|
|
77
85
|
const pointer = [];
|
|
@@ -85,11 +93,11 @@ function collect() {
|
|
|
85
93
|
return {
|
|
86
94
|
title: document.title,
|
|
87
95
|
height: scroller ? scroller.scrollHeight : document.documentElement.scrollHeight,
|
|
88
|
-
scroller: scroller ? (scroller.id ? `#${scroller.id}` : scroller.tagName.toLowerCase() + (scroller.className && typeof scroller.className === 'string' ? '.' + scroller.className.trim().split(/\s+/)[0] : '')) : null,
|
|
96
|
+
scroller: scroller ? (scroller.getAttribute('id') ? `#${scroller.getAttribute('id')}` : scroller.tagName.toLowerCase() + (scroller.className && typeof scroller.className === 'string' ? '.' + scroller.className.trim().split(/\s+/)[0] : '')) : null,
|
|
89
97
|
headings: [...document.querySelectorAll('h1, h2, h3')].filter(vis).map((h) => ({ tag: h.tagName.toLowerCase(), text: clean(h.innerText).slice(0, 100), ...pos(h) })),
|
|
90
98
|
anchors: [...document.querySelectorAll('[id]')].filter((el) => vis(el) && el !== scroller && el.getBoundingClientRect().height > 150 &&
|
|
91
|
-
el.getBoundingClientRect().height < document.documentElement.scrollHeight * 0.8 && !/^(__|radix|headlessui|:)/.test(el.id))
|
|
92
|
-
.map((el) => ({ id: el.id, ...pos(el) })).slice(0, 30),
|
|
99
|
+
el.getBoundingClientRect().height < document.documentElement.scrollHeight * 0.8 && !/^(__|radix|headlessui|:)/.test(el.getAttribute('id')))
|
|
100
|
+
.map((el) => ({ id: el.getAttribute('id'), ...pos(el) })).slice(0, 30),
|
|
93
101
|
images: [...document.querySelectorAll('img[alt]')].filter((el) => vis(el) && el.alt.trim()).map((el) => ({ alt: clean(el.alt).slice(0, 80), ...pos(el) })).slice(0, 30),
|
|
94
102
|
interactive: [...interactive, ...pointer],
|
|
95
103
|
};
|
|
@@ -160,7 +168,7 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
|
|
|
160
168
|
'',
|
|
161
169
|
'## Clickable elements — verified targets (each matches exactly one visible element) for click/move/zoom',
|
|
162
170
|
' Copy targets exactly; `name` is the accessible name, which may differ from the on-screen label.',
|
|
163
|
-
...elements.map((e) => `${fold(e.y)} y=${e.y}\tat (${e.cx}, ${e.cy})\t${yaml(e.target)}${e.role === 'textbox' ? ` (text input ${e.w}×${e.h} — click it, then \`type: …\`)` : e.role === 'slider' ? ' (slider — `slide: { target: …, to: 0..1 }`)' : e.options ? ` (dropdown — \`select: { target: …, option: "…" }\`; options: ${e.options.map((o) => `"${o.text}"${o.on ? ' (selected)' : ''}`).join(', ')})` : shows(e)}${e.href ? ` → ${e.href}` : ''}${e.kind === 'pointer' ? ' (custom clickable)' : ''}`),
|
|
171
|
+
...elements.map((e) => `${fold(e.y)} y=${e.y}\tat (${e.cx}, ${e.cy})\t${yaml(e.target)}${e.role === 'textbox' ? ` (text input ${e.w}×${e.h} — click it, then \`type: …\`)` : e.role === 'slider' ? (e.range ? ` (slider ${e.range.lo}…${e.range.hi}, now ${e.range.now} — \`slide: { target: …, value: ${e.range.now} }\`)` : ' (slider — `slide: { target: …, to: 0..1 }`)') : e.options ? ` (dropdown — \`select: { target: …, option: "…" }\`; options: ${e.options.map((o) => `"${o.text}"${o.on ? ' (selected)' : ''}`).join(', ')})` : shows(e)}${e.href ? ` → ${e.href}` : ''}${e.kind === 'pointer' ? ' (custom clickable)' : ''}`),
|
|
164
172
|
'',
|
|
165
173
|
'## Images with alt text (target: { alt: "..." })',
|
|
166
174
|
...(info.images.length ? info.images.map((i) => `${fold(i.y)} y=${i.y}\t"${i.alt}"`) : ['(none)']),
|