@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/qa.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aidemo qa <dir>` — post-render checks on the FINISHED MP4 (issue #52).
|
|
3
|
+
*
|
|
4
|
+
* `lint` predicts problems before the take and `output/report.json` records
|
|
5
|
+
* what compose did, but neither sees the file a viewer opens: a white opening
|
|
6
|
+
* frame, a blank poster, a master that is too quiet, captions that flash by,
|
|
7
|
+
* an address pill naming the wrong host. Those were caught locally with a
|
|
8
|
+
* ~150-line ffmpeg + python script; this is that script, in the engine, so
|
|
9
|
+
* every agent-driven render is self-checking.
|
|
10
|
+
*
|
|
11
|
+
* Pure measurement over existing artifacts: ffmpeg/ffprobe only, no browser,
|
|
12
|
+
* no network, nothing written.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { relative } from "node:path";
|
|
17
|
+
import type { Project } from "./project.js";
|
|
18
|
+
import {
|
|
19
|
+
probeDurationMs,
|
|
20
|
+
probeFlatLeadMs,
|
|
21
|
+
} from "./ffmpeg.js";
|
|
22
|
+
import { ComposeReportSchema, type Storyboard } from "./types.js";
|
|
23
|
+
import { exists, log, ok, readJson } from "./util.js";
|
|
24
|
+
|
|
25
|
+
export type QaStatus = "pass" | "warn" | "note";
|
|
26
|
+
|
|
27
|
+
export interface QaCheck {
|
|
28
|
+
/** Stable id: length | blank-open | poster | loudness | bed | hold | captions | hygiene. */
|
|
29
|
+
id: string;
|
|
30
|
+
status: QaStatus;
|
|
31
|
+
message: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface QaResult {
|
|
35
|
+
video: string;
|
|
36
|
+
durationMs: number;
|
|
37
|
+
checks: QaCheck[];
|
|
38
|
+
warnings: number;
|
|
39
|
+
notes: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Integrated loudness / true peak / the momentary p10 (the bed under speech). */
|
|
43
|
+
interface LoudnessStats {
|
|
44
|
+
integrated: number;
|
|
45
|
+
truePeak: number;
|
|
46
|
+
/** 10th percentile of the momentary loudness — what plays in the gaps. */
|
|
47
|
+
momentaryP10: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ebur128(file: string): Promise<LoudnessStats | null> {
|
|
51
|
+
return new Promise((resolvePromise) => {
|
|
52
|
+
const proc = spawn(
|
|
53
|
+
"ffmpeg",
|
|
54
|
+
["-hide_banner", "-nostats", "-i", file, "-af", "ebur128=peak=true", "-f", "null", "-"],
|
|
55
|
+
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
56
|
+
);
|
|
57
|
+
let err = "";
|
|
58
|
+
proc.stderr.on("data", (d) => (err += d.toString()));
|
|
59
|
+
proc.on("error", () => resolvePromise(null));
|
|
60
|
+
proc.on("close", () => {
|
|
61
|
+
const moments: number[] = [];
|
|
62
|
+
for (const m of err.matchAll(/M:\s*(-?[\d.]+|-inf)/g)) {
|
|
63
|
+
const v = m[1] === "-inf" ? -70 : parseFloat(m[1]);
|
|
64
|
+
if (Number.isFinite(v) && v > -70) moments.push(v);
|
|
65
|
+
}
|
|
66
|
+
const summary = err.slice(err.lastIndexOf("Integrated loudness"));
|
|
67
|
+
const I = /I:\s*(-?[\d.]+)\s*LUFS/.exec(summary);
|
|
68
|
+
const TP = /Peak:\s*(-?[\d.]+)\s*dBFS/.exec(summary);
|
|
69
|
+
if (!I) return resolvePromise(null);
|
|
70
|
+
moments.sort((a, b) => a - b);
|
|
71
|
+
resolvePromise({
|
|
72
|
+
integrated: parseFloat(I[1]),
|
|
73
|
+
truePeak: TP ? parseFloat(TP[1]) : 0,
|
|
74
|
+
momentaryP10: moments.length ? moments[Math.floor(moments.length * 0.1)] : -70,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Mean luma of a still image (a blank poster is ~white or ~black). */
|
|
81
|
+
function imageYavg(file: string): Promise<number | null> {
|
|
82
|
+
return new Promise((resolvePromise) => {
|
|
83
|
+
const proc = spawn(
|
|
84
|
+
"ffmpeg",
|
|
85
|
+
[
|
|
86
|
+
"-hide_banner",
|
|
87
|
+
"-nostats",
|
|
88
|
+
"-i",
|
|
89
|
+
file,
|
|
90
|
+
"-vf",
|
|
91
|
+
"signalstats,metadata=print:file=-",
|
|
92
|
+
"-f",
|
|
93
|
+
"null",
|
|
94
|
+
"-",
|
|
95
|
+
],
|
|
96
|
+
{ stdio: ["ignore", "pipe", "ignore"] }
|
|
97
|
+
);
|
|
98
|
+
let out = "";
|
|
99
|
+
proc.stdout.on("data", (d) => (out += d.toString()));
|
|
100
|
+
proc.on("error", () => resolvePromise(null));
|
|
101
|
+
proc.on("close", () => {
|
|
102
|
+
const m = /YAVG=\s*([\d.]+)/.exec(out);
|
|
103
|
+
resolvePromise(m ? parseFloat(m[1]) : null);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Host of a URL-ish string, or null. */
|
|
109
|
+
function hostOf(url: string): string | null {
|
|
110
|
+
try {
|
|
111
|
+
return new URL(url.includes("://") ? url : `https://${url}`).host;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function runQa(project: Project, storyboard: Storyboard): Promise<QaResult> {
|
|
118
|
+
const video = project.outputPath;
|
|
119
|
+
if (!(await exists(video))) {
|
|
120
|
+
throw new Error(`no video at ${video} — run 'aidemo render <dir>' (or compose) first`);
|
|
121
|
+
}
|
|
122
|
+
const checks: QaCheck[] = [];
|
|
123
|
+
const add = (id: string, status: QaStatus, message: string): void => {
|
|
124
|
+
checks.push({ id, status, message });
|
|
125
|
+
};
|
|
126
|
+
const durationMs = await probeDurationMs(video);
|
|
127
|
+
|
|
128
|
+
// 1. Length against the storyboard's own target.
|
|
129
|
+
const target = storyboard.targetLengthSeconds;
|
|
130
|
+
const secs = Math.round(durationMs / 1000);
|
|
131
|
+
if (target == null) {
|
|
132
|
+
add("length", "note", `${secs}s (no targetLengthSeconds declared)`);
|
|
133
|
+
} else if (Math.abs(secs - target) > Math.max(10, target * 0.2)) {
|
|
134
|
+
add("length", "warn", `${secs}s vs targetLengthSeconds ${target}`);
|
|
135
|
+
} else {
|
|
136
|
+
add("length", "pass", `${secs}s (target ${target}s)`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 2. The opening frames: a demo that starts on the browser's white pre-paint.
|
|
140
|
+
const introMs = storyboard.intro?.durationMs ?? 0;
|
|
141
|
+
const blankMs = await probeFlatLeadMs(video, 4000, introMs).catch(() => 0);
|
|
142
|
+
if (blankMs > 250) {
|
|
143
|
+
add(
|
|
144
|
+
"blank-open",
|
|
145
|
+
"warn",
|
|
146
|
+
`opens on ${(blankMs / 1000).toFixed(2)}s of blank page — set output.trimLeadingBlank`
|
|
147
|
+
);
|
|
148
|
+
} else {
|
|
149
|
+
add("blank-open", "pass", `content from ${(blankMs / 1000).toFixed(2)}s`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3. Poster: a white rectangle is worse than no poster at all.
|
|
153
|
+
if (await exists(project.posterPath)) {
|
|
154
|
+
const yavg = await imageYavg(project.posterPath);
|
|
155
|
+
if (yavg != null && (yavg > 180 || yavg < 25)) {
|
|
156
|
+
add(
|
|
157
|
+
"poster",
|
|
158
|
+
"warn",
|
|
159
|
+
`${relative(project.dir, project.posterPath)} looks blank (YAVG=${yavg.toFixed(1)}) — ` +
|
|
160
|
+
`set output.poster to an explicit ms`
|
|
161
|
+
);
|
|
162
|
+
} else {
|
|
163
|
+
add("poster", "pass", `${relative(project.dir, project.posterPath)} has content`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 4. Master loudness, and the music bed's level in the gaps.
|
|
168
|
+
const loud = await ebur128(video);
|
|
169
|
+
if (loud) {
|
|
170
|
+
// The loudness pass only runs with music, or when `output.loudness` is an
|
|
171
|
+
// explicit object — a narration-only render is deliberately untouched, so
|
|
172
|
+
// measuring it against -16 LUFS would warn on every such demo.
|
|
173
|
+
const normalized =
|
|
174
|
+
storyboard.output?.loudness !== false &&
|
|
175
|
+
(storyboard.music != null || typeof storyboard.output?.loudness === "object");
|
|
176
|
+
const wantI = normalized ? -16 : null;
|
|
177
|
+
if (wantI != null && Math.abs(loud.integrated - wantI) > 2) {
|
|
178
|
+
add(
|
|
179
|
+
"loudness",
|
|
180
|
+
"warn",
|
|
181
|
+
`I=${loud.integrated.toFixed(1)} LUFS (target ${wantI}), TP=${loud.truePeak.toFixed(1)} dBTP`
|
|
182
|
+
);
|
|
183
|
+
} else {
|
|
184
|
+
add(
|
|
185
|
+
"loudness",
|
|
186
|
+
wantI == null ? "note" : "pass",
|
|
187
|
+
`I=${loud.integrated.toFixed(1)} LUFS, TP=${loud.truePeak.toFixed(1)} dBTP` +
|
|
188
|
+
(wantI == null ? " (no loudness pass — narration only)" : "")
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (storyboard.music) {
|
|
192
|
+
const bed = loud.momentaryP10;
|
|
193
|
+
if (bed > -18) {
|
|
194
|
+
add("bed", "warn", `music bed ≈ ${bed.toFixed(1)} LUFS in the gaps — it will fight the voice`);
|
|
195
|
+
} else if (bed < -40) {
|
|
196
|
+
add("bed", "note", `music bed ≈ ${bed.toFixed(1)} LUFS — effectively inaudible`);
|
|
197
|
+
} else {
|
|
198
|
+
add("bed", "pass", `music bed ≈ ${bed.toFixed(1)} LUFS (p10 of the master)`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 5. Hold shares + whatever compose already warned about.
|
|
204
|
+
if (await exists(project.reportPath)) {
|
|
205
|
+
const report = ComposeReportSchema.safeParse(await readJson(project.reportPath));
|
|
206
|
+
if (report.success) {
|
|
207
|
+
for (const s of report.data.scenes) {
|
|
208
|
+
if (s.holdPct > 0.25) {
|
|
209
|
+
add("hold", "warn", `${s.id} held ${Math.round(s.holdPct * 100)}%`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const w of report.data.warnings) {
|
|
213
|
+
add("hold", "warn", `engine: ${w.code} — ${w.message}`);
|
|
214
|
+
}
|
|
215
|
+
if (!report.data.warnings.length) add("hold", "pass", "no compose warnings");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 6. Caption readability (the cue stats the captions step logs).
|
|
220
|
+
if (await exists(project.captionsCuesPath)) {
|
|
221
|
+
const cues = (await readJson(project.captionsCuesPath)) as Array<{
|
|
222
|
+
startMs: number;
|
|
223
|
+
endMs: number;
|
|
224
|
+
text: string;
|
|
225
|
+
}>;
|
|
226
|
+
const short = cues.filter((c) => c.endMs - c.startMs < 1000).length;
|
|
227
|
+
let midClause = 0;
|
|
228
|
+
cues.forEach((c, i) => {
|
|
229
|
+
const next = cues[i + 1];
|
|
230
|
+
if (!next) return;
|
|
231
|
+
if (!/[.!?,;:—–]["'”’)]?$/.test(c.text.trim()) && /^\p{Ll}/u.test(next.text.trim())) {
|
|
232
|
+
midClause++;
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
const pct = Math.round((midClause / Math.max(1, cues.length - 1)) * 100);
|
|
236
|
+
if (short > 2 || pct > 25) {
|
|
237
|
+
add("captions", "warn", `${cues.length} cues: ${short} under 1s, ${pct}% mid-clause breaks`);
|
|
238
|
+
} else {
|
|
239
|
+
add("captions", "pass", `${cues.length} cues, ${pct}% mid-clause breaks`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 7. Hygiene the eye catches and the schema can't: an address pill that
|
|
244
|
+
// names one host while the demo visits several, ASCII-only chapter
|
|
245
|
+
// titles in a non-English demo.
|
|
246
|
+
const frameUrl = storyboard.frame?.url;
|
|
247
|
+
if (frameUrl && frameUrl !== "auto") {
|
|
248
|
+
const hosts = new Set<string>();
|
|
249
|
+
for (const scene of storyboard.scenes) {
|
|
250
|
+
for (const a of scene.actions) {
|
|
251
|
+
if (a.op === "goto") {
|
|
252
|
+
const h = hostOf(a.url);
|
|
253
|
+
if (h) hosts.add(h);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const pill = hostOf(frameUrl);
|
|
258
|
+
if (hosts.size > 1 || (pill && hosts.size === 1 && !hosts.has(pill))) {
|
|
259
|
+
add(
|
|
260
|
+
"hygiene",
|
|
261
|
+
"warn",
|
|
262
|
+
`frame.url is the fixed "${frameUrl}" but the demo visits ${[...hosts].join(", ")} — ` +
|
|
263
|
+
`set frame.url: "auto" to track the page`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const lang = (storyboard.language ?? project.lang ?? "en").toLowerCase().split(/[-_]/)[0];
|
|
268
|
+
if (lang !== "en" && storyboard.output?.chapters) {
|
|
269
|
+
const ascii = storyboard.scenes.filter(
|
|
270
|
+
(s) => (s.title ?? s.id) && !/[^\x20-\x7e]/.test(s.title ?? s.id)
|
|
271
|
+
).length;
|
|
272
|
+
if (ascii === storyboard.scenes.length) {
|
|
273
|
+
add(
|
|
274
|
+
"hygiene",
|
|
275
|
+
"note",
|
|
276
|
+
`every chapter title is ASCII in a "${lang}" demo — they are probably scene ids, not titles`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const warnings = checks.filter((c) => c.status === "warn").length;
|
|
282
|
+
const notes = checks.filter((c) => c.status === "note").length;
|
|
283
|
+
return { video, durationMs, checks, warnings, notes };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Print a QA result the way the rest of the CLI prints stages. */
|
|
287
|
+
export function logQa(res: QaResult): void {
|
|
288
|
+
for (const c of res.checks) {
|
|
289
|
+
const glyph = c.status === "pass" ? "✓" : c.status === "warn" ? "W" : "·";
|
|
290
|
+
log(` ${glyph} ${c.id.padEnd(11)}${c.message}`);
|
|
291
|
+
}
|
|
292
|
+
const line = `${res.warnings} warning(s), ${res.notes} note(s)`;
|
|
293
|
+
if (res.warnings) log(`── ${line}`);
|
|
294
|
+
else ok(line);
|
|
295
|
+
}
|
package/src/recorder.ts
CHANGED
|
@@ -132,6 +132,8 @@ export interface RecordOptions {
|
|
|
132
132
|
* to rebuild app state; recording starts at this scene.
|
|
133
133
|
*/
|
|
134
134
|
fromScene?: string;
|
|
135
|
+
/** With `fromScene`: skip the reused scenes' actions instead of replaying them (state-earning flows). */
|
|
136
|
+
noReplay?: boolean;
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
/**
|
|
@@ -190,6 +192,23 @@ export async function record(
|
|
|
190
192
|
if (profileWarning && !seededOnPurpose) log(` ! ${profileWarning}`);
|
|
191
193
|
|
|
192
194
|
const { width, height } = storyboard.video;
|
|
195
|
+
// Raw-video density (issue #51). The page always lays out at width x height
|
|
196
|
+
// CSS px; `deviceScaleFactor` only decides how many real pixels each of them
|
|
197
|
+
// gets in the recording. Chrome itself has always rendered at 2x for
|
|
198
|
+
// crispness, so 1 (the default) keeps the old, unchanged output size.
|
|
199
|
+
const videoScale = storyboard.video.deviceScaleFactor ?? 1;
|
|
200
|
+
const dsf = Math.max(2, videoScale);
|
|
201
|
+
if (videoScale > 1) {
|
|
202
|
+
log(
|
|
203
|
+
`video: ${width}x${height} CSS px recorded at ${width * videoScale}x${height * videoScale} ` +
|
|
204
|
+
`(deviceScaleFactor ${videoScale})`
|
|
205
|
+
);
|
|
206
|
+
if (external) {
|
|
207
|
+
log(
|
|
208
|
+
` ! --capture ${mode} records real screen pixels — video.deviceScaleFactor is ignored`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
193
212
|
const videoDir = dirname(project.rawVideoPath);
|
|
194
213
|
log(`viewport: ${width}x${height}`);
|
|
195
214
|
if (external) log(`capture mode: ${mode}`);
|
|
@@ -238,7 +257,8 @@ export async function record(
|
|
|
238
257
|
);
|
|
239
258
|
} else {
|
|
240
259
|
throw new Error(
|
|
241
|
-
`--from-scene: scene "${sc.id}" changed since the previous take (actions, hide, redact, viewport, cursor mode, setup or params) — resume from "${sc.id}" or earlier`
|
|
260
|
+
`--from-scene: scene "${sc.id}" changed since the previous take (actions, hide, redact, viewport, cursor mode, setup or params) — resume from "${sc.id}" or earlier. ` +
|
|
261
|
+
`A rewritten \`goto\` URL counts: a single-use magic link or a rotated token in an earlier scene changes its hash even though nothing else moved.`
|
|
242
262
|
);
|
|
243
263
|
}
|
|
244
264
|
}
|
|
@@ -314,10 +334,13 @@ export async function record(
|
|
|
314
334
|
? { viewport: null }
|
|
315
335
|
: {
|
|
316
336
|
viewport: { width, height },
|
|
317
|
-
deviceScaleFactor:
|
|
337
|
+
deviceScaleFactor: dsf,
|
|
318
338
|
recordVideo: {
|
|
319
339
|
dir: dirname(project.rawVideoPath),
|
|
320
|
-
|
|
340
|
+
// `deviceScaleFactor: n` records n x the CSS viewport, so the
|
|
341
|
+
// product reads at output size (issue #51); the default 1x path
|
|
342
|
+
// still writes exactly width x height.
|
|
343
|
+
size: { width: width * videoScale, height: height * videoScale },
|
|
321
344
|
},
|
|
322
345
|
}),
|
|
323
346
|
});
|
|
@@ -389,6 +412,23 @@ export async function record(
|
|
|
389
412
|
}
|
|
390
413
|
|
|
391
414
|
const t0 = Date.now();
|
|
415
|
+
let salvageTailMs = 0;
|
|
416
|
+
let builtinTailMs = 0;
|
|
417
|
+
// Ctrl-C / SIGTERM on a stuck take used to lose everything — the process
|
|
418
|
+
// died before the timeline was written, so there was nothing to resume from
|
|
419
|
+
// (issue #55). Fold the signals into the abort signal the player polls, so
|
|
420
|
+
// an interrupted take takes the salvage path and writes what it has.
|
|
421
|
+
const interrupt = new AbortController();
|
|
422
|
+
const onSignal = (sig: NodeJS.Signals): void => {
|
|
423
|
+
if (interrupt.signal.aborted) return;
|
|
424
|
+
log(`\n${sig} — stopping the take and salvaging the scenes recorded so far`);
|
|
425
|
+
interrupt.abort();
|
|
426
|
+
};
|
|
427
|
+
process.on("SIGINT", onSignal);
|
|
428
|
+
process.on("SIGTERM", onSignal);
|
|
429
|
+
const signal = options.signal
|
|
430
|
+
? AbortSignal.any([options.signal, interrupt.signal])
|
|
431
|
+
: interrupt.signal;
|
|
392
432
|
const logsDir = project.p("logs");
|
|
393
433
|
const doneScenes: TimelineScene[] = [];
|
|
394
434
|
let timeline: Timeline;
|
|
@@ -401,11 +441,12 @@ export async function record(
|
|
|
401
441
|
t0,
|
|
402
442
|
video: { width, height },
|
|
403
443
|
logsDir,
|
|
404
|
-
signal
|
|
444
|
+
signal,
|
|
405
445
|
probe: options.probe,
|
|
406
446
|
captureCursorPath: composeCursor,
|
|
407
447
|
baseDir: project.dir,
|
|
408
448
|
replayUntil: fromIndex || undefined,
|
|
449
|
+
skipReplay: !!options.noReplay,
|
|
409
450
|
onSceneStart: options.onSceneStart,
|
|
410
451
|
onSceneReplayed: (id, i, total) => {
|
|
411
452
|
// Progress-wise a replayed scene is "done": its footage already exists.
|
|
@@ -443,6 +484,12 @@ export async function record(
|
|
|
443
484
|
recordErr = err;
|
|
444
485
|
const lastEnd = doneScenes.length ? doneScenes[doneScenes.length - 1].endMs : 0;
|
|
445
486
|
timeline = { totalMs: lastEnd, leadInMs: 0, scenes: [...reused, ...doneScenes] };
|
|
487
|
+
// The take kept recording through the FAILED scene (its goto, waits and
|
|
488
|
+
// the assert/timeout that broke it). That footage sits AFTER the last
|
|
489
|
+
// completed scene, so it must not be mistaken for front lead-in at
|
|
490
|
+
// finalize: otherwise every reused scene of a later `--from-scene` resume
|
|
491
|
+
// is read that many ms too late and plays ahead of its narration.
|
|
492
|
+
salvageTailMs = Math.max(0, Date.now() - t0 - lastEnd);
|
|
446
493
|
if (capture && !capFile) {
|
|
447
494
|
const stopAt = Date.now();
|
|
448
495
|
capFile = await capture.stop().catch(() => null);
|
|
@@ -450,7 +497,12 @@ export async function record(
|
|
|
450
497
|
}
|
|
451
498
|
} finally {
|
|
452
499
|
if (capture && !capFile) await capture.stop().catch(() => {});
|
|
453
|
-
// For built-in capture, closing the context finalizes the video file
|
|
500
|
+
// For built-in capture, closing the context finalizes the video file —
|
|
501
|
+
// and the recorder keeps taking frames until then. Everything after the
|
|
502
|
+
// last scene's end is TAIL, not front lead-in: attributing it to the
|
|
503
|
+
// lead-in shifts every scene's window later by that much, so a reused
|
|
504
|
+
// scene's window ran on into the next scene's navigation (issue #55).
|
|
505
|
+
builtinTailMs = Math.max(0, Date.now() - (t0 + timeline!.totalMs));
|
|
454
506
|
await context.close();
|
|
455
507
|
}
|
|
456
508
|
|
|
@@ -518,10 +570,14 @@ export async function record(
|
|
|
518
570
|
// The front lead-in (about:blank + launch) = actual video length minus the
|
|
519
571
|
// measured content span. This is more reliable than timing the launch.
|
|
520
572
|
const videoMs = await probeDurationMs(project.rawVideoPath);
|
|
521
|
-
|
|
573
|
+
// salvageTailMs (the failed scene's footage) is part of builtinTailMs —
|
|
574
|
+
// both are measured from the last COMPLETED scene's end — so take the
|
|
575
|
+
// larger of the two rather than subtracting twice.
|
|
576
|
+
const tail = Math.max(builtinTailMs, salvageTailMs);
|
|
577
|
+
timeline.leadInMs = Math.max(0, videoMs - timeline.totalMs - tail);
|
|
522
578
|
await writeJson(project.timelinePath, timeline);
|
|
523
579
|
log(
|
|
524
|
-
`video ${videoMs}ms, content ${timeline.totalMs}ms → lead-in ${timeline.leadInMs}ms`
|
|
580
|
+
`video ${videoMs}ms, content ${timeline.totalMs}ms, tail ${tail}ms${salvageTailMs ? ` (failed-scene footage ${salvageTailMs}ms)` : ""} → lead-in ${timeline.leadInMs}ms`
|
|
525
581
|
);
|
|
526
582
|
ok(`recording → ${project.rawVideoPath}`);
|
|
527
583
|
finalized = true;
|
|
@@ -546,6 +602,8 @@ export async function record(
|
|
|
546
602
|
if (KEEP_RE.test(f) && !referenced.has(p)) await fs.rm(p, { force: true });
|
|
547
603
|
}
|
|
548
604
|
}
|
|
605
|
+
process.off("SIGINT", onSignal);
|
|
606
|
+
process.off("SIGTERM", onSignal);
|
|
549
607
|
if (recordErr) {
|
|
550
608
|
log(
|
|
551
609
|
`salvaged ${doneScenes.length}/${storyboard.scenes.length} scene(s) before failure; ` +
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storyboard-supplied regex patterns (`assert.textMatches`, `assert.url`, and
|
|
3
|
+
* the `textMatches` option the player threads through) are compiled at replay
|
|
4
|
+
* time. A storyboard is data — it can arrive from an import, a template, or a
|
|
5
|
+
* generated variant — so a pattern must not be able to hang the take. Compile
|
|
6
|
+
* every one of them through here: the length cap and the nested-quantifier
|
|
7
|
+
* check turn a catastrophic-backtracking pattern into an authoring error
|
|
8
|
+
* raised before the browser ever touches it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Longest pattern we accept; real storyboard matchers are a few dozen chars. */
|
|
12
|
+
const MAX_PATTERN_LEN = 1000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A quantified group whose body is itself quantified — `(a+)+`, `(a*)*`,
|
|
16
|
+
* `(\w+\s?)+` — is the shape that backtracks exponentially.
|
|
17
|
+
*/
|
|
18
|
+
const NESTED_QUANTIFIER = /\((?:\?[:=!<][a-zA-Z]*)?[^()]*[+*}][^()]*\)\s*[+*]|\((?:\?[:=!<][a-zA-Z]*)?[^()]*[+*][^()]*\)\s*\{\d+,\d*\}/;
|
|
19
|
+
|
|
20
|
+
export function isUnsafeRegexSource(pattern: string): string | null {
|
|
21
|
+
if (pattern.length > MAX_PATTERN_LEN)
|
|
22
|
+
return `pattern is ${pattern.length} characters (limit ${MAX_PATTERN_LEN})`;
|
|
23
|
+
if (NESTED_QUANTIFIER.test(pattern))
|
|
24
|
+
return "pattern nests a quantifier inside a quantified group, which can backtrack exponentially";
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compile a storyboard-supplied pattern, or throw with `where` naming the
|
|
30
|
+
* field so the author can see which matcher to fix.
|
|
31
|
+
*/
|
|
32
|
+
export function compileUserRegex(pattern: string, flags: string, where: string): RegExp {
|
|
33
|
+
const unsafe = isUnsafeRegexSource(pattern);
|
|
34
|
+
if (unsafe) throw new Error(`${where}: unsafe regex — ${unsafe}`);
|
|
35
|
+
return new RegExp(pattern, flags);
|
|
36
|
+
}
|