storyshot 0.7.3 → 0.8.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 CHANGED
@@ -143,8 +143,12 @@ After your next release, re-render it and you have an up-to-date video.
143
143
  |---|---|---|
144
144
  | a promo / launch / landing-page clip | `pitch` | short and tightly cut: intro card, the app in a browser frame, music, captions |
145
145
  | 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 |
146
- | a lesson or explainer | `course` | explains the *why*: concept cards, the idea shown in the app, question cards, a recap, no music; `.srt` + chapters |
146
+ | a lesson | `course` | explains the *why*: concept cards, the idea shown in the app, question cards, a recap, no music; `.srt` + chapters |
147
147
  | help-center snippets | `docs` | silent per-step GIFs and PNGs cropped to the action, plus a ready-to-paste Markdown article |
148
+ | an animated explainer of how something works | `explainer` | no web app: every step is a **scene**, an animation the agent writes and times to the narration (a mechanism, a diagram that grows, a number); calm music, subtitles, chapters |
149
+
150
+ Tutorials and lessons can use scenes too: a `scene:` step between recorded
151
+ steps explains an idea the app can't show, then the video goes back to the app.
148
152
 
149
153
  Length follows the purpose and the content, not a target: a promo is as short as
150
154
  its message, a tutorial as long as the workflow takes to explain.
@@ -199,6 +203,8 @@ from `storyshot guide`.
199
203
  | `init_storyboard` | Writes a starter storyboard with verified targets. |
200
204
  | `check_storyboard` | Validates in about a second: typos with "did you mean", action and target shapes, `TODO`s, keys, the session file, and narration that sounds machine-written ("not just X, but Y", "seamless", 赋能…) or runs on without a breath. |
201
205
  | `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. |
206
+ | `scene_cues` | For scene steps: synthesizes the narration and returns when each phrase is spoken (the times `S.at(k)` gives the scene). |
207
+ | `preview_scene` | Stills of one scene a beat into each phrase, plus HyperFrames lint and its layout check, in 20–40 s, without rendering the video. |
202
208
 
203
209
  Any other MCP client: run `npx -y storyshot mcp` (stdio), and point the agent at the playbook (`storyshot guide`). When run from a git
204
210
  checkout, `setup` points to `node /path/to/checkout/bin/storyshot.mjs mcp` instead.
@@ -222,6 +228,9 @@ Everything the agent does is also a command:
222
228
  | `storyshot render videos/acme-promo.yaml [--draft]` | Record, then compose the video and soundtrack. |
223
229
  | `storyshot compose videos/acme-promo.yaml [--draft]` | Rebuild from the last recording, in seconds. |
224
230
  | `storyshot record videos/acme-promo.yaml` | Only record. |
231
+ | `storyshot init --purpose explainer --topic "how a TaqMan probe works" [--lang zh] [--portrait]` | Write an explainer storyboard of scene steps and a starter scene file for each (`videos/scenes/`). |
232
+ | `storyshot cues videos/<name>.yaml` | When each phrase of each scene's narration is spoken. |
233
+ | `storyshot scene videos/<name>.yaml --step 2 [--at 1.5,4] [--no-check]` | Preview one scene: stills, lint and layout check. |
225
234
  | `storyshot guide` | Print the playbook and the reference. |
226
235
  | `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. |
227
236
  | `storyshot doctor` | Check Node, ffmpeg (and the filters and encoders Storyshot uses), Chromium and voices; install Chromium if it's missing. |
@@ -268,7 +277,8 @@ pace: slow # fast (pitch) | normal (docs) |
268
277
  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 }
269
278
  article: { title: Export a drawing, intro: … } # docs: heading and first paragraph of <out>-steps/README.md
270
279
  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
271
- cards: auto # intro/section/end cards: auto follows the frame (dark frames get dark cards), or light | dark
280
+ cards: auto # intro/section/end cards: auto follows the film's ground, or light | dark
281
+ ground: auto # is the film light or dark overall? light | dark; auto reads it from the scenes' backgrounds, else from the frame. It sets the colour of burned-in subtitles (ink letters with a white outline on a light film, white letters with an ink outline on a dark one) and the cards' ground
272
282
  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 }
273
283
  # 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
274
284
  chapters: false # write <out>.chapters.txt for YouTube
@@ -475,6 +485,52 @@ usually shouldn't.
475
485
  | `say` | Voiceover line played over the end card. |
476
486
  | `emblem` | With `hyperframes`: one to three short monospace lines over the title (an alignment, a formula, a line of code); `false` for none. On HyperFrames cards, text too long for the frame (a title, subtitle, emblem or call to action) wraps and then shrinks until it fits. |
477
487
 
