storyshot 0.1.3 → 0.3.1

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
  }
@@ -124,7 +140,33 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
124
140
  // --- 3. zoom keyframes: [time, zoom, [cx, cy]] in output px -------------------------------
125
141
  const C0 = [W / 2, H / 2];
126
142
  const kf = [[0, 1, C0]];
143
+ // follow camera: steps without a zoom of their own zoom in on the boxes their actions touched
144
+ // (clicks, typing, marks), and consecutive followed steps pan from one subject to the next
145
+ const { width: VW0, height: VH0 } = cfg.viewport;
146
+ const followOf = (g) => {
147
+ if (!cfg.follow || g.step.zoom || g.step.follow === false) return null;
148
+ const bs = (tl.steps[g.i].marks ?? []).map((m) => m.box).filter((b) => b && b.width < VW0 * 0.9);
149
+ if (!bs.length) return null;
150
+ 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 });
151
+ const w = u.x1 - u.x0 + 160, h = u.y1 - u.y0 + 140; // some context around the subject
152
+ const scale = Math.max(1, Math.min(cfg.follow.scale, VW0 / w, VH0 / h));
153
+ if (scale < 1.15) return null;
154
+ return { box: { x: u.x0, y: u.y0, width: u.x1 - u.x0, height: u.y1 - u.y0 }, scale };
155
+ };
156
+ let followEnd = -1;
127
157
  for (const g of segs) {
158
+ const f = followOf(g);
159
+ if (f) {
160
+ g.zoomBox = f.box;
161
+ const c = [(f.box.x + f.box.width / 2) * K, (f.box.y + f.box.height / 2) * K];
162
+ const move = Math.min(0.7, (g.o1 - g.o0) / 3);
163
+ g.zoomPeak = g.o0 + move + 0.1;
164
+ if (Math.abs(followEnd - g.o0) < 1e-3 && kf.at(-1)[1] === 1) kf.pop(); // pan straight from the previous subject
165
+ else kf.push([g.o0, 1, C0]);
166
+ kf.push([g.o0 + move, f.scale, c], [g.o1 - 0.6, f.scale, c], [g.o1, 1, C0]);
167
+ followEnd = g.o1;
168
+ continue;
169
+ }
128
170
  const z = g.step.zoom;
129
171
  const t = z?.target;
130
172
  if (t && typeof t === 'object' && typeof t.x === 'number' && typeof t.y === 'number' && !t.role && !t.css && !t.text) {
@@ -169,9 +211,13 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
169
211
  const z = z0 + (z1 - z0) * e, cx = c0[0] + (c1[0] - c0[0]) * e, cy = c0[1] + (c1[1] - c0[1]) * e;
170
212
  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
213
  };
214
+ // browser-window frame: the page is drawn at (FX, FY), FW x FH, inside a window on a backdrop
215
+ const even = (x) => 2 * Math.round(x / 2);
216
+ const FW = cfg.frame ? even(W * 0.86) : W, FH = cfg.frame ? even(H * 0.86) : H, BAR = cfg.frame ? Math.round(H * 0.034) : 0;
217
+ const FX = even((W - FW) / 2), FY = cfg.frame ? even((H - FH - BAR) / 2 + BAR) : 0, F = FW / W;
172
218
  const onScreen = (b, t) => {
173
219
  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 };
220
+ 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
221
  };
176
222
 
177
223
  // --- 4. captions + end card rendered as PNGs ----------------------------------------------
@@ -227,7 +273,9 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
227
273
  const other = want === 'top' ? 'bottom' : 'top';
228
274
  let pos = want;
229
275
  const hit = hits(want);
230
- if (hit && !hits(other)) {
276
+ if (hit && cfg.subtitles === 'burn' && other === 'bottom') {
277
+ 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`);
278
+ } else if (hit && !hits(other)) {
231
279
  pos = other;
232
280
  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
281
  } 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`);
@@ -235,20 +283,80 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
235
283
  else captions.push({ text: g.step.caption, t: [g.o0, g.o1], pos, n });
236
284
  }
237
285
  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');
