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/cards.js ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Motion pieces rendered from HTML — title cards and callout overlays.
3
+ *
4
+ * Chromium is the motion renderer: a template page defines `__seek(tMs)`
5
+ * which lays the frame out for that exact moment, and Node steps through
6
+ * the timeline one screenshot per video frame. Deterministic to the pixel —
7
+ * the same take renders the same twice — and styled with the same theme as
8
+ * the caption band, so cards look like Retake, not like a slide deck.
9
+ *
10
+ * All of it is render-time: change a title, `retake render`, seconds.
11
+ */
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { chromium } from "playwright";
16
+ import { execFileSync } from "node:child_process";
17
+ const AMBER = "#e8b04b";
18
+ const ease = "t<0?0:t>1?1:t*t*(3-2*t)"; // smoothstep, in the page
19
+ function cardHtml(w, h, theme, title, subtitle, ms) {
20
+ const titleSize = Math.round(Math.min(w * 0.055, h * 0.14));
21
+ const subSize = Math.round(titleSize * 0.4);
22
+ return `<!doctype html><meta charset="utf-8"><style>
23
+ html,body{margin:0;width:${w}px;height:${h}px;overflow:hidden;background:${theme.background}}
24
+ .wrap{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","Helvetica Neue",Arial,sans-serif}
25
+ .title{font-size:${titleSize}px;font-weight:700;letter-spacing:-.02em;color:${theme.ink};opacity:0}
26
+ .rule{height:${Math.max(3, Math.round(titleSize * 0.07))}px;background:${AMBER};border-radius:99px;margin:${Math.round(titleSize * 0.35)}px 0;width:0}
27
+ .sub{font-size:${subSize}px;color:${theme.ink};opacity:0;font-weight:400}
28
+ </style>
29
+ <div class="wrap"><div class="title"></div><div class="rule"></div><div class="sub"></div></div>
30
+ <script>
31
+ const T = document.querySelector(".title"), R = document.querySelector(".rule"), S = document.querySelector(".sub");
32
+ T.textContent = ${JSON.stringify(title)}; S.textContent = ${JSON.stringify(subtitle ?? "")};
33
+ const DUR = ${ms}, FADE = Math.min(420, DUR * 0.22), OUT = Math.min(320, DUR * 0.18);
34
+ const ez = (t) => (${ease});
35
+ // The window in which nothing moves. The rule keeps growing until FADE*2 —
36
+ // well past the title's own fade — so this cannot be guessed from the
37
+ // outside, and the renderer asks the template rather than assuming.
38
+ window.__still = [FADE * 2, DUR - OUT];
39
+ window.__seek = (ms) => {
40
+ const a = ez(ms / FADE); // entrance
41
+ const o = ez((DUR - ms) / OUT); // exit
42
+ const v = Math.min(a, o);
43
+ T.style.opacity = v; T.style.transform = "translateY(" + (1 - a) * 14 + "px)";
44
+ R.style.width = (ez((ms - FADE * 0.4) / (FADE * 1.6)) * ${Math.round(titleSize * 3.2)}) * Math.min(1, o + 0.0) + "px";
45
+ R.style.opacity = Math.min(1, o + 0.2);
46
+ S.style.opacity = v; S.style.transform = "translateY(" + (1 - a) * 10 + "px)";
47
+ return true;
48
+ };
49
+ </script>`;
50
+ }
51
+ function calloutHtml(w, h, box, label, ms, fontSize, ink) {
52
+ const pad = 10;
53
+ const r = { x: box.x - pad, y: box.y - pad, w: box.width + 2 * pad, h: box.height + 2 * pad };
54
+ const below = r.y + r.h + 18 + fontSize * 2 < h; // label under the ring when there is room
55
+ return `<!doctype html><meta charset="utf-8"><style>
56
+ html,body{margin:0;width:${w}px;height:${h}px;overflow:hidden;background:transparent}
57
+ .ring{position:absolute;left:${r.x}px;top:${r.y}px;width:${r.w}px;height:${r.h}px;border:3px solid ${AMBER};border-radius:14px;box-shadow:0 0 0 3px rgba(0,0,0,.06);opacity:0}
58
+ .tag{position:absolute;left:${Math.max(8, Math.min(w - 8, r.x))}px;top:${below ? r.y + r.h + 12 : Math.max(8, r.y - fontSize * 1.9 - 12)}px;background:${ink};color:#f4f4ef;font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",Arial,sans-serif;font-size:${fontSize}px;font-weight:600;line-height:1;padding:${Math.round(fontSize * 0.55)}px ${Math.round(fontSize * 0.8)}px;border-radius:10px;opacity:0;max-width:${Math.round(w * 0.6)}px;white-space:nowrap}
59
+ </style>
60
+ <div class="ring"></div>${label ? '<div class="tag"></div>' : ""}
61
+ <script>
62
+ const ring = document.querySelector(".ring"), tag = document.querySelector(".tag");
63
+ if (tag) tag.textContent = ${JSON.stringify(label ?? "")};
64
+ const DUR = ${ms}, IN = Math.min(360, DUR * 0.25), OUT = Math.min(280, DUR * 0.2);
65
+ const ez = (t) => (${ease});
66
+ // The tag lands last, at IN*1.6 — later than the ring's own entrance.
67
+ window.__still = [IN * 1.6, DUR - OUT];
68
+ window.__seek = (ms) => {
69
+ const a = ez(ms / IN), o = ez((DUR - ms) / OUT), v = Math.min(a, o);
70
+ ring.style.opacity = v;
71
+ ring.style.transform = "scale(" + (1.12 - 0.12 * a) + ")";
72
+ if (tag) { tag.style.opacity = Math.min(ez((ms - IN * 0.6) / IN), o); tag.style.transform = "translateY(" + (1 - a) * 6 + "px)"; }
73
+ return true;
74
+ };
75
+ </script>`;
76
+ }
77
+ /** Screenshot a __seek()-driven page at every frame; returns the frame dir.
78
+ *
79
+ * A card only MOVES at its ends: it eases in, holds, eases out. Photographing
80
+ * the held middle was paying ~250ms a frame for an unchanging picture — 30s of
81
+ * a launch cut's render, for two cards. So the held frames are copies of the
82
+ * settled one, at a millisecond each.
83
+ *
84
+ * Which frames those are is the template's business, not this function's. It
85
+ * publishes `__still = [from, to]`, because the answer is not guessable from
86
+ * outside: the card's rule keeps growing until twice the title's fade, and a
87
+ * renderer that assumed otherwise froze it half-drawn. A template with no
88
+ * `__still` gets every frame shot — the slow path is the safe default. */
89
+ async function renderFrames(html, w, h, ms, fps, transparent) {
90
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "retake-frames-"));
91
+ const browser = await chromium.launch();
92
+ try {
93
+ const page = await browser.newPage({ viewport: { width: w, height: h } });
94
+ await page.setContent(html, { waitUntil: "load" });
95
+ const frames = Math.max(2, Math.round((ms / 1000) * fps));
96
+ const still = await page.evaluate(() => window.__still);
97
+ // One frame of margin at each end, so rounding never lands the copied
98
+ // frame on a moment the template was still moving.
99
+ const margin = 1000 / fps;
100
+ const held = (t) => !!still && still[1] - still[0] > 2 * margin && t * 1000 >= still[0] + margin && t * 1000 <= still[1] - margin;
101
+ let settled = "";
102
+ for (let i = 0; i < frames; i++) {
103
+ const t = i / fps;
104
+ const file = path.join(dir, `f${String(i).padStart(5, "0")}.png`);
105
+ if (!held(t) || !settled) {
106
+ await page.evaluate((ms2) => window.__seek(ms2), t * 1000);
107
+ await page.screenshot({ path: file, omitBackground: transparent });
108
+ if (held(t) && !settled)
109
+ settled = file;
110
+ }
111
+ else {
112
+ fs.copyFileSync(settled, file);
113
+ }
114
+ }
115
+ return dir;
116
+ }
117
+ finally {
118
+ await browser.close();
119
+ }
120
+ }
121
+ /** A title card as an H.264 segment at the final canvas size, plus (for the
122
+ intro) the settled middle frame as cover.png — the poster. */
123
+ export async function renderCard(ffmpeg, outDir, kind, spec, w, h, fps, theme) {
124
+ const dir = await renderFrames(cardHtml(w, h, theme, spec.title, spec.subtitle, spec.ms), w, h, spec.ms, fps, false);
125
+ const mp4 = path.join(outDir, `.card-${kind}.mp4`);
126
+ execFileSync(ffmpeg, ["-y", "-loglevel", "error", "-framerate", String(fps), "-i", path.join(dir, "f%05d.png"), "-c:v", "libx264", "-crf", "17", "-preset", "veryfast", "-pix_fmt", "yuv420p", mp4], { stdio: "inherit" });
127
+ let cover;
128
+ if (kind === "intro") {
129
+ const frames = fs.readdirSync(dir).sort();
130
+ const settled = frames[Math.min(frames.length - 1, Math.round(frames.length * 0.55))];
131
+ cover = path.join(outDir, "cover.png");
132
+ fs.copyFileSync(path.join(dir, settled), cover);
133
+ }
134
+ fs.rmSync(dir, { recursive: true, force: true });
135
+ return { mp4, cover };
136
+ }
137
+ /** A callout as a VP9 segment with alpha, overlaid onto the take at render. */
138
+ export async function renderCalloutOverlay(ffmpeg, outDir, index, c, w, h, fps, fontSize, ink) {
139
+ const dir = await renderFrames(calloutHtml(w, h, c.box, c.label, c.ms, fontSize, ink), w, h, c.ms, fps, true);
140
+ const webm = path.join(outDir, `.callout-${index}.webm`);
141
+ execFileSync(ffmpeg, ["-y", "-loglevel", "error", "-framerate", String(fps), "-i", path.join(dir, "f%05d.png"), "-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-b:v", "0", "-crf", "28", webm], { stdio: "inherit" });
142
+ fs.rmSync(dir, { recursive: true, force: true });
143
+ return webm;
144
+ }
145
+ /** A cover built from a real frame of the video with the title laid over it —
146
+ a screenshot plus a few words, which is what actually stops a scroll. Not
147
+ a skin of the centred card: the product is the picture. */
148
+ export async function renderTitledCover(ffmpeg, frame, out, spec, w, h, theme) {
149
+ const b64 = fs.readFileSync(frame).toString("base64");
150
+ const titleSize = Math.round(Math.min(w * 0.045, h * 0.1));
151
+ const subSize = Math.round(titleSize * 0.42);
152
+ const html = `<!doctype html><meta charset="utf-8"><style>
153
+ html,body{margin:0;width:${w}px;height:${h}px;overflow:hidden;background:${theme.background};font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","Helvetica Neue",Arial,sans-serif}
154
+ .shot{position:absolute;inset:0;background:url(data:image/png;base64,${b64}) center/cover no-repeat}
155
+ .veil{position:absolute;inset:0;background:linear-gradient(to bottom, rgba(0,0,0,0) 38%, rgba(0,0,0,.14) 58%, ${theme.background} 78%)}
156
+ .txt{position:absolute;left:0;right:0;bottom:${Math.round(h * 0.055)}px;text-align:center;padding:0 ${Math.round(w * 0.08)}px}
157
+ .t{font-size:${titleSize}px;font-weight:700;letter-spacing:-.02em;color:${theme.ink};margin:0}
158
+ .r{height:${Math.max(3, Math.round(titleSize * 0.08))}px;width:${Math.round(titleSize * 2.6)}px;background:${AMBER};border-radius:99px;margin:${Math.round(titleSize * 0.3)}px auto}
159
+ .s{font-size:${subSize}px;color:${theme.ink};margin:0;opacity:.75}
160
+ </style>
161
+ <div class="shot"></div><div class="veil"></div>
162
+ <div class="txt"><p class="t">${escapeHtml(spec.title)}</p><div class="r"></div>${spec.subtitle ? `<p class="s">${escapeHtml(spec.subtitle)}</p>` : ""}</div>`;
163
+ const browser = await chromium.launch();
164
+ try {
165
+ const page = await browser.newPage({ viewport: { width: w, height: h } });
166
+ await page.setContent(html, { waitUntil: "load" });
167
+ await page.screenshot({ path: out });
168
+ return out;
169
+ }
170
+ finally {
171
+ await browser.close();
172
+ }
173
+ }
174
+ const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
175
+ /** @internal The card template, exported so test/cards.test.ts can prove the
176
+ frame-skipping is byte-identical to shooting every frame. */
177
+ export const __test_cardHtml = cardHtml;
package/dist/cli.js CHANGED
@@ -24,11 +24,13 @@ import path from "node:path";
24
24
  import { createRequire } from "node:module";
