storyshot 0.3.1 → 0.4.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
@@ -22,9 +22,13 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
22
22
  const readTime = (c) => (c?.bullets?.length ? 1.6 + 0.4 * c.bullets.length + 0.6 * c.bullets.length : 0);
23
23
  // `think: N` (question cards): after the question is read out, N seconds with a countdown bar
24
24
  const cardLen = (c, voice, min) => (c.duration === 'auto' || c.duration == null ? Math.max(min, (voice ?? 0) + 0.9, readTime(c)) + (c.think ?? 0) : c.duration);
25
- const END = !ec0 ? 0 : ec0.duration === 'auto' ? Math.max(1.0, (voiceLens.endCard ?? 0) + 0.6, readTime(ec0)) : ec0.duration;
25
+ // the end card carries the call to action: long enough to read it (and for a command to type itself out)
26
+ const ctaTime = (c) => (c?.cta ? 0.8 + String(c.cta).length * 0.045 + 1.6 : 0);
27
+ const END = !ec0 ? 0 : ec0.duration === 'auto' ? Math.max(2.5, (voiceLens.endCard ?? 0) + 0.6, readTime(ec0), ctaTime(ec0)) : ec0.duration;
28
+ if (ec0 && END < 2.5) warn(`the end card shows for only ${END.toFixed(1)}s: too short to read its call to action — use duration: auto, or at least 2.5`);
26
29
  const in0 = cfg.intro;
27
30
  const INTRO = !in0 ? 0 : in0.duration === 'auto' ? Math.max(1.8, (voiceLens.intro ?? 0) + 0.8, readTime(in0)) : in0.duration;
31
+ if (INTRO > 8) warn(`the intro card plays ${INTRO.toFixed(1)}s before anything happens: viewers leave during long intros — shorten its say or bullets (aim for 6–8s)`);
28
32
  const cards = []; // section cards: { i, t0, t1, card }
29
33
 
30
34
  // --- 1. segments (one per visible step), split into motion / waiting pieces ------------
@@ -116,17 +120,30 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
116
120
  const tw = 160, th = 90, rate = 10;
117
121
  const buf = execFileSync('ffmpeg', ['-loglevel', 'error', '-framerate', `${FPS}`, '-i', path.join(SEQ, '%05d.jpg'),
118
122
  '-vf', `fps=${rate},scale=${tw}:${th},format=gray`, '-f', 'rawvideo', 'pipe:1'], { maxBuffer: 1 << 28 });
119
- const n = Math.floor(buf.length / (tw * th)), score = [];
123
+ // plus the share of 8x8 blocks with no detail at all: an empty canvas or image area that is still
124
+ // loading (dark or light) next to toolbars that are already there
125
+ const n = Math.floor(buf.length / (tw * th)), score = [], flat = [];
120
126
  for (let f = 0; f < n; f++) {
121
127
  let e = 0;
128
+ const blocks = new Float64Array(Math.ceil(tw / 8) * Math.ceil(th / 8));
122
129
  for (let y = 0, o = f * tw * th; y < th - 1; y++) for (let x = 0; x < tw - 1; x++) {
123
- const k = o + y * tw + x;
124
- e += Math.abs(buf[k] - buf[k + 1]) + Math.abs(buf[k] - buf[k + tw]);
130
+ const k = o + y * tw + x, d = Math.abs(buf[k] - buf[k + 1]) + Math.abs(buf[k] - buf[k + tw]);
131
+ e += d; blocks[Math.floor(y / 8) * Math.ceil(tw / 8) + Math.floor(x / 8)] += d;
125
132
  }
126
133
  score.push(e / (tw * th));
134
+ flat.push(blocks.filter((b) => b / 64 < 0.6).length / blocks.length);
127
135
  }
128
136
  const median = [...score].sort((a, b) => a - b)[Math.floor(n / 2)] ?? 0;
129
137
  const blank = score.map((e) => e < 3 && e < median * 0.4);
