@tandryukha/aidemo 0.14.1 → 0.15.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/docs/AUTHORING.md +142 -20
- package/package.json +1 -1
- package/src/captions.ts +174 -30
- package/src/cli.ts +134 -7
- package/src/compose.ts +340 -36
- package/src/cursor.ts +13 -4
- package/src/ffmpeg.ts +68 -0
- package/src/frames.ts +54 -5
- package/src/import-trace.ts +20 -9
- package/src/inspect.ts +9 -0
- package/src/lint.ts +75 -3
- package/src/mcp/jobs.ts +1 -0
- package/src/mcp/server.ts +67 -3
- package/src/player.ts +317 -15
- package/src/qa.ts +295 -0
- package/src/recorder.ts +65 -7
- package/src/safe-regex.ts +36 -0
- package/src/types.ts +155 -19
package/src/ffmpeg.ts
CHANGED
|
@@ -171,6 +171,74 @@ export function probeFlatTailMs(
|
|
|
171
171
|
});
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Mirror of `probeFlatTailMs` for the HEAD of a file: how many ms at the start
|
|
176
|
+
* are a flat, contentless frame (the browser's white pre-paint after a `goto`).
|
|
177
|
+
* Used to open the video on the product instead of on a blank page, and to pick
|
|
178
|
+
* a poster frame that isn't a white rectangle (issue #47). Best-effort: any
|
|
179
|
+
* probe failure resolves to 0.
|
|
180
|
+
*/
|
|
181
|
+
export function probeFlatLeadMs(
|
|
182
|
+
file: string,
|
|
183
|
+
windowMs = 4000,
|
|
184
|
+
startMs = 0
|
|
185
|
+
): Promise<number> {
|
|
186
|
+
return new Promise((resolvePromise) => {
|
|
187
|
+
const proc = spawn(
|
|
188
|
+
"ffmpeg",
|
|
189
|
+
[
|
|
190
|
+
"-hide_banner",
|
|
191
|
+
"-nostats",
|
|
192
|
+
...(startMs > 0 ? ["-ss", (startMs / 1000).toFixed(3)] : []),
|
|
193
|
+
"-i",
|
|
194
|
+
file,
|
|
195
|
+
"-t",
|
|
196
|
+
(windowMs / 1000).toFixed(3),
|
|
197
|
+
"-vf",
|
|
198
|
+
"signalstats,metadata=print:file=-",
|
|
199
|
+
"-f",
|
|
200
|
+
"null",
|
|
201
|
+
"-",
|
|
202
|
+
],
|
|
203
|
+
{ stdio: ["ignore", "pipe", "ignore"] }
|
|
204
|
+
);
|
|
205
|
+
let out = "";
|
|
206
|
+
proc.stdout.on("data", (d) => (out += d.toString()));
|
|
207
|
+
proc.on("error", () => resolvePromise(0));
|
|
208
|
+
proc.on("close", () => {
|
|
209
|
+
const frames: Array<{ t: number; spread: number }> = [];
|
|
210
|
+
let t = NaN;
|
|
211
|
+
let low = NaN;
|
|
212
|
+
let high = NaN;
|
|
213
|
+
const flush = (): void => {
|
|
214
|
+
if (!Number.isNaN(t) && !Number.isNaN(low) && !Number.isNaN(high)) {
|
|
215
|
+
frames.push({ t, spread: high - low });
|
|
216
|
+
}
|
|
217
|
+
low = NaN;
|
|
218
|
+
high = NaN;
|
|
219
|
+
};
|
|
220
|
+
for (const line of out.split("\n")) {
|
|
221
|
+
const ts = /pts_time:\s*([\d.]+)/.exec(line);
|
|
222
|
+
if (ts) {
|
|
223
|
+
flush();
|
|
224
|
+
t = parseFloat(ts[1]);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const lo = /YLOW=\s*([\d.]+)/.exec(line);
|
|
228
|
+
if (lo) low = parseFloat(lo[1]);
|
|
229
|
+
const hi = /YHIGH=\s*([\d.]+)/.exec(line);
|
|
230
|
+
if (hi) high = parseFloat(hi[1]);
|
|
231
|
+
}
|
|
232
|
+
flush();
|
|
233
|
+
const FLAT_SPREAD = 4;
|
|
234
|
+
if (!frames.length || frames[0].spread > FLAT_SPREAD) return resolvePromise(0);
|
|
235
|
+
let i = 0;
|
|
236
|
+
while (i + 1 < frames.length && frames[i + 1].spread <= FLAT_SPREAD) i++;
|
|
237
|
+
resolvePromise(Math.max(0, Math.round(frames[i].t * 1000)));
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
174
242
|
/** A motionless span found by `freezedetect`, in ms from the file's start. */
|
|
175
243
|
export interface FreezeSpan {
|
|
176
244
|
startMs: number;
|
package/src/frames.ts
CHANGED
|
@@ -26,6 +26,14 @@ export interface FramesOptions {
|
|
|
26
26
|
width?: number;
|
|
27
27
|
/** Output directory. Default <demo>/output/frames. */
|
|
28
28
|
outDir?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Also tile the extracted frames into one contact sheet PNG
|
|
31
|
+
* (`output/frames/<source>-sheet.png`) — the single most useful review
|
|
32
|
+
* artifact for a finished cut (issue #52), and one image instead of 40.
|
|
33
|
+
*/
|
|
34
|
+
sheet?: boolean;
|
|
35
|
+
/** Columns in the contact sheet. Default 5. */
|
|
36
|
+
sheetColumns?: number;
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
export interface FramesResult {
|
|
@@ -33,6 +41,8 @@ export interface FramesResult {
|
|
|
33
41
|
durationMs: number;
|
|
34
42
|
everySec: number;
|
|
35
43
|
files: string[];
|
|
44
|
+
/** The contact sheet, when `sheet` was asked for. */
|
|
45
|
+
sheet?: string;
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
/** `mm-ss` (plus tenths when the cadence isn't whole seconds) — unique + sortable. */
|
|
@@ -52,7 +62,7 @@ export async function extractFrames(
|
|
|
52
62
|
const everySec = Math.max(0.2, opts.everySec ?? 3);
|
|
53
63
|
const width = Math.max(64, Math.round(opts.width ?? 640));
|
|
54
64
|
const kind = opts.source ?? "final";
|
|
55
|
-
if (kind === "take") return takeFrames(project, everySec, width, opts.outDir);
|
|
65
|
+
if (kind === "take") return takeFrames(project, everySec, width, opts.outDir, opts);
|
|
56
66
|
const source = kind === "raw" ? await project.resolveRawVideo() : project.outputPath;
|
|
57
67
|
if (!(await exists(source))) {
|
|
58
68
|
throw new Error(
|
|
@@ -76,8 +86,8 @@ export async function extractFrames(
|
|
|
76
86
|
source,
|
|
77
87
|
"-vf",
|
|
78
88
|
`fps=1/${everySec},scale=${width}:-2`,
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
// No `-vsync vfr` here: the `fps` filter already sets the rate, and
|
|
90
|
+
// ffmpeg 8 removed the option outright (portability baseline).
|
|
81
91
|
pattern,
|
|
82
92
|
]);
|
|
83
93
|
// ffmpeg numbers frames 0001…; rename to the timestamp each one represents.
|
|
@@ -92,7 +102,41 @@ export async function extractFrames(
|
|
|
92
102
|
await fs.rename(join(outDir, f), join(outDir, name));
|
|
93
103
|
files.push(join(outDir, name));
|
|
94
104
|
}
|
|
95
|
-
|
|
105
|
+
const sheet = opts.sheet
|
|
106
|
+
? await buildSheet(files, join(outDir, `${kind}-sheet.png`), opts.sheetColumns)
|
|
107
|
+
: undefined;
|
|
108
|
+
return { source, durationMs, everySec, files, ...(sheet ? { sheet } : {}) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Tile frames into one contact sheet (ffmpeg `tile`). Uses the already-scaled
|
|
113
|
+
* PNGs as an image sequence, so no second decode of the video.
|
|
114
|
+
*/
|
|
115
|
+
export async function buildSheet(
|
|
116
|
+
files: string[],
|
|
117
|
+
outPath: string,
|
|
118
|
+
columns = 5
|
|
119
|
+
): Promise<string | undefined> {
|
|
120
|
+
if (!files.length) return undefined;
|
|
121
|
+
const cols = Math.max(1, Math.min(10, columns));
|
|
122
|
+
const rows = Math.ceil(files.length / cols);
|
|
123
|
+
const inputs = files.flatMap((f) => ["-i", f]);
|
|
124
|
+
const chain =
|
|
125
|
+
files.map((_, i) => `[${i}:v]scale=480:-2,pad=iw+8:ih+8:4:4:color=0x11141c[t${i}]`).join(";") +
|
|
126
|
+
";" +
|
|
127
|
+
files.map((_, i) => `[t${i}]`).join("") +
|
|
128
|
+
`concat=n=${files.length}:v=1:a=0,tile=${cols}x${rows}[sheet]`;
|
|
129
|
+
await runFfmpeg([
|
|
130
|
+
...inputs,
|
|
131
|
+
"-filter_complex",
|
|
132
|
+
chain,
|
|
133
|
+
"-map",
|
|
134
|
+
"[sheet]",
|
|
135
|
+
"-frames:v",
|
|
136
|
+
"1",
|
|
137
|
+
outPath,
|
|
138
|
+
]);
|
|
139
|
+
return outPath;
|
|
96
140
|
}
|
|
97
141
|
|
|
98
142
|
/**
|
|
@@ -106,7 +150,8 @@ async function takeFrames(
|
|
|
106
150
|
project: Project,
|
|
107
151
|
everySec: number,
|
|
108
152
|
width: number,
|
|
109
|
-
outDirOpt?: string
|
|
153
|
+
outDirOpt?: string,
|
|
154
|
+
sheetOpts?: Pick<FramesOptions, "sheet" | "sheetColumns">
|
|
110
155
|
): Promise<FramesResult> {
|
|
111
156
|
const timeline = TimelineSchema.parse(await readJson(project.timelinePath));
|
|
112
157
|
const rawVideo = await project.resolveRawVideo();
|
|
@@ -150,10 +195,14 @@ async function takeFrames(
|
|
|
150
195
|
carryMs = Math.max(0, carryMs + shot * stepMs - sceneMs);
|
|
151
196
|
elapsedMs += sceneMs;
|
|
152
197
|
}
|
|
198
|
+
const sheet = sheetOpts?.sheet
|
|
199
|
+
? await buildSheet(files, join(outDir, "take-sheet.png"), sheetOpts.sheetColumns)
|
|
200
|
+
: undefined;
|
|
153
201
|
return {
|
|
154
202
|
source: [...sources].map((f) => relative(project.dir, f)).join(", "),
|
|
155
203
|
durationMs: elapsedMs,
|
|
156
204
|
everySec,
|
|
157
205
|
files,
|
|
206
|
+
...(sheet ? { sheet } : {}),
|
|
158
207
|
};
|
|
159
208
|
}
|
package/src/import-trace.ts
CHANGED
|
@@ -274,6 +274,17 @@ function stepsFromTrace(entries: Map<string, Buffer>, notes: string[]): Step[] {
|
|
|
274
274
|
return steps;
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Escape a literal for embedding inside a double-quoted selector value. The
|
|
279
|
+
* captured text still carries the spec's own source escapes, so unescape once
|
|
280
|
+
* first; then escape backslashes before quotes — escaping only `"` leaves a
|
|
281
|
+
* trailing `\` producing `"foo\"`, which swallows the closing quote and
|
|
282
|
+
* rewrites the selector.
|
|
283
|
+
*/
|
|
284
|
+
function q(v: string): string {
|
|
285
|
+
return v.replace(/\\(.)/g, "$1").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
286
|
+
}
|
|
287
|
+
|
|
277
288
|
/** Tolerant line-based parse of a Playwright test file (page.* chains). */
|
|
278
289
|
export function stepsFromTest(src: string, notes: string[]): Step[] {
|
|
279
290
|
const steps: Step[] = [];
|
|
@@ -310,30 +321,30 @@ export function stepsFromTest(src: string, notes: string[]): Step[] {
|
|
|
310
321
|
let part = "";
|
|
311
322
|
switch (fn) {
|
|
312
323
|
case "getByRole":
|
|
313
|
-
part = name ? `role=${arg}[name="${S(name, 1)}"${exact ? "" : "i"}]` : `role=${arg}`;
|
|
324
|
+
part = name ? `role=${arg}[name="${q(S(name, 1))}"${exact ? "" : "i"}]` : `role=${arg}`;
|
|
314
325
|
break;
|
|
315
326
|
case "getByTestId":
|
|
316
|
-
part = `[data-testid="${arg}"]`;
|
|
327
|
+
part = `[data-testid="${q(arg)}"]`;
|
|
317
328
|
break;
|
|
318
329
|
case "getByText":
|
|
319
|
-
part = exact ? `text="${arg}"` : `text=${arg}`;
|
|
330
|
+
part = exact ? `text="${q(arg)}"` : `text=${arg}`;
|
|
320
331
|
break;
|
|
321
332
|
case "getByLabel":
|
|
322
333
|
// Same engine the trace importer emits: resolves to the control.
|
|
323
|
-
part = `internal:label="${arg
|
|
334
|
+
part = `internal:label="${q(arg)}"${exact ? "s" : "i"}`;
|
|
324
335
|
break;
|
|
325
336
|
case "getByPlaceholder":
|
|
326
|
-
part = `[placeholder="${arg}"]`;
|
|
337
|
+
part = `[placeholder="${q(arg)}"]`;
|
|
327
338
|
break;
|
|
328
339
|
case "getByTitle":
|
|
329
|
-
part = `[title="${arg}"]`;
|
|
340
|
+
part = `[title="${q(arg)}"]`;
|
|
330
341
|
break;
|
|
331
342
|
case "getByAltText":
|
|
332
|
-
part = `[alt="${arg}"]`;
|
|
343
|
+
part = `[alt="${q(arg)}"]`;
|
|
333
344
|
break;
|
|
334
345
|
case "locator":
|
|
335
346
|
part = arg;
|
|
336
|
-
if (hasText) part += `:has-text("${S(hasText, 1)}")`;
|
|
347
|
+
if (hasText) part += `:has-text("${q(S(hasText, 1))}")`;
|
|
337
348
|
break;
|
|
338
349
|
case "frameLocator":
|
|
339
350
|
frameSel = arg;
|
|
@@ -426,7 +437,7 @@ export function stepsFromTest(src: string, notes: string[]): Step[] {
|
|
|
426
437
|
if (attr && val && !regexArg) {
|
|
427
438
|
steps.push({
|
|
428
439
|
method: "assert",
|
|
429
|
-
selector: `${selector} >> :scope[${attr}="${val
|
|
440
|
+
selector: `${selector} >> :scope[${attr}="${q(val)}"]`,
|
|
430
441
|
});
|
|
431
442
|
} else if (attr) {
|
|
432
443
|
steps.push({ method: "assert", selector: `${selector} >> :scope[${attr}]` });
|
package/src/inspect.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { chromium, type Frame, type Page } from "playwright";
|
|
14
14
|
import { chromeProfileDir } from "./config.js";
|
|
15
15
|
import { ensureProfileUnlocked } from "./login.js";
|
|
16
|
+
import { applySeeds, type Seeds } from "./setup.js";
|
|
16
17
|
|
|
17
18
|
export interface InspectElement {
|
|
18
19
|
/** ARIA role (explicit or implied by the tag). */
|
|
@@ -60,6 +61,13 @@ export interface InspectOptions {
|
|
|
60
61
|
frames?: Record<string, string>;
|
|
61
62
|
/** Settle time after load (ms, default 1200). */
|
|
62
63
|
settleMs?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Cookies / localStorage to inject before the first navigation — the same
|
|
66
|
+
* seeding `probe`/`record` do, so an authenticated page can be scanned
|
|
67
|
+
* (issue #53). Collect them with `collectSeeds` (storyboard `setup` +
|
|
68
|
+
* `--storage-state` / `--cookie`).
|
|
69
|
+
*/
|
|
70
|
+
seeds?: Seeds | null;
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
const DEFAULT_LIMIT = 80;
|
|
@@ -199,6 +207,7 @@ export async function inspectPage(opts: InspectOptions): Promise<InspectResult>
|
|
|
199
207
|
});
|
|
200
208
|
try {
|
|
201
209
|
const page = context.pages()[0] ?? (await context.newPage());
|
|
210
|
+
if (opts.seeds) await applySeeds(context, page, opts.seeds);
|
|
202
211
|
await page.goto(opts.url, { waitUntil: "domcontentloaded" });
|
|
203
212
|
await page
|
|
204
213
|
.waitForLoadState("networkidle", { timeout: 4000 })
|
package/src/lint.ts
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* be 60 % frozen frame before TTS is paid for.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import type
|
|
16
|
+
import { OUTPUT_PRESETS, type Storyboard, type Scene, type Action } from "./types.js";
|
|
17
17
|
import { clampScaleForWidth } from "./zoom.js";
|
|
18
|
+
import { compileUserRegex } from "./safe-regex.js";
|
|
18
19
|
|
|
19
20
|
export type LintSeverity = "error" | "warn" | "info";
|
|
20
21
|
|
|
@@ -163,6 +164,9 @@ export function estimateActionMs(a: Action, autoIdle = false): number {
|
|
|
163
164
|
return 800;
|
|
164
165
|
case "back":
|
|
165
166
|
return 1400; // like goto
|
|
167
|
+
case "dialog":
|
|
168
|
+
// Arming a handler costs nothing; `holdMs` pauses before answering.
|
|
169
|
+
return 20 + (a.holdMs ?? 0);
|
|
166
170
|
default:
|
|
167
171
|
return 300;
|
|
168
172
|
}
|
|
@@ -359,14 +363,14 @@ export function lintStoryboard(
|
|
|
359
363
|
for (const re of [a.textMatches, a.url]) {
|
|
360
364
|
if (re == null) continue;
|
|
361
365
|
try {
|
|
362
|
-
|
|
366
|
+
compileUserRegex(re, "", "assert");
|
|
363
367
|
} catch (e) {
|
|
364
368
|
push({
|
|
365
369
|
severity: "error",
|
|
366
370
|
code: "assert-bad-regex",
|
|
367
371
|
scene: scene.id,
|
|
368
372
|
action: ai,
|
|
369
|
-
message: `\`assert\` regex ${JSON.stringify(re)} is
|
|
373
|
+
message: `\`assert\` regex ${JSON.stringify(re)} is unusable: ${(e as Error).message}`,
|
|
370
374
|
});
|
|
371
375
|
}
|
|
372
376
|
}
|
|
@@ -573,6 +577,74 @@ export function lintStoryboard(
|
|
|
573
577
|
}
|
|
574
578
|
}
|
|
575
579
|
|
|
580
|
+
// --- legibility + framing (issues #51, #58) ---
|
|
581
|
+
const dsf = sb.video.deviceScaleFactor ?? 1;
|
|
582
|
+
const framePad = sb.frame?.padding ?? (sb.frame ? 48 : 0);
|
|
583
|
+
if (sb.video.width >= 1800 && dsf === 1) {
|
|
584
|
+
const contentPct = sb.frame
|
|
585
|
+
? Math.round(((sb.video.width - 2 * framePad) / sb.video.width) * 100)
|
|
586
|
+
: 100;
|
|
587
|
+
push({
|
|
588
|
+
severity: "warn",
|
|
589
|
+
code: "viewport-legibility",
|
|
590
|
+
message:
|
|
591
|
+
`recording at ${sb.video.width}x${sb.video.height} CSS px: a desktop site lays out its ` +
|
|
592
|
+
`content column in the middle of that viewport, so body text ends up ~14px` +
|
|
593
|
+
(sb.frame ? ` inside a frame that keeps only ${contentPct}% of the canvas` : "") +
|
|
594
|
+
" — unreadable after the output downscale",
|
|
595
|
+
fix:
|
|
596
|
+
"record at 1280–1440 CSS px with `video.deviceScaleFactor: 2` and let `output.preset` " +
|
|
597
|
+
"scale the 2x raw down; the UI is a third bigger on screen and stays crisp",
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
const preset = sb.output?.preset ? OUTPUT_PRESETS[sb.output.preset] : null;
|
|
601
|
+
const outW = sb.output?.width ?? preset?.width;
|
|
602
|
+
const outH = sb.output?.height ?? preset?.height;
|
|
603
|
+
const fit = sb.output?.fit ?? preset?.fit ?? "contain";
|
|
604
|
+
if (outW && outH) {
|
|
605
|
+
const capAspect = sb.video.width / sb.video.height;
|
|
606
|
+
const outAspect = outW / outH;
|
|
607
|
+
if (fit === "contain" && Math.abs(capAspect - outAspect) / outAspect > 0.02) {
|
|
608
|
+
const barPx =
|
|
609
|
+
capAspect > outAspect
|
|
610
|
+
? Math.round((outH - outW / capAspect) / 2)
|
|
611
|
+
: Math.round((outW - outH * capAspect) / 2);
|
|
612
|
+
const side = capAspect > outAspect ? "top and bottom" : "left and right";
|
|
613
|
+
push({
|
|
614
|
+
severity: "warn",
|
|
615
|
+
code: "output-letterbox",
|
|
616
|
+
message:
|
|
617
|
+
`capture is ${sb.video.width}x${sb.video.height} (${capAspect.toFixed(2)}:1) but the output ` +
|
|
618
|
+
`is ${outW}x${outH} (${outAspect.toFixed(2)}:1) with fit "contain" — ${barPx}px bars on the ${side}` +
|
|
619
|
+
(sb.frame ? ", on top of the frame padding's own bars" : ""),
|
|
620
|
+
fix:
|
|
621
|
+
`either record at the output's aspect (e.g. ${outH === 1080 ? "1600x900" : `${outW / 2}x${outH / 2}`} ` +
|
|
622
|
+
`with video.deviceScaleFactor: 2) or set output.fit: "cover" for an edge-to-edge cut`,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// --- opening frame (issue #47) ---
|
|
628
|
+
const firstScene = sb.scenes[0];
|
|
629
|
+
const opener = firstScene?.actions[0];
|
|
630
|
+
if (opener?.op === "goto") {
|
|
631
|
+
const settles = firstScene.actions
|
|
632
|
+
.slice(1)
|
|
633
|
+
.findIndex((a) => a.op === "waitFor" || a.op === "pause" || a.op === "waitForChange");
|
|
634
|
+
if (settles !== 0) {
|
|
635
|
+
push({
|
|
636
|
+
severity: "info",
|
|
637
|
+
code: "blank-open",
|
|
638
|
+
scene: firstScene.id,
|
|
639
|
+
action: 0,
|
|
640
|
+
message:
|
|
641
|
+
"the demo opens on a `goto` with nothing between it and the first beat — the video " +
|
|
642
|
+
"(and the poster) starts on the browser's white pre-paint while the first caption is already up",
|
|
643
|
+
fix: "follow the goto with a waitFor on the page's own content (or set `output.trimLeadingBlank: true`)",
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
576
648
|
const order: Record<LintSeverity, number> = { error: 0, warn: 1, info: 2 };
|
|
577
649
|
issues.sort((a, b) => order[a.severity] - order[b.severity]);
|
|
578
650
|
return { issues, estimate, narrationTotalMs, wordsPerSec: wps };
|
package/src/mcp/jobs.ts
CHANGED
package/src/mcp/server.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
5
5
|
import { readFile } from "node:fs/promises";
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
|
-
import { ENGINE_ROOT, engineVersion, captionsAutoOffline } from "../config.js";
|
|
7
|
+
import { ENGINE_ROOT, engineVersion, captionsAutoOffline, chromeProfileDir } from "../config.js";
|
|
8
8
|
import { Project, parseStoryboard } from "../project.js";
|
|
9
9
|
import { StoryboardSchema, SeedCookieSchema, type SeedCookie, type Storyboard } from "../types.js";
|
|
10
10
|
import { generateVoice } from "../voice.js";
|
|
@@ -23,7 +23,9 @@ import { exportGif } from "../gif.js";
|
|
|
23
23
|
import { buildEmbed } from "../embed.js";
|
|
24
24
|
import { extractStills, storyboardHasStills } from "../stills.js";
|
|
25
25
|
import { extractFrames } from "../frames.js";
|
|
26
|
+
import { runQa, logQa } from "../qa.js";
|
|
26
27
|
import { inspectPage } from "../inspect.js";
|
|
28
|
+
import { collectSeeds, runPreflight } from "../setup.js";
|
|
27
29
|
import { importTrace, scaffoldImported } from "../import-trace.js";
|
|
28
30
|
import { exportWalkthrough } from "../walkthrough.js";
|
|
29
31
|
import { GUIDE_TOPIC_NAMES, guideHeadings, guidePath as guideFilePath, sliceGuide } from "../guide.js";
|
|
@@ -1079,7 +1081,11 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
1079
1081
|
captionsSrt: lp.captionsSrtPath,
|
|
1080
1082
|
report: lp.reportPath,
|
|
1081
1083
|
durationMs: report.durationMs,
|
|
1084
|
+
// warningCount lets an orchestrating agent gate on the render
|
|
1085
|
+
// without parsing the warning list (issue #59).
|
|
1086
|
+
warningCount: report.warnings.length,
|
|
1082
1087
|
warnings: report.warnings,
|
|
1088
|
+
...(report.blankOpenMs ? { blankOpenMs: report.blankOpenMs } : {}),
|
|
1083
1089
|
};
|
|
1084
1090
|
})
|
|
1085
1091
|
);
|
|
@@ -1168,11 +1174,37 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
1168
1174
|
...(gifPath ? { gif: gifPath } : {}),
|
|
1169
1175
|
report: lp.reportPath,
|
|
1170
1176
|
durationMs: report.durationMs,
|
|
1177
|
+
// warningCount lets an orchestrating agent gate on the render
|
|
1178
|
+
// without parsing the warning list (issue #59).
|
|
1179
|
+
warningCount: report.warnings.length,
|
|
1171
1180
|
warnings: report.warnings,
|
|
1181
|
+
...(report.blankOpenMs ? { blankOpenMs: report.blankOpenMs } : {}),
|
|
1172
1182
|
};
|
|
1173
1183
|
})
|
|
1174
1184
|
);
|
|
1175
1185
|
|
|
1186
|
+
registerJob(
|
|
1187
|
+
"qa",
|
|
1188
|
+
"Post-render checks on the FINISHED MP4 — blank opening, blank poster, " +
|
|
1189
|
+
"master loudness, music-bed level, per-scene hold shares + compose " +
|
|
1190
|
+
"warnings, caption readability, frame/chapter hygiene. Run it after " +
|
|
1191
|
+
"compose/render: it sees what a viewer sees, which neither lint (pre-take) " +
|
|
1192
|
+
"nor report.json (structural) can.",
|
|
1193
|
+
{
|
|
1194
|
+
dir: DIR_INPUT,
|
|
1195
|
+
params: PARAMS_INPUT,
|
|
1196
|
+
lang: LANG_INPUT,
|
|
1197
|
+
},
|
|
1198
|
+
(project, args) => async (job) =>
|
|
1199
|
+
jobs.runStage(job, "qa", async () => {
|
|
1200
|
+
const storyboard = await project.loadStoryboard({ params: args.params });
|
|
1201
|
+
const sb = args.lang ? localizeStoryboard(storyboard, args.lang) : storyboard;
|
|
1202
|
+
const res = await runQa(langProject(project, args.lang), sb);
|
|
1203
|
+
logQa(res);
|
|
1204
|
+
return res;
|
|
1205
|
+
})
|
|
1206
|
+
);
|
|
1207
|
+
|
|
1176
1208
|
registerJob(
|
|
1177
1209
|
"gif",
|
|
1178
1210
|
"Convert output/final-demo.mp4 to a README-ready GIF.",
|
|
@@ -1295,18 +1327,50 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
1295
1327
|
.record(z.string(), z.string())
|
|
1296
1328
|
.optional()
|
|
1297
1329
|
.describe("iframe selectors to scan too, as in the storyboard `frames` block"),
|
|
1330
|
+
useSetup: z
|
|
1331
|
+
.boolean()
|
|
1332
|
+
.optional()
|
|
1333
|
+
.describe(
|
|
1334
|
+
"honour the storyboard's setup block (storageState/cookies/preflight) so an " +
|
|
1335
|
+
"authenticated page can be scanned. Default true."
|
|
1336
|
+
),
|
|
1337
|
+
storageState: z
|
|
1338
|
+
.string()
|
|
1339
|
+
.optional()
|
|
1340
|
+
.describe("extra Playwright storageState JSON to seed before the scan"),
|
|
1298
1341
|
},
|
|
1299
1342
|
(project, args) => async (job) =>
|
|
1300
1343
|
jobs.runStage(job, "inspect", async () => {
|
|
1301
1344
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1302
1345
|
const png = project.p("logs", `inspect-${stamp}.png`);
|
|
1346
|
+
// #53: scan authenticated pages — same seeding surface as probe/record.
|
|
1347
|
+
let storyboard: Storyboard | null = null;
|
|
1348
|
+
if (args.useSetup !== false) {
|
|
1349
|
+
storyboard = await project.loadStoryboard({ relaxed: true }).catch(() => null);
|
|
1350
|
+
}
|
|
1351
|
+
if (storyboard?.setup?.preflight) {
|
|
1352
|
+
await runPreflight(storyboard.setup.preflight, {
|
|
1353
|
+
demoDir: project.dir,
|
|
1354
|
+
storyboardPath: project.storyboardPath,
|
|
1355
|
+
profileDir: args.profile ?? chromeProfileDir(),
|
|
1356
|
+
});
|
|
1357
|
+
storyboard = await project
|
|
1358
|
+
.loadStoryboard({ relaxed: true })
|
|
1359
|
+
.catch(() => storyboard);
|
|
1360
|
+
}
|
|
1361
|
+
const seeds = await collectSeeds(
|
|
1362
|
+
project.dir,
|
|
1363
|
+
storyboard ?? ({ setup: undefined } as never),
|
|
1364
|
+
{ storageState: args.storageState }
|
|
1365
|
+
);
|
|
1303
1366
|
const res = await inspectPage({
|
|
1304
1367
|
url: args.url,
|
|
1305
1368
|
headless: args.headless !== false,
|
|
1306
1369
|
profileDir: args.profile,
|
|
1307
1370
|
limit: args.limit,
|
|
1308
|
-
viewport: args.viewport,
|
|
1309
|
-
frames: args.frames,
|
|
1371
|
+
viewport: args.viewport ?? storyboard?.video,
|
|
1372
|
+
frames: args.frames ?? storyboard?.frames,
|
|
1373
|
+
seeds,
|
|
1310
1374
|
screenshotPath: png,
|
|
1311
1375
|
});
|
|
1312
1376
|
const file = project.p("logs", `inspect-${stamp}.json`);
|