storyshot 0.8.0 → 0.10.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
@@ -259,9 +259,11 @@ by hand.
259
259
  ```yaml
260
260
  url: https://example.com
261
261
  out: acme-promo.mp4 # relative to this file; default: <file name>.mp4
262
- viewport: { width: 1600, height: 900 } # CSS px the site is laid out at
263
- output: { width: 1920, height: 1080, fps: 30 } # same aspect ratio; use 1080x1920 with a 900x1600 viewport for vertical
264
- theme: { accent: "#d9f75b", ink: "#0f172a", font: Inter }
262
+ aspect: landscape # landscape (16:9, default) | portrait (9:16, 1080x1920: a phone upright, and most short-video feeds) | square (1:1). 16:9, 9:16 and 1:1 work too,
263
+ # and so does any other ratio: "3:4" is 1080x1440 (what Xiaohongshu fills), "4:5" is 1080x1350. The short side is always 1080
264
+ viewport: { width: 1600, height: 900 } # CSS px the site is laid out at; leave it out and it follows the output's shape
265
+ output: { width: 1920, height: 1080, fps: 30 } # an exact size, when `aspect` is not the shape you want; the viewport must share its aspect ratio
266
+ theme: { accent: "#d9f75b", ink: "#0f172a", font: Inter, paper: "#e9ddc4" } # paper: the film's own background. The intro, section and end cards are built on it instead of the default near-white, which is cold against a warm film. Leave it out and the cards follow `ground:`
265
267
  music: { bpm: 120, volume: 1 } # or { file: track.mp3 }, or false
266
268
  sfx: true # click / whoosh / riser sounds; clicks = no whooshes on cards and page changes, and no rising sound under a riser step, only its chime (tutorial and course default); false = none
267
269
  voice: { provider: elevenlabs, voice: hpp4J3VqNfWAUOO0d1Us } # optional voiceover, see below
@@ -501,7 +503,7 @@ the scene file.
501
503
  ```yaml
502
504
  purpose: explainer # scenes only: no url, nothing recorded
503
505
  voice: { provider: qwen, voice: dylan }
504
- subtitles: burn
506
+ subtitles: true # an .srt beside the video; `burn` puts the words on the picture (social, muted autoplay)
505
507
  steps:
506
508
  - scene: scenes/question.html # or { file, title, lead: 0.5, tail: 0.8 }
507
509
  say: An intact probe carries a dye. Why doesn't it glow?
@@ -515,12 +517,38 @@ scene. A scene's narration may be a whole paragraph: it is spoken in chunks of
515
517
  whole sentences, and `voice.maxPause` caps the pauses inside a line (0.55 s in
516
518
  an explainer).
517
519
 
520
+ `scenes: { three: true }` adds three.js and storyshot's `Kit3` helpers, for a
521
+ scene whose subject is an **object** rather than a quantity: a solid is shaded
522
+ flat by which way it faces and its edges are found in a second normal/depth
523
+ pass, so it reads as a drawn figure rather than a render, and
524
+ `eng.part(…, { explode })` + `eng.explode(k)` give an exploded view from one
525
+ number. It is for the few scenes that need it — a list, a curve or a count is
526
+ better and sharper in HTML — and it suits a light ground, because the ink edge
527
+ is a drawn line on paper and a rim light on black. See "3D scenes, used
528
+ sparingly" in `storyshot guide`.
529
+
518
530
  Every scene of a film shares one look, and the look belongs to the subject and
519
531
  the audience: a dark instrument panel suits a mechanism explained to specialists,
520
532
  a warm paper poster suits civics explained to nine-year-olds. Say which ground
521
533
  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.
