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