storyshot 0.1.3 → 0.4.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/compose.mjs CHANGED
@@ -18,14 +18,21 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
18
18
  const { width: W, height: H, fps: FPS } = cfg.output;
19
19
  const K = tl.scale; // css px -> output px
20
20
  const ec0 = cfg.endCard;
21
- const END = !ec0 ? 0 : ec0.duration === 'auto' ? Math.max(1.0, (voiceLens.endCard ?? 0) + 0.6) : ec0.duration;
21
+ // a card with bullets needs time to read them: they appear one by one, then hold
22
+ const readTime = (c) => (c?.bullets?.length ? 1.6 + 0.4 * c.bullets.length + 0.6 * c.bullets.length : 0);
23
+ // `think: N` (question cards): after the question is read out, N seconds with a countdown bar
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;
26
+ const in0 = cfg.intro;
27
+ const INTRO = !in0 ? 0 : in0.duration === 'auto' ? Math.max(1.8, (voiceLens.intro ?? 0) + 0.8, readTime(in0)) : in0.duration;
28
+ const cards = []; // section cards: { i, t0, t1, card }
22
29
 
23
30
  // --- 1. segments (one per visible step), split into motion / waiting pieces ------------
24
31
  // Waiting (page loads, processing) is squeezed hard; cursor motion keeps a natural pace.
25
32
  // `duration: auto` plays motion in real time and makes the step as long as its narration
26
33
  // needs: extra time first shows more of the waiting, then holds the last frame.
27
34
  const segs = [], pieces = [];