534
+ follow it. When the film is drawn on a *colour* rather than on plain light or
535
+ dark, give it as `theme: { paper: "#e9ddc4" }` and the title, section and end
536
+ cards are built on that ground instead of the default near-white, which is a
537
+ cold blue-grey and reads as a different film. The playbook's "The look follows
538
+ the subject" has the styles and what each ground costs.
539
+
540
+ `aspect: portrait` makes the video 1080x1920 — a phone held upright, and what
541
+ most short-video feeds want (`square` is 1:1, `landscape` 16:9 is the default,
542
+ and `output:` still takes an exact size). The feeds do not all want the same
543
+ tall frame, so any ratio works written out: `aspect: "3:4"` is 1080x1440, which
544
+ is the shape Xiaohongshu's feed fills and the one it crops a 9:16 video down
545
+ to, and `aspect: "4:5"` is 1080x1350 for an Instagram feed. The short side is
546
+ always 1080 and both sides come out even. A portrait film is not a landscape
547
+ film resized: a tall frame reads top to bottom, so a scene that puts an object
548
+ beside its labels stacks them instead, and a side-by-side comparison becomes an
549
+ upper and a lower half. Each scene's root carries `data-width="1080"
550
+ data-height="1920"`, and `storyshot check` says so when it does not match. The
551
+ playbook's "Portrait" has the type scale and the margins a feed covers.
524
552
 
525
553
  `lead` is how long the scene plays before the voice starts (default 0.5 s), and
526
554
  `tail` is how long it holds after the voice ends (default 0.8 s). Consecutive
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.10.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
@@ -590,6 +681,17 @@ much cheaper to show one previewed scene in the chosen style and ask.
590
681
  - A glow that spills out of its box: `data-layout-allow-overflow` on the box.
591
682
  - A label sitting on its object: `data-layout-allow-overlap` on the container.
592
683
  - Small credits or texture: `data-layout-ignore`.
684
+ - But `data-layout-ignore` takes an element out of the check entirely, so two
685
+ ignored elements may overlap and nothing will say so. A source credit
686
+ running under a "schematic" mark in the same corner is the usual way this
687
+ bites. Read that corner off the still.
688
+ - **A CSS rule beats an SVG presentation attribute.** If your scene CSS says
689
+ `.fig .ln { fill: none; stroke-width: 4 }`, then `fill="#c00"` or
690
+ `stroke-width="7"` *as attributes* on a `.ln` element do nothing at all —
691
+ silently, with no warning. Shapes come out unfilled and strokes come out the
692
+ wrong weight. Set them as inline **styles** instead, or give the element its
693
+ own class. This is the single most common reason a drawn scene looks wrong
694
+ while every check passes.
593
695
  - **"The film's last line needs 2 s".** Only the video's last scene must hold
594
696
  its final words. Elsewhere, reveal text on an earlier phrase or raise
595
697
  `tail:`.
@@ -597,6 +699,10 @@ much cheaper to show one previewed scene in the chosen style and ask.
597
699
  so they come out dark when the scenes do; `ground: light | dark` settles the
598
700
  whole film and `cards: light | dark` overrides the cards alone. Keep
599
701
  end-card bullets short: about 10 Chinese characters or 5 English words each.
702
+ `ground` only picks light or dark, so a film drawn on a *colour* — warm
703
+ paper, a blueprint blue — still opens on storyshot's near-white, which reads
704
+ as cold and as a different film. Give that colour as `theme: { paper:
705
+ "#e9ddc4" }` and every card tone is mixed from it and the ink instead.
600
706
 
601
707
  Long narration is fine: a scene's `say:` is spoken in chunks of whole
602
708
  sentences, and in an explainer no pause inside a line lasts longer than
@@ -606,7 +712,11 @@ Explainer defaults:
606
712
  - **Music:** `music: { style: calm }`, soft pads with no drums;
607
713
  `music: false` for none.
608
714
  - **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
715
+ video. `true` is the default and the right one for an explainer watched on a
716
+ screen: the scenes already carry text, and a burned-in line lighting up word
717
+ by word at the bottom pulls the eye off the drawing for the whole film. Burn
718
+ them for social video, for a feed that autoplays muted, or when asked — and
719
+ then say so, because it is a trade, not a free addition. Burned-in words take
610
720
  their colour from `ground:` (see "The look follows the subject"), so set it.
611
721
  - **Ground:** `ground: light | dark`, chosen for the subject and the audience,
612
722
  not inherited from the last film.
@@ -620,6 +730,47 @@ each invent one. Settle anything they disagree about (a colour that fails
620
730
  contrast, an object drawn two ways) in the kit, once, not scene by scene.
621
731
  Review every scene's stills yourself afterwards.
622
732
 
733
+ ### Portrait
734
+
735
+ `aspect: portrait` makes the video 1080×1920 — a phone held upright, which is
736
+ what most short-video feeds want. `square` is 1:1 and `landscape` (16:9) is
737
+ the default; `output:` still takes an exact size when none of them fits, and
738
+ `viewport:` now follows the output's shape unless it is given.
739
+
740
+ **Ask which feed it is for before you pick 9:16.** The tall frames are not
741
+ interchangeable. Any ratio can be written out — `aspect: "3:4"` is 1080×1440,
742
+ `aspect: "4:5"` is 1080×1350 — the short side is always 1080. 9:16 is right for
743
+ TikTok, Reels, Shorts and Douyin. **Xiaohongshu (RED) fills its feed with 3:4**
744
+ and crops a 9:16 video to it, so a 9:16 film posted there loses the top and
745
+ bottom of every frame — which, in a film that stacks its beats down a column, is
746
+ where the first and last beat live. 3:4 is a different staging problem again:
747
+ the frame is only 1.33× taller than wide while the type still has to grow for a
748
+ 1080-wide frame, so it holds *less* than 16:9 did. Cut harder.
749
+
750
+ A portrait film is **not a 16:9 film resized**. The frame is 1.8× taller than
751
+ it is wide, so rebuild each scene rather than scaling it:
752
+
753
+ - **One column, read top to bottom.** A 16:9 scene puts the object on one side
754
+ and its labels on the other. In portrait that side-by-side has nowhere to go:
755
+ stack it instead — the object, then the label under it, then the next beat
756
+ under that. A scene that is a left-to-right comparison in 16:9 becomes a
757
+ top-and-bottom one.
758
+ - **Fewer things, bigger.** 1080 px of width is half what you had. Carry one
759
+ idea per screenful, and give the subject the middle of the frame, where a
760
+ thumb is not.
761
+ - **Type goes up, not down.** The 28 px floor is measured at 1080p, so in
762
+ portrait it is a *width* floor: 28 px across 1080 px reads like 50 px across
763
+ 1920. Titles 92–120 px, leads 52–64 px, labels 36–40 px.
764
+ - **Keep the ends clear.** Feeds put a caption, a handle and buttons over the
765
+ bottom fifth and a status bar over the top. Hold the content between about
766
+ 12% and 80% of the height. With `subtitles: burn`, `S.safeBottom` is already
767
+ 22% in portrait (16% in landscape) — keep above it.
768
+ - **Burn the subtitles.** A portrait film is usually watched in a feed that
769
+ autoplays muted, which is the one case where `subtitles: burn` earns its
770
+ cost. Say so when you do it.
771
+ - Each scene's root still carries `data-width="1080" data-height="1920"`, and
772
+ `storyshot check` tells you when it does not match the video.
773
+
623
774
  ## 4. Render, look, fix
624
775
 
625
776
  1. `render_video` (use `draft: true` while iterating if the voice is
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
@@ -36,7 +36,7 @@ export function narrationLint(text) {
36
36
  return { tells, long };
37
37
  }
38
38
 
39
- const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'intro', 'frame', 'pace', 'follow', 'article', 'cards', 'ground', 'lang', 'colorScheme',
39
+ const TOP = ['name', 'url', 'out', 'purpose', 'aspect', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'intro', 'frame', 'pace', 'follow', 'article', 'cards', 'ground', 'lang', 'colorScheme',
40
40
  'scrollOffset', 'captionPosition', 'captions', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card', 'hyperframes', 'scenes'];
41
41
  const STEP = ['caption', 'say', 'sayAt', 'duration', 'zoom', 'actions', 'hidden', 'sfx', 'highlight', 'captionPosition', 'card', 'follow', 'doc', 'scene'];
42
42
  const SCENE = ['file', 'title', 'lead', 'tail'];
@@ -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
@@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { renderHyperframesCards } from './hyperframes.mjs';
8
- import { sceneSeconds, filmGround } from './scenes.mjs';
8
+ import { sceneSeconds, filmGround, mixColour, shadeColour } from './scenes.mjs';
9
9
 
10
10
  const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
11
11
  const md = (s, accent) => esc(s).replace(/\*\*(.+?)\*\*/g, `<b style="color:${accent};font-weight:700">$1</b>`);
@@ -388,11 +388,18 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
388
388
  // draw on a dark ground get dark cards, so a dark film doesn't cut to a white card; `ground: light |
