retake-demos 0.2.1 → 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 +3 -4
- package/dist/manifest.js +13 -7
- package/dist/record.js +8 -7
- package/dist/ui/chat.html +4 -5
- package/dist/ui/guide.html +1 -1
- package/package.json +5 -3
- package/scripts/patch-testreel.mjs +74 -0
- package/skill/SKILL.md +3 -3
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/
|
|
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
|
|
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
|
|
|
@@ -146,8 +146,7 @@ Apps that keep their session in IndexedDB (Firebase) can't be restored from `sto
|
|
|
146
146
|
## Limits worth knowing
|
|
147
147
|
|
|
148
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.
|
|
149
|
-
- **~
|
|
150
|
-
- **`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.
|
|
151
150
|
- **macOS is where it's used daily.** `videotoolbox` hardware encoding falls back to libx264 elsewhere; Linux is untested beyond CI.
|
|
152
151
|
|
|
153
152
|
## How it's built
|
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).
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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 >
|
|
316
|
-
w.push(`~${moves} cursor moves — the cursor
|
|
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;
|
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
|
-
|
|
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
|
|
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 —
|
|
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 (
|
|
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
|
|
144
|
-
|
|
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>
|
|
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);
|
|
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.
|
package/dist/ui/guide.html
CHANGED
|
@@ -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>
|
|
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>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "retake-demos",
|
|
3
|
-
"version": "0.2.
|
|
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": "
|
|
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:
|
|
126
|
-
|
|
127
|
-
|
|
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
|