storyshot 0.7.3 → 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,10 +550,13 @@ 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(), {
559
+ const hf = cfg.hyperframes || cards.some((c) => c.scene) ? await renderHyperframesCards(hfJobs(), {
541
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])) },
@@ -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
@@ -582,16 +599,37 @@ const hashOf = (...parts) => {
582
599
  // backdrop?, page?, logo?, logoSize?, logoTarget?, image?, alpha? }]
583
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) }
584
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.
585
603
  export async function renderHyperframesCards(jobs, ctx, { log = console.log, warn = log } = {}) {
586
- 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;
587
608
  let cli = hyperframesCli();
588
- if (cli.error) { warn(`hyperframes: ${cli.error} — using the built-in cards`); return {}; }
589
- 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;
590
615
  const root = path.join(ctx.work, 'hf');
591
616
  fs.mkdirSync(root, { recursive: true });
592
- const out = {};
593
617
  const todo = [];
594
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
+ }
595
633
  const launchCard = template === 'launch' && job.kind !== 'section';
596
634
  if (launchCard) {
597
635
  try { await import('roughjs'); } catch { warn('hyperframes: the launch template needs the roughjs package (npm i roughjs) — using the built-in cards'); return {}; }
@@ -627,10 +665,12 @@ export async function renderHyperframesCards(jobs, ctx, { log = console.log, war
627
665
  if (!todo.length) return out;
628
666
 
629
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)}`;
630
669
  warn(`hyperframes: could not install the CLI (${String(e.message).slice(0, 200)}) — using the built-in cards`);
631
670
  return out;
632
671
  }
633
- 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})`);
634
674
  const one = async (t) => {
635
675
  const t0 = Date.now();
636
676
  try {
@@ -649,9 +689,10 @@ export async function renderHyperframesCards(jobs, ctx, { log = console.log, war
649
689
  }
650
690
  fs.writeFileSync(path.join(t.dir, 'done'), '');
651
691
  out[t.job.name] = { dir: t.frames, ext: t.ext, alpha: t.alpha };
652
- 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`);
653
693
  } catch (e) {
654
- 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`);
655
696
  }
656
697
  };
657
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
+ }
package/src/mcp.mjs CHANGED
@@ -13,7 +13,8 @@ import { build } from './build.mjs';
13
13
  import { record } from './record.mjs';
14
14
  import { guide } from './guide.mjs';
15
15
  import { check, formatCheck } from './check.mjs';
16
- import { initStoryboard } from './init.mjs';
16
+ import { initStoryboard, initExplainer } from './init.mjs';
17
+ import { sceneCues, previewScene, formatPreview } from './scene-tools.mjs';
17
18
  import { openLoginWindow, finishLogin, authFileFor } from './auth.mjs';
18
19
  import { ensureBrowser, ffmpegInstallCommand } from './doctor.mjs';
19
20
  import { execFileSync } from 'node:child_process';
@@ -29,7 +30,8 @@ const abs = (p) => path.resolve(p);
29
30
 
