retake-demos 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -17,7 +17,7 @@ Retake drives a real browser through a web app, records it, and hands back an MP
17
17
 
18
18
  It is built for the way people work now: **you ask your coding agent for a demo in one sentence**, the agent drafts the file, dry-runs every selector, records a preview, reads the proof log, fixes what failed, and records the real one. Claude Code, Codex, Cursor and anything else that speaks MCP get Retake's tools; `retake ui` is the window you watch it in.
19
19
 
20
- <p align="center"><img src="assets/ui.png" width="900" alt="The Retake window: your demos on the left, the video and its scenes in the middle"></p>
20
+ <p align="center"><img src="assets/window.png" width="900" alt="The Retake window: your demos on the left, the video and its scenes in the middle"></p>
21
21
 
22
22
  ## Install
23
23
 
@@ -53,7 +53,7 @@ outputs/<name>/
53
53
  take.json the raw timeline the renderer reads
54
54
  ```
55
55
 
56
- In the window: play it, switch speed (0.75× to 2×, re-rendered in seconds), download, show in Finder, open the file to edit. Demos group by the app they point at; archive what you're done with.
56
+ In the window: play it, switch speed (0.75× to 1.3×, re-rendered in seconds), download, or open its folder — stills, proof log, manifest. Demos group by the app they point at; archive what you're done with.
57
57
 
58
58
  ## The loop
59
59
 
@@ -78,7 +78,7 @@ Exit codes: `0` good · `2` a step failed or the video is a raw fallback · `3`
78
78
  name: first-project
79
79
  title: "Creating your first project"
80
80
  url: http://localhost:3000
81
- preset: post-landscape # 1080p @ 30fps · `retake presets`
81
+ preset: post-landscape # `retake presets` — 1920×1080 page + caption band
82
82
  viewport: { width: 1440, height: 1080 }
83
83
  scale: 1.8 # page drawn at 1.8× so text stays crisp
84
84
  reducedMotion: true
@@ -113,15 +113,17 @@ The full template is [`demos/example.yaml`](demos/example.yaml); `retake init` c
113
113
 
114
114
  Think in the publishing format, not the browser window.
115
115
 
116
- | Preset | Canvas | For |
117
- |---|---:|---|
118
- | `draft` | 960×540 @ 15fps | iterating — same layout as the finals at a quarter of the pixels, fastest |
119
- | `preview-fast` | 1920×1080 @ 24fps | checking timing and framing, hardware-encoded |
120
- | `post-landscape` *(default)* | 1920×1080 @ 30fps | the general-purpose demo, plus a CRF-14 master |
121
- | `post-square` | 1080×1080 @ 30fps | feeds |
122
- | `post-vertical` | 1080×1920 @ 30fps | shorts and reels |
123
- | `docs-gif` | 1440×900 @ 24fps | README and docs GIFs |
124
- | `master` | 1920×1080 @ 30fps | archive, CRF 12 |
116
+ | Preset | Video | Page area | For |
117
+ |---|---:|---:|---|
118
+ | `draft` | 960×616 | 960×540 | iterating — the finals' layout at a quarter of the pixels, fastest |
119
+ | `preview-fast` | 1920×1230 | 1920×1080 | checking timing and framing, hardware-encoded |
120
+ | `post-landscape` *(default)* | 1920×1230 | 1920×1080 | the general-purpose demo, plus a CRF-14 master |
121
+ | `post-square` | 1080×1080 | 1080×950 | feeds — a true square |
122
+ | `post-vertical` | 1080×1920 | 1080×1750 | shorts and reels — a true 9:16 |
123
+ | `docs-gif` | 1440×1000 | 1440×900 | README and docs GIFs |
124
+ | `master` | 1920×1230 | 1920×1080 | archive, CRF 12 |
125
+
126
+ **The video is the page area plus the caption band.** Landscape keeps a full 1920×1080 page and the band goes under it (1920×1230) — the captions never cover the app. Square and vertical fit the band inside the canvas, because a feed that wants 1:1 or 9:16 will letterbox anything else. A manifest's `viewport` replaces the page area and the video follows it, so a 1240×1080 viewport gives a 1240×1230 video. `retake presets` prints this table, and `retake validate` tells you the exact size of the video a manifest will produce before you record it.
125
127
 
126
128
  **Page scale** is what makes it crisp: the page lays out as if the viewport were smaller and every glyph is drawn at 2×, so 1080p text reads on a phone. **Camera** eases toward whatever the demo just touched, at render time, clamped so it can never crop the thing it points at — per scene: `camera: static` or `camera: { focus: ".result", zoom: 1.3 }`. Captions, camera, speed, trim, format and layout are all render-time: change them and `retake render` takes seconds, never touches the browser.
127
129
 
@@ -144,8 +146,7 @@ Apps that keep their session in IndexedDB (Firebase) can't be restored from `sto
144
146
  ## Limits worth knowing
