storyshot 0.1.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,35 @@ second or two on screen.
15
15
  The agent plans, writes and fixes the storyboard. Storyshot does the
16
16
  recording and editing, and the same storyboard always renders the same video.
17
17
 
18
+ ## Next to similar tools
19
+
20
+ Storyshot, [promo-video-recorder](https://github.com/palamut62/promo-video-recorder)
21
+ and [demo-dev](https://www.npmjs.com/package/demo-dev) each made a promo of this
22
+ repo's page, with the same storyline and free local voices only. The grid
23
+ below is muted; open the full videos to hear them.
24
+
25
+ https://github.com/user-attachments/assets/3d8175ab-ba15-4b61-961d-481e57566e7a
26
+
27
+ <details>
28
+ <summary>The three full videos, with sound</summary>
29
+
30
+ **Storyshot**
31
+
32
+ https://github.com/user-attachments/assets/22987356-2a74-4e28-9a14-a1b01d04f932
33
+
34
+ **promo-video-recorder**
35
+
36
+ https://github.com/user-attachments/assets/e5eb86e5-e69c-403b-ad6c-c4499592307a
37
+
38
+ **demo-dev**
39
+
40
+ https://github.com/user-attachments/assets/8d3370ca-7a90-48af-b1c9-88189763e95d
41
+
42
+ </details>
43
+
44
+ How each one was made, what we saw, and the caveats:
45
+ [docs/comparison](docs/comparison/README.md).
46
+
18
47
  ## Quick start
19
48
 
20
49
  Open your project in Cursor or Claude Code and paste this into the agent chat:
@@ -51,8 +80,8 @@ macOS, Linux and Windows.
51
80
 
52
81
  ```
53
82
  Here's the plan. Reply "ok", or change any line:
54
- 1. Type: pitch: a 15–30 s promo
55
- 2. Length: about 15 s, 6 shots
83
+ 1. Type: pitch: a promo for the landing page
84
+ 2. Length: about 20 s, 6 shots
56
85
  3. Flow: hero → Examples → Try It → AI result → pricing → end card
57
86
  4. Voice: none (other options: free local English voice; ElevenLabs, key found in .env)
58
87
  5. Music: upbeat · Captions: English · Subtitle file (.srt): no
@@ -82,10 +111,13 @@ After your next release, re-render it and you have an up-to-date video.
82
111
 
83
112
  | ask for | type (`purpose`) | you get |
84
113
  |---|---|---|
85
- | a promo / launch / landing-page clip | `pitch` | 15–30 s, music, captions, tightly cut |
86
- | a how-to or onboarding video | `tutorial` | narrated, a spotlight on each click, numbered steps, soft music, `.srt` + chapters |
87
- | a lesson or explainer | `course` | narrated and explains the *why*, no music, `.srt` + chapters |
88
- | help-center snippets | `docs` | silent, one GIF per step |
114
+ | a promo / launch / landing-page clip | `pitch` | short and tightly cut: intro card, the app in a browser frame, music, captions |
115
+ | a how-to or onboarding video | `tutorial` | paced to follow along: one narrated action per step, the camera following each click, part cards, a second example, a recap; `.srt` + chapters |
116
+ | a lesson or explainer | `course` | explains the *why*: concept cards, the idea shown in the app, question cards, a recap, no music; `.srt` + chapters |
117
+ | help-center snippets | `docs` | silent per-step GIFs and PNGs cropped to the action, plus a ready-to-paste Markdown article |
118
+
119
+ Length follows the purpose and the content, not a target: a promo is as short as
120
+ its message, a tutorial as long as the workflow takes to explain.
89
121
 
90
122
  Any type can also be vertical (9:16) or in another language.
91
123
 
@@ -199,11 +231,19 @@ captionPosition: bottom # or top; can also be set per st
199
231
  purpose: pitch # pitch | tutorial | course | docs (sets the defaults below)
200
232
  highlight: false # spotlight targets before click/type (per step or per action too)
201
233
  stepNumbers: false # numbered caption badges
202
- subtitles: false # write <out>.srt (narration, else captions)
234
+ subtitles: false # write <out>.srt (narration, else captions); `burn` also burns the narration into the video, each word lighting up as it is spoken (captions move to the top)
235
+ pace: slow # fast (pitch) | normal (docs) | slow (tutorial, course): cursor and typing speed, a beat after each click, hold after narration, voice speed, how much of a wait is shown
236
+ follow: true # tutorial/course default: the camera zooms in on what each step touches and pans between steps (a step's own zoom wins; follow: false on a step skips it); or { scale: 1.8 }
237
+ frame: true # pitch default: the recording sits in a browser window on a backdrop; false = full frame, or { background: <css> }
203
238
  chapters: false # write <out>.chapters.txt for YouTube
204
- clips: null # gif | webm | mp4: one file per step in <out>-steps/
239
+ clips: null # gif | webm | mp4 | png, or a list (docs default: [gif, png]): one file per step in <out>-steps/, cropped to what the step touches, plus README.md, a ready-to-paste article (step captions, `doc:` text, images)
205
240
  maxWait: 1.0 # with duration: auto, the longest a wait is shown
206
241
 
242
+ intro: # optional title card before the recording (same fields as end_card)
243
+ title: Acme
244
+ subtitle: Ship **faster**
245
+ say: Meet Acme.
246
+
207
247
  setup: # runs before recording starts
208
248
  - dismiss: { role: button, name: Accept cookies }
209
249
 
@@ -220,6 +260,11 @@ steps:
220
260
  sfx: riser # rising sound + chime (or `chime`)
221
261
  actions:
222
262
  - waitFor: { text: Done, timeout: 120000 } # waiting is compressed automatically
263
+ - card: { kicker: Part 2, title: Read the results } # a section card before this step (tutorials); becomes a chapter
264
+ caption: The hits table
265
+ say: Each row is a protein similar to yours.
266
+ actions:
267
+ - mark: { css: "tbody tr:nth-child(1)" }
223
268
  - hidden: true # cut, not shown in the video
224
269
  actions:
225
270
  - goto: /
@@ -260,6 +305,10 @@ You can add `exact: true`, `last: true` or `nth: 2` to any of them.
260
305
  | `scrollTo` | `scrollTo: { css: "#pricing", ms: 1200 }` |
261
306
  | `scroll` | `scroll: 600` |
262
307
  | `dismiss` | `dismiss: { role: button, name: Close }` (clicks only if visible) |
308
+ | `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. |
309
+ | `unmark` | `unmark: true` clears the marks (and notes) earlier |
310
+ | `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`). |
311
+ | `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. |
263
312
 
264
313
  `ms` sets how long the cursor takes to travel to the target (`dragMs` for each
265
314
  drag move). `highlight: true/false` on `click`, `type` or `move` overrides the
@@ -326,17 +375,28 @@ changed. Write names the way they should be pronounced (`say: P X Genius.`),
326
375
  and spell out prices (`eleven ninety-nine`) in `pronounce` or in the line itself. At normal pace that comes to
327
376
  about 2.5 words per second, or roughly 35 words for a 15 s video.
328
377
 
329
- ### End card
378
+ ### Intro and end card
330
379
 
331
- Every field is optional; leave out `end_card` entirely for no end card.
380
+ Every field is optional; leave out `intro` / `end_card` for none. A step's
381
+ `card` takes the same fields and plays before that step (section titles in
382
+ tutorials). Both are short
383
+ animations: the logo pops in, the title rises, the `**bold**` highlight sweeps,
384
+ and a `cta` that is a command (`npx …`, `npm …`, `pip …`, `brew …`, `curl …`)
385
+ types itself out in a terminal pill. `init` writes an `intro` for promos;
386
+ tutorials and lessons can add one (e.g. "How to export a report"), docs GIFs
387
+ usually shouldn't.
332
388
 
333
389
  | field | meaning |
334
390
  |---|---|
335
391
  | `logo` | A target on the page (captured as an image, e.g. `{ alt: Acme logo }`) or a path to an image file. If the logo image already contains the wordmark, leave out `title`. |
336
392
  | `title` | Large product name next to the logo. |
393
+ | `kicker` | Small label above the title, e.g. `Part 2`. |
337
394
  | `subtitle` | One line; `**bold**` gets an accent highlight. |
395
+ | `image` | A picture or diagram file (png, jpg, svg) under the title: concept cards in lessons. |
396
+ | `think` | Seconds to pause after the card's `say` (a question card), with a "Pause and think" countdown. The next step shows the answer. |
397
+ | `bullets` | A numbered list that appears one item at a time: "what you'll learn" on an intro, a recap on the end card. The card stays up long enough to read it. |
338
398
  | `cta` | Dark call-to-action pill, e.g. `Start free at acme.com`. |
339
- | `duration` | Seconds (default 1.0). |
399
+ | `duration` | Seconds. Default: fits the `say` line, else 1.0 (end card) or 2.2 (intro). |
340
400
  | `say` | Voiceover line played over the end card. |
341
401
 
342
402
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyshot",
3
- "version": "0.1.3",
3
+ "version": "0.3.1",
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": {
@@ -55,9 +55,9 @@ everything.
55
55
 
56
56
  ```
57
57
  Here's the plan. Reply "ok", or change any line:
58
- 1. Type: pitch: a 15–30 s promo (other options: tutorial = narrated how-to;
59
- course = narrated lesson; docs = silent step-by-step GIFs)
60
- 2. Length: about 15 s, 6 shots
58
+ 1. Type: pitch: a promo for the landing page (other options: tutorial =
59
+ narrated how-to; course = narrated lesson; docs = silent step-by-step GIFs)
60
+ 2. Length: about 20 s, 6 shots (it follows from the flow below)
61
61
  3. Flow: hero → Examples → Try It → AI result → pricing → end card
62
62
  4. Voice: none (other options: free local English voice; ElevenLabs, best
63
63
  quality and Chinese support, key found in .env)
@@ -67,14 +67,16 @@ everything.
67
67
  window for you to log in once; please use a demo account)
