storyshot 0.3.1 → 0.4.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
@@ -6,7 +6,7 @@ docs.
6
6
 
7
7
  > *"make a 15 s promo video of https://myapp.com"*
8
8
 
9
- Storyshot gives Cursor, Claude Code and other coding agents what they need to
9
+ Storyshot gives coding agents (Cursor, Claude Code, Codex, Gemini CLI, Copilot in VS Code, Windsurf, Cline) what they need to
10
10
  do this well. A real browser clicks through your site, and the recording gets
11
11
  a smooth cursor, automatic zoom, captions, an optional AI voiceover, music
12
12
  and an end card. Loading time is squeezed out, so a 30-second AI job takes a
@@ -15,6 +15,13 @@ 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
+ ## Gallery
19
+
20
+ **[boheling.github.io/storyshot-gallery](https://boheling.github.io/storyshot-gallery/)**: promos,
21
+ tutorials, lessons and help-center docs of real web apps (Squoosh, Excalidraw, NCBI BLAST, Desmos,
22
+ regex101, RCSB PDB, Datasette and more, two of them in Chinese). Each one was made by an agent from a
23
+ one-paragraph request, and comes with its storyboard and notes on how it went.
24
+
18
25
  ## Next to similar tools
19
26
 
20
27
  Storyshot, [promo-video-recorder](https://github.com/palamut62/promo-video-recorder)
@@ -46,11 +53,28 @@ How each one was made, what we saw, and the caveats:
46
53
 
47
54
  ## Quick start
48
55
 
49
- Open your project in Cursor or Claude Code and paste this into the agent chat:
56
+ Open your project in your coding agent and paste this into its chat, with your
57
+ agent's name (`cursor`, `claude`, `codex`, `gemini`, `vscode`, `windsurf` or `cline`):
58
+
59
+ > Set up Storyshot in this project: run `npx -y storyshot setup cursor` and do
60
+ > everything it says. Then make a short promo video of https://myapp.com
61
+
62
+ | agent | what `setup` writes |
63
+ |---|---|
64
+ | Cursor | `.cursor/mcp.json` and the skill in `.cursor/skills/storyshot/` |
65
+ | Claude Code | `.mcp.json` and the skill in `.claude/skills/storyshot/` |
66
+ | Codex | the skill in `.agents/skills/storyshot/`, a short section in `AGENTS.md` pointing to it, and the MCP server through `codex mcp add` (in `~/.codex/config.toml`, for your user). Storyshot launches a browser, which Codex's sandbox blocks: approve it when Codex asks, or run Codex with `--sandbox danger-full-access` |
67
+ | Gemini CLI | `.gemini/settings.json`, a section in `GEMINI.md`, the skill in `.agents/skills/storyshot/` (trust the folder when Gemini asks, or it ignores project settings) |
68
+ | Copilot in VS Code (`vscode`) | `.vscode/mcp.json`, a section in `.github/copilot-instructions.md`, the skill in `.agents/skills/storyshot/` |
69
+ | Windsurf | `.windsurf/rules/storyshot.md`, the skill in `.agents/skills/storyshot/`, and the MCP server in `~/.codeium/windsurf/mcp_config.json` (for your user) |
70
+ | Cline | `.clinerules/storyshot.md`, the skill in `.agents/skills/storyshot/`, and the MCP server in Cline's own settings in each editor it is installed in (for your user) |
71
+
72
+ Existing files are merged, not replaced: other MCP servers stay, and instruction files get one marked
73
+ Storyshot section that a second `setup` updates instead of duplicating.
50
74
 
51
- > Set up Storyshot in this project: run `npx -y storyshot setup cursor` (use
52
- > `setup claude` in Claude Code) and do everything it says. Then make a 15 s
53
- > promo video of https://myapp.com
75
+ **Tested end to end** (setup, then an agent making a real video from a plain request): **Claude Code,
76
+ Cursor and Codex**. Gemini CLI, Copilot in VS Code, Windsurf and Cline get the configuration their docs
77
+ describe, but haven't been tested with a real session yet; reports are welcome.
54
78
 
55
79
  The agent does the rest:
56
80
  - `setup` writes the MCP server config and the Storyshot skill, adds
@@ -61,7 +85,7 @@ The agent does the rest:
61
85
  - The agent reads the skill and starts on your video in the same chat, using
62
86
  the CLI.
63
87
 
64
- Reload Cursor (or restart Claude Code) when convenient. From the next chat on,
88
+ Restart or reload your agent when convenient (`setup` says how for each one). From the next chat on,
65
89
  the agent uses the Storyshot MCP tools automatically, and you can just ask:
66
90
 
67
91
  - *"make a 15 s promo video of https://myapp.com"*, or type `/storyshot`
@@ -169,7 +193,7 @@ from `storyshot guide`.
169
193
  | `check_storyboard` | Validates in about a second: typos with "did you mean", action and target shapes, `TODO`s, keys, the session file. |
170
194
  | `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. |
171
195
 
172
- Other MCP clients: run `npx -y storyshot mcp` (stdio). When run from a git
196
+ Any other MCP client: run `npx -y storyshot mcp` (stdio), and point the agent at the playbook (`storyshot guide`). When run from a git
173
197
  checkout, `setup` points to `node /path/to/checkout/bin/storyshot.mjs mcp` instead.
174
198
 
175
199
  When a step fails, the error names the step and action, and says whether the
@@ -192,7 +216,7 @@ Everything the agent does is also a command:
192
216
  | `storyshot compose videos/acme-promo.yaml [--draft]` | Rebuild from the last recording, in seconds. |
193
217
  | `storyshot record videos/acme-promo.yaml` | Only record. |
194
218
  | `storyshot guide` | Print the playbook and the reference. |
195
- | `storyshot setup cursor\|claude` | Set up the MCP server and the skill in this project, and install or check what Storyshot needs. |
219
+ | `storyshot setup <agent>` (`cursor`, `claude`, `codex`, `gemini`, `vscode`, `windsurf`, `cline`) | Set up the MCP server and the skill in this project, and install or check what Storyshot needs. |
196
220
  | `storyshot doctor` | Check Node, ffmpeg, Chromium and voices; install Chromium if it's missing. |
197
221
  | `storyshot mcp` | Run the MCP server. |
198
222
 
@@ -228,13 +252,18 @@ voice: { provider: elevenlabs, voice: hpp4J3VqNfWAUOO0d1Us } # optional voiceo
228
252
  storageState: auth.json # optional Playwright login state
229
253
  ignoreHTTPSErrors: true # internal site with a self-signed HTTPS certificate (plain http:// needs nothing)
230
254
  captionPosition: bottom # or top; can also be set per step. A caption that would cover a step's click target or zoom subject moves to the other side automatically
255
+ captions: true # false keeps caption pills off the video (chapters and SRT still use them), e.g. with subtitles: burn
231
256
  purpose: pitch # pitch | tutorial | course | docs (sets the defaults below)
232
257
  highlight: false # spotlight targets before click/type (per step or per action too)
233
258
  stepNumbers: false # numbered caption badges
234
259
  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
260
  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
261
  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> }
262
+ article: { title: Export a drawing, intro: … } # docs: heading and first paragraph of <out>-steps/README.md
263
+ lang: es # language of built-in labels ("Pause and think", chapter "Introduction", "Downloaded"): en, zh, es, pt, fr, de, ja; default: zh if the steps contain Chinese, else en
264
+ cards: auto # intro/section/end cards: auto follows the frame (dark frames get dark cards), or light | dark
265
+ frame: aurora # the recording sits in a browser window on a backdrop: aurora (dark, grain; pitch default), studio (light, grain; tutorial and course default), dark, pastel, blur (the page itself, blurred); false = full frame; or { style, background: <css>, window, title, icon }
266
+ # window: app draws an installed app's window (its icon and name in the title bar, no address bar) for a PWA or desktop web app; title and icon come from the page's web app manifest unless given
238
267
  chapters: false # write <out>.chapters.txt for YouTube
239
268
  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)
240
269
  maxWait: 1.0 # with duration: auto, the longest a wait is shown
@@ -273,8 +302,7 @@ end_card:
273
302
  logo: { alt: Company logo } # captured from the page, or a path to an image
274
303
  title: Acme
275
304
  subtitle: Ship **faster**
276
- cta: Start free at acme.com
277
- duration: 1.0
305
+ cta: Start free at acme.com # duration: auto (default) fits the say line, bullets and cta, at least 2.5 s
278
306
  ```
279
307
 
280
308
  Steps that share a caption keep it on screen across both steps.
@@ -293,8 +321,9 @@ You can add `exact: true`, `last: true` or `nth: 2` to any of them.
293
321
  | action | example |
294
322
  |---|---|
295
323
  | `click` | `click: { role: button, name: Convert, ms: 800, offset: { x: 0.3, y: 0.5 } }` |
324
+ | `dblclick` | `dblclick: { text: Start }` double-clicks (e.g. to edit a label in place) |
296
325
  | `move` / `hover` | `move: { text: "$0" }` or `move: { x: 0.5, y: 0.4, ms: 1200 }` |
297
- | `type` | `type: { target: { label: Email }, text: hi@acme.com, delay: 60 }`, or `type: hello` to type into whatever has focus |
326
+ | `type` | `type: { target: { label: Email }, text: hi@acme.com, delay: 60 }`, or `type: hello` to type into whatever has focus. `clear: true` empties a pre-filled field first |
298
327
  | `drag` | `drag: { from: { css: ".slider" }, by: [{ x: -300 }, { x: 450 }] }` (several moves while held), or `to: <target or point>` |
299
328
  | `press` | `press: Enter`, `press: "^"`, `press: { key: Meta+K, show: true }`. When highlighting, shortcuts and special keys show an on-screen badge; plain characters don't. `show: false` hides it. |
300
329
  | `pause` | `pause: 700` (ms) |
@@ -305,9 +334,12 @@ You can add `exact: true`, `last: true` or `nth: 2` to any of them.
305
334
  | `scrollTo` | `scrollTo: { css: "#pricing", ms: 1200 }` |
306
335
  | `scroll` | `scroll: 600` |
307
336
  | `dismiss` | `dismiss: { role: button, name: Close }` (clicks only if visible) |
337
+ | `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`) |
308
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. |
309
339
  | `unmark` | `unmark: true` clears the marks (and notes) earlier |
310
340
  | `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
+ | `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 |
311
343
  | `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. |
312
344
 
313
345
  `ms` sets how long the cursor takes to travel to the target (`dragMs` for each
package/bin/storyshot.mjs CHANGED
@@ -24,7 +24,8 @@ Usage:
24
24
  (after changing captions, durations, zoom, music, voice, end card)
25
25
  storyshot record <storyboard.yaml> only (re-)record the browser session
26
26
  storyshot guide print the agent playbook + storyboard reference
27
- storyshot setup cursor|claude add the MCP server + playbook to this project for Cursor / Claude Code,
27
+ storyshot setup <agent> add the MCP server + playbook to this project for your coding agent:
28
+ cursor, claude, codex, gemini, vscode (Copilot), windsurf, cline —
28
29
  and install / check everything Storyshot needs
29
30
  storyshot doctor check (and install what it can of) Chromium, ffmpeg, voices
30
31
  storyshot mcp run as an MCP server (stdio)
@@ -77,7 +78,14 @@ try {
77
78
  ` show up in this session, use the CLI instead: ${cliCommand()} <command>`,
78
79
  arg === 'cursor'
79
80
  ? ' User: reload Cursor (Cmd/Ctrl+Shift+P → "Reload Window") so the storyshot MCP tools load; future chats use them automatically.'
80
- : ' User: restart Claude Code in this project and approve the storyshot MCP server; future sessions use it automatically.',
81
+ : ({
82
+ claude: ' User: restart Claude Code in this project and approve the storyshot MCP server; future sessions use it automatically.',
83
+ codex: ' User: start a new Codex session in this project; it reads AGENTS.md and loads the storyshot MCP server. Storyshot needs to launch a browser, which Codex\'s sandbox blocks: approve it when Codex asks, or start Codex with --sandbox danger-full-access.',
84
+ gemini: ' User: restart Gemini CLI in this project and trust the folder when asked (Gemini ignores project settings in untrusted folders); /mcp then lists the storyshot tools.',
85
+ vscode: ' User: in VS Code, open .vscode/mcp.json and click Start on "storyshot", then use Copilot Chat in Agent mode.',
86
+ windsurf: ' User: in Windsurf, open Cascade → MCP servers and refresh; the storyshot tools then appear.',
87
+ cline: ' User: in Cline, open MCP Servers and make sure "storyshot" is enabled (restart the editor if it is not listed).',
88
+ })[arg],
81
89
  ].join('\n'));
82
90
  } else if (cmd === 'login') {
83
91
  const { login, authFileFor } = await import('../src/auth.mjs');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyshot",
3
- "version": "0.3.1",
3
+ "version": "0.4.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": {
@@ -38,6 +38,12 @@ is missing, a tool says so and gives the install command. Run that command
38
38
  yourself (with the user's approval), then retry. `storyshot doctor` checks
39
39
  everything.
40
40
 
41
+ **Never fake the video.** Storyshot records the real site in a real browser. If
42
+ the browser can't start (an agent sandbox that blocks it, a missing Chromium),
43
+ or the tools keep failing, stop and tell the user what blocked it and how to
44
+ allow it. Never produce a substitute: no mock-ups, redrawn screens, scripted
45
+ frames or stock footage presented as a recording of their app.
46
+
41
47
  ## 1. Look, then confirm the brief
42
48
 
43
49
  1. **Look first.** `inspect_page` the start URL (about 5 s). Note what the site
@@ -166,7 +172,9 @@ small controls are readable. Your job is the structure:
166
172
  cropped to what it touches and holds the result before looping; a PNG of
167
173
  each result and `<out>-steps/README.md` are written too. Give every step a
168
174
  short `caption` (the heading) and a `doc:` line (the article text), and
169
- `note` the field the reader must not miss.
175
+ `note` the field the reader must not miss. Set the article's heading and
176
+ first paragraph with `article: { title: …, intro: … }`. Downloads show a
177
+ "Downloaded" bubble; `paste` and copy-to-clipboard work.
170
178
  - **End with a recap**: `end_card` with `bullets` listing the steps or rules
171
179
  to remember, and a `say` that repeats them.
172
180
  - **Narrating real results.** You can't know the numbers (scores, counts,
@@ -185,10 +193,12 @@ Rules of thumb from real renders (promos and tutorials):
185
193
 
186
194
  - **One idea per step.**
187
195
  - `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
196
+ shot; the number of shots comes from the story, plus an `end_card` held long enough to read its call to action (at least 2.5 s; leave its `duration` out). Typical shape: `intro` → hero → 2–4
189
197
  steps of the core flow ending on a visible result → pricing or CTA → end
190
198
  card. Promos sit in a browser window on a backdrop (`frame: false` turns
191
- that off).
199
+ that off). For an app people install (a PWA, or a desktop app built on
200
+ web tech), use `frame: { window: app }`: the title bar shows the app's
201
+ icon and name, not an address bar.
192
202
  - Other purposes: leave out `duration`. `auto` times each step to its
