storyshot 0.8.0 → 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
@@ -501,7 +501,7 @@ the scene file.
501
501
  ```yaml
502
502
  purpose: explainer # scenes only: no url, nothing recorded
503
503
  voice: { provider: qwen, voice: dylan }
504
- subtitles: burn
504
+ subtitles: true # an .srt beside the video; `burn` puts the words on the picture (social, muted autoplay)
505
505
  steps:
506
506
  - scene: scenes/question.html # or { file, title, lead: 0.5, tail: 0.8 }
507
507
  say: An intact probe carries a dye. Why doesn't it glow?
@@ -515,6 +515,16 @@ scene. A scene's narration may be a whole paragraph: it is spoken in chunks of
515
515
  whole sentences, and `voice.maxPause` caps the pauses inside a line (0.55 s in
516
516
  an explainer).
517
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
+
518
528
  Every scene of a film shares one look, and the look belongs to the subject and
519
529
  the audience: a dark instrument panel suits a mechanism explained to specialists,
520
530
  a warm paper poster suits civics explained to nine-year-olds. Say which ground
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "storyshot",
3
- "version": "0.8.0",
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": {
@@ -93,7 +93,7 @@ or stands in for a recording of them.
93
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 |
94
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 |
95
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 |
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
97
 
98
98
  An explainer has no site to look at, so the plan carries one more line: the
99
99
  **look** — who the viewer is, and the style that suits them and the subject
@@ -287,6 +287,10 @@ common ones):
287
287
  screen (its bullets light up as you name them; a formula goes in `emblem`).
288
288
  5. A check: a question card, then the step that reveals the answer.
289
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.
290
294
 
291
295
  Every step answers the question the step before it raised. Link them out loud:
292
296
  "Now that b has moved the line up, let's see what m does." When the camera
@@ -417,7 +421,9 @@ with no `say:` needs a `duration`. A scene has no `actions`, `caption` or
417
421
  question, one concrete example that runs through the whole video (one
418
422
  gene, one request, one dataset). Then one question per scene, answered by
419
423
  one picture. Follow "Writing the narration" above: short spoken sentences;
420
- question → concrete case → name the idea → why → recap.
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.
421
427
  3. **Lines.** Write the `say:` lines: one idea per scene, usually 2–5
422
428
  sentences (about 60 English words or 120 Chinese characters at most).
423
429
  Commas are cue points: each phrase can trigger one change on screen.
@@ -435,6 +441,91 @@ with no `say:` needs a `duration`. A scene has no `actions`, `caption` or
435
441
  editing only scenes or lines, `compose` re-renders just the scenes that
436
442
  changed.
437
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
+
438
529
  **The scene file contract** (`storyshot check` and `preview_scene` enforce it):
439
530
 
440
531
  ```html
@@ -606,7 +697,11 @@ Explainer defaults:
606
697
  - **Music:** `music: { style: calm }`, soft pads with no drums;
607
698
  `music: false` for none.
608
699
  - **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
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
610
705
  their colour from `ground:` (see "The look follows the subject"), so set it.
611
706
  - **Ground:** `ground: light | dark`, chosen for the subject and the audience,
612
707
  not inherited from the last film.
package/src/audio.mjs CHANGED
@@ -44,29 +44,47 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
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
45
  } else if (cfg.music?.style === 'calm') {
46
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
47
+ // sparse bell; no drums, nothing on the beat that would pull at the voice.
48
+ // The film has to *end*, not stop. Truncating the loop wherever the video happens to finish leaves
49
+ // the last chord hanging (and faded down, which a listener hears as "turned off", not "over"), so
50
+ // the loop is phased to reach IV one chord before the end, the tonic lands there — under the last
51
+ // spoken line — and rings out through the tail: a plagal cadence, the gentlest full stop there is.
48
52
  const BEAT = 60 / cfg.music.bpm, LEN = BEAT * 8;
49
53
  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
54
+ const CAD = Math.max(0, events.total - LEN); // where the tonic lands (a clip shorter than one
55
+ // chord is all cadence, which is the right answer)
56
+ const before = Math.ceil(CAD / LEN); // chords before it...
57
+ const phase = ((2 - before) % 4 + 4) % 4; // ...phased so the last of them is IV
58
+ const pad = (ch, t0, len, gain = 1) => {
52
59
  for (const [k, note] of [...ch, ch[0] + 12].entries()) {
53
60
  for (const det of [-0.06, 0.06]) {
54
61
  const f = hz(note + det), filt = svf();
55
62
  add(music, t0, len, (t) => {
56
63
  const env = Math.min(1, t / 1.4) * Math.min(1, Math.max(0, len - t) / 1.6);
57
64
  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);
65
+ }, 0.024 * gain, (k - 1.5) * 0.35 + det * 2);
59
66
  }
60
67
  }
61
68
  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);