138
+ for (const g of segs) {
139
+ // compared with how the step ends (its content loaded): much emptier = still loading. Only right
140
+ // after the step waited for something (a load), so drawing on an empty canvas isn't flagged.
141
+ const last = Math.min(n - 1, Math.max(0, Math.floor(g.o1 * rate) - 1));
142
+ 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]);
143
+ for (let f = Math.ceil(g.o0 * rate); f <= last; f++) {
144
+ 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;
145
+ }
146
+ }
130
147
  for (const g of segs) {
131
148
  let run = 0, from = 0;
132
149
  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`); };
@@ -137,6 +154,39 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
137
154
  }
138
155
  }
139
156
 
157
+ // --- 2c. dead air: stretches where nothing moves and nobody speaks --------------------------------
158
+ {
159
+ const tw = 160, th = 90, rate = 10;
160
+ const buf = execFileSync('ffmpeg', ['-loglevel', 'error', '-framerate', `${FPS}`, '-i', path.join(SEQ, '%05d.jpg'),
161
+ '-vf', `fps=${rate},scale=${tw}:${th},format=gray`, '-f', 'rawvideo', 'pipe:1'], { maxBuffer: 1 << 28 });
162
+ const n = Math.floor(buf.length / (tw * th)), still = [false];
163
+ for (let f = 1; f < n; f++) {
164
+ let changed = 0; // pixels that changed visibly: even the cursor moving changes a few
165
+ for (let k = 0, a = f * tw * th, b = a - tw * th; k < tw * th; k++) if (Math.abs(buf[a + k] - buf[b + k]) > 10) changed++;
166
+ still.push(changed < 4);
167
+ }
168
+ const spoken = [
169
+ ...(in0 ? [[0, INTRO]] : []), // cards are their own pictures (the frames under them don't show)
170
+ ...cards.map((c) => [c.t0, c.t1]),
171
+ ...segs.filter((g) => voiceLens.steps[g.i] != null).map((g) => [g.o0 + (g.step.sayAt ?? 0.15), g.o0 + (g.step.sayAt ?? 0.15) + voiceLens.steps[g.i]]),
172
+ ];
173
+ let from = null;
174
+ const report = (to) => {
175
+ if (from != null && to - from >= 3) {
176
+ const ns = segs.filter((x) => x.o1 > from + 1e-6 && x.o0 < to - 1e-6).map((x) => x.i + 1);
177
+ warn(`${ns.length > 1 ? `steps ${ns[0]}–${ns.at(-1)}` : `step ${ns[0] ?? segs.at(-1).i + 1}`}: ${(to - from).toFixed(1)}s of dead air at ${from.toFixed(1)}–${to.toFixed(1)}s (nothing moves and nothing is said) — shorten the wait or pause, hide the load, or narrate what is happening`);
178
+ }
179
+ from = null;
180
+ };
181
+ for (let f = 0; f < n; f++) {
182
+ const t = f / rate;
183
+ const dead = still[f] && !spoken.some(([a, b]) => t >= a && t <= b);
184
+ if (dead && from == null) from = t;
185
+ else if (!dead) report(t);
186
+ }
187
+ report(n / rate);
188
+ }
189
+
140
190
  // --- 3. zoom keyframes: [time, zoom, [cx, cy]] in output px -------------------------------
141
191
  const C0 = [W / 2, H / 2];
142
192
  const kf = [[0, 1, C0]];
@@ -172,7 +222,9 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
172
222
  if (t && typeof t === 'object' && typeof t.x === 'number' && typeof t.y === 'number' && !t.role && !t.css && !t.text) {
173
223
  // a point needs no measuring, so it works even if the zoom was added after recording
174
224
  const { width: VW, height: VH } = cfg.viewport;
175
- g.zoomBox = { x: t.x <= 1 ? t.x * VW : t.x, y: t.y <= 1 ? t.y * VH : t.y, width: 0, height: 0 };
225
+ // a point names the middle of the subject: give it some area, so captions keep off it too
226
+ 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;
227
+ g.zoomBox = { x: px - bw / 2, y: py - bh / 2, width: bw, height: bh };
176
228
  } else if (t && 'zoomTarget' in tl.steps[g.i] && JSON.stringify(tl.steps[g.i].zoomTarget) !== JSON.stringify(t)) {
177
229
  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)`);
178
230
  g.zoomBox = null;
@@ -182,7 +234,9 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
182
234
  const b = g.zoomBox, off = z.offset ?? {};
183
235
  const c = [(b.x + b.width / 2 + (off.x ?? 0)) * K, (b.y + b.height / 2 + (off.y ?? 0)) * K];
184
236
  const [rin, rout] = Array.isArray(z.ramp) ? z.ramp : [z.ramp ?? 0.5, z.ramp ?? 0.5];
185
- const s = g.o0 + (z.start ?? 0), e = Math.min(TOTAL, g.o0 + (z.end ?? g.o1 - g.o0)), scale = z.scale ?? 1.3;
237
+ // a zoom belongs to its step: an `end` past the step (e.g. after the narration grew it) stops there
238
+ if (z.end != null && g.o0 + z.end > g.o1 + 0.05) warn(`step ${g.i + 1}: zoom end ${z.end}s is past the step's end (${(g.o1 - g.o0).toFixed(1)}s), so it stops there — leave out end to zoom to the step's end`);
239
+ const s = g.o0 + (z.start ?? 0), e = Math.min(TOTAL, g.o1, g.o0 + (z.end ?? g.o1 - g.o0)), scale = z.scale ?? 1.3;
186
240
  const last = kf.at(-1)[0];
187
241
  if (s < last) { warn(`zoom in step ${g.i + 1} overlaps the previous zoom, skipped`); continue; }
188
242
  kf.push([s, 1, C0], [Math.min(s + rin, e), scale, c], [Math.max(s + rin, e - rout), scale, c], [e, 1, C0]);
@@ -213,13 +267,27 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
213
267
  };
214
268
  // browser-window frame: the page is drawn at (FX, FY), FW x FH, inside a window on a backdrop
215
269
  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;
270
+ 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;
217
271
  const FX = even((W - FW) / 2), FY = cfg.frame ? even((H - FH - BAR) / 2 + BAR) : 0, F = FW / W;
218
272
  const onScreen = (b, t) => {
219
273
  const v = view(t);
220
274
  return { x: FX + (b.x * K - v.x0) * v.z * F, y: FY + (b.y * K - v.y0) * v.z * F, w: b.width * K * v.z * F, h: b.height * K * v.z * F };
221
275
  };
