retake-demos 0.2.1 → 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/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
  }
package/dist/ui/chat.html CHANGED
@@ -56,7 +56,9 @@
56
56
  /* the video */
57
57
  .pin { display:none; }
58
58
  .pin.show { display:block; }
59
- .pin video { width:100%; height:auto; max-height:62vh; border-radius:12px; background:#000; display:block; border:1px solid var(--rule2); box-shadow:0 10px 30px rgba(24,28,24,.10); }
59
+ /* The element takes the video's own shape: width follows the height cap, so
60
+ a 1.6:1 take gets no black bars at the sides. */
61
+ .pin video { width:auto; max-width:100%; height:auto; max-height:62vh; margin:0 auto; border-radius:12px; background:#000; display:block; border:1px solid var(--rule2); box-shadow:0 10px 30px rgba(24,28,24,.10); }
60
62
  .under { display:flex; align-items:center; gap:8px; margin-top:10px; font-size:13px; color:var(--muted); flex-wrap:wrap; }
61
63
  .under a, .under button { color:var(--ink); text-decoration:none; border:1px solid var(--rule2); background:#fff; border-radius:999px; padding:5px 12px; cursor:pointer; font-size:13px; }
62
64
  .under a:hover, .under button:hover { border-color:var(--ink); }
@@ -68,6 +70,13 @@
68
70
  .stills { display:flex; gap:8px; margin-top:10px; overflow-x:auto; padding-bottom:4px; }
69
71
  .stills img { height:64px; border-radius:7px; border:1px solid var(--rule2); box-shadow:0 2px 6px rgba(24,28,24,.06); cursor:pointer; flex:none; }
70
72
  .stills img:hover { border-color:var(--ink); }
73
+ .stills img.poster, .coverrow img.poster { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent); }
74
+ .coverrow { display:flex; gap:8px; align-items:center; margin-top:10px; flex-wrap:wrap; }
75
+ .coverrow img { height:64px; border-radius:7px; border:1px solid var(--rule2); cursor:pointer; }
76
+ .coverrow img:hover { border-color:var(--ink); }
77
+ .coverrow .lab { font:11px ui-monospace, Menlo, monospace; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); }
78
+ .coverrow label.up { font-size:12.5px; color:var(--muted); border:1px dashed var(--rule2); border-radius:8px; padding:8px 12px; cursor:pointer; }
79
+ .coverrow label.up:hover { border-color:var(--ink); color:var(--ink); }
71
80
  /* the library: one quiet card at the video's side */
72
81
  .lib { border:1px solid var(--rule); background:var(--card); border-radius:16px; padding:14px 10px; position:sticky; top:14px; line-height:1.4; }
73
82
  .lib h2 { font:11px ui-monospace, Menlo, monospace; letter-spacing:.07em; text-transform:uppercase; color:var(--muted); font-weight:500; margin:2px 0 10px 12px; }
@@ -140,15 +149,17 @@
140
149
  <option value="0.75">0.75×</option><option value="0.85">0.85×</option><option value="1" selected>1×</option><option value="1.15">1.15×</option><option value="1.3">1.3×</option>
141
150
  </select>
142
151
  <a id="vidDl" download>Download</a>
143
- <button id="vidFolder">Show in Finder</button>
144
- <a id="vidEdit" href="/classic">Edit</a>
152
+ <button id="vidFolder">Show in folder</button>
153
+ <button id="vidCover" title="Make the frame showing right now the poster">Use this frame</button>
154
+ <!-- The workshop (/classic) still exists but is not advertised until it has been tested as carefully as this page. -->
145
155
  </div>
156
+ <div class="coverrow" id="coverrow"></div>
146
157
  <div class="stills" id="stills"></div>
147
158
  </div>
148
159
  <div class="empty" id="empty" style="display:none">
149
160
  <div class="mark"><i></i><i></i><i></i><i></i></div>
150
161
  <h2>Nothing recorded yet</h2>
151
- <p>Ask your agent for a demo — <code>record a demo of my app showing the sign-up flow</code> —<br>or build one by hand in the <a href="/classic">workshop</a>. It will appear here.<br><a href="/guide">How Retake works</a> — one page, two minutes.</p>
162
+ <p>Ask your agent for a demo — <code>record a demo of my app showing the sign-up flow</code> —<br>It will appear here.<br><a href="/guide">How Retake works</a> — one page, two minutes.</p>
152
163
  </div>
153
164
  </div>
154
165
  <aside class="lib" id="lib" style="display:none">
@@ -164,6 +175,19 @@ let current = null, demos = [], settings = null;
164
175
  async function api(u, o) { const r = await fetch(u, o); const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || r.statusText); return j; }
