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 +34 -6
- package/package.json +19 -4
- package/skills/storyshot/SKILL.md +154 -3
- package/src/__pycache__/qwen_tts.cpython-312.pyc +0 -0
- package/src/audio.mjs +25 -7
- package/src/check.mjs +3 -3
- package/src/compose.mjs +20 -8
- package/src/config.mjs +59 -6
- package/src/hyperframes.mjs +25 -6
- package/src/init.mjs +22 -8
- package/src/outputs.mjs +6 -1
- package/src/scene-kit/kit3d.js +340 -0
- package/src/scene-tools.mjs +32 -4
- package/src/scenes.mjs +52 -9
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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.
|
|
523
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
Binary file
|
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
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
69
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
package/src/hyperframes.mjs
CHANGED
|
@@ -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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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.
|
|
117
|
-
theme: { accent: "#b45309", ink: "#17181b" } # accent: the one colour that points
|
|
118
|
-
# spoken
|
|
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
|
|
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
|
|
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',
|
|
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
|
+
})();
|
package/src/scene-tools.mjs
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? {}))
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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];
|