222
276
 
277
+ // what the address bar shows over time: the page's URL when each visible moment was recorded
278
+ const addressOf = (u) => { try { const x = new URL(u); return x.host.replace(/^www\./, '') + x.pathname.replace(/\/$/, ''); } catch { return String(u); } };
279
+ const urlAt = (r) => (tl.urls ?? []).filter((x) => x.t <= r).at(-1)?.url ?? cfg.url;
280
+ const addresses = [];
281
+ for (const g of cfg.frame?.window === 'browser' ? segs : []) {
282
+ const r = tl.steps[g.i], changes = (tl.urls ?? []).filter((x) => x.t > r.start && x.t <= r.end);
283
+ for (const [t, u] of [[g.o0, urlAt(r.start)], ...changes.map((x) => [toOut(x.t) ?? g.o0, x.url])]) {
284
+ const text = addressOf(u), last = addresses.at(-1);
285
+ if (last?.text === text) continue;
286
+ if (last) last.t1 = t;
287
+ addresses.push({ text, t0: last ? t : 0, t1: TOTAL });
288
+ }
289
+ }
290
+
223
291
  // --- 4. captions + end card rendered as PNGs ----------------------------------------------
224
292
  const { accent, ink, font } = cfg.theme;
225
293
  const fontCss = `font-family:'${font}',-apple-system,'Segoe UI','Helvetica Neue',sans-serif;`;
@@ -232,8 +300,10 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
232
300
  await pg.screenshot({ path: file, omitBackground: transparent });
233
301
  };
234
302
  const fs1 = Math.round(H * 0.043), MARGIN = Math.round(H * 0.06);
303
+ // in a browser-window frame a top caption sits just inside the page, below the address bar
304
+ const TOP = cfg.frame ? FY + Math.round(H * 0.022) : MARGIN;
235
305
  const bubble = (text, n, pos) => `<body style="margin:0;background:transparent">
236
- <div style="position:absolute;left:0;right:0;${pos === 'top' ? 'top' : 'bottom'}:${MARGIN}px;display:flex;justify-content:center;padding:0 5%">
306
+ <div style="position:absolute;left:0;right:0;${pos === 'top' ? `top:${TOP}` : `bottom:${MARGIN}`}px;display:flex;justify-content:center;padding:0 5%">
237
307
  <div id="cap" style="${fontCss}font-size:${fs1}px;font-weight:500;letter-spacing:-.01em;color:#fff;background:${ink}e0;text-align:center;
238
308
  padding:${Math.round(fs1 * 0.43)}px ${Math.round(fs1 * 0.87)}px;border-radius:${fs1}px;box-shadow:0 12px 40px rgba(0,0,0,.28);border:1px solid rgba(255,255,255,.12)">
239
309
  ${cfg.stepNumbers ? `<span style="display:inline-flex;align-items:center;justify-content:center;width:1.45em;height:1.45em;margin:-.2em .55em -.2em -.35em;border-radius:50%;background:${accent};color:${ink};font-weight:700;font-size:.85em;vertical-align:middle">${n}</span>` : ''}${md(text, accent)}</div></div></body>`;
@@ -258,7 +328,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
258
328
  const n = prev && prev.text === g.step.caption && Math.abs(prev.t[1] - g.o0) < 1e-6 ? prev.n : captions.length + 1;
259
329
  const explicit = g.step.captionPosition, want = explicit ?? cfg.captionPosition;
260
330
  const { w, h } = await sizeOf(g.step.caption, n);
261
- const rectOf = (pos) => ({ x: (W - w) / 2, y: pos === 'top' ? MARGIN : H - MARGIN - h, w, h });
331
+ const rectOf = (pos) => ({ x: (W - w) / 2, y: pos === 'top' ? TOP : H - MARGIN - h, w, h });
262
332
  const subjects = [];
263
333
  for (const m of tl.steps[g.i].marks ?? []) {
264
334
  const t = toOut(m.t);
@@ -280,51 +350,61 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
280
350
  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`);
281
351
  } 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`);
282
352
  if (prev && n === prev.n && prev.pos === pos) prev.t[1] = g.o1;
283
- else captions.push({ text: g.step.caption, t: [g.o0, g.o1], pos, n });
353
+ else captions.push({ text: g.step.caption, t: [g.o0, g.o1], pos, n, h });
284
354
  }
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);
355
+ for (const [i, c] of (cfg.captions ? captions : []).entries()) await shot(bubble(c.text, c.n, c.pos), path.join(cfg.work, `cap${i}.png`), true);
286
356
  // Intro and end cards are short animations (logo pops, title rises, the highlight sweeps, a command
287
357
  // CTA types itself out), rendered frame by frame by seeking the page's CSS animations.
288
358
  const u = H / 1080;
