retake-demos 0.2.2 → 0.3.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/pace.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Idle compression: the app's dead time, shortened at render.
3
+ *
4
+ * The take's own timeline is the honest signal — a waitFor or navigate step
5
+ * records exactly how long the viewer sat looking at nothing changing. Each
6
+ * such stretch is shown as ~keepSeconds: the real start at 1×, then the rest
7
+ * fast-forwarded. The author's `wait` steps are pacing and are never touched;
8
+ * scene holds stay exactly as written.
9
+ *
10
+ * Implemented as a pre-pass: the source video is re-cut into a warped source
11
+ * and every clock in the take (timeline, trim, duration) is remapped through
12
+ * the same piecewise-linear warp, so captions, camera keys and stills land
13
+ * where they always did — the rest of the render pipeline never knows.
14
+ */
15
+ import path from "node:path";
16
+ /** The app made the viewer wait: show the start, fast-forward the rest. */
17
+ const WAITING = new Set(["waitFor", "navigate"]);
18
+ /** Only stretches meaningfully longer than what we would keep are worth a cut. */
19
+ const SLACK = 0.6;
20
+ /** Typing is not dead time — it is the person speaking — so it keeps its
21
+ start AND its finish at real speed and only accelerates the middle. Every
22
+ keystroke is a round-trip to the browser (~57ms whatever `delay` says), so
23
+ a real sentence costs five seconds nobody watches. */
24
+ const TYPE_BUDGET = 2.4;
25
+ const TYPE_HEAD = 0.8;
26
+ const TYPE_TAIL = 0.7;
27
+ /** Plan the warp: 1× everywhere except the tail of each long app-wait and the
28
+ middle of each long typing run. */
29
+ export function planIdle(take, keepSeconds) {
30
+ const segs = [];
31
+ for (const e of take.timeline) {
32
+ if (!e.ok)
33
+ continue;
34
+ const dur = e.end - e.start;
35
+ if (WAITING.has(e.action)) {
36
+ if (dur < keepSeconds + SLACK)
37
+ continue;
38
+ // Head at real speed, tail fast-forwarded into the remaining budget.
39
+ const head = keepSeconds * 0.6;
40
+ const shownTail = keepSeconds * 0.4;
41
+ segs.push({ from: e.start + head, to: e.end, rate: (dur - head) / shownTail });
42
+ }
43
+ else if (e.action === "type" || e.action === "fill") {
44
+ if (dur < TYPE_BUDGET + SLACK)
45
+ continue;
46
+ const middle = dur - TYPE_HEAD - TYPE_TAIL;
47
+ const shownMiddle = Math.max(0.3, TYPE_BUDGET - TYPE_HEAD - TYPE_TAIL);
48
+ segs.push({ from: e.start + TYPE_HEAD, to: e.end - TYPE_TAIL, rate: middle / shownMiddle });
49
+ }
50
+ }
51
+ return segs.sort((a, b) => a.from - b.from);
52
+ }
53
+ /** Source time → warped time. */
54
+ export function warpTime(segs, t) {
55
+ let out = t;
56
+ for (const s of segs) {
57
+ if (t <= s.from)
58
+ break;
59
+ const inSeg = Math.min(t, s.to) - s.from;
60
+ out -= inSeg - inSeg / s.rate;
61
+ }
62
+ return out;
63
+ }
64
+ /** The take with every clock remapped. */
65
+ export function warpTake(take, segs) {
66
+ const w = (t) => warpTime(segs, t);
67
+ return {
68
+ ...take,
69
+ trimBefore: w(take.trimBefore),
70
+ duration: w(take.duration),
71
+ timeline: take.timeline.map((e) => ({ ...e, start: w(e.start), end: w(e.end) })),
72
+ };
73
+ }
74
+ /** ffmpeg args to cut the raw source into the warped source. */
75
+ export function warpFilterArgs(src, segs, duration, out) {
76
+ // Alternating 1× and fast segments covering [0, duration].
77
+ const pieces = [];
78
+ let cur = 0;
79
+ for (const s of segs) {
80
+ if (s.from > cur)
81
+ pieces.push({ a: cur, b: s.from, rate: 1 });
82
+ pieces.push({ a: s.from, b: s.to, rate: s.rate });
83
+ cur = s.to;
84
+ }
85
+ if (cur < duration)
86
+ pieces.push({ a: cur, b: duration, rate: 1 });
87
+ const graph = pieces.map((p, i) => `[0:v]trim=start=${p.a.toFixed(3)}:end=${p.b.toFixed(3)},setpts=(PTS-STARTPTS)/${p.rate.toFixed(4)}[s${i}]`).join(";") +
88
+ ";" + pieces.map((_, i) => `[s${i}]`).join("") + `concat=n=${pieces.length}:v=1:a=0[out]`;
89
+ return ["-i", path.resolve(src), "-filter_complex", graph, "-map", "[out]", "-an", out];
90
+ }
91
+ /** Human line for the proof log. */
92
+ export function describeIdle(take, segs) {
93
+ return segs.map((s) => {
94
+ const e = take.timeline.find((x) => x.start <= s.from && x.end >= s.to);
95
+ const real = e ? e.end - e.start : s.to - s.from;
96
+ const shown = real - (s.to - s.from) + (s.to - s.from) / s.rate;
97
+ return `${e ? e.summary.slice(0, 52) : "wait"}: ${real.toFixed(1)}s shown as ${shown.toFixed(1)}s`;
98
+ });
99
+ }
package/dist/presets.js CHANGED
@@ -8,7 +8,7 @@ export const PRESETS = {
8
8
  name: "draft",
9
9
  width: 960, height: 540, scale: 1, fps: 15, crf: 0,
10
10
  gif: false, layout: "band", cameraZoom: 1.2,
11
- cursorSize: 36, captionFontSize: 24, bandHeight: 76,
11
+ cursorSize: 36, captionFontSize: 24, bandHeight: 76, bandFit: "text",
12
12
  encoder: "videotoolbox", master: false,
13
13
  description: "Fastest. Same layout as the final at a quarter of the pixels — for iterating, never for shipping.",
14
14
  },
