retake-demos 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -14,6 +14,31 @@ clone of this repo the same commands run from source as `npm run retake -- …`.
14
14
  The MCP route is better than the CLI when it exists: `retake install` registers
15
15
  the tools and the skill with Claude Code, so the loop below becomes tool calls.
16
16
 
17
+ ## What a change costs
18
+
19
+ Reaching for a new recording when a re-render would do is the one mistake
20
+ that wastes real time. Almost nothing needs the camera twice.
21
+
22
+ | You want to change | Do this | Cost |
23
+ |---|---|---|
24
+ | size, shape, format | `retake render <dir> --preset <other>` | seconds |
25
+ | caption words, speed, zoom, cards, music | edit the manifest, `retake render` | seconds |
26
+ | **where** a caption or still sits | `nudge: <ms>` on the scene, then render | seconds |
27
+ | the ENDING of a demo | `retake run … --from <scene>` | the tail only |
28
+ | one beat, while iterating | `retake run … --until <scene>` | the head only |
29
+ | what the demo actually DOES | full `retake run` | the whole thing |
30
+
31
+ `--from` runs the earlier steps at full speed and off camera so the app
32
+ reaches the right state, then starts recording there. A seven-minute demo
33
+ whose last third changed costs ninety seconds.
34
+
35
+ Housekeeping, since outputs grow: `retake takes <dir>` lists the kept
36
+ versions of a demo and `retake restore <dir> <id>` puts one back;
37
+ `retake tidy` reports reclaimable disk and removes nothing without
38
+ `--apply`. Neither ever touches a raw recording or a take.json — those are
39
+ the only things a re-render cannot rebuild.
40
+
41
+
17
42
  ## Starting from nothing (the person pasted the repo link)
18
43
 
19
44
  ```
@@ -89,12 +114,36 @@ use Retake's drafting, or draft itself; both are fine.
89
114
 
90
115
  Full reference: `README.md` → "Demo-as-code". The short version:
91
116
 
117
+ - **Wait for the RESULT, and know the three ways a bare selector lies.** It
118
+ resolves the moment the element exists, which is wrong when (a) the element
119
+ is the PREVIOUS action's banner still on screen — use
120
+ `{ action: waitFor, selector: ".landed", gone: true }` and wait for it to
121
+ leave; (b) the element is a shell that streams its text in — use
122
+ `minChars: 40`; (c) the element is there but still changing — use
123
+ `stableMs: 500`, which resolves when its subtree has been quiet that long.
124
+ Each of these cost a real take before it existed.
92
125
  - **Wait for results, not timers.** After any click that loads or computes
93
126
  something, `{ action: waitFor, selector: <thing that appears> }`. A fixed
94
127
  `wait` is for pacing only.
95
128
  - **Selectors must be unique.** Playwright runs strict: a selector matching two
96
129
  elements fails. Prefer ids, `data-testid`, `aria-label`, then
97
130
  `button:has-text("…")`; append `>> nth=0` only when duplicates are intended.
131
+ - **`run --name <x>`** records into outputs/<x> without touching the
132
+ original; **`render <dir> --out <newdir>`** re-renders a copy and leaves
133
+ the published cut alone. Revising is never overwriting.
134
+ - **A failed step writes `failed-step.png`** (full page, the exact moment)
135
+ and the failing URL into the log and take.json. Read the picture first.
136
+ - **`manifest.used.yaml`** sits in every output — the exact manifest this
137
+ video came from, not whatever the file on disk says today.
138
+ - **Size is a render-time choice.** One preset = one output size, always
139
+ (the caption band sits inside the canvas). Any take renders at any preset:
140
+ `retake render outputs/<name> --preset post-vertical`. Never re-record to
141
+ change a shape, and never set `viewport` — `validate` warns because it
142
+ makes one demo a different shape from the rest.
143
+ - **`retake contact outputs/<name>`** — one timestamped grid of the whole
144
+ video. This is how you review a cut without pulling frames by hand, and
145
+ how you check a caption is still true in the middle of its own scene.
146
+ Launch cuts write `contact.png` automatically.
98
147
  - **The poster** is `thumbnail.png`. `outputs.thumbnail: { scene: <label> }` picks the scene; the person can override it in the window from any frame, a generated cover, or their own image (`POST /api/cover/<name>`). Launch mode also writes `cover.png` (the title card) and `cover-titled.png` (a real frame with the title on it) as candidates. Do not re-render to change a poster.
99
148
  - **`mode:`** — `demo` (default) proves the interaction: nothing in the frame
100
149
  but the product. A CLIENT WALKTHROUGH IS A DEMO. `launch` presents the
@@ -211,3 +260,18 @@ recording-relevant changed, and the UI's one button works this out for you.
211
260
  adds ~8s to every take and competes with the app being recorded. Ladder:
212
261
  `draft` while fixing, `preview-fast` to check final framing, a post preset to
213
262
  ship. Never ship a draft.
263
+
264
+ ## Every release gets a note a non-developer can read
265
+
266
+ `site/releases.json` is the one list of what shipped. The landing page and the
267
+ Retake window both read it — there is no second copy to update.
268
+
269
+ When a release changes anything a person can see, add an entry: a version, a
270
+ date, a headline, and a few lines. Write them for the person using Retake, not
271
+ for whoever wrote the code. Name no files. Name no flag they will not type. Do
272
+ not write "refactor", "regression", "schema" or "pipeline". If a line only
273
+ makes sense to someone who has read the source, it is not a release note —
274
+ it belongs in the commit message, which is where that audience already is.
275
+
276
+ Good: "You choose the cover image. Click any frame to make it the one people
277
+ see first." Bad: "Poster selection is now persisted via a marker file."
package/README.md CHANGED
@@ -65,6 +65,7 @@ outputs/<name>/
65
65
  cover.png launch mode: the title card, settled
66
66
  cover-titled.png launch mode: a real frame with the title over it
67
67
  proof-log.md result, shot list, every step's timing, pass/fail, what was on screen when it failed
68
+ contact.png launch cuts: one timestamped grid of the whole video (`retake contact`)
68
69
  take.json the raw timeline the renderer reads
69
70
  ```
