retake-demos 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/render.js CHANGED
@@ -16,11 +16,16 @@ import fs from "node:fs";
16
16
  import os from "node:os";
17
17
  import path from "node:path";
18
18
  import { createHash } from "node:crypto";
19
- import { execFileSync, spawnSync } from "node:child_process";
20
- import { capSecondsFor } from "./record.js";
19
+ import YAML from "yaml";
20
+ import { execFile, execFileSync, spawnSync } from "node:child_process";
21
+ import { capSecondsFor, restorePrevious } from "./record.js";
22
+ import { bandHeightFor, maxCharsFor, wrap } from "./captions.js";
23
+ import { renderCard, renderCalloutOverlay, renderTitledCover } from "./cards.js";
24
+ import { describeIdle, planIdle, warpFilterArgs, warpTake } from "./pace.js";
25
+ import { DEFAULT_VOICE, audioSeconds, synthesize } from "./voice.js";
21
26
  import { createRequire } from "node:module";
22
27
  import { chromium } from "playwright";
23
- import { resolve } from "./manifest.js";
28
+ import { expandEnv, resolve } from "./manifest.js";
24
29
  /** What the render depends on: the raw take + everything in the manifest that
25
30
  shapes the picture. Same hash → same output → skip. */
26
31
  export function renderHash(m, take) {
@@ -28,7 +33,14 @@ export function renderHash(m, take) {
28
33
  const src = take.video && fs.existsSync(take.video) ? fs.statSync(take.video) : null;
29
34
  const h = createHash("sha1");
30
35
  const merged = applyManifest(m, take);
31
- h.update(JSON.stringify({ q, tempo: m.tempo, cap: m.captions, theme: m.theme, camera: m.camera, gif: m.outputs.gif, thumb: m.outputs.thumbnail, stills: m.outputs.stills, video: src ? [src.size, Math.round(src.mtimeMs)] : null, tl: merged.timeline.map((t) => [t.start, t.end, t.label, t.caption, t.holdMs, t.camera]), trim: [merged.trimBefore, merged.duration] }));
36
+ h.update(JSON.stringify({ q, tempo: m.tempo, cap: m.captions, theme: m.theme, camera: m.camera, cards: [m.intro, m.outro], idle: m.compressIdle, vo: m.voiceover, music: m.music ? [m.music, (() => { try {
37
+ const f = typeof m.music === "string" ? m.music : m.music.file;
38
+ const st = fs.statSync(path.resolve(f));
39
+ return [st.size, Math.round(st.mtimeMs)];
40
+ }
41
+ catch {
42
+ return null;
43
+ } })()] : null, gif: m.outputs.gif, thumb: m.outputs.thumbnail, stills: m.outputs.stills, video: src ? [src.size, Math.round(src.mtimeMs)] : null, tl: merged.timeline.map((t) => [t.start, t.end, t.label, t.caption, t.holdMs, t.camera, t.callout]), trim: [merged.trimBefore, merged.duration] }));
32
44
  return h.digest("hex").slice(0, 12);
33
45
  }
34
46
  const require = createRequire(import.meta.url);
@@ -79,38 +91,6 @@ export function probe(file) {
79
91
  };
80
92
  }
81
93
  export const videoDuration = (file) => probe(file).duration;
82
- /** Word-wrap long captions; two-line captions break near the middle so no
83
- word is left orphaned on the second line. */
84
- function wrap(text, max) {
85
- if (text.length <= max)
86
- return text;
87
- const words = text.split(/\s+/);
88
- if (text.length <= max * 2) {
89
- let best = 1, bestDiff = Infinity;
90
- for (let i = 1; i < words.length; i++) {
91
- const a = words.slice(0, i).join(" ").length;
92
- const b = words.slice(i).join(" ").length;
93
- if (a <= max && b <= max && Math.abs(a - b) < bestDiff) {
94
- best = i;
95
- bestDiff = Math.abs(a - b);
96
- }
97
- }
98
- return words.slice(0, best).join(" ") + "\n" + words.slice(best).join(" ");
99
- }
100
- const out = [];
101
- let line = "";
102
- for (const w of words) {
103
- if (line && (line + " " + w).length > max) {
104
- out.push(line);
105
- line = w;
106
- }
107
- else
108
- line = line ? line + " " + w : w;
109
- }
110
- if (line)
111
- out.push(line);
112
- return out.join("\n");
113
- }
114
94
  /** ffmpeg drawtext needs a few characters escaped inside the filter graph. */
