retake-demos 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dryrun.js CHANGED
@@ -8,13 +8,74 @@
8
8
  */
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
- import { runFileOrCommandSeed, resolvePoint, dragPoints } from "./record.js";
11
+ import { applyPageSetup, authState, dragPoints, gateOnApp, resolvePoint, runEvaluateSeed, runFileOrCommandSeed } from "./record.js";
12
12
  import { chromium } from "playwright";
13
13
  import { expandEnv, resolve } from "./manifest.js";
14
+ async function elInfo(page, selector, timeout) {
15
+ try {
16
+ const h = await page.locator(selector).first().elementHandle({ timeout });
17
+ if (!h)
18
+ return null;
19
+ const info = await h.evaluate((el) => {
20
+ const e = el;
21
+ return {
22
+ tag: e.tagName.toLowerCase(),
23
+ type: (e.type ?? "").toLowerCase(),
24
+ disabled: !!e.disabled || e.getAttribute("aria-disabled") === "true",
25
+ readOnly: !!e.readOnly,
26
+ editable: e.isContentEditable,
27
+ };
28
+ });
29
+ await h.dispose();
30
+ return info;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /** Does this action make sense on this element? Returns the complaint, or null.
37
+ *
38
+ * This is the check that pays for itself: an action that cannot work on the
39
+ * element it names is knowable before a single frame is recorded, and finding
40
+ * it out during a take costs the whole take. So the message names the fix, not
41
+ * just the symptom — a report that says "use `action: select`" ends the
42
+ * problem, one that says "element is not an <input>" starts an investigation. */
43
+ export function actionFits(action, el) {
44
+ const NOT_TEXT = new Set(["button", "submit", "reset", "checkbox", "radio", "file", "image", "range", "color"]);
45
+ const is = (t) => el.tag === t;
46
+ if (action === "type" || action === "fill") {
47
+ if (is("select"))
48
+ return `\`${action}\` cannot be used on a <select> — use \`action: select\` with \`value: "<the option>"\``;
49
+ if (is("input") && el.type === "file")
50
+ return `\`${action}\` cannot be used on a file input — use \`action: upload\` with \`file:\``;
51
+ if (is("input") && NOT_TEXT.has(el.type))
52
+ return `\`${action}\` cannot be used on <input type="${el.type}"> — it holds no text; \`action: click\` is probably what you mean`;
53
+ if (!is("input") && !is("textarea") && !el.editable)
54
+ return `\`${action}\` needs a text field, but this is a <${el.tag}> that is not editable — if it is a custom editor, target the inner element that accepts typing`;
55
+ if (el.disabled)
56
+ return `\`${action}\` cannot type into a disabled <${el.tag}> — something earlier has to enable it first`;
57
+ if (el.readOnly)
58
+ return `\`${action}\` cannot type into a read-only <${el.tag}>`;
59
+ }
60
+ if (action === "select" && !is("select")) {
61
+ return `\`select\` only works on a real <select>, and this is a <${el.tag}> — for a custom dropdown, \`click\` it and then \`click\` the option`;
62
+ }
63
+ if (action === "upload" && !(is("input") && el.type === "file")) {
64
+ return `\`upload\` needs <input type="file">, and this is a <${el.tag}>${el.type && el.type !== el.tag ? ` type="${el.type}"` : ""}`;
65
+ }
66
+ if (action === "click" && el.disabled) {
67
+ return `this <${el.tag}> is disabled right now — the click would do nothing; wait for whatever enables it (\`action: waitFor\`) before clicking`;
68
+ }
69
+ return null;
70
+ }
14
71
  export async function dryRun(m, manifestDir, log, opts = {}) {
15
72
  const q = resolve(m);
16
73
  const lines = [];
17
74
  let failures = 0;
75
+ /** What the page actually laid out at — the width its breakpoints saw. */
76
+ let layoutWidth = null;
77
+ // Failure pictures land beside the demo's other output, not in the cwd.
78
+ const shotDir = path.join(opts.outRoot ?? "outputs", m.name);
18
79
  // Seeds run here too (file and command kinds — the cheap, out-of-page
19
80
  // ones). Without them dry checks a different app state than run will,
20
81
  // and a selector failure that is really a state difference looks like a
@@ -28,11 +89,23 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
28
89
  log("dry: seeds skipped (--no-seed) — app state may not match what run will see");
29
90
  }
30
91
  const browser = await chromium.launch({ headless: true });
92
+ // Start from the same session run would. Without this, a demo behind a
93
+ // login was dry-run logged OUT — every selector "missing", or worse, a
94
+ // green dry against a marketing page and a failed take against the app.
95
+ const auth = authState(m, manifestDir);
96
+ if (auth.path)
97
+ log(auth.fresh ? `dry: reusing session ${path.relative(process.cwd(), auth.path)}` : `dry: no fresh session — auth.setup will have to sign in`);
31
98
  const context = await browser.newContext({
32
99
  viewport: q.viewport,
33
100
  colorScheme: m.colorScheme,
34
101
  ...(m.reducedMotion ? { reducedMotion: "reduce" } : {}),
102
+ ...(auth.fresh && auth.path ? { storageState: auth.path } : {}),
35
103
  });
104
+ // Whatever the recorder injects, dry injects — one function, so the two
105
+ // cannot drift apart again. (Measured, for the record: `html{zoom}` does
106
+ // NOT move an app's media queries, which is why the layout width reported
107
+ // below is asked of the page rather than computed as width÷scale.)
108
+ await applyPageSetup(context, m, q);
36
109
  const stubs = new Map();
37
110
  const arm = async (d) => {
38
111
  const key = `${d.method ?? "ANY"} ${d.url}`;
@@ -56,6 +129,20 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
56
129
  // go network-idle, and the manifest's waitForSelector is the real gate.
57
130
  await page.goto(expandEnv(m.url), { waitUntil: "domcontentloaded", timeout: 60000 });
58
131
  await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => { });
132
+ // `evaluate` seeds write into the page itself — the panel that only
133
+ // exists once some state is seeded. Dry skipped them, so dry and run were
134
+ // literally looking at different apps; that was one of three lost takes
135
+ // in the field report.
136
+ if (opts.seed !== false) {
137
+ for (const sd of m.seed)
138
+ if (sd.kind === "evaluate")
139
+ await runEvaluateSeed(page, sd, manifestDir, log);
140
+ }
141
+ // The manifest's own readiness gate, with run's wrong-app diagnosis. A dry
142
+ // run that starts stepping before the app has booted reports selector
143
+ // failures that are really a race.
144
+ await gateOnApp(page, m, shotDir);
145
+ layoutWidth = await page.evaluate(() => document.documentElement.clientWidth).catch(() => null);
59
146
  for (const s of [...(m.auth?.setup ?? []), ...m.setup])
60
147
  await run(s, true);
61
148
  for (const [i, s] of m.steps.entries())
@@ -69,6 +156,16 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
69
156
  const tag = isSetup ? "setup" : String(index).padStart(3, "0");
70
157
  const short = 8000; // long enough for a click that navigates, short enough to stay fast
71
158
  try {
159
+ // Before doing anything: does this action fit the element it names? A
160
+ // mismatch is the expensive kind of failure — the selector resolves, dry
161
+ // passes, and the take dies on it minutes into a recording.
162
+ const sel = step.selector;
163
+ if (sel && ["type", "fill", "select", "upload", "click"].includes(step.action)) {
164
+ const el = await elInfo(page, sel, short);
165
+ const complaint = el && actionFits(step.action, el);
166
+ if (complaint)
167
+ throw new Error(complaint);
168
+ }
72
169
  // A point step (click/hover `at`, or a drag) is verified by resolving
73
170
  // its target's rect in the page — the same way the recorder will.
74
171
  const point = async (t) => {
@@ -117,7 +214,7 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
117
214
  await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => { });
118
215
  break;
119
216
  case "waitFor":
120
- await page.waitForSelector(step.selector, { timeout: Math.min(step.timeout ?? 15000, 15000) });
217
+ await page.waitForSelector(step.selector, { state: step.gone ? "hidden" : "visible", timeout: Math.min(step.timeout ?? 15000, 15000) });
121
218
  break;
122
219
  // noWaitAfter: a click that submits a form starts a navigation, and
123
220
  // waiting for the element to settle afterwards reports a false failure
@@ -138,6 +235,19 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
138
235
  case "hover":
139
236
  await page.locator(step.selector).first().hover({ timeout: short });
140
237
  break;
238
+ case "select": {
239
+ // Resolving is not enough: an option that does not exist is the
240
+ // whole failure mode, so dry proves the value is selectable.
241
+ const sel = page.locator(step.selector).first();
242
+ await sel.waitFor({ timeout: short });
243
+ try {
244
+ await sel.selectOption(step.value, { timeout: short });
245
+ }
246
+ catch {
247
+ await sel.selectOption({ label: step.value }, { timeout: short });
248
+ }
249
+ break;
250
+ }
141
251
  case "scroll":
142
252
  if (step.to)
143
253
  await page.locator(step.to).first().boundingBox({ timeout: short });
@@ -179,6 +289,16 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
179
289
  where = `\n on ${page.url()} — “${text}”`;
180
290
  }
181
291
  catch { /* page may be gone */ }
292
+ // Text alone cannot say "another element is covering the button" or
293
+ // "the layout is not what you think". A picture can, and dry is the
294
+ // cheap pass — it should hand over more than run's does, not less.
295
+ try {
296
+ const shot = path.join(shotDir, `dry-failed-${String(failures).padStart(2, "0")}.png`);
297
+ fs.mkdirSync(shotDir, { recursive: true });
298
+ await page.screenshot({ path: shot, fullPage: true });
299
+ where += `\n picture: ${path.relative(process.cwd(), shot)}`;
300
+ }
301
+ catch { /* not fatal */ }
182
302
  const line = `✗ [${tag}] ${describe(step)} — ${why}${where}`;
183
303
  lines.push(line);
184
304
  log(line);
@@ -186,8 +306,68 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
186
306
  }
187
307
  const ok = failures === 0;
188
308
  log(ok ? `dry run: all ${m.steps.length} steps resolved` : `dry run: ${failures} step(s) would fail`);
309
+ // A preset's width is VIDEO pixels: at scale 2 a 1920 preset lays the page
310
+ // out at 960 CSS px, under most desktop breakpoints, so a responsive app
311
+ // quietly serves its MOBILE layout and the steps are looking for fields that
312
+ // are not there. It reads as a pile of broken selectors, and costs a whole
313
+ // take to work out. Only asked when something actually failed, and only
314
+ // reported when the wider page proves it.
315
+ if (!ok) {
316
+ // Measured, not computed: `zoom` does not move an app's media queries, so
317
+ // width÷scale is not the number the page's breakpoints saw. Ask the page.
318
+ const cssWidth = layoutWidth ?? q.viewport.width;
319
+ if (cssWidth < 1100) {
320
+ const missing = lines.map((l) => /✗ \[[^\]]+\] \w+ (.+?) —/.exec(l)?.[1]).filter((x) => !!x);
321
+ const found = await resolvesWiderThan(m, missing, cssWidth, manifestDir);
322
+ if (found.length) {
323
+ const note = `⚠ ${found.length} of these selector(s) DO exist when the page is 1280 CSS px wide, but not at ${cssWidth}px.\n` +
324
+ ` This take lays the page out at ${cssWidth} CSS px, which is below this app's desktop breakpoint — you are filming its mobile layout.\n` +
325
+ ` Fix: give the manifest a wider \`viewport\`, or write the steps against the layout you are actually filming.\n` +
326
+ ` Resolved wider: ${found.slice(0, 4).join(", ")}${found.length > 4 ? ` … +${found.length - 4}` : ""}`;
327
+ lines.push(note);
328
+ log(note);
329
+ }
330
+ }
331
+ }
189
332
  return { ok, lines, failures };
