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 CHANGED
@@ -259,9 +259,11 @@ by hand.
259
259
  ```yaml
260
260
  url: https://example.com
261
261
  out: acme-promo.mp4 # relative to this file; default: <file name>.mp4
262
- viewport: { width: 1600, height: 900 } # CSS px the site is laid out at
263
- output: { width: 1920, height: 1080, fps: 30 } # same aspect ratio; use 1080x1920 with a 900x1600 viewport for vertical
264
- theme: { accent: "#d9f75b", ink: "#0f172a", font: Inter }
262
+ aspect: landscape # landscape (16:9, default) | portrait (9:16, 1080x1920: a phone upright, and most short-video feeds) | square (1:1). 16:9, 9:16 and 1:1 work too,
263
+ # and so does any other ratio: "3:4" is 1080x1440 (what Xiaohongshu fills), "4:5" is 1080x1350. The short side is always 1080
264
+ viewport: { width: 1600, height: 900 } # CSS px the site is laid out at; leave it out and it follows the output's shape
265
+ output: { width: 1920, height: 1080, fps: 30 } # an exact size, when `aspect` is not the shape you want; the viewport must share its aspect ratio
266
+ theme: { accent: "#d9f75b", ink: "#0f172a", font: Inter, paper: "#e9ddc4" } # paper: the film's own background. The intro, section and end cards are built on it instead of the default near-white, which is cold against a warm film. Leave it out and the cards follow `ground:`
265
267
  music: { bpm: 120, volume: 1 } # or { file: track.mp3 }, or false
266
268
  sfx: true # click / whoosh / riser sounds; clicks = no whooshes on cards and page changes, and no rising sound under a riser step, only its chime (tutorial and course default); false = none
267
269
  voice: { provider: elevenlabs, voice: hpp4J3VqNfWAUOO0d1Us } # optional voiceover, see below
@@ -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. The playbook's "The look follows the subject" has the styles and what
533
- each ground costs.
534
+ follow it. When the film is drawn on a *colour* rather than on plain light or
535
+ dark, give it as `theme: { paper: "#e9ddc4" }` and the title, section and end
536
+ cards are built on that ground instead of the default near-white, which is a
537
+ cold blue-grey and reads as a different film. The playbook's "The look follows
538
+ the subject" has the styles and what each ground costs.
539
+
540
+ `aspect: portrait` makes the video 1080x1920 — a phone held upright, and what
541
+ most short-video feeds want (`square` is 1:1, `landscape` 16:9 is the default,
542
+ and `output:` still takes an exact size). The feeds do not all want the same
543
+ tall frame, so any ratio works written out: `aspect: "3:4"` is 1080x1440, which
544
+ is the shape Xiaohongshu's feed fills and the one it crops a 9:16 video down
545
+ to, and `aspect: "4:5"` is 1080x1350 for an Instagram feed. The short side is
546
+ always 1080 and both sides come out even. A portrait film is not a landscape
547
+ film resized: a tall frame reads top to bottom, so a scene that puts an object
548
+ beside its labels stacks them instead, and a side-by-side comparison becomes an
549
+ upper and a lower half. Each scene's root carries `data-width="1080"
550
+ data-height="1920"`, and `storyshot check` says so when it does not match. The
551
+ playbook's "Portrait" has the type scale and the margins a feed covers.
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.9.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
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
11
11
  const md = (s, accent) => esc(s).replace(/\*\*(.+?)\*\*/g, `<b style="color:${accent};font-weight:700">$1</b>`);
@@ -388,11 +388,18 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
388
388
  // draw on a dark ground get dark cards, so a dark film doesn't cut to a white card; `ground: light |
389
389
  // dark` says it outright and `cards: light | dark` overrides the cards alone
390
390
  const darkCards = cfg.cards === 'dark' || (cfg.cards !== 'light' && filmGround(cfg) === 'dark');