389
389
  // dark` says it outright and `cards: light | dark` overrides the cards alone
390
390
  const darkCards = cfg.cards === 'dark' || (cfg.cards !== 'light' && filmGround(cfg) === 'dark');
391
- const CK = darkCards
392
- ? { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#1e2a44 0%,#111830 60%,#0b1020 100%)`, title: '#f1f5f9', sub: '#cbd5e1', muted: '#94a3b8', item: '#e2e8f0', track: '#26314d',
393
- pillBg: '#1a2238', pillFg: '#e2e8f0', pillLine: '1px solid #33405f', ctaBg: accent, ctaFg: ink, shadow: 'rgba(0,0,0,.45)' }
394
- : { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#ffffff 0%,#eef2f7 60%,#e2e8f0 100%)`, title: ink, sub: '#334155', muted: '#64748b', item: '#1e293b', track: '#e2e8f0',
395
- pillBg: ink, pillFg: '#e2e8f0', pillLine: 'none', ctaBg: ink, ctaFg: '#fff', shadow: 'rgba(15,23,42,.25)' };
391
+ // `theme.paper`: the film's own ground. Every card tone is then mixed from that paper and the ink,
392
+ // so a film drawn on warm paper does not open on a cold blue-white title card.
393
+ const paper = cfg.theme.paper;
394
+ const CK = paper
395
+ ? { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,${shadeColour(paper, darkCards ? -0.07 : 0.08)} 0%,${paper} 58%,${shadeColour(paper, darkCards ? 0.05 : -0.07)} 100%)`,
396
+ title: ink, sub: mixColour(ink, paper, 0.24), muted: mixColour(ink, paper, 0.46), item: mixColour(ink, paper, 0.12), track: mixColour(ink, paper, 0.76),
397
+ pillBg: ink, pillFg: paper, pillLine: 'none', ctaBg: ink, ctaFg: paper, shadow: `${mixColour(ink, '#000000', 0.3)}40` }
398
+ : darkCards
399
+ ? { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#1e2a44 0%,#111830 60%,#0b1020 100%)`, title: '#f1f5f9', sub: '#cbd5e1', muted: '#94a3b8', item: '#e2e8f0', track: '#26314d',
400
+ pillBg: '#1a2238', pillFg: '#e2e8f0', pillLine: '1px solid #33405f', ctaBg: accent, ctaFg: ink, shadow: 'rgba(0,0,0,.45)' }
401
+ : { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#ffffff 0%,#eef2f7 60%,#e2e8f0 100%)`, title: ink, sub: '#334155', muted: '#64748b', item: '#1e293b', track: '#e2e8f0',
402
+ pillBg: ink, pillFg: '#e2e8f0', pillLine: 'none', ctaBg: ink, ctaFg: '#fff', shadow: 'rgba(15,23,42,.25)' };
396
403
  const card = (c, logoPath, { thinkAt = 0 } = {}) => {
397
404
  const image = c.imageFile && fs.existsSync(c.imageFile)
398
405
  ? `<img class="a rise" src="data:image/${path.extname(c.imageFile).slice(1).replace('jpg', 'jpeg').replace('svg', 'svg+xml')};base64,${fs.readFileSync(c.imageFile).toString('base64')}" style="animation-delay:.4s;max-width:${W * 0.6}px;max-height:${H * 0.42}px;border-radius:${12 * H / 1080}px;box-shadow:0 ${12 * H / 1080}px ${36 * H / 1080}px rgba(15,23,42,.18)">` : '';
@@ -557,7 +564,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
557
564
  // doesn't render) the built-in ones. A launch outro is drawn with transparency over the last frame,
558
565
  // which the ffmpeg pass below zooms into the page.
559
566
  const hf = cfg.hyperframes || cards.some((c) => c.scene) ? await renderHyperframesCards(hfJobs(), {
560
- W, H, FPS, dark: darkCards, accent, ink, font, lang: cfg.lang, labels: cfg.labels, opts: cfg.hyperframes, work: cfg.work, subs: cfg.subtitles === 'burn',
567
+ W, H, FPS, dark: darkCards, accent, ink, paper, font, lang: cfg.lang, labels: cfg.labels, opts: cfg.hyperframes, work: cfg.work, subs: cfg.subtitles === 'burn',
561
568
  frame: { FX, FY, FW, FH, ...Object.fromEntries([['frameBg', 'frame-bg.png'], ['chrome', 'frame-chrome.png'], ['url', 'frame-url0.png']]
562
569
  .map(([k, f]) => [k, cfg.frame && fs.existsSync(path.join(cfg.work, f)) ? path.join(cfg.work, f) : null])) },
563
570
  }, { log, warn }) : {};
@@ -659,7 +666,12 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
659
666
  `[${last}][in]overlay=0:0:eof_action=pass[v${++idx}];\n`;
660
667
  last = `v${idx}`;
661
668
  }