25
25
  import { spawnSync } from "node:child_process";
26
26
  import { Command } from "commander";
27
- import { loadManifest, warnings } from "./manifest.js";
28
- import { acquireLock, captureHash, record, releaseLock } from "./record.js";
27
+ import { loadManifest, resolve, warnings } from "./manifest.js";
28
+ import { acquireLock, captureHash, EXPENSIVE_TAKE_SECONDS, keepPrevious, keptTakes, lastCaptureSeconds, record, releaseLock, restoreKept, restorePrevious, stashPrevious } from "./record.js";
29
29
  import { check, render } from "./render.js";
30
30
  import { presetNames } from "./presets.js";
31
+ import { applyTidy, mb, planTidy } from "./tidy.js";
31
32
  import { PKG_ROOT, VERSION, entry } from "./paths.js";
33
+ import { SECRET_NAME, writeEnvFile } from "./env.js";
32
34
  // The manifest may reference ${VARS}; load .env before any command reads one.
33
35
  // (`describe` and the UI used to do this individually, so a plain `run` from
34
36
  // the terminal silently saw empty values — which is how a whole take can fail
@@ -41,6 +43,35 @@ catch { /* .env is optional */ }
41
43
  const program = new Command();
42
44
  program.name("retake").description("Rerun the demo instead of re-recording it.").version(VERSION);