70
71
 
@@ -118,9 +119,11 @@ outputs:
118
119
 
119
120
  The full template is [`demos/example.yaml`](demos/example.yaml); `retake init` copies it into a new workspace.
120
121
 
121
- **Steps** — `click`, `type`, `fill`, `hover`, `drag`, `scroll`, `keyboard`, `navigate`, `wait`, `waitFor`, `evaluate`, `upload`, `download`, `screenshot`, `scene`. Every step takes `pauseAfter`, `waitFor`, `timeout`, `secret`. Targets are a selector, or a point — `{x, y}` or `{selector, dx, dy}` — for canvases and SVG editors. `${VAR}` expands from `.env` and fails loudly when unset.
122
+ **Steps** — `click`, `type`, `fill`, `select`, `hover`, `drag`, `scroll`, `keyboard`, `navigate`, `wait`, `waitFor`, `evaluate`, `upload`, `download`, `screenshot`, `scene`. Every step takes `pauseAfter`, `waitFor`, `timeout`, `secret`. Targets are a selector, or a point — `{x, y}` or `{selector, dx, dy}` — for canvases and SVG editors. `${VAR}` expands from `.env` and fails loudly when unset.
122
123
 
123
- **Scenes** are the spine: `{ action: scene, label, caption }` at each beat. They take their timestamps from the actual run, so captions land on the frames they describe even when the app took 3 seconds this time and 11 the last.
124
+ **Waits** take three modifiers, because a bare selector resolves the moment the element exists: `gone: true` waits for it to leave (the previous action's banner), `minChars: 40` waits for it to hold real text (a streamed reply), `stableMs: 500` waits for its subtree to stop changing.
125
+
126
+ **Scenes** are the spine: `{ action: scene, label, caption }` at each beat. They take their timestamps from the actual run, so captions land on the frames they describe even when the app took 3 seconds this time and 11 the last. **Labels must be unique** — `thumbnail`, `until`, the stills and the proof log all address scenes by label, so two scenes called the same thing make every one of those ambiguous. `validate` rejects it.
124
127
 
125
128
  **Two kinds of take.** `mode: demo` (the default) proves how something works — a client walkthrough, a PR clip, a lesson — and keeps everything but the product out of the frame. `mode: launch` presents the product in public and may carry a title card, music, emphasis and a branded ending. `validate` warns if a demo is carrying launch furniture. **The launch cut.** `compressIdle: true` shows the app's long waits as ~1.5 s each (your own pacing `wait`s are never touched); `voiceover: true` reads the captions aloud (edge-tts, `pipx install edge-tts` once); `typing: brisk` makes typed text land fast with the pauses carrying the meaning. All render-time except typing. **Music.** `music: track.mp3` (or `{ file, gainDb, fadeOutMs }`) mixes a bed under the video at render time, looped or trimmed to fit and faded out at the end. Bring your own track — it ships inside your video, so it must be one you may use (CC0 or licensed). **Cards and callouts.** `intro:`/`outro:` add a title card in Retake's own look — rendered at render time, so changing a word is a re-render, and the intro's settled frame is written as `cover.png`, the poster. `{ action: callout, selector, label, ms }` draws an animated ring and label around an element while the take holds — recorded coordinates, drawn at render, moving with the camera. Sparingly: the captions carry the story.
126
129
 
@@ -132,15 +135,15 @@ Think in the publishing format, not the browser window.
132
135
 
133
136
  | Preset | Video | Page area | For |
134
137
  |---|---:|---:|---|
135
- | `draft` | 960×592 | 960×540 | iterating — the finals' layout at a quarter of the pixels, fastest |
136
- | `preview-fast` | 1920×1180 | 1920×1080 | checking timing and framing, hardware-encoded |
137
- | `post-landscape` *(default)* | 1920×1180 | 1920×1080 | the general-purpose demo, plus a CRF-14 master |
138
- | `post-square` | 1080×1080 | 1080×960 | feeds — a true square |
139
- | `post-vertical` | 1080×1920 | 1080×1800 | shorts and reels — a true 9:16 |
140
- | `docs-gif` | 1440×970 | 1440×900 | README and docs GIFs |
141
- | `master` | 1920×1180 | 1920×1080 | archive, CRF 12 |
142
-
143
- **The video is the page area plus the caption band, and the band fits the captions.** On landscape presets the page keeps its full 1920×1080 and the band under it is sized to the longest caption — about 100 px for one line, 160 for two, one height for the whole take (video sizes above are for one-line captions). The captions never cover the app. Square and vertical keep a fixed 120 px band inside the canvas, because a feed that wants 1:1 or 9:16 will letterbox anything else. `captions: false` → no band at all. A manifest's `viewport` replaces the page area and the video follows it, so a 1240×1080 viewport gives a 1240×1230 video. `retake presets` prints this table, and `retake validate` tells you the exact size of the video a manifest will produce before you record it.
138
+ | `draft` | 960×540 | 960×464 | iterating — the finals' layout at a quarter of the pixels, fastest |
139
+ | `preview-fast` | 1920×1080 | 1920×930 | checking timing and framing, hardware-encoded |
140
+ | `post-landscape` *(default)* | 1920×1080 | 1920×930 | the general-purpose demo, plus a CRF-14 master |
141
+ | `post-square` | 1080×1080 | 1080×960 | feeds |
142
+ | `post-vertical` | 1080×1920 | 1080×1800 | shorts and reels |
143
+ | `docs-gif` | 1440×900 | 1440×800 | README and docs GIFs |
144
+ | `master` | 1920×1080 | 1920×930 | archive, CRF 12 |
145
+
146
+ **One preset, one size — and size is not recorded.** The caption band sits inside the canvas, so `post-landscape` is always exactly 1920×1080 and two demos of the same app are never different shapes. Better still, the shape is decided at render: the take is fitted into whichever canvas you ask for, so `retake render outputs/x --preset post-vertical` gives you a 9:16 cut of a demo you recorded landscape — seconds, no browser, no re-record. Setting `viewport` in a manifest overrides the page and `validate` warns, because that is how one demo ends up a different shape from the rest.
144
147
 
145
148
  **Page scale** is what makes it crisp: the page lays out as if the viewport were smaller and every glyph is drawn at 2×, so 1080p text reads on a phone. **Camera** is still by default — a calm demo reads as real. `camera: auto` eases toward whatever the demo just touched, at render time, clamped so it can never crop the thing it points at; per scene: `camera: { focus: ".result", zoom: 1.3 }`. Zoom is something you ask for after seeing a take, not something that happens to you. Captions, camera, speed, trim, format and layout are all render-time: change them and `retake render` takes seconds, never touches the browser.
146
149
 
package/dist/captions.js CHANGED
@@ -39,18 +39,17 @@ export function captionLines(texts, width, fontSize) {
39
39
  }
40
40
  /** The band's height for this take: fitted to the text, or the preset's
41
41
  fixed band when the canvas is the constraint. 0 when captions are off. */
42
- export function bandHeightFor(q, width, texts, pageHeight = q.height) {
42
+ export function bandHeightFor(q, captionTexts) {
43
43
  if (q.layout !== "band" && q.layout !== "card")
44
44
  return 0;
45
45
  if (!q.captions)
46
46
  return 0;
47
- // Fixed canvas: whatever the page turned out to be (an older take may have
48
- // a different page height), the band fills the rest so the canvas is exact.
49
- if (q.bandFit === "fill")
50
- return Math.max(0, q.height + q.bandHeight - pageHeight);
51
- const size = q.captions.fontSize;
52
- const lines = captionLines(texts, width, size);
53
- // glyph height ≈ 1.05 × size; line spacing 0.25 × size; breathing room above and below.
54
- const h = size * 1.05 * lines + size * 0.25 * (lines - 1) + size * 1.1;
55
- return Math.ceil(h / 2) * 2;
47
+ // A band with nothing to draw in it is an empty strip on the video: when
48
+ // the texts are known and none exist, the page gets the whole frame.
49
+ if (captionTexts && !captionTexts.some((t) => t && t.trim()))
50
+ return 0;
51
+ // Fixed per preset. The default page is the canvas minus this, so the
52
+ // finished video is exactly the preset's size — every take the same shape,
53
+ // and no player letterboxing it.
54
+ return q.bandHeight;
56
55
  }
package/dist/cards.js CHANGED
@@ -32,6 +32,10 @@ function cardHtml(w, h, theme, title, subtitle, ms) {
32
32
  T.textContent = ${JSON.stringify(title)}; S.textContent = ${JSON.stringify(subtitle ?? "")};
33
33
  const DUR = ${ms}, FADE = Math.min(420, DUR * 0.22), OUT = Math.min(320, DUR * 0.18);
34
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];
35
39
  window.__seek = (ms) => {
36
40
  const a = ez(ms / FADE); // entrance
37
41
  const o = ez((DUR - ms) / OUT); // exit
@@ -59,6 +63,8 @@ function calloutHtml(w, h, box, label, ms, fontSize, ink) {
59
63
  if (tag) tag.textContent = ${JSON.stringify(label ?? "")};
60
64
  const DUR = ${ms}, IN = Math.min(360, DUR * 0.25), OUT = Math.min(280, DUR * 0.2);
61
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];
62
68
  window.__seek = (ms) => {
63
69
  const a = ez(ms / IN), o = ez((DUR - ms) / OUT), v = Math.min(a, o);
64
70
  ring.style.opacity = v;
@@ -68,19 +74,49 @@ function calloutHtml(w, h, box, label, ms, fontSize, ink) {
68
74
  };
69
75
  </script>`;
70
76
  }
71
- /** Screenshot a __seek()-driven page at every frame; returns the frame dir. */
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. */
72
89
  async function renderFrames(html, w, h, ms, fps, transparent) {
73
90
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "retake-frames-"));
74
91
  const browser = await chromium.launch();
75
- const page = await browser.newPage({ viewport: { width: w, height: h } });
76
- await page.setContent(html, { waitUntil: "load" });
77
- const frames = Math.max(2, Math.round((ms / 1000) * fps));
78
- for (let i = 0; i < frames; i++) {
79
- await page.evaluate((t) => window.__seek(t), (i / fps) * 1000);
80
- await page.screenshot({ path: path.join(dir, `f${String(i).padStart(5, "0")}.png`), omitBackground: transparent });
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();
81
119
  }
82
- await browser.close();
83
- return dir;
84
120
  }
85
121
  /** A title card as an H.264 segment at the final canvas size, plus (for the
86
122
  intro) the settled middle frame as cover.png — the poster. */
@@ -125,10 +161,17 @@ export async function renderTitledCover(ffmpeg, frame, out, spec, w, h, theme) {
125
161
  <div class="shot"></div><div class="veil"></div>
126
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>`;
127
163
  const browser = await chromium.launch();
128
- const page = await browser.newPage({ viewport: { width: w, height: h } });
129
- await page.setContent(html, { waitUntil: "load" });
130
- await page.screenshot({ path: out });
131
- await browser.close();
132
- return out;
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
+ }
133
173
  }
134
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,10 +24,11 @@ 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";
32
33
  import { SECRET_NAME, writeEnvFile } from "./env.js";
33
34
  // The manifest may reference ${VARS}; load .env before any command reads one.
@@ -42,6 +43,35 @@ catch { /* .env is optional */ }
42
43
  const program = new Command();
43
44
  program.name("retake").description("Rerun the demo instead of re-recording it.").version(VERSION);
44
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
+ }
45
75
  program
46
76
  .command("run")
47
77
  .argument("<manifest>", "demo .yaml / .json")
@@ -54,15 +84,30 @@ program
54
84
  .option("--reuse", "reuse the last raw recording if nothing that shapes it changed (re-render only)", false)
55
85
  .option("--gif", "also produce a GIF (overrides the manifest)", false)
56
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")
57
89
  .action(async (file, opts) => {
58
90
  const loaded = loadManifest(file);
59
- 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
+ }
60
103
  if (opts.gif)
61
104
  manifest = { ...manifest, outputs: { ...manifest.outputs, gif: true } };
62
105
  if (opts.preset && !presetNames().includes(opts.preset))
63
106
  throw new Error(`unknown preset ${opts.preset} — one of ${presetNames().join(", ")}`);
64
107
  const dir = loaded.dir;
65
- 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);
66
111
  acquireLock(outDir); // throws if another run owns this folder
67
112
  // Reuse: same capture hash + raw video present → skip the browser.
68
113
  let take;
@@ -77,21 +122,26 @@ program
77
122
  }