193
203
  narration and plays motion in real time.
194
204
  - **Captions ≤ 7 words.** `**bold**` the payoff ("Every text box is
@@ -219,8 +229,9 @@ Rules of thumb from real renders (promos and tutorials):
219
229
  `mark` the claims as they are spoken, zoom on 2–3 of them, and end on a
220
230
  command or URL. If the product has a real flow, show that instead.
221
231
  - **Cut boring navigation** with a `hidden: true` step (e.g. `goto: /pricing`).
222
- - **Clean the frame** in `setup`: dismiss cookie banners, announcement bars and
223
- chat widgets. `dismiss` is a no-op when the element isn't there.
232
+ - **Clean the frame** in `setup`: dismiss cookie banners and announcement bars
233
+ (`dismiss` is a no-op when the element isn't there), and `hide` survey
234
+ pop-ups, feedback tabs and chat widgets, which often appear late.
224
235
  - **Keep the caption off the subject.** Captions sit bottom-centre. When one
225
236
  would cover a target the step clicks, hovers, types into or drags, or the
226
237
  zoom subject, it moves to the top automatically (with a warning). Other things
@@ -248,8 +259,13 @@ the page. Test new targets with `inspect_page` + `after_step`, which is much
248
259
  faster than a failed render.
249
260
 
250
261
  Notes:
251
- - The end card's duration fits its `say:` line automatically; leave out
252
- `duration` there.
262
+ - The end card's duration fits its `say:` line, bullets and `cta`
263
+ automatically (never under 2.5 s); leave out `duration` there.
264
+ - Compose warns about **dead air**: 3 s or more where nothing moves and
265
+ nothing is said. Shorten the wait or pause, hide the load in a `hidden`
266
+ step, or narrate it. Keep `intro` cards to 6–8 s.
267
+ - With `subtitles: burn`, the top caption pills can repeat the subtitles;
268
+ `captions: false` keeps them off the video (chapters and SRT still use them).
253
269
  - `.storyshot/` next to the YAML is a cache of recordings, screenshots and
254
270
  voice lines. Add it to `.gitignore`.
255
271
 
package/src/audio.mjs CHANGED
@@ -92,8 +92,14 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
92
92
 
93
93
  // --- sound effects ------------------------------------------------------------------------
94
94
  if (cfg.sfx) {
95
+ // a mouse click: the press (band-passed noise with a small body thump), then a quieter release
95
96
  for (const c of events.clicks) {
96
- add(sfx, c, 0.05, (t) => (Math.sin(2 * Math.PI * 2900 * t) * 0.6 + rand() * 0.4) * Math.exp(-t * 140), 0.45);
97
+ const fa = svf(), fb = svf();
98
+ add(sfx, c, 0.1, (t) => {
99
+ const press = t < 0.02 ? (fa(rand(), 3200, 2.5).bp * 1.4 + Math.sin(2 * Math.PI * 190 * t) * 0.5) * Math.exp(-t * 320) : 0;
100
+ const r = t - 0.075, release = r > 0 && r < 0.015 ? fb(rand(), 4200, 3).bp * 0.8 * Math.exp(-r * 420) : 0;
101
+ return press + release;
102
+ }, 0.55);
97
103
  }
98
104
  for (const c of events.whooshes) {
99
105
  const filt = svf(), len = 0.45;
@@ -177,8 +183,10 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
177
183
  const wavFile = path.join(cfg.work, 'soundtrack.wav');
178
184
  fs.writeFileSync(wavFile, wav);
179
185
 
186
+ // loudnorm alone lets peaks through after AAC encoding; the limiter keeps true peaks near −2 dB,
187
+ // so re-encoding by video sites doesn't clip
180
188
  execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', silentVideo, '-i', wavFile,
181
- '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', 'loudnorm=I=-14:TP=-1.5:LRA=11', '-ar', `${SR}`, '-c:a', 'aac', '-b:a', '192k', '-shortest', '-movflags', '+faststart', cfg.out]);
189
+ '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', `loudnorm=I=-14:TP=-1.5:LRA=11,aresample=${SR},alimiter=limit=0.7:level=false:attack=1:release=50`, '-ar', `${SR}`, '-c:a', 'aac', '-b:a', '192k', '-shortest', '-movflags', '+faststart', cfg.out]);
182
190
  log(`soundtrack: ${cfg.music ? (cfg.music.file ? 'music file' : `synth ${cfg.music.bpm} bpm`) : 'no music'}, ${events.clicks.length} clicks, ${events.whooshes.length} whooshes` +
183
191
  (cfg.voice ? `, ${events.voice.length} narration lines (${cfg.voice.provider}/${cfg.voice.voice})` : ''));