286
+ // Intro and end cards are short animations (logo pops, title rises, the highlight sweeps, a command
287
+ // CTA types itself out), rendered frame by frame by seeking the page's CSS animations.
288
+ const u = H / 1080;
289
+ const card = (c, logoPath, { thinkAt = 0 } = {}) => {
290
+ const image = c.imageFile && fs.existsSync(c.imageFile)
291
+ ? `<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)">` : '';
292
+ 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:#64748b">
293
+ <div style="margin-bottom:${12 * H / 1080}px">Pause and think</div>
294
+ <div style="height:${8 * H / 1080}px;border-radius:99px;background:#e2e8f0;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>
295
+ <style>@keyframes drain{from{transform:scaleX(1)}to{transform:scaleX(0)}}</style>` : '';
241
296
  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}
297
+ ? `<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">` : '';
298
+ const cmd = c.cta && /^\$?\s*(npx|npm|pnpm|yarn|bun|pip|pipx|uv|brew|curl|git|docker|cargo|go)\b/.test(c.cta);
299
+ const ctaText = String(c.cta ?? '').replace(/^\$\s*/, '');
300
+ return `<body style="margin:0;width:${W}px;height:${H}px;${fontCss}
245
301
  background:radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#ffffff 0%,#eef2f7 60%,#e2e8f0 100%);
246
302
  display:flex;flex-direction:column;align-items:center;justify-content:center;gap:${28 * u}px;text-align:center">
303
+ <style>
304
+ .a{animation-fill-mode:both;animation-timing-function:cubic-bezier(.2,.8,.2,1)}
305
+ .pop{animation-name:pop;animation-duration:.55s;animation-delay:.05s}
306
+ .rise{animation-name:rise;animation-duration:.6s}
307
+ .hl{background:linear-gradient(transparent 62%,${accent} 62%) no-repeat 0 0/0% 100%;animation:sweep .6s cubic-bezier(.4,0,.2,1) .75s both}
308
+ .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}
309
+ .caret{display:inline-block;width:.55em;height:1.1em;margin-left:.1em;vertical-align:-.15em;background:${accent};animation:blink 1s steps(1) .8s infinite}
310
+ @keyframes pop{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}
311
+ @keyframes rise{from{opacity:0;transform:translateY(${24 * u}px)}to{opacity:1;transform:none}}
312
+ @keyframes sweep{to{background-size:100% 100%}}
313
+ @keyframes type{to{width:${ctaText.length}ch}}
314
+ @keyframes blink{50%{opacity:0}}
315
+ </style>
316
+ ${c.kicker ? `<div class="a rise" style="font-size:${30 * u}px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#64748b">${esc(c.kicker)}</div>` : ''}
247
317
  <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);
318
+ ${c.title ? `<span class="a rise" style="animation-delay:.12s;font-size:${92 * u}px;font-weight:600;letter-spacing:-.03em;color:${ink}">${esc(c.title).replace(/\*\*(.+?)\*\*/g, '<span class="hl">$1</span>')}</span>` : ''}</div>
319
+ ${c.subtitle ? `<div class="a rise" style="animation-delay:.3s;font-size:${44 * u}px;color:#334155;letter-spacing:-.01em">${esc(c.subtitle).replace(/\*\*(.+?)\*\*/g, '<span class="hl">$1</span>')}</div>` : ''}
320
+ ${image}${think}
321
+ ${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) =>
322
+ `<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:#1e293b">
323
+ <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>
324
+ <span>${md(b, ink)}</span></li>`).join('')}</ol>` : ''}
325
+ ${!c.cta ? '' : cmd
326
+ ? `<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:#e2e8f0;background:${ink};padding:${20 * u}px ${36 * u}px;border-radius:${16 * u}px;text-align:left;box-shadow:0 ${16 * u}px ${40 * u}px rgba(15,23,42,.25)"><span style="color:${accent}">$</span> <span class="type">${esc(ctaText)}</span><span class="caret"></span></div>`
327
+ : `<div class="a rise" style="animation-delay:.45s;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(c.cta)}</div>`}
328
+ </body>`;
329
+ };
330
+ const animate = async (html, seconds, dir) => {
331
+ fs.rmSync(dir, { recursive: true, force: true });
332
+ fs.mkdirSync(dir, { recursive: true });
333
+ await pg.setContent(`<html><head>${head}</head>${html}</html>`, { waitUntil: 'networkidle' }).catch(() => {});
334
+ await pg.evaluate(() => document.fonts.ready);
335
+ const n = Math.ceil(seconds * FPS) + 1;
336
+ for (let k = 0; k < n; k++) {
337
+ await pg.evaluate((ms) => document.getAnimations().forEach((a) => { a.pause(); a.currentTime = ms; }), (k * 1000) / FPS);
338
+ await pg.screenshot({ path: path.join(dir, `${String(k).padStart(4, '0')}.jpg`), type: 'jpeg', quality: 92 });
339
+ }
340
+ };
341
+ const ec = cfg.endCard;
342
+ if (ec) await animate(card(ec, ec.logoFile ?? path.join(cfg.work, 'logo.png')), END + 0.5, path.join(cfg.work, 'endcard'));
343
+ 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}`));
344
+ if (in0) await animate(card(in0, in0.logoFile ?? path.join(cfg.work, 'intro-logo.png')), INTRO, path.join(cfg.work, 'intro'));
345
+ if (cfg.frame) {
346
+ // backdrop + window (under the page) and the backdrop again with a rounded hole (over it, for the corners)
347
+ const bg = cfg.frame.background ?? `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})`;
348
+ const R = Math.round(12 * u), host = new URL(cfg.url).host.replace(/^www\./, '') + new URL(cfg.url).pathname.replace(/\/$/, '');
349
+ const dot = (c) => `<span style="width:${13 * u}px;height:${13 * u}px;border-radius:50%;background:${c};display:inline-block"></span>`;
350
+ await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:${bg};${fontCss}">
351
+ <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,.45),0 0 0 1px rgba(255,255,255,.08)"></div>
352
+ <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:#e8ebef;display:flex;align-items:center;border-bottom:1px solid #d5d9df">
353
+ <div style="display:flex;gap:${8 * u}px;margin-left:${16 * u}px">${dot('#ff5f57')}${dot('#febc2e')}${dot('#28c840')}</div>
354
+ <div style="position:absolute;left:50%;transform:translateX(-50%);font-size:${15 * u}px;color:#4b5563;background:#fff;border-radius:${8 * u}px;padding:${4 * u}px ${60 * u}px">${esc(host)}</div>
355
+ </div></body>`, path.join(cfg.work, 'frame-bg.png'), false);
356
+ const hole = encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><rect x="${FX}" y="${FY - BAR}" width="${FW}" height="${FH + BAR}" rx="${R}"/></svg>`);
357
+ await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:transparent">
358
+ <div style="position:absolute;inset:0;background:${bg};-webkit-mask:linear-gradient(#000 0 0),url('data:image/svg+xml,${hole}');-webkit-mask-composite:xor;mask-composite:exclude"></div></body>`,
359
+ path.join(cfg.work, 'frame-fg.png'), true);
252
360
  }
253
361
  await browser.close();
254
362
 
@@ -258,17 +366,38 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
258
366
  `zoompan=z='${zExpr}':x='clip(${cxExpr}-iw/zoom/2,0,iw-iw/zoom)':y='clip(${cyExpr}-ih/zoom/2,0,ih-ih/zoom)'` +
259
367
  `:d=1:fps=${FPS}:s=${W}x${H},format=yuv420p${END ? `,tpad=stop_mode=clone:stop_duration=${END}` : ''}[v0];\n`;
260
368
  let last = 'v0', idx = 1;
369
+ if (cfg.frame) {
370
+ inputs.push('-loop', '1', '-t', `${TOTAL}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, 'frame-bg.png'),
371
+ '-loop', '1', '-t', `${TOTAL}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, 'frame-fg.png'));
372
+ graph += `[v0]scale=${FW}:${FH}:flags=lanczos[pg];[${idx}:v][pg]overlay=${FX}:${FY}[fb];[fb][${idx + 1}:v]overlay=0:0,format=yuv420p[vf];\n`;
373
+ last = 'vf'; idx += 2;
374
+ }
261
375
  for (const [i, c] of captions.entries()) {
262
376
  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`;