30
31
  const server = new McpServer({ name: 'storyshot', version: pkg.version }, {
31
32
  instructions: [
32
- 'Storyshot makes product demo, tutorial, course and docs videos of web apps from a YAML storyboard.',
33
+ 'Storyshot makes product demo, tutorial, course and docs videos of web apps from a YAML storyboard,',
34
+ 'and explainers: a concept told with animated scenes you write (scene steps; also usable inside tutorials and lessons).',
33
35
  'Before writing anything, inspect_page the start url (it reports login walls), then confirm the brief with the user in ONE message:',
34
36
  'purpose (pitch/tutorial/course/docs), length, flow, voice, music, subtitles, language, and whether a login is needed (then call login).',
35
37
  'Workflow: storyboard_guide (once) → init_storyboard (or inspect_page) → edit the YAML → check_storyboard → render_video',
@@ -49,15 +51,22 @@ server.registerTool('init_storyboard', {
49
51
  title: 'Create a starter storyboard',
50
52
  description: 'Inspect a URL and write a starter storyboard YAML that already uses verified click targets from that page (listed as comments at the bottom). Start here, then edit the file.',
51
53
  inputSchema: {
52
- url: z.string().describe('Start page of the web app'),
54
+ url: z.string().optional().describe('Start page of the web app (not needed for purpose explainer)'),
53
55
  path: z.string().optional().describe('Absolute path for the YAML (must not exist yet). Default: <cwd>/videos/<site>-<kind>[-<topic>].yaml, kind = promo/tutorial/lesson/docs from purpose'),
54
- purpose: z.enum(['pitch', 'tutorial', 'course', 'docs']).optional().describe('pitch = short promo (default); tutorial = narrated how-to, paced to follow along; course = narrated lesson that explains the why; docs = per-step GIFs/PNGs + a Markdown article'),
56
+ purpose: z.enum(['pitch', 'tutorial', 'course', 'docs', 'explainer']).optional().describe('pitch = short promo (default); tutorial = narrated how-to, paced to follow along; course = narrated lesson that explains the why; docs = per-step GIFs/PNGs + a Markdown article; explainer = a concept told with animated scenes you write (no url: give topic)'),
57
+ lang: z.string().optional().describe('explainer: language of the narration, e.g. zh (Mandarin, Qwen3-TTS Dylan) or en'),
58
+ portrait: z.boolean().optional().describe('explainer: a vertical 1080x1920 video'),
55
59
  topic: z.string().optional().describe('Short topic for the file name, e.g. "export-pdf" → videos/<site>-tutorial-export-pdf.yaml. Use it for tutorials/lessons, since a site usually gets several'),
56
60
  storage_state: z.string().optional().describe('Absolute path to a saved login (from the login tool) for apps behind a login'),
57
61
  ignore_https_errors: z.boolean().optional().describe('Accept a self-signed HTTPS certificate (internal sites only); also writes ignoreHTTPSErrors: true into the storyboard'),
58
62
  },
59
- }, async ({ url, path: file, purpose = 'pitch', topic, storage_state, ignore_https_errors = false }) => {
63
+ }, async ({ url, path: file, purpose = 'pitch', topic, storage_state, ignore_https_errors = false, lang, portrait = false }) => {
60
64
  try {
65
+ if (purpose === 'explainer') {
66
+ const r = initExplainer({ topic, file: file ? abs(file) : undefined, lang, portrait });
67
+ return { content: [text(`Wrote ${r.file} and the starter scenes\n${r.scenes.join('\n')}\n\n${r.yaml}\nNext: write the say: lines, call scene_cues, write the scenes with S.at(k), then preview_scene each one.`)] };
68
+ }
69
+ if (!url) return fail('give the url of the web app (only purpose: explainer starts without one)');
61
70
  await ensureBrowser(() => {});
62
71
  const r = await initStoryboard(url, { purpose, topic, file: file ? abs(file) : undefined, storageState: storage_state ? abs(storage_state) : undefined, ignoreHTTPSErrors: ignore_https_errors });
63
72
  return { content: [text(`${r.login?.hint ? `ℹ Login: ${r.login.hint}. If the flow needs it, call login first.\n\n` : ''}Wrote ${r.file}:\n\n${r.yaml}`), image(r.screenshot)] };
@@ -143,6 +152,35 @@ server.registerTool('check_storyboard', {
143
152
  return { isError: !r.ok, content: [text(formatCheck(r))] };
144
153
  });
145
154
 
155
+ server.registerTool('scene_cues', {
156
+ title: 'Scene cues',
157
+ description: 'For storyboards with scene steps: synthesizes the narration and returns, for every scene, its length and when each phrase of its say: line is spoken (scene seconds = what S.at(k) returns inside the scene). Call it after writing the say: lines and before timing the animations; again after changing a line.',
158
+ inputSchema: { storyboard: z.string().describe('Absolute path to the storyboard YAML'), draft: z.boolean().optional().describe('use the free local voice') },
159
+ }, async ({ storyboard, draft = false }) => {
160
+ try {
161
+ const r = await sceneCues(loadConfig(abs(storyboard), { draft }), { log: () => {} });
162
+ return { content: [text(`${r.text || 'no scene steps in this storyboard'}\n\n(also in ${r.file})`)] };
163
+ } catch (e) { return fail(`scene_cues failed: ${e.message}`); }
164
+ });
165
+
166
+ server.registerTool('preview_scene', {
167
+ title: 'Preview a scene',
168
+ description: 'Render stills of one scene step as HyperFrames will (default: a beat into each phrase of its narration, and the last frame), plus HyperFrames lint and its layout/contrast check, in about 20–40 s — without rendering the video. Returns the contact sheet: look at every still (does each part appear when the voice names it? overlaps, crops, text under the subtitles?), fix the scene, preview again.',
169
+ inputSchema: {
170
+ storyboard: z.string().describe('Absolute path to the storyboard YAML'),
171
+ step: z.number().int().min(1).describe('The scene\'s step number (from 1)'),
172
+ at: z.array(z.number().min(0)).optional().describe('Scene seconds to capture instead of the defaults'),
173
+ check: z.boolean().optional().describe('Run the layout/contrast check too (default true; ~20 s)'),
174
+ draft: z.boolean().optional(),
175
+ },
176
+ }, async ({ storyboard, step, at, check: chk = true, draft = false }) => {
177
+ try {
178
+ const r = await previewScene(loadConfig(abs(storyboard), { draft }), step, { at, check: chk, log: () => {} });
179
+ const pics = r.sheets?.length ? r.sheets.map(image) : r.images.slice(0, 6).map(image);
180
+ return { isError: !!r.lint.errors.length, content: [text(formatPreview(r, step)), ...pics] };
181
+ } catch (e) { return fail(`preview_scene failed: ${e.message}`); }
182
+ });
183
+
146
184
  // --- background render jobs -------------------------------------------------------------------
147
185
  const jobs = new Map();
148
186
  let seq = 0;