78
123
  for (const w of warnings(manifest))
79
124
  say(`⚠ ${w}`);
125
+ if (!take)
126
+ for (const l of costHints(manifest, outDir, opts))
127
+ say(l);
80
128
  say(`▶ ${manifest.title ?? manifest.name} → ${path.relative(process.cwd(), outDir)}${take ? " · reusing last recording" : ""}`);
81
129
  say(`[stage] capture ${take ? "skip" : "start"}`);
82
130
  const t0 = Date.now();
83
131
  if (!take) {
84
- // Wipe everything except the lock.
85
- for (const f of fs.readdirSync(outDir))
86
- if (f !== ".retake-lock")
87
- 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);
88
135
  try {
89
- 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 });
90
137
  }
91
138
  catch (e) {
139
+ if (restorePrevious(outDir))
140
+ say("↩ recording failed — your previous take has been put back");
92
141
  releaseLock(outDir);
93
142
  throw e;
94
143
  }
144
+ keepPrevious(outDir, say);
95
145
  }
96
146
  say(`■ take done in ${((Date.now() - t0) / 1000).toFixed(1)}s · ${take.ok ? "all steps ok" : "SOME STEPS FAILED"} · ${take.video ?? "no video"}`);
97
147
  try {
@@ -120,6 +170,83 @@ program
120
170
  if (!take.ok || !take.video)
121
171
  process.exitCode = 2;
122
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
+ });
123
250
  program