359
+ // cards follow the frame: dark backdrops (aurora, dark, blur) get dark cards, so a dark app doesn't
360
+ // cut to a white card; `cards: light | dark` overrides
361
+ const darkCards = cfg.cards === 'dark' || (cfg.cards !== 'light' && ['aurora', 'dark', 'blur'].includes(cfg.frame?.style));
362
+ const CK = darkCards
363
+ ? { 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',
364
+ pillBg: '#1a2238', pillFg: '#e2e8f0', pillLine: '1px solid #33405f', ctaBg: accent, ctaFg: ink, shadow: 'rgba(0,0,0,.45)' }
365
+ : { 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',
366
+ pillBg: ink, pillFg: '#e2e8f0', pillLine: 'none', ctaBg: ink, ctaFg: '#fff', shadow: 'rgba(15,23,42,.25)' };
289
367
  const card = (c, logoPath, { thinkAt = 0 } = {}) => {
290
368
  const image = c.imageFile && fs.existsSync(c.imageFile)
291
369
  ? `<img class="a rise" src="data:image/${path.extname(c.imageFile).slice(1).replace('jpg', 'jpeg').replace('svg', 'svg+xml')};base64,${fs.readFileSync(c.imageFile).toString('base64')}" style="animation-delay:.4s;max-width:${W * 0.6}px;max-height:${H * 0.42}px;border-radius:${12 * H / 1080}px;box-shadow:0 ${12 * H / 1080}px ${36 * H / 1080}px rgba(15,23,42,.18)">` : '';
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>
370
+ const think = c.think ? `<div class="a rise" style="animation-delay:${thinkAt.toFixed(2)}s;position:absolute;left:50%;bottom:${H * 0.1}px;transform:translateX(-50%);width:${W * 0.3}px;text-align:center;font-size:${26 * H / 1080}px;color:${CK.muted}">
371
+ <div style="margin-bottom:${12 * H / 1080}px">${cfg.labels.think}</div>
372
+ <div style="height:${8 * H / 1080}px;border-radius:99px;background:${CK.track};overflow:hidden"><div style="height:100%;background:${accent};transform-origin:left;animation:drain ${c.think}s linear ${thinkAt.toFixed(2)}s both"></div></div></div>
295
373
  <style>@keyframes drain{from{transform:scaleX(1)}to{transform:scaleX(0)}}</style>` : '';
296
374
  const logo = fs.existsSync(logoPath)
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">` : '';
375
+ ? `<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
298
376
  const cmd = c.cta && /^\$?\s*(npx|npm|pnpm|yarn|bun|pip|pipx|uv|brew|curl|git|docker|cargo|go)\b/.test(c.cta);
299
377
  const ctaText = String(c.cta ?? '').replace(/^\$\s*/, '');
300
378
  return `<body style="margin:0;width:${W}px;height:${H}px;${fontCss}
301
- background:radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#ffffff 0%,#eef2f7 60%,#e2e8f0 100%);
379
+ background:${CK.bg};
302
380
  display:flex;flex-direction:column;align-items:center;justify-content:center;gap:${28 * u}px;text-align:center">
303
381
  <style>
304
382
  .a{animation-fill-mode:both;animation-timing-function:cubic-bezier(.2,.8,.2,1)}
305
383
  .pop{animation-name:pop;animation-duration:.55s;animation-delay:.05s}
306
384
  .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}
385
+ ${darkCards
386
+ ? `.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}`
387
+ : `.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}`}
308
388
  .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
389
  .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
390
  @keyframes pop{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}
311
391
  @keyframes rise{from{opacity:0;transform:translateY(${24 * u}px)}to{opacity:1;transform:none}}
312
- @keyframes sweep{to{background-size:100% 100%}}
392
+ @keyframes sweep{to{background-size:100% ${darkCards ? `${Math.max(2, Math.round(3 * u))}px` : '100%'}}}
313
393
  @keyframes type{to{width:${ctaText.length}ch}}
314
394
  @keyframes blink{50%{opacity:0}}
315
395
  </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>` : ''}
396
+ ${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>` : ''}
317
397
  <div style="display:flex;align-items:center;gap:${22 * u}px">${logo}
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>` : ''}
398
+ ${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>
399
+ ${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>` : ''}
320
400
  ${image}${think}
321
401
  ${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">
402
+ `<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}">
323
403
  <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>` : ''}
404
+ <span>${md(b, darkCards ? accent : ink)}</span></li>`).join('')}</ol>` : ''}
325
405
  ${!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>`}
406
+ ? `<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>`
407
+ : `<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>`}
328
408
  </body>`;
329
409
  };