391
- const CK = darkCards
392
- ? { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#1e2a44 0%,#111830 60%,#0b1020 100%)`, title: '#f1f5f9', sub: '#cbd5e1', muted: '#94a3b8', item: '#e2e8f0', track: '#26314d',
393
- pillBg: '#1a2238', pillFg: '#e2e8f0', pillLine: '1px solid #33405f', ctaBg: accent, ctaFg: ink, shadow: 'rgba(0,0,0,.45)' }
394
- : { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#ffffff 0%,#eef2f7 60%,#e2e8f0 100%)`, title: ink, sub: '#334155', muted: '#64748b', item: '#1e293b', track: '#e2e8f0',
395
- pillBg: ink, pillFg: '#e2e8f0', pillLine: 'none', ctaBg: ink, ctaFg: '#fff', shadow: 'rgba(15,23,42,.25)' };
391
+ // `theme.paper`: the film's own ground. Every card tone is then mixed from that paper and the ink,
392
+ // so a film drawn on warm paper does not open on a cold blue-white title card.
393
+ const paper = cfg.theme.paper;
394
+ const CK = paper
395
+ ? { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,${shadeColour(paper, darkCards ? -0.07 : 0.08)} 0%,${paper} 58%,${shadeColour(paper, darkCards ? 0.05 : -0.07)} 100%)`,
396
+ title: ink, sub: mixColour(ink, paper, 0.24), muted: mixColour(ink, paper, 0.46), item: mixColour(ink, paper, 0.12), track: mixColour(ink, paper, 0.76),
397
+ pillBg: ink, pillFg: paper, pillLine: 'none', ctaBg: ink, ctaFg: paper, shadow: `${mixColour(ink, '#000000', 0.3)}40` }
398
+ : darkCards
399
+ ? { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#1e2a44 0%,#111830 60%,#0b1020 100%)`, title: '#f1f5f9', sub: '#cbd5e1', muted: '#94a3b8', item: '#e2e8f0', track: '#26314d',
400
+ pillBg: '#1a2238', pillFg: '#e2e8f0', pillLine: '1px solid #33405f', ctaBg: accent, ctaFg: ink, shadow: 'rgba(0,0,0,.45)' }
401
+ : { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#ffffff 0%,#eef2f7 60%,#e2e8f0 100%)`, title: ink, sub: '#334155', muted: '#64748b', item: '#1e293b', track: '#e2e8f0',
402
+ pillBg: ink, pillFg: '#e2e8f0', pillLine: 'none', ctaBg: ink, ctaFg: '#fff', shadow: 'rgba(15,23,42,.25)' };
396
403
  const card = (c, logoPath, { thinkAt = 0 } = {}) => {
397
404
  const image = c.imageFile && fs.existsSync(c.imageFile)
398
405
  ? `<img class="a rise" src="data:image/${path.extname(c.imageFile).slice(1).replace('jpg', 'jpeg').replace('svg', 'svg+xml')};base64,${fs.readFileSync(c.imageFile).toString('base64')}" style="animation-delay:.4s;max-width:${W * 0.6}px;max-height:${H * 0.42}px;border-radius:${12 * H / 1080}px;box-shadow:0 ${12 * H / 1080}px ${36 * H / 1080}px rgba(15,23,42,.18)">` : '';
@@ -557,7 +564,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
557
564
  // doesn't render) the built-in ones. A launch outro is drawn with transparency over the last frame,
558
565
  // which the ffmpeg pass below zooms into the page.
559
566
  const hf = cfg.hyperframes || cards.some((c) => c.scene) ? await renderHyperframesCards(hfJobs(), {
560
- W, H, FPS, dark: darkCards, accent, ink, font, lang: cfg.lang, labels: cfg.labels, opts: cfg.hyperframes, work: cfg.work, subs: cfg.subtitles === 'burn',
567
+ W, H, FPS, dark: darkCards, accent, ink, paper, font, lang: cfg.lang, labels: cfg.labels, opts: cfg.hyperframes, work: cfg.work, subs: cfg.subtitles === 'burn',
561
568
  frame: { FX, FY, FW, FH, ...Object.fromEntries([['frameBg', 'frame-bg.png'], ['chrome', 'frame-chrome.png'], ['url', 'frame-url0.png']]
562
569
  .map(([k, f]) => [k, cfg.frame && fs.existsSync(path.join(cfg.work, f)) ? path.join(cfg.work, f) : null])) },
563
570
  }, { log, warn }) : {};
package/src/config.mjs CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import YAML from 'yaml';
5
5
  import { VOICE_DEFAULTS, kokoroVoiceFor, qwenVoiceFor } from './voice.mjs';
6
6
  import { QWEN_VOICES, qwenSupported, qwenVoice } from './qwen-tts.mjs';
7
- import { SCENE_DEFAULTS } from './scenes.mjs';
7
+ import { SCENE_DEFAULTS, toRgb } from './scenes.mjs';
8
8
 
9
9
  // Built-in labels drawn into the video, by `lang` (e.g. es, es-MX); other languages get English
10
10
  export const LABELS = {
@@ -54,6 +54,27 @@ export const PACES = {
54
54
 
55
55
  export const FRAME_STYLES = ['aurora', 'studio', 'dark', 'pastel', 'blur'];
56
56
 
57
+ // The shape of the video, as one word in the storyboard: `aspect: portrait`. Portrait is a phone held
58
+ // upright and is what every short-video feed wants; square still plays either way round.
59
+ export const ASPECTS = { landscape: [1920, 1080], portrait: [1080, 1920], square: [1080, 1080] };
60
+ export const ASPECT_ALIASES = {
61
+ '16:9': 'landscape', wide: 'landscape', horizontal: 'landscape',
62
+ '9:16': 'portrait', vertical: 'portrait', tall: 'portrait',
63
+ '1:1': 'square',
64
+ };
65
+ // Any other shape is written as the ratio itself — `aspect: "3:4"` → 1080x1440, `aspect: "4:5"` →
66
+ // 1080x1350 — because the feeds do not all want the same tall frame: a 9:16 video is cropped in
67
+ // Xiaohongshu's feed, where 3:4 is what fills it. The short side is always 1080, and both sides come
68
+ // out even, which h.264 requires.
69
+ export function aspectFrame(ratio) {
70
+ const m = String(ratio).match(/^\s*(\d+(?:\.\d+)?)\s*[:x/]\s*(\d+(?:\.\d+)?)\s*$/);
71
+ const [w, h] = [Number(m?.[1]), Number(m?.[2])];
72
+ if (!m || !(w > 0) || !(h > 0)) return null;
73
+ const even = (n) => 2 * Math.round(n / 2);
74
+ return w >= h ? [even((1080 * w) / h), 1080] : [1080, even((1080 * h) / w)];
75
+ }
76
+ export const orientationOf = (w, h) => (h > w ? 'portrait' : h === w ? 'square' : 'landscape');
77
+
57
78
  export const VIDEO_DIR = 'videos';
58
79
  export const KINDS = { pitch: 'promo', tutorial: 'tutorial', course: 'lesson', docs: 'docs', explainer: 'explainer' };
59
80
  // pxgenius.ai → pxgenius; an IP or a port is kept whole so two internal apps don't share a name
@@ -94,11 +115,29 @@ export function loadConfig(file, { draft = false } = {}) {
94
115
  // only a storyboard made entirely of scenes has nothing to record
95
116
  if (!raw.url && !raw.steps.every((s) => s?.scene || s?.hidden)) throw new Error('demo.yaml: `url` is required (only a storyboard whose steps are all scenes can leave it out)');
96
117
 
97
- const viewport = { width: 1600, height: 900, ...raw.viewport };
98
- const output = { width: 1920, height: 1080, fps: 30, ...raw.output };
118
+ // aspect: the shape of the video in one word — landscape (16:9, the default), portrait (9:16: a
119
+ // phone held upright, and most short-video feeds) or square (1:1) — or as a ratio, `3:4` for the
120
+ // shape Xiaohongshu's feed fills. `output` still wins when it is given outright, so an unusual
121
+ // size stays possible.
122
+ const aspectName = raw.aspect == null ? null : (ASPECT_ALIASES[String(raw.aspect).toLowerCase()] ?? String(raw.aspect).toLowerCase());
123
+ const aspectSize = aspectName == null ? null : (ASPECTS[aspectName] ?? aspectFrame(aspectName));
124
+ if (aspectName && !aspectSize) throw new Error(`aspect must be one of ${Object.keys(ASPECTS).join(', ')}, or a ratio like "3:4" (16:9, 9:16 and 1:1 also work)`);
125
+ const [AW, AH] = aspectSize ?? ASPECTS.landscape;
126
+ const output = { width: AW, height: AH, fps: 30, ...raw.output };
127
+ // The browser viewport is only what a recording is captured at, and it has to share the output's
128
+ // aspect ratio. Unless it is given, it follows the output — so a portrait or square video never
129
+ // asks for that arithmetic, and a storyboard of scenes (which records nothing) never mentions it.
130
+ const long = 1600;
131
+ const viewport = {
132
+ ...(output.width >= output.height
133
+ ? { width: long, height: Math.round((long * output.height) / output.width) }
134
+ : { width: Math.round((long * output.width) / output.height), height: long }),
135
+ ...raw.viewport,
136
+ };
99
137
  const scale = output.width / viewport.width;
100
138
  if (Math.abs(viewport.height * scale - output.height) > 1) {
101
- throw new Error(`viewport ${viewport.width}x${viewport.height} and output ${output.width}x${output.height} must have the same aspect ratio`);
139
+ throw new Error(`viewport ${viewport.width}x${viewport.height} and output ${output.width}x${output.height} must have the same aspect ratio`
140
+ + ' (leave `viewport` out and it follows the output)');
102
141
  }
103
142
 
104
143
  const purpose = raw.purpose ?? 'pitch';
@@ -199,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: { accent: '#d9f75b', ink: '#0f172a', font: 'Inter', ...raw.theme },
274
+ // theme.paper: the film's own background colour. The title, section and end cards are built on it
275
+ // instead of storyshot's default near-white, which is a cold blue-grey and cuts badly against a
276
+ // film drawn on warm paper. Leave it out and the cards keep the default ground for `ground:`.
277
+ theme: { accent: '#d9f75b', ink: '#0f172a', font: 'Inter', paper: null, ...raw.theme },
278
+ // landscape | portrait | square, for anything that lays out by the shape rather than the pixels
279
+ // which way up the finished video is, whatever got it there: a named aspect, a ratio or `output`
280
+ aspect: orientationOf(output.width, output.height),
235
281
  setup: raw.setup ?? [],
236
282
  steps,
237
283
  endCard,
@@ -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
- const P = dark
305
- ? { 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)' }
306
- : { ground: '#eef2f7', ground2: '#dfe6ef', glow: '#ffffff', text: ink, muted: '#56667d', line: '#c6d0dc', chip: '#ffffff', chipLine: '#d6dee8', band: 0.17, shadow: 'rgba(13,27,46,.08)' };
307
+ // `theme.paper`: the film's own ground, which every card tone is then mixed from, so a film drawn on
308
+ // warm paper opens on that paper instead of storyshot's default cold blue-white.
309
+ const paper = ctx.paper;
310
+ const P = paper
311
+ ? { ground: paper, ground2: shadeColour(paper, dark ? 0.035 : -0.045), glow: shadeColour(paper, dark ? -0.045 : 0.05), text: ink,
312
+ muted: mixColour(ink, paper, 0.44), line: mixColour(ink, paper, 0.7), chip: shadeColour(paper, dark ? 0.07 : 0.06),
313
+ chipLine: mixColour(ink, paper, 0.74), band: dark ? 0.14 : 0.17, shadow: `${mixColour(ink, '#000000', 0.35)}1f` }
314
+ : dark
315
+ ? { ground: '#0c1222', ground2: '#070b16', glow: '#1b2744', text: '#eef2f8', muted: '#9aa8bd', line: '#2a3550', chip: '#131c31', chipLine: '#26324d', band: 0.14, shadow: 'rgba(0,0,0,.35)' }
316
+ : { ground: '#eef2f7', ground2: '#dfe6ef', glow: '#ffffff', text: ink, muted: '#56667d', line: '#c6d0dc', chip: '#ffffff', chipLine: '#d6dee8', band: 0.17, shadow: 'rgba(13,27,46,.08)' };
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
- .bg{background:radial-gradient(${W * 0.73}px ${H * 0.88}px at 50% 40%, ${P.glow} 0%, ${P.ground} 52%, ${P.ground2} 100%)}
359
+ /* the grain is what keeps a wide, low-saturation gradient (warm paper above all) from banding into
360
+ visible rings once the frames are written as JPEG */
361
+ .bg{background:${paper ? `${GRAIN}, ` : ''}radial-gradient(${W * 0.73}px ${H * 0.88}px at 50% 40%, ${P.glow} 0%, ${P.ground} 52%, ${P.ground2} 100%)}
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 ? `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
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
- <div id="${info.id}" class="clip" data-composition-id="${info.id}" data-composition-src="compositions/scene.html" data-start="0" data-duration="${D}" data-track-index="1" data-width="${W}" data-height="${H}"></div>
147
+ <!-- The host the scene mounts into carries no \`id\`, only \`data-composition-id\`, which is what
148
+ HyperFrames matches on. It used to carry the scene's id as well, and since the scene's own root
149
+ element must have that same id, every scene then had TWO elements with it in one document:
150
+ document.getElementById('<id>') returned this empty host, and a scene that appended to it put
151
+ everything outside its own styled root and rendered a blank frame. -->
152
+ <div class="clip" data-composition-id="${info.id}" data-composition-src="compositions/scene.html" data-start="0" data-duration="${D}" data-track-index="1" data-width="${W}" data-height="${H}"></div>
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 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);
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];