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.
@@ -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}"`);
package/dist/pace.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Idle compression: the app's dead time, shortened at render.
3
+ *
4
+ * The take's own timeline is the honest signal — a waitFor or navigate step
5
+ * records exactly how long the viewer sat looking at nothing changing. Each
6
+ * such stretch is shown as ~keepSeconds: the real start at 1×, then the rest
7
+ * fast-forwarded. The author's `wait` steps are pacing and are never touched;
8
+ * scene holds stay exactly as written.
9
+ *
10
+ * Implemented as a pre-pass: the source video is re-cut into a warped source
11
+ * and every clock in the take (timeline, trim, duration) is remapped through
12
+ * the same piecewise-linear warp, so captions, camera keys and stills land
13
+ * where they always did — the rest of the render pipeline never knows.
14
+ */
15
+ import path from "node:path";
16
+ /** The app made the viewer wait: show the start, fast-forward the rest. */
17
+ const WAITING = new Set(["waitFor", "navigate"]);
18
+ /** Only stretches meaningfully longer than what we would keep are worth a cut. */
19
+ const SLACK = 0.6;
20
+ /** Typing is not dead time — it is the person speaking — so it keeps its
21
+ start AND its finish at real speed and only accelerates the middle. Every
22
+ keystroke is a round-trip to the browser (~57ms whatever `delay` says), so
23
+ a real sentence costs five seconds nobody watches. */
24
+ const TYPE_BUDGET = 2.4;
25
+ const TYPE_HEAD = 0.8;
26
+ const TYPE_TAIL = 0.7;
27
+ /** Plan the warp: 1× everywhere except the tail of each long app-wait and the
28
+ middle of each long typing run. */
29
+ export function planIdle(take, keepSeconds) {
30
+ const segs = [];
31
+ for (const e of take.timeline) {
32
+ if (!e.ok)
33
+ continue;
34
+ const dur = e.end - e.start;
35
+ if (WAITING.has(e.action)) {
36
+ if (dur < keepSeconds + SLACK)
37
+ continue;
38
+ // Head at real speed, tail fast-forwarded into the remaining budget.
39
+ const head = keepSeconds * 0.6;
40
+ const shownTail = keepSeconds * 0.4;
41
+ segs.push({ from: e.start + head, to: e.end, rate: (dur - head) / shownTail });
42
+ }
43
+ else if (e.action === "type" || e.action === "fill") {
44
+ if (dur < TYPE_BUDGET + SLACK)
45
+ continue;
46
+ const middle = dur - TYPE_HEAD - TYPE_TAIL;
47
+ const shownMiddle = Math.max(0.3, TYPE_BUDGET - TYPE_HEAD - TYPE_TAIL);
48
+ segs.push({ from: e.start + TYPE_HEAD, to: e.end - TYPE_TAIL, rate: middle / shownMiddle });
49
+ }
50
+ }
51
+ return segs.sort((a, b) => a.from - b.from);
52
+ }
53
+ /** Source time → warped time. */
54
+ export function warpTime(segs, t) {
55
+ let out = t;
56
+ for (const s of segs) {
57
+ if (t <= s.from)
58
+ break;
59
+ const inSeg = Math.min(t, s.to) - s.from;
60
+ out -= inSeg - inSeg / s.rate;
61
+ }
62
+ return out;
63
+ }
64
+ /** The take with every clock remapped. */
65
+ export function warpTake(take, segs) {
66
+ const w = (t) => warpTime(segs, t);
67
+ return {
68
+ ...take,
69
+ trimBefore: w(take.trimBefore),
70
+ duration: w(take.duration),
71
+ timeline: take.timeline.map((e) => ({ ...e, start: w(e.start), end: w(e.end) })),
72
+ };
73
+ }
74
+ /** ffmpeg args to cut the raw source into the warped source. */
75
+ export function warpFilterArgs(src, segs, duration, out) {
76
+ // Alternating 1× and fast segments covering [0, duration].
77
+ const pieces = [];
78
+ let cur = 0;
79
+ for (const s of segs) {
80
+ if (s.from > cur)
81
+ pieces.push({ a: cur, b: s.from, rate: 1 });
82
+ pieces.push({ a: s.from, b: s.to, rate: s.rate });
83
+ cur = s.to;
84
+ }
85
+ if (cur < duration)
86
+ pieces.push({ a: cur, b: duration, rate: 1 });
87
+ const graph = pieces.map((p, i) => `[0:v]trim=start=${p.a.toFixed(3)}:end=${p.b.toFixed(3)},setpts=(PTS-STARTPTS)/${p.rate.toFixed(4)}[s${i}]`).join(";") +
88
+ ";" + pieces.map((_, i) => `[s${i}]`).join("") + `concat=n=${pieces.length}:v=1:a=0[out]`;
89
+ return ["-i", path.resolve(src), "-filter_complex", graph, "-map", "[out]", "-an", out];
90
+ }
91
+ /** Human line for the proof log. */
92
+ export function describeIdle(take, segs) {
93
+ return segs.map((s) => {
94
+ const e = take.timeline.find((x) => x.start <= s.from && x.end >= s.to);
95
+ const real = e ? e.end - e.start : s.to - s.from;
96
+ const shown = real - (s.to - s.from) + (s.to - s.from) / s.rate;
97
+ return `${e ? e.summary.slice(0, 52) : "wait"}: ${real.toFixed(1)}s shown as ${shown.toFixed(1)}s`;
98
+ });
99
+ }
package/dist/presets.js CHANGED
@@ -8,7 +8,7 @@ export const PRESETS = {
8
8
  name: "draft",
9
9
  width: 960, height: 540, scale: 1, fps: 15, crf: 0,
10
10
  gif: false, layout: "band", cameraZoom: 1.2,
11
- cursorSize: 36, captionFontSize: 24, bandHeight: 76,
11
+ cursorSize: 36, captionFontSize: 24, bandHeight: 76, bandFit: "text",
12
12
  encoder: "videotoolbox", master: false,
13
13
  description: "Fastest. Same layout as the final at a quarter of the pixels — for iterating, never for shipping.",
14
14
  },