28
- let acc = 0, afterHidden = false;
35
+ let acc = INTRO, afterHidden = false; // the intro card plays over the first frame
29
36
  for (const [i, s] of cfg.steps.entries()) {
30
37
  const r = tl.steps[i];
31
38
  if (s.hidden) { afterHidden = segs.length > 0; continue; }
@@ -45,10 +52,12 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
45
52
  const Wt = parts.filter((p) => p[2]).reduce((x, p) => x + p[1] - p[0], 0);
46
53
  let D, waitBudget, motionBudget, hold = 0;
47
54
  if (s.duration === 'auto') {
48
- const voiceNeed = voiceLens.steps[i] != null ? voiceLens.steps[i] + (s.sayAt ?? 0.15) + 0.4 : 0;
55
+ const voiceNeed = voiceLens.steps[i] != null ? voiceLens.steps[i] + (s.sayAt ?? 0.15) + cfg.pace.tail : 0;
49
56
  waitBudget = Math.min(Wt, cfg.maxWait);
50
57
  motionBudget = M;
51
- D = Math.max(M + waitBudget, voiceNeed, 0.8);
58
+ // a silent step holds its result too (docs GIFs: long enough to see it before the loop restarts)
59
+ const quiet = s.say ? 0 : cfg.clips.length ? 1.5 : cfg.pace.tail;
60
+ D = Math.max(M + waitBudget + quiet, voiceNeed, 0.8);
52
61
  let extra = D - M - waitBudget;
53
62
  const stretch = Math.min(extra, Wt - waitBudget);
54
63
  waitBudget += stretch; extra -= stretch;
@@ -58,6 +67,13 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
58
67
  waitBudget = M + Wt <= D ? Wt * D / (M + Wt) : M === 0 ? D : Math.min(Wt, Math.max(D - M, 0.15 * D));
59
68
  motionBudget = D - waitBudget;
60
69
  }
70
+ if (s.card) {
71
+ // a section card plays over the step's first frame, before the step
72
+ const C = cardLen(s.card, voiceLens.cards?.[i], 2.4);
73
+ pieces.push({ raw: [r.start, r.start], o0: acc, o1: acc + C });
74
+ cards.push({ i, t0: acc, t1: acc + C, card: s.card, thinkAt: C - (s.card.think ?? 0) - 0.3 });
75
+ acc += C;
76
+ }
61
77
  const g = { i, step: s, o0: acc, o1: acc + D, zoomBox: r.zoomBox, afterHidden, hold,
62
78
  motionSpeed: M && motionBudget ? M / motionBudget : 1, waitSpeed: Wt && waitBudget ? Wt / waitBudget : 1 };
63
79
  for (const [a, b, w] of parts) {
@@ -89,7 +105,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
89
105
  const t = n / FPS;
90
106
  while (pi + 1 < pieces.length && t >= pieces[pi].o1) pi++;
91
107
  const p = pieces[pi];
92
- const r = p.raw[0] + Math.min(1, (t - p.o0) / Math.max(1e-6, p.o1 - p.o0)) * (p.raw[1] - p.raw[0]);
108
+ const r = p.raw[0] + Math.min(1, Math.max(0, (t - p.o0) / Math.max(1e-6, p.o1 - p.o0))) * (p.raw[1] - p.raw[0]);
93
109
  while (fi + 1 < tl.frames.length && tl.frames[fi + 1].t <= r) fi++;
94
110
  fs.linkSync(path.join(cfg.work, 'raw', tl.frames[fi].file), path.join(SEQ, `${String(n).padStart(5, '0')}.jpg`));
95
111
  }
@@ -100,17 +116,30 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
100
116
  const tw = 160, th = 90, rate = 10;
101
117
  const buf = execFileSync('ffmpeg', ['-loglevel', 'error', '-framerate', `${FPS}`, '-i', path.join(SEQ, '%05d.jpg'),
102
118
  '-vf', `fps=${rate},scale=${tw}:${th},format=gray`, '-f', 'rawvideo', 'pipe:1'], { maxBuffer: 1 << 28 });
103
- const n = Math.floor(buf.length / (tw * th)), score = [];
119
+ // plus the share of 8x8 blocks with no detail at all: an empty canvas or image area that is still
120
+ // loading (dark or light) next to toolbars that are already there
121
+ const n = Math.floor(buf.length / (tw * th)), score = [], flat = [];
104
122
  for (let f = 0; f < n; f++) {
105
123
  let e = 0;
124
+ const blocks = new Float64Array(Math.ceil(tw / 8) * Math.ceil(th / 8));
106
125
  for (let y = 0, o = f * tw * th; y < th - 1; y++) for (let x = 0; x < tw - 1; x++) {
107
- const k = o + y * tw + x;
108
- e += Math.abs(buf[k] - buf[k + 1]) + Math.abs(buf[k] - buf[k + tw]);
126
+ const k = o + y * tw + x, d = Math.abs(buf[k] - buf[k + 1]) + Math.abs(buf[k] - buf[k + tw]);
127
+ e += d; blocks[Math.floor(y / 8) * Math.ceil(tw / 8) + Math.floor(x / 8)] += d;
109
128
  }
110
129
  score.push(e / (tw * th));
130
+ flat.push(blocks.filter((b) => b / 64 < 0.6).length / blocks.length);
111
131
  }
112
132
  const median = [...score].sort((a, b) => a - b)[Math.floor(n / 2)] ?? 0;
113
133
  const blank = score.map((e) => e < 3 && e < median * 0.4);
134
+ for (const g of segs) {
135
+ // compared with how the step ends (its content loaded): much emptier = still loading. Only right
136
+ // after the step waited for something (a load), so drawing on an empty canvas isn't flagged.
137
+ const last = Math.min(n - 1, Math.max(0, Math.floor(g.o1 * rate) - 1));
138
+ const loading = (tl.steps[g.i].waits ?? []).map(([a, b]) => [toOut(a), toOut(b)]).filter(([a]) => a != null).map(([a, b]) => [a, (b ?? a) + 1.5]);
139
+ for (let f = Math.ceil(g.o0 * rate); f <= last; f++) {
140
+ if (flat[f] > 0.55 && flat[f] - flat[last] > 0.25 && loading.some(([a, b]) => f / rate >= a - 0.1 && f / rate <= b)) blank[f] = true;
141
+ }
142
+ }
114
143
  for (const g of segs) {
115
144
  let run = 0, from = 0;
116
145
  const report = (to) => { if (run / rate >= 0.2) warn(`step ${g.i + 1}: ${(run / rate).toFixed(1)}s of near-blank frames at ${from.toFixed(1)}–${to.toFixed(1)}s (the page is still loading) — move that load into a hidden step, or wait for the loaded content before the step starts`); };
@@ -124,13 +153,41 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
124
153
  // --- 3. zoom keyframes: [time, zoom, [cx, cy]] in output px -------------------------------
125
154
  const C0 = [W / 2, H / 2];
126
155
  const kf = [[0, 1, C0]];
156
+ // follow camera: steps without a zoom of their own zoom in on the boxes their actions touched
157
+ // (clicks, typing, marks), and consecutive followed steps pan from one subject to the next
158
+ const { width: VW0, height: VH0 } = cfg.viewport;
159
+ const followOf = (g) => {
160
+ if (!cfg.follow || g.step.zoom || g.step.follow === false) return null;
161
+ const bs = (tl.steps[g.i].marks ?? []).map((m) => m.box).filter((b) => b && b.width < VW0 * 0.9);
162
+ if (!bs.length) return null;
163
+ const u = bs.reduce((a, b) => ({ x0: Math.min(a.x0, b.x), y0: Math.min(a.y0, b.y), x1: Math.max(a.x1, b.x + b.width), y1: Math.max(a.y1, b.y + b.height) }), { x0: 1e9, y0: 1e9, x1: -1e9, y1: -1e9 });
164
+ const w = u.x1 - u.x0 + 160, h = u.y1 - u.y0 + 140; // some context around the subject
165
+ const scale = Math.max(1, Math.min(cfg.follow.scale, VW0 / w, VH0 / h));
166
+ if (scale < 1.15) return null;
167
+ return { box: { x: u.x0, y: u.y0, width: u.x1 - u.x0, height: u.y1 - u.y0 }, scale };
168
+ };
169
+ let followEnd = -1;
127
170
  for (const g of segs) {
171
+ const f = followOf(g);
172
+ if (f) {
173
+ g.zoomBox = f.box;
174
+ const c = [(f.box.x + f.box.width / 2) * K, (f.box.y + f.box.height / 2) * K];
175
+ const move = Math.min(0.7, (g.o1 - g.o0) / 3);
176
+ g.zoomPeak = g.o0 + move + 0.1;
177
+ if (Math.abs(followEnd - g.o0) < 1e-3 && kf.at(-1)[1] === 1) kf.pop(); // pan straight from the previous subject
178
+ else kf.push([g.o0, 1, C0]);
179
+ kf.push([g.o0 + move, f.scale, c], [g.o1 - 0.6, f.scale, c], [g.o1, 1, C0]);
180
+ followEnd = g.o1;
181
+ continue;
182
+ }
128
183
  const z = g.step.zoom;
129
184
  const t = z?.target;
130
185
  if (t && typeof t === 'object' && typeof t.x === 'number' && typeof t.y === 'number' && !t.role && !t.css && !t.text) {
131
186
  // a point needs no measuring, so it works even if the zoom was added after recording
132
187
  const { width: VW, height: VH } = cfg.viewport;
133
- g.zoomBox = { x: t.x <= 1 ? t.x * VW : t.x, y: t.y <= 1 ? t.y * VH : t.y, width: 0, height: 0 };
188
+ // a point names the middle of the subject: give it some area, so captions keep off it too
189
+ const px = t.x <= 1 ? t.x * VW : t.x, py = t.y <= 1 ? t.y * VH : t.y, bw = VW * 0.12, bh = VH * 0.1;
190
+ g.zoomBox = { x: px - bw / 2, y: py - bh / 2, width: bw, height: bh };
134
191
  } else if (t && 'zoomTarget' in tl.steps[g.i] && JSON.stringify(tl.steps[g.i].zoomTarget) !== JSON.stringify(t)) {
135
192
  warn(`step ${g.i + 1}: zoom target was added or changed after recording, so its position is unknown — re-render with mode "render" (or use a point target)`);
136
193
  g.zoomBox = null;
@@ -169,9 +226,13 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
169
226
  const z = z0 + (z1 - z0) * e, cx = c0[0] + (c1[0] - c0[0]) * e, cy = c0[1] + (c1[1] - c0[1]) * e;
170
227
  return { z, x0: Math.min(Math.max(cx - W / z / 2, 0), W - W / z), y0: Math.min(Math.max(cy - H / z / 2, 0), H - H / z) };
171
228
  };
229
+ // browser-window frame: the page is drawn at (FX, FY), FW x FH, inside a window on a backdrop
230
+ const even = (x) => 2 * Math.round(x / 2);
231
+ const FW = cfg.frame ? even(W * 0.82) : W, FH = cfg.frame ? even(H * 0.82) : H, BAR = cfg.frame ? Math.round(H * 0.034) : 0;
232
+ const FX = even((W - FW) / 2), FY = cfg.frame ? even((H - FH - BAR) / 2 + BAR) : 0, F = FW / W;
172
233
  const onScreen = (b, t) => {
173
234
  const v = view(t);
174
- return { x: (b.x * K - v.x0) * v.z, y: (b.y * K - v.y0) * v.z, w: b.width * K * v.z, h: b.height * K * v.z };
235
+ 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 };
175
236
  };
176
237
 
177
238
  // --- 4. captions + end card rendered as PNGs ----------------------------------------------
@@ -227,28 +288,112 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
227
288
  const other = want === 'top' ? 'bottom' : 'top';
228
289
  let pos = want;
229
290
  const hit = hits(want);
230
- if (hit && !hits(other)) {
291
+ if (hit && cfg.subtitles === 'burn' && other === 'bottom') {
292
+ warn(`step ${g.i + 1}: the caption covers ${hit.what}, and the bottom is kept for the burned-in subtitles — shorten the caption, or change the zoom`);
293
+ } else if (hit && !hits(other)) {
231
294
  pos = other;
232
295
  warn(`step ${g.i + 1}: the ${want} caption would cover ${hit.what}, so it was moved to the ${other}` + (explicit ? ` (despite captionPosition: ${explicit})` : '') + ` — set captionPosition: ${other} to make this explicit`);
233
296
  } else if (hit) warn(`step ${g.i + 1}: the caption covers ${hit.what} at both top and bottom — shorten the caption, or change the zoom`);
234
297
  if (prev && n === prev.n && prev.pos === pos) prev.t[1] = g.o1;
235
- else captions.push({ text: g.step.caption, t: [g.o0, g.o1], pos, n });
298
+ else captions.push({ text: g.step.caption, t: [g.o0, g.o1], pos, n, h });
236
299
  }
237
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);
238
- const ec = cfg.endCard;
239
- if (ec) {
240
- const logoPath = ec.logoFile ?? path.join(cfg.work, 'logo.png');
301
+ // Intro and end cards are short animations (logo pops, title rises, the highlight sweeps, a command
302
+ // CTA types itself out), rendered frame by frame by seeking the page's CSS animations.
303
+ const u = H / 1080;
304
+ // cards follow the frame: dark backdrops (aurora, dark, blur) get dark cards, so a dark app doesn't
305
+ // cut to a white card; `cards: light | dark` overrides
306
+ const darkCards = cfg.cards === 'dark' || (cfg.cards !== 'light' && ['aurora', 'dark', 'blur'].includes(cfg.frame?.style));
307
+ const CK = darkCards
308
+ ? { 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',
309
+ pillBg: '#1a2238', pillFg: '#e2e8f0', pillLine: '1px solid #33405f', ctaBg: accent, ctaFg: ink, shadow: 'rgba(0,0,0,.45)' }
310
+ : { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#ffffff 0%,#eef2f7 60%,#e2e8f0 100%)`, title: ink, sub: '#334155', muted: '#64748b', item: '#1e293b', track: '#e2e8f0',
311
+ pillBg: ink, pillFg: '#e2e8f0', pillLine: 'none', ctaBg: ink, ctaFg: '#fff', shadow: 'rgba(15,23,42,.25)' };
312
+ const card = (c, logoPath, { thinkAt = 0 } = {}) => {
313
+ const image = c.imageFile && fs.existsSync(c.imageFile)
314
+ ? `<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
+ 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>
317
+ <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
+ <style>@keyframes drain{from{transform:scaleX(1)}to{transform:scaleX(0)}}</style>` : '';
241
319
  const logo = fs.existsSync(logoPath)
242
- ? `<img src="data:image/${path.extname(logoPath).slice(1).replace('jpg', 'jpeg').replace('svg', 'svg+xml')};base64,${fs.readFileSync(logoPath).toString('base64')}" style="height:${Math.round(H * 0.09)}px">` : '';
243
- const u = H / 1080;
244
- await shot(`<body style="margin:0;width:${W}px;height:${H}px;${fontCss}
245
- background:radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#ffffff 0%,#eef2f7 60%,#e2e8f0 100%);
320
+ ? `<img class="a pop" src="data:image/${path.extname(logoPath).slice(1).replace('jpg', 'jpeg').replace('svg', 'svg+xml')};base64,${fs.readFileSync(logoPath).toString('base64')}" style="height:${Math.round(H * 0.09)}px;${darkCards ? `background:#fff;padding:${Math.round(10 * u)}px ${Math.round(16 * u)}px;border-radius:${Math.round(14 * u)}px;box-shadow:0 ${8 * u}px ${24 * u}px rgba(0,0,0,.35)` : ''}">` : ''; // logos captured from a light page often carry its white: give them a plate on dark cards
321
+ const cmd = c.cta && /^\$?\s*(npx|npm|pnpm|yarn|bun|pip|pipx|uv|brew|curl|git|docker|cargo|go)\b/.test(c.cta);
322
+ const ctaText = String(c.cta ?? '').replace(/^\$\s*/, '');
323
+ return `<body style="margin:0;width:${W}px;height:${H}px;${fontCss}
324
+ background:${CK.bg};
246
325
  display:flex;flex-direction:column;align-items:center;justify-content:center;gap:${28 * u}px;text-align:center">
326
+ <style>
327
+ .a{animation-fill-mode:both;animation-timing-function:cubic-bezier(.2,.8,.2,1)}
328
+ .pop{animation-name:pop;animation-duration:.55s;animation-delay:.05s}
329
+ .rise{animation-name:rise;animation-duration:.6s}
330
+ ${darkCards
331
+ ? `.hl{color:${accent};background:linear-gradient(${accent},${accent}) no-repeat 0 100%/0% ${Math.max(2, Math.round(3 * u))}px;padding-bottom:${Math.round(4 * u)}px;animation:sweep .6s cubic-bezier(.4,0,.2,1) .75s both}`
332
+ : `.hl{background:linear-gradient(transparent 60%,${accent} 60%) no-repeat 0 0/0% 100%;animation:sweep .6s cubic-bezier(.4,0,.2,1) .75s both}`}
333
+ .type{display:inline-block;overflow:hidden;white-space:nowrap;vertical-align:bottom;width:0;animation:type ${(ctaText.length * 0.045).toFixed(2)}s steps(${ctaText.length || 1}) .8s both}
334
+ .caret{display:inline-block;width:.55em;height:1.1em;margin-left:.1em;vertical-align:-.15em;background:${accent};animation:blink 1s steps(1) .8s infinite}
335
+ @keyframes pop{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}
336
+ @keyframes rise{from{opacity:0;transform:translateY(${24 * u}px)}to{opacity:1;transform:none}}
337
+ @keyframes sweep{to{background-size:100% ${darkCards ? `${Math.max(2, Math.round(3 * u))}px` : '100%'}}}
338
+ @keyframes type{to{width:${ctaText.length}ch}}
339
+ @keyframes blink{50%{opacity:0}}
340
+ </style>
341
+ ${c.kicker ? `<div class="a rise" style="font-size:${30 * u}px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:${CK.muted}">${esc(c.kicker)}</div>` : ''}
247
342
  <div style="display:flex;align-items:center;gap:${22 * u}px">${logo}
248
- ${ec.title ? `<span style="font-size:${92 * u}px;font-weight:600;letter-spacing:-.03em;color:${ink}">${esc(ec.title)}</span>` : ''}</div>
249
- ${ec.subtitle ? `<div style="font-size:${44 * u}px;color:#334155;letter-spacing:-.01em">${esc(ec.subtitle).replace(/\*\*(.+?)\*\*/g, `<span style="background:linear-gradient(transparent 62%,${accent} 62%)">$1</span>`)}</div>` : ''}
250
- ${ec.cta ? `<div style="margin-top:${18 * u}px;font-size:${38 * u}px;font-weight:600;color:#fff;background:${ink};padding:${20 * u}px ${46 * u}px;border-radius:${16 * u}px">${esc(ec.cta)}</div>` : ''}
251
- </body>`, path.join(cfg.work, 'endcard.png'), false);
343
+ ${c.title ? `<span class="a rise" style="animation-delay:.12s;display:block;max-width:${W * 0.8}px;text-wrap:balance;line-height:1.15;font-size:${(c.title.length > 40 ? 68 : 92) * u}px;font-weight:600;letter-spacing:-.03em;color:${CK.title}">${esc(c.title).replace(/\*\*(.+?)\*\*/g, '<span class="hl">$1</span>')}</span>` : ''}</div>
344
+ ${c.subtitle ? `<div class="a rise" style="animation-delay:.3s;font-size:${44 * u}px;color:${CK.sub};letter-spacing:-.01em">${esc(c.subtitle).replace(/\*\*(.+?)\*\*/g, '<span class="hl">$1</span>')}</div>` : ''}
345
+ ${image}${think}
346
+ ${c.bullets?.length ? `<ol style="margin:${10 * u}px 0 0;padding:0;list-style:none;text-align:left;display:flex;flex-direction:column;gap:${18 * u}px">${c.bullets.map((b, k) =>
347
+ `<li class="a rise" style="animation-delay:${(0.6 + 0.4 * k).toFixed(2)}s;display:flex;align-items:center;gap:${20 * u}px;font-size:${38 * u}px;color:${CK.item}">
348
+ <span style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:${52 * u}px;height:${52 * u}px;border-radius:50%;background:${accent};color:${ink};font-weight:700;font-size:${28 * u}px">${k + 1}</span>
349
+ <span>${md(b, darkCards ? accent : ink)}</span></li>`).join('')}</ol>` : ''}
350
+ ${!c.cta ? '' : cmd
351
+ ? `<div class="a rise" style="animation-delay:.45s;margin-top:${18 * u}px;font:500 ${36 * u}px ui-monospace,SFMono-Regular,Menlo,monospace;color:${CK.pillFg};background:${CK.pillBg};border:${CK.pillLine};padding:${20 * u}px ${36 * u}px;border-radius:${16 * u}px;text-align:left;box-shadow:0 ${16 * u}px ${40 * u}px ${CK.shadow}"><span style="color:${accent}">$</span> <span class="type">${esc(ctaText)}</span><span class="caret"></span></div>`
352
+ : `<div class="a rise" style="animation-delay:.45s;margin-top:${18 * u}px;font-size:${38 * u}px;font-weight:600;color:${CK.ctaFg};background:${CK.ctaBg};padding:${20 * u}px ${46 * u}px;border-radius:${16 * u}px">${esc(c.cta)}</div>`}
353
+ </body>`;
354
+ };
355
+ const animate = async (html, seconds, dir) => {
356
+ fs.rmSync(dir, { recursive: true, force: true });
357
+ fs.mkdirSync(dir, { recursive: true });
358
+ await pg.setContent(`<html><head>${head}</head>${html}</html>`, { waitUntil: 'networkidle' }).catch(() => {});
359
+ await pg.evaluate(() => document.fonts.ready);
360
+ const n = Math.ceil(seconds * FPS) + 1;
361
+ for (let k = 0; k < n; k++) {
362
+ await pg.evaluate((ms) => document.getAnimations().forEach((a) => { a.pause(); a.currentTime = ms; }), (k * 1000) / FPS);
363
+ await pg.screenshot({ path: path.join(dir, `${String(k).padStart(4, '0')}.jpg`), type: 'jpeg', quality: 92 });
364
+ }
365
+ };
366
+ const ec = cfg.endCard;
367
+ if (ec) await animate(card(ec, ec.logoFile ?? path.join(cfg.work, 'logo.png')), END + 0.5, path.join(cfg.work, 'endcard'));
368
+ for (const [k, c] of cards.entries()) await animate(card(c.card, c.card.logoFile ?? '', { thinkAt: c.thinkAt }), c.t1 - c.t0, path.join(cfg.work, `card${k}`));
369
+ if (in0) await animate(card(in0, in0.logoFile ?? path.join(cfg.work, 'intro-logo.png')), INTRO, path.join(cfg.work, 'intro'));
370
+ if (cfg.frame) {
371
+ // three layers: the backdrop (a picture, or the page itself blurred), the window chrome (shadow and
372
+ // title bar), and the page with rounded bottom corners
373
+ const grain = `url('data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="2" stitchTiles="stitch"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .09 0"/></filter><rect width="200" height="200" filter="url(#n)"/></svg>')}')`;
374
+ const STYLES = {
375
+ aurora: { dark: true, bg: `${grain}, radial-gradient(${W * 0.47}px ${H * 0.65}px at 20% 20%, #22d3ee99, transparent 60%), radial-gradient(${W * 0.47}px ${H * 0.65}px at 80% 30%, #a78bfa99, transparent 60%), radial-gradient(${W * 0.52}px ${H * 0.74}px at 50% 100%, #34d39988, transparent 60%), #0b1020` },
376
+ studio: { dark: false, bg: `${grain}, radial-gradient(${W * 0.73}px ${H * 0.83}px at 50% 0%, #ffffff, #e9edf3 60%, #d6dce6)` },
377
+ dark: { dark: true, bg: `radial-gradient(${W * 0.7}px ${H * 0.8}px at 15% 0%, ${accent}66, transparent 70%), radial-gradient(${W * 0.7}px ${H * 0.8}px at 100% 100%, #6366f166, transparent 70%), linear-gradient(135deg, #1e293b, ${ink})` },
378
+ pastel: { dark: false, bg: 'linear-gradient(135deg, #fde2e4 0%, #e2ece9 35%, #dfe7fd 70%, #f0e6ff 100%)' },
379
+ blur: { dark: true, bg: null },
380
+ };
381
+ 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(/\/$/, '');
383
+ const dot = (col) => `<span style="width:${13 * u}px;height:${13 * u}px;border-radius:50%;background:${col};display:inline-block"></span>`;
384
+ const [barBg, barFg, pill, line] = st.dark ? ['#1f2430', '#9aa4b2', '#2b3140', '#11151d'] : ['#e8ebef', '#4b5563', '#fff', '#d5d9df'];
385
+ 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);
386
+ await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:transparent;${fontCss}">
387
+ <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
+ <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
+ <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>
391
+ </div></body>`, path.join(cfg.work, 'frame-chrome.png'), true);
392
+ // alpha mask for the page: rounded bottom corners (the title bar covers the top ones)
393
+ await pg.setViewportSize({ width: FW, height: FH });
394
+ 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>`);
395
+ await pg.screenshot({ path: path.join(cfg.work, 'frame-mask.png') });
396
+ await pg.setViewportSize({ width: W, height: H });
252
397
  }
253
398
  await browser.close();
254
399
 
@@ -258,20 +403,54 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
258
403
  `zoompan=z='${zExpr}':x='clip(${cxExpr}-iw/zoom/2,0,iw-iw/zoom)':y='clip(${cyExpr}-ih/zoom/2,0,ih-ih/zoom)'` +
259
404
  `:d=1:fps=${FPS}:s=${W}x${H},format=yuv420p${END ? `,tpad=stop_mode=clone:stop_duration=${END}` : ''}[v0];\n`;
260
405
  let last = 'v0', idx = 1;
406
+ if (cfg.frame) {
407
+ const still = (f) => inputs.push('-loop', '1', '-t', `${TOTAL}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, f));
408
+ const blur = !cfg.frame.background && cfg.frame.style === 'blur';
409
+ let base;
410
+ if (blur) {
411
+ graph += `[v0]split[v0a][v0b];[v0b]scale=${even(W / 12)}:${even(H / 12)},gblur=sigma=4,scale=${W}:${H},eq=brightness=-0.12:saturation=1.3[fbg];\n`;
412
+ base = 'fbg';
413
+ } else { still('frame-bg.png'); base = `${idx++}:v`; }
414
+ still('frame-chrome.png'); const chrome = idx++;
415
+ still('frame-mask.png'); const mask = idx++;
416
+ 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
+ `[${base}][${chrome}:v]overlay=0:0[fc];[fc][pg]overlay=${FX}:${FY},format=yuv420p[vf];\n`;
418
+ last = 'vf';
419
+ }
420
+ // docs: a second, caption-free copy for the per-step clips and stills (the article carries the
421
+ // text), so crops never have to dodge a caption
422
+ const clean = cfg.clips.length ? path.join(cfg.work, 'video-clean.mp4') : null;
423
+ if (clean) { graph += `[${last}]split[vmain][vclean0];\n`; last = 'vmain'; }
261
424
  for (const [i, c] of captions.entries()) {
262
425
  const a = c.t[0] + 0.1, b = c.t[1] - 0.1;
263
- inputs.push('-loop', '1', '-t', `${TOTAL}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, `cap${i}.png`));
264
- graph += `[${idx}:v]format=rgba,fade=in:st=${a.toFixed(3)}:d=0.15:alpha=1,fade=out:st=${(b - 0.15).toFixed(3)}:d=0.15:alpha=1[c${i}];\n` +
265
- `[${last}][c${i}]overlay=0:0:enable='between(t,${a.toFixed(3)},${b.toFixed(3)})'[v${idx}];\n`;
426
+ // each caption is an input only as long as it is on screen (decoding a looped PNG for the whole
427
+ // video, once per caption, made long tutorials take minutes to compose)
428
+ inputs.push('-loop', '1', '-t', `${(b - a + 0.1).toFixed(3)}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, `cap${i}.png`));
429
+ graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${a.toFixed(3)}/TB,fade=in:st=${a.toFixed(3)}:d=0.15:alpha=1,fade=out:st=${(b - 0.15).toFixed(3)}:d=0.15:alpha=1[c${i}];\n` +
430
+ `[${last}][c${i}]overlay=0:0:eof_action=pass:enable='between(t,${a.toFixed(3)},${b.toFixed(3)})'[v${idx}];\n`;
266
431
  last = `v${idx++}`;
267
432
  }
433
+ for (const [k, c] of cards.entries()) {
434
+ 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` +
436
+ `[${last}][sc${k}]overlay=0:0:eof_action=pass:enable='between(t,${c.t0.toFixed(3)},${c.t1.toFixed(3)})'[v${++idx}];\n`;
437
+ last = `v${idx}`;
438
+ }
268
439
  if (ec) {
269
440
  const st = VIDEO - 0.25;
270
- inputs.push('-loop', '1', '-t', `${TOTAL}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, 'endcard.png'));
271
- graph += `[${idx}:v]format=rgba,fade=in:st=${st.toFixed(3)}:d=0.3:alpha=1[ec];\n[${last}][ec]overlay=0:0:enable='gte(t,${st.toFixed(3)})'[v${++idx}];\n`;
441
+ inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, 'endcard', '%04d.jpg'));
442
+ graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${st.toFixed(3)}/TB,fade=in:st=${st.toFixed(3)}:d=0.3:alpha=1[ec];\n` +
443
+ `[${last}][ec]overlay=0:0:eof_action=repeat:enable='gte(t,${st.toFixed(3)})'[v${++idx}];\n`;
444
+ last = `v${idx}`;
445
+ }
446
+ if (in0) {
447
+ 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` +
449
+ `[${last}][in]overlay=0:0:eof_action=pass[v${++idx}];\n`;
272
450
  last = `v${idx}`;