69
+ 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 * gain);
70
+ };
71
+ for (let b = 0, t0 = 0; t0 < CAD; b++, t0 = b * LEN) {
72
+ const ch = chords[(b + phase) % chords.length], len = Math.min(LEN, CAD - t0) + 1.2; // rings into the next chord
73
+ pad(ch, t0, len);
63
74
  if (b % 2 === 0) for (const [j, note] of [ch[2] + 12, ch[1] + 24].entries()) {
64
75
  const f = hz(note);
65
76
  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
77
  }
67
78
  }
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);
79
+ // the cadence: the tonic swells a little louder than the loop ever did, with two bells over it
80
+ const RING = events.total - CAD, TONIC = [48, 52, 55];
81
+ pad(TONIC, CAD, RING, 1.15);
82
+ for (const [j, note] of [TONIC[2] + 12, TONIC[0] + 24].entries()) {
83
+ const f = hz(note);
84
+ add(music, CAD + j * BEAT, RING, (t) => (Math.sin(2 * Math.PI * f * t) + 0.2 * Math.sin(4 * Math.PI * f * t)) * Math.exp(-t * 0.9) * Math.min(1, t / 0.01), 0.035, j ? 0.3 : -0.3);
85
+ }
86
+ // fade in over 2 s; the cadence is what ends the music, so the fade out only guards the cut
87
+ 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 / 1.2);
70
88
  } else if (cfg.music) {
71
89
  // I–V–vi–IV in C at `bpm`, resolving on the end card (or fading at the end)
72
90
  const BEAT = 60 / cfg.music.bpm, BAR = BEAT * 4;
package/src/check.mjs CHANGED
@@ -179,12 +179,12 @@ export function check(file, { draft = false } = {}) {
179
179
  // scenes: the file is there and keeps the HyperFrames contract (the full lint runs in `storyshot scene`)
180
180
  for (const [i, s] of cfg.steps.entries()) {
181
181
  if (!s.scene || s.hidden) continue;
182
- const r = sceneInfo(s.scene.file, cfg.output);
182
+ const r = sceneInfo(s.scene.file, { ...cfg.output, three: !!cfg.sceneKit?.three });
183
183
  errors.push(...r.errors.map((e) => `step ${i + 1}: ${e}`));
184
184
  warnings.push(...r.warnings.map((w) => `step ${i + 1}: ${w}`));
185
185
  if (!cfg.voice && !(typeof s.duration === 'number')) errors.push(`step ${i + 1}: a scene is as long as its narration, and there is no voice: — add a voice: block, or give the scene a duration`);
186
186
  }
187
- if (raw.scenes) unknown(raw.scenes, ['css', 'js'], 'scenes');
187
+ if (raw.scenes) unknown(raw.scenes, ['css', 'js', 'three'], 'scenes');
188
188
  for (const f of [...cfg.sceneKit.css, ...cfg.sceneKit.js]) if (!fs.existsSync(f)) errors.push(`scenes: ${f} does not exist`);
189
189
  for (const f of cfg.sceneKit.css) if (!/\.css$/i.test(f)) errors.push(`scenes.css: ${path.basename(f)} is not a .css file`);
190
190
  for (const f of cfg.sceneKit.js) if (!/\.m?js$/i.test(f)) errors.push(`scenes.js: ${path.basename(f)} is not a .js file`);
package/src/compose.mjs CHANGED
@@ -659,7 +659,12 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
659
659
  `[${last}][in]overlay=0:0:eof_action=pass[v${++idx}];\n`;
660
660
  last = `v${idx}`;
661
661
  }
662
- graph += `[${last}]trim=duration=${TOTAL},format=yuv420p[out]`;
662
+ // The picture needs a full stop too. Without one the file simply ceases on whatever was on screen —
663
+ // which in an explainer is the recap, the busiest frame of the film, held still and then cut. An end
664
+ // card is already an ending; otherwise settle to the film's own ground over the last of the tail.
665
+ const OUTRO = ec ? 0 : Math.min(0.9, TOTAL * 0.02);
666
+ const outro = OUTRO > 0.05 ? `,fade=out:st=${(TOTAL - OUTRO).toFixed(3)}:d=${OUTRO.toFixed(3)}:color=${filmGround(cfg) === 'light' ? 'white' : 'black'}` : '';
667
+ graph += `[${last}]trim=duration=${TOTAL},format=yuv420p${outro}[out]`;
663
668
  if (clean) graph += `;\n[vclean0]trim=duration=${TOTAL},format=yuv420p[clean]`;
664
669
  const graphFile = path.join(cfg.work, 'graph.txt');
665
670
  fs.writeFileSync(graphFile, graph);
package/src/config.mjs CHANGED
@@ -202,7 +202,14 @@ export function loadConfig(file, { draft = false } = {}) {
202
202
 
203
203
  // scenes: { css: [files], js: [files] } shared by every scene (a palette, drawing helpers), loaded before each one
204
204
  const sk = raw.scenes ?? {};
205
- const sceneKit = { css: [].concat(sk.css ?? []).map((f) => path.resolve(dir, f)), js: [].concat(sk.js ?? []).map((f) => path.resolve(dir, f)) };
205
+ // `three: true` adds storyshot's own drawn-3D helpers (three.js + Kit3) before the storyboard's
206
+ // own kit files, for scenes whose subject is an object rather than a quantity (guide: "3D scenes").
207
+ if (sk.three != null && typeof sk.three !== 'boolean') throw new Error('scenes.three is true or false (it loads three.js and storyshot\'s Kit3 helpers into every scene)');
208
+ const sceneKit = {
209
+ css: [].concat(sk.css ?? []).map((f) => path.resolve(dir, f)),
210
+ js: [].concat(sk.js ?? []).map((f) => path.resolve(dir, f)),
211
+ three: sk.three === true,
212
+ };
206
213
  return {
207
214
  name,
208
215
  purpose,
@@ -20,6 +20,7 @@ import { writeSceneProject, sceneAssets, fileHash } from './scenes.mjs';
20
20
 
21
21
  export const HF_VERSION = '0.8.217';
22
22
  const HERE = path.dirname(fileURLToPath(import.meta.url));
23
+ const KIT3D = path.join(HERE, 'scene-kit', 'kit3d.js');
23
24
  // families HyperFrames resolves by name; any other needs an @font-face
24
25
  const BUNDLED = ['Inter', 'Montserrat', 'Outfit', 'Nunito', 'Oswald', 'League Gothic', 'Archivo Black', 'Space Mono', 'IBM Plex Mono', 'JetBrains Mono',
25
26
  'EB Garamond', 'Playfair Display', 'Source Code Pro', 'Noto Sans JP', 'Roboto', 'Open Sans', 'Lato', 'Poppins'];
@@ -591,7 +592,10 @@ blurIn('#try', ${n(ctaAt + 0.85)}, { d: 0.25 });` : ''}
591
592
  // --- rendering ---------------------------------------------------------------------------------------
592
593
  const hashOf = (...parts) => {
593
594
  const h = crypto.createHash('sha1');
594
- for (const p of parts) h.update(typeof p === 'string' ? p : JSON.stringify(p));
595
+ // JSON.stringify(undefined) is undefined, not a string, and Hash.update throws on it. A scene step
596
+ // with a `duration:` and no `say:` line is legal and hashes `job.say` as undefined, so keep a name
597
+ // for the empty ones rather than crashing the whole render on them.
598
+ for (const p of parts) h.update(typeof p === 'string' ? p : JSON.stringify(p) ?? String(p));
595
599
  return h.digest('hex').slice(0, 16);
596
600
  };
597
601
 
@@ -622,6 +626,9 @@ export async function renderHyperframesCards(jobs, ctx, { log = console.log, war
622
626
  const files = sceneAssets(job.scene.file);
623
627
  const key = hashOf(HF_VERSION, ctx.FPS, job.seconds.toFixed(3), job.lead, job.cues, job.say, ctx.W, ctx.H, ctx.accent, ctx.ink, ctx.font, ctx.lang, ctx.subs,
624
628
  fs.existsSync(job.scene.file) ? fileHash(job.scene.file) : 'missing', ...Object.entries(files).map(([k, f]) => [k, fileHash(f)]),
629
+ // the shipped 3D helpers are part of the picture too: upgrading storyshot has to invalidate
630
+ // the cached frames of every 3D scene, or a fixed engine renders the old frames back
631
+ job.kit?.three ? ['kit3d', fileHash(KIT3D)] : 'no-three',
625
632
  ...[...(job.kit?.css ?? []), ...(job.kit?.js ?? [])].map((f) => [f, fs.existsSync(f) ? fileHash(f) : 'missing']));
626
633
  const dir = path.join(root, `${job.name}-${key}`), frames = path.join(dir, 'frames');
627
634
  if (fs.existsSync(path.join(dir, 'done'))) { out[job.name] = { dir: frames, ext: 'jpg', alpha: false }; continue; }
package/src/init.mjs CHANGED
@@ -97,10 +97,21 @@ export function initExplainer({ topic, file, lang = 'en', portrait = false } = {
97
97
  const dir = path.dirname(path.resolve(file));
98
98
  const zh = /^zh/i.test(lang);
99
99
  const [W, H] = portrait ? [1080, 1920] : [1920, 1080];
100
+ // Four steps, not three: a recap re-counts what the film said and then runs out, which a viewer hears
101
+ // as the film being cut off. The last step is the ending itself (`storyshot guide`: "How an explainer ends").
100
102
  const scenes = [1, 2, 3].map((k) => ({ id: `${name}-s${k}`.replace(/[^a-z0-9-]/gi, '-'), rel: `scenes/${name}-${k}.html` }));
103
+ scenes.push({ id: `${name}-close`.replace(/[^a-z0-9-]/gi, '-'), rel: `scenes/${name}-close.html` });
101
104
  const say = zh
102
- ? ['TODO 一个观众会问的问题,一两句话。', 'TODO 用一个具体例子回答,每个短句对应画面上的一个变化。', 'TODO 一句话总结。']
103
- : ['TODO the question a viewer would ask, in one or two sentences.', 'TODO answer it with one concrete example, one change on screen per phrase.', 'TODO the idea in one line.'];
105
+ ? ['TODO 一个观众会问的问题,一两句话。', 'TODO 用一个具体例子回答,每个短句对应画面上的一个变化。', 'TODO 把讲过的再数一遍。',
106
+ 'TODO 结尾:这件事到底意味着什么(一句在第一场说不出来的话);哪些还没有答案;留给观众的一句话,落在片子自己的那张图上。']
107
+ : ['TODO the question a viewer would ask, in one or two sentences.', 'TODO answer it with one concrete example, one change on screen per phrase.',
108
+ 'TODO count back over what the film showed, one line each.',
109
+ 'TODO the ending. What this actually means (the sentence you could not have said in scene 1), what is still open, and one thought to leave with, on the film\'s own picture.'];
110
+ const note = [
111
+ '\n # scene: { file: …, title: Chapter title, lead: 0.5, tail: 0.8 } (lead: seconds before the voice starts)', '', '',
112
+ zh ? '\n # 回顾数完就停,所以听起来没有结局;这一场才是结局。最后一下落在最后一句话上,\n # 而且到最后一帧还在动(一条还在画的线,一道还在合的帘子),别停在一张满画面上。'
113
+ : '\n # The recap stops rather than ends; this scene is the ending. Land its last move ON the final words,\n # and leave something still moving at the last frame — not a held frame of the film\'s fullest moment.',
114
+ ];
104
115
  const yaml = `# Storyshot storyboard (explainer) — every step is a scene: an animation you write as a HyperFrames
105
116
  # composition (scenes/*.html), timed to the step's narration. Reference: \`storyshot guide\` ("Scenes and explainers").
106
117
  # Loop: write the say: lines → storyshot cues (when each phrase is spoken) → write the scenes with S.at(k)
@@ -109,16 +120,19 @@ name: ${name}
109
120
  purpose: explainer
110
121
  out: ${name}.mp4
111
122
  ${zh ? 'lang: zh\nvoice: { provider: qwen, voice: dylan } # free, local (Apple Silicon); elsewhere kokoro zf_001' : 'voice: { provider: kokoro } # free, local'}
112
- ${portrait ? `output: { width: ${W}, height: ${H} }\nviewport: { width: 900, height: 1600 }\n` : ''}subtitles: burn # or true for an .srt beside the video
123
+ ${portrait ? `output: { width: ${W}, height: ${H} }\nviewport: { width: 900, height: 1600 }\n` : ''}subtitles: true # an .srt beside the video, which is the right one for an explainer: the
124
+ # scenes already carry text, and \`burn\` lights the words up at the bottom
125
+ # of the picture, pulling the eye off the drawing for the whole film.
126
+ # Burn them for social video or a feed that autoplays muted, and say so.
113
127
  ground: light # light | dark: the film's ground, chosen for the subject and its audience
114
128
  # (\`storyshot guide\`: "The look follows the subject"). Paper for a subject that
115
129
  # is taught, printed or voted on; dark for a mechanism, an instrument or anything
116
- # that glows. Subtitles and cards follow it; paint every scene to match.
117
- theme: { accent: "#b45309", ink: "#17181b" } # accent: the one colour that points. It also paints the word being
118
- # spoken in the subtitles, so it has to read on the ground above
130
+ # that glows. The cards follow it; paint every scene to match.
131
+ theme: { accent: "#b45309", ink: "#17181b" } # accent: the one colour that points (with \`subtitles: burn\`, also the word
132
+ # being spoken), so it has to read on the ground above
119
133
 
120
134
  steps:
121
- ${scenes.map((sc, k) => ` - scene: ${sc.rel}${k === 0 ? '\n # scene: { file: …, title: Chapter title, lead: 0.5, tail: 0.8 } (lead: seconds before the voice starts)' : ''}
135
+ ${scenes.map((sc, k) => ` - scene: ${sc.rel}${note[k] ?? ''}
122
136
  say: ${say[k]}`).join('\n\n')}
123
137
  `;
124
138
  fs.mkdirSync(path.join(dir, 'scenes', 'assets'), { recursive: true });
@@ -145,7 +159,7 @@ function starterScene(id, W, H) {
145
159
  <script>
146
160
  (function () {
147
161
  // S.at(k): when phrase k of this step's say: line starts (S.end(k): ends); S.duration, S.width, S.height,
148
- // S.safeBottom (px the burned-in subtitles take), S.cues ([{ text, t, end }]). Run \`storyshot cues\` to see them.
162
+ // S.safeBottom (px burned-in subtitles take; 0 without \`subtitles: burn\`), S.cues ([{ text, t, end }]). Run \`storyshot cues\`.
149
163
  const S = window.__storyshot;
150
164
  const tl = gsap.timeline({ paused: true });
151
165
  tl.fromTo('#${id}-line', { opacity: 0, filter: 'blur(8px)' }, { opacity: 1, filter: 'blur(0px)', duration: 0.4, ease: 'power3.out' }, S.at(0));
package/src/outputs.mjs CHANGED
@@ -129,7 +129,12 @@ export async function burnSubtitles(cfg, narration, { log = console.log, rendere
129
129
  // letters with an ink outline on a dark film, ink letters with a white outline on a light one, where
130
130
  // white letters would need a dark bar under them. The word being spoken is the accent either way.
131
131
  const light = filmGround(cfg) === 'light' && isDark(cfg.theme.ink);
132
- const restCol = light ? cfg.theme.ink : '#ffffff', edgeCol = light ? '#ffffff' : cfg.theme.ink, sweep = cfg.theme.accent;
132
+ const restCol = light ? cfg.theme.ink : '#ffffff', sweep = cfg.theme.accent;
133
+ // The outline is what separates the letters from the picture, so it has to be the OPPOSITE of them.
134
+ // `theme.ink` is only dark on a light film — a dark film names its light text colour `ink` — and
135
+ // taking the outline from it there put a near-white edge around the accent-coloured word being
136
+ // spoken, which reads as a ghosted double of every letter, crawling along the line as it is read.
137
+ const edgeCol = light ? '#ffffff' : isDark(cfg.theme.ink) ? cfg.theme.ink : '#000000';
133
138
  const shade = light ? [255, 255, 255] : [0, 0, 0];
134
139
  if (renderer === 'png') {
135
140
  // no libass in this ffmpeg: draw each state of each line (the words spoken so far in the accent
@@ -0,0 +1,340 @@
1
+ /* Kit3 — drawn 3D for a storyshot scene: solids shaded by which way they face, with ink edges found
2
+ in a normal/depth pass, so a shape reads as a textbook figure rather than as a render.
3
+
4
+ Why not just light it. A lit render says "photograph"; a diagram has to say "drawing". Three things
5
+ do that, and none of them is a light:
6
+ 1. Flat tone by FACING. Each face gets one of three tones picked from its own normal — no falloff,
7
+ no specular, no shadows. Curved things step smoothly between tones so they never staircase.
8
+ 2. INK EDGES drawn after the fact. The scene is rendered a second time into a buffer that holds
9
+ the view normal, a line tone and the depth; a full-screen pass then marks a pixel as a line
10
+ where the normal turns a corner or the depth jumps. That gives silhouettes AND creases, which
11
+ is what a draughtsman draws and what a light never produces.
12
+ 3. A LONG LENS. Perspective at fov 18 is nearly axonometric: parallel edges stay parallel, so the
13
+ figure reads as a diagram instead of as a photograph of an object.
14
+ The approach is the standard non-photorealistic one and is the same idea Atlas of Physics uses
15
+ (MIT, nathanliow/atlas-of-physics); this is storyshot's own implementation of it.
16
+
17
+ It draws only when asked. Everything on screen is a function of the timeline's time, so a scene
18
+ calls eng.render() from its GSAP onUpdate and never from a frame loop — frames are rendered by
19
+ seeking, and a clock of its own would make every render different.
20
+
21
+ The UMD build of three.js carries no addons (no EffectComposer, no ShaderPass), so the two passes
22
+ and the composite here are written against plain render targets.
23
+
24
+ Scenes use it like this:
25
+
26
+ const eng = Kit3.engine(canvas, { bg: '#0f1218', ink: '#0a0c10' });
27
+ const flask = eng.part(eng.scene, { explode: [0, 220, 0] });
28
+ eng.solid(flask, new THREE.CylinderGeometry(1, 1, 2, 48), { ramp: Kit3.RAMP.glass, tone: 'hi' });
29
+ eng.look({ target: [0, 0.6, 0], az: -0.7, el: 0.42, dist: 26 });
30
+ ...
31
+ tl.fromTo(p, { t: 0 }, { t: S.duration, duration: S.duration, ease: 'none',
32
+ onUpdate() { eng.explode(k(p.t)); eng.render(); } }, 0);
33
+ */
34
+ window.Kit3 = (function () {
35
+ /* How dark a line is, the way a draughtsman grades them: a part that is only context gets a faint
36
+ edge, the one thing being talked about gets the full one. Stored per solid, read by the edge pass. */
37
+ const TONE = { ghost: 0.15, faint: 0.3, lo: 0.5, mid: 0.7, hi: 0.85, lit: 1.0 };
38
+
39
+ /* A ramp is [top, lightSide, darkSide] — the three tones a solid can show. A film gives its own,
40
+ in its own palette; these are the neutrals every film can fall back on. */
41
+ const RAMP = {
42
+ paper: ['#ffffff', '#f2f1ec', '#d8d6cf'],
43
+ panel: ['#2b313d', '#222834', '#171b23'],
44
+ glass: ['#39414f', '#2a313d', '#1d222c'],
45
+ dead: ['#3a3f4a', '#2f343e', '#262a33'],
46
+ };
47
+
48
+ const hex = (c) => new THREE.Color(c);
49
+
50
+ /* ---- the beauty pass: three tones chosen by which way the face points ------------------------ */
51
+ // The side tone comes from the horizontal part of the world normal, so turning the camera does not
52
+ // repaint the object: a figure that re-shades while the camera moves reads as lit, not as drawn.
53
+ // The instancing guard is what lets one call draw a flask's worth of cells: three.js defines
54
+ // USE_INSTANCING for an InstancedMesh, and the normal has to be turned by the instance too.
55
+ const SHADE_VERT = `
56
+ varying vec3 vN;
57
+ void main() {
58
+ vec3 n = normal;
59
+ #ifdef USE_INSTANCING
60
+ n = mat3(instanceMatrix) * n;
61
+ #endif
62
+ vN = normalize(mat3(modelMatrix) * n);
63
+ #include <begin_vertex>
64
+ #include <project_vertex>
65
+ }`;
66
+ const SHADE_FRAG = `
67
+ uniform vec3 uTop, uLight, uDark;
68
+ uniform float uOpacity, uLit;
69
+ varying vec3 vN;
70
+ void main() {
71
+ vec3 n = normalize(vN);
72
+ // sides: blend the two side tones by how much the face turns towards the light direction.
73
+ // Continuous, so a cylinder steps through the tones instead of banding into facets.
74
+ float side = clamp(dot(normalize(vec3(-0.55, 0.0, 0.83)), normalize(vec3(n.x, 0.0, n.z) + 1e-5)) * 0.5 + 0.5, 0.0, 1.0);
75
+ vec3 c = mix(uDark, uLight, side);
76
+ c = mix(c, uTop, smoothstep(0.55, 0.95, n.y)); // tops catch the page's light
77
+ c = mix(c, uDark * 0.94, smoothstep(-0.45, -0.95, n.y)); // undersides sit down
78
+ c = mix(c, uTop, uLit * 0.4); // the one thing being named lifts
79
+ gl_FragColor = vec4(c, uOpacity);
80
+ }`;
81
+
82
+ /* ---- the line pass: view normal (xy), line tone, depth ---------------------------------------- */
83
+ // Only xy of the view normal is kept: z is recoverable and the blue channel is worth more as the
84
+ // per-solid line tone, which is what lets one part be drawn heavier than the rest.
85
+ const NORM_VERT = `
86
+ varying vec3 vVN; varying float vDepth;
87
+ void main() {
88
+ vec3 n = normal;
89
+ #ifdef USE_INSTANCING
90
+ n = mat3(instanceMatrix) * n;
91
+ #endif
92
+ vVN = normalize(normalMatrix * n);
93
+ #include <begin_vertex>
94
+ #include <project_vertex>
95
+ vDepth = -mvPosition.z;
96
+ }`;
97
+ const NORM_FRAG = `
98
+ uniform float uTone, uFar;
99
+ varying vec3 vVN; varying float vDepth;
100
+ void main() {
101
+ vec3 n = normalize(vVN);
102
+ gl_FragColor = vec4(n.xy * 0.5 + 0.5, uTone, clamp(vDepth / uFar, 0.0, 1.0));
103
+ }`;
104
+
105
+ /* ---- the composite: find the edges, ink them over the colour --------------------------------- */
106
+ const EDGE_FRAG = `
107
+ uniform sampler2D uColor, uNormal;
108
+ uniform vec2 uTexel;
109
+ uniform vec3 uInk;
110
+ uniform float uNormalCut, uDepthCut, uWeight;
111
+ varying vec2 vUv;
112
+
113
+ vec4 nd(vec2 uv) { return texture2D(uNormal, uv); }
114
+
115
+ void main() {
116
+ vec4 c = texture2D(uColor, vUv);
117
+ vec4 m = nd(vUv);
118
+ // four neighbours is enough for a 1 px line and keeps the pass cheap at 1920x1080
119
+ vec4 l = nd(vUv - vec2(uTexel.x, 0.0)), r = nd(vUv + vec2(uTexel.x, 0.0));
120
+ vec4 d = nd(vUv - vec2(0.0, uTexel.y)), u = nd(vUv + vec2(0.0, uTexel.y));
121
+
122
+ // a crease: the normal turns a corner
123
+ float dn = max(max(length(l.xy - m.xy), length(r.xy - m.xy)),
124
+ max(length(d.xy - m.xy), length(u.xy - m.xy)));
125
+ // a silhouette: the depth jumps. Scaled by distance so a far edge is not thicker than a near one.
126
+ float dd = max(max(abs(l.a - m.a), abs(r.a - m.a)), max(abs(d.a - m.a), abs(u.a - m.a)));
127
+ dd = dd / max(m.a, 0.02);
128
+
129
+ float edge = max(smoothstep(uNormalCut, uNormalCut * 2.4, dn),
130
+ smoothstep(uDepthCut, uDepthCut * 2.6, dd));
131
+ // the heaviest tone around the pixel wins, so a lit part keeps its full line against a faint one
132
+ float tone = max(max(l.b, r.b), max(max(d.b, u.b), m.b));
133
+ gl_FragColor = vec4(mix(c.rgb, uInk, clamp(edge * tone * uWeight, 0.0, 1.0)), 1.0);
134
+ }`;
135
+
136
+ function engine(canvas, opts = {}) {
137
+ const W = canvas.width, H = canvas.height;
138
+ const bg = opts.bg ?? '#ffffff', ink = opts.ink ?? '#18181a', FAR = opts.far ?? 200;
139
+
140
+ // A diagram's colours are chosen, not photographed: with colour management off and the output
141
+ // left linear, a hex in the palette lands in the frame as exactly that hex, and two renders of
142
+ // the same second are the same pixels.
143
+ THREE.ColorManagement.enabled = false;
144
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, preserveDrawingBuffer: true });
145
+ renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
146
+ renderer.setPixelRatio(1);
147
+ renderer.setSize(W, H, false);
148
+ renderer.setClearColor(hex(bg), 1);
149
+ renderer.autoClear = true;
150
+
151
+ const scene = new THREE.Scene();
152
+ // fov 18: a long lens. Parallel edges stay nearly parallel, which is what makes it read as a figure.
153
+ const camera = new THREE.PerspectiveCamera(opts.fov ?? 18, W / H, 0.1, FAR);
154
+
155
+ // Two buffers at the frame's own size: colour, and (normal.xy, tone, depth).
156
+ const rtOpts = { minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, depthBuffer: true, stencilBuffer: false };
157
+ const rtColor = new THREE.WebGLRenderTarget(W, H, rtOpts);
158
+ const rtNorm = new THREE.WebGLRenderTarget(W, H, { ...rtOpts, type: THREE.HalfFloatType });
159
+
160
+ const quadScene = new THREE.Scene();
161
+ const quadCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
162
+ const edgeMat = new THREE.ShaderMaterial({
163
+ uniforms: {
164
+ uColor: { value: rtColor.texture }, uNormal: { value: rtNorm.texture },
165
+ uTexel: { value: new THREE.Vector2(1 / W, 1 / H) }, uInk: { value: hex(ink) },
166
+ uNormalCut: { value: opts.creaseCut ?? 0.18 }, uDepthCut: { value: opts.silhouetteCut ?? 0.012 },
167
+ uWeight: { value: opts.inkWeight ?? 1 },
168
+ },
169
+ vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }',
170
+ fragmentShader: EDGE_FRAG, depthTest: false, depthWrite: false,
171
+ });
172
+ quadScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), edgeMat));
173
+
174
+ const parts = []; // everything that can be pulled apart
175
+ const normMats = new Map(); // one line-pass material per tone value
176
+
177
+ const normalFor = (tone) => {
178
+ if (!normMats.has(tone)) normMats.set(tone, new THREE.ShaderMaterial({
179
+ uniforms: { uTone: { value: tone }, uFar: { value: FAR } },
180
+ vertexShader: NORM_VERT, fragmentShader: NORM_FRAG,
181
+ }));
182
+ return normMats.get(tone);
183
+ };
184
+
185
+ const api = {
186
+ THREE, scene, camera, renderer, parts,
187
+
188
+ /* A group that can be pulled out of the assembly. `at` is where it sits assembled and `explode`
189
+ where it goes when pulled apart; explode(k) then moves every part along its own vector.
190
+ Pass `at` rather than setting .position afterwards: the part remembers where home is, and
191
+ the next explode(k) would otherwise put it back at the origin instead. */
192
+ part(parent, { at = [0, 0, 0], explode = [0, 0, 0], name = '' } = {}) {
193
+ const g = new THREE.Group();
194
+ g.position.set(...at);
195
+ g.userData.home = g.position.clone();
196
+ g.userData.out = new THREE.Vector3(...explode);
197
+ g.userData.name = name;
198
+ parent.add(g);
199
+ parts.push(g);
200
+ return g;
201
+ },
202
+
203
+ /* One drawn solid. `ramp` is its three tones, `tone` how heavy its ink line is. */
204
+ solid(parent, geometry, { ramp = RAMP.paper, tone = 'mid', opacity = 1, lit = 0 } = {}) {
205
+ const [top, light, dark] = ramp;
206
+ const mat = new THREE.ShaderMaterial({
207
+ uniforms: {
208
+ uTop: { value: hex(top) }, uLight: { value: hex(light) }, uDark: { value: hex(dark) },
209
+ uOpacity: { value: opacity }, uLit: { value: lit },
210
+ },
211
+ vertexShader: SHADE_VERT, fragmentShader: SHADE_FRAG,
212
+ transparent: opacity < 1, depthWrite: opacity >= 1,
213
+ });
214
+ const m = new THREE.Mesh(geometry, mat);
215
+ m.userData.tone = typeof tone === 'number' ? tone : (TONE[tone] ?? TONE.mid);
216
+ m.userData.shade = mat;
217
+ parent.add(m);
218
+ return m;
219
+ },
220
+
221
+ /* Many copies of one shape in one draw call — a flask's worth of cells, a rack of guides.
222
+ Place them with setMatrixAt + instanceMatrix.needsUpdate, and set .count to how many are
223
+ on screen this frame; the ones past the count are simply not drawn. */
224
+ instances(parent, geometry, count, { ramp = RAMP.paper, tone = 'mid', opacity = 1, lit = 0 } = {}) {
225
+ const probe = api.solid(parent, geometry, { ramp, tone, opacity, lit });
226
+ parent.remove(probe);
227
+ const inst = new THREE.InstancedMesh(geometry, probe.material, count);
228
+ inst.userData = probe.userData;
229
+ inst.count = 0;
230
+ // An InstancedMesh is culled against ONE bounding sphere: the geometry's, around the mesh's
231
+ // own origin. Instances placed anywhere else — a field of particles crossing the frame, cells
232
+ // laid out across a flask — put nothing in that sphere, so the whole batch is culled and
233
+ // simply never appears. A diagram holds a handful of batches, so stop culling them.
234
+ inst.frustumCulled = false;
235
+ parent.add(inst);
236
+ return inst;
237
+ },
238
+
239
+ /* Light one solid up without changing its colour: the tone lifts and its line goes to full. */
240
+ lift(mesh, k) {
241
+ mesh.userData.shade.uniforms.uLit.value = k;
242
+ mesh.userData.tone = TONE.faint + (TONE.lit - TONE.faint) * k;
243
+ },
244
+
245
+ /* Fading a solid has to take its LINE with it. The line pass draws every mesh with an opaque
246
+ material of its own, so a solid at opacity 0 keeps a full-strength ink edge and goes on
247
+ hanging in the frame as a wireframe ghost. Below a hair's width of opacity the mesh leaves
248
+ both passes. */
249
+ fade(mesh, o) {
250
+ mesh.userData.shade.uniforms.uOpacity.value = o;
251
+ mesh.userData.shade.transparent = o < 1;
252
+ mesh.userData.shade.depthWrite = o >= 1;
253
+ mesh.visible = o > 0.004;
254
+ },
255
+
256
+ /* Place the camera the way a figure is staged: look at a point from an angle and a distance.
257
+ `shift` slides the whole camera sideways afterwards, in world units along the camera's OWN
258
+ right axis, which is how you leave the left third of the frame for the words. Offsetting the
259
+ target instead looks right until the azimuth changes: world x is not screen right, so the
260
+ subject swings and comes towards the lens as the camera turns. Negative shift = subject right. */
261
+ look({ target = [0, 0, 0], az = -0.7, el = 0.42, dist = 30, shift = 0, rise = 0 } = {}) {
262
+ const [tx, ty, tz] = target;
263
+ camera.position.set(tx + Math.sin(az) * Math.cos(el) * dist, ty + Math.sin(el) * dist, tz + Math.cos(az) * Math.cos(el) * dist);
264
+ camera.lookAt(tx, ty, tz);
265
+ if (shift || rise) {
266
+ camera.updateMatrixWorld(true);
267
+ const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0);
268
+ const up = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 1);
269
+ camera.position.addScaledVector(right, shift).addScaledVector(up, rise);
270
+ camera.updateMatrixWorld(true);
271
+ }
272
+ },
273
+
274
+ /* 0 = assembled, 1 = fully apart. One number drives the whole exploded view. */
275
+ explode(k) {
276
+ for (const g of parts) g.position.copy(g.userData.home).addScaledVector(g.userData.out, k);
277
+ },
278
+
279
+ /* Two passes and the composite. Called from the timeline, never from a loop. */
280
+ render() {
281
+ renderer.setRenderTarget(rtColor);
282
+ renderer.setClearColor(hex(bg), 1);
283
+ renderer.clear();
284
+ renderer.render(scene, camera);
285
+
286
+ // the line pass: swap every mesh to its tone's normal material, then swap back
287
+ const saved = [];
288
+ scene.traverse((o) => {
289
+ if (o.isMesh) { saved.push([o, o.material]); o.material = normalFor(o.userData.tone ?? TONE.mid); }
290
+ });
291
+ renderer.setRenderTarget(rtNorm);
292
+ // far depth and a normal that matches nothing, so the background never grows an edge of its own
293
+ renderer.setClearColor(0x000000, 1);
294
+ renderer.clear();
295
+ renderer.render(scene, camera);
296
+ for (const [o, m] of saved) o.material = m;
297
+
298
+ renderer.setRenderTarget(null);
299
+ renderer.render(quadScene, quadCam);
300
+ },
301
+ };
302
+ return api;
303
+ }
304
+
305
+ /* A box with soft corners and a bevelled edge, standing on y = 0. A sharp box drawn with ink edges
306
+ reads as a crate; most lab plastic is rounded, and the bevel is what gives the silhouette the
307
+ double line that says "moulded object" rather than "cube". */
308
+ function slab(w, h, d, r = 0.6, bevel = 0.14) {
309
+ const x = w / 2, z = d / 2, rr = Math.max(0.01, Math.min(r, x - 0.01, z - 0.01));
310
+ const s = new THREE.Shape();
311
+ s.moveTo(-x + rr, -z);
312
+ s.lineTo(x - rr, -z); s.quadraticCurveTo(x, -z, x, -z + rr);
313
+ s.lineTo(x, z - rr); s.quadraticCurveTo(x, z, x - rr, z);
314
+ s.lineTo(-x + rr, z); s.quadraticCurveTo(-x, z, -x, z - rr);
315
+ s.lineTo(-x, -z + rr); s.quadraticCurveTo(-x, -z, -x + rr, -z);
316
+ const g = new THREE.ExtrudeGeometry(s, {
317
+ depth: Math.max(0.01, h - bevel * 2), bevelEnabled: bevel > 0,
318
+ bevelSize: bevel, bevelThickness: bevel, bevelSegments: 2, curveSegments: 8,
319
+ });
320
+ g.rotateX(-Math.PI / 2); // ExtrudeGeometry grows along +Z; stand it up on +Y
321
+ g.translate(0, bevel, 0); // and sit it on the ground rather than straddling it
322
+ return g;
323
+ }
324
+
325
+ /* A seeded random number generator (mulberry32). A scene is rendered by seeking, and the same
326
+ second has to come back the same pixels every time, so `Math.random()` is banned outright —
327
+ scattering cells in a flask or jittering a crowd needs a generator that starts from a number.
328
+ Give each scattered thing its own seed so adding one does not reshuffle the rest. */
329
+ function rng(seed = 1) {
330
+ let a = (seed >>> 0) + 0x6d2b79f5;
331
+ return function () {
332
+ a = (a + 0x6d2b79f5) >>> 0;
333
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
334
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
335
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
336
+ };
337
+ }
338
+
339
+ return { engine, slab, rng, TONE, RAMP };
340
+ })();
@@ -50,15 +50,24 @@ export async function previewScene(cfg, stepNo, { at, check = true, log = consol
50
50
  for (const f of j.findings ?? []) {
51
51
  // the wrapper is ours: only what is about the scene file counts
52
52
  if (f.file && !/scene\.html$/.test(f.file)) continue;
53
+ // lint reads the composition alone, so it cannot see that `scenes: { three: true }` already
54
+ // loaded three.js and Kit3 in the page around it
55
+ if (f.code === 'missing_three_script' && cfg.sceneKit?.three) continue;
53
56
  (f.severity === 'error' ? lint.errors : f.severity === 'warning' ? lint.warnings : []).push(`${f.code}: ${f.message}${f.fixHint ? ` — ${f.fixHint}` : ''}`);
54
57
  }
55
58
  } catch { if (r.code !== 0) lint.errors.push((r.err || r.out).trim().split('\n').slice(-3).join(' | ')); }
56
59
  }
57
60
 
58
61
  // stills: as each phrase is spoken (a beat into it), and the end
59
- let times = at?.length ? at : [...local.map((c) => c.t + Math.min(0.9, (c.end - c.t) * 0.6)), seconds - 0.05];
60
- times = [...new Set(times.map((t) => +Math.max(0, Math.min(seconds - 0.02, t)).toFixed(2)))].sort((a, b) => a - b);
61
- if (times.length > 12) times = Array.from({ length: 12 }, (_, k) => times[Math.round(k * (times.length - 1) / 11)]);
62
+ // HOLD is a probe, not a shot worth looking at: the same picture at HOLD and at the end means
63
+ // nothing has moved for the last 2.5 s (see "does the film end, or does it just stop?" below).
64
+ const HOLD = seconds > 4 ? +(seconds - 2.55).toFixed(2) : null;
65
+ let times = at?.length ? at : [...local.map((c) => c.t + Math.min(0.9, (c.end - c.t) * 0.6)), HOLD, seconds - 0.05];
66
+ times = [...new Set(times.filter((t) => t != null).map((t) => +Math.max(0, Math.min(seconds - 0.02, t)).toFixed(2)))].sort((a, b) => a - b);
67
+ if (times.length > 12) {
68
+ times = Array.from({ length: 12 }, (_, k) => times[Math.round(k * (times.length - 1) / 11)]);
69
+ if (HOLD != null && !at?.length && !times.includes(HOLD)) times = [...new Set([...times, HOLD])].sort((a, b) => a - b);
70
+ }
62
71
  const shots = path.join(dir, 'shots');
63
72
  const r = await hyperframesRun(['snapshot', dir, '--at', times.join(','), '--no-end', '--output', shots], { log });
64
73
  if (r.code !== 0) throw new Error(`the scene did not load in HyperFrames: ${(r.err || r.out).trim().split('\n').filter(Boolean).slice(-4).join(' | ')}`);
@@ -97,7 +106,26 @@ export async function previewScene(cfg, stepNo, { at, check = true, log = consol
97
106
  found[k] = [...groups.values()].map((ws) => (ws.length > 1 ? `${ws[0]} (and ${ws.length - 1} more like it)` : ws[0]));
98
107
  }
99
108
  }
100
- return { dir, seconds, cues: local, times, images, sheet, sheets, lint, check: check ? found : null, subs: ctx.subs };
109
+ // Does the film end, or does it just stop? A recap scene adds and adds, and its last frame — the
110
+ // fullest of the whole film — is then held until the file runs out. The layout check samples too
111
+ // few moments to see it, but the stills are already rendered: if the last two are the same picture
112
+ // and they are seconds apart, the film has no last beat. (Only the film's last scene: a held frame
113
+ // in the middle is a pause, and a scene handing over to an end card has already done its part.)
114
+ const last = cfg.steps.map((x, k) => (x.hidden ? -1 : k)).filter((k) => k >= 0).at(-1);
115
+ const hold = HOLD == null ? -1 : times.indexOf(HOLD);
116
+ if (images.length === times.length && hold >= 0 && hold < times.length - 1 && last === i && !cfg.endCard) {
117
+ const same = (a, b) => { try { return fs.readFileSync(a).equals(fs.readFileSync(b)); } catch { return false; } };
118
+ if (same(images[images.length - 1], images[hold])) {
119
+ found.warnings.push(
120
+ `frozen_ending (at ${times[times.length - 1].toFixed(1)}s): nothing has changed on screen since ${times[hold].toFixed(1)}s, ` +
121
+ `so the film ends on a still frame of its fullest moment. A frozen picture is usually the symptom: check the WORDS first — read this ` +
122
+ `scene's say: line alone, and if it summarises and stops rather than concluding, the film has no ending to show. An explainer ends on a ` +
123
+ `closing beat of its own after the recap: what this actually means, what is still open, and one thought to leave with. Then clear everything ` +
124
+ `except the one thing the viewer should leave holding, land that move ON the final words, and prefer a move still running when the file ends`);
125
+ }
126
+ }
127
+ return { dir, seconds, cues: local, times, images, sheet, sheets, lint,
128
+ check: check || found.warnings.length ? found : null, subs: ctx.subs };
101
129
  }
102
130
 
103
131
  // What to do about the findings agents meet most in scenes (HyperFrames' own messages point at its docs).
package/src/scenes.mjs CHANGED
@@ -12,9 +12,18 @@
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import crypto from 'node:crypto';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
15
18
 
16
19
  export const SCENE_DEFAULTS = { lead: 0.5, tail: 0.8 };
17
20
  export const GSAP_URL = 'https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js';
21
+ // r160 is the last three.js with a classic-script build; everything after it is ESM only, and a
22
+ // module script in a scene would run after HyperFrames has already collected the timelines.
23
+ export const THREE_URL = 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js';
24
+ // That build announces its own deprecation the moment it loads, and the layout check reports every
25
+ // console warning, so a 3D scene would carry one forever. Drop that one line and nothing else.
26
+ const THREE_QUIET = `(function () { const w = console.warn; console.warn = function (m) { if (typeof m === 'string' && /build\\/three(\\.min)?\\.js" are deprecated/.test(m)) return; return w.apply(console, arguments); }; })();`;
18
27
 
19
28
  // How long a scene plays: a fixed `duration`, else its narration plus a lead-in and a tail.
20
29
  export function sceneSeconds(step, voiceLen) {
@@ -24,7 +33,7 @@ export function sceneSeconds(step, voiceLen) {
24
33
  }
25
34
 
26
35
  // What a scene file declares, and what is wrong with it (errors stop the render, warnings don't).
27
- export function sceneInfo(file, { width, height } = {}) {
36
+ export function sceneInfo(file, { width, height, three = null } = {}) {
28
37
  const errors = [], warnings = [];
29
38
  if (!fs.existsSync(file)) return { errors: [`the scene ${file} does not exist — write it (\`storyshot init --purpose explainer\` writes a starter)`], warnings };
30
39
  const html = fs.readFileSync(file, 'utf8');
@@ -46,6 +55,11 @@ export function sceneInfo(file, { width, height } = {}) {
46
55
  if (/repeat\s*:\s*-1/.test(html)) errors.push(`${name}: repeat: -1 never ends; repeat a fixed number of times`);
47
56
  if (!/__storyshot/.test(html)) warnings.push(`${name}: times nothing from window.__storyshot — read them from its cues (S.at(k)) so the animation stays on the words when the narration changes`);
48
57
  if (/["'(]\/assets\//.test(html)) errors.push(`${name}: asset paths are relative: "assets/…", not "/assets/…"`);
58
+ // A 3D scene whose storyboard forgot `scenes: { three: true }` throws on its first line and
59
+ // renders an empty frame — the scene still "works" everywhere except in the video.
60
+ if (three === false && /\b(THREE|Kit3)\s*\./.test(html)) {
61
+ errors.push(`${name}: uses ${/\bKit3\s*\./.test(html) ? 'Kit3' : 'THREE'} but the storyboard does not load it — add \`scenes: { three: true }\` (with your css/js kit, if any)`);
62
+ }
49
63
  return { id, html, errors, warnings };
50
64
  }
51
65
 
@@ -124,7 +138,8 @@ html,body{width:${W}px;height:${H}px;overflow:hidden;background:#000}
124
138
  #root{position:relative;width:100%;height:100%;font-family:var(--font)}
125
139
  </style>
126
140
  <script>${sceneRuntime(job, ctx)}
127
- ${GSAP_GUARD}</script>
141
+ ${GSAP_GUARD}${job.kit?.three ? `\n${THREE_QUIET}` : ''}</script>
142
+ ${job.kit?.three ? `<script src="${THREE_URL}"></script>\n<script src="kit3d.js"></script>` : ''}
128
143
  ${(job.kit?.css ?? []).map((f, k) => `<link rel="stylesheet" href="kit/${k}-${path.basename(f)}">`).join('\n')}
129
144
  ${(job.kit?.js ?? []).map((f, k) => `<script src="kit/${k}-${path.basename(f)}"></script>`).join('\n')}
130
145
  </head><body>
@@ -140,15 +155,20 @@ export const fileHash = (f) => crypto.createHash('sha1').update(fs.readFileSync(
140
155
 
141
156
  // Write a scene's project into `dir` (index.html, the scene, its assets). Returns the scene info.
142
157
  export function writeSceneProject(job, ctx, dir) {
143
- const info = sceneInfo(job.scene.file, { width: ctx.W, height: ctx.H });
158
+ const info = sceneInfo(job.scene.file, { width: ctx.W, height: ctx.H, three: !!job.kit?.three });
144
159
  if (info.errors.length) throw new Error(info.errors.join('; '));
145
160
  fs.mkdirSync(path.join(dir, 'compositions'), { recursive: true });
146
161
  fs.writeFileSync(path.join(dir, 'index.html'), sceneDoc(job, ctx, info));
147
162
  fs.writeFileSync(path.join(dir, 'compositions', 'scene.html'), info.html);
163
+ // `scenes: { three: true }`: storyshot's own drawn-3D helpers, loaded before the storyboard's kit
164
+ if (job.kit?.three) fs.copyFileSync(path.join(HERE, 'scene-kit', 'kit3d.js'), path.join(dir, 'kit3d.js'));
148
165
  // the storyboard's scene kit, shared by every scene
149
- for (const [kind, list] of Object.entries(job.kit ?? {})) for (const [k, f] of list.entries()) {
150
- fs.mkdirSync(path.join(dir, 'kit'), { recursive: true });
151
- fs.copyFileSync(f, path.join(dir, 'kit', `${k}-${path.basename(f)}`));
166
+ for (const [kind, list] of Object.entries(job.kit ?? {})) {
167
+ if (!Array.isArray(list)) continue;
168
+ for (const [k, f] of list.entries()) {
169
+ fs.mkdirSync(path.join(dir, 'kit'), { recursive: true });
170
+ fs.copyFileSync(f, path.join(dir, 'kit', `${k}-${path.basename(f)}`));
171
+ }
152
172
  }
153
173
  for (const [k, f] of Object.entries(sceneAssets(job.scene.file))) {
154
174
  fs.mkdirSync(path.dirname(path.join(dir, 'assets', k)), { recursive: true });