@plaintake/scenario 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,753 @@
1
+ // ../schema/src/errors.ts
2
+ var EXIT_CODES = {
3
+ ok: 0,
4
+ assertion: 1,
5
+ usage: 2,
6
+ toolchain: 3,
7
+ capture: 4,
8
+ render: 5,
9
+ verification: 6
10
+ };
11
+ var DemoError = class extends Error {
12
+ kind;
13
+ exitCode;
14
+ details;
15
+ constructor(kind, message, details = {}) {
16
+ super(message);
17
+ this.name = "DemoError";
18
+ this.kind = kind;
19
+ this.exitCode = EXIT_CODES[kind];
20
+ this.details = details;
21
+ }
22
+ };
23
+
24
+ // ../schema/src/event.ts
25
+ import { z } from "zod";
26
+ var NanosString = z.string().regex(/^\d+$/, "must be a digit-only nanosecond string");
27
+ var DemoEventTypeSchema = z.enum([
28
+ "session.start",
29
+ "session.finish",
30
+ "chapter",
31
+ "step.start",
32
+ "step.finish",
33
+ "assertion.pass",
34
+ "assertion.fail",
35
+ "privacy.mask",
36
+ /*
37
+ * A bounded stretch where the recorder was waiting on something it does not drive.
38
+ * One member, not two, because a handoff and a plain condition answer the same question a
39
+ * reader of events.ndjson has — *why is the video sitting still here?* — and `payload.kind`
40
+ * separates them exactly as `step.finish` already carries `holdMs`.
41
+ *
42
+ * Appended once, when the wait ends, so `tEndNs` is a real reading rather than a
43
+ * placeholder: the `assertion.pass` shape, not the `step.start`/`step.finish` pair.
44
+ */
45
+ "human.wait"
46
+ ]);
47
+ var TargetSchema = z.object({
48
+ role: z.string().optional(),
49
+ accessibleName: z.string().optional(),
50
+ selector: z.string().optional(),
51
+ rect: z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() }).optional()
52
+ });
53
+ var DemoEventSchema = z.object({
54
+ schema: z.literal("agent-demo.event/v1"),
55
+ seq: z.number().int().nonnegative(),
56
+ tStartNs: NanosString,
57
+ tEndNs: NanosString.optional(),
58
+ type: DemoEventTypeSchema,
59
+ stableId: z.string().min(1).optional(),
60
+ title: z.string().optional(),
61
+ subtitle: z.string().optional(),
62
+ target: TargetSchema.optional(),
63
+ payload: z.record(z.string(), z.unknown()).optional()
64
+ });
65
+
66
+ // ../schema/src/json-schema.ts
67
+ import { z as z3 } from "zod";
68
+
69
+ // ../schema/src/scenario.ts
70
+ import { z as z2 } from "zod";
71
+ var ScenarioIntroSchema = z2.object({
72
+ lines: z2.array(z2.string().min(1)).min(1).max(2, "intro.lines takes at most two lines \u2014 a third would have to shrink the type to fit"),
73
+ narration: z2.string().min(1).optional(),
74
+ durationMs: z2.number().int().positive().max(15e3, "intro.durationMs must be at most 15000ms").optional()
75
+ });
76
+ var ScenarioCameraSchema = z2.object({
77
+ maxZoom: z2.number().min(1, "camera.maxZoom must be at least 1 (no zoom)").max(1.6, "camera.maxZoom must be at most 1.6 \u2014 beyond that the upscaled capture turns UI text to mush").optional(),
78
+ margin: z2.number().min(0, "camera.margin must be at least 0").max(2, "camera.margin must be at most 2 (as a fraction of the target, per side)").optional(),
79
+ easeMs: z2.number().int().min(133, "camera.easeMs must be at least 133ms").max(2e3, "camera.easeMs must be at most 2000ms").optional(),
80
+ minDwellMs: z2.number().int().min(0, "camera.minDwellMs must be at least 0").max(5e3, "camera.minDwellMs must be at most 5000ms").optional()
81
+ });
82
+ var ScenarioMetaSchema = z2.object({
83
+ schema: z2.literal("agent-demo.scenario/v1"),
84
+ id: z2.string().regex(/^[a-z0-9][a-z0-9-]*$/, "must be a lowercase kebab-case id"),
85
+ title: z2.string().min(1),
86
+ language: z2.string().min(2).default("en"),
87
+ viewport: z2.object({
88
+ width: z2.literal(1920),
89
+ height: z2.literal(1080),
90
+ deviceScaleFactor: z2.literal(1)
91
+ }),
92
+ locale: z2.literal("en-US"),
93
+ timezoneId: z2.literal("UTC"),
94
+ colorScheme: z2.literal("light"),
95
+ reducedMotion: z2.literal("reduce"),
96
+ /** Exact console error strings the scenario tolerates. Anything else fails the run. */
97
+ allowedConsoleErrors: z2.array(z2.string()).default([]),
98
+ /*
99
+ * Whether this scenario hands the real browser window to a person, and in which phase.
100
+ *
101
+ * Declared here rather than discovered when `demo.handoff()` is first called, because an
102
+ * unavailable capability must be refused *before* Chromium starts — and what a `run()`
103
+ * body is going to do is not knowable then. Four things read it: whether Chromium launches
104
+ * headed, whether a Playwright trace is recorded at all, the refusals in `runScenario`, and
105
+ * which phase may legally call `demo.handoff()`.
106
+ *
107
+ * Headedness derives from this and from nothing else. There is deliberately no `--headed`
108
+ * flag: one would let the shipped examples be recorded headed, and S2 measured that headed
109
+ * and headless Chromium put glyphs on different pixels — so `make reproduce` would compare
110
+ * two videos that were never meant to match.
111
+ *
112
+ * The same precedent as everywhere else here: a `.default()`, so every scenario written
113
+ * before this parses unchanged.
114
+ */
115
+ handoff: z2.enum(["none", "preflight", "session"]).default("none"),
116
+ /*
117
+ * How long a handoff waits for the person before giving up. Not a patience limit —
118
+ * past a few minutes, a block is indistinguishable from a hang, and the person is better
119
+ * served by a failure that names the wait than by a recorder that sits there.
120
+ *
121
+ * In the schema rather than only in the runtime so `validate` catches an out-of-range
122
+ * value with no browser and no waiting.
123
+ */
124
+ handoffTimeoutMs: z2.number().int().min(5e3, "handoffTimeoutMs must be at least 5000ms").max(
125
+ 3e5,
126
+ "handoffTimeoutMs must be at most 300000ms (5 minutes) \u2014 past that a wait is indistinguishable from a hang"
127
+ ).default(12e4),
128
+ /*
129
+ * The opening card. Optional on the same precedent, like `handoff` above: every scenario
130
+ * written before this parses unchanged, and one that declares no intro renders exactly
131
+ * the video it always did.
132
+ *
133
+ * Note this is the field that makes an intro *possible* at all — Zod strips unknown keys
134
+ * (`scenario/src/define.ts`), so before it existed an `intro:` block in a demo file was
135
+ * discarded in silence rather than refused.
136
+ */
137
+ intro: ScenarioIntroSchema.optional(),
138
+ /*
139
+ * Framing overrides for this scenario's pages. Optional like everything above it, and
140
+ * inert when absent: the defaults are the constants the camera has always used.
141
+ *
142
+ * Note this is read only when the camera is on at all (`--camera zoom`). A scenario
143
+ * recorded flat carries these harmlessly.
144
+ */
145
+ camera: ScenarioCameraSchema.optional()
146
+ });
147
+
148
+ // ../schema/src/manifest.ts
149
+ import { z as z4 } from "zod";
150
+ var Sha256 = z4.string().regex(/^[0-9a-f]{64}$/);
151
+ var BundleManifestSchema = z4.object({
152
+ schema: z4.literal("agent-demo.bundle/v1"),
153
+ status: z4.enum(["passed", "failed"]),
154
+ scenario: z4.object({ id: z4.string().min(1), sourceSha256: Sha256 }),
155
+ toolchain: z4.object({
156
+ node: z4.string(),
157
+ playwright: z4.string(),
158
+ chromiumRevision: z4.string(),
159
+ ffmpeg: z4.string(),
160
+ /** May be the literal "unknown" — never a fabricated version. */
161
+ libass: z4.string(),
162
+ fontSha256: Sha256,
163
+ containerImage: z4.string().optional()
164
+ }),
165
+ environment: z4.object({
166
+ os: z4.string(),
167
+ /** Part of the reproducibility claim: byte-identity holds per architecture. */
168
+ architecture: z4.string(),
169
+ locale: z4.literal("en-US"),
170
+ timezone: z4.literal("UTC"),
171
+ viewport: z4.tuple([z4.literal(1920), z4.literal(1080)]),
172
+ deviceScaleFactor: z4.literal(1)
173
+ }),
174
+ assertions: z4.array(z4.object({ id: z4.string().min(1), status: z4.enum(["passed", "failed"]) })),
175
+ artifacts: z4.array(
176
+ z4.object({
177
+ path: z4.string().regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/, "must be a relative POSIX path"),
178
+ sha256: Sha256,
179
+ bytes: z4.number().int().nonnegative(),
180
+ role: z4.enum(["source", "derived"])
181
+ })
182
+ )
183
+ });
184
+
185
+ // ../schema/src/render-plan.ts
186
+ import { z as z5 } from "zod";
187
+ var CueSchema = z5.object({
188
+ id: z5.string().min(1),
189
+ startMs: z5.number().int().nonnegative(),
190
+ endMs: z5.number().int().positive(),
191
+ lines: z5.array(z5.string()).min(1).max(2)
192
+ });
193
+ var HEX_COLOUR = z5.string().regex(/^#[0-9A-Fa-f]{6}$/, "must be a #RRGGBB colour");
194
+ var OutroSchema = z5.object({
195
+ durationMs: z5.number().int().positive().max(15e3),
196
+ lines: z5.array(z5.string().min(1)).min(1).max(2),
197
+ backgroundColor: HEX_COLOUR,
198
+ textColor: HEX_COLOUR,
199
+ assPath: z5.literal("captions/outro.ass")
200
+ });
201
+ var IntroSchema = z5.object({
202
+ durationMs: z5.number().int().positive().max(15e3),
203
+ lines: z5.array(z5.string().min(1)).min(1).max(2),
204
+ backgroundColor: HEX_COLOUR,
205
+ textColor: HEX_COLOUR,
206
+ assPath: z5.literal("captions/intro.ass")
207
+ });
208
+ var ChapterMarkSchema = z5.object({
209
+ startMs: z5.number().int().nonnegative(),
210
+ endMs: z5.number().int().positive(),
211
+ title: z5.string().min(1)
212
+ });
213
+ var ChaptersSchema = z5.object({
214
+ metadataPath: z5.literal("render/chapters.ffmetadata"),
215
+ marks: z5.array(ChapterMarkSchema).min(1)
216
+ }).refine(
217
+ ({ marks }) => marks[0]?.startMs === 0 && marks.every(
218
+ (mark, index) => mark.endMs > mark.startMs && (index === 0 || marks[index - 1]?.endMs === mark.startMs)
219
+ ),
220
+ {
221
+ // Contiguity is enforced rather than tolerated: a gap or an overlap produces a
222
+ // chapter list that players render inconsistently, and there is no legitimate way
223
+ // for the derivation to generate one.
224
+ message: "marks must start at 0 and tile the content contiguously",
225
+ path: ["marks"]
226
+ }
227
+ );
228
+ var CursorPointSchema = z5.object({
229
+ id: z5.string().min(1),
230
+ x: z5.number().int().min(0).max(1920),
231
+ y: z5.number().int().min(0).max(1080),
232
+ arriveMs: z5.number().int().nonnegative(),
233
+ departMs: z5.number().int().nonnegative(),
234
+ action: z5.enum(["click", "type", "point"]),
235
+ rippleMs: z5.number().int().nonnegative().optional(),
236
+ /**
237
+ * Counter-scale percent, so the cursor stays one size on screen while the camera
238
+ * magnifies its window — up to 1.6x — and the emitter compensates with `\fscx`/`\fscy`.
239
+ * Absent means 100, so it carries no default: a cursor bundle from before the camera
240
+ * existed still parses and regenerates byte-for-byte.
241
+ */
242
+ scale: z5.number().int().min(1).max(100).optional()
243
+ });
244
+ var CursorSchema = z5.object({
245
+ assPath: z5.literal("captions/cursor.ass"),
246
+ points: z5.array(CursorPointSchema).min(1)
247
+ }).refine(
248
+ ({ points }) => points.every(
249
+ (point, index) => point.arriveMs <= point.departMs && (point.rippleMs === void 0 || point.rippleMs >= point.arriveMs) && (index === 0 || points[index - 1].departMs <= point.arriveMs && points[index - 1].arriveMs < point.arriveMs)
250
+ ),
251
+ {
252
+ // The derivation emits points in event order, one per targeted step, so an overlap
253
+ // or an out-of-order point can only mean the derivation is broken — fail the plan
254
+ // rather than render a cursor that travels backwards in time.
255
+ message: "points must be ordered, arrive before departing, and not overlap",
256
+ path: ["points"]
257
+ }
258
+ );
259
+ var CameraShotSchema = z5.object({
260
+ id: z5.string().min(1),
261
+ enterMs: z5.number().int().nonnegative(),
262
+ holdFromMs: z5.number().int().nonnegative(),
263
+ x: z5.number().int().min(0),
264
+ y: z5.number().int().min(0),
265
+ w: z5.number().int().positive(),
266
+ h: z5.number().int().positive()
267
+ });
268
+ var CameraSchema = z5.object({
269
+ commandPath: z5.literal("render/camera.cmd"),
270
+ shots: z5.array(CameraShotSchema).min(1)
271
+ }).refine(
272
+ ({ shots }) => shots.every(
273
+ (shot) => shot.x % 2 === 0 && shot.y % 2 === 0 && // Implied by the %32 and 9w/16 rules below; kept so a future relaxation of
274
+ // either cannot silently reintroduce M10's odd-geometry masking.
275
+ shot.w % 2 === 0 && shot.h % 2 === 0 && shot.w % 32 === 0 && shot.h === shot.w * 9 / 16 && shot.x + shot.w <= 1920 && shot.y + shot.h <= 1080
276
+ ),
277
+ {
278
+ // Odd geometry is silently masked down on yuv420p (M10), and a window that is not
279
+ // exact 16:9 or leaves the 1920x1080 frame cannot feed the `scale=1920:1080` the
280
+ // camera command is built around. Reject at parse time rather than discover it in
281
+ // a rendered frame.
282
+ message: "every shot window must be even in x, y, w and h (M10), w a multiple of 32, h exactly 9w/16, and inside the 1920x1080 frame",
283
+ path: ["shots"]
284
+ }
285
+ ).refine(
286
+ ({ shots }) => shots[0]?.enterMs === 0 && shots[0]?.holdFromMs === 0 && shots[0]?.x === 0 && shots[0]?.y === 0 && shots[0]?.w === 1920 && shots[0]?.h === 1080 && shots.every(
287
+ (shot, index) => shot.holdFromMs >= shot.enterMs && (index === 0 || shots[index - 1].holdFromMs <= shot.enterMs)
288
+ ),
289
+ {
290
+ // The shots are the timeline: a gap between one shot's hold and the next shot's
291
+ // ease would leave the previous window on screen with nothing frozen saying so, and
292
+ // pinning shot 0 to the full frame keeps "no zoom yet" a shot rather than an
293
+ // implicit default the renderer would have to know about.
294
+ message: "shots must tile the timeline: shot 0 is the full frame held from 0, each shot enters at or after the previous shot is held, and no ease ends before it starts",
295
+ path: ["shots"]
296
+ }
297
+ );
298
+ var SpeechClipSchema = z5.object({
299
+ id: z5.string().min(1),
300
+ /*
301
+ * Bundle-relative and unescapable by construction: no slash, no leading dot, so `..` and
302
+ * an absolute path are both unrepresentable. The renderer's `assertBundleRelative` says
303
+ * the same thing at the point of use; this says it at the point of freezing.
304
+ *
305
+ * The grammar is deliberately a *subset* of `assertBundleRelative`'s, which is why `:` is
306
+ * excluded even though a file name may legally contain one. A colon is forbidden in any path
307
+ * that reaches an FFmpeg argument, and two guards that disagree are worse than either alone:
308
+ * the looser one is upstream, so a plan carrying `speech/clips/step:3.wav` would parse, be
309
+ * frozen, and only then be refused at render time — a whole recording thrown away for a
310
+ * character the recorder could have replaced. `narration.test.ts` asserts the two agree on a
311
+ * table of candidates, because a comment cannot.
312
+ */
313
+ path: z5.string().regex(/^speech\/clips\/[A-Za-z0-9][A-Za-z0-9._-]*\.wav$/),
314
+ atMs: z5.number().int().nonnegative(),
315
+ durationMs: z5.number().int().positive(),
316
+ source: z5.enum(["synth", "file"])
317
+ });
318
+ var SpeechEngineSchema = z5.object({
319
+ name: z5.string().min(1),
320
+ version: z5.string().min(1),
321
+ modelSha256: z5.string().regex(/^[0-9a-f]{64}$/),
322
+ voice: z5.string().min(1)
323
+ });
324
+ var SpeechSchema = z5.object({
325
+ trackPath: z5.literal("speech/narration.wav"),
326
+ /*
327
+ * 24 kHz mono, because that is what Kokoro emits and nothing on this path resamples.
328
+ * Written as literals rather than imported from `@plaintake/audio`, which owns the
329
+ * format: that package depends on this one for `DemoError`, so importing it back would
330
+ * be the cycle `no-circular` refuses. `packages/audio/src/wav.test.ts` asserts the two
331
+ * agree, since a comment cannot.
332
+ */
333
+ sampleRate: z5.literal(24e3),
334
+ channels: z5.literal(1),
335
+ clips: z5.array(SpeechClipSchema).min(1),
336
+ engine: SpeechEngineSchema.optional()
337
+ }).refine(
338
+ ({ clips }) => new Set(clips.map((clip) => clip.id)).size === clips.length && clips.every(
339
+ (clip, index) => index === 0 || clips[index - 1].atMs + clips[index - 1].durationMs <= clip.atMs
340
+ ),
341
+ {
342
+ /*
343
+ * Ordered and non-overlapping, enforced rather than tolerated — which is also what makes
344
+ * the mixer a *layout* rather than a mix, with no summing, no clipping and no limiter to
345
+ * make deterministic. Two clips overlapping would be two voices talking over each other,
346
+ * which no derivation should ever produce: the recorder holds each step open for its own
347
+ * clip. Duplicate ids would collide on one file path.
348
+ */
349
+ message: "clips must have unique ids and be laid out in order without overlapping",
350
+ path: ["clips"]
351
+ }
352
+ ).refine(({ clips, engine }) => engine !== void 0 || clips.every((clip) => clip.source === "file"), {
353
+ // A synthesised clip with no record of what synthesised it is the one state this block
354
+ // exists to prevent. The converse is fine: an engine recorded on an all-file track is
355
+ // merely redundant, not misleading.
356
+ message: "engine must be recorded whenever any clip was synthesised",
357
+ path: ["engine"]
358
+ });
359
+ var RENDER_PLAN_SCHEMA = "agent-demo.render/v1";
360
+ var RenderPlanSchema = z5.object({
361
+ schema: z5.literal(RENDER_PLAN_SCHEMA),
362
+ source: z5.object({
363
+ path: z5.literal("raw/session.webm"),
364
+ sha256: z5.string().regex(/^[0-9a-f]{64}$/),
365
+ durationMs: z5.number().int().nonnegative()
366
+ }),
367
+ video: z5.object({
368
+ width: z5.literal(1920),
369
+ height: z5.literal(1080),
370
+ fps: z5.literal(30),
371
+ pixelFormat: z5.literal("yuv420p"),
372
+ /*
373
+ * Everything before the closing card: the opening card, if there is one, plus the
374
+ * recorded content. Not the content alone.
375
+ *
376
+ * That is the definition the rest of the plan already depends on, rather than a
377
+ * convenience. Every other timestamp here — cue, cursor point, camera shot, chapter
378
+ * mark — is an offset into the finished video, so the number they are bounded by has to
379
+ * be one too. It is also where the outro starts (`writeCaptions`), where the cursor and
380
+ * camera tracks are told the picture ends, and what the last chapter mark tiles up to;
381
+ * defining it as the content alone would have put an intro's length into all four
382
+ * call sites separately.
383
+ *
384
+ * With no intro it is exactly what it always was, which is what keeps a plan without
385
+ * one byte-identical to what earlier versions wrote.
386
+ */
387
+ durationMs: z5.number().int().positive(),
388
+ tailPadMs: z5.number().int().nonnegative()
389
+ }),
390
+ captions: z5.object({
391
+ language: z5.string().min(2),
392
+ srtPath: z5.literal("captions/captions.srt"),
393
+ vttPath: z5.literal("captions/captions.vtt"),
394
+ assPath: z5.literal("captions/captions.ass"),
395
+ cues: z5.array(CueSchema)
396
+ }),
397
+ /**
398
+ * How the burned-in captions were drawn. This is the **source** of
399
+ * `captions/captions.ass` rather than a note written alongside it, so a bundle describes
400
+ * its own appearance and the two cannot disagree.
401
+ *
402
+ * Every field added here carries a default, and each default is the value that preceded
403
+ * it: an opaque outline, no box, `BorderStyle 1`. A plan frozen before this therefore
404
+ * parses to exactly the style it was rendered with. It was never a compatibility event in
405
+ * either direction — a render consumes the frozen ASS file rather than this block, so an
406
+ * older reader ignoring fields it does not know still executes the same frozen arguments
407
+ * over the same frozen ASS. That observation generalises to the schema as a whole.
408
+ */
409
+ style: z5.object({
410
+ fontFile: z5.literal("assets/fonts/NotoSans-Regular.ttf"),
411
+ fontName: z5.literal("Noto Sans"),
412
+ fontSize: z5.number().int().positive(),
413
+ textColor: z5.string(),
414
+ outlineColor: z5.string(),
415
+ /** ASS `Outline`: a stroke width under `BorderStyle 1`, box padding under `BorderStyle 4`. */
416
+ outlineWidth: z5.number().int().nonnegative(),
417
+ marginBottom: z5.number().int().nonnegative(),
418
+ marginSide: z5.number().int().nonnegative().default(80),
419
+ borderStyle: z5.union([z5.literal(1), z5.literal(4)]).default(1),
420
+ outlineOpacity: z5.number().min(0).max(1).default(1),
421
+ boxColor: HEX_COLOUR.default("#000000"),
422
+ /*
423
+ * Opaque, which under the `BorderStyle 1` default draws nothing at all: `BackColour` is
424
+ * the *shadow* colour there, and `Shadow` is 0. `&H00000000` is both the historical value
425
+ * and ASS's own default, so a plan frozen before this field existed regenerates its ASS
426
+ * byte-for-byte rather than byte-for-byte-except-one-field-nobody-can-see.
427
+ */
428
+ boxOpacity: z5.number().min(0).max(1).default(1)
429
+ }),
430
+ ffmpeg: z5.object({
431
+ base: z5.array(z5.string()),
432
+ soft: z5.array(z5.string()),
433
+ hard: z5.array(z5.string())
434
+ }),
435
+ intro: IntroSchema.optional(),
436
+ outro: OutroSchema.optional(),
437
+ chapters: ChaptersSchema.optional(),
438
+ cursor: CursorSchema.optional(),
439
+ camera: CameraSchema.optional(),
440
+ speech: SpeechSchema.optional()
441
+ });
442
+
443
+ // ../schema/src/results.ts
444
+ import { z as z6 } from "zod";
445
+ var RELATIVE_POSIX = z6.string().regex(/^[A-Za-z0-9.][A-Za-z0-9._/-]*$/, "must be a relative POSIX path").refine((value) => !value.split("/").includes(".."), "must not traverse upwards");
446
+ var Sha2562 = z6.string().regex(/^[0-9a-f]{64}$/);
447
+ var DISPLAY_PATH = z6.string().min(1);
448
+ var envelope = (kind) => ({
449
+ schema: z6.literal("agent-demo.result/v1"),
450
+ kind: z6.literal(kind),
451
+ ok: z6.boolean(),
452
+ /** Human-readable failures. Empty iff ok. */
453
+ problems: z6.array(z6.string())
454
+ });
455
+ var ArtifactRefSchema = z6.object({
456
+ path: RELATIVE_POSIX,
457
+ sha256: Sha2562,
458
+ bytes: z6.number().int().nonnegative()
459
+ });
460
+ var StreamSummarySchema = z6.object({
461
+ container: z6.enum(["mp4", "webm", "other"]),
462
+ durationMs: z6.number().int().nonnegative(),
463
+ video: z6.object({
464
+ codec: z6.string(),
465
+ width: z6.number().int(),
466
+ height: z6.number().int(),
467
+ pixelFormat: z6.string(),
468
+ fps: z6.number().int(),
469
+ frames: z6.number().int(),
470
+ /**
471
+ * The video stream's own duration, as distinct from the container's above. The
472
+ * constant-rate check measures frames against this, because a padded narration track makes
473
+ * the container's duration the audio's. Zero for a WebM, which carries no per-track
474
+ * duration at all.
475
+ */
476
+ durationMs: z6.number().int().nonnegative()
477
+ }),
478
+ subtitles: z6.array(z6.object({ codec: z6.string(), language: z6.string(), title: z6.string() })),
479
+ /** Properties, not a count — a narration track muxed in stereo or at the wrong rate
480
+ * still counts as one stream. */
481
+ audio: z6.array(
482
+ z6.object({
483
+ codec: z6.string(),
484
+ sampleRate: z6.number().int().nonnegative(),
485
+ channels: z6.number().int().nonnegative()
486
+ })
487
+ ),
488
+ /** Titled chapters make FFmpeg's MP4 muxer add a `bin_data` track beside the `chpl` atom. */
489
+ dataStreams: z6.number().int().nonnegative(),
490
+ chapters: z6.array(
491
+ z6.object({
492
+ startMs: z6.number().int().nonnegative(),
493
+ endMs: z6.number().int().nonnegative(),
494
+ title: z6.string()
495
+ })
496
+ )
497
+ });
498
+ var ToolchainSchema = z6.object({
499
+ node: z6.string(),
500
+ playwright: z6.string(),
501
+ chromiumRevision: z6.string(),
502
+ ffmpeg: z6.string(),
503
+ /** May be the literal "unknown" — never fabricated. */
504
+ libass: z6.string(),
505
+ fontSha256: Sha2562
506
+ });
507
+ var AssertionSchema = z6.object({ id: z6.string().min(1), status: z6.enum(["passed", "failed"]) });
508
+ var StatusSchema = z6.enum(["passed", "failed"]);
509
+ var VariantsSchema = z6.enum(["soft", "hard"]);
510
+ var ValidationResultSchema = z6.object({
511
+ ...envelope("validate"),
512
+ /** Absent when the scenario could not be loaded at all. */
513
+ scenario: z6.object({
514
+ id: z6.string(),
515
+ title: z6.string(),
516
+ language: z6.string(),
517
+ /**
518
+ * Whether this scenario needs a person, and in which phase. Reported so an author
519
+ * — or an agent reading `--json` — learns that a scenario cannot run unattended
520
+ * without first trying to run it. Defaulted, so a result written before this parses.
521
+ */
522
+ handoff: z6.enum(["none", "preflight", "session"]).default("none"),
523
+ sha256: Sha2562
524
+ }).optional()
525
+ });
526
+ var RunCommandResultSchema = z6.object({
527
+ ...envelope("run"),
528
+ bundleDir: DISPLAY_PATH,
529
+ status: StatusSchema,
530
+ cueCount: z6.number().int().nonnegative(),
531
+ /** Zero on the Free Tier, and zero for a scenario with no `chapter` calls. */
532
+ chapterCount: z6.number().int().nonnegative(),
533
+ /**
534
+ * How many lines the video speaks. Zero for a silent run, which is the default.
535
+ *
536
+ * The only place this fact surfaces. A narrated bundle is otherwise indistinguishable from a
537
+ * silent one without probing the MP4 — and the failure worth catching is `--speech on` on a
538
+ * scenario whose steps carry no `subtitle`, which produces a perfectly valid silent video and
539
+ * would look like the feature being broken rather than like nothing having been written to say.
540
+ */
541
+ narratedCount: z6.number().int().nonnegative(),
542
+ diagnostics: z6.array(z6.object({ code: z6.string(), cueId: z6.string(), detail: z6.string() })),
543
+ assertions: z6.array(AssertionSchema),
544
+ outputs: z6.array(ArtifactRefSchema)
545
+ });
546
+ var RenderCommandResultSchema = z6.object({
547
+ ...envelope("render"),
548
+ bundleDir: DISPLAY_PATH,
549
+ variants: VariantsSchema,
550
+ streams: z6.object({
551
+ base: StreamSummarySchema.optional(),
552
+ soft: StreamSummarySchema.optional(),
553
+ hard: StreamSummarySchema.optional()
554
+ }),
555
+ outputs: z6.array(ArtifactRefSchema)
556
+ });
557
+ var VerificationReportSchema = z6.object({
558
+ ...envelope("verify"),
559
+ bundleDir: DISPLAY_PATH,
560
+ artifactCount: z6.number().int().nonnegative()
561
+ });
562
+ var InspectResultSchema = z6.object({
563
+ ...envelope("inspect"),
564
+ bundleDir: DISPLAY_PATH,
565
+ status: StatusSchema,
566
+ scenario: z6.object({ id: z6.string(), sourceSha256: Sha2562 }),
567
+ toolchain: ToolchainSchema,
568
+ video: z6.object({
569
+ /** The whole output: the cards, if any, plus the content between them. */
570
+ durationMs: z6.number().int(),
571
+ frames: z6.number().int(),
572
+ width: z6.number().int(),
573
+ height: z6.number().int(),
574
+ fps: z6.number().int(),
575
+ /**
576
+ * Split out because `durationMs` alone is misleading once a card exists: 12,034ms of
577
+ * output can be 9,034ms of recording. The parts are reported separately rather than
578
+ * leaving the reader to subtract.
579
+ *
580
+ * **These do not sum to `durationMs` exactly.** `durationMs` is measured from the
581
+ * rendered container while these come from the plan, and 361 frames at 30fps is 12,033ms
582
+ * against a planned 12,034 — under one frame of rounding. No probe of the finished MP4
583
+ * can separate content frames from card frames, so the split has to be declared.
584
+ *
585
+ * `contentDurationMs` is the recording alone, with both cards taken off — which is what
586
+ * the word means, and is worth stating because the plan's own `video.durationMs` is a
587
+ * different number that includes the opening card.
588
+ */
589
+ contentDurationMs: z6.number().int().nonnegative(),
590
+ introDurationMs: z6.number().int().nonnegative().default(0),
591
+ outroDurationMs: z6.number().int().nonnegative()
592
+ }),
593
+ captions: z6.object({
594
+ language: z6.string(),
595
+ cueCount: z6.number().int().nonnegative(),
596
+ characterCount: z6.number().int().nonnegative(),
597
+ medianCueMs: z6.number().int().nonnegative(),
598
+ shortestCueMs: z6.number().int().nonnegative(),
599
+ longestCueMs: z6.number().int().nonnegative()
600
+ }),
601
+ /** Measured from the rendered MP4 where possible, declared from the plan otherwise. */
602
+ chapters: z6.array(z6.object({ startMs: z6.number().int().nonnegative(), title: z6.string() })),
603
+ /**
604
+ * The narration this bundle carries, or absent for a silent one.
605
+ *
606
+ * Absent rather than a zero-clip object, matching the plan field it is read from — and matching
607
+ * what the distinction means: a silent bundle has no narration, not an empty one.
608
+ *
609
+ * `voice` is present only when something was synthesised. An author-supplied voiceover has no
610
+ * model behind it, and inventing a voice id for it would make the one field that answers "why
611
+ * does this sound different" answer wrongly.
612
+ */
613
+ narration: z6.object({
614
+ clipCount: z6.number().int().positive(),
615
+ /** Total speech, excluding the silence between clips. Not the video's length. */
616
+ spokenMs: z6.number().int().nonnegative(),
617
+ /** How many clips the author supplied rather than the model producing. */
618
+ suppliedCount: z6.number().int().nonnegative(),
619
+ voice: z6.string().optional()
620
+ }).optional(),
621
+ /** The rendered MP4s, from the manifest, so hashes are not recomputed. */
622
+ outputs: z6.array(ArtifactRefSchema),
623
+ assertions: z6.array(AssertionSchema),
624
+ artifactCount: z6.number().int().nonnegative(),
625
+ /** Everything the manifest accounts for. What "delete this bundle" would reclaim. */
626
+ bundleBytes: z6.number().int().nonnegative()
627
+ });
628
+ var DoctorResultSchema = z6.object({
629
+ ...envelope("doctor"),
630
+ /**
631
+ * The PlainTake build this is. Here rather than in `ToolchainSchema` on purpose:
632
+ * that record is written into a bundle and hashed, so a version in it would change every
633
+ * manifest on every release and break the byte-for-byte golden bundle. `doctor` is the
634
+ * one command a bug report is asked for, which makes it the right place.
635
+ */
636
+ version: z6.string(),
637
+ ffmpeg: z6.string(),
638
+ ffprobe: z6.string(),
639
+ libass: z6.string(),
640
+ hasLibass: z6.boolean(),
641
+ hasX264: z6.boolean(),
642
+ /**
643
+ * Whether this FFmpeg can encode AAC, which is what a narrated bundle is muxed with.
644
+ *
645
+ * Reported but **not** folded into `problems`, so its absence never fails `doctor`. AAC is
646
+ * needed by a bundle that carries narration and by nothing else, so a build without it is
647
+ * perfectly usable for the silent default — and `toolchainProblems` already refuses the render
648
+ * that actually needs it, with a fix string. Failing `doctor` here would be telling someone
649
+ * their installation is broken for a capability they may never turn on.
650
+ */
651
+ hasAac: z6.boolean(),
652
+ filters: z6.array(z6.string()),
653
+ /**
654
+ * Whether spoken narration could be produced right now, and what is missing if not.
655
+ *
656
+ * Separate from `problems` for the same reason as `hasAac`: the voice model is a one-time
657
+ * opt-in download, so its absence is a *state*, not a fault. `ready` is the same question
658
+ * `openNarrator` asks before a run, answered by the same function (`speechAssetProblems`), so
659
+ * `doctor` cannot say speech is fine and then a run refuse it.
660
+ */
661
+ speech: z6.object({
662
+ ready: z6.boolean(),
663
+ /** Installed voices, in catalogue order. Empty means the model cannot speak yet. */
664
+ voices: z6.array(z6.string()),
665
+ /** What is missing, in the same words a refused run would use. Empty when ready. */
666
+ notes: z6.array(z6.string())
667
+ })
668
+ });
669
+ var okMatchesProblems = (value) => value.ok === (value.problems.length === 0);
670
+ var ActivateReasonSchema = z6.enum([
671
+ "empty-key",
672
+ "not-found",
673
+ "revoked",
674
+ "offline",
675
+ "unexpected-response",
676
+ "not-configured"
677
+ ]);
678
+ var ActivateResultSchema = z6.object({
679
+ ...envelope("activate"),
680
+ /** Present only when ok is false, so a machine reader can distinguish the classes. */
681
+ reason: ActivateReasonSchema.optional(),
682
+ /** Present only on success — a failed activation has no record. The key itself is
683
+ * deliberately absent: the caller already has it, and stdout may be logged. */
684
+ licence: z6.object({
685
+ subject: z6.string().min(1),
686
+ activatedAt: z6.string().min(1),
687
+ uses: z6.number().int().nonnegative()
688
+ }).optional()
689
+ }).refine(okMatchesProblems, "ok must be true iff problems is empty").refine(
690
+ // Making the "present only when" comments above literally true, so a reader can
691
+ // switch on `ok` and know which optional fields exist without checking each one.
692
+ (value) => value.reason !== void 0 === !value.ok && (value.licence === void 0 || value.ok),
693
+ "reason appears iff ok is false, and licence only on success"
694
+ );
695
+ var LicenceResultSchema = z6.discriminatedUnion("tier", [
696
+ z6.object({
697
+ ...envelope("licence"),
698
+ tier: z6.literal("free"),
699
+ /**
700
+ * Why a present-but-unusable record degraded to free, in readLicense's own words.
701
+ * Must never quote a filesystem path: assertNoAbsolutePaths exempts only `problems`.
702
+ */
703
+ problem: z6.string().optional()
704
+ }),
705
+ z6.object({
706
+ ...envelope("licence"),
707
+ tier: z6.literal("pro"),
708
+ subject: z6.string().min(1),
709
+ activatedAt: z6.string().min(1),
710
+ uses: z6.number().int().nonnegative()
711
+ })
712
+ ]).refine(okMatchesProblems, "ok must be true iff problems is empty");
713
+
714
+ // ../schema/src/zod-format.ts
715
+ import { ZodError } from "zod";
716
+ function isZodError(error) {
717
+ return error instanceof ZodError;
718
+ }
719
+ function formatZodError(error) {
720
+ return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
721
+ }
722
+
723
+ // src/define.ts
724
+ function defineDemo(input) {
725
+ if (typeof input?.run !== "function") {
726
+ throw new DemoError("usage", "defineDemo requires a run function");
727
+ }
728
+ if (input.preflight !== void 0 && typeof input.preflight !== "function") {
729
+ throw new DemoError("usage", "defineDemo: preflight must be a function when present");
730
+ }
731
+ if (input.warmup !== void 0 && typeof input.warmup !== "function") {
732
+ throw new DemoError("usage", "defineDemo: warmup must be a function when present");
733
+ }
734
+ let meta;
735
+ try {
736
+ meta = ScenarioMetaSchema.parse(input);
737
+ } catch (error) {
738
+ if (isZodError(error)) throw new DemoError("usage", formatZodError(error));
739
+ throw error;
740
+ }
741
+ return {
742
+ ...meta,
743
+ run: input.run,
744
+ // Re-attached conditionally, so a scenario without one has no `preflight` key at all
745
+ // rather than a key whose value is `undefined` — `record()` tests for the key.
746
+ // `warmup` follows the same rule for the same reason.
747
+ ...input.preflight === void 0 ? {} : { preflight: input.preflight },
748
+ ...input.warmup === void 0 ? {} : { warmup: input.warmup }
749
+ };
750
+ }
751
+ export {
752
+ defineDemo
753
+ };