377
+ // each caption is an input only as long as it is on screen (decoding a looped PNG for the whole
378
+ // video, once per caption, made long tutorials take minutes to compose)
379
+ inputs.push('-loop', '1', '-t', `${(b - a + 0.1).toFixed(3)}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, `cap${i}.png`));
380
+ 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` +
381
+ `[${last}][c${i}]overlay=0:0:eof_action=pass:enable='between(t,${a.toFixed(3)},${b.toFixed(3)})'[v${idx}];\n`;
266
382
  last = `v${idx++}`;
267
383
  }
384
+ for (const [k, c] of cards.entries()) {
385
+ inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, `card${k}`, '%04d.jpg'));
386
+ 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` +
387
+ `[${last}][sc${k}]overlay=0:0:eof_action=pass:enable='between(t,${c.t0.toFixed(3)},${c.t1.toFixed(3)})'[v${++idx}];\n`;
388
+ last = `v${idx}`;
389
+ }
268
390
  if (ec) {
269
391
  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`;
392
+ inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, 'endcard', '%04d.jpg'));
393
+ 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` +
394
+ `[${last}][ec]overlay=0:0:eof_action=repeat:enable='gte(t,${st.toFixed(3)})'[v${++idx}];\n`;
395
+ last = `v${idx}`;
396
+ }
397
+ if (in0) {
398
+ inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, 'intro', '%04d.jpg'));
399
+ graph += `[${idx}:v]format=rgba,fade=out:st=${Math.max(0, INTRO - 0.35).toFixed(3)}:d=0.35:alpha=1[in];\n` +
400
+ `[${last}][in]overlay=0:0:eof_action=pass[v${++idx}];\n`;
272
401
  last = `v${idx}`;
273
402
  }
274
403
  graph += `[${last}]trim=duration=${TOTAL},format=yuv420p[out]`;
@@ -285,6 +414,9 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
285
414
  // --- 6. preview contact sheet: one frame per step (at its zoom peak) + end card ------------
286
415
  // zoomed steps: at the zoom peak; others: late in the step, where its actions' result is visible
287
416
  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 ?? '' }));
417
+ 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 ?? '' });
418
+ shots.sort((a, b) => a.t - b.t);
419
+ if (in0) shots.unshift({ t: INTRO * 0.8, label: `intro · 0.0–${INTRO.toFixed(1)}s`, caption: '' });
288
420
  if (ec) shots.push({ t: VIDEO + END / 2, label: `end card · ${VIDEO.toFixed(1)}–${TOTAL.toFixed(1)}s`, caption: '' });
289
421
  const cells = shots.map((sh, k) => {
290
422
  const f = path.join(cfg.work, `pv${k}.jpg`);
@@ -311,18 +443,37 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
311
443
  whooshes: dedupe([
312
444
  ...tl.navs.map(toOut).filter((t) => t != null && t > 0.2),
313
445
  ...segs.filter((g) => g.afterHidden).map((g) => g.o0),
446
+ ...(in0 ? [INTRO - 0.3] : []),
447
+ ...cards.flatMap((c) => [c.t0, c.t1 - 0.3]),
314
448
  ...(ec ? [VIDEO - 0.25] : []),
315
449
  ]),
316
450
  risers: segs.filter((g) => g.step.sfx === 'riser').map((g) => [g.o0, g.o1]),
317
451
  chimes: segs.filter((g) => g.step.sfx === 'riser' || g.step.sfx === 'chime').map((g) => g.o1),
318
452
  // narration: each line may run until the next one starts
319
453
  voice: [
454
+ ...(in0?.say ? [{ text: in0.say, start: 0.15, where: 'intro' }] : []),
455
+ ...cards.filter((c) => c.card.say).map((c) => ({ text: c.card.say, start: c.t0 + 0.15, where: `step ${c.i + 1} card` })),
320
456
  ...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
457
  ...(ec?.say ? [{ text: ec.say, start: VIDEO + 0.05, where: 'end card' }] : []),
322
458
  ],
323
459
  };
460
+ events.voice.sort((a, b) => a.start - b.start);
324
461
  events.voice.forEach((v, k) => { v.until = events.voice[k + 1]?.start ?? TOTAL - 0.1; });
325
462
  fs.writeFileSync(path.join(cfg.work, 'events.json'), JSON.stringify(events, null, 1));
326
463
  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 })) };
464
+ // docs clips: the part of the frame each step is about (what it clicked, typed into, marked or
465
+ // noted, with some context), in output px, same aspect ratio as the video
466
+ for (const g of segs) {
467
+ 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);
468
+ if (!bs.length) continue;
469
+ const x0 = Math.min(...bs.map((b) => b.x)), y0 = Math.min(...bs.map((b) => b.y));
470
+ const x1 = Math.max(...bs.map((b) => b.x + b.w)), y1 = Math.max(...bs.map((b) => b.y + b.h));
471
+ const pad = H * 0.12;
472
+ let w = Math.max(x1 - x0 + 2 * pad, W * 0.5), h = Math.max(y1 - y0 + 2 * pad, H * 0.5);
473
+ if (w / h > W / H) h = w * H / W; else w = h * W / H;
474
+ w = Math.min(W, w); h = Math.min(H, h);
475
+ const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
476
+ 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) };
477
+ }
478
+ return { silent, 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
479
  }
package/src/config.mjs CHANGED
@@ -19,14 +19,23 @@ 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: true, 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: false, pace: 'slow', follow: true },
24
+ course: { duration: 'auto', music: false, sfx: true, highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: false, 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
+
30
39
  export const VIDEO_DIR = 'videos';
31
40
  export const KINDS = { pitch: 'promo', tutorial: 'tutorial', course: 'lesson', docs: 'docs' };
32
41
  // pxgenius.ai → pxgenius; an IP or a port is kept whole so two internal apps don't share a name
@@ -76,26 +85,39 @@ export function loadConfig(file, { draft = false } = {}) {
76
85
  const P = PURPOSES[purpose];
77
86
  if (!P) throw new Error(`purpose must be one of ${Object.keys(PURPOSES).join(', ')}`);
78
87
  const pick = (k) => (raw[k] !== undefined ? raw[k] : P[k]);
88
+ if (!PACES[pick('pace')]) throw new Error(`pace must be one of ${Object.keys(PACES).join(', ')}`);
79
89
 
80
90
  const steps = raw.steps.map((s, i) => {
81
91
  const duration = s.duration ?? P.duration;
82
92
  if (!s.hidden && duration !== 'auto' && !(duration > 0)) {
83
93
  throw new Error(`step ${i + 1}: visible steps need a positive \`duration\` in seconds, or \`duration: auto\``);
