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 +44 -12
- package/bin/storyshot.mjs +10 -2
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +23 -7
- package/src/audio.mjs +10 -2
- package/src/build.mjs +2 -2
- package/src/check.mjs +13 -7
- package/src/compose.mjs +182 -51
- package/src/config.mjs +35 -5
- package/src/inspect.mjs +8 -4
- package/src/outputs.mjs +29 -11
- package/src/record.mjs +184 -18
- package/src/setup.mjs +81 -2
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,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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.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
|
|
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
|
|
223
|
-
|
|
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
|
|
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
|
-
|
|
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',
|
|
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.
|
|
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}:
|
|
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') {
|