662
- graph += `[${last}]trim=duration=${TOTAL},format=yuv420p[out]`;
669
+ // The picture needs a full stop too. Without one the file simply ceases on whatever was on screen —
670
+ // which in an explainer is the recap, the busiest frame of the film, held still and then cut. An end
671
+ // card is already an ending; otherwise settle to the film's own ground over the last of the tail.
672
+ const OUTRO = ec ? 0 : Math.min(0.9, TOTAL * 0.02);
673
+ const outro = OUTRO > 0.05 ? `,fade=out:st=${(TOTAL - OUTRO).toFixed(3)}:d=${OUTRO.toFixed(3)}:color=${filmGround(cfg) === 'light' ? 'white' : 'black'}` : '';
674
+ graph += `[${last}]trim=duration=${TOTAL},format=yuv420p${outro}[out]`;
663
675
  if (clean) graph += `;\n[vclean0]trim=duration=${TOTAL},format=yuv420p[clean]`;
664
676
  const graphFile = path.join(cfg.work, 'graph.txt');
665
677
  fs.writeFileSync(graphFile, graph);
package/src/config.mjs CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import YAML from 'yaml';
5
5
  import { VOICE_DEFAULTS, kokoroVoiceFor, qwenVoiceFor } from './voice.mjs';
6
6
  import { QWEN_VOICES, qwenSupported, qwenVoice } from './qwen-tts.mjs';
7
- import { SCENE_DEFAULTS } from './scenes.mjs';
7
+ import { SCENE_DEFAULTS, toRgb } from './scenes.mjs';
8
8
 
9
9
  // Built-in labels drawn into the video, by `lang` (e.g. es, es-MX); other languages get English
