storyshot 0.7.3 → 0.9.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,62 @@ 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: true # an .srt beside the video; `burn` puts the words on the picture (social, muted autoplay)
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
+ `scenes: { three: true }` adds three.js and storyshot's `Kit3` helpers, for a
519
+ scene whose subject is an **object** rather than a quantity: a solid is shaded
520
+ flat by which way it faces and its edges are found in a second normal/depth
521
+ pass, so it reads as a drawn figure rather than a render, and
522
+ `eng.part(…, { explode })` + `eng.explode(k)` give an exploded view from one
523
+ number. It is for the few scenes that need it — a list, a curve or a count is
524
+ better and sharper in HTML — and it suits a light ground, because the ink edge
525
+ is a drawn line on paper and a rim light on black. See "3D scenes, used
526
+ sparingly" in `storyshot guide`.
527
+
528
+ Every scene of a film shares one look, and the look belongs to the subject and
529
+ the audience: a dark instrument panel suits a mechanism explained to specialists,
530
+ a warm paper poster suits civics explained to nine-year-olds. Say which ground
531
+ you chose with `ground: light | dark`, and the burned-in subtitles and the cards
532
+ follow it. The playbook's "The look follows the subject" has the styles and what
533
+ each ground costs.
534
+
535
+ `lead` is how long the scene plays before the voice starts (default 0.5 s), and
536
+ `tail` is how long it holds after the voice ends (default 0.8 s). Consecutive
537
+ scenes cut into each other. Scenes are cached like cards, so `compose`
538
+ re-renders only the ones that changed. `storyshot check` validates the
539
+ contract, and `storyshot scene` runs the full HyperFrames lint and layout check
540
+ on one scene. The playbook's "Scenes and explainers" says how to write scenes
541
+ that explain. `music: { style: calm }` (the explainer default) is a soft pad
542
+ with no drums.
543
+
478
544
  ### HyperFrames cards
479
545
 
480
546
  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,7 +1,7 @@