@@ -16,7 +16,7 @@ export const PRESETS = {
16
16
  name: "preview-fast",
17
17
  width: 1920, height: 1080, scale: 2, fps: 24, crf: 0,
18
18
  gif: false, layout: "band", cameraZoom: 1.25,
19
- cursorSize: 72, captionFontSize: 46, bandHeight: 150,
19
+ cursorSize: 72, captionFontSize: 46, bandHeight: 150, bandFit: "text",
20
20
  encoder: "videotoolbox", master: false,
21
21
  description: "Same framing as post, hardware-encoded MP4 only. For checking timing and framing fast.",
22
22
  },
@@ -24,7 +24,7 @@ export const PRESETS = {
24
24
  name: "proof-fast",
25
25
  width: 960, height: 720, scale: 1, fps: 24, crf: 24,
26
26
  gif: false, layout: "band", cameraZoom: 1.2,
27
- cursorSize: 40, captionFontSize: 26, bandHeight: 84,
27
+ cursorSize: 40, captionFontSize: 26, bandHeight: 84, bandFit: "text",
28
28
  encoder: "videotoolbox", master: false,
29
29
  description: "Small, quick internal check.",
30
30
  },
@@ -32,7 +32,7 @@ export const PRESETS = {
32
32
  name: "post-landscape",
33
33
  width: 1920, height: 1080, scale: 2, fps: 30, crf: 17,
34
34
  gif: false, layout: "band", cameraZoom: 1.25,
35
- cursorSize: 72, captionFontSize: 46, bandHeight: 150,
35
+ cursorSize: 72, captionFontSize: 46, bandHeight: 150, bandFit: "text",
36
36
  encoder: "x264", master: true,
37
37
  description: "1080p, 30fps, CRF 17. The general-purpose demo. GIF on request.",
38
38
  },
@@ -41,25 +41,25 @@ export const PRESETS = {
41
41
  /* The page area is the canvas minus the caption band, so the file really
42
42
  is 1080×1080 — a square preset that produced 1080×1210 got letterboxed
43
43
  by every feed. Landscape keeps its full-height page on purpose. */
44
- width: 1080, height: 950, scale: 1.6, fps: 30, crf: 17,
44
+ width: 1080, height: 960, scale: 1.6, fps: 30, crf: 17,
45
45
  gif: false, layout: "band", cameraZoom: 1.25,
46
- cursorSize: 60, captionFontSize: 40, bandHeight: 130,
46
+ cursorSize: 60, captionFontSize: 36, bandHeight: 120, bandFit: "fill",
47
47
  encoder: "x264", master: true,
48
- description: "A true 1080×1080 (page 1080×950 + caption band), feed-friendly.",
48
+ description: "A true 1080×1080 (page 1080×960 + a fixed 120px caption band), feed-friendly.",
49
49
  },