10
10
  export const LABELS = {
@@ -54,6 +54,27 @@ export const PACES = {
54
54
 
55
55
  export const FRAME_STYLES = ['aurora', 'studio', 'dark', 'pastel', 'blur'];
56
56
 
57
+ // The shape of the video, as one word in the storyboard: `aspect: portrait`. Portrait is a phone held
58
+ // upright and is what every short-video feed wants; square still plays either way round.
59
+ export const ASPECTS = { landscape: [1920, 1080], portrait: [1080, 1920], square: [1080, 1080] };
60
+ export const ASPECT_ALIASES = {
61
+ '16:9': 'landscape', wide: 'landscape', horizontal: 'landscape',
62
+ '9:16': 'portrait', vertical: 'portrait', tall: 'portrait',
63
+ '1:1': 'square',
64
+ };
65
+ // Any other shape is written as the ratio itself — `aspect: "3:4"` → 1080x1440, `aspect: "4:5"` →
66
+ // 1080x1350 — because the feeds do not all want the same tall frame: a 9:16 video is cropped in
67
+ // Xiaohongshu's feed, where 3:4 is what fills it. The short side is always 1080, and both sides come
68
+ // out even, which h.264 requires.
69
+ export function aspectFrame(ratio) {
70
+ const m = String(ratio).match(/^\s*(\d+(?:\.\d+)?)\s*[:x/]\s*(\d+(?:\.\d+)?)\s*$/);
71
+ const [w, h] = [Number(m?.[1]), Number(m?.[2])];
72
+ if (!m || !(w > 0) || !(h > 0)) return null;
73
+ const even = (n) => 2 * Math.round(n / 2);
74
+ return w >= h ? [even((1080 * w) / h), 1080] : [1080, even((1080 * h) / w)];
75
+ }
76
+ export const orientationOf = (w, h) => (h > w ? 'portrait' : h === w ? 'square' : 'landscape');
77
+
57
78
  export const VIDEO_DIR = 'videos';
58
79
  export const KINDS = { pitch: 'promo', tutorial: 'tutorial', course: 'lesson', docs: 'docs', explainer: 'explainer' };
59
80
  // pxgenius.ai → pxgenius; an IP or a port is kept whole so two internal apps don't share a name
@@ -94,11 +115,29 @@ export function loadConfig(file, { draft = false } = {}) {
94
115
  // only a storyboard made entirely of scenes has nothing to record
95
116
  if (!raw.url && !raw.steps.every((s) => s?.scene || s?.hidden)) throw new Error('demo.yaml: `url` is required (only a storyboard whose steps are all scenes can leave it out)');
96
117
 
97
- const viewport = { width: 1600, height: 900, ...raw.viewport };
98
- const output = { width: 1920, height: 1080, fps: 30, ...raw.output };
118
+ // aspect: the shape of the video in one word — landscape (16:9, the default), portrait (9:16: a
119
+ // phone held upright, and most short-video feeds) or square (1:1) — or as a ratio, `3:4` for the
120
+ // shape Xiaohongshu's feed fills. `output` still wins when it is given outright, so an unusual
121
+ // size stays possible.
122
+ const aspectName = raw.aspect == null ? null : (ASPECT_ALIASES[String(raw.aspect).toLowerCase()] ?? String(raw.aspect).toLowerCase());
123
+ const aspectSize = aspectName == null ? null : (ASPECTS[aspectName] ?? aspectFrame(aspectName));
124
+ if (aspectName && !aspectSize) throw new Error(`aspect must be one of ${Object.keys(ASPECTS).join(', ')}, or a ratio like "3:4" (16:9, 9:16 and 1:1 also work)`);
125
+ const [AW, AH] = aspectSize ?? ASPECTS.landscape;
126
+ const output = { width: AW, height: AH, fps: 30, ...raw.output };
127
+ // The browser viewport is only what a recording is captured at, and it has to share the output's
128
+ // aspect ratio. Unless it is given, it follows the output — so a portrait or square video never
129
+ // asks for that arithmetic, and a storyboard of scenes (which records nothing) never mentions it.
130
+ const long = 1600;
131
+ const viewport = {
132
+ ...(output.width >= output.height
133
+ ? { width: long, height: Math.round((long * output.height) / output.width) }
134
+ : { width: Math.round((long * output.width) / output.height), height: long }),
135
+ ...raw.viewport,
136
+ };
99
137
  const scale = output.width / viewport.width;
100
138
  if (Math.abs(viewport.height * scale - output.height) > 1) {
101
- throw new Error(`viewport ${viewport.width}x${viewport.height} and output ${output.width}x${output.height} must have the same aspect ratio`);
139
+ throw new Error(`viewport ${viewport.width}x${viewport.height} and output ${output.width}x${output.height} must have the same aspect ratio`
140
+ + ' (leave `viewport` out and it follows the output)');
102
141
  }
103
142
 
104
143
  const purpose = raw.purpose ?? 'pitch';
@@ -199,10 +238,18 @@ export function loadConfig(file, { draft = false } = {}) {
199
238
  for (const c of clips) if (!['gif', 'webm', 'mp4', 'png'].includes(c)) throw new Error('clips must be gif, webm, mp4 or png (or a list of them)');
200
239
  if (music?.file) music.file = path.resolve(dir, music.file);
201
240
  if (music?.style && !['calm'].includes(music.style)) throw new Error('music.style must be calm (or leave it out for the upbeat synth)');
241
+ if (raw.theme?.paper != null && !toRgb(raw.theme.paper)) throw new Error('theme.paper is the film\'s background colour, as a hex or rgb() value (e.g. "#e9ddc4")');
202
242
 
203
243
  // scenes: { css: [files], js: [files] } shared by every scene (a palette, drawing helpers), loaded before each one
204
244
  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)) };
245
+ // `three: true` adds storyshot's own drawn-3D helpers (three.js + Kit3) before the storyboard's
246
+ // own kit files, for scenes whose subject is an object rather than a quantity (guide: "3D scenes").
247
+ 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)');
248
+ const sceneKit = {
249
+ css: [].concat(sk.css ?? []).map((f) => path.resolve(dir, f)),
250
+ js: [].concat(sk.js ?? []).map((f) => path.resolve(dir, f)),
251
+ three: sk.three === true,
252
+ };
206
253
  return {
207
254
  name,
208
255
  purpose,
@@ -224,7 +271,13 @@ export function loadConfig(file, { draft = false } = {}) {
224
271
  scrollOffset: raw.scrollOffset ?? 40,
225
272
  // burned-in subtitles sit at the bottom, so captions move to the top
226
273
  captionPosition: raw.captionPosition ?? (pick('subtitles') === 'burn' ? 'top' : 'bottom'),
227
- theme: { accent: '#d9f75b', ink: '#0f172a', font: 'Inter', ...raw.theme },
274
+ // theme.paper: the film's own background colour. The title, section and end cards are built on it
275
+ // instead of storyshot's default near-white, which is a cold blue-grey and cuts badly against a
276
+ // film drawn on warm paper. Leave it out and the cards keep the default ground for `ground:`.
277
+ theme: { accent: '#d9f75b', ink: '#0f172a', font: 'Inter', paper: null, ...raw.theme },
278
+ // landscape | portrait | square, for anything that lays out by the shape rather than the pixels
279
+ // which way up the finished video is, whatever got it there: a named aspect, a ratio or `output`
280
+ aspect: orientationOf(output.width, output.height),
228
281
  setup: raw.setup ?? [],
229
282
  steps,
230
283
  endCard,
@@ -16,10 +16,11 @@ import path from 'node:path';
16
16
  import { createRequire } from 'node:module';
17
17
  import os from 'node:os';
18
18
  import { fileURLToPath } from 'node:url';
19
- import { writeSceneProject, sceneAssets, fileHash } from './scenes.mjs';
19
+ import { writeSceneProject, sceneAssets, fileHash, mixColour, shadeColour } 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'];
@@ -151,6 +152,9 @@ const isCmd = (cta) => /^\$?\s*(npx|npm|pnpm|yarn|bun|pip|pipx|uv|brew|curl|git|
151
152
  const lum = (hex) => { const m = /^#?([0-9a-f]{6})$/i.exec(hex ?? ''); if (!m) return 0.5; const n = parseInt(m[1], 16); return (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; };
152
153
  // a light accent (a highlighter colour) made dark enough to draw lines and text with on white: same
153
154
  // hue, lightness capped (lower for yellows and greens, which read light at any lightness)
155
+ // Fine noise, laid over a flat or near-flat card ground so an 8-bit gradient does not contour.
156
+ const GRAIN = `url('data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="2" stitchTiles="stitch"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .055 0"/></filter><rect width="200" height="200" filter="url(#n)"/></svg>')}')`;
157
+
154
158
  const deepen = (hex) => {
155
159
  const m = /^#?([0-9a-f]{6})$/i.exec(hex ?? ''); if (!m) return '#4f46e5';
156
160
  const n = parseInt(m[1], 16), [r, g, b] = [n >> 16, (n >> 8) & 255, n & 255].map((c) => c / 255);
@@ -300,9 +304,16 @@ function lesson(job, ctx) {
300
304
  const { W, H, dark, font, opts } = ctx, u = Math.min(H, W * 1.15) / 1080, D = job.seconds, c = job.card, accent = ctx.accent, ink = ctx.ink;
301
305
  // portrait with burned-in subtitles: the subtitles sit above the bottom fifth, so the card keeps above them
302
306
  const lift = ctx.subs && H > W ? H * 0.22 : 0;
303
- const P = dark
304
- ? { ground: '#0c1222', ground2: '#070b16', glow: '#1b2744', text: '#eef2f8', muted: '#9aa8bd', line: '#2a3550', chip: '#131c31', chipLine: '#26324d', band: 0.14, shadow: 'rgba(0,0,0,.35)' }
305
- : { ground: '#eef2f7', ground2: '#dfe6ef', glow: '#ffffff', text: ink, muted: '#56667d', line: '#c6d0dc', chip: '#ffffff', chipLine: '#d6dee8', band: 0.17, shadow: 'rgba(13,27,46,.08)' };
307
+ // `theme.paper`: the film's own ground, which every card tone is then mixed from, so a film drawn on
308
+ // warm paper opens on that paper instead of storyshot's default cold blue-white.
309
+ const paper = ctx.paper;
310
+ const P = paper
311
+ ? { ground: paper, ground2: shadeColour(paper, dark ? 0.035 : -0.045), glow: shadeColour(paper, dark ? -0.045 : 0.05), text: ink,
312
+ muted: mixColour(ink, paper, 0.44), line: mixColour(ink, paper, 0.7), chip: shadeColour(paper, dark ? 0.07 : 0.06),
313
+ chipLine: mixColour(ink, paper, 0.74), band: dark ? 0.14 : 0.17, shadow: `${mixColour(ink, '#000000', 0.35)}1f` }
314
+ : dark
315
+ ? { ground: '#0c1222', ground2: '#070b16', glow: '#1b2744', text: '#eef2f8', muted: '#9aa8bd', line: '#2a3550', chip: '#131c31', chipLine: '#26324d', band: 0.14, shadow: 'rgba(0,0,0,.35)' }
316
+ : { ground: '#eef2f7', ground2: '#dfe6ef', glow: '#ffffff', text: ink, muted: '#56667d', line: '#c6d0dc', chip: '#ffffff', chipLine: '#d6dee8', band: 0.17, shadow: 'rgba(13,27,46,.08)' };
306
317
  const strong = dark ? accent : deepen(accent); // accent as text/line on this ground
307
318
  const bandText = opts.band ? String(opts.band).replace(/\s+/g, '') : null;
308
319
  const colorOf = (ch) => (opts.bandColors === 'aminoacid' ? RES[ch] : opts.bandColors === 'nucleotide' ? NUC[ch] : null) ?? strong;
@@ -345,7 +356,9 @@ function lesson(job, ctx) {
345
356
 
346
357
  const css = `${fontFaces(font)}
347
358
  #stage{font-family:'${font}','StoryshotCJK',sans-serif;color:${P.text}}
348
- .bg{background:radial-gradient(${W * 0.73}px ${H * 0.88}px at 50% 40%, ${P.glow} 0%, ${P.ground} 52%, ${P.ground2} 100%)}
359
+ /* the grain is what keeps a wide, low-saturation gradient (warm paper above all) from banding into
360
+ visible rings once the frames are written as JPEG */
361
+ .bg{background:${paper ? `${GRAIN}, ` : ''}radial-gradient(${W * 0.73}px ${H * 0.88}px at 50% 40%, ${P.glow} 0%, ${P.ground} 52%, ${P.ground2} 100%)}
349
362
  .backdrop{object-fit:cover;opacity:${dark ? 0.16 : 0.1};-webkit-mask-image:radial-gradient(ellipse 60% 60% at 50% 50%,#000 35%,transparent 100%)}
350
363
  .band-wrap{position:absolute;left:0;right:0;height:${80 * u}px;overflow:hidden;opacity:${P.band};-webkit-mask-image:linear-gradient(90deg,transparent,#000 18%,#000 82%,transparent)}
351
364
  .band{position:absolute;left:0;top:0;white-space:nowrap;font:500 ${54 * u}px 'IBM Plex Mono',monospace;letter-spacing:.3em}
@@ -591,7 +604,10 @@ blurIn('#try', ${n(ctaAt + 0.85)}, { d: 0.25 });` : ''}
591
604
  // --- rendering ---------------------------------------------------------------------------------------
592
605
  const hashOf = (...parts) => {
593
606
  const h = crypto.createHash('sha1');
594
- for (const p of parts) h.update(typeof p === 'string' ? p : JSON.stringify(p));
607
+ // JSON.stringify(undefined) is undefined, not a string, and Hash.update throws on it. A scene step
608
+ // with a `duration:` and no `say:` line is legal and hashes `job.say` as undefined, so keep a name
609
+ // for the empty ones rather than crashing the whole render on them.
610
+ for (const p of parts) h.update(typeof p === 'string' ? p : JSON.stringify(p) ?? String(p));
595
611
  return h.digest('hex').slice(0, 16);
596
612
  };
597
613
 
@@ -622,6 +638,9 @@ export async function renderHyperframesCards(jobs, ctx, { log = console.log, war
622
638
  const files = sceneAssets(job.scene.file);
623
639
  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
640
  fs.existsSync(job.scene.file) ? fileHash(job.scene.file) : 'missing', ...Object.entries(files).map(([k, f]) => [k, fileHash(f)]),
641
+ // the shipped 3D helpers are part of the picture too: upgrading storyshot has to invalidate
642
+ // the cached frames of every 3D scene, or a fixed engine renders the old frames back
643
+ job.kit?.three ? ['kit3d', fileHash(KIT3D)] : 'no-three',
625
644
  ...[...(job.kit?.css ?? []), ...(job.kit?.js ?? [])].map((f) => [f, fs.existsSync(f) ? fileHash(f) : 'missing']));
626
645
  const dir = path.join(root, `${job.name}-${key}`), frames = path.join(dir, 'frames');
627
646
  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 ? 'aspect: portrait # 9:16, 1080x1920: a phone held upright, and every short-video feed.\n # Write every scene as one column: `storyshot guide` ("Portrait").\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,12 +138,18 @@ 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>
131
146
  <div id="root" data-composition-id="main" data-start="0" data-duration="${D}" data-width="${W}" data-height="${H}">
132
- <div id="${info.id}" class="clip" data-composition-id="${info.id}" data-composition-src="compositions/scene.html" data-start="0" data-duration="${D}" data-track-index="1" data-width="${W}" data-height="${H}"></div>
147
+ <!-- The host the scene mounts into carries no \`id\`, only \`data-composition-id\`, which is what
148
+ HyperFrames matches on. It used to carry the scene's id as well, and since the scene's own root
149
+ element must have that same id, every scene then had TWO elements with it in one document:
150
+ document.getElementById('<id>') returned this empty host, and a scene that appended to it put
151
+ everything outside its own styled root and rendered a blank frame. -->
152
+ <div class="clip" data-composition-id="${info.id}" data-composition-src="compositions/scene.html" data-start="0" data-duration="${D}" data-track-index="1" data-width="${W}" data-height="${H}"></div>
133
153
  ${job.preview && ctx.subs ? `<div id="storyshot-subtitle-band" data-layout-ignore style="position:absolute;left:0;right:0;bottom:0;height:${Math.round(H > W ? H * 0.22 : H * 0.16)}px;z-index:9999;background:repeating-linear-gradient(135deg,rgba(255,64,64,.18) 0 14px,rgba(255,64,64,.08) 14px 28px);border-top:3px dashed rgba(255,64,64,.9);pointer-events:none"><span style="position:absolute;right:24px;top:10px;font:600 26px/1 sans-serif;color:#ff4040">burned-in subtitles</span></div>` : ''}
134
154
  </div>
135
155
  <script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
@@ -140,15 +160,20 @@ export const fileHash = (f) => crypto.createHash('sha1').update(fs.readFileSync(
140
160
 
141
161
  // Write a scene's project into `dir` (index.html, the scene, its assets). Returns the scene info.
142
162
  export function writeSceneProject(job, ctx, dir) {
143
- const info = sceneInfo(job.scene.file, { width: ctx.W, height: ctx.H });
163
+ const info = sceneInfo(job.scene.file, { width: ctx.W, height: ctx.H, three: !!job.kit?.three });
144
164
  if (info.errors.length) throw new Error(info.errors.join('; '));
145
165
  fs.mkdirSync(path.join(dir, 'compositions'), { recursive: true });
146
166
  fs.writeFileSync(path.join(dir, 'index.html'), sceneDoc(job, ctx, info));
147
167
  fs.writeFileSync(path.join(dir, 'compositions', 'scene.html'), info.html);
168
+ // `scenes: { three: true }`: storyshot's own drawn-3D helpers, loaded before the storyboard's kit
169
+ if (job.kit?.three) fs.copyFileSync(path.join(HERE, 'scene-kit', 'kit3d.js'), path.join(dir, 'kit3d.js'));
148
170
  // 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)}`));
171
+ for (const [kind, list] of Object.entries(job.kit ?? {})) {
172
+ if (!Array.isArray(list)) continue;
173
+ for (const [k, f] of list.entries()) {
174
+ fs.mkdirSync(path.join(dir, 'kit'), { recursive: true });
175
+ fs.copyFileSync(f, path.join(dir, 'kit', `${k}-${path.basename(f)}`));
176
+ }
152
177
  }
153
178
  for (const [k, f] of Object.entries(sceneAssets(job.scene.file))) {
154
179
  fs.mkdirSync(path.dirname(path.join(dir, 'assets', k)), { recursive: true });
@@ -175,11 +200,29 @@ export function formatTimings(list) {
175
200
  ].join('\n')).join('\n\n');
176
201
  }
177
202
 
203
+ // The red, green and blue of a hex or rgb() colour, 0–255. null for anything else (a variable, an
204
+ // image, a colour function).
205
+ export function toRgb(colour) {
206
+ const hex = String(colour).match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i)?.[1], rgb = String(colour).match(/rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i);
207
+ return hex ? (hex.length === 3 ? [...hex].map((h) => parseInt(h + h, 16)) : [0, 2, 4].map((k) => parseInt(hex.slice(k, k + 2), 16))) : rgb ? rgb.slice(1, 4).map(Number) : null;
208
+ }
209
+ const hex2 = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
210
+
211
+ // Mix two colours: t = 0 is all `a`, t = 1 is all `b`. Returns a hex colour, or null if either side
212
+ // can't be read. This is how a film's own `theme.paper` becomes a whole set of card tones instead of
213
+ // storyshot's default near-white ones, which are cold against warm paper.
214
+ export function mixColour(a, b, t) {
215
+ const [x, y] = [toRgb(a), toRgb(b)];
216
+ if (!x || !y) return null;
217
+ return `#${x.map((v, i) => hex2(v + (y[i] - v) * t)).join('')}`;
218
+ }
219
+ // Lighten (amount > 0) or darken (amount < 0) a colour, 0–1.
220
+ export const shadeColour = (c, amount) => mixColour(c, amount >= 0 ? '#ffffff' : '#000000', Math.abs(amount));
221
+
178
222
  // How light a hex or rgb() colour is, 0 (black) to 1 (white). null for anything else (a variable, an
179
223
  // image, a colour function).
180
224
  export function luminance(colour) {
181
- const hex = String(colour).match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i)?.[1], rgb = String(colour).match(/rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i);
182
- const c = hex ? (hex.length === 3 ? [...hex].map((h) => parseInt(h + h, 16)) : [0, 2, 4].map((k) => parseInt(hex.slice(k, k + 2), 16))) : rgb ? rgb.slice(1, 4).map(Number) : null;
225
+ const c = toRgb(colour);
183
226
  if (!c) return null;
184
227
  const lin = c.map((v) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4));
185
228
  return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];