storyshot 0.4.0 → 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
@@ -252,15 +252,18 @@ voice: { provider: elevenlabs, voice: hpp4J3VqNfWAUOO0d1Us } # optional voiceo
252
252
  storageState: auth.json # optional Playwright login state
253
253
  ignoreHTTPSErrors: true # internal site with a self-signed HTTPS certificate (plain http:// needs nothing)
254
254
  captionPosition: bottom # or top; can also be set per step. A caption that would cover a step's click target or zoom subject moves to the other side automatically
255
+ captions: true # false keeps caption pills off the video (chapters and SRT still use them), e.g. with subtitles: burn
255
256
  purpose: pitch # pitch | tutorial | course | docs (sets the defaults below)
256
257
  highlight: false # spotlight targets before click/type (per step or per action too)
257
258
  stepNumbers: false # numbered caption badges
258
- 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
259
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
260
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 }
261
262
  article: { title: Export a drawing, intro: … } # docs: heading and first paragraph of <out>-steps/README.md
263
+ lang: es # language of built-in labels ("Pause and think", chapter "Introduction", "Downloaded"): en, zh, es, pt, fr, de, ja; default: zh if the steps contain Chinese, else en
262
264
  cards: auto # intro/section/end cards: auto follows the frame (dark frames get dark cards), or light | dark
263
- frame: aurora # the recording sits in a browser window on a backdrop: aurora (dark, grain; pitch default), studio (light, grain; tutorial and course default), dark, pastel, blur (the page itself, blurred); false = full frame; or { style, background: <css> }
265
+ frame: aurora # the recording sits in a browser window on a backdrop: aurora (dark, grain; pitch default), studio (light, grain; tutorial and course default), dark, pastel, blur (the page itself, blurred); false = full frame; or { style, background: <css>, window, title, icon }
266
+ # window: app draws an installed app's window (its icon and name in the title bar, no address bar) for a PWA or desktop web app; title and icon come from the page's web app manifest unless given
264
267
  chapters: false # write <out>.chapters.txt for YouTube
265
268
  clips: null # gif | webm | mp4 | png, or a list (docs default: [gif, png]): one file per step in <out>-steps/, cropped to what the step touches, plus README.md, a ready-to-paste article (step captions, `doc:` text, images)
266
269
  maxWait: 1.0 # with duration: auto, the longest a wait is shown
@@ -299,8 +302,7 @@ end_card:
299
302
  logo: { alt: Company logo } # captured from the page, or a path to an image
300
303
  title: Acme
301
304
  subtitle: Ship **faster**