50
50
  "post-vertical": {
51
51
  name: "post-vertical",
52
- width: 1080, height: 1750, scale: 2, fps: 30, crf: 17,
52
+ width: 1080, height: 1800, scale: 2, fps: 30, crf: 17,
53
53
  gif: false, layout: "band", cameraZoom: 1.3,
54
- cursorSize: 60, captionFontSize: 44, bandHeight: 170,
54
+ cursorSize: 60, captionFontSize: 40, bandHeight: 120, bandFit: "fill",
55
55
  encoder: "x264", master: true,
56
- description: "A true 1080×1920 (page 1080×1750 + caption band) for shorts/reels. Narrow-column apps suit it best.",
56
+ description: "A true 1080×1920 (page 1080×1800 + a fixed 120px caption band) for shorts/reels. Narrow-column apps suit it best.",
57
57
  },
58
58
  "docs-gif": {
59
59
  name: "docs-gif",
60
60
  width: 1440, height: 900, scale: 1.5, fps: 24, crf: 20,
61
61
  gif: { width: 900, fps: 15 }, layout: "band", cameraZoom: 1.2,
62
- cursorSize: 48, captionFontSize: 32, bandHeight: 100,
62
+ cursorSize: 48, captionFontSize: 32, bandHeight: 100, bandFit: "text",
63
63
  encoder: "x264", master: false,
64
64
  description: "README/docs GIF, 900px wide.",
65
65
  },
@@ -67,7 +67,7 @@ export const PRESETS = {
67
67
  name: "master",
68
68
  width: 1920, height: 1080, scale: 2, fps: 30, crf: 12,
69
69
  gif: false, layout: "band", cameraZoom: 1.25,
70
- cursorSize: 72, captionFontSize: 46, bandHeight: 150,
70
+ cursorSize: 72, captionFontSize: 46, bandHeight: 150, bandFit: "text",
71
71
  encoder: "x264", master: true,
72
72
  description: "Highest quality, slowest. Archive/source render.",
73
73
  },
