storyshot 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -217,7 +217,7 @@ Everything the agent does is also a command:
217
217
  | `storyshot record videos/acme-promo.yaml` | Only record. |
218
218
  | `storyshot guide` | Print the playbook and the reference. |
219
219
  | `storyshot setup <agent>` (`cursor`, `claude`, `codex`, `gemini`, `vscode`, `windsurf`, `cline`) | Set up the MCP server and the skill in this project, and install or check what Storyshot needs. |
220
- | `storyshot doctor` | Check Node, ffmpeg, Chromium and voices; install Chromium if it's missing. |
220
+ | `storyshot doctor` | Check Node, ffmpeg (and the filters and encoders Storyshot uses), Chromium and voices; install Chromium if it's missing. |
221
221
  | `storyshot mcp` | Run the MCP server. |
222
222
 
223
223
  Recordings, previews and voice lines are cached in `.storyshot/` next to the
@@ -256,7 +256,7 @@ captions: true # false keeps caption pills off
256
256
  purpose: pitch # pitch | tutorial | course | docs (sets the defaults below)
257
257
  highlight: false # spotlight targets before click/type (per step or per action too)
258
258
  stepNumbers: false # numbered caption badges
259
- subtitles: false # write <out>.srt (narration, else captions); `burn` also burns the narration into the video, each word lighting up as it is spoken (captions move to the top)
259
+ subtitles: false # write <out>.srt (narration, else captions); `burn` also burns the narration into the video, each word lighting up as it is spoken (captions move to the top); uses ffmpeg's libass when it has it, else draws the words itself
260
260
  pace: slow # fast (pitch) | normal (docs) | slow (tutorial, course): cursor and typing speed, a beat after each click, hold after narration, voice speed, how much of a wait is shown
261
261
  follow: true # tutorial/course default: the camera zooms in on what each step touches and pans between steps (a step's own zoom wins; follow: false on a step skips it); or { scale: 1.8 }
262
262
  article: { title: Export a drawing, intro: … } # docs: heading and first paragraph of <out>-steps/README.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyshot",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Turn a short YAML storyboard into a polished product demo video of your web app — smooth cursor, auto zoom, captions, music.",
5
5
  "author": "boheling <boheling@gmail.com>",