@@ -16,7 +16,7 @@ export const PRESETS = {
16
16
  name: "preview-fast",
17
17
  width: 1920, height: 1080, scale: 2, fps: 24, crf: 0,
18
18
  gif: false, layout: "band", cameraZoom: 1.25,
19
- cursorSize: 72, captionFontSize: 46, bandHeight: 150,
19
+ cursorSize: 72, captionFontSize: 46, bandHeight: 150, bandFit: "text",
20
20
  encoder: "videotoolbox", master: false,
21
21
  description: "Same framing as post, hardware-encoded MP4 only. For checking timing and framing fast.",
22
22
  },
@@ -24,7 +24,7 @@ export const PRESETS = {
24
24
  name: "proof-fast",
25
25
  width: 960, height: 720, scale: 1, fps: 24, crf: 24,
26
26
  gif: false, layout: "band", cameraZoom: 1.2,
27
- cursorSize: 40, captionFontSize: 26, bandHeight: 84,
27
+ cursorSize: 40, captionFontSize: 26, bandHeight: 84, bandFit: "text",
28
28
  encoder: "videotoolbox", master: false,
29
29
  description: "Small, quick internal check.",
30
30
  },
@@ -32,7 +32,7 @@ export const PRESETS = {
32
32
  name: "post-landscape",
33
33
  width: 1920, height: 1080, scale: 2, fps: 30, crf: 17,
34
34
  gif: false, layout: "band", cameraZoom: 1.25,
35
- cursorSize: 72, captionFontSize: 46, bandHeight: 150,
35
+ cursorSize: 72, captionFontSize: 46, bandHeight: 150, bandFit: "text",
36
36
  encoder: "x264", master: true,
37
37
  description: "1080p, 30fps, CRF 17. The general-purpose demo. GIF on request.",
38
38
  },