124
251
  .command("render")
125
252
  .argument("<dir>", "an outputs/<name> dir containing take.json")
@@ -128,9 +255,39 @@ program
128
255
  .option("--scene <label>", "render just one scene to scene-<label>.mp4 (fast, hardware encode)")
129
256
  .option("--gif", "also produce a GIF", false)
130
257
  .option("--force", "ignore the render cache", false)
258
+ .option("--out <dir>", "write the render into a NEW directory, leaving the original untouched")
131
259
  .action(async (dir, manifestFile, opts) => {
132
260
  const takePath = path.join(dir, "take.json");
133
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
+ }
134
291
  const name = path.basename(path.resolve(dir));
135
292
  const mf = manifestFile ?? guessManifest(name);
136
293
  const loaded = loadManifest(mf);
@@ -412,10 +569,20 @@ program
412
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.`);
413
570
  say(`(Nothing is recorded. Only the session is kept, in ${path.relative(process.cwd(), statePath)}, good for ${manifest.auth.maxAgeHours}h.)`);
414
571
  await new Promise((ok) => { process.stdin.resume(); process.stdin.once("data", () => ok()); });
415
- fs.mkdirSync(path.dirname(statePath), { recursive: true });
416
- await context.storageState({ path: statePath });
417
- fs.chmodSync(statePath, 0o600);
418
- await browser.close();
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
+ }
419
586
  say(`✓ session saved → ${path.relative(process.cwd(), statePath)}. Takes of ${manifest.name} now start signed in; run this again when it goes stale.`);
420
587
  process.exit(0);
421
588
  });
@@ -471,6 +638,17 @@ program
471
638
  say(`· ${i.title}${i.length ? ` (${i.length})` : ""}`);
472
639
  say(`✓ ${path.relative(process.cwd(), file)}`);
473
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
+ });
474
652
  program
475
653
  .command("gif")
476
654
  .argument("<dir>", "an outputs/<name> dir")
@@ -490,12 +668,11 @@ program
490
668
  const { bandHeightFor } = await import("./captions.js");
491
669
  say(`${"preset".padEnd(16)} ${"video".padEnd(12)} ${"page".padEnd(11)} fps layout`);
492
670
  for (const p of Object.values(PRESETS)) {
493
- const q = { captions: { fontSize: p.captionFontSize }, layout: p.layout, bandHeight: p.bandHeight, bandFit: p.bandFit, height: p.height };
494
- const one = bandHeightFor(q, p.width, ["one line"]), two = bandHeightFor(q, p.width, ["a caption long enough to wrap ".repeat(6)]);
495
- const video = p.layout === "card" ? `${p.width}×${p.height}` : p.layout !== "band" ? `${p.width}×${p.height}` : one === two ? `${p.width}×${p.height + one}` : `${p.width}×${p.height + one}–${p.height + two}`;
496
- say(`${p.name.padEnd(16)} ${video.padEnd(12)} ${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}`);
497
674
  }
498
- say(`\nvideo = page + caption band. On landscape presets the band fits the captions (one line, or two); square and vertical keep a fixed band so the canvas stays exact. A manifest's \`viewport\` replaces the page size (the video follows it). \`captions: false\` → no band.`);
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.`);
499
676
  });
500
677
  program
501
678
  .command("validate")
@@ -508,10 +685,10 @@ program
508
685
  // here rather than letting it surprise someone after a two-minute take.
509
686
  const { bandHeightFor } = await import("./captions.js");
510
687
  const outW = q.layout === "card" ? q.width : q.viewport.width;
511
- const band = bandHeightFor(q, outW, manifest.steps.flatMap((s) => (s.action === "scene" && s.caption ? [s.caption] : [])), q.viewport.height);
688
+ const band = bandHeightFor(q, manifest.steps.flatMap((s) => (s.action === "scene" && s.caption ? [s.caption] : [])));
512
689
  const outH = (q.layout === "card" ? q.height : q.viewport.height) + (q.layout === "band" ? band : 0);
513
690
  say(`ok: ${manifest.name} · ${manifest.steps.length} steps · ${manifest.steps.filter((s) => s.action === "scene").length} scenes`);
514
- say(` ${q.name} · video ${outW}×${outH}${q.layout === "band" ? (band ? ` (page ${q.viewport.width}×${q.viewport.height} + ${band}px caption band${q.bandFit === "text" ? ", sized to the captions" : ""})` : " (captions off, no 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` : ""}`);
515
692
  for (const w of warnings(manifest))
516
693
  say(`⚠ ${w}`);
517
694
  });
package/dist/describe.js CHANGED
@@ -313,10 +313,11 @@ const SCHEMA_DOC = `
313
313
  Manifest YAML fields:
314
314
  name (kebab-case), title, url, mode: demo, preset: post-landscape, waitForSelector (selector present when page is ready),
315
315
  camera: static, setup: [steps run before recording], steps: [...], outputs: {mp4: true, gif: true, thumbnail: {scene: <label>}}
316
- Do NOT set viewport, cursor, chrome, background, fps, crf or captions — the preset handles quality.
316
+ NEVER set viewport. The preset decides the size and every take at a preset comes out identical; setting viewport makes this demo a different shape from every other one and players letterbox it. Same for cursor, chrome, background, fps, crf and captions — the preset handles all of it.
317
317
  Always set mode: demo — this is a demo, so nothing goes in the frame but the product. Never emit intro or outro cards, music, callouts or voiceover: those belong to mode: launch, which only a person can ask for.
318
318
  Steps (each may have pauseAfter ms, waitFor selector, timeout ms):
319
319
  {action: scene, label, caption, camera?: static | {focus: selector, zoom: 1.25}} a named beat; caption is burned in until the next scene (under 60 chars). Set camera: static on every scene unless the thing being shown is genuinely too small to read at full frame — a calm, still demo reads as more real than a zooming one. If the person asked for zooms (or stillness), that wins.
320
+ {action: select, selector, value} choose in a <select> — value or the visible label, whichever the page uses
320
321
  {action: wait, ms}
321
322
  {action: click, selector, zoom: 1.3} zoom is optional
322
323
  {action: type, selector, text, delay: 30}