190
333
  }
334
+ /** Load the same page at a desktop width and report which of these selectors
335
+ resolve there. Proof, not a guess: it is only worth telling someone their
336
+ preset is filming the mobile layout if the wider page really does have the
337
+ thing they were looking for. */
338
+ async function resolvesWiderThan(m, selectors, narrowPx, manifestDir) {
339
+ const unique = [...new Set(selectors)].filter((s) => s && !s.startsWith("http"));
340
+ if (!unique.length || narrowPx >= 1280)
341
+ return [];
342
+ const browser = await chromium.launch({ headless: true });
343
+ try {
344
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, colorScheme: m.colorScheme });
345
+ const page = await ctx.newPage();
346
+ for (const d of m.stub) {
347
+ await ctx.route(d.url, async (route) => {
348
+ const body = d.json !== undefined ? JSON.stringify(d.json) : d.from ? fs.readFileSync(path.resolve(manifestDir, expandEnv(d.from)), "utf8") : "{}";
349
+ await route.fulfill({ status: d.status ?? 200, contentType: d.contentType, body });
350
+ });
351
+ }
352
+ await page.goto(expandEnv(m.url), { waitUntil: "domcontentloaded", timeout: 30000 });
353
+ await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => { });
354
+ const found = [];
355
+ for (const sel of unique) {
356
+ try {
357
+ if (await page.locator(sel).first().isVisible({ timeout: 1500 }))
358
+ found.push(sel);
359
+ }
360
+ catch { /* still missing */ }
361
+ }
362
+ return found;
363
+ }
364
+ catch {
365
+ return [];
366
+ }
367
+ finally {
368
+ await browser.close().catch(() => { });
369
+ }
370
+ }
191
371
  function describe(s) {
192
372
  if ("selector" in s && s.selector)
193
373
  return `${s.action} ${s.selector}`;
package/dist/manifest.js CHANGED
@@ -79,6 +79,14 @@ export const Step = z.discriminatedUnion("action", [
79
79
  slow it down and the viewer can follow what went past. */
80
80
  speed: z.number().positive().max(4).optional(),
81
81
  }),
82
+ /** Choose an option in a <select>. A form-heavy app is mostly dropdowns, and
83
+ without this a demo of one has to fake them with evaluate. */
84
+ Base.extend({
85
+ action: z.literal("select"),
86
+ selector: Selector,
87
+ /** The option's value, or its visible label — whichever the page uses. */
88
+ value: z.string(),
89
+ }),
82
90
  Base.extend({
83
91
  action: z.literal("zoom"),
84
92
  selector: Selector.optional(),
@@ -97,7 +105,23 @@ export const Step = z.discriminatedUnion("action", [
97
105
  Base.extend({ action: z.literal("download"), selector: Selector.optional(), saveAs: z.string().optional(), expect: z.string().optional() }),
98
106
  /** Wait for a selector to appear (Retake extra; the thing testreel's JSON lacks
99
107
  for AI-driven apps whose next screen arrives whenever the model answers). */
100
- Base.extend({ action: z.literal("waitFor"), selector: Selector }),
108
+ /** Wait for a real result. A bare selector resolves the instant the element
109
+ exists, which is the wrong moment three ways, all of them seen in the
110
+ field: the element is a shell that streams its text in afterwards; the
111
+ element is the PREVIOUS run\'s banner still on screen; the element is
112
+ there but the app is still busy. Hence: */
113
+ Base.extend({
114
+ action: z.literal("waitFor"),
115
+ selector: Selector,
116
+ /** Wait for it to go AWAY instead of appear — the fix for waiting on a
117
+ stale success banner from the previous action. */
118
+ gone: z.boolean().default(false),
119
+ /** Resolve only once the subtree has stopped changing for this long.
120
+ This is how you wait for a streamed reply to finish. */
121
+ stableMs: z.number().min(50).max(30_000).optional(),
122
+ /** Resolve only once it holds at least this much text. */
123
+ minChars: z.number().int().positive().optional(),
124
+ }),
101
125
  /** Run JS in the page (Retake extra). Used for seeding, hiding chrome, etc. */
102
126
  Base.extend({ action: z.literal("evaluate"), script: z.string().min(1) }),
103
127
  /** Change what a stubbed endpoint answers, mid-take. This is how a demo shows
@@ -126,6 +150,11 @@ export const Step = z.discriminatedUnion("action", [
126
150
  Base.extend({
127
151
  action: z.literal("scene"),
128
152
  label: z.string().min(1),
153
+ /** Move this scene's marker in the finished video, in milliseconds
154
+ (negative = earlier). RENDER-TIME: it shifts where the caption, the
155
+ still and the thumbnail land, and never costs a new recording. A
156
+ caption that arrives a beat late is a re-render, not a re-take. */
157
+ nudge: z.number().int().min(-60_000).max(60_000).optional(),
129
158
  caption: z.string().optional(),
130
159
  holdMs: z.number().positive().optional(),
131
160
  /** Where the camera looks during this scene. Omitted → the manifest's
@@ -385,6 +414,29 @@ export function warnings(m) {
385
414
  const w = [];
386
415
  // A demo proves an interaction; a launch presents the product. Launch
387
416
  // furniture in a demo is noise the viewer has to look past.
417
+ // A held opening frame is the point of a launch cut, not a mistake.
418
+ const launch = m.mode === "launch";
419
+ if (m.viewport) {
420
+ const p = PRESETS[m.preset] ?? PRESETS[DEFAULT_PRESET];
421
+ const band = m.captions === false ? 0 : p.bandHeight;
422
+ if (m.viewport.width !== p.width || m.viewport.height !== p.height - band) {
423
+ w.push(`\`viewport\` overrides the preset, so this video will be ${m.viewport.width}×${m.viewport.height + band} instead of ${p.name}'s ${p.width}×${p.height}. Two demos of the same app then come out different shapes and players letterbox them. Drop \`viewport\` unless the app genuinely needs it.`);
424
+ }
425
+ }
426
+ // A navigation that only changes the #fragment does not reload the document.
427
+ // An SPA keeps every bit of in-memory state, so a stub armed just before it
428
+ // is never fetched — and the screen looks plausible, just stale, which is
429
+ // the worst kind of wrong to debug. Knowable from the manifest alone.
430
+ {
431
+ const hops = [m.url, ...m.steps.flatMap((st) => (st.action === "navigate" ? [st.url] : []))];
432
+ for (let i = 1; i < hops.length; i++) {
433
+ const [a, b] = [hops[i - 1], hops[i]];
434
+ const strip = (u) => u.split("#")[0];
435
+ if (a !== b && strip(a) === strip(b) && b.includes("#")) {
436
+ w.push(`step ${i} navigates to \`${b}\` which differs from the previous URL ONLY by its #fragment — the browser will not reload the document. A single-page app keeps all of its in-memory state, so anything you armed just before this (a \`stub\`, a seed) is never re-fetched and the screen looks right but stale. Add a throwaway query param (\`?r=${i}\`) before the fragment to force a real load, or drive the change by clicking the app's own link.`);
437
+ }
438
+ }
439
+ }
388
440
  if (m.mode === "demo") {
389
441
  const launchy = [m.intro && "intro card", m.outro && "outro card", m.music && "music", m.steps.some((s) => s.action === "callout") && "callouts"].filter(Boolean);
390
442
  if (launchy.length)
@@ -412,7 +464,9 @@ export function warnings(m) {
412
464
  const nextScene = rest.findIndex((x) => x.action === "scene");
413
465
  const between = nextScene === -1 ? rest : rest.slice(0, nextScene);
414
466
  // A closing hold on the payoff is normal; only a waits-only scene *followed by another* is empty.
415
- if (nextScene !== -1 && between.length && between.every((x) => x.action === "wait"))
467
+ // In a launch cut the opening scene IS a held frame — that is the shot.
468
+ const isFirstScene = m.steps.findIndex((x) => x.action === "scene") === i;
469
+ if (nextScene !== -1 && between.length && between.every((x) => x.action === "wait") && !(launch && isFirstScene))
416
470
  w.push(`scene "${st.label}" has only waits before the next scene — nothing happens on camera in it.`);
417
471
  });
418
472
  return w;
@@ -448,7 +502,15 @@ export function totp(base32Secret, nowMs = Date.now()) {
448
502
  }
449
503
  export function resolve(m) {
450
504
  const p = PRESETS[m.preset] ?? PRESETS[DEFAULT_PRESET];
451
- const viewport = { width: m.viewport?.width ?? p.width, height: m.viewport?.height ?? p.height };
505
+ // The page is the canvas minus the caption strip, so the finished video is
506
+ // exactly the preset's size. A manifest may override it, and `warnings()`
507
+ // says out loud that doing so changes the output size.
508
+ // No captions anywhere (switched off, or no scene carries one) → no band
509
+ // to reserve: the page records at the full canvas and fills the frame.
510
+ const anyCaption = m.captions !== false && m.steps.some((st) => st.action === "scene" && Boolean(st.caption?.trim()));
511
+ const layout = m.layout ?? p.layout;
512
+ const band = anyCaption && (layout === "band" || layout === "card") ? p.bandHeight : 0;
513
+ const viewport = { width: m.viewport?.width ?? p.width, height: m.viewport?.height ?? p.height - band };
452
514
  const gifOverride = m.outputs.gif;
453
515
  /* `gif: true` means "yes, a GIF" on any preset. Only docs-gif carries GIF
454
516
  settings, so asking for one anywhere else used to resolve to the preset's
@@ -466,7 +528,8 @@ export function resolve(m) {
466
528
  scale: m.scale ?? p.scale,
467
529
  fps: m.fps ?? p.fps,
468
530
  crf: m.crf ?? p.crf,
469
- layout: m.layout ?? p.layout,
531
+ layout,
532
+ bandHeight: band,
470
533
  cameraZoom: typeof m.camera === "object" ? m.camera.zoom : p.cameraZoom,
471
534
  gif,
472
535
  cursor,
@@ -19,7 +19,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
19
19
  import { z } from "zod";
20
20
  import YAML from "yaml";
21
21
  import { loadManifest } from "../manifest.js";
22
- import { record, captureHash, acquireLock, releaseLock } from "../record.js";
22
+ import { record, captureHash, acquireLock, releaseLock, keepPrevious, restorePrevious, stashPrevious, EXPENSIVE_TAKE_SECONDS } from "../record.js";
23
23
  import { render, check, ffmpegBin } from "../render.js";
24
24
  import { execFileSync } from "node:child_process";
25
25
  import { dryRun } from "../dryrun.js";
@@ -360,20 +360,35 @@ server.registerTool("dry", { description: "Run a demo with no camera: every sele
360
360
  await tell(r.ok ? `Checked: all ${manifest.steps.length} steps resolve` : `${r.failures} step(s) would fail`);
361
361
  return text(r.ok ? `ok: all ${manifest.steps.length} steps resolve` : r.lines.join("\n"));
362
362
  });
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 }) => {
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 — ALWAYS start there, and on a long demo show that draft to the person before spending the full take. Returns the receipts. SIZE IS NOT RECORDED: any take renders at any preset later (`render` with a preset), so never re-record to change the shape and never set `viewport` in a manifest — the preset owns the size and every take at a preset is identical. `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"), from: z.string().optional().describe("start the take AT this scene: earlier steps still run, at full speed and off camera. When only the ending changed, this is the difference between re-recording 90 seconds and re-recording seven minutes") }, annotations: RETAKE_WRITE }, async ({ name, preview, until, from }) => {
364
364
  LAST_DEMO = name;
365
365
  if (!safe(name) || !fs.existsSync(manifestPath(name)))
366
366
  return text(`no demo "${name}"`);
367
367
  const loaded = loadManifest(manifestPath(name));
368
- const manifest = preview ? { ...loaded.manifest, preset: "preview-fast" } : loaded.manifest;
368
+ // preview means "the fast shape", so a manifest that pins its own viewport
369
+ // must not win silently — that is how a draft ends up costing what the real
370
+ // take costs.
371
+ const { viewport: pinned, ...rest } = loaded.manifest;
372
+ const manifest = preview ? { ...rest, preset: "preview-fast" } : loaded.manifest;
373
+ if (preview && pinned)
374
+ await tell(`preview overrides the pinned ${pinned.width}×${pinned.height} viewport`);
369
375
  const outDir = path.join(OUT, name);
370
376
  acquireLock(outDir);
371
377
  try {
372
- for (const f of fs.existsSync(outDir) ? fs.readdirSync(outDir) : [])
373
- if (f !== ".retake-lock")
374
- fs.rmSync(path.join(outDir, f), { recursive: true, force: true });
378
+ // Moved aside, not deleted: a failed recording must not cost the person
379
+ // the take they already had.
380
+ stashPrevious(outDir);
375
381
  await tell(`Recording ${name}${preview ? " (preview)" : ""}…`);
376
- const take = await record(manifest, { outDir, manifestDir: DEMOS, locked: true, until, log: () => { } });
382
+ let take;
383
+ try {
384
+ take = await record(manifest, { outDir, manifestDir: DEMOS, locked: true, until, from, log: () => { } });
385
+ }
386
+ catch (e) {
387
+ if (restorePrevious(outDir))
388
+ await tell("Recording failed — your previous take has been put back.");
389
+ throw e;
390
+ }
391
+ keepPrevious(outDir, (l) => void tell(l));
377
392
  await tell(`Rendering…`);
378
393
  const a = await render(manifest, take, outDir, {});
379
394
  const c = check(outDir, manifest);
@@ -383,13 +398,24 @@ server.registerTool("run", { description: "Record the demo and render it. Slow (
383
398
  // the page showed, so even a small model gets a one-line repair.
384
399
  if (failed)
385
400
  return text(`FAILED at step ${failed.index} — ${failed.summary}: ${failed.error ?? "error"}${failed.screen ? `\nOn screen at that moment: “${failed.screen}”` : ""}\nThe camera stopped there (${(take.duration - take.trimBefore).toFixed(0)}s recorded). This is not a demo yet: edit the manifest, dry, run again. Do NOT call done on a failed take.\n\n${summariseTake(take)}`);
386
- return text(`${summariseTake(take)}\nvideo: ${a.mp4 ?? "none"}\ncheck: ${c.ok ? "pass" : "FAIL"}\n${c.lines.filter((l) => l.startsWith("FAIL")).join("\n")}`);
401
+ // The gate: a person's eyes are the only check for whether this is the
402
+ // RIGHT video, and they are cheapest to apply to a draft. Say so here,
403
+ // where the agent is deciding what to do next, rather than after the
404
+ // expensive take has already been spent.
405
+ // Measured, not estimated: this draft just performed the same demo against
406
+ // the same app, so its own capture time is what the full take will cost.
407
+ const took = take.captureSec ?? 0;
408
+ const mins = took >= 120 ? `${Math.round(took / 60)} min` : `${Math.round(took)}s`;
409
+ const gate = preview && took >= EXPENSIVE_TAKE_SECONDS && c.ok
410
+ ? `\n\nTHIS DRAFT TOOK ${mins} TO RECORD, and the full take costs about the same again. Do not run it at full quality yet. Show this draft to the person — \`ask\` them, say how long it is and what it shows, and point them at the Retake window. Their answer is the one thing no check here can give you: whether this is the right video. If they cannot be reached, stop and say what is ready and what the full take would cost; do not spend it on their behalf.`
411
+ : "";
412
+ return text(`${summariseTake(take)}\nvideo: ${a.mp4 ?? "none"}\ncheck: ${c.ok ? "pass" : "FAIL"}\n${c.lines.filter((l) => l.startsWith("FAIL")).join("\n")}${gate}`);
387
413
  }
388
414
  finally {
389
415
  releaseLock(outDir);
390
416
  }
391
417
  });
392
- server.registerTool("render", { description: "Re-render the last recording of a demo with the current manifest (captions, camera, trim, format). No browser, seconds.", inputSchema: { name: z.string() }, annotations: RETAKE_WRITE }, async ({ name }) => {
418
+ server.registerTool("render", { description: "Re-render the last recording with the current manifest. No browser, seconds. THIS, NOT `run`, IS THE ANSWER TO ALMOST EVERY CHANGE: size and shape and format, a caption's words, speed, zoom, cards, music, the poster, and WHERE a caption sits (`nudge: <ms>` on the scene moves its marker, and the still and thumbnail follow). Only a change to what the demo DOES needs the camera again — and even then, `run` with `from`/`until` records one end of it rather than all of it.", inputSchema: { name: z.string() }, annotations: RETAKE_WRITE }, async ({ name }) => {
393
419
  LAST_DEMO = name;
394
420
  const outDir = path.join(OUT, name);
395
421
  const tp = path.join(outDir, "take.json");
@@ -466,4 +492,19 @@ server.registerTool("done", { description: "Call when the demo is recorded and a
466
492
  return text("ok");
467
493
  });
468
494
  const transport = new StdioServerTransport();
495
+ // Leave when the client does. Without this the server sat on a closed stdin
496
+ // forever — a day of agent sessions left a row of them running, each holding a
497
+ // Node runtime for a client that had long since exited.
498
+ let leaving = false;
499
+ const leave = () => {
500
+ if (leaving)
501
+ return;
502
+ leaving = true;
503
+ void server.close().catch(() => { }).finally(() => process.exit(0));
504
+ };
505
+ transport.onclose = leave;
506
+ process.stdin.once("end", leave);
507
+ process.stdin.once("close", leave);
508
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"])
509
+ process.once(sig, leave);
469
510
  await server.connect(transport);
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, bandFit: "text",
11
+ cursorSize: 36, captionFontSize: 24, bandHeight: 76,
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, bandFit: "text",
19
+ cursorSize: 72, captionFontSize: 46, bandHeight: 150,
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, bandFit: "text",
27
+ cursorSize: 40, captionFontSize: 26, bandHeight: 84,
28
28
  encoder: "videotoolbox", master: false,
29
29
  description: "Small, quick internal check.",
30
30
  },
@@ -32,34 +32,34 @@ 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, bandFit: "text",
35
+ cursorSize: 72, captionFontSize: 46, bandHeight: 150,
36
36
  encoder: "x264", master: true,
37
- description: "1080p, 30fps, CRF 17. The general-purpose demo. GIF on request.",
37
+ description: "A true 1920×1080, 30fps, CRF 17. The general-purpose demo. GIF on request.",
38
38
  },
39
39
  "post-square": {
40
40
  name: "post-square",
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: 960, scale: 1.6, fps: 30, crf: 17,
44
+ width: 1080, height: 1080, scale: 1.6, fps: 30, crf: 17,
45
45
  gif: false, layout: "band", cameraZoom: 1.25,
46
- cursorSize: 60, captionFontSize: 36, bandHeight: 120, bandFit: "fill",
46
+ cursorSize: 60, captionFontSize: 36, bandHeight: 120,
47
47
  encoder: "x264", master: true,
48
- description: "A true 1080×1080 (page 1080×960 + a fixed 120px caption band), feed-friendly.",
48
+ description: "1080×1080 for feeds. Page 1080×960 with a 120px caption band.",
49
49
  },
50
50
  "post-vertical": {
51
51
  name: "post-vertical",
52
- width: 1080, height: 1800, scale: 2, fps: 30, crf: 17,
52
+ width: 1080, height: 1920, scale: 2, fps: 30, crf: 17,
53
53
  gif: false, layout: "band", cameraZoom: 1.3,
54
- cursorSize: 60, captionFontSize: 40, bandHeight: 120, bandFit: "fill",
54
+ cursorSize: 60, captionFontSize: 40, bandHeight: 120,
55
55
  encoder: "x264", master: true,
56
- description: "A true 1080×1920 (page 1080×1800 + a fixed 120px caption band) for shorts/reels. Narrow-column apps suit it best.",
56
+ description: "1080×1920 for shorts and 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, bandFit: "text",
62
+ cursorSize: 48, captionFontSize: 32, bandHeight: 100,
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, bandFit: "text",
70
+ cursorSize: 72, captionFontSize: 46, bandHeight: 150,
71
71
  encoder: "x264", master: true,
72
72
  description: "Highest quality, slowest. Archive/source render.",
73
73
  },