145
147
 
146
148
  - **One page.** Retake records one tab. `keepInTab` (on by default) folds `window.open` and `target="_blank"` back into it, so popup logins and "preview" buttons stay on camera; flows that genuinely need a second window do not.
147
- - **~45 cursor moves per take.** The cursor overlay is one nested expression per move and ffmpeg's parser has a depth limit. `validate` warns, the recorder detects it, `check` fails. Split long stories into chapters of 30–45 seconds — the viewer agrees.
148
- - **`scale` uses CSS zoom**, which shifts the coordinate space some forms rely on for focus; if typing goes nowhere, `scale: 1`. (`deviceScaleFactor` is not the fix — Playwright's screencast never upscales.)
149
+ - **~180 cursor moves per take.** Retake flattens testreel's cursor expressions at install time (the stock ones hit ffmpeg's nesting limit at ~45 moves); the remaining ceiling is the filter's size as one process argument. `validate` warns, the recorder detects it, `check` fails. Long stories still want chapters of 30–45 seconds — for the viewer, not the tool.
149
150
  - **macOS is where it's used daily.** `videotoolbox` hardware encoding falls back to libx264 elsewhere; Linux is untested beyond CI.
150
151
 
151
152
  ## How it's built
package/dist/cli.js CHANGED
@@ -384,15 +384,26 @@ program
384
384
  .description("list quality presets")
385
385
  .action(async () => {
386
386
  const { PRESETS } = await import("./presets.js");
387
- for (const p of Object.values(PRESETS))
388
- say(`${p.name.padEnd(16)} ${String(p.width + "×" + p.height).padEnd(10)} ${p.fps}fps scale ${p.scale}× ${p.layout.padEnd(5)} — ${p.description}`);
387
+ say(`${"preset".padEnd(16)} ${"video".padEnd(11)} ${"page".padEnd(11)} fps layout`);
388
+ for (const p of Object.values(PRESETS)) {
389
+ const video = p.layout === "card" ? `${p.width}×${p.height}` : `${p.width}×${p.height + (p.layout === "band" ? p.bandHeight : 0)}`;
390
+ say(`${p.name.padEnd(16)} ${video.padEnd(11)} ${String(p.width + "×" + p.height).padEnd(11)} ${String(p.fps).padEnd(4)} ${p.layout.padEnd(5)} — ${p.description}`);
391
+ }
392
+ say(`\nvideo = page + caption band. A manifest's \`viewport\` replaces the page size (the video follows it).`);
389
393
  });
390
394
  program
391
395
  .command("validate")
392
396
  .argument("<manifest>")
