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/cli.js CHANGED
@@ -29,6 +29,7 @@ import { acquireLock, captureHash, record, releaseLock } from "./record.js";
29
29
  import { check, render } from "./render.js";
30
30
  import { presetNames } from "./presets.js";
31
31
  import { PKG_ROOT, VERSION, entry } from "./paths.js";
32
+ import { SECRET_NAME, writeEnvFile } from "./env.js";
32
33
  // The manifest may reference ${VARS}; load .env before any command reads one.
33
34
  // (`describe` and the UI used to do this individually, so a plain `run` from
34
35
  // the terminal silently saw empty values — which is how a whole take can fail
@@ -316,6 +317,108 @@ program
316
317
  say(`Keep ${o.ui} open while it works — the plan, the run and the video appear there.`);
317
318
  say("Retake will not start your app from outside its own window unless you add RETAKE_ALLOW_START=1 above.");
318
319
  });
320
+ /** Read one line from stdin. On a terminal, `hide` switches to raw mode so the
321
+ characters are not echoed; on a pipe, lines are simply consumed in order. */
322
+ let stdinBuf = "";
323
+ function promptLine(label, hide) {
324
+ process.stdout.write(label);
325
+ const tty = !!process.stdin.isTTY;
326
+ const raw = hide && tty;
327
+ if (raw)
328
+ process.stdin.setRawMode(true);
329
+ process.stdin.resume();
330
+ return new Promise((resolve) => {
331
+ const finish = (line) => {
332
+ process.stdin.off("data", onData);
333
+ if (raw)
334
+ process.stdin.setRawMode(false);
335
+ process.stdin.pause();
336
+ if (raw)
337
+ process.stdout.write("\n");
338
+ resolve(line.trim());
339
+ };
340
+ const take = () => {
341
+ const i = stdinBuf.search(/\r|\n/);
342
+ if (i < 0)
343
+ return false;
344
+ const line = stdinBuf.slice(0, i);
345
+ stdinBuf = stdinBuf.slice(i + 1).replace(/^\n/, "");
346
+ finish(line);
347
+ return true;
348
+ };
349
+ const onData = (d) => {
350
+ const sIn = d.toString();
351
+ if (raw) {
352
+ for (const ch of sIn) {
353
+ if (ch === "\u0003") {
354
+ process.stdout.write("\n");
355
+ process.exit(130);
356
+ }
357
+ if (ch === "\u007f" || ch === "\b") {
358
+ stdinBuf = stdinBuf.slice(0, -1);
359
+ continue;
360
+ }
361
+ stdinBuf += ch;
362
+ }
363
+ }
364
+ else
365
+ stdinBuf += sIn;
366
+ take();
367
+ };
368
+ if (take())
369
+ return;
370
+ process.stdin.on("data", onData);
371
+ });
372
+ }
373
+ program
374
+ .command("secret")
375
+ .description("put a demo account into this workspace's .env without opening the file — typed here, hidden, kept on this machine only")
376
+ .argument("<names...>", "variable names, e.g. APP_USER APP_PASSWORD APP_TOTP_SECRET")
377
+ .action(async (names) => {
378
+ const bad = names.filter((n) => !SECRET_NAME.test(n));
379
+ if (bad.length)
380
+ throw new Error(`names look like APP_PASSWORD — not: ${bad.join(", ")}`);
381
+ 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.`);
382
+ const values = {};
383
+ for (const n of names) {
384
+ const v = await promptLine(`${n}${/PASS|SECRET|TOKEN|KEY|PIN|OTP/.test(n) ? " (hidden)" : ""}: `, /PASS|SECRET|TOKEN|KEY|PIN|OTP/.test(n));
385
+ if (v)
386
+ values[n] = v;
387
+ }
388
+ const set = writeEnvFile(process.cwd(), values);
389
+ if (!set.length) {
390
+ say("nothing saved");
391
+ return;
392
+ }
393
+ 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.`);
394
+ });
395
+ program
396
+ .command("signin")
397
+ .description("sign in by hand once — 2FA, SSO, captcha, anything — and keep the session for every later take")
398
+ .argument("<manifest>", "the demo whose auth.storageState should be saved")
399
+ .action(async (file) => {
400
+ const { manifest, dir } = loadManifest(file);
401
+ if (!manifest.auth?.storageState)
402
+ throw new Error(`${file} has no auth.storageState — add:\n auth:\n storageState: .auth/${manifest.name}.json\n maxAgeHours: 24`);
403
+ const { resolve, expandEnv } = await import("./manifest.js");
404
+ const { chromium } = await import("playwright");
405
+ const q = resolve(manifest);
406
+ const statePath = path.resolve(dir, expandEnv(manifest.auth.storageState));
407
+ const browser = await chromium.launch({ headless: false });
408
+ const context = await browser.newContext({ viewport: q.viewport });
409
+ const page = await context.newPage();
410
+ await page.goto(manifest.url, { waitUntil: "domcontentloaded" }).catch(() => { });
411
+ say(`A browser is open at ${manifest.url}.`);
412
+ 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
+ say(`(Nothing is recorded. Only the session is kept, in ${path.relative(process.cwd(), statePath)}, good for ${manifest.auth.maxAgeHours}h.)`);
414
+ 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();
419
+ say(`✓ session saved → ${path.relative(process.cwd(), statePath)}. Takes of ${manifest.name} now start signed in; run this again when it goes stale.`);
420
+ process.exit(0);
421
+ });
319
422
  program