184
192
  return { out: cfg.out, warnings, narration };
package/src/build.mjs CHANGED
@@ -10,9 +10,9 @@ export async function build(cfg, mode = 'render', { log = console.log } = {}) {
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, cards, segs } = await compose(cfg, { log, voiceLens });
13
+ const { silent, clean, events, preview, warnings, captions, cards, segs } = await compose(cfg, { log, voiceLens });
14
14
  const audio = await soundtrack(cfg, events, silent, { log });
15
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
+ const extras = await writeOutputs(cfg, { captions, cards, segs, clean, narration: audio.narration, total: events.total }, { log });
17
17
  return { out: audio.out, extras, preview, warnings: [...warnings, ...audio.warnings], duration: events.total, seconds: Math.round((Date.now() - t0) / 1000) };
18
18
  }
package/src/check.mjs CHANGED
@@ -4,17 +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', 'intro', 'frame', 'pace', 'follow', 'colorScheme',
8
- 'scrollOffset', 'captionPosition', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card'];
7
+ const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'intro', 'frame', 'pace', 'follow', 'article', 'cards', 'lang', 'colorScheme',
8
+ 'scrollOffset', 'captionPosition', 'captions', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card'];
9
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
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
13
  const OPTS = ['ms', 'offset', 'timeout', 'highlight', 'color', 'box'];
