storyshot 0.7.2 → 0.8.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/src/check.mjs CHANGED
@@ -6,6 +6,8 @@ import { gitIgnored } from './auth.mjs';
6
6
  import { isZhVoice, zhVoiceStatus } from './kokoro-zh.mjs';
7
7
  import { qwenStatus } from './qwen-tts.mjs';
8
8
  import { sentences } from './voice.mjs';
9
+ import { sceneInfo, filmGround, contrast } from './scenes.mjs';
10
+ import path from 'node:path';
9
11
 
10
12
  // Phrases that mark narration as machine-written, and sentences too long to say in one breath.
11
13
  const TELLS = [
@@ -34,9 +36,10 @@ export function narrationLint(text) {
34
36
  return { tells, long };
35
37
  }
36
38
 
37
- const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'intro', 'frame', 'pace', 'follow', 'article', 'cards', 'lang', 'colorScheme',
38
- 'scrollOffset', 'captionPosition', 'captions', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card', 'hyperframes'];
39
- const STEP = ['caption', 'say', 'sayAt', 'duration', 'zoom', 'actions', 'hidden', 'sfx', 'highlight', 'captionPosition', 'card', 'follow', 'doc'];
39
+ const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'intro', 'frame', 'pace', 'follow', 'article', 'cards', 'ground', 'lang', 'colorScheme',
40
+ 'scrollOffset', 'captionPosition', 'captions', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card', 'hyperframes', 'scenes'];
41
+ const STEP = ['caption', 'say', 'sayAt', 'duration', 'zoom', 'actions', 'hidden', 'sfx', 'highlight', 'captionPosition', 'card', 'follow', 'doc', 'scene'];
42
+ const SCENE = ['file', 'title', 'lead', 'tail'];
40
43
  const ZOOM = ['target', 'scale', 'start', 'end', 'ramp', 'offset'];
41
44
  const END = ['logo', 'title', 'subtitle', 'cta', 'duration', 'say', 'kicker', 'bullets', 'image', 'think', 'emblem'];
42
45
  const HYPERFRAMES = ['template', 'band', 'bandColors', 'emblem', 'hand', 'backdrop'];
@@ -103,9 +106,14 @@ export function check(file, { draft = false } = {}) {
103
106
  if (!Array.isArray(s.actions ?? [])) errors.push(`${where}: actions must be a list`);
104
107
  for (const [j, a] of (s.actions ?? []).entries()) checkAction(a, `${where} action ${j + 1}`);
105
108
  if (s.card) unknown(s.card, END, `${where} card`);
109
+ if (s.scene) {
110
+ if (typeof s.scene === 'object') unknown(s.scene, SCENE, `${where} scene`);
111
+ for (const k of ['caption', 'zoom', 'sayAt', 'follow', 'highlight']) if (s[k] != null) warnings.push(`${where}: ${k} does nothing on a scene (a scene draws its own picture and text)`);
112
+ for (const k of ['lead', 'tail']) if (s.scene?.[k] != null && !(typeof s.scene[k] === 'number' && s.scene[k] >= 0)) errors.push(`${where} scene: ${k} is seconds (≥ 0)`);
113
+ }
106
114
  // tutorials: one thing per step, so the viewer can follow (and the narration can explain it)
107
115
  const purpose = raw.purpose ?? 'pitch';
108
- if ((purpose === 'tutorial' || purpose === 'course') && !s.hidden) {
116
+ if ((purpose === 'tutorial' || purpose === 'course') && !s.hidden && !s.scene) {
109
117
  const doing = (s.actions ?? []).filter((a) => a && ['click', 'dblclick', 'type', 'drag', 'press', 'goto', 'select', 'slide', 'paste'].includes(Object.keys(a)[0]) && !(Object.keys(a)[0] === 'press' && /^(Enter|Tab)$/.test(String(Object.values(a)[0]))));
110
118
  if (doing.length > 2) warnings.push(`${where}: does ${doing.length} things (${doing.map((a) => Object.keys(a)[0]).join(', ')}) in one step — in a ${purpose}, split it so each action gets its own explanation and the viewer can keep up`);
111
119
  if (doing.length && !s.say) warnings.push(`${where}: does something but has no say: line — in a ${purpose}, tell the viewer what and why`);
@@ -168,6 +176,34 @@ export function check(file, { draft = false } = {}) {
168
176
  }
169
177
  let summary = '';
170
178
  if (cfg) {
179
+ // scenes: the file is there and keeps the HyperFrames contract (the full lint runs in `storyshot scene`)
180
+ for (const [i, s] of cfg.steps.entries()) {
181
+ if (!s.scene || s.hidden) continue;
182
+ const r = sceneInfo(s.scene.file, cfg.output);
183
+ errors.push(...r.errors.map((e) => `step ${i + 1}: ${e}`));
184
+ warnings.push(...r.warnings.map((w) => `step ${i + 1}: ${w}`));
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
+ }
187
+ if (raw.scenes) unknown(raw.scenes, ['css', 'js'], 'scenes');
188
+ for (const f of [...cfg.sceneKit.css, ...cfg.sceneKit.js]) if (!fs.existsSync(f)) errors.push(`scenes: ${f} does not exist`);
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
+ 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`);
191
+ const ids = cfg.steps.filter((s) => s.scene && !s.hidden).map((s) => sceneInfo(s.scene.file).id).filter(Boolean);
192
+ for (const id of new Set(ids.filter((x, k) => ids.indexOf(x) !== k))) errors.push(`two scenes share the composition id "${id}" — give each scene its own id (and prefix its element ids with it)`);
193
+ if (cfg.purpose === 'explainer' && !cfg.steps.some((s) => s.scene)) warnings.push('an explainer is made of scenes: give its steps a scene: file');
194
+ // burned-in subtitles follow the film's ground: ink letters on a light film, white on a dark one,
195
+ // with the word being spoken in the accent either way, so the accent has to carry a large text's
196
+ // 3:1 against that ground
197
+ if (cfg.subtitles === 'burn') {
198
+ const ground = filmGround(cfg), against = ground === 'light' ? '#ffffff' : '#000000';
199
+ const ratio = contrast(cfg.theme.accent, against);
200
+ if (ground && ratio != null && ratio < 3) {
201
+ warnings.push(`theme.accent (${cfg.theme.accent}) is only ${ratio}:1 against ${ground === 'light' ? 'white' : 'black'}, and the burned-in subtitles paint the word being spoken in the accent: on this ${ground} film it would barely read — ${ground === 'light' ? 'darken' : 'lighten'} the accent (large text needs 3:1)`);
202
+ }
203
+ if (ground == null && cfg.steps.some((s) => s.scene && !s.hidden)) {
204
+ warnings.push("the scenes' ground could not be read (a variable, an image, or a rule this check cannot follow), so the burned-in subtitles stay white, which needs something dark under it — say which the film is with `ground: light` or `ground: dark`");
205
+ }
206
+ }
171
207
  const visible = cfg.steps.filter((s) => !s.hidden);
172
208
  const says = visible.filter((s) => s.say).length + (cfg.endCard?.say ? 1 : 0) + (cfg.intro?.say ? 1 : 0);
173
209
  if (cfg.voice && !says) warnings.push('voice is set but no step has a `say:` line');
@@ -230,7 +266,8 @@ export function check(file, { draft = false } = {}) {
230
266
  const ec = cfg.endCard ? (cfg.endCard.duration === 'auto' ? null : cfg.endCard.duration) : 0;
231
267
  const length = auto ? `${fixed ? `${fixed.toFixed(1)}s fixed + ` : ''}${auto} steps timed to narration${cfg.endCard ? ' + end card' : ''}${cfg.intro ? ' + intro' : ''}`
232
268
  : `${fixed.toFixed(1)}s of steps${cfg.endCard ? ` + ${ec ?? 'auto'}s end card` : ''}${ec != null ? ` = ${(fixed + ec).toFixed(1)}s total` : ''}`;
233
- summary = `${cfg.name}: purpose ${cfg.purpose}, ${visible.length} visible steps, ${length}` +
269
+ const nScenes = visible.filter((s) => s.scene).length;
270
+ summary = `${cfg.name}: purpose ${cfg.purpose}, ${visible.length} visible steps${nScenes ? ` (${nScenes} scene${nScenes > 1 ? 's' : ''})` : ''}, ${length}` +
234
271
  (cfg.voice ? `, voice ${cfg.voice.provider}` : '') + ` → ${cfg.out}`;
235
272
  }
236
273
  return { ok: !errors.length, errors, warnings, summary };
package/src/compose.mjs CHANGED
@@ -5,6 +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
9
 
9
10
  const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
10
11
  const md = (s, accent) => esc(s).replace(/\*\*(.+?)\*\*/g, `<b style="color:${accent};font-weight:700">$1</b>`);
@@ -89,6 +90,16 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
89
90
  cards.push({ i, t0: acc, t1: acc + C, card: s.card, thinkAt: C - (s.card.think ?? 0) - 0.3 });
90
91
  acc += C;
91
92
  }
93
+ if (s.scene) {
94
+ // a scene is its own picture (the last frame shown holds under it) for as long as its narration needs
95
+ const C = sceneSeconds(s, voiceLens.steps[i]);
96
+ const prev = pieces.at(-1)?.raw[1] ?? r.start;
97
+ pieces.push({ raw: [prev, prev], o0: acc, o1: acc + C });
98
+ cards.push({ i, t0: acc, t1: acc + C, card: { title: s.scene.title ?? '' }, scene: s.scene, say: s.say, lead: s.scene.lead, cues: voiceLens.cues?.[i] ?? [] });
99
+ acc += C;
100
+ afterHidden = false;
101
+ continue;
102
+ }
92
103
  const g = { i, step: s, o0: acc, o1: acc + D, sayAt, zoomBox: r.zoomBox, afterHidden, hold,
93
104
  motionSpeed: M && motionBudget ? M / motionBudget : 1, waitSpeed: Wt && waitBudget ? Wt / waitBudget : 1 };
94
105
  for (const [a, b, w] of parts) {
@@ -185,7 +196,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
185
196
  const report = (to) => {
186
197
  if (from != null && to - from >= 3) {
187
198
  const ns = segs.filter((x) => x.o1 > from + 1e-6 && x.o0 < to - 1e-6).map((x) => x.i + 1);
188
- warn(`${ns.length > 1 ? `steps ${ns[0]}–${ns.at(-1)}` : `step ${ns[0] ?? segs.at(-1).i + 1}`}: ${(to - from).toFixed(1)}s of dead air at ${from.toFixed(1)}–${to.toFixed(1)}s (nothing moves and nothing is said) — shorten the wait or pause, hide the load, or narrate what is happening`);
199
+ warn(`${ns.length > 1 ? `steps ${ns[0]}–${ns.at(-1)}` : `step ${ns[0] ?? (segs.at(-1)?.i ?? cfg.steps.length - 1) + 1}`}: ${(to - from).toFixed(1)}s of dead air at ${from.toFixed(1)}–${to.toFixed(1)}s (nothing moves and nothing is said) — shorten the wait or pause, hide the load, or narrate what is happening`);
189
200
  }
190
201
  from = null;
191
202
  };
@@ -373,9 +384,10 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
373
384
  // Intro and end cards are short animations (logo pops, title rises, the highlight sweeps, a command
374
385
  // CTA types itself out), rendered frame by frame by seeking the page's CSS animations.
375
386
  const u = H / 1080;
376
- // cards follow the frame: dark backdrops (aurora, dark, blur) get dark cards, so a dark app doesn't
377
- // cut to a white card; `cards: light | dark` overrides
378
- const darkCards = cfg.cards === 'dark' || (cfg.cards !== 'light' && ['aurora', 'dark', 'blur'].includes(cfg.frame?.style));
387
+ // cards follow the film's ground (see filmGround): dark backdrops (aurora, dark, blur) and scenes that
388
+ // draw on a dark ground get dark cards, so a dark film doesn't cut to a white card; `ground: light |
389
+ // dark` says it outright and `cards: light | dark` overrides the cards alone
390
+ const darkCards = cfg.cards === 'dark' || (cfg.cards !== 'light' && filmGround(cfg) === 'dark');
379
391
  const CK = darkCards
380
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',
381
393
  pillBg: '#1a2238', pillFg: '#e2e8f0', pillLine: '1px solid #33405f', ctaBg: accent, ctaFg: ink, shadow: 'rgba(0,0,0,.45)' }
@@ -485,7 +497,10 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
485
497
  // what each HyperFrames card is about: its text, when its narration plays (card seconds), and
486
498
  // pictures from the recording (the page it leads into, blurred, as a backdrop; for a launch intro, the
487
499
  // first page sharp, and where the app shows the intro's logo, so the logo can land there)
500
+ // scenes: rendered with HyperFrames whatever `hyperframes:` says (they are HyperFrames compositions);
501
+ // the cards only when it is on
488
502
  function hfJobs() {
503
+ if (!cfg.hyperframes) return cards.flatMap((c, k) => (c.scene ? [sceneJob(c, k)] : []));
489
504
  const launch = cfg.hyperframes.template === 'launch';
490
505
  const seqAt = (t) => path.join(SEQ, `${String(Math.min(nFrames - 1, Math.max(0, Math.round(t * FPS)))).padStart(5, '0')}.jpg`);
491
506
  const blurred = (t, name) => {
@@ -522,6 +537,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
522
537
  page: launch && nFrames ? seqAt(INTRO) : null, backdrop: launch ? null : blurred(INTRO + 0.05, 'intro') });
523
538
  }
524
539
  for (const [k, c] of cards.entries()) {
540
+ if (c.scene) { jobs.push(sceneJob(c, k)); continue; }
525
541
  jobs.push({ name: `card${k}`, kind: 'section', seconds: c.t1 - c.t0, card: c.card, voice: voice(c.card.say, 0.15, voiceLens.cards?.[c.i]),
526
542
  index: k, of: cards.length, thinkAt: c.thinkAt, image: c.card.imageFile, backdrop: blurred(c.t1 + 0.05, `card${k}`) });
527
543
  }
@@ -534,11 +550,14 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
534
550
  return jobs;
535
551
  }
536
552
 
553
+ function sceneJob(c, k) {
554
+ return { name: `card${k}`, kind: 'scene', seconds: c.t1 - c.t0, scene: c.scene, say: c.say, lead: c.lead, cues: c.cues, step: c.i, kit: cfg.sceneKit };
555
+ }
537
556
  // the cards: HyperFrames compositions when asked for (`hyperframes:`), else (or for any card that
538
557
  // doesn't render) the built-in ones. A launch outro is drawn with transparency over the last frame,
539
558
  // which the ffmpeg pass below zooms into the page.
540
- const hf = cfg.hyperframes ? await renderHyperframesCards(hfJobs(), {
541
- W, H, FPS, dark: darkCards, accent, ink, font, lang: cfg.lang, labels: cfg.labels, opts: cfg.hyperframes, work: cfg.work,
559
+ 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',
542
561
  frame: { FX, FY, FW, FH, ...Object.fromEntries([['frameBg', 'frame-bg.png'], ['chrome', 'frame-chrome.png'], ['url', 'frame-url0.png']]
543
562
  .map(([k, f]) => [k, cfg.frame && fs.existsSync(path.join(cfg.work, f)) ? path.join(cfg.work, f) : null])) },
544
563
  }, { log, warn }) : {};
@@ -555,6 +574,10 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
555
574
  }
556
575
  for (const [k, c] of cards.entries()) {
557
576
  if (hf[`card${k}`]) place(`card${k}`, c.t1 - c.t0);
577
+ else if (c.scene) {
578
+ await browser.close();
579
+ throw new Error(`step ${c.i + 1}: the scene ${path.relative(process.cwd(), c.scene.file)} did not render${hf._errors?.[`card${k}`] ? `: ${hf._errors[`card${k}`]}` : ''} — preview it with \`storyshot scene <storyboard> --step ${c.i + 1}\` (MCP: preview_scene), fix it, and render again`);
580
+ }
558
581
  else await animate(card(c.card, c.card.logoFile ?? '', { thinkAt: c.thinkAt }), c.t1 - c.t0, path.join(cfg.work, `card${k}`));
559
582
  }
560
583
  if (in0) {
@@ -607,7 +630,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
607
630
  const lead = in0 && Math.abs(c.t0 - INTRO) < 0.02 ? Math.min(0.2, INTRO) : 0, s0 = c.t0 - lead;
608
631
  inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, `card${k}`, '%04d.jpg'));