393
- .action((file) => {
397
+ .action(async (file) => {
394
398
  const { manifest } = loadManifest(file);
399
+ const { resolve } = await import("./manifest.js");
400
+ const q = resolve(manifest);
401
+ // The video is the page area plus the caption band, so say the real number
402
+ // here rather than letting it surprise someone after a two-minute take.
403
+ const outW = q.layout === "card" ? q.width : q.viewport.width;
404
+ const outH = (q.layout === "card" ? q.height : q.viewport.height) + (q.layout === "band" ? q.bandHeight : 0);
395
405
  say(`ok: ${manifest.name} · ${manifest.steps.length} steps · ${manifest.steps.filter((s) => s.action === "scene").length} scenes`);
406
+ say(` ${q.name} · video ${outW}×${outH}${q.layout === "band" ? ` (page ${q.viewport.width}×${q.viewport.height} + ${q.bandHeight}px caption band)` : ""} @ ${q.fps}fps${q.gif ? ` · gif ${q.gif.width}px` : ""}`);
396
407
  for (const w of warnings(manifest))
397
408
  say(`⚠ ${w}`);
398
409
  });
package/dist/manifest.js CHANGED
@@ -203,10 +203,9 @@ const ManifestShape = z.object({
203
203
  waitForSelector: Selector.optional(),
204
204
  speed: z.number().positive().default(1),
205
205
  /** Cursor overlay (testreel). false → none; {style: touch} → tap dot. Size is preset-scaled unless set. */
206
- /** cursor.idleHide: fade the cursor out when idle (testreel default). Each
207
- idle gap adds a nested if() to the ffmpeg alpha expression, and ffmpeg's
208
- parser has a depth limit — long demos blow past it. Retake turns it off
209
- automatically above 60 steps; set it explicitly either way. */
206
+ /** cursor.idleHide: fade the cursor out when idle (testreel default). Long
207
+ demos used to lose it (nested-if depth in ffmpeg); the flat cursor
208
+ expressions lifted that, so it is simply on unless you say otherwise. */
210
209
  cursor: z.union([z.boolean(), z.object({ style: z.enum(["default", "pointer", "text", "touch"]).optional(), size: z.number().optional(), idleHideMs: z.number().optional(), idleHide: z.boolean().optional() })]).default(true),
211
210
  seed: z.array(Seed).default([]),
212
211
  /** What a failed step does to the take. "stop" (default): the camera stops
@@ -294,7 +293,14 @@ export function loadManifest(file) {
294
293
  }).join("\n");
295
294
  throw new Error(`Invalid manifest ${file}:\n${issues}`);
296
295
  }
297
- return { manifest: parsed.data, file: abs, dir: path.dirname(abs) };
296
+ const dir = path.dirname(abs);
297
+ // A fixture page can live next to its manifest: `url: file://./page.html`
298
+ // resolves against the manifest's folder, so a cloned repo's demos work.
299
+ const m = parsed.data;
300
+ const rel = /^file:\/\/(\.{1,2}\/.*)$/.exec(m.url);
301
+ if (rel)
302
+ m.url = "file://" + path.resolve(dir, rel[1]);
303
+ return { manifest: m, file: abs, dir };
298
304
  }
299
305
  /** Things worth saying out loud before a run. Not errors — the run proceeds. */
300
306
  export function warnings(m) {
@@ -312,8 +318,8 @@ export function warnings(m) {
312
318
  if (m.auth && !m.auth.setup.length && !m.setup.length)
313
319
  w.push("`auth.storageState` is set but there are no sign-in steps under `auth.setup` — there is nothing to save a session from.");
314
320
  const moves = m.steps.filter((st) => ["click", "type", "fill", "hover", "scroll", "upload"].includes(st.action)).length;
315
- if (m.cursor !== false && moves > 45)
316
- w.push(`~${moves} cursor moves — the cursor overlay (testreel/ffmpeg) cannot exceed ~45 in one take and will be MISSING from the video. Split the demo into shorter ones, or set \`cursor: false\`.`);
321
+ if (m.cursor !== false && moves > 180)
322
+ w.push(`~${moves} cursor moves — past ~180 in one take the cursor filter no longer fits in one ffmpeg argument and the overlay will be MISSING. Split the demo into chapters, or set \`cursor: false\`.`);
317
323
  m.steps.forEach((st, i) => {
318
324
  if (st.action !== "scene")
319
325
  return;
@@ -339,7 +345,14 @@ export function resolve(m) {
339
345
  const p = PRESETS[m.preset] ?? PRESETS[DEFAULT_PRESET];
340
346
  const viewport = { width: m.viewport?.width ?? p.width, height: m.viewport?.height ?? p.height };
341
347
  const gifOverride = m.outputs.gif;
342
- const gif = gifOverride === false ? false : gifOverride === true ? p.gif : p.gif === false ? { width: gifOverride.width ?? 720, fps: gifOverride.fps ?? 15 } : { width: gifOverride.width ?? p.gif.width, fps: gifOverride.fps ?? p.gif.fps };
348
+ /* `gif: true` means "yes, a GIF" on any preset. Only docs-gif carries GIF
349
+ settings, so asking for one anywhere else used to resolve to the preset's
350
+ `false` and produce nothing, silently. */
351
+ const GIF_DEFAULT = { width: 900, fps: 15 };
352
+ const gif = gifOverride === false ? false
353
+ : gifOverride === true ? (p.gif === false ? GIF_DEFAULT : p.gif)
354
+ : p.gif === false ? { width: gifOverride.width ?? GIF_DEFAULT.width, fps: gifOverride.fps ?? GIF_DEFAULT.fps }
355
+ : { width: gifOverride.width ?? p.gif.width, fps: gifOverride.fps ?? p.gif.fps };
343
356
  const cursor = m.cursor === false ? false : { style: (m.cursor === true ? "default" : m.cursor.style ?? "default"), size: m.cursor === true ? p.cursorSize : m.cursor.size ?? p.cursorSize, idleHideMs: m.cursor === true ? 2000 : m.cursor.idleHideMs ?? 2000, idleHide: m.cursor === true ? undefined : m.cursor.idleHide };
344
357
  const captions = m.captions === false ? false : { fontSize: m.captions === true ? p.captionFontSize : m.captions.fontSize ?? p.captionFontSize, color: m.captions === true ? undefined : m.captions.color };
345
358
  return {
package/dist/presets.js CHANGED
@@ -38,19 +38,22 @@ export const PRESETS = {
38
38
  },
39
39
  "post-square": {
40
40
  name: "post-square",
41
- width: 1080, height: 1080, scale: 1.6, fps: 30, crf: 17,
41
+ /* The page area is the canvas minus the caption band, so the file really
42
+ is 1080×1080 — a square preset that produced 1080×1210 got letterboxed
43
+ by every feed. Landscape keeps its full-height page on purpose. */
44
+ width: 1080, height: 950, scale: 1.6, fps: 30, crf: 17,
42
45
  gif: false, layout: "band", cameraZoom: 1.25,
43
46
  cursorSize: 60, captionFontSize: 40, bandHeight: 130,
44
47
  encoder: "x264", master: true,
45
- description: "1080×1080, feed-friendly.",
48
+ description: "A true 1080×1080 (page 1080×950 + caption band), feed-friendly.",
46
49
  },
47
50
  "post-vertical": {
48
51
  name: "post-vertical",
49
- width: 1080, height: 1920, scale: 2, fps: 30, crf: 17,
52
+ width: 1080, height: 1750, scale: 2, fps: 30, crf: 17,
50
53
  gif: false, layout: "band", cameraZoom: 1.3,
51
54
  cursorSize: 60, captionFontSize: 44, bandHeight: 170,
52
55
  encoder: "x264", master: true,
53
- description: "9:16 for shorts/reels. Apps with a narrow column suit it best.",
56
+ description: "A true 1080×1920 (page 1080×1750 + caption band) for shorts/reels. Narrow-column apps suit it best.",
54
57
  },
55
58
  "docs-gif": {
56
59
  name: "docs-gif",
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
  }
@@ -293,10 +297,7 @@ export async function record(m, opts) {
293
297
  // past roughly sixty steps the nesting exceeds ffmpeg's parser and the
294
298
  // composite fails. Long demos get an always-visible cursor unless told
295
299
  // 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`);
300
+ const cursorIdleHide = q.cursor === false ? undefined : q.cursor.idleHide;
300
301
  const rec = await recordPage(page, {
301
302
  outputDir: opts.outDir,
302
303
  name: m.name,
@@ -343,7 +344,7 @@ export async function record(m, opts) {
343
344
  // overlay silently fails and the video ships with no cursor at all.
344
345
  const moves = cursorMoves(m);
345
346
  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.`);
347
+ 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
348
  let pastUntil = false;
348
349
  for (const [i, step] of m.steps.entries()) {
349
350
  // --until <scene>: record that scene in full, stop at the next one.
@@ -430,7 +431,7 @@ export async function record(m, opts) {
430
431
  screenshots = result.screenshots;
431
432
  const joined = compositeLog.join("");
432
433
  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.`;
434
+ 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
435
  partial = partial ? `${partial}; ${why}` : why;
435
436
  ok = false;
436
437
  log(`✗ ${why}`);
package/dist/ui/chat.html CHANGED
@@ -140,15 +140,15 @@
140
140
  <option value="0.75">0.75×</option><option value="0.85">0.85×</option><option value="1" selected>1×</option><option value="1.15">1.15×</option><option value="1.3">1.3×</option>
141
141
  </select>
142
142
  <a id="vidDl" download>Download</a>
143
- <button id="vidFolder">Show in Finder</button>
144
- <a id="vidEdit" href="/classic">Edit</a>
143
+ <button id="vidFolder">Show in folder</button>
144
+ <!-- The workshop (/classic) still exists but is not advertised until it has been tested as carefully as this page. -->
145
145
  </div>
146
146
  <div class="stills" id="stills"></div>
147
147
  </div>
148
148
  <div class="empty" id="empty" style="display:none">
149
149
  <div class="mark"><i></i><i></i><i></i><i></i></div>
150
150
  <h2>Nothing recorded yet</h2>
151
- <p>Ask your agent for a demo — <code>record a demo of my app showing the sign-up flow</code> —<br>or build one by hand in the <a href="/classic">workshop</a>. It will appear here.<br><a href="/guide">How Retake works</a> — one page, two minutes.</p>
151
+ <p>Ask your agent for a demo — <code>record a demo of my app showing the sign-up flow</code> —<br>It will appear here.<br><a href="/guide">How Retake works</a> — one page, two minutes.</p>
152
152
  </div>
153
153
  </div>
154
154
  <aside class="lib" id="lib" style="display:none">
@@ -246,7 +246,6 @@ async function pin(name) {
246
246
  $('vidMeta').title = failed?.screen ? 'On screen at that moment: ' + failed.screen : '';
247
247
  $('vidDl').href = `/out/${name}/demo.mp4${q}`; $('vidDl').setAttribute('download', name + '.mp4');
248
248
  $('vidFolder').onclick = () => api('/api/open/' + name, { method: 'POST' }).catch(() => {});
249
- $('vidEdit').href = '/classic#' + encodeURIComponent(name);
250
249
  const tempo = (demos.find((x) => x.name === name)?.settings?.tempo) || 1;
251
250
  $('vidSpeed').value = String(tempo);
252
251
  $('vid').playbackRate = 1; // the file already carries its tempo
@@ -286,7 +285,7 @@ async function drawDemos() {
286
285
  const dot = p.lastTake ? (p.lastTake.ok && !p.lastTake.partial ? 'ok' : 'bad') : '';
287
286
  const when = p.lastTake ? new Date(p.lastTake.finishedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : 'no take yet';
288
287
  el.innerHTML = `<span class="dot ${dot}"></span><span class="nm">${esc(p.name)}</span><small>${esc(when)}</small><button class="zap" title="Archive this demo">✕</button>`;
289
- el.onclick = () => { if (p.lastTake) pin(p.name); else location.href = '/classic#' + encodeURIComponent(p.name); };
288
+ el.onclick = () => { if (p.lastTake) pin(p.name); };
290
289
  el.querySelector('.zap').onclick = async (ev) => {
291
290
  ev.stopPropagation();
292
291
  // Archive, not destroy — but moving someone's work still deserves a beat.
@@ -50,7 +50,7 @@
50
50
 
51
51
  <section>
52
52
  <h3>2 · Judge the take</h3>
53
- <p>The video lands here — newest on top, one still per scene under it. <b>Download</b> for the file, <b>Edit</b> to open its steps and knobs in the workshop.</p>
53
+ <p>The video lands here — newest on top, one still per scene under it. <b>Download</b> for the file, <b>Show in folder</b> for everything else — the stills, the proof log, the manifest.</p>
54
54
  </section>
55
55
 
56
56
  <section>
@@ -381,7 +381,9 @@ function saveDraftSoon() { clearTimeout(draftTimer); draftTimer = setTimeout(asy
381
381
  }, 500); }
382
382
 
383
383
  // ---------- demo view ----------
384
- const PRESET_LABEL = { 'post-landscape': '1080p', 'post-square': 'square', 'post-vertical': 'vertical', 'docs-gif': 'docs', 'proof-fast': 'small', 'master': 'master', 'preview-fast': 'preview' };
384
+ // Shape, not a resolution: the video is the page plus the caption band, so
385
+ // "1080p" was never true of what post-landscape actually writes (1920×1230).
386
+ const PRESET_LABEL = { 'post-landscape': 'landscape', 'post-square': 'square', 'post-vertical': 'vertical', 'docs-gif': 'docs', 'proof-fast': 'small', 'master': 'master', 'preview-fast': 'preview', 'draft': 'draft' };
385
387
  async function select(name) {
386
388
  if (dirty && !confirm('Discard unsaved manifest edits?')) return;
387
389
  current = name; dirty = false; setMsg('');
@@ -708,7 +710,7 @@ document.addEventListener('keydown', (e) => {
708
710
  // ---------- boot ----------
709
711
  (async () => {
710
712
  setQuality(quality);
711
- try { const ps = await api('/api/presets'); $('sPreset').innerHTML = ps.filter((p) => p.name !== 'preview-fast').map((p) => `<option value="${p.name}">${p.name} — ${p.width}×${p.height}</option>`).join(''); } catch {}
713
+ try { const ps = await api('/api/presets'); $('sPreset').innerHTML = ps.filter((p) => p.name !== 'preview-fast').map((p) => { const h = p.layout === 'card' ? p.height : p.height + (p.layout === 'band' ? p.bandHeight : 0); return `<option value="${p.name}">${p.name} — ${p.width}×${h}</option>`; }).join(''); } catch {}
712
714
  try { settings = await api('/api/settings'); $('modelTag').textContent = settings.active ? 'model: ' + settings.active : 'no model — set one in Settings'; } catch {}
713
715
  const list = await loadDemos();
714
716
  // Arriving from the review page's Edit link: /classic#<demo> opens that demo.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retake-demos",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Demo-as-code for coding agents: a YAML manifest in, a product walkthrough video out. Rerun the demo instead of re-recording it.",
5
5
  "keywords": [
6
6
  "demo",
@@ -37,10 +37,12 @@
37
37
  "demos/example.yaml",
38
38
  ".env.example",
39
39
  "README.md",
40
- "LICENSE"
40
+ "LICENSE",
41
+ "scripts/patch-testreel.mjs"
41
42
  ],
42
43
  "scripts": {
43
44
  "build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/ui && cp src/ui/*.html dist/ui/ && chmod +x dist/cli.js",
45
+ "postinstall": "node scripts/patch-testreel.mjs",
44
46
  "prepare": "npm run build",
45
47
  "prepublishOnly": "npm run typecheck && npm test",
46
48
  "retake": "tsx src/cli.ts",
@@ -52,7 +54,7 @@
52
54
  "@modelcontextprotocol/sdk": "^1.30.0",
53
55
  "commander": "^14.0.3",
54
56
  "playwright": "^1.58.2",
55
- "testreel": "^0.2.0",
57
+ "testreel": "0.2.0",
56
58
  "yaml": "^2.8.3",
57
59
  "zod": "^4.4.3"
58
60
  },
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Lift testreel's cursor cap.
4
+ *
5
+ * testreel draws the cursor overlay with ffmpeg expressions it builds as one
6
+ * nested `if(lt(t,…),…,if(lt(t,…),…,…))` per keyframe. ffmpeg's expression
7
+ * parser stops at 98 nesting levels, so past ~45 cursor moves the overlay
8
+ * silently drops out of the video. Upstream (0.2.0) has not changed this.
9
+ *
10
+ * The same piecewise functions can be written flat — a sum of windowed
11
+ * segments, `gte(t,a)*lt(t,b)*value` — with no nesting and the same result
12
+ * frame for frame. This script rewrites the four builders Retake relies on
13
+ * (x/y position, show/hide, fade, cursor style) in testreel's dist files.
14
+ *
15
+ * It runs on `postinstall`, is idempotent, and refuses to touch anything if
16
+ * the functions are not byte-identical to testreel 0.2.0 — a newer testreel
17
+ * means re-checking, not guessing. (testreel is pinned to an exact version
18
+ * in package.json for the same reason.)
19
+ */
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { createRequire } from "node:module";
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const MARK = "/*retake:flat-cursor*/";
26
+
27
+ // The originals, exactly as shipped in testreel 0.2.0 (dist/index.js).
28
+ const ORIGINALS = {
29
+ Qe: 'function Qe(e,t){if(e.length===0)return"0";if(e.length===1)return String(e[0].value);let o=String(e[e.length-1].value);for(let r=e.length-1;r>=1;r--){let n=e[r-1],i=e[r],s=i.transitionMs/1e3,a=i.time,u=a+s,l=`${n.value}+(${i.value}-${n.value})*(t-${a.toFixed(4)})/${s.toFixed(4)}`;o=`if(lt(t,${a.toFixed(4)}),${n.value},if(lt(t,${u.toFixed(4)}),${l},${o}))`}return o}',
30
+ _t: 'function _t(e){let t=e.filter(n=>n.type==="hide"||n.type==="show");if(t.length===0)return"1";let r=t[t.length-1].type==="hide"?"0":"1";for(let n=t.length-2;n>=0;n--){let i=t[n].type==="hide"?"0":"1";r=`if(lt(t,${t[n+1].time.toFixed(4)}),${i},${r})`}return r=`if(lt(t,${t[0].time.toFixed(4)}),1,${r})`,r}',
31
+ zt: 'function zt(e,t,o="T"){let r=e.filter(a=>a.type==="hide"||a.type==="show").sort((a,u)=>a.time-u.time);if(r.length===0)return"1";let n=Math.max(.001,t/1e3),s=r[r.length-1].type==="hide"?"0":"1";for(let a=r.length-1;a>=0;a--){let u=r[a],l=a===0?1:r[a-1].type==="hide"?0:1,p=u.type==="hide"?0:1,w=u.time,d=u.time+n;if(l===p){s=`if(lt(${o},${w.toFixed(4)}),${l},${s})`;continue}let f=p-l,h=`${l}+(${f})*(${o}-${w.toFixed(4)})/${n.toFixed(4)}`;s=`if(lt(${o},${w.toFixed(4)}),${l},if(lt(${o},${d.toFixed(4)}),${h},${s}))`}return s}',
32
+ Mt: 'function Mt(e,t,o){let r=e.filter(a=>a.type==="move"&&a.cursorStyle!==void 0);if(r.length===0)return o===t?"1":"0";let n=[];n.push({time:0,active:o===t});for(let a of r)n.push({time:a.time,active:(a.cursorStyle??o)===t});let i=[n[0]];for(let a=1;a<n.length;a++)n[a].active!==i[i.length-1].active&&i.push(n[a]);if(i.length===1)return i[0].active?"1":"0";let s=i[i.length-1].active?"1":"0";for(let a=i.length-2;a>=0;a--){let u=i[a].active?"1":"0";s=`if(lt(t,${i[a+1].time.toFixed(4)}),${u},${s})`}return s}',
33
+ };
34
+
35
+ // Flat replacements. `__flat(v, segs)` sums windowed segments; a segment is
36
+ // {f, t, e}: from time f (null = -∞) to time t (null = +∞), expression e.
37
+ // Segments are contiguous and non-overlapping, so the sum IS the function.
38
+ // Where the originals let a running transition finish before the next
39
+ // keyframe takes over (nested ifs, outermost wins), these do the same.
40
+ const FLAT = {
41
+ helper: `${MARK}function __flat(v,a){a=a.filter(s=>s.f==null||s.t==null||s.t>s.f);if(a.length===0)return"0";if(a.length===1)return"("+a[0].e+")";return a.map(s=>{let g=s.f==null?"":"gte("+v+","+s.f.toFixed(4)+")*",l=s.t==null?"":"lt("+v+","+s.t.toFixed(4)+")*";return g+l+"("+s.e+")"}).join("+")}`,
42
+ Qe: 'function Qe(e,t){if(e.length===0)return"0";if(e.length===1)return String(e[0].value);let a=[{f:null,t:e[1].time,e:String(e[0].value)}],cur=e[1].time;for(let r=1;r<e.length;r++){let n=e[r-1],i=e[r],s=i.transitionMs/1e3,st=Math.max(i.time,cur),u=i.time+s,nx=r+1<e.length?e[r+1].time:null;if(u>st){a.push({f:st,t:u,e:`${n.value}+(${i.value}-${n.value})*(t-${i.time.toFixed(4)})/${s.toFixed(4)}`});cur=u}if(nx==null||nx>cur){a.push({f:cur,t:nx,e:String(i.value)});if(nx!=null)cur=nx}}return __flat("t",a)}',
43
+ _t: 'function _t(e){let t=e.filter(n=>n.type==="hide"||n.type==="show");if(t.length===0)return"1";let a=[{f:null,t:t[0].time,e:"1"}];for(let n=0;n<t.length;n++)a.push({f:t[n].time,t:n+1<t.length?t[n+1].time:null,e:t[n].type==="hide"?"0":"1"});return __flat("t",a)}',
44
+ zt: 'function zt(e,t,o="T"){let r=e.filter(a=>a.type==="hide"||a.type==="show").sort((a,u)=>a.time-u.time);if(r.length===0)return"1";let n=Math.max(.001,t/1e3),a=[{f:null,t:r[0].time,e:"1"}],cur=r[0].time;for(let i=0;i<r.length;i++){let u=r[i],l=i===0?1:r[i-1].type==="hide"?0:1,p=u.type==="hide"?0:1,w=u.time,nx=i+1<r.length?r[i+1].time:null;if(l!==p){let st=Math.max(w,cur),d=w+n;if(d>st){a.push({f:st,t:d,e:`${l}+(${p-l})*(${o}-${w.toFixed(4)})/${n.toFixed(4)}`});cur=d}}if(nx==null||nx>cur){a.push({f:cur,t:nx,e:String(p)});if(nx!=null)cur=nx}}return __flat(o,a)}',
45
+ Mt: 'function Mt(e,t,o){let r=e.filter(a=>a.type==="move"&&a.cursorStyle!==void 0);if(r.length===0)return o===t?"1":"0";let n=[];n.push({time:0,active:o===t});for(let a of r)n.push({time:a.time,active:(a.cursorStyle??o)===t});let i=[n[0]];for(let a=1;a<n.length;a++)n[a].active!==i[i.length-1].active&&i.push(n[a]);if(i.length===1)return i[0].active?"1":"0";let s=[];for(let a=0;a<i.length;a++)s.push({f:a===0?null:i[a].time,t:a+1<i.length?i[a+1].time:null,e:i[a].active?"1":"0"});return __flat("t",s)}',
46
+ };
47
+
48
+ function patchFile(file) {
49
+ let src = fs.readFileSync(file, "utf8");
50
+ if (src.includes(MARK)) return "already patched";
51
+ for (const [name, original] of Object.entries(ORIGINALS)) {
52
+ const n = src.split(original).length - 1;
53
+ if (n !== 1) throw new Error(`${path.basename(file)}: expected exactly one copy of ${name} as shipped in testreel 0.2.0, found ${n} — testreel changed; re-check scripts/patch-testreel.mjs before trusting the cursor past ~45 moves`);
54
+ }
55
+ // Prepend the helper to the first builder so it is defined once, in scope.
56
+ src = src.replace(ORIGINALS.Qe, FLAT.helper + FLAT.Qe);
57
+ for (const name of ["_t", "zt", "Mt"]) src = src.replace(ORIGINALS[name], FLAT[name]);
58
+ fs.writeFileSync(file, src);
59
+ return "patched";
60
+ }
61
+
62
+ let dir;
63
+ try {
64
+ // testreel's exports map hides package.json; walk up from the resolved entry.
65
+ let d = path.dirname(require.resolve("testreel"));
66
+ while (!fs.existsSync(path.join(d, "package.json"))) d = path.dirname(d);
67
+ dir = d;
68
+ } catch { console.log("patch-testreel: testreel not installed yet — nothing to do"); process.exit(0); }
69
+ const version = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).version;
70
+ if (version !== "0.2.0") throw new Error(`patch-testreel: written for testreel 0.2.0, found ${version}`);
71
+
72
+ // Retake imports the ESM build; the CJS build has different minified names
73
+ // and is left alone.
74
+ console.log(`patch-testreel: dist/index.js — ${patchFile(path.join(dir, "dist", "index.js"))}`);
package/skill/SKILL.md CHANGED
@@ -122,9 +122,9 @@ outcome there is.
122
122
 
123
123
  ## Long demos: split, and record up to the beat you are fixing
124
124
 
125
- Hard limit to know: the cursor overlay cannot survive more than ~45 cursor
126
- moves in one take (an ffmpeg expression-depth limit in testreel). `validate`
127
- warns, `check` fails if it happened. Past that, split — or `cursor: false`.
125
+ Hard limit to know: ~180 cursor moves in one take (the cursor filter must
126
+ fit in one ffmpeg argument). `validate` warns, `check` fails if it happened.
127
+ Past that, split — or `cursor: false`.
128
128
 
129
129
  A take is real time: a six-minute walkthrough costs six minutes per
130
130
  attempt and nobody watches six minutes. Above ~40 steps, split the story