1
1
  {
2
2
  "name": "storyshot",
3
- "version": "0.7.3",
4
- "description": "Turn a short YAML storyboard into a polished product demo video of your web app — smooth cursor, auto zoom, captions, music.",
3
+ "version": "0.9.0",
4
+ "description": "Ask your coding agent for a video of your web app: promo clips, narrated tutorials, lessons and docs GIFs from a YAML storyboard.",
5
5
  "author": "boheling <boheling@gmail.com>",
6
6
  "repository": {
7
7
  "type": "git",
@@ -26,10 +26,25 @@
26
26
  },
27
27
  "keywords": [
28
28
  "demo",
29
+ "demo-video",
30
+ "product-demo",
31
+ "promo-video",
29
32
  "video",
30
- "playwright",
31
33
  "screen-recording",
32
- "product-demo"
34
+ "screencast",
35
+ "tutorial",
36
+ "gif",
37
+ "storyboard",
38
+ "playwright",
39
+ "ffmpeg",
40
+ "mcp",
41
+ "mcp-server",
42
+ "claude-code",
43
+ "cursor",
44
+ "codex",
45
+ "coding-agent",
46
+ "ai-agent",
47
+ "agent-skill"
33
48
  ],
34
49
  "license": "MIT",
35
50
  "dependencies": {
@@ -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, then a closing scene after the recap ("How an explainer ends"); no web app | yes | calm | your scenes in a look chosen for the subject and the audience, an .srt beside the video (burned in only for social or muted autoplay), 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,
@@ -270,6 +287,10 @@ common ones):
270
287
  screen (its bullets light up as you name them; a formula goes in `emblem`).
271
288
  5. A check: a question card, then the step that reveals the answer.
272
289
  6. The recap, in the same words as before.
290
+ 7. A last word, which the recap is not: what the viewer can do now that they
291
+ could not do at the start, and the one thing to keep. A film that stops on
292
+ its recap sounds cut off, whatever its type — see "How an explainer ends"
293
+ below, which is where that is spelled out.
273
294
 
274
295
  Every step answers the question the step before it raised. Link them out loud:
275
296
  "Now that b has moved the line up, let's see what m does." When the camera
@@ -365,6 +386,335 @@ Notes:
365
386
  - `.storyshot/` next to the YAML is a cache of recordings, screenshots and
366
387
  voice lines. Add it to `.gitignore`.
367
388
 
389
+ ### Scenes and explainers
390
+
391
+ A **scene** is a step whose picture you write instead of recording: an
392
+ animation that shows an idea the app can't, such as how a probe lights up, why an
393
+ algorithm scores a match, or what a number means. It is a HyperFrames
394
+ composition, an HTML file with a GSAP timeline, and it plays as long as the
395
+ step's `say:` line needs. Use it two ways:
396
+
397
+ - **`purpose: explainer`**: the whole video is scenes, with no `url` and nothing
398
+ recorded. Use it for "explain how X works" when there is no web app to show.
399
+ - **Inside a tutorial or lesson**: a `scene:` step between recorded steps
400
+ explains the why ("what does this score mean?"), then the video goes back to
401
+ the app. Keep these to 10–30 s.
402
+
403
+ ```yaml
404
+ - scene: scenes/fret.html # or { file: …, title: Chapter title, lead: 0.5, tail: 0.8 }
405
+ say: The quencher sits right next to the dye. Its energy is passed across before it can become light.
406
+ ```
407
+
408
+ `lead` is how long the scene plays before the voice starts (default 0.5 s).
409
+ `tail` is how long it holds after the voice ends (default 0.8 s). A scene
410
+ with no `say:` needs a `duration`. A scene has no `actions`, `caption` or
411
+ `zoom`, because it draws its own picture and text.
412
+
413
+ **Workflow.** Write the words first, then the pictures:
414
+ 1. **Facts.** Every claim, number and sequence on screen comes from a source
415
+ you checked: a paper, the official docs, a database. Put the source on
416
+ screen in small type where its number appears, and list the sources in your
417
+ reply. Mark drawings that are schematic ("示意", "illustrative"). A
418
+ beautiful wrong diagram is worse than none. (A credit line may be smaller
419
+ than the 28 px text floor: put `data-layout-ignore` on it.)
420
+ 2. **Story.** Start from something the viewer knows: a real case, their own
421
+ question, one concrete example that runs through the whole video (one
422
+ gene, one request, one dataset). Then one question per scene, answered by
423
+ one picture. Follow "Writing the narration" above: short spoken sentences;
424
+ question → concrete case → name the idea → why → recap → **a closing scene**
425
+ ("How an explainer ends" below). The arc does not end on the recap: plan the
426
+ closing scene here, with the others, not after the first render.
427
+ 3. **Lines.** Write the `say:` lines: one idea per scene, usually 2–5
428
+ sentences (about 60 English words or 120 Chinese characters at most).
429
+ Commas are cue points: each phrase can trigger one change on screen.
430
+ 4. **Cues.** Run `storyshot cues` (MCP `scene_cues`). It synthesizes the voice
431
+ and prints each scene's length and when each phrase is spoken. These are
432
+ the times `S.at(k)` returns inside the scene.
433
+ 5. **Look.** Choose the film's style from its subject and its audience, write
434
+ it down (ground, palette, type, how objects are drawn), and put it in the
435
+ scene kit before any scene is written. "The look follows the subject" below.
436
+ 6. **Scenes.** Write each scene file (contract below). Then preview it with
437
+ `storyshot scene … --step N` (MCP `preview_scene`), which gives stills a beat
438
+ into each phrase, HyperFrames lint, and a layout check. Look at every still,
439
+ fix the scene, and preview again until it is right.
440
+ 7. **Render**, look at `preview.jpg` (one frame per scene), and fix. After
441
+ editing only scenes or lines, `compose` re-renders just the scenes that
442
+ changed.
443
+
444
+ **How an explainer ends.** A recap is not an ending. A recap re-counts what the
445
+ film already said and then runs out, and a viewer hears that as the film being
446
+ cut off — "像没有结局一样,很突兀". Storyshot resolves the music and settles the
447
+ picture, but a full stop is something only the script has: a conclusion, what is
448
+ still open, and one thought to leave with. **Write the last scene as a scene of
449
+ its own, after the recap**, and give it the four or five sentences the rest of
450
+ the film earned:
451
+
452
+ - **What this actually means.** Not a summary — the thing that is true now that
453
+ the viewer has seen all of it. Often it is the one sentence you could not have
454
+ said in scene 1: *the lesson is not "keep going", it is that a guess got
455
+ treated as a proof while an old algorithm waited for hardware.*
456
+ - **What is still open.** Name it plainly, with the same honesty as the body. A
457
+ film that admits its limit ends; a film that trails off into a caveat stops.
458
+ - **One thought to leave with**, in the film's own images, not a new one. Land
459
+ the final words on it. *The brake was all you had; now you can also hand over
460
+ the picture of who to chase.*
461
+
462
+ Then the picture follows the words:
463
+
464
+ - **The closing frame shows less than the one before it.** Let the recap build,
465
+ then take it away: clear everything except the one object, number or sentence
466
+ the film was about. A viewer leaves holding one thing, so choose which.
467
+ - **Keep the last 2 s alive.** Land the closing move *on* the final words, and
468
+ prefer a move that is still running when the file ends — a line still being
469
+ drawn, a curtain still closing — over one that finished and is being held.
470
+ - Three or more seconds of a motionless frame is the defect the layout check
471
+ calls `dead_stretch`; it does not see the `tail`, so check that yourself, and
472
+ `preview_scene` reports `frozen_ending` when the last scene is already frozen.
473
+ - A recap that now has a closing scene after it should stop holding a long
474
+ `tail` for an ending it no longer has: shorten it and let the recap dissolve
475
+ as its last words land, so it reads as a page turn.
476
+ - An `end_card` is an alternative ending, not an extra one: with a card the last
477
+ scene hands over to it, and storyshot leaves the picture to the card.
478
+
479
+ One practical trap when you time that scene: **the voice breaks cues on commas,
480
+ not just full stops**, so "一个线,一道帘子,一张纸" is three cues, not one. Run
481
+ `cues` (or `preview_scene`) and key the beats to the list it prints, never to
482
+ the sentences you counted in the `say:` line.
483
+
484
+ **3D scenes, used sparingly.** `scenes: { three: true }` loads three.js and
485
+ storyshot's own `Kit3` into every scene of the film. Kit3 does not light a
486
+ scene, it *draws* one: flat tone chosen by which way a face points, ink edges
487
+ found in a second normal/depth pass, and a long lens (fov 18) so parallel edges
488
+ stay parallel. The result reads as a figure in a textbook, not as a render.
489
+
490
+ **Most scenes should not use it.** Reach for 3D only when the form itself
491
+ carries the meaning:
492
+
493
+ - it is a **real object** the viewer could hold or stand next to (a culture
494
+ flask, an instrument, a ballot box);
495
+ - the point is **how the parts fit together**, so an exploded view *is* the
496
+ answer (`eng.part(parent, { at, explode })`, then `eng.explode(k)`);
497
+ - the point is **spatial**: something is inside, under or behind something
498
+ else, or how densely a volume is filled is the fact;
499
+ - the idea needs **two angles** before it lands.
500
+
501
+ Leave everything else in 2D — it is sharper, smaller, faster to write, and the
502
+ type stays type. A quantity, a sequence, a list, a curve, a year, a name, and
503
+ any object that is really a figure of speech (a "barcode", a "reading frame", a
504
+ brake) all belong in HTML and SVG. Extruding a bar chart into boxes adds a third
505
+ dimension with no information in it. Keep 3D to roughly **a third of a film's
506
+ scenes at most**, on the one object the film keeps coming back to; a film where
507
+ every scene is a rendered object stops reading as an explanation and starts
508
+ reading as a product demo.
509
+
510
+ **Prefer a light ground for it.** The edge pass finds a silhouette and draws it
511
+ *dark*, which on paper is a drawn line and on a dark ground has to become a pale
512
+ rim — that is a light, which is the one thing the technique exists to avoid. On
513
+ a dark film, translucency also stops working: glass reads as a hole. Pick the
514
+ film's ground for its subject as always, but if the film will carry 3D, that is
515
+ an argument for paper. And remember a ground change is a **palette** change: a
516
+ signal colour chosen to glow on blue-black usually fails contrast on paper (a
517
+ `#ff5c8a` that sang on `#0f1218` reads at 2.6:1 on `#f4f1ea`).
518
+
519
+ Keep the words in HTML over the canvas, not in the 3D scene: text drawn in WebGL
520
+ is soft, cannot be checked for contrast or the 28 px floor, and does not pick up
521
+ the film's fonts. The scene renders from the timeline — `eng.render()` inside a
522
+ GSAP `onUpdate`, never a frame loop — and uses `Kit3.rng(seed)` where it needs
523
+ scatter, because frames are rendered by seeking and `Math.random` would give a
524
+ different picture every render. `storyshot guide` prints Kit3's full API in
525
+ `src/scene-kit/kit3d.js`; a scene that calls `Kit3` or `THREE` without
526
+ `scenes: { three: true }` is a `check` error, because it would otherwise render
527
+ an empty frame.
528
+
529
+ **The scene file contract** (`storyshot check` and `preview_scene` enforce it):
530
+
531
+ ```html
532
+ <template>
533
+ <style>
534
+ #fret { position: absolute; inset: 0; overflow: hidden; background: #121110; font-family: var(--font); }
535
+ #fret-dye { position: absolute; left: 600px; top: 420px; width: 120px; height: 120px; border-radius: 50%; background: #f4a62a; }
536
+ </style>
537
+ <div id="fret" data-composition-id="fret" data-width="1920" data-height="1080">
538
+ <div id="fret-dye"></div>
539
+ </div>
540
+ <script>
541
+ (function () {
542
+ const S = window.__storyshot; // this scene's timing, size and theme
543
+ const tl = gsap.timeline({ paused: true });
544
+ tl.fromTo('#fret-dye', { scale: 0 }, { scale: 1, duration: 0.6, ease: 'expo.out' }, S.at(0));
545
+ tl.fromTo('#fret-dye', { opacity: 1 }, { opacity: 0.25, duration: 0.4 }, S.at('passed across'));
546
+ window.__timelines['fret'] = tl;
547
+ })();
548
+ </script>
549
+ </template>
550
+ ```
551
+
552
+ - One `<template>` holds the styles, markup and script. The root element's
553
+ `id` equals its `data-composition-id`, is unique among the scenes, and
554
+ prefixes every other id in the file. `data-width`/`data-height` match the
555
+ video (1920×1080, or 1080×1920 for portrait).
556
+ - One paused GSAP timeline, registered as `window.__timelines['<id>']`. Use
557
+ `fromTo` (not `from`). No `Date`, timers, `requestAnimationFrame`,
558
+ `repeat: -1`, or unseeded `Math.random`: frames are rendered by seeking the
559
+ timeline.
560
+ - Media go in `assets/` next to the scene file, referenced as `assets/x.png`.
561
+ - **A look shared by every scene** (a palette, fonts, a helper that draws
562
+ your recurring objects): put it in files and list them once at the top of
563
+ the storyboard, `scenes: { css: scenes/kit/kit.css, js: scenes/kit/kit.js }`.
564
+ They load before each scene's own script, so a scene can call your helpers
565
+ and use your CSS variables.
566
+ - **`window.__storyshot`** (`S`) gives you:
567
+ - `S.at(k, offset)`: when phrase `k` starts. `k` counts from 0, `-1` is the
568
+ last phrase, and a string means the phrase that contains it.
569
+ - `S.end(k)`: when phrase `k` ends.
570
+ - `S.cues`: `[{ text, t, end }]` for every phrase.
571
+ - `S.duration`, `S.width`, `S.height`, `S.lead`.
572
+ - `S.safeBottom`: the pixels at the bottom that burned-in subtitles take
573
+ (16% of the height, 22% in portrait, 0 without `subtitles: burn`); keep
574
+ content above it. The same value is available as the CSS variable
575
+ `var(--safe-bottom)`. `preview_scene` draws this band on its stills as a
576
+ striped red strip.
577
+ - `S.theme`: `{ accent, ink, font }`, also available as `var(--accent)`,
578
+ `var(--ink)` and `var(--font)`. The font falls back to the machine's CJK
579
+ font for Chinese.
580
+
581
+ Time every reveal from `S.at`, never from hard-coded seconds, so the
582
+ animation stays on the words when a line changes.
583
+
584
+ **The look follows the subject and the audience.** There is no house style, and
585
+ the last explainer's look is not a default. A film for eight-year-olds about
586
+ voting and a film for biologists about pooled screens should not look alike:
587
+ choose the style, say so in the plan, and put it in the scene kit
588
+ (`scenes: { css: …, js: … }`) before the first scene is written, so ten scenes
589
+ can't disagree about it.
590
+
591
+ Two questions decide it. **Who is watching?** Children and general audiences
592
+ need big friendly drawings, few colours and a light ground; specialists read
593
+ dense instrument-like pictures and are at home on a dark panel. **What is the
594
+ subject made of?** A thing that glows, fluoresces, counts or signals wants a
595
+ dark ground to light up on; a thing that is printed, taught, filled in or voted
596
+ on wants paper. Starting points, not a menu to pick blindly:
597
+
598
+ | subject, and who it is for | the look | made of |
599
+ |---|---|---|
600
+ | 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 |
601
+ | 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 |
602
+ | 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 |
603
+ | a machine, a building, a physical process | blueprint | deep blue or sepia ground, a faint grid, dimension and leader lines, small-caps part labels |
604
+ | a person, a place, a historical event | storybook | a tinted ground that is neither black nor white, illustrated figures, soft shapes, one warm accent |
605
+
606
+ **Then tell Storyshot which ground you chose**: `ground: light` or
607
+ `ground: dark` in the storyboard. Burned-in subtitles and the intro/section/end
608
+ cards follow it — ink letters with a white outline on a light film, white
609
+ letters with an ink outline on a dark one — so the words read without a bar
610
+ drawn under them. Left out, `auto` reads the ground from the scenes — their own
611
+ background, or the kit rule for a class on their root (`<div id="x"
612
+ class="world">`). When it is a variable or an image, nothing can be read:
613
+ `check` warns, and the subtitles stay white until you say which the film is.
614
+
615
+ **What a ground costs.** The choice is not only a colour; it changes what works:
616
+ - **On a light ground**, a glow is a smudge: make it a flat tinted panel, a ring
617
+ or nothing. Keep shadows at `rgba(<ink>, .12)` or less, or they look dirty. A
618
+ cream card that floated on black becomes a *white* sheet, which needs its own
619
+ shadow and some real structure inside (rules, a divider) or it reads as a
620
+ blank rectangle. A saturated accent that sang on black usually fails the
621
+ contrast check as text on paper (amber `#f4a62a` on white is about 2:1):
622
+ darken it for text, keep the bright one for fills, or give the words a thin
623
+ ink outline — and settle that in the kit, once.
624
+ - **On a dark ground**, thin dark lines and dark figures vanish: every object
625
+ needs a light value of its own, and faint white strokes (`rgba(255,255,255,.06)`)
626
+ that carry structure there have no quiet equivalent on paper.
627
+ - **Either way**, a colour used as a *fill* can be bright, while the same colour
628
+ as *text* has to pass the contrast check (the layout check measures it, 3:1
629
+ for large text and 4.5:1 for normal). Watch a colour sitting inside a panel
630
+ of its own hue (teal dots on a teal wing): it measures against the panel, not
631
+ against the ground, and that is where contrast is usually lost.
632
+
633
+ **Repaint before the scenes exist, not after.** Converting a finished ten-scene
634
+ film to the other ground is an hour or more of work with nothing new to show
635
+ for it: the colour swap is mechanical, but every glow, shadow, faint stroke and
636
+ floating card needs a judgement, and the contrast findings all change. It is
637
+ much cheaper to show one previewed scene in the chosen style and ask.
638
+
639
+ **Pictures that explain** (what the preview stills should show):
640
+ - **The object is the explanation.** Draw the actual thing, such as the
641
+ molecule, the queue or the circuit, big enough to be the hero: at least 40%
642
+ of the frame's width or height. Use the real data where it exists: a real
643
+ sequence, real numbers. Never show a small icon floating in an empty frame:
644
+ if a still is mostly empty ground, scale the diagram up until it fills the
645
+ frame.
646
+ - **One diagram that grows.** Start from the simplest true picture and add one
647
+ part per phrase, each when the voice names it, with its label on it (a
648
+ leader line, not a legend). Never put the finished diagram on screen at
649
+ once.
650
+ - **Motion.** One hero move per beat, and something new every 2–4 s.
651
+ Arrivals ease out (`expo.out`, 0.5–0.7 s) and exits are shorter. Text lands
652
+ and then stays still while it is read.
653
+ - **Text.** At least 28 px at 1080p, and nothing under `S.safeBottom`. Never
654
+ repeat the narration word for word on screen (the subtitles do that).
655
+ Avoid bullet slides and rows of three cards.
656
+ - **Colour.** Rationed: one accent, plus one colour per thing the story
657
+ tracks (for example, allele G amber and allele A green everywhere: dye,
658
+ dot, label).
659
+ - **Seams.** Consecutive scenes cut straight into each other. End a scene on
660
+ a state the next one starts from (the same object in the same place), or
661
+ on a clean hold. The video's first frame is its thumbnail: show the subject
662
+ there, never an empty ground.
663
+ - **Look before you move on.** Read `preview_scene`'s stills like a stranger
664
+ would. Is every object recognisable without explanation, does each part
665
+ appear when it is named, and does nothing overlap, crop or sit under the
666
+ subtitles? Fix every ✖ from lint and the layout check.
667
+
668
+ **Gotchas** (each one cost a real run several previews):
669
+ - **Several tweens on one property.** Several `fromTo`s on the same property
670
+ of one element (fade a label in, later fade it out) are fine: Storyshot
671
+ gives every later one `immediateRender: false`, so it does not show its
672
+ start state from the first frame. With `to()` or `from()`, or a position
673
+ that is not a number, set `immediateRender: false` yourself.
674
+ - **SVG sizes.** Animate them with `attr: { width: 300 }`. A CSS transform
675
+ on an SVG `<rect>` (`scaleX` with `transform-origin`) can render nothing.
676
+ - **Lines that draw themselves.** Give the path `pathLength="1"`,
677
+ `stroke-dasharray: 1`, and tween `strokeDashoffset` from 1 to 0 with
678
+ `autoRound: false`. Without it, GSAP rounds the offset to whole pixels and
679
+ the line pops in instead of drawing.
680
+ - **Layout check findings that are intended.**
681
+ - A glow that spills out of its box: `data-layout-allow-overflow` on the box.
682
+ - A label sitting on its object: `data-layout-allow-overlap` on the container.
683
+ - Small credits or texture: `data-layout-ignore`.
684
+ - **"The film's last line needs 2 s".** Only the video's last scene must hold
685
+ its final words. Elsewhere, reveal text on an earlier phrase or raise
686
+ `tail:`.
687
+ - **The ground under the cards.** Intro and end cards follow the film's ground,
688
+ so they come out dark when the scenes do; `ground: light | dark` settles the
689
+ whole film and `cards: light | dark` overrides the cards alone. Keep
690
+ end-card bullets short: about 10 Chinese characters or 5 English words each.
691
+
692
+ Long narration is fine: a scene's `say:` is spoken in chunks of whole
693
+ sentences, and in an explainer no pause inside a line lasts longer than
694
+ `voice.maxPause` (0.55 s by default), so the voice keeps moving.
695
+
696
+ Explainer defaults:
697
+ - **Music:** `music: { style: calm }`, soft pads with no drums;
698
+ `music: false` for none.
699
+ - **Subtitles:** `subtitles: true` writes an .srt; `burn` puts them on the
700
+ video. `true` is the default and the right one for an explainer watched on a
701
+ screen: the scenes already carry text, and a burned-in line lighting up word
702
+ by word at the bottom pulls the eye off the drawing for the whole film. Burn
703
+ them for social video, for a feed that autoplays muted, or when asked — and
704
+ then say so, because it is a trade, not a free addition. Burned-in words take
705
+ their colour from `ground:` (see "The look follows the subject"), so set it.
706
+ - **Ground:** `ground: light | dark`, chosen for the subject and the audience,
707
+ not inherited from the last film.
708
+ - **Chapters:** come from the scenes' `title`s.
709
+ - **Cards:** an `intro` and an `end_card` work as in lessons.
710
+
711
+ Long explainers (more than about 6 scenes) build fastest if each scene is
712
+ written by its own sub-agent, all from the same lines, cues, scene contract and
713
+ scene kit — and from the same written description of the look, or they will
714
+ each invent one. Settle anything they disagree about (a colour that fails
715
+ contrast, an object drawn two ways) in the kit, once, not scene by scene.
716
+ Review every scene's stills yourself afterwards.
717
+
368
718
  ## 4. Render, look, fix
369
719
 
370
720
  1. `render_video` (use `draft: true` while iterating if the voice is
@@ -407,6 +757,9 @@ Notes:
407
757
  - Draft looks right: `compose` with `draft: false` for the final voice.
408
758
  Only lines not yet synthesized cost credits. `draft` only matters with
409
759
  a paid voice; with `kokoro`, it is the same voice either way.
760
+ - Changed only scenes (their files, lines or assets): `compose`; only the
761
+ scenes that changed render again. An explainer has nothing to record,
762
+ so `render` and `compose` do the same.
410
763
 
411
764
  ## 5. Deliver
412
765