115
95
  function esc(s) {
116
96
  return s.replace(/\\/g, "\\\\").replace(/'/g, "\u2019").replace(/:/g, "\\:").replace(/%/g, "\\%").replace(/,/g, "\\,");
@@ -148,6 +128,45 @@ export function applyManifest(m, take) {
148
128
  const duration = Math.max(trimBefore + 1, take.duration - tail);
149
129
  return { ...take, timeline, trimBefore, duration };
150
130
  }
131
+ /** Apply the manifest's render-time scene nudges to a take.
132
+ *
133
+ * Scene markers come out of the recording, so getting one in the wrong place
134
+ * used to mean recording the whole thing again — with a live model in the
135
+ * middle of it, at 2.3x the video's length. A nudge moves the marker instead,
136
+ * and everything downstream (captions, stills, the thumbnail) reads scene
137
+ * times through here, so all of it moves together for the price of a render.
138
+ *
139
+ * Order is preserved by clamping: a scene can never be pushed past its
140
+ * neighbours, because a timeline that crosses over itself would produce
141
+ * captions in the wrong sequence rather than an error anyone could see.
142
+ */
143
+ export function nudgeScenes(take, m) {
144
+ const wanted = new Map();
145
+ for (const st of m?.steps ?? []) {
146
+ if (st.action === "scene" && st.nudge)
147
+ wanted.set(st.label, st.nudge);
148
+ }
149
+ if (!wanted.size)
150
+ return { take, applied: [] };
151
+ const applied = [];
152
+ const GAP = 0.1; // scenes stay strictly ordered
153
+ const sceneIdx = take.timeline.map((t, i) => (t.action === "scene" ? i : -1)).filter((i) => i >= 0);
154
+ const timeline = take.timeline.map((t) => ({ ...t }));
155
+ for (const [n, i] of sceneIdx.entries()) {
156
+ const want = wanted.get(timeline[i].label ?? "");
157
+ if (!want)
158
+ continue;
159
+ const prev = n > 0 ? timeline[sceneIdx[n - 1]].start + GAP : 0;
160
+ const next = n < sceneIdx.length - 1 ? timeline[sceneIdx[n + 1]].start - GAP : take.duration;
161
+ const target = timeline[i].start + want / 1000;
162
+ const to = Math.min(Math.max(target, prev), Math.max(prev, next));
163
+ const span = timeline[i].end - timeline[i].start;
164
+ applied.push({ label: timeline[i].label ?? "?", ms: want, clamped: Math.abs(to - target) > 0.01 });
165
+ timeline[i].start = to;
166
+ timeline[i].end = to + span;
167
+ }
168
+ return { take: { ...take, timeline }, applied };
169
+ }
151
170
  export function scenes(take) {
152
171
  return take.timeline.filter((t) => t.action === "scene");
153
172
  }
@@ -265,12 +284,31 @@ async function renderCardFrame(file, W, H, g, bg) {
265
284
  }
266
285
  }
267
286
  // --- render ------------------------------------------------------------------
268
- export async function render(m, take, outDir, opts = {}) {
287
+ export async function render(m, takeIn, outDir, opts = {}) {
288
+ let take = takeIn;
269
289
  const o = typeof opts === "function" ? { log: opts } : opts;
270
290
  const log = o.log;
271
291
  const q = resolve(m);
292
+ // Before anything reads a scene time: markers move at render, so captions,
293
+ // stills and the thumbnail all shift together and none of it costs a take.
294
+ {
295
+ const n = nudgeScenes(take, m);
296
+ take = n.take;
297
+ for (const a of n.applied) {
298
+ log?.(`scene "${a.label}" moved ${a.ms > 0 ? "+" : ""}${(a.ms / 1000).toFixed(1)}s${a.clamped ? " (clamped — it would have crossed a neighbouring scene)" : ""}`);
299
+ }
300
+ }
272
301
  const proofLog = path.join(outDir, "proof-log.md");
273
302
  const writeProof = (a) => fs.writeFileSync(proofLog, renderProof(m, take, q, a));
303
+ // An output directory that cannot say what produced it makes an audit
304
+ // guesswork: the manifest on disk today is not necessarily the one this
305
+ // was rendered from. Keep a copy beside the take.
306
+ if (!o.scene) {
307
+ try {
308
+ fs.writeFileSync(path.join(outDir, "manifest.used.yaml"), YAML.stringify(m));
309
+ }
310
+ catch { /* not fatal */ }
311
+ }
274
312
  if (!o.scene)
275
313
  writeProof({});
276
314
  if (!take.video)
@@ -296,6 +334,21 @@ export async function render(m, take, outDir, opts = {}) {
296
334
  }
297
335
  // Captions, holds, camera zooms and trim come from the manifest as it is now.
298
336
  take = applyManifest(m, take);
337
+ // Idle compression: re-cut the source so the app's dead waits are shown
338
+ // short, and remap every clock in the take through the same warp.
339
+ let idleLines = [];
340
+ if (m.compressIdle && take.video) {
341
+ const keep = m.compressIdle === true ? 1.5 : m.compressIdle.keepSeconds;
342
+ const segs = planIdle(take, keep);
343
+ if (segs.length) {
344
+ const paced = path.join(outDir, ".paced.mp4");
345
+ ff([...warpFilterArgs(take.video, segs, take.duration, paced).slice(0, -1), "-r", String(resolve(m).fps), "-c:v", "libx264", "-crf", "14", "-preset", "veryfast", "-pix_fmt", "yuv420p", paced], log);
346
+ idleLines = describeIdle(take, segs);
347
+ for (const l of idleLines)
348
+ log?.(`idle: ${l}`);
349
+ take = { ...warpTake(take, segs), video: paced };
350
+ }
351
+ }
299
352
  // Tempo: the source is cut in its own time (-ss/-t below), then every
300
353
  // clock downstream — captions, camera keys, stills — runs in output time.
301
354
  const srcTake = take;
@@ -316,15 +369,18 @@ export async function render(m, take, outDir, opts = {}) {
316
369
  const to = sc[i + 1] ? sc[i + 1].start : take.duration;
317
370
  take = { ...take, trimBefore: from, duration: to };
318
371
  }
319
- const src = path.resolve(srcVideo);
372
+ const src = path.resolve(take.video ?? srcVideo); // take.video may be the idle-warped re-cut
320
373
  // Source (recorded viewport) vs canvas (preset output). They differ when a
321
374
  // narrow app was recorded at its own aspect and gets centred on the canvas.
322
375
  const SW = take.quality?.width ?? q.viewport.width;
323
376
  const SH = take.quality?.height ?? q.viewport.height;
324
377
  // band/overlay/none: the canvas IS the recording (site fills the frame).
325
378
  // card: the preset canvas, with the recording framed inside it.
326
- const W = q.layout === "card" ? q.width : SW;
327
- const H = q.layout === "card" ? q.height : SH;
379
+ // The finished frame is ALWAYS the preset's canvas. Whatever page size the
380
+ // take was recorded at gets fitted into it below — so a size change is a
381
+ // re-render, never a re-record, and one preset always means one shape.
382
+ const W = q.width;
383
+ const H = q.height;
328
384
  const fps = q.fps;
329
385
  const bg = q.theme.background;
330
386
  const ink = q.theme.ink;
@@ -344,13 +400,22 @@ export async function render(m, take, outDir, opts = {}) {
344
400
  let frameInput = null;
345
401
  let geom = null;
346
402
  const capSize = q.captions ? q.captions.fontSize : 0;
403
+ // One band height for the whole take, from its captions (see captions.ts).
404
+ const bandH = bandHeightFor(q, captionWindows(take).map((w) => w.text));
347
405
  if (layout === "band") {
348
- // Site fills the frame; caption strip below it.
349
- filters.push(`pad=${W}:${H + q.bandHeight}:0:0:color=${bg}`);
350
- capY = `${H}+${q.bandHeight / 2}-th/2`;
406
+ // Site fills the frame; caption strip below it — none at all if captions are off.
407
+ // Fit the recording into the page area, letterboxing on the theme colour
408
+ // rather than black, then the caption strip underneath. A take recorded
409
+ // at 1440×1080 becomes a true 1920×1080, a 1080×1920, whatever is asked.
410
+ const pageH = H - bandH;
411
+ filters.push(`scale=${W}:${pageH}:force_original_aspect_ratio=decrease:flags=lanczos`, `pad=${W}:${pageH}:(ow-iw)/2:(oh-ih)/2:color=${bg}`);
412
+ if (bandH) {
413
+ filters.push(`pad=${W}:${H}:0:0:color=${bg}`);
414
+ capY = `${pageH}+${bandH / 2}-th/2`;
415
+ }
351
416
  }
352
417
  else if (layout === "card") {
353
- geom = cardGeometry(W, H, SW, SH, q.bandHeight);
418
+ geom = cardGeometry(W, H, SW, SH, bandH);
354
419
  frameInput = path.join(outDir, ".frame.png");
355
420
  await renderCardFrame(frameInput, W, H, geom, bg);
356
421
  filters.push(`scale=${geom.vw}:${geom.vh}:flags=lanczos`, `pad=${W}:${H}:${geom.vx}:${geom.vy}:color=${bg}`);
@@ -361,19 +426,46 @@ export async function render(m, take, outDir, opts = {}) {
361
426
  capColor = q.captions ? q.captions.color ?? "white" : "white";
362
427
  capBox = ":box=1:boxcolor=black@0.55:boxborderw=18";
363
428
  }
364
- filters.push("format=yuv420p");
429
+ // Square pixels, explicitly. Playwright's webm arrives with a SAR of
430
+ // 1216:1215 — a half-percent stretch nobody notices until concat refuses
431
+ // to join it to a title card that is honestly 1:1.
432
+ filters.push("setsar=1", "format=yuv420p");
365
433
  const captionFilters = [];
366
434
  if (q.captions && layout !== "none") {
367
- const maxChars = Math.max(28, Math.round((W / capSize) * 1.55));
435
+ const maxChars = maxCharsFor(W, capSize);
368
436
  for (const w of captionWindows(take)) {
369
437
  captionFilters.push(`drawtext=${font}text='${esc(wrap(w.text, maxChars))}':fontsize=${capSize}:fontcolor=${capColor}${capBox}:line_spacing=${Math.round(capSize * 0.25)}` +
370
438
  `:x=(w-tw)/2:y=${capY}:enable='between(t,${w.from.toFixed(2)},${w.to.toFixed(2)})'`);
371
439
  }
372
440
  }
373
- const graph = frameInput
374
- ? `[0:v]${filters.join(",")}[v];[v][1:v]overlay=0:0:format=auto${captionFilters.length ? "," + captionFilters.join(",") : ""}[out]`
375
- : `[0:v]${[...filters, ...captionFilters].join(",")}[out]`;
376
- const inputs = ["-ss", srcTake.trimBefore.toFixed(3), "-t", (srcTake.duration - srcTake.trimBefore).toFixed(3), "-i", src, ...(frameInput ? ["-i", frameInput] : [])];
441
+ // Callouts: recorded boxes become animated overlays, applied in SOURCE
442
+ // coordinates (before the camera filter) so a moving camera carries them.
443
+ const callouts = take.timeline.filter((e) => e.callout && e.ok);
444
+ const calloutInputs = [];
445
+ let preCam = "";
446
+ if (callouts.length && !o.scene && !frameInput) {
447
+ const camIdx = cam.filter ? filters.indexOf(cam.filter) : filters.length;
448
+ const before = filters.slice(0, camIdx), after = filters.slice(camIdx);
449
+ let label = "b0";
450
+ let g = `[0:v]${before.join(",")}[${label}]`;
451
+ for (let i = 0; i < callouts.length; i++) {
452
+ const e = callouts[i];
453
+ const from = Math.max(0, e.start - take.trimBefore);
454
+ const webm = await renderCalloutOverlay(ffmpegBin(), outDir, i, e.callout, SW, SH, fps, Math.max(15, Math.round(q.captions ? q.captions.fontSize * 0.55 : 20)), ink);
455
+ calloutInputs.push(webm);
456
+ const inIdx = 1 + (frameInput ? 1 : 0) + i;
457
+ const next = `b${i + 1}`;
458
+ g += `;[${inIdx}:v]setpts=PTS+${from.toFixed(3)}/TB[c${i}];[${label}][c${i}]overlay=0:0:eof_action=pass[${next}]`;
459
+ label = next;
460
+ }
461
+ preCam = `${g};[${label}]${after.length ? after.join(",") : "null"}`;
462
+ }
463
+ const graph = preCam
464
+ ? `${preCam}${captionFilters.length ? "," + captionFilters.join(",") : ""}[out]`
465
+ : frameInput
466
+ ? `[0:v]${filters.join(",")}[v];[v][1:v]overlay=0:0:format=auto${captionFilters.length ? "," + captionFilters.join(",") : ""}[out]`
467
+ : `[0:v]${[...filters, ...captionFilters].join(",")}[out]`;
468
+ const inputs = ["-ss", srcTake.trimBefore.toFixed(3), "-t", (srcTake.duration - srcTake.trimBefore).toFixed(3), "-i", src, ...(frameInput ? ["-i", frameInput] : []), ...calloutInputs.flatMap((f) => ["-c:v", "libvpx-vp9", "-i", f])];
377
469
  const compose = ["-filter_complex", graph, "-map", "[out]", "-r", String(fps), "-an", "-pix_fmt", "yuv420p", "-movflags", "+faststart"];
378
470
  const x264 = (crf, preset) => ["-c:v", "libx264", "-crf", String(crf), "-preset", preset];
379
471
  // Hardware encode is a macOS thing; elsewhere the "fast" presets are a fast x264.
@@ -401,6 +493,98 @@ export async function render(m, take, outDir, opts = {}) {
401
493
  }
402
494
  if (frameInput)
403
495
  fs.rmSync(frameInput, { force: true });
496
+ for (const f of calloutInputs)
497
+ fs.rmSync(f, { force: true });
498
+ // Title cards: rendered at the finished frame size, spliced on. The intro's
499
+ // settled frame becomes cover.png — the poster.
500
+ let cover;
501
+ if ((m.intro || m.outro) && !o.scene) {
502
+ const dims = probe(mp4);
503
+ const segs = [];
504
+ if (m.intro) {
505
+ const c = await renderCard(ffmpegBin(), outDir, "intro", m.intro, dims.width, dims.height, fps, q.theme);
506
+ segs.push(c.mp4);
507
+ cover = c.cover;
508
+ }
509
+ segs.push(mp4);
510
+ if (m.outro)
511
+ segs.push((await renderCard(ffmpegBin(), outDir, "outro", m.outro, dims.width, dims.height, fps, q.theme)).mp4);
512
+ if (segs.length > 1) {
513
+ const joined = path.join(outDir, ".joined.mp4");
514
+ ff([...segs.flatMap((f) => ["-i", f]), "-filter_complex", `${segs.map((_, i) => `[${i}:v]`).join("")}concat=n=${segs.length}:v=1:a=0[out]`, "-map", "[out]", "-r", String(fps), "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-an", ...x264(q.crf, "medium"), joined], log);
515
+ fs.renameSync(joined, mp4);
516
+ for (const f of segs)
517
+ if (f !== mp4)
518
+ fs.rmSync(f, { force: true });
519
+ }
520
+ mark("cards");
521
+ }
522
+ // Audio: the music bed and/or the captions read aloud, mixed in one pass.
523
+ // The deliverable gets it; the master stays a clean video-only source.
524
+ if ((m.music || m.voiceover) && !o.scene) {
525
+ const dur = probe(mp4).duration;
526
+ // Cards shift every scene: narration is placed in FINAL time.
527
+ const introShift = m.intro ? m.intro.ms / 1000 : 0;
528
+ const audioIn = [];
529
+ const chains = [];
530
+ const mixIns = [];
531
+ let idx = 1; // 0 is the video
532
+ if (m.music) {
533
+ const spec = typeof m.music === "string" ? { file: m.music, gainDb: -14, fadeOutMs: 1800 } : m.music;
534
+ const musicFile = path.resolve(expandEnv(spec.file));
535
+ if (!fs.existsSync(musicFile))
536
+ throw new Error(`music: no such file ${musicFile} — the track ships inside the video, so it must be one the person may use (CC0 or licensed)`);
537
+ const fadeStart = Math.max(0, dur - spec.fadeOutMs / 1000);
538
+ // Ducked a step further when a voice speaks over it.
539
+ const gain = spec.gainDb - (m.voiceover ? 7 : 0);
540
+ audioIn.push("-stream_loop", "-1", "-i", musicFile);
541
+ chains.push(`[${idx}:a]volume=${gain}dB,afade=t=in:st=0:d=0.6,afade=t=out:st=${fadeStart.toFixed(2)}:d=${(spec.fadeOutMs / 1000).toFixed(2)}[mus]`);
542
+ mixIns.push("[mus]");
543
+ idx++;
544
+ }
545
+ if (m.voiceover) {
546
+ const spec = m.voiceover === true ? { voice: DEFAULT_VOICE, gainDb: 0, script: undefined, fragments: true } : m.voiceover;
547
+ if (spec.script) {
548
+ // One script, one performance: prosody carries across the whole
549
+ // video the way a person reads. Starts after the title card.
550
+ const clip = path.join(outDir, ".vo-0.mp3");
551
+ synthesize(spec.script, spec.voice, clip);
552
+ const spoken = audioSeconds(ffmpegBin(), clip);
553
+ if (spoken > dur - introShift + 0.5)
554
+ log?.(`voiceover: the script reads ${spoken.toFixed(1)}s but there are ${(dur - introShift).toFixed(1)}s of video after the title card — cut the script or lengthen the scenes, then re-render`);
555
+ audioIn.push("-i", clip);
556
+ chains.push(`[${idx}:a]volume=${spec.gainDb}dB,adelay=${Math.round(introShift * 1000)}:all=1[vo0]`);
557
+ mixIns.push("[vo0]");
558
+ idx++;
559
+ }
560
+ else {
561
+ // Explicitly-asked-for fragments: one clip per caption. Known to
562
+ // reset prosody at every line; the schema refuses it by default.
563
+ const wins = captionWindows(take);
564
+ for (let i = 0; i < wins.length; i++) {
565
+ const w = wins[i];
566
+ const clip = path.join(outDir, `.vo-${i}.mp3`);
567
+ synthesize(w.text, spec.voice, clip);
568
+ const spoken = audioSeconds(ffmpegBin(), clip);
569
+ const at = w.from + introShift;
570
+ if (spoken > w.to - w.from + 0.4)
571
+ log?.(`voiceover: “${w.text.slice(0, 40)}…” runs ${spoken.toFixed(1)}s but its scene holds ${(w.to - w.from).toFixed(1)}s — lengthen the scene's holdMs or trailing wait and re-render`);
572
+ audioIn.push("-i", clip);
573
+ chains.push(`[${idx}:a]volume=${spec.gainDb}dB,adelay=${Math.round(at * 1000)}:all=1[vo${i}]`);
574
+ mixIns.push(`[vo${i}]`);
575
+ idx++;
576
+ }
577
+ }
578
+ }
579
+ const mixed = path.join(outDir, ".with-audio.mp4");
580
+ const graph = `${chains.join(";")};${mixIns.join("")}amix=inputs=${mixIns.length}:duration=longest:normalize=0,atrim=0:${dur.toFixed(2)}[a]`;
581
+ ff(["-i", mp4, ...audioIn, "-filter_complex", graph, "-map", "0:v", "-map", "[a]", "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", "-shortest", mixed], log);
582
+ fs.renameSync(mixed, mp4);
583
+ for (const f of fs.readdirSync(outDir))
584
+ if (/^\.vo-\d+\.mp3$/.test(f))
585
+ fs.rmSync(path.join(outDir, f), { force: true });
586
+ mark("audio");
587
+ }
404
588
  if (o.scene)
405
589
  return { mp4, proofLog };
406
590
  // --- 3. GIF (secondary, opt-in) --------------------------------------------
@@ -429,9 +613,19 @@ export async function render(m, take, outDir, opts = {}) {
429
613
  }
430
614
  mark("gif");
431
615
  }
616
+ // Cards are spliced onto the front of the deliverable, so every timestamp
617
+ // in the take is that much later in the file frames are pulled from.
618
+ const cardShift = !o.scene && m.intro ? m.intro.ms / 1000 : 0;
432
619
  // --- 4. thumbnail ----------------------------------------------------------
620
+ // A person's chosen poster (the window's "Use this frame" / a still / an
621
+ // upload) outlives re-renders: the choice writes a .poster marker, and the
622
+ // scene-based default never overwrites a deliberate choice.
433
623
  let thumbnail;
434
- if (m.outputs.thumbnail) {
624
+ if (m.outputs.thumbnail && fs.existsSync(path.join(outDir, ".poster")) && fs.existsSync(path.join(outDir, "thumbnail.png"))) {
625
+ thumbnail = path.join(outDir, "thumbnail.png");
626
+ log?.("thumbnail: keeping the poster you chose (delete .poster to go back to the scene default)");
627
+ }
628
+ else if (m.outputs.thumbnail) {
435
629
  const sc = scenes(take);
436
630
  let at;
437
631
  if (typeof m.outputs.thumbnail === "object") {
@@ -449,13 +643,48 @@ export async function render(m, take, outDir, opts = {}) {
449
643
  at = last ? last.start - take.trimBefore + 1.0 : Math.max(0, take.duration - take.trimBefore - 0.5);
450
644
  }
451
645
  thumbnail = path.join(outDir, "thumbnail.png");
452
- ff(["-ss", Math.max(0, at).toFixed(2), "-i", mp4, "-frames:v", "1", thumbnail], log);
646
+ // Clamp inside the file. The default is "last scene + 1s", which lands
647
+ // PAST the end whenever a take stops soon after its final scene — ffmpeg
648
+ // then writes nothing and the render died on the missing file, reported
649
+ // in the field as "all steps ok, but no video".
650
+ const seekable = Math.max(0, probe(mp4).duration - 0.25);
651
+ const seek = Math.min(Math.max(0, at + cardShift), seekable);
652
+ ff(["-ss", seek.toFixed(2), "-i", mp4, "-frames:v", "1", thumbnail], log);
653
+ if (!fs.existsSync(thumbnail)) {
654
+ log?.(`thumbnail: nothing at ${seek.toFixed(1)}s — taking the last frame instead`);
655
+ ff(["-sseof", "-0.3", "-i", mp4, "-frames:v", "1", thumbnail], log);
656
+ }
657
+ if (!fs.existsSync(thumbnail)) {
658
+ log?.("thumbnail: could not be taken — the video is fine, the poster is not");
659
+ thumbnail = undefined;
660
+ }
661
+ // A launch cut gets a second candidate: this frame with the title on it.
662
+ // The person picks between them (or any still, or their own file) in the
663
+ // window; both are just PNGs, and the poster is whichever is thumbnail.png.
664
+ if (m.mode === "launch" && m.intro && thumbnail) {
665
+ const d = probe(mp4);
666
+ await renderTitledCover(ffmpegBin(), thumbnail, path.join(outDir, "cover-titled.png"), m.intro, d.width, d.height, q.theme);
667
+ }
453
668
  mark("thumbnail");
454
669
  }
670
+ // A launch cut is a thing someone signs off, so give them the artifact
671
+ // that makes signing off possible without ffmpeg by hand.
672
+ if (m.mode === "launch" && !o.scene && fs.existsSync(path.join(outDir, "demo.mp4"))) {
673
+ try {
674
+ contactSheet(outDir, 2, 420, undefined);
675
+ mark("contact");
676
+ }
677
+ catch (e) {
678
+ log?.(`contact sheet: ${e.message}`);
679
+ }
680
+ }
455
681
  // --- 5. scene stills -------------------------------------------------------
456
682
  // Every scene already carries its real timestamp, so one crisp frame per
457
683
  // scene is free — and a video usually ships with a picture next to it.
458
684
  let stills = [];
685
+ // Every still in ONE ffmpeg pass: a process launch and a fresh decode per
686
+ // frame cost more than the frames do (7 stills was 3s of pure overhead).
687
+ const shots = [];
459
688
  if (m.outputs.stills) {
460
689
  const dir = path.join(outDir, "stills");
461
690
  fs.rmSync(dir, { recursive: true, force: true });
@@ -470,17 +699,29 @@ export async function render(m, take, outDir, opts = {}) {
470
699
  const at = Math.min(Math.max(0, from + Math.max(0.8, (next - from) / 2)), Math.max(0, end - 0.2));
471
700
  const base = `${String(i + 1).padStart(2, "0")}-${(sce.label || "scene").replace(/[^a-z0-9-]+/gi, "-")}`;
472
701
  const file = path.join(dir, `${base}.png`);
473
- ff(["-ss", at.toFixed(2), "-i", mp4, "-frames:v", "1", file], log);
702
+ shots.push({ at: at + cardShift, file });
474
703
  stills.push(file);
475
704
  // And the scene's last moment: the caption names what the scene
476
705
  // achieves, and that is usually on screen at the end, not the middle.
477
706
  const atEnd = Math.min(Math.max(from + 0.4, next - 0.6), Math.max(0, end - 0.2));
478
707
  if (atEnd - at > 0.5) {
479
708
  const fe = path.join(dir, `${base}-end.png`);
480
- ff(["-ss", atEnd.toFixed(2), "-i", mp4, "-frames:v", "1", fe], log);
709
+ shots.push({ at: atEnd + cardShift, file: fe });
481
710
  stills.push(fe);
482
711
  }
483
712
  }
713
+ if (shots.length) {
714
+ // Each still is its own ffmpeg with an INPUT-side seek: `-ss` before
715
+ // `-i` jumps by keyframe, while one batched process with `-ss` after
716
+ // `-i` decodes sequentially to every timestamp and is far slower (it
717
+ // turned 3s of stills into 18s — measured, then reverted). The launches
718
+ // are independent, so they run at once instead of in a queue.
719
+ const bin = ffmpegBin();
720
+ await Promise.all(shots.map((s) => new Promise((done) => {
721
+ execFile(bin, ["-hide_banner", "-loglevel", "error", "-y", "-ss", s.at.toFixed(2), "-i", mp4, "-frames:v", "1", s.file], () => done());
722
+ })));
723
+ stills = stills.filter((f) => fs.existsSync(f));
724
+ }
484
725
  if (sc.length)
485
726
  mark(`stills ×${sc.length}`);
486
727
  }
@@ -489,8 +730,9 @@ export async function render(m, take, outDir, opts = {}) {
489
730
  const p = probe(mp4);
490
731
  const facts = {
491
732
  width: p.width, height: p.height, fps: p.fps || fps, duration: p.duration,
492
- sizes: Object.fromEntries([master, mp4, gif, thumbnail].filter((f) => !!f).map((f) => [path.basename(f), fs.statSync(f).size])),
733
+ sizes: Object.fromEntries([master, mp4, gif, thumbnail].filter((f) => !!f && fs.existsSync(f)).map((f) => [path.basename(f), fs.statSync(f).size])),
493
734
  gifTool, layout, cameraScenes: cam.count, encoder: q.encoder, timings: t.marks, renderHash: hash, preset: q.name,
735
+ mode: m.mode,
494
736
  };
495
737
  const a = { master, mp4, gif, thumbnail, proofLog, facts };
496
738
  writeProof(a);
@@ -498,6 +740,38 @@ export async function render(m, take, outDir, opts = {}) {
498
740
  return a;
499
741
  }
500
742
  /** GIF straight from the finished demo.mp4 — no re-render, no browser. */
743
+ /** One timestamped contact sheet of the finished video.
744
+ *
745
+ * Reviewing a take meant pulling frames out with ffmpeg by hand, a dozen
746
+ * timestamps at a time, eight videos deep. This is that, once: a grid with
747
+ * the playback time burned into every cell, so a still can be reasoned about
748
+ * as a moment rather than a picture. Per-scene stills cannot do this — they
749
+ * cannot show a caption going false *inside* a scene, and they carry no clock.
750
+ */
751
+ export function contactSheet(outDir, everySec = 2, width = 420, log) {
752
+ const mp4 = path.join(outDir, "demo.mp4");
753
+ if (!fs.existsSync(mp4))
754
+ throw new Error(`no demo.mp4 in ${outDir} — render it first`);
755
+ const dur = probe(mp4).duration;
756
+ const cells = Math.max(1, Math.ceil(dur / everySec));
757
+ const cols = Math.min(4, Math.max(1, Math.ceil(Math.sqrt(cells))));
758
+ const rows = Math.ceil(cells / cols);
759
+ const fontFile = DEFAULT_FONTS.find((f) => fs.existsSync(f));
760
+ const font = fontFile ? `fontfile='${fontFile}':` : "";
761
+ const out = path.join(outDir, "contact.png");
762
+ // drawtext runs before tile, so each cell carries its own playback time.
763
+ const vf = [
764
+ `fps=1/${everySec}`,
765
+ `scale=${width}:-2`,
766
+ `drawtext=${font}text='%{pts\\:hms}':x=8:y=8:fontsize=${Math.round(width / 20)}:fontcolor=white:box=1:boxcolor=black@0.55:boxborderw=5`,
767
+ `tile=${cols}x${rows}:margin=8:padding=6:color=#e6e9e2`,
768
+ ].join(",");
769
+ ff(["-i", mp4, "-vf", vf, "-frames:v", "1", out], log);
770
+ if (!fs.existsSync(out))
771
+ throw new Error("ffmpeg produced no contact sheet");
772
+ log?.(`contact: ${cells} frames every ${everySec}s, ${cols}×${rows} → ${path.relative(process.cwd(), out)}`);
773
+ return out;
774
+ }
501
775
  export function makeGif(outDir, width = 900, fps = 18, log) {
502
776
  const mp4 = path.join(outDir, "demo.mp4");
503
777
  if (!fs.existsSync(mp4))
@@ -537,9 +811,17 @@ export function check(outDir, m) {
537
811
  const say = (pass, text) => { lines.push(`${pass ? "pass" : "FAIL"} ${text}`); if (!pass)
538
812
  ok = false; };
539
813
  const takePath = path.join(outDir, "take.json");
814
+ // A run killed outright (a signal, a closed pipe) leaves its predecessor in
815
+ // the stash with no catch block ever running. Anything that READS the folder
816
+ // puts it back, so a person looking at it never sees an empty directory and
817
+ // concludes their take is gone.
818
+ if (!fs.existsSync(takePath))
819
+ restorePrevious(outDir);
540
820
  if (!fs.existsSync(takePath))
541
821
  return { ok: false, lines: ["FAIL no take.json"] };
542
- const take = JSON.parse(fs.readFileSync(takePath, "utf8"));
822
+ // The same shift render applied, so a nudged scene is not reported as a
823
+ // stall or a caption in the wrong place by a check reading raw times.
824
+ const take = nudgeScenes(JSON.parse(fs.readFileSync(takePath, "utf8")), m).take;
543
825
  // The take knows which preset it was recorded with; a `--preset` on the run
544
826
  // must win over the manifest's default, or a draft fails for lacking a master.
545
827
  const q = m ? resolve(take.quality?.preset && take.quality.preset !== m.preset ? { ...m, preset: take.quality.preset } : m) : null;
@@ -547,17 +829,23 @@ export function check(outDir, m) {
547
829
  if (!fs.existsSync(mp4))
548
830
  return { ok: false, lines: ["FAIL demo.mp4 missing"] };
549
831
  const p = probe(mp4);
550
- const wantW = q ? (q.layout === "card" ? q.width : (take.quality?.width ?? q.viewport.width)) : undefined;
832
+ const wantW = q?.width;
551
833
  say(true, `resolution: ${p.width}×${p.height}`);
552
834
  if (wantW)
553
- say(p.width === wantW, `matches expected width ${wantW}`);
835
+ say(p.width === wantW, `matches ${q?.name}: ${wantW}px wide`);
554
836
  say(p.width >= 960, `at least 960px wide`);
555
837
  say(true, `fps: ${p.fps}`);
556
838
  /* A take may legitimately run long — a full product walkthrough does.
557
839
  The manifest's own maxSeconds is the author's stated bound; a take
558
840
  inside it is not a runaway, so do not fail it for length. */
559
841
  const longest = m ? capSecondsFor(m) : 300;
560
- say(p.duration >= 5 && p.duration <= longest, `duration: ${p.duration.toFixed(1)}s`);
842
+ // Whole-video judgements (is it long enough, does it have every scene) do
843
+ // not apply to a deliberate fragment.
844
+ const fragment = /\((?:from|until)\)/.test(take.partial ?? "");
845
+ if (fragment)
846
+ lines.push(`— duration: ${p.duration.toFixed(1)}s (not judged — this is a fragment)`);
847
+ else
848
+ say(p.duration >= 5 && p.duration <= longest, `duration: ${p.duration.toFixed(1)}s`);
561
849
  if (p.duration > 60)
562
850
  lines.push(`— ${p.duration.toFixed(0)}s is long for a social post (aim for 15–45s); fine for a walkthrough`);
563
851
  /* A step that stalls is not a failed step, so nothing above notices it — but
@@ -566,23 +854,76 @@ export function check(outDir, m) {
566
854
  other seven navigations took under four seconds; every check passed.
567
855
  `wait` and `scene` are excluded because their whole job is to take time. */
568
856
  const PATIENCE = 8;
569
- const stalled = (take.timeline || [])
857
+ // Judge what shipped. compressIdle rewrites the timeline at render, so
858
+ // reading raw step durations here failed good videos for a stall the
859
+ // viewer never sees — and a check that cries wolf teaches you to skim
860
+ // past the one that matters.
861
+ const idleSegs = m?.compressIdle && take.video
862
+ ? planIdle(take, m.compressIdle === true ? 1.5 : m.compressIdle.keepSeconds)
863
+ : [];
864
+ const shownTake = idleSegs.length ? warpTake(take, idleSegs) : take;
865
+ const stalled = (shownTake.timeline || [])
570
866
  .filter((t) => t.action !== "wait" && t.action !== "scene" && t.action !== "stub")
571
867
  .map((t) => ({ ...t, took: t.end - t.start }))
572
868
  .filter((t) => t.took > PATIENCE)
573
869
  .sort((a, b) => b.took - a.took);
870
+ // Did the app break, and was it still broken when the video stopped? A
871
+ // take can pass every step and end on an error screen; one did, and only
872
+ // frame-by-frame review found it. An error in the last stretch is the
873
+ // shape that matters — earlier ones are usually recovered from.
874
+ const errs = take.pageErrors ?? [];
875
+ if (errs.length) {
876
+ const endsAt = take.duration;
877
+ const late = errs.filter((e) => e.at >= Math.max(0, endsAt - 6));
878
+ if (late.length) {
879
+ say(false, `the app reported an error in the last seconds and the video ends on it: “${late[0].text.slice(0, 120)}” at ${late[0].at.toFixed(1)}s — watch the final frames before shipping this`);
880
+ }
881
+ else {
882
+ lines.push(`— the app reported ${errs.length} error${errs.length > 1 ? "s" : ""} during the take (earliest “${errs[0].text.slice(0, 80)}”), none in the last 6s`);
883
+ }
884
+ }
574
885
  if (stalled.length) {
575
886
  const worst = stalled[0];
887
+ // A timestamp says a stall happened; it cannot say why. The frame can —
888
+ // a spinner, a modal, an error, or the app simply sitting there are four
889
+ // different problems and "25.5s at 0:56" is the same sentence for all of
890
+ // them. Pulled from the finished video, so it costs a seek, not a take.
891
+ const shots = [];
892
+ const trim = shownTake.trimBefore ?? 0;
893
+ for (const [i, t] of stalled.slice(0, 3).entries()) {
894
+ // A third of the way in: past whatever triggered it, still inside the
895
+ // held stretch rather than on the frame that ends it.
896
+ const at = Math.max(0, t.start - trim + Math.min(t.took / 3, 4));
897
+ const file = path.join(outDir, `stalled-${String(i + 1).padStart(2, "0")}.png`);
898
+ try {
899
+ ff(["-ss", at.toFixed(2), "-i", mp4, "-frames:v", "1", "-y", file]);
900
+ if (fs.existsSync(file))
901
+ shots.push(path.relative(process.cwd(), file));
902
+ }
903
+ catch { /* a picture is a bonus, never the reason a check fails */ }
904
+ }
576
905
  say(false, `${stalled.length} step${stalled.length > 1 ? "s" : ""} stalled over ${PATIENCE}s — ` +
577
906
  `the take holds a still frame there. Worst: ${worst.took.toFixed(1)}s at ` +
578
907
  `${Math.floor(worst.start / 60)}:${String(Math.floor(worst.start % 60)).padStart(2, "0")} — ${worst.summary}`);
579
- for (const t of stalled.slice(1, 4)) {
580
- lines.push(`— also ${t.took.toFixed(1)}s: ${t.summary}`);
908
+ if (shots[0])
909
+ lines.push(`— what the frame shows there: ${shots[0]} — open it; a spinner, a modal and an idle app are three different fixes`);
910
+ for (const [i, t] of stalled.slice(1, 4).entries()) {
911
+ lines.push(`— also ${t.took.toFixed(1)}s: ${t.summary}${shots[i + 1] ? ` (${shots[i + 1]})` : ""}`);
581
912
  }
582
913
  }
583
914
  else {
584
915
  say(true, `no step stalled (nothing over ${PATIENCE}s)`);
585
916
  }
917
+ // A stub nobody asked for is a silent lie: the take passes and the screen
918
+ // shows live data where the demo promised canned. Worth a failed check, not
919
+ // a footnote — this is the one the reporter spent a morning on.
920
+ const dead = Object.entries(take.stubHits ?? {}).filter(([, n]) => !n).map(([k]) => k);
921
+ if (take.stubbed?.length) {
922
+ if (dead.length)
923
+ say(false, `${dead.length} stub${dead.length > 1 ? "s" : ""} never answered a request: ${dead.join(", ")} — those screens showed the app's real data. Check the URL pattern matches, and that the page actually re-fetched (a navigate that changes only the #fragment does not reload an SPA).`);
924
+ else
925
+ say(true, `all ${take.stubbed.length} stub${take.stubbed.length > 1 ? "s" : ""} answered at least one request`);
926
+ }
586
927
  const mb = (f) => (fs.statSync(f).size / 1e6).toFixed(1) + " MB";
587
928
  say(true, `demo.mp4: ${mb(mp4)}`);
588
929
  const master = path.join(outDir, "master.mp4");
@@ -601,7 +942,13 @@ export function check(outDir, m) {
601
942
  lines.push("— thumbnail: not requested");
602
943
  say(fs.existsSync(path.join(outDir, "proof-log.md")), "proof log exists");
603
944
  say(take.ok, take.ok ? "all steps passed" : "some steps failed");
604
- say(!take.partial, take.partial ? `partial: ${take.partial}` : "polished render (not fallback)");
945
+ // A fragment recorded on purpose (--from / --until) is not a defect. Saying
946
+ // FAIL about work that did exactly what was asked is how people learn to
947
+ // skim past checks, including the ones that matter.
948
+ if (fragment)
949
+ say(true, `fragment — ${take.partial}`);
950
+ else
951
+ say(!take.partial, take.partial ? `partial: ${take.partial}` : "polished render (not fallback)");
605
952
  if (take.partial && /NO CURSOR/.test(take.partial))
606
953
  say(false, "cursor overlay failed — the video has no cursor (split the demo or set cursor: false)");
607
954
  const sc = scenes(m ? applyManifest(m, take) : take);
@@ -632,11 +979,27 @@ function renderProof(m, take, q, a) {
632
979
  "",
633
980
  `- URL: ${m.url}`,
634
981
  `- Preset: ${q.name} · canvas ${q.width}×${q.height} · viewport ${take.quality?.width ?? q.viewport.width}×${take.quality?.height ?? q.viewport.height} @ ${q.fps}fps · page scale ${q.scale}× · layout ${q.layout}`,
982
+ // Measured during the take, not computed from the preset: `zoom` does not
983
+ // move an app's media queries, so width÷scale would be a plausible lie.
984
+ // Named outright because a responsive app serves a different layout below
985
+ // a breakpoint, and this is the number that explains which one you filmed.
986
+ ...(take.contentWidth && take.layoutWidth && take.contentWidth !== take.layoutWidth
987
+ ? [`- Page width: content had **${take.contentWidth} CSS px** of room (viewport ${take.layoutWidth} ÷ ${q.scale}× scale), while media queries read **${take.layoutWidth} px**. A responsive app therefore renders its DESKTOP layout squeezed into ${take.contentWidth}px rather than switching to mobile — if this take looks cramped, that is why, and \`scale: 1\` is the fix.`]
988
+ : take.layoutWidth ? [`- Page laid out at: **${take.layoutWidth} CSS px** wide.`] : []),
635
989
  `- Started: ${take.startedAt}`,
636
990
  `- Finished: ${take.finishedAt}`,
637
991
  `- Result: **${take.ok ? "all steps passed" : "some steps failed"}**${take.partial ? ` · **partial** — ${take.partial}` : ""}`,
638
992
  `- Setup trimmed: ${take.trimBefore.toFixed(1)}s · final length ≈ ${(take.duration - take.trimBefore).toFixed(1)}s`,
639
- ...(take.stubbed?.length ? [`- **Stubbed data**: ${take.stubbed.join(", ")} — these screens were fed canned responses, not a live backend.`] : []),
993
+ // A nudged demo no longer matches raw step timing; say so, or the next
994
+ // person reads the timeline against the video and thinks one is wrong.
995
+ ...(() => {
996
+ const n = (m.steps ?? []).filter((st) => st.action === "scene" && st.nudge).map((st) => `${st.label} ${st.nudge > 0 ? "+" : ""}${(st.nudge / 1000).toFixed(1)}s`);
997
+ return n.length ? [`- **Scene markers moved at render**: ${n.join(", ")} — the captions and stills follow these, not the raw step times below.`] : [];
998
+ })(),
999
+ ...(take.stubbed?.length ? [`- **Stubbed data**: ${take.stubbed.map((k) => `${k}${take.stubHits ? ` (${take.stubHits[k] ?? 0} request${(take.stubHits[k] ?? 0) === 1 ? "" : "s"})` : ""}`).join(", ")} — these screens were fed canned responses, not a live backend.`] : []),
1000
+ ...(take.stubHits && Object.entries(take.stubHits).some(([, n]) => !n)
1001
+ ? [`- **Stubs that never fired**: ${Object.entries(take.stubHits).filter(([, n]) => !n).map(([k]) => k).join(", ")} — armed, but no request matched, so those screens showed whatever the app really returned. Usually the pattern does not match the real URL, or the page never re-fetched (a navigation that only changes the #fragment does not reload an SPA).`]
1002
+ : []),
640
1003
  "",
641
1004
  "## Artifacts",
642
1005
  "",