488
+ ### Scenes
489
+
490
+ A `scene:` step plays an animation you write instead of a recording: one
491
+ [HyperFrames](https://hyperframes.heygen.com) composition (a `<template>` with a
492
+ root whose `id` equals its `data-composition-id`, and one paused GSAP timeline
493
+ registered as `window.__timelines['<id>']`). It is as long as the step's `say:`
494
+ line needs, and reads its timing from `window.__storyshot`: `S.at(k)` is when
495
+ phrase `k` of the line is spoken (phrases end at sentence ends and commas),
496
+ `S.end(k)` when it ends, `S.cues` all of them. It also gets `S.duration`, the
497
+ frame size, `S.safeBottom` (the room the burned-in subtitles take) and the theme
498
+ as CSS variables (`--accent`, `--ink`, `--font`). Media go in `assets/` next to
499
+ the scene file.
500
+
501
+ ```yaml
502
+ purpose: explainer # scenes only: no url, nothing recorded
503
+ voice: { provider: qwen, voice: dylan }
504
+ subtitles: burn
505
+ steps:
506
+ - scene: scenes/question.html # or { file, title, lead: 0.5, tail: 0.8 }
507
+ say: An intact probe carries a dye. Why doesn't it glow?
508
+ - scene: { file: scenes/fret.html, title: Quenching }
509
+ say: The quencher sits right next to the dye, and takes its energy before it can become light.
510
+ ```
511
+
512
+ Files every scene shares (a palette, drawing helpers) are listed once:
513
+ `scenes: { css: scenes/kit.css, js: scenes/kit.js }` loads them before each
514
+ scene. A scene's narration may be a whole paragraph: it is spoken in chunks of
515
+ whole sentences, and `voice.maxPause` caps the pauses inside a line (0.55 s in
516
+ an explainer).
517
+
518
+ Every scene of a film shares one look, and the look belongs to the subject and
519
+ the audience: a dark instrument panel suits a mechanism explained to specialists,
520
+ a warm paper poster suits civics explained to nine-year-olds. Say which ground
521
+ you chose with `ground: light | dark`, and the burned-in subtitles and the cards
522
+ follow it. The playbook's "The look follows the subject" has the styles and what
523
+ each ground costs.
524
+
525
+ `lead` is how long the scene plays before the voice starts (default 0.5 s), and
526
+ `tail` is how long it holds after the voice ends (default 0.8 s). Consecutive
527
+ scenes cut into each other. Scenes are cached like cards, so `compose`
528
+ re-renders only the ones that changed. `storyshot check` validates the
529
+ contract, and `storyshot scene` runs the full HyperFrames lint and layout check
530
+ on one scene. The playbook's "Scenes and explainers" says how to write scenes
531
+ that explain. `music: { style: calm }` (the explainer default) is a soft pad
532
+ with no drums.
533
+
478
534
  ### HyperFrames cards
479
535
 
480
536
  Tutorials and lessons render their intro, section and end cards with
package/bin/storyshot.mjs CHANGED
@@ -11,6 +11,9 @@ Usage:
11
11
  storyshot init <url> [--purpose pitch|tutorial|course|docs] [--topic export-pdf] [--storage <site>-login.json]
12
12
  write a starter storyboard using verified targets from the page,
13
13
  named by type: videos/<site>-promo|tutorial|lesson|docs[-<topic>].yaml
14
+ storyshot init --purpose explainer --topic "how a TaqMan probe works" [--lang zh] [--portrait]
15
+ a concept explained with animated scenes (no web app): a storyboard
16
+ of scene steps + starter scene files in videos/scenes/
14
17
  storyshot login <url> [--save videos/<site>-login.json]
15
18
  open a browser window to log in once; saves the session for
16
19
  \`storageState:\` (and adds it to .gitignore)
@@ -23,6 +26,11 @@ Usage:
23
26
  storyshot compose <storyboard.yaml> re-compose from the last recording
24
27
  (after changing captions, durations, zoom, music, voice, end card)
25
28
  storyshot record <storyboard.yaml> only (re-)record the browser session
29
+ storyshot cues <storyboard.yaml> when each phrase of each scene's narration is spoken
30
+ (the times S.at(k) returns in the scene); synthesizes the voice
31
+ storyshot scene <storyboard.yaml> --step N [--at 1.5,4] [--no-check]
32
+ preview one scene: stills as each phrase is spoken (or at --at
33
+ seconds), HyperFrames lint and layout check — without a full render
26
34
  storyshot guide print the agent playbook + storyboard reference
27
35
  storyshot setup <agent> add the MCP server + playbook to this project for your coding agent:
28
36
  cursor, claude, codex, gemini, vscode (Copilot), windsurf, cline —
@@ -41,8 +49,10 @@ Options:
41
49
  const args = process.argv.slice(2);
42
50
  const [cmd, arg] = args;
43
51
  const flag = (name) => { const i = args.indexOf(`--${name}`); return i > 0 ? args[i + 1] : undefined; };
44
- const NEEDS_ARG = ['login', 'init', 'inspect', 'check', 'render', 'compose', 'record', 'setup'];
45
- if (![...NEEDS_ARG, 'mcp', 'guide', 'doctor'].includes(cmd) || (NEEDS_ARG.includes(cmd) && !arg)) {
52
+ const NEEDS_ARG = ['login', 'init', 'inspect', 'check', 'render', 'compose', 'record', 'setup', 'cues', 'scene'];
53
+ // an explainer has no url to start from: `storyshot init --purpose explainer --topic …`
54
+ const explainerInit = cmd === 'init' && flag('purpose') === 'explainer';
55
+ if (![...NEEDS_ARG, 'mcp', 'guide', 'doctor'].includes(cmd) || (NEEDS_ARG.includes(cmd) && !arg && !explainerInit)) {
46
56
  process.stdout.write(USAGE);
47
57
  process.exit(['--help', '-h', 'help'].includes(cmd) ? 0 : 1);
48
58
  }
@@ -92,6 +102,21 @@ try {
92
102
  const r = await login(arg, { file: flag('save') ?? authFileFor(arg), viewport: viewport() });
93
103
  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}` : ''}` +
94
104
  `\n use it: \`storageState: ${r.file.split(/[\\/]/).pop()}\` in the storyboard (path relative to the yaml), or \`--storage ${r.file}\` for inspect/init`);