@@ -41,25 +41,25 @@ export const PRESETS = {
41
41
  /* The page area is the canvas minus the caption band, so the file really
42
42
  is 1080×1080 — a square preset that produced 1080×1210 got letterboxed
43
43
  by every feed. Landscape keeps its full-height page on purpose. */
44
- width: 1080, height: 950, scale: 1.6, fps: 30, crf: 17,
44
+ width: 1080, height: 960, scale: 1.6, fps: 30, crf: 17,
45
45
  gif: false, layout: "band", cameraZoom: 1.25,
46
- cursorSize: 60, captionFontSize: 40, bandHeight: 130,
46
+ cursorSize: 60, captionFontSize: 36, bandHeight: 120, bandFit: "fill",
47
47
  encoder: "x264", master: true,
48
- description: "A true 1080×1080 (page 1080×950 + caption band), feed-friendly.",
48
+ description: "A true 1080×1080 (page 1080×960 + a fixed 120px caption band), feed-friendly.",
49
49
  },
50
50
  "post-vertical": {
51
51
  name: "post-vertical",
52
- width: 1080, height: 1750, scale: 2, fps: 30, crf: 17,
52
+ width: 1080, height: 1800, scale: 2, fps: 30, crf: 17,
53
53
  gif: false, layout: "band", cameraZoom: 1.3,
54
- cursorSize: 60, captionFontSize: 44, bandHeight: 170,
54
+ cursorSize: 60, captionFontSize: 40, bandHeight: 120, bandFit: "fill",
55
55
  encoder: "x264", master: true,
56
- description: "A true 1080×1920 (page 1080×1750 + caption band) for shorts/reels. Narrow-column apps suit it best.",
56
+ description: "A true 1080×1920 (page 1080×1800 + a fixed 120px caption band) for shorts/reels. Narrow-column apps suit it best.",
57
57
  },
58
58
  "docs-gif": {
59
59
  name: "docs-gif",
60
60
  width: 1440, height: 900, scale: 1.5, fps: 24, crf: 20,
61
61
  gif: { width: 900, fps: 15 }, layout: "band", cameraZoom: 1.2,
62
- cursorSize: 48, captionFontSize: 32, bandHeight: 100,
62
+ cursorSize: 48, captionFontSize: 32, bandHeight: 100, bandFit: "text",
63
63
  encoder: "x264", master: false,
64
64
  description: "README/docs GIF, 900px wide.",
65
65
  },
@@ -67,7 +67,7 @@ export const PRESETS = {
67
67
  name: "master",
68
68
  width: 1920, height: 1080, scale: 2, fps: 30, crf: 12,
69
69
  gif: false, layout: "band", cameraZoom: 1.25,
70
- cursorSize: 72, captionFontSize: 46, bandHeight: 150,
70
+ cursorSize: 72, captionFontSize: 46, bandHeight: 150, bandFit: "text",
71
71
  encoder: "x264", master: true,
72
72
  description: "Highest quality, slowest. Archive/source render.",
73
73
  },
package/dist/record.js CHANGED
@@ -44,7 +44,11 @@ export function capSecondsFor(m) {
44
44
  return Math.min(3600, Math.max(240, Math.round(m.steps.length * 10 + waits)));
45
45
  }
46
46
  /** Roughly how many times the cursor will move on camera. */
47
- export const CURSOR_MOVE_LIMIT = 45;
47
+ /** Cursor moves per take. testreel's nested-if overlay died at ~45 (ffmpeg's
48
+ parser stops at 98 levels); scripts/patch-testreel.mjs flattens those
49
+ expressions, so the ceiling is now the size of the filter graph as one
50
+ argv string — ~630 bytes a move against Linux's 128 KB per-argument cap. */
51
+ export const CURSOR_MOVE_LIMIT = 180;
48
52
  export function cursorMoves(m) {
49
53
  return m.steps.filter((s) => ["click", "type", "fill", "hover", "scroll", "upload"].includes(s.action)).length;
50
54
  }