302
- cta: Start free at acme.com
303
- duration: 1.0
305
+ cta: Start free at acme.com # duration: auto (default) fits the say line, bullets and cta, at least 2.5 s
304
306
  ```
305
307
 
306
308
  Steps that share a caption keep it on screen across both steps.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyshot",
3
- "version": "0.4.0",
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": {
@@ -193,10 +193,12 @@ Rules of thumb from real renders (promos and tutorials):
193
193
 
194
194
  - **One idea per step.**
195
195
  - `pitch`: 1.5–3 s per step (`duration:`), so the viewer gets one beat per
196
- shot; the number of shots comes from the story, plus a 1–1.5 s `end_card`. Typical shape: `intro` → hero → 2–4
196
+ shot; the number of shots comes from the story, plus an `end_card` held long enough to read its call to action (at least 2.5 s; leave its `duration` out). Typical shape: `intro` → hero → 2–4
197
197
  steps of the core flow ending on a visible result → pricing or CTA → end
198
198
  card. Promos sit in a browser window on a backdrop (`frame: false` turns
199
- that off).
199
+ that off). For an app people install (a PWA, or a desktop app built on
200
+ web tech), use `frame: { window: app }`: the title bar shows the app's
201
+ icon and name, not an address bar.
200
202
  - Other purposes: leave out `duration`. `auto` times each step to its
201
203
  narration and plays motion in real time.
202
204
  - **Captions ≤ 7 words.** `**bold**` the payoff ("Every text box is
@@ -257,8 +259,13 @@ the page. Test new targets with `inspect_page` + `after_step`, which is much
257
259
  faster than a failed render.
258
260
 
259
261
  Notes:
260
- - The end card's duration fits its `say:` line automatically; leave out
261
- `duration` there.
262
+ - The end card's duration fits its `say:` line, bullets and `cta`
263
+ automatically (never under 2.5 s); leave out `duration` there.
264
+ - Compose warns about **dead air**: 3 s or more where nothing moves and
265
+ nothing is said. Shorten the wait or pause, hide the load in a `hidden`
266
+ step, or narrate it. Keep `intro` cards to 6–8 s.
267
+ - With `subtitles: burn`, the top caption pills can repeat the subtitles;
268
+ `captions: false` keeps them off the video (chapters and SRT still use them).
262
269
  - `.storyshot/` next to the YAML is a cache of recordings, screenshots and
263
270
  voice lines. Add it to `.gitignore`.
264
271
 
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));
@@ -183,8 +189,10 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
183
189
  const wavFile = path.join(cfg.work, 'soundtrack.wav');
184
190
  fs.writeFileSync(wavFile, wav);
185
191
 
192
+ // loudnorm alone lets peaks through after AAC encoding; the limiter keeps true peaks near −2 dB,
193
+ // so re-encoding by video sites doesn't clip
186
194
  execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', silentVideo, '-i', wavFile,
187
- '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', 'loudnorm=I=-14:TP=-1.5:LRA=11', '-ar', `${SR}`, '-c:a', 'aac', '-b:a', '192k', '-shortest', '-movflags', '+faststart', cfg.out]);
195
+ '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', `loudnorm=I=-14:TP=-1.5:LRA=11,aresample=${SR},alimiter=limit=0.7:level=false:attack=1:release=50`, '-ar', `${SR}`, '-c:a', 'aac', '-b:a', '192k', '-shortest', '-movflags', '+faststart', cfg.out]);
188
196
  log(`soundtrack: ${cfg.music ? (cfg.music.file ? 'music file' : `synth ${cfg.music.bpm} bpm`) : 'no music'}, ${events.clicks.length} clicks, ${events.whooshes.length} whooshes` +
189
197
  (cfg.voice ? `, ${events.voice.length} narration lines (${cfg.voice.provider}/${cfg.voice.voice})` : ''));
190
198
  return { out: cfg.out, warnings, narration };
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
@@ -5,7 +5,7 @@ import { loadConfig } from './config.mjs';
5
5
  import { gitIgnored } from './auth.mjs';
6
6
 
7
7
  const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'intro', 'frame', 'pace', 'follow', 'article', 'cards', 'lang', 'colorScheme',
8
- 'scrollOffset', 'captionPosition', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card'];
8
+ 'scrollOffset', 'captionPosition', 'captions', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card'];
9
9
  const STEP = ['caption', 'say', 'sayAt', 'duration', 'zoom', 'actions', 'hidden', 'sfx', 'highlight', 'captionPosition', 'card', 'follow', 'doc'];
10
10
  const ZOOM = ['target', 'scale', 'start', 'end', 'ramp', 'offset'];
11
11
  const END = ['logo', 'title', 'subtitle', 'cta', 'duration', 'say', 'kicker', 'bullets', 'image', 'think'];
@@ -85,7 +85,7 @@ export function check(file, { draft = false } = {}) {
85
85
  if (raw.end_card) unknown(raw.end_card, END, 'end_card');
86
86
  if (raw.intro) unknown(raw.intro, END, 'intro');
87
87
  if (raw.article) unknown(raw.article, ['title', 'intro'], 'article');
88
- if (raw.frame && typeof raw.frame === 'object') unknown(raw.frame, ['background', 'style'], 'frame');
88
+ if (raw.frame && typeof raw.frame === 'object') unknown(raw.frame, ['background', 'style', 'window', 'title', 'icon'], 'frame');
89
89
  const todo = (fs.readFileSync(file, 'utf8').split('\n').map((l, i) => [l.replace(/#.*$/, ''), i + 1]).filter(([l]) => /\bTODO\b/.test(l)));
90
90
  for (const [, n] of todo) errors.push(`line ${n}: replace the TODO placeholder`);
91
91
 
@@ -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/compose.mjs CHANGED
@@ -22,9 +22,13 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
22
22
  const readTime = (c) => (c?.bullets?.length ? 1.6 + 0.4 * c.bullets.length + 0.6 * c.bullets.length : 0);
23
23
  // `think: N` (question cards): after the question is read out, N seconds with a countdown bar
24
24
  const cardLen = (c, voice, min) => (c.duration === 'auto' || c.duration == null ? Math.max(min, (voice ?? 0) + 0.9, readTime(c)) + (c.think ?? 0) : c.duration);
25
- const END = !ec0 ? 0 : ec0.duration === 'auto' ? Math.max(1.0, (voiceLens.endCard ?? 0) + 0.6, readTime(ec0)) : ec0.duration;
25
+ // the end card carries the call to action: long enough to read it (and for a command to type itself out)
26
+ const ctaTime = (c) => (c?.cta ? 0.8 + String(c.cta).length * 0.045 + 1.6 : 0);
27
+ const END = !ec0 ? 0 : ec0.duration === 'auto' ? Math.max(2.5, (voiceLens.endCard ?? 0) + 0.6, readTime(ec0), ctaTime(ec0)) : ec0.duration;
28
+ if (ec0 && END < 2.5) warn(`the end card shows for only ${END.toFixed(1)}s: too short to read its call to action — use duration: auto, or at least 2.5`);
26
29
  const in0 = cfg.intro;
27
30
  const INTRO = !in0 ? 0 : in0.duration === 'auto' ? Math.max(1.8, (voiceLens.intro ?? 0) + 0.8, readTime(in0)) : in0.duration;
31
+ if (INTRO > 8) warn(`the intro card plays ${INTRO.toFixed(1)}s before anything happens: viewers leave during long intros — shorten its say or bullets (aim for 6–8s)`);
28
32
  const cards = []; // section cards: { i, t0, t1, card }
29
33
 
30
34
  // --- 1. segments (one per visible step), split into motion / waiting pieces ------------
@@ -150,6 +154,39 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
150
154
  }
151
155
  }
152
156
 
157
+ // --- 2c. dead air: stretches where nothing moves and nobody speaks --------------------------------
158
+ {
159
+ const tw = 160, th = 90, rate = 10;
160
+ const buf = execFileSync('ffmpeg', ['-loglevel', 'error', '-framerate', `${FPS}`, '-i', path.join(SEQ, '%05d.jpg'),
161
+ '-vf', `fps=${rate},scale=${tw}:${th},format=gray`, '-f', 'rawvideo', 'pipe:1'], { maxBuffer: 1 << 28 });
162
+ const n = Math.floor(buf.length / (tw * th)), still = [false];
163
+ for (let f = 1; f < n; f++) {
164
+ let changed = 0; // pixels that changed visibly: even the cursor moving changes a few
165
+ for (let k = 0, a = f * tw * th, b = a - tw * th; k < tw * th; k++) if (Math.abs(buf[a + k] - buf[b + k]) > 10) changed++;
166
+ still.push(changed < 4);
167
+ }
168
+ const spoken = [
169
+ ...(in0 ? [[0, INTRO]] : []), // cards are their own pictures (the frames under them don't show)
170
+ ...cards.map((c) => [c.t0, c.t1]),
171
+ ...segs.filter((g) => voiceLens.steps[g.i] != null).map((g) => [g.o0 + (g.step.sayAt ?? 0.15), g.o0 + (g.step.sayAt ?? 0.15) + voiceLens.steps[g.i]]),
172
+ ];
173
+ let from = null;
174
+ const report = (to) => {
175
+ if (from != null && to - from >= 3) {
176
+ const ns = segs.filter((x) => x.o1 > from + 1e-6 && x.o0 < to - 1e-6).map((x) => x.i + 1);
177
+ 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`);
178
+ }
179
+ from = null;
180
+ };
181
+ for (let f = 0; f < n; f++) {
182
+ const t = f / rate;
183
+ const dead = still[f] && !spoken.some(([a, b]) => t >= a && t <= b);
184
+ if (dead && from == null) from = t;
185
+ else if (!dead) report(t);
186
+ }
187
+ report(n / rate);
188
+ }
189
+
153
190
  // --- 3. zoom keyframes: [time, zoom, [cx, cy]] in output px -------------------------------
154
191
  const C0 = [W / 2, H / 2];
155
192
  const kf = [[0, 1, C0]];
@@ -197,7 +234,9 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
197
234
  const b = g.zoomBox, off = z.offset ?? {};
198
235
  const c = [(b.x + b.width / 2 + (off.x ?? 0)) * K, (b.y + b.height / 2 + (off.y ?? 0)) * K];
199
236
  const [rin, rout] = Array.isArray(z.ramp) ? z.ramp : [z.ramp ?? 0.5, z.ramp ?? 0.5];