43
45
  const say = (l) => process.stdout.write(l + "\n");
46
+ /** Said once, before the camera rolls, when a take is long enough that the
47
+ cheaper ways of getting the same answer are worth knowing about.
48
+
49
+ A person who has already spent eight full-quality captures learning the
50
+ shape of a demo has paid for something `--preset draft` would have given
51
+ them in a quarter of the time. The tools existed; nothing said so at the
52
+ moment the cost was being incurred. This is that moment. */
53
+ export function costHints(m, outDir, opts) {
54
+ const q = resolve(m);
55
+ // Only says a number it has actually seen. A demo nobody has recorded yet
56
+ // gets no estimate, because the honest one does not exist: step count
57
+ // predicts capture time with ~33% error over 31 real takes, and a confident
58
+ // wrong number is worse than none.
59
+ const last = lastCaptureSeconds(outDir);
60
+ if (last === null || last < EXPENSIVE_TAKE_SECONDS)
61
+ return [];
62
+ const draft = q.name === "draft" || opts.preset === "draft";
63
+ const mins = (n) => (n >= 120 ? `${Math.round(n / 60)} min` : `${Math.round(n)}s`);
64
+ const out = [`ℹ the last take of this demo took ${mins(last)} to record.`];
65
+ const ways = [];
66
+ if (!draft)
67
+ ways.push("`--preset draft` records the same layout and timing at a quarter of the pixels — use it while the demo is still changing, then run the real preset once");
68
+ if (!opts.until)
69
+ ways.push("`--until <scene>` stops after one beat, so fixing the ending does not re-record the beginning");
70
+ ways.push("captions, speed, zoom, cards, poster and SIZE are all re-renders — `retake render outputs/" + m.name + " --preset <other>` never needs the camera again");
71
+ for (const w of ways)
72
+ out.push(` · ${w}`);
73
+ return out;
74
+ }
44
75
  program
