storyshot 0.9.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 +23 -5
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +56 -0
- package/src/__pycache__/qwen_tts.cpython-312.pyc +0 -0
- package/src/check.mjs +1 -1
- package/src/compose.mjs +14 -7
- package/src/config.mjs +51 -5
- package/src/hyperframes.mjs +17 -5
- package/src/init.mjs +1 -1
- package/src/scenes.mjs +26 -3
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
|
|
@@ -529,8 +531,24 @@ Every scene of a film shares one look, and the look belongs to the subject and
|
|
|
529
531
|
the audience: a dark instrument panel suits a mechanism explained to specialists,
|
|
530
532
|
a warm paper poster suits civics explained to nine-year-olds. Say which ground
|
|
531
533
|
you chose with `ground: light | dark`, and the burned-in subtitles and the cards
|
|
532
|
-
follow it.
|
|
533
|
-
|
|
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.
|
|
534
552
|
|
|
535
553
|
`lead` is how long the scene plays before the voice starts (default 0.5 s), and
|
|
536
554
|
`tail` is how long it holds after the voice ends (default 0.8 s). Consecutive
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storyshot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
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": {
|
|
@@ -681,6 +681,17 @@ much cheaper to show one previewed scene in the chosen style and ask.
|
|
|
681
681
|
- A glow that spills out of its box: `data-layout-allow-overflow` on the box.
|
|
682
682
|
- A label sitting on its object: `data-layout-allow-overlap` on the container.
|
|
683
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.
|
|
684
695
|
- **"The film's last line needs 2 s".** Only the video's last scene must hold
|
|
685
696
|
its final words. Elsewhere, reveal text on an earlier phrase or raise
|
|
686
697
|
`tail:`.
|
|
@@ -688,6 +699,10 @@ much cheaper to show one previewed scene in the chosen style and ask.
|
|
|
688
699
|
so they come out dark when the scenes do; `ground: light | dark` settles the
|
|
689
700
|
whole film and `cards: light | dark` overrides the cards alone. Keep
|
|
690
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.
|
|
691
706
|
|
|
692
707
|
Long narration is fine: a scene's `say:` is spoken in chunks of whole
|
|
693
708
|
sentences, and in an explainer no pause inside a line lasts longer than
|
|
@@ -715,6 +730,47 @@ each invent one. Settle anything they disagree about (a colour that fails
|
|
|
715
730
|
contrast, an object drawn two ways) in the kit, once, not scene by scene.
|
|
716
731
|
Review every scene's stills yourself afterwards.
|
|
717
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
|
+
|
|
718
774
|
## 4. Render, look, fix
|
|
719
775
|
|
|
720
776
|
1. `render_video` (use `draft: true` while iterating if the voice is
|
|
Binary file
|
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'];
|
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 }) : {};
|
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,6 +238,7 @@ 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 ?? {};
|
|
@@ -231,7 +271,13 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
231
271
|
scrollOffset: raw.scrollOffset ?? 40,
|
|
232
272
|
// burned-in subtitles sit at the bottom, so captions move to the top
|
|
233
273
|
captionPosition: raw.captionPosition ?? (pick('subtitles') === 'burn' ? 'top' : 'bottom'),
|
|
234
|
-
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),
|
|
235
281
|
setup: raw.setup ?? [],
|
|
236
282
|
steps,
|
|
237
283
|
endCard,
|
package/src/hyperframes.mjs
CHANGED
|
@@ -16,7 +16,7 @@ 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));
|
|
@@ -152,6 +152,9 @@ const isCmd = (cta) => /^\$?\s*(npx|npm|pnpm|yarn|bun|pip|pipx|uv|brew|curl|git|
|
|
|
152
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; };
|
|
153
153
|
// a light accent (a highlighter colour) made dark enough to draw lines and text with on white: same
|
|
154
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
|
+
|
|
155
158
|
const deepen = (hex) => {
|
|
156
159
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex ?? ''); if (!m) return '#4f46e5';
|
|
157
160
|
const n = parseInt(m[1], 16), [r, g, b] = [n >> 16, (n >> 8) & 255, n & 255].map((c) => c / 255);
|
|
@@ -301,9 +304,16 @@ function lesson(job, ctx) {
|
|
|
301
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;
|
|
302
305
|
// portrait with burned-in subtitles: the subtitles sit above the bottom fifth, so the card keeps above them
|
|
303
306
|
const lift = ctx.subs && H > W ? H * 0.22 : 0;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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)' };
|
|
307
317
|
const strong = dark ? accent : deepen(accent); // accent as text/line on this ground
|
|
308
318
|
const bandText = opts.band ? String(opts.band).replace(/\s+/g, '') : null;
|
|
309
319
|
const colorOf = (ch) => (opts.bandColors === 'aminoacid' ? RES[ch] : opts.bandColors === 'nucleotide' ? NUC[ch] : null) ?? strong;
|
|
@@ -346,7 +356,9 @@ function lesson(job, ctx) {
|
|
|
346
356
|
|
|
347
357
|
const css = `${fontFaces(font)}
|
|
348
358
|
#stage{font-family:'${font}','StoryshotCJK',sans-serif;color:${P.text}}
|
|
349
|
-
|
|
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%)}
|
|
350
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%)}
|
|
351
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)}
|
|
352
364
|
.band{position:absolute;left:0;top:0;white-space:nowrap;font:500 ${54 * u}px 'IBM Plex Mono',monospace;letter-spacing:.3em}
|
package/src/init.mjs
CHANGED
|
@@ -120,7 +120,7 @@ name: ${name}
|
|
|
120
120
|
purpose: explainer
|
|
121
121
|
out: ${name}.mp4
|
|
122
122
|
${zh ? 'lang: zh\nvoice: { provider: qwen, voice: dylan } # free, local (Apple Silicon); elsewhere kokoro zf_001' : 'voice: { provider: kokoro } # free, local'}
|
|
123
|
-
${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
124
|
# scenes already carry text, and \`burn\` lights the words up at the bottom
|
|
125
125
|
# of the picture, pulling the eye off the drawing for the whole film.
|
|
126
126
|
# Burn them for social video or a feed that autoplays muted, and say so.
|
package/src/scenes.mjs
CHANGED
|
@@ -144,7 +144,12 @@ ${(job.kit?.css ?? []).map((f, k) => `<link rel="stylesheet" href="kit/${k}-${pa
|
|
|
144
144
|
${(job.kit?.js ?? []).map((f, k) => `<script src="kit/${k}-${path.basename(f)}"></script>`).join('\n')}
|
|
145
145
|
</head><body>
|
|
146
146
|
<div id="root" data-composition-id="main" data-start="0" data-duration="${D}" data-width="${W}" data-height="${H}">
|
|
147
|
-
|
|
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>
|
|
148
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>` : ''}
|
|
149
154
|
</div>
|
|
150
155
|
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
|
@@ -195,11 +200,29 @@ export function formatTimings(list) {
|
|
|
195
200
|
].join('\n')).join('\n\n');
|
|
196
201
|
}
|
|
197
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
|
+
|
|
198
222
|
// How light a hex or rgb() colour is, 0 (black) to 1 (white). null for anything else (a variable, an
|
|
199
223
|
// image, a colour function).
|
|
200
224
|
export function luminance(colour) {
|
|
201
|
-
const
|
|
202
|
-
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);
|
|
203
226
|
if (!c) return null;
|
|
204
227
|
const lin = c.map((v) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4));
|
|
205
228
|
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|