200
- const s = g.o0 + (z.start ?? 0), e = Math.min(TOTAL, g.o0 + (z.end ?? g.o1 - g.o0)), scale = z.scale ?? 1.3;
237
+ // a zoom belongs to its step: an `end` past the step (e.g. after the narration grew it) stops there
238
+ if (z.end != null && g.o0 + z.end > g.o1 + 0.05) warn(`step ${g.i + 1}: zoom end ${z.end}s is past the step's end (${(g.o1 - g.o0).toFixed(1)}s), so it stops there — leave out end to zoom to the step's end`);
239
+ const s = g.o0 + (z.start ?? 0), e = Math.min(TOTAL, g.o1, g.o0 + (z.end ?? g.o1 - g.o0)), scale = z.scale ?? 1.3;
201
240
  const last = kf.at(-1)[0];
202
241
  if (s < last) { warn(`zoom in step ${g.i + 1} overlaps the previous zoom, skipped`); continue; }
203
242
  kf.push([s, 1, C0], [Math.min(s + rin, e), scale, c], [Math.max(s + rin, e - rout), scale, c], [e, 1, C0]);
@@ -235,6 +274,20 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
235
274
  return { x: FX + (b.x * K - v.x0) * v.z * F, y: FY + (b.y * K - v.y0) * v.z * F, w: b.width * K * v.z * F, h: b.height * K * v.z * F };
236
275
  };
237
276
 
277
+ // what the address bar shows over time: the page's URL when each visible moment was recorded
278
+ const addressOf = (u) => { try { const x = new URL(u); return x.host.replace(/^www\./, '') + x.pathname.replace(/\/$/, ''); } catch { return String(u); } };
279
+ const urlAt = (r) => (tl.urls ?? []).filter((x) => x.t <= r).at(-1)?.url ?? cfg.url;
280
+ const addresses = [];
281
+ for (const g of cfg.frame?.window === 'browser' ? segs : []) {
282
+ const r = tl.steps[g.i], changes = (tl.urls ?? []).filter((x) => x.t > r.start && x.t <= r.end);
283
+ for (const [t, u] of [[g.o0, urlAt(r.start)], ...changes.map((x) => [toOut(x.t) ?? g.o0, x.url])]) {
284
+ const text = addressOf(u), last = addresses.at(-1);
285
+ if (last?.text === text) continue;
286
+ if (last) last.t1 = t;
287
+ addresses.push({ text, t0: last ? t : 0, t1: TOTAL });
288
+ }
289
+ }
290
+
238
291
  // --- 4. captions + end card rendered as PNGs ----------------------------------------------
239
292
  const { accent, ink, font } = cfg.theme;
240
293
  const fontCss = `font-family:'${font}',-apple-system,'Segoe UI','Helvetica Neue',sans-serif;`;
@@ -247,8 +300,10 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
247
300
  await pg.screenshot({ path: file, omitBackground: transparent });
248
301
  };
249
302
  const fs1 = Math.round(H * 0.043), MARGIN = Math.round(H * 0.06);
303
+ // in a browser-window frame a top caption sits just inside the page, below the address bar
304
+ const TOP = cfg.frame ? FY + Math.round(H * 0.022) : MARGIN;
250
305
  const bubble = (text, n, pos) => `<body style="margin:0;background:transparent">
251
- <div style="position:absolute;left:0;right:0;${pos === 'top' ? 'top' : 'bottom'}:${MARGIN}px;display:flex;justify-content:center;padding:0 5%">
306
+ <div style="position:absolute;left:0;right:0;${pos === 'top' ? `top:${TOP}` : `bottom:${MARGIN}`}px;display:flex;justify-content:center;padding:0 5%">
252
307
  <div id="cap" style="${fontCss}font-size:${fs1}px;font-weight:500;letter-spacing:-.01em;color:#fff;background:${ink}e0;text-align:center;
253
308
  padding:${Math.round(fs1 * 0.43)}px ${Math.round(fs1 * 0.87)}px;border-radius:${fs1}px;box-shadow:0 12px 40px rgba(0,0,0,.28);border:1px solid rgba(255,255,255,.12)">
254
309
  ${cfg.stepNumbers ? `<span style="display:inline-flex;align-items:center;justify-content:center;width:1.45em;height:1.45em;margin:-.2em .55em -.2em -.35em;border-radius:50%;background:${accent};color:${ink};font-weight:700;font-size:.85em;vertical-align:middle">${n}</span>` : ''}${md(text, accent)}</div></div></body>`;
@@ -273,7 +328,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
273
328
  const n = prev && prev.text === g.step.caption && Math.abs(prev.t[1] - g.o0) < 1e-6 ? prev.n : captions.length + 1;
274
329
  const explicit = g.step.captionPosition, want = explicit ?? cfg.captionPosition;
275
330
  const { w, h } = await sizeOf(g.step.caption, n);
276
- const rectOf = (pos) => ({ x: (W - w) / 2, y: pos === 'top' ? MARGIN : H - MARGIN - h, w, h });
331
+ const rectOf = (pos) => ({ x: (W - w) / 2, y: pos === 'top' ? TOP : H - MARGIN - h, w, h });
277
332
  const subjects = [];
278
333
  for (const m of tl.steps[g.i].marks ?? []) {
279
334
  const t = toOut(m.t);
@@ -297,7 +352,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
297
352
  if (prev && n === prev.n && prev.pos === pos) prev.t[1] = g.o1;
298
353
  else captions.push({ text: g.step.caption, t: [g.o0, g.o1], pos, n, h });
299
354
  }
300
- for (const [i, c] of captions.entries()) await shot(bubble(c.text, c.n, c.pos), path.join(cfg.work, `cap${i}.png`), true);
355
+ for (const [i, c] of (cfg.captions ? captions : []).entries()) await shot(bubble(c.text, c.n, c.pos), path.join(cfg.work, `cap${i}.png`), true);
301
356
  // Intro and end cards are short animations (logo pops, title rises, the highlight sweeps, a command
302
357
  // CTA types itself out), rendered frame by frame by seeking the page's CSS animations.
303
358
  const u = H / 1080;
@@ -313,7 +368,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
313
368
  const image = c.imageFile && fs.existsSync(c.imageFile)
314
369
  ? `<img class="a rise" src="data:image/${path.extname(c.imageFile).slice(1).replace('jpg', 'jpeg').replace('svg', 'svg+xml')};base64,${fs.readFileSync(c.imageFile).toString('base64')}" style="animation-delay:.4s;max-width:${W * 0.6}px;max-height:${H * 0.42}px;border-radius:${12 * H / 1080}px;box-shadow:0 ${12 * H / 1080}px ${36 * H / 1080}px rgba(15,23,42,.18)">` : '';