6
6
  "repository": {
package/src/audio.mjs CHANGED
@@ -137,8 +137,14 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
137
137
  let dur = pcm.length / 2 / SR;
138
138
  const start = Math.max(line.start, prevEnd + 0.12), room = line.until - start - 0.1;
139
139
  let sped = 1;
140
- if (dur > room && dur / room <= 1.2) { sped = dur / room; pcm = tempo(pcm, sped); dur = pcm.length / 2 / SR; }
141
- else if (dur > room) warn(`${line.where}: narration is ${dur.toFixed(1)}s but only ${Math.max(0, room).toFixed(1)}s until ${line === events.voice.at(-1) ? 'the video ends' : 'the next line'} — shorten "${line.text}" or give the step more duration`);
140
+ // a line pushed past its window by the one before has no room left (room <= 0): it plays late, at normal speed
141
+ if (dur > room && room > 0 && dur / room <= 1.2) { sped = dur / room; pcm = tempo(pcm, sped); dur = pcm.length / 2 / SR; }
142
+ else if (dur > room) warn(room > 0
143
+ ? `${line.where}: narration is ${dur.toFixed(1)}s but only ${room.toFixed(1)}s until ${line === events.voice.at(-1) ? 'the video ends' : 'the next line'} — shorten "${line.text}" or give the step more duration`
144
+ : `${line.where}: the narration before it runs ${(start - line.start).toFixed(1)}s past where this line should start, so "${line.text}" plays late — shorten the earlier lines or give their steps more duration`);
145
+ if (start + dur > events.total) warn(start >= events.total
146
+ ? `${line.where}: "${line.text}" is cut off: the narration before it pushes it past the end of the video (${events.total.toFixed(1)}s) — shorten the lines or give the steps more duration`
147
+ : `${line.where}: the last ${(start + dur - events.total).toFixed(1)}s of "${line.text}" is cut off at the end of the video — shorten it or give the steps more duration`);
142
148
  log(` voice ${start.toFixed(2)}–${(start + dur).toFixed(2)}s ${line.where}${sped > 1 ? ` (sped up x${sped.toFixed(2)})` : ''} "${line.text}"`);
143
149
  let pk = 0;
144
150
  for (const v of pcm) pk = Math.max(pk, Math.abs(v));
package/src/build.mjs CHANGED
@@ -12,7 +12,7 @@ export async function build(cfg, mode = 'render', { log = console.log } = {}) {
12
12
  const voiceLens = await measureVoice(cfg, { log });
13
13
  const { silent, clean, events, preview, warnings, captions, cards, segs } = await compose(cfg, { log, voiceLens });
14
14
  const audio = await soundtrack(cfg, events, silent, { log });
15
- if (cfg.subtitles === 'burn') burnSubtitles(cfg, audio.narration ?? [], { log });
15
+ if (cfg.subtitles === 'burn') await burnSubtitles(cfg, audio.narration ?? [], { log });
16
16
  const extras = await writeOutputs(cfg, { captions, cards, segs, clean, narration: audio.narration, total: events.total }, { log });
17
17
  return { out: audio.out, extras, preview, warnings: [...warnings, ...audio.warnings], duration: events.total, seconds: Math.round((Date.now() - t0) / 1000) };
18
18
  }
package/src/check.mjs CHANGED
@@ -138,6 +138,22 @@ export function check(file, { draft = false } = {}) {
138
138
  const says = visible.filter((s) => s.say).length + (cfg.endCard?.say ? 1 : 0) + (cfg.intro?.say ? 1 : 0);
139
139
  if (cfg.voice && !says) warnings.push('voice is set but no step has a `say:` line');
140
140
  if (!cfg.voice && says) warnings.push('steps have `say:` lines but there is no `voice:` block, so they will not be spoken');
141
+ // fixed-length steps: a line that clearly can't be spoken before the next one starts (a rough
142
+ // estimate from its length; the render measures the real voice)
143
+ if (cfg.voice) {
144
+ const words = (t) => { const x = String(t).replace(/\*\*/g, ''); const c = (x.match(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/g) ?? []).length; return { c, w: x.replace(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/g, ' ').split(/\s+/).filter(Boolean).length }; };
145
+ const secs = (t) => { const { c, w } = words(t); return (c / 4.5 + w / 2.8) / (cfg.voice.speed ?? 1); };
146
+ const vis = visible.map((s) => ({ s, i: cfg.steps.indexOf(s) }));
147
+ for (const [k, { s, i }] of vis.entries()) {
148
+ if (!s.say) continue;
149
+ const nextK = vis.findIndex((v, j) => j > k && v.s.say);
150
+ const span = vis.slice(k, nextK < 0 ? undefined : nextK).map((v) => v.s.duration);
151
+ if (!span.every((d) => typeof d === 'number')) continue;
152
+ const room = span.reduce((a, d) => a + d, 0) - (s.sayAt ?? 0.15) + (nextK < 0 && cfg.endCard ? 2.5 : 0);
153
+ const need = secs(s.say);
154
+ if (need > room * 1.4 && need - room > 1) warnings.push(`step ${i + 1}: the say: line takes about ${need.toFixed(1)}s to speak but has ${room.toFixed(1)}s before ${nextK < 0 ? 'the video ends' : 'the next line'} — shorten it or raise duration (the lines after it would play late)`);
155
+ }
156
+ }
141
157
  if (!draft && cfg.voice && ['elevenlabs', 'openai'].includes(cfg.voice.provider)) {
142
158
  const env = cfg.voice.provider === 'elevenlabs' ? 'ELEVENLABS_API_KEY' : 'OPENAI_API_KEY';
143
159
  if (!process.env[env]) errors.push(`voice provider ${cfg.voice.provider} needs ${env} (environment or a .env file next to the storyboard) — or render with draft mode to use the free local voice`);
package/src/doctor.mjs CHANGED
@@ -5,6 +5,7 @@ import { chromium } from 'playwright';
5
5
  import { execFileSync } from 'node:child_process';
6
6
  import { createRequire } from 'node:module';
7
7
  import path from 'node:path';
8
+ import { hasFilter, hasEncoder, NEEDED, OPTIONAL } from './ffmpeg.mjs';
8
9
 
9
10
  const has = (cmd, args = ['--version']) => { try { execFileSync(cmd, args, { stdio: 'ignore' }); return true; } catch { return false; } };
10
11
 
@@ -47,7 +48,15 @@ export async function doctor({ log = console.log } = {}) {
47
48
 
48
49
  try {
49
50
  const v = execFileSync('ffmpeg', ['-version']).toString().match(/ffmpeg version (\S+)/)?.[1];
50
- lines.push(`✔ ffmpeg ${v ?? ''}`.trim());
51
+ // a build can lack pieces Storyshot uses: say which, and what that affects
52
+ const missing = [...NEEDED.filters.filter((f) => !hasFilter(f)), ...NEEDED.encoders.filter((e) => !hasEncoder(e))];
53
+ if (missing.length) {
54
+ lines.push(`✖ ffmpeg ${v ?? ''} is missing ${missing.join(', ')}`);
55
+ todo.push(`install an ffmpeg build with ${missing.join(', ')} (${process.platform === 'darwin' ? 'e.g. brew install ffmpeg-full, then put "$(brew --prefix ffmpeg-full)/bin" first on your PATH, as it is keg-only; or a static build from https://ffmpeg.org/download.html' : 'e.g. a static build from https://ffmpeg.org/download.html'})`);
56
+ } else lines.push(`✔ ffmpeg ${v ?? ''}`.trim());
57
+ for (const o of OPTIONAL) {
58
+ if (o.filter ? !hasFilter(o.filter) : !hasEncoder(o.encoder)) lines.push(`· ffmpeg has no ${o.filter ? `${o.filter} filter` : `${o.encoder} encoder`} — ${o.for}`);
59
+ }
51
60
  } catch {
52
61
  lines.push('✖ ffmpeg not found');
53
62
  todo.push(ffmpegInstallCommand());
package/src/ffmpeg.mjs ADDED
@@ -0,0 +1,27 @@
1
+ // What the installed ffmpeg can do: builds differ (Homebrew's default ffmpeg has no libass, so no
2
+ // `ass` filter for burned-in subtitles).
3
+ import { execFileSync } from 'node:child_process';
4
+
5
+ const cache = {};
6
+ const list = (kind) => {
7
+ if (!cache[kind]) {
8
+ try {
9
+ const out = execFileSync('ffmpeg', ['-hide_banner', `-${kind}`], { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
10
+ // " .. zoompan V->V ..." (filters) or " V....D libx264 ..." (encoders): the name is the second column
11
+ cache[kind] = new Set(out.split('\n').map((l) => l.trim().split(/\s+/)[1]).filter(Boolean));
12
+ } catch { cache[kind] = new Set(); }
13
+ }
14
+ return cache[kind];
15
+ };
16
+ export const hasFilter = (name) => list('filters').has(name);
17
+ export const hasEncoder = (name) => list('encoders').has(name);
18
+
19
+ // what Storyshot needs from ffmpeg, and what each optional piece is for
20
+ export const NEEDED = {
21
+ filters: ['zoompan', 'overlay', 'alphamerge', 'gblur', 'fade', 'tpad', 'loudnorm', 'alimiter', 'aresample', 'atempo', 'silenceremove', 'areverse', 'palettegen', 'paletteuse'],
22
+ encoders: ['libx264', 'aac'],
23
+ };
24
+ export const OPTIONAL = [
25
+ { filter: 'ass', for: '`subtitles: burn` uses its own renderer instead (the word highlight steps word by word rather than sweeping)' },
26
+ { encoder: 'libvpx-vp9', for: 'webm clips (clips: webm)' },
27
+ ];
package/src/outputs.mjs CHANGED
@@ -2,6 +2,8 @@
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { chromium } from 'playwright';
6
+ import { hasFilter } from './ffmpeg.mjs';
5
7
 
6
8
  const plain = (s) => String(s).replace(/\*\*/g, '');
7
9
  const srtTime = (t) => {
@@ -80,7 +82,7 @@ export async function writeOutputs(cfg, { captions, cards = [], segs, clean, nar
80
82
 
81
83
  // `subtitles: burn`: the narration burned into the video, a few words at a time, each word lighting
82
84
  // up as it is spoken (karaoke). Word timing is estimated from word length within each spoken line.
83
- export function burnSubtitles(cfg, narration, { log = console.log } = {}) {
85
+ export async function burnSubtitles(cfg, narration, { log = console.log, renderer = hasFilter('ass') ? 'ass' : 'png' } = {}) {
84
86
  if (!narration.length) return log('note: subtitles: burn needs narration (say: lines and a voice); nothing burned in');
85
87
  const { width: W, height: H } = cfg.output;
86
88
  const bgr = (hex, a = '00') => { const h = hex.replace('#', ''); return `&H${a}${h.slice(4, 6)}${h.slice(2, 4)}${h.slice(0, 2)}`.toUpperCase(); };
@@ -90,7 +92,7 @@ export function burnSubtitles(cfg, narration, { log = console.log } = {}) {
90
92
  const cjk = cfg.lang === 'zh' || narration.some((l) => /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(l.text));
91
93
  const size = Math.round(H * (cjk ? 0.042 : 0.048)), edge = Math.max(2, Math.round(size * (cjk ? 0.07 : 0.09)));
92
94
  const fontName = cjk && cfg.theme.font === 'Inter' ? (process.platform === 'darwin' ? 'PingFang SC' : 'Noto Sans CJK SC') : cfg.theme.font;
93
- const events = [];
95
+ const cues = []; // { words: [{ w, start, d }] }: one on-screen line each
94
96
  for (const line of narration) {
95
97
  // CJK: one token per character (Latin words inside stay whole), punctuation stays with the one before
96
98
  const words = cjk
@@ -104,8 +106,7 @@ export function burnSubtitles(cfg, narration, { log = console.log } = {}) {
104
106
  let chunk = [];
105
107
  const flush = () => {
106
108
  if (!chunk.length) return;
107
- const s0 = chunk[0].start, e0 = chunk.at(-1).start + chunk.at(-1).d;
108
- events.push(`Dialogue: 0,${t(s0)},${t(e0 + 0.05)},Sub,,0,0,0,,${chunk.map((x) => `{\\kf${Math.round(x.d * 100)}}${x.w.replace(/[{}]/g, '')}`).join(cjk ? '' : ' ')}`);
109
+ cues.push({ words: chunk });
109
110
  chunk = [];
110
111
  };
111
112
  const len = () => chunk.reduce((a, x) => a + x.w.length, 0);
@@ -117,6 +118,64 @@ export function burnSubtitles(cfg, narration, { log = console.log } = {}) {
117
118
  }
118
119
  flush();
119
120
  }
121
+ const tmp = cfg.out.replace(/(\.[^.]+)$/, '.burn$1');
122
+ const encode = ['-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-c:a', 'copy', '-movflags', '+faststart', tmp];
123
+ const edgeCol = cfg.theme.ink, sweep = cfg.theme.accent;
124
+ if (renderer === 'png') {
125
+ // no libass in this ffmpeg: draw each state of each line (the words spoken so far in the accent
126
+ // colour) in Chromium, string them into one transparent track and overlay it
127
+ const dir = path.join(cfg.work, 'subs');
128
+ fs.rmSync(dir, { recursive: true, force: true });
129
+ fs.mkdirSync(dir, { recursive: true });
130
+ const esc = (x) => x.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
131
+ const font = `'${fontName}',${cjk ? "'PingFang SC','Hiragino Sans GB','Noto Sans CJK SC','Microsoft YaHei'," : ''}-apple-system,'Segoe UI','Helvetica Neue',sans-serif`;
132
+ const head = cjk ? '' : `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontName)}:wght@400;700&display=block">`;
133
+ const browser = await chromium.launch();
134
+ const pg = await browser.newPage({ viewport: { width: W, height: H } });
135
+ const shot = async (html, file) => {
136
+ await pg.setContent(`<html><head>${head}</head><body style="margin:0;background:transparent">${html}</body></html>`, { waitUntil: 'networkidle' }).catch(() => {});
137
+ await pg.evaluate(() => document.fonts.ready);
138
+ await pg.screenshot({ path: file, omitBackground: true });
139
+ };
140
+ const list = ['ffconcat version 1.0'];
141
+ let at = 0, n = 0;
142
+ const push = (file, until) => { if (until - at > 0.001) { list.push(`file '${file}'`, `duration ${(until - at).toFixed(3)}`); at = until; } };
143
+ await shot('', path.join(dir, 'blank.png'));
144
+ // libass sizes a font by its line height (ascent + descent), CSS by its em: match libass
145
+ await pg.setContent(`<html><head>${head}</head><body></body></html>`, { waitUntil: 'networkidle' }).catch(() => {});
146
+ const em = await pg.evaluate(async (font) => {
147
+ await document.fonts.load(`100px ${font}`);
148
+ const m = document.createElement('canvas').getContext('2d');
149
+ m.font = `100px ${font}`;
150
+ const r = m.measureText('Hg中');
151
+ return 100 / ((r.fontBoundingBoxAscent + r.fontBoundingBoxDescent) || 100);
152
+ }, font);
153
+ const px = Math.round(size * em);
154
+ for (const cue of cues) {
155
+ const end = cue.words.at(-1).start + cue.words.at(-1).d + 0.05;
156
+ if (cue.words[0].start > at) push('blank.png', cue.words[0].start);
157
+ for (const [j, x] of cue.words.entries()) {
158
+ const file = `s${String(n++).padStart(5, '0')}.png`;
159
+ const text = cue.words.map((y, k) => `<span style="color:${k <= j ? sweep : '#fff'}">${esc(y.w)}</span>`).join(cjk ? '' : ' ');
160
+ await shot(`<div style="position:absolute;left:${Math.round(W * 0.08)}px;right:${Math.round(W * 0.08)}px;bottom:${Math.round(H * 0.06)}px;text-align:center;
161
+ font-family:${font};font-size:${px}px;font-weight:${cjk ? 400 : 700};line-height:1.2;
162
+ -webkit-text-stroke:${edge * 2}px ${edgeCol};paint-order:stroke fill;text-shadow:${Math.round(edge * 0.8)}px ${Math.round(edge * 0.8)}px 0 rgba(0,0,0,.5)">${text}</div>`, path.join(dir, file));
163
+ push(file, j + 1 < cue.words.length ? cue.words[j + 1].start : end);
164
+ }
165
+ }
166
+ await browser.close();
167
+ push('blank.png', at + 1);
168
+ list.push("file 'blank.png'"); // the last entry's duration only counts when a file follows it
169
+ fs.writeFileSync(path.join(dir, 'list.txt'), list.join('\n') + '\n');
170
+ execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', cfg.out, '-f', 'concat', '-safe', '0', '-i', path.join(dir, 'list.txt'),
171
+ '-filter_complex', '[1:v]format=rgba[s];[0:v][s]overlay=0:0:eof_action=pass,format=yuv420p[v]', '-map', '[v]', '-map', '0:a?', ...encode]);
172
+ fs.renameSync(tmp, cfg.out);
173
+ return log(`subtitles: burned in ${cues.length} lines with word-by-word highlight (drawn by Storyshot: this ffmpeg has no libass)`);
174
+ }
175
+ const events = cues.map(({ words }) => {
176
+ const s0 = words[0].start, e0 = words.at(-1).start + words.at(-1).d;
177
+ return `Dialogue: 0,${t(s0)},${t(e0 + 0.05)},Sub,,0,0,0,,${words.map((x) => `{\\kf${Math.round(x.d * 100)}}${x.w.replace(/[{}]/g, '')}`).join(cjk ? '' : ' ')}`;
178
+ });
120
179
  const ass = `[Script Info]
121
180
  ScriptType: v4.00+
122
181
  PlayResX: ${W}
@@ -125,17 +184,17 @@ WrapStyle: 0
125
184
 
126
185
  [V4+ Styles]
127
186
  Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
128
- Style: Sub,${fontName},${size},${bgr(cfg.theme.accent)},&H00FFFFFF,${bgr(cfg.theme.ink)},&H80000000,${cjk ? 0 : 1},0,0,0,100,100,0,0,1,${edge},${Math.round(edge * 0.8)},2,${Math.round(W * 0.08)},${Math.round(W * 0.08)},${Math.round(H * 0.06)},1
187
+ Style: Sub,${fontName},${size},${bgr(sweep)},&H00FFFFFF,${bgr(edgeCol)},&H80000000,${cjk ? 0 : 1},0,0,0,100,100,0,0,1,${edge},${Math.round(edge * 0.8)},2,${Math.round(W * 0.08)},${Math.round(W * 0.08)},${Math.round(H * 0.06)},1
129
188
 
130
189
  [Events]
131
190
  Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
132
191
  ${events.join('\n')}
133
192
  `;
134
- const file = path.join(cfg.work, 'subtitles.ass'), tmp = cfg.out.replace(/(\.[^.]+)$/, '.burn$1');
193
+ const file = path.join(cfg.work, 'subtitles.ass');
135
194
  fs.writeFileSync(file, ass);
136
195
  // the filter argument is parsed by ffmpeg: escape the characters it treats specially in paths
137
196
  const arg = file.replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'");
138
- execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', cfg.out, '-vf', `ass='${arg}'`, '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-c:a', 'copy', '-movflags', '+faststart', tmp]);
197
+ execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', cfg.out, '-vf', `ass='${arg}'`, ...encode]);
139
198
  fs.renameSync(tmp, cfg.out);
140
199
  log(`subtitles: burned in ${events.length} lines with word-by-word highlight`);
141
200
  }
package/src/voice.mjs CHANGED
@@ -94,6 +94,7 @@ export async function speak(cfg, line, { log = console.log } = {}) {
94
94
 
95
95
  // Speed a clip up (factor > 1) with ffmpeg's pitch-preserving atempo.
96
96
  export function tempo(pcm, factor) {
97
+ if (!(factor >= 0.5 && factor <= 100)) throw new Error(`internal: speed factor ${factor} is outside what ffmpeg's atempo accepts (0.5–100)`);
97
98
  const out = execFileSync('ffmpeg', ['-loglevel', 'error', '-f', 'f32le', '-ac', '2', '-ar', `${SR}`, '-i', 'pipe:0',
98
99
  '-af', `atempo=${factor.toFixed(4)}`, '-f', 'f32le', '-ac', '2', '-ar', `${SR}`, 'pipe:1'], { input: Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength), maxBuffer: 1 << 28 });
99
100
  return new Float32Array(out.buffer, out.byteOffset, out.length / 4);