45
76
  .command("run")
46
77
  .argument("<manifest>", "demo .yaml / .json")
@@ -53,15 +84,30 @@ program
53
84
  .option("--reuse", "reuse the last raw recording if nothing that shapes it changed (re-render only)", false)
54
85
  .option("--gif", "also produce a GIF (overrides the manifest)", false)
55
86
  .option("--until <scene>", "record up to the end of this scene, then stop (iterate on one beat without paying for the whole take)")
87
+ .option("--from <scene>", "start the take at this scene — earlier steps still run, at full speed and off camera, so a demo whose ENDING changed stops costing its beginning")
88
+ .option("--name <name>", "write to outputs/<name> instead of the manifest's name — revise a cut without overwriting the published one")
56
89
  .action(async (file, opts) => {
57
90
  const loaded = loadManifest(file);
58
- let manifest = opts.preset ? { ...loaded.manifest, preset: opts.preset } : loaded.manifest;
91
+ // Asking for a preset means asking for its SHAPE. A manifest that pins its
92
+ // own `viewport` used to win silently, so `--preset draft` recorded at
93
+ // full size and saved nothing — on the one demo where iterating cheaply
94
+ // mattered most, and with a failing "matches draft" check as the only
95
+ // hint. An explicit --preset now drops the pin.
96
+ let manifest = loaded.manifest;
97
+ if (opts.preset) {
98
+ const { viewport: pinned, ...rest } = loaded.manifest;
99
+ manifest = { ...rest, preset: opts.preset };
100
+ if (pinned)
101
+ say(`ℹ --preset ${opts.preset} overrides this manifest's viewport (${pinned.width}×${pinned.height}); recording at the preset's own size`);
102
+ }
59
103
  if (opts.gif)
60
104
  manifest = { ...manifest, outputs: { ...manifest.outputs, gif: true } };
61
105
  if (opts.preset && !presetNames().includes(opts.preset))
62
106
  throw new Error(`unknown preset ${opts.preset} — one of ${presetNames().join(", ")}`);
63
107
  const dir = loaded.dir;
64
- const outDir = path.resolve(opts.out, manifest.name);
108
+ if (opts.name && !/^[a-z0-9-]+$/.test(opts.name))
109
+ throw new Error("--name must be kebab-case");
110
+ const outDir = path.resolve(opts.out, opts.name ?? manifest.name);
65
111
  acquireLock(outDir); // throws if another run owns this folder
66
112
  // Reuse: same capture hash + raw video present → skip the browser.
67
113
  let take;
@@ -76,21 +122,26 @@ program
76
122
  }
77
123
  for (const w of warnings(manifest))
78
124
  say(`⚠ ${w}`);
125
+ if (!take)
126
+ for (const l of costHints(manifest, outDir, opts))
127
+ say(l);
79
128
  say(`▶ ${manifest.title ?? manifest.name} → ${path.relative(process.cwd(), outDir)}${take ? " · reusing last recording" : ""}`);
80
129
  say(`[stage] capture ${take ? "skip" : "start"}`);
81
130
  const t0 = Date.now();
82
131
  if (!take) {
83
- // Wipe everything except the lock.
84
- for (const f of fs.readdirSync(outDir))
85
- if (f !== ".retake-lock")
86
- fs.rmSync(path.join(outDir, f), { recursive: true, force: true });
132
+ // The previous take is moved aside, not deleted: if this recording dies
133
+ // the person still has the one that worked. See stashPrevious.
134
+ stashPrevious(outDir);
87
135
  try {
88
- take = await record(manifest, { until: opts.until, outDir, headed: opts.headed, skipSeed: opts.skipSeed, manifestDir: dir, log: say, locked: true });
136
+ take = await record(manifest, { until: opts.until, from: opts.from, outDir, headed: opts.headed, skipSeed: opts.skipSeed, manifestDir: dir, log: say, locked: true });
89
137
  }
90
138
  catch (e) {
139
+ if (restorePrevious(outDir))
140
+ say("↩ recording failed — your previous take has been put back");
91
141
  releaseLock(outDir);
92
142
  throw e;
93
143
  }
144
+ keepPrevious(outDir, say);
94
145
  }
95
146
  say(`■ take done in ${((Date.now() - t0) / 1000).toFixed(1)}s · ${take.ok ? "all steps ok" : "SOME STEPS FAILED"} · ${take.video ?? "no video"}`);
96
147
  try {
@@ -119,6 +170,83 @@ program
119
170
  if (!take.ok || !take.video)
120
171
  process.exitCode = 2;
121
172
  });