315
370
  const think = c.think ? `<div class="a rise" style="animation-delay:${thinkAt.toFixed(2)}s;position:absolute;left:50%;bottom:${H * 0.1}px;transform:translateX(-50%);width:${W * 0.3}px;text-align:center;font-size:${26 * H / 1080}px;color:${CK.muted}">
316
- <div style="margin-bottom:${12 * H / 1080}px">${cfg.lang === 'zh' ? '先想一想' : 'Pause and think'}</div>
371
+ <div style="margin-bottom:${12 * H / 1080}px">${cfg.labels.think}</div>
317
372
  <div style="height:${8 * H / 1080}px;border-radius:99px;background:${CK.track};overflow:hidden"><div style="height:100%;background:${accent};transform-origin:left;animation:drain ${c.think}s linear ${thinkAt.toFixed(2)}s both"></div></div></div>
318
373
  <style>@keyframes drain{from{transform:scaleX(1)}to{transform:scaleX(0)}}</style>` : '';
319
374
  const logo = fs.existsSync(logoPath)
@@ -379,7 +434,16 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
379
434
  blur: { dark: true, bg: null },
380
435
  };
381
436
  const st = STYLES[cfg.frame.style], bg = cfg.frame.background ?? st.bg;
382
- const R = Math.round(14 * u), host = new URL(cfg.url).host.replace(/^www\./, '') + new URL(cfg.url).pathname.replace(/\/$/, '');
437
+ // an app window: the app's icon and name in the title bar instead of an address bar
438
+ let app = null;
439
+ if (cfg.frame.window === 'app') {
440
+ const MIME = { svg: 'image/svg+xml', ico: 'image/x-icon', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', png: 'image/png' };
441
+ const [file, type] = cfg.frame.iconFile ? [cfg.frame.iconFile, MIME[path.extname(cfg.frame.iconFile).slice(1).toLowerCase()] ?? 'image/png']
442
+ : tl.app?.icon ? [path.join(cfg.work, tl.app.icon.file), tl.app.icon.type] : [];
443
+ const icon = file && fs.existsSync(file) ? `data:${type};base64,${fs.readFileSync(file).toString('base64')}` : null;
444
+ app = { icon, title: cfg.frame.title ?? tl.app?.title ?? (() => { try { return new URL(cfg.url).host.replace(/^www\./, ''); } catch { return ''; } })() };
445
+ }
446
+ const R = Math.round(14 * u);
383
447
  const dot = (col) => `<span style="width:${13 * u}px;height:${13 * u}px;border-radius:50%;background:${col};display:inline-block"></span>`;
384
448
  const [barBg, barFg, pill, line] = st.dark ? ['#1f2430', '#9aa4b2', '#2b3140', '#11151d'] : ['#e8ebef', '#4b5563', '#fff', '#d5d9df'];
385
449
  if (bg) await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:${bg}"></body>`, path.join(cfg.work, 'frame-bg.png'), false);
@@ -387,8 +451,16 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
387
451
  <div style="position:absolute;left:${FX}px;top:${FY - BAR}px;width:${FW}px;height:${FH + BAR}px;border-radius:${R}px;background:#fff;box-shadow:0 ${30 * u}px ${80 * u}px rgba(0,0,0,${st.dark ? '.5' : '.25'}),0 0 0 1px rgba(255,255,255,.08)"></div>
388
452
  <div style="position:absolute;left:${FX}px;top:${FY - BAR}px;width:${FW}px;height:${BAR}px;border-radius:${R}px ${R}px 0 0;background:${barBg};display:flex;align-items:center;border-bottom:1px solid ${line}">
389
453
  <div style="display:flex;gap:${8 * u}px;margin-left:${16 * u}px">${dot('#ff5f57')}${dot('#febc2e')}${dot('#28c840')}</div>
390
- <div style="position:absolute;left:50%;transform:translateX(-50%);font-size:${15 * u}px;color:${barFg};background:${pill};border-radius:${8 * u}px;padding:${4 * u}px ${60 * u}px">${esc(host)}</div>
454
+ ${app ? `<div style="position:absolute;left:0;right:0;top:0;bottom:0;display:flex;align-items:center;justify-content:center;gap:${8 * u}px;font-size:${15 * u}px;font-weight:600;color:${barFg}">
455
+ ${app.icon ? `<img src="${app.icon}" style="width:${18 * u}px;height:${18 * u}px;object-fit:contain;border-radius:${4 * u}px">` : ''}<span style="max-width:${FW * 0.5}px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(app.title)}</span></div>` : ''}
391
456
  </div></body>`, path.join(cfg.work, 'frame-chrome.png'), true);
457
+ // the address bar follows the page: one pill per address it shows
458
+ for (const [k, a] of addresses.entries()) {
459
+ await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:transparent;${fontCss}">
460
+ <div style="position:absolute;left:${FX}px;top:${FY - BAR}px;width:${FW}px;height:${BAR}px;display:flex;align-items:center;justify-content:center">
461
+ <div style="font-size:${15 * u}px;color:${barFg};background:${pill};border-radius:${8 * u}px;padding:${4 * u}px ${60 * u}px;max-width:${FW * 0.5}px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(a.text)}</div>
462
+ </div></body>`, path.join(cfg.work, `frame-url${k}.png`), true);
463
+ }
392
464
  // alpha mask for the page: rounded bottom corners (the title bar covers the top ones)
393
465
  await pg.setViewportSize({ width: FW, height: FH });
394
466
  await pg.setContent(`<body style="margin:0;width:${FW}px;height:${FH}px;background:#000"><div style="width:100%;height:100%;background:#fff;border-radius:0 0 ${R}px ${R}px"></div></body>`);
@@ -416,12 +488,17 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
416
488
  graph += `[${blur ? 'v0a' : 'v0'}]scale=${FW}:${FH}:flags=lanczos,format=rgba[pg0];[${mask}:v]format=gray,scale=${FW}:${FH}[pm];[pg0][pm]alphamerge[pg];\n` +
417
489
  `[${base}][${chrome}:v]overlay=0:0[fc];[fc][pg]overlay=${FX}:${FY},format=yuv420p[vf];\n`;
418
490
  last = 'vf';
491
+ for (const [k, a] of addresses.entries()) {
492
+ inputs.push('-loop', '1', '-t', `${(a.t1 - a.t0 + 0.1).toFixed(3)}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, `frame-url${k}.png`));
493
+ graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${a.t0.toFixed(3)}/TB[u${k}];[${last}][u${k}]overlay=0:0:eof_action=pass:enable='between(t,${a.t0.toFixed(3)},${a.t1.toFixed(3)})'[vu${k}];\n`;
494
+ last = `vu${k}`; idx++;
495
+ }
419
496
  }
