storyshot 0.7.2 → 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 +65 -3
- package/bin/storyshot.mjs +27 -2
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +261 -3
- package/src/audio.mjs +27 -2
- package/src/build.mjs +15 -1
- package/src/check.mjs +42 -5
- package/src/compose.mjs +34 -10
- package/src/config.mjs +34 -4
- package/src/hyperframes.mjs +63 -17
- package/src/init.mjs +70 -0
- package/src/mcp.mjs +43 -5
- package/src/outputs.mjs +22 -12
- package/src/record.mjs +1 -0
- package/src/scene-tools.mjs +123 -0
- package/src/scenes.mjs +234 -0
- package/src/voice.mjs +114 -5
package/README.md
CHANGED
|
@@ -15,6 +15,12 @@ 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
|
+
https://github.com/user-attachments/assets/05dd9a73-2edc-46af-9b2b-a8e33c7005b4
|
|
19
|
+
|
|
20
|
+
A 68-second pitch for Storyshot, made with [HyperFrames](https://hyperframes.heygen.com) and a free
|
|
21
|
+
local voice (Kokoro). The request, storyboard, recording, contact sheet and render log in it, and the
|
|
22
|
+
16 videos on the wall, are real Storyshot output.
|
|
23
|
+
|
|
18
24
|
## Gallery
|
|
19
25
|
|
|
20
26
|
**[boheling.github.io/storyshot-gallery](https://boheling.github.io/storyshot-gallery/)**: promos,
|
|
@@ -137,8 +143,12 @@ After your next release, re-render it and you have an up-to-date video.
|
|
|
137
143
|
|---|---|---|
|
|
138
144
|
| a promo / launch / landing-page clip | `pitch` | short and tightly cut: intro card, the app in a browser frame, music, captions |
|
|
139
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 |
|
|
140
|
-
| a lesson
|
|
146
|
+
| a lesson | `course` | explains the *why*: concept cards, the idea shown in the app, question cards, a recap, no music; `.srt` + chapters |
|
|
141
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.
|
|
142
152
|
|
|
143
153
|
Length follows the purpose and the content, not a target: a promo is as short as
|
|
144
154
|
its message, a tutorial as long as the workflow takes to explain.
|
|
@@ -193,6 +203,8 @@ from `storyshot guide`.
|
|
|
193
203
|
| `init_storyboard` | Writes a starter storyboard with verified targets. |
|
|
194
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. |
|
|
195
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. |
|
|
196
208
|
|
|
197
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
|
|
198
210
|
checkout, `setup` points to `node /path/to/checkout/bin/storyshot.mjs mcp` instead.
|
|
@@ -216,6 +228,9 @@ Everything the agent does is also a command:
|
|
|
216
228
|
| `storyshot render videos/acme-promo.yaml [--draft]` | Record, then compose the video and soundtrack. |
|
|
217
229
|
| `storyshot compose videos/acme-promo.yaml [--draft]` | Rebuild from the last recording, in seconds. |
|
|
218
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. |
|
|
219
234
|
| `storyshot guide` | Print the playbook and the reference. |
|
|
220
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. |
|
|
221
236
|
| `storyshot doctor` | Check Node, ffmpeg (and the filters and encoders Storyshot uses), Chromium and voices; install Chromium if it's missing. |
|
|
@@ -257,12 +272,13 @@ captions: true # false keeps caption pills off
|
|
|
257
272
|
purpose: pitch # pitch | tutorial | course | docs (sets the defaults below)
|
|
258
273
|
highlight: false # spotlight targets before click/type (per step or per action too)
|
|
259
274
|
stepNumbers: false # numbered caption badges
|
|
260
|
-
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); uses ffmpeg's libass when it has it, else draws the words itself
|
|
275
|
+
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; in a vertical video the lines sit above the bottom fifth, which short-video apps cover, and the cards keep above them); uses ffmpeg's libass when it has it, else draws the words itself
|
|
261
276
|
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
|
|
262
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 }
|
|
263
278
|
article: { title: Export a drawing, intro: … } # docs: heading and first paragraph of <out>-steps/README.md
|
|
264
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
|
|
265
|
-
cards: auto # intro/section/end cards: auto follows the
|
|
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
|
|
266
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 }
|
|
267
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
|
|
268
284
|
chapters: false # write <out>.chapters.txt for YouTube
|
|
@@ -469,6 +485,52 @@ usually shouldn't.
|
|
|
469
485
|
| `say` | Voiceover line played over the end card. |
|
|
470
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. |
|
|
471
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
|
+
|
|
472
534
|
### HyperFrames cards
|
|
473
535
|
|
|
474
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
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|