retake-demos 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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({}),
@@ -248,6 +312,20 @@ const ManifestShape = z.object({
248
312
  .prefault({}),
249
313
  });
250
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
+ }
251
329
  [...m.steps, ...m.setup].forEach((st, i) => {
252
330
  if ((st.action === "click" || st.action === "hover") && !st.selector && !st.at) {
253
331
  ctx.addIssue({ code: "custom", path: ["steps", i], message: `${st.action} needs either a selector or an \`at\` point` });
@@ -305,6 +383,13 @@ export function loadManifest(file) {
305
383
  /** Things worth saying out loud before a run. Not errors — the run proceeds. */
306
384
  export function warnings(m) {
307
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
+ }
308
393
  // Secrets typed during recorded steps end up in the video. In `setup` they do
309
394
  // not: setup runs before the camera and is trimmed off the front.
310
395
  const onCamera = m.steps.filter((s) => s.secret).length;
@@ -334,13 +419,33 @@ export function warnings(m) {
334
419
  }
335
420
  /** `${VAR}` substitution against process.env, like testreel. */
336
421
  export function expandEnv(s) {
337
- return s.replace(/\$\{([A-Z0-9_]+)\}/g, (_, k) => {
422
+ return s.replace(/\$\{(TOTP:)?([A-Z0-9_]+)\}/g, (_, fn, k) => {
338
423
  const v = process.env[k];
339
- if (v === undefined)
340
- throw new Error(`manifest references \${${k}} but it is not set`);
341
- 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;
342
431
  });
343
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
+ }
344
449
  export function resolve(m) {
345
450
  const p = PRESETS[m.preset] ?? PRESETS[DEFAULT_PRESET];
346
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.",
@@ -27,6 +27,7 @@ import { scout, draftManifest, suggestIdeas, pickProvider, loadDotenv } from "..
27
27
  import { digest } from "../digest.js";
28
28
  import { startApp as reallyStartApp, listeningPorts as ports, waitForUrl as waitUrl } from "../appserver.js";
29
29
  import { applyEdits, receiptsFor } from "../edits.js";
30
+ import { SECRET_NAME, missingSecrets, readEnvFile } from "../env.js";
30
31
  const ROOT = process.env.RETAKE_ROOT || process.cwd();
31
32
  const DEMOS = path.join(ROOT, "demos");
32
33
  const OUT = process.env.RETAKE_OUT ? path.resolve(process.env.RETAKE_OUT.replace(/^~/, os.homedir())) : path.join(ROOT, "outputs");
@@ -129,7 +130,63 @@ server.registerTool("start_app", {
129
130
  await tell(`Could not start it: ${r.why}`);
130
131
  return text(`FAILED: ${r.why}\nlog tail:\n${r.log.slice(-800)}`);
131
132
  });
132
- server.registerTool("ask", { description: "Ask the person ONE question and wait for the answer. Use only when genuinely blocked (which app, a credential name, a choice between two things). Keep it one sentence, with the evidence.", inputSchema: { question: z.string() }, annotations: RETAKE_WRITE }, async ({ question }) => {
133
+ server.registerTool("secrets", {
134
+ description: [
135
+ "Get the person's demo-account credentials into place WITHOUT ever seeing them. Call this the moment a demo needs a login (read_project found sign-in fields, scout found a password field, or they said so): pass the variable NAMES you will use (APP_USER, APP_PASSWORD, and APP_TOTP_SECRET if the site asks for an authenticator code).",
136
+ "It returns at once with what to tell the person — ALWAYS relay it in full, with the link: assume they have never seen the Retake window and do not know it exists. They type the values into a form there (or into their terminal); the values go straight into the workspace .env; you only ever learn 'set'. When they say they are done, call this tool again with the same names.",
137
+ "Then the manifest references them as ${APP_USER} / ${APP_PASSWORD} with `secret: true`, inside auth.setup. Never ask for a password in chat, never put a literal credential in a manifest, never invent values. For logins a script cannot do (SMS code, SSO, captcha): `retake signin demos/<name>.yaml` — the person signs in once in a real window and Retake keeps the session.",
138
+ ].join(" "),
139
+ inputSchema: {
140
+ names: z.array(z.string().regex(SECRET_NAME, "UPPER_SNAKE names like APP_PASSWORD")).min(1).describe("the environment variable names the manifest will reference"),
141
+ why: z.string().describe("one sentence the person will read: what the login is for, e.g. 'The dashboard demo starts behind the admin sign-in.'"),
142
+ },
143
+ annotations: RETAKE_WRITE,
144
+ }, async ({ names, why }) => {
145
+ const reload = () => { for (const [k, v] of Object.entries(readEnvFile(ROOT)))
146
+ if (v.trim())
147
+ process.env[k] = v; };
148
+ const how = `Reference them as ${names.map((n) => "${" + n + "}").join(", ")} with \`secret: true\` inside auth.setup${names.some((n) => /TOTP/.test(n)) ? ` (the authenticator one as ${"${TOTP:" + names.find((n) => /TOTP/.test(n)) + "}"} — Retake computes the current code)` : ""}. The values stay in .env; you never see them, and they are never on camera.`;
149
+ reload();
150
+ let missing = missingSecrets(ROOT, names);
151
+ if (!missing.length)
152
+ return text(`All set: ${names.join(", ")} are in the workspace .env. ${how} Continue.`);
153
+ const link = UI || "http://localhost:4310";
154
+ const list = missing.join(" and ");
155
+ // Is the window actually there? A dead URL must not leave the person
156
+ // staring at a link that does nothing.
157
+ const windowUp = UI ? await fetch(`${UI}/api/settings`).then((r) => r.ok).catch(() => false) : false;
158
+ if (windowUp) {
159
+ // One open form per set of names — calling again must not stack forms.
160
+ const pending = await fetch(`${UI}/api/secrets/pending`).then((r) => r.json()).catch(() => []);
161
+ let req = pending.find((r) => r.names.join() === missing.join());
162
+ if (req) {
163
+ // Second call: they said "done" (or we are checking). Give the form a
164
+ // short moment, then report — never sit here in silence.
165
+ const deadline = Date.now() + 20_000;
166
+ while (Date.now() < deadline) {
167
+ const st = await fetch(`${UI}/api/secrets/${req.id}`).then((x) => x.json()).catch(() => null);
168
+ if (st?.filled)
169
+ break;
170
+ await new Promise((ok) => setTimeout(ok, 2000));
171
+ }
172
+ reload();
173
+ missing = missingSecrets(ROOT, names);
174
+ if (!missing.length)
175
+ return text(`Set: ${names.join(", ")} — saved to the workspace .env from the Retake window. ${how} Continue.`);
176
+ return text(`Not yet — ${missing.join(", ")} still empty. The form is still open in the Retake window. Tell them again, with the link: “Open ${link} — there is a form waiting for ${list}. Type the demo account's values, press Save to .env, and tell me when it's done.” Then call secrets again.`);
177
+ }
178
+ try {
179
+ const r = await fetch(`${UI}/api/secrets/request`, { method: "POST", body: JSON.stringify({ names: missing, why }) });
180
+ if (!r.ok)
181
+ throw new Error(String(r.status));
182
+ await tell(`Waiting for you: ${missing.join(", ")} — a form is open in the window.`);
183
+ return text(`A form is now open in the Retake window. Tell the person this, including the link: “Open ${link} in your browser — a form is waiting there for ${list}. Use a demo account for the app (the result is a video). What you type is saved to one local file on your computer and never sent to me or anywhere else. Press Save to .env, then tell me it's done.” When they say done, call secrets again with the same names.`);
184
+ }
185
+ catch { /* fall through to the terminal route */ }
186
+ }
187
+ return text(`The Retake window is not open. Tell the person this, exactly: “Retake needs a demo account for this app. Either run \`retake ui\` and open ${link} — I'll ask you there — or run \`retake secret ${missing.join(" ")}\` in your Retake workspace folder and type the values into the terminal (hidden, kept on your machine). If the sign-in needs a code from your phone, SSO or a captcha, run \`retake signin demos/<name>.yaml\` instead and log in once by hand. Tell me when it's done.” Then call secrets again with the same names.`);
188
+ });
189
+ server.registerTool("ask", { description: "Ask the person ONE question and wait for the answer. Use only when genuinely blocked (which app, a choice between two things). Keep it one sentence, with the evidence. NEVER for passwords or tokens — that is what `secrets` is for.", inputSchema: { question: z.string() }, annotations: RETAKE_WRITE }, async ({ question }) => {
133
190
  try {
134
191
  const a = await waitForHuman("question", question);
135
192
  return text(`They answered: ${a}`);
@@ -249,7 +306,7 @@ server.registerTool("read_manifest", { description: "The YAML of a demo.", input
249
306
  return text(`no demo "${name}"`);
250
307
  return text(fs.readFileSync(manifestPath(name), "utf8"));
251
308
  });
252
- server.registerTool("write_manifest", { description: "Create or replace a demo's YAML. It is validated; errors come back instead of being written. Prefer `edit` for small changes to an existing demo.", inputSchema: { name: z.string(), yaml: z.string() }, annotations: RETAKE_WRITE }, async ({ name, yaml }) => {
309
+ server.registerTool("write_manifest", { description: "Create or replace a demo's YAML. It is validated; errors come back instead of being written. Prefer `edit` for small changes to an existing demo. ALWAYS set `mode:`. `mode: demo` (the default) proves how something works and keeps everything but the product out of the frame — a client walkthrough, a PR clip, a bug repro, a lesson, anything for someone who already wants to know how it works. `mode: launch` presents the product in public (a launch, a post, an ad, a landing page) and is the ONLY mode where intro/outro cards, music, callouts and a branded ending belong — and a launch cut ends with THEIR product, never with Retake. Decide from the AUDIENCE, not the subject, and never upgrade a demo to a launch yourself: if you are unsure, use demo and ask.", inputSchema: { name: z.string(), yaml: z.string() }, annotations: RETAKE_WRITE }, async ({ name, yaml }) => {
253
310
  LAST_DEMO = name;
254
311
  if (!safe(name))
255
312
  return text("name must be kebab-case");
@@ -269,7 +326,7 @@ server.registerTool("write_manifest", { description: "Create or replace a demo's
269
326
  await tell(`Wrote demos/${name}.yaml — ${r.data.steps.length} steps, ${r.data.steps.filter((s) => s.action === "scene").length} scenes`);
270
327
  return text(`written: demos/${name}.yaml (${r.data.steps.length} steps)`);
271
328
  });
272
- server.registerTool("draft", { description: "Let Retake draft a manifest from a sentence: scouts the URL (and reads the project if given) and writes demos/<name>.yaml. Then dry-run it.", inputSchema: { name: z.string(), url: z.string().url(), describe: z.string(), project: z.string().optional() }, annotations: RETAKE_WRITE }, async ({ name, url, describe, project }) => {
329
+ server.registerTool("draft", { description: "Let Retake draft a manifest from a sentence: scouts the URL (and reads the project if given) and writes demos/<name>.yaml. Then dry-run it. Drafts are always `mode: demo` — nothing in the frame but the product. If this is a launch or a post, say so afterwards with edit (see write_manifest for what launch mode allows).", inputSchema: { name: z.string(), url: z.string().url(), describe: z.string(), project: z.string().optional() }, annotations: RETAKE_WRITE }, async ({ name, url, describe, project }) => {
273
330
  LAST_DEMO = name;
274
331
  if (!safe(name))
275
332
  return text("name must be kebab-case");
@@ -284,7 +341,7 @@ server.registerTool("draft", { description: "Let Retake draft a manifest from a
284
341
  await tell(`Drafted demos/${name}.yaml — ${m.steps.length} steps, ${m.steps.filter((s) => s.action === "scene").length} scenes`);
285
342
  return text(`drafted demos/${name}.yaml (${m.steps.length} steps). Next: dry.\n\n${d.yaml}`);
286
343
  });
287
- server.registerTool("edit", { description: "Make small structured changes to a demo without rewriting it: captions, camera, holds, trim, waits, selectors, text; delete_step; insert_step {after, step:{action,…}} to add one step; set_step {step, value:{action,…}} to replace one. Steps are validated. Keeps comments. Returns what changed and whether it needs re-recording. Prefer this over write_manifest — one verb costs a line, a rewrite costs the file.", inputSchema: { name: z.string(), edits: z.array(z.object({ op: z.string() }).passthrough()) }, annotations: RETAKE_WRITE }, async ({ name, edits }) => {
344
+ server.registerTool("edit", { description: "Make small structured changes to a demo without rewriting it: captions, camera, holds, trim, waits, selectors, text; delete_step; insert_step {after, step:{action,…}} to add one step; set_step {step, value:{action,…}} to replace one. Steps are validated. Keeps comments. Returns what changed and whether it needs re-recording. Prefer this over write_manifest — one verb costs a line, a rewrite costs the file. Turning a demo into a launch cut is edits too: set mode: launch, then intro/outro cards, one callout at most, music (their file), compressIdle. Never do that unasked — a demo is the default and a client walkthrough is a demo.", inputSchema: { name: z.string(), edits: z.array(z.object({ op: z.string() }).passthrough()) }, annotations: RETAKE_WRITE }, async ({ name, edits }) => {
288
345
  LAST_DEMO = name;
289
346
  if (!safe(name) || !fs.existsSync(manifestPath(name)))
290
347
  return text(`no demo "${name}"`);
@@ -303,7 +360,7 @@ server.registerTool("dry", { description: "Run a demo with no camera: every sele
303
360
  await tell(r.ok ? `Checked: all ${manifest.steps.length} steps resolve` : `${r.failures} step(s) would fail`);
304
361
  return text(r.ok ? `ok: all ${manifest.steps.length} steps resolve` : r.lines.join("\n"));
305
362
  });
306
- server.registerTool("run", { description: "Record the demo and render it. Slow (the demo is performed in real time). preview=true for a fast low-cost render to check the story. Returns the receipts.", inputSchema: { name: z.string(), preview: z.boolean().default(true), until: z.string().optional().describe("record up to the end of this scene label, then stop — for iterating on one beat of a long demo") }, annotations: RETAKE_WRITE }, async ({ name, preview, until }) => {
363
+ server.registerTool("run", { description: "Record the demo and render it. Slow (the demo is performed in real time). preview=true for a fast low-cost render to check the story. Returns the receipts. `check: pass` and healthy audio prove the FILE is sound, not that the demo is good — call `look` at the payoff scene and judge it: can a stranger state the promise from that frame, is the result legible at playback size, is any beat longer than what it tells you? If you cannot answer yes, fix the cut before calling done.", inputSchema: { name: z.string(), preview: z.boolean().default(true), until: z.string().optional().describe("record up to the end of this scene label, then stop — for iterating on one beat of a long demo") }, annotations: RETAKE_WRITE }, async ({ name, preview, until }) => {
307
364
  LAST_DEMO = name;
308
365
  if (!safe(name) || !fs.existsSync(manifestPath(name)))
309
366
  return text(`no demo "${name}"`);