420
497
  // docs: a second, caption-free copy for the per-step clips and stills (the article carries the
421
498
  // text), so crops never have to dodge a caption
422
499
  const clean = cfg.clips.length ? path.join(cfg.work, 'video-clean.mp4') : null;
423
500
  if (clean) { graph += `[${last}]split[vmain][vclean0];\n`; last = 'vmain'; }
424
- for (const [i, c] of captions.entries()) {
501
+ for (const [i, c] of (cfg.captions ? captions : []).entries()) {
425
502
  const a = c.t[0] + 0.1, b = c.t[1] - 0.1;
426
503
  // each caption is an input only as long as it is on screen (decoding a looped PNG for the whole
427
504
  // video, once per caption, made long tutorials take minutes to compose)
@@ -432,7 +509,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
432
509
  }
433
510
  for (const [k, c] of cards.entries()) {
434
511
  inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, `card${k}`, '%04d.jpg'));
435
- graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${c.t0.toFixed(3)}/TB,fade=in:st=${c.t0.toFixed(3)}:d=0.25:alpha=1,fade=out:st=${Math.max(c.t0, c.t1 - 0.3).toFixed(3)}:d=0.3:alpha=1[sc${k}];\n` +
512
+ graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${c.t0.toFixed(3)}/TB,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` +
436
513
  `[${last}][sc${k}]overlay=0:0:eof_action=pass:enable='between(t,${c.t0.toFixed(3)},${c.t1.toFixed(3)})'[v${++idx}];\n`;
437
514
  last = `v${idx}`;
438
515
  }
@@ -445,7 +522,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
445
522
  }
446
523
  if (in0) {
447
524
  inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, 'intro', '%04d.jpg'));
448
- graph += `[${idx}:v]format=rgba,fade=out:st=${Math.max(0, INTRO - 0.35).toFixed(3)}:d=0.35:alpha=1[in];\n` +
525
+ graph += `[${idx}:v]format=rgba,fade=out:st=${Math.max(0, INTRO - 0.2).toFixed(3)}:d=0.2:alpha=1[in];\n` +
449
526
  `[${last}][in]overlay=0:0:eof_action=pass[v${++idx}];\n`;
450
527
  last = `v${idx}`;
451
528
  }
@@ -497,8 +574,8 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
497
574
  ...(tl.gotos ?? tl.navs.filter((n) => !tl.clicks.some((c) => n - c >= 0 && n - c < 2) && !tl.steps.some((s) => (s.waits ?? []).some(([a, b]) => n >= a - 0.5 && n <= b + 0.5))))
498
575
  .map(toOut).filter((t) => t != null && t > 0.2),
499
576
  ...segs.filter((g) => g.afterHidden).map((g) => g.o0),
500
- ...(in0 ? [INTRO - 0.3] : []),
501
- ...cards.flatMap((c) => [c.t0, c.t1 - 0.3]),
577
+ ...(in0 ? [INTRO - 0.2] : []),
578
+ ...cards.flatMap((c) => [c.t0, c.t1 - 0.15]),
502
579
  ...(ec ? [VIDEO - 0.25] : []),
503
580
  ]),
504
581
  risers: segs.filter((g) => g.step.sfx === 'riser').map((g) => [g.o0, g.o1]),
package/src/config.mjs CHANGED
@@ -4,6 +4,17 @@ import path from 'node:path';
4
4
  import YAML from 'yaml';
5
5
  import { VOICE_DEFAULTS } from './voice.mjs';
6
6
 
7
+ // Built-in labels drawn into the video, by `lang` (e.g. es, es-MX); other languages get English
8
+ export const LABELS = {
9
+ en: { think: 'Pause and think', intro: 'Introduction', downloaded: 'Downloaded' },
10
+ zh: { think: '先想一想', intro: '开场', downloaded: '已下载' },
11
+ es: { think: 'Piénsalo un momento', intro: 'Introducción', downloaded: 'Descargado' },
12
+ pt: { think: 'Pense um pouco', intro: 'Introdução', downloaded: 'Baixado' },
13
+ fr: { think: 'Réfléchissez', intro: 'Introduction', downloaded: 'Téléchargé' },
14
+ de: { think: 'Kurz nachdenken', intro: 'Einführung', downloaded: 'Heruntergeladen' },
15
+ ja: { think: '考えてみよう', intro: 'はじめに', downloaded: 'ダウンロード済み' },
16
+ };
17
+
7
18
  // Load API keys (ELEVENLABS_API_KEY, OPENAI_API_KEY) from .env next to the storyboard
8
19
  // and in the working directory; variables already set in the environment win.
9
20
  export function loadEnv(dir) {
@@ -99,7 +110,8 @@ export function loadConfig(file, { draft = false } = {}) {
99
110
  });
100
111
 
101
112
  let endCard = raw.end_card === false ? null : raw.end_card ?? null;
102
- if (endCard) endCard = { duration: endCard.say ? 'auto' : 1.0, ...endCard };
113
+ // auto: as long as its narration, bullets and call to action need, and never under 2.5s
114
+ if (endCard) endCard = { duration: 'auto', ...endCard };
103
115
  // An intro title card (promos; optional for tutorials and lessons). Same fields as the end card.
104
116
  let intro = raw.intro === false ? null : raw.intro ?? null;
105
117
  if (intro) intro = { duration: intro.say ? 'auto' : 2.2, ...intro };
