storyshot 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -16
- package/bin/storyshot.mjs +5 -3
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +94 -16
- package/src/auth.mjs +105 -27
- package/src/build.mjs +4 -3
- package/src/check.mjs +31 -6
- package/src/compose.mjs +174 -23
- package/src/config.mjs +55 -13
- package/src/init.mjs +10 -4
- package/src/inspect.mjs +18 -8
- package/src/mcp.mjs +32 -24
- package/src/outputs.mjs +84 -17
- package/src/record.mjs +242 -20
- package/src/voice.mjs +3 -1
package/README.md
CHANGED
|
@@ -15,6 +15,35 @@ second or two on screen.
|
|
|
15
15
|
The agent plans, writes and fixes the storyboard. Storyshot does the
|
|
16
16
|
recording and editing, and the same storyboard always renders the same video.
|
|
17
17
|
|
|
18
|
+
## Next to similar tools
|
|
19
|
+
|
|
20
|
+
Storyshot, [promo-video-recorder](https://github.com/palamut62/promo-video-recorder)
|
|
21
|
+
and [demo-dev](https://www.npmjs.com/package/demo-dev) each made a promo of this
|
|
22
|
+
repo's page, with the same storyline and free local voices only. The grid
|
|
23
|
+
below is muted; open the full videos to hear them.
|
|
24
|
+
|
|
25
|
+
https://github.com/user-attachments/assets/3d8175ab-ba15-4b61-961d-481e57566e7a
|
|
26
|
+
|
|
27
|
+
<details>
|
|
28
|
+
<summary>The three full videos, with sound</summary>
|
|
29
|
+
|
|
30
|
+
**Storyshot**
|
|
31
|
+
|
|
32
|
+
https://github.com/user-attachments/assets/22987356-2a74-4e28-9a14-a1b01d04f932
|
|
33
|
+
|
|
34
|
+
**promo-video-recorder**
|
|
35
|
+
|
|
36
|
+
https://github.com/user-attachments/assets/e5eb86e5-e69c-403b-ad6c-c4499592307a
|
|
37
|
+
|
|
38
|
+
**demo-dev**
|
|
39
|
+
|
|
40
|
+
https://github.com/user-attachments/assets/8d3370ca-7a90-48af-b1c9-88189763e95d
|
|
41
|
+
|
|
42
|
+
</details>
|
|
43
|
+
|
|
44
|
+
How each one was made, what we saw, and the caveats:
|
|
45
|
+
[docs/comparison](docs/comparison/README.md).
|
|
46
|
+
|
|
18
47
|
## Quick start
|
|
19
48
|
|
|
20
49
|
Open your project in Cursor or Claude Code and paste this into the agent chat:
|
|
@@ -51,8 +80,8 @@ macOS, Linux and Windows.
|
|
|
51
80
|
|
|
52
81
|
```
|
|
53
82
|
Here's the plan. Reply "ok", or change any line:
|
|
54
|
-
1. Type: pitch: a
|
|
55
|
-
2. Length: about
|
|
83
|
+
1. Type: pitch: a promo for the landing page
|
|
84
|
+
2. Length: about 20 s, 6 shots
|
|
56
85
|
3. Flow: hero → Examples → Try It → AI result → pricing → end card
|
|
57
86
|
4. Voice: none (other options: free local English voice; ElevenLabs, key found in .env)
|
|
58
87
|
5. Music: upbeat · Captions: English · Subtitle file (.srt): no
|
|
@@ -82,10 +111,13 @@ After your next release, re-render it and you have an up-to-date video.
|
|
|
82
111
|
|
|
83
112
|
| ask for | type (`purpose`) | you get |
|
|
84
113
|
|---|---|---|
|
|
85
|
-
| a promo / launch / landing-page clip | `pitch` |
|
|
86
|
-
| a how-to or onboarding video | `tutorial` | narrated,
|
|
87
|
-
| a lesson or explainer | `course` |
|
|
88
|
-
| help-center snippets | `docs` | silent,
|
|
114
|
+
| a promo / launch / landing-page clip | `pitch` | short and tightly cut: intro card, the app in a browser frame, music, captions |
|
|
115
|
+
| a how-to or onboarding video | `tutorial` | paced to follow along: one narrated action per step, the camera following each click, part cards, a second example, a recap; `.srt` + chapters |
|
|
116
|
+
| a lesson or explainer | `course` | explains the *why*: concept cards, the idea shown in the app, question cards, a recap, no music; `.srt` + chapters |
|
|
117
|
+
| help-center snippets | `docs` | silent per-step GIFs and PNGs cropped to the action, plus a ready-to-paste Markdown article |
|
|
118
|
+
|
|
119
|
+
Length follows the purpose and the content, not a target: a promo is as short as
|
|
120
|
+
its message, a tutorial as long as the workflow takes to explain.
|
|
89
121
|
|
|
90
122
|
Any type can also be vertical (9:16) or in another language.
|
|
91
123
|
|
|
@@ -108,11 +140,14 @@ so a change only pays for the lines that changed.
|
|
|
108
140
|
|
|
109
141
|
When the flow is behind a login, the agent says so in its plan. Then:
|
|
110
142
|
|
|
111
|
-
1. A
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
143
|
+
1. A normal Chrome window opens (the `login` tool, or `storyshot login <url>`).
|
|
144
|
+
No script is attached to it while you log in, so "Sign in with Google" and
|
|
145
|
+
2FA work as usual.
|
|
146
|
+
2. You log in there. When you see the app logged in, tell the agent "done"
|
|
147
|
+
(or press Enter in the terminal). You can also just close the window.
|
|
148
|
+
3. The session is saved to `videos/<site>-login.json`. It includes cookies,
|
|
149
|
+
localStorage, IndexedDB and sessionStorage, and the file is added to `.gitignore` because
|
|
150
|
+
it holds your session. The temporary browser profile is deleted.
|
|
116
151
|
4. Every recording starts logged in, so the video never shows a login screen.
|
|
117
152
|
|
|
118
153
|
If the session expires, Storyshot says so ("the saved login has expired, run
|
|
@@ -191,15 +226,24 @@ music: { bpm: 120, volume: 1 } # or { file: track.mp3 }, or fal
|
|
|
191
226
|
sfx: true # click / whoosh / riser sounds
|
|
192
227
|
voice: { provider: elevenlabs, voice: hpp4J3VqNfWAUOO0d1Us } # optional voiceover, see below
|
|
193
228
|
storageState: auth.json # optional Playwright login state
|
|
229
|
+
ignoreHTTPSErrors: true # internal site with a self-signed HTTPS certificate (plain http:// needs nothing)
|
|
194
230
|
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
|
|
195
231
|
purpose: pitch # pitch | tutorial | course | docs (sets the defaults below)
|
|
196
232
|
highlight: false # spotlight targets before click/type (per step or per action too)
|
|
197
233
|
stepNumbers: false # numbered caption badges
|
|
198
|
-
subtitles: false # write <out>.srt (narration, else captions)
|
|
234
|
+
subtitles: false # write <out>.srt (narration, else captions); `burn` also burns the narration into the video, each word lighting up as it is spoken (captions move to the top)
|
|
235
|
+
pace: slow # fast (pitch) | normal (docs) | slow (tutorial, course): cursor and typing speed, a beat after each click, hold after narration, voice speed, how much of a wait is shown
|
|
236
|
+
follow: true # tutorial/course default: the camera zooms in on what each step touches and pans between steps (a step's own zoom wins; follow: false on a step skips it); or { scale: 1.8 }
|
|
237
|
+
frame: true # pitch default: the recording sits in a browser window on a backdrop; false = full frame, or { background: <css> }
|
|
199
238
|
chapters: false # write <out>.chapters.txt for YouTube
|
|
200
|
-
clips: null # gif | webm | mp4: one file per step in <out>-steps
|
|
239
|
+
clips: null # gif | webm | mp4 | png, or a list (docs default: [gif, png]): one file per step in <out>-steps/, cropped to what the step touches, plus README.md, a ready-to-paste article (step captions, `doc:` text, images)
|
|
201
240
|
maxWait: 1.0 # with duration: auto, the longest a wait is shown
|
|
202
241
|
|
|
242
|
+
intro: # optional title card before the recording (same fields as end_card)
|
|
243
|
+
title: Acme
|
|
244
|
+
subtitle: Ship **faster**
|
|
245
|
+
say: Meet Acme.
|
|
246
|
+
|
|
203
247
|
setup: # runs before recording starts
|
|
204
248
|
- dismiss: { role: button, name: Accept cookies }
|
|
205
249
|
|
|
@@ -216,6 +260,11 @@ steps:
|
|
|
216
260
|
sfx: riser # rising sound + chime (or `chime`)
|
|
217
261
|
actions:
|
|
218
262
|
- waitFor: { text: Done, timeout: 120000 } # waiting is compressed automatically
|
|
263
|
+
- card: { kicker: Part 2, title: Read the results } # a section card before this step (tutorials); becomes a chapter
|
|
264
|
+
caption: The hits table
|
|
265
|
+
say: Each row is a protein similar to yours.
|
|
266
|
+
actions:
|
|
267
|
+
- mark: { css: "tbody tr:nth-child(1)" }
|
|
219
268
|
- hidden: true # cut, not shown in the video
|
|
220
269
|
actions:
|
|
221
270
|
- goto: /
|
|
@@ -256,6 +305,10 @@ You can add `exact: true`, `last: true` or `nth: 2` to any of them.
|
|
|
256
305
|
| `scrollTo` | `scrollTo: { css: "#pricing", ms: 1200 }` |
|
|
257
306
|
| `scroll` | `scroll: 600` |
|
|
258
307
|
| `dismiss` | `dismiss: { role: button, name: Close }` (clicks only if visible) |
|
|
308
|
+
| `mark` | `mark: "automatic zoom, captions"` sweeps a highlighter over that phrase, even inside a paragraph (scrolling to it if needed). `mark: { css: "table tr:nth-child(3)" }` marks any target with a box. Options: `ms` (sweep time, default 700), `color` (default: theme accent), `box: true/false`. Marks stay until the step ends. |
|
|
309
|
+
| `unmark` | `unmark: true` clears the marks (and notes) earlier |
|
|
310
|
+
| `note` | `note: { target: { label: Database }, text: Curated proteins only }` puts a short label with an arrow next to the element, on the side with the most room (or `side: left/right/top/bottom`). It stays until the step ends and gets reading time (about 3 words a second, or `ms`). |
|
|
311
|
+
| `select` | `select: { target: { label: Database }, option: "UniProtKB/Swiss-Prot" }` picks an option in a dropdown (by its visible text, or part of it). `inspect` lists each dropdown's options. |
|
|
259
312
|
|
|
260
313
|
`ms` sets how long the cursor takes to travel to the target (`dragMs` for each
|
|
261
314
|
drag move). `highlight: true/false` on `click`, `type` or `move` overrides the
|
|
@@ -322,17 +375,28 @@ changed. Write names the way they should be pronounced (`say: P X Genius.`),
|
|
|
322
375
|
and spell out prices (`eleven ninety-nine`) in `pronounce` or in the line itself. At normal pace that comes to
|
|
323
376
|
about 2.5 words per second, or roughly 35 words for a 15 s video.
|
|
324
377
|
|
|
325
|
-
###
|
|
378
|
+
### Intro and end card
|
|
326
379
|
|
|
327
|
-
Every field is optional; leave out `end_card`
|
|
380
|
+
Every field is optional; leave out `intro` / `end_card` for none. A step's
|
|
381
|
+
`card` takes the same fields and plays before that step (section titles in
|
|
382
|
+
tutorials). Both are short
|
|
383
|
+
animations: the logo pops in, the title rises, the `**bold**` highlight sweeps,
|
|
384
|
+
and a `cta` that is a command (`npx …`, `npm …`, `pip …`, `brew …`, `curl …`)
|
|
385
|
+
types itself out in a terminal pill. `init` writes an `intro` for promos;
|
|
386
|
+
tutorials and lessons can add one (e.g. "How to export a report"), docs GIFs
|
|
387
|
+
usually shouldn't.
|
|
328
388
|
|
|
329
389
|
| field | meaning |
|
|
330
390
|
|---|---|
|
|
331
391
|
| `logo` | A target on the page (captured as an image, e.g. `{ alt: Acme logo }`) or a path to an image file. If the logo image already contains the wordmark, leave out `title`. |
|
|
332
392
|
| `title` | Large product name next to the logo. |
|
|
393
|
+
| `kicker` | Small label above the title, e.g. `Part 2`. |
|
|
333
394
|
| `subtitle` | One line; `**bold**` gets an accent highlight. |
|
|
395
|
+
| `image` | A picture or diagram file (png, jpg, svg) under the title: concept cards in lessons. |
|
|
396
|
+
| `think` | Seconds to pause after the card's `say` (a question card), with a "Pause and think" countdown. The next step shows the answer. |
|
|
397
|
+
| `bullets` | A numbered list that appears one item at a time: "what you'll learn" on an intro, a recap on the end card. The card stays up long enough to read it. |
|
|
334
398
|
| `cta` | Dark call-to-action pill, e.g. `Start free at acme.com`. |
|
|
335
|
-
| `duration` | Seconds
|
|
399
|
+
| `duration` | Seconds. Default: fits the `say` line, else 1.0 (end card) or 2.2 (intro). |
|
|
336
400
|
| `say` | Voiceover line played over the end card. |
|
|
337
401
|
|
|
338
402
|
## License
|
package/bin/storyshot.mjs
CHANGED
|
@@ -33,6 +33,8 @@ Options:
|
|
|
33
33
|
--draft (render/compose) use the free local voice instead of a paid TTS provider and write
|
|
34
34
|
<out>.draft.mp4; when it looks right, run \`compose\` without --draft (reuses the recording)
|
|
35
35
|
--viewport 1600x900 (inspect, init)
|
|
36
|
+
--insecure (inspect, init) accept a self-signed HTTPS certificate (internal sites);
|
|
37
|
+
storyboards use \`ignoreHTTPSErrors: true\`
|
|
36
38
|
`;
|
|
37
39
|
|
|
38
40
|
const args = process.argv.slice(2);
|
|
@@ -80,11 +82,11 @@ try {
|
|
|
80
82
|
} else if (cmd === 'login') {
|
|
81
83
|
const { login, authFileFor } = await import('../src/auth.mjs');
|
|
82
84
|
const r = await login(arg, { file: flag('save') ?? authFileFor(arg), viewport: viewport() });
|
|
83
|
-
console.log(`✔ saved the login session to ${r.file} (${r.how}, ${r.cookies} cookies)${r.gitignore ? `\n added it to ${r.gitignore}` : ''}` +
|
|
85
|
+
console.log(`✔ saved the login session to ${r.file} (${r.how}, ${r.cookies} cookies, ${r.siteCookies} for this site${r.sessionStorage ? `, sessionStorage for ${r.sessionStorage} site(s)` : ''})${r.gitignore ? `\n added it to ${r.gitignore}` : ''}` +
|
|
84
86
|
`\n use it: \`storageState: ${r.file.split(/[\\/]/).pop()}\` in the storyboard (path relative to the yaml), or \`--storage ${r.file}\` for inspect/init`);
|
|
85
87
|
} else if (cmd === 'init') {
|
|
86
88
|
const { initStoryboard } = await import('../src/init.mjs');
|
|
87
|
-
const r = await initStoryboard(arg, { purpose: flag('purpose') ?? 'pitch', topic: flag('topic'), file: flag('file'), storageState: flag('storage'), viewport: viewport() });
|
|
89
|
+
const r = await initStoryboard(arg, { purpose: flag('purpose') ?? 'pitch', topic: flag('topic'), file: flag('file'), storageState: flag('storage'), ignoreHTTPSErrors: args.includes('--insecure'), viewport: viewport() });
|
|
88
90
|
console.log(`✔ wrote ${r.file}\n screenshot: ${r.screenshot}\n next: edit it, then \`storyshot check ${r.file}\``);
|
|
89
91
|
} else if (cmd === 'inspect') {
|
|
90
92
|
await (await import('../src/doctor.mjs')).ensureBrowser();
|
|
@@ -94,7 +96,7 @@ try {
|
|
|
94
96
|
const after = flag('after');
|
|
95
97
|
console.log((await record(cfg, { log: () => {}, inspectAfter: after != null ? Number(after) : cfg.steps.length })).text);
|
|
96
98
|
} else {
|
|
97
|
-
console.log((await inspect(arg, { viewport: viewport(), storageState: flag('storage') })).text);
|
|
99
|
+
console.log((await inspect(arg, { viewport: viewport(), storageState: flag('storage'), ignoreHTTPSErrors: args.includes('--insecure') })).text);
|
|
98
100
|
}
|
|
99
101
|
} else if (cmd === 'check') {
|
|
100
102
|
const r = check(arg, { draft: args.includes('--draft') });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storyshot",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Turn a short YAML storyboard into a polished product demo video of your web app — smooth cursor, auto zoom, captions, music.",
|
|
5
5
|
"author": "boheling <boheling@gmail.com>",
|
|
6
6
|
"repository": {
|
|
@@ -55,9 +55,9 @@ everything.
|
|
|
55
55
|
|
|
56
56
|
```
|
|
57
57
|
Here's the plan. Reply "ok", or change any line:
|
|
58
|
-
1. Type: pitch: a
|
|
59
|
-
course = narrated lesson; docs = silent step-by-step GIFs)
|
|
60
|
-
2. Length: about
|
|
58
|
+
1. Type: pitch: a promo for the landing page (other options: tutorial =
|
|
59
|
+
narrated how-to; course = narrated lesson; docs = silent step-by-step GIFs)
|
|
60
|
+
2. Length: about 20 s, 6 shots (it follows from the flow below)
|
|
61
61
|
3. Flow: hero → Examples → Try It → AI result → pricing → end card
|
|
62
62
|
4. Voice: none (other options: free local English voice; ElevenLabs, best
|
|
63
63
|
quality and Chinese support, key found in .env)
|
|
@@ -67,14 +67,16 @@ everything.
|
|
|
67
67
|
window for you to log in once; please use a demo account)
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
Defaults by type (propose these unless the request says otherwise)
|
|
70
|
+
Defaults by type (propose these unless the request says otherwise). There
|
|
71
|
+
is no target length: it follows from the purpose and the content. Estimate
|
|
72
|
+
it from the flow and say it in the plan, so the user can react.
|
|
71
73
|
|
|
72
|
-
| type | length | voice | music | on screen / files |
|
|
74
|
+
| type | length comes from | voice | music | on screen / files |
|
|
73
75
|
|---|---|---|---|---|
|
|
74
|
-
| `pitch` |
|
|
75
|
-
| `tutorial` |
|
|
76
|
-
| `course` |
|
|
77
|
-
| `docs` | one
|
|
76
|
+
| `pitch` | where it's shown and its one message: a landing-page loop or social clip is a handful of shots; a launch video can tell a longer story | optional | upbeat | captions, intro, frame |
|
|
77
|
+
| `tutorial` | every step of the workflow, each explained, plus a second example; as long as that takes | yes | soft | step numbers, spotlight, camera follows, part cards, recap, .srt, chapters |
|
|
78
|
+
| `course` | the concepts to teach: each idea, shown in the app, checked with a question | yes | none | spotlight, camera follows, concept and question cards, recap, .srt, chapters |
|
|
79
|
+
| `docs` | one clip per step, each just long enough to see its result | none | none | step numbers, spotlight, cropped GIFs + PNGs, README.md article |
|
|
78
80
|
|
|
79
81
|
Captions match the site's language. `kokoro` (free, local) speaks English
|
|
80
82
|
only; for other languages, use ElevenLabs.
|
|
@@ -82,9 +84,14 @@ everything.
|
|
|
82
84
|
- Tell the user a browser window will open and that they should log in
|
|
83
85
|
there, preferably with a demo account (the video shows whatever is on
|
|
84
86
|
screen).
|
|
85
|
-
- Call `login` with a URL behind the login. It
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
- Call `login` with a URL behind the login. It opens a normal Chrome
|
|
88
|
+
window and returns at once. Tell the user to log in there and, once the
|
|
89
|
+
app shows them logged in, to leave the window open and reply "done".
|
|
90
|
+
Nothing watches the window meanwhile (Google sign-in breaks if a script
|
|
91
|
+
probes it), so wait for their reply.
|
|
92
|
+
- Then call `login` again with the same URL and `done: true`. It saves the
|
|
93
|
+
session to `videos/<site>-login.json`, adds it to `.gitignore` and closes
|
|
94
|
+
the window. (CLI: `storyshot login <url>`, then press Enter.)
|
|
88
95
|
- Pass that file as `storage_state` to `inspect_page` and `init_storyboard`,
|
|
89
96
|
and add `storageState: <site>-login.json` to the storyboard.
|
|
90
97
|
- Never ask for, store or type a password, and never write login steps.
|
|
@@ -116,12 +123,72 @@ everything.
|
|
|
116
123
|
|
|
117
124
|
## 3. Write the storyboard
|
|
118
125
|
|
|
119
|
-
|
|
126
|
+
### Tutorials and lessons are not ads
|
|
127
|
+
|
|
128
|
+
A promo shows as much as possible, fast. A tutorial does the opposite: the
|
|
129
|
+
viewer has to be able to **do it themselves** afterwards, often in a technical
|
|
130
|
+
or scientific tool they have never seen. Storyshot paces `tutorial` and
|
|
131
|
+
`course` slowly by default (`pace: slow`: slower cursor and typing, a beat
|
|
132
|
+
after every click, a hold after every narration line, slower voice, real
|
|
133
|
+
progress shown for waits) and the camera follows the action (`follow`), so
|
|
134
|
+
small controls are readable. Your job is the structure:
|
|
135
|
+
|
|
136
|
+
- **Start with the goal**: an `intro` with `bullets` saying what the viewer
|
|
137
|
+
will be able to do ("Run a BLAST search", "Read the hits table", "Judge a
|
|
138
|
+
match by E-value"), and what they need beforehand.
|
|
139
|
+
- **Split the workflow into parts**, each opened by a `card`
|
|
140
|
+
(`card: { kicker: Part 2, title: Read the results }` on its first step).
|
|
141
|
+
Cards become the YouTube chapters.
|
|
142
|
+
- **One action per step.** Each click, field or choice is its own step with
|
|
143
|
+
its own `say:` explaining **what** it does and **why this value**. Never
|
|
144
|
+
"fill in the form and submit" in one line. `check` warns when a tutorial
|
|
145
|
+
step does more than two things.
|
|
146
|
+
- **Show, then explain the result.** After an action that produces output
|
|
147
|
+
(a results table, a number, a plot, a status), add a step that only looks:
|
|
148
|
+
`mark` the value or row you're talking about (`mark: "E value"`,
|
|
149
|
+
`mark: { css: "tbody tr:nth-child(1)" }`), and say what it means and how to
|
|
150
|
+
judge it ("an E-value this small means the match is not by chance").
|
|
151
|
+
- **Label the controls that matter.** `note: { target: …, text: "FASTA or accession" }`
|
|
152
|
+
puts a short callout with an arrow next to a field, button or column. Use it for
|
|
153
|
+
the one thing a newcomer would get wrong, not for everything.
|
|
154
|
+
- **Dropdowns:** `select: { target: …, option: "…" }` (inspect lists the
|
|
155
|
+
options). Say why this option.
|
|
156
|
+
- **Then a different example.** Run the workflow again with a second input
|
|
157
|
+
that behaves differently (another sequence, sample, parameter), and point
|
|
158
|
+
out what changed and why. Comparison is what makes the concept stick.
|
|
159
|
+
A short `card` ("Now a second example") separates it.
|
|
160
|
+
- **Lessons (`course`) explain the why.** Use concept cards for ideas that
|
|
161
|
+
aren't on screen (`card: { kicker: Concept, title: …, bullets: […], image:
|
|
162
|
+
diagram.svg }`), and question cards before a reveal (`card: { kicker:
|
|
163
|
+
Question, title: "Which hit is the real homolog?", think: 3 }`, then the
|
|
164
|
+
next step marks the answer).
|
|
165
|
+
- **Docs GIFs (`docs`)** become a help-center article: each step's clip is
|
|
166
|
+
cropped to what it touches and holds the result before looping; a PNG of
|
|
167
|
+
each result and `<out>-steps/README.md` are written too. Give every step a
|
|
168
|
+
short `caption` (the heading) and a `doc:` line (the article text), and
|
|
169
|
+
`note` the field the reader must not miss.
|
|
170
|
+
- **End with a recap**: `end_card` with `bullets` listing the steps or rules
|
|
171
|
+
to remember, and a `say` that repeats them.
|
|
172
|
+
- **Narrating real results.** You can't know the numbers (scores, counts,
|
|
173
|
+
E-values) before the app produces them. Write the result steps with
|
|
174
|
+
general narration, render once, read the actual values from the preview
|
|
175
|
+
frames, then rewrite those `say:`/`note`/caption lines with the real
|
|
176
|
+
numbers and run `compose` only (no re-recording, no new jobs).
|
|
177
|
+
- **Waiting is part of the lesson.** Long computations are still compressed,
|
|
178
|
+
but say what is happening ("BLAST is now comparing your sequence against
|
|
179
|
+
the database; this usually takes under a minute").
|
|
180
|
+
- Length follows the content. Don't cut explanation to hit a length, and
|
|
181
|
+
don't pad: if a part is long, split it into more parts rather than
|
|
182
|
+
speeding it up.
|
|
183
|
+
|
|
184
|
+
Rules of thumb from real renders (promos and tutorials):
|
|
120
185
|
|
|
121
186
|
- **One idea per step.**
|
|
122
|
-
- `pitch`: 1.5–3 s per step (`duration:`)
|
|
123
|
-
|
|
124
|
-
core flow ending on a visible result → pricing or CTA → end
|
|
187
|
+
- `pitch`: 1.5–3 s per step (`duration:`), so the viewer gets one beat per
|
|
188
|
+
shot; the number of shots comes from the story, plus a 1–1.5 s `end_card`. Typical shape: `intro` → hero → 2–4
|
|
189
|
+
steps of the core flow ending on a visible result → pricing or CTA → end
|
|
190
|
+
card. Promos sit in a browser window on a backdrop (`frame: false` turns
|
|
191
|
+
that off).
|
|
125
192
|
- Other purposes: leave out `duration`. `auto` times each step to its
|
|
126
193
|
narration and plays motion in real time.
|
|
127
194
|
- **Captions ≤ 7 words.** `**bold**` the payoff ("Every text box is
|
|
@@ -143,6 +210,14 @@ Rules of thumb from real renders:
|
|
|
143
210
|
and `press: "^"` send single keys. In `tutorial`/`course`, the spotlight
|
|
144
211
|
highlights each click target automatically; add `highlight: true` to a
|
|
145
212
|
`move` to point at something without clicking it.
|
|
213
|
+
- **Point at what the narration names.** `mark: "the exact phrase"` sweeps a
|
|
214
|
+
highlighter over words on the page (or `mark: <target>` boxes a table row,
|
|
215
|
+
card or code line) while the voice says them. On text-heavy pages a mark
|
|
216
|
+
plus a zoom tells the viewer where to look.
|
|
217
|
+
- **Static pages (a landing page, docs, a README) have nothing to click.**
|
|
218
|
+
Don't make a slideshow of scrolls: open with an `intro` (the promise),
|
|
219
|
+
`mark` the claims as they are spoken, zoom on 2–3 of them, and end on a
|
|
220
|
+
command or URL. If the product has a real flow, show that instead.
|
|
146
221
|
- **Cut boring navigation** with a `hidden: true` step (e.g. `goto: /pricing`).
|
|
147
222
|
- **Clean the frame** in `setup`: dismiss cookie banners, announcement bars and
|
|
148
223
|
chat widgets. `dismiss` is a no-op when the element isn't there.
|
|
@@ -163,6 +238,9 @@ Rules of thumb from real renders:
|
|
|
163
238
|
`at (x, y)`.
|
|
164
239
|
- Never put real credentials in a storyboard. For logged-in apps, use
|
|
165
240
|
`storageState` (see step 1).
|
|
241
|
+
- Internal sites: `http://` URLs just work. If an `https://` internal site
|
|
242
|
+
fails with an untrusted certificate, add `ignoreHTTPSErrors: true` (inspect/init:
|
|
243
|
+
`ignore_https_errors`). Never turn it on for public sites.
|
|
166
244
|
|
|
167
245
|
Run `check_storyboard` after every edit, and fix every ✖ before rendering. It
|
|
168
246
|
checks structure, typos and leftover `TODO`s, but not whether targets exist on
|
package/src/auth.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Logged-in apps: detect login walls, and `storyshot login` — the user logs in once in a real
|
|
2
2
|
// browser window and the session (cookies, localStorage, IndexedDB) is saved for recording.
|
|
3
3
|
import { chromium } from 'playwright';
|
|
4
|
-
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
5
|
+
import os from 'node:os';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { VIDEO_DIR, siteOf } from './config.mjs';
|
|
@@ -41,44 +42,121 @@ export function gitIgnored(file) {
|
|
|
41
42
|
function ignore(file) {
|
|
42
43
|
if (gitIgnored(file) === true) return null;
|
|
43
44
|
let root = process.cwd();
|
|
44
|
-
try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: path.dirname(path.resolve(file)) }).toString().trim(); } catch {}
|
|
45
|
+
try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: path.dirname(path.resolve(file)), stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch {}
|
|
45
46
|
const gi = path.join(root, '.gitignore');
|
|
46
47
|
const rel = path.relative(root, path.resolve(file)).split(path.sep).join('/');
|
|
48
|
+
if (rel.startsWith('../') || path.isAbsolute(rel)) return null; // outside the project: nothing to ignore
|
|
47
49
|
const cur = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
|
|
48
50
|
if (cur.split('\n').includes(rel)) return null;
|
|
49
51
|
fs.writeFileSync(gi, `${cur}${cur && !cur.endsWith('\n') ? '\n' : ''}# Storyshot login session (contains your session cookies — never commit)\n${rel}\n`);
|
|
50
52
|
return gi;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
// --- login ------------------------------------------------------------------------------------
|
|
56
|
+
// The user logs in in a plain Chrome window that NOTHING is attached to while they do it: Google
|
|
57
|
+
// (and other SSO providers) refuse automated browsers, polling the page from a script makes
|
|
58
|
+
// Google's sign-in bounce between the app and accounts.google.com, and even an open DevTools port
|
|
59
|
+
// gets "This browser or app may not be secure". So Chrome runs with only a temporary profile; when
|
|
60
|
+
// the user is done we quit it and read the session back from that profile.
|
|
61
|
+
|
|
62
|
+
function chromeExecutable() {
|
|
63
|
+
const home = os.homedir();
|
|
64
|
+
const candidates = process.platform === 'darwin'
|
|
65
|
+
? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', path.join(home, 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome')]
|
|
66
|
+
: process.platform === 'win32'
|
|
67
|
+
? [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA].filter(Boolean).map((d) => path.join(d, 'Google', 'Chrome', 'Application', 'chrome.exe'))
|
|
68
|
+
: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
|
|
69
|
+
return candidates.find((c) => fs.existsSync(c)) ?? chromium.executablePath();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Opens the window and returns at once. Pass the result to finishLogin() when the user is done.
|
|
73
|
+
export function openLoginWindow(url, { viewport = { width: 1600, height: 900 }, headless = false } = {}) {
|
|
74
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'storyshot-login-'));
|
|
75
|
+
// "Continue where you left off" also keeps session cookies when the user closes the window.
|
|
76
|
+
fs.mkdirSync(path.join(dir, 'Default'), { recursive: true });
|
|
77
|
+
fs.writeFileSync(path.join(dir, 'Default', 'Preferences'), JSON.stringify({ session: { restore_on_startup: 1 } }));
|
|
78
|
+
const exe = chromeExecutable();
|
|
79
|
+
const args = [`--user-data-dir=${dir}`, '--no-first-run', '--no-default-browser-check',
|
|
80
|
+
`--window-size=${viewport.width},${viewport.height}`, ...(headless ? ['--headless=new'] : []), url];
|
|
81
|
+
let proc;
|
|
82
|
+
try { proc = spawn(exe, args, { stdio: 'ignore' }); } catch (e) {
|
|
83
|
+
throw new Error(`could not open a browser window (${e.message}). Log in on a machine with a screen (storyshot login ${url}) and copy the file here.`);
|
|
60
84
|
}
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
const session = { url, dir, exe, proc, exited: false };
|
|
86
|
+
proc.on('exit', () => { session.exited = true; });
|
|
87
|
+
proc.on('error', () => { session.exited = true; });
|
|
88
|
+
return session;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Quit the window (if the user hasn't), read the session back from its profile, save it and delete
|
|
92
|
+
// the temporary profile.
|
|
93
|
+
export async function finishLogin(session, { file = authFileFor(session.url), log = console.log } = {}) {
|
|
94
|
+
const { url, dir, exe, proc } = session;
|
|
95
|
+
const how = session.exited ? 'window closed' : 'logged in';
|
|
96
|
+
let state;
|
|
97
|
+
try {
|
|
98
|
+
if (!session.exited) {
|
|
99
|
+
// SIGTERM makes Chrome shut down cleanly and flush cookies; force it if it hangs
|
|
100
|
+
proc.kill('SIGTERM');
|
|
101
|
+
const t = setTimeout(() => proc.kill('SIGKILL'), 10_000);
|
|
102
|
+
await new Promise((r) => (session.exited ? r() : proc.once('exit', r)));
|
|
103
|
+
clearTimeout(t);
|
|
104
|
+
}
|
|
105
|
+
// --restore-last-session reopens the user's tabs, which brings back their sessionStorage (some
|
|
106
|
+
// apps, e.g. Microsoft MSAL, keep the login token there; storageState() doesn't capture it)
|
|
107
|
+
const context = await chromium.launchPersistentContext(dir, { executablePath: exe, headless: true, ignoreHTTPSErrors: true, args: ['--restore-last-session'] });
|
|
75
108
|
await new Promise((r) => setTimeout(r, 1000));
|
|
109
|
+
const sessionStorage = {};
|
|
110
|
+
for (const p of context.pages()) {
|
|
111
|
+
if (!/^https?:/.test(p.url())) continue;
|
|
112
|
+
await p.waitForLoadState('load', { timeout: 15_000 }).catch(() => {});
|
|
113
|
+
const items = await p.evaluate(() => Object.entries(window.sessionStorage)).catch(() => []);
|
|
114
|
+
if (items.length) Object.assign(sessionStorage[new URL(p.url()).origin] ??= {}, Object.fromEntries(items));
|
|
115
|
+
}
|
|
116
|
+
// load the app once in a fresh tab, so its localStorage / IndexedDB are part of the snapshot
|
|
117
|
+
const page = await context.newPage();
|
|
118
|
+
await page.goto(url, { waitUntil: 'load', timeout: 30_000 }).catch(() => {});
|
|
119
|
+
state = await context.storageState({ indexedDB: true });
|
|
120
|
+
const extra = Object.entries(sessionStorage).map(([origin, items]) => ({ origin, items: Object.entries(items).map(([name, value]) => ({ name, value })) }));
|
|
121
|
+
if (extra.length) state.sessionStorage = extra;
|
|
122
|
+
await context.close().catch(() => {});
|
|
123
|
+
} finally {
|
|
124
|
+
if (!session.exited) proc.kill('SIGKILL');
|
|
125
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
76
126
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (done === 'timeout') log('warning: timed out waiting for the login; saving the session as it is');
|
|
127
|
+
const host = new URL(url).hostname.replace(/^www\./, '');
|
|
128
|
+
const siteCookies = state.cookies.filter((c) => host.endsWith(c.domain.replace(/^\./, '')) || c.domain.replace(/^\./, '').endsWith(host)).length;
|
|
80
129
|
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
|
81
130
|
fs.writeFileSync(file, JSON.stringify(state, null, 1));
|
|
82
131
|
const gi = ignore(file);
|
|
83
|
-
return { file: path.resolve(file), how
|
|
132
|
+
return { file: path.resolve(file), how, gitignore: gi, cookies: state.cookies.length, siteCookies, sessionStorage: state.sessionStorage?.length ?? 0 };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A browser context with a saved login: storageState plus the sessionStorage that login() saved
|
|
136
|
+
// alongside it (Playwright has no sessionStorage in storageState, so it is restored by a script).
|
|
137
|
+
export async function sessionContext(browser, { storageState, ...options } = {}) {
|
|
138
|
+
let state = storageState;
|
|
139
|
+
if (typeof state === 'string') state = JSON.parse(fs.readFileSync(state, 'utf8'));
|
|
140
|
+
const { sessionStorage, ...rest } = state ?? {};
|
|
141
|
+
const context = await browser.newContext({ ...options, storageState: state ? rest : undefined });
|
|
142
|
+
if (sessionStorage?.length) {
|
|
143
|
+
await context.addInitScript((saved) => {
|
|
144
|
+
const s = saved.find((o) => o.origin === location.origin);
|
|
145
|
+
if (s) for (const { name, value } of s.items) if (window.sessionStorage.getItem(name) === null) window.sessionStorage.setItem(name, value);
|
|
146
|
+
}, sessionStorage);
|
|
147
|
+
}
|
|
148
|
+
return context;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// CLI: open the window, wait for Enter (or the window closing), then save.
|
|
152
|
+
export async function login(url, { file = authFileFor(url), viewport, log = console.log } = {}) {
|
|
153
|
+
const session = openLoginWindow(url, { viewport });
|
|
154
|
+
log(`A Chrome window is open at ${url}. Log in there (2FA and "Sign in with Google" work).`);
|
|
155
|
+
log('When you see the app logged in, come back here and press Enter (or just close the window).');
|
|
156
|
+
await new Promise((resolve) => {
|
|
157
|
+
const done = () => { process.stdin.pause(); resolve(); };
|
|
158
|
+
process.stdin.resume(); process.stdin.once('data', done);
|
|
159
|
+
session.proc.once('exit', done);
|
|
160
|
+
});
|
|
161
|
+
return finishLogin(session, { file, log });
|
|
84
162
|
}
|
package/src/build.mjs
CHANGED
|
@@ -3,15 +3,16 @@ import { record } from './record.mjs';
|
|
|
3
3
|
import { compose } from './compose.mjs';
|
|
4
4
|
import { soundtrack } from './audio.mjs';
|
|
5
5
|
import { measureVoice } from './voice.mjs';
|
|
6
|
-
import { writeOutputs } from './outputs.mjs';
|
|
6
|
+
import { writeOutputs, burnSubtitles } from './outputs.mjs';
|
|
7
7
|
|
|
8
8
|
export async function build(cfg, mode = 'render', { log = console.log } = {}) {
|
|
9
9
|
const t0 = Date.now();
|
|
10
10
|
if (mode !== 'compose') await record(cfg, { log });
|
|
11
11
|
if (mode === 'record') return { warnings: [] };
|
|
12
12
|
const voiceLens = await measureVoice(cfg, { log });
|
|
13
|
-
const { silent, events, preview, warnings, captions, segs } = await compose(cfg, { log, voiceLens });
|
|
13
|
+
const { silent, events, preview, warnings, captions, cards, segs } = await compose(cfg, { log, voiceLens });
|
|
14
14
|
const audio = await soundtrack(cfg, events, silent, { log });
|
|
15
|
-
|
|
15
|
+
if (cfg.subtitles === 'burn') burnSubtitles(cfg, audio.narration ?? [], { log });
|
|
16
|
+
const extras = await writeOutputs(cfg, { captions, cards, segs, narration: audio.narration, total: events.total }, { log });
|
|
16
17
|
return { out: audio.out, extras, preview, warnings: [...warnings, ...audio.warnings], duration: events.total, seconds: Math.round((Date.now() - t0) / 1000) };
|
|
17
18
|
}
|