609
632
  graph += `[${idx}:v]format=rgba,${lead ? `tpad=start_duration=${lead.toFixed(3)}:start_mode=clone,` : ''}setpts=PTS-STARTPTS+${s0.toFixed(3)}/TB,` +
610
- `${lead ? '' : `fade=in:st=${c.t0.toFixed(3)}:d=0.15:alpha=1,`}fade=out:st=${Math.max(c.t0, c.t1 - 0.15).toFixed(3)}:d=0.15:alpha=1[sc${k}];\n` +
633
+ `${lead || c.scene ? '' : `fade=in:st=${c.t0.toFixed(3)}:d=0.15:alpha=1,`}${c.scene ? 'null' : `fade=out:st=${Math.max(c.t0, c.t1 - 0.15).toFixed(3)}:d=0.15:alpha=1`}[sc${k}];\n` +
611
634
  `[${last}][sc${k}]overlay=0:0:eof_action=pass:enable='between(t,${s0.toFixed(3)},${c.t1.toFixed(3)})'[v${++idx}];\n`;
612
635
  last = `v${idx}`;
613
636
  }
@@ -652,7 +675,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
652
675
  // --- 6. preview contact sheet: one frame per step (at its zoom peak) + end card ------------
653
676
  // zoomed steps: at the zoom peak; others: late in the step, where its actions' result is visible