320
423
  .command("dry")
321
424
  .description("run the manifest with no camera and report what would fail")
@@ -384,12 +487,15 @@ program
384
487
  .description("list quality presets")
385
488
  .action(async () => {
386
489
  const { PRESETS } = await import("./presets.js");
387
- say(`${"preset".padEnd(16)} ${"video".padEnd(11)} ${"page".padEnd(11)} fps layout`);
490
+ const { bandHeightFor } = await import("./captions.js");
491
+ say(`${"preset".padEnd(16)} ${"video".padEnd(12)} ${"page".padEnd(11)} fps layout`);
388
492
  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}`);
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}`);
391
497
  }
392
- say(`\nvideo = page + caption band. A manifest's \`viewport\` replaces the page size (the video follows it).`);
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.`);
393
499
  });
394
500
  program
395
501
  .command("validate")
@@ -400,10 +506,12 @@ program
400
506
  const q = resolve(manifest);
401
507
  // The video is the page area plus the caption band, so say the real number
402
508
  // here rather than letting it surprise someone after a two-minute take.
509
+ const { bandHeightFor } = await import("./captions.js");
403
510
  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);
511
+ const band = bandHeightFor(q, outW, manifest.steps.flatMap((s) => (s.action === "scene" && s.caption ? [s.caption] : [])), q.viewport.height);
512
+ const outH = (q.layout === "card" ? q.height : q.viewport.height) + (q.layout === "band" ? band : 0);
405
513
  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` : ""}`);
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` : ""}`);
407
515
  for (const w of warnings(manifest))
408
516
  say(`⚠ ${w}`);
409
517
  });
package/dist/describe.js CHANGED
@@ -25,16 +25,34 @@ import YAML from "yaml";
25
25
  import { Manifest } from "./manifest.js";
26
26
  import { digest } from "./digest.js";
27
27
  export function loadDotenv(root, files = [".env"]) {
28
+ // One parser for every reader of .env (see env.ts), so a password written
29
+ // by the window or `retake secret` is typed back exactly as entered.
28
30
  for (const name of files) {
29
- const f = path.join(root, name);
30
- if (!fs.existsSync(f))
31
+ for (const [k, v] of Object.entries(parseEnvText(root, name)))
32
+ if (process.env[k] === undefined)
33
+ process.env[k] = v;
34
+ }
35
+ }
36
+ function parseEnvText(root, name) {
37
+ const f = path.join(root, name);
38
+ if (!fs.existsSync(f))
39
+ return {};
40
+ const out = {};
41
+ for (const raw of fs.readFileSync(f, "utf8").split(/\r?\n/)) {
42
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
43
+ if (!m)
31
44
  continue;
32
- for (const line of fs.readFileSync(f, "utf8").split("\n")) {
33
- const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line);
34
- if (m && process.env[m[1]] === undefined)
35
- process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
45
+ const v = m[2].trim();
46
+ if (v.startsWith('"') && v.endsWith('"')) {
47
+ try {
48
+ out[m[1]] = JSON.parse(v);
49
+ continue;
50
+ }
51
+ catch { /* literal */ }
36
52
  }
53
+ out[m[1]] = v.replace(/^(["'])(.*)\1$/, "$2");
37
54
  }
55
+ return out;
38
56
  }
39
57
  function cliPath(bin) {
40
58
  const home = os.homedir();
@@ -293,9 +311,10 @@ export async function proposeEdits(input) {
293
311
  // --- drafting ---------------------------------------------------------------
294
312
  const SCHEMA_DOC = `