14
14
  const ACTIONS = {
15
- pause: 'number', wait: 'number', move: 'target', hover: 'target', click: 'target', dismiss: 'target', waitFor: 'target',
15
+ pause: 'number', wait: 'number', move: 'target', hover: 'target', click: 'target', dblclick: '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
+ mark: 'mark', unmark: 'any', note: 'note', select: 'select', hide: 'any', slide: 'slide', paste: 'type',
18
18
  };
19
19
 
20
20
  const dist = (a, b) => {
@@ -73,7 +73,7 @@ export function check(file, { draft = false } = {}) {
73
73
  // tutorials: one thing per step, so the viewer can follow (and the narration can explain it)
74
74
  const purpose = raw.purpose ?? 'pitch';
75
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]))));
76
+ const doing = (s.actions ?? []).filter((a) => a && ['click', 'dblclick', 'type', 'drag', 'press', 'goto', 'select', 'slide', 'paste'].includes(Object.keys(a)[0]) && !(Object.keys(a)[0] === 'press' && /^(Enter|Tab)$/.test(String(Object.values(a)[0]))));
77
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
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
79
  const kinds = (s.actions ?? []).map((a) => a && Object.keys(a)[0]);
@@ -84,7 +84,8 @@ export function check(file, { draft = false } = {}) {
84
84
  }