273
451
  }
274
452
  graph += `[${last}]trim=duration=${TOTAL},format=yuv420p[out]`;
453
+ if (clean) graph += `;\n[vclean0]trim=duration=${TOTAL},format=yuv420p[clean]`;
275
454
  const graphFile = path.join(cfg.work, 'graph.txt');
276
455
  fs.writeFileSync(graphFile, graph);
277
456
  const silent = path.join(cfg.work, 'video-silent.mp4');
@@ -280,11 +459,15 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
280
459
  const major = Number((execFileSync('ffmpeg', ['-version']).toString().match(/ffmpeg version n?(\d+)/) ?? [])[1] ?? 7);
281
460
  const graphArgs = major >= 7 ? ['-/filter_complex', graphFile] : ['-filter_complex_script', graphFile];
282
461
  execFileSync('ffmpeg', ['-y', '-loglevel', 'error', ...inputs, ...graphArgs, '-map', '[out]',
283
- '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', `${FPS}`, silent]);
462
+ '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', `${FPS}`, silent,
463
+ ...(clean ? ['-map', '[clean]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', `${FPS}`, clean] : [])]);
284
464
 
285
465
  // --- 6. preview contact sheet: one frame per step (at its zoom peak) + end card ------------
286
466
  // zoomed steps: at the zoom peak; others: late in the step, where its actions' result is visible