68
68
  ```
69
69
 
70
- Defaults by type (propose these unless the request says otherwise):
70
+ Defaults by type (propose these unless the request says otherwise). There
71
+ is no target length: it follows from the purpose and the content. Estimate
72
+ it from the flow and say it in the plan, so the user can react.
71
73
 
72
- | type | length | voice | music | on screen / files |
74
+ | type | length comes from | voice | music | on screen / files |
73
75
  |---|---|---|---|---|
74
- | `pitch` | 15 s (or 30 s), 5–7 shots | optional | upbeat | captions |
75
- | `tutorial` | 45–90 s, 6–12 steps | yes | soft | step numbers, spotlight, .srt, chapters |
76
- | `course` | 1–3 min | yes | none | spotlight, .srt, chapters |
77
- | `docs` | one GIF per step | none | none | step numbers, spotlight, GIFs |
76
+ | `pitch` | where it's shown and its one message: a landing-page loop or social clip is a handful of shots; a launch video can tell a longer story | optional | upbeat | captions, intro, frame |
77
+ | `tutorial` | every step of the workflow, each explained, plus a second example; as long as that takes | yes | soft | step numbers, spotlight, camera follows, part cards, recap, .srt, chapters |
78
+ | `course` | the concepts to teach: each idea, shown in the app, checked with a question | yes | none | spotlight, camera follows, concept and question cards, recap, .srt, chapters |
79
+ | `docs` | one clip per step, each just long enough to see its result | none | none | step numbers, spotlight, cropped GIFs + PNGs, README.md article |
78
80
 
79
81
  Captions match the site's language. `kokoro` (free, local) speaks English
80
82
  only; for other languages, use ElevenLabs.
@@ -121,12 +123,72 @@ everything.
121
123
 
122
124
  ## 3. Write the storyboard
123
125
 
124
- Rules of thumb from real renders:
126
+ ### Tutorials and lessons are not ads
127
+
128
+ A promo shows as much as possible, fast. A tutorial does the opposite: the
129
+ viewer has to be able to **do it themselves** afterwards, often in a technical
130
+ or scientific tool they have never seen. Storyshot paces `tutorial` and
131
+ `course` slowly by default (`pace: slow`: slower cursor and typing, a beat
132
+ after every click, a hold after every narration line, slower voice, real
133
+ progress shown for waits) and the camera follows the action (`follow`), so
134
+ small controls are readable. Your job is the structure:
135
+
136
+ - **Start with the goal**: an `intro` with `bullets` saying what the viewer
137
+ will be able to do ("Run a BLAST search", "Read the hits table", "Judge a
138
+ match by E-value"), and what they need beforehand.
139
+ - **Split the workflow into parts**, each opened by a `card`
140
+ (`card: { kicker: Part 2, title: Read the results }` on its first step).
141
+ Cards become the YouTube chapters.
142
+ - **One action per step.** Each click, field or choice is its own step with
143
+ its own `say:` explaining **what** it does and **why this value**. Never
144
+ "fill in the form and submit" in one line. `check` warns when a tutorial
145
+ step does more than two things.
146
+ - **Show, then explain the result.** After an action that produces output
147
+ (a results table, a number, a plot, a status), add a step that only looks:
148
+ `mark` the value or row you're talking about (`mark: "E value"`,
149
+ `mark: { css: "tbody tr:nth-child(1)" }`), and say what it means and how to
150
+ judge it ("an E-value this small means the match is not by chance").
151
+ - **Label the controls that matter.** `note: { target: …, text: "FASTA or accession" }`
152
+ puts a short callout with an arrow next to a field, button or column. Use it for
153
+ the one thing a newcomer would get wrong, not for everything.
154
+ - **Dropdowns:** `select: { target: …, option: "…" }` (inspect lists the
155
+ options). Say why this option.
156
+ - **Then a different example.** Run the workflow again with a second input
157
+ that behaves differently (another sequence, sample, parameter), and point
158
+ out what changed and why. Comparison is what makes the concept stick.
159
+ A short `card` ("Now a second example") separates it.
160
+ - **Lessons (`course`) explain the why.** Use concept cards for ideas that
161
+ aren't on screen (`card: { kicker: Concept, title: …, bullets: […], image:
162
+ diagram.svg }`), and question cards before a reveal (`card: { kicker:
163
+ Question, title: "Which hit is the real homolog?", think: 3 }`, then the
164
+ next step marks the answer).
165
+ - **Docs GIFs (`docs`)** become a help-center article: each step's clip is
166
+ cropped to what it touches and holds the result before looping; a PNG of
167
+ each result and `<out>-steps/README.md` are written too. Give every step a
168
+ short `caption` (the heading) and a `doc:` line (the article text), and
169
+ `note` the field the reader must not miss.
170
+ - **End with a recap**: `end_card` with `bullets` listing the steps or rules
171
+ to remember, and a `say` that repeats them.
172
+ - **Narrating real results.** You can't know the numbers (scores, counts,
173
+ E-values) before the app produces them. Write the result steps with
174
+ general narration, render once, read the actual values from the preview
175
+ frames, then rewrite those `say:`/`note`/caption lines with the real
176
+ numbers and run `compose` only (no re-recording, no new jobs).
177
+ - **Waiting is part of the lesson.** Long computations are still compressed,
178
+ but say what is happening ("BLAST is now comparing your sequence against
179
+ the database; this usually takes under a minute").
180
+ - Length follows the content. Don't cut explanation to hit a length, and
181
+ don't pad: if a part is long, split it into more parts rather than
182
+ speeding it up.
183
+
184
+ Rules of thumb from real renders (promos and tutorials):
125
185
 
126
186
  - **One idea per step.**
127
- - `pitch`: 1.5–3 s per step (`duration:`). A 15 s video is 5–7 visible
128
- steps plus a 1–1.5 s `end_card`. Typical shape: hero → 2–4 steps of the
129
- core flow ending on a visible result → pricing or CTA → end card.
187
+ - `pitch`: 1.5–3 s per step (`duration:`), so the viewer gets one beat per
188
+ shot; the number of shots comes from the story, plus a 1–1.5 s `end_card`. Typical shape: `intro` → hero → 2–4
189
+ steps of the core flow ending on a visible result → pricing or CTA → end
190
+ card. Promos sit in a browser window on a backdrop (`frame: false` turns
191
+ that off).
130
192
  - Other purposes: leave out `duration`. `auto` times each step to its
131
193
  narration and plays motion in real time.
132
194
  - **Captions ≤ 7 words.** `**bold**` the payoff ("Every text box is
@@ -148,6 +210,14 @@ Rules of thumb from real renders:
148
210
  and `press: "^"` send single keys. In `tutorial`/`course`, the spotlight
149
211
  highlights each click target automatically; add `highlight: true` to a
150
212
  `move` to point at something without clicking it.
213
+ - **Point at what the narration names.** `mark: "the exact phrase"` sweeps a
214
+ highlighter over words on the page (or `mark: <target>` boxes a table row,
215
+ card or code line) while the voice says them. On text-heavy pages a mark
216
+ plus a zoom tells the viewer where to look.
217
+ - **Static pages (a landing page, docs, a README) have nothing to click.**
218
+ Don't make a slideshow of scrolls: open with an `intro` (the promise),
219
+ `mark` the claims as they are spoken, zoom on 2–3 of them, and end on a
220
+ command or URL. If the product has a real flow, show that instead.
151
221
  - **Cut boring navigation** with a `hidden: true` step (e.g. `goto: /pricing`).
152
222
  - **Clean the frame** in `setup`: dismiss cookie banners, announcement bars and
153
223
  chat widgets. `dismiss` is a no-op when the element isn't there.
package/src/build.mjs CHANGED
@@ -3,15 +3,16 @@ import { record } from './record.mjs';
3
3
  import { compose } from './compose.mjs';
4
4
  import { soundtrack } from './audio.mjs';
5
5
  import { measureVoice } from './voice.mjs';
6
- import { writeOutputs } from './outputs.mjs';
6
+ import { writeOutputs, burnSubtitles } from './outputs.mjs';
7
7
 
8
8
  export async function build(cfg, mode = 'render', { log = console.log } = {}) {
9
9
  const t0 = Date.now();
10
10
  if (mode !== 'compose') await record(cfg, { log });
11
11
  if (mode === 'record') return { warnings: [] };
12
12
  const voiceLens = await measureVoice(cfg, { log });
13
- const { silent, events, preview, warnings, captions, segs } = await compose(cfg, { log, voiceLens });
13
+ const { silent, events, preview, warnings, captions, cards, segs } = await compose(cfg, { log, voiceLens });
14
14
  const audio = await soundtrack(cfg, events, silent, { log });
15
- const extras = await writeOutputs(cfg, { captions, segs, narration: audio.narration, total: events.total }, { log });
15
+ if (cfg.subtitles === 'burn') burnSubtitles(cfg, audio.narration ?? [], { log });
16
+ const extras = await writeOutputs(cfg, { captions, cards, segs, narration: audio.narration, total: events.total }, { log });
16
17
  return { out: audio.out, extras, preview, warnings: [...warnings, ...audio.warnings], duration: events.total, seconds: Math.round((Date.now() - t0) / 1000) };
17
18
  }
package/src/check.mjs CHANGED
@@ -4,16 +4,17 @@ import YAML from 'yaml';
4
4
  import { loadConfig } from './config.mjs';
5
5
  import { gitIgnored } from './auth.mjs';
6
6
 
7
- const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'colorScheme',
7
+ const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'intro', 'frame', 'pace', 'follow', 'colorScheme',
8
8
  'scrollOffset', 'captionPosition', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card'];
9
- const STEP = ['caption', 'say', 'sayAt', 'duration', 'zoom', 'actions', 'hidden', 'sfx', 'highlight', 'captionPosition'];
9
+ const STEP = ['caption', 'say', 'sayAt', 'duration', 'zoom', 'actions', 'hidden', 'sfx', 'highlight', 'captionPosition', 'card', 'follow', 'doc'];
10
10
  const ZOOM = ['target', 'scale', 'start', 'end', 'ramp', 'offset'];
11
- const END = ['logo', 'title', 'subtitle', 'cta', 'duration', 'say'];
11
+ const END = ['logo', 'title', 'subtitle', 'cta', 'duration', 'say', 'kicker', 'bullets', 'image', 'think'];
12
12
  const TARGET = ['role', 'name', 'text', 'label', 'placeholder', 'alt', 'testId', 'css', 'exact', 'last', 'nth'];
13
- const OPTS = ['ms', 'offset', 'timeout', 'highlight'];
13
+ const OPTS = ['ms', 'offset', 'timeout', 'highlight', 'color', 'box'];
14
14
  const ACTIONS = {
15
15
  pause: 'number', wait: 'number', move: 'target', hover: 'target', click: 'target', dismiss: 'target', waitFor: 'target',
16
16
  waitForUrl: 'string', networkIdle: 'any', goto: 'string', scrollTo: 'target', scroll: 'any', type: 'type', drag: 'drag', press: 'any',
17
+ mark: 'mark', unmark: 'any', note: 'note', select: 'select',
17
18
  };
18
19
 
19
20
  const dist = (a, b) => {
@@ -68,8 +69,22 @@ export function check(file, { draft = false } = {}) {
68
69
  if (s.hidden && (s.caption || s.say)) warnings.push(`${where}: hidden steps are cut from the video, so caption/say are ignored`);
69
70
  if (!Array.isArray(s.actions ?? [])) errors.push(`${where}: actions must be a list`);
70
71
  for (const [j, a] of (s.actions ?? []).entries()) checkAction(a, `${where} action ${j + 1}`);
72
+ if (s.card) unknown(s.card, END, `${where} card`);
73
+ // tutorials: one thing per step, so the viewer can follow (and the narration can explain it)
74
+ const purpose = raw.purpose ?? 'pitch';
75
+ if ((purpose === 'tutorial' || purpose === 'course') && !s.hidden) {
76
+ const doing = (s.actions ?? []).filter((a) => a && ['click', 'type', 'drag', 'press', 'goto', 'select'].includes(Object.keys(a)[0]) && !(Object.keys(a)[0] === 'press' && /^(Enter|Tab)$/.test(String(Object.values(a)[0]))));
77
+ if (doing.length > 2) warnings.push(`${where}: does ${doing.length} things (${doing.map((a) => Object.keys(a)[0]).join(', ')}) in one step — in a ${purpose}, split it so each action gets its own explanation and the viewer can keep up`);
78
+ if (doing.length && !s.say) warnings.push(`${where}: does something but has no say: line — in a ${purpose}, tell the viewer what and why`);
79
+ const kinds = (s.actions ?? []).map((a) => a && Object.keys(a)[0]);
80
+ if (s.say && kinds.some((k) => k === 'scroll' || k === 'scrollTo') && !kinds.some((k) => ['mark', 'note', 'click', 'type', 'select', 'move', 'hover'].includes(k)) && !s.zoom) {
81
+ warnings.push(`${where}: scrolls and talks but points at nothing — on a dense page the viewer can't tell what you mean; \`mark\` or \`note\` the row or value the narration is about (the camera then zooms to it)`);
82
+ }
83
+ }
71
84
  }