295
313
  Manifest YAML fields:
296
- name (kebab-case), title, url, preset: post-landscape, waitForSelector (selector present when page is ready),
297
- camera: auto, setup: [steps run before recording], steps: [...], outputs: {mp4: true, gif: true, thumbnail: {scene: <label>}}
314
+ name (kebab-case), title, url, mode: demo, preset: post-landscape, waitForSelector (selector present when page is ready),
315
+ camera: static, setup: [steps run before recording], steps: [...], outputs: {mp4: true, gif: true, thumbnail: {scene: <label>}}
298
316
  Do NOT set viewport, cursor, chrome, background, fps, crf or captions — the preset handles quality.
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.
299
318
  Steps (each may have pauseAfter ms, waitFor selector, timeout ms):
300
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.
301
320
  {action: wait, ms}
@@ -307,7 +326,7 @@ Steps (each may have pauseAfter ms, waitFor selector, timeout ms):
307
326
  {action: keyboard, key}
308
327
  {action: waitFor, selector, timeout: 30000} wait for something to appear (use after actions that trigger async work)
309
328
  {action: evaluate, script}
310
- Rules: never invent field names or add "?" to keys; 15–35 seconds total; 3–6 scenes; open with a scene; every click uses a selector from the scouted list verbatim (selectors ending in \">> nth=N\" are already disambiguated — keep them exactly); after any action that loads or computes something, waitFor an element that only appears as a result (never one that is already on the page) — if you cannot know it from the scout, use {action: wait, ms: 3000} instead; end with a wait of ~2000ms on the payoff; do not use zoom steps — scenes carry the camera.
329
+ Rules: never invent field names or add "?" to keys; write mode: demo; no cards, music, callouts or voiceover; 15–35 seconds total; 3–6 scenes; open with a scene; every click uses a selector from the scouted list verbatim (selectors ending in \">> nth=N\" are already disambiguated — keep them exactly); after any action that loads or computes something, waitFor an element that only appears as a result (never one that is already on the page) — if you cannot know it from the scout, use {action: wait, ms: 3000} instead; end with a wait of ~2000ms on the payoff; do not use zoom steps — scenes carry the camera; write a caption ONLY where it says something the screen cannot (never narrate a visible click), and hold the payoff in ONE frame rather than moving between tabs or views to show its parts separately.
311
330
  `;
312
331
  /** The person's standing taste — demos/style.md, written once, read by every
313
332
  draft. "No zooms, cursor visible, captions plain" should not need repeating. */
package/dist/dryrun.js CHANGED
@@ -160,6 +160,9 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
160
160
  case "wait":
161
161
  await page.waitForTimeout(isSetup ? step.ms : Math.min(step.ms, 400));
162
162
  break;
163
+ case "callout":
164
+ await point(step.selector ?? step.at);
165
+ break;
163
166
  default: break; // scene, screenshot, zoom, download: nothing to verify
164
167
  }
165
168
  if (step.pauseAfter)
package/dist/env.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The workspace `.env` — where an app's demo-account credentials live.
3
+ *
4
+ * Three things write it: the person (by hand, or `retake secret NAME`), the
5
+ * window (when an agent asks for a secret by name), and Settings. None of
6
+ * them ever hands a value to a model: the agent learns that `APP_PASSWORD`
7
+ * is *set*, and references it as `${APP_PASSWORD}` with `secret: true`.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
12
+ /** Parse KEY=value lines (quotes stripped, comments and blanks ignored). */
13
+ export function readEnvFile(root) {
14
+ const file = path.join(root, ".env");
15
+ if (!fs.existsSync(file))
16
+ return {};
17
+ const out = {};
18
+ for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
19
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
20
+ if (!m)
21
+ continue;
22
+ const v = m[2].trim();
23
+ // Double-quoted values are JSON strings (that is how writeEnvFile writes
24
+ // anything awkward); single-quoted are literal; bare is bare.
25
+ if (v.startsWith('"') && v.endsWith('"')) {
26
+ try {
27
+ out[m[1]] = JSON.parse(v);
28
+ continue;
29
+ }
30
+ catch { /* fall through */ }
31
+ }
32
+ out[m[1]] = v.replace(/^(["'])(.*)\1$/, "$2");
33
+ }
34
+ return out;
35
+ }
36
+ /** Set (or, with "", remove) keys in .env, leaving every other line alone.
37
+ Returns the keys actually written. Also updates process.env so the
38
+ calling process sees the new values without a restart. */
39
+ export function writeEnvFile(root, set) {
40
+ const file = path.join(root, ".env");
41
+ const lines = fs.existsSync(file) ? fs.readFileSync(file, "utf8").split("\n") : [];
42
+ const written = [];
43
+ for (const [k, v] of Object.entries(set)) {
44
+ if (v === undefined)
45
+ continue;
46
+ const i = lines.findIndex((l) => l.startsWith(k + "="));
47
+ // Quote when the value has anything a shell-ish parser would trip on.
48
+ const line = `${k}=${/[\s#"']/.test(v) ? JSON.stringify(v) : v}`;
49
+ if (v === "") {
50
+ if (i >= 0)
51
+ lines.splice(i, 1);
52
+ delete process.env[k];
53
+ }
54
+ else {
55
+ if (i >= 0)
56
+ lines[i] = line;
57
+ else
58
+ lines.push(line);
59
+ process.env[k] = v;
60
+ }
61
+ written.push(k);
62
+ }
63
+ fs.writeFileSync(file, lines.filter((l, i, a) => l !== "" || i < a.length - 1).join("\n").replace(/\n*$/, "\n"), { mode: 0o600 });
64
+ // `mode` above only applies to a new file; an existing .env keeps whatever
65
+ // it had. Credentials went in, so: owner-only, every time.
66
+ try {
67
+ fs.chmodSync(file, 0o600);
68
+ }
69
+ catch { /* windows */ }
70
+ return written;
71
+ }
72
+ /** Which of these names are not set (missing or blank) in .env + the environment. */
73
+ export function missingSecrets(root, names) {
74
+ const file = readEnvFile(root);
75
+ return names.filter((n) => !(process.env[n]?.trim() || file[n]?.trim()));
76
+ }
package/dist/manifest.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * - `outputs` which artifacts to produce and how
9
9
  */
10
10
  import fs from "node:fs";
11
+ import { createHmac } from "node:crypto";
11
12
  import path from "node:path";
12
13
  import YAML from "yaml";
13
14
  import { z } from "zod";
@@ -110,6 +111,16 @@ export const Step = z.discriminatedUnion("action", [
110
111
  json: z.unknown().optional(),
111
112
  from: z.string().optional(),
112
113
  }),
114
+ /** Point at something while the take holds: an animated ring and an optional
115
+ label, drawn at render time from the box recorded here. Sparingly — one
116
+ per scene at most; the captions carry the story. */
117
+ Base.extend({
118
+ action: z.literal("callout"),
119
+ selector: Selector.optional(),
120
+ at: Point.optional(),
121
+ label: z.string().max(60).optional(),
122
+ ms: z.number().positive().max(8000).default(2200),
123
+ }),
113
124
  /** A named beat. Timestamps go into the proof log; `caption` gets burned into
114
125
  the video from this moment until the next scene (or `holdMs`). */
115
126
  Base.extend({
@@ -124,7 +135,7 @@ export const Step = z.discriminatedUnion("action", [
124
135
  .optional(),
125
136
  }),
126
137
  ]).superRefine((st, ctx) => {
127
- if ((st.action === "click" || st.action === "hover") && !st.selector && !st.at) {
138
+ if ((st.action === "click" || st.action === "hover" || st.action === "callout") && !st.selector && !st.at) {
128
139
  ctx.addIssue({ code: "custom", message: `${st.action} needs either a selector or an \`at\` point` });
129
140
  }
130
141
  });
@@ -177,7 +188,60 @@ const ManifestShape = z.object({
177
188
  /** band (default): the site fills the frame, caption strip below · card: the recording framed on a soft canvas · overlay-*: caption over the video · none */
178
189
  layout: z.enum(["band", "card", "overlay-bottom", "overlay-top", "none"]).optional(),
179
190
  /** auto: each scene zooms toward the last element the demo touched · static: no camera moves. */
180
- camera: z.union([z.literal("auto"), z.literal("static"), z.object({ zoom: z.number().min(1).max(3) })]).default("auto"),
191
+ /** Compress at render what nobody watches. A waitFor/navigate that made
192
+ the viewer sit through N seconds is shown as ~keepSeconds (real start,
193
+ then a fast-forward); a long typing run keeps its start and its finish
194
+ at real speed and accelerates the middle (every keystroke is a
195
+ round-trip, so a real sentence costs ~5s of video). The author's own
196
+ `wait` steps are pacing and are never touched. Off by default. */
197
+ compressIdle: z.union([z.boolean(), z.object({ keepSeconds: z.number().min(0.8).max(5).default(1.5) })]).default(false),
198
+ /** What this take is FOR, and it changes what is allowed to be in it.
199
+ `demo` (the default) proves an interaction: direct, legible, minimally
200
+ framed — a title card or music in a demo is noise. `launch` presents
201
+ the product, so cards, music, emphasis and a branded ending are
202
+ legitimate, and each one still has to earn the second it costs.
203
+ Retake never upgrades a demo to a launch on its own. */
204
+ mode: z.enum(["demo", "launch"]).default("demo"),
205
+ /** How typed text lands. `natural` is the per-step default (~80ms a key);
206
+ `brisk` is the launch rhythm — fast keys (~22ms), the pauses carrying
207
+ the meaning instead. A step's own `delay` always wins. */
208
+ typing: z.enum(["natural", "brisk"]).default("natural"),
209
+ /** The captions, read aloud (edge-tts). Each scene's caption is synthesized
210
+ and placed at the scene's start; music ducks under it. The captions are
211
+ the script — there is nothing separate to write. */
212
+ voiceover: z
213
+ .union([
214
+ z.boolean(),
215
+ z.object({
216
+ voice: z.string().default("en-US-JennyNeural"),
217
+ gainDb: z.number().min(-20).max(10).default(0),
218
+ /** One continuous script, synthesized as a single performance and
219
+ laid under the whole video — the only shape that keeps prosody.
220
+ Without it, narration is per-scene fragments: prosody resets at
221
+ every line and the read/wait rhythm follows scene boundaries
222
+ instead of speech. Fragments are refused unless `fragments: true`
223
+ says you know. */
224
+ script: z.string().optional(),
225
+ fragments: z.boolean().default(false),
226
+ /** A person has heard this voice and this script and approved them.
227
+ Synthesis succeeding is not approval. */
228
+ approved: z.boolean().default(false),
229
+ }),
230
+ ])
231
+ .default(false),
232
+ /** A music bed under the whole video, mixed at render time: looped or
233
+ trimmed to fit, faded out over the last moments. The person supplies the
234
+ file (CC0/licensed — it ships inside their video); Retake bundles none. */
235
+ music: z.union([z.string(), z.object({ file: z.string(), gainDb: z.number().min(-40).max(6).default(-14), fadeOutMs: z.number().positive().max(8000).default(1800) })]).optional(),
236
+ /** Title cards, rendered at render time (change them, re-render in seconds).
237
+ The intro's settled frame is also written as cover.png — the poster. */
238
+ intro: z.object({ title: z.string().max(60), subtitle: z.string().max(90).optional(), ms: z.number().positive().max(6000).default(2400) }).optional(),
239
+ outro: z.object({ title: z.string().max(60), subtitle: z.string().max(90).optional(), ms: z.number().positive().max(6000).default(2000) }).optional(),
240
+ /** Still by default. A zoom is something a person asks for after seeing a
241
+ take, never something that happens to them: a calm demo reads as real
242
+ and can never crop the wrong thing. `auto` eases toward the last thing
243
+ touched; a scene can override either way. */
244
+ camera: z.union([z.literal("auto"), z.literal("static"), z.object({ zoom: z.number().min(1).max(3) })]).default("static"),
181
245
  /** Seconds cut from the head/tail of the finished video. Render-only: change
182
246
  it and re-render, no re-recording. */
183
247
  trim: z.object({ head: z.number().nonnegative().default(0), tail: z.number().nonnegative().default(0) }).prefault({}),
@@ -203,10 +267,9 @@ const ManifestShape = z.object({
203
267
  waitForSelector: Selector.optional(),
204
268
  speed: z.number().positive().default(1),
205
269
  /** Cursor overlay (testreel). false → none; {style: touch} → tap dot. Size is preset-scaled unless set. */
206
- /** cursor.idleHide: fade the cursor out when idle (testreel default). Each
207
- idle gap adds a nested if() to the ffmpeg alpha expression, and ffmpeg's
208
- parser has a depth limit — long demos blow past it. Retake turns it off
209
- automatically above 60 steps; set it explicitly either way. */
270
+ /** cursor.idleHide: fade the cursor out when idle (testreel default). Long
271
+ demos used to lose it (nested-if depth in ffmpeg); the flat cursor
272
+ expressions lifted that, so it is simply on unless you say otherwise. */
210
273
  cursor: z.union([z.boolean(), z.object({ style: z.enum(["default", "pointer", "text", "touch"]).optional(), size: z.number().optional(), idleHideMs: z.number().optional(), idleHide: z.boolean().optional() })]).default(true),
211
274
  seed: z.array(Seed).default([]),
212
275
  /** What a failed step does to the take. "stop" (default): the camera stops
@@ -249,6 +312,20 @@ const ManifestShape = z.object({
249
312
  .prefault({}),
250
313
  });
251
314
  export const Manifest = ManifestShape.superRefine((m, ctx) => {
315
+ // A synthetic voice is a claim on the person's taste. Retake will not put
316
+ // one in a deliverable until they have heard it and said yes — and will
317
+ // not read the captions as fragments at all, because that is the defect,
318
+ // not the voice: prosody resets every line and the rhythm follows scene
319
+ // boundaries instead of speech.
320
+ if (m.voiceover) {
321
+ const vo = m.voiceover === true ? { approved: false, script: undefined, fragments: false } : m.voiceover;
322
+ if (!vo.approved) {
323
+ ctx.addIssue({ code: "custom", path: ["voiceover"], message: "voiceover is not approved. Synthesize it, LISTEN to it with the person, and only then set `approved: true`. Successful synthesis is not approval; a silent cut beats an unapproved synthetic voice." });
324
+ }
325
+ if (!vo.script && !vo.fragments) {
326
+ ctx.addIssue({ code: "custom", path: ["voiceover"], message: "voiceover needs `script:` — one continuous script, synthesized as a single performance. Reading the captions line by line resets prosody at every scene and paces the speech off scene boundaries. Set `fragments: true` only if you have heard the fragmented read and want it." });
327
+ }
328
+ }
252
329
  [...m.steps, ...m.setup].forEach((st, i) => {
253
330
  if ((st.action === "click" || st.action === "hover") && !st.selector && !st.at) {
254
331
  ctx.addIssue({ code: "custom", path: ["steps", i], message: `${st.action} needs either a selector or an \`at\` point` });
@@ -294,11 +371,25 @@ export function loadManifest(file) {
294
371
  }).join("\n");
295
372
  throw new Error(`Invalid manifest ${file}:\n${issues}`);
296
373
  }
297
- return { manifest: parsed.data, file: abs, dir: path.dirname(abs) };
374
+ const dir = path.dirname(abs);
375
+ // A fixture page can live next to its manifest: `url: file://./page.html`
376
+ // resolves against the manifest's folder, so a cloned repo's demos work.
377
+ const m = parsed.data;
378
+ const rel = /^file:\/\/(\.{1,2}\/.*)$/.exec(m.url);
379
+ if (rel)
380
+ m.url = "file://" + path.resolve(dir, rel[1]);
381
+ return { manifest: m, file: abs, dir };
298
382
  }
299
383
  /** Things worth saying out loud before a run. Not errors — the run proceeds. */
300
384
  export function warnings(m) {
301
385
  const w = [];
386
+ // A demo proves an interaction; a launch presents the product. Launch
387
+ // furniture in a demo is noise the viewer has to look past.
388
+ if (m.mode === "demo") {
389
+ const launchy = [m.intro && "intro card", m.outro && "outro card", m.music && "music", m.steps.some((s) => s.action === "callout") && "callouts"].filter(Boolean);
390
+ if (launchy.length)
391
+ w.push(`this is \`mode: demo\` but carries ${launchy.join(", ")} — a demo proves the interaction and wants nothing else in the frame. Set \`mode: launch\` if it is a launch video, or drop them.`);
392
+ }
302
393
  // Secrets typed during recorded steps end up in the video. In `setup` they do
303
394
  // not: setup runs before the camera and is trimmed off the front.
304
395
  const onCamera = m.steps.filter((s) => s.secret).length;
@@ -312,8 +403,8 @@ export function warnings(m) {
312
403
  if (m.auth && !m.auth.setup.length && !m.setup.length)
313
404
  w.push("`auth.storageState` is set but there are no sign-in steps under `auth.setup` — there is nothing to save a session from.");
314
405
  const moves = m.steps.filter((st) => ["click", "type", "fill", "hover", "scroll", "upload"].includes(st.action)).length;
315
- if (m.cursor !== false && moves > 45)
316
- w.push(`~${moves} cursor moves — the cursor overlay (testreel/ffmpeg) cannot exceed ~45 in one take and will be MISSING from the video. Split the demo into shorter ones, or set \`cursor: false\`.`);
406
+ if (m.cursor !== false && moves > 180)
407
+ w.push(`~${moves} cursor moves — past ~180 in one take the cursor filter no longer fits in one ffmpeg argument and the overlay will be MISSING. Split the demo into chapters, or set \`cursor: false\`.`);
317
408
  m.steps.forEach((st, i) => {
318
409
  if (st.action !== "scene")
319
410
  return;
@@ -328,13 +419,33 @@ export function warnings(m) {
328
419
  }
329
420
  /** `${VAR}` substitution against process.env, like testreel. */
330
421
  export function expandEnv(s) {
331
- return s.replace(/\$\{([A-Z0-9_]+)\}/g, (_, k) => {
422
+ return s.replace(/\$\{(TOTP:)?([A-Z0-9_]+)\}/g, (_, fn, k) => {
332
423
  const v = process.env[k];
333
- if (v === undefined)
334
- throw new Error(`manifest references \${${k}} but it is not set`);
335
- return v;
424
+ // `APP_USER=` with nothing after it is the template, not a value — a blank
425
+ // login that "succeeds" is worse than a loud stop.
426
+ if (v === undefined || v.trim() === "")
427
+ throw new Error(`manifest references \${${k}} but it is not set — add it to .env (a demo account, never a real one)`);
428
+ // ${TOTP:APP_TOTP_SECRET}: the six-digit code for right now, from an
429
+ // authenticator enrolment secret. Computed at fill time, so it is valid.
430
+ return fn ? totp(v) : v;
336
431
  });
337
432
  }
433
+ /** RFC 6238 TOTP (SHA-1, 6 digits, 30s) from a base32 secret — what every
434
+ authenticator app enrols with. */
435
+ export function totp(base32Secret, nowMs = Date.now()) {
436
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
437
+ const clean = base32Secret.toUpperCase().replace(/[^A-Z2-7]/g, "");
438
+ let bits = "";
439
+ for (const c of clean)
440
+ bits += alphabet.indexOf(c).toString(2).padStart(5, "0");
441
+ const key = Buffer.from((bits.match(/.{8}/g) ?? []).map((b) => parseInt(b, 2)));
442
+ const counter = Buffer.alloc(8);
443
+ counter.writeBigUInt64BE(BigInt(Math.floor(nowMs / 30000)));
444
+ const h = createHmac("sha1", key).update(counter).digest();
445
+ const off = h[h.length - 1] & 0xf;
446
+ const code = ((h[off] & 0x7f) << 24 | h[off + 1] << 16 | h[off + 2] << 8 | h[off + 3]) % 1_000_000;
447
+ return String(code).padStart(6, "0");
448
+ }
338
449
  export function resolve(m) {
339
450
  const p = PRESETS[m.preset] ?? PRESETS[DEFAULT_PRESET];
340
451
  const viewport = { width: m.viewport?.width ?? p.width, height: m.viewport?.height ?? p.height };
@@ -44,6 +44,10 @@ export function brief(input) {
44
44
  "6. When it is good, run with preview=false once for the real quality, then done with one sentence.",
45
45
  "",
46
46
  "Rules:",
47
+ "- Every manifest carries `mode:`. `demo` is the default and covers almost everything — it proves how the thing works and keeps everything but the product out of the frame. `mode: launch` is only for a video that presents the product in public (a launch, a post, an ad); it is the one mode where title cards, music, a callout and a branded ending belong, and it must end with THEIR product, never Retake. Decide from the audience, not the subject: a client walkthrough is a demo. Never upgrade a demo to a launch on your own.",
48
+ "- A caption earns its place only by saying what the screen cannot. Do not narrate a visible click. Hold the payoff in ONE frame instead of touring its parts.",
49
+ "- `check: pass` proves the file is sound, not that the demo is good. Use `look` on the payoff and judge it as a viewer before `done`.",
50
+ "- Never add voiceover. It needs a continuous script and a person who has heard it.",
47
51
  "- Never type real passwords into a manifest; use ${ENV_VAR} and secret: true, and tell the person which variable to set.",
48
52
  "- Never invent selectors; use what scout/read_project returned.",
49
53
  "- Never accept a static manifest for an interactive request. It must perform the requested actions after setup, not merely describe them with scenes and waits.",