173
+ program
174
+ .command("takes")
175
+ .description("the kept versions of a demo — previous recordings you can go back to")
176
+ .argument("<dir>", "an outputs/<name> dir")
177
+ .action((dir) => {
178
+ const kept = keptTakes(path.resolve(dir));
179
+ if (!kept.length) {
180
+ say(`no kept versions of ${path.basename(dir)} — the current take is the only one.`);
181
+ return;
182
+ }
183
+ say(`kept versions of ${path.basename(dir)} (newest first). Only the recording is stored; everything else re-renders.`);
184
+ for (const k of kept)
185
+ say(` ${k.id} ${k.seconds}s · ${k.steps} steps · ${k.mb} MB`);
186
+ say(`\nGo back to one: retake restore ${path.relative(process.cwd(), dir)} <id>`);
187
+ });
188
+ program
189
+ .command("restore")
190
+ .description("make a kept version the current take again, and re-render it")
191
+ .argument("<dir>", "an outputs/<name> dir")
192
+ .argument("<id>", "a version id from `retake takes`")
193
+ .argument("[manifest]", "manifest to render with (default demos/<name>.yaml)")
194
+ .action(async (dir, id, manifestFile) => {
195
+ const outDir = path.resolve(dir);
196
+ const name = path.basename(outDir);
197
+ acquireLock(outDir);
198
+ try {
199
+ if (!restoreKept(outDir, id))
200
+ throw new Error(`no version "${id}" of ${name} — \`retake takes ${path.relative(process.cwd(), outDir)}\` lists them`);
201
+ say(`restored ${id} as the current take`);
202
+ const file = manifestFile ?? path.join("demos", `${name}.yaml`);
203
+ if (!fs.existsSync(file)) {
204
+ say(`re-render it with: retake render ${path.relative(process.cwd(), outDir)} <manifest>`);
205
+ return;
206
+ }
207
+ const { manifest } = loadManifest(file);
208
+ const take = JSON.parse(fs.readFileSync(path.join(outDir, "take.json"), "utf8"));
209
+ const a = await render(manifest, take, outDir, { log: (l) => { if (!l.startsWith("$"))
210
+ say(l); } });
211
+ for (const f of [a.master, a.mp4, a.thumbnail, a.proofLog])
212
+ if (f)
213
+ say(`✓ ${path.relative(process.cwd(), f)}`);
214
+ }
215
+ finally {
216
+ releaseLock(outDir);
217
+ }
218
+ });
219
+ program
220
+ .command("tidy")
221
+ .description("reclaim disk from outputs — shows what it would remove; nothing goes without --apply")
222
+ .option("-o, --out <dir>", "output root", "outputs")
223
+ .option("--apply", "actually remove them", false)
224
+ .option("--deep", "also remove demo.mp4 (still re-renderable, but it is the deliverable)", false)
225
+ .option("--orphans", "also remove whole folders whose manifest is gone", false)
226
+ .action((opts) => {
227
+ const root = path.resolve(opts.out);
228
+ const plan = planTidy(root, { deep: opts.deep, orphans: opts.orphans, demosDir: path.resolve("demos") });
229
+ if (!plan.bytes) {
230
+ say(`nothing to reclaim in ${path.relative(process.cwd(), root)} — ${mb(plan.keptBytes)} of recordings and deliverables, all of it worth keeping.`);
231
+ return;
232
+ }
233
+ for (const g of plan.groups) {
234
+ say(`${mb(g.bytes).padStart(8)} ${g.what} — ${g.why}`);
235
+ for (const f of g.files.slice(0, 4))
236
+ say(` ${path.relative(root, f)}`);
237
+ if (g.files.length > 4)
238
+ say(` … and ${g.files.length - 4} more`);
239
+ }
240
+ say(`${mb(plan.bytes).padStart(8)} TOTAL reclaimable · ${mb(plan.keptBytes)} kept (raw recordings and take.json — the only things a re-render cannot rebuild)`);
241
+ if (!opts.apply) {
242
+ say(`\nNothing removed. Run again with --apply.`);
243
+ if (!opts.deep)
244
+ say(`--deep also drops demo.mp4 · --orphans also drops folders whose manifest is gone`);
245
+ return;
246
+ }
247
+ const freed = applyTidy(plan);
248
+ say(`\nfreed ${mb(freed)}. Every demo can still be rebuilt with \`retake render outputs/<name>\`.`);
249
+ });
122
250
  program