330
410
  const animate = async (html, seconds, dir) => {
@@ -343,20 +423,49 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
343
423
  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
424
  if (in0) await animate(card(in0, in0.logoFile ?? path.join(cfg.work, 'intro-logo.png')), INTRO, path.join(cfg.work, 'intro'));
345
425
  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">
426
+ // three layers: the backdrop (a picture, or the page itself blurred), the window chrome (shadow and
427
+ // title bar), and the page with rounded bottom corners
428
+ 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>')}')`;
429
+ const STYLES = {
430
+ 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` },
431
+ studio: { dark: false, bg: `${grain}, radial-gradient(${W * 0.73}px ${H * 0.83}px at 50% 0%, #ffffff, #e9edf3 60%, #d6dce6)` },
432
+ 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})` },
433
+ pastel: { dark: false, bg: 'linear-gradient(135deg, #fde2e4 0%, #e2ece9 35%, #dfe7fd 70%, #f0e6ff 100%)' },
434
+ blur: { dark: true, bg: null },
435
+ };
436
+ const st = STYLES[cfg.frame.style], bg = cfg.frame.background ?? st.bg;
437
+ // an app window: the app's icon and name in the title bar instead of an address bar
438
+ let app = null;
439
+ if (cfg.frame.window === 'app') {
440
+ const MIME = { svg: 'image/svg+xml', ico: 'image/x-icon', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', png: 'image/png' };
441
+ const [file, type] = cfg.frame.iconFile ? [cfg.frame.iconFile, MIME[path.extname(cfg.frame.iconFile).slice(1).toLowerCase()] ?? 'image/png']
442
+ : tl.app?.icon ? [path.join(cfg.work, tl.app.icon.file), tl.app.icon.type] : [];
443
+ const icon = file && fs.existsSync(file) ? `data:${type};base64,${fs.readFileSync(file).toString('base64')}` : null;
444
+ app = { icon, title: cfg.frame.title ?? tl.app?.title ?? (() => { try { return new URL(cfg.url).host.replace(/^www\./, ''); } catch { return ''; } })() };
445
+ }
446
+ const R = Math.round(14 * u);
447
+ const dot = (col) => `<span style="width:${13 * u}px;height:${13 * u}px;border-radius:50%;background:${col};display:inline-block"></span>`;
448
+ const [barBg, barFg, pill, line] = st.dark ? ['#1f2430', '#9aa4b2', '#2b3140', '#11151d'] : ['#e8ebef', '#4b5563', '#fff', '#d5d9df'];
449
+ if (bg) await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:${bg}"></body>`, path.join(cfg.work, 'frame-bg.png'), false);
450
+ await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:transparent;${fontCss}">
451
+ <div style="position:absolute;left:${FX}px;top:${FY - BAR}px;width:${FW}px;height:${FH + BAR}px;border-radius:${R}px;background:#fff;box-shadow:0 ${30 * u}px ${80 * u}px rgba(0,0,0,${st.dark ? '.5' : '.25'}),0 0 0 1px rgba(255,255,255,.08)"></div>
452
+ <div style="position:absolute;left:${FX}px;top:${FY - BAR}px;width:${FW}px;height:${BAR}px;border-radius:${R}px ${R}px 0 0;background:${barBg};display:flex;align-items:center;border-bottom:1px solid ${line}">
353
453
  <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);
454
+ ${app ? `<div style="position:absolute;left:0;right:0;top:0;bottom:0;display:flex;align-items:center;justify-content:center;gap:${8 * u}px;font-size:${15 * u}px;font-weight:600;color:${barFg}">
455
+ ${app.icon ? `<img src="${app.icon}" style="width:${18 * u}px;height:${18 * u}px;object-fit:contain;border-radius:${4 * u}px">` : ''}<span style="max-width:${FW * 0.5}px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(app.title)}</span></div>` : ''}
456
+ </div></body>`, path.join(cfg.work, 'frame-chrome.png'), true);
457
+ // the address bar follows the page: one pill per address it shows
458
+ for (const [k, a] of addresses.entries()) {
459
+ await shot(`<body style="margin:0;width:${W}px;height:${H}px;background:transparent;${fontCss}">
460
+ <div style="position:absolute;left:${FX}px;top:${FY - BAR}px;width:${FW}px;height:${BAR}px;display:flex;align-items:center;justify-content:center">
461
+ <div style="font-size:${15 * u}px;color:${barFg};background:${pill};border-radius:${8 * u}px;padding:${4 * u}px ${60 * u}px;max-width:${FW * 0.5}px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(a.text)}</div>
462
+ </div></body>`, path.join(cfg.work, `frame-url${k}.png`), true);
463
+ }
464
+ // alpha mask for the page: rounded bottom corners (the title bar covers the top ones)
465
+ await pg.setViewportSize({ width: FW, height: FH });
466
+ await pg.setContent(`<body style="margin:0;width:${FW}px;height:${FH}px;background:#000"><div style="width:100%;height:100%;background:#fff;border-radius:0 0 ${R}px ${R}px"></div></body>`);
467
+ await pg.screenshot({ path: path.join(cfg.work, 'frame-mask.png') });
468
+ await pg.setViewportSize({ width: W, height: H });
360
469
  }
361
470
  await browser.close();
362
471
 
@@ -367,12 +476,29 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
367
476
  `:d=1:fps=${FPS}:s=${W}x${H},format=yuv420p${END ? `,tpad=stop_mode=clone:stop_duration=${END}` : ''}[v0];\n`;
368
477
  let last = 'v0', idx = 1;
369
478
  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;
479
+ const still = (f) => inputs.push('-loop', '1', '-t', `${TOTAL}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, f));
480
+ const blur = !cfg.frame.background && cfg.frame.style === 'blur';
481
+ let base;
482
+ if (blur) {
483
+ 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`;
484
+ base = 'fbg';
485
+ } else { still('frame-bg.png'); base = `${idx++}:v`; }
486
+ still('frame-chrome.png'); const chrome = idx++;
487
+ still('frame-mask.png'); const mask = idx++;
488
+ graph += `[${blur ? 'v0a' : 'v0'}]scale=${FW}:${FH}:flags=lanczos,format=rgba[pg0];[${mask}:v]format=gray,scale=${FW}:${FH}[pm];[pg0][pm]alphamerge[pg];\n` +
489
+ `[${base}][${chrome}:v]overlay=0:0[fc];[fc][pg]overlay=${FX}:${FY},format=yuv420p[vf];\n`;
490
+ last = 'vf';
491
+ for (const [k, a] of addresses.entries()) {
492
+ inputs.push('-loop', '1', '-t', `${(a.t1 - a.t0 + 0.1).toFixed(3)}`, '-framerate', `${FPS}`, '-i', path.join(cfg.work, `frame-url${k}.png`));
493
+ graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${a.t0.toFixed(3)}/TB[u${k}];[${last}][u${k}]overlay=0:0:eof_action=pass:enable='between(t,${a.t0.toFixed(3)},${a.t1.toFixed(3)})'[vu${k}];\n`;
494
+ last = `vu${k}`; idx++;
495
+ }
374
496
  }
