storyshot 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/package.json +19 -4
- package/skills/storyshot/SKILL.md +98 -3
- package/src/audio.mjs +25 -7
- package/src/check.mjs +2 -2
- package/src/compose.mjs +6 -1
- package/src/config.mjs +8 -1
- package/src/hyperframes.mjs +8 -1
- 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 +26 -6
package/README.md
CHANGED
|
@@ -501,7 +501,7 @@ the scene file.
|
|
|
501
501
|
```yaml
|
|
502
502
|
purpose: explainer # scenes only: no url, nothing recorded
|
|
503
503
|
voice: { provider: qwen, voice: dylan }
|
|
504
|
-
subtitles: burn
|
|
504
|
+
subtitles: true # an .srt beside the video; `burn` puts the words on the picture (social, muted autoplay)
|
|
505
505
|
steps:
|
|
506
506
|
- scene: scenes/question.html # or { file, title, lead: 0.5, tail: 0.8 }
|
|
507
507
|
say: An intact probe carries a dye. Why doesn't it glow?
|
|
@@ -515,6 +515,16 @@ scene. A scene's narration may be a whole paragraph: it is spoken in chunks of
|
|
|
515
515
|
whole sentences, and `voice.maxPause` caps the pauses inside a line (0.55 s in
|
|
516
516
|
an explainer).
|
|
517
517
|
|
|
518
|
+
`scenes: { three: true }` adds three.js and storyshot's `Kit3` helpers, for a
|
|
519
|
+
scene whose subject is an **object** rather than a quantity: a solid is shaded
|
|
520
|
+
flat by which way it faces and its edges are found in a second normal/depth
|
|
521
|
+
pass, so it reads as a drawn figure rather than a render, and
|
|
522
|
+
`eng.part(…, { explode })` + `eng.explode(k)` give an exploded view from one
|
|
523
|
+
number. It is for the few scenes that need it — a list, a curve or a count is
|
|
524
|
+
better and sharper in HTML — and it suits a light ground, because the ink edge
|
|
525
|
+
is a drawn line on paper and a rim light on black. See "3D scenes, used
|
|
526
|
+
sparingly" in `storyshot guide`.
|
|
527
|
+
|
|
518
528
|
Every scene of a film shares one look, and the look belongs to the subject and
|
|
519
529
|
the audience: a dark instrument panel suits a mechanism explained to specialists,
|
|
520
530
|
a warm paper poster suits civics explained to nine-year-olds. Say which ground
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storyshot",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Ask your coding agent for a video of your web app: promo clips, narrated tutorials, lessons and docs GIFs from a YAML storyboard.",
|
|
5
5
|
"author": "boheling <boheling@gmail.com>",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -26,10 +26,25 @@
|
|
|
26
26
|
},
|
|
27
27
|
"keywords": [
|
|
28
28
|
"demo",
|
|
29
|
+
"demo-video",
|
|
30
|
+
"product-demo",
|
|
31
|
+
"promo-video",
|
|
29
32
|
"video",
|
|
30
|
-
"playwright",
|
|
31
33
|
"screen-recording",
|
|
32
|
-
"
|
|
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
|
|
@@ -606,7 +697,11 @@ Explainer defaults:
|
|
|
606
697
|
- **Music:** `music: { style: calm }`, soft pads with no drums;
|
|
607
698
|
`music: false` for none.
|
|
608
699
|
- **Subtitles:** `subtitles: true` writes an .srt; `burn` puts them on the
|
|
609
|
-
video
|
|
700
|
+
video. `true` is the default and the right one for an explainer watched on a
|
|
701
|
+
screen: the scenes already carry text, and a burned-in line lighting up word
|
|
702
|
+
by word at the bottom pulls the eye off the drawing for the whole film. Burn
|
|
703
|
+
them for social video, for a feed that autoplays muted, or when asked — and
|
|
704
|
+
then say so, because it is a trade, not a free addition. Burned-in words take
|
|
610
705
|
their colour from `ground:` (see "The look follows the subject"), so set it.
|
|
611
706
|
- **Ground:** `ground: light | dark`, chosen for the subject and the audience,
|
|
612
707
|
not inherited from the last film.
|
package/src/audio.mjs
CHANGED
|
@@ -44,29 +44,47 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
|
|
|
44
44
|
for (let i = 0; i < Math.min(N, f.length / 2); i++) { music[0][i] = f[2 * i] * 0.5; music[1][i] = f[2 * i + 1] * 0.5; }
|
|
45
45
|
} else if (cfg.music?.style === 'calm') {
|
|
46
46
|
// calm (explainers): slow pad chords vi–IV–I–V in C, two bars each, a soft bass under them and a
|
|
47
|
-
// sparse bell; no drums, nothing on the beat that would pull at the voice
|
|
47
|
+
// sparse bell; no drums, nothing on the beat that would pull at the voice.
|
|
48
|
+
// The film has to *end*, not stop. Truncating the loop wherever the video happens to finish leaves
|
|
49
|
+
// the last chord hanging (and faded down, which a listener hears as "turned off", not "over"), so
|
|
50
|
+
// the loop is phased to reach IV one chord before the end, the tonic lands there — under the last
|
|
51
|
+
// spoken line — and rings out through the tail: a plagal cadence, the gentlest full stop there is.
|
|
48
52
|
const BEAT = 60 / cfg.music.bpm, LEN = BEAT * 8;
|
|
49
53
|
const chords = [[57, 60, 64], [53, 57, 60], [48, 52, 55], [55, 59, 62]];
|
|
50
|
-
|
|
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
|
@@ -179,12 +179,12 @@ export function check(file, { draft = false } = {}) {
|
|
|
179
179
|
// scenes: the file is there and keeps the HyperFrames contract (the full lint runs in `storyshot scene`)
|
|
180
180
|
for (const [i, s] of cfg.steps.entries()) {
|
|
181
181
|
if (!s.scene || s.hidden) continue;
|
|
182
|
-
const r = sceneInfo(s.scene.file, cfg.output);
|
|
182
|
+
const r = sceneInfo(s.scene.file, { ...cfg.output, three: !!cfg.sceneKit?.three });
|
|
183
183
|
errors.push(...r.errors.map((e) => `step ${i + 1}: ${e}`));
|
|
184
184
|
warnings.push(...r.warnings.map((w) => `step ${i + 1}: ${w}`));
|
|
185
185
|
if (!cfg.voice && !(typeof s.duration === 'number')) errors.push(`step ${i + 1}: a scene is as long as its narration, and there is no voice: — add a voice: block, or give the scene a duration`);
|
|
186
186
|
}
|
|
187
|
-
if (raw.scenes) unknown(raw.scenes, ['css', 'js'], 'scenes');
|
|
187
|
+
if (raw.scenes) unknown(raw.scenes, ['css', 'js', 'three'], 'scenes');
|
|
188
188
|
for (const f of [...cfg.sceneKit.css, ...cfg.sceneKit.js]) if (!fs.existsSync(f)) errors.push(`scenes: ${f} does not exist`);
|
|
189
189
|
for (const f of cfg.sceneKit.css) if (!/\.css$/i.test(f)) errors.push(`scenes.css: ${path.basename(f)} is not a .css file`);
|
|
190
190
|
for (const f of cfg.sceneKit.js) if (!/\.m?js$/i.test(f)) errors.push(`scenes.js: ${path.basename(f)} is not a .js file`);
|
package/src/compose.mjs
CHANGED
|
@@ -659,7 +659,12 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
659
659
|
`[${last}][in]overlay=0:0:eof_action=pass[v${++idx}];\n`;
|
|
660
660
|
last = `v${idx}`;
|
|
661
661
|
}
|
|
662
|
-
|
|
662
|
+
// The picture needs a full stop too. Without one the file simply ceases on whatever was on screen —
|
|
663
|
+
// which in an explainer is the recap, the busiest frame of the film, held still and then cut. An end
|
|
664
|
+
// card is already an ending; otherwise settle to the film's own ground over the last of the tail.
|
|
665
|
+
const OUTRO = ec ? 0 : Math.min(0.9, TOTAL * 0.02);
|
|
666
|
+
const outro = OUTRO > 0.05 ? `,fade=out:st=${(TOTAL - OUTRO).toFixed(3)}:d=${OUTRO.toFixed(3)}:color=${filmGround(cfg) === 'light' ? 'white' : 'black'}` : '';
|
|
667
|
+
graph += `[${last}]trim=duration=${TOTAL},format=yuv420p${outro}[out]`;
|
|
663
668
|
if (clean) graph += `;\n[vclean0]trim=duration=${TOTAL},format=yuv420p[clean]`;
|
|
664
669
|
const graphFile = path.join(cfg.work, 'graph.txt');
|
|
665
670
|
fs.writeFileSync(graphFile, graph);
|
package/src/config.mjs
CHANGED
|
@@ -202,7 +202,14 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
202
202
|
|
|
203
203
|
// scenes: { css: [files], js: [files] } shared by every scene (a palette, drawing helpers), loaded before each one
|
|
204
204
|
const sk = raw.scenes ?? {};
|
|
205
|
-
|
|
205
|
+
// `three: true` adds storyshot's own drawn-3D helpers (three.js + Kit3) before the storyboard's
|
|
206
|
+
// own kit files, for scenes whose subject is an object rather than a quantity (guide: "3D scenes").
|
|
207
|
+
if (sk.three != null && typeof sk.three !== 'boolean') throw new Error('scenes.three is true or false (it loads three.js and storyshot\'s Kit3 helpers into every scene)');
|
|
208
|
+
const sceneKit = {
|
|
209
|
+
css: [].concat(sk.css ?? []).map((f) => path.resolve(dir, f)),
|
|
210
|
+
js: [].concat(sk.js ?? []).map((f) => path.resolve(dir, f)),
|
|
211
|
+
three: sk.three === true,
|
|
212
|
+
};
|
|
206
213
|
return {
|
|
207
214
|
name,
|
|
208
215
|
purpose,
|
package/src/hyperframes.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { writeSceneProject, sceneAssets, fileHash } from './scenes.mjs';
|
|
|
20
20
|
|
|
21
21
|
export const HF_VERSION = '0.8.217';
|
|
22
22
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const KIT3D = path.join(HERE, 'scene-kit', 'kit3d.js');
|
|
23
24
|
// families HyperFrames resolves by name; any other needs an @font-face
|
|
24
25
|
const BUNDLED = ['Inter', 'Montserrat', 'Outfit', 'Nunito', 'Oswald', 'League Gothic', 'Archivo Black', 'Space Mono', 'IBM Plex Mono', 'JetBrains Mono',
|
|
25
26
|
'EB Garamond', 'Playfair Display', 'Source Code Pro', 'Noto Sans JP', 'Roboto', 'Open Sans', 'Lato', 'Poppins'];
|
|
@@ -591,7 +592,10 @@ blurIn('#try', ${n(ctaAt + 0.85)}, { d: 0.25 });` : ''}
|
|
|
591
592
|
// --- rendering ---------------------------------------------------------------------------------------
|
|
592
593
|
const hashOf = (...parts) => {
|
|
593
594
|
const h = crypto.createHash('sha1');
|
|
594
|
-
|
|
595
|
+
// JSON.stringify(undefined) is undefined, not a string, and Hash.update throws on it. A scene step
|
|
596
|
+
// with a `duration:` and no `say:` line is legal and hashes `job.say` as undefined, so keep a name
|
|
597
|
+
// for the empty ones rather than crashing the whole render on them.
|
|
598
|
+
for (const p of parts) h.update(typeof p === 'string' ? p : JSON.stringify(p) ?? String(p));
|
|
595
599
|
return h.digest('hex').slice(0, 16);
|
|
596
600
|
};
|
|
597
601
|
|
|
@@ -622,6 +626,9 @@ export async function renderHyperframesCards(jobs, ctx, { log = console.log, war
|
|
|
622
626
|
const files = sceneAssets(job.scene.file);
|
|
623
627
|
const key = hashOf(HF_VERSION, ctx.FPS, job.seconds.toFixed(3), job.lead, job.cues, job.say, ctx.W, ctx.H, ctx.accent, ctx.ink, ctx.font, ctx.lang, ctx.subs,
|
|
624
628
|
fs.existsSync(job.scene.file) ? fileHash(job.scene.file) : 'missing', ...Object.entries(files).map(([k, f]) => [k, fileHash(f)]),
|
|
629
|
+
// the shipped 3D helpers are part of the picture too: upgrading storyshot has to invalidate
|
|
630
|
+
// the cached frames of every 3D scene, or a fixed engine renders the old frames back
|
|
631
|
+
job.kit?.three ? ['kit3d', fileHash(KIT3D)] : 'no-three',
|
|
625
632
|
...[...(job.kit?.css ?? []), ...(job.kit?.js ?? [])].map((f) => [f, fs.existsSync(f) ? fileHash(f) : 'missing']));
|
|
626
633
|
const dir = path.join(root, `${job.name}-${key}`), frames = path.join(dir, 'frames');
|
|
627
634
|
if (fs.existsSync(path.join(dir, 'done'))) { out[job.name] = { dir: frames, ext: 'jpg', alpha: false }; continue; }
|
package/src/init.mjs
CHANGED
|
@@ -97,10 +97,21 @@ export function initExplainer({ topic, file, lang = 'en', portrait = false } = {
|
|
|
97
97
|
const dir = path.dirname(path.resolve(file));
|
|
98
98
|
const zh = /^zh/i.test(lang);
|
|
99
99
|
const [W, H] = portrait ? [1080, 1920] : [1920, 1080];
|
|
100
|
+
// Four steps, not three: a recap re-counts what the film said and then runs out, which a viewer hears
|
|
101
|
+
// as the film being cut off. The last step is the ending itself (`storyshot guide`: "How an explainer ends").
|
|
100
102
|
const scenes = [1, 2, 3].map((k) => ({ id: `${name}-s${k}`.replace(/[^a-z0-9-]/gi, '-'), rel: `scenes/${name}-${k}.html` }));
|
|
103
|
+
scenes.push({ id: `${name}-close`.replace(/[^a-z0-9-]/gi, '-'), rel: `scenes/${name}-close.html` });
|
|
101
104
|
const say = zh
|
|
102
|
-
? ['TODO 一个观众会问的问题,一两句话。', 'TODO 用一个具体例子回答,每个短句对应画面上的一个变化。', 'TODO
|
|
103
|
-
|
|
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:
|
|
123
|
+
${portrait ? `output: { width: ${W}, height: ${H} }\nviewport: { width: 900, height: 1600 }\n` : ''}subtitles: true # an .srt beside the video, which is the right one for an explainer: the
|
|
124
|
+
# scenes already carry text, and \`burn\` lights the words up at the bottom
|
|
125
|
+
# of the picture, pulling the eye off the drawing for the whole film.
|
|
126
|
+
# Burn them for social video or a feed that autoplays muted, and say so.
|
|
113
127
|
ground: light # light | dark: the film's ground, chosen for the subject and its audience
|
|
114
128
|
# (\`storyshot guide\`: "The look follows the subject"). Paper for a subject that
|
|
115
129
|
# is taught, printed or voted on; dark for a mechanism, an instrument or anything
|
|
116
|
-
# that glows.
|
|
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,7 +138,8 @@ html,body{width:${W}px;height:${H}px;overflow:hidden;background:#000}
|
|
|
124
138
|
#root{position:relative;width:100%;height:100%;font-family:var(--font)}
|
|
125
139
|
</style>
|
|
126
140
|
<script>${sceneRuntime(job, ctx)}
|
|
127
|
-
${GSAP_GUARD}</script>
|
|
141
|
+
${GSAP_GUARD}${job.kit?.three ? `\n${THREE_QUIET}` : ''}</script>
|
|
142
|
+
${job.kit?.three ? `<script src="${THREE_URL}"></script>\n<script src="kit3d.js"></script>` : ''}
|
|
128
143
|
${(job.kit?.css ?? []).map((f, k) => `<link rel="stylesheet" href="kit/${k}-${path.basename(f)}">`).join('\n')}
|
|
129
144
|
${(job.kit?.js ?? []).map((f, k) => `<script src="kit/${k}-${path.basename(f)}"></script>`).join('\n')}
|
|
130
145
|
</head><body>
|
|
@@ -140,15 +155,20 @@ export const fileHash = (f) => crypto.createHash('sha1').update(fs.readFileSync(
|
|
|
140
155
|
|
|
141
156
|
// Write a scene's project into `dir` (index.html, the scene, its assets). Returns the scene info.
|
|
142
157
|
export function writeSceneProject(job, ctx, dir) {
|
|
143
|
-
const info = sceneInfo(job.scene.file, { width: ctx.W, height: ctx.H });
|
|
158
|
+
const info = sceneInfo(job.scene.file, { width: ctx.W, height: ctx.H, three: !!job.kit?.three });
|
|
144
159
|
if (info.errors.length) throw new Error(info.errors.join('; '));
|
|
145
160
|
fs.mkdirSync(path.join(dir, 'compositions'), { recursive: true });
|
|
146
161
|
fs.writeFileSync(path.join(dir, 'index.html'), sceneDoc(job, ctx, info));
|
|
147
162
|
fs.writeFileSync(path.join(dir, 'compositions', 'scene.html'), info.html);
|
|
163
|
+
// `scenes: { three: true }`: storyshot's own drawn-3D helpers, loaded before the storyboard's kit
|
|
164
|
+
if (job.kit?.three) fs.copyFileSync(path.join(HERE, 'scene-kit', 'kit3d.js'), path.join(dir, 'kit3d.js'));
|
|
148
165
|
// the storyboard's scene kit, shared by every scene
|
|
149
|
-
for (const [kind, list] of Object.entries(job.kit ?? {}))
|
|
150
|
-
|
|
151
|
-
|
|
166
|
+
for (const [kind, list] of Object.entries(job.kit ?? {})) {
|
|
167
|
+
if (!Array.isArray(list)) continue;
|
|
168
|
+
for (const [k, f] of list.entries()) {
|
|
169
|
+
fs.mkdirSync(path.join(dir, 'kit'), { recursive: true });
|
|
170
|
+
fs.copyFileSync(f, path.join(dir, 'kit', `${k}-${path.basename(f)}`));
|
|
171
|
+
}
|
|
152
172
|
}
|
|
153
173
|
for (const [k, f] of Object.entries(sceneAssets(job.scene.file))) {
|
|
154
174
|
fs.mkdirSync(path.dirname(path.join(dir, 'assets', k)), { recursive: true });
|