287
467
  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 ?? '' }));
468
+ 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 ?? '' });
469
+ shots.sort((a, b) => a.t - b.t);
470
+ if (in0) shots.unshift({ t: INTRO * 0.8, label: `intro · 0.0–${INTRO.toFixed(1)}s`, caption: '' });
288
471
  if (ec) shots.push({ t: VIDEO + END / 2, label: `end card · ${VIDEO.toFixed(1)}–${TOTAL.toFixed(1)}s`, caption: '' });
289
472
  const cells = shots.map((sh, k) => {
290
473
  const f = path.join(cfg.work, `pv${k}.jpg`);
@@ -309,20 +492,42 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
309
492
  endCard: ec ? VIDEO : null,
310
493
  clicks: tl.clicks.map(toOut).filter((t) => t != null),
311
494
  whooshes: dedupe([
312
- ...tl.navs.map(toOut).filter((t) => t != null && t > 0.2),
495
+ // whoosh on deliberate scene changes (goto) only: a page the app opens after a click, or a
496
+ // results page reloading itself while you wait, is not a scene change
497
+ ...(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
+ .map(toOut).filter((t) => t != null && t > 0.2),
313
499
  ...segs.filter((g) => g.afterHidden).map((g) => g.o0),
500
+ ...(in0 ? [INTRO - 0.3] : []),
501
+ ...cards.flatMap((c) => [c.t0, c.t1 - 0.3]),
314
502
  ...(ec ? [VIDEO - 0.25] : []),
315
503
  ]),
316
504
  risers: segs.filter((g) => g.step.sfx === 'riser').map((g) => [g.o0, g.o1]),
317
505
  chimes: segs.filter((g) => g.step.sfx === 'riser' || g.step.sfx === 'chime').map((g) => g.o1),
318
506
  // narration: each line may run until the next one starts
319
507
  voice: [
508
+ ...(in0?.say ? [{ text: in0.say, start: 0.15, where: 'intro' }] : []),
509
+ ...cards.filter((c) => c.card.say).map((c) => ({ text: c.card.say, start: c.t0 + 0.15, where: `step ${c.i + 1} card` })),
320
510
  ...segs.filter((g) => g.step.say).map((g) => ({ text: g.step.say, start: g.o0 + (g.step.sayAt ?? 0.15), where: `step ${g.i + 1}` })),
321
511
  ...(ec?.say ? [{ text: ec.say, start: VIDEO + 0.05, where: 'end card' }] : []),
322
512
  ],
323
513
  };
514
+ events.voice.sort((a, b) => a.start - b.start);
324
515
  events.voice.forEach((v, k) => { v.until = events.voice[k + 1]?.start ?? TOTAL - 0.1; });
325
516
  fs.writeFileSync(path.join(cfg.work, 'events.json'), JSON.stringify(events, null, 1));
326
517
  log(segs.map((g) => ` ${g.o0.toFixed(1)}–${g.o1.toFixed(1)}s step ${g.i + 1} motion x${g.motionSpeed.toFixed(1)} waiting x${g.waitSpeed.toFixed(1)}`).join('\n'));
327
- return { silent, events, preview, warnings, captions, segs: segs.map((g) => ({ i: g.i, o0: g.o0, o1: g.o1, caption: g.step.caption })) };
518
+ // docs clips: the part of the frame each step is about (what it clicked, typed into, marked or
519
+ // noted, with some context), in output px, same aspect ratio as the video
520
+ for (const g of segs) {
521
+ const bs = (tl.steps[g.i].marks ?? []).map((m) => onScreen(m.box, Math.max(g.o0, g.o1 - 0.1))).filter((b) => b.w > 0);
522
+ if (!bs.length) continue;
523
+ const x0 = Math.min(...bs.map((b) => b.x)), y0 = Math.min(...bs.map((b) => b.y));
524
+ const x1 = Math.max(...bs.map((b) => b.x + b.w)), y1 = Math.max(...bs.map((b) => b.y + b.h));
525
+ const pad = H * 0.12;
526
+ let w = Math.max(x1 - x0 + 2 * pad, W * 0.5), h = Math.max(y1 - y0 + 2 * pad, H * 0.5);
527
+ if (w / h > W / H) h = w * H / W; else w = h * W / H;
528
+ w = Math.min(W, w); h = Math.min(H, h);
529
+ let cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
530
+ 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) };
531
+ }
532
+ 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 })) };
328
533
  }