375
- for (const [i, c] of captions.entries()) {
497
+ // docs: a second, caption-free copy for the per-step clips and stills (the article carries the
498
+ // text), so crops never have to dodge a caption
499
+ const clean = cfg.clips.length ? path.join(cfg.work, 'video-clean.mp4') : null;
500
+ if (clean) { graph += `[${last}]split[vmain][vclean0];\n`; last = 'vmain'; }
501
+ for (const [i, c] of (cfg.captions ? captions : []).entries()) {
376
502
  const a = c.t[0] + 0.1, b = c.t[1] - 0.1;
377
503
  // each caption is an input only as long as it is on screen (decoding a looped PNG for the whole
378
504
  // video, once per caption, made long tutorials take minutes to compose)
@@ -383,7 +509,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
383
509
  }
384
510
  for (const [k, c] of cards.entries()) {
385
511
  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` +
512
+ graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${c.t0.toFixed(3)}/TB,fade=in:st=${c.t0.toFixed(3)}:d=0.15:alpha=1,fade=out:st=${Math.max(c.t0, c.t1 - 0.15).toFixed(3)}:d=0.15:alpha=1[sc${k}];\n` +
387
513
  `[${last}][sc${k}]overlay=0:0:eof_action=pass:enable='between(t,${c.t0.toFixed(3)},${c.t1.toFixed(3)})'[v${++idx}];\n`;
388
514
  last = `v${idx}`;
389
515
  }
@@ -396,11 +522,12 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
396
522
  }