654
677
  const shots = segs.map((g) => ({ t: Math.min(g.o1 - 0.05, g.zoomPeak ?? g.o0 + (g.o1 - g.o0) * 0.8), label: `step ${g.i + 1} · ${g.o0.toFixed(1)}–${g.o1.toFixed(1)}s${g.step.zoom ? ' · zoom' : ''}`, caption: g.step.caption ?? '' }));
655
- for (const c of cards) shots.push({ t: c.t0 + (c.t1 - c.t0) * 0.85, label: `card · ${c.t0.toFixed(1)}–${c.t1.toFixed(1)}s`, caption: c.card.title ?? '' });
678
+ for (const c of cards) shots.push({ t: c.t0 + (c.t1 - c.t0) * 0.85, label: `${c.scene ? `step ${c.i + 1} scene` : 'card'} · ${c.t0.toFixed(1)}–${c.t1.toFixed(1)}s`, caption: c.card.title || (c.scene ? path.basename(c.scene.file) : '') });
656
679
  shots.sort((a, b) => a.t - b.t);
657
680
  if (in0) shots.unshift({ t: INTRO * 0.8, label: `intro · 0.0–${INTRO.toFixed(1)}s`, caption: '' });
658
681
  if (ec) shots.push({ t: VIDEO + END / 2, label: `end card · ${VIDEO.toFixed(1)}–${TOTAL.toFixed(1)}s`, caption: '' });
@@ -685,7 +708,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
685
708
  .map(toOut).filter((t) => t != null && t > 0.2),
686
709
  ...segs.filter((g) => g.afterHidden).map((g) => g.o0),
687
710
  ...(in0 ? [INTRO - 0.2] : []),
688
- ...cards.flatMap((c) => [c.t0, c.t1 - 0.15]),
711
+ ...cards.filter((c) => !c.scene).flatMap((c) => [c.t0, c.t1 - 0.15]),
689
712
  ...(ec ? [VIDEO - 0.25] : []),
690
713
  ]),
691
714
  risers: segs.filter((g) => g.step.sfx === 'riser').map((g) => [g.o0, g.o1]),