165
176
 
166
177
  const REPO = 'https://github.com/glebbogachev00/retake';
178
+ async function setCover(body, marked) {
179
+ try {
180
+ await api('/api/cover/' + current, { method: 'POST', body: JSON.stringify(body) });
181
+ // Cache-bust: the poster keeps its filename, so the browser needs telling.
182
+ const v = '?v=' + Date.now();
183
+ $('vid').poster = `/out/${current}/thumbnail.png` + v;
184
+ for (const i of document.querySelectorAll('.stills img, .coverrow img')) i.classList.remove('poster');
185
+ if (marked) marked.classList.add('poster');
186
+ const meta = $('vidMeta').textContent;
187
+ $('vidMeta').textContent = meta.replace(/ · poster set$/, '') + ' · poster set';
188
+ } catch (e) { $('vidMeta').textContent = 'could not set the poster: ' + e.message; }
189
+ }
190
+
167
191
  document.getElementById('gremlinBtn').onclick = async () => {
168
192
  const veil = document.createElement('div');
169
193
  veil.className = 'veil';
@@ -246,13 +270,30 @@ async function pin(name) {
246
270
  $('vidMeta').title = failed?.screen ? 'On screen at that moment: ' + failed.screen : '';
247
271
  $('vidDl').href = `/out/${name}/demo.mp4${q}`; $('vidDl').setAttribute('download', name + '.mp4');
248
272
  $('vidFolder').onclick = () => api('/api/open/' + name, { method: 'POST' }).catch(() => {});
249
- $('vidEdit').href = '/classic#' + encodeURIComponent(name);
273
+ $('vidCover').onclick = () => setCover({ at: $('vid').currentTime });
250
274
  const tempo = (demos.find((x) => x.name === name)?.settings?.tempo) || 1;
251
275
  $('vidSpeed').value = String(tempo);
252
276
  $('vid').playbackRate = 1; // the file already carries its tempo
277
+ // The poster is the first thing anyone sees of a video, so it is chosen
278
+ // here, not guessed: any scene still, any frame of the take, a generated
279
+ // cover, or the person's own image. All of it just rewrites thumbnail.png,
280
+ // so it costs no re-render.
253
281
  const st = (d.files || []).filter((f) => f.startsWith('stills/')).sort();
254
- $('stills').innerHTML = st.map((f) => `<img src="/out/${name}/${f}${q}" title="${esc(f.slice(7, -4))}" loading="lazy">`).join('');
255
- for (const img of $('stills').querySelectorAll('img')) img.onclick = () => window.open(img.src, '_blank');
282
+ $('stills').innerHTML = st.map((f) => `<img src="/out/${name}/${f}${q}" title="Make this the poster — ${esc(f.slice(7, -4))}" loading="lazy" data-still="${esc(f.slice(7))}">`).join('');
283
+ for (const img of $('stills').querySelectorAll('img')) img.onclick = () => setCover({ still: img.dataset.still }, img);
284
+
285
+ const covers = (d.files || []).filter((f) => f === 'cover.png' || f === 'cover-titled.png');
286
+ $('coverrow').innerHTML =
287
+ (covers.length ? `<span class="lab">poster</span>` + covers.map((f) => `<img src="/out/${name}/${f}${q}" title="${f === 'cover.png' ? 'The title card' : 'This frame, with the title on it'}" data-file="${f}">`).join('') : `<span class="lab">poster</span>`) +
288
+ `<label class="up">Upload an image<input type="file" accept="image/png,image/jpeg,image/webp" hidden id="coverUp"></label>`;
289
+ for (const img of $('coverrow').querySelectorAll('img[data-file]')) img.onclick = () => setCover({ file: img.dataset.file }, img);
290
+ $('coverUp').onchange = (e) => {
291
+ const f = e.target.files?.[0];
292
+ if (!f) return;
293
+ const r = new FileReader();
294
+ r.onload = () => setCover({ dataUrl: r.result });
295
+ r.readAsDataURL(f);
296
+ };
256
297
  $('pin').classList.add('show');
257
298
  drawDemos();
258
299
  return true;
@@ -286,7 +327,7 @@ async function drawDemos() {
286
327
  const dot = p.lastTake ? (p.lastTake.ok && !p.lastTake.partial ? 'ok' : 'bad') : '';
287
328
  const when = p.lastTake ? new Date(p.lastTake.finishedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : 'no take yet';
288
329
  el.innerHTML = `<span class="dot ${dot}"></span><span class="nm">${esc(p.name)}</span><small>${esc(when)}</small><button class="zap" title="Archive this demo">✕</button>`;
289
- el.onclick = () => { if (p.lastTake) pin(p.name); else location.href = '/classic#' + encodeURIComponent(p.name); };
330
+ el.onclick = () => { if (p.lastTake) pin(p.name); };
290
331
  el.querySelector('.zap').onclick = async (ev) => {
291
332
  ev.stopPropagation();
292
333
  // Archive, not destroy — but moving someone's work still deserves a beat.
@@ -356,6 +397,41 @@ $('vidSpeed').addEventListener('change', async () => {
356
397
  } catch (e) { $('vidMeta').textContent = `${current} · could not re-render: ${e.message}`; }
357
398
  });
358
399
 
400
+ // ---- secrets: the agent asks by name; the value goes to .env, never to it ----
401
+ const secretOpen = new Set();
402
+ function askSecrets(r) {
403
+ if (!r || secretOpen.has(r.id)) return;
404
+ secretOpen.add(r.id);
405
+ const isSecret = (n) => /PASS|SECRET|TOKEN|KEY|PIN|OTP/.test(n);
406
+ const veil = document.createElement('div');
407
+ veil.className = 'veil';
408
+ veil.innerHTML = `<div class="modal" role="dialog" aria-modal="true" style="width:min(92vw,460px)">
409
+ <h3>Your agent needs a sign-in</h3>
410
+ <p>${esc(r.why || 'The demo starts behind a login.')}</p>
411
+ <p style="font-size:13px;color:var(--muted)"><b>This stays on your computer.</b> What you type is written to one local file — <code>${esc(settings?.envPath || '.env')}</code>, readable by you only — so Retake can sign in during recording. It is not sent to the agent, to any server, or to us; the agent only learns that the names are set. Use a <b>demo account</b>: the result is a video.</p>
412
+ <form id="secForm" autocomplete="off">
413
+ ${r.names.map((n) => `<label style="display:block;margin:10px 0 4px;font-size:12.5px;letter-spacing:.04em;color:var(--muted)">${esc(n)}</label>
414
+ <input name="${esc(n)}" type="${isSecret(n) ? 'password' : 'text'}" autocomplete="${isSecret(n) ? 'new-password' : 'off'}" style="width:100%;border:1px solid var(--rule2);border-radius:10px;padding:10px 12px;font:inherit" />`).join('')}
415
+ <div class="row" style="margin-top:16px">
416
+ <button class="btn" type="button" data-no>Not now</button>
417
+ <button class="btn" type="submit" style="background:var(--accent);border-color:var(--accent);color:#fff">Save to .env</button>
418
+ </div>
419
+ </form>
420
+ </div>`;
421
+ const close = () => { veil.remove(); secretOpen.delete(r.id); };
422
+ veil.querySelector('[data-no]').onclick = close;
423
+ veil.querySelector('#secForm').onsubmit = async (e) => {
424
+ e.preventDefault();
425
+ const values = {};
426
+ for (const n of r.names) { const v = veil.querySelector(`input[name="${n}"]`).value; if (v.trim()) values[n] = v; }
427
+ if (!Object.keys(values).length) return;
428
+ try { await api('/api/secrets/fill', { method: 'POST', body: JSON.stringify({ id: r.id, values }) }); close(); }
429
+ catch (err) { veil.querySelector('h3').textContent = 'Could not save: ' + err.message; }
430
+ };
431
+ document.body.appendChild(veil);
432
+ setTimeout(() => veil.querySelector('input')?.focus(), 40);
433
+ }
434
+
359
435
  // ---- the live feed: an agent working shows up here as it works -------------
360
436
  function watchActivity() {
361
437
  const es = new EventSource('/api/activity/stream');
@@ -373,8 +449,10 @@ function watchActivity() {
373
449
  if (window.__boot && d.boot && d.boot !== window.__boot) location.reload();
374
450
  window.__boot = d.boot || window.__boot;
375
451
  apply(d);
452
+ for (const r of d.secrets || []) askSecrets(r);
376
453
  });
377
454
  es.addEventListener('line', (ev) => apply(JSON.parse(ev.data)));
455
+ es.addEventListener('secrets', (ev) => askSecrets(JSON.parse(ev.data)));
378
456
  es.addEventListener('finished', async (ev) => { apply(JSON.parse(ev.data)); await drawDemos(); await newest(); setTimeout(() => $('live').classList.remove('show'), 8000); });
379
457
  es.onerror = () => { es.close(); setTimeout(watchActivity, 3000); };
380
458
  }
@@ -50,7 +50,7 @@
50
50
 
51
51
  <section>
52
52
  <h3>2 · Judge the take</h3>
53
- <p>The video lands here — newest on top, one still per scene under it. <b>Download</b> for the file, <b>Edit</b> to open its steps and knobs in the workshop.</p>
53
+ <p>The video lands here — newest on top, one still per scene under it. <b>Download</b> for the file, <b>Show in folder</b> for everything else — the stills, the proof log, the manifest.</p>
54
54
  </section>
55
55
 
56
56
  <section>
@@ -75,8 +75,9 @@
75
75
  <section>
76
76
  <h3>Good to know</h3>
77
77
  <ul>
78
+ <li><b>Two kinds of take.</b> A <i>demo</i> proves how something works — a client walkthrough, a clip for a pull request, a lesson — and keeps everything but your product out of the frame. That is the default. A <i>launch</i> video presents your product in public, and only then do a title card, music and a closing card belong. Your agent asks where it is going; if you shrug, it makes a demo.</li>
78
79
  <li>Retake never starts your app without you saying so.</li>
79
- <li>Passwords stay in <code>.env</code> — never in a demo file, never on camera.</li>
80
+ <li><b>Apps behind a login:</b> when the agent needs a sign-in, a form appears here asking for a demo account. What you type is written to one local file (<code>.env</code> in your workspace) and never sent to the agent or anywhere else. The sign-in runs before the camera and is cut off; the session is kept, so later takes skip it. For codes from your phone, SSO or a captcha: <code>retake signin demos/&lt;name&gt;.yaml</code> — log in once by hand, Retake keeps the session.</li>
80
81
  <li>A failed step names what was on screen at the time — read the proof log before guessing.</li>
81
82
  </ul>
82
83
  </section>
@@ -402,7 +402,7 @@ function fillSettings(d) {
402
402
  const st = d.settings || {};
403
403
  $('sPreset').value = st.preset || 'post-landscape';
404
404
  $('sLayout').value = (st.layout === 'band' || !st.layout) ? 'default' : st.layout;
405
- $('sCamera').value = st.camera || 'auto';
405
+ $('sCamera').value = st.camera || 'static';
406
406
  $('sCursor').value = st.cursor || 'default';
407
407
  paintAction();
408
408
  }
@@ -499,7 +499,7 @@ function openScene(label, t) {
499
499
  sceneOpen = label;
500
500
  $('sceneWho').textContent = `Scene “${label}” · ${t.toFixed(1)}s`;
501
501
  $('sceneCap').value = sc?.caption || '';
502
- $('sceneCam').value = typeof sc?.camera === 'number' ? String(sc.camera) : (sc?.camera || 'auto');
502
+ $('sceneCam').value = typeof sc?.camera === 'number' ? String(sc.camera) : (sc?.camera || 'static');
503
503
  $('sceneEdit').classList.add('show');
504
504
  $('scrub').querySelectorAll('b').forEach((b) => b.classList.toggle('on', b.dataset.label === label));
505
505
  const v = $('stage').querySelector('video'); if (v) { v.currentTime = t; v.pause(); }