storyshot 0.1.3 → 0.4.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 +110 -20
- package/bin/storyshot.mjs +10 -2
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +94 -15
- package/src/audio.mjs +7 -1
- package/src/build.mjs +4 -3
- package/src/check.mjs +39 -8
- package/src/compose.mjs +236 -31
- package/src/config.mjs +50 -12
- package/src/init.mjs +7 -1
- package/src/inspect.mjs +12 -5
- package/src/mcp.mjs +1 -1
- package/src/outputs.mjs +89 -17
- package/src/record.mjs +361 -21
- package/src/setup.mjs +81 -2
- package/src/voice.mjs +3 -1
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
|
|
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,13 +15,66 @@ 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
|
+
|
|
25
|
+
## Next to similar tools
|
|
26
|
+
|
|
27
|
+
Storyshot, [promo-video-recorder](https://github.com/palamut62/promo-video-recorder)
|
|
28
|
+
and [demo-dev](https://www.npmjs.com/package/demo-dev) each made a promo of this
|
|
29
|
+
repo's page, with the same storyline and free local voices only. The grid
|
|
30
|
+
below is muted; open the full videos to hear them.
|
|
31
|
+
|
|
32
|
+
https://github.com/user-attachments/assets/3d8175ab-ba15-4b61-961d-481e57566e7a
|
|
33
|
+
|
|
34
|
+
<details>
|
|
35
|
+
<summary>The three full videos, with sound</summary>
|
|
36
|
+
|
|
37
|
+
**Storyshot**
|
|
38
|
+
|
|
39
|
+
https://github.com/user-attachments/assets/22987356-2a74-4e28-9a14-a1b01d04f932
|
|
40
|
+
|
|
41
|
+
**promo-video-recorder**
|
|
42
|
+
|
|
43
|
+
https://github.com/user-attachments/assets/e5eb86e5-e69c-403b-ad6c-c4499592307a
|
|
44
|
+
|
|
45
|
+
**demo-dev**
|
|
46
|
+
|
|
47
|
+
https://github.com/user-attachments/assets/8d3370ca-7a90-48af-b1c9-88189763e95d
|
|
48
|
+
|
|
49
|
+
</details>
|
|
50
|
+
|
|
51
|
+
How each one was made, what we saw, and the caveats:
|
|
52
|
+
[docs/comparison](docs/comparison/README.md).
|
|
53
|
+
|
|
18
54
|
## Quick start
|
|
19
55
|
|
|
20
|
-
Open your project in
|
|
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.
|
|
21
74
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
25
78
|
|
|
26
79
|
The agent does the rest:
|
|
27
80
|
- `setup` writes the MCP server config and the Storyshot skill, adds
|
|
@@ -32,7 +85,7 @@ The agent does the rest:
|
|
|
32
85
|
- The agent reads the skill and starts on your video in the same chat, using
|
|
33
86
|
the CLI.
|
|
34
87
|
|
|
35
|
-
|
|
88
|
+
Restart or reload your agent when convenient (`setup` says how for each one). From the next chat on,
|
|
36
89
|
the agent uses the Storyshot MCP tools automatically, and you can just ask:
|
|
37
90
|
|
|
38
91
|
- *"make a 15 s promo video of https://myapp.com"*, or type `/storyshot`
|
|
@@ -51,8 +104,8 @@ macOS, Linux and Windows.
|
|
|
51
104
|
|
|
52
105
|
```
|
|
53
106
|
Here's the plan. Reply "ok", or change any line:
|
|
54
|
-
1. Type: pitch: a
|
|
55
|
-
2. Length: about
|
|
107
|
+
1. Type: pitch: a promo for the landing page
|
|
108
|
+
2. Length: about 20 s, 6 shots
|
|
56
109
|
3. Flow: hero → Examples → Try It → AI result → pricing → end card
|
|
57
110
|
4. Voice: none (other options: free local English voice; ElevenLabs, key found in .env)
|
|
58
111
|
5. Music: upbeat · Captions: English · Subtitle file (.srt): no
|
|
@@ -82,10 +135,13 @@ After your next release, re-render it and you have an up-to-date video.
|
|
|
82
135
|
|
|
83
136
|
| ask for | type (`purpose`) | you get |
|
|
84
137
|
|---|---|---|
|
|
85
|
-
| a promo / launch / landing-page clip | `pitch` |
|
|
86
|
-
| a how-to or onboarding video | `tutorial` | narrated,
|
|
87
|
-
| a lesson or explainer | `course` |
|
|
88
|
-
| help-center snippets | `docs` | silent,
|
|
138
|
+
| a promo / launch / landing-page clip | `pitch` | short and tightly cut: intro card, the app in a browser frame, music, captions |
|
|
139
|
+
| 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 |
|
|
140
|
+
| a lesson or explainer | `course` | explains the *why*: concept cards, the idea shown in the app, question cards, a recap, no music; `.srt` + chapters |
|
|
141
|
+
| help-center snippets | `docs` | silent per-step GIFs and PNGs cropped to the action, plus a ready-to-paste Markdown article |
|
|
142
|
+
|
|
143
|
+
Length follows the purpose and the content, not a target: a promo is as short as
|
|
144
|
+
its message, a tutorial as long as the workflow takes to explain.
|
|
89
145
|
|
|
90
146
|
Any type can also be vertical (9:16) or in another language.
|
|
91
147
|
|
|
@@ -137,7 +193,7 @@ from `storyshot guide`.
|
|
|
137
193
|
| `check_storyboard` | Validates in about a second: typos with "did you mean", action and target shapes, `TODO`s, keys, the session file. |
|
|
138
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. |
|
|
139
195
|
|
|
140
|
-
|
|
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
|
|
141
197
|
checkout, `setup` points to `node /path/to/checkout/bin/storyshot.mjs mcp` instead.
|
|
142
198
|
|
|
143
199
|
When a step fails, the error names the step and action, and says whether the
|
|
@@ -160,7 +216,7 @@ Everything the agent does is also a command:
|
|
|
160
216
|
| `storyshot compose videos/acme-promo.yaml [--draft]` | Rebuild from the last recording, in seconds. |
|
|
161
217
|
| `storyshot record videos/acme-promo.yaml` | Only record. |
|
|
162
218
|
| `storyshot guide` | Print the playbook and the reference. |
|
|
163
|
-
| `storyshot setup cursor
|
|
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. |
|
|
164
220
|
| `storyshot doctor` | Check Node, ffmpeg, Chromium and voices; install Chromium if it's missing. |
|
|
165
221
|
| `storyshot mcp` | Run the MCP server. |
|
|
166
222
|
|
|
@@ -199,11 +255,21 @@ captionPosition: bottom # or top; can also be set per st
|
|
|
199
255
|
purpose: pitch # pitch | tutorial | course | docs (sets the defaults below)
|
|
200
256
|
highlight: false # spotlight targets before click/type (per step or per action too)
|
|
201
257
|
stepNumbers: false # numbered caption badges
|
|
202
|
-
subtitles: false # write <out>.srt (narration, else captions)
|
|
258
|
+
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)
|
|
259
|
+
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
|
|
260
|
+
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 }
|
|
261
|
+
article: { title: Export a drawing, intro: … } # docs: heading and first paragraph of <out>-steps/README.md
|
|
262
|
+
cards: auto # intro/section/end cards: auto follows the frame (dark frames get dark cards), or light | dark
|
|
263
|
+
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> }
|
|
203
264
|
chapters: false # write <out>.chapters.txt for YouTube
|
|
204
|
-
clips: null # gif | webm | mp4: one file per step in <out>-steps
|
|
265
|
+
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
266
|
maxWait: 1.0 # with duration: auto, the longest a wait is shown
|
|
206
267
|
|
|
268
|
+
intro: # optional title card before the recording (same fields as end_card)
|
|
269
|
+
title: Acme
|
|
270
|
+
subtitle: Ship **faster**
|
|
271
|
+
say: Meet Acme.
|
|
272
|
+
|
|
207
273
|
setup: # runs before recording starts
|
|
208
274
|
- dismiss: { role: button, name: Accept cookies }
|
|
209
275
|
|
|
@@ -220,6 +286,11 @@ steps:
|
|
|
220
286
|
sfx: riser # rising sound + chime (or `chime`)
|
|
221
287
|
actions:
|
|
222
288
|
- waitFor: { text: Done, timeout: 120000 } # waiting is compressed automatically
|
|
289
|
+
- card: { kicker: Part 2, title: Read the results } # a section card before this step (tutorials); becomes a chapter
|
|
290
|
+
caption: The hits table
|
|
291
|
+
say: Each row is a protein similar to yours.
|
|
292
|
+
actions:
|
|
293
|
+
- mark: { css: "tbody tr:nth-child(1)" }
|
|
223
294
|
- hidden: true # cut, not shown in the video
|
|
224
295
|
actions:
|
|
225
296
|
- goto: /
|
|
@@ -248,8 +319,9 @@ You can add `exact: true`, `last: true` or `nth: 2` to any of them.
|
|
|
248
319
|
| action | example |
|
|
249
320
|
|---|---|
|
|
250
321
|
| `click` | `click: { role: button, name: Convert, ms: 800, offset: { x: 0.3, y: 0.5 } }` |
|
|
322
|
+
| `dblclick` | `dblclick: { text: Start }` double-clicks (e.g. to edit a label in place) |
|
|
251
323
|
| `move` / `hover` | `move: { text: "$0" }` or `move: { x: 0.5, y: 0.4, ms: 1200 }` |
|
|
252
|
-
| `type` | `type: { target: { label: Email }, text: hi@acme.com, delay: 60 }`, or `type: hello` to type into whatever has focus |
|
|
324
|
+
| `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 |
|
|
253
325
|
| `drag` | `drag: { from: { css: ".slider" }, by: [{ x: -300 }, { x: 450 }] }` (several moves while held), or `to: <target or point>` |
|
|
254
326
|
| `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. |
|
|
255
327
|
| `pause` | `pause: 700` (ms) |
|
|
@@ -260,6 +332,13 @@ You can add `exact: true`, `last: true` or `nth: 2` to any of them.
|
|
|
260
332
|
| `scrollTo` | `scrollTo: { css: "#pricing", ms: 1200 }` |
|
|
261
333
|
| `scroll` | `scroll: 600` |
|
|
262
334
|
| `dismiss` | `dismiss: { role: button, name: Close }` (clicks only if visible) |
|
|
335
|
+
| `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`) |
|
|
336
|
+
| `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. |
|
|
337
|
+
| `unmark` | `unmark: true` clears the marks (and notes) earlier |
|
|
338
|
+
| `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`). |
|
|
339
|
+
| `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 |
|
|
340
|
+
| `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 |
|
|
341
|
+
| `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
342
|
|
|
264
343
|
`ms` sets how long the cursor takes to travel to the target (`dragMs` for each
|
|
265
344
|
drag move). `highlight: true/false` on `click`, `type` or `move` overrides the
|
|
@@ -326,17 +405,28 @@ changed. Write names the way they should be pronounced (`say: P X Genius.`),
|
|
|
326
405
|
and spell out prices (`eleven ninety-nine`) in `pronounce` or in the line itself. At normal pace that comes to
|
|
327
406
|
about 2.5 words per second, or roughly 35 words for a 15 s video.
|
|
328
407
|
|
|
329
|
-
###
|
|
408
|
+
### Intro and end card
|
|
330
409
|
|
|
331
|
-
Every field is optional; leave out `end_card`
|
|
410
|
+
Every field is optional; leave out `intro` / `end_card` for none. A step's
|
|
411
|
+
`card` takes the same fields and plays before that step (section titles in
|
|
412
|
+
tutorials). Both are short
|
|
413
|
+
animations: the logo pops in, the title rises, the `**bold**` highlight sweeps,
|
|
414
|
+
and a `cta` that is a command (`npx …`, `npm …`, `pip …`, `brew …`, `curl …`)
|
|
415
|
+
types itself out in a terminal pill. `init` writes an `intro` for promos;
|
|
416
|
+
tutorials and lessons can add one (e.g. "How to export a report"), docs GIFs
|
|
417
|
+
usually shouldn't.
|
|
332
418
|
|
|
333
419
|
| field | meaning |
|
|
334
420
|
|---|---|
|
|
335
421
|
| `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
422
|
| `title` | Large product name next to the logo. |
|
|
423
|
+
| `kicker` | Small label above the title, e.g. `Part 2`. |
|
|
337
424
|
| `subtitle` | One line; `**bold**` gets an accent highlight. |
|
|
425
|
+
| `image` | A picture or diagram file (png, jpg, svg) under the title: concept cards in lessons. |
|
|
426
|
+
| `think` | Seconds to pause after the card's `say` (a question card), with a "Pause and think" countdown. The next step shows the answer. |
|
|
427
|
+
| `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
428
|
| `cta` | Dark call-to-action pill, e.g. `Start free at acme.com`. |
|
|
339
|
-
| `duration` | Seconds
|
|
429
|
+
| `duration` | Seconds. Default: fits the `say` line, else 1.0 (end card) or 2.2 (intro). |
|
|
340
430
|
| `say` | Voiceover line played over the end card. |
|
|
341
431
|
|
|
342
432
|
## License
|
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
|
|
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
|
-
:
|
|
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
|
+
"version": "0.4.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": {
|
|
@@ -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
|
|
@@ -55,9 +61,9 @@ everything.
|
|
|
55
61
|
|
|
56
62
|
```
|
|
57
63
|
Here's the plan. Reply "ok", or change any line:
|
|
58
|
-
1. Type: pitch: a
|
|
59
|
-
course = narrated lesson; docs = silent step-by-step GIFs)
|
|
60
|
-
2. Length: about
|
|
64
|
+
1. Type: pitch: a promo for the landing page (other options: tutorial =
|
|
65
|
+
narrated how-to; course = narrated lesson; docs = silent step-by-step GIFs)
|
|
66
|
+
2. Length: about 20 s, 6 shots (it follows from the flow below)
|
|
61
67
|
3. Flow: hero → Examples → Try It → AI result → pricing → end card
|
|
62
68
|
4. Voice: none (other options: free local English voice; ElevenLabs, best
|
|
63
69
|
quality and Chinese support, key found in .env)
|
|
@@ -67,14 +73,16 @@ everything.
|
|
|
67
73
|
window for you to log in once; please use a demo account)
|
|
68
74
|
```
|
|
69
75
|
|
|
70
|
-
Defaults by type (propose these unless the request says otherwise)
|
|
76
|
+
Defaults by type (propose these unless the request says otherwise). There
|
|
77
|
+
is no target length: it follows from the purpose and the content. Estimate
|
|
78
|
+
it from the flow and say it in the plan, so the user can react.
|
|
71
79
|
|
|
72
|
-
| type | length | voice | music | on screen / files |
|
|
80
|
+
| type | length comes from | voice | music | on screen / files |
|
|
73
81
|
|---|---|---|---|---|
|
|
74
|
-
| `pitch` |
|
|
75
|
-
| `tutorial` |
|
|
76
|
-
| `course` |
|
|
77
|
-
| `docs` | one
|
|
82
|
+
| `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 |
|
|
83
|
+
| `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 |
|
|
84
|
+
| `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 |
|
|
85
|
+
| `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
86
|
|
|
79
87
|
Captions match the site's language. `kokoro` (free, local) speaks English
|
|
80
88
|
only; for other languages, use ElevenLabs.
|
|
@@ -121,12 +129,74 @@ everything.
|
|
|
121
129
|
|
|
122
130
|
## 3. Write the storyboard
|
|
123
131
|
|
|
124
|
-
|
|
132
|
+
### Tutorials and lessons are not ads
|
|
133
|
+
|
|
134
|
+
A promo shows as much as possible, fast. A tutorial does the opposite: the
|
|
135
|
+
viewer has to be able to **do it themselves** afterwards, often in a technical
|
|
136
|
+
or scientific tool they have never seen. Storyshot paces `tutorial` and
|
|
137
|
+
`course` slowly by default (`pace: slow`: slower cursor and typing, a beat
|
|
138
|
+
after every click, a hold after every narration line, slower voice, real
|
|
139
|
+
progress shown for waits) and the camera follows the action (`follow`), so
|
|
140
|
+
small controls are readable. Your job is the structure:
|
|
141
|
+
|
|
142
|
+
- **Start with the goal**: an `intro` with `bullets` saying what the viewer
|
|
143
|
+
will be able to do ("Run a BLAST search", "Read the hits table", "Judge a
|
|
144
|
+
match by E-value"), and what they need beforehand.
|
|
145
|
+
- **Split the workflow into parts**, each opened by a `card`
|
|
146
|
+
(`card: { kicker: Part 2, title: Read the results }` on its first step).
|
|
147
|
+
Cards become the YouTube chapters.
|
|
148
|
+
- **One action per step.** Each click, field or choice is its own step with
|
|
149
|
+
its own `say:` explaining **what** it does and **why this value**. Never
|
|
150
|
+
"fill in the form and submit" in one line. `check` warns when a tutorial
|
|
151
|
+
step does more than two things.
|
|
152
|
+
- **Show, then explain the result.** After an action that produces output
|
|
153
|
+
(a results table, a number, a plot, a status), add a step that only looks:
|
|
154
|
+
`mark` the value or row you're talking about (`mark: "E value"`,
|
|
155
|
+
`mark: { css: "tbody tr:nth-child(1)" }`), and say what it means and how to
|
|
156
|
+
judge it ("an E-value this small means the match is not by chance").
|
|
157
|
+
- **Label the controls that matter.** `note: { target: …, text: "FASTA or accession" }`
|
|
158
|
+
puts a short callout with an arrow next to a field, button or column. Use it for
|
|
159
|
+
the one thing a newcomer would get wrong, not for everything.
|
|
160
|
+
- **Dropdowns:** `select: { target: …, option: "…" }` (inspect lists the
|
|
161
|
+
options). Say why this option.
|
|
162
|
+
- **Then a different example.** Run the workflow again with a second input
|
|
163
|
+
that behaves differently (another sequence, sample, parameter), and point
|
|
164
|
+
out what changed and why. Comparison is what makes the concept stick.
|
|
165
|
+
A short `card` ("Now a second example") separates it.
|
|
166
|
+
- **Lessons (`course`) explain the why.** Use concept cards for ideas that
|
|
167
|
+
aren't on screen (`card: { kicker: Concept, title: …, bullets: […], image:
|
|
168
|
+
diagram.svg }`), and question cards before a reveal (`card: { kicker:
|
|
169
|
+
Question, title: "Which hit is the real homolog?", think: 3 }`, then the
|
|
170
|
+
next step marks the answer).
|
|
171
|
+
- **Docs GIFs (`docs`)** become a help-center article: each step's clip is
|
|
172
|
+
cropped to what it touches and holds the result before looping; a PNG of
|
|
173
|
+
each result and `<out>-steps/README.md` are written too. Give every step a
|
|
174
|
+
short `caption` (the heading) and a `doc:` line (the article text), and
|
|
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.
|
|
178
|
+
- **End with a recap**: `end_card` with `bullets` listing the steps or rules
|
|
179
|
+
to remember, and a `say` that repeats them.
|
|
180
|
+
- **Narrating real results.** You can't know the numbers (scores, counts,
|
|
181
|
+
E-values) before the app produces them. Write the result steps with
|
|
182
|
+
general narration, render once, read the actual values from the preview
|
|
183
|
+
frames, then rewrite those `say:`/`note`/caption lines with the real
|
|
184
|
+
numbers and run `compose` only (no re-recording, no new jobs).
|
|
185
|
+
- **Waiting is part of the lesson.** Long computations are still compressed,
|
|
186
|
+
but say what is happening ("BLAST is now comparing your sequence against
|
|
187
|
+
the database; this usually takes under a minute").
|
|
188
|
+
- Length follows the content. Don't cut explanation to hit a length, and
|
|
189
|
+
don't pad: if a part is long, split it into more parts rather than
|
|
190
|
+
speeding it up.
|
|
191
|
+
|
|
192
|
+
Rules of thumb from real renders (promos and tutorials):
|
|
125
193
|
|
|
126
194
|
- **One idea per step.**
|
|
127
|
-
- `pitch`: 1.5–3 s per step (`duration:`)
|
|
128
|
-
|
|
129
|
-
core flow ending on a visible result → pricing or CTA → end
|
|
195
|
+
- `pitch`: 1.5–3 s per step (`duration:`), so the viewer gets one beat per
|
|
196
|
+
shot; the number of shots comes from the story, plus a 1–1.5 s `end_card`. Typical shape: `intro` → hero → 2–4
|
|
197
|
+
steps of the core flow ending on a visible result → pricing or CTA → end
|
|
198
|
+
card. Promos sit in a browser window on a backdrop (`frame: false` turns
|
|
199
|
+
that off).
|
|
130
200
|
- Other purposes: leave out `duration`. `auto` times each step to its
|
|
131
201
|
narration and plays motion in real time.
|
|
132
202
|
- **Captions ≤ 7 words.** `**bold**` the payoff ("Every text box is
|
|
@@ -148,9 +218,18 @@ Rules of thumb from real renders:
|
|
|
148
218
|
and `press: "^"` send single keys. In `tutorial`/`course`, the spotlight
|
|
149
219
|
highlights each click target automatically; add `highlight: true` to a
|
|
150
220
|
`move` to point at something without clicking it.
|
|
221
|
+
- **Point at what the narration names.** `mark: "the exact phrase"` sweeps a
|
|
222
|
+
highlighter over words on the page (or `mark: <target>` boxes a table row,
|
|
223
|
+
card or code line) while the voice says them. On text-heavy pages a mark
|
|
224
|
+
plus a zoom tells the viewer where to look.
|
|
225
|
+
- **Static pages (a landing page, docs, a README) have nothing to click.**
|
|
226
|
+
Don't make a slideshow of scrolls: open with an `intro` (the promise),
|
|
227
|
+
`mark` the claims as they are spoken, zoom on 2–3 of them, and end on a
|
|
228
|
+
command or URL. If the product has a real flow, show that instead.
|
|
151
229
|
- **Cut boring navigation** with a `hidden: true` step (e.g. `goto: /pricing`).
|
|
152
|
-
- **Clean the frame** in `setup`: dismiss cookie banners
|
|
153
|
-
|
|
230
|
+
- **Clean the frame** in `setup`: dismiss cookie banners and announcement bars
|
|
231
|
+
(`dismiss` is a no-op when the element isn't there), and `hide` survey
|
|
232
|
+
pop-ups, feedback tabs and chat widgets, which often appear late.
|
|
154
233
|
- **Keep the caption off the subject.** Captions sit bottom-centre. When one
|
|
155
234
|
would cover a target the step clicks, hovers, types into or drags, or the
|
|
156
235
|
zoom subject, it moves to the top automatically (with a warning). Other things
|
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
|
-
|
|
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;
|
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, 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, clean, 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', 'article', 'cards', 'lang', '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
|
-
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', hide: 'any', slide: 'slide', paste: 'type',
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
const dist = (a, b) => {
|
|
@@ -68,8 +69,23 @@ 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', '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
|
+
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.article) unknown(raw.article, ['title', 'intro'], 'article');
|
|
88
|
+
if (raw.frame && typeof raw.frame === 'object') unknown(raw.frame, ['background', 'style'], 'frame');
|
|
73
89
|
const todo = (fs.readFileSync(file, 'utf8').split('\n').map((l, i) => [l.replace(/#.*$/, ''), i + 1]).filter(([l]) => /\bTODO\b/.test(l)));
|
|
74
90
|
for (const [, n] of todo) errors.push(`line ${n}: replace the TODO placeholder`);
|
|
75
91
|
|
|
@@ -85,9 +101,24 @@ export function check(file, { draft = false } = {}) {
|
|
|
85
101
|
if (type === 'number' && typeof v !== 'number') errors.push(`${where}: ${kind} takes milliseconds, e.g. "${kind}: 800"`);
|
|
86
102
|
if (type === 'string' && typeof v !== 'string') errors.push(`${where}: ${kind} takes a string`);
|
|
87
103
|
if (type === 'target') target(v, `${where} (${kind})`);
|
|
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
|
+
}
|
|
109
|
+
if (type === 'select') {
|
|
110
|
+
if (!v?.target || v?.option == null) errors.push(`${where}: select needs { target: <dropdown>, option: "visible text of the option" }`);
|
|
111
|
+
else target(v.target, `${where} (select.target)`);
|
|
112
|
+
}
|
|
113
|
+
if (type === 'note') {
|
|
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`);
|
|
116
|
+
else { target(v.target, `${where} (note.target)`); unknown(v, ['target', 'text', 'side', 'ms', 'timeout'], `${where} (note)`); }
|
|
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`);
|
|
118
|
+
}
|
|
88
119
|
if (type === 'type') {
|
|
89
120
|
if (typeof v === 'string' || typeof v === 'number') return;
|
|
90
|
-
if (v?.text == null) errors.push(`${where}:
|
|
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`);
|
|
91
122
|
else if (v.target) target(v.target, `${where} (type.target)`);
|
|
92
123
|
}
|
|
93
124
|
if (type === 'drag') {
|
|
@@ -104,7 +135,7 @@ export function check(file, { draft = false } = {}) {
|
|
|
104
135
|
let summary = '';
|
|
105
136
|
if (cfg) {
|
|
106
137
|
const visible = cfg.steps.filter((s) => !s.hidden);
|
|
107
|
-
const says = visible.filter((s) => s.say).length + (cfg.endCard?.say ? 1 : 0);
|
|
138
|
+
const says = visible.filter((s) => s.say).length + (cfg.endCard?.say ? 1 : 0) + (cfg.intro?.say ? 1 : 0);
|
|
108
139
|
if (cfg.voice && !says) warnings.push('voice is set but no step has a `say:` line');
|
|
109
140
|
if (!cfg.voice && says) warnings.push('steps have `say:` lines but there is no `voice:` block, so they will not be spoken');
|
|
110
141
|
if (!draft && cfg.voice && ['elevenlabs', 'openai'].includes(cfg.voice.provider)) {
|
|
@@ -126,7 +157,7 @@ export function check(file, { draft = false } = {}) {
|
|
|
126
157
|
const fixed = visible.filter((s) => s.duration !== 'auto').reduce((a, s) => a + s.duration, 0);
|
|
127
158
|
const auto = visible.filter((s) => s.duration === 'auto').length;
|
|
128
159
|
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' : ''}`
|
|
160
|
+
const length = auto ? `${fixed ? `${fixed.toFixed(1)}s fixed + ` : ''}${auto} steps timed to narration${cfg.endCard ? ' + end card' : ''}${cfg.intro ? ' + intro' : ''}`
|
|
130
161
|
: `${fixed.toFixed(1)}s of steps${cfg.endCard ? ` + ${ec ?? 'auto'}s end card` : ''}${ec != null ? ` = ${(fixed + ec).toFixed(1)}s total` : ''}`;
|
|
131
162
|
summary = `${cfg.name}: purpose ${cfg.purpose}, ${visible.length} visible steps, ${length}` +
|
|
132
163
|
(cfg.voice ? `, voice ${cfg.voice.provider}` : '') + ` → ${cfg.out}`;
|