demoflow 0.0.0-stage → 0.1.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.
@@ -0,0 +1,4442 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/main.ts
4
+ import { realpathSync as realpathSync2 } from "node:fs";
5
+ import { pathToFileURL } from "node:url";
6
+ import { Command, CommanderError, Option } from "commander";
7
+
8
+ // src/config/errors.ts
9
+ var EXIT_INVALID_INPUT = 2;
10
+ var CliError = class extends Error {
11
+ code;
12
+ exitCode;
13
+ issues;
14
+ constructor(code, message, issues = [], exitCode = EXIT_INVALID_INPUT) {
15
+ super(message);
16
+ this.name = "CliError";
17
+ this.code = code;
18
+ this.exitCode = exitCode;
19
+ this.issues = issues;
20
+ }
21
+ };
22
+
23
+ // src/config/home.ts
24
+ import { readFileSync } from "node:fs";
25
+
26
+ // src/config/platform.ts
27
+ import { homedir } from "node:os";
28
+ import path from "node:path";
29
+ function pathFor(platform) {
30
+ return platform === "win32" ? path.win32 : path.posix;
31
+ }
32
+ function absoluteEnv(host, name) {
33
+ const value = host.env[name];
34
+ if (value === void 0 || value === "") return void 0;
35
+ return pathFor(host.platform).isAbsolute(value) ? value : void 0;
36
+ }
37
+ function currentHost() {
38
+ return { platform: process.platform, env: process.env, homedir: homedir() };
39
+ }
40
+
41
+ // src/config/home.ts
42
+ function runDir(home, runId, platform) {
43
+ return pathFor(platform).join(home, "runs", runId);
44
+ }
45
+ function globalConfigPath(host) {
46
+ const p = pathFor(host.platform);
47
+ if (host.platform === "darwin") {
48
+ return p.join(
49
+ host.homedir,
50
+ "Library",
51
+ "Application Support",
52
+ "demoflow",
53
+ "config.json"
54
+ );
55
+ }
56
+ if (host.platform === "win32") {
57
+ const appData = absoluteEnv(host, "APPDATA") ?? p.join(host.homedir, "AppData", "Roaming");
58
+ return p.join(appData, "demoflow", "config.json");
59
+ }
60
+ const configHome = absoluteEnv(host, "XDG_CONFIG_HOME") ?? p.join(host.homedir, ".config");
61
+ return p.join(configHome, "demoflow", "config.json");
62
+ }
63
+ function osDataDir(host) {
64
+ const p = pathFor(host.platform);
65
+ if (host.platform === "darwin") {
66
+ return p.join(host.homedir, "Library", "Application Support", "demoflow");
67
+ }
68
+ if (host.platform === "win32") {
69
+ const local = absoluteEnv(host, "LOCALAPPDATA") ?? p.join(host.homedir, "AppData", "Local");
70
+ return p.join(local, "demoflow");
71
+ }
72
+ const dataHome = absoluteEnv(host, "XDG_DATA_HOME") ?? p.join(host.homedir, ".local", "share");
73
+ return p.join(dataHome, "demoflow");
74
+ }
75
+ function loadGlobalConfig(host, readFile4 = (file) => readFileSync(file, "utf8")) {
76
+ const file = globalConfigPath(host);
77
+ let text;
78
+ try {
79
+ text = readFile4(file);
80
+ } catch (error) {
81
+ if (error.code === "ENOENT") return {};
82
+ throw invalid(file, error instanceof Error ? error.message : String(error));
83
+ }
84
+ let raw;
85
+ try {
86
+ raw = JSON.parse(text);
87
+ } catch (error) {
88
+ throw invalid(file, error instanceof Error ? error.message : String(error));
89
+ }
90
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
91
+ throw invalid(file, "expected a JSON object");
92
+ }
93
+ const { artifactsDir } = raw;
94
+ if (artifactsDir === void 0) return {};
95
+ if (typeof artifactsDir !== "string" || artifactsDir === "") {
96
+ throw invalid(file, '"artifactsDir" must be a non-empty string');
97
+ }
98
+ const p = pathFor(host.platform);
99
+ return { artifactsDir: p.resolve(p.dirname(file), artifactsDir) };
100
+ }
101
+ function invalid(file, reason) {
102
+ return new CliError(
103
+ "invalid-global-config",
104
+ `global config ${file} is invalid: ${reason}`
105
+ );
106
+ }
107
+ function resolveHome(input) {
108
+ const p = pathFor(input.platform);
109
+ if (input.out !== void 0 && input.out !== "") {
110
+ return { home: p.resolve(input.cwd, input.out), source: "out" };
111
+ }
112
+ const fromEnv = input.env.DEMOFLOW_HOME;
113
+ if (fromEnv !== void 0 && fromEnv !== "") {
114
+ return { home: p.resolve(input.cwd, fromEnv), source: "env" };
115
+ }
116
+ if (input.projectConfig.artifactsDir !== void 0) {
117
+ return {
118
+ home: p.resolve(input.projectRoot, input.projectConfig.artifactsDir),
119
+ source: "project-config"
120
+ };
121
+ }
122
+ if (input.globalConfig.artifactsDir !== void 0) {
123
+ return { home: input.globalConfig.artifactsDir, source: "global-config" };
124
+ }
125
+ return { home: osDataDir(input), source: "os-data-dir" };
126
+ }
127
+
128
+ // src/config/project.ts
129
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
130
+ import path2 from "node:path";
131
+
132
+ // ../../packages/core/dist/common.js
133
+ import { z } from "zod";
134
+ var NonEmptyStringSchema = z.string().min(1);
135
+ var HttpUrlSchema = z.url({
136
+ protocol: /^https?$/,
137
+ error: "expected an absolute http or https URL"
138
+ }).meta({ pattern: "^[hH][tT][tT][pP][sS]?://" });
139
+ var ViewportSchema = z.strictObject({
140
+ width: z.number().int().positive(),
141
+ height: z.number().int().positive()
142
+ });
143
+ var JsonSchemaRefSchema = z.string().optional();
144
+ function exactlyOne(keys) {
145
+ return (value, ctx) => {
146
+ const present = keys.filter((key) => value[key] !== void 0);
147
+ if (present.length !== 1) {
148
+ ctx.addIssue({
149
+ code: "custom",
150
+ message: `expected exactly one of ${keys.join("|")}`
151
+ });
152
+ }
153
+ };
154
+ }
155
+ function exactlyOneJsonSchema(keys) {
156
+ return { oneOf: keys.map((key) => ({ required: [key] })) };
157
+ }
158
+
159
+ // ../../packages/core/dist/json-schema.js
160
+ import { z as z6 } from "zod";
161
+
162
+ // ../../packages/core/dist/project-config.js
163
+ import { z as z2 } from "zod";
164
+ var OriginSchema = HttpUrlSchema.refine((value) => new URL(value).origin === value, "expected an origin such as https://example.com, without a path");
165
+ var RenderConfigSchema = z2.strictObject({
166
+ width: z2.number().int().positive().default(1920),
167
+ height: z2.number().int().positive().default(1080),
168
+ fps: z2.number().int().positive().default(30),
169
+ idle: z2.strictObject({
170
+ thresholdMs: z2.number().int().nonnegative().default(1e3),
171
+ compressToMs: z2.number().int().nonnegative().default(400)
172
+ }).prefault({})
173
+ }).prefault({});
174
+ var ProjectConfigSchema = z2.strictObject({
175
+ $schema: JsonSchemaRefSchema,
176
+ baseUrl: HttpUrlSchema,
177
+ // Defaults to the origin of `baseUrl`.
178
+ allowedOrigins: z2.array(OriginSchema).optional(),
179
+ devCommand: z2.strictObject({
180
+ command: NonEmptyStringSchema,
181
+ args: z2.array(z2.string()).optional(),
182
+ readyUrl: HttpUrlSchema,
183
+ timeoutMs: z2.number().int().positive().optional()
184
+ }).optional(),
185
+ artifactsDir: NonEmptyStringSchema.optional(),
186
+ render: RenderConfigSchema
187
+ }).transform(({ allowedOrigins, ...config }) => ({
188
+ ...config,
189
+ allowedOrigins: allowedOrigins ?? [new URL(config.baseUrl).origin]
190
+ })).meta({
191
+ title: "DemoFlow project config",
192
+ description: "demoflow.config.json at the project root."
193
+ });
194
+
195
+ // ../../packages/core/dist/scenario.js
196
+ import { z as z5 } from "zod";
197
+
198
+ // ../../packages/core/dist/step.js
199
+ import { z as z4 } from "zod";
200
+
201
+ // ../../packages/core/dist/locator.js
202
+ import { z as z3 } from "zod";
203
+ var LOCATOR_KEYS = [
204
+ "role",
205
+ "label",
206
+ "testId",
207
+ "text",
208
+ "placeholder",
209
+ "css"
210
+ ];
211
+ var exact = z3.boolean().optional();
212
+ var LocatorSchema = z3.union([
213
+ // getByRole — `role` is an ARIA role.
214
+ z3.strictObject({
215
+ role: NonEmptyStringSchema,
216
+ name: z3.string().optional(),
217
+ exact
218
+ }),
219
+ z3.strictObject({ label: NonEmptyStringSchema, exact }),
220
+ z3.strictObject({ testId: NonEmptyStringSchema }),
221
+ z3.strictObject({ text: NonEmptyStringSchema, exact }),
222
+ z3.strictObject({ placeholder: NonEmptyStringSchema, exact }),
223
+ z3.strictObject({ css: NonEmptyStringSchema })
224
+ ], { error: `expected exactly one of ${LOCATOR_KEYS.join("|")}` });
225
+ function isCssLocator(locator) {
226
+ return "css" in locator;
227
+ }
228
+
229
+ // ../../packages/core/dist/step.js
230
+ var STEP_ACTIONS = [
231
+ "navigate",
232
+ "waitFor",
233
+ "click",
234
+ "fill",
235
+ "select",
236
+ "hover",
237
+ "scroll",
238
+ "assertVisible",
239
+ "assertText",
240
+ "screenshot",
241
+ "marker",
242
+ "wait"
243
+ ];
244
+ var StepActionSchema = z4.enum(STEP_ACTIONS);
245
+ var MarkerKindSchema = z4.enum(["scene", "caption"]);
246
+ var common = {
247
+ caption: z4.string().max(120).optional(),
248
+ timeoutMs: z4.number().int().min(100).max(6e4).default(1e4)
249
+ };
250
+ function step(action, shape2) {
251
+ return z4.strictObject({ action: z4.literal(action), ...shape2, ...common });
252
+ }
253
+ var navigateKeys = ["path", "url"];
254
+ var fillKeys = ["value", "valueFrom"];
255
+ var scrollKeys = ["to", "target"];
256
+ var StepSchema = z4.discriminatedUnion("action", [
257
+ step("navigate", {
258
+ // `//host` and `/\host` are protocol-relative: they leave the origin.
259
+ path: z4.string().regex(/^\/(?![/\\])/, "expected a path starting with a single /").optional(),
260
+ url: HttpUrlSchema.optional()
261
+ }).superRefine(exactlyOne(navigateKeys)).meta(exactlyOneJsonSchema(navigateKeys)),
262
+ step("waitFor", {
263
+ target: LocatorSchema,
264
+ state: z4.enum(["visible", "hidden"]).default("visible")
265
+ }),
266
+ step("click", { target: LocatorSchema }),
267
+ step("fill", {
268
+ target: LocatorSchema,
269
+ value: z4.string().optional(),
270
+ // Secrets never appear in a scenario (ADR-0003 §5).
271
+ valueFrom: z4.strictObject({
272
+ env: z4.string().regex(/^[A-Z_][A-Z0-9_]*$/, "expected an environment variable name (A-Z, 0-9, _)")
273
+ }).optional()
274
+ }).superRefine(exactlyOne(fillKeys)).meta(exactlyOneJsonSchema(fillKeys)),
275
+ step("select", { target: LocatorSchema, option: z4.string() }),
276
+ step("hover", { target: LocatorSchema }),
277
+ step("scroll", {
278
+ to: z4.enum(["top", "bottom"]).optional(),
279
+ target: LocatorSchema.optional(),
280
+ // Scroll inside this container instead of the page.
281
+ within: LocatorSchema.optional()
282
+ }).superRefine(exactlyOne(scrollKeys)).meta(exactlyOneJsonSchema(scrollKeys)),
283
+ step("assertVisible", { target: LocatorSchema }),
284
+ step("assertText", {
285
+ target: LocatorSchema,
286
+ text: NonEmptyStringSchema,
287
+ match: z4.enum(["exact", "contains"]).default("contains")
288
+ }),
289
+ step("screenshot", {
290
+ name: z4.string().regex(/^[a-z0-9-]+$/, "expected lowercase letters, digits and -")
291
+ }),
292
+ step("marker", {
293
+ title: NonEmptyStringSchema,
294
+ kind: MarkerKindSchema.default("scene")
295
+ }),
296
+ step("wait", { ms: z4.number().int().min(1).max(5e3) })
297
+ ], { error: `expected action to be one of ${STEP_ACTIONS.join("|")}` });
298
+
299
+ // ../../packages/core/dist/scenario.js
300
+ var ScenarioSchema = z5.strictObject({
301
+ $schema: JsonSchemaRefSchema,
302
+ schemaVersion: z5.literal(1),
303
+ name: NonEmptyStringSchema,
304
+ description: z5.string().optional(),
305
+ browser: z5.literal("chromium").default("chromium"),
306
+ baseUrl: HttpUrlSchema.optional(),
307
+ viewport: ViewportSchema.default({ width: 1920, height: 1080 }),
308
+ steps: z5.array(StepSchema).min(1, "expected at least one step").max(200, "expected at most 200 steps")
309
+ }).meta({
310
+ title: "DemoFlow scenario",
311
+ description: "A scenario DemoFlow runs, records and renders."
312
+ });
313
+
314
+ // ../../packages/core/dist/parse.js
315
+ import { z as z7 } from "zod";
316
+ function formatPath(path16) {
317
+ let out = "";
318
+ for (const segment of path16) {
319
+ if (typeof segment === "number") {
320
+ out += `[${segment}]`;
321
+ } else {
322
+ const key = String(segment);
323
+ if (/^[A-Za-z_$][\w$]*$/.test(key)) {
324
+ out += out === "" ? key : `.${key}`;
325
+ } else {
326
+ out += `[${JSON.stringify(key)}]`;
327
+ }
328
+ }
329
+ }
330
+ return out;
331
+ }
332
+ function toIssues(issues, prefix = []) {
333
+ return issues.flatMap((issue) => {
334
+ const path16 = [...prefix, ...issue.path];
335
+ if (issue.code === "unrecognized_keys") {
336
+ return issue.keys.map((key) => ({
337
+ path: formatPath([...path16, key]),
338
+ code: issue.code,
339
+ message: "unknown field"
340
+ }));
341
+ }
342
+ if (issue.code === "invalid_union") {
343
+ const candidates = issue.errors.filter((branch) => branch.every((inner) => inner.path.length > 0));
344
+ if (candidates.length === 1)
345
+ return toIssues(candidates[0], path16);
346
+ }
347
+ return [
348
+ { path: formatPath(path16), code: issue.code, message: issue.message }
349
+ ];
350
+ });
351
+ }
352
+ function parseWith(schema, input) {
353
+ const result = schema.safeParse(input);
354
+ return result.success ? { ok: true, value: result.data } : { ok: false, errors: toIssues(result.error.issues) };
355
+ }
356
+ function parseScenario(input) {
357
+ const result = parseWith(ScenarioSchema, input);
358
+ if (!result.ok)
359
+ return result;
360
+ const warnings = [];
361
+ result.value.steps.forEach((step2, index) => {
362
+ const locators = [
363
+ ["target", "target" in step2 ? step2.target : void 0],
364
+ ["within", "within" in step2 ? step2.within : void 0]
365
+ ];
366
+ for (const [key, locator] of locators) {
367
+ if (locator !== void 0 && isCssLocator(locator)) {
368
+ warnings.push({
369
+ path: formatPath(["steps", index, key]),
370
+ code: "css-locator",
371
+ message: "a CSS selector breaks when the markup changes; prefer role, label, testId, text or placeholder"
372
+ });
373
+ }
374
+ }
375
+ });
376
+ return { ok: true, value: result.value, warnings };
377
+ }
378
+ function parseProjectConfig(input) {
379
+ const result = parseWith(ProjectConfigSchema, input);
380
+ return result.ok ? { ...result, warnings: [] } : result;
381
+ }
382
+
383
+ // ../../packages/core/dist/run-result.js
384
+ import { z as z8 } from "zod";
385
+ var StageStatusSchema = z8.enum([
386
+ "pending",
387
+ "running",
388
+ "passed",
389
+ "failed",
390
+ "skipped"
391
+ ]);
392
+ var STAGE_NAMES = [
393
+ "readiness",
394
+ "execution",
395
+ "verification",
396
+ "recording",
397
+ "rendering",
398
+ "storage"
399
+ ];
400
+ var RunStatusSchema = z8.enum([
401
+ "completed",
402
+ "completed_with_warnings",
403
+ "failed",
404
+ "running"
405
+ ]);
406
+ var TimestampSchema = z8.iso.datetime({ offset: true });
407
+ var StageSchema = z8.strictObject({
408
+ status: StageStatusSchema,
409
+ startedAt: TimestampSchema.optional(),
410
+ finishedAt: TimestampSchema.optional(),
411
+ error: z8.strictObject({
412
+ message: z8.string(),
413
+ stepIndex: z8.number().int().nonnegative().optional()
414
+ }).optional()
415
+ });
416
+ var StagesSchema = z8.strictObject({
417
+ readiness: StageSchema,
418
+ execution: StageSchema,
419
+ verification: StageSchema,
420
+ recording: StageSchema,
421
+ rendering: StageSchema,
422
+ storage: StageSchema
423
+ });
424
+ function deriveRunStatus(stages, warnings = []) {
425
+ const statuses = STAGE_NAMES.map((name) => stages[name].status);
426
+ if (statuses.includes("failed"))
427
+ return "failed";
428
+ if (statuses.some((status) => status === "running" || status === "pending")) {
429
+ return "running";
430
+ }
431
+ return warnings.length > 0 ? "completed_with_warnings" : "completed";
432
+ }
433
+ var RenderOutputSchema = z8.strictObject({
434
+ renderer: NonEmptyStringSchema,
435
+ // Relative to the run directory.
436
+ file: NonEmptyStringSchema,
437
+ durationMs: z8.number().nonnegative(),
438
+ sizeBytes: z8.number().int().nonnegative(),
439
+ width: z8.number().int().positive(),
440
+ height: z8.number().int().positive(),
441
+ fps: z8.number().positive()
442
+ });
443
+ var RunResultSchema = z8.strictObject({
444
+ schemaVersion: z8.literal(1),
445
+ runId: NonEmptyStringSchema,
446
+ // The scenario's path relative to the project root (ADR-0003 §2).
447
+ scenarioId: NonEmptyStringSchema,
448
+ scenarioName: NonEmptyStringSchema,
449
+ startedAt: TimestampSchema,
450
+ finishedAt: TimestampSchema,
451
+ status: RunStatusSchema,
452
+ stages: StagesSchema,
453
+ renders: z8.array(RenderOutputSchema),
454
+ warnings: z8.array(z8.string())
455
+ }).refine((result) => result.status === deriveRunStatus(result.stages, result.warnings), {
456
+ message: "expected status to match the status derived from the stages",
457
+ path: ["status"]
458
+ });
459
+
460
+ // ../../packages/core/dist/timeline.js
461
+ import { z as z9 } from "zod";
462
+ var MASKED_VALUE = "***";
463
+ var PointSchema = z9.strictObject({ x: z9.number(), y: z9.number() });
464
+ var BoxSchema = z9.strictObject({
465
+ x: z9.number(),
466
+ y: z9.number(),
467
+ width: z9.number().nonnegative(),
468
+ height: z9.number().nonnegative()
469
+ });
470
+ var msSchema = z9.number().nonnegative();
471
+ var MaskIntervalSchema = z9.strictObject({
472
+ startMs: msSchema,
473
+ // Absent: still on the page when the run ended.
474
+ endMs: msSchema.optional(),
475
+ box: BoxSchema
476
+ }).refine((interval) => (interval.endMs ?? Infinity) >= interval.startMs, {
477
+ message: "expected endMs to be at or after startMs",
478
+ path: ["endMs"]
479
+ });
480
+ var MaskVisibilitySchema = z9.array(MaskIntervalSchema).superRefine((intervals, ctx) => {
481
+ for (const [index, interval] of intervals.entries()) {
482
+ const previous = intervals[index - 1];
483
+ if (previous === void 0)
484
+ continue;
485
+ if (previous.endMs === void 0) {
486
+ ctx.addIssue({
487
+ code: "custom",
488
+ message: "expected only the last interval to be open",
489
+ path: [index - 1, "endMs"]
490
+ });
491
+ } else if (interval.startMs < previous.startMs) {
492
+ ctx.addIssue({
493
+ code: "custom",
494
+ message: "expected intervals ordered by startMs",
495
+ path: [index, "startMs"]
496
+ });
497
+ }
498
+ }
499
+ });
500
+ var TimelineEventSchema = z9.strictObject({
501
+ stepIndex: z9.number().int().nonnegative(),
502
+ action: StepActionSchema,
503
+ startMs: msSchema,
504
+ endMs: msSchema,
505
+ target: z9.strictObject({ box: BoxSchema }).optional(),
506
+ // The real pointer path (ADR-0004 §2).
507
+ pointer: z9.strictObject({ from: PointSchema, to: PointSchema }).optional(),
508
+ scroll: z9.strictObject({
509
+ before: PointSchema,
510
+ after: PointSchema,
511
+ container: BoxSchema.optional()
512
+ }).optional(),
513
+ // A `fill` from `valueFrom.env`.
514
+ masked: z9.boolean().optional(),
515
+ // `***` when masked; omitted for anything but `fill`.
516
+ value: z9.string().optional(),
517
+ assertion: z9.strictObject({ passed: z9.boolean(), message: z9.string().optional() }).optional(),
518
+ // Relative to the run directory.
519
+ screenshot: NonEmptyStringSchema.optional(),
520
+ marker: z9.strictObject({ title: NonEmptyStringSchema, kind: MarkerKindSchema }).optional(),
521
+ caption: z9.string().optional(),
522
+ error: z9.strictObject({ message: z9.string() }).optional(),
523
+ // Only on a masked `fill`: when its field was on the page (ADR-0004; #18).
524
+ maskVisibility: MaskVisibilitySchema.optional()
525
+ }).refine((event) => event.endMs >= event.startMs, {
526
+ message: "expected endMs to be at or after startMs",
527
+ path: ["endMs"]
528
+ }).refine((event) => event.masked !== true || event.value === void 0 || event.value === MASKED_VALUE, {
529
+ message: `expected a masked value to be written as ${MASKED_VALUE}`,
530
+ path: ["value"]
531
+ }).refine((event) => event.maskVisibility === void 0 || event.masked === true, {
532
+ message: "expected maskVisibility only on a masked event",
533
+ path: ["maskVisibility"]
534
+ });
535
+ var TimelineSchema = z9.strictObject({
536
+ schemaVersion: z9.literal(1),
537
+ viewport: ViewportSchema,
538
+ deviceScaleFactor: z9.number().positive(),
539
+ videoFile: NonEmptyStringSchema,
540
+ events: z9.array(TimelineEventSchema),
541
+ // Painted at clock zero, cleared before step 0; the renderer measures the
542
+ // video/clock offset from it (#19).
543
+ syncMark: z9.strictObject({
544
+ colour: z9.string().regex(/^#[0-9a-f]{6}$/),
545
+ onMs: msSchema,
546
+ offMs: msSchema
547
+ }).refine((mark) => mark.offMs > mark.onMs, {
548
+ message: "expected offMs to be after onMs",
549
+ path: ["offMs"]
550
+ }).optional()
551
+ }).refine((timeline) => {
552
+ const first = timeline.events[0];
553
+ return timeline.syncMark === void 0 || first === void 0 || timeline.syncMark.offMs <= first.startMs;
554
+ }, {
555
+ message: "expected the sync mark to be cleared before the first event",
556
+ path: ["syncMark", "offMs"]
557
+ });
558
+
559
+ // src/config/project.ts
560
+ var PROJECT_CONFIG_FILE = "demoflow.config.json";
561
+ function findProjectRoot(scenarioPath, fileExists = existsSync, pathApi = path2) {
562
+ let dir = pathApi.dirname(pathApi.resolve(scenarioPath));
563
+ for (; ; ) {
564
+ if (fileExists(pathApi.join(dir, PROJECT_CONFIG_FILE))) return dir;
565
+ const parent = pathApi.dirname(dir);
566
+ if (parent === dir) {
567
+ throw new CliError(
568
+ "no-project-config",
569
+ `no ${PROJECT_CONFIG_FILE} found in ${scenarioPath} or any parent directory`
570
+ );
571
+ }
572
+ dir = parent;
573
+ }
574
+ }
575
+ function projectRootAt(dir, fileExists = existsSync, pathApi = path2) {
576
+ if (!fileExists(pathApi.join(dir, PROJECT_CONFIG_FILE))) {
577
+ throw new CliError(
578
+ "no-project-config",
579
+ `no ${PROJECT_CONFIG_FILE} found in ${dir}`
580
+ );
581
+ }
582
+ return dir;
583
+ }
584
+ function scenarioId(root, scenarioPath, pathApi = path2) {
585
+ const file = pathApi.resolve(root, scenarioPath);
586
+ const relative = pathApi.relative(root, file);
587
+ if (relative === ".." || relative.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(relative)) {
588
+ return `inline/${pathApi.basename(file)}`;
589
+ }
590
+ return relative.split(pathApi.sep).join("/");
591
+ }
592
+ function loadProjectConfig(root) {
593
+ const file = path2.join(root, PROJECT_CONFIG_FILE);
594
+ let raw;
595
+ try {
596
+ raw = JSON.parse(readFileSync2(file, "utf8"));
597
+ } catch (error) {
598
+ const reason = error instanceof Error ? error.message : String(error);
599
+ throw new CliError(
600
+ "invalid-config",
601
+ `cannot read ${PROJECT_CONFIG_FILE}: ${reason}`
602
+ );
603
+ }
604
+ const parsed = parseProjectConfig(raw);
605
+ if (!parsed.ok) {
606
+ throw new CliError(
607
+ "invalid-config",
608
+ `${PROJECT_CONFIG_FILE} is invalid`,
609
+ parsed.errors
610
+ );
611
+ }
612
+ return parsed.value;
613
+ }
614
+
615
+ // src/commands/errors.ts
616
+ var CommandError = class extends Error {
617
+ code;
618
+ exitCode = EXIT_INVALID_INPUT;
619
+ issues;
620
+ constructor(code, message, issues = []) {
621
+ super(message);
622
+ this.name = "CommandError";
623
+ this.code = code;
624
+ this.issues = issues;
625
+ }
626
+ };
627
+ var EXIT_STAGE_FAILED = 3;
628
+ function isInputError(error) {
629
+ return error instanceof CliError || error instanceof CommandError;
630
+ }
631
+ function describeError(error) {
632
+ const lines = [`${error.code}: ${error.message}`];
633
+ for (const issue of error.issues) {
634
+ lines.push(
635
+ ` ${issue.path === "" ? "(root)" : issue.path}: ${issue.message}`
636
+ );
637
+ }
638
+ return lines.join("\n");
639
+ }
640
+
641
+ // src/commands/io.ts
642
+ var processIo = {
643
+ stdout: (text) => {
644
+ process.stdout.write(text);
645
+ },
646
+ stderr: (text) => {
647
+ process.stderr.write(text);
648
+ }
649
+ };
650
+
651
+ // src/commands/run.ts
652
+ import { existsSync as existsSync4 } from "node:fs";
653
+ import path10 from "node:path";
654
+
655
+ // ../../packages/engine/dist/capture/observer.js
656
+ import { mkdir, rename, writeFile } from "node:fs/promises";
657
+ import path3 from "node:path";
658
+ var RUN_FILES = {
659
+ video: "source.webm",
660
+ timeline: "timeline.json",
661
+ scenario: "scenario.json",
662
+ result: "result.json",
663
+ screenshotsDir: "screenshots"
664
+ };
665
+ function createCaptureObserver(options) {
666
+ let runDir2 = "";
667
+ let scenario;
668
+ let recording;
669
+ return {
670
+ async onRunStarted(event) {
671
+ runDir2 = event.runDir;
672
+ scenario = event.scenario;
673
+ recording = event.recording;
674
+ await mkdir(runDir2, { recursive: true });
675
+ await writeJson(path3.join(runDir2, RUN_FILES.scenario), event.scenario);
676
+ },
677
+ async onScreenshot({ name, png }) {
678
+ const dir = path3.join(runDir2, RUN_FILES.screenshotsDir);
679
+ await mkdir(dir, { recursive: true });
680
+ await writeFile(path3.join(dir, `${name}.png`), png);
681
+ return `${RUN_FILES.screenshotsDir}/${name}.png`;
682
+ },
683
+ async onRunFinished({ outcome }) {
684
+ if (scenario === void 0 || recording === void 0)
685
+ return;
686
+ if (outcome.videoPath !== void 0) {
687
+ await rename(outcome.videoPath, path3.join(runDir2, RUN_FILES.video));
688
+ const timeline = TimelineSchema.parse({
689
+ schemaVersion: 1,
690
+ viewport: recording.viewport,
691
+ deviceScaleFactor: recording.deviceScaleFactor,
692
+ videoFile: RUN_FILES.video,
693
+ events: outcome.events,
694
+ ...outcome.syncMark === void 0 ? {} : { syncMark: outcome.syncMark }
695
+ });
696
+ await writeJson(path3.join(runDir2, RUN_FILES.timeline), timeline);
697
+ }
698
+ await writeJson(path3.join(runDir2, RUN_FILES.result), runResult(outcome, scenario, {
699
+ runId: options.runId ?? path3.basename(runDir2),
700
+ scenarioId: options.scenarioId
701
+ }));
702
+ }
703
+ };
704
+ }
705
+ function runResult(outcome, scenario, ids) {
706
+ return RunResultSchema.parse({
707
+ schemaVersion: 1,
708
+ runId: ids.runId,
709
+ scenarioId: ids.scenarioId,
710
+ scenarioName: scenario.name,
711
+ startedAt: outcome.startedAt,
712
+ finishedAt: outcome.finishedAt,
713
+ status: outcome.status,
714
+ stages: outcome.stages,
715
+ renders: [],
716
+ warnings: outcome.warnings
717
+ });
718
+ }
719
+ async function writeJson(file, value) {
720
+ await writeFile(file, `${JSON.stringify(value, null, 2)}
721
+ `);
722
+ }
723
+
724
+ // ../../packages/engine/dist/execute/actions.js
725
+ import { setTimeout as sleep } from "node:timers/promises";
726
+
727
+ // ../../packages/engine/dist/execute/locator.js
728
+ function resolveLocator(page, locator) {
729
+ if ("role" in locator) {
730
+ return page.getByRole(locator.role, {
731
+ name: locator.name,
732
+ exact: locator.exact
733
+ });
734
+ }
735
+ if ("label" in locator) {
736
+ return page.getByLabel(locator.label, { exact: locator.exact });
737
+ }
738
+ if ("testId" in locator)
739
+ return page.getByTestId(locator.testId);
740
+ if ("text" in locator) {
741
+ return page.getByText(locator.text, { exact: locator.exact });
742
+ }
743
+ if ("placeholder" in locator) {
744
+ return page.getByPlaceholder(locator.placeholder, {
745
+ exact: locator.exact
746
+ });
747
+ }
748
+ return page.locator(locator.css);
749
+ }
750
+ function describeLocator(locator) {
751
+ return JSON.stringify(locator);
752
+ }
753
+
754
+ // ../../packages/engine/dist/execute/pointer.js
755
+ function moveSteps(from, to) {
756
+ const distance2 = Math.hypot(to.x - from.x, to.y - from.y);
757
+ return Math.min(40, Math.max(8, Math.round(distance2 / 12)));
758
+ }
759
+ function viewportCentre(viewport) {
760
+ return { x: viewport.width / 2, y: viewport.height / 2 };
761
+ }
762
+ function boxCentre(box) {
763
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
764
+ }
765
+
766
+ // ../../packages/engine/dist/execute/readiness.js
767
+ var OriginGuard = class {
768
+ #allowed;
769
+ constructor(allowedOrigins) {
770
+ this.#allowed = new Set(allowedOrigins.map((value) => originOf(value)));
771
+ }
772
+ /** Returns the offending origin, or `undefined` when the URL may be opened. */
773
+ check(url) {
774
+ if (url.startsWith("about:") || url.startsWith("chrome-error:")) {
775
+ return void 0;
776
+ }
777
+ const origin = originOf(url);
778
+ return this.#allowed.has(origin) ? void 0 : origin;
779
+ }
780
+ };
781
+ function originOf(url) {
782
+ try {
783
+ const origin = new URL(url).origin;
784
+ return origin === "null" ? `${new URL(url).protocol}` : origin;
785
+ } catch {
786
+ return url;
787
+ }
788
+ }
789
+ function originNotAllowed(origin) {
790
+ return `origin-not-allowed ${origin}`;
791
+ }
792
+ function navigateUrl(step2, baseUrl) {
793
+ if (step2.url !== void 0)
794
+ return step2.url;
795
+ if (step2.path === void 0 || baseUrl === void 0)
796
+ return void 0;
797
+ return new URL(step2.path, baseUrl).href;
798
+ }
799
+ function checkReadiness(scenario, input) {
800
+ const guard = new OriginGuard(input.allowedOrigins);
801
+ if (input.baseUrl !== void 0) {
802
+ const origin = guard.check(input.baseUrl);
803
+ if (origin !== void 0) {
804
+ return { ok: false, problem: { message: originNotAllowed(origin) } };
805
+ }
806
+ }
807
+ const secrets = /* @__PURE__ */ new Map();
808
+ for (const [stepIndex, step2] of scenario.steps.entries()) {
809
+ if (step2.action === "navigate") {
810
+ const url = navigateUrl(step2, input.baseUrl);
811
+ if (url === void 0) {
812
+ return {
813
+ ok: false,
814
+ problem: { message: `missing-base-url ${step2.path}`, stepIndex }
815
+ };
816
+ }
817
+ const origin = guard.check(url);
818
+ if (origin !== void 0) {
819
+ return {
820
+ ok: false,
821
+ problem: { message: originNotAllowed(origin), stepIndex }
822
+ };
823
+ }
824
+ }
825
+ if (step2.action === "fill" && step2.valueFrom !== void 0) {
826
+ const name = step2.valueFrom.env;
827
+ const value = input.env[name];
828
+ if (value === void 0 || value === "") {
829
+ return {
830
+ ok: false,
831
+ problem: { message: `missing-env ${name}`, stepIndex }
832
+ };
833
+ }
834
+ secrets.set(stepIndex, value);
835
+ }
836
+ }
837
+ return { ok: true, secrets };
838
+ }
839
+
840
+ // ../../packages/engine/dist/execute/actions.js
841
+ var TYPING_DELAY_MS = 35;
842
+ var SCROLL_FRAME_FALLBACK_MS = 100;
843
+ var MAX_REAIMS = 3;
844
+ var NAVIGATION_START_MS = 100;
845
+ var StepFailure = class extends Error {
846
+ stage;
847
+ detail;
848
+ constructor(stage, message, detail = {}) {
849
+ super(message);
850
+ this.stage = stage;
851
+ this.detail = detail;
852
+ }
853
+ };
854
+ async function runAction(step2, ctx) {
855
+ switch (step2.action) {
856
+ case "navigate": {
857
+ const url = navigateUrl(step2, ctx.baseUrl);
858
+ if (url === void 0) {
859
+ throw new StepFailure("execution", `missing-base-url ${step2.path}`);
860
+ }
861
+ const origin = ctx.guard.check(url);
862
+ if (origin !== void 0) {
863
+ throw new StepFailure("execution", originNotAllowed(origin));
864
+ }
865
+ await ctx.page.goto(url, { timeout: step2.timeoutMs });
866
+ return {};
867
+ }
868
+ case "waitFor": {
869
+ const target = locate(ctx, step2.target);
870
+ await target.waitFor({ state: step2.state, timeout: step2.timeoutMs });
871
+ return step2.state === "visible" ? withBox(await measure(target)) : {};
872
+ }
873
+ case "click":
874
+ case "hover": {
875
+ const target = locate(ctx, step2.target);
876
+ if (step2.action === "click") {
877
+ await target.click({ trial: true, timeout: step2.timeoutMs });
878
+ } else {
879
+ await target.hover({ trial: true, timeout: step2.timeoutMs });
880
+ }
881
+ const { box, from, to } = await glide(ctx, target, step2.target);
882
+ if (step2.action === "click") {
883
+ await ctx.page.mouse.down();
884
+ await ctx.page.mouse.up();
885
+ await navigationStart(ctx.page);
886
+ }
887
+ return { target: { box }, pointer: { from, to } };
888
+ }
889
+ case "fill": {
890
+ const target = locate(ctx, step2.target);
891
+ const masked = step2.valueFrom !== void 0;
892
+ const value = masked ? ctx.secret : step2.value;
893
+ if (value === void 0) {
894
+ throw new StepFailure("execution", "fill has no value", masked ? { masked: true, value: MASKED_VALUE } : {});
895
+ }
896
+ if (!masked) {
897
+ await target.fill("", { timeout: step2.timeoutMs });
898
+ const box2 = await measure(target);
899
+ await type(target, value, step2.timeoutMs);
900
+ return { ...withBox(box2), value };
901
+ }
902
+ let box = null;
903
+ try {
904
+ await target.fill("", { timeout: step2.timeoutMs });
905
+ box = await measure(target);
906
+ await type(target, value, step2.timeoutMs);
907
+ } catch (error) {
908
+ box ??= await measure(target);
909
+ throw maskedFailure(error, withBox(box));
910
+ }
911
+ return { ...withBox(box), masked: true, value: MASKED_VALUE };
912
+ }
913
+ case "select": {
914
+ const target = locate(ctx, step2.target);
915
+ await target.selectOption(step2.option, { timeout: step2.timeoutMs });
916
+ return withBox(await measure(target));
917
+ }
918
+ case "scroll":
919
+ return scroll(ctx, step2);
920
+ case "assertVisible": {
921
+ const target = locate(ctx, step2.target);
922
+ try {
923
+ await target.waitFor({ state: "visible", timeout: step2.timeoutMs });
924
+ } catch {
925
+ const message = `expected ${describeLocator(step2.target)} to be visible`;
926
+ throw new StepFailure("verification", message, {
927
+ assertion: { passed: false, message }
928
+ });
929
+ }
930
+ return { ...withBox(await measure(target)), assertion: { passed: true } };
931
+ }
932
+ case "assertText":
933
+ return assertText(ctx, step2);
934
+ case "screenshot": {
935
+ const png = await ctx.page.screenshot({ type: "png" });
936
+ let screenshot;
937
+ for (const observer of ctx.observers) {
938
+ const stored = await observer.onScreenshot?.({
939
+ stepIndex: ctx.stepIndex,
940
+ name: step2.name,
941
+ png
942
+ });
943
+ screenshot ??= stored;
944
+ }
945
+ return screenshot === void 0 ? {} : { screenshot };
946
+ }
947
+ case "marker":
948
+ return { marker: { title: step2.title, kind: step2.kind } };
949
+ case "wait":
950
+ await sleep(step2.ms);
951
+ return {};
952
+ }
953
+ }
954
+ function locate(ctx, locator) {
955
+ return resolveLocator(ctx.page, locator);
956
+ }
957
+ async function measure(target) {
958
+ return target.boundingBox({ timeout: 1e3 }).catch(() => null);
959
+ }
960
+ function withBox(box) {
961
+ return box === null ? {} : { target: { box } };
962
+ }
963
+ async function type(target, value, timeoutMs) {
964
+ await target.pressSequentially(value, {
965
+ delay: TYPING_DELAY_MS,
966
+ timeout: timeoutMs + value.length * TYPING_DELAY_MS
967
+ });
968
+ }
969
+ function maskedFailure(error, target) {
970
+ const detail = { ...target, masked: true, value: MASKED_VALUE };
971
+ if (error instanceof StepFailure) {
972
+ return new StepFailure(error.stage, error.message, {
973
+ ...error.detail,
974
+ ...detail
975
+ });
976
+ }
977
+ const [firstLine = ""] = errorMessage(error).split("\n");
978
+ return new StepFailure("execution", firstLine, detail);
979
+ }
980
+ async function glide(ctx, target, locator) {
981
+ const from = ctx.pointer;
982
+ let box = await requireBox(target, locator);
983
+ let to = boxCentre(box);
984
+ await ctx.page.mouse.move(to.x, to.y, { steps: moveSteps(from, to) });
985
+ ctx.pointer = to;
986
+ for (let attempt = 0; attempt < MAX_REAIMS; attempt++) {
987
+ const now = await requireBox(target, locator);
988
+ const moved = Math.abs(now.x - box.x) > 0.5 || Math.abs(now.y - box.y) > 0.5 || Math.abs(now.width - box.width) > 0.5 || Math.abs(now.height - box.height) > 0.5;
989
+ box = now;
990
+ if (!moved)
991
+ break;
992
+ const next = boxCentre(box);
993
+ await ctx.page.mouse.move(next.x, next.y, {
994
+ steps: moveSteps(to, next)
995
+ });
996
+ to = next;
997
+ ctx.pointer = to;
998
+ }
999
+ return { box, from, to };
1000
+ }
1001
+ async function navigationStart(page) {
1002
+ await page.waitForEvent("request", {
1003
+ predicate: (request) => request.isNavigationRequest() && request.frame() === page.mainFrame(),
1004
+ timeout: NAVIGATION_START_MS
1005
+ }).catch(() => void 0);
1006
+ }
1007
+ async function requireBox(target, locator) {
1008
+ const box = await measure(target);
1009
+ if (box === null) {
1010
+ throw new StepFailure("execution", `${describeLocator(locator)} has no box to point at`);
1011
+ }
1012
+ return box;
1013
+ }
1014
+ async function scroll(ctx, step2) {
1015
+ const container = step2.within === void 0 ? void 0 : locate(ctx, step2.within);
1016
+ await container?.waitFor({ state: "visible", timeout: step2.timeoutMs });
1017
+ const target = step2.target === void 0 ? void 0 : locate(ctx, step2.target);
1018
+ await target?.waitFor({ state: "attached", timeout: step2.timeoutMs });
1019
+ const handles = await Promise.all([
1020
+ container?.elementHandle({ timeout: step2.timeoutMs }) ?? null,
1021
+ target?.elementHandle({ timeout: step2.timeoutMs }) ?? null
1022
+ ]);
1023
+ try {
1024
+ const [scroller, element] = handles;
1025
+ const { before, after } = await ctx.page.evaluate(scrollAndSettle, {
1026
+ scroller,
1027
+ target: element,
1028
+ edge: step2.to ?? null,
1029
+ timeoutMs: step2.timeoutMs,
1030
+ fallbackMs: SCROLL_FRAME_FALLBACK_MS
1031
+ });
1032
+ const containerBox = container === void 0 ? null : await measure(container);
1033
+ return {
1034
+ scroll: containerBox === null ? { before, after } : { before, after, container: containerBox }
1035
+ };
1036
+ } finally {
1037
+ await Promise.all(handles.map((handle) => handle?.dispose()));
1038
+ }
1039
+ }
1040
+ async function scrollAndSettle({ scroller, target, edge: edge2, timeoutMs, fallbackMs }) {
1041
+ const root = document.scrollingElement ?? document.documentElement;
1042
+ const read = () => scroller === null ? { x: window.scrollX, y: window.scrollY } : { x: scroller.scrollLeft, y: scroller.scrollTop };
1043
+ const before = read();
1044
+ const box = scroller ?? root;
1045
+ const maxX = box.scrollWidth - box.clientWidth;
1046
+ const maxY = box.scrollHeight - box.clientHeight;
1047
+ const clamp2 = (value, max) => Math.min(Math.max(value, 0), Math.max(max, 0));
1048
+ let destination;
1049
+ if (target === null) {
1050
+ destination = { x: before.x, y: edge2 === "top" ? 0 : maxY };
1051
+ } else {
1052
+ const rect = target.getBoundingClientRect();
1053
+ const frame = scroller?.getBoundingClientRect();
1054
+ const left = frame === void 0 ? 0 : frame.left + box.clientLeft;
1055
+ const top = frame === void 0 ? 0 : frame.top + box.clientTop;
1056
+ const width = scroller === null ? window.innerWidth : box.clientWidth;
1057
+ const height = scroller === null ? window.innerHeight : box.clientHeight;
1058
+ let dx = 0;
1059
+ if (rect.left < left)
1060
+ dx = rect.left - left;
1061
+ else if (rect.right > left + width)
1062
+ dx = rect.right - (left + width);
1063
+ destination = {
1064
+ x: clamp2(before.x + dx, maxX),
1065
+ y: clamp2(before.y + rect.top - top + rect.height / 2 - height / 2, maxY)
1066
+ };
1067
+ }
1068
+ const reached = (at) => Math.abs(at.x - destination.x) <= 1 && Math.abs(at.y - destination.y) <= 1;
1069
+ let endedAt;
1070
+ const onScrollEnd = () => {
1071
+ endedAt = read();
1072
+ };
1073
+ const events = scroller ?? document;
1074
+ events.addEventListener("scrollend", onScrollEnd);
1075
+ if (target !== null) {
1076
+ target.scrollIntoView({ behavior: "smooth", block: "center" });
1077
+ } else {
1078
+ (scroller ?? window).scrollTo({ top: destination.y, behavior: "smooth" });
1079
+ }
1080
+ const nextFrame = () => new Promise((resolve) => {
1081
+ requestAnimationFrame(() => resolve());
1082
+ setTimeout(resolve, fallbackMs);
1083
+ });
1084
+ const deadline = performance.now() + timeoutMs;
1085
+ let last = read();
1086
+ while (performance.now() < deadline) {
1087
+ await nextFrame();
1088
+ const now = read();
1089
+ const still = now.x === last.x && now.y === last.y;
1090
+ const moved = now.x !== before.x || now.y !== before.y;
1091
+ const ended = endedAt?.x === now.x && endedAt.y === now.y;
1092
+ last = now;
1093
+ if (still && (moved ? ended : reached(now)))
1094
+ break;
1095
+ }
1096
+ events.removeEventListener("scrollend", onScrollEnd);
1097
+ return { before, after: last };
1098
+ }
1099
+ async function assertText(ctx, step2) {
1100
+ const target = locate(ctx, step2.target);
1101
+ const deadline = Date.now() + step2.timeoutMs;
1102
+ await target.waitFor({ state: "visible", timeout: step2.timeoutMs });
1103
+ let actual = "";
1104
+ for (; ; ) {
1105
+ actual = normalise(await target.innerText({ timeout: step2.timeoutMs }));
1106
+ if (textMatches(actual, step2.text, step2.match)) {
1107
+ return {
1108
+ ...withBox(await measure(target)),
1109
+ assertion: { passed: true }
1110
+ };
1111
+ }
1112
+ if (Date.now() >= deadline)
1113
+ break;
1114
+ await sleep(100);
1115
+ }
1116
+ const verb = step2.match === "exact" ? "to be" : "to contain";
1117
+ const message = `expected ${describeLocator(step2.target)} ${verb} ${JSON.stringify(step2.text)}, got ${JSON.stringify(actual)}`;
1118
+ throw new StepFailure("verification", message, {
1119
+ ...withBox(await measure(target)),
1120
+ assertion: { passed: false, message }
1121
+ });
1122
+ }
1123
+ function normalise(text) {
1124
+ return text.replace(/\s+/g, " ").trim();
1125
+ }
1126
+ function textMatches(actual, expected, match) {
1127
+ const wanted = normalise(expected);
1128
+ return match === "exact" ? actual === wanted : actual.includes(wanted);
1129
+ }
1130
+ function errorMessage(error) {
1131
+ return error instanceof Error ? error.message : String(error);
1132
+ }
1133
+
1134
+ // ../../packages/engine/dist/execute/recording.js
1135
+ var DEVICE_SCALE_FACTOR = 2;
1136
+ function recordingOptions(scenario, runDir2) {
1137
+ return {
1138
+ viewport: scenario.viewport,
1139
+ deviceScaleFactor: DEVICE_SCALE_FACTOR,
1140
+ videoDir: runDir2,
1141
+ videoSize: { ...scenario.viewport }
1142
+ };
1143
+ }
1144
+ function toContextOptions(recording) {
1145
+ return {
1146
+ viewport: recording.viewport,
1147
+ deviceScaleFactor: recording.deviceScaleFactor,
1148
+ recordVideo: { dir: recording.videoDir, size: recording.videoSize }
1149
+ };
1150
+ }
1151
+
1152
+ // ../../packages/engine/dist/execute/sync-mark.js
1153
+ var SYNC_MARK_COLOUR = "#ff00ff";
1154
+ var SYNC_MARK_REPAINT_MS = 200;
1155
+ var SYNC_MARK_HOLD_MS = 400;
1156
+ var SYNC_EXCHANGES = 5;
1157
+ var ELEMENT_ID = "__demoflow_sync_mark__";
1158
+ var REPAINT_COLOUR = "#fe00fe";
1159
+ async function recordSyncMark(page, clock) {
1160
+ try {
1161
+ const edges = await page.evaluate(showMark, {
1162
+ id: ELEMENT_ID,
1163
+ colour: SYNC_MARK_COLOUR,
1164
+ repaintColour: REPAINT_COLOUR,
1165
+ repaintMs: SYNC_MARK_REPAINT_MS,
1166
+ holdMs: SYNC_MARK_HOLD_MS
1167
+ });
1168
+ const exchanges = [];
1169
+ for (let i = 0; i < SYNC_EXCHANGES; i++) {
1170
+ const sentMs = clock.now();
1171
+ const pageMs = await page.evaluate(() => performance.now());
1172
+ exchanges.push({ sentMs, pageMs, receivedMs: clock.now() });
1173
+ }
1174
+ return toSyncMark(edges, exchanges);
1175
+ } catch {
1176
+ return void 0;
1177
+ }
1178
+ }
1179
+ function toSyncMark(edges, exchanges) {
1180
+ if (!(edges.offMs > edges.onMs))
1181
+ return void 0;
1182
+ const shiftMs = pageToClockShift(exchanges);
1183
+ const mappedOnMs = edges.onMs + shiftMs;
1184
+ if (mappedOnMs >= 0) {
1185
+ return {
1186
+ colour: SYNC_MARK_COLOUR,
1187
+ onMs: mappedOnMs,
1188
+ offMs: edges.offMs + shiftMs
1189
+ };
1190
+ }
1191
+ return {
1192
+ colour: SYNC_MARK_COLOUR,
1193
+ onMs: 0,
1194
+ offMs: edges.offMs - edges.onMs
1195
+ };
1196
+ }
1197
+ function pageToClockShift(exchanges) {
1198
+ let best;
1199
+ for (const exchange of exchanges) {
1200
+ const roundTrip = exchange.receivedMs - exchange.sentMs;
1201
+ if (best === void 0 || roundTrip < best.receivedMs - best.sentMs) {
1202
+ best = exchange;
1203
+ }
1204
+ }
1205
+ if (best === void 0)
1206
+ throw new Error("no clock exchange");
1207
+ return (best.sentMs + best.receivedMs) / 2 - best.pageMs;
1208
+ }
1209
+ function showMark(input) {
1210
+ const mark = document.createElement("div");
1211
+ mark.id = input.id;
1212
+ mark.setAttribute("style", `position:fixed;inset:0;z-index:2147483647;background:${input.colour}`);
1213
+ document.documentElement.appendChild(mark);
1214
+ return new Promise((resolve) => {
1215
+ let onMs;
1216
+ let odd = false;
1217
+ const frame = (atMs) => {
1218
+ if (onMs === void 0)
1219
+ onMs = atMs;
1220
+ if (atMs - onMs < input.holdMs) {
1221
+ odd = atMs - onMs < input.repaintMs && !odd;
1222
+ mark.style.background = odd ? input.repaintColour : input.colour;
1223
+ requestAnimationFrame(frame);
1224
+ return;
1225
+ }
1226
+ mark.remove();
1227
+ const shownMs = onMs;
1228
+ requestAnimationFrame(() => resolve({ onMs: shownMs, offMs: atMs }));
1229
+ };
1230
+ requestAnimationFrame(frame);
1231
+ });
1232
+ }
1233
+
1234
+ // ../../packages/engine/dist/run.js
1235
+ import { mkdir as mkdir2 } from "node:fs/promises";
1236
+ import { chromium } from "playwright";
1237
+
1238
+ // ../../packages/engine/dist/execute/clock.js
1239
+ import { performance as performance2 } from "node:perf_hooks";
1240
+ function startClock() {
1241
+ const zero = performance2.now();
1242
+ return { now: () => Math.max(0, performance2.now() - zero) };
1243
+ }
1244
+
1245
+ // ../../packages/engine/dist/execute/mask.js
1246
+ var MaskTracker = class {
1247
+ page;
1248
+ fields = [];
1249
+ navigated = false;
1250
+ constructor(page) {
1251
+ this.page = page;
1252
+ page.on("framenavigated", (frame) => {
1253
+ if (frame === page.mainFrame())
1254
+ this.navigated = true;
1255
+ });
1256
+ }
1257
+ /**
1258
+ * Starts tracking a masked fill's field from the fill itself. The event's
1259
+ * `maskVisibility` is the tracker's own list: later steps extend it.
1260
+ */
1261
+ track(event, locator) {
1262
+ const box = event.target?.box;
1263
+ if (event.masked !== true || box === void 0)
1264
+ return;
1265
+ const intervals = [{ startMs: event.startMs, box }];
1266
+ event.maskVisibility = intervals;
1267
+ this.fields.push({ locator, intervals });
1268
+ }
1269
+ /**
1270
+ * Checks every tracked field. Called after each step, before its `endMs`
1271
+ * is stamped, so a field seen gone here is gone by that `endMs`.
1272
+ */
1273
+ async look() {
1274
+ const navigated = this.navigated;
1275
+ this.navigated = false;
1276
+ const fields = await Promise.all(this.fields.map(async (field) => ({
1277
+ field,
1278
+ sighting: await this.sight(field.locator)
1279
+ })));
1280
+ return { navigated, fields };
1281
+ }
1282
+ record(sightings, startMs, endMs) {
1283
+ for (const { field, sighting } of sightings.fields) {
1284
+ const open = field.intervals.at(-1);
1285
+ const isOpen = open !== void 0 && open.endMs === void 0;
1286
+ if (isOpen && (sightings.navigated || sighting === "gone")) {
1287
+ open.endMs = endMs;
1288
+ }
1289
+ const isClosed = open === void 0 || open.endMs !== void 0;
1290
+ if (isClosed && sighting !== void 0 && sighting !== "gone") {
1291
+ field.intervals.push({ startMs, box: sighting });
1292
+ }
1293
+ }
1294
+ }
1295
+ /** Any visible match counts as the field: the conservative reading. */
1296
+ async sight(locator) {
1297
+ const visible = resolveLocator(this.page, locator).filter({ visible: true }).first();
1298
+ let shown;
1299
+ try {
1300
+ shown = await visible.isVisible();
1301
+ } catch {
1302
+ return void 0;
1303
+ }
1304
+ if (!shown)
1305
+ return "gone";
1306
+ const box = await visible.boundingBox({ timeout: 1e3 }).catch(() => null);
1307
+ return box ?? "gone";
1308
+ }
1309
+ };
1310
+
1311
+ // ../../packages/engine/dist/run.js
1312
+ var ASSERTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText"]);
1313
+ async function runScenario(scenario, options) {
1314
+ const observers = options.observers ?? [];
1315
+ const emit = async (hook, event) => {
1316
+ for (const observer of observers) {
1317
+ const handler = observer[hook];
1318
+ await handler?.call(observer, event);
1319
+ }
1320
+ };
1321
+ const startedAt = timestamp();
1322
+ const stages = pendingStages();
1323
+ const recording = recordingOptions(scenario, options.runDir);
1324
+ const baseUrl = scenario.baseUrl ?? options.baseUrl;
1325
+ await mkdir2(options.runDir, { recursive: true });
1326
+ await emit("onRunStarted", {
1327
+ scenario,
1328
+ runDir: options.runDir,
1329
+ recording,
1330
+ startedAt
1331
+ });
1332
+ stages.readiness = { status: "running", startedAt };
1333
+ const readiness = checkReadiness(scenario, {
1334
+ baseUrl,
1335
+ allowedOrigins: options.allowedOrigins,
1336
+ env: options.env ?? process.env
1337
+ });
1338
+ if (!readiness.ok) {
1339
+ stages.readiness = finish(stages.readiness, "failed", readiness.problem);
1340
+ for (const name of ["execution", "verification", "recording"]) {
1341
+ stages[name] = { status: "skipped" };
1342
+ }
1343
+ const outcome = conclude(startedAt, stages, []);
1344
+ await emit("onRunFinished", { outcome, video: null });
1345
+ return outcome;
1346
+ }
1347
+ stages.readiness = finish(stages.readiness, "passed");
1348
+ const secrets = [...readiness.secrets.values()];
1349
+ const scrub = (message) => secrets.reduce((text, secret) => text.replaceAll(secret, "***"), message);
1350
+ const browser = await chromium.launch({
1351
+ headless: options.headless ?? true
1352
+ });
1353
+ try {
1354
+ await emit("onBrowserLaunched", { browser });
1355
+ const context = await browser.newContext(toContextOptions(recording));
1356
+ const guard = new OriginGuard(options.allowedOrigins);
1357
+ const navigation = watchNavigation(context, guard);
1358
+ stages.recording = { status: "running", startedAt: timestamp() };
1359
+ const page = await context.newPage();
1360
+ const clock = startClock();
1361
+ await emit("onPageCreated", { page, recording });
1362
+ const syncMark = await recordSyncMark(page, clock);
1363
+ const events = [];
1364
+ const hasAssertions = scenario.steps.some((step2) => ASSERTIONS.has(step2.action));
1365
+ stages.execution = { status: "running", startedAt: timestamp() };
1366
+ if (hasAssertions) {
1367
+ stages.verification = { status: "running", startedAt: timestamp() };
1368
+ }
1369
+ const failure2 = await executeSteps({
1370
+ scenario,
1371
+ page,
1372
+ clock,
1373
+ baseUrl,
1374
+ guard,
1375
+ secrets: readiness.secrets,
1376
+ pointer: viewportCentre(scenario.viewport),
1377
+ observers,
1378
+ events,
1379
+ navigation,
1380
+ masks: new MaskTracker(page),
1381
+ scrub,
1382
+ emit
1383
+ });
1384
+ if (failure2 === void 0) {
1385
+ stages.execution = finish(stages.execution, "passed");
1386
+ stages.verification = hasAssertions ? finish(stages.verification, "passed") : { status: "skipped" };
1387
+ } else {
1388
+ const other = failure2.stage === "execution" ? "verification" : "execution";
1389
+ stages[failure2.stage] = finish(stages[failure2.stage], "failed", {
1390
+ message: failure2.message,
1391
+ stepIndex: failure2.stepIndex
1392
+ });
1393
+ stages[other] = { ...stages[other], status: "skipped" };
1394
+ }
1395
+ await context.close();
1396
+ const video = page.video();
1397
+ let videoPath;
1398
+ try {
1399
+ videoPath = video === null ? void 0 : await video.path();
1400
+ stages.recording = finish(stages.recording, videoPath === void 0 ? "failed" : "passed", videoPath === void 0 ? { message: "no video recorded" } : void 0);
1401
+ } catch (error) {
1402
+ stages.recording = finish(stages.recording, "failed", {
1403
+ message: errorMessage(error)
1404
+ });
1405
+ }
1406
+ const outcome = conclude(startedAt, stages, events, videoPath, syncMark);
1407
+ await emit("onRunFinished", { outcome, video });
1408
+ return outcome;
1409
+ } finally {
1410
+ await browser.close();
1411
+ }
1412
+ }
1413
+ async function executeSteps(input) {
1414
+ const { clock, emit, scrub } = input;
1415
+ let pointer = input.pointer;
1416
+ for (const [stepIndex, step2] of input.scenario.steps.entries()) {
1417
+ const startMs = clock.now();
1418
+ await emit("onStepStarted", { stepIndex, step: step2, startMs });
1419
+ const ctx = {
1420
+ page: input.page,
1421
+ stepIndex,
1422
+ baseUrl: input.baseUrl,
1423
+ guard: input.guard,
1424
+ secret: input.secrets.get(stepIndex),
1425
+ pointer,
1426
+ observers: input.observers
1427
+ };
1428
+ let failure2;
1429
+ let result = {};
1430
+ let foreign = input.navigation.take();
1431
+ if (foreign === void 0) {
1432
+ try {
1433
+ result = await runAction(step2, ctx);
1434
+ } catch (error) {
1435
+ failure2 = error instanceof StepFailure ? error : new StepFailure("execution", errorMessage(error));
1436
+ }
1437
+ pointer = ctx.pointer;
1438
+ foreign = input.navigation.take();
1439
+ }
1440
+ if (foreign !== void 0) {
1441
+ failure2 = new StepFailure("execution", originNotAllowed(foreign));
1442
+ }
1443
+ const sightings = await input.masks.look();
1444
+ const endMs = Math.max(startMs, clock.now());
1445
+ input.masks.record(sightings, startMs, endMs);
1446
+ const event = {
1447
+ stepIndex,
1448
+ action: step2.action,
1449
+ startMs,
1450
+ endMs,
1451
+ ...result,
1452
+ ...failure2 === void 0 ? {} : failure2.detail,
1453
+ ...step2.caption === void 0 ? {} : { caption: step2.caption }
1454
+ };
1455
+ if (failure2 !== void 0 && failure2.stage === "execution") {
1456
+ event.error = { message: scrub(failure2.message) };
1457
+ }
1458
+ if (event.assertion?.message !== void 0) {
1459
+ event.assertion = {
1460
+ ...event.assertion,
1461
+ message: scrub(event.assertion.message)
1462
+ };
1463
+ }
1464
+ if (step2.action === "fill") {
1465
+ input.masks.track(event, step2.target);
1466
+ }
1467
+ input.events.push(event);
1468
+ await emit("onStepFinished", { stepIndex, step: step2, event });
1469
+ if (failure2 !== void 0) {
1470
+ return {
1471
+ stage: failure2.stage,
1472
+ message: scrub(failure2.message),
1473
+ stepIndex
1474
+ };
1475
+ }
1476
+ }
1477
+ return void 0;
1478
+ }
1479
+ function watchNavigation(context, guard) {
1480
+ let foreign;
1481
+ const note = (url) => {
1482
+ foreign ??= guard.check(url);
1483
+ };
1484
+ context.on("request", (request) => {
1485
+ if (request.isNavigationRequest() && request.frame().parentFrame() === null) {
1486
+ note(request.url());
1487
+ }
1488
+ });
1489
+ context.on("page", (page) => {
1490
+ page.on("framenavigated", (frame) => {
1491
+ if (frame.parentFrame() === null)
1492
+ note(frame.url());
1493
+ });
1494
+ });
1495
+ return {
1496
+ take() {
1497
+ const origin = foreign;
1498
+ foreign = void 0;
1499
+ return origin;
1500
+ }
1501
+ };
1502
+ }
1503
+ function pendingStages() {
1504
+ return {
1505
+ readiness: { status: "pending" },
1506
+ execution: { status: "pending" },
1507
+ verification: { status: "pending" },
1508
+ recording: { status: "pending" },
1509
+ rendering: { status: "pending" },
1510
+ storage: { status: "pending" }
1511
+ };
1512
+ }
1513
+ function finish(stage, status, error) {
1514
+ return {
1515
+ ...stage,
1516
+ status,
1517
+ finishedAt: timestamp(),
1518
+ ...error === void 0 ? {} : { error }
1519
+ };
1520
+ }
1521
+ function conclude(startedAt, stages, events, videoPath, syncMark) {
1522
+ const warnings = [];
1523
+ return {
1524
+ startedAt,
1525
+ finishedAt: timestamp(),
1526
+ status: deriveRunStatus(stages, warnings),
1527
+ stages,
1528
+ events,
1529
+ warnings,
1530
+ ...syncMark === void 0 ? {} : { syncMark },
1531
+ ...videoPath === void 0 ? {} : { videoPath }
1532
+ };
1533
+ }
1534
+ function timestamp() {
1535
+ return (/* @__PURE__ */ new Date()).toISOString();
1536
+ }
1537
+
1538
+ // ../../packages/renderer/dist/ffmpeg/index.js
1539
+ import path6 from "node:path";
1540
+
1541
+ // ../../packages/renderer/dist/renderer.js
1542
+ import { mkdir as mkdir3, readFile as readFile2, rename as rename2, stat, writeFile as writeFile2 } from "node:fs/promises";
1543
+ import path5 from "node:path";
1544
+
1545
+ // ../../packages/renderer/dist/plan/ffmpeg.js
1546
+ import { spawn, spawnSync } from "node:child_process";
1547
+ import { existsSync as existsSync2 } from "node:fs";
1548
+ import { createRequire } from "node:module";
1549
+ var resolved;
1550
+ function resolveFfmpeg() {
1551
+ if (resolved !== void 0)
1552
+ return resolved;
1553
+ const system = spawnSync("ffmpeg", ["-hide_banner", "-encoders"], {
1554
+ encoding: "utf8",
1555
+ windowsHide: true
1556
+ });
1557
+ if (system.status === 0 && /\slibx264\s/.test(system.stdout)) {
1558
+ resolved = "ffmpeg";
1559
+ return resolved;
1560
+ }
1561
+ const bundled = createRequire(import.meta.url)("ffmpeg-static");
1562
+ if (bundled === null || !existsSync2(bundled)) {
1563
+ throw new Error("no FFmpeg: none on PATH, and ffmpeg-static has no binary (was its install script blocked?)");
1564
+ }
1565
+ resolved = bundled;
1566
+ return resolved;
1567
+ }
1568
+ async function runFfmpeg(args) {
1569
+ const { stdout, stderr } = await runFfmpegRaw(args);
1570
+ return { stdout: stdout.toString("utf8"), stderr };
1571
+ }
1572
+ function runFfmpegRaw(args) {
1573
+ return new Promise((resolve, reject) => {
1574
+ const child = spawn(resolveFfmpeg(), ["-hide_banner", "-nostdin", ...args], {
1575
+ stdio: ["ignore", "pipe", "pipe"],
1576
+ windowsHide: true
1577
+ });
1578
+ const stdout = [];
1579
+ let stderr = "";
1580
+ child.stdout.on("data", (chunk) => {
1581
+ stdout.push(chunk);
1582
+ });
1583
+ child.stderr.setEncoding("utf8").on("data", (chunk) => {
1584
+ stderr += chunk;
1585
+ });
1586
+ child.on("error", reject);
1587
+ child.on("close", (code) => {
1588
+ if (code === 0) {
1589
+ resolve({ stdout: Buffer.concat(stdout), stderr });
1590
+ } else {
1591
+ const tail = stderr.trim().split("\n").slice(-5).join("\n");
1592
+ reject(new Error(`ffmpeg exited with ${code}: ${tail}`));
1593
+ }
1594
+ });
1595
+ });
1596
+ }
1597
+ var FREEZE_MAX_DIFF = 24;
1598
+ var FRAME_DIFF = "tblend=all_mode=difference,signalstats,metadata=mode=print:key=lavfi.signalstats.YMAX";
1599
+ async function probeVideo(file, options = {}) {
1600
+ const filter = options.freezes === void 0 ? ["-c", "copy"] : ["-vf", FRAME_DIFF];
1601
+ const { stdout, stderr } = await runFfmpeg([
1602
+ "-i",
1603
+ file,
1604
+ "-map",
1605
+ "0:v:0",
1606
+ ...filter,
1607
+ "-f",
1608
+ "null",
1609
+ "-progress",
1610
+ "pipe:1",
1611
+ "-nostats",
1612
+ "-"
1613
+ ]);
1614
+ return parseProbe(stdout, stderr, options.freezes);
1615
+ }
1616
+ function parseProbe(progress, log, freezeOptions) {
1617
+ const stream = /Stream #\d+:\d+[^\n]*?: Video: ([^\n]+)/.exec(log);
1618
+ if (stream === null)
1619
+ throw new Error("ffmpeg reported no video stream");
1620
+ const details = stream[1];
1621
+ const codec = /^(\w+)/.exec(details)?.[1] ?? "unknown";
1622
+ const size = /, (\d{2,5})x(\d{2,5})/.exec(details);
1623
+ if (size === null)
1624
+ throw new Error(`no frame size in "${details}"`);
1625
+ const rate = /([\d.]+) (?:fps|tbr)/.exec(details);
1626
+ if (rate === null)
1627
+ throw new Error(`no frame rate in "${details}"`);
1628
+ const fps = Number(rate[1]);
1629
+ const pixelFormat = /^\w+(?: \([^)]*\))*, (\w+)/.exec(details)?.[1];
1630
+ const durationMs = readDuration(progress, log, fps);
1631
+ return {
1632
+ width: Number(size[1]),
1633
+ height: Number(size[2]),
1634
+ fps,
1635
+ durationMs,
1636
+ codec,
1637
+ ...pixelFormat === void 0 ? {} : { pixelFormat },
1638
+ freezes: freezeOptions === void 0 ? [] : readFreezes(log, fps, durationMs, freezeOptions)
1639
+ };
1640
+ }
1641
+ function readFreezes(log, fps, durationMs, options) {
1642
+ const maxDiff = options.maxDiff ?? FREEZE_MAX_DIFF;
1643
+ const frameMs = 1e3 / fps;
1644
+ const freezes = [];
1645
+ let run;
1646
+ const close = () => {
1647
+ if (run !== void 0 && run.endMs - run.startMs >= options.minMs) {
1648
+ freezes.push(run);
1649
+ }
1650
+ run = void 0;
1651
+ };
1652
+ for (const match of log.matchAll(/pts_time:([\d.]+)\s*\n[^\n]*lavfi\.signalstats\.YMAX=(\d+)/g)) {
1653
+ const atMs = Number(match[1]) * 1e3;
1654
+ if (Number(match[2]) > maxDiff) {
1655
+ close();
1656
+ } else if (run === void 0) {
1657
+ run = {
1658
+ startMs: Math.max(0, atMs - frameMs),
1659
+ endMs: Math.min(durationMs, atMs + frameMs)
1660
+ };
1661
+ } else {
1662
+ run.endMs = Math.min(durationMs, atMs + frameMs);
1663
+ }
1664
+ }
1665
+ close();
1666
+ return freezes;
1667
+ }
1668
+ function readDuration(progress, log, fps) {
1669
+ const header = /Duration: (\d+):(\d+):([\d.]+)/.exec(log);
1670
+ if (header !== null) {
1671
+ const [, h, m, s] = header;
1672
+ return (Number(h) * 3600 + Number(m) * 60 + Number(s)) * 1e3;
1673
+ }
1674
+ const frames = [...progress.matchAll(/^frame=(\d+)$/gm)].at(-1)?.[1];
1675
+ if (frames === void 0)
1676
+ throw new Error("ffmpeg reported no duration");
1677
+ return Number(frames) / fps * 1e3;
1678
+ }
1679
+
1680
+ // ../../packages/renderer/dist/plan/plan.js
1681
+ import { readFile } from "node:fs/promises";
1682
+ import path4 from "node:path";
1683
+
1684
+ // ../../packages/renderer/dist/plan/sync-mark.js
1685
+ var SYNC_GRID = 4;
1686
+ var SYNC_TOLERANCE = 64;
1687
+ var MAX_SYNC_OFFSET_MS = 500;
1688
+ var SEARCH_PAST_OFF_MS = 1e3;
1689
+ function parseColour(hex2) {
1690
+ const value = Number.parseInt(hex2.slice(1), 16);
1691
+ return [value >> 16 & 255, value >> 8 & 255, value & 255];
1692
+ }
1693
+ function syncClearFrame(frames, colour) {
1694
+ const marked = frames.map((cells) => cells.length > 0 && cells.every((cell) => cell.every((channel, index) => Math.abs(channel - colour[index]) <= SYNC_TOLERANCE)));
1695
+ const first = marked.indexOf(true);
1696
+ if (first === -1)
1697
+ return void 0;
1698
+ const clear = marked.findIndex((isMarked, index) => index > first && !isMarked);
1699
+ return clear === -1 ? void 0 : clear;
1700
+ }
1701
+ async function findSyncClear(file, fps, mark) {
1702
+ try {
1703
+ const { stdout } = await runFfmpegRaw([
1704
+ "-loglevel",
1705
+ "error",
1706
+ "-t",
1707
+ String((mark.offMs + SEARCH_PAST_OFF_MS) / 1e3),
1708
+ "-i",
1709
+ file,
1710
+ "-map",
1711
+ "0:v:0",
1712
+ "-vf",
1713
+ `fps=${fps},crop=iw/2:ih/2:0:0,scale=${SYNC_GRID}:${SYNC_GRID}:flags=area`,
1714
+ "-f",
1715
+ "rawvideo",
1716
+ "-pix_fmt",
1717
+ "rgb24",
1718
+ "pipe:1"
1719
+ ]);
1720
+ const frameBytes = SYNC_GRID * SYNC_GRID * 3;
1721
+ const frames = [];
1722
+ for (let at = 0; at + frameBytes <= stdout.length; at += frameBytes) {
1723
+ const cells = [];
1724
+ for (let cell = at; cell < at + frameBytes; cell += 3) {
1725
+ cells.push([stdout[cell], stdout[cell + 1], stdout[cell + 2]]);
1726
+ }
1727
+ frames.push(cells);
1728
+ }
1729
+ const clear = syncClearFrame(frames, parseColour(mark.colour));
1730
+ return clear === void 0 ? void 0 : clear * 1e3 / fps;
1731
+ } catch {
1732
+ return void 0;
1733
+ }
1734
+ }
1735
+ function syncOffset(mark, clearMs) {
1736
+ if (clearMs === void 0)
1737
+ return void 0;
1738
+ const offsetMs = clearMs - mark.offMs;
1739
+ return Math.abs(offsetMs) <= MAX_SYNC_OFFSET_MS ? offsetMs : void 0;
1740
+ }
1741
+
1742
+ // ../../packages/renderer/dist/plan/time-map.js
1743
+ function idleStretches(freezes, active, thresholdMs, endMs) {
1744
+ const idle = [];
1745
+ for (const freeze of freezes) {
1746
+ let pieces = [
1747
+ {
1748
+ startMs: Math.max(0, freeze.startMs),
1749
+ endMs: Math.min(endMs, freeze.endMs)
1750
+ }
1751
+ ];
1752
+ for (const window2 of active) {
1753
+ pieces = pieces.flatMap((piece) => subtract(piece, window2));
1754
+ }
1755
+ idle.push(...pieces.filter((piece) => piece.endMs - piece.startMs >= thresholdMs));
1756
+ }
1757
+ return mergeSorted(idle);
1758
+ }
1759
+ function subtract(piece, cut) {
1760
+ if (cut.endMs <= piece.startMs || cut.startMs >= piece.endMs)
1761
+ return [piece];
1762
+ const out = [];
1763
+ if (cut.startMs > piece.startMs) {
1764
+ out.push({ startMs: piece.startMs, endMs: cut.startMs });
1765
+ }
1766
+ if (cut.endMs < piece.endMs) {
1767
+ out.push({ startMs: cut.endMs, endMs: piece.endMs });
1768
+ }
1769
+ return out;
1770
+ }
1771
+ function mergeSorted(intervals) {
1772
+ const sorted = [...intervals].sort((a, b) => a.startMs - b.startMs);
1773
+ const out = [];
1774
+ for (const interval of sorted) {
1775
+ const last = out.at(-1);
1776
+ if (last !== void 0 && interval.startMs <= last.endMs) {
1777
+ last.endMs = Math.max(last.endMs, interval.endMs);
1778
+ } else {
1779
+ out.push({ ...interval });
1780
+ }
1781
+ }
1782
+ return out;
1783
+ }
1784
+ function buildTimeMap(idle, endMs, compressToMs, startMs = 0) {
1785
+ const segments = [];
1786
+ let source = startMs;
1787
+ let output = 0;
1788
+ const push = (sourceEndMs, outputLength) => {
1789
+ if (sourceEndMs <= source)
1790
+ return;
1791
+ segments.push({
1792
+ outputStartMs: output,
1793
+ outputEndMs: output + outputLength,
1794
+ sourceStartMs: source,
1795
+ sourceEndMs
1796
+ });
1797
+ source = sourceEndMs;
1798
+ output += outputLength;
1799
+ };
1800
+ for (const stretch of idle) {
1801
+ if (stretch.startMs >= endMs)
1802
+ break;
1803
+ const stretchStart = Math.max(stretch.startMs, source);
1804
+ push(stretchStart, stretchStart - source);
1805
+ const stretchEnd = Math.min(stretch.endMs, endMs);
1806
+ push(stretchEnd, Math.min(compressToMs, stretchEnd - stretchStart));
1807
+ }
1808
+ push(endMs, endMs - source);
1809
+ return segments;
1810
+ }
1811
+ function toOutputMs(map, sourceMs) {
1812
+ const first = map[0];
1813
+ const last = map.at(-1);
1814
+ if (first === void 0 || last === void 0)
1815
+ return 0;
1816
+ if (sourceMs <= first.sourceStartMs)
1817
+ return first.outputStartMs;
1818
+ if (sourceMs >= last.sourceEndMs)
1819
+ return last.outputEndMs;
1820
+ const segment = map.find((s) => sourceMs >= s.sourceStartMs && sourceMs < s.sourceEndMs) ?? last;
1821
+ return lerp(sourceMs, segment.sourceStartMs, segment.sourceEndMs, segment.outputStartMs, segment.outputEndMs);
1822
+ }
1823
+ function toSourceMs(map, outputMs) {
1824
+ const first = map[0];
1825
+ const last = map.at(-1);
1826
+ if (first === void 0 || last === void 0)
1827
+ return 0;
1828
+ if (outputMs <= first.outputStartMs)
1829
+ return first.sourceStartMs;
1830
+ if (outputMs >= last.outputEndMs)
1831
+ return last.sourceEndMs;
1832
+ const segment = map.find((s) => outputMs >= s.outputStartMs && outputMs < s.outputEndMs) ?? last;
1833
+ return lerp(outputMs, segment.outputStartMs, segment.outputEndMs, segment.sourceStartMs, segment.sourceEndMs);
1834
+ }
1835
+ function lerp(value, a0, a1, b0, b1) {
1836
+ return a1 === a0 ? b0 : b0 + (value - a0) / (a1 - a0) * (b1 - b0);
1837
+ }
1838
+
1839
+ // ../../packages/renderer/dist/plan/plan.js
1840
+ var PLAN_INPUTS = {
1841
+ timeline: "timeline.json",
1842
+ result: "result.json"
1843
+ };
1844
+ var DEFAULT_OFFSET_MS = -100;
1845
+ var DEFAULTS = {
1846
+ idleThresholdMs: 1e3,
1847
+ idleCompressToMs: 400,
1848
+ output: { width: 1920, height: 1080, fps: 30 },
1849
+ /** Zoom on a source with at least two pixels per CSS pixel (ADR-0004 §1). */
1850
+ zoom: 1.6,
1851
+ /** Zoom on a 1× source: past the source pixels already, so kept gentle (spec 4, Risks). */
1852
+ zoomOnLowResSource: 1.25
1853
+ };
1854
+ var TIMING = {
1855
+ clickRingMs: 300,
1856
+ zoomInMs: 500,
1857
+ zoomOutMs: 400,
1858
+ captionTailMs: 800,
1859
+ endCardMs: 3e3,
1860
+ /** A target whose centre is closer than this to the previous one keeps the zoom and pans. */
1861
+ keepZoomWithinPx: 400,
1862
+ /** A pan never whips across in less than this. */
1863
+ minPanMs: 400,
1864
+ /** The engine waits up to this long for a navigation after the press. */
1865
+ pressToEndMs: 100,
1866
+ /** Masks start this early, to absorb the residual clock offset; one source frame when it is measured. */
1867
+ maskLeadMs: 200,
1868
+ /** Masks end this late, for the same reason: an early end leaks, a late one is safe (#48). */
1869
+ maskTrailMs: 200,
1870
+ /** Masks overhang the field's box by this much on every side. */
1871
+ maskMarginPx: 4,
1872
+ /** A successful run keeps this much video after its last event. */
1873
+ tailMs: 1e3,
1874
+ /** A target box fills at most this share of the zoomed view. */
1875
+ targetFill: 0.9
1876
+ };
1877
+ var ACTIVE = /* @__PURE__ */ new Set([
1878
+ "navigate",
1879
+ "click",
1880
+ "hover",
1881
+ "fill",
1882
+ "select",
1883
+ "scroll"
1884
+ ]);
1885
+ var CAMERA = /* @__PURE__ */ new Set(["click", "fill", "select"]);
1886
+ async function buildRenderPlan(runDir2, options = {}) {
1887
+ const timeline = TimelineSchema.parse(await readJson(path4.join(runDir2, PLAN_INPUTS.timeline)));
1888
+ const result = RunResultSchema.parse(await readJson(path4.join(runDir2, PLAN_INPUTS.result)));
1889
+ const videoFile = path4.join(runDir2, timeline.videoFile);
1890
+ const source = await probeVideo(videoFile, {
1891
+ freezes: {
1892
+ minMs: options.idle?.thresholdMs ?? DEFAULTS.idleThresholdMs
1893
+ }
1894
+ });
1895
+ const syncClearMs = timeline.syncMark === void 0 ? void 0 : await findSyncClear(videoFile, source.fps, timeline.syncMark);
1896
+ return planRender({
1897
+ timeline,
1898
+ result,
1899
+ source,
1900
+ ...syncClearMs === void 0 ? {} : { syncClearMs }
1901
+ }, options);
1902
+ }
1903
+ async function readJson(file) {
1904
+ return JSON.parse(await readFile(file, "utf8"));
1905
+ }
1906
+ function planRender(input, options = {}) {
1907
+ const { timeline, result, source } = input;
1908
+ const measuredMs = timeline.syncMark === void 0 ? void 0 : syncOffset(timeline.syncMark, input.syncClearMs);
1909
+ const offsetMs = options.offsetMs ?? measuredMs ?? DEFAULT_OFFSET_MS;
1910
+ const offsetMeasured = options.offsetMs === void 0 && measuredMs !== void 0;
1911
+ const maskLeadMs = offsetMeasured ? 1e3 / source.fps : TIMING.maskLeadMs;
1912
+ const maskTrailMs = offsetMeasured ? 1e3 / source.fps : TIMING.maskTrailMs;
1913
+ const startMs = measuredMs === void 0 ? 0 : clamp(input.syncClearMs ?? 0, 0, source.durationMs);
1914
+ const thresholdMs = options.idle?.thresholdMs ?? DEFAULTS.idleThresholdMs;
1915
+ const compressToMs = options.idle?.compressToMs ?? DEFAULTS.idleCompressToMs;
1916
+ const output = {
1917
+ width: options.output?.width ?? DEFAULTS.output.width,
1918
+ height: options.output?.height ?? DEFAULTS.output.height,
1919
+ fps: options.output?.fps ?? DEFAULTS.output.fps
1920
+ };
1921
+ const viewport = timeline.viewport;
1922
+ const sourceScale = source.width / viewport.width;
1923
+ const maxZoom = options.zoom ?? (sourceScale >= 2 ? DEFAULTS.zoom : DEFAULTS.zoomOnLowResSource);
1924
+ const events = timeline.events;
1925
+ const failure2 = failedStage(result);
1926
+ const toSource = (timelineMs) => clamp(timelineMs + offsetMs, 0, source.durationMs);
1927
+ const failedEvent = failure2?.stepIndex === void 0 ? void 0 : events.find((event) => event.stepIndex === failure2.stepIndex);
1928
+ const lastEvent = events.at(-1);
1929
+ const sourceEndMs = failure2 !== void 0 ? toSource(failedEvent?.endMs ?? lastEvent?.endMs ?? source.durationMs) : lastEvent === void 0 ? source.durationMs : toSource(lastEvent.endMs + TIMING.tailMs);
1930
+ const active = events.filter((event) => ACTIVE.has(event.action)).map((event) => ({
1931
+ startMs: toSource(event.startMs),
1932
+ endMs: toSource(event.endMs)
1933
+ }));
1934
+ const idle = idleStretches(source.freezes, active, thresholdMs, sourceEndMs);
1935
+ const timeMap = buildTimeMap(idle, sourceEndMs, compressToMs, startMs);
1936
+ const out = (timelineMs) => toOutputMs(timeMap, toSource(timelineMs));
1937
+ const videoEndMs = timeMap.at(-1)?.outputEndMs ?? 0;
1938
+ const failureSpec = failure2 === void 0 ? void 0 : failureCard(failure2, videoEndMs, TIMING.endCardMs);
1939
+ return {
1940
+ version: 1,
1941
+ output: {
1942
+ ...output,
1943
+ codec: "h264",
1944
+ pixelFormat: "yuv420p",
1945
+ faststart: true
1946
+ },
1947
+ source: {
1948
+ file: timeline.videoFile,
1949
+ width: source.width,
1950
+ height: source.height,
1951
+ fps: source.fps,
1952
+ durationMs: source.durationMs,
1953
+ scale: sourceScale
1954
+ },
1955
+ viewport,
1956
+ fit: fitInto(viewport, output),
1957
+ offsetMs,
1958
+ offsetMeasured,
1959
+ maskLeadMs,
1960
+ maskTrailMs,
1961
+ durationMs: videoEndMs + (failureSpec?.endCard.durationMs ?? 0),
1962
+ timeMap,
1963
+ cursor: cursorMoves(events, out),
1964
+ clicks: clickRings(events, out),
1965
+ camera: cameraKeyframes(events, out, viewport, maxZoom),
1966
+ masks: maskSpans(events, out, viewport, videoEndMs, {
1967
+ leadMs: maskLeadMs,
1968
+ trailMs: maskTrailMs
1969
+ }),
1970
+ captions: captions(events, out, videoEndMs),
1971
+ ...failureSpec === void 0 ? {} : { failure: failureSpec }
1972
+ };
1973
+ }
1974
+ function clamp(value, min, max) {
1975
+ return Math.min(max, Math.max(min, value));
1976
+ }
1977
+ function failedStage(result) {
1978
+ for (const stage of [
1979
+ "readiness",
1980
+ "execution",
1981
+ "verification",
1982
+ "recording"
1983
+ ]) {
1984
+ const state = result.stages[stage];
1985
+ if (state.status === "failed") {
1986
+ return {
1987
+ stage,
1988
+ message: state.error?.message ?? "no message",
1989
+ ...state.error?.stepIndex === void 0 ? {} : { stepIndex: state.error.stepIndex }
1990
+ };
1991
+ }
1992
+ }
1993
+ return void 0;
1994
+ }
1995
+ function failureCard(failure2, startMs, durationMs) {
1996
+ const stage = failure2.stage[0].toUpperCase() + failure2.stage.slice(1);
1997
+ const stepNumber = failure2.stepIndex === void 0 ? void 0 : failure2.stepIndex + 1;
1998
+ const where = stepNumber === void 0 ? "" : ` at step ${stepNumber}`;
1999
+ return {
2000
+ stage: failure2.stage,
2001
+ ...stepNumber === void 0 ? {} : { stepNumber },
2002
+ message: failure2.message,
2003
+ watermark: true,
2004
+ endCard: {
2005
+ startMs,
2006
+ durationMs,
2007
+ text: `${stage} failed${where}: ${failure2.message}`
2008
+ }
2009
+ };
2010
+ }
2011
+ function fitInto(viewport, output) {
2012
+ const scale = Math.min(output.width / viewport.width, output.height / viewport.height);
2013
+ return {
2014
+ x: (output.width - viewport.width * scale) / 2,
2015
+ y: (output.height - viewport.height * scale) / 2,
2016
+ scale
2017
+ };
2018
+ }
2019
+ function pressMs(event) {
2020
+ return event.action === "click" ? Math.max(event.startMs, event.endMs - TIMING.pressToEndMs) : event.endMs;
2021
+ }
2022
+ function cursorMoves(events, out) {
2023
+ return events.flatMap((event) => event.pointer === void 0 ? [] : [
2024
+ {
2025
+ startMs: out(event.startMs),
2026
+ endMs: out(pressMs(event)),
2027
+ from: event.pointer.from,
2028
+ to: event.pointer.to
2029
+ }
2030
+ ]);
2031
+ }
2032
+ function clickRings(events, out) {
2033
+ return events.flatMap((event) => event.action !== "click" || event.pointer === void 0 ? [] : [
2034
+ {
2035
+ atMs: out(pressMs(event)),
2036
+ durationMs: TIMING.clickRingMs,
2037
+ point: event.pointer.to
2038
+ }
2039
+ ]);
2040
+ }
2041
+ function cameraKeyframes(events, out, viewport, maxZoom) {
2042
+ const shots = events.flatMap((event) => {
2043
+ if (!CAMERA.has(event.action) || event.target === void 0)
2044
+ return [];
2045
+ const { box } = event.target;
2046
+ const zoom = Math.max(1, Math.min(maxZoom, TIMING.targetFill * viewport.width / Math.max(box.width, 1), TIMING.targetFill * viewport.height / Math.max(box.height, 1)));
2047
+ const action = event.action === "click" ? { startMs: out(pressMs(event)), endMs: out(pressMs(event)) } : { startMs: out(event.startMs), endMs: out(event.endMs) };
2048
+ return [{ ...action, centre: centreOf(box), zoom }];
2049
+ });
2050
+ const keys = [];
2051
+ const push = (atMs, centre, zoom) => {
2052
+ const last = keys.at(-1);
2053
+ keys.push({ atMs: Math.max(atMs, last?.atMs ?? 0, 0), centre, zoom });
2054
+ };
2055
+ const zoomOut = (shot) => {
2056
+ push(shot.endMs, shot.centre, shot.zoom);
2057
+ push(shot.endMs + TIMING.zoomOutMs, shot.centre, 1);
2058
+ };
2059
+ let previous;
2060
+ for (const shot of shots) {
2061
+ let arriveMs = shot.startMs;
2062
+ if (previous === void 0) {
2063
+ const fromMs = Math.max(0, shot.startMs - TIMING.zoomInMs);
2064
+ push(fromMs, shot.centre, 1);
2065
+ arriveMs = Math.max(arriveMs, fromMs + TIMING.zoomInMs);
2066
+ } else if (distance(previous.centre, shot.centre) < TIMING.keepZoomWithinPx || shot.startMs - TIMING.zoomInMs < previous.endMs + TIMING.zoomOutMs) {
2067
+ const fromMs = clamp(shot.startMs - TIMING.zoomInMs, previous.startMs, previous.endMs);
2068
+ push(fromMs, previous.centre, previous.zoom);
2069
+ arriveMs = Math.max(arriveMs, fromMs + TIMING.minPanMs);
2070
+ } else {
2071
+ zoomOut(previous);
2072
+ push(shot.startMs - TIMING.zoomInMs, shot.centre, 1);
2073
+ }
2074
+ push(arriveMs, shot.centre, shot.zoom);
2075
+ previous = shot;
2076
+ }
2077
+ if (previous !== void 0)
2078
+ zoomOut(previous);
2079
+ return keys;
2080
+ }
2081
+ function centreOf(box) {
2082
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
2083
+ }
2084
+ function distance(a, b) {
2085
+ return Math.hypot(a.x - b.x, a.y - b.y);
2086
+ }
2087
+ function maskSpans(events, out, viewport, videoEndMs, { leadMs, trailMs }) {
2088
+ const spans = [];
2089
+ for (const [index, event] of events.entries()) {
2090
+ if (event.masked !== true)
2091
+ continue;
2092
+ if (event.maskVisibility !== void 0) {
2093
+ for (const interval of event.maskVisibility) {
2094
+ const startMs2 = Math.max(0, out(interval.startMs) - leadMs);
2095
+ const endMs2 = interval.endMs === void 0 ? videoEndMs : Math.min(out(interval.endMs) + trailMs, videoEndMs);
2096
+ let box2 = grow(interval.box, TIMING.maskMarginPx);
2097
+ const keys2 = [{ atMs: startMs2, box: box2 }];
2098
+ for (const later of events.slice(index + 1)) {
2099
+ const scroll2 = later.scroll;
2100
+ if (scroll2 === void 0 || scroll2.container !== void 0)
2101
+ continue;
2102
+ if (later.startMs < interval.startMs || interval.endMs !== void 0 && later.startMs >= interval.endMs) {
2103
+ continue;
2104
+ }
2105
+ keys2.push({ atMs: Math.min(out(later.startMs), endMs2), box: box2 });
2106
+ box2 = {
2107
+ ...box2,
2108
+ x: box2.x - (scroll2.after.x - scroll2.before.x),
2109
+ y: box2.y - (scroll2.after.y - scroll2.before.y)
2110
+ };
2111
+ keys2.push({ atMs: Math.min(out(later.endMs), endMs2), box: box2 });
2112
+ }
2113
+ spans.push({ startMs: startMs2, endMs: endMs2, keys: keys2 });
2114
+ }
2115
+ continue;
2116
+ }
2117
+ if (event.target === void 0) {
2118
+ spans.push(wholeViewport(event, out, viewport, videoEndMs, leadMs));
2119
+ continue;
2120
+ }
2121
+ let box = grow(event.target.box, TIMING.maskMarginPx);
2122
+ const startMs = Math.max(0, out(event.startMs) - leadMs);
2123
+ const keys = [{ atMs: startMs, box }];
2124
+ let endMs = videoEndMs;
2125
+ for (const later of events.slice(index + 1)) {
2126
+ if (later.action === "navigate") {
2127
+ endMs = out(later.endMs) + trailMs;
2128
+ break;
2129
+ }
2130
+ const scroll2 = later.scroll;
2131
+ if (scroll2 === void 0 || scroll2.container !== void 0)
2132
+ continue;
2133
+ keys.push({ atMs: out(later.startMs), box });
2134
+ box = {
2135
+ ...box,
2136
+ x: box.x - (scroll2.after.x - scroll2.before.x),
2137
+ y: box.y - (scroll2.after.y - scroll2.before.y)
2138
+ };
2139
+ keys.push({ atMs: out(later.endMs), box });
2140
+ if (!overlaps(box, viewport)) {
2141
+ endMs = out(later.endMs) + trailMs;
2142
+ break;
2143
+ }
2144
+ }
2145
+ spans.push({ startMs, endMs: Math.min(endMs, videoEndMs), keys });
2146
+ }
2147
+ return spans;
2148
+ }
2149
+ function wholeViewport(event, out, viewport, videoEndMs, leadMs) {
2150
+ const startMs = Math.max(0, out(event.startMs) - leadMs);
2151
+ const box = grow({ x: 0, y: 0, width: viewport.width, height: viewport.height }, TIMING.maskMarginPx);
2152
+ return { startMs, endMs: videoEndMs, keys: [{ atMs: startMs, box }] };
2153
+ }
2154
+ function grow(box, by) {
2155
+ return {
2156
+ x: box.x - by,
2157
+ y: box.y - by,
2158
+ width: box.width + 2 * by,
2159
+ height: box.height + 2 * by
2160
+ };
2161
+ }
2162
+ function overlaps(box, viewport) {
2163
+ return box.x < viewport.width && box.y < viewport.height && box.x + box.width > 0 && box.y + box.height > 0;
2164
+ }
2165
+ function captions(events, out, videoEndMs) {
2166
+ const list = events.flatMap((event) => {
2167
+ const text = event.marker?.kind === "caption" ? event.marker.title : event.caption;
2168
+ if (text === void 0 || text.trim() === "")
2169
+ return [];
2170
+ return [
2171
+ {
2172
+ startMs: out(event.startMs),
2173
+ endMs: Math.min(videoEndMs, out(event.endMs) + TIMING.captionTailMs),
2174
+ text
2175
+ }
2176
+ ];
2177
+ });
2178
+ for (const [index, caption] of list.entries()) {
2179
+ const next = list[index + 1];
2180
+ if (next !== void 0) {
2181
+ caption.endMs = Math.min(caption.endMs, next.startMs);
2182
+ }
2183
+ }
2184
+ return list.filter((caption) => caption.endMs > caption.startMs);
2185
+ }
2186
+
2187
+ // ../../packages/renderer/dist/renderer.js
2188
+ function renderPath(runDir2, renderer) {
2189
+ return path5.join(runDir2, "render", `${renderer}.mp4`);
2190
+ }
2191
+ async function recordRender(runDir2, renderer, file, draw) {
2192
+ await updateResult(runDir2, (result) => {
2193
+ result.stages.rendering = { status: "running", startedAt: timestamp2() };
2194
+ });
2195
+ try {
2196
+ await mkdir3(path5.dirname(file), { recursive: true });
2197
+ await draw();
2198
+ const probe = await probeVideo(file);
2199
+ const output = {
2200
+ renderer,
2201
+ // Run-relative and `/`-separated on every platform: it goes into JSON.
2202
+ file: path5.relative(runDir2, file).split(path5.sep).join("/"),
2203
+ durationMs: Math.round(probe.durationMs),
2204
+ sizeBytes: (await stat(file)).size,
2205
+ width: probe.width,
2206
+ height: probe.height,
2207
+ fps: probe.fps
2208
+ };
2209
+ await updateResult(runDir2, (result) => {
2210
+ result.renders = [
2211
+ ...result.renders.filter((entry2) => entry2.renderer !== renderer),
2212
+ output
2213
+ ];
2214
+ result.stages.rendering = finish2(result.stages.rendering, "passed");
2215
+ });
2216
+ return output;
2217
+ } catch (error) {
2218
+ const message = error instanceof Error ? error.message : String(error);
2219
+ await updateResult(runDir2, (result) => {
2220
+ result.stages.rendering = finish2(result.stages.rendering, "failed", {
2221
+ message
2222
+ });
2223
+ });
2224
+ throw error;
2225
+ }
2226
+ }
2227
+ var queues = /* @__PURE__ */ new Map();
2228
+ function updateResult(runDir2, change) {
2229
+ const file = path5.resolve(runDir2, PLAN_INPUTS.result);
2230
+ const run = async () => {
2231
+ const result = RunResultSchema.parse(JSON.parse(await readFile2(file, "utf8")));
2232
+ change(result);
2233
+ result.status = deriveRunStatus(result.stages, result.warnings);
2234
+ const valid = RunResultSchema.parse(result);
2235
+ const temporary = `${file}.${process.pid}.tmp`;
2236
+ await writeFile2(temporary, `${JSON.stringify(valid, null, 2)}
2237
+ `);
2238
+ await rename2(temporary, file);
2239
+ };
2240
+ const next = (queues.get(file) ?? Promise.resolve()).then(run, run);
2241
+ queues.set(file, next.catch(() => void 0));
2242
+ return next;
2243
+ }
2244
+ function finish2(stage, status, error) {
2245
+ return {
2246
+ ...stage,
2247
+ status,
2248
+ finishedAt: timestamp2(),
2249
+ ...error === void 0 ? {} : { error }
2250
+ };
2251
+ }
2252
+ function timestamp2() {
2253
+ return (/* @__PURE__ */ new Date()).toISOString();
2254
+ }
2255
+
2256
+ // ../../packages/renderer/dist/ffmpeg/pipeline.js
2257
+ import { spawn as spawn2 } from "node:child_process";
2258
+
2259
+ // ../../packages/renderer/dist/plan/frame.js
2260
+ function frameCount(plan) {
2261
+ return Math.max(1, Math.round(plan.durationMs / 1e3 * plan.output.fps));
2262
+ }
2263
+ function easeInOut(t) {
2264
+ const x = Math.min(1, Math.max(0, t));
2265
+ return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2;
2266
+ }
2267
+ function frameAt(plan, frame) {
2268
+ const atMs = frame * 1e3 / plan.output.fps;
2269
+ const card = plan.failure?.endCard;
2270
+ const onCard = card !== void 0 && atMs >= card.startMs;
2271
+ const view = cameraAt(plan, onCard ? card.startMs : atMs);
2272
+ const { fit, viewport, source } = plan;
2273
+ const content = {
2274
+ x: fit.x,
2275
+ y: fit.y,
2276
+ width: viewport.width * fit.scale,
2277
+ height: viewport.height * fit.scale
2278
+ };
2279
+ const scale = content.width / view.width;
2280
+ const toOutput = (point) => ({
2281
+ x: content.x + (point.x - view.x) * scale,
2282
+ y: content.y + (point.y - view.y) * scale
2283
+ });
2284
+ const crop = {
2285
+ x: view.x * source.scale,
2286
+ y: view.y * source.scale,
2287
+ width: view.width * source.scale,
2288
+ height: view.height * source.scale
2289
+ };
2290
+ const watermark = plan.failure !== void 0;
2291
+ if (onCard) {
2292
+ return {
2293
+ sourceMs: null,
2294
+ crop,
2295
+ content,
2296
+ scale,
2297
+ rings: [],
2298
+ masks: [],
2299
+ watermark,
2300
+ endCard: card.text
2301
+ };
2302
+ }
2303
+ const cursor = cursorAt(plan.cursor, atMs);
2304
+ const rings = plan.clicks.flatMap((ring) => {
2305
+ const progress = (atMs - ring.atMs) / ring.durationMs;
2306
+ return progress >= 0 && progress < 1 ? [{ point: toOutput(ring.point), progress }] : [];
2307
+ });
2308
+ const masks = plan.masks.flatMap((mask) => {
2309
+ if (atMs < mask.startMs || atMs >= mask.endMs)
2310
+ return [];
2311
+ const box = maskBoxAt(mask, atMs);
2312
+ const corner = toOutput(box);
2313
+ const clipped = intersect({
2314
+ x: corner.x,
2315
+ y: corner.y,
2316
+ width: box.width * scale,
2317
+ height: box.height * scale
2318
+ }, content);
2319
+ return clipped === void 0 ? [] : [clipped];
2320
+ });
2321
+ const caption = plan.captions.find((entry2) => atMs >= entry2.startMs && atMs < entry2.endMs)?.text;
2322
+ return {
2323
+ sourceMs: toSourceMs(plan.timeMap, atMs),
2324
+ crop,
2325
+ content,
2326
+ scale,
2327
+ ...cursor === void 0 ? {} : { cursor: toOutput(cursor) },
2328
+ rings,
2329
+ masks,
2330
+ ...caption === void 0 ? {} : { caption },
2331
+ watermark
2332
+ };
2333
+ }
2334
+ function cameraAt(plan, atMs) {
2335
+ const { centre, zoom } = cameraStateAt(plan.camera, atMs);
2336
+ const { width: vw, height: vh } = plan.viewport;
2337
+ const width = vw / zoom;
2338
+ const height = vh / zoom;
2339
+ return {
2340
+ x: Math.min(vw - width, Math.max(0, centre.x - width / 2)),
2341
+ y: Math.min(vh - height, Math.max(0, centre.y - height / 2)),
2342
+ width,
2343
+ height
2344
+ };
2345
+ }
2346
+ function cameraStateAt(keys, atMs) {
2347
+ const next = keys.findIndex((key) => key.atMs > atMs);
2348
+ if (next === 0 || keys.length === 0) {
2349
+ return { centre: keys[0]?.centre ?? { x: 0, y: 0 }, zoom: 1 };
2350
+ }
2351
+ if (next === -1) {
2352
+ const last = keys[keys.length - 1];
2353
+ return { centre: last.centre, zoom: last.zoom };
2354
+ }
2355
+ const a = keys[next - 1];
2356
+ const b = keys[next];
2357
+ const t = easeInOut((atMs - a.atMs) / (b.atMs - a.atMs));
2358
+ return {
2359
+ centre: mix(a.centre, b.centre, t),
2360
+ zoom: a.zoom + (b.zoom - a.zoom) * t
2361
+ };
2362
+ }
2363
+ function cursorAt(moves, atMs) {
2364
+ let current;
2365
+ for (const move of moves) {
2366
+ if (move.startMs > atMs)
2367
+ break;
2368
+ current = move;
2369
+ }
2370
+ if (current === void 0)
2371
+ return void 0;
2372
+ const length = current.endMs - current.startMs;
2373
+ const t = length <= 0 ? 1 : easeInOut((atMs - current.startMs) / length);
2374
+ return mix(current.from, current.to, t);
2375
+ }
2376
+ function maskBoxAt(mask, atMs) {
2377
+ const next = mask.keys.findIndex((key) => key.atMs > atMs);
2378
+ if (next === 0)
2379
+ return mask.keys[0].box;
2380
+ if (next === -1)
2381
+ return mask.keys[mask.keys.length - 1].box;
2382
+ const a = mask.keys[next - 1];
2383
+ const b = mask.keys[next];
2384
+ const t = b.atMs === a.atMs ? 1 : (atMs - a.atMs) / (b.atMs - a.atMs);
2385
+ return { ...a.box, ...mix(a.box, b.box, t) };
2386
+ }
2387
+ function mix(a, b, t) {
2388
+ return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
2389
+ }
2390
+ function intersect(a, b) {
2391
+ const x = Math.max(a.x, b.x);
2392
+ const y = Math.max(a.y, b.y);
2393
+ const right = Math.min(a.x + a.width, b.x + b.width);
2394
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
2395
+ return right > x && bottom > y ? { x, y, width: right - x, height: bottom - y } : void 0;
2396
+ }
2397
+
2398
+ // ../../packages/renderer/dist/ffmpeg/font.js
2399
+ import { fileURLToPath } from "node:url";
2400
+
2401
+ // ../../packages/renderer/dist/ffmpeg/opentype.js
2402
+ import { readFileSync as readFileSync3 } from "node:fs";
2403
+ import { createRequire as createRequire2 } from "node:module";
2404
+ var opentype = createRequire2(import.meta.url)("opentype.js");
2405
+ function loadFont(file) {
2406
+ const bytes = readFileSync3(file);
2407
+ return opentype.parse(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
2408
+ }
2409
+
2410
+ // ../../packages/renderer/dist/ffmpeg/outline.js
2411
+ var TOLERANCE = 0.05;
2412
+ function fillPath(commands, scale, dx) {
2413
+ const lines = flatten(commands, scale, dx);
2414
+ if (lines.length === 0) {
2415
+ return { x: 0, y: 0, width: 0, height: 0, alpha: new Float32Array(0) };
2416
+ }
2417
+ let left = Number.POSITIVE_INFINITY;
2418
+ let top = Number.POSITIVE_INFINITY;
2419
+ let right = Number.NEGATIVE_INFINITY;
2420
+ let bottom = Number.NEGATIVE_INFINITY;
2421
+ for (let i = 0; i < lines.length; i += 2) {
2422
+ left = Math.min(left, lines[i]);
2423
+ right = Math.max(right, lines[i]);
2424
+ top = Math.min(top, lines[i + 1]);
2425
+ bottom = Math.max(bottom, lines[i + 1]);
2426
+ }
2427
+ const x = Math.floor(left);
2428
+ const y = Math.floor(top);
2429
+ const width = Math.max(1, Math.ceil(right) - x);
2430
+ const height = Math.max(1, Math.ceil(bottom) - y);
2431
+ const stride = width + 2;
2432
+ const accumulation = new Float32Array(stride * height);
2433
+ for (let i = 0; i + 3 < lines.length; i += 4) {
2434
+ accumulate(accumulation, stride, height, lines[i] - x, lines[i + 1] - y, lines[i + 2] - x, lines[i + 3] - y);
2435
+ }
2436
+ const alpha = new Float32Array(width * height);
2437
+ for (let row = 0; row < height; row++) {
2438
+ let sum = 0;
2439
+ for (let column = 0; column < width; column++) {
2440
+ sum += accumulation[row * stride + column];
2441
+ alpha[row * width + column] = Math.round(Math.min(1, Math.abs(sum)) * 255) / 255;
2442
+ }
2443
+ }
2444
+ return { x, y, width, height, alpha };
2445
+ }
2446
+ function flatten(commands, scale, dx) {
2447
+ const out = [];
2448
+ let startX = 0;
2449
+ let startY = 0;
2450
+ let penX = 0;
2451
+ let penY = 0;
2452
+ let open = false;
2453
+ const lineTo = (x, y) => {
2454
+ if (x !== penX || y !== penY)
2455
+ out.push(penX, penY, x, y);
2456
+ penX = x;
2457
+ penY = y;
2458
+ };
2459
+ const close = () => {
2460
+ if (open)
2461
+ lineTo(startX, startY);
2462
+ open = false;
2463
+ };
2464
+ const px = (x) => dx + x * scale;
2465
+ const py = (y) => -y * scale;
2466
+ for (const command of commands) {
2467
+ switch (command.type) {
2468
+ case "M":
2469
+ close();
2470
+ startX = penX = px(command.x);
2471
+ startY = penY = py(command.y);
2472
+ open = true;
2473
+ break;
2474
+ case "L":
2475
+ lineTo(px(command.x), py(command.y));
2476
+ break;
2477
+ case "Q": {
2478
+ const x0 = penX;
2479
+ const y0 = penY;
2480
+ const x1 = px(command.x1);
2481
+ const y1 = py(command.y1);
2482
+ const x2 = px(command.x);
2483
+ const y2 = py(command.y);
2484
+ const bend = Math.hypot(x0 - 2 * x1 + x2, y0 - 2 * y1 + y2);
2485
+ const n = Math.max(1, Math.ceil(Math.sqrt(bend / (4 * TOLERANCE))));
2486
+ for (let i = 1; i <= n; i++) {
2487
+ const t = i / n;
2488
+ const u = 1 - t;
2489
+ lineTo(u * u * x0 + 2 * u * t * x1 + t * t * x2, u * u * y0 + 2 * u * t * y1 + t * t * y2);
2490
+ }
2491
+ break;
2492
+ }
2493
+ case "C": {
2494
+ const x0 = penX;
2495
+ const y0 = penY;
2496
+ const x1 = px(command.x1);
2497
+ const y1 = py(command.y1);
2498
+ const x2 = px(command.x2);
2499
+ const y2 = py(command.y2);
2500
+ const x3 = px(command.x);
2501
+ const y3 = py(command.y);
2502
+ const bend = Math.max(Math.hypot(x0 - 2 * x1 + x2, y0 - 2 * y1 + y2), Math.hypot(x1 - 2 * x2 + x3, y1 - 2 * y2 + y3));
2503
+ const n = Math.max(1, Math.ceil(Math.sqrt(0.75 * bend / TOLERANCE)));
2504
+ for (let i = 1; i <= n; i++) {
2505
+ const t = i / n;
2506
+ const u = 1 - t;
2507
+ lineTo(u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3, u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3);
2508
+ }
2509
+ break;
2510
+ }
2511
+ case "Z":
2512
+ close();
2513
+ break;
2514
+ }
2515
+ }
2516
+ close();
2517
+ return out;
2518
+ }
2519
+ function accumulate(buffer, stride, height, ax, ay, bx, by) {
2520
+ if (ay === by)
2521
+ return;
2522
+ let x0 = ax;
2523
+ let y0 = ay;
2524
+ let x1 = bx;
2525
+ let y1 = by;
2526
+ let direction = 1;
2527
+ if (y0 > y1) {
2528
+ direction = -1;
2529
+ [x0, y0, x1, y1] = [x1, y1, x0, y0];
2530
+ }
2531
+ const slope = (x1 - x0) / (y1 - y0);
2532
+ let x = x0;
2533
+ if (y0 < 0) {
2534
+ x -= y0 * slope;
2535
+ y0 = 0;
2536
+ }
2537
+ const last = Math.min(height, Math.ceil(y1));
2538
+ for (let row = Math.max(0, Math.floor(y0)); row < last; row++) {
2539
+ const base = row * stride;
2540
+ const dy = Math.min(row + 1, y1) - Math.max(row, y0);
2541
+ const next = x + slope * dy;
2542
+ const d = dy * direction;
2543
+ const lo = Math.min(x, next);
2544
+ const hi = Math.max(x, next);
2545
+ const loCell = Math.floor(lo);
2546
+ const hiCell = Math.ceil(hi);
2547
+ if (hiCell <= loCell + 1) {
2548
+ const share = (x + next) / 2 - loCell;
2549
+ buffer[base + loCell] += d - d * share;
2550
+ buffer[base + loCell + 1] += d * share;
2551
+ } else {
2552
+ const s = 1 / (hi - lo);
2553
+ const loFraction = lo - loCell;
2554
+ const first = 0.5 * s * (1 - loFraction) * (1 - loFraction);
2555
+ const hiFraction = hi - hiCell + 1;
2556
+ const end = 0.5 * s * hiFraction * hiFraction;
2557
+ buffer[base + loCell] += d * first;
2558
+ if (hiCell === loCell + 2) {
2559
+ buffer[base + loCell + 1] += d * (1 - first - end);
2560
+ } else {
2561
+ const second = s * (1.5 - loFraction);
2562
+ buffer[base + loCell + 1] += d * (second - first);
2563
+ for (let cell = loCell + 2; cell < hiCell - 1; cell++) {
2564
+ buffer[base + cell] += d * s;
2565
+ }
2566
+ const before = second + (hiCell - loCell - 3) * s;
2567
+ buffer[base + hiCell - 1] += d * (1 - before - end);
2568
+ }
2569
+ buffer[base + hiCell] += d * end;
2570
+ }
2571
+ x = next;
2572
+ }
2573
+ }
2574
+
2575
+ // ../../packages/renderer/dist/ffmpeg/font.js
2576
+ var FILES = {
2577
+ regular: "Inter-Regular.ttf",
2578
+ semibold: "Inter-SemiBold.ttf"
2579
+ };
2580
+ var faces = /* @__PURE__ */ new Map();
2581
+ function face(weight) {
2582
+ let loaded = faces.get(weight);
2583
+ if (loaded === void 0) {
2584
+ const font = loadFont(fileURLToPath(new URL(`../../fonts/${FILES[weight]}`, import.meta.url)));
2585
+ loaded = {
2586
+ font,
2587
+ capUnits: font.tables.os2.sCapHeight,
2588
+ descentUnits: -font.descender,
2589
+ fallback: font.charToGlyphIndex("?")
2590
+ };
2591
+ faces.set(weight, loaded);
2592
+ }
2593
+ return loaded;
2594
+ }
2595
+ var CAP_RATIO = 0.72;
2596
+ function capHeight(style) {
2597
+ return Math.round(style.fontPx * CAP_RATIO);
2598
+ }
2599
+ function unitOf(style) {
2600
+ return capHeight(style) / face(style.weight).capUnits;
2601
+ }
2602
+ function shape(text, weight) {
2603
+ const { font, fallback } = face(weight);
2604
+ const shaped = [];
2605
+ let pen = 0;
2606
+ let previous = -1;
2607
+ for (const char of text) {
2608
+ const index = font.charToGlyphIndex(char);
2609
+ const glyph = index === 0 ? fallback : index;
2610
+ if (previous >= 0)
2611
+ pen += font.getKerningValue(previous, glyph);
2612
+ shaped.push({ glyph, at: pen });
2613
+ pen += font.glyphs.get(glyph).advanceWidth ?? 0;
2614
+ previous = glyph;
2615
+ }
2616
+ shaped.push({ glyph: -1, at: pen });
2617
+ return shaped;
2618
+ }
2619
+ function measureText(text, style) {
2620
+ const shaped = shape(text, style.weight);
2621
+ return shaped[shaped.length - 1].at * unitOf(style);
2622
+ }
2623
+ function wrapText(text, style, maxWidth) {
2624
+ const lines = [];
2625
+ for (const paragraph of text.split("\n")) {
2626
+ let line = "";
2627
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
2628
+ const candidate = line === "" ? word : `${line} ${word}`;
2629
+ if (measureText(candidate, style) <= maxWidth) {
2630
+ line = candidate;
2631
+ continue;
2632
+ }
2633
+ if (line !== "")
2634
+ lines.push(line);
2635
+ line = "";
2636
+ for (const char of word) {
2637
+ if (line !== "" && measureText(line + char, style) > maxWidth) {
2638
+ lines.push(line);
2639
+ line = "";
2640
+ }
2641
+ line += char;
2642
+ }
2643
+ }
2644
+ lines.push(line);
2645
+ }
2646
+ return lines;
2647
+ }
2648
+ function lineHeight(style) {
2649
+ return style.fontPx * 1.25;
2650
+ }
2651
+ function descent(style) {
2652
+ return unitOf(style) * face(style.weight).descentUnits;
2653
+ }
2654
+ var PHASES = 4;
2655
+ var bitmaps = /* @__PURE__ */ new Map();
2656
+ function bitmap(weight, unit, glyph, phase) {
2657
+ const key = `${weight}/${unit}/${glyph}/${phase}`;
2658
+ let mask = bitmaps.get(key);
2659
+ if (mask === void 0) {
2660
+ const { commands } = face(weight).font.glyphs.get(glyph).path;
2661
+ mask = fillPath(commands, unit, phase / PHASES);
2662
+ bitmaps.set(key, mask);
2663
+ }
2664
+ return mask;
2665
+ }
2666
+ function textGlyphs(text, style, x, y) {
2667
+ const unit = unitOf(style);
2668
+ const baseline = Math.round(y);
2669
+ const placed = [];
2670
+ for (const { glyph, at } of shape(text, style.weight)) {
2671
+ if (glyph < 0)
2672
+ break;
2673
+ const steps = Math.round((x + at * unit) * PHASES);
2674
+ const whole = Math.floor(steps / PHASES);
2675
+ const mask = bitmap(style.weight, unit, glyph, steps - whole * PHASES);
2676
+ if (mask.width > 0)
2677
+ placed.push({ mask, x: whole, y: baseline });
2678
+ }
2679
+ return placed;
2680
+ }
2681
+
2682
+ // ../../packages/renderer/dist/ffmpeg/raster.js
2683
+ function hex(value) {
2684
+ const n = Number.parseInt(value.slice(1), 16);
2685
+ return { r: n >> 16 & 255, g: n >> 8 & 255, b: n & 255 };
2686
+ }
2687
+ function createPicture(width, height) {
2688
+ return { width, height, data: new Uint8Array(width * height * 3) };
2689
+ }
2690
+ function fill(picture, colour) {
2691
+ const { data } = picture;
2692
+ for (let i = 0; i < data.length; i += 3) {
2693
+ data[i] = colour.r;
2694
+ data[i + 1] = colour.g;
2695
+ data[i + 2] = colour.b;
2696
+ }
2697
+ }
2698
+ function blendAt(data, index, colour, alpha) {
2699
+ const keep = 1 - alpha;
2700
+ data[index] = data[index] * keep + colour.r * alpha;
2701
+ data[index + 1] = data[index + 1] * keep + colour.g * alpha;
2702
+ data[index + 2] = data[index + 2] * keep + colour.b * alpha;
2703
+ }
2704
+ function pixelBounds(picture, box) {
2705
+ return [
2706
+ Math.max(0, Math.round(box.x)),
2707
+ Math.max(0, Math.round(box.y)),
2708
+ Math.min(picture.width, Math.round(box.x + box.width)),
2709
+ Math.min(picture.height, Math.round(box.y + box.height))
2710
+ ];
2711
+ }
2712
+ function drawScaled(target, source, crop, content) {
2713
+ const [x0, y0, x1, y1] = pixelBounds(target, content);
2714
+ const columns = samples(x0, x1, content.x, crop.x, crop.width / content.width, source.width);
2715
+ const rows = samples(y0, y1, content.y, crop.y, crop.height / content.height, source.height);
2716
+ const src = source.data;
2717
+ const dst = target.data;
2718
+ const stride = source.width * 3;
2719
+ for (let y = y0; y < y1; y++) {
2720
+ const top = rows.from[y - y0] * stride;
2721
+ const bottom = rows.to[y - y0] * stride;
2722
+ const fy = rows.weight[y - y0];
2723
+ let out = (y * target.width + x0) * 3;
2724
+ for (let x = 0; x < x1 - x0; x++) {
2725
+ const left = columns.from[x] * 3;
2726
+ const right = columns.to[x] * 3;
2727
+ const fx = columns.weight[x];
2728
+ for (let c = 0; c < 3; c++) {
2729
+ const a = src[top + left + c];
2730
+ const b = src[top + right + c];
2731
+ const d = src[bottom + left + c];
2732
+ const e = src[bottom + right + c];
2733
+ const upper = a + (b - a) * fx;
2734
+ const lower = d + (e - d) * fx;
2735
+ dst[out + c] = upper + (lower - upper) * fy + 0.5;
2736
+ }
2737
+ out += 3;
2738
+ }
2739
+ }
2740
+ }
2741
+ function samples(start, end, contentStart, cropStart, step2, size) {
2742
+ const count = Math.max(0, end - start);
2743
+ const from = new Int32Array(count);
2744
+ const to = new Int32Array(count);
2745
+ const weight = new Float32Array(count);
2746
+ for (let i = 0; i < count; i++) {
2747
+ const at = Math.min(size - 1, Math.max(0, cropStart + (start + i + 0.5 - contentStart) * step2 - 0.5));
2748
+ from[i] = Math.floor(at);
2749
+ to[i] = Math.min(size - 1, from[i] + 1);
2750
+ weight[i] = at - from[i];
2751
+ }
2752
+ return { from, to, weight };
2753
+ }
2754
+ function fillBox(picture, box, colour, alpha) {
2755
+ const [x0, y0, x1, y1] = pixelBounds(picture, box);
2756
+ for (let y = y0; y < y1; y++) {
2757
+ for (let x = x0; x < x1; x++) {
2758
+ blendAt(picture.data, (y * picture.width + x) * 3, colour, alpha);
2759
+ }
2760
+ }
2761
+ }
2762
+ function blurBox(picture, box, sigma) {
2763
+ const [bx0, by0, bx1, by1] = pixelBounds(picture, box);
2764
+ if (bx1 <= bx0 || by1 <= by0)
2765
+ return;
2766
+ const margin = Math.ceil(sigma * 3);
2767
+ const x0 = Math.max(0, bx0 - margin);
2768
+ const y0 = Math.max(0, by0 - margin);
2769
+ const x1 = Math.min(picture.width, bx1 + margin);
2770
+ const y1 = Math.min(picture.height, by1 + margin);
2771
+ const width = x1 - x0;
2772
+ const height = y1 - y0;
2773
+ const channels = [0, 1, 2].map((c) => {
2774
+ const plane = new Float32Array(width * height);
2775
+ for (let y = 0; y < height; y++) {
2776
+ for (let x = 0; x < width; x++) {
2777
+ plane[y * width + x] = picture.data[((y + y0) * picture.width + x + x0) * 3 + c];
2778
+ }
2779
+ }
2780
+ return plane;
2781
+ });
2782
+ const scratch = new Float32Array(width * height);
2783
+ for (const size of gaussBoxes(sigma, 3)) {
2784
+ const radius = (size - 1) / 2;
2785
+ for (const plane of channels) {
2786
+ boxPass(plane, scratch, width, height, radius, true);
2787
+ boxPass(scratch, plane, width, height, radius, false);
2788
+ }
2789
+ }
2790
+ for (let y = by0; y < by1; y++) {
2791
+ for (let x = bx0; x < bx1; x++) {
2792
+ const at = (y - y0) * width + (x - x0);
2793
+ const index = (y * picture.width + x) * 3;
2794
+ for (let c = 0; c < 3; c++)
2795
+ picture.data[index + c] = channels[c][at];
2796
+ }
2797
+ }
2798
+ }
2799
+ function gaussBoxes(sigma, n) {
2800
+ const ideal = Math.sqrt(12 * sigma * sigma / n + 1);
2801
+ let lower = Math.floor(ideal);
2802
+ if (lower % 2 === 0)
2803
+ lower--;
2804
+ const upper = lower + 2;
2805
+ const m = Math.round((12 * sigma * sigma - n * lower * lower - 4 * n * lower - 3 * n) / (-4 * lower - 4));
2806
+ return Array.from({ length: n }, (_, i) => i < m ? lower : upper);
2807
+ }
2808
+ function boxPass(from, to, width, height, radius, horizontal) {
2809
+ const length = horizontal ? width : height;
2810
+ const lines = horizontal ? height : width;
2811
+ const step2 = horizontal ? 1 : width;
2812
+ const scale = 1 / (2 * radius + 1);
2813
+ for (let line = 0; line < lines; line++) {
2814
+ const base = horizontal ? line * width : line;
2815
+ const at = (i) => from[base + Math.min(length - 1, Math.max(0, i)) * step2];
2816
+ let sum = 0;
2817
+ for (let i = -radius; i <= radius; i++)
2818
+ sum += at(i);
2819
+ for (let i = 0; i < length; i++) {
2820
+ to[base + i * step2] = sum * scale;
2821
+ sum += at(i + radius + 1) - at(i - radius);
2822
+ }
2823
+ }
2824
+ }
2825
+ function paint(picture, bounds, colour, alpha, coverage) {
2826
+ const x0 = Math.max(0, Math.floor(bounds.x));
2827
+ const y0 = Math.max(0, Math.floor(bounds.y));
2828
+ const x1 = Math.min(picture.width, Math.ceil(bounds.x + bounds.width));
2829
+ const y1 = Math.min(picture.height, Math.ceil(bounds.y + bounds.height));
2830
+ for (let y = y0; y < y1; y++) {
2831
+ for (let x = x0; x < x1; x++) {
2832
+ const amount = coverage(x + 0.5, y + 0.5) * alpha;
2833
+ if (amount > 0) {
2834
+ blendAt(picture.data, (y * picture.width + x) * 3, colour, amount);
2835
+ }
2836
+ }
2837
+ }
2838
+ }
2839
+ function edge(distance2) {
2840
+ return Math.min(1, Math.max(0, distance2 + 0.5));
2841
+ }
2842
+ function roundRectDistance(box, radius, x, y) {
2843
+ const hx = box.width / 2 - radius;
2844
+ const hy = box.height / 2 - radius;
2845
+ const dx = Math.abs(x - (box.x + box.width / 2)) - hx;
2846
+ const dy = Math.abs(y - (box.y + box.height / 2)) - hy;
2847
+ const outside = Math.hypot(Math.max(dx, 0), Math.max(dy, 0));
2848
+ return outside + Math.min(Math.max(dx, dy), 0) - radius;
2849
+ }
2850
+ function polygonDistance(points, x, y) {
2851
+ let nearest = Number.POSITIVE_INFINITY;
2852
+ let inside = false;
2853
+ for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
2854
+ const a = points[j];
2855
+ const b = points[i];
2856
+ nearest = Math.min(nearest, segmentDistance(a.x, a.y, b.x, b.y, x, y));
2857
+ if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) {
2858
+ inside = !inside;
2859
+ }
2860
+ }
2861
+ return inside ? -nearest : nearest;
2862
+ }
2863
+ function segmentDistance(x1, y1, x2, y2, x, y) {
2864
+ const dx = x2 - x1;
2865
+ const dy = y2 - y1;
2866
+ const length = dx * dx + dy * dy;
2867
+ const t = length === 0 ? 0 : Math.min(1, Math.max(0, ((x - x1) * dx + (y - y1) * dy) / length));
2868
+ return Math.hypot(x - (x1 + t * dx), y - (y1 + t * dy));
2869
+ }
2870
+ function mergeMasks(parts) {
2871
+ if (parts.length === 0) {
2872
+ return { x: 0, y: 0, width: 0, height: 0, alpha: new Float32Array(0) };
2873
+ }
2874
+ let left = Number.POSITIVE_INFINITY;
2875
+ let top = Number.POSITIVE_INFINITY;
2876
+ let right = Number.NEGATIVE_INFINITY;
2877
+ let bottom = Number.NEGATIVE_INFINITY;
2878
+ for (const { mask, x, y } of parts) {
2879
+ left = Math.min(left, mask.x + x);
2880
+ top = Math.min(top, mask.y + y);
2881
+ right = Math.max(right, mask.x + x + mask.width);
2882
+ bottom = Math.max(bottom, mask.y + y + mask.height);
2883
+ }
2884
+ const width = right - left;
2885
+ const alpha = new Float32Array(width * (bottom - top));
2886
+ for (const { mask, x, y } of parts) {
2887
+ const ox = mask.x + x - left;
2888
+ const oy = mask.y + y - top;
2889
+ for (let my = 0; my < mask.height; my++) {
2890
+ const row = (oy + my) * width + ox;
2891
+ for (let mx = 0; mx < mask.width; mx++) {
2892
+ const amount = mask.alpha[my * mask.width + mx];
2893
+ if (amount > 0)
2894
+ alpha[row + mx] = Math.min(1, alpha[row + mx] + amount);
2895
+ }
2896
+ }
2897
+ }
2898
+ return { x: left, y: top, width, height: bottom - top, alpha };
2899
+ }
2900
+ function paintMask(picture, mask, colour, dx = 0, dy = 0) {
2901
+ const ox = mask.x + Math.round(dx);
2902
+ const oy = mask.y + Math.round(dy);
2903
+ for (let my = 0; my < mask.height; my++) {
2904
+ const y = oy + my;
2905
+ if (y < 0 || y >= picture.height)
2906
+ continue;
2907
+ for (let mx = 0; mx < mask.width; mx++) {
2908
+ const x = ox + mx;
2909
+ if (x < 0 || x >= picture.width)
2910
+ continue;
2911
+ const amount = mask.alpha[my * mask.width + mx];
2912
+ if (amount > 0) {
2913
+ blendAt(picture.data, (y * picture.width + x) * 3, colour, amount);
2914
+ }
2915
+ }
2916
+ }
2917
+ }
2918
+
2919
+ // ../../packages/renderer/dist/ffmpeg/compose.js
2920
+ var WHITE = hex("#ffffff");
2921
+ var BLACK = hex("#000000");
2922
+ var LOOK = {
2923
+ mask: { sigma: 12, wash: hex("#808080"), washAlpha: 0.35 },
2924
+ cursor: { fill: WHITE, outline: hex("#111111"), outlineWidth: 2 },
2925
+ ring: { colour: WHITE, width: 4, fromRadius: 12, toRadius: 36 },
2926
+ caption: {
2927
+ panel: BLACK,
2928
+ panelAlpha: 0.6,
2929
+ padding: 16,
2930
+ radius: 12,
2931
+ bottom: 64,
2932
+ maxWidth: 1600,
2933
+ text: { fontPx: 40, weight: "regular" }
2934
+ },
2935
+ watermark: {
2936
+ fill: hex("#d93025"),
2937
+ margin: 24,
2938
+ paddingX: 16,
2939
+ paddingY: 8,
2940
+ radius: 12,
2941
+ label: "FAILED",
2942
+ text: { fontPx: 32, weight: "semibold" }
2943
+ },
2944
+ endCard: {
2945
+ background: hex("#111111"),
2946
+ maxWidth: 1600,
2947
+ text: { fontPx: 48, weight: "regular" }
2948
+ }
2949
+ };
2950
+ var ARROW = [
2951
+ { x: 0, y: 0 },
2952
+ { x: 0, y: 23 },
2953
+ { x: 5.5, y: 18 },
2954
+ { x: 9.5, y: 27.6 },
2955
+ { x: 13.2, y: 26 },
2956
+ { x: 9.3, y: 16.6 },
2957
+ { x: 16.6, y: 16.6 }
2958
+ ];
2959
+ function createCompositor(output) {
2960
+ const captions2 = /* @__PURE__ */ new Map();
2961
+ const cards = /* @__PURE__ */ new Map();
2962
+ const badge = layBadge(output);
2963
+ return {
2964
+ compose(target, state, source) {
2965
+ if (state.endCard !== void 0) {
2966
+ fill(target, LOOK.endCard.background);
2967
+ const card = cached(cards, state.endCard, (text) => layEndCard(text, output));
2968
+ paintMask(target, card.mask, WHITE);
2969
+ } else {
2970
+ fill(target, BLACK);
2971
+ if (source !== void 0) {
2972
+ drawScaled(target, source, state.crop, state.content);
2973
+ }
2974
+ for (const box of state.masks) {
2975
+ blurBox(target, box, LOOK.mask.sigma);
2976
+ fillBox(target, box, LOOK.mask.wash, LOOK.mask.washAlpha);
2977
+ }
2978
+ for (const ring of state.rings) {
2979
+ drawRing(target, ring.point, ring.progress, state.scale);
2980
+ }
2981
+ if (state.cursor !== void 0) {
2982
+ drawCursor(target, state.cursor, state.scale);
2983
+ }
2984
+ if (state.caption !== void 0) {
2985
+ const caption = cached(captions2, state.caption, (text) => layCaption(text, output));
2986
+ const { panel } = LOOK.caption;
2987
+ drawPanel(target, caption.panel, panel, LOOK.caption.panelAlpha);
2988
+ paintMask(target, caption.mask, WHITE);
2989
+ }
2990
+ }
2991
+ if (state.watermark) {
2992
+ drawPanel(target, badge.panel, LOOK.watermark.fill, 1);
2993
+ paintMask(target, badge.mask, WHITE);
2994
+ }
2995
+ }
2996
+ };
2997
+ }
2998
+ function cached(store, text, lay) {
2999
+ let laid = store.get(text);
3000
+ if (laid === void 0) {
3001
+ laid = lay(text);
3002
+ store.set(text, laid);
3003
+ }
3004
+ return laid;
3005
+ }
3006
+ function drawRing(target, point, progress, scale) {
3007
+ const { colour, width, fromRadius, toRadius } = LOOK.ring;
3008
+ const radius = (fromRadius + (toRadius - fromRadius) * progress) * scale;
3009
+ const half = width * scale / 2;
3010
+ const reach = radius + half + 1;
3011
+ paint(target, {
3012
+ x: point.x - reach,
3013
+ y: point.y - reach,
3014
+ width: reach * 2,
3015
+ height: reach * 2
3016
+ }, colour, 1 - progress, (x, y) => edge(half - Math.abs(Math.hypot(x - point.x, y - point.y) - radius)));
3017
+ }
3018
+ function drawCursor(target, tip, scale) {
3019
+ const shape2 = ARROW.map((p) => ({
3020
+ x: tip.x + p.x * scale,
3021
+ y: tip.y + p.y * scale
3022
+ }));
3023
+ const outline = LOOK.cursor.outlineWidth * scale;
3024
+ const xs = shape2.map((p) => p.x);
3025
+ const ys = shape2.map((p) => p.y);
3026
+ const bounds = {
3027
+ x: Math.min(...xs) - outline - 1,
3028
+ y: Math.min(...ys) - outline - 1,
3029
+ width: Math.max(...xs) - Math.min(...xs) + 2 * outline + 2,
3030
+ height: Math.max(...ys) - Math.min(...ys) + 2 * outline + 2
3031
+ };
3032
+ paint(target, bounds, LOOK.cursor.outline, 1, (x, y) => edge(outline - polygonDistance(shape2, x, y)));
3033
+ paint(target, bounds, LOOK.cursor.fill, 1, (x, y) => edge(-polygonDistance(shape2, x, y)));
3034
+ }
3035
+ function drawPanel(target, panel, colour, alpha) {
3036
+ const radius = Math.min(LOOK.caption.radius, panel.height / 2);
3037
+ paint(target, panel, colour, alpha, (x, y) => edge(-roundRectDistance(panel, radius, x, y)));
3038
+ }
3039
+ function textMask(lines, style, centreX, baseline) {
3040
+ const glyphs = lines.flatMap((line, index) => textGlyphs(line, style, centreX - measureText(line, style) / 2, baseline + index * lineHeight(style)));
3041
+ return mergeMasks(glyphs);
3042
+ }
3043
+ function blockHeight(lines, style) {
3044
+ return (lines.length - 1) * lineHeight(style) + capHeight(style) + descent(style);
3045
+ }
3046
+ function layCaption(text, output) {
3047
+ const { padding, bottom, maxWidth, text: style } = LOOK.caption;
3048
+ const lines = wrapText(text, style, maxWidth - 2 * padding);
3049
+ const width = Math.max(...lines.map((line) => measureText(line, style))) + 2 * padding;
3050
+ const height = blockHeight(lines, style) + 2 * padding;
3051
+ const panel = {
3052
+ x: (output.width - width) / 2,
3053
+ y: output.height - bottom - height,
3054
+ width,
3055
+ height
3056
+ };
3057
+ return {
3058
+ panel,
3059
+ mask: textMask(lines, style, output.width / 2, panel.y + padding + capHeight(style))
3060
+ };
3061
+ }
3062
+ function layBadge(output) {
3063
+ const { margin, paddingX, paddingY, label, text: style } = LOOK.watermark;
3064
+ const width = measureText(label, style) + 2 * paddingX;
3065
+ const height = capHeight(style) + 2 * paddingY;
3066
+ const panel = {
3067
+ x: output.width - margin - width,
3068
+ y: margin,
3069
+ width,
3070
+ height
3071
+ };
3072
+ return {
3073
+ panel,
3074
+ mask: textMask([label], style, panel.x + width / 2, panel.y + paddingY + capHeight(style))
3075
+ };
3076
+ }
3077
+ function layEndCard(text, output) {
3078
+ const { maxWidth, text: style } = LOOK.endCard;
3079
+ const lines = wrapText(text, style, maxWidth);
3080
+ const height = (lines.length - 1) * lineHeight(style) + capHeight(style);
3081
+ const top = (output.height - height) / 2;
3082
+ const panel = { x: 0, y: 0, width: output.width, height: output.height };
3083
+ return {
3084
+ panel,
3085
+ mask: textMask(lines, style, output.width / 2, top + capHeight(style))
3086
+ };
3087
+ }
3088
+
3089
+ // ../../packages/renderer/dist/ffmpeg/pipeline.js
3090
+ async function drawPlan(sourceFile, plan, out) {
3091
+ const { output } = plan;
3092
+ const compositor = createCompositor(output);
3093
+ const source = openSource(sourceFile, plan.source);
3094
+ const encoder = startEncoder(plan, out);
3095
+ const pictures = [0, 1].map(() => createPicture(output.width, output.height));
3096
+ const pending = [void 0, void 0];
3097
+ try {
3098
+ const frames = frameCount(plan);
3099
+ for (let frame = 0; frame < frames; frame++) {
3100
+ const slot = frame % 2;
3101
+ await pending[slot];
3102
+ const state = frameAt(plan, frame);
3103
+ const picture = state.sourceMs === null ? void 0 : await source.at(sourceFrameIndex(state.sourceMs, plan.source));
3104
+ compositor.compose(pictures[slot], state, picture);
3105
+ pending[slot] = write(encoder.input, pictures[slot].data);
3106
+ }
3107
+ await Promise.all(pending);
3108
+ encoder.input.end();
3109
+ await encoder.done;
3110
+ } catch (error) {
3111
+ if (isPipeError(error))
3112
+ await encoder.done;
3113
+ encoder.input.destroy();
3114
+ await release(encoder.process, encoder.done);
3115
+ throw error;
3116
+ } finally {
3117
+ await source.close();
3118
+ }
3119
+ }
3120
+ function sourceFrameIndex(sourceMs, source) {
3121
+ return Math.max(0, Math.floor(sourceMs * source.fps / 1e3 + 1e-6));
3122
+ }
3123
+ function openSource(file, spec) {
3124
+ const child = spawn2(resolveFfmpeg(), [
3125
+ "-hide_banner",
3126
+ "-nostdin",
3127
+ "-loglevel",
3128
+ "error",
3129
+ "-i",
3130
+ file,
3131
+ "-map",
3132
+ "0:v:0",
3133
+ // Constant rate: frame `i` is the picture at `i / fps`.
3134
+ "-vf",
3135
+ `fps=${spec.fps},scale=${spec.width}:${spec.height}`,
3136
+ "-f",
3137
+ "rawvideo",
3138
+ "-pix_fmt",
3139
+ "rgb24",
3140
+ "pipe:1"
3141
+ ], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
3142
+ const failed = exited(child, "decoding the source");
3143
+ failed.catch(() => void 0);
3144
+ const frames = readFrames(child, spec.width * spec.height * 3);
3145
+ let index = -1;
3146
+ let current;
3147
+ let ended = false;
3148
+ return {
3149
+ async at(wanted) {
3150
+ while (index < wanted && !ended) {
3151
+ const next = await frames.next();
3152
+ if (next.done) {
3153
+ ended = true;
3154
+ await failed;
3155
+ } else {
3156
+ current = {
3157
+ width: spec.width,
3158
+ height: spec.height,
3159
+ data: next.value
3160
+ };
3161
+ index++;
3162
+ }
3163
+ }
3164
+ if (current === void 0)
3165
+ throw new Error("the source has no frames");
3166
+ return current;
3167
+ },
3168
+ close() {
3169
+ child.stdout?.destroy();
3170
+ return release(child, failed);
3171
+ }
3172
+ };
3173
+ }
3174
+ async function release(child, exit) {
3175
+ if (child.exitCode === null && child.signalCode === null)
3176
+ child.kill();
3177
+ await exit.catch(() => void 0);
3178
+ }
3179
+ async function* readFrames(child, size) {
3180
+ if (child.stdout === null)
3181
+ throw new Error("the decoder has no stdout");
3182
+ let frame = new Uint8Array(size);
3183
+ let filled = 0;
3184
+ for await (const chunk of child.stdout) {
3185
+ let offset = 0;
3186
+ while (offset < chunk.length) {
3187
+ const take = Math.min(size - filled, chunk.length - offset);
3188
+ frame.set(chunk.subarray(offset, offset + take), filled);
3189
+ filled += take;
3190
+ offset += take;
3191
+ if (filled === size) {
3192
+ yield frame;
3193
+ frame = new Uint8Array(size);
3194
+ filled = 0;
3195
+ }
3196
+ }
3197
+ }
3198
+ }
3199
+ function startEncoder(plan, out) {
3200
+ const { output } = plan;
3201
+ const child = spawn2(resolveFfmpeg(), [
3202
+ "-hide_banner",
3203
+ "-loglevel",
3204
+ "error",
3205
+ "-y",
3206
+ "-f",
3207
+ "rawvideo",
3208
+ "-pix_fmt",
3209
+ "rgb24",
3210
+ "-s",
3211
+ `${output.width}x${output.height}`,
3212
+ "-r",
3213
+ String(output.fps),
3214
+ "-i",
3215
+ "pipe:0",
3216
+ // BT.709, limited range, tagged in the stream: FFmpeg's
3217
+ // default RGB → YUV conversion is BT.601 and leaves the file untagged.
3218
+ "-vf",
3219
+ `scale=in_range=pc:out_range=tv:out_color_matrix=bt709,format=${output.pixelFormat}`,
3220
+ "-colorspace",
3221
+ "bt709",
3222
+ "-color_primaries",
3223
+ "bt709",
3224
+ "-color_trc",
3225
+ "bt709",
3226
+ "-color_range",
3227
+ "tv",
3228
+ "-c:v",
3229
+ "libx264",
3230
+ "-preset",
3231
+ "medium",
3232
+ "-crf",
3233
+ "18",
3234
+ "-pix_fmt",
3235
+ output.pixelFormat,
3236
+ ...output.faststart ? ["-movflags", "+faststart"] : [],
3237
+ out
3238
+ ], { stdio: ["pipe", "ignore", "pipe"], windowsHide: true });
3239
+ if (child.stdin === null)
3240
+ throw new Error("the encoder has no stdin");
3241
+ child.stdin.on("error", () => void 0);
3242
+ const done = exited(child, "encoding the MP4");
3243
+ done.catch(() => void 0);
3244
+ return { process: child, input: child.stdin, done };
3245
+ }
3246
+ function exited(child, doing) {
3247
+ let stderr = "";
3248
+ child.stderr?.setEncoding("utf8").on("data", (chunk) => {
3249
+ stderr += chunk;
3250
+ });
3251
+ return new Promise((resolve, reject) => {
3252
+ child.on("error", reject);
3253
+ child.on("close", (code, signal) => {
3254
+ if (code === 0) {
3255
+ resolve();
3256
+ } else {
3257
+ const tail = stderr.trim().split("\n").slice(-5).join("\n");
3258
+ reject(new Error(`ffmpeg failed ${doing} (${signal ?? `exit ${code}`}): ${tail}`));
3259
+ }
3260
+ });
3261
+ });
3262
+ }
3263
+ function isPipeError(error) {
3264
+ const code = error?.code;
3265
+ return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
3266
+ }
3267
+ function write(stream, data) {
3268
+ return new Promise((resolve, reject) => {
3269
+ stream.write(data, (error) => error ? reject(error) : resolve());
3270
+ });
3271
+ }
3272
+
3273
+ // ../../packages/renderer/dist/ffmpeg/index.js
3274
+ var ffmpegRenderer = {
3275
+ name: "ffmpeg",
3276
+ render(runDir2, plan, out = renderPath(runDir2, "ffmpeg")) {
3277
+ return recordRender(runDir2, "ffmpeg", out, () => drawPlan(path6.resolve(runDir2, plan.source.file), plan, out));
3278
+ }
3279
+ };
3280
+
3281
+ // src/commands/browser.ts
3282
+ import { spawn as spawn3 } from "node:child_process";
3283
+ import { existsSync as existsSync3 } from "node:fs";
3284
+ import { createRequire as createRequire3 } from "node:module";
3285
+ import path7 from "node:path";
3286
+ import { chromium as chromium2 } from "playwright";
3287
+ var defaultBrowserDeps = {
3288
+ executablePath: () => chromium2.executablePath(),
3289
+ install: installChromium
3290
+ };
3291
+ function chromiumPresent(deps = defaultBrowserDeps) {
3292
+ return existsSync3(deps.executablePath());
3293
+ }
3294
+ async function ensureChromium(io, deps = defaultBrowserDeps) {
3295
+ if (chromiumPresent(deps)) return "present";
3296
+ io.stderr("Chromium is not installed: downloading it once (~250 MB)\n");
3297
+ const code = await deps.install(io);
3298
+ if (code === 0 && chromiumPresent(deps)) {
3299
+ io.stderr("Chromium installed\n");
3300
+ return "installed";
3301
+ }
3302
+ io.stderr(
3303
+ `Chromium could not be installed (the installer exited ${code}); run \`demoflow setup\` to retry
3304
+ `
3305
+ );
3306
+ return "failed";
3307
+ }
3308
+ function installChromium(io) {
3309
+ const manifest = createRequire3(import.meta.url).resolve(
3310
+ "playwright/package.json"
3311
+ );
3312
+ const cli = path7.join(path7.dirname(manifest), "cli.js");
3313
+ return new Promise((resolve) => {
3314
+ const child = spawn3(process.execPath, [cli, "install", "chromium"], {
3315
+ env: process.env,
3316
+ stdio: ["ignore", "pipe", "pipe"],
3317
+ windowsHide: true
3318
+ });
3319
+ for (const stream of [child.stdout, child.stderr]) {
3320
+ stream.setEncoding("utf8");
3321
+ stream.on("data", (chunk) => io.stderr(chunk));
3322
+ }
3323
+ child.on("error", (error) => {
3324
+ io.stderr(`${error.message}
3325
+ `);
3326
+ resolve(1);
3327
+ });
3328
+ child.on("close", (code) => resolve(code ?? 1));
3329
+ });
3330
+ }
3331
+
3332
+ // src/commands/exit.ts
3333
+ function exitCodeFor(result) {
3334
+ const { stages } = result;
3335
+ if (stages.readiness.status === "failed") return EXIT_INVALID_INPUT;
3336
+ if (stages.execution.status === "failed" || stages.verification.status === "failed") {
3337
+ return 1;
3338
+ }
3339
+ const others = [stages.recording, stages.rendering, stages.storage];
3340
+ if (others.some((stage) => stage.status === "failed")) {
3341
+ return EXIT_STAGE_FAILED;
3342
+ }
3343
+ return 0;
3344
+ }
3345
+
3346
+ // src/commands/output.ts
3347
+ import path8 from "node:path";
3348
+ function formatJson(result, runDir2) {
3349
+ return `${JSON.stringify({ ...result, runDir: runDir2 })}
3350
+ `;
3351
+ }
3352
+ function formatSummary(result, runDir2) {
3353
+ const lines = [`Run ${result.runId}: ${result.status}`];
3354
+ const width = Math.max(...STAGE_NAMES.map((name) => name.length));
3355
+ for (const name of STAGE_NAMES) {
3356
+ const stage = result.stages[name];
3357
+ const detail = stage.error === void 0 ? "" : ` ${stage.error.message}`;
3358
+ lines.push(` ${name.padEnd(width)} ${stage.status}${detail}`);
3359
+ }
3360
+ for (const warning of result.warnings) lines.push(`warning: ${warning}`);
3361
+ for (const render of result.renders) {
3362
+ lines.push(`Video: ${path8.join(runDir2, ...render.file.split("/"))}`);
3363
+ }
3364
+ lines.push(`Run directory: ${runDir2}`);
3365
+ return `${lines.join("\n")}
3366
+ `;
3367
+ }
3368
+
3369
+ // src/commands/run-id.ts
3370
+ import { randomBytes } from "node:crypto";
3371
+ var BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
3372
+ function generateRunId(now = /* @__PURE__ */ new Date(), random = randomBytes) {
3373
+ const iso = now.toISOString();
3374
+ const date = iso.slice(0, 10).replaceAll("-", "");
3375
+ const time = iso.slice(11, 19).replaceAll(":", "");
3376
+ const suffix = Array.from(random(6), (byte) => BASE32[byte % 32]).join("");
3377
+ return `${date}-${time}-${suffix}`;
3378
+ }
3379
+ var SAFE_RUN_ID = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
3380
+ function assertSafeRunId(id) {
3381
+ if (!SAFE_RUN_ID.test(id)) {
3382
+ throw new CommandError(
3383
+ "invalid-run-id",
3384
+ `--run-id must be a plain directory name (letters, digits, "_", ".", "-"): ${JSON.stringify(id)}`
3385
+ );
3386
+ }
3387
+ }
3388
+
3389
+ // src/commands/scenario-file.ts
3390
+ import { readFileSync as readFileSync4 } from "node:fs";
3391
+ function loadScenario(file) {
3392
+ let raw;
3393
+ try {
3394
+ raw = JSON.parse(readFileSync4(file, "utf8"));
3395
+ } catch (error) {
3396
+ const reason = error instanceof Error ? error.message : String(error);
3397
+ throw new CommandError(
3398
+ "scenario-unreadable",
3399
+ `cannot read ${file}: ${reason}`
3400
+ );
3401
+ }
3402
+ const parsed = parseScenario(raw);
3403
+ if (!parsed.ok) {
3404
+ throw new CommandError(
3405
+ "invalid-scenario",
3406
+ `${file} is not a valid scenario`,
3407
+ parsed.errors
3408
+ );
3409
+ }
3410
+ return { scenario: parsed.value, warnings: parsed.warnings };
3411
+ }
3412
+
3413
+ // src/commands/settle.ts
3414
+ import { readFile as readFile3, rename as rename3, writeFile as writeFile3 } from "node:fs/promises";
3415
+ import path9 from "node:path";
3416
+ var RESULT_FILE = "result.json";
3417
+ async function readResult(runDir2) {
3418
+ return RunResultSchema.parse(
3419
+ JSON.parse(await readFile3(path9.join(runDir2, RESULT_FILE), "utf8"))
3420
+ );
3421
+ }
3422
+ async function settleRun(runDir2) {
3423
+ const result = await readResult(runDir2);
3424
+ if (result.stages.rendering.status === "pending") {
3425
+ result.stages.rendering = { status: "skipped" };
3426
+ }
3427
+ if (result.stages.storage.status === "pending") {
3428
+ result.stages.storage = { status: "skipped" };
3429
+ }
3430
+ result.status = deriveRunStatus(result.stages, result.warnings);
3431
+ const valid = RunResultSchema.parse(result);
3432
+ const file = path9.join(runDir2, RESULT_FILE);
3433
+ const temporary = `${file}.${process.pid}.tmp`;
3434
+ await writeFile3(temporary, `${JSON.stringify(valid, null, 2)}
3435
+ `);
3436
+ await rename3(temporary, file);
3437
+ return valid;
3438
+ }
3439
+
3440
+ // src/commands/run.ts
3441
+ function defaultRunDeps() {
3442
+ return { host: currentHost(), cwd: process.cwd(), runId: generateRunId };
3443
+ }
3444
+ async function runCommand(file, flags, io, deps = defaultRunDeps()) {
3445
+ const { host } = deps;
3446
+ const root = flags.project === void 0 ? findProjectRoot(file) : projectRootAt(path10.resolve(deps.cwd, flags.project));
3447
+ const config = loadProjectConfig(root);
3448
+ const { home } = resolveHome({
3449
+ ...host,
3450
+ out: flags.out,
3451
+ cwd: deps.cwd,
3452
+ projectRoot: root,
3453
+ projectConfig: config,
3454
+ globalConfig: loadGlobalConfig(host)
3455
+ });
3456
+ const { scenario, warnings } = loadScenario(file);
3457
+ for (const warning of warnings) {
3458
+ io.stderr(`warning ${warning.path}: ${warning.message}
3459
+ `);
3460
+ }
3461
+ const runId = flags.runId ?? deps.runId();
3462
+ if (flags.runId !== void 0) assertSafeRunId(flags.runId);
3463
+ const runDir2 = runDir(home, runId, host.platform);
3464
+ if (flags.runId !== void 0 && existsSync4(runDir2)) {
3465
+ throw new CommandError(
3466
+ "invalid-run-id",
3467
+ `run directory already exists: ${runDir2}`
3468
+ );
3469
+ }
3470
+ if (await ensureChromium(io, deps.browser) === "failed") {
3471
+ return EXIT_STAGE_FAILED;
3472
+ }
3473
+ io.stderr(`Running ${scenarioId(root, file)} (${runId})
3474
+ `);
3475
+ await runScenario(scenario, {
3476
+ runDir: runDir2,
3477
+ baseUrl: flags.baseUrl ?? config.baseUrl,
3478
+ allowedOrigins: config.allowedOrigins,
3479
+ env: host.env,
3480
+ headless: flags.headed !== true,
3481
+ observers: [
3482
+ createCaptureObserver({ scenarioId: scenarioId(root, file), runId })
3483
+ ]
3484
+ });
3485
+ let renderError;
3486
+ if (existsSync4(path10.join(runDir2, RUN_FILES.video))) {
3487
+ io.stderr("Rendering\n");
3488
+ try {
3489
+ const plan = await buildRenderPlan(runDir2, {
3490
+ idle: config.render.idle,
3491
+ output: {
3492
+ width: config.render.width,
3493
+ height: config.render.height,
3494
+ fps: config.render.fps
3495
+ }
3496
+ });
3497
+ await ffmpegRenderer.render(runDir2, plan);
3498
+ } catch (error) {
3499
+ renderError = error;
3500
+ }
3501
+ }
3502
+ const result = await settleRun(runDir2);
3503
+ if (renderError !== void 0) {
3504
+ const reason = renderError instanceof Error ? renderError.message : String(renderError);
3505
+ io.stderr(`rendering failed: ${reason}
3506
+ `);
3507
+ }
3508
+ io.stdout(
3509
+ flags.json === true ? formatJson(result, runDir2) : formatSummary(result, runDir2)
3510
+ );
3511
+ const code = exitCodeFor(result);
3512
+ return code === 0 && renderError !== void 0 ? EXIT_STAGE_FAILED : code;
3513
+ }
3514
+
3515
+ // src/commands/setup.ts
3516
+ async function setupCommand(io, deps = defaultBrowserDeps) {
3517
+ const outcome = await ensureChromium(io, deps);
3518
+ if (outcome === "failed") return EXIT_STAGE_FAILED;
3519
+ io.stdout(
3520
+ `Chromium ${outcome === "present" ? "is installed" : "installed"}: ${deps.executablePath()}
3521
+ `
3522
+ );
3523
+ return 0;
3524
+ }
3525
+
3526
+ // src/commands/validate.ts
3527
+ function formatIssue(issue) {
3528
+ return ` ${issue.path === "" ? "(root)" : issue.path}: ${issue.message}`;
3529
+ }
3530
+ function validateCommand(file, io) {
3531
+ const { warnings } = loadScenario(file);
3532
+ for (const warning of warnings) {
3533
+ io.stderr(`warning ${warning.code}:
3534
+ ${formatIssue(warning)}
3535
+ `);
3536
+ }
3537
+ io.stdout(`${file} is valid
3538
+ `);
3539
+ return 0;
3540
+ }
3541
+
3542
+ // src/mcp/command.ts
3543
+ import { rm } from "node:fs/promises";
3544
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3545
+
3546
+ // src/mcp/child.ts
3547
+ import { spawn as spawn4 } from "node:child_process";
3548
+
3549
+ // src/entry.ts
3550
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
3551
+ var CLI_ENTRY = fileURLToPath2(new URL("./main.js", import.meta.url));
3552
+
3553
+ // src/mcp/child.ts
3554
+ var KILL_GRACE_MS = 5e3;
3555
+ var STDERR_TAIL = 8 * 1024;
3556
+ var spawnRun = (args, cwd) => {
3557
+ const child = spawn4(process.execPath, [CLI_ENTRY, ...args], {
3558
+ cwd,
3559
+ env: process.env,
3560
+ stdio: ["ignore", "pipe", "pipe"],
3561
+ detached: process.platform !== "win32",
3562
+ windowsHide: true
3563
+ });
3564
+ let stderr = "";
3565
+ child.stdout?.resume();
3566
+ child.stderr?.setEncoding("utf8");
3567
+ child.stderr?.on("data", (chunk) => {
3568
+ stderr = (stderr + chunk).slice(-STDERR_TAIL);
3569
+ });
3570
+ let done = false;
3571
+ const exited2 = new Promise((resolve) => {
3572
+ child.on("error", (error) => {
3573
+ stderr += `
3574
+ ${error.message}`;
3575
+ if (child.pid === void 0) {
3576
+ done = true;
3577
+ resolve({ code: null, stderrTail: stderr });
3578
+ }
3579
+ });
3580
+ child.on("close", (code) => {
3581
+ done = true;
3582
+ resolve({ code, stderrTail: stderr });
3583
+ });
3584
+ });
3585
+ return {
3586
+ pid: child.pid,
3587
+ exited: exited2,
3588
+ kill: async () => {
3589
+ if (done) return;
3590
+ killTree(child, "SIGTERM");
3591
+ const timer = setTimeout(() => killTree(child, "SIGKILL"), KILL_GRACE_MS);
3592
+ await exited2;
3593
+ clearTimeout(timer);
3594
+ if (process.platform !== "win32") killTree(child, "SIGKILL");
3595
+ }
3596
+ };
3597
+ };
3598
+ function killTree(child, signal) {
3599
+ const { pid } = child;
3600
+ if (pid === void 0) return;
3601
+ if (process.platform === "win32") {
3602
+ const taskkill = spawn4("taskkill", ["/PID", String(pid), "/T", "/F"], {
3603
+ stdio: "ignore",
3604
+ windowsHide: true
3605
+ });
3606
+ taskkill.on("error", () => child.kill());
3607
+ return;
3608
+ }
3609
+ try {
3610
+ process.kill(-pid, signal);
3611
+ } catch {
3612
+ }
3613
+ }
3614
+
3615
+ // src/mcp/server.ts
3616
+ import { existsSync as existsSync9 } from "node:fs";
3617
+ import { mkdir as mkdir4, writeFile as writeFile4 } from "node:fs/promises";
3618
+ import path15 from "node:path";
3619
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3620
+ import {
3621
+ CallToolRequestSchema,
3622
+ ListToolsRequestSchema
3623
+ } from "@modelcontextprotocol/sdk/types.js";
3624
+ import { z as z10 } from "zod";
3625
+
3626
+ // src/version.ts
3627
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
3628
+ import path11 from "node:path";
3629
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
3630
+ function packageVersion(from = import.meta.url) {
3631
+ let dir = path11.dirname(fileURLToPath3(from));
3632
+ for (; ; ) {
3633
+ const file = path11.join(dir, "package.json");
3634
+ if (existsSync5(file)) {
3635
+ const manifest = JSON.parse(readFileSync5(file, "utf8"));
3636
+ if (manifest.name === "demoflow" && typeof manifest.version === "string")
3637
+ return manifest.version;
3638
+ }
3639
+ const parent = path11.dirname(dir);
3640
+ if (parent === dir)
3641
+ throw new Error(`no demoflow package.json above ${from}`);
3642
+ dir = parent;
3643
+ }
3644
+ }
3645
+
3646
+ // src/mcp/errors.ts
3647
+ var ToolError = class extends Error {
3648
+ code;
3649
+ hint;
3650
+ issues;
3651
+ constructor(code, message, hint, issues = []) {
3652
+ super(message);
3653
+ this.name = "ToolError";
3654
+ this.code = code;
3655
+ this.hint = hint;
3656
+ this.issues = issues;
3657
+ }
3658
+ toPayload() {
3659
+ const payload = {
3660
+ code: this.code,
3661
+ message: this.message,
3662
+ hint: this.hint
3663
+ };
3664
+ if (this.issues.length > 0) {
3665
+ payload.issues = this.issues.map((issue) => ({
3666
+ path: issue.path === "" ? "(root)" : issue.path,
3667
+ message: issue.message
3668
+ }));
3669
+ }
3670
+ return payload;
3671
+ }
3672
+ };
3673
+
3674
+ // src/mcp/project.ts
3675
+ import { existsSync as existsSync6, realpathSync, statSync } from "node:fs";
3676
+ import path12 from "node:path";
3677
+ function resolveProject(project, cwd, host) {
3678
+ if (project !== void 0 && !path12.isAbsolute(project)) {
3679
+ throw new ToolError(
3680
+ "project-not-found",
3681
+ `project must be an absolute path: ${JSON.stringify(project)}`,
3682
+ "Pass the absolute path of the directory that holds demoflow.config.json."
3683
+ );
3684
+ }
3685
+ const start = project ?? cwd;
3686
+ if (!isDirectory(start)) {
3687
+ throw new ToolError(
3688
+ "project-not-found",
3689
+ `no such directory: ${start}`,
3690
+ "Pass the absolute path of the directory that holds demoflow.config.json."
3691
+ );
3692
+ }
3693
+ let root;
3694
+ try {
3695
+ root = findProjectRoot(path12.join(start, PROJECT_CONFIG_FILE));
3696
+ } catch (error) {
3697
+ if (!(error instanceof CliError)) throw error;
3698
+ throw new ToolError(
3699
+ "project-not-found",
3700
+ `no ${PROJECT_CONFIG_FILE} in ${start} or any parent directory`,
3701
+ project === void 0 ? "Pass `project`: the server was not started inside a DemoFlow project." : `Create ${PROJECT_CONFIG_FILE} in the project, or pass another path.`
3702
+ );
3703
+ }
3704
+ try {
3705
+ const config = loadProjectConfig(root);
3706
+ const { home } = resolveHome({
3707
+ ...host,
3708
+ cwd: root,
3709
+ projectRoot: root,
3710
+ projectConfig: config,
3711
+ globalConfig: loadGlobalConfig(host)
3712
+ });
3713
+ return { root, config, home };
3714
+ } catch (error) {
3715
+ if (!(error instanceof CliError)) throw error;
3716
+ throw new ToolError(
3717
+ "invalid-project-config",
3718
+ error.message,
3719
+ `Fix ${PROJECT_CONFIG_FILE} (or the global config) and call again.`,
3720
+ error.issues
3721
+ );
3722
+ }
3723
+ }
3724
+ function resolveScenarioFile(root, file) {
3725
+ const absolute = path12.resolve(root, file);
3726
+ const outside = new ToolError(
3727
+ "file-not-in-project",
3728
+ `${file} is not a *.demo.json file inside ${root}`,
3729
+ "Pass the path of a *.demo.json file inside the project, or an inline `scenario`."
3730
+ );
3731
+ if (!absolute.endsWith(".demo.json") || !isInside(root, absolute)) {
3732
+ throw outside;
3733
+ }
3734
+ if (!existsSync6(absolute)) {
3735
+ throw new ToolError(
3736
+ "scenario-unreadable",
3737
+ `no such file: ${absolute}`,
3738
+ "Check the path; it is resolved against the project root."
3739
+ );
3740
+ }
3741
+ if (!isInside(realpathSync(root), realpathSync(absolute))) throw outside;
3742
+ try {
3743
+ loadScenario(absolute);
3744
+ } catch (error) {
3745
+ if (!(error instanceof CommandError)) throw error;
3746
+ throw new ToolError(
3747
+ error.code === "invalid-scenario" ? "invalid-scenario" : "scenario-unreadable",
3748
+ error.message,
3749
+ error.code === "invalid-scenario" ? "Fix the scenario at the paths in `issues` and call again." : "The file must be readable JSON.",
3750
+ error.issues
3751
+ );
3752
+ }
3753
+ return absolute;
3754
+ }
3755
+ function checkInlineScenario(raw) {
3756
+ const parsed = parseScenario(raw);
3757
+ if (!parsed.ok) {
3758
+ throw new ToolError(
3759
+ "invalid-scenario",
3760
+ "the inline scenario is not a valid scenario",
3761
+ "Fix the scenario at the paths in `issues` and call again.",
3762
+ parsed.errors
3763
+ );
3764
+ }
3765
+ }
3766
+ function isInside(root, file) {
3767
+ const relative = path12.relative(root, file);
3768
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path12.sep}`) && !path12.isAbsolute(relative);
3769
+ }
3770
+ function isDirectory(dir) {
3771
+ try {
3772
+ return statSync(dir).isDirectory();
3773
+ } catch {
3774
+ return false;
3775
+ }
3776
+ }
3777
+
3778
+ // src/mcp/readonly.ts
3779
+ import { existsSync as existsSync7 } from "node:fs";
3780
+ import { readdir } from "node:fs/promises";
3781
+ import path13 from "node:path";
3782
+ var DEFAULT_LIST_LIMIT = 20;
3783
+ var MAX_LIST_LIMIT = 100;
3784
+ var SCENARIO_DIR = "demoflow";
3785
+ function validateScenario(input, cwd, host) {
3786
+ const inline = input.scenario !== void 0;
3787
+ if (inline === (input.file !== void 0)) {
3788
+ throw new ToolError(
3789
+ "both-or-neither-scenario",
3790
+ inline ? "both `scenario` and `file` were given" : "neither `scenario` nor `file` was given",
3791
+ "Pass exactly one: an inline `scenario` object, or the `file` of a *.demo.json in the project."
3792
+ );
3793
+ }
3794
+ if (input.file !== void 0) {
3795
+ const project = resolveProject(input.project, cwd, host);
3796
+ resolveScenarioFile(project.root, input.file);
3797
+ } else {
3798
+ checkInlineScenario(input.scenario);
3799
+ }
3800
+ return { valid: true };
3801
+ }
3802
+ async function listDemos(input, cwd, host) {
3803
+ const project = resolveProject(input.project, cwd, host);
3804
+ const limit = input.limit ?? DEFAULT_LIST_LIMIT;
3805
+ const runsDir = path13.dirname(runDir(project.home, "x", host.platform));
3806
+ let names;
3807
+ try {
3808
+ names = await readdir(runsDir);
3809
+ } catch {
3810
+ return { demos: [] };
3811
+ }
3812
+ const demos = [];
3813
+ for (const name of names) {
3814
+ const runDir2 = runDir(project.home, name, host.platform);
3815
+ try {
3816
+ const result = await readResult(runDir2);
3817
+ const demo = {
3818
+ taskId: name,
3819
+ scenarioName: result.scenarioName,
3820
+ status: result.status,
3821
+ startedAt: result.startedAt
3822
+ };
3823
+ const render = result.renders[0];
3824
+ if (render !== void 0) {
3825
+ demo.durationMs = render.durationMs;
3826
+ const video = path13.join(runDir2, ...render.file.split("/"));
3827
+ if (existsSync7(video)) demo.video = video;
3828
+ }
3829
+ demos.push(demo);
3830
+ } catch {
3831
+ }
3832
+ }
3833
+ demos.sort(
3834
+ (a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt) || (a.taskId < b.taskId ? 1 : a.taskId > b.taskId ? -1 : 0)
3835
+ );
3836
+ return { demos: demos.slice(0, limit) };
3837
+ }
3838
+ async function checkProject(input, cwd, host) {
3839
+ const project = resolveProject(input.project, cwd, host);
3840
+ return {
3841
+ root: project.root,
3842
+ configValid: true,
3843
+ scenarios: await scenarioFiles(project.root),
3844
+ ffmpeg: ffmpegAvailability(),
3845
+ browser: await browserAvailability()
3846
+ };
3847
+ }
3848
+ async function scenarioFiles(root) {
3849
+ let entries;
3850
+ try {
3851
+ entries = (await readdir(path13.join(root, SCENARIO_DIR), { recursive: true })).map(String);
3852
+ } catch {
3853
+ return [];
3854
+ }
3855
+ return entries.filter((entry2) => entry2.endsWith(".demo.json")).map((entry2) => [SCENARIO_DIR, ...entry2.split(path13.sep)].join("/")).sort();
3856
+ }
3857
+ function ffmpegAvailability() {
3858
+ try {
3859
+ return resolveFfmpeg() === "ffmpeg" ? "system" : "bundled";
3860
+ } catch {
3861
+ return "missing";
3862
+ }
3863
+ }
3864
+ async function browserAvailability() {
3865
+ try {
3866
+ const { chromium: chromium3 } = await import("playwright");
3867
+ return existsSync7(chromium3.executablePath()) ? "installed" : "missing";
3868
+ } catch {
3869
+ return "missing";
3870
+ }
3871
+ }
3872
+
3873
+ // src/mcp/view.ts
3874
+ import { existsSync as existsSync8 } from "node:fs";
3875
+ import path14 from "node:path";
3876
+ function taskView(input) {
3877
+ const { taskId, status, runDir: runDir2, result } = input;
3878
+ const view = { taskId, status };
3879
+ if (input.position !== void 0) view.position = input.position;
3880
+ if (input.message !== void 0) view.message = input.message;
3881
+ if (result !== void 0) {
3882
+ view.stages = result.stages;
3883
+ const failedStep = failedStepOf(result.stages);
3884
+ if (failedStep !== void 0) view.failedStep = failedStep;
3885
+ }
3886
+ if (status !== "queued" && existsSync8(runDir2)) {
3887
+ view.artifacts = artifactsOf(runDir2, result);
3888
+ }
3889
+ return view;
3890
+ }
3891
+ function failedStepOf(stages) {
3892
+ for (const name of STAGE_NAMES) {
3893
+ const error = stages[name].error;
3894
+ if (error?.stepIndex !== void 0) {
3895
+ return { stepIndex: error.stepIndex, message: error.message };
3896
+ }
3897
+ }
3898
+ return void 0;
3899
+ }
3900
+ function artifactsOf(runDir2, result) {
3901
+ const artifacts = { runDir: runDir2 };
3902
+ const render = result?.renders[0];
3903
+ const video = render === void 0 ? void 0 : path14.join(runDir2, ...render.file.split("/"));
3904
+ if (video !== void 0 && existsSync8(video)) artifacts.video = video;
3905
+ const timeline = path14.join(runDir2, RUN_FILES.timeline);
3906
+ if (existsSync8(timeline)) artifacts.timeline = timeline;
3907
+ const source = path14.join(runDir2, RUN_FILES.video);
3908
+ if (existsSync8(source)) artifacts.source = source;
3909
+ return artifacts;
3910
+ }
3911
+
3912
+ // src/mcp/server.ts
3913
+ var DEFAULT_TIMEOUT_MINUTES = 15;
3914
+ var MINUTE_MS = 6e4;
3915
+ var MAX_WAIT_SECONDS = 50;
3916
+ var GenerateDemoInput = z10.strictObject({
3917
+ project: z10.string().optional().describe(
3918
+ "Absolute path of the project (the directory with demoflow.config.json); default: the server's working directory."
3919
+ ),
3920
+ scenario: ScenarioSchema.optional().describe(
3921
+ "An inline scenario. Exactly one of `scenario` and `file`."
3922
+ ),
3923
+ file: z10.string().optional().describe(
3924
+ "A *.demo.json file inside the project, relative to its root or absolute. Exactly one of `scenario` and `file`."
3925
+ ),
3926
+ timeoutMinutes: z10.number().int().min(1).max(60).optional().describe(
3927
+ `The task is killed and timed_out after this long; default ${DEFAULT_TIMEOUT_MINUTES}.`
3928
+ )
3929
+ });
3930
+ var GenerateDemoArgs = GenerateDemoInput.extend({
3931
+ scenario: z10.unknown().optional()
3932
+ });
3933
+ var GetTaskInput = z10.strictObject({
3934
+ taskId: z10.string().describe("The ID returned by generate_demo."),
3935
+ waitSeconds: z10.number().min(0).max(MAX_WAIT_SECONDS).optional().describe(
3936
+ `Hold the reply until the task finishes or this many seconds pass (0\u2013${MAX_WAIT_SECONDS}, default 0). Poll again to wait longer.`
3937
+ )
3938
+ });
3939
+ var CancelTaskInput = z10.strictObject({
3940
+ taskId: z10.string().describe("The ID returned by generate_demo.")
3941
+ });
3942
+ var PROJECT_DESCRIPTION = "Absolute path of the project (the directory with demoflow.config.json); default: the server's working directory.";
3943
+ var ValidateScenarioInput = z10.strictObject({
3944
+ project: z10.string().optional().describe(PROJECT_DESCRIPTION),
3945
+ scenario: ScenarioSchema.optional().describe(
3946
+ "An inline scenario. Exactly one of `scenario` and `file`."
3947
+ ),
3948
+ file: z10.string().optional().describe(
3949
+ "A *.demo.json file inside the project, relative to its root or absolute. Exactly one of `scenario` and `file`."
3950
+ )
3951
+ });
3952
+ var ValidateScenarioArgs = ValidateScenarioInput.extend({
3953
+ scenario: z10.unknown().optional()
3954
+ });
3955
+ var ListDemosInput = z10.strictObject({
3956
+ project: z10.string().optional().describe(PROJECT_DESCRIPTION),
3957
+ limit: z10.number().int().min(1).max(MAX_LIST_LIMIT).optional().describe(
3958
+ `How many runs to return, newest first (1\u2013${MAX_LIST_LIMIT}, default ${DEFAULT_LIST_LIMIT}).`
3959
+ )
3960
+ });
3961
+ var CheckProjectInput = z10.strictObject({
3962
+ project: z10.string().optional().describe(PROJECT_DESCRIPTION)
3963
+ });
3964
+ var TOOLS = [
3965
+ {
3966
+ name: "generate_demo",
3967
+ description: "Queue a demo video run of a DemoFlow scenario in a local project. Returns a task ID at once; poll it with get_task. Runs execute one at a time. The first run on a machine may download the browser (~250 MB) and take longer.",
3968
+ inputSchema: inputSchema(GenerateDemoInput)
3969
+ },
3970
+ {
3971
+ name: "get_task",
3972
+ description: `A task's status, stage results and artifact paths (video, timeline, source, run directory). Can wait up to ${MAX_WAIT_SECONDS} s for it to finish.`,
3973
+ inputSchema: inputSchema(GetTaskInput)
3974
+ },
3975
+ {
3976
+ name: "cancel_task",
3977
+ description: "Cancel a task: a queued one never starts, a running one is killed; a finished one is unchanged.",
3978
+ inputSchema: inputSchema(CancelTaskInput)
3979
+ },
3980
+ {
3981
+ name: "validate_scenario",
3982
+ description: "Check a scenario (inline or a *.demo.json in the project) against the scenario schema without running it. Returns { valid: true } or an invalid-scenario error with issue paths.",
3983
+ inputSchema: inputSchema(ValidateScenarioInput)
3984
+ },
3985
+ {
3986
+ name: "list_demos",
3987
+ description: "Recent demo runs of the project's artifacts home, newest first: task ID, scenario name, status, start time, duration and video path.",
3988
+ inputSchema: inputSchema(ListDemosInput)
3989
+ },
3990
+ {
3991
+ name: "check_project",
3992
+ description: "Whether a project is ready to run demos: its scenario files, a valid config, and FFmpeg and browser availability.",
3993
+ inputSchema: inputSchema(CheckProjectInput)
3994
+ }
3995
+ ];
3996
+ function createMcpServer(deps) {
3997
+ const { queue, host, cwd } = deps;
3998
+ const newTaskId = deps.newTaskId ?? (() => generateRunId());
3999
+ const homes = /* @__PURE__ */ new Set();
4000
+ const server = new Server(
4001
+ { name: "demoflow", version: packageVersion() },
4002
+ { capabilities: { tools: {} } }
4003
+ );
4004
+ server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: TOOLS }));
4005
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4006
+ const args = request.params.arguments ?? {};
4007
+ try {
4008
+ switch (request.params.name) {
4009
+ case "generate_demo":
4010
+ return ok(await generateDemo(parse(GenerateDemoArgs, args)));
4011
+ case "get_task":
4012
+ return ok(await getTask(parse(GetTaskInput, args)));
4013
+ case "cancel_task":
4014
+ return ok(await cancelTask(parse(CancelTaskInput, args)));
4015
+ case "validate_scenario":
4016
+ return ok(
4017
+ validateScenario(parse(ValidateScenarioArgs, args), cwd, host)
4018
+ );
4019
+ case "list_demos":
4020
+ return ok(await listDemos(parse(ListDemosInput, args), cwd, host));
4021
+ case "check_project":
4022
+ return ok(
4023
+ await checkProject(parse(CheckProjectInput, args), cwd, host)
4024
+ );
4025
+ default:
4026
+ throw new ToolError(
4027
+ "invalid-input",
4028
+ `unknown tool: ${request.params.name}`,
4029
+ `Call one of: ${TOOLS.map((tool) => tool.name).join(", ")}.`
4030
+ );
4031
+ }
4032
+ } catch (error) {
4033
+ if (error instanceof ToolError) return failure(error);
4034
+ throw error;
4035
+ }
4036
+ });
4037
+ async function generateDemo(input) {
4038
+ const inline = input.scenario !== void 0;
4039
+ if (inline === (input.file !== void 0)) {
4040
+ throw new ToolError(
4041
+ "both-or-neither-scenario",
4042
+ inline ? "both `scenario` and `file` were given" : "neither `scenario` nor `file` was given",
4043
+ "Pass exactly one: an inline `scenario` object, or the `file` of a *.demo.json in the project."
4044
+ );
4045
+ }
4046
+ const project = resolveProject(input.project, cwd, host);
4047
+ let scenarioFile;
4048
+ if (input.file !== void 0) {
4049
+ scenarioFile = resolveScenarioFile(project.root, input.file);
4050
+ } else {
4051
+ checkInlineScenario(input.scenario);
4052
+ }
4053
+ let taskId = newTaskId();
4054
+ while (queue.get(taskId) !== void 0 || existsSync9(runDir(project.home, taskId, host.platform))) {
4055
+ taskId = newTaskId();
4056
+ }
4057
+ const runDir2 = runDir(project.home, taskId, host.platform);
4058
+ let staged;
4059
+ if (scenarioFile === void 0) {
4060
+ staged = path15.join(project.home, "mcp", `${taskId}.demo.json`);
4061
+ await mkdir4(path15.dirname(staged), { recursive: true });
4062
+ await writeFile4(staged, `${JSON.stringify(input.scenario, null, 2)}
4063
+ `);
4064
+ scenarioFile = staged;
4065
+ }
4066
+ homes.add(project.home);
4067
+ const task = queue.submit({
4068
+ taskId,
4069
+ root: project.root,
4070
+ runDir: runDir2,
4071
+ scenarioFile,
4072
+ staged,
4073
+ timeoutMs: (input.timeoutMinutes ?? DEFAULT_TIMEOUT_MINUTES) * MINUTE_MS
4074
+ });
4075
+ return { taskId, status: task.status, position: queue.position(taskId) };
4076
+ }
4077
+ async function getTask(input) {
4078
+ checkTaskId(input.taskId);
4079
+ const task = queue.get(input.taskId);
4080
+ if (task === void 0) return fromDisk(input.taskId);
4081
+ const waitSeconds = Math.min(input.waitSeconds ?? 0, MAX_WAIT_SECONDS);
4082
+ await queue.wait(input.taskId, waitSeconds * 1e3);
4083
+ return taskView({
4084
+ taskId: input.taskId,
4085
+ status: task.status,
4086
+ runDir: task.spec.runDir,
4087
+ result: task.result,
4088
+ message: task.message,
4089
+ position: task.status === "queued" ? queue.position(input.taskId) : void 0
4090
+ });
4091
+ }
4092
+ async function cancelTask(input) {
4093
+ checkTaskId(input.taskId);
4094
+ const task = await queue.cancel(input.taskId);
4095
+ if (task !== void 0)
4096
+ return { taskId: input.taskId, status: task.status };
4097
+ const { status } = await fromDisk(input.taskId);
4098
+ return { taskId: input.taskId, status };
4099
+ }
4100
+ async function fromDisk(taskId) {
4101
+ for (const home of candidateHomes()) {
4102
+ const runDir2 = runDir(home, taskId, host.platform);
4103
+ if (!existsSync9(runDir2)) continue;
4104
+ try {
4105
+ const result = await readResult(runDir2);
4106
+ const finished = result.status === "completed" || result.status === "completed_with_warnings";
4107
+ return taskView({
4108
+ taskId,
4109
+ status: finished ? "completed" : "failed",
4110
+ runDir: runDir2,
4111
+ result,
4112
+ message: result.status === "running" ? "the run never finished: the server that ran it stopped" : void 0
4113
+ });
4114
+ } catch {
4115
+ return taskView({
4116
+ taskId,
4117
+ status: "failed",
4118
+ runDir: runDir2,
4119
+ message: "the run directory has no readable result.json"
4120
+ });
4121
+ }
4122
+ }
4123
+ throw new ToolError(
4124
+ "task-not-found",
4125
+ `no task ${taskId}`,
4126
+ "Use a task ID returned by generate_demo. Unfinished tasks of a stopped server are gone; a finished one is found when its project is the server working directory or was used since the start."
4127
+ );
4128
+ }
4129
+ function candidateHomes() {
4130
+ const env = host.env.DEMOFLOW_HOME;
4131
+ const candidates = [
4132
+ ...homes,
4133
+ tryOrUndefined(() => resolveProject(void 0, cwd, host).home),
4134
+ env === void 0 || env === "" ? void 0 : path15.resolve(cwd, env),
4135
+ tryOrUndefined(() => loadGlobalConfig(host).artifactsDir),
4136
+ osDataDir(host)
4137
+ ];
4138
+ return new Set(
4139
+ candidates.filter((home) => home !== void 0)
4140
+ );
4141
+ }
4142
+ return server;
4143
+ }
4144
+ function checkTaskId(taskId) {
4145
+ try {
4146
+ assertSafeRunId(taskId);
4147
+ } catch (error) {
4148
+ if (!(error instanceof CommandError)) throw error;
4149
+ throw new ToolError(
4150
+ "task-not-found",
4151
+ `no task ${JSON.stringify(taskId)}`,
4152
+ "Use a task ID returned by generate_demo."
4153
+ );
4154
+ }
4155
+ }
4156
+ function parse(schema, args) {
4157
+ const parsed = schema.safeParse(args);
4158
+ if (parsed.success) return parsed.data;
4159
+ const issues = parsed.error.issues.map((issue) => ({
4160
+ path: formatPath(issue.path),
4161
+ code: issue.code,
4162
+ message: issue.message
4163
+ }));
4164
+ throw new ToolError(
4165
+ "invalid-input",
4166
+ "the tool arguments do not match its input schema",
4167
+ "Fix the arguments at the paths in `issues` and call again.",
4168
+ issues
4169
+ );
4170
+ }
4171
+ function inputSchema(schema) {
4172
+ const { $schema: _, ...json } = z10.toJSONSchema(schema, {
4173
+ io: "input",
4174
+ target: "draft-2020-12"
4175
+ });
4176
+ return { ...json, type: "object" };
4177
+ }
4178
+ function ok(value) {
4179
+ return {
4180
+ content: [{ type: "text", text: JSON.stringify(value) }],
4181
+ structuredContent: value
4182
+ };
4183
+ }
4184
+ function failure(error) {
4185
+ const payload = error.toPayload();
4186
+ return {
4187
+ isError: true,
4188
+ content: [{ type: "text", text: JSON.stringify(payload) }],
4189
+ structuredContent: { ...payload }
4190
+ };
4191
+ }
4192
+ function tryOrUndefined(read) {
4193
+ try {
4194
+ return read();
4195
+ } catch (error) {
4196
+ if (error instanceof ToolError || error instanceof CliError)
4197
+ return void 0;
4198
+ throw error;
4199
+ }
4200
+ }
4201
+
4202
+ // src/mcp/tasks.ts
4203
+ function isTerminal(status) {
4204
+ return status !== "queued" && status !== "running";
4205
+ }
4206
+ var TaskQueue = class {
4207
+ tasks = /* @__PURE__ */ new Map();
4208
+ queue = [];
4209
+ waiters = /* @__PURE__ */ new Map();
4210
+ running;
4211
+ closed = false;
4212
+ deps;
4213
+ constructor(deps) {
4214
+ this.deps = deps;
4215
+ }
4216
+ submit(spec) {
4217
+ const task = { spec, status: "queued" };
4218
+ this.tasks.set(spec.taskId, task);
4219
+ if (this.closed) {
4220
+ this.end(task, "cancelled");
4221
+ return task;
4222
+ }
4223
+ this.queue.push(task);
4224
+ this.pump();
4225
+ return task;
4226
+ }
4227
+ get(taskId) {
4228
+ return this.tasks.get(taskId);
4229
+ }
4230
+ /** Tasks ahead of this one: `0` while it runs or is next. */
4231
+ position(taskId) {
4232
+ const index = this.queue.findIndex((task) => task.spec.taskId === taskId);
4233
+ if (index === -1) return 0;
4234
+ return index + (this.running === void 0 ? 0 : 1);
4235
+ }
4236
+ /** Resolves when the task reaches a terminal status, or after `ms`. */
4237
+ async wait(taskId, ms) {
4238
+ const task = this.tasks.get(taskId);
4239
+ if (task === void 0 || isTerminal(task.status) || ms <= 0) return;
4240
+ await new Promise((resolve) => {
4241
+ const waiters = this.waiters.get(taskId) ?? /* @__PURE__ */ new Set();
4242
+ this.waiters.set(taskId, waiters);
4243
+ const done = () => {
4244
+ clearTimeout(timer);
4245
+ waiters.delete(done);
4246
+ resolve();
4247
+ };
4248
+ const timer = setTimeout(done, ms);
4249
+ waiters.add(done);
4250
+ });
4251
+ }
4252
+ /**
4253
+ * A queued task is removed and never starts; a running one's child is
4254
+ * killed; a finished one is unchanged. Resolves with the task's status set.
4255
+ */
4256
+ async cancel(taskId) {
4257
+ const task = this.tasks.get(taskId);
4258
+ if (task === void 0) return void 0;
4259
+ if (task.status === "queued") {
4260
+ this.queue.splice(this.queue.indexOf(task), 1);
4261
+ this.end(task, "cancelled");
4262
+ } else if (this.running?.task === task) {
4263
+ await this.stop(this.running, "cancelled");
4264
+ }
4265
+ return task;
4266
+ }
4267
+ /** Shutdown: nothing new starts, the queue is cancelled, the child is killed. */
4268
+ async close() {
4269
+ this.closed = true;
4270
+ for (const task of this.queue.splice(0)) this.end(task, "cancelled");
4271
+ if (this.running !== void 0) await this.stop(this.running, "cancelled");
4272
+ }
4273
+ pump() {
4274
+ if (this.closed || this.running !== void 0) return;
4275
+ const task = this.queue.shift();
4276
+ if (task === void 0) return;
4277
+ const { spec } = task;
4278
+ task.status = "running";
4279
+ const child = this.deps.spawn(
4280
+ [
4281
+ "run",
4282
+ spec.scenarioFile,
4283
+ "--json",
4284
+ "--run-id",
4285
+ spec.taskId,
4286
+ "--project",
4287
+ spec.root
4288
+ ],
4289
+ spec.root
4290
+ );
4291
+ this.deps.log?.(`task ${spec.taskId} started (pid ${child.pid ?? "?"})`);
4292
+ const timer = setTimeout(() => {
4293
+ void this.stop(running, "timed_out");
4294
+ }, spec.timeoutMs);
4295
+ const running = {
4296
+ task,
4297
+ child,
4298
+ settled: child.exited.then(async (exit) => {
4299
+ clearTimeout(timer);
4300
+ await this.finish(running, exit);
4301
+ })
4302
+ };
4303
+ this.running = running;
4304
+ }
4305
+ async stop(running, reason) {
4306
+ running.ending ??= reason;
4307
+ await running.child.kill();
4308
+ await running.settled;
4309
+ }
4310
+ async finish(running, exit) {
4311
+ const { task } = running;
4312
+ try {
4313
+ task.result = await this.deps.readResult(task.spec.runDir);
4314
+ } catch {
4315
+ }
4316
+ let status;
4317
+ if (running.ending !== void 0) {
4318
+ status = running.ending;
4319
+ } else if (task.result?.status === "completed" || task.result?.status === "completed_with_warnings") {
4320
+ status = "completed";
4321
+ } else {
4322
+ status = "failed";
4323
+ if (task.result === void 0) {
4324
+ task.message = failureMessage(exit);
4325
+ }
4326
+ }
4327
+ this.running = void 0;
4328
+ this.end(task, status);
4329
+ this.pump();
4330
+ }
4331
+ end(task, status) {
4332
+ task.status = status;
4333
+ this.deps.log?.(`task ${task.spec.taskId} ${status}`);
4334
+ if (task.spec.staged !== void 0) {
4335
+ this.deps.removeFile(task.spec.staged).catch(() => void 0);
4336
+ }
4337
+ for (const waiter of this.waiters.get(task.spec.taskId) ?? []) waiter();
4338
+ this.waiters.delete(task.spec.taskId);
4339
+ }
4340
+ };
4341
+ function failureMessage(exit) {
4342
+ const lines = exit.stderrTail.trim().split("\n").slice(-5).join("\n");
4343
+ return `demoflow run exited with code ${exit.code ?? "null"}${lines === "" ? "" : `: ${lines}`}`;
4344
+ }
4345
+
4346
+ // src/mcp/command.ts
4347
+ var SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
4348
+ async function mcpCommand(io) {
4349
+ const queue = new TaskQueue({
4350
+ spawn: spawnRun,
4351
+ readResult,
4352
+ removeFile: (file) => rm(file, { force: true }),
4353
+ log: (line) => io.stderr(`demoflow mcp: ${line}
4354
+ `)
4355
+ });
4356
+ const server = createMcpServer({
4357
+ queue,
4358
+ host: currentHost(),
4359
+ cwd: process.cwd()
4360
+ });
4361
+ const transport = new StdioServerTransport();
4362
+ let stop = () => void 0;
4363
+ const stopped = new Promise((resolve) => {
4364
+ stop = resolve;
4365
+ });
4366
+ const onSignal = (signal) => stop(signal);
4367
+ const onEnd = () => stop("stdin closed");
4368
+ for (const signal of SHUTDOWN_SIGNALS) process.on(signal, onSignal);
4369
+ process.stdin.on("end", onEnd);
4370
+ process.stdin.on("close", onEnd);
4371
+ server.onclose = () => stop("transport closed");
4372
+ await server.connect(transport);
4373
+ io.stderr("demoflow mcp: serving on stdio\n");
4374
+ if (!chromiumPresent()) {
4375
+ io.stderr(
4376
+ "demoflow mcp: Chromium is not installed; the first generate_demo downloads it (or run `demoflow setup`)\n"
4377
+ );
4378
+ }
4379
+ const reason = await stopped;
4380
+ for (const signal of SHUTDOWN_SIGNALS) process.off(signal, onSignal);
4381
+ process.stdin.off("end", onEnd);
4382
+ process.stdin.off("close", onEnd);
4383
+ await queue.close();
4384
+ await server.close();
4385
+ process.stdin.destroy();
4386
+ io.stderr(`demoflow mcp: stopped (${reason})
4387
+ `);
4388
+ return 0;
4389
+ }
4390
+
4391
+ // src/main.ts
4392
+ async function main(argv, io = processIo) {
4393
+ let exitCode = 0;
4394
+ const program = new Command("demoflow").description("Turn a scenario into a demo video").version(packageVersion()).exitOverride().configureOutput({
4395
+ writeOut: (text) => io.stdout(text),
4396
+ writeErr: (text) => io.stderr(text)
4397
+ });
4398
+ program.command("validate").description("Check a scenario against the schema").argument("<scenario>", "path to a .demo.json file").action((scenario) => {
4399
+ exitCode = validateCommand(scenario, io);
4400
+ });
4401
+ program.command("run").description("Run a scenario and render its video").argument("<scenario>", "path to a .demo.json file").option("--base-url <url>", "override baseUrl from demoflow.config.json").option("--out <dir>", "artifacts directory (the home of runs/)").option("--headed", "show the browser window").option("--json", "print the result as one JSON object on stdout").addOption(new Option("--run-id <id>", "name the run directory").hideHelp()).addOption(new Option("--project <dir>", "the project root").hideHelp()).action(
4402
+ async (scenario, flags) => {
4403
+ exitCode = await runCommand(scenario, flags, io);
4404
+ }
4405
+ );
4406
+ program.command("setup").description("Install Chromium now instead of on the first run").action(async () => {
4407
+ exitCode = await setupCommand(io);
4408
+ });
4409
+ program.command("mcp").description("Serve demo generation to MCP clients over stdio").addHelpText(
4410
+ "after",
4411
+ "\nRegister with Claude Code:\n claude mcp add demoflow -- npx demoflow mcp\n"
4412
+ ).action(async () => {
4413
+ exitCode = await mcpCommand(io);
4414
+ });
4415
+ try {
4416
+ await program.parseAsync(argv, { from: "user" });
4417
+ return exitCode;
4418
+ } catch (error) {
4419
+ if (isInputError(error)) {
4420
+ io.stderr(`${describeError(error)}
4421
+ `);
4422
+ return error.exitCode;
4423
+ }
4424
+ if (isCommanderError(error))
4425
+ return error.exitCode === 1 ? 2 : error.exitCode;
4426
+ io.stderr(
4427
+ `${error instanceof Error ? error.stack ?? error.message : String(error)}
4428
+ `
4429
+ );
4430
+ return EXIT_STAGE_FAILED;
4431
+ }
4432
+ }
4433
+ function isCommanderError(error) {
4434
+ return error instanceof CommanderError;
4435
+ }
4436
+ var entry = process.argv[1];
4437
+ if (entry !== void 0 && import.meta.url === pathToFileURL(realpathSync2(entry)).href) {
4438
+ process.exitCode = await main(process.argv.slice(2));
4439
+ }
4440
+ export {
4441
+ main
4442
+ };