@@ -694,6 +717,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
694
717
  voice: [
695
718
  ...(in0?.say ? [{ text: in0.say, start: 0.15, where: 'intro' }] : []),
696
719
  ...cards.filter((c) => c.card.say).map((c) => ({ text: c.card.say, start: c.t0 + 0.15, where: `step ${c.i + 1} card` })),
720
+ ...cards.filter((c) => c.scene && c.say).map((c) => ({ text: c.say, start: c.t0 + c.lead, where: `step ${c.i + 1} scene`, chunk: true })),
697
721
  ...segs.filter((g) => g.step.say).map((g) => ({ text: g.step.say, start: g.o0 + g.sayAt, where: `step ${g.i + 1}` })),
698
722
  ...(ec?.say ? [{ text: ec.say, start: VIDEO + 0.05, where: 'end card' }] : []),
699
723
  ],
@@ -716,5 +740,5 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
716
740
  let cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
717
741
  g.crop = { x: even(Math.max(0, Math.min(W - w, cx - w / 2))), y: even(Math.max(0, Math.min(H - h, cy - h / 2))), w: even(w), h: even(h) };
718
742
  }
719
- return { silent, clean, events, preview, warnings, captions, cards: cards.map((c) => ({ t: c.t0, title: [c.card.kicker, c.card.title].filter(Boolean).join(' · ') })), segs: segs.map((g) => ({ i: g.i, o0: g.o0, o1: g.o1, caption: g.step.caption, doc: g.step.doc ?? g.step.say, crop: g.crop })) };
743
+ return { silent, clean, events, preview, warnings, captions, cards: cards.filter((c) => !c.scene || c.card.title).map((c) => ({ t: c.t0, title: [c.card.kicker, c.card.title].filter(Boolean).join(' · ') })), segs: segs.map((g) => ({ i: g.i, o0: g.o0, o1: g.o1, caption: g.step.caption, doc: g.step.doc ?? g.step.say, crop: g.crop })) };
720
744
  }
package/src/config.mjs CHANGED
@@ -4,6 +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
8
 
8
9
  // Built-in labels drawn into the video, by `lang` (e.g. es, es-MX); other languages get English
