retake-demos 0.2.2 → 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/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";
@@ -78,6 +79,14 @@ export const Step = z.discriminatedUnion("action", [
78
79
  slow it down and the viewer can follow what went past. */
79
80
  speed: z.number().positive().max(4).optional(),
80
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
+ }),
81
90
  Base.extend({
82
91
  action: z.literal("zoom"),
83
92
  selector: Selector.optional(),
@@ -96,7 +105,23 @@ export const Step = z.discriminatedUnion("action", [
96
105
  Base.extend({ action: z.literal("download"), selector: Selector.optional(), saveAs: z.string().optional(), expect: z.string().optional() }),
97
106
  /** Wait for a selector to appear (Retake extra; the thing testreel's JSON lacks
98
107
  for AI-driven apps whose next screen arrives whenever the model answers). */
99
- 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
+ }),
100
125
  /** Run JS in the page (Retake extra). Used for seeding, hiding chrome, etc. */
101
126
  Base.extend({ action: z.literal("evaluate"), script: z.string().min(1) }),
102
127
  /** Change what a stubbed endpoint answers, mid-take. This is how a demo shows
@@ -110,11 +135,26 @@ export const Step = z.discriminatedUnion("action", [
110
135
  json: z.unknown().optional(),
111
136
  from: z.string().optional(),
112
137
  }),
138
+ /** Point at something while the take holds: an animated ring and an optional
139
+ label, drawn at render time from the box recorded here. Sparingly — one
140
+ per scene at most; the captions carry the story. */
141
+ Base.extend({
142
+ action: z.literal("callout"),
143
+ selector: Selector.optional(),
144
+ at: Point.optional(),
145
+ label: z.string().max(60).optional(),
146
+ ms: z.number().positive().max(8000).default(2200),
147
+ }),
113
148
  /** A named beat. Timestamps go into the proof log; `caption` gets burned into
114
149
  the video from this moment until the next scene (or `holdMs`). */
115
150
  Base.extend({
116
151
  action: z.literal("scene"),
117
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(),
118
158
  caption: z.string().optional(),
119
159
  holdMs: z.number().positive().optional(),
120
160
  /** Where the camera looks during this scene. Omitted → the manifest's
@@ -124,7 +164,7 @@ export const Step = z.discriminatedUnion("action", [
124
164
  .optional(),
125
165
  }),
126
166
  ]).superRefine((st, ctx) => {
127
- if ((st.action === "click" || st.action === "hover") && !st.selector && !st.at) {
167
+ if ((st.action === "click" || st.action === "hover" || st.action === "callout") && !st.selector && !st.at) {
128
168
  ctx.addIssue({ code: "custom", message: `${st.action} needs either a selector or an \`at\` point` });
129
169
  }
130
170
  });
@@ -177,7 +217,60 @@ const ManifestShape = z.object({
177
217
  /** 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
218
  layout: z.enum(["band", "card", "overlay-bottom", "overlay-top", "none"]).optional(),
179
219
  /** 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"),
220
+ /** Compress at render what nobody watches. A waitFor/navigate that made
221
+ the viewer sit through N seconds is shown as ~keepSeconds (real start,
222
+ then a fast-forward); a long typing run keeps its start and its finish
223
+ at real speed and accelerates the middle (every keystroke is a
224
+ round-trip, so a real sentence costs ~5s of video). The author's own
225
+ `wait` steps are pacing and are never touched. Off by default. */
226
+ compressIdle: z.union([z.boolean(), z.object({ keepSeconds: z.number().min(0.8).max(5).default(1.5) })]).default(false),
227
+ /** What this take is FOR, and it changes what is allowed to be in it.
228
+ `demo` (the default) proves an interaction: direct, legible, minimally
229
+ framed — a title card or music in a demo is noise. `launch` presents
230
+ the product, so cards, music, emphasis and a branded ending are
231
+ legitimate, and each one still has to earn the second it costs.
232
+ Retake never upgrades a demo to a launch on its own. */
233
+ mode: z.enum(["demo", "launch"]).default("demo"),
234
+ /** How typed text lands. `natural` is the per-step default (~80ms a key);
235
+ `brisk` is the launch rhythm — fast keys (~22ms), the pauses carrying
236
+ the meaning instead. A step's own `delay` always wins. */
237
+ typing: z.enum(["natural", "brisk"]).default("natural"),
238
+ /** The captions, read aloud (edge-tts). Each scene's caption is synthesized
239
+ and placed at the scene's start; music ducks under it. The captions are
240
+ the script — there is nothing separate to write. */
241
+ voiceover: z
242
+ .union([
243
+ z.boolean(),
244
+ z.object({
245
+ voice: z.string().default("en-US-JennyNeural"),
246
+ gainDb: z.number().min(-20).max(10).default(0),
247
+ /** One continuous script, synthesized as a single performance and
248
+ laid under the whole video — the only shape that keeps prosody.
249
+ Without it, narration is per-scene fragments: prosody resets at
250
+ every line and the read/wait rhythm follows scene boundaries
251
+ instead of speech. Fragments are refused unless `fragments: true`
252
+ says you know. */
253
+ script: z.string().optional(),
254
+ fragments: z.boolean().default(false),
255
+ /** A person has heard this voice and this script and approved them.
256
+ Synthesis succeeding is not approval. */
257
+ approved: z.boolean().default(false),
258
+ }),
259
+ ])
260
+ .default(false),
261
+ /** A music bed under the whole video, mixed at render time: looped or
262
+ trimmed to fit, faded out over the last moments. The person supplies the
263
+ file (CC0/licensed — it ships inside their video); Retake bundles none. */
264
+ 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(),
265
+ /** Title cards, rendered at render time (change them, re-render in seconds).
266
+ The intro's settled frame is also written as cover.png — the poster. */
267
+ intro: z.object({ title: z.string().max(60), subtitle: z.string().max(90).optional(), ms: z.number().positive().max(6000).default(2400) }).optional(),
268
+ outro: z.object({ title: z.string().max(60), subtitle: z.string().max(90).optional(), ms: z.number().positive().max(6000).default(2000) }).optional(),
269
+ /** Still by default. A zoom is something a person asks for after seeing a
270
+ take, never something that happens to them: a calm demo reads as real
271
+ and can never crop the wrong thing. `auto` eases toward the last thing
272
+ touched; a scene can override either way. */
273
+ camera: z.union([z.literal("auto"), z.literal("static"), z.object({ zoom: z.number().min(1).max(3) })]).default("static"),
181
274
  /** Seconds cut from the head/tail of the finished video. Render-only: change
182
275
  it and re-render, no re-recording. */
183
276
  trim: z.object({ head: z.number().nonnegative().default(0), tail: z.number().nonnegative().default(0) }).prefault({}),
@@ -248,6 +341,20 @@ const ManifestShape = z.object({
248
341
  .prefault({}),
249
342
  });
250
343
  export const Manifest = ManifestShape.superRefine((m, ctx) => {
344
+ // A synthetic voice is a claim on the person's taste. Retake will not put
345
+ // one in a deliverable until they have heard it and said yes — and will
346
+ // not read the captions as fragments at all, because that is the defect,
347
+ // not the voice: prosody resets every line and the rhythm follows scene
348
+ // boundaries instead of speech.
349
+ if (m.voiceover) {
350
+ const vo = m.voiceover === true ? { approved: false, script: undefined, fragments: false } : m.voiceover;
351
+ if (!vo.approved) {
352
+ 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." });
353
+ }
354
+ if (!vo.script && !vo.fragments) {
355
+ 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." });
356
+ }
357
+ }
251
358
  [...m.steps, ...m.setup].forEach((st, i) => {
252
359
  if ((st.action === "click" || st.action === "hover") && !st.selector && !st.at) {
253
360
  ctx.addIssue({ code: "custom", path: ["steps", i], message: `${st.action} needs either a selector or an \`at\` point` });
@@ -305,6 +412,36 @@ export function loadManifest(file) {
305
412
  /** Things worth saying out loud before a run. Not errors — the run proceeds. */
306
413
  export function warnings(m) {
307
414
  const w = [];
415
+ // A demo proves an interaction; a launch presents the product. Launch
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
+ }
440
+ if (m.mode === "demo") {
441
+ const launchy = [m.intro && "intro card", m.outro && "outro card", m.music && "music", m.steps.some((s) => s.action === "callout") && "callouts"].filter(Boolean);
442
+ if (launchy.length)
443
+ 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.`);
444
+ }
308
445
  // Secrets typed during recorded steps end up in the video. In `setup` they do
309
446
  // not: setup runs before the camera and is trimmed off the front.
310
447
  const onCamera = m.steps.filter((s) => s.secret).length;
@@ -327,23 +464,53 @@ export function warnings(m) {
327
464
  const nextScene = rest.findIndex((x) => x.action === "scene");
328
465
  const between = nextScene === -1 ? rest : rest.slice(0, nextScene);
329
466
  // A closing hold on the payoff is normal; only a waits-only scene *followed by another* is empty.
330
- 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))
331
470
  w.push(`scene "${st.label}" has only waits before the next scene — nothing happens on camera in it.`);
332
471
  });
333
472
  return w;
334
473
  }
335
474
  /** `${VAR}` substitution against process.env, like testreel. */
336
475
  export function expandEnv(s) {
337
- return s.replace(/\$\{([A-Z0-9_]+)\}/g, (_, k) => {
476
+ return s.replace(/\$\{(TOTP:)?([A-Z0-9_]+)\}/g, (_, fn, k) => {
338
477
  const v = process.env[k];
339
- if (v === undefined)
340
- throw new Error(`manifest references \${${k}} but it is not set`);
341
- return v;
478
+ // `APP_USER=` with nothing after it is the template, not a value — a blank
479
+ // login that "succeeds" is worse than a loud stop.
480
+ if (v === undefined || v.trim() === "")
481
+ throw new Error(`manifest references \${${k}} but it is not set — add it to .env (a demo account, never a real one)`);
482
+ // ${TOTP:APP_TOTP_SECRET}: the six-digit code for right now, from an
483
+ // authenticator enrolment secret. Computed at fill time, so it is valid.
484
+ return fn ? totp(v) : v;
342
485
  });
343
486
  }
487
+ /** RFC 6238 TOTP (SHA-1, 6 digits, 30s) from a base32 secret — what every
488
+ authenticator app enrols with. */
489
+ export function totp(base32Secret, nowMs = Date.now()) {
490
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
491
+ const clean = base32Secret.toUpperCase().replace(/[^A-Z2-7]/g, "");
492
+ let bits = "";
493
+ for (const c of clean)
494
+ bits += alphabet.indexOf(c).toString(2).padStart(5, "0");
495
+ const key = Buffer.from((bits.match(/.{8}/g) ?? []).map((b) => parseInt(b, 2)));
496
+ const counter = Buffer.alloc(8);
497
+ counter.writeBigUInt64BE(BigInt(Math.floor(nowMs / 30000)));
498
+ const h = createHmac("sha1", key).update(counter).digest();
499
+ const off = h[h.length - 1] & 0xf;
500
+ const code = ((h[off] & 0x7f) << 24 | h[off + 1] << 16 | h[off + 2] << 8 | h[off + 3]) % 1_000_000;
501
+ return String(code).padStart(6, "0");
502
+ }
344
503
  export function resolve(m) {
345
504
  const p = PRESETS[m.preset] ?? PRESETS[DEFAULT_PRESET];
346
- 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 };
347
514
  const gifOverride = m.outputs.gif;
348
515
  /* `gif: true` means "yes, a GIF" on any preset. Only docs-gif carries GIF
349
516
  settings, so asking for one anywhere else used to resolve to the preset's
@@ -361,7 +528,8 @@ export function resolve(m) {
361
528
  scale: m.scale ?? p.scale,
362
529
  fps: m.fps ?? p.fps,
363
530
  crf: m.crf ?? p.crf,
364
- layout: m.layout ?? p.layout,
531
+ layout,
532
+ bandHeight: band,
365
533
  cameraZoom: typeof m.camera === "object" ? m.camera.zoom : p.cameraZoom,
366
534
  gif,
367
535
  cursor,
@@ -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.",
@@ -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";
@@ -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,20 +360,35 @@ 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 — 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 }) => {
307
364
  LAST_DEMO = name;
308
365
  if (!safe(name) || !fs.existsSync(manifestPath(name)))
309
366
  return text(`no demo "${name}"`);
310
367
  const loaded = loadManifest(manifestPath(name));
311
- 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`);
312
375
  const outDir = path.join(OUT, name);
313
376
  acquireLock(outDir);
314
377
  try {
315
- for (const f of fs.existsSync(outDir) ? fs.readdirSync(outDir) : [])
316
- if (f !== ".retake-lock")
317
- 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);
318
381
  await tell(`Recording ${name}${preview ? " (preview)" : ""}…`);
319
- 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));
320
392
  await tell(`Rendering…`);
321
393
  const a = await render(manifest, take, outDir, {});
322
394
  const c = check(outDir, manifest);
@@ -326,13 +398,24 @@ server.registerTool("run", { description: "Record the demo and render it. Slow (
326
398
  // the page showed, so even a small model gets a one-line repair.
327
399
  if (failed)
328
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)}`);
329
- 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}`);
330
413
  }
331
414
  finally {
332
415
  releaseLock(outDir);
333
416
  }
334
417
  });
335
- 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 }) => {
336
419
  LAST_DEMO = name;
337
420
  const outDir = path.join(OUT, name);
338
421
  const tp = path.join(outDir, "take.json");
@@ -409,4 +492,19 @@ server.registerTool("done", { description: "Call when the demo is recorded and a
409
492
  return text("ok");
410
493
  });
411
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);
412
510
  await server.connect(transport);
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
@@ -34,26 +34,26 @@ export const PRESETS = {
34
34
  gif: false, layout: "band", cameraZoom: 1.25,
35
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: 950, 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: 40, bandHeight: 130,
46
+ cursorSize: 60, captionFontSize: 36, bandHeight: 120,
47
47
  encoder: "x264", master: true,
48
- description: "A true 1080×1080 (page 1080×950 + 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: 1750, 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: 44, bandHeight: 170,
54
+ cursorSize: 60, captionFontSize: 40, bandHeight: 120,
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: "1080×1920 for shorts and reels. Narrow-column apps suit it best.",
57
57
  },
58
58
  "docs-gif": {
59
59
  name: "docs-gif",