84
94
  }
85
- return { actions: [], ...s, duration };
95
+ const card = s.card ? { ...s.card, ...(s.card.image ? { imageFile: path.resolve(dir, s.card.image) } : {}) } : undefined;
96
+ return { actions: [], ...s, duration, card };
86
97
  });
87
98
 
88
99
  let endCard = raw.end_card === false ? null : raw.end_card ?? null;
89
100
  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);
101
+ // An intro title card (promos; optional for tutorials and lessons). Same fields as the end card.
102
+ let intro = raw.intro === false ? null : raw.intro ?? null;
103
+ if (intro) intro = { duration: intro.say ? 'auto' : 2.2, ...intro };
104
+ for (const card of [endCard, intro]) {
105
+ if (card?.image) card.imageFile = path.resolve(dir, card.image);
106
+ if (card?.logo && typeof card.logo === 'string' && /\.(png|jpe?g|svg|webp)$/i.test(card.logo)) card.logoFile = path.resolve(dir, card.logo);
92
107
  }
108
+ // Browser-window frame on a backdrop (promos by default): true, false, or { background: <css> }
109
+ const frame0 = pick('frame');
110
+ // follow: the camera zooms in on what each step touches and pans from step to step (tutorials)
111
+ const follow0 = pick('follow');
112
+ const follow = follow0 ? { scale: 1.8, ...(typeof follow0 === 'object' ? follow0 : {}) } : null;
113
+ const frame = frame0 ? { background: null, ...(typeof frame0 === 'object' ? frame0 : {}) } : null;
93
114
 