package/src/config.mjs CHANGED
@@ -19,14 +19,25 @@ export function loadEnv(dir) {
19
19
 
20
20
  // What the video is for sets sensible defaults; anything in the YAML overrides them.
21
21
  export const PURPOSES = {
22
- pitch: { duration: null, music: { bpm: 120, volume: 1 }, sfx: true, highlight: false, stepNumbers: false, subtitles: false, chapters: false, clips: null },
23
- tutorial: { duration: 'auto', music: { bpm: 96, volume: 0.35 }, sfx: true, highlight: true, stepNumbers: true, subtitles: true, chapters: true, clips: null },
24
- course: { duration: 'auto', music: false, sfx: true, highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null },
25
- docs: { duration: 'auto', music: false, sfx: false, highlight: true, stepNumbers: true, subtitles: false, chapters: false, clips: 'gif' },
22
+ pitch: { duration: null, music: { bpm: 120, volume: 1 }, sfx: true, highlight: false, stepNumbers: false, subtitles: false, chapters: false, clips: null, frame: 'aurora', pace: 'fast', follow: false },
23
+ tutorial: { duration: 'auto', music: { bpm: 96, volume: 0.35 }, sfx: true, highlight: true, stepNumbers: true, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
24
+ course: { duration: 'auto', music: false, sfx: true, highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
25
+ docs: { duration: 'auto', music: false, sfx: false, highlight: true, stepNumbers: true, subtitles: false, chapters: false, clips: ['gif', 'png'], frame: false, pace: 'normal', follow: false },
26
26
  };
27
27
 
28
28
  // Where storyboards and their videos go, and how they're named: <site>-<kind>[-<topic>].yaml →
29
29
  // .mp4, so a promo, a tutorial and a lesson for the same app never collide or all read "demo".
30
+ // How fast things happen on screen. Promos are brisk; tutorials and lessons give the viewer time to
31
+ // follow each action and to read its result before the next one starts.
32
+ export const PACES = {
33
+ // cursor ms typing ms/char beat after a click (ms) hold after narration (s) waits shown up to (s) voice speed
34
+ fast: { cursor: 700, type: 55, beat: 0, tail: 0.4, maxWait: 1.0, voiceSpeed: 1 },
35
+ normal: { cursor: 900, type: 70, beat: 350, tail: 0.8, maxWait: 1.5, voiceSpeed: 1 },
36
+ slow: { cursor: 1200, type: 95, beat: 800, tail: 1.6, maxWait: 3.0, voiceSpeed: 0.92 },
37
+ };
38
+
39
+ export const FRAME_STYLES = ['aurora', 'studio', 'dark', 'pastel', 'blur'];
40
+
30
41
  export const VIDEO_DIR = 'videos';
31
42
  export const KINDS = { pitch: 'promo', tutorial: 'tutorial', course: 'lesson', docs: 'docs' };
32
43
  // pxgenius.ai → pxgenius; an IP or a port is kept whole so two internal apps don't share a name
@@ -76,26 +87,43 @@ export function loadConfig(file, { draft = false } = {}) {
76
87
  const P = PURPOSES[purpose];
77
88
  if (!P) throw new Error(`purpose must be one of ${Object.keys(PURPOSES).join(', ')}`);
78
89
  const pick = (k) => (raw[k] !== undefined ? raw[k] : P[k]);
90
+ if (!PACES[pick('pace')]) throw new Error(`pace must be one of ${Object.keys(PACES).join(', ')}`);
79
91
 
80
92
  const steps = raw.steps.map((s, i) => {
81
93
  const duration = s.duration ?? P.duration;
82
94
  if (!s.hidden && duration !== 'auto' && !(duration > 0)) {
83
95
  throw new Error(`step ${i + 1}: visible steps need a positive \`duration\` in seconds, or \`duration: auto\``);
84
96
  }
85
- return { actions: [], ...s, duration };
97
+ const card = s.card ? { ...s.card, ...(s.card.image ? { imageFile: path.resolve(dir, s.card.image) } : {}) } : undefined;
98
+ return { actions: [], ...s, duration, card };
86
99
  });
87
100
 
88
101
  let endCard = raw.end_card === false ? null : raw.end_card ?? null;
89
102
  if (endCard) endCard = { duration: endCard.say ? 'auto' : 1.0, ...endCard };
90
- if (endCard?.logo && typeof endCard.logo === 'string' && /\.(png|jpe?g|svg|webp)$/i.test(endCard.logo)) {
91
- endCard.logoFile = path.resolve(dir, endCard.logo);
103
+ // An intro title card (promos; optional for tutorials and lessons). Same fields as the end card.
104
+ let intro = raw.intro === false ? null : raw.intro ?? null;
105
+ if (intro) intro = { duration: intro.say ? 'auto' : 2.2, ...intro };
106
+ for (const card of [endCard, intro]) {
107
+ if (card?.image) card.imageFile = path.resolve(dir, card.image);
108
+ if (card?.logo && typeof card.logo === 'string' && /\.(png|jpe?g|svg|webp)$/i.test(card.logo)) card.logoFile = path.resolve(dir, card.logo);
92
109
  }
110
+ // Browser-window frame on a backdrop (promos by default): true, false, or { background: <css> }
111
+ const frame0 = pick('frame');
112
+ // follow: the camera zooms in on what each step touches and pans from step to step (tutorials)
113
+ const follow0 = pick('follow');
114
+ 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)
117
+ 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;
119
+ if (frame && !FRAME_STYLES.includes(frame.style)) throw new Error(`frame style must be one of ${FRAME_STYLES.join(', ')}`);
93
120
 
94
121
  let voice = null;
95
122
  if (raw.voice) {
96
123
  const provider = raw.voice.provider ?? 'kokoro';
97
124
  if (!VOICE_DEFAULTS[provider]) throw new Error(`voice.provider must be one of ${Object.keys(VOICE_DEFAULTS).join(', ')}`);
98
- voice = { speed: 1, volume: 1, ...VOICE_DEFAULTS[provider], ...raw.voice, provider };
125
+ const pace0 = PACES[pick('pace')] ?? PACES.normal;
126
+ voice = { speed: pace0.voiceSpeed, volume: 1, ...VOICE_DEFAULTS[provider], ...raw.voice, provider };
99
127
  if (draft && provider !== 'kokoro') {
100
128
  voice = { ...voice, provider: 'kokoro', voice: raw.voice.draftVoice ?? VOICE_DEFAULTS.kokoro.voice, model: undefined };
101
129
  }
@@ -103,8 +131,9 @@ export function loadConfig(file, { draft = false } = {}) {
103
131
 
104
132
  const m = pick('music');
105
133
  let music = m === false || m === null ? null : { bpm: 120, volume: 1, ...(P.music || {}), ...(raw.music || {}) };
106
- const clips = pick('clips');
107
- if (clips && !['gif', 'webm', 'mp4'].includes(clips)) throw new Error('clips must be gif, webm or mp4');
134
+ // per-step files for docs: any of gif, webm, mp4, png (annotated still of the step's result)
135
+ const clips = [].concat(pick('clips') ?? []).filter(Boolean);
136
+ 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)');
108
137
  if (music?.file) music.file = path.resolve(dir, music.file);
109
138
 
110
139
  return {
@@ -115,7 +144,8 @@ export function loadConfig(file, { draft = false } = {}) {
115
144
  subtitles: pick('subtitles'),
116
145
  chapters: pick('chapters'),
117
146
  clips,
118
- maxWait: raw.maxWait ?? 1.0,
147
+ pace: { name: pick('pace'), ...PACES[pick('pace')] },
148
+ maxWait: raw.maxWait ?? PACES[pick('pace')].maxWait,
119
149
  url: raw.url,
120
150
  viewport,
121
151
  output,
@@ -124,11 +154,19 @@ export function loadConfig(file, { draft = false } = {}) {
124
154
  ignoreHTTPSErrors: raw.ignoreHTTPSErrors === true,
125
155
  storageState: raw.storageState ? path.resolve(dir, raw.storageState) : undefined,
126
156
  scrollOffset: raw.scrollOffset ?? 40,
127
- captionPosition: raw.captionPosition ?? 'bottom',
157
+ // burned-in subtitles sit at the bottom, so captions move to the top
158
+ captionPosition: raw.captionPosition ?? (pick('subtitles') === 'burn' ? 'top' : 'bottom'),
128
159
  theme: { accent: '#d9f75b', ink: '#0f172a', font: 'Inter', ...raw.theme },
129
160
  setup: raw.setup ?? [],
130
161
  steps,
131
162
  endCard,
163
+ intro,
164
+ article: raw.article ?? null,
165
+ cards: raw.cards ?? 'auto',
166
+ // language of the built-in labels ("Pause and think", "Introduction"): `lang`, else guessed from the captions
167
+ lang: raw.lang ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? 'zh' : 'en'),
168
+ frame,
169
+ follow,
132
170
  music,
133
171
  voice,
134
172
  sfx: pick('sfx') !== false,
package/src/init.mjs CHANGED
@@ -40,7 +40,13 @@ purpose: ${purpose}
40
40
  out: ${name}.mp4
41
41
  ${storageState ? `storageState: ${path.relative(path.dirname(path.resolve(file)), path.resolve(storageState)).split(path.sep).join('/')} # saved login (never commit it)\n` : ''}${ignoreHTTPSErrors ? 'ignoreHTTPSErrors: true # internal site with a self-signed certificate\n' : ''}${narrated ? 'voice: { provider: kokoro } # free, local, English. For the final: { provider: elevenlabs, voice: <voice id> } + ELEVENLABS_API_KEY' : '# voice: { provider: kokoro } # optional voiceover; add `say:` lines to steps'}
42
42
 
43
- setup: # runs before recording; clean the frame here${dismiss ? `\n - dismiss: ${tgt(dismiss.target)}` : '\n # - dismiss: { role: button, name: Accept cookies }'}
43
+ ${purpose === 'pitch' ? `intro: # title card before the recording (promos; optional for tutorials/lessons)
44
+ title: ${q(page.title.split(/[|—–-]/)[0].trim() || host)}
45
+ subtitle: TODO the one-line promise, with the **key words** in bold
46
+ ${PURPOSES[purpose]?.frame ? '# frame: false # the recording sits in a browser window on a backdrop; false = full frame\n' : ''}
47
+ ` : purpose === 'tutorial' || purpose === 'course' ? `# intro: { title: TODO, subtitle: TODO } # optional title card (e.g. "How to export a report")
48
+
49
+ ` : ''}setup: # runs before recording; clean the frame here${dismiss ? `\n - dismiss: ${tgt(dismiss.target)}` : '\n # - dismiss: { role: button, name: Accept cookies }'}
44
50
 
45
51
  steps:
46
52
  - caption: ${q(h1?.text?.slice(0, 50) || page.title || host)}${say('TODO one sentence on what this product does.')}${dur(2.5)}
package/src/inspect.mjs CHANGED
@@ -20,7 +20,8 @@ function collect() {
20
20
  };
21
21
  const vis = (el) => {
22
22
  const r = el.getBoundingClientRect(), s = getComputedStyle(el);
23
- return r.width > 2 && r.height > 2 && s.visibility !== 'hidden' && s.display !== 'none' && +s.opacity > 0.05;
23
+ // (parked far off-screen = hidden: a common way to hide form fields)
24
+ return r.width > 2 && r.height > 2 && r.right > 0 && r.left < Math.max(innerWidth, document.documentElement.scrollWidth) && s.visibility !== 'hidden' && s.display !== 'none' && +s.opacity > 0.05;
24
25
  };
25
26
  const docScrolls = document.documentElement.scrollHeight > innerHeight + 10;
26
27
  let scroller = null;
@@ -64,9 +65,12 @@ function collect() {
64
65
  // editors (contenteditable, CodeMirror…): name them by label/placeholder, never by their (long) content
65
66
  const editorName = (el) => clean(el.getAttribute('aria-label')) || clean(el.getAttribute('placeholder')) || clean(el.getAttribute('aria-placeholder')) ||
66
67
  clean(el.closest('label')?.innerText) || '';
68
+ // a label's own text, without the links/buttons inside it (e.g. a "Clear" link)
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; };
67
70
  const interactive = [...document.querySelectorAll(NATIVE)]
68
71
  .filter((el) => vis(el) && el.tagName !== 'LABEL' && !isAd(el) && !(el.isContentEditable && el.parentElement?.isContentEditable))
69
- .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: el.labels?.[0] ? clean(el.labels[0].innerText) : null, ...pos(el) }));
72
+ .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) }));
70
74
  // custom clickables: outermost cursor:pointer elements that aren't native controls
