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 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 needed). `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`. 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` may run past the step, e.g. to hold the zoom through a click. |
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
- Other fields: `speed` (default 1), `volume` (default 1), and `pronounce`, a
413
- map of word → spoken form used only for TTS, e.g.
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 (optional)
468
+ ### HyperFrames cards
447
469
 
448
- `hyperframes: true` renders the intro, section and end cards with
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. Two templates, picked by `purpose`:
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.5.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). For other languages, use
92
- ElevenLabs.
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
- - **More motion on the cards** (when the user asks for livelier titles, or for
183
- a launch video): `hyperframes: true` renders the intro, section and end
184
- cards with HyperFrames (needs Node 22+; installs itself on first use). For a
185
- subject with its own material, give the titles a backdrop from it:
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
- Promos get hand-drawn accents; `hand: true` uses hand lettering. Write the
188
- intro's `bullets` and the end card's recap with the same words the `say`
189
- line uses: each point lights up when the narration names it.
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, `sfx: riser`, and in `pitch` mode
224
- `duration: 1.0–1.5`. Waiting is compressed automatically.
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 `drag` for sliders and before/after
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
- for (const [a, b] of events.risers) {
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) { sped = dur / room; pcm = tempo(pcm, sped); dur = pcm.length / 2 / SR; }
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
- narration.push({ start, end: start + dur, text: line.text });
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
- fast: { cursor: 700, type: 55, beat: 0, tail: 0.4, maxWait: 1.0, voiceSpeed: 1 },
46
- normal: { cursor: 900, type: 70, beat: 350, tail: 0.8, maxWait: 1.5, voiceSpeed: 1 },
47
- slow: { cursor: 1200, type: 95, beat: 800, tail: 1.6, maxWait: 3.0, voiceSpeed: 0.92 },
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
- const hf0 = raw.hyperframes;
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 voice follows the narration's language (zf_001 for Chinese, af_heart otherwise)
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
- voice = { speed: pace0.voiceSpeed, volume: 1, ...VOICE_DEFAULTS[provider], ...(provider === 'kokoro' ? { voice: kokoroVoice } : {}), ...raw.voice, provider };
154
- if (draft && provider !== 'kokoro') {
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 and transition whooshes), 'clicks' (no whooshes: tutorials and lessons, where a
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.split(/[|—–-]/)[0].trim() || host)}
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.split(/[|—–-]/)[0].trim() || host)}
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
- if (el.id && !/^[0-9:]|:/.test(el.id)) return `#${CSS.escape(el.id)}`;
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)']),