123
251
  .command("render")
124
252
  .argument("<dir>", "an outputs/<name> dir containing take.json")
@@ -127,9 +255,39 @@ program
127
255
  .option("--scene <label>", "render just one scene to scene-<label>.mp4 (fast, hardware encode)")
128
256
  .option("--gif", "also produce a GIF", false)
129
257
  .option("--force", "ignore the render cache", false)
258
+ .option("--out <dir>", "write the render into a NEW directory, leaving the original untouched")
130
259
  .action(async (dir, manifestFile, opts) => {
131
260
  const takePath = path.join(dir, "take.json");
132
261
  const take = JSON.parse(fs.readFileSync(takePath, "utf8"));
262
+ // Revising a published cut should not destroy it. Copy the take (and the
263
+ // raw video it points at) into the new directory and render there.
264
+ if (opts.out) {
265
+ const dest = path.resolve(opts.out);
266
+ fs.mkdirSync(dest, { recursive: true });
267
+ for (const f of fs.readdirSync(path.resolve(dir))) {
268
+ if (/^(demo|master)\.mp4$|^demo\.gif$|\.png$|^proof-log\.md$|^facts\.json$|^stills$/.test(f))
269
+ continue;
270
+ const from = path.join(path.resolve(dir), f);
271
+ if (fs.statSync(from).isDirectory())
272
+ continue;
273
+ fs.copyFileSync(from, path.join(dest, f));
274
+ }
275
+ if (take.video) {
276
+ // The take points at its raw video by absolute path; a copied
277
+ // directory has to point at its own copy or it renders the original.
278
+ const base = path.basename(take.video);
279
+ const copied = path.join(dest, base);
280
+ if (fs.existsSync(copied))
281
+ take.video = copied;
282
+ else {
283
+ fs.copyFileSync(take.video, copied);
284
+ take.video = copied;
285
+ }
286
+ fs.writeFileSync(path.join(dest, "take.json"), JSON.stringify(take, null, 2));
287
+ }
288
+ say(`▶ rendering into ${path.relative(process.cwd(), dest)} — ${path.relative(process.cwd(), path.resolve(dir))} is untouched`);
289
+ dir = dest;
290
+ }
133
291
  const name = path.basename(path.resolve(dir));
134
292
  const mf = manifestFile ?? guessManifest(name);
135
293
  const loaded = loadManifest(mf);
@@ -316,6 +474,118 @@ program
316
474
  say(`Keep ${o.ui} open while it works — the plan, the run and the video appear there.`);
317
475
  say("Retake will not start your app from outside its own window unless you add RETAKE_ALLOW_START=1 above.");
318
476
  });