105
+ } else if (explainerInit) {
106
+ const { initExplainer } = await import('../src/init.mjs');
107
+ const r = initExplainer({ topic: flag('topic'), file: flag('file'), lang: flag('lang'), portrait: args.includes('--portrait') });
108
+ console.log(`✔ wrote ${r.file}\n${r.scenes.map((f) => ` + ${f}`).join('\n')}\n next: write the say: lines, then \`storyshot cues ${r.file}\` and the scenes; preview one with \`storyshot scene ${r.file} --step 1\``);
109
+ } else if (cmd === 'cues') {
110
+ const { sceneCues } = await import('../src/scene-tools.mjs');
111
+ const r = await sceneCues(loadConfig(arg, { draft: args.includes('--draft') }));
112
+ console.log(`${r.text || 'no scene steps in this storyboard'}\n\n✔ ${r.file}`);
113
+ } else if (cmd === 'scene') {
114
+ const { previewScene, formatPreview } = await import('../src/scene-tools.mjs');
115
+ const step = Number(flag('step'));
116
+ if (!(step >= 1)) throw new Error('which scene? --step N (the step number in the storyboard, from 1)');
117
+ const at = flag('at') ? flag('at').split(',').map(Number).filter((x) => x >= 0) : null;
118
+ const r = await previewScene(loadConfig(arg, { draft: args.includes('--draft') }), step, { at, check: !args.includes('--no-check') });
119
+ console.log(formatPreview(r, step));
95
120
  } else if (cmd === 'init') {
96
121
  const { initStoryboard } = await import('../src/init.mjs');
97
122
  const r = await initStoryboard(arg, { purpose: flag('purpose') ?? 'pitch', topic: flag('topic'), file: flag('file'), storageState: flag('storage'), ignoreHTTPSErrors: args.includes('--insecure'), viewport: viewport() });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyshot",
3
- "version": "0.7.3",
3
+ "version": "0.8.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": {
@@ -1,18 +1,23 @@
1
1
  ---
2
2
  name: storyshot
3
- description: Make videos of a web app with Storyshot — promo/demo clips, narrated how-to tutorials, teaching walkthroughs, or per-step GIFs for docs (smooth cursor, auto zoom, captions, voiceover, music). Use when the user asks for a demo video, promo or launch clip, product walkthrough, tutorial or onboarding video, course/lesson video, or GIFs of their website or web app.
3
+ description: Make videos of a web app with Storyshot — promo/demo clips, narrated how-to tutorials, teaching walkthroughs, or per-step GIFs for docs (smooth cursor, auto zoom, captions, voiceover, music) — and narrated explainers of a concept told with animated scenes. Use when the user asks for a demo video, promo or launch clip, product walkthrough, tutorial or onboarding video, course/lesson video, GIFs of their website or web app, or an animated explainer video of how something works.
4
4
  ---
5
5
 
6
6
  # Making videos with Storyshot
7
7
 
8
8
  Storyshot drives a real browser through a site according to a YAML storyboard,
9
9
  then retimes, zooms, captions, narrates and scores the recording into an MP4.
10
+ A step can also be a **scene**: an animation you write (a HyperFrames
11
+ composition) that explains an idea, timed to its narration. A storyboard of
12
+ scenes only is an **explainer** (`purpose: explainer`, no url); see
13
+ "Scenes and explainers" below.
10
14
  Your job: **look → confirm the brief with the user → write the storyboard →
11
15
  check → render → look at the preview → fix → repeat**, then deliver.
12
16
 
13
17
  A render takes 1–3 minutes and a paid voice costs credits, so a wrong guess
14
18
  about what the user wants is the most expensive mistake. Confirm the brief
15
- once, up front, before writing anything.
19
+ once, up front, before writing anything (unless the user said not to ask:
20
+ then state your choices in the reply).
16
21
 
17
22
  ## Tools
18
23
 
@@ -30,6 +35,9 @@ Use the `storyshot` MCP tools if they are available. Otherwise use the CLI:
30
35
  | record + render (1–3 min) | `render_video` → `render_status` if it returns a `job_id` | `storyshot render videos/<name>.yaml` |
31
36
  | re-render without re-recording | `render_video` mode `compose` | `storyshot compose videos/<name>.yaml` |
32
37
  | free draft voice | `render_video` `draft: true` | `--draft` |
38
+ | starter explainer (scene steps + starter scene files) | `init_storyboard` `purpose: explainer`, `topic` | `storyshot init --purpose explainer --topic "how X works" [--lang zh]` |
39
+ | when each phrase of each scene's narration is spoken | `scene_cues` | `storyshot cues videos/<name>.yaml` |
40
+ | stills + lint + layout check of one scene (20–40 s) | `preview_scene` (`step`) | `storyshot scene videos/<name>.yaml --step 2` |
33
41
 
34
42
  Always pass **absolute paths** to MCP tools.
35
43
 
@@ -42,7 +50,9 @@ everything.
42
50
  the browser can't start (an agent sandbox that blocks it, a missing Chromium),
43
51
  or the tools keep failing, stop and tell the user what blocked it and how to
44
52
  allow it. Never produce a substitute: no mock-ups, redrawn screens, scripted
45
- frames or stock footage presented as a recording of their app.
53
+ frames or stock footage presented as a recording of their app. A scene explains
54
+ an idea (a mechanism, a diagram, a number); it never redraws the app's screens
55
+ or stands in for a recording of them.
46
56
 
47
57
  ## 1. Look, then confirm the brief
48
58
 
@@ -83,6 +93,13 @@ frames or stock footage presented as a recording of their app.
83
93
  | `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
94
  | `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
95
  | `docs` | one clip per step, each just long enough to see its result | none | none | step numbers, spotlight, cropped GIFs + PNGs, README.md article |
96
+ | `explainer` | the questions the viewer has about the concept, each answered by one scene; no web app | yes | calm | your scenes in a look chosen for the subject and the audience, .srt or burned-in subtitles, chapters |
97
+
98
+ An explainer has no site to look at, so the plan carries one more line: the
99
+ **look** — who the viewer is, and the style that suits them and the subject
100
+ ("3rd–5th graders, so a classroom poster: warm paper, dark ink, chunky
101
+ shapes"). See "The look follows the subject" in step 3. It is much cheaper to
102
+ agree on it now than to repaint ten finished scenes.
86
103
 
87
104
  Captions match the site's language. `kokoro` (free, local) speaks English
88
105
  and Mandarin Chinese: for Chinese lines use `voice: { provider: kokoro,
@@ -365,6 +382,244 @@ Notes:
365
382
  - `.storyshot/` next to the YAML is a cache of recordings, screenshots and
366
383
  voice lines. Add it to `.gitignore`.
367
384
 
385
+ ### Scenes and explainers
386
+
387
+ A **scene** is a step whose picture you write instead of recording: an
388
+ animation that shows an idea the app can't, such as how a probe lights up, why an
389
+ algorithm scores a match, or what a number means. It is a HyperFrames
390
+ composition, an HTML file with a GSAP timeline, and it plays as long as the
391
+ step's `say:` line needs. Use it two ways:
392
+
393
+ - **`purpose: explainer`**: the whole video is scenes, with no `url` and nothing
394
+ recorded. Use it for "explain how X works" when there is no web app to show.
395
+ - **Inside a tutorial or lesson**: a `scene:` step between recorded steps
396
+ explains the why ("what does this score mean?"), then the video goes back to
397
+ the app. Keep these to 10–30 s.
398
+
399
+ ```yaml
400
+ - scene: scenes/fret.html # or { file: …, title: Chapter title, lead: 0.5, tail: 0.8 }
401
+ say: The quencher sits right next to the dye. Its energy is passed across before it can become light.
402
+ ```
403
+
404
+ `lead` is how long the scene plays before the voice starts (default 0.5 s).
405
+ `tail` is how long it holds after the voice ends (default 0.8 s). A scene
406
+ with no `say:` needs a `duration`. A scene has no `actions`, `caption` or
407
+ `zoom`, because it draws its own picture and text.
408
+
409
+ **Workflow.** Write the words first, then the pictures:
410
+ 1. **Facts.** Every claim, number and sequence on screen comes from a source
411
+ you checked: a paper, the official docs, a database. Put the source on
412
+ screen in small type where its number appears, and list the sources in your
413
+ reply. Mark drawings that are schematic ("示意", "illustrative"). A
414
+ beautiful wrong diagram is worse than none. (A credit line may be smaller
415
+ than the 28 px text floor: put `data-layout-ignore` on it.)
416
+ 2. **Story.** Start from something the viewer knows: a real case, their own
417
+ question, one concrete example that runs through the whole video (one
418
+ gene, one request, one dataset). Then one question per scene, answered by
419
+ one picture. Follow "Writing the narration" above: short spoken sentences;
420
+ question → concrete case → name the idea → why → recap.
421
+ 3. **Lines.** Write the `say:` lines: one idea per scene, usually 2–5
422
+ sentences (about 60 English words or 120 Chinese characters at most).
423
+ Commas are cue points: each phrase can trigger one change on screen.
424
+ 4. **Cues.** Run `storyshot cues` (MCP `scene_cues`). It synthesizes the voice
425
+ and prints each scene's length and when each phrase is spoken. These are
426
+ the times `S.at(k)` returns inside the scene.
427
+ 5. **Look.** Choose the film's style from its subject and its audience, write
428
+ it down (ground, palette, type, how objects are drawn), and put it in the
429
+ scene kit before any scene is written. "The look follows the subject" below.
430
+ 6. **Scenes.** Write each scene file (contract below). Then preview it with
431
+ `storyshot scene … --step N` (MCP `preview_scene`), which gives stills a beat
432
+ into each phrase, HyperFrames lint, and a layout check. Look at every still,
433
+ fix the scene, and preview again until it is right.
434
+ 7. **Render**, look at `preview.jpg` (one frame per scene), and fix. After
435
+ editing only scenes or lines, `compose` re-renders just the scenes that
436
+ changed.
437
+
438
+ **The scene file contract** (`storyshot check` and `preview_scene` enforce it):
439
+
440
+ ```html
441
+ <template>
442
+ <style>
443
+ #fret { position: absolute; inset: 0; overflow: hidden; background: #121110; font-family: var(--font); }
444
+ #fret-dye { position: absolute; left: 600px; top: 420px; width: 120px; height: 120px; border-radius: 50%; background: #f4a62a; }
445
+ </style>
446
+ <div id="fret" data-composition-id="fret" data-width="1920" data-height="1080">
447
+ <div id="fret-dye"></div>
448
+ </div>
449
+ <script>
450
+ (function () {
451
+ const S = window.__storyshot; // this scene's timing, size and theme
452
+ const tl = gsap.timeline({ paused: true });
453
+ tl.fromTo('#fret-dye', { scale: 0 }, { scale: 1, duration: 0.6, ease: 'expo.out' }, S.at(0));
454
+ tl.fromTo('#fret-dye', { opacity: 1 }, { opacity: 0.25, duration: 0.4 }, S.at('passed across'));
455
+ window.__timelines['fret'] = tl;
456
+ })();
457
+ </script>
458
+ </template>
459
+ ```
460
+
461
+ - One `<template>` holds the styles, markup and script. The root element's
462
+ `id` equals its `data-composition-id`, is unique among the scenes, and
463
+ prefixes every other id in the file. `data-width`/`data-height` match the
464
+ video (1920×1080, or 1080×1920 for portrait).
465
+ - One paused GSAP timeline, registered as `window.__timelines['<id>']`. Use
466
+ `fromTo` (not `from`). No `Date`, timers, `requestAnimationFrame`,
467
+ `repeat: -1`, or unseeded `Math.random`: frames are rendered by seeking the
468
+ timeline.
469
+ - Media go in `assets/` next to the scene file, referenced as `assets/x.png`.
470
+ - **A look shared by every scene** (a palette, fonts, a helper that draws
471
+ your recurring objects): put it in files and list them once at the top of
472
+ the storyboard, `scenes: { css: scenes/kit/kit.css, js: scenes/kit/kit.js }`.
473
+ They load before each scene's own script, so a scene can call your helpers
474
+ and use your CSS variables.
475
+ - **`window.__storyshot`** (`S`) gives you:
476
+ - `S.at(k, offset)`: when phrase `k` starts. `k` counts from 0, `-1` is the
477
+ last phrase, and a string means the phrase that contains it.
478
+ - `S.end(k)`: when phrase `k` ends.
479
+ - `S.cues`: `[{ text, t, end }]` for every phrase.
480
+ - `S.duration`, `S.width`, `S.height`, `S.lead`.
481
+ - `S.safeBottom`: the pixels at the bottom that burned-in subtitles take
482
+ (16% of the height, 22% in portrait, 0 without `subtitles: burn`); keep
483
+ content above it. The same value is available as the CSS variable
484
+ `var(--safe-bottom)`. `preview_scene` draws this band on its stills as a
485
+ striped red strip.
486
+ - `S.theme`: `{ accent, ink, font }`, also available as `var(--accent)`,
487
+ `var(--ink)` and `var(--font)`. The font falls back to the machine's CJK
488
+ font for Chinese.
489
+
490
+ Time every reveal from `S.at`, never from hard-coded seconds, so the
491
+ animation stays on the words when a line changes.
492
+
493
+ **The look follows the subject and the audience.** There is no house style, and
494
+ the last explainer's look is not a default. A film for eight-year-olds about
495
+ voting and a film for biologists about pooled screens should not look alike:
496
+ choose the style, say so in the plan, and put it in the scene kit
497
+ (`scenes: { css: …, js: … }`) before the first scene is written, so ten scenes
498
+ can't disagree about it.
499
+
500
+ Two questions decide it. **Who is watching?** Children and general audiences
501
+ need big friendly drawings, few colours and a light ground; specialists read
502
+ dense instrument-like pictures and are at home on a dark panel. **What is the
503
+ subject made of?** A thing that glows, fluoresces, counts or signals wants a
504
+ dark ground to light up on; a thing that is printed, taught, filled in or voted
505
+ on wants paper. Starting points, not a menu to pick blindly:
506
+
507
+ | subject, and who it is for | the look | made of |
508
+ |---|---|---|
509
+ | a mechanism, molecule, instrument or pipeline, for people who work on it | instrument panel | near-black ground, one or two signal colours that light up, hairlines, mono labels, counters, glows |
510
+ | anything for children; civics, history and everyday subjects for everyone | classroom poster | warm paper, dark ink, chunky rounded shapes, thick strokes, big plain type, drawings with visible hand |
511
+ | money, policy, a market, a result, for a general adult or professional audience | clean report | white ground, one accent, wide margins, one typeface, real numbers, nothing decorative |
512
+ | a machine, a building, a physical process | blueprint | deep blue or sepia ground, a faint grid, dimension and leader lines, small-caps part labels |
513
+ | a person, a place, a historical event | storybook | a tinted ground that is neither black nor white, illustrated figures, soft shapes, one warm accent |
514
+
515
+ **Then tell Storyshot which ground you chose**: `ground: light` or
516
+ `ground: dark` in the storyboard. Burned-in subtitles and the intro/section/end
517
+ cards follow it — ink letters with a white outline on a light film, white
518
+ letters with an ink outline on a dark one — so the words read without a bar
519
+ drawn under them. Left out, `auto` reads the ground from the scenes — their own
520
+ background, or the kit rule for a class on their root (`<div id="x"
521
+ class="world">`). When it is a variable or an image, nothing can be read:
522
+ `check` warns, and the subtitles stay white until you say which the film is.
523
+
524
+ **What a ground costs.** The choice is not only a colour; it changes what works:
525
+ - **On a light ground**, a glow is a smudge: make it a flat tinted panel, a ring
526
+ or nothing. Keep shadows at `rgba(<ink>, .12)` or less, or they look dirty. A
527
+ cream card that floated on black becomes a *white* sheet, which needs its own
528
+ shadow and some real structure inside (rules, a divider) or it reads as a
529
+ blank rectangle. A saturated accent that sang on black usually fails the
530
+ contrast check as text on paper (amber `#f4a62a` on white is about 2:1):
531
+ darken it for text, keep the bright one for fills, or give the words a thin
532
+ ink outline — and settle that in the kit, once.
533
+ - **On a dark ground**, thin dark lines and dark figures vanish: every object
534
+ needs a light value of its own, and faint white strokes (`rgba(255,255,255,.06)`)
535
+ that carry structure there have no quiet equivalent on paper.
536
+ - **Either way**, a colour used as a *fill* can be bright, while the same colour
537
+ as *text* has to pass the contrast check (the layout check measures it, 3:1
538
+ for large text and 4.5:1 for normal). Watch a colour sitting inside a panel
539
+ of its own hue (teal dots on a teal wing): it measures against the panel, not
540
+ against the ground, and that is where contrast is usually lost.
541
+
542
+ **Repaint before the scenes exist, not after.** Converting a finished ten-scene
543
+ film to the other ground is an hour or more of work with nothing new to show
544
+ for it: the colour swap is mechanical, but every glow, shadow, faint stroke and
545
+ floating card needs a judgement, and the contrast findings all change. It is
546
+ much cheaper to show one previewed scene in the chosen style and ask.
547
+
548
+ **Pictures that explain** (what the preview stills should show):
549
+ - **The object is the explanation.** Draw the actual thing, such as the
550
+ molecule, the queue or the circuit, big enough to be the hero: at least 40%
551
+ of the frame's width or height. Use the real data where it exists: a real
552
+ sequence, real numbers. Never show a small icon floating in an empty frame:
553
+ if a still is mostly empty ground, scale the diagram up until it fills the
554
+ frame.
555
+ - **One diagram that grows.** Start from the simplest true picture and add one
556
+ part per phrase, each when the voice names it, with its label on it (a
557
+ leader line, not a legend). Never put the finished diagram on screen at
558
+ once.
559
+ - **Motion.** One hero move per beat, and something new every 2–4 s.
560
+ Arrivals ease out (`expo.out`, 0.5–0.7 s) and exits are shorter. Text lands
561
+ and then stays still while it is read.
562
+ - **Text.** At least 28 px at 1080p, and nothing under `S.safeBottom`. Never
563
+ repeat the narration word for word on screen (the subtitles do that).
564
+ Avoid bullet slides and rows of three cards.
565
+ - **Colour.** Rationed: one accent, plus one colour per thing the story
566
+ tracks (for example, allele G amber and allele A green everywhere: dye,
567
+ dot, label).
568
+ - **Seams.** Consecutive scenes cut straight into each other. End a scene on
569
+ a state the next one starts from (the same object in the same place), or
570
+ on a clean hold. The video's first frame is its thumbnail: show the subject
571
+ there, never an empty ground.
572
+ - **Look before you move on.** Read `preview_scene`'s stills like a stranger
573
+ would. Is every object recognisable without explanation, does each part
574
+ appear when it is named, and does nothing overlap, crop or sit under the
575
+ subtitles? Fix every ✖ from lint and the layout check.
576
+
577
+ **Gotchas** (each one cost a real run several previews):
578
+ - **Several tweens on one property.** Several `fromTo`s on the same property
579
+ of one element (fade a label in, later fade it out) are fine: Storyshot
580
+ gives every later one `immediateRender: false`, so it does not show its
581
+ start state from the first frame. With `to()` or `from()`, or a position
582
+ that is not a number, set `immediateRender: false` yourself.
583
+ - **SVG sizes.** Animate them with `attr: { width: 300 }`. A CSS transform
584
+ on an SVG `<rect>` (`scaleX` with `transform-origin`) can render nothing.
585
+ - **Lines that draw themselves.** Give the path `pathLength="1"`,
586
+ `stroke-dasharray: 1`, and tween `strokeDashoffset` from 1 to 0 with
587
+ `autoRound: false`. Without it, GSAP rounds the offset to whole pixels and
588
+ the line pops in instead of drawing.
589
+ - **Layout check findings that are intended.**
590
+ - A glow that spills out of its box: `data-layout-allow-overflow` on the box.
591
+ - A label sitting on its object: `data-layout-allow-overlap` on the container.
592
+ - Small credits or texture: `data-layout-ignore`.
593
+ - **"The film's last line needs 2 s".** Only the video's last scene must hold
594
+ its final words. Elsewhere, reveal text on an earlier phrase or raise
595
+ `tail:`.
596
+ - **The ground under the cards.** Intro and end cards follow the film's ground,
597
+ so they come out dark when the scenes do; `ground: light | dark` settles the
598
+ whole film and `cards: light | dark` overrides the cards alone. Keep
599
+ end-card bullets short: about 10 Chinese characters or 5 English words each.
600
+
601
+ Long narration is fine: a scene's `say:` is spoken in chunks of whole
602
+ sentences, and in an explainer no pause inside a line lasts longer than
603
+ `voice.maxPause` (0.55 s by default), so the voice keeps moving.
604
+
605
+ Explainer defaults:
606
+ - **Music:** `music: { style: calm }`, soft pads with no drums;
607
+ `music: false` for none.
608
+ - **Subtitles:** `subtitles: true` writes an .srt; `burn` puts them on the
609
+ video, the better choice for social video and Chinese. Burned-in words take
610
+ their colour from `ground:` (see "The look follows the subject"), so set it.
611
+ - **Ground:** `ground: light | dark`, chosen for the subject and the audience,
612
+ not inherited from the last film.
613
+ - **Chapters:** come from the scenes' `title`s.
614
+ - **Cards:** an `intro` and an `end_card` work as in lessons.
615
+
616
+ Long explainers (more than about 6 scenes) build fastest if each scene is
617
+ written by its own sub-agent, all from the same lines, cues, scene contract and
618
+ scene kit — and from the same written description of the look, or they will
619
+ each invent one. Settle anything they disagree about (a colour that fails
620
+ contrast, an object drawn two ways) in the kit, once, not scene by scene.
621
+ Review every scene's stills yourself afterwards.
622
+
368
623
  ## 4. Render, look, fix
369
624
 
370
625
  1. `render_video` (use `draft: true` while iterating if the voice is
@@ -407,6 +662,9 @@ Notes:
407
662
  - Draft looks right: `compose` with `draft: false` for the final voice.
408
663
  Only lines not yet synthesized cost credits. `draft` only matters with
409
664
  a paid voice; with `kokoro`, it is the same voice either way.
665
+ - Changed only scenes (their files, lines or assets): `compose`; only the
666
+ scenes that changed render again. An explainer has nothing to record,
667
+ so `render` and `compose` do the same.
410
668
 
411
669
  ## 5. Deliver
412
670
 
package/src/audio.mjs CHANGED
@@ -42,6 +42,31 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
42
42
  '-f', 'f32le', '-ac', '2', '-ar', `${SR}`, 'pipe:1'], { maxBuffer: 1 << 30 });
43
43
  const f = new Float32Array(pcm.buffer, pcm.byteOffset, pcm.length / 4);
44
44
  for (let i = 0; i < Math.min(N, f.length / 2); i++) { music[0][i] = f[2 * i] * 0.5; music[1][i] = f[2 * i + 1] * 0.5; }
45
+ } else if (cfg.music?.style === 'calm') {
46
+ // calm (explainers): slow pad chords vi–IV–I–V in C, two bars each, a soft bass under them and a
47
+ // sparse bell; no drums, nothing on the beat that would pull at the voice
48
+ const BEAT = 60 / cfg.music.bpm, LEN = BEAT * 8;
49
+ const chords = [[57, 60, 64], [53, 57, 60], [48, 52, 55], [55, 59, 62]];
50
+ for (let b = 0, t0 = 0; t0 < events.total; b++, t0 = b * LEN) {
51
+ const ch = chords[b % chords.length], len = Math.min(LEN, events.total - t0) + 1.2; // rings into the next chord
52
+ for (const [k, note] of [...ch, ch[0] + 12].entries()) {
53
+ for (const det of [-0.06, 0.06]) {
54
+ const f = hz(note + det), filt = svf();
55
+ add(music, t0, len, (t) => {
56
+ const env = Math.min(1, t / 1.4) * Math.min(1, Math.max(0, len - t) / 1.6);
57
+ return filt(saw(f * t) * 0.6 + Math.sin(2 * Math.PI * f * t) * 0.4, 650 + 250 * Math.sin(t * 0.4 + k)).lp * env;
58
+ }, 0.024, (k - 1.5) * 0.35 + det * 2);
59
+ }
60
+ }
61
+ const fb = hz(ch[0] - 12);
62
+ add(music, t0, len, (t) => Math.sin(2 * Math.PI * fb * t) * Math.min(1, t / 0.6) * Math.min(1, Math.max(0, len - t) / 1.2), 0.09);
63
+ if (b % 2 === 0) for (const [j, note] of [ch[2] + 12, ch[1] + 24].entries()) {
64
+ const f = hz(note);
65
+ add(music, t0 + j * BEAT * 2, 2.5, (t) => (Math.sin(2 * Math.PI * f * t) + 0.15 * Math.sin(4 * Math.PI * f * t)) * Math.exp(-t * 1.8) * Math.min(1, t / 0.01), 0.03, j ? 0.3 : -0.3);
66
+ }
67
+ }
68
+ // fade in over 2 s, out over the last 3 s
69
+ for (const c of music) for (let i = 0; i < N; i++) c[i] *= Math.min(1, i / SR / 2) * Math.min(1, (N - i) / SR / 3);
45
70
  } else if (cfg.music) {
46
71
  // I–V–vi–IV in C at `bpm`, resolving on the end card (or fading at the end)
47
72
  const BEAT = 60 / cfg.music.bpm, BAR = BEAT * 4;
@@ -134,7 +159,7 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
134
159
  if (cfg.voice && events.voice?.length) {
135
160
  let prevEnd = 0;
136
161
  for (const line of events.voice) {
137
- let { pcm, parts } = await speak(cfg, line.text, { log });
162
+ let { pcm, parts } = await speak(cfg, line.text, { log, chunk: !!line.chunk });
138
163
  let dur = pcm.length / 2 / SR;
139
164
  const start = Math.max(line.start, prevEnd + 0.12), room = line.until - start - 0.1;
140
165
  let sped = 1;
@@ -198,7 +223,7 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
198
223
  // so re-encoding by video sites doesn't clip
199
224
  execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', silentVideo, '-i', wavFile,
200
225
  '-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]);
201
- log(`soundtrack: ${cfg.music ? (cfg.music.file ? 'music file' : `synth ${cfg.music.bpm} bpm`) : 'no music'}, ${events.clicks.length} clicks, ${cfg.sfx?.transitions ? events.whooshes.length : 0} whooshes` +
226
+ log(`soundtrack: ${cfg.music ? (cfg.music.file ? 'music file' : `synth${cfg.music.style ? ` ${cfg.music.style}` : ''} ${cfg.music.bpm} bpm`) : 'no music'}, ${events.clicks.length} clicks, ${cfg.sfx?.transitions ? events.whooshes.length : 0} whooshes` +
202
227
  (cfg.voice ? `, ${events.voice.length} narration lines (${cfg.voice.provider}/${cfg.voice.voice})` : ''));
203
228
  return { out: cfg.out, warnings, narration };
204
229
  }
package/src/build.mjs CHANGED
@@ -1,4 +1,7 @@
1
1
  // record → compose → soundtrack, shared by the CLI and the MCP server.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
2
5
  import { record } from './record.mjs';
3
6
  import { compose } from './compose.mjs';
4
7
  import { soundtrack } from './audio.mjs';
@@ -7,7 +10,9 @@ import { writeOutputs, burnSubtitles } from './outputs.mjs';
7
10
 
8
11
  export async function build(cfg, mode = 'render', { log = console.log } = {}) {
9
12
  const t0 = Date.now();
10
- if (mode !== 'compose') await record(cfg, { log });
13
+ // a storyboard of scenes only (an explainer) has no web app to record: an empty recording stands in
14
+ if (!cfg.url) noRecording(cfg);
15
+ else if (mode !== 'compose') await record(cfg, { log });
11
16
  if (mode === 'record') return { warnings: [] };
12
17
  const voiceLens = await measureVoice(cfg, { log });
13
18
  const { silent, clean, events, preview, warnings, captions, cards, segs } = await compose(cfg, { log, voiceLens });
@@ -16,3 +21,12 @@ export async function build(cfg, mode = 'render', { log = console.log } = {}) {
16
21
  const extras = await writeOutputs(cfg, { captions, cards, segs, clean, narration: audio.narration, total: events.total }, { log });
17
22
  return { out: audio.out, extras, preview, warnings: [...warnings, ...audio.warnings], duration: events.total, seconds: Math.round((Date.now() - t0) / 1000) };
18
23
  }
24
+
25
+ // The recording of a storyboard with nothing to record: one black frame, every step empty.
26
+ export function noRecording(cfg) {
27
+ fs.mkdirSync(path.join(cfg.work, 'raw'), { recursive: true });
28
+ const frame = path.join(cfg.work, 'raw', 'blank.jpg');
29
+ if (!fs.existsSync(frame)) execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'lavfi', '-i', `color=c=black:s=${cfg.output.width}x${cfg.output.height}`, '-frames:v', '1', frame]);
30
+ const steps = cfg.steps.map((s) => ({ start: 0, end: 0, hidden: !!s.hidden, zoomBox: null, zoomTarget: null, waits: [], holds: 0, marks: [] }));
31
+ fs.writeFileSync(path.join(cfg.work, 'timeline.json'), JSON.stringify({ frames: [{ t: 0, file: 'blank.jpg' }], steps, clicks: [], navs: [], gotos: [], urls: [], app: null, introLogo: null, scale: cfg.scale }, null, 1));
32
+ }