71
75
  const pointer = [];
72
76
  for (const el of document.querySelectorAll('body *')) {
@@ -114,12 +118,15 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
114
118
  seen.add(key);
115
119
  let target = null;
116
120
  if (el.kind === 'role') {
121
+ // sliders, inputs and dropdowns: their label is stable, their value (often their name) is not
122
+ if (['slider', 'textbox', 'combobox', 'searchbox', 'spinbutton'].includes(el.role) && el.label) target = await verify(page.getByLabel(el.label, { exact: true }), { label: el.label, exact: true }, el);
117
123
  // our name guess can differ from the browser's accessible name, so fall back gracefully
118
- if (el.name) target = await verify(page.getByRole(el.role, { name: el.name, exact: true }), { role: el.role, name: el.name, exact: true }, el);
124
+ if (!target && el.name) target = await verify(page.getByRole(el.role, { name: el.name, exact: true }), { role: el.role, name: el.name, exact: true }, el);
119
125
  if (!target && el.alt) target = await verify(page.getByAltText(el.alt, { exact: true }), { alt: el.alt, exact: true }, el);
120
126
  if (!target && el.name) target = await verify(page.getByRole(el.role, { name: el.name }), { role: el.role, name: el.name }, el);
121
127
  if (!target && el.name) target = await verify(page.getByText(el.name, { exact: true }), { text: el.name, exact: true }, el);
122
- if (!target && el.label) target = { label: el.label };
128
+ if (!target && el.label) target = await verify(page.getByLabel(el.label, { exact: true }), { label: el.label, exact: true }, el);
129
+ if (!target && el.label) target = await verify(page.getByLabel(el.label), { label: el.label }, el);
123
130
  if (!target && el.role === 'link' && el.href) target = `a[href="${el.href}"]`;
124
131
  } else {
125
132
  if (el.alt) target = await verify(page.getByAltText(el.alt, { exact: true }), { alt: el.alt, exact: true }, el);
@@ -151,7 +158,7 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
151
158
  '',
152
159
  '## Clickable elements — verified targets (each matches exactly one visible element) for click/move/zoom',
153
160
  ' Copy targets exactly; `name` is the accessible name, which may differ from the on-screen label.',
154
- ...elements.map((e) => `${fold(e.y)} y=${e.y}\tat (${e.cx}, ${e.cy})\t${yaml(e.target)}${e.role === 'textbox' ? ` (text input ${e.w}×${e.h} — click it, then \`type: …\`)` : shows(e)}${e.href ? ` → ${e.href}` : ''}${e.kind === 'pointer' ? ' (custom clickable)' : ''}`),
161
+ ...elements.map((e) => `${fold(e.y)} y=${e.y}\tat (${e.cx}, ${e.cy})\t${yaml(e.target)}${e.role === 'textbox' ? ` (text input ${e.w}×${e.h} — click it, then \`type: …\`)` : e.role === 'slider' ? ' (slider — `slide: { target: …, to: 0..1 }`)' : e.options ? ` (dropdown — \`select: { target: …, option: "…" }\`; options: ${e.options.map((o) => `"${o.text}"${o.on ? ' (selected)' : ''}`).join(', ')})` : shows(e)}${e.href ? ` → ${e.href}` : ''}${e.kind === 'pointer' ? ' (custom clickable)' : ''}`),
155
162
  '',
156
163
  '## Images with alt text (target: { alt: "..." })',
157
164
  ...(info.images.length ? info.images.map((i) => `${fold(i.y)} y=${i.y}\t"${i.alt}"`) : ['(none)']),