@@ -112,11 +124,15 @@ export function loadConfig(file, { draft = false } = {}) {
112
124
  // follow: the camera zooms in on what each step touches and pans from step to step (tutorials)
113
125
  const follow0 = pick('follow');
114
126
  const follow = follow0 ? { scale: 1.8, ...(typeof follow0 === 'object' ? follow0 : {}) } : null;
115
- // frame: false | true | a style name | { style, background: <css> }. Styles: aurora (dark, grain),
116
- // studio (light, grain), dark, pastel, blur (the page itself, blurred, behind the window)
127
+ // frame: false | true | a style name | { style, background: <css>, window, title, icon }. Styles: aurora
128
+ // (dark, grain), studio (light, grain), dark, pastel, blur (the page itself, blurred, behind the window).
129
+ // window: browser (an address bar) or app (an installed web app / desktop app: its icon and name in the
130
+ // title bar, no address bar; both are read from the page's web app manifest unless given)
117
131
  const styleOf = (f) => (typeof f === 'string' ? f : f?.style) ?? (typeof P.frame === 'string' ? P.frame : 'aurora');
118
- const frame = frame0 ? { background: null, ...(typeof frame0 === 'object' ? frame0 : {}), style: styleOf(frame0) } : null;
132
+ const frame = frame0 ? { background: null, window: 'browser', ...(typeof frame0 === 'object' ? frame0 : {}), style: styleOf(frame0) } : null;
119
133
  if (frame && !FRAME_STYLES.includes(frame.style)) throw new Error(`frame style must be one of ${FRAME_STYLES.join(', ')}`);
134
+ if (frame && !['browser', 'app'].includes(frame.window)) throw new Error('frame.window must be browser or app');
135
+ if (frame?.icon) frame.iconFile = path.resolve(dir, frame.icon);
120
136
 
121
137
  let voice = null;
122
138
  if (raw.voice) {
@@ -162,9 +178,13 @@ export function loadConfig(file, { draft = false } = {}) {
162
178
  endCard,
163
179
  intro,
164
180
  article: raw.article ?? null,
181
+ // captions: false keeps the caption pills off the video (chapters and SRT still use them),
182
+ // e.g. when burned-in subtitles already carry the words
183
+ captions: raw.captions !== false,
165
184
  cards: raw.cards ?? 'auto',
166
185
  // language of the built-in labels ("Pause and think", "Introduction"): `lang`, else guessed from the captions
167
186
  lang: raw.lang ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? 'zh' : 'en'),
187
+ labels: LABELS[String(raw.lang ?? '').toLowerCase().split(/[-_]/)[0]] ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? LABELS.zh : LABELS.en),
168
188
  frame,
169
189
  follow,
170
190
  music,
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/inspect.mjs CHANGED
@@ -67,10 +67,12 @@ function collect() {
67
67
  clean(el.closest('label')?.innerText) || '';
68
68
  // a label's own text, without the links/buttons inside it (e.g. a "Clear" link)
69
69
  const labelText = (lab) => { if (!lab) return null; const c = lab.cloneNode(true); c.querySelectorAll('a,button,select,input,textarea').forEach((x) => x.remove()); return clean(c.innerText) || null; };
70
+ // styled checkboxes and radios hide the real input and draw their label instead: list them where the label is
71
+ const shownBy = (el) => (el.tagName === 'INPUT' && ['checkbox', 'radio'].includes(el.type) && !vis(el) ? [...(el.labels ?? [])].find(vis) : null);
70
72
  const interactive = [...document.querySelectorAll(NATIVE)]
71
- .filter((el) => vis(el) && el.tagName !== 'LABEL' && !isAd(el) && !(el.isContentEditable && el.parentElement?.isContentEditable))
73
+ .filter((el) => (vis(el) || shownBy(el)) && el.tagName !== 'LABEL' && !isAd(el) && !(el.isContentEditable && el.parentElement?.isContentEditable))
72
74
  .map((el) => ({ kind: 'role', role: roleOf(el), name: (el.isContentEditable || el.tagName === 'TEXTAREA' ? editorName(el) : nameOf(el)).slice(0, 80), shows: clean(el.innerText).slice(0, 60), alt: clean(el.querySelector('img[alt]')?.alt) || null, href: el.getAttribute('href'), label: labelText(el.labels?.[0]),
73
- options: el.tagName === 'SELECT' ? [...el.options].map((o) => ({ text: clean(o.text), on: o.selected })).filter((o) => o.text).slice(0, 12) : null, ...pos(el) }));
75
+ options: el.tagName === 'SELECT' ? [...el.options].map((o) => ({ text: clean(o.text), on: o.selected })).filter((o) => o.text).slice(0, 12) : null, ...pos(shownBy(el) ?? el) }));
74
76
  // custom clickables: outermost cursor:pointer elements that aren't native controls
75
77
  const pointer = [];
76
78
  for (const el of document.querySelectorAll('body *')) {
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) => {
@@ -27,7 +29,7 @@ export async function writeOutputs(cfg, { captions, cards = [], segs, clean, nar
27
29
  if (cfg.chapters && (captions.length || cards.length)) {
28
30
  // section cards make the chapters when there are any; otherwise one chapter per caption
29
31
  const lines = cards.length
30
- ? [...(cards[0].t > 1 ? [`0:00 ${cfg.lang === 'zh' ? '开场' : 'Introduction'}`] : []), ...cards.map((c, k) => `${clock(k === 0 && cards[0].t <= 1 ? 0 : c.t)} ${plain(c.title)}`)]
32
+ ? [...(cards[0].t > 1 ? [`0:00 ${cfg.labels.intro}`] : []), ...cards.map((c, k) => `${clock(k === 0 && cards[0].t <= 1 ? 0 : c.t)} ${plain(c.title)}`)]
31
33
  : captions.map((c, k) => `${clock(k === 0 ? 0 : c.t[0])} ${cfg.stepNumbers ? `${c.n}. ` : ''}${plain(c.text)}`);
32
34
  fs.writeFileSync(`${base}.chapters.txt`, `${lines.join('\n')}\n`);
33
35
  files.push(`${base}.chapters.txt`);
@@ -80,15 +82,22 @@ 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(); };
87
89
  const t = (s) => { const cs = Math.round(s * 100); return `${Math.floor(cs / 360000)}:${String(Math.floor(cs / 6000) % 60).padStart(2, '0')}:${String(Math.floor(cs / 100) % 60).padStart(2, '0')}.${String(cs % 100).padStart(2, '0')}`; };
88
- const size = Math.round(H * 0.048), edge = Math.max(2, Math.round(size * 0.09));
89
- const events = [];
90
+ // Chinese, Japanese, Korean: no spaces between words, so lines are cut by characters (one line of
91
+ // at most ~18), in a lighter weight and a font that has the glyphs
92
+ const cjk = cfg.lang === 'zh' || narration.some((l) => /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(l.text));
93
+ const size = Math.round(H * (cjk ? 0.042 : 0.048)), edge = Math.max(2, Math.round(size * (cjk ? 0.07 : 0.09)));
94
+ const fontName = cjk && cfg.theme.font === 'Inter' ? (process.platform === 'darwin' ? 'PingFang SC' : 'Noto Sans CJK SC') : cfg.theme.font;
95
+ const cues = []; // { words: [{ w, start, d }] }: one on-screen line each
90
96
  for (const line of narration) {
91
- const words = plain(line.text).split(/\s+/).filter(Boolean);
97
+ // CJK: one token per character (Latin words inside stay whole), punctuation stays with the one before
98
+ const words = cjk
99
+ ? (plain(line.text).match(/[A-Za-z0-9.%+-]+[,。!?;:、,.!?;:))」』]*|\S[,。!?;:、,.!?;:))」』]*/g) ?? [])
100
+ : plain(line.text).split(/\s+/).filter(Boolean);
92
101
  const weight = (w) => w.replace(/[^\p{L}\p{N}]/gu, '').length + 2;