477
+ /** Read one line from stdin. On a terminal, `hide` switches to raw mode so the
478
+ characters are not echoed; on a pipe, lines are simply consumed in order. */
479
+ let stdinBuf = "";
480
+ function promptLine(label, hide) {
481
+ process.stdout.write(label);
482
+ const tty = !!process.stdin.isTTY;
483
+ const raw = hide && tty;
484
+ if (raw)
485
+ process.stdin.setRawMode(true);
486
+ process.stdin.resume();
487
+ return new Promise((resolve) => {
488
+ const finish = (line) => {
489
+ process.stdin.off("data", onData);
490
+ if (raw)
491
+ process.stdin.setRawMode(false);
492
+ process.stdin.pause();
493
+ if (raw)
494
+ process.stdout.write("\n");
495
+ resolve(line.trim());
496
+ };
497
+ const take = () => {
498
+ const i = stdinBuf.search(/\r|\n/);
499
+ if (i < 0)
500
+ return false;
501
+ const line = stdinBuf.slice(0, i);
502
+ stdinBuf = stdinBuf.slice(i + 1).replace(/^\n/, "");
503
+ finish(line);
504
+ return true;
505
+ };
506
+ const onData = (d) => {
507
+ const sIn = d.toString();
508
+ if (raw) {
509
+ for (const ch of sIn) {
510
+ if (ch === "\u0003") {
511
+ process.stdout.write("\n");
512
+ process.exit(130);
513
+ }
514
+ if (ch === "\u007f" || ch === "\b") {
515
+ stdinBuf = stdinBuf.slice(0, -1);
516
+ continue;
517
+ }
518
+ stdinBuf += ch;
519
+ }
520
+ }
521
+ else
522
+ stdinBuf += sIn;
523
+ take();
524
+ };
525
+ if (take())
526
+ return;
527
+ process.stdin.on("data", onData);
528
+ });
529
+ }
530
+ program
531
+ .command("secret")
532
+ .description("put a demo account into this workspace's .env without opening the file — typed here, hidden, kept on this machine only")
533
+ .argument("<names...>", "variable names, e.g. APP_USER APP_PASSWORD APP_TOTP_SECRET")
534
+ .action(async (names) => {
535
+ const bad = names.filter((n) => !SECRET_NAME.test(n));
536
+ if (bad.length)
537
+ throw new Error(`names look like APP_PASSWORD — not: ${bad.join(", ")}`);
538
+ say(`Stays on this computer: written to ${path.join(process.cwd(), ".env")} (readable by you only), never sent anywhere, never shown to an agent. Use a demo account.`);
539
+ const values = {};
540
+ for (const n of names) {
541
+ const v = await promptLine(`${n}${/PASS|SECRET|TOKEN|KEY|PIN|OTP/.test(n) ? " (hidden)" : ""}: `, /PASS|SECRET|TOKEN|KEY|PIN|OTP/.test(n));
542
+ if (v)
543
+ values[n] = v;
544
+ }
545
+ const set = writeEnvFile(process.cwd(), values);
546
+ if (!set.length) {
547
+ say("nothing saved");
548
+ return;
549
+ }
550
+ say(`✓ ${set.join(", ")} saved to ${path.join(process.cwd(), ".env")} (mode 600). A manifest references them as \${NAME} with secret: true — the values never go on camera.`);
551
+ });
552
+ program
553
+ .command("signin")
554
+ .description("sign in by hand once — 2FA, SSO, captcha, anything — and keep the session for every later take")
555
+ .argument("<manifest>", "the demo whose auth.storageState should be saved")
556
+ .action(async (file) => {
557
+ const { manifest, dir } = loadManifest(file);
558
+ if (!manifest.auth?.storageState)
559
+ throw new Error(`${file} has no auth.storageState — add:\n auth:\n storageState: .auth/${manifest.name}.json\n maxAgeHours: 24`);
560
+ const { resolve, expandEnv } = await import("./manifest.js");
561
+ const { chromium } = await import("playwright");
562
+ const q = resolve(manifest);
563
+ const statePath = path.resolve(dir, expandEnv(manifest.auth.storageState));
564
+ const browser = await chromium.launch({ headless: false });
565
+ const context = await browser.newContext({ viewport: q.viewport });
566
+ const page = await context.newPage();
567
+ await page.goto(manifest.url, { waitUntil: "domcontentloaded" }).catch(() => { });
568
+ say(`A browser is open at ${manifest.url}.`);
569
+ say(`Sign in there — codes, SSO, captcha, whatever it asks. When you can see the signed-in app, come back here and press Enter.`);
570
+ say(`(Nothing is recorded. Only the session is kept, in ${path.relative(process.cwd(), statePath)}, good for ${manifest.auth.maxAgeHours}h.)`);
571
+ await new Promise((ok) => { process.stdin.resume(); process.stdin.once("data", () => ok()); });
572
+ try {
573
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
574
+ await context.storageState({ path: statePath });
575
+ fs.chmodSync(statePath, 0o600);
576
+ }
577
+ catch (e) {
578
+ // Closing the window instead of pressing Enter is the usual way here:
579
+ // say so plainly rather than showing a stack, and leave no orphan.
580
+ const why = page.isClosed() ? "the browser window was closed before the session could be saved" : String(e.message ?? e);
581
+ throw new Error(`Could not save the session — ${why}.\nRun \`retake signin ${file}\` again, and this time leave the window open and press Enter here.`);
582
+ }
583
+ finally {
584
+ await browser.close().catch(() => { });
585
+ }
586
+ say(`✓ session saved → ${path.relative(process.cwd(), statePath)}. Takes of ${manifest.name} now start signed in; run this again when it goes stale.`);
587
+ process.exit(0);
588
+ });
319
589
  program