package/dist/record.js CHANGED
@@ -191,6 +191,10 @@ export async function record(m, opts) {
191
191
  const stateFresh = !!statePath && fs.existsSync(statePath) && (Date.now() - fs.statSync(statePath).mtimeMs) / 3.6e6 < (m.auth?.maxAgeHours ?? 72);
192
192
  if (statePath)
193
193
  log(stateFresh ? `auth: reusing session ${path.relative(process.cwd(), statePath)}` : `auth: no fresh session — running setup to sign in`);
194
+ // No session and nothing that could create one: stop here rather than record
195
+ // a logged-out take that passes every check.
196
+ if (statePath && !stateFresh && !(m.auth?.setup?.length))
197
+ throw new Error(`no fresh session at ${path.relative(process.cwd(), statePath)} and no auth.setup to sign in with — run \`retake signin ${path.relative(process.cwd(), path.join(opts.manifestDir, m.name + ".yaml"))}\` and log in once by hand, or add the sign-in steps under auth.setup`);
194
198
  const context = await browser.newContext({
195
199
  viewport: size,
196
200
  colorScheme: m.colorScheme,
@@ -332,6 +336,10 @@ export async function record(m, opts) {
332
336
  if (statePath && !stateFresh && authOk) {
333
337
  fs.mkdirSync(path.dirname(statePath), { recursive: true });
334
338
  await context.storageState({ path: statePath });
339
+ try {
340
+ fs.chmodSync(statePath, 0o600);
341
+ }
342
+ catch { /* windows */ }
335
343
  log(`auth: saved session → ${path.relative(process.cwd(), statePath)}`);
336
344
  }
337
345
  else if (statePath && !stateFresh) {
@@ -366,6 +374,15 @@ export async function record(m, opts) {
366
374
  end: 0,
367
375
  ok: true,
368
376
  };
377
+ if (step.action === "callout") {
378
+ // Resolve the box now; the ring itself is drawn at render time.
379
+ const target = step.selector ?? step.at;
380
+ const c = await resolvePoint(page, target, step.timeout ?? 10_000);
381
+ const half = typeof target === "string" || !("x" in target)
382
+ ? await page.locator((typeof target === "string" ? target : target.selector)).first().evaluate((el) => { const r = el.getBoundingClientRect(); return { w: r.width, h: r.height }; }).catch(() => ({ w: 48, h: 48 }))
383
+ : { w: 48, h: 48 };
384
+ entry.callout = { box: { x: c.x - half.w / 2, y: c.y - half.h / 2, width: half.w, height: half.h }, label: step.label, ms: step.ms };
385
+ }
369
386
  if (step.action === "scene") {
370
387
  entry.label = step.label;
371
388
  entry.caption = step.caption;
@@ -481,10 +498,18 @@ export async function record(m, opts) {
481
498
  // calibrated to within ~0.2s of the first video frame on a warm route). The
482
499
  // real file length is authoritative for the end.
483
500
  const trimBefore = sec(setupEnd - t0);
484
- const duration = video ? videoDuration(video) : sec(endMs - t0);
501
+ // The recording keeps rolling while the browser is torn down, so the raw
502
+ // video always has a tail of nothing after the last step. The take ends
503
+ // where the demo ends (plus a breath), never where the recorder stopped —
504
+ // otherwise every video ships with dead seconds at the end.
505
+ const GRACE = 0.4;
506
+ const videoSec = video ? videoDuration(video) : sec(endMs - t0);
485
507
  const last = timeline.at(-1);
486
- if (last && Math.abs(duration - last.end) > 3) {
487
- log(`note: video is ${duration.toFixed(1)}s but the last step ended at ${last.end.toFixed(1)}s — check anchoring`);
508
+ const duration = last && videoSec > last.end + GRACE + 0.1 ? last.end + GRACE : videoSec;
509
+ if (last && duration < videoSec - 0.05)
510
+ log(`trim: ${(videoSec - duration).toFixed(1)}s of tail after the last step dropped`);
511
+ if (last && videoSec < last.end - 0.5) {
512
+ log(`note: video is ${videoSec.toFixed(1)}s but the last step ended at ${last.end.toFixed(1)}s — check anchoring`);
488
513
  }
489
514
  const finishedAt = new Date().toISOString();
490
515
  const take = {
@@ -615,7 +640,7 @@ async function runStep(rec, page, step, m, ctx) {
615
640
  await rec.click(step.selector, { timeout, zoom: step.zoom });
616
641
  break;
617
642
  case "type":
618
- await rec.type(step.selector, expandEnv(step.text), { delay: step.delay ?? 70, clear: step.clear, timeout });
643
+ await rec.type(step.selector, expandEnv(step.text), { delay: step.delay ?? (m.typing === "brisk" ? 22 : 70), clear: step.clear, timeout });
619
644
  break;
620
645
  case "fill":
621
646
  await rec.fill(step.selector, expandEnv(step.text), { timeout });
@@ -695,6 +720,11 @@ async function runStep(rec, page, step, m, ctx) {
695
720
  case "stub":
696
721
  await ctx.stub({ url: step.url, method: step.method, status: step.status ?? 200, json: step.json, from: step.from, contentType: "application/json; charset=utf-8" });
697
722
  break;
723
+ case "callout":
724
+ // The box was resolved in the caller; on camera this is a hold while
725
+ // the (render-time) ring has the viewer's eye.
726
+ await page.waitForTimeout(step.ms / m.speed);
727
+ break;
698
728
  case "scene":
699
729
  // A marker only. Its timestamp is the point of the step.
700
730
  break;
@@ -743,6 +773,8 @@ export function describe(step) {
743
773
  return `evaluate (${step.script.length} chars)`;
744
774
  case "stub":
745
775
  return `stub ${step.method ? step.method + " " : ""}${step.url}`;
776
+ case "callout":
777
+ return `callout ${step.selector ?? pointName(step.at)}${step.label ? ` “${step.label}”` : ""}`;
746
778
  case "scene":
747
779
  return `scene: ${step.label}`;
748
780
  }
package/dist/render.js CHANGED
@@ -18,9 +18,13 @@ import path from "node:path";
18
18
  import { createHash } from "node:crypto";
19
19
  import { execFileSync, spawnSync } from "node:child_process";
20
20
  import { capSecondsFor } from "./record.js";
21
+ import { bandHeightFor, maxCharsFor, wrap } from "./captions.js";
22
+ import { renderCard, renderCalloutOverlay, renderTitledCover } from "./cards.js";
23
+ import { describeIdle, planIdle, warpFilterArgs, warpTake } from "./pace.js";
24
+ import { DEFAULT_VOICE, audioSeconds, synthesize } from "./voice.js";
21
25
  import { createRequire } from "node:module";
22
26
  import { chromium } from "playwright";
23
- import { resolve } from "./manifest.js";
27
+ import { expandEnv, resolve } from "./manifest.js";
24
28
  /** What the render depends on: the raw take + everything in the manifest that
25
29
  shapes the picture. Same hash → same output → skip. */
26
30
  export function renderHash(m, take) {
@@ -28,7 +32,14 @@ export function renderHash(m, take) {
28
32
  const src = take.video && fs.existsSync(take.video) ? fs.statSync(take.video) : null;
29
33
  const h = createHash("sha1");
30
34
  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] }));
35
+ 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 {
36
+ const f = typeof m.music === "string" ? m.music : m.music.file;
37
+ const st = fs.statSync(path.resolve(f));
38
+ return [st.size, Math.round(st.mtimeMs)];
39
+ }
40
+ catch {
41
+ return null;
42
+ } })()] : 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
43
  return h.digest("hex").slice(0, 12);
33
44
  }
34
45
  const require = createRequire(import.meta.url);
@@ -79,38 +90,6 @@ export function probe(file) {
79
90
  };
80
91
  }
81
92
  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
93
  /** ffmpeg drawtext needs a few characters escaped inside the filter graph. */
115
94
  function esc(s) {
116
95
  return s.replace(/\\/g, "\\\\").replace(/'/g, "\u2019").replace(/:/g, "\\:").replace(/%/g, "\\%").replace(/,/g, "\\,");
@@ -296,6 +275,21 @@ export async function render(m, take, outDir, opts = {}) {
296
275
  }
297
276
  // Captions, holds, camera zooms and trim come from the manifest as it is now.
298
277
  take = applyManifest(m, take);
278
+ // Idle compression: re-cut the source so the app's dead waits are shown
279
+ // short, and remap every clock in the take through the same warp.
280
+ let idleLines = [];
281
+ if (m.compressIdle && take.video) {
282
+ const keep = m.compressIdle === true ? 1.5 : m.compressIdle.keepSeconds;
283
+ const segs = planIdle(take, keep);
284
+ if (segs.length) {
285
+ const paced = path.join(outDir, ".paced.mp4");
286
+ 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);
287
+ idleLines = describeIdle(take, segs);
288
+ for (const l of idleLines)
289
+ log?.(`idle: ${l}`);
290
+ take = { ...warpTake(take, segs), video: paced };
291
+ }
292
+ }
299
293
  // Tempo: the source is cut in its own time (-ss/-t below), then every
300
294
  // clock downstream — captions, camera keys, stills — runs in output time.
301
295
  const srcTake = take;
@@ -316,7 +310,7 @@ export async function render(m, take, outDir, opts = {}) {
316
310
  const to = sc[i + 1] ? sc[i + 1].start : take.duration;
317
311
  take = { ...take, trimBefore: from, duration: to };
318
312
  }
319
- const src = path.resolve(srcVideo);
313
+ const src = path.resolve(take.video ?? srcVideo); // take.video may be the idle-warped re-cut
320
314
  // Source (recorded viewport) vs canvas (preset output). They differ when a
321
315
  // narrow app was recorded at its own aspect and gets centred on the canvas.
322
316
  const SW = take.quality?.width ?? q.viewport.width;
@@ -344,13 +338,17 @@ export async function render(m, take, outDir, opts = {}) {
344
338
  let frameInput = null;
345
339
  let geom = null;
346
340
  const capSize = q.captions ? q.captions.fontSize : 0;
341
+ // One band height for the whole take, from its captions (see captions.ts).
342
+ const bandH = bandHeightFor(q, W, captionWindows(take).map((w) => w.text), H);
347
343
  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`;
344
+ // Site fills the frame; caption strip below it — none at all if captions are off.
345
+ if (bandH) {
346
+ filters.push(`pad=${W}:${H + bandH}:0:0:color=${bg}`);
347
+ capY = `${H}+${bandH / 2}-th/2`;
348
+ }
351
349
  }
352
350
  else if (layout === "card") {
353
- geom = cardGeometry(W, H, SW, SH, q.bandHeight);
351
+ geom = cardGeometry(W, H, SW, SH, bandH);
354
352
  frameInput = path.join(outDir, ".frame.png");
355
353
  await renderCardFrame(frameInput, W, H, geom, bg);
356
354
  filters.push(`scale=${geom.vw}:${geom.vh}:flags=lanczos`, `pad=${W}:${H}:${geom.vx}:${geom.vy}:color=${bg}`);
@@ -364,16 +362,40 @@ export async function render(m, take, outDir, opts = {}) {
364
362
  filters.push("format=yuv420p");
365
363
  const captionFilters = [];
366
364
  if (q.captions && layout !== "none") {
367
- const maxChars = Math.max(28, Math.round((W / capSize) * 1.55));
365
+ const maxChars = maxCharsFor(W, capSize);
368
366
  for (const w of captionWindows(take)) {
369
367
  captionFilters.push(`drawtext=${font}text='${esc(wrap(w.text, maxChars))}':fontsize=${capSize}:fontcolor=${capColor}${capBox}:line_spacing=${Math.round(capSize * 0.25)}` +
370
368
  `:x=(w-tw)/2:y=${capY}:enable='between(t,${w.from.toFixed(2)},${w.to.toFixed(2)})'`);
371
369
  }
372
370
  }
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] : [])];
371
+ // Callouts: recorded boxes become animated overlays, applied in SOURCE
372
+ // coordinates (before the camera filter) so a moving camera carries them.
373
+ const callouts = take.timeline.filter((e) => e.callout && e.ok);
374
+ const calloutInputs = [];
375
+ let preCam = "";
376
+ if (callouts.length && !o.scene && !frameInput) {
377
+ const camIdx = cam.filter ? filters.indexOf(cam.filter) : filters.length;
378
+ const before = filters.slice(0, camIdx), after = filters.slice(camIdx);
379
+ let label = "b0";
380
+ let g = `[0:v]${before.join(",")}[${label}]`;
381
+ for (let i = 0; i < callouts.length; i++) {
382
+ const e = callouts[i];
383
+ const from = Math.max(0, e.start - take.trimBefore);
384
+ 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);
385
+ calloutInputs.push(webm);
386
+ const inIdx = 1 + (frameInput ? 1 : 0) + i;
387
+ const next = `b${i + 1}`;
388
+ g += `;[${inIdx}:v]setpts=PTS+${from.toFixed(3)}/TB[c${i}];[${label}][c${i}]overlay=0:0:eof_action=pass[${next}]`;
389
+ label = next;
390
+ }
391
+ preCam = `${g};[${label}]${after.length ? after.join(",") : "null"}`;
392
+ }
393
+ const graph = preCam
394
+ ? `${preCam}${captionFilters.length ? "," + captionFilters.join(",") : ""}[out]`
395
+ : frameInput
396
+ ? `[0:v]${filters.join(",")}[v];[v][1:v]overlay=0:0:format=auto${captionFilters.length ? "," + captionFilters.join(",") : ""}[out]`
397
+ : `[0:v]${[...filters, ...captionFilters].join(",")}[out]`;
398
+ 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
399
  const compose = ["-filter_complex", graph, "-map", "[out]", "-r", String(fps), "-an", "-pix_fmt", "yuv420p", "-movflags", "+faststart"];
378
400
  const x264 = (crf, preset) => ["-c:v", "libx264", "-crf", String(crf), "-preset", preset];
379
401
  // Hardware encode is a macOS thing; elsewhere the "fast" presets are a fast x264.
@@ -401,6 +423,98 @@ export async function render(m, take, outDir, opts = {}) {
401
423
  }
402
424
  if (frameInput)
403
425
  fs.rmSync(frameInput, { force: true });
426
+ for (const f of calloutInputs)
427
+ fs.rmSync(f, { force: true });
428
+ // Title cards: rendered at the finished frame size, spliced on. The intro's
429
+ // settled frame becomes cover.png — the poster.
430
+ let cover;
431
+ if ((m.intro || m.outro) && !o.scene) {
432
+ const dims = probe(mp4);
433
+ const segs = [];
434
+ if (m.intro) {
435
+ const c = await renderCard(ffmpegBin(), outDir, "intro", m.intro, dims.width, dims.height, fps, q.theme);
436
+ segs.push(c.mp4);
437
+ cover = c.cover;
438
+ }
439
+ segs.push(mp4);
440
+ if (m.outro)
441
+ segs.push((await renderCard(ffmpegBin(), outDir, "outro", m.outro, dims.width, dims.height, fps, q.theme)).mp4);
442
+ if (segs.length > 1) {
443
+ const joined = path.join(outDir, ".joined.mp4");
444
+ 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);
445
+ fs.renameSync(joined, mp4);
446
+ for (const f of segs)
447
+ if (f !== mp4)
448
+ fs.rmSync(f, { force: true });
449
+ }
450
+ mark("cards");
451
+ }
452
+ // Audio: the music bed and/or the captions read aloud, mixed in one pass.
453
+ // The deliverable gets it; the master stays a clean video-only source.
454
+ if ((m.music || m.voiceover) && !o.scene) {
455
+ const dur = probe(mp4).duration;
456
+ // Cards shift every scene: narration is placed in FINAL time.
457
+ const introShift = m.intro ? m.intro.ms / 1000 : 0;
458
+ const audioIn = [];
459
+ const chains = [];
460
+ const mixIns = [];
461
+ let idx = 1; // 0 is the video
462
+ if (m.music) {
463
+ const spec = typeof m.music === "string" ? { file: m.music, gainDb: -14, fadeOutMs: 1800 } : m.music;
464
+ const musicFile = path.resolve(expandEnv(spec.file));
465
+ if (!fs.existsSync(musicFile))
466
+ 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)`);
467
+ const fadeStart = Math.max(0, dur - spec.fadeOutMs / 1000);
468
+ // Ducked a step further when a voice speaks over it.
469
+ const gain = spec.gainDb - (m.voiceover ? 7 : 0);
470
+ audioIn.push("-stream_loop", "-1", "-i", musicFile);
471
+ 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]`);
472
+ mixIns.push("[mus]");
473
+ idx++;
474
+ }
475
+ if (m.voiceover) {
476
+ const spec = m.voiceover === true ? { voice: DEFAULT_VOICE, gainDb: 0, script: undefined, fragments: true } : m.voiceover;
477
+ if (spec.script) {
478
+ // One script, one performance: prosody carries across the whole
479
+ // video the way a person reads. Starts after the title card.
480
+ const clip = path.join(outDir, ".vo-0.mp3");
481
+ synthesize(spec.script, spec.voice, clip);
482
+ const spoken = audioSeconds(ffmpegBin(), clip);
483
+ if (spoken > dur - introShift + 0.5)
484
+ 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`);
485
+ audioIn.push("-i", clip);
486
+ chains.push(`[${idx}:a]volume=${spec.gainDb}dB,adelay=${Math.round(introShift * 1000)}:all=1[vo0]`);
487
+ mixIns.push("[vo0]");
488
+ idx++;
489
+ }
490
+ else {
491
+ // Explicitly-asked-for fragments: one clip per caption. Known to
492
+ // reset prosody at every line; the schema refuses it by default.
493
+ const wins = captionWindows(take);
494
+ for (let i = 0; i < wins.length; i++) {
495
+ const w = wins[i];
496
+ const clip = path.join(outDir, `.vo-${i}.mp3`);
497
+ synthesize(w.text, spec.voice, clip);
498
+ const spoken = audioSeconds(ffmpegBin(), clip);
499
+ const at = w.from + introShift;
500
+ if (spoken > w.to - w.from + 0.4)
501
+ 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`);
502
+ audioIn.push("-i", clip);
503
+ chains.push(`[${idx}:a]volume=${spec.gainDb}dB,adelay=${Math.round(at * 1000)}:all=1[vo${i}]`);
504
+ mixIns.push(`[vo${i}]`);
505
+ idx++;
506
+ }
507
+ }
508
+ }
509
+ const mixed = path.join(outDir, ".with-audio.mp4");
510
+ const graph = `${chains.join(";")};${mixIns.join("")}amix=inputs=${mixIns.length}:duration=longest:normalize=0,atrim=0:${dur.toFixed(2)}[a]`;
511
+ 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);
512
+ fs.renameSync(mixed, mp4);
513
+ for (const f of fs.readdirSync(outDir))
514
+ if (/^\.vo-\d+\.mp3$/.test(f))
515
+ fs.rmSync(path.join(outDir, f), { force: true });
516
+ mark("audio");
517
+ }
404
518
  if (o.scene)
405
519
  return { mp4, proofLog };
406
520
  // --- 3. GIF (secondary, opt-in) --------------------------------------------
@@ -429,9 +543,19 @@ export async function render(m, take, outDir, opts = {}) {
429
543
  }
430
544
  mark("gif");
431
545
  }
546
+ // Cards are spliced onto the front of the deliverable, so every timestamp
547
+ // in the take is that much later in the file frames are pulled from.
548
+ const cardShift = !o.scene && m.intro ? m.intro.ms / 1000 : 0;
432
549
  // --- 4. thumbnail ----------------------------------------------------------
550
+ // A person's chosen poster (the window's "Use this frame" / a still / an
551
+ // upload) outlives re-renders: the choice writes a .poster marker, and the
552
+ // scene-based default never overwrites a deliberate choice.
433
553
  let thumbnail;
434
- if (m.outputs.thumbnail) {
554
+ if (m.outputs.thumbnail && fs.existsSync(path.join(outDir, ".poster")) && fs.existsSync(path.join(outDir, "thumbnail.png"))) {
555
+ thumbnail = path.join(outDir, "thumbnail.png");
556
+ log?.("thumbnail: keeping the poster you chose (delete .poster to go back to the scene default)");
557
+ }
558
+ else if (m.outputs.thumbnail) {
435
559
  const sc = scenes(take);
436
560
  let at;
437
561
  if (typeof m.outputs.thumbnail === "object") {
@@ -449,7 +573,14 @@ export async function render(m, take, outDir, opts = {}) {
449
573
  at = last ? last.start - take.trimBefore + 1.0 : Math.max(0, take.duration - take.trimBefore - 0.5);
450
574
  }
451
575
  thumbnail = path.join(outDir, "thumbnail.png");
452
- ff(["-ss", Math.max(0, at).toFixed(2), "-i", mp4, "-frames:v", "1", thumbnail], log);
576
+ ff(["-ss", Math.max(0, at + cardShift).toFixed(2), "-i", mp4, "-frames:v", "1", thumbnail], log);
577
+ // A launch cut gets a second candidate: this frame with the title on it.
578
+ // The person picks between them (or any still, or their own file) in the
579
+ // window; both are just PNGs, and the poster is whichever is thumbnail.png.
580
+ if (m.mode === "launch" && m.intro) {
581
+ const d = probe(mp4);
582
+ await renderTitledCover(ffmpegBin(), thumbnail, path.join(outDir, "cover-titled.png"), m.intro, d.width, d.height, q.theme);
583
+ }
453
584
  mark("thumbnail");
454
585
  }
455
586
  // --- 5. scene stills -------------------------------------------------------
@@ -470,14 +601,14 @@ export async function render(m, take, outDir, opts = {}) {
470
601
  const at = Math.min(Math.max(0, from + Math.max(0.8, (next - from) / 2)), Math.max(0, end - 0.2));
471
602
  const base = `${String(i + 1).padStart(2, "0")}-${(sce.label || "scene").replace(/[^a-z0-9-]+/gi, "-")}`;
472
603
  const file = path.join(dir, `${base}.png`);
473
- ff(["-ss", at.toFixed(2), "-i", mp4, "-frames:v", "1", file], log);
604
+ ff(["-ss", (at + cardShift).toFixed(2), "-i", mp4, "-frames:v", "1", file], log);
474
605
  stills.push(file);
475
606
  // And the scene's last moment: the caption names what the scene
476
607
  // achieves, and that is usually on screen at the end, not the middle.
477
608
  const atEnd = Math.min(Math.max(from + 0.4, next - 0.6), Math.max(0, end - 0.2));
478
609
  if (atEnd - at > 0.5) {
479
610
  const fe = path.join(dir, `${base}-end.png`);
480
- ff(["-ss", atEnd.toFixed(2), "-i", mp4, "-frames:v", "1", fe], log);
611
+ ff(["-ss", (atEnd + cardShift).toFixed(2), "-i", mp4, "-frames:v", "1", fe], log);
481
612
  stills.push(fe);
482
613
  }
483
614
  }