9
10
  export const LABELS = {
@@ -35,6 +36,8 @@ export const PURPOSES = {
35
36
  tutorial: { duration: 'auto', music: { bpm: 96, volume: 0.35 }, sfx: 'clicks', highlight: true, stepNumbers: true, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true, hyperframes: true },
36
37
  course: { duration: 'auto', music: false, sfx: 'clicks', highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true, hyperframes: true },
37
38
  docs: { duration: 'auto', music: false, sfx: false, highlight: true, stepNumbers: true, subtitles: false, chapters: false, clips: ['gif', 'png'], frame: false, pace: 'normal', follow: false, hyperframes: false },
39
+ // a concept explained with animated scenes the agent writes (no web app to record)
40
+ explainer: { duration: 'auto', music: { style: 'calm', bpm: 64, volume: 0.3 }, sfx: false, highlight: false, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: false, pace: 'normal', follow: false, hyperframes: true },
38
41
  };
39
42
 
40
43
  // Where storyboards and their videos go, and how they're named: <site>-<kind>[-<topic>].yaml →
@@ -52,7 +55,7 @@ export const PACES = {
52
55
  export const FRAME_STYLES = ['aurora', 'studio', 'dark', 'pastel', 'blur'];
53
56
 
54
57
  export const VIDEO_DIR = 'videos';
55
- export const KINDS = { pitch: 'promo', tutorial: 'tutorial', course: 'lesson', docs: 'docs' };
58
+ export const KINDS = { pitch: 'promo', tutorial: 'tutorial', course: 'lesson', docs: 'docs', explainer: 'explainer' };
56
59
  // pxgenius.ai → pxgenius; an IP or a port is kept whole so two internal apps don't share a name
57
60
  // (http://10.1.2.3:8080 → 10-1-2-3-8080, http://localhost:3000 → localhost-3000)
58
61
  export const siteOf = (url) => {
@@ -70,7 +73,8 @@ export const certError = (e, url) => (/ERR_CERT|ERR_SSL/.test(e?.message ?? '')
70
73
  const slug = (s) => String(s).toLowerCase().normalize('NFKD').replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '').slice(0, 40);
71
74
 
72
75
  export function defaultStoryboard(url, purpose = 'pitch', topic) {
73
- const base = [siteOf(url), KINDS[purpose] ?? purpose, topic && slug(topic)].filter(Boolean).join('-');
76
+ // an explainer has no site: it is named by its topic
77
+ const base = (url ? [siteOf(url), KINDS[purpose] ?? purpose, topic && slug(topic)] : [topic && slug(topic), KINDS[purpose] ?? purpose]).filter(Boolean).join('-');
74
78
  for (let i = 1; ; i++) {
75
79
  const file = path.join(VIDEO_DIR, `${base}${i > 1 ? `-${i}` : ''}.yaml`);
76
80
  if (!fs.existsSync(file)) return file;
@@ -86,8 +90,9 @@ export function loadConfig(file, { draft = false } = {}) {
86
90
  const raw = YAML.parse(fs.readFileSync(abs, 'utf8'));
87
91
  const dir = path.dirname(abs);
88
92
  const name = raw.name ?? path.basename(abs).replace(/\.ya?ml$/, '');
89
- if (!raw.url) throw new Error('demo.yaml: `url` is required');
90
93
  if (!Array.isArray(raw.steps) || !raw.steps.length) throw new Error('demo.yaml: `steps` must be a non-empty list');
94
+ // only a storyboard made entirely of scenes has nothing to record
95
+ 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)');
91
96
 
92
97
  const viewport = { width: 1600, height: 900, ...raw.viewport };
93
98
  const output = { width: 1920, height: 1080, fps: 30, ...raw.output };
@@ -103,6 +108,17 @@ export function loadConfig(file, { draft = false } = {}) {
103
108
  if (!PACES[pick('pace')]) throw new Error(`pace must be one of ${Object.keys(PACES).join(', ')}`);
104
109
 
105
110
  const steps = raw.steps.map((s, i) => {
111
+ // scene: an animation the agent wrote (see scenes.mjs), timed to the step's narration
112
+ let scene;
113
+ if (s.scene) {
114
+ const sc = typeof s.scene === 'string' ? { file: s.scene } : s.scene;
115
+ if (!sc.file) throw new Error(`step ${i + 1}: scene needs a file: \`scene: scenes/<name>.html\``);
116
+ if (s.actions?.length) throw new Error(`step ${i + 1}: a scene plays its own animation instead of the web app, so it has no actions — move them into a step of their own`);
117
+ if (!s.say && typeof s.duration !== 'number') throw new Error(`step ${i + 1}: a scene without a say: line needs a duration in seconds`);
118
+ scene = { ...SCENE_DEFAULTS, ...sc, file: path.resolve(dir, sc.file) };
119
+ const card = s.card ? { ...s.card, ...(s.card.image ? { imageFile: path.resolve(dir, s.card.image) } : {}) } : undefined;
120
+ return { ...s, actions: [], duration: typeof s.duration === 'number' ? s.duration : 'auto', card, scene };
121
+ }
106
122
  const duration = s.duration ?? P.duration;
107
123
  if (!s.hidden && duration !== 'auto' && !(duration > 0)) {
108
124
  throw new Error(`step ${i + 1}: visible steps need a positive \`duration\` in seconds, or \`duration: auto\``);
@@ -136,6 +152,7 @@ export function loadConfig(file, { draft = false } = {}) {
136
152
  const frame = frame0 ? { background: null, window: 'browser', ...(typeof frame0 === 'object' ? frame0 : {}), style: styleOf(frame0) } : null;
137
153
  if (frame && !FRAME_STYLES.includes(frame.style)) throw new Error(`frame style must be one of ${FRAME_STYLES.join(', ')}`);
138
154
  if (frame && !['browser', 'app'].includes(frame.window)) throw new Error('frame.window must be browser or app');
155
+ if (!['auto', 'light', 'dark'].includes(raw.ground ?? 'auto')) throw new Error('ground must be light or dark (leave it out and auto reads it from the scenes, or from the frame)');
139
156
  if (frame?.icon) frame.iconFile = path.resolve(dir, frame.icon);
140
157
 
141
158
  // hyperframes: true | { template: lesson | launch, band, bandColors, emblem, hand, backdrop } — cards
@@ -156,6 +173,9 @@ export function loadConfig(file, { draft = false } = {}) {
156
173
  const byLang = { kokoro: kokoroVoice, qwen: qwenVoiceFor(raw) }[provider];
157
174
  voice = { speed: pace0.voiceSpeed, volume: 1, pause: pace0.sentencePause, ...VOICE_DEFAULTS[provider], ...(byLang ? { voice: byLang } : {}), ...(raw.lang ? { lang: raw.lang } : {}), ...raw.voice, provider };
158
175
  if (!(typeof voice.pause === 'number' && voice.pause >= 0 && voice.pause <= 3)) throw new Error('voice.pause is the shortest pause between the sentences of a line, in seconds, 0–3 (0 leaves the voice as it is)');
176
+ // explainers: a scene's narration keeps moving (no pause in a line longer than this)
177
+ if (voice.maxPause === undefined && purpose === 'explainer') voice.maxPause = 0.55;
178
+ if (voice.maxPause != null && !(typeof voice.maxPause === 'number' && voice.maxPause >= voice.pause)) throw new Error(`voice.maxPause is the longest pause inside a line, in seconds, at least voice.pause (${voice.pause})`);
159
179
  if (provider === 'qwen') {
160
180
  const v = qwenVoice(voice.voice);
161
181
  if (!v) throw new Error(`voice: qwen has no voice "${voice.voice}" (one of ${QWEN_VOICES.join(', ')})`);
@@ -178,10 +198,15 @@ export function loadConfig(file, { draft = false } = {}) {
178
198
  const clips = [].concat(pick('clips') ?? []).filter(Boolean);
179
199
  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)');
180
200
  if (music?.file) music.file = path.resolve(dir, music.file);
201
+ if (music?.style && !['calm'].includes(music.style)) throw new Error('music.style must be calm (or leave it out for the upbeat synth)');
181
202
 
203
+ // scenes: { css: [files], js: [files] } shared by every scene (a palette, drawing helpers), loaded before each one
204
+ const sk = raw.scenes ?? {};
205
+ const sceneKit = { css: [].concat(sk.css ?? []).map((f) => path.resolve(dir, f)), js: [].concat(sk.js ?? []).map((f) => path.resolve(dir, f)) };
182
206
  return {
183
207
  name,
184
208
  purpose,
209
+ sceneKit,
185
210
  highlight: pick('highlight'),
186
211
  stepNumbers: pick('stepNumbers'),
187
212
  subtitles: pick('subtitles'),
@@ -189,7 +214,7 @@ export function loadConfig(file, { draft = false } = {}) {
189
214
  clips,
190
215
  pace: { name: pick('pace'), ...PACES[pick('pace')] },
191
216
  maxWait: raw.maxWait ?? PACES[pick('pace')].maxWait,
192
- url: raw.url,
217
+ url: raw.url ?? null,
193
218
  viewport,
194
219
  output,
195
220
  scale,
@@ -209,6 +234,11 @@ export function loadConfig(file, { draft = false } = {}) {
209
234
  // e.g. when burned-in subtitles already carry the words
210
235
  captions: raw.captions !== false,
211
236
  cards: raw.cards ?? 'auto',
237
+ // ground: light | dark — is the film light or dark overall? It decides the colour of the burned-in
238
+ // subtitles (ink letters on a light film, white on a dark one) and the cards' ground. `auto` reads
239
+ // it from the scenes, or from the frame's backdrop; say it outright when the scenes take their
240
+ // ground from the shared scene kit, which `auto` cannot always see through.
241
+ ground: raw.ground ?? 'auto',
212
242
  // language of the built-in labels ("Pause and think", "Introduction"): `lang`, else guessed from the captions
213
243
  lang: raw.lang ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? 'zh' : 'en'),
214
244
  labels: LABELS[String(raw.lang ?? '').toLowerCase().split(/[-_]/)[0]] ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? LABELS.zh : LABELS.en),
@@ -16,6 +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
20
 
20
21
  export const HF_VERSION = '0.8.217';
21
22
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -66,6 +67,22 @@ const run = (cli, args) => new Promise((resolve, reject) => {
66
67
  p.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(out.trim().split('\n').filter(Boolean).slice(-3).join(' | ') || `exit ${code}`))));
67
68
  });
68
69
 
70
+ // Run the HyperFrames CLI (installing the pinned version on first use); resolves with its exit code and output.
71
+ export async function hyperframesRun(args, { log = console.log } = {}) {
72
+ const c = hyperframesCli();
73
+ if (c.error) throw new Error(`HyperFrames: ${c.error}`);
74
+ const cli = await ensureCli(log);
75
+ return new Promise((resolve, reject) => {
76
+ const p = spawn(cli.cmd, [...cli.args, ...args], { env: { ...process.env, HYPERFRAMES_NO_TELEMETRY: '1', DO_NOT_TRACK: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
77
+ let out = '', err = '';
78
+ p.stdout.on('data', (d) => { out += d; });
79
+ p.stderr.on('data', (d) => { err += d; });
80
+ p.on('error', reject);
81
+ p.on('close', (code) => resolve({ code, out, err }));
82
+ });
83
+ }
84
+ export const sceneFontFaces = (font) => fontFaces(font);
85
+
69
86
  // --- narration cues --------------------------------------------------------------------------------
70
87
  // When the narration names each item (a bullet, the call to action): the item's earliest content word
71
88
  // found in the line after the previous item's, placed by its character position (speech runs at a
@@ -233,7 +250,7 @@ function pageTimeline(D) {
233
250
  return { fromTo: (sel, from, to, at = 0) => add(sel, from, to, at), to: (sel, to, at = 0) => add(sel, null, to, at), build };
234
251
  }
235
252
 
236
- const doc = ({ W, H, D, css, body, js, bg = 'transparent' }) => `<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=${W}, height=${H}">
253
+ const doc = ({ W, H, D, css, body, js, bg = 'transparent', floor = H * 0.97 }) => `<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=${W}, height=${H}">
237
254
  <style>
238
255
  *{margin:0;padding:0;box-sizing:border-box}
239
256
  html,body{width:${W}px;height:${H}px;overflow:hidden;background:${bg}}
@@ -267,7 +284,7 @@ const refit = () => {
267
284
  const box = document.querySelector('[data-fit-box]');
268
285
  for (let k = 0; box && fits.length && k < 30; k++) {
269
286
  const kids = [...box.querySelectorAll(':scope > *, :scope > .right > *')].filter((c) => c.offsetHeight && !c.classList.contains('right'));
270
- if (!kids.length || (Math.min(...kids.map((c) => c.offsetTop)) >= ${H * 0.03} && Math.max(...kids.map((c) => c.offsetTop + c.offsetHeight)) <= ${H * 0.97})) break;
287
+ if (!kids.length || (Math.min(...kids.map((c) => c.offsetTop)) >= ${H * 0.03} && Math.max(...kids.map((c) => c.offsetTop + c.offsetHeight)) <= ${floor})) break;
271
288
  fits.forEach(shrink);
272
289
  }
273
290
  };
@@ -279,7 +296,10 @@ tl.build();
279
296
 
280
297
  // --- lesson: tutorials, courses, docs ---------------------------------------------------------------
281
298
  function lesson(job, ctx) {
282
- const { W, H, dark, font, opts } = ctx, u = H / 1080, D = job.seconds, c = job.card, accent = ctx.accent, ink = ctx.ink;
299
+ // sizes are designed for 1920x1080; a portrait frame (1080x1920) sizes them by its width instead
300
+ const { W, H, dark, font, opts } = ctx, u = Math.min(H, W * 1.15) / 1080, D = job.seconds, c = job.card, accent = ctx.accent, ink = ctx.ink;
301
+ // portrait with burned-in subtitles: the subtitles sit above the bottom fifth, so the card keeps above them
302
+ const lift = ctx.subs && H > W ? H * 0.22 : 0;
283
303
  const P = dark
284
304
  ? { ground: '#0c1222', ground2: '#070b16', glow: '#1b2744', text: '#eef2f8', muted: '#9aa8bd', line: '#2a3550', chip: '#131c31', chipLine: '#26324d', band: 0.14, shadow: 'rgba(0,0,0,.35)' }
285
305
  : { ground: '#eef2f7', ground2: '#dfe6ef', glow: '#ffffff', text: ink, muted: '#56667d', line: '#c6d0dc', chip: '#ffffff', chipLine: '#d6dee8', band: 0.17, shadow: 'rgba(13,27,46,.08)' };
@@ -293,9 +313,11 @@ function lesson(job, ctx) {
293
313
  const em = job.kind === 'end' ? null : c.emblem === false ? null : [].concat(c.emblem ?? opts.emblem ?? []).filter(Boolean).slice(0, 3).map(String);
294
314
  const emblem = !em?.length ? '' : (() => {
295
315
  const a = em[0], z = em.at(-1);
296
- const diff = (i) => em.length > 1 && a[i] !== z[i];
297
- const row = (s, k) => `<div class="row r${k === 0 ? 'q' : k === em.length - 1 ? 's' : 'm'}">${[...s].map((ch, i) =>
298
- `<i class="${(k === 1 && em.length === 3 ? ch !== '|' && ch !== ' ' : diff(i)) ? 'df' : ''}">${ch === ' ' ? '&nbsp;' : esc(ch)}</i>`).join('')}</div>`;
316
+ // (only lines of the same length line up like an alignment; lines of code differ everywhere)
317
+ const aligned = em.length > 1 && em.every((x) => x.length === a.length);
318
+ const diff = (i) => aligned && a[i] !== z[i];
319
+ const row = (s, k) => `<div class="row r${k === 0 || !aligned ? 'q' : k === em.length - 1 ? 's' : 'm'}">${[...s].map((ch, i) =>
320
+ `<i class="${aligned && (k === 1 && em.length === 3 ? ch !== '|' && ch !== ' ' : diff(i)) ? 'df' : ''}">${ch === ' ' ? '&nbsp;' : esc(ch)}</i>`).join('')}</div>`;
299
321
  return `<div id="aln" class="aln" data-layout-ignore>${em.map(row).join('')}</div>`;
300
322
  })();
301
323
  const kicker = c.kicker ?? (job.kind === 'end' && c.subtitle && c.bullets?.length && String(c.subtitle).length <= 14 ? c.subtitle : null);
@@ -328,7 +350,7 @@ function lesson(job, ctx) {
328
350
  .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)}
329
351
  .band{position:absolute;left:0;top:0;white-space:nowrap;font:500 ${54 * u}px 'IBM Plex Mono',monospace;letter-spacing:.3em}
330
352
  .band i,.aln i{font-style:normal}
331
- .col{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:0 ${W * 0.06}px}
353
+ .col{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:0 ${W * 0.06}px ${lift}px}
332
354
  .aln{font:500 ${30 * u}px 'IBM Plex Mono',monospace;letter-spacing:.34em;line-height:1.12;margin-bottom:${44 * u}px;color:${P.muted}}
333
355
  .aln .row{white-space:nowrap}.aln i{display:inline-block}
334
356
  .aln .rm{color:${strong};opacity:.8}.aln i.df{color:${dark ? accent : P.text};background:${dark ? 'transparent' : accent};font-weight:700}
@@ -364,7 +386,7 @@ h1{font-weight:600;letter-spacing:-.03em;line-height:1.08;max-width:${W * 0.82}p
364
386
  .cta.cmd{font:500 ${36 * u}px 'IBM Plex Mono',monospace;background:${dark ? '#1a2238' : ink};color:#e2e8f0;text-align:left}
365
387
  .cta.cmd b{color:${accent}}
366
388
  #type{display:inline-block;white-space:nowrap}
367
- .think{position:absolute;left:50%;bottom:${H * 0.1}px;width:${W * 0.3}px;margin-left:${-W * 0.15}px;text-align:center;font-size:${26 * u}px;color:${P.muted}}
389
+ .think{position:absolute;left:50%;bottom:${lift ? H * 0.2 : H * 0.1}px;width:${W * 0.3}px;margin-left:${-W * 0.15}px;text-align:center;font-size:${26 * u}px;color:${P.muted}}
368
390
  .tlabel{margin-bottom:${12 * u}px}
369
391
  .track{height:${8 * u}px;border-radius:99px;background:${P.line};overflow:hidden}
370
392
  .drain{height:100%;background:${accent};transform-origin:0 50%}`;
@@ -427,7 +449,7 @@ ${isCmd(c.cta) ? `tl.fromTo('#type', { clipPath: 'inset(0 100% 0 0)' }, { clipPa
427
449
  ${think ? `blurIn('#think', ${n(job.thinkAt)});
428
450
  tl.fromTo('#drain', { scaleX: 1 }, { scaleX: 0, duration: ${c.think}, ease: 'none' }, ${n(job.thinkAt)});` : ''}
429
451
  `;
430
- return doc({ W, H, D, css, body, js, bg: P.ground });
452
+ return doc({ W, H, D, css, body, js, bg: P.ground, floor: H * 0.97 - lift });
431
453
  }
432
454
 
433
455
  // --- launch: promos ----------------------------------------------------------------------------------
@@ -575,18 +597,39 @@ const hashOf = (...parts) => {
575
597
 
576
598
  // jobs: [{ name, kind: intro|section|end, seconds, card, voice: {text, start, len}?, index, of, thinkAt,
577
599
  // backdrop?, page?, logo?, logoSize?, logoTarget?, image?, alpha? }]
578
- // ctx: { W, H, FPS, dark, accent, ink, font, lang, labels, opts, frame: {FX,FY,FW,FH,frameBg,chrome,url}, work }
600
+ // ctx: { W, H, FPS, dark, accent, ink, font, lang, labels, opts, frame: {FX,FY,FW,FH,frameBg,chrome,url}, work, subs (burned-in subtitles) }
579
601
  // Returns { [name]: { dir, ext, alpha } } for the cards that rendered; the rest fall back.
602
+ // Scene jobs ({ kind: 'scene', scene, say, lead, cues }) have no fallback: why one failed is in out._errors.
580
603
  export async function renderHyperframesCards(jobs, ctx, { log = console.log, warn = log } = {}) {
581
- if (!jobs.length) return {};
604
+ const errors = {};
605
+ const out = {};
606
+ Object.defineProperty(out, '_errors', { value: errors, enumerable: false });
607
+ if (!jobs.length) return out;
582
608
  let cli = hyperframesCli();
583
- if (cli.error) { warn(`hyperframes: ${cli.error} — using the built-in cards`); return {}; }
584
- const template = ctx.opts.template;
609
+ if (cli.error) {
610
+ for (const j of jobs) if (j.kind === 'scene') errors[j.name] = cli.error;
611
+ warn(`hyperframes: ${cli.error} — using the built-in cards`);
612
+ return out;
613
+ }
614
+ const template = ctx.opts?.template;
585
615
  const root = path.join(ctx.work, 'hf');
586
616
  fs.mkdirSync(root, { recursive: true });
587
- const out = {};
588
617
  const todo = [];
589
618
  for (const job of jobs) {
619
+ if (job.kind === 'scene') {
620
+ // the agent's composition, wrapped in a project as long as the scene (see scenes.mjs)
621
+ const sctx = { ...ctx, fontFaces: fontFaces(ctx.font) };
622
+ const files = sceneAssets(job.scene.file);
623
+ 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
+ fs.existsSync(job.scene.file) ? fileHash(job.scene.file) : 'missing', ...Object.entries(files).map(([k, f]) => [k, fileHash(f)]),
625
+ ...[...(job.kit?.css ?? []), ...(job.kit?.js ?? [])].map((f) => [f, fs.existsSync(f) ? fileHash(f) : 'missing']));
626
+ const dir = path.join(root, `${job.name}-${key}`), frames = path.join(dir, 'frames');
627
+ if (fs.existsSync(path.join(dir, 'done'))) { out[job.name] = { dir: frames, ext: 'jpg', alpha: false }; continue; }
628
+ fs.rmSync(dir, { recursive: true, force: true });
629
+ try { writeSceneProject(job, sctx, dir); } catch (e) { errors[job.name] = e.message; continue; }
630
+ todo.push({ job, dir, frames, ext: 'jpg', alpha: false });
631
+ continue;
632
+ }
590
633
  const launchCard = template === 'launch' && job.kind !== 'section';
591
634
  if (launchCard) {
592
635
  try { await import('roughjs'); } catch { warn('hyperframes: the launch template needs the roughjs package (npm i roughjs) — using the built-in cards'); return {}; }
@@ -622,10 +665,12 @@ export async function renderHyperframesCards(jobs, ctx, { log = console.log, war
622
665
  if (!todo.length) return out;
623
666
 
624
667
  try { cli = await ensureCli(log); } catch (e) {
668
+ for (const t of todo) if (t.job.kind === 'scene') errors[t.job.name] = `could not install the HyperFrames CLI: ${String(e.message).slice(0, 200)}`;
625
669
  warn(`hyperframes: could not install the CLI (${String(e.message).slice(0, 200)}) — using the built-in cards`);
626
670
  return out;
627
671
  }
628
- log(`hyperframes: rendering ${todo.length} card${todo.length > 1 ? 's' : ''} (${cli.how})`);
672
+ const nScenes = todo.filter((t) => t.job.kind === 'scene').length, nCards = todo.length - nScenes;
673
+ log(`hyperframes: rendering ${[nScenes && `${nScenes} scene${nScenes > 1 ? 's' : ''}`, nCards && `${nCards} card${nCards > 1 ? 's' : ''}`].filter(Boolean).join(' and ')} (${cli.how})`);
629
674
  const one = async (t) => {
630
675
  const t0 = Date.now();
631
676
  try {
@@ -644,9 +689,10 @@ export async function renderHyperframesCards(jobs, ctx, { log = console.log, war
644
689
  }
645
690
  fs.writeFileSync(path.join(t.dir, 'done'), '');
646
691
  out[t.job.name] = { dir: t.frames, ext: t.ext, alpha: t.alpha };
647
- log(` ${t.job.name}: ${t.job.seconds.toFixed(1)}s card in ${((Date.now() - t0) / 1000).toFixed(0)}s`);
692
+ log(` ${t.job.kind === 'scene' ? `step ${t.job.step + 1} scene` : t.job.name}: ${t.job.seconds.toFixed(1)}s ${t.job.kind === 'scene' ? path.basename(t.job.scene.file) : 'card'} in ${((Date.now() - t0) / 1000).toFixed(0)}s`);
648
693
  } catch (e) {
649
- warn(`hyperframes: the ${t.job.name} card did not render (${String(e.message).slice(0, 300)}) — using the built-in one`);
694
+ if (t.job.kind === 'scene') errors[t.job.name] = String(e.message).slice(0, 600);
695
+ else warn(`hyperframes: the ${t.job.name} card did not render (${String(e.message).slice(0, 300)}) — using the built-in one`);
650
696
  }
651
697
  };
652
698
  // two renders at a time (each one drives its own browser)
package/src/init.mjs CHANGED
@@ -86,3 +86,73 @@ ${page.elements.slice(0, 40).map((e) => `# y=${String(e.y).padEnd(5)} ${tgt(e.
86
86
  fs.writeFileSync(file, yaml);
87
87
  return { file: path.resolve(file), yaml, screenshot: page.screenshot, inspect: page.text, login: page.login };
88
88
  }
89
+
90
+ // `storyshot init --purpose explainer --topic <topic>`: no web app, so nothing to inspect — a storyboard
91
+ // of three scene steps and a starter scene file for each, timed to its narration with window.__storyshot.
92
+ export function initExplainer({ topic, file, lang = 'en', portrait = false } = {}) {
93
+ if (!topic) throw new Error('an explainer is named by its topic: --topic "how a TaqMan probe works" (MCP: topic)');
94
+ file ??= defaultStoryboard(null, 'explainer', topic);
95
+ if (fs.existsSync(file)) throw new Error(`${file} already exists — edit it, or choose another path`);
96
+ const name = path.basename(file).replace(/\.ya?ml$/, '');
97
+ const dir = path.dirname(path.resolve(file));
98
+ const zh = /^zh/i.test(lang);
99
+ const [W, H] = portrait ? [1080, 1920] : [1920, 1080];
100
+ const scenes = [1, 2, 3].map((k) => ({ id: `${name}-s${k}`.replace(/[^a-z0-9-]/gi, '-'), rel: `scenes/${name}-${k}.html` }));
101
+ const say = zh
102
+ ? ['TODO 一个观众会问的问题,一两句话。', 'TODO 用一个具体例子回答,每个短句对应画面上的一个变化。', 'TODO 一句话总结。']
103
+ : ['TODO the question a viewer would ask, in one or two sentences.', 'TODO answer it with one concrete example, one change on screen per phrase.', 'TODO the idea in one line.'];
104
+ const yaml = `# Storyshot storyboard (explainer) — every step is a scene: an animation you write as a HyperFrames
105
+ # composition (scenes/*.html), timed to the step's narration. Reference: \`storyshot guide\` ("Scenes and explainers").
106
+ # Loop: write the say: lines → storyshot cues (when each phrase is spoken) → write the scenes with S.at(k)
107
+ # → storyshot scene ${path.basename(file)} --step N (stills + checks) → storyshot render → look → fix.
108
+ name: ${name}
109
+ purpose: explainer
110
+ out: ${name}.mp4
111
+ ${zh ? 'lang: zh\nvoice: { provider: qwen, voice: dylan } # free, local (Apple Silicon); elsewhere kokoro zf_001' : 'voice: { provider: kokoro } # free, local'}
112
+ ${portrait ? `output: { width: ${W}, height: ${H} }\nviewport: { width: 900, height: 1600 }\n` : ''}subtitles: burn # or true for an .srt beside the video
113
+ ground: light # light | dark: the film's ground, chosen for the subject and its audience
114
+ # (\`storyshot guide\`: "The look follows the subject"). Paper for a subject that
115
+ # is taught, printed or voted on; dark for a mechanism, an instrument or anything
116
+ # that glows. Subtitles and cards follow it; paint every scene to match.
117
+ theme: { accent: "#b45309", ink: "#17181b" } # accent: the one colour that points. It also paints the word being
118
+ # spoken in the subtitles, so it has to read on the ground above
119
+
120
+ steps:
121
+ ${scenes.map((sc, k) => ` - scene: ${sc.rel}${k === 0 ? '\n # scene: { file: …, title: Chapter title, lead: 0.5, tail: 0.8 } (lead: seconds before the voice starts)' : ''}
122
+ say: ${say[k]}`).join('\n\n')}
123
+ `;
124
+ fs.mkdirSync(path.join(dir, 'scenes', 'assets'), { recursive: true });
125
+ for (const sc of scenes) fs.writeFileSync(path.join(dir, sc.rel), starterScene(sc.id, W, H));
126
+ fs.writeFileSync(file, yaml);
127
+ return { file: path.resolve(file), yaml, scenes: scenes.map((sc) => path.join(dir, sc.rel)) };
128
+ }
129
+
130
+ // A scene that already works: a ground, a line that lands on the first phrase and a mark that grows on
131
+ // the second. Replace its picture; keep the contract (template, matching ids, timeline registered).
132
+ // Every scene of a film shares one ground (here the light one the storyboard declares): for a dark film,
133
+ // repaint all of them together and set `ground: dark`.
134
+ function starterScene(id, W, H) {
135
+ return `<template>
136
+ <style>
137
+ #${id} { position: absolute; inset: 0; overflow: hidden; background: #f6f2e9; color: var(--ink); font-family: var(--font); }
138
+ #${id}-line { position: absolute; left: ${Math.round(W * 0.083)}px; right: ${Math.round(W * 0.083)}px; top: ${Math.round(H * 0.33)}px; font-size: ${Math.round(Math.min(W, H * 1.15) * 0.074)}px; font-weight: 600; line-height: 1.25; }
139
+ #${id}-bar { position: absolute; left: ${Math.round(W * 0.083)}px; top: ${Math.round(H * 0.68)}px; width: ${Math.round(W * 0.5)}px; height: 14px; border-radius: 7px; background: var(--accent); transform-origin: 0 50%; }
140
+ </style>
141
+ <div id="${id}" data-composition-id="${id}" data-width="${W}" data-height="${H}">
142
+ <div id="${id}-line">TODO the picture of this step: the object, diagram or number the voice explains</div>
143
+ <div id="${id}-bar"></div>
144
+ </div>
145
+ <script>
146
+ (function () {
147
+ // S.at(k): when phrase k of this step's say: line starts (S.end(k): ends); S.duration, S.width, S.height,
148
+ // S.safeBottom (px the burned-in subtitles take), S.cues ([{ text, t, end }]). Run \`storyshot cues\` to see them.
149
+ const S = window.__storyshot;
150
+ const tl = gsap.timeline({ paused: true });
151
+ tl.fromTo('#${id}-line', { opacity: 0, filter: 'blur(8px)' }, { opacity: 1, filter: 'blur(0px)', duration: 0.4, ease: 'power3.out' }, S.at(0));
152
+ tl.fromTo('#${id}-bar', { scaleX: 0 }, { scaleX: 1, duration: 0.8, ease: 'expo.out' }, S.at(1));
153
+ window.__timelines['${id}'] = tl;
154
+ })();
155
+ </script>
156
+ </template>
157
+ `;
158
+ }