85
85
  if (raw.end_card) unknown(raw.end_card, END, 'end_card');
86
86
  if (raw.intro) unknown(raw.intro, END, 'intro');
87
- if (raw.frame && typeof raw.frame === 'object') unknown(raw.frame, ['background'], 'frame');
87
+ if (raw.article) unknown(raw.article, ['title', 'intro'], 'article');
88
+ if (raw.frame && typeof raw.frame === 'object') unknown(raw.frame, ['background', 'style', 'window', 'title', 'icon'], 'frame');
88
89
  const todo = (fs.readFileSync(file, 'utf8').split('\n').map((l, i) => [l.replace(/#.*$/, ''), i + 1]).filter(([l]) => /\bTODO\b/.test(l)));
89
90
  for (const [, n] of todo) errors.push(`line ${n}: replace the TODO placeholder`);
90
91
 
@@ -101,18 +102,23 @@ export function check(file, { draft = false } = {}) {
101
102
  if (type === 'string' && typeof v !== 'string') errors.push(`${where}: ${kind} takes a string`);
102
103
  if (type === 'target') target(v, `${where} (${kind})`);
103
104
  if (type === 'mark' && typeof v !== 'string') target(v, `${where} (mark)`); // a string is a phrase to highlight
105
+ if (type === 'slide') {
106
+ 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)`);
107
+ else target(v.target, `${where} (slide.target)`);
108
+ }
104
109
  if (type === 'select') {
105
110
  if (!v?.target || v?.option == null) errors.push(`${where}: select needs { target: <dropdown>, option: "visible text of the option" }`);
106
111
  else target(v.target, `${where} (select.target)`);
107
112
  }
108
113
  if (type === 'note') {
109
114
  if (!v?.target || !v?.text) errors.push(`${where}: note needs { target: <target>, text: "short label" }`);
115
+ else if (isPoint(v.target)) errors.push(`${where}: a note points at an element, not a screen position — target the element (a label, button, cell…) next to that spot`);
110
116
  else { target(v.target, `${where} (note.target)`); unknown(v, ['target', 'text', 'side', 'ms', 'timeout'], `${where} (note)`); }
111
117
  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
118
  }
113
119
  if (type === 'type') {
114
120
  if (typeof v === 'string' || typeof v === 'number') return;
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`);
121
+ if (v?.text == null) errors.push(`${where}: ${kind} needs text — "type: hello" types into the focused element, "type: { target: <target>, text: hello }" clicks the target first`);
116
122
  else if (v.target) target(v.target, `${where} (type.target)`);
117
123
  }
118
124
  if (type === 'drag') {