72
85
  if (raw.end_card) unknown(raw.end_card, END, 'end_card');
86
+ if (raw.intro) unknown(raw.intro, END, 'intro');
87
+ if (raw.frame && typeof raw.frame === 'object') unknown(raw.frame, ['background'], 'frame');
73
88
  const todo = (fs.readFileSync(file, 'utf8').split('\n').map((l, i) => [l.replace(/#.*$/, ''), i + 1]).filter(([l]) => /\bTODO\b/.test(l)));
74
89
  for (const [, n] of todo) errors.push(`line ${n}: replace the TODO placeholder`);
75
90
 
@@ -85,6 +100,16 @@ export function check(file, { draft = false } = {}) {
85
100
  if (type === 'number' && typeof v !== 'number') errors.push(`${where}: ${kind} takes milliseconds, e.g. "${kind}: 800"`);
86
101
  if (type === 'string' && typeof v !== 'string') errors.push(`${where}: ${kind} takes a string`);
87
102
  if (type === 'target') target(v, `${where} (${kind})`);
103
+ if (type === 'mark' && typeof v !== 'string') target(v, `${where} (mark)`); // a string is a phrase to highlight
104
+ if (type === 'select') {
105
+ if (!v?.target || v?.option == null) errors.push(`${where}: select needs { target: <dropdown>, option: "visible text of the option" }`);
106
+ else target(v.target, `${where} (select.target)`);
107
+ }
108
+ if (type === 'note') {
109
+ if (!v?.target || !v?.text) errors.push(`${where}: note needs { target: <target>, text: "short label" }`);
110
+ else { target(v.target, `${where} (note.target)`); unknown(v, ['target', 'text', 'side', 'ms', 'timeout'], `${where} (note)`); }
111
+ if (v?.side && !['auto', 'left', 'right', 'top', 'bottom'].includes(v.side)) errors.push(`${where}: note side must be auto, left, right, top or bottom`);
112
+ }
88
113
  if (type === 'type') {
89
114
  if (typeof v === 'string' || typeof v === 'number') return;
90
115
  if (v?.text == null) errors.push(`${where}: type needs text — "type: hello" types into the focused element, "type: { target: <target>, text: hello }" clicks the target first`);
@@ -104,7 +129,7 @@ export function check(file, { draft = false } = {}) {
104
129
  let summary = '';
105
130
  if (cfg) {
106
131
  const visible = cfg.steps.filter((s) => !s.hidden);
107
- const says = visible.filter((s) => s.say).length + (cfg.endCard?.say ? 1 : 0);
132
+ const says = visible.filter((s) => s.say).length + (cfg.endCard?.say ? 1 : 0) + (cfg.intro?.say ? 1 : 0);
108
133
  if (cfg.voice && !says) warnings.push('voice is set but no step has a `say:` line');
109
134
  if (!cfg.voice && says) warnings.push('steps have `say:` lines but there is no `voice:` block, so they will not be spoken');
110
135
  if (!draft && cfg.voice && ['elevenlabs', 'openai'].includes(cfg.voice.provider)) {
@@ -126,7 +151,7 @@ export function check(file, { draft = false } = {}) {
126
151
  const fixed = visible.filter((s) => s.duration !== 'auto').reduce((a, s) => a + s.duration, 0);
127
152
  const auto = visible.filter((s) => s.duration === 'auto').length;
128
153
  const ec = cfg.endCard ? (cfg.endCard.duration === 'auto' ? null : cfg.endCard.duration) : 0;
129
- const length = auto ? `${fixed ? `${fixed.toFixed(1)}s fixed + ` : ''}${auto} steps timed to narration${cfg.endCard ? ' + end card' : ''}`
154
+ const length = auto ? `${fixed ? `${fixed.toFixed(1)}s fixed + ` : ''}${auto} steps timed to narration${cfg.endCard ? ' + end card' : ''}${cfg.intro ? ' + intro' : ''}`
130
155
  : `${fixed.toFixed(1)}s of steps${cfg.endCard ? ` + ${ec ?? 'auto'}s end card` : ''}${ec != null ? ` = ${(fixed + ec).toFixed(1)}s total` : ''}`;
131
156
  summary = `${cfg.name}: purpose ${cfg.purpose}, ${visible.length} visible steps, ${length}` +
132
157
  (cfg.voice ? `, voice ${cfg.voice.provider}` : '') + ` → ${cfg.out}`;