94
115
  let voice = null;
95
116
  if (raw.voice) {
96
117
  const provider = raw.voice.provider ?? 'kokoro';
97
118
  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 };
119
+ const pace0 = PACES[pick('pace')] ?? PACES.normal;
120
+ voice = { speed: pace0.voiceSpeed, volume: 1, ...VOICE_DEFAULTS[provider], ...raw.voice, provider };
99
121
  if (draft && provider !== 'kokoro') {
100
122
  voice = { ...voice, provider: 'kokoro', voice: raw.voice.draftVoice ?? VOICE_DEFAULTS.kokoro.voice, model: undefined };
101
123
  }
@@ -103,8 +125,9 @@ export function loadConfig(file, { draft = false } = {}) {
103
125
 
104
126
  const m = pick('music');
105
127
  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');
128
+ // per-step files for docs: any of gif, webm, mp4, png (annotated still of the step's result)
129
+ const clips = [].concat(pick('clips') ?? []).filter(Boolean);
130
+ 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
131
  if (music?.file) music.file = path.resolve(dir, music.file);
109
132
 
110
133
  return {
@@ -115,7 +138,8 @@ export function loadConfig(file, { draft = false } = {}) {
115
138
  subtitles: pick('subtitles'),
116
139
  chapters: pick('chapters'),
117
140
  clips,
118
- maxWait: raw.maxWait ?? 1.0,
141
+ pace: { name: pick('pace'), ...PACES[pick('pace')] },
142
+ maxWait: raw.maxWait ?? PACES[pick('pace')].maxWait,
119
143
  url: raw.url,
120
144
  viewport,
121
145
  output,
@@ -124,11 +148,15 @@ export function loadConfig(file, { draft = false } = {}) {
124
148
  ignoreHTTPSErrors: raw.ignoreHTTPSErrors === true,
125
149
  storageState: raw.storageState ? path.resolve(dir, raw.storageState) : undefined,
126
150
  scrollOffset: raw.scrollOffset ?? 40,
127
- captionPosition: raw.captionPosition ?? 'bottom',
151
+ // burned-in subtitles sit at the bottom, so captions move to the top
152
+ captionPosition: raw.captionPosition ?? (pick('subtitles') === 'burn' ? 'top' : 'bottom'),
128
153
  theme: { accent: '#d9f75b', ink: '#0f172a', font: 'Inter', ...raw.theme },
129
154
  setup: raw.setup ?? [],
130
155
  steps,
131
156
  endCard,
157
+ intro,
158
+ frame,
159
+ follow,
132
160
  music,
133
161
  voice,
134
162
  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 *')) {
@@ -119,7 +123,8 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
119
123
  if (!target && el.alt) target = await verify(page.getByAltText(el.alt, { exact: true }), { alt: el.alt, exact: true }, el);
120
124
  if (!target && el.name) target = await verify(page.getByRole(el.role, { name: el.name }), { role: el.role, name: el.name }, el);
121
125
  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 };
126
+ if (!target && el.label) target = await verify(page.getByLabel(el.label, { exact: true }), { label: el.label, exact: true }, el);
127
+ if (!target && el.label) target = await verify(page.getByLabel(el.label), { label: el.label }, el);
123
128
  if (!target && el.role === 'link' && el.href) target = `a[href="${el.href}"]`;
124
129
  } else {
125
130
  if (el.alt) target = await verify(page.getByAltText(el.alt, { exact: true }), { alt: el.alt, exact: true }, el);
@@ -151,7 +156,7 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
151
156
  '',
152
157
  '## Clickable elements — verified targets (each matches exactly one visible element) for click/move/zoom',
153
158
  ' 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)' : ''}`),
159
+ ...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.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
160
  '',
156
161
  '## Images with alt text (target: { alt: "..." })',
157
162
  ...(info.images.length ? info.images.map((i) => `${fold(i.y)} y=${i.y}\t"${i.alt}"`) : ['(none)']),
package/src/mcp.mjs CHANGED
@@ -51,7 +51,7 @@ server.registerTool('init_storyboard', {
51
51
  inputSchema: {
52
52
  url: z.string().describe('Start page of the web app'),
53
53
  path: z.string().optional().describe('Absolute path for the YAML (must not exist yet). Default: <cwd>/videos/<site>-<kind>[-<topic>].yaml, kind = promo/tutorial/lesson/docs from purpose'),
54
- purpose: z.enum(['pitch', 'tutorial', 'course', 'docs']).optional().describe('pitch = 15–30s promo (default); tutorial = narrated how-to; course = narrated lesson; docs = silent per-step GIFs'),
54
+ purpose: z.enum(['pitch', 'tutorial', 'course', 'docs']).optional().describe('pitch = short promo (default); tutorial = narrated how-to, paced to follow along; course = narrated lesson that explains the why; docs = per-step GIFs/PNGs + a Markdown article'),
55
55
  topic: z.string().optional().describe('Short topic for the file name, e.g. "export-pdf" → videos/<site>-tutorial-export-pdf.yaml. Use it for tutorials/lessons, since a site usually gets several'),
56
56
  storage_state: z.string().optional().describe('Absolute path to a saved login (from the login tool) for apps behind a login'),
57
57
  ignore_https_errors: z.boolean().optional().describe('Accept a self-signed HTTPS certificate (internal sites only); also writes ignoreHTTPSErrors: true into the storyboard'),