93
102
  const total = words.reduce((a, w) => a + weight(w), 0), span = line.end - line.start;
94
103
  let at = line.start;
@@ -97,13 +106,76 @@ export function burnSubtitles(cfg, narration, { log = console.log } = {}) {
97
106
  let chunk = [];
98
107
  const flush = () => {
99
108
  if (!chunk.length) return;
100
- const s0 = chunk[0].start, e0 = chunk.at(-1).start + chunk.at(-1).d;
101
- 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(' ')}`);
109
+ cues.push({ words: chunk });
102
110
  chunk = [];
103
111
  };
104
- for (const x of timed) { chunk.push(x); if (chunk.length >= 7 || /[.!?;:]$/.test(x.w)) flush(); }
112
+ const len = () => chunk.reduce((a, x) => a + x.w.length, 0);
113
+ for (const x of timed) {
114
+ // a CJK line ends at a sentence break, or at a comma once it is half full, or at 18 characters
115
+ if (cjk && chunk.length && len() + x.w.length > 18) flush();
116
+ chunk.push(x);
117
+ if (cjk ? /[。!?;!?;]$/.test(x.w) || (/[,、:,:]$/.test(x.w) && len() >= 9) : chunk.length >= 7 || /[.!?;:]$/.test(x.w)) flush();
118
+ }
105
119
  flush();
106
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
+ });
107
179
  const ass = `[Script Info]
108
180
  ScriptType: v4.00+
109
181
  PlayResX: ${W}
@@ -112,17 +184,17 @@ WrapStyle: 0
112
184
 
113
185
  [V4+ Styles]
114
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
115
- Style: Sub,${cfg.theme.font},${size},${bgr(cfg.theme.accent)},&H00FFFFFF,${bgr(cfg.theme.ink)},&H80000000,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
116
188
 
117
189
  [Events]
118
190
  Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
119
191
  ${events.join('\n')}
120
192
  `;
121
- const file = path.join(cfg.work, 'subtitles.ass'), tmp = cfg.out.replace(/(\.[^.]+)$/, '.burn$1');
193
+ const file = path.join(cfg.work, 'subtitles.ass');
122
194
  fs.writeFileSync(file, ass);
123
195
  // the filter argument is parsed by ffmpeg: escape the characters it treats specially in paths
124
196
  const arg = file.replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'");
125
- 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]);
126
198
  fs.renameSync(tmp, cfg.out);
127
199
  log(`subtitles: burned in ${events.length} lines with word-by-word highlight`);
128
200
  }
package/src/record.mjs CHANGED
@@ -260,13 +260,13 @@ function pageScript({ scrollOffset }) {
260
260
  window.__demoMenuHover = (i) => { const m = document.getElementById('__demo_menu'); if (m) [...m.children].forEach((o, k) => { o.style.background = k === i ? '#e0ecff' : ''; }); };
261
261
  window.__demoMenuClose = () => document.getElementById('__demo_menu')?.remove();
262
262
  // downloads leave no trace in a recording: show a browser-style "downloaded" bubble instead
263
- window.__demoDownload = (name) => {
263
+ window.__demoDownload = (name, label = 'Downloaded') => {
264
264
  const el = document.createElement('div');
265
- el.innerHTML = `<span style="display:inline-grid;place-items:center;width:30px;height:30px;border-radius:50%;background:#22c55e;color:#fff;font-weight:700">✓</span><span><b style="display:block;font-weight:600">${name.replace(/[<&]/g, '')}</b><span style="color:#64748b;font-size:14px">Downloaded</span></span>`;
265
+ el.innerHTML = `<span style="display:inline-grid;place-items:center;width:30px;height:30px;border-radius:50%;background:#22c55e;color:#fff;font-weight:700">✓</span><span><b style="display:block;font-weight:600">${name.replace(/[<&]/g, '')}</b><span style="color:#64748b;font-size:14px">${label}</span></span>`;
266
266
  Object.assign(el.style, { position: 'fixed', right: '24px', top: '20px', zIndex: '2147483646', pointerEvents: 'none', display: 'flex', alignItems: 'center', gap: '12px',
267
267
  background: '#fff', color: '#0f172a', font: '16px -apple-system, "Segoe UI", sans-serif', padding: '12px 18px 12px 14px', borderRadius: '12px', boxShadow: '0 12px 32px rgba(15,23,42,.25)', border: '1px solid #e2e8f0' });
268
268
  document.documentElement.appendChild(el);
269
- el.animate([{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'none', offset: 0.1 }, { opacity: 1, offset: 0.85 }, { opacity: 0 }], { duration: 2800, easing: 'ease-out' }).onfinish = () => el.remove();
269
+ el.animate([{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'none', offset: 0.1 }, { opacity: 1, offset: 0.9 }, { opacity: 0 }], { duration: 4500, easing: 'ease-out' }).onfinish = () => el.remove();
270
270
  };
271
271
  window.__demoUnnote = (instant) => {
272
272
  // detach it first, so a note or mark added during the fade gets a fresh layer instead of vanishing with this one
@@ -343,7 +343,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
343
343
  await context.addInitScript(pageScript, { scrollOffset: cfg.scrollOffset });
344
344
  const page = await context.newPage();
345
345
  page.on('download', (d) => {
346
- page.evaluate((n) => window.__demoDownload?.(n), d.suggestedFilename()).catch(() => {});
346
+ page.evaluate(([n, l]) => window.__demoDownload?.(n, l), [d.suggestedFilename(), cfg.labels.downloaded]).catch(() => {});
347
347
  marks.push({ t: Date.now() / 1000, box: { x: cfg.viewport.width - 360, y: 10, width: 340, height: 80 } }); // so docs crops include the bubble
348
348
  });
349
349
  const sleep = (ms) => page.waitForTimeout(ms);
@@ -551,13 +551,18 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
551
551
  await press();
552
552
  }
553
553
  const text = String(v.text);
554
- const before = await page.evaluate(() => (document.activeElement?.value ?? document.activeElement?.textContent ?? '').length).catch(() => 0);
555
- const copied = await page.evaluate((t) => navigator.clipboard.writeText(t).then(() => true, () => false), text).catch(() => false);
554
+ // the paste worked if the page received a paste event carrying the text (code editors keep their
555
+ // text outside the focused element, so comparing its value would miss it and paste twice)
556
+ const copied = await page.evaluate((t) => navigator.clipboard.writeText(t).then(() => {
557
+ window.__demoPasted = false;
558
+ document.addEventListener('paste', (e) => { window.__demoPasted = !!e.clipboardData?.getData('text'); }, { capture: true, once: true });
559
+ return true;
560
+ }, () => false), text).catch(() => false);
556
561
  if (!fast) await page.evaluate((l) => window.__demoKeys(l), process.platform === 'darwin' ? '⌘ V' : 'Ctrl V').catch(() => {});
557
562
  if (copied) await page.keyboard.press('ControlOrMeta+V');
558
563
  await sleep(200);
559
- const after = await page.evaluate(() => (document.activeElement?.value ?? document.activeElement?.textContent ?? '').length).catch(() => 0);
560
- if (!copied || after === before) await page.keyboard.insertText(text); // no clipboard access: insert it in one go
564
+ const pasted = copied && await page.evaluate(() => window.__demoPasted === true).catch(() => false);
565
+ if (!pasted) await page.keyboard.insertText(text); // no clipboard access: insert it in one go
561
566
  if (lit) await spotOff();
562
567
  await settle();
563
568
  await beat();
@@ -572,8 +577,16 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
572
577
  const from = await locate(page, value.target).evaluate((el) => {
573
578
  const r = el.type === 'range' ? el : el.querySelector?.('input[type=range]');
574
579
  if (r) return (Number(r.value) - Number(r.min || 0)) / ((Number(r.max || 100) - Number(r.min || 0)) || 1);
575
- const now = Number(el.getAttribute('aria-valuenow')), lo = Number(el.getAttribute('aria-valuemin') ?? 0), hi = Number(el.getAttribute('aria-valuemax') ?? 100);
576
- return Number.isFinite(now) ? (now - lo) / ((hi - lo) || 1) : 0.5;
580
+ const aria = el.hasAttribute('aria-valuenow') ? el : el.querySelector?.('[aria-valuenow]');
581
+ if (aria) {
582
+ const now = Number(aria.getAttribute('aria-valuenow')), lo = Number(aria.getAttribute('aria-valuemin') ?? 0), hi = Number(aria.getAttribute('aria-valuemax') ?? 100);
583
+ if (Number.isFinite(now)) return (now - lo) / ((hi - lo) || 1);
584
+ }
585
+ // custom sliders (e.g. Desmos): start where the knob is drawn, so the value doesn't jump first
586
+ const t = el.getBoundingClientRect();
587
+ const knob = [...el.querySelectorAll('[role=slider], [class*=thumb], [class*=handle], [class*=knob]')]
588
+ .map((k) => k.getBoundingClientRect()).find((k) => k.width > 0 && k.width < t.width * 0.5);
589
+ return knob ? (knob.x + knob.width / 2 - t.x) / (t.width || 1) : 0.5;
577
590
  }).catch(() => 0.5);
578
591
  const x0 = b.x + b.width * from, x1 = b.x + b.width * Math.max(0, Math.min(1, value.to)), y = b.y + b.height / 2;
579
592
  await moveTo(x0, y, value.ms ?? cfg.pace.cursor);
@@ -706,6 +719,36 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
706
719
  await locate(page, card.logo).screenshot({ path: path.join(cfg.work, file), omitBackground: true, timeout: 5000 })
707
720
  .catch((e) => log(`warning: could not capture logo (${e.message.split('\n')[0]})`));
708
721
  }
722
+ // an app window shows the app's icon and name: from the web app manifest, else the page's own
723
+ let app = null;
724
+ if (cfg.frame?.window === 'app' && !fast) {
725
+ const found = await page.evaluate(async () => {
726
+ const abs = (u, base) => { try { return new URL(u, base).href; } catch { return null; } };
727
+ const size = (s) => Math.max(0, ...String(s ?? '').split(/\s+/).map((x) => (x === 'any' ? 1e4 : parseInt(x, 10) || 0)));
728
+ let name = null, icon = null;
729
+ const link = document.querySelector('link[rel="manifest"]');
730
+ if (link) {
731
+ try {
732
+ const m = await (await fetch(link.href)).json();
733
+ name = m.short_name ?? m.name ?? null;
734
+ const best = (m.icons ?? []).filter((i) => !/monochrome/.test(i.purpose ?? '')).sort((a, b) => size(b.sizes) - size(a.sizes))[0];
735
+ if (best) icon = abs(best.src, link.href);
736
+ } catch {}
737
+ }
738
+ const links = [...document.querySelectorAll('link[rel~="icon"], link[rel="apple-touch-icon"]')].sort((a, b) => size(b.sizes) - size(a.sizes));
739
+ icon ??= links[0]?.href ?? abs('/favicon.ico', location.href);
740
+ return { name: name ?? (document.querySelector('meta[name="application-name"]')?.content || document.title || null), icon };
741
+ }).catch(() => ({}));
742
+ app = { title: found.name ?? null, icon: null };
743
+ if (!cfg.frame.iconFile && found.icon) {
744
+ const r = await page.request.get(found.icon).catch(() => null);
745
+ if (r?.ok()) {
746
+ const type = (r.headers()['content-type'] ?? 'image/png').split(';')[0];
747
+ fs.writeFileSync(path.join(cfg.work, 'app-icon'), await r.body());
748
+ app.icon = { file: 'app-icon', type };
749
+ } else log(`warning: could not fetch the app icon (${found.icon})`);
750
+ }
751
+ }
709
752
  await nudge();
710
753
  await sleep(600);
711
754
 
@@ -734,8 +777,12 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
734
777
  frames.push({ file, t: metadata.timestamp });
735
778
  cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
736
779
  });
737
- const navs = [];
738
- page.on('framenavigated', (f) => { if (f === page.mainFrame()) navs.push(now()); });
780
+ const navs = [], urls = [{ t: now(), url: page.url() }]; // urls: what the frame's address bar shows
781
+ page.on('framenavigated', (f) => {
782
+ if (f !== page.mainFrame()) return;
783
+ navs.push(now());
784
+ if (f.url() !== urls.at(-1).url) urls.push({ t: now(), url: f.url() });
785
+ });
739
786
  await cdp.send('Page.startScreencast', {
740
787
  format: 'jpeg', quality: 92, maxWidth: cfg.output.width, maxHeight: cfg.output.height, everyNthFrame: 1,
741
788
  });
@@ -777,7 +824,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
777
824
  await cdp.send('Page.stopScreencast');
778
825
  await browser.close();
779
826
 
780
- const timeline = { frames, steps, clicks, navs, gotos, scale: cfg.scale };
827
+ const timeline = { frames, steps, clicks, navs, gotos, urls, app, scale: cfg.scale };
781
828
  fs.writeFileSync(path.join(cfg.work, 'timeline.json'), JSON.stringify(timeline, null, 1));
782
829
  log(`recorded ${frames.length} frames`);
783
830
  return timeline;
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);