320
590
  .command("dry")
321
591
  .description("run the manifest with no camera and report what would fail")
@@ -368,6 +638,17 @@ program
368
638
  say(`· ${i.title}${i.length ? ` (${i.length})` : ""}`);
369
639
  say(`✓ ${path.relative(process.cwd(), file)}`);
370
640
  });
641
+ program
642
+ .command("contact")
643
+ .argument("<dir>", "an outputs/<name> dir")
644
+ .option("--every <seconds>", "seconds between frames", "2")
645
+ .option("-w, --width <px>", "width of each cell", "420")
646
+ .description("one timestamped contact sheet of the finished video — the artifact for signing a cut off")
647
+ .action(async (dir, opts) => {
648
+ const { contactSheet } = await import("./render.js");
649
+ const out = contactSheet(path.resolve(dir), Number(opts.every), Number(opts.width), say);
650
+ say(`✓ ${path.relative(process.cwd(), out)}`);
651
+ });
371
652
  program
372
653
  .command("gif")
373
654
  .argument("<dir>", "an outputs/<name> dir")
@@ -384,12 +665,14 @@ program
384
665
  .description("list quality presets")
385
666
  .action(async () => {
386
667
  const { PRESETS } = await import("./presets.js");
387
- say(`${"preset".padEnd(16)} ${"video".padEnd(11)} ${"page".padEnd(11)} fps layout`);
668
+ const { bandHeightFor } = await import("./captions.js");
669
+ say(`${"preset".padEnd(16)} ${"video".padEnd(12)} ${"page".padEnd(11)} fps layout`);
388
670
  for (const p of Object.values(PRESETS)) {
389
- const video = p.layout === "card" ? `${p.width}×${p.height}` : `${p.width}×${p.height + (p.layout === "band" ? p.bandHeight : 0)}`;
390
- say(`${p.name.padEnd(16)} ${video.padEnd(11)} ${String(p.width + "×" + p.height).padEnd(11)} ${String(p.fps).padEnd(4)} ${p.layout.padEnd(5)} — ${p.description}`);
671
+ const band = p.layout === "band" || p.layout === "card" ? p.bandHeight : 0;
672
+ const page = `${p.width}×${p.height - band}`;
673
+ say(`${p.name.padEnd(16)} ${String(p.width + "×" + p.height).padEnd(12)} ${page.padEnd(11)} ${String(p.fps).padEnd(4)} ${p.layout.padEnd(5)} — ${p.description}`);
391
674
  }
392
- say(`\nvideo = page + caption band. A manifest's \`viewport\` replaces the page size (the video follows it).`);
675
+ say(`\nOne preset, one size: the caption band sits inside the canvas, so every take at a preset comes out identical and no player letterboxes it. A manifest's \`viewport\` overrides the page (validate warns). \`captions: false\` → the page fills the frame.`);
393
676
  });
394
677
  program
395
678
  .command("validate")
@@ -400,10 +683,12 @@ program
400
683
  const q = resolve(manifest);
401
684
  // The video is the page area plus the caption band, so say the real number
402
685
  // here rather than letting it surprise someone after a two-minute take.
686
+ const { bandHeightFor } = await import("./captions.js");
403
687
  const outW = q.layout === "card" ? q.width : q.viewport.width;
404
- const outH = (q.layout === "card" ? q.height : q.viewport.height) + (q.layout === "band" ? q.bandHeight : 0);
688
+ const band = bandHeightFor(q, manifest.steps.flatMap((s) => (s.action === "scene" && s.caption ? [s.caption] : [])));
689
+ const outH = (q.layout === "card" ? q.height : q.viewport.height) + (q.layout === "band" ? band : 0);
405
690
  say(`ok: ${manifest.name} · ${manifest.steps.length} steps · ${manifest.steps.filter((s) => s.action === "scene").length} scenes`);
406
- say(` ${q.name} · video ${outW}×${outH}${q.layout === "band" ? ` (page ${q.viewport.width}×${q.viewport.height} + ${q.bandHeight}px caption band)` : ""} @ ${q.fps}fps${q.gif ? ` · gif ${q.gif.width}px` : ""}`);
691
+ say(` ${q.name} · video ${outW}×${outH}${q.layout === "band" ? (band ? ` (page ${q.viewport.width}×${q.viewport.height} + a ${band}px caption band)` : " (captions off — the page fills the frame)") : ""} @ ${q.fps}fps${q.gif ? ` · gif ${q.gif.width}px` : ""}`);
407
692
  for (const w of warnings(manifest))
408
693
  say(`⚠ ${w}`);
409
694
  });