@@ -187,6 +191,10 @@ export async function record(m, opts) {
187
191
  const stateFresh = !!statePath && fs.existsSync(statePath) && (Date.now() - fs.statSync(statePath).mtimeMs) / 3.6e6 < (m.auth?.maxAgeHours ?? 72);
188
192
  if (statePath)
189
193
  log(stateFresh ? `auth: reusing session ${path.relative(process.cwd(), statePath)}` : `auth: no fresh session — running setup to sign in`);
194
+ // No session and nothing that could create one: stop here rather than record
195
+ // a logged-out take that passes every check.
196
+ if (statePath && !stateFresh && !(m.auth?.setup?.length))
197
+ throw new Error(`no fresh session at ${path.relative(process.cwd(), statePath)} and no auth.setup to sign in with — run \`retake signin ${path.relative(process.cwd(), path.join(opts.manifestDir, m.name + ".yaml"))}\` and log in once by hand, or add the sign-in steps under auth.setup`);
190
198
  const context = await browser.newContext({
191
199
  viewport: size,
192
200
  colorScheme: m.colorScheme,
@@ -293,10 +301,7 @@ export async function record(m, opts) {
293
301
  // past roughly sixty steps the nesting exceeds ffmpeg's parser and the
294
302
  // composite fails. Long demos get an always-visible cursor unless told
295
303
  // otherwise — the fade was never the point of the video.
296
- const LONG = 60;
297
- const cursorIdleHide = q.cursor === false ? undefined : q.cursor.idleHide ?? (m.steps.length <= LONG);
298
- if (q.cursor !== false && q.cursor.idleHide === undefined && m.steps.length > LONG)
299
- log(`cursor: ${m.steps.length} steps — idle-hide fades off (ffmpeg nesting limit); set cursor.idleHide to override`);
304
+ const cursorIdleHide = q.cursor === false ? undefined : q.cursor.idleHide;
300
305
  const rec = await recordPage(page, {
301
306
  outputDir: opts.outDir,
302
307
  name: m.name,
@@ -331,6 +336,10 @@ export async function record(m, opts) {
331
336
  if (statePath && !stateFresh && authOk) {
332
337
  fs.mkdirSync(path.dirname(statePath), { recursive: true });
333
338
  await context.storageState({ path: statePath });
339
+ try {
340
+ fs.chmodSync(statePath, 0o600);
341
+ }
342
+ catch { /* windows */ }
334
343
  log(`auth: saved session → ${path.relative(process.cwd(), statePath)}`);
335
344
  }
336
345
  else if (statePath && !stateFresh) {
@@ -343,7 +352,7 @@ export async function record(m, opts) {
343
352
  // overlay silently fails and the video ships with no cursor at all.
344
353
  const moves = cursorMoves(m);
345
354
  if (q.cursor !== false && moves > CURSOR_MOVE_LIMIT)
346
- log(`cursor: ~${moves} cursor moves — testreel's overlay cannot exceed ~${CURSOR_MOVE_LIMIT}; the cursor may be MISSING from this video. Split the demo, or set cursor: false to be honest about it.`);
355
+ log(`cursor: ~${moves} cursor moves — past ~${CURSOR_MOVE_LIMIT} the cursor filter no longer fits in one ffmpeg argument and the overlay may be MISSING. Split the demo (a viewer wants chapters anyway), or set cursor: false to be honest about it.`);
347
356
  let pastUntil = false;
348
357
  for (const [i, step] of m.steps.entries()) {
349
358
  // --until <scene>: record that scene in full, stop at the next one.
@@ -365,6 +374,15 @@ export async function record(m, opts) {
365
374
  end: 0,
366
375
  ok: true,
367
376
  };
377
+ if (step.action === "callout") {
378
+ // Resolve the box now; the ring itself is drawn at render time.
379
+ const target = step.selector ?? step.at;
380
+ const c = await resolvePoint(page, target, step.timeout ?? 10_000);
381
+ const half = typeof target === "string" || !("x" in target)
382
+ ? await page.locator((typeof target === "string" ? target : target.selector)).first().evaluate((el) => { const r = el.getBoundingClientRect(); return { w: r.width, h: r.height }; }).catch(() => ({ w: 48, h: 48 }))
383
+ : { w: 48, h: 48 };
384
+ entry.callout = { box: { x: c.x - half.w / 2, y: c.y - half.h / 2, width: half.w, height: half.h }, label: step.label, ms: step.ms };
385
+ }
368
386
  if (step.action === "scene") {
369
387
  entry.label = step.label;
370
388
  entry.caption = step.caption;
@@ -430,7 +448,7 @@ export async function record(m, opts) {
430
448
  screenshots = result.screenshots;
431
449
  const joined = compositeLog.join("");
432
450
  if (/Error initializing filters|Missing '\)' or too many args/.test(joined)) {
433
- const why = `cursor overlay failed in testreel's ffmpeg pass (expression too deep — ~${moves} moves, limit ~${CURSOR_MOVE_LIMIT}); the video has NO CURSOR. Split the demo or set cursor: false.`;
451
+ const why = `cursor overlay failed in testreel's ffmpeg pass (~${moves} moves; the filter is too large past ~${CURSOR_MOVE_LIMIT}); the video has NO CURSOR. Split the demo or set cursor: false.`;
434
452
  partial = partial ? `${partial}; ${why}` : why;
435
453
  ok = false;
436
454
  log(`✗ ${why}`);
@@ -480,10 +498,18 @@ export async function record(m, opts) {
480
498
  // calibrated to within ~0.2s of the first video frame on a warm route). The
481
499
  // real file length is authoritative for the end.
482
500
  const trimBefore = sec(setupEnd - t0);
483
- const duration = video ? videoDuration(video) : sec(endMs - t0);
501
+ // The recording keeps rolling while the browser is torn down, so the raw
502
+ // video always has a tail of nothing after the last step. The take ends
503
+ // where the demo ends (plus a breath), never where the recorder stopped —
504
+ // otherwise every video ships with dead seconds at the end.
505
+ const GRACE = 0.4;
506
+ const videoSec = video ? videoDuration(video) : sec(endMs - t0);
484
507
  const last = timeline.at(-1);
485
- if (last && Math.abs(duration - last.end) > 3) {
486
- log(`note: video is ${duration.toFixed(1)}s but the last step ended at ${last.end.toFixed(1)}s — check anchoring`);
508
+ const duration = last && videoSec > last.end + GRACE + 0.1 ? last.end + GRACE : videoSec;
509
+ if (last && duration < videoSec - 0.05)
510
+ log(`trim: ${(videoSec - duration).toFixed(1)}s of tail after the last step dropped`);
511
+ if (last && videoSec < last.end - 0.5) {
512
+ log(`note: video is ${videoSec.toFixed(1)}s but the last step ended at ${last.end.toFixed(1)}s — check anchoring`);
487
513
  }
488
514
  const finishedAt = new Date().toISOString();
489
515
  const take = {
@@ -614,7 +640,7 @@ async function runStep(rec, page, step, m, ctx) {
614
640
  await rec.click(step.selector, { timeout, zoom: step.zoom });
615
641
  break;
616
642
  case "type":
617
- await rec.type(step.selector, expandEnv(step.text), { delay: step.delay ?? 70, clear: step.clear, timeout });
643
+ await rec.type(step.selector, expandEnv(step.text), { delay: step.delay ?? (m.typing === "brisk" ? 22 : 70), clear: step.clear, timeout });
618
644
  break;
619
645
  case "fill":
620
646
  await rec.fill(step.selector, expandEnv(step.text), { timeout });
@@ -694,6 +720,11 @@ async function runStep(rec, page, step, m, ctx) {
694
720
  case "stub":
695
721
  await ctx.stub({ url: step.url, method: step.method, status: step.status ?? 200, json: step.json, from: step.from, contentType: "application/json; charset=utf-8" });
696
722
  break;
723
+ case "callout":
724
+ // The box was resolved in the caller; on camera this is a hold while
725
+ // the (render-time) ring has the viewer's eye.
726
+ await page.waitForTimeout(step.ms / m.speed);
727
+ break;
697
728
  case "scene":
698
729
  // A marker only. Its timestamp is the point of the step.
699
730
  break;
@@ -742,6 +773,8 @@ export function describe(step) {
742
773
  return `evaluate (${step.script.length} chars)`;
743
774
  case "stub":
744
775
  return `stub ${step.method ? step.method + " " : ""}${step.url}`;
776
+ case "callout":
777
+ return `callout ${step.selector ?? pointName(step.at)}${step.label ? ` “${step.label}”` : ""}`;
745
778
  case "scene":
746
779
  return `scene: ${step.label}`;
747
780
  }