397
523
  if (in0) {
398
524
  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` +
525
+ graph += `[${idx}:v]format=rgba,fade=out:st=${Math.max(0, INTRO - 0.2).toFixed(3)}:d=0.2:alpha=1[in];\n` +
400
526
  `[${last}][in]overlay=0:0:eof_action=pass[v${++idx}];\n`;
401
527
  last = `v${idx}`;
402
528
  }
403
529
  graph += `[${last}]trim=duration=${TOTAL},format=yuv420p[out]`;
530
+ if (clean) graph += `;\n[vclean0]trim=duration=${TOTAL},format=yuv420p[clean]`;
404
531
  const graphFile = path.join(cfg.work, 'graph.txt');
405
532
  fs.writeFileSync(graphFile, graph);
406
533
  const silent = path.join(cfg.work, 'video-silent.mp4');
@@ -409,7 +536,8 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
409
536
  const major = Number((execFileSync('ffmpeg', ['-version']).toString().match(/ffmpeg version n?(\d+)/) ?? [])[1] ?? 7);
410
537
  const graphArgs = major >= 7 ? ['-/filter_complex', graphFile] : ['-filter_complex_script', graphFile];
411
538
  execFileSync('ffmpeg', ['-y', '-loglevel', 'error', ...inputs, ...graphArgs, '-map', '[out]',
412
- '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', `${FPS}`, silent]);
539
+ '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', `${FPS}`, silent,
540
+ ...(clean ? ['-map', '[clean]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', `${FPS}`, clean] : [])]);
413
541
 
414
542
  // --- 6. preview contact sheet: one frame per step (at its zoom peak) + end card ------------
415
543
  // zoomed steps: at the zoom peak; others: late in the step, where its actions' result is visible
@@ -441,10 +569,13 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
441
569
  endCard: ec ? VIDEO : null,
442
570
  clicks: tl.clicks.map(toOut).filter((t) => t != null),
443
571
  whooshes: dedupe([
444
- ...tl.navs.map(toOut).filter((t) => t != null && t > 0.2),
572
+ // whoosh on deliberate scene changes (goto) only: a page the app opens after a click, or a
573
+ // results page reloading itself while you wait, is not a scene change
574
+ ...(tl.gotos ?? tl.navs.filter((n) => !tl.clicks.some((c) => n - c >= 0 && n - c < 2) && !tl.steps.some((s) => (s.waits ?? []).some(([a, b]) => n >= a - 0.5 && n <= b + 0.5))))
575
+ .map(toOut).filter((t) => t != null && t > 0.2),
445
576
  ...segs.filter((g) => g.afterHidden).map((g) => g.o0),
446
- ...(in0 ? [INTRO - 0.3] : []),
447
- ...cards.flatMap((c) => [c.t0, c.t1 - 0.3]),
577
+ ...(in0 ? [INTRO - 0.2] : []),
578
+ ...cards.flatMap((c) => [c.t0, c.t1 - 0.15]),
448
579
  ...(ec ? [VIDEO - 0.25] : []),
449
580
  ]),
450
581
  risers: segs.filter((g) => g.step.sfx === 'riser').map((g) => [g.o0, g.o1]),
@@ -472,8 +603,8 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
472
603
  let w = Math.max(x1 - x0 + 2 * pad, W * 0.5), h = Math.max(y1 - y0 + 2 * pad, H * 0.5);
473
604
  if (w / h > W / H) h = w * H / W; else w = h * W / H;
474
605
  w = Math.min(W, w); h = Math.min(H, h);
475
- const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
606
+ let cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
476
607
  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
608
  }
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 })) };
609
+ 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 })) };
479
610
  }
package/src/config.mjs CHANGED
@@ -4,6 +4,17 @@ import path from 'node:path';
4
4
  import YAML from 'yaml';
5
5
  import { VOICE_DEFAULTS } from './voice.mjs';
6
6
 
7
+ // Built-in labels drawn into the video, by `lang` (e.g. es, es-MX); other languages get English
8
+ export const LABELS = {
9
+ en: { think: 'Pause and think', intro: 'Introduction', downloaded: 'Downloaded' },
10
+ zh: { think: '先想一想', intro: '开场', downloaded: '已下载' },
11
+ es: { think: 'Piénsalo un momento', intro: 'Introducción', downloaded: 'Descargado' },
12
+ pt: { think: 'Pense um pouco', intro: 'Introdução', downloaded: 'Baixado' },
13
+ fr: { think: 'Réfléchissez', intro: 'Introduction', downloaded: 'Téléchargé' },
14
+ de: { think: 'Kurz nachdenken', intro: 'Einführung', downloaded: 'Heruntergeladen' },
15
+ ja: { think: '考えてみよう', intro: 'はじめに', downloaded: 'ダウンロード済み' },
16
+ };
17
+
7
18
  // Load API keys (ELEVENLABS_API_KEY, OPENAI_API_KEY) from .env next to the storyboard
8
19
  // and in the working directory; variables already set in the environment win.
9
20
  export function loadEnv(dir) {
@@ -19,9 +30,9 @@ export function loadEnv(dir) {
19
30
 
20
31
  // What the video is for sets sensible defaults; anything in the YAML overrides them.
21
32
  export const PURPOSES = {
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 },
33
+ 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 },
34
+ 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 },
35
+ course: { duration: 'auto', music: false, sfx: true, highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
25
36
  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
37
  };
27
38
 
@@ -36,6 +47,8 @@ export const PACES = {
36
47
  slow: { cursor: 1200, type: 95, beat: 800, tail: 1.6, maxWait: 3.0, voiceSpeed: 0.92 },
37
48
  };
38
49
 
50
+ export const FRAME_STYLES = ['aurora', 'studio', 'dark', 'pastel', 'blur'];
51
+
39
52
  export const VIDEO_DIR = 'videos';
40
53
  export const KINDS = { pitch: 'promo', tutorial: 'tutorial', course: 'lesson', docs: 'docs' };
41
54
  // pxgenius.ai → pxgenius; an IP or a port is kept whole so two internal apps don't share a name
@@ -97,7 +110,8 @@ export function loadConfig(file, { draft = false } = {}) {
97
110
  });
98
111
 
99
112
  let endCard = raw.end_card === false ? null : raw.end_card ?? null;
100
- if (endCard) endCard = { duration: endCard.say ? 'auto' : 1.0, ...endCard };
113
+ // auto: as long as its narration, bullets and call to action need, and never under 2.5s
114
+ if (endCard) endCard = { duration: 'auto', ...endCard };
101
115
  // An intro title card (promos; optional for tutorials and lessons). Same fields as the end card.
102
116
  let intro = raw.intro === false ? null : raw.intro ?? null;
103
117
  if (intro) intro = { duration: intro.say ? 'auto' : 2.2, ...intro };
@@ -110,7 +124,15 @@ export function loadConfig(file, { draft = false } = {}) {
110
124
  // follow: the camera zooms in on what each step touches and pans from step to step (tutorials)
111
125
  const follow0 = pick('follow');
112
126
  const follow = follow0 ? { scale: 1.8, ...(typeof follow0 === 'object' ? follow0 : {}) } : null;
113
- const frame = frame0 ? { background: null, ...(typeof frame0 === 'object' ? frame0 : {}) } : null;
127
+ // frame: false | true | a style name | { style, background: <css>, window, title, icon }. Styles: aurora
128
+ // (dark, grain), studio (light, grain), dark, pastel, blur (the page itself, blurred, behind the window).
129
+ // window: browser (an address bar) or app (an installed web app / desktop app: its icon and name in the
130
+ // title bar, no address bar; both are read from the page's web app manifest unless given)
131
+ const styleOf = (f) => (typeof f === 'string' ? f : f?.style) ?? (typeof P.frame === 'string' ? P.frame : 'aurora');
132
+ const frame = frame0 ? { background: null, window: 'browser', ...(typeof frame0 === 'object' ? frame0 : {}), style: styleOf(frame0) } : null;
133
+ if (frame && !FRAME_STYLES.includes(frame.style)) throw new Error(`frame style must be one of ${FRAME_STYLES.join(', ')}`);
134
+ if (frame && !['browser', 'app'].includes(frame.window)) throw new Error('frame.window must be browser or app');
135
+ if (frame?.icon) frame.iconFile = path.resolve(dir, frame.icon);
114
136
 
115
137
  let voice = null;
116
138
  if (raw.voice) {
@@ -155,6 +177,14 @@ export function loadConfig(file, { draft = false } = {}) {
155
177
  steps,
156
178
  endCard,
157
179
  intro,
180
+ article: raw.article ?? null,
181
+ // captions: false keeps the caption pills off the video (chapters and SRT still use them),
182
+ // e.g. when burned-in subtitles already carry the words
183
+ captions: raw.captions !== false,
184
+ cards: raw.cards ?? 'auto',
185
+ // language of the built-in labels ("Pause and think", "Introduction"): `lang`, else guessed from the captions
186
+ lang: raw.lang ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? 'zh' : 'en'),
187
+ labels: LABELS[String(raw.lang ?? '').toLowerCase().split(/[-_]/)[0]] ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? LABELS.zh : LABELS.en),
158
188
  frame,
159
189
  follow,
160
190
  music,
package/src/inspect.mjs CHANGED
@@ -67,10 +67,12 @@ function collect() {
67
67
  clean(el.closest('label')?.innerText) || '';
68
68
  // a label's own text, without the links/buttons inside it (e.g. a "Clear" link)
69
69
  const labelText = (lab) => { if (!lab) return null; const c = lab.cloneNode(true); c.querySelectorAll('a,button,select,input,textarea').forEach((x) => x.remove()); return clean(c.innerText) || null; };
70
+ // styled checkboxes and radios hide the real input and draw their label instead: list them where the label is
71
+ const shownBy = (el) => (el.tagName === 'INPUT' && ['checkbox', 'radio'].includes(el.type) && !vis(el) ? [...(el.labels ?? [])].find(vis) : null);
70
72
  const interactive = [...document.querySelectorAll(NATIVE)]
71
- .filter((el) => vis(el) && el.tagName !== 'LABEL' && !isAd(el) && !(el.isContentEditable && el.parentElement?.isContentEditable))
73
+ .filter((el) => (vis(el) || shownBy(el)) && el.tagName !== 'LABEL' && !isAd(el) && !(el.isContentEditable && el.parentElement?.isContentEditable))
72
74
  .map((el) => ({ kind: 'role', role: roleOf(el), name: (el.isContentEditable || el.tagName === 'TEXTAREA' ? editorName(el) : nameOf(el)).slice(0, 80), shows: clean(el.innerText).slice(0, 60), alt: clean(el.querySelector('img[alt]')?.alt) || null, href: el.getAttribute('href'), label: labelText(el.labels?.[0]),
73
- options: el.tagName === 'SELECT' ? [...el.options].map((o) => ({ text: clean(o.text), on: o.selected })).filter((o) => o.text).slice(0, 12) : null, ...pos(el) }));
75
+ options: el.tagName === 'SELECT' ? [...el.options].map((o) => ({ text: clean(o.text), on: o.selected })).filter((o) => o.text).slice(0, 12) : null, ...pos(shownBy(el) ?? el) }));
74
76
  // custom clickables: outermost cursor:pointer elements that aren't native controls
75
77
  const pointer = [];
76
78
  for (const el of document.querySelectorAll('body *')) {
@@ -118,8 +120,10 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
118
120
  seen.add(key);
119
121
  let target = null;
120
122
  if (el.kind === 'role') {
123
+ // sliders, inputs and dropdowns: their label is stable, their value (often their name) is not
124
+ 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);
121
125
  // our name guess can differ from the browser's accessible name, so fall back gracefully
122
- if (el.name) target = await verify(page.getByRole(el.role, { name: el.name, exact: true }), { role: el.role, name: el.name, exact: true }, el);
126
+ 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);
123
127
  if (!target && el.alt) target = await verify(page.getByAltText(el.alt, { exact: true }), { alt: el.alt, exact: true }, el);
124
128
  if (!target && el.name) target = await verify(page.getByRole(el.role, { name: el.name }), { role: el.role, name: el.name }, el);
125
129
  if (!target && el.name) target = await verify(page.getByText(el.name, { exact: true }), { text: el.name, exact: true }, el);
@@ -156,7 +160,7 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
156
160
  '',
157
161
  '## Clickable elements — verified targets (each matches exactly one visible element) for click/move/zoom',
158
162
  ' Copy targets exactly; `name` is the accessible name, which may differ from the on-screen label.',
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)' : ''}`),
163
+ ...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)' : ''}`),
160
164
  '',
161
165
  '## Images with alt text (target: { alt: "..." })',
162
166
  ...(info.images.length ? info.images.map((i) => `${fold(i.y)} y=${i.y}\t"${i.alt}"`) : ['(none)']),