@plaintake/scenario 1.23.0 → 1.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,3 +1,62 @@
1
+ // ../schema/src/baseline.ts
2
+ import { z } from "zod";
3
+ var Sha256 = z.string().regex(/^[0-9a-f]{64}$/);
4
+ var DiffCategorySchema = z.enum([
5
+ "step",
6
+ "assertion",
7
+ "target-name",
8
+ "target-role",
9
+ "target-bounds",
10
+ "timing",
11
+ "caption",
12
+ "actor"
13
+ ]);
14
+ var SeveritySchema = z.enum(["fail", "report"]);
15
+ var DifferenceSchema = z.object({
16
+ category: DiffCategorySchema,
17
+ severity: SeveritySchema,
18
+ detail: z.string()
19
+ });
20
+ var RectSchema = z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() });
21
+ var SnapshotTargetSchema = z.object({
22
+ role: z.string().nullable(),
23
+ accessibleName: z.string().nullable(),
24
+ rect: RectSchema.nullable()
25
+ });
26
+ var SnapshotStepSchema = z.object({
27
+ stableId: z.string().min(1),
28
+ target: SnapshotTargetSchema.nullable(),
29
+ /** `null` for a step that started and never finished. */
30
+ durationMs: z.number().nullable()
31
+ });
32
+ var SemanticSnapshotSchema = z.object({
33
+ /** sha256 of the bundle's `scenario/scenario.ts`; `null` when the bundle has none. */
34
+ scenarioSha256: Sha256.nullable(),
35
+ steps: z.array(SnapshotStepSchema),
36
+ assertions: z.array(z.object({ stableId: z.string().min(1), status: z.enum(["passed", "failed"]) })),
37
+ /** Absent when the bundle carries no render plan — a `check` bundle. */
38
+ captions: z.array(z.object({ id: z.string(), lines: z.array(z.string()) })).optional(),
39
+ actors: z.object({
40
+ ids: z.array(z.string()),
41
+ turnOrder: z.array(z.string()),
42
+ /** Absent without a render plan, or for a single-actor plan. */
43
+ labels: z.array(z.object({ id: z.string(), label: z.string() })).optional()
44
+ })
45
+ });
46
+ var BASELINE_SCHEMA = "plaintake.baseline/v1";
47
+ var BaselineFileSchema = z.object({
48
+ schema: z.literal(BASELINE_SCHEMA),
49
+ /** Informational only: which PlainTake wrote the file. */
50
+ plaintakeVersion: z.string().min(1),
51
+ snapshot: SemanticSnapshotSchema.extend({ scenarioSha256: Sha256 })
52
+ });
53
+ var BaselineStatusSchema = z.enum(["match", "drift", "scenario-changed", "absent", "skipped", "updated"]);
54
+ var BaselineReportSchema = z.object({
55
+ path: z.string().min(1),
56
+ status: BaselineStatusSchema,
57
+ differences: z.array(DifferenceSchema)
58
+ });
59
+
1
60
  // ../schema/src/errors.ts
2
61
  var EXIT_CODES = {
3
62
  ok: 0,
@@ -6,7 +65,8 @@ var EXIT_CODES = {
6
65
  toolchain: 3,
7
66
  capture: 4,
8
67
  render: 5,
9
- verification: 6
68
+ verification: 6,
69
+ drift: 7
10
70
  };
11
71
  var DemoError = class extends Error {
12
72
  kind;
@@ -22,9 +82,9 @@ var DemoError = class extends Error {
22
82
  };
23
83
 
24
84
  // ../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([
85
+ import { z as z2 } from "zod";
86
+ var NanosString = z2.string().regex(/^\d+$/, "must be a digit-only nanosecond string");
87
+ var DemoEventTypeSchema = z2.enum([
28
88
  "session.start",
29
89
  "session.finish",
30
90
  "chapter",
@@ -54,7 +114,7 @@ var DemoEventTypeSchema = z.enum([
54
114
  "turn",
55
115
  /*
56
116
  * A full-frame motion-graphics cut-away: `demo.explain(...)` stops the screencast, splices
57
- * in a PlainMotion-rendered scene at this point on the timeline, and resumes the recording
117
+ * in a rendered motion-graphics scene at this point on the timeline, and resumes the recording
58
118
  * on the same page, same actor. A member of its own for the same reason `turn` is one: a
59
119
  * cut-away is not a step — no target, no verb, no hold — and its `payload` carries the
60
120
  * whole authored request (id, narration, scene props, cues) because the scene is compiled
@@ -64,23 +124,23 @@ var DemoEventTypeSchema = z.enum([
64
124
  */
65
125
  "explain"
66
126
  ]);
67
- var TargetSchema = z.object({
68
- role: z.string().optional(),
69
- accessibleName: z.string().optional(),
70
- selector: z.string().optional(),
71
- rect: z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() }).optional()
127
+ var TargetSchema = z2.object({
128
+ role: z2.string().optional(),
129
+ accessibleName: z2.string().optional(),
130
+ selector: z2.string().optional(),
131
+ rect: z2.object({ x: z2.number(), y: z2.number(), width: z2.number(), height: z2.number() }).optional()
72
132
  });
73
- var DemoEventSchema = z.object({
74
- schema: z.literal("agent-demo.event/v1"),
75
- seq: z.number().int().nonnegative(),
133
+ var DemoEventSchema = z2.object({
134
+ schema: z2.literal("agent-demo.event/v1"),
135
+ seq: z2.number().int().nonnegative(),
76
136
  tStartNs: NanosString,
77
137
  tEndNs: NanosString.optional(),
78
138
  type: DemoEventTypeSchema,
79
- stableId: z.string().min(1).optional(),
80
- title: z.string().optional(),
81
- subtitle: z.string().optional(),
139
+ stableId: z2.string().min(1).optional(),
140
+ title: z2.string().optional(),
141
+ subtitle: z2.string().optional(),
82
142
  target: TargetSchema.optional(),
83
- payload: z.record(z.string(), z.unknown()).optional(),
143
+ payload: z2.record(z2.string(), z2.unknown()).optional(),
84
144
  /**
85
145
  * Which actor performed this event, for a `turn` hand-off or a verb step taken during one.
86
146
  * Absent means the scenario's default actor — a single-actor event log never sets this, so
@@ -88,27 +148,34 @@ var DemoEventSchema = z.object({
88
148
  * here: the default actor's id is an application-layer notion the recorder resolves, not a
89
149
  * value this schema should invent and materialise on parse.
90
150
  */
91
- actor: z.string().min(1).optional()
151
+ actor: z2.string().min(1).optional()
92
152
  });
93
153
 
94
154
  // ../schema/src/json-schema.ts
95
- import { z as z3 } from "zod";
155
+ import { z as z4 } from "zod";
96
156
 
97
157
  // ../schema/src/scenario.ts
98
- import { z as z2 } from "zod";
99
- var ScenarioIntroSchema = z2.object({
100
- 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"),
101
- narration: z2.string().min(1).optional(),
102
- durationMs: z2.number().int().positive().max(15e3, "intro.durationMs must be at most 15000ms").optional()
158
+ import { z as z3 } from "zod";
159
+ var ScenarioIntroSchema = z3.object({
160
+ lines: z3.array(z3.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"),
161
+ narration: z3.string().min(1).optional(),
162
+ durationMs: z3.number().int().positive().max(15e3, "intro.durationMs must be at most 15000ms").optional()
103
163
  });
104
- var ScenarioCameraSchema = z2.object({
105
- 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(),
106
- 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(),
107
- easeMs: z2.number().int().min(133, "camera.easeMs must be at least 133ms").max(2e3, "camera.easeMs must be at most 2000ms").optional(),
108
- minDwellMs: z2.number().int().min(0, "camera.minDwellMs must be at least 0").max(5e3, "camera.minDwellMs must be at most 5000ms").optional()
164
+ var ScenarioHookSchema = z3.union([
165
+ z3.string().min(1),
166
+ z3.object({
167
+ text: z3.string().min(1),
168
+ durationMs: z3.number().int().min(1e3, "hook.durationMs must be at least 1000ms").max(1e4, "hook.durationMs must be at most 10000ms \u2014 past that it is a title, not a hook").optional()
169
+ })
170
+ ]);
171
+ var ScenarioCameraSchema = z3.object({
172
+ maxZoom: z3.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(),
173
+ margin: z3.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(),
174
+ easeMs: z3.number().int().min(133, "camera.easeMs must be at least 133ms").max(2e3, "camera.easeMs must be at most 2000ms").optional(),
175
+ minDwellMs: z3.number().int().min(0, "camera.minDwellMs must be at least 0").max(5e3, "camera.minDwellMs must be at most 5000ms").optional()
109
176
  });
110
- var ScenarioSpeechSchema = z2.object({
111
- speed: z2.number().min(0.5, "speech.speed must be at least 0.5 (half speed)").max(2, "speech.speed must be at most 2.0 (double speed) \u2014 an unmeasured, conservative ceiling").optional(),
177
+ var ScenarioSpeechSchema = z3.object({
178
+ speed: z3.number().min(0.5, "speech.speed must be at least 0.5 (half speed)").max(2, "speech.speed must be at most 2.0 (double speed) \u2014 an unmeasured, conservative ceiling").optional(),
112
179
  /*
113
180
  * The voices this scenario may switch to mid-run, beyond the run-wide default `--voice`
114
181
  * picks. A step names one with `demo.step({ voice })` or an actor with `demo.actor(id,
@@ -135,24 +202,63 @@ var ScenarioSpeechSchema = z2.object({
135
202
  * A mix is refused at record time with both sides named — the run's pronunciation
136
203
  * dictionary is configured once, for the one language it speaks.
137
204
  */
138
- voices: z2.array(z2.string().min(1, "speech.voices entries must be non-empty voice names")).min(1, "speech.voices must name at least one voice \u2014 an empty list declares nothing, so omit it").optional()
205
+ voices: z3.array(z3.string().min(1, "speech.voices entries must be non-empty voice names")).min(1, "speech.voices must name at least one voice \u2014 an empty list declares nothing, so omit it").optional()
139
206
  });
140
- var ScenarioMetaSchema = z2.object({
141
- schema: z2.literal("agent-demo.scenario/v1"),
142
- id: z2.string().regex(/^[a-z0-9][a-z0-9-]*$/, "must be a lowercase kebab-case id"),
143
- title: z2.string().min(1),
144
- language: z2.string().min(2).default("en"),
145
- viewport: z2.object({
146
- width: z2.literal(1920),
147
- height: z2.literal(1080),
148
- deviceScaleFactor: z2.literal(1)
207
+ var EnvName = z3.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/);
208
+ var TerminalMetaSchema = z3.object({
209
+ shell: z3.enum(["bash", "zsh", "sh"]).default("bash"),
210
+ command: z3.array(z3.string().min(1)).min(1).optional(),
211
+ cols: z3.number().int().min(40).max(200).default(120),
212
+ rows: z3.number().int().min(12).max(60).default(34),
213
+ cwd: z3.string().min(1).optional(),
214
+ env: z3.array(EnvName).default([]),
215
+ secrets: z3.array(EnvName).default([]),
216
+ browser: z3.boolean().optional(),
217
+ label: z3.string().trim().min(1).optional(),
218
+ transition: z3.enum(["cut", "card"]).optional()
219
+ }).superRefine((t, ctx) => {
220
+ for (const field of ["label", "transition"]) {
221
+ if (t[field] !== void 0 && t.browser !== true) {
222
+ ctx.addIssue({ code: "custom", path: [field], message: `terminal.${field} needs terminal.browser: true` });
223
+ }
224
+ }
225
+ for (const name of t.secrets) {
226
+ if (!t.env.includes(name)) {
227
+ ctx.addIssue({ code: "custom", path: ["secrets"], message: `secret ${name} must also be listed in terminal.env` });
228
+ }
229
+ }
230
+ });
231
+ var ScenarioMetaSchema = z3.object({
232
+ schema: z3.literal("agent-demo.scenario/v1"),
233
+ id: z3.string().regex(/^[a-z0-9][a-z0-9-]*$/, "must be a lowercase kebab-case id"),
234
+ title: z3.string().min(1),
235
+ language: z3.string().min(2).default("en"),
236
+ viewport: z3.object({
237
+ width: z3.literal(1920),
238
+ height: z3.literal(1080),
239
+ deviceScaleFactor: z3.literal(1)
149
240
  }),
150
- locale: z2.literal("en-US"),
151
- timezoneId: z2.literal("UTC"),
152
- colorScheme: z2.literal("light"),
153
- reducedMotion: z2.literal("reduce"),
241
+ /*
242
+ * How large the page is drawn inside the fixed 1920x1080 capture. At `s`, the page lays out
243
+ * at a 1920/s x 1080/s CSS viewport with deviceScaleFactor `s`, so Chromium paints every
244
+ * CSS pixel as s x s device pixels: the frame is still 1920x1080, the UI in it is s times
245
+ * larger and sharp, and the app's own responsive layout sees the narrower viewport. It is
246
+ * what a person gets from browser zoom, and what a vertical (`--aspect 9:16`) cut needs to
247
+ * stay legible once the whole frame is letterboxed to 1080 wide.
248
+ *
249
+ * `viewport` above still describes the capture, not the layout, which is why it stays a
250
+ * literal. Only 1.5 and 2 are offered besides 1: both divide 1920x1080 into whole CSS
251
+ * pixels, and both were measured (`ui-scale-dpr-spike.spec.ts`) to capture device-pixel
252
+ * frames headless and headed. Optional and inert when absent: every scenario written
253
+ * before this records exactly as it always did.
254
+ */
255
+ uiScale: z3.union([z3.literal(1), z3.literal(1.5), z3.literal(2)]).optional(),
256
+ locale: z3.literal("en-US"),
257
+ timezoneId: z3.literal("UTC"),
258
+ colorScheme: z3.literal("light"),
259
+ reducedMotion: z3.literal("reduce"),
154
260
  /** Exact console error strings the scenario tolerates. Anything else fails the run. */
155
- allowedConsoleErrors: z2.array(z2.string()).default([]),
261
+ allowedConsoleErrors: z3.array(z3.string()).default([]),
156
262
  /*
157
263
  * Whether this scenario hands the real browser window to a person, and in which phase.
158
264
  *
@@ -170,7 +276,7 @@ var ScenarioMetaSchema = z2.object({
170
276
  * The same precedent as everywhere else here: a `.default()`, so every scenario written
171
277
  * before this parses unchanged.
172
278
  */
173
- handoff: z2.enum(["none", "preflight", "session"]).default("none"),
279
+ handoff: z3.enum(["none", "preflight", "session"]).default("none"),
174
280
  /*
175
281
  * How long a handoff waits for the person before giving up. Not a patience limit —
176
282
  * past a few minutes, a block is indistinguishable from a hang, and the person is better
@@ -179,7 +285,7 @@ var ScenarioMetaSchema = z2.object({
179
285
  * In the schema rather than only in the runtime so `validate` catches an out-of-range
180
286
  * value with no browser and no waiting.
181
287
  */
182
- handoffTimeoutMs: z2.number().int().min(5e3, "handoffTimeoutMs must be at least 5000ms").max(
288
+ handoffTimeoutMs: z3.number().int().min(5e3, "handoffTimeoutMs must be at least 5000ms").max(
183
289
  3e5,
184
290
  "handoffTimeoutMs must be at most 300000ms (5 minutes) \u2014 past that a wait is indistinguishable from a hang"
185
291
  ).default(12e4),
@@ -193,6 +299,12 @@ var ScenarioMetaSchema = z2.object({
193
299
  * discarded in silence rather than refused.
194
300
  */
195
301
  intro: ScenarioIntroSchema.optional(),
302
+ /*
303
+ * The hook, drawn over the top band of a portrait render. Optional on the same precedent as
304
+ * `intro`; a scenario without one renders exactly what it always did, and a 16:9 render of one
305
+ * that has it carries it undrawn.
306
+ */
307
+ hook: ScenarioHookSchema.optional(),
196
308
  /*
197
309
  * Framing overrides for this scenario's pages. Optional like everything above it, and
198
310
  * inert when absent: the defaults are the constants the camera has always used.
@@ -208,6 +320,8 @@ var ScenarioMetaSchema = z2.object({
208
320
  * comment for the field's shape and the reasoning behind `speed`'s bounds.
209
321
  */
210
322
  speech: ScenarioSpeechSchema.optional(),
323
+ /* A live terminal as the recorded source instead of a web page; see `TerminalMetaSchema`. */
324
+ terminal: TerminalMetaSchema.optional(),
211
325
  /*
212
326
  * A text-substitution dictionary applied to narration immediately before synthesis — not the
213
327
  * vendored en-us phoneme dictionary `install-voice` fetches (a different thing entirely: that
@@ -227,120 +341,167 @@ var ScenarioMetaSchema = z2.object({
227
341
  *
228
342
  * Optional and inert when absent, the same precedent as everything else on this schema.
229
343
  */
230
- pronunciations: z2.record(z2.string().min(1, "pronunciation keys must not be empty"), z2.string()).optional()
344
+ pronunciations: z3.record(z3.string().min(1, "pronunciation keys must not be empty"), z3.string()).optional()
231
345
  });
232
346
 
233
347
  // ../schema/src/manifest.ts
234
- import { z as z4 } from "zod";
235
- var Sha256 = z4.string().regex(/^[0-9a-f]{64}$/);
236
- var BundleManifestSchema = z4.object({
237
- schema: z4.literal("agent-demo.bundle/v1"),
238
- status: z4.enum(["passed", "failed"]),
239
- scenario: z4.object({ id: z4.string().min(1), sourceSha256: Sha256 }),
240
- toolchain: z4.object({
241
- node: z4.string(),
242
- playwright: z4.string(),
243
- chromiumRevision: z4.string(),
244
- ffmpeg: z4.string(),
348
+ import { z as z5 } from "zod";
349
+ var Sha2562 = z5.string().regex(/^[0-9a-f]{64}$/);
350
+ var BundleManifestSchema = z5.object({
351
+ schema: z5.literal("agent-demo.bundle/v1"),
352
+ status: z5.enum(["passed", "failed"]),
353
+ scenario: z5.object({ id: z5.string().min(1), sourceSha256: Sha2562 }),
354
+ toolchain: z5.object({
355
+ node: z5.string(),
356
+ playwright: z5.string(),
357
+ chromiumRevision: z5.string(),
358
+ ffmpeg: z5.string(),
245
359
  /** May be the literal "unknown" — never a fabricated version. */
246
- libass: z4.string(),
247
- fontSha256: Sha256,
248
- containerImage: z4.string().optional(),
360
+ libass: z5.string(),
361
+ fontSha256: Sha2562,
362
+ containerImage: z5.string().optional(),
249
363
  /**
250
- * The plainmotion CLI's own `--version` string, present exactly when a scenario called
251
- * `demo.explain()` at least once — a run that never cut away never spawned it, and
252
- * `containerImage`'s own optionality is the precedent for saying so with an absent field
253
- * rather than a fabricated one.
364
+ * The plainmotion CLI's own `--version` string, present on bundles recorded by 1.28.0 or earlier
365
+ * whose scenario called `demo.explain()` — explain scenes were then rendered by that
366
+ * external CLI. Read-only now: explain scenes render in process, so nothing writes it, and
367
+ * it stays optional so those bundles keep verifying.
254
368
  */
255
- plainmotion: z4.string().optional()
369
+ plainmotion: z5.string().optional()
256
370
  }),
257
- environment: z4.object({
258
- os: z4.string(),
371
+ environment: z5.object({
372
+ os: z5.string(),
259
373
  /** Part of the reproducibility claim: byte-identity holds per architecture. */
260
- architecture: z4.string(),
261
- locale: z4.literal("en-US"),
262
- timezone: z4.literal("UTC"),
263
- viewport: z4.tuple([z4.literal(1920), z4.literal(1080)]),
264
- deviceScaleFactor: z4.literal(1)
374
+ architecture: z5.string(),
375
+ locale: z5.literal("en-US"),
376
+ timezone: z5.literal("UTC"),
377
+ /*
378
+ * The captured frame: 1920x1080 pixels at one pixel per video pixel, always. `uiScale`
379
+ * below says how the page was laid out into that frame; it never changes these two.
380
+ */
381
+ viewport: z5.tuple([z5.literal(1920), z5.literal(1080)]),
382
+ deviceScaleFactor: z5.literal(1),
383
+ /*
384
+ * The scenario's `uiScale`, written only when it is not 1 — so every manifest recorded
385
+ * before the option existed, and every one recorded without it, stays byte-identical.
386
+ * Recorded because it changes what every target rect in the bundle was measured against
387
+ * (CSS pixels times this), which a diff between two bundles must not mistake for drift.
388
+ */
389
+ uiScale: z5.union([z5.literal(1.5), z5.literal(2)]).optional()
265
390
  }),
266
- assertions: z4.array(z4.object({ id: z4.string().min(1), status: z4.enum(["passed", "failed"]) })),
267
- artifacts: z4.array(
268
- z4.object({
269
- path: z4.string().regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/, "must be a relative POSIX path"),
270
- sha256: Sha256,
271
- bytes: z4.number().int().nonnegative(),
272
- role: z4.enum(["source", "derived"])
391
+ assertions: z5.array(z5.object({ id: z5.string().min(1), status: z5.enum(["passed", "failed"]) })),
392
+ artifacts: z5.array(
393
+ z5.object({
394
+ path: z5.string().regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/, "must be a relative POSIX path"),
395
+ sha256: Sha2562,
396
+ bytes: z5.number().int().nonnegative(),
397
+ role: z5.enum(["source", "derived"])
273
398
  })
274
399
  )
275
400
  });
276
401
 
277
402
  // ../schema/src/narration-index.ts
278
- import { z as z5 } from "zod";
403
+ import { z as z6 } from "zod";
279
404
  var NARRATION_INDEX_SCHEMA = "agent-demo.narration-index/v1";
280
- var NarrationIndexClipSchema = z5.object({
405
+ var NarrationIndexClipSchema = z6.object({
281
406
  /** The step id, which is also the clip id and the cache filename stem. */
282
- id: z5.string().min(1),
407
+ id: z6.string().min(1),
283
408
  /**
284
409
  * The marker-free, normalised line — `clipFor`'s `text`. Never empty: the runtime does not call
285
410
  * the narrator for a step that normalises to nothing, so an empty line here is a corrupt index.
286
411
  */
287
- text: z5.string().min(1),
412
+ text: z6.string().min(1),
288
413
  /**
289
414
  * The author's `[pause]` segments, present only when a marker actually split the line — which is
290
415
  * two or more pieces, never one. When present it is what the cache key is built from (the pieces
291
416
  * join by NUL), so it must round-trip exactly or a warmed marked line lands under a different key
292
417
  * from the one the recording will look for.
293
418
  */
294
- breaks: z5.array(z5.string().min(1)).min(2).optional(),
419
+ breaks: z6.array(z6.string().min(1)).min(2).optional(),
295
420
  /**
296
421
  * The resolved voice, present only when it was not the run's default (`speech.voice` below).
297
422
  * Absent means "spoken by the default", exactly as it does on the frozen plan's per-clip voice.
298
423
  */
299
- voice: z5.string().min(1).optional()
424
+ voice: z6.string().min(1).optional()
300
425
  });
301
- var NarrationIndexSchema = z5.object({
302
- schema: z5.literal(NARRATION_INDEX_SCHEMA),
303
- speech: z5.object({
304
- voice: z5.string().min(1),
305
- voices: z5.array(z5.string().min(1)).optional(),
306
- speed: z5.number().positive(),
307
- dtype: z5.string().min(1),
308
- pronunciations: z5.record(z5.string().min(1), z5.string()).optional()
426
+ var NarrationIndexSchema = z6.object({
427
+ schema: z6.literal(NARRATION_INDEX_SCHEMA),
428
+ speech: z6.object({
429
+ voice: z6.string().min(1),
430
+ voices: z6.array(z6.string().min(1)).optional(),
431
+ speed: z6.number().positive(),
432
+ dtype: z6.string().min(1),
433
+ pronunciations: z6.record(z6.string().min(1), z6.string()).optional()
309
434
  }),
310
- clips: z5.array(NarrationIndexClipSchema).min(1)
435
+ clips: z6.array(NarrationIndexClipSchema).min(1)
311
436
  });
312
437
 
313
438
  // ../schema/src/render-plan.ts
314
- import { z as z6 } from "zod";
315
- var CueSchema = z6.object({
316
- id: z6.string().min(1),
317
- startMs: z6.number().int().nonnegative(),
318
- endMs: z6.number().int().positive(),
319
- lines: z6.array(z6.string()).min(1).max(2)
320
- });
321
- var HEX_COLOUR = z6.string().regex(/^#[0-9A-Fa-f]{6}$/, "must be a #RRGGBB colour");
322
- var OutroSchema = z6.object({
323
- durationMs: z6.number().int().positive().max(15e3),
324
- lines: z6.array(z6.string().min(1)).min(1).max(2),
439
+ import { z as z7 } from "zod";
440
+ var CueSchema = z7.object({
441
+ id: z7.string().min(1),
442
+ startMs: z7.number().int().nonnegative(),
443
+ endMs: z7.number().int().positive(),
444
+ lines: z7.array(z7.string()).min(1).max(2),
445
+ /**
446
+ * When each word of `lines` is spoken, in video milliseconds — one entry per space-separated
447
+ * word of the lines joined, in order. Frozen only by a narrated recording under the social
448
+ * layout, whose captions light each word as the voice reaches it (`toAss`); absent everywhere
449
+ * else, so every other plan's cues serialise exactly as they did.
450
+ *
451
+ * Times, never invented: they are the synthesiser's own word timestamps, mapped back through
452
+ * the pronunciation dictionary onto the caption's words. A cue whose words those timestamps
453
+ * do not describe gets no `words` at all and is drawn whole, which is what a silent recording
454
+ * gets too.
455
+ */
456
+ words: z7.array(z7.number().int().nonnegative()).min(1).optional()
457
+ }).refine(
458
+ ({ words, lines, startMs, endMs }) => words === void 0 || words.length === lines.join(" ").split(" ").filter((word) => word !== "").length && words.every((at, index) => at >= startMs && at < endMs && (index === 0 || at >= words[index - 1])),
459
+ {
460
+ message: "words must time every word of the cue, in order, inside the cue",
461
+ path: ["words"]
462
+ }
463
+ );
464
+ var HEX_COLOUR = z7.string().regex(/^#[0-9A-Fa-f]{6}$/, "must be a #RRGGBB colour");
465
+ var OutroSchema = z7.object({
466
+ durationMs: z7.number().int().positive().max(15e3),
467
+ lines: z7.array(z7.string().min(1)).min(1).max(2),
325
468
  backgroundColor: HEX_COLOUR,
326
469
  textColor: HEX_COLOUR,
327
- assPath: z6.literal("captions/outro.ass")
470
+ assPath: z7.literal("captions/outro.ass")
328
471
  });
329
- var IntroSchema = z6.object({
330
- durationMs: z6.number().int().positive().max(15e3),
331
- lines: z6.array(z6.string().min(1)).min(1).max(2),
472
+ var IntroSchema = z7.object({
473
+ durationMs: z7.number().int().positive().max(15e3),
474
+ lines: z7.array(z7.string().min(1)).min(1).max(2),
332
475
  backgroundColor: HEX_COLOUR,
333
476
  textColor: HEX_COLOUR,
334
- assPath: z6.literal("captions/intro.ass")
477
+ assPath: z7.literal("captions/intro.ass")
478
+ });
479
+ var HookSchema = z7.object({
480
+ lines: z7.array(z7.string().min(1)).min(1).max(2),
481
+ durationMs: z7.number().int().min(1e3).max(1e4),
482
+ assPath: z7.literal("captions/hook.ass"),
483
+ /**
484
+ * The hook drawn as a picture rather than as ASS: a transparent PNG the width of the output
485
+ * and as tall as the bottom of its band, rasterised in Chromium when the hook was set (at
486
+ * record time, or by a `render --hook`/`--aspect` re-cut) so its emoji are drawn in colour by
487
+ * the system's emoji face — libass can only draw them as outlines. Frozen as a source file:
488
+ * a plain re-render overlays it and never opens a browser. Absent, the hook is drawn from
489
+ * `assPath` exactly as it was before 1.30, so older bundles re-render byte-identically.
490
+ */
491
+ imagePath: z7.literal("captions/hook.png").optional()
492
+ });
493
+ var FrameSchema = z7.object({
494
+ radius: z7.number().int().positive().max(96),
495
+ assPath: z7.literal("captions/frame.ass")
335
496
  });
336
- var ChapterMarkSchema = z6.object({
337
- startMs: z6.number().int().nonnegative(),
338
- endMs: z6.number().int().positive(),
339
- title: z6.string().min(1)
497
+ var ChapterMarkSchema = z7.object({
498
+ startMs: z7.number().int().nonnegative(),
499
+ endMs: z7.number().int().positive(),
500
+ title: z7.string().min(1)
340
501
  });
341
- var ChaptersSchema = z6.object({
342
- metadataPath: z6.literal("render/chapters.ffmetadata"),
343
- marks: z6.array(ChapterMarkSchema).min(1)
502
+ var ChaptersSchema = z7.object({
503
+ metadataPath: z7.literal("render/chapters.ffmetadata"),
504
+ marks: z7.array(ChapterMarkSchema).min(1)
344
505
  }).refine(
345
506
  ({ marks }) => marks[0]?.startMs === 0 && marks.every(
346
507
  (mark, index) => mark.endMs > mark.startMs && (index === 0 || marks[index - 1]?.endMs === mark.startMs)
@@ -353,14 +514,14 @@ var ChaptersSchema = z6.object({
353
514
  path: ["marks"]
354
515
  }
355
516
  );
356
- var CursorPointSchema = z6.object({
357
- id: z6.string().min(1),
358
- x: z6.number().int().min(0).max(1920),
359
- y: z6.number().int().min(0).max(1080),
360
- arriveMs: z6.number().int().nonnegative(),
361
- departMs: z6.number().int().nonnegative(),
362
- action: z6.enum(["click", "type", "point"]),
363
- rippleMs: z6.number().int().nonnegative().optional(),
517
+ var CursorPointSchema = z7.object({
518
+ id: z7.string().min(1),
519
+ x: z7.number().int().min(0).max(1920),
520
+ y: z7.number().int().min(0).max(1080),
521
+ arriveMs: z7.number().int().nonnegative(),
522
+ departMs: z7.number().int().nonnegative(),
523
+ action: z7.enum(["click", "type", "point"]),
524
+ rippleMs: z7.number().int().nonnegative().optional(),
364
525
  /**
365
526
  * Size-correction percent, applied by the emitter with `\fscx`/`\fscy`, so the arrow
366
527
  * stays one size on screen whatever the frame does to it between the cursor filter and
@@ -391,11 +552,21 @@ var CursorPointSchema = z6.object({
391
552
  * always did — so a cursor bundle from before the camera, or from before the letterbox,
392
553
  * still parses and regenerates byte-for-byte.
393
554
  */
394
- scale: z6.number().int().min(1).max(400).optional()
555
+ scale: z7.number().int().min(1).max(400).optional(),
556
+ /**
557
+ * Present only on the first point after a hard cut (`TransitionCutSchema`): the output
558
+ * instant the picture changes to the next actor's segment. The pointer then does not glide
559
+ * from the previous point — it parks there until `cutMs` and is parked on this point from
560
+ * `cutMs` on, so it never slides from a terminal row into a web page over live frames, nor
561
+ * moves over a frozen picture. Bounded by the previous point's `departMs` and this point's
562
+ * `arriveMs` (`CursorSchema`'s refine). Absent everywhere else, so a cut-free plan's points
563
+ * are byte-identical.
564
+ */
565
+ cutMs: z7.number().int().nonnegative().optional()
395
566
  });
396
- var CursorSchema = z6.object({
397
- assPath: z6.literal("captions/cursor.ass"),
398
- points: z6.array(CursorPointSchema).min(1)
567
+ var CursorSchema = z7.object({
568
+ assPath: z7.literal("captions/cursor.ass"),
569
+ points: z7.array(CursorPointSchema).min(1)
399
570
  }).refine(
400
571
  ({ points }) => points.every(
401
572
  (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)
@@ -407,19 +578,29 @@ var CursorSchema = z6.object({
407
578
  message: "points must be ordered, arrive before departing, and not overlap",
408
579
  path: ["points"]
409
580
  }
581
+ ).refine(
582
+ ({ points }) => points.every(
583
+ (point, index) => point.cutMs === void 0 || index > 0 && points[index - 1].departMs <= point.cutMs && point.cutMs <= point.arriveMs
584
+ ),
585
+ {
586
+ // A jump needs somewhere to jump from, and it must happen between leaving the previous
587
+ // point and reaching this one — anything else would show two arrows or none.
588
+ message: "cutMs must fall within [previous point departMs, arriveMs], never on the first point",
589
+ path: ["points"]
590
+ }
410
591
  );
411
- var CameraShotSchema = z6.object({
412
- id: z6.string().min(1),
413
- enterMs: z6.number().int().nonnegative(),
414
- holdFromMs: z6.number().int().nonnegative(),
415
- x: z6.number().int().min(0),
416
- y: z6.number().int().min(0),
417
- w: z6.number().int().positive(),
418
- h: z6.number().int().positive()
592
+ var CameraShotSchema = z7.object({
593
+ id: z7.string().min(1),
594
+ enterMs: z7.number().int().nonnegative(),
595
+ holdFromMs: z7.number().int().nonnegative(),
596
+ x: z7.number().int().min(0),
597
+ y: z7.number().int().min(0),
598
+ w: z7.number().int().positive(),
599
+ h: z7.number().int().positive()
419
600
  });
420
- var CameraSchema = z6.object({
421
- commandPath: z6.literal("render/camera.cmd"),
422
- shots: z6.array(CameraShotSchema).min(1)
601
+ var CameraSchema = z7.object({
602
+ commandPath: z7.literal("render/camera.cmd"),
603
+ shots: z7.array(CameraShotSchema).min(1)
423
604
  }).refine(
424
605
  ({ shots }) => shots.every(
425
606
  (shot) => shot.x % 2 === 0 && shot.y % 2 === 0 && // Implied by the %32 and 9w/16 rules below; kept so a future relaxation of
@@ -447,39 +628,87 @@ var CameraSchema = z6.object({
447
628
  path: ["shots"]
448
629
  }
449
630
  );
450
- var ActorSchema = z6.object({
451
- id: z6.string().min(1),
452
- label: z6.string().min(1)
631
+ var ActorSchema = z7.object({
632
+ id: z7.string().min(1),
633
+ label: z7.string().min(1)
453
634
  });
454
- var SegmentSchema = z6.object({
455
- id: z6.string().min(1),
456
- actorId: z6.string().min(1),
457
- startMs: z6.number().int().nonnegative(),
458
- endMs: z6.number().int().positive()
635
+ var SegmentSchema = z7.object({
636
+ id: z7.string().min(1),
637
+ actorId: z7.string().min(1),
638
+ startMs: z7.number().int().nonnegative(),
639
+ endMs: z7.number().int().positive(),
640
+ /**
641
+ * Where this segment's frames actually sit inside the concatenated `raw/session.webm`,
642
+ * measured from the segment files themselves (`probeSegmentSpanMs`, last packet's pts plus
643
+ * its duration — the offset the concat demuxer gives the next file). Present only on plans
644
+ * with a hard cut or a `terminal.browser` scenario, and then on every segment (`RenderPlanSchema`'s refine): the splice trims
645
+ * `[sourceStartMs, sourceEndMs)` instead of the capture-derived `[startMs, endMs)`, because
646
+ * Chromium's WebM writer can end a segment file short of its capture window, or round a
647
+ * sub-second capture up to a 1 s file, and a cut's freeze would otherwise clone a frame of
648
+ * the wrong actor. `sourceEndMs` is the usable end — the file's span capped at `captureMs` for
649
+ * every segment but the last (which reads past its file's end into the head tail-pad clone, as
650
+ * the capture-based trim always has) — so it may stop short of the next `sourceStartMs`.
651
+ * `startMs`/`endMs` keep their capture-derived values regardless.
652
+ */
653
+ sourceStartMs: z7.number().int().nonnegative().optional(),
654
+ sourceEndMs: z7.number().int().positive().optional()
459
655
  });
460
- var TransitionCardSchema = z6.object({
461
- fromActorId: z6.string().min(1),
462
- toActorId: z6.string().min(1),
463
- durationMs: z6.number().int().positive().max(15e3),
464
- lines: z6.array(z6.string().min(1)).min(1).max(2),
656
+ var TransitionCardSchema = z7.object({
657
+ fromActorId: z7.string().min(1),
658
+ toActorId: z7.string().min(1),
659
+ durationMs: z7.number().int().positive().max(15e3),
660
+ lines: z7.array(z7.string().min(1)).min(1).max(2),
465
661
  backgroundColor: HEX_COLOUR,
466
662
  textColor: HEX_COLOUR,
467
- assPath: z6.string().regex(/^captions\/turn-[0-9]+\.ass$/)
663
+ assPath: z7.string().regex(/^captions\/turn-[0-9]+\.ass$/),
664
+ /**
665
+ * The outgoing segment's last-frame hold before this card, exactly as on a cut
666
+ * (`TransitionCutSchema.freezeMs`): `contentMs − trimmedSpanMs(source window)`. Only plans
667
+ * with measured source bounds (every plan with a cut, and every `terminal.browser` plan) carry it; a card in any other plan
668
+ * never does, so it parses and serialises as it always has.
669
+ */
670
+ freezeMs: z7.number().int().positive().optional()
468
671
  });
469
- var ExplainSegmentPlanSchema = z6.object({
470
- id: z6.string().min(1),
471
- segmentPath: z6.string().regex(/^explain\/[a-z0-9][a-z0-9-]*\/segment\.mp4$/),
472
- durationMs: z6.number().int().positive()
672
+ var TransitionCutSchema = z7.object({
673
+ kind: z7.literal("cut"),
674
+ fromActorId: z7.string().min(1),
675
+ toActorId: z7.string().min(1),
676
+ /**
677
+ * How long the outgoing segment's last frame is held before the cut (`tpad=stop_mode=clone`),
678
+ * so the video reaches the joint the derivation placed at the segment's content length:
679
+ * `contentMs − trimmedSpanMs(source window)`, present only when that is at least one frame —
680
+ * usually a trailing cue's overrun, but possibly a single frame (33ms) with no overrun at
681
+ * all, when the trim keeps one frame fewer than the content slot reserves. The joint itself
682
+ * still claims 0 (`transitionGapMs`). Cards and explain scenes in a source-bound plan (a cut, or `terminal.browser`) carry the
683
+ * same optional `freezeMs`; in a plan without source bounds nothing carries one.
684
+ */
685
+ freezeMs: z7.number().int().positive().optional()
686
+ }).refine((cut) => cut.fromActorId !== cut.toActorId, {
687
+ // The same reason `runtime.ts` refuses a turn to the actor already holding the browser:
688
+ // a joint where nobody changes is an explain scene's, never a hand-off's.
689
+ message: "a cut must hand off between two different actors (fromActorId !== toActorId)",
690
+ path: ["toActorId"]
473
691
  });
474
- var HighlightRectSchema = z6.object({
475
- id: z6.string().min(1),
476
- x: z6.number().int().min(0),
477
- y: z6.number().int().min(0),
478
- width: z6.number().int().positive(),
479
- height: z6.number().int().positive(),
480
- startMs: z6.number().int().nonnegative(),
481
- endMs: z6.number().int().positive(),
482
- label: z6.string().min(1).optional(),
692
+ var TransitionSchema = z7.union([TransitionCardSchema, TransitionCutSchema]);
693
+ var ExplainSegmentPlanSchema = z7.object({
694
+ id: z7.string().min(1),
695
+ segmentPath: z7.string().regex(/^explain\/[a-z0-9][a-z0-9-]*\/segment\.mp4$/),
696
+ durationMs: z7.number().int().positive(),
697
+ /**
698
+ * The hold on the capture segment *before* this scene, as on a cut or card joint
699
+ * (`TransitionCutSchema.freezeMs`). Only in plans with measured source bounds.
700
+ */
701
+ freezeMs: z7.number().int().positive().optional()
702
+ });
703
+ var HighlightRectSchema = z7.object({
704
+ id: z7.string().min(1),
705
+ x: z7.number().int().min(0),
706
+ y: z7.number().int().min(0),
707
+ width: z7.number().int().positive(),
708
+ height: z7.number().int().positive(),
709
+ startMs: z7.number().int().nonnegative(),
710
+ endMs: z7.number().int().positive(),
711
+ label: z7.string().min(1).optional(),
483
712
  /**
484
713
  * The rect the spotlight glides *from*, when this window's cutout should animate rather
485
714
  * than cut straight to the held `x`/`y`/`width`/`height` — the pre-reveal position a
@@ -491,11 +720,11 @@ var HighlightRectSchema = z6.object({
491
720
  * whole span — so every plan frozen before this field existed, and every window a future
492
721
  * derivation decides not to glide, is unaffected by construction.
493
722
  */
494
- from: z6.object({
495
- x: z6.number().int().min(0),
496
- y: z6.number().int().min(0),
497
- width: z6.number().int().positive(),
498
- height: z6.number().int().positive()
723
+ from: z7.object({
724
+ x: z7.number().int().min(0),
725
+ y: z7.number().int().min(0),
726
+ width: z7.number().int().positive(),
727
+ height: z7.number().int().positive()
499
728
  }).optional(),
500
729
  /**
501
730
  * The absolute video-ms at which the glide from `from` completes and the spotlight
@@ -504,7 +733,7 @@ var HighlightRectSchema = z6.object({
504
733
  * has to be real time left to glide across) and no later than `endMs` (the window
505
734
  * cannot settle after it has already closed).
506
735
  */
507
- settleMs: z6.number().int().positive().optional()
736
+ settleMs: z7.number().int().positive().optional()
508
737
  }).refine(({ x, y, width, height }) => x + width <= 1920 && y + height <= 1080, {
509
738
  // Unlike CameraShotSchema's crop window, a highlight rect is drawn by libass, not fed to
510
739
  // `crop` — there is no yuv420p even-pixel rule to enforce — but it still has to fit the
@@ -534,9 +763,9 @@ var HighlightRectSchema = z6.object({
534
763
  message: "settleMs must be strictly after startMs and no later than endMs",
535
764
  path: ["settleMs"]
536
765
  });
537
- var HighlightSchema = z6.object({
538
- assPath: z6.literal("captions/highlight.ass"),
539
- rects: z6.array(HighlightRectSchema).min(1)
766
+ var HighlightSchema = z7.object({
767
+ assPath: z7.literal("captions/highlight.ass"),
768
+ rects: z7.array(HighlightRectSchema).min(1)
540
769
  }).refine(
541
770
  ({ rects }) => rects.every(
542
771
  (rect, index) => rect.endMs > rect.startMs && (index === 0 || rects[index - 1].endMs <= rect.startMs)
@@ -546,8 +775,8 @@ var HighlightSchema = z6.object({
546
775
  path: ["rects"]
547
776
  }
548
777
  );
549
- var SpeechClipSchema = z6.object({
550
- id: z6.string().min(1),
778
+ var SpeechClipSchema = z7.object({
779
+ id: z7.string().min(1),
551
780
  /*
552
781
  * Bundle-relative and unescapable by construction: no slash, no leading dot, so `..` and
553
782
  * an absolute path are both unrepresentable. The renderer's `assertBundleRelative` says
@@ -561,10 +790,10 @@ var SpeechClipSchema = z6.object({
561
790
  * character the recorder could have replaced. `narration.test.ts` asserts the two agree on a
562
791
  * table of candidates, because a comment cannot.
563
792
  */
564
- path: z6.string().regex(/^speech\/clips\/[A-Za-z0-9][A-Za-z0-9._-]*\.wav$/),
565
- atMs: z6.number().int().nonnegative(),
566
- durationMs: z6.number().int().positive(),
567
- source: z6.enum(["synth", "file", "explain"]),
793
+ path: z7.string().regex(/^speech\/clips\/[A-Za-z0-9][A-Za-z0-9._-]*\.wav$/),
794
+ atMs: z7.number().int().nonnegative(),
795
+ durationMs: z7.number().int().positive(),
796
+ source: z7.enum(["synth", "file", "explain"]),
568
797
  /*
569
798
  * Which voice synthesised this clip, when it was not the plan-wide default that
570
799
  * `engine.voice` names. Absent on every clip of a one-voice recording and on every
@@ -580,16 +809,16 @@ var SpeechClipSchema = z6.object({
580
809
  * Optional and additive: every bundle frozen before per-step voices existed carries no
581
810
  * `voice` on any clip and must keep verifying, which is what `optional()` buys.
582
811
  */
583
- voice: z6.string().min(1).optional()
812
+ voice: z7.string().min(1).optional()
584
813
  });
585
- var SpeechEngineSchema = z6.object({
586
- name: z6.string().min(1),
587
- version: z6.string().min(1),
588
- modelSha256: z6.string().regex(/^[0-9a-f]{64}$/),
589
- voice: z6.string().min(1)
814
+ var SpeechEngineSchema = z7.object({
815
+ name: z7.string().min(1),
816
+ version: z7.string().min(1),
817
+ modelSha256: z7.string().regex(/^[0-9a-f]{64}$/),
818
+ voice: z7.string().min(1)
590
819
  });
591
- var SpeechSchema = z6.object({
592
- trackPath: z6.literal("speech/narration.wav"),
820
+ var SpeechSchema = z7.object({
821
+ trackPath: z7.literal("speech/narration.wav"),
593
822
  /*
594
823
  * 24 kHz mono, because that is what Kokoro emits and nothing on this path resamples.
595
824
  * Written as literals rather than imported from `@plaintake/audio`, which owns the
@@ -597,10 +826,26 @@ var SpeechSchema = z6.object({
597
826
  * be the cycle `no-circular` refuses. `packages/audio/src/wav.test.ts` asserts the two
598
827
  * agree, since a comment cannot.
599
828
  */
600
- sampleRate: z6.literal(24e3),
601
- channels: z6.literal(1),
602
- clips: z6.array(SpeechClipSchema).min(1),
603
- engine: SpeechEngineSchema.optional()
829
+ sampleRate: z7.literal(24e3),
830
+ channels: z7.literal(1),
831
+ clips: z7.array(SpeechClipSchema).min(1),
832
+ engine: SpeechEngineSchema.optional(),
833
+ /**
834
+ * Normalise the assembled track's integrated loudness (ITU-R BS.1770) to `targetLufs`, with
835
+ * a make-up gain and a lookahead limiter that holds every sample at or under `maxPeakDbfs`.
836
+ * The short-form platforms play every upload at about -14 LUFS and turn a quieter one down
837
+ * no further — they only ever turn a louder one *down* — so an unnormalised narration simply
838
+ * plays quieter than everything around it.
839
+ *
840
+ * A target, not a gain: the gain and the limiting are a pure function of the frozen clips
841
+ * (`normalizeLoudness`, `@plaintake/audio`) recomputed by every freeze in a fixed sequence of
842
+ * double-precision operations, so the same clips always produce the same track. Absent means the clips are laid out at the level they
843
+ * were synthesised at, which is every plan before this field.
844
+ */
845
+ loudness: z7.object({
846
+ targetLufs: z7.number().min(-30).max(-5),
847
+ maxPeakDbfs: z7.number().min(-12).max(0)
848
+ }).optional()
604
849
  }).refine(
605
850
  ({ clips }) => new Set(clips.map((clip) => clip.id)).size === clips.length && clips.every(
606
851
  (clip, index) => index === 0 || clips[index - 1].atMs + clips[index - 1].durationMs <= clip.atMs
@@ -620,19 +865,19 @@ var SpeechSchema = z6.object({
620
865
  // A synthesised clip with no record of what synthesised it is the one state this block
621
866
  // exists to prevent. The converse is fine: an engine recorded on an all-file track is
622
867
  // merely redundant, not misleading. An `explain` clip is exempt rather than overlooked:
623
- // it was synthesised by plainmotion, whose identity the manifest's toolchain block
624
- // carries — the `engine` here would name a synthesiser that never touched it.
868
+ // its scene's own frozen plan records what synthesised it — the `engine` here names the
869
+ // step narrator's identity, which an explain scene's voice need not share.
625
870
  message: "engine must be recorded whenever any clip was synthesised",
626
871
  path: ["engine"]
627
872
  });
628
- var ThemeSchema = z6.object({ accentColor: HEX_COLOUR });
873
+ var ThemeSchema = z7.object({ accentColor: HEX_COLOUR });
629
874
  var RENDER_PLAN_SCHEMA = "agent-demo.render/v1";
630
- var RenderPlanSchema = z6.object({
631
- schema: z6.literal(RENDER_PLAN_SCHEMA),
632
- source: z6.object({
633
- path: z6.literal("raw/session.webm"),
634
- sha256: z6.string().regex(/^[0-9a-f]{64}$/),
635
- durationMs: z6.number().int().nonnegative()
875
+ var RenderPlanSchema = z7.object({
876
+ schema: z7.literal(RENDER_PLAN_SCHEMA),
877
+ source: z7.object({
878
+ path: z7.literal("raw/session.webm"),
879
+ sha256: z7.string().regex(/^[0-9a-f]{64}$/),
880
+ durationMs: z7.number().int().nonnegative()
636
881
  }),
637
882
  /**
638
883
  * The frame. **Three geometries live here and they are not the same thing**, which is the
@@ -694,23 +939,47 @@ var RenderPlanSchema = z6.object({
694
939
  * one would be backwards — a plan is frozen once and rendered later, so both belong at the
695
940
  * parse, not at the encode.
696
941
  */
697
- video: z6.object({
698
- width: z6.number().int().positive().multipleOf(2),
699
- height: z6.number().int().positive().multipleOf(2),
700
- fps: z6.literal(30),
701
- pixelFormat: z6.literal("yuv420p"),
702
- capture: z6.object({ width: z6.literal(1920), height: z6.literal(1080) }).default({ width: 1920, height: 1080 }),
703
- letterbox: z6.object({
704
- width: z6.number().int().positive().multipleOf(2),
705
- height: z6.number().int().positive().multipleOf(2),
706
- x: z6.number().int().nonnegative().multipleOf(2),
707
- y: z6.number().int().nonnegative().multipleOf(2),
942
+ video: z7.object({
943
+ width: z7.number().int().positive().multipleOf(2),
944
+ height: z7.number().int().positive().multipleOf(2),
945
+ fps: z7.literal(30),
946
+ pixelFormat: z7.literal("yuv420p"),
947
+ capture: z7.object({ width: z7.literal(1920), height: z7.literal(1080) }).default({ width: 1920, height: 1080 }),
948
+ letterbox: z7.object({
949
+ width: z7.number().int().positive().multipleOf(2),
950
+ height: z7.number().int().positive().multipleOf(2),
951
+ x: z7.number().int().nonnegative().multipleOf(2),
952
+ y: z7.number().int().nonnegative().multipleOf(2),
708
953
  /**
709
954
  * The fill for everything the box does not cover. `#RRGGBB` for the reason
710
955
  * `OutroSchema`'s colours are: the value reaches an FFmpeg filtergraph, and that
711
956
  * grammar admits no metacharacter.
712
957
  */
713
- padColor: HEX_COLOUR
958
+ padColor: HEX_COLOUR,
959
+ /**
960
+ * `follow` — the `--reframe follow` opt-in — fills the box with a 4:3 window of the
961
+ * capture that follows the camera shots (or a fixed centre window with no camera),
962
+ * instead of the whole 16:9 frame. The one place this plan crops the picture for its
963
+ * shape rather than for a zoom, and still a render-time decision: the window is a
964
+ * pure function of `camera.shots` and this box (`followWindow`, `renderer-ffmpeg`),
965
+ * so a bundle re-cuts into or out of it without re-recording. Absent means the whole
966
+ * frame is scaled in — every plan before this field, unchanged.
967
+ */
968
+ reframe: z7.literal("follow").optional(),
969
+ /**
970
+ * The two layouts that treat the picture as an object on a background rather than as
971
+ * the background itself — `--reframe social` and `--reframe inset`:
972
+ *
973
+ * - `social` (9:16, always `follow`): a 9:8 follow window in a box sized and placed for
974
+ * short-form feeds — clear of the platforms' own top bar, bottom caption block and
975
+ * right-hand action rail — with a band above it for the `hook`, bold captions below it,
976
+ * and rounded corners on a branded pad;
977
+ * - `inset` (16:9, never `follow`): the whole frame scaled into a box inset from every
978
+ * edge, with rounded corners on a branded pad.
979
+ *
980
+ * Absent means the plain letterbox or follow box — every plan before these, unchanged.
981
+ */
982
+ layout: z7.enum(["social", "inset"]).optional()
714
983
  }).optional(),
715
984
  /*
716
985
  * Everything before the closing card: the opening card, if there is one, plus the
@@ -727,8 +996,8 @@ var RenderPlanSchema = z6.object({
727
996
  * With no intro it is exactly what it always was, which is what keeps a plan without
728
997
  * one byte-identical to what earlier versions wrote.
729
998
  */
730
- durationMs: z6.number().int().positive(),
731
- tailPadMs: z6.number().int().nonnegative()
999
+ durationMs: z7.number().int().positive(),
1000
+ tailPadMs: z7.number().int().nonnegative()
732
1001
  }).refine(
733
1002
  ({ width, height, letterbox }) => letterbox === void 0 || letterbox.x + letterbox.width <= width && letterbox.y + letterbox.height <= height,
734
1003
  {
@@ -741,13 +1010,28 @@ var RenderPlanSchema = z6.object({
741
1010
  message: "the letterbox box must fit inside the output frame",
742
1011
  path: ["letterbox"]
743
1012
  }
1013
+ ).refine(
1014
+ ({ letterbox }) => letterbox?.reframe === void 0 || (letterbox.layout === "social" ? letterbox.width * 8 === letterbox.height * 9 : letterbox.width * 3 === letterbox.height * 4),
1015
+ {
1016
+ // The follow window is cut to the box's own shape and scaled into it with no further
1017
+ // correction, so a box of any other shape would freeze a stretched picture: 4:3 for the
1018
+ // plain follow box, 9:8 for the social one.
1019
+ message: "a follow letterbox must be exactly 4:3 (9:8 under the social layout)",
1020
+ path: ["letterbox"]
1021
+ }
1022
+ ).refine(
1023
+ ({ letterbox }) => letterbox?.layout === void 0 || (letterbox.layout === "social" ? letterbox.reframe === "follow" : letterbox.reframe === void 0),
1024
+ {
1025
+ message: "the social layout always follows the camera and the inset layout never does",
1026
+ path: ["letterbox"]
1027
+ }
744
1028
  ),
745
- captions: z6.object({
746
- language: z6.string().min(2),
747
- srtPath: z6.literal("captions/captions.srt"),
748
- vttPath: z6.literal("captions/captions.vtt"),
749
- assPath: z6.literal("captions/captions.ass"),
750
- cues: z6.array(CueSchema)
1029
+ captions: z7.object({
1030
+ language: z7.string().min(2),
1031
+ srtPath: z7.literal("captions/captions.srt"),
1032
+ vttPath: z7.literal("captions/captions.vtt"),
1033
+ assPath: z7.literal("captions/captions.ass"),
1034
+ cues: z7.array(CueSchema)
751
1035
  }),
752
1036
  /**
753
1037
  * How the burned-in captions were drawn. This is the **source** of
@@ -761,18 +1045,18 @@ var RenderPlanSchema = z6.object({
761
1045
  * older reader ignoring fields it does not know still executes the same frozen arguments
762
1046
  * over the same frozen ASS. That observation generalises to the schema as a whole.
763
1047
  */
764
- style: z6.object({
765
- fontFile: z6.literal("assets/fonts/NotoSans-Regular.ttf"),
766
- fontName: z6.literal("Noto Sans"),
767
- fontSize: z6.number().int().positive(),
768
- textColor: z6.string(),
769
- outlineColor: z6.string(),
1048
+ style: z7.object({
1049
+ fontFile: z7.literal("assets/fonts/NotoSans-Regular.ttf"),
1050
+ fontName: z7.literal("Noto Sans"),
1051
+ fontSize: z7.number().int().positive(),
1052
+ textColor: z7.string(),
1053
+ outlineColor: z7.string(),
770
1054
  /** ASS `Outline`: a stroke width under `BorderStyle 1`, box padding under `BorderStyle 4`. */
771
- outlineWidth: z6.number().int().nonnegative(),
772
- marginBottom: z6.number().int().nonnegative(),
773
- marginSide: z6.number().int().nonnegative().default(80),
774
- borderStyle: z6.union([z6.literal(1), z6.literal(4)]).default(1),
775
- outlineOpacity: z6.number().min(0).max(1).default(1),
1055
+ outlineWidth: z7.number().int().nonnegative(),
1056
+ marginBottom: z7.number().int().nonnegative(),
1057
+ marginSide: z7.number().int().nonnegative().default(80),
1058
+ borderStyle: z7.union([z7.literal(1), z7.literal(4)]).default(1),
1059
+ outlineOpacity: z7.number().min(0).max(1).default(1),
776
1060
  boxColor: HEX_COLOUR.default("#000000"),
777
1061
  /*
778
1062
  * Opaque, which under the `BorderStyle 1` default draws nothing at all: `BackColour` is
@@ -780,7 +1064,7 @@ var RenderPlanSchema = z6.object({
780
1064
  * and ASS's own default, so a plan frozen before this field existed regenerates its ASS
781
1065
  * byte-for-byte rather than byte-for-byte-except-one-field-nobody-can-see.
782
1066
  */
783
- boxOpacity: z6.number().min(0).max(1).default(1),
1067
+ boxOpacity: z7.number().min(0).max(1).default(1),
784
1068
  /**
785
1069
  * ASS `\an` alignment: which edge of the frame the caption block is anchored to, and
786
1070
  * therefore which margin `marginBottom` is measured from.
@@ -799,15 +1083,25 @@ var RenderPlanSchema = z6.object({
799
1083
  * remaining code, middle-centre (5), would put the words back over the picture — which
800
1084
  * is the thing the letterbox exists to stop.
801
1085
  */
802
- alignment: z6.union([z6.literal(2), z6.literal(8)]).default(2)
1086
+ alignment: z7.union([z7.literal(2), z7.literal(8)]).default(2),
1087
+ /**
1088
+ * The social layout's captions: bold, every character drawn by an explicitly chosen face
1089
+ * (`faceRuns`, `@plaintake/subtitles`, so an emoji or an arrow never depends on the font
1090
+ * provider's fallback), and — on a cue that carries `words` — each word lit in
1091
+ * `highlightColor` as it is spoken, the short-form convention. Absent means the caption style
1092
+ * every other plan has, so its ASS is byte-for-byte what it was.
1093
+ */
1094
+ social: z7.object({ highlightColor: HEX_COLOUR }).optional()
803
1095
  }),
804
- ffmpeg: z6.object({
805
- base: z6.array(z6.string()),
806
- soft: z6.array(z6.string()),
807
- hard: z6.array(z6.string())
1096
+ ffmpeg: z7.object({
1097
+ base: z7.array(z7.string()),
1098
+ soft: z7.array(z7.string()),
1099
+ hard: z7.array(z7.string())
808
1100
  }),
809
1101
  intro: IntroSchema.optional(),
810
1102
  outro: OutroSchema.optional(),
1103
+ hook: HookSchema.optional(),
1104
+ frame: FrameSchema.optional(),
811
1105
  chapters: ChaptersSchema.optional(),
812
1106
  cursor: CursorSchema.optional(),
813
1107
  camera: CameraSchema.optional(),
@@ -862,15 +1156,15 @@ var RenderPlanSchema = z6.object({
862
1156
  * argument arrays from the plan, and read this field (via `outputOf`) so a re-cut writes
863
1157
  * the file the original recording wrote rather than the fallback name.
864
1158
  */
865
- outputPath: z6.string().regex(/^output\/[a-z0-9][a-z0-9-]*\.mp4$/, "must be output/<scenario-id>.mp4, a kebab-case id").optional(),
1159
+ outputPath: z7.string().regex(/^output\/[a-z0-9][a-z0-9-]*\.mp4$/, "must be output/<scenario-id>.mp4, a kebab-case id").optional(),
866
1160
  /**
867
1161
  * The capture timeline cut into windows — of a genuine multi-actor cast, of an implicit
868
1162
  * single actor pausing for explain cut-aways, or both at once. `segments` is the one
869
1163
  * field of the five below that a segmented capture *always* carries; the other four are
870
1164
  * two independent, narrower capabilities layered on top of it:
871
1165
  *
872
- * - `actors`/`transitionCards`/`badgesAssPath` — the multi-actor demo's cast, the cards
873
- * drawn at each hand-off, and the badge track naming the active actor throughout. Three
1166
+ * - `actors`/`transitionCards`/`badgesAssPath` — the multi-actor demo's cast, the card
1167
+ * drawn (or the hard cut taken — `TransitionCutSchema`, zero screen time) at each hand-off, and the badge track naming the active actor throughout. Three
874
1168
  * fields that carry one capability and so, like every other optional block above, are
875
1169
  * present or absent together (the first refinement below) — and never without
876
1170
  * `segments`, which they reference by `actorId` (the second). Absent means no genuine
@@ -879,18 +1173,18 @@ var RenderPlanSchema = z6.object({
879
1173
  * whenever a scenario called `demo.explain()` at all, with or without a cast alongside
880
1174
  * it — never without `segments` either (the third refinement), for the same reason.
881
1175
  *
882
- * Every gap between adjacent `segments` is claimed by exactly one occupant, a card or an
883
- * explain scene, never both and never neither (the fifth refinement) — which is also why
1176
+ * Every gap between adjacent `segments` is claimed by exactly one occupant, a card, a cut or
1177
+ * an explain scene — a cut claims its gap with zero screen time, but it still claims it — never both and never neither (the fifth refinement) — which is also why
884
1178
  * `segments` can appear with `transitionCards` and `explainSegments` both absent only when
885
1179
  * `segments.length === 1` never occurs (`SegmentSchema`'s own `.min(2)` on the array rules
886
1180
  * it out): a lone, unsegmented capture has no gap to claim in the first place, and needs
887
1181
  * none of these five fields at all.
888
1182
  */
889
- actors: z6.array(ActorSchema).min(2).optional(),
890
- segments: z6.array(SegmentSchema).min(2).optional(),
891
- transitionCards: z6.array(TransitionCardSchema).min(1).optional(),
892
- badgesAssPath: z6.literal("captions/badges.ass").optional(),
893
- explainSegments: z6.array(ExplainSegmentPlanSchema).min(1).optional()
1183
+ actors: z7.array(ActorSchema).min(2).optional(),
1184
+ segments: z7.array(SegmentSchema).min(2).optional(),
1185
+ transitionCards: z7.array(TransitionSchema).min(1).optional(),
1186
+ badgesAssPath: z7.literal("captions/badges.ass").optional(),
1187
+ explainSegments: z7.array(ExplainSegmentPlanSchema).min(1).optional()
894
1188
  }).refine(
895
1189
  (plan) => {
896
1190
  const castFields = [plan.actors, plan.transitionCards, plan.badgesAssPath];
@@ -903,7 +1197,12 @@ var RenderPlanSchema = z6.object({
903
1197
  message: "actors, transitionCards and badgesAssPath must be present together or absent together",
904
1198
  path: ["actors"]
905
1199
  }
906
- ).refine((plan) => plan.actors === void 0 || plan.segments !== void 0, {
1200
+ ).refine((plan) => plan.frame === void 0 || plan.video.letterbox?.layout !== void 0, {
1201
+ // Corners are drawn on the box a layout lays the picture in; a plan with no such box has no
1202
+ // corner to round, and drawing one anyway would paint pad colour over the picture itself.
1203
+ message: "frame needs a social or inset letterbox to round the corners of",
1204
+ path: ["frame"]
1205
+ }).refine((plan) => plan.actors === void 0 || plan.segments !== void 0, {
907
1206
  // A cast with nowhere to draw its own windows — `segment.actorId` is what a badge or a
908
1207
  // transition card is drawn against — cannot render.
909
1208
  message: "actors requires segments \u2014 a cast with no capture windows of its own cannot render",
@@ -928,6 +1227,24 @@ var RenderPlanSchema = z6.object({
928
1227
  message: "segments must tile raw/session.webm contiguously from 0",
929
1228
  path: ["segments"]
930
1229
  }
1230
+ ).refine(
1231
+ (plan) => {
1232
+ if (plan.segments === void 0) return true;
1233
+ const { segments } = plan;
1234
+ const sourced = segments.filter(
1235
+ (segment) => segment.sourceStartMs !== void 0 || segment.sourceEndMs !== void 0
1236
+ );
1237
+ if (sourced.length === 0) return true;
1238
+ return segments.every(
1239
+ (segment, index) => segment.sourceStartMs !== void 0 && segment.sourceEndMs !== void 0 && segment.sourceEndMs > segment.sourceStartMs && (index === 0 || segment.sourceStartMs >= segments[index - 1].sourceEndMs)
1240
+ );
1241
+ },
1242
+ {
1243
+ // All or none: a splice that trimmed some segments by measured source position and the
1244
+ // rest by capture position would mix two coordinate systems in one graph.
1245
+ message: "sourceStartMs/sourceEndMs must be present on every segment or on none, each window non-empty and in order without overlap",
1246
+ path: ["segments"]
1247
+ }
931
1248
  ).refine(
932
1249
  (plan) => {
933
1250
  if (plan.segments === void 0) return true;
@@ -961,95 +1278,108 @@ var RenderPlanSchema = z6.object({
961
1278
  );
962
1279
 
963
1280
  // ../schema/src/results.ts
964
- import { z as z7 } from "zod";
965
- var RELATIVE_POSIX = z7.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");
966
- var Sha2562 = z7.string().regex(/^[0-9a-f]{64}$/);
967
- var DISPLAY_PATH = z7.string().min(1);
1281
+ import { z as z8 } from "zod";
1282
+ var RELATIVE_POSIX = z8.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");
1283
+ var Sha2563 = z8.string().regex(/^[0-9a-f]{64}$/);
1284
+ var DISPLAY_PATH = z8.string().min(1);
968
1285
  var envelope = (kind) => ({
969
- schema: z7.literal("agent-demo.result/v1"),
970
- kind: z7.literal(kind),
971
- ok: z7.boolean(),
1286
+ schema: z8.literal("agent-demo.result/v1"),
1287
+ kind: z8.literal(kind),
1288
+ ok: z8.boolean(),
972
1289
  /** Human-readable failures. Empty iff ok. */
973
- problems: z7.array(z7.string())
1290
+ problems: z8.array(z8.string())
974
1291
  });
975
- var ArtifactRefSchema = z7.object({
1292
+ var ArtifactRefSchema = z8.object({
976
1293
  path: RELATIVE_POSIX,
977
- sha256: Sha2562,
978
- bytes: z7.number().int().nonnegative()
1294
+ sha256: Sha2563,
1295
+ bytes: z8.number().int().nonnegative()
979
1296
  });
980
- var StreamSummarySchema = z7.object({
981
- container: z7.enum(["mp4", "webm", "other"]),
982
- durationMs: z7.number().int().nonnegative(),
983
- video: z7.object({
984
- codec: z7.string(),
985
- width: z7.number().int(),
986
- height: z7.number().int(),
987
- pixelFormat: z7.string(),
988
- fps: z7.number().int(),
989
- frames: z7.number().int(),
1297
+ var StreamSummarySchema = z8.object({
1298
+ container: z8.enum(["mp4", "webm", "other"]),
1299
+ durationMs: z8.number().int().nonnegative(),
1300
+ video: z8.object({
1301
+ codec: z8.string(),
1302
+ width: z8.number().int(),
1303
+ height: z8.number().int(),
1304
+ pixelFormat: z8.string(),
1305
+ fps: z8.number().int(),
1306
+ frames: z8.number().int(),
990
1307
  /**
991
1308
  * The video stream's own duration, as distinct from the container's above. The
992
1309
  * constant-rate check measures frames against this, because a padded narration track makes
993
1310
  * the container's duration the audio's. Zero for a WebM, which carries no per-track
994
1311
  * duration at all.
995
1312
  */
996
- durationMs: z7.number().int().nonnegative()
1313
+ durationMs: z8.number().int().nonnegative()
997
1314
  }),
998
- subtitles: z7.array(z7.object({ codec: z7.string(), language: z7.string(), title: z7.string() })),
1315
+ subtitles: z8.array(z8.object({ codec: z8.string(), language: z8.string(), title: z8.string() })),
999
1316
  /** Properties, not a count — a narration track muxed in stereo or at the wrong rate
1000
1317
  * still counts as one stream. */
1001
- audio: z7.array(
1002
- z7.object({
1003
- codec: z7.string(),
1004
- sampleRate: z7.number().int().nonnegative(),
1005
- channels: z7.number().int().nonnegative()
1318
+ audio: z8.array(
1319
+ z8.object({
1320
+ codec: z8.string(),
1321
+ sampleRate: z8.number().int().nonnegative(),
1322
+ channels: z8.number().int().nonnegative()
1006
1323
  })
1007
1324
  ),
1008
1325
  /** Titled chapters make FFmpeg's MP4 muxer add a `bin_data` track beside the `chpl` atom. */
1009
- dataStreams: z7.number().int().nonnegative(),
1010
- chapters: z7.array(
1011
- z7.object({
1012
- startMs: z7.number().int().nonnegative(),
1013
- endMs: z7.number().int().nonnegative(),
1014
- title: z7.string()
1326
+ dataStreams: z8.number().int().nonnegative(),
1327
+ chapters: z8.array(
1328
+ z8.object({
1329
+ startMs: z8.number().int().nonnegative(),
1330
+ endMs: z8.number().int().nonnegative(),
1331
+ title: z8.string()
1015
1332
  })
1016
1333
  )
1017
1334
  });
1018
- var ToolchainSchema = z7.object({
1019
- node: z7.string(),
1020
- playwright: z7.string(),
1021
- chromiumRevision: z7.string(),
1022
- ffmpeg: z7.string(),
1335
+ var ToolchainSchema = z8.object({
1336
+ node: z8.string(),
1337
+ playwright: z8.string(),
1338
+ chromiumRevision: z8.string(),
1339
+ ffmpeg: z8.string(),
1023
1340
  /** May be the literal "unknown" — never fabricated. */
1024
- libass: z7.string(),
1025
- fontSha256: Sha2562
1341
+ libass: z8.string(),
1342
+ fontSha256: Sha2563
1026
1343
  });
1027
- var AssertionSchema = z7.object({ id: z7.string().min(1), status: z7.enum(["passed", "failed"]) });
1028
- var StatusSchema = z7.enum(["passed", "failed"]);
1029
- var VariantsSchema = z7.enum(["soft", "hard"]);
1030
- var ValidationResultSchema = z7.object({
1344
+ var AssertionSchema = z8.object({ id: z8.string().min(1), status: z8.enum(["passed", "failed"]) });
1345
+ var StatusSchema = z8.enum(["passed", "failed"]);
1346
+ var VariantsSchema = z8.enum(["soft", "hard"]);
1347
+ var ValidationResultSchema = z8.object({
1031
1348
  ...envelope("validate"),
1032
1349
  /** Absent when the scenario could not be loaded at all. */
1033
- scenario: z7.object({
1034
- id: z7.string(),
1035
- title: z7.string(),
1036
- language: z7.string(),
1350
+ scenario: z8.object({
1351
+ id: z8.string(),
1352
+ title: z8.string(),
1353
+ language: z8.string(),
1037
1354
  /**
1038
1355
  * Whether this scenario needs a person, and in which phase. Reported so an author
1039
1356
  * — or an agent reading `--json` — learns that a scenario cannot run unattended
1040
1357
  * without first trying to run it. Defaulted, so a result written before this parses.
1041
1358
  */
1042
- handoff: z7.enum(["none", "preflight", "session"]).default("none"),
1043
- sha256: Sha2562
1359
+ handoff: z8.enum(["none", "preflight", "session"]).default("none"),
1360
+ sha256: Sha2563,
1361
+ /**
1362
+ * The terminal the scenario records instead of a web page, with its defaults applied, or
1363
+ * null for a browser scenario. Defaulted like `handoff`, so a result written before
1364
+ * terminals existed parses.
1365
+ */
1366
+ terminal: z8.object({
1367
+ cols: z8.number().int(),
1368
+ rows: z8.number().int(),
1369
+ shell: z8.string(),
1370
+ command: z8.array(z8.string()).optional(),
1371
+ /** Present (true) only when the scenario declares `terminal.browser`. */
1372
+ browser: z8.literal(true).optional()
1373
+ }).nullable().default(null)
1044
1374
  }).optional()
1045
1375
  });
1046
- var RunCommandResultSchema = z7.object({
1376
+ var RunCommandResultSchema = z8.object({
1047
1377
  ...envelope("run"),
1048
1378
  bundleDir: DISPLAY_PATH,
1049
1379
  status: StatusSchema,
1050
- cueCount: z7.number().int().nonnegative(),
1380
+ cueCount: z8.number().int().nonnegative(),
1051
1381
  /** Zero on the Free Tier, and zero for a scenario with no `chapter` calls. */
1052
- chapterCount: z7.number().int().nonnegative(),
1382
+ chapterCount: z8.number().int().nonnegative(),
1053
1383
  /**
1054
1384
  * How many lines the video speaks. Zero for a silent run, which is the default.
1055
1385
  *
@@ -1058,119 +1388,130 @@ var RunCommandResultSchema = z7.object({
1058
1388
  * scenario whose steps carry no `subtitle`, which produces a perfectly valid silent video and
1059
1389
  * would look like the feature being broken rather than like nothing having been written to say.
1060
1390
  */
1061
- narratedCount: z7.number().int().nonnegative(),
1062
- diagnostics: z7.array(z7.object({ code: z7.string(), cueId: z7.string(), detail: z7.string() })),
1063
- assertions: z7.array(AssertionSchema),
1064
- outputs: z7.array(ArtifactRefSchema),
1391
+ narratedCount: z8.number().int().nonnegative(),
1392
+ diagnostics: z8.array(z8.object({ code: z8.string(), cueId: z8.string(), detail: z8.string() })),
1393
+ assertions: z8.array(AssertionSchema),
1394
+ outputs: z8.array(ArtifactRefSchema),
1065
1395
  /**
1066
1396
  * The previous bundle this run replaced, archived as a `.<timestamp>` sibling rather
1067
1397
  * than deleted. Absent when nothing occupied the output directory — the common first
1068
1398
  * run — or when `--no-archive` asked for the old replace semantics. Optional and
1069
1399
  * additive, so every result written before archives existed still parses.
1070
1400
  */
1071
- archivedPath: DISPLAY_PATH.optional()
1401
+ archivedPath: DISPLAY_PATH.optional(),
1402
+ /**
1403
+ * The capture-drift verdict against `<name>.baseline.json` beside the scenario. Absent when
1404
+ * the recording failed (a failed recording is not evidence of drift) and in every result
1405
+ * written before baselines existed, so it is optional and additive.
1406
+ */
1407
+ baseline: BaselineReportSchema.optional()
1072
1408
  });
1073
- var CheckAssertionSchema = AssertionSchema.extend({ message: z7.string().optional() });
1074
- var CheckCommandResultSchema = z7.object({
1409
+ var CheckAssertionSchema = AssertionSchema.extend({ message: z8.string().optional() });
1410
+ var CheckCommandResultSchema = z8.object({
1075
1411
  ...envelope("check"),
1076
1412
  bundleDir: DISPLAY_PATH,
1077
1413
  status: StatusSchema,
1078
- assertions: z7.array(CheckAssertionSchema),
1414
+ assertions: z8.array(CheckAssertionSchema),
1079
1415
  /**
1080
1416
  * Handoff notes, plus a `speech.close` note when closing the narrator failed after
1081
1417
  * capture. Cue, chapter, cursor and camera diagnostics are all derived from a render plan
1082
1418
  * that `check` never builds — fabricating them would report on capabilities this command
1083
1419
  * cannot see.
1084
1420
  */
1085
- diagnostics: z7.array(z7.object({ code: z7.string(), cueId: z7.string(), detail: z7.string() }))
1421
+ diagnostics: z8.array(z8.object({ code: z8.string(), cueId: z8.string(), detail: z8.string() })),
1422
+ /**
1423
+ * The capture-drift verdict against `<name>.baseline.json` beside the scenario. Absent when
1424
+ * the recording failed (a failed recording is not evidence of drift) and in every result
1425
+ * written before baselines existed, so it is optional and additive.
1426
+ */
1427
+ baseline: BaselineReportSchema.optional()
1086
1428
  });
1087
- var RenderCommandResultSchema = z7.object({
1429
+ var RenderCommandResultSchema = z8.object({
1088
1430
  ...envelope("render"),
1089
1431
  bundleDir: DISPLAY_PATH,
1090
1432
  variants: VariantsSchema,
1091
- streams: z7.object({
1433
+ streams: z8.object({
1092
1434
  base: StreamSummarySchema.optional(),
1093
1435
  soft: StreamSummarySchema.optional(),
1094
1436
  hard: StreamSummarySchema.optional()
1095
1437
  }),
1096
- outputs: z7.array(ArtifactRefSchema)
1438
+ outputs: z8.array(ArtifactRefSchema)
1097
1439
  });
1098
- var VerificationReportSchema = z7.object({
1440
+ var VerificationReportSchema = z8.object({
1099
1441
  ...envelope("verify"),
1100
1442
  bundleDir: DISPLAY_PATH,
1101
- artifactCount: z7.number().int().nonnegative()
1443
+ artifactCount: z8.number().int().nonnegative()
1102
1444
  });
1103
- var WarmCommandResultSchema = z7.object({
1445
+ var WarmCommandResultSchema = z8.object({
1104
1446
  ...envelope("warm"),
1105
1447
  bundleDir: DISPLAY_PATH,
1106
- clipCount: z7.number().int().nonnegative(),
1107
- synthesizedCount: z7.number().int().nonnegative(),
1108
- cachedCount: z7.number().int().nonnegative()
1448
+ clipCount: z8.number().int().nonnegative(),
1449
+ synthesizedCount: z8.number().int().nonnegative(),
1450
+ cachedCount: z8.number().int().nonnegative()
1109
1451
  });
1110
- var DifferenceCategorySchema = z7.enum(["step", "assertion", "target", "timing", "caption", "actor"]);
1111
- var DiffCommandResultSchema = z7.object({
1452
+ var DiffCommandResultSchema = z8.object({
1112
1453
  ...envelope("diff"),
1113
1454
  bundleA: DISPLAY_PATH,
1114
1455
  bundleB: DISPLAY_PATH,
1115
- identical: z7.boolean(),
1116
- differences: z7.array(z7.object({ category: DifferenceCategorySchema, detail: z7.string() }))
1456
+ identical: z8.boolean(),
1457
+ differences: z8.array(DifferenceSchema)
1117
1458
  });
1118
- var CompareCommandResultSchema = z7.object({
1459
+ var CompareCommandResultSchema = z8.object({
1119
1460
  ...envelope("compare"),
1120
1461
  bundleA: DISPLAY_PATH,
1121
1462
  bundleB: DISPLAY_PATH,
1122
- checks: z7.array(z7.object({ name: z7.string(), ok: z7.boolean(), detail: z7.string() }))
1463
+ checks: z8.array(z8.object({ name: z8.string(), ok: z8.boolean(), detail: z8.string() }))
1123
1464
  });
1124
- var InitResultSchema = z7.object({
1465
+ var InitResultSchema = z8.object({
1125
1466
  ...envelope("init"),
1126
- files: z7.array(DISPLAY_PATH),
1127
- validates: z7.boolean()
1467
+ files: z8.array(DISPLAY_PATH),
1468
+ validates: z8.boolean()
1128
1469
  });
1129
- var PruneCandidateSchema = z7.object({
1470
+ var PruneCandidateSchema = z8.object({
1130
1471
  path: DISPLAY_PATH,
1131
- bytes: z7.number().int().nonnegative(),
1132
- scenarioId: z7.string().optional()
1472
+ bytes: z8.number().int().nonnegative(),
1473
+ scenarioId: z8.string().optional()
1133
1474
  });
1134
- var PruneCommandResultSchema = z7.object({
1475
+ var PruneCommandResultSchema = z8.object({
1135
1476
  ...envelope("prune"),
1136
- dryRun: z7.boolean(),
1477
+ dryRun: z8.boolean(),
1137
1478
  /** What was removed (confirmed) or would be (dry run) — never includes a `failed` entry. */
1138
- candidates: z7.array(PruneCandidateSchema),
1479
+ candidates: z8.array(PruneCandidateSchema),
1139
1480
  /** A discovered bundle that was never attempted, and why — never silently dropped. */
1140
- skipped: z7.array(z7.object({ path: DISPLAY_PATH, reason: z7.string() })),
1481
+ skipped: z8.array(z8.object({ path: DISPLAY_PATH, reason: z8.string() })),
1141
1482
  /** A selected candidate `rm` was attempted on and could not remove, and why. Always empty
1142
1483
  * on a dry run, since nothing is attempted until `--yes`. */
1143
- failed: z7.array(z7.object({ path: DISPLAY_PATH, reason: z7.string() })),
1484
+ failed: z8.array(z8.object({ path: DISPLAY_PATH, reason: z8.string() })),
1144
1485
  /** What `candidates` account for: bytes actually reclaimed if `dryRun` is false, or would
1145
1486
  * be reclaimed if every candidate succeeded. */
1146
- bytesReclaimed: z7.number().int().nonnegative()
1487
+ bytesReclaimed: z8.number().int().nonnegative()
1147
1488
  });
1148
- var ImportCommandResultSchema = z7.object({
1489
+ var ImportCommandResultSchema = z8.object({
1149
1490
  ...envelope("import"),
1150
1491
  tracePath: DISPLAY_PATH,
1151
1492
  outputPath: DISPLAY_PATH,
1152
1493
  /** How many `demo.step` calls the draft carries. Zero only on the failure path. */
1153
- stepCount: z7.number().int().nonnegative(),
1494
+ stepCount: z8.number().int().nonnegative(),
1154
1495
  /** Whether the written draft already passes `plaintake validate`. Best-effort: a draft
1155
1496
  * that fails is still written, still a success, and its problems are warnings here. */
1156
- validates: z7.boolean(),
1497
+ validates: z8.boolean(),
1157
1498
  /** Author-facing advice: multi-page traces, redactions, unimported trace calls, and the
1158
1499
  * standing review-for-secrets line. Never empty on success. */
1159
- warnings: z7.array(z7.string())
1500
+ warnings: z8.array(z8.string())
1160
1501
  });
1161
- var InspectResultSchema = z7.object({
1502
+ var InspectResultSchema = z8.object({
1162
1503
  ...envelope("inspect"),
1163
1504
  bundleDir: DISPLAY_PATH,
1164
1505
  status: StatusSchema,
1165
- scenario: z7.object({ id: z7.string(), sourceSha256: Sha2562 }),
1506
+ scenario: z8.object({ id: z8.string(), sourceSha256: Sha2563 }),
1166
1507
  toolchain: ToolchainSchema,
1167
- video: z7.object({
1508
+ video: z8.object({
1168
1509
  /** The whole output: the cards, if any, plus the content between them. */
1169
- durationMs: z7.number().int(),
1170
- frames: z7.number().int(),
1171
- width: z7.number().int(),
1172
- height: z7.number().int(),
1173
- fps: z7.number().int(),
1510
+ durationMs: z8.number().int(),
1511
+ frames: z8.number().int(),
1512
+ width: z8.number().int(),
1513
+ height: z8.number().int(),
1514
+ fps: z8.number().int(),
1174
1515
  /**
1175
1516
  * Split out because `durationMs` alone is misleading once a card exists: 12,034ms of
1176
1517
  * output can be 9,034ms of recording. The parts are reported separately rather than
@@ -1185,20 +1526,20 @@ var InspectResultSchema = z7.object({
1185
1526
  * the word means, and is worth stating because the plan's own `video.durationMs` is a
1186
1527
  * different number that includes the opening card.
1187
1528
  */
1188
- contentDurationMs: z7.number().int().nonnegative(),
1189
- introDurationMs: z7.number().int().nonnegative().default(0),
1190
- outroDurationMs: z7.number().int().nonnegative()
1529
+ contentDurationMs: z8.number().int().nonnegative(),
1530
+ introDurationMs: z8.number().int().nonnegative().default(0),
1531
+ outroDurationMs: z8.number().int().nonnegative()
1191
1532
  }),
1192
- captions: z7.object({
1193
- language: z7.string(),
1194
- cueCount: z7.number().int().nonnegative(),
1195
- characterCount: z7.number().int().nonnegative(),
1196
- medianCueMs: z7.number().int().nonnegative(),
1197
- shortestCueMs: z7.number().int().nonnegative(),
1198
- longestCueMs: z7.number().int().nonnegative()
1533
+ captions: z8.object({
1534
+ language: z8.string(),
1535
+ cueCount: z8.number().int().nonnegative(),
1536
+ characterCount: z8.number().int().nonnegative(),
1537
+ medianCueMs: z8.number().int().nonnegative(),
1538
+ shortestCueMs: z8.number().int().nonnegative(),
1539
+ longestCueMs: z8.number().int().nonnegative()
1199
1540
  }),
1200
1541
  /** Measured from the rendered MP4 where possible, declared from the plan otherwise. */
1201
- chapters: z7.array(z7.object({ startMs: z7.number().int().nonnegative(), title: z7.string() })),
1542
+ chapters: z8.array(z8.object({ startMs: z8.number().int().nonnegative(), title: z8.string() })),
1202
1543
  /**
1203
1544
  * The narration this bundle carries, or absent for a silent one.
1204
1545
  *
@@ -1209,13 +1550,13 @@ var InspectResultSchema = z7.object({
1209
1550
  * model behind it, and inventing a voice id for it would make the one field that answers "why
1210
1551
  * does this sound different" answer wrongly.
1211
1552
  */
1212
- narration: z7.object({
1213
- clipCount: z7.number().int().positive(),
1553
+ narration: z8.object({
1554
+ clipCount: z8.number().int().positive(),
1214
1555
  /** Total speech, excluding the silence between clips. Not the video's length. */
1215
- spokenMs: z7.number().int().nonnegative(),
1556
+ spokenMs: z8.number().int().nonnegative(),
1216
1557
  /** How many clips the author supplied rather than the model producing. */
1217
- suppliedCount: z7.number().int().nonnegative(),
1218
- voice: z7.string().optional()
1558
+ suppliedCount: z8.number().int().nonnegative(),
1559
+ voice: z8.string().optional()
1219
1560
  }).optional(),
1220
1561
  /**
1221
1562
  * The multi-actor demo's cast, or absent for a single-actor bundle — every bundle
@@ -1232,19 +1573,19 @@ var InspectResultSchema = z7.object({
1232
1573
  * the field a reader of this JSON would already trust as the timeline, not from a second
1233
1574
  * field that only agrees with it by a schema-level invariant this result does not surface.
1234
1575
  */
1235
- actors: z7.object({
1576
+ actors: z8.object({
1236
1577
  /** `plan.actors.length`. Reported alongside `labels` rather than left for a caller to
1237
1578
  * compute, matching `narration.clipCount` sitting beside `narration` above for the
1238
1579
  * same reason: the count is the fact most callers want first, before the list. */
1239
- count: z7.number().int().min(2),
1580
+ count: z8.number().int().min(2),
1240
1581
  /** In `plan.actors` order — the cast list a badge or transition card would draw, not
1241
1582
  * deduplicated or sorted. */
1242
- labels: z7.array(z7.string().min(1)).min(2),
1583
+ labels: z8.array(z8.string().min(1)).min(2),
1243
1584
  /** Hand-offs, not participants: one fewer than `plan.segments.length`, since a
1244
1585
  * segment is a window one actor already holds and a turn is the cut between two of
1245
1586
  * them — the same count `plan.transitionCards.length` carries, arrived at
1246
1587
  * independently from `segments` for the reason above. */
1247
- turnCount: z7.number().int().nonnegative()
1588
+ turnCount: z8.number().int().nonnegative()
1248
1589
  }).refine((value) => value.count === value.labels.length, "actors.count must equal actors.labels.length").optional(),
1249
1590
  /**
1250
1591
  * The explain scenes this bundle cut away to, in composite order, or absent for a bundle
@@ -1256,20 +1597,20 @@ var InspectResultSchema = z7.object({
1256
1597
  * `actors` is sourced from the plan rather than re-read from `demo.explain()` calls: a
1257
1598
  * bundle whose scenario has since been edited or deleted still inspects correctly.
1258
1599
  */
1259
- explainScenes: z7.array(
1260
- z7.object({
1261
- id: z7.string().min(1),
1262
- durationMs: z7.number().int().positive()
1600
+ explainScenes: z8.array(
1601
+ z8.object({
1602
+ id: z8.string().min(1),
1603
+ durationMs: z8.number().int().positive()
1263
1604
  })
1264
1605
  ).min(1).optional(),
1265
1606
  /** The rendered MP4s, from the manifest, so hashes are not recomputed. */
1266
- outputs: z7.array(ArtifactRefSchema),
1267
- assertions: z7.array(AssertionSchema),
1268
- artifactCount: z7.number().int().nonnegative(),
1607
+ outputs: z8.array(ArtifactRefSchema),
1608
+ assertions: z8.array(AssertionSchema),
1609
+ artifactCount: z8.number().int().nonnegative(),
1269
1610
  /** Everything the manifest accounts for. What "delete this bundle" would reclaim. */
1270
- bundleBytes: z7.number().int().nonnegative()
1611
+ bundleBytes: z8.number().int().nonnegative()
1271
1612
  });
1272
- var DoctorResultSchema = z7.object({
1613
+ var DoctorResultSchema = z8.object({
1273
1614
  ...envelope("doctor"),
1274
1615
  /**
1275
1616
  * The PlainTake build this is. Here rather than in `ToolchainSchema` on purpose:
@@ -1277,12 +1618,12 @@ var DoctorResultSchema = z7.object({
1277
1618
  * manifest on every release and break the byte-for-byte golden bundle. `doctor` is the
1278
1619
  * one command a bug report is asked for, which makes it the right place.
1279
1620
  */
1280
- version: z7.string(),
1281
- ffmpeg: z7.string(),
1282
- ffprobe: z7.string(),
1283
- libass: z7.string(),
1284
- hasLibass: z7.boolean(),
1285
- hasX264: z7.boolean(),
1621
+ version: z8.string(),
1622
+ ffmpeg: z8.string(),
1623
+ ffprobe: z8.string(),
1624
+ libass: z8.string(),
1625
+ hasLibass: z8.boolean(),
1626
+ hasX264: z8.boolean(),
1286
1627
  /**
1287
1628
  * Whether this FFmpeg can encode AAC, which is what a narrated bundle is muxed with.
1288
1629
  *
@@ -1292,8 +1633,8 @@ var DoctorResultSchema = z7.object({
1292
1633
  * that actually needs it, with a fix string. Failing `doctor` here would be telling someone
1293
1634
  * their installation is broken for a capability they may never turn on.
1294
1635
  */
1295
- hasAac: z7.boolean(),
1296
- filters: z7.array(z7.string()),
1636
+ hasAac: z8.boolean(),
1637
+ filters: z8.array(z8.string()),
1297
1638
  /**
1298
1639
  * Whether spoken narration could be produced right now, and what is missing if not.
1299
1640
  *
@@ -1302,32 +1643,27 @@ var DoctorResultSchema = z7.object({
1302
1643
  * `openNarrator` asks before a run, answered by the same function (`speechAssetProblems`), so
1303
1644
  * `doctor` cannot say speech is fine and then a run refuse it.
1304
1645
  */
1305
- speech: z7.object({
1306
- ready: z7.boolean(),
1646
+ speech: z8.object({
1647
+ ready: z8.boolean(),
1307
1648
  /** Installed voices, in catalogue order. Empty means the model cannot speak yet. */
1308
- voices: z7.array(z7.string()),
1649
+ voices: z8.array(z8.string()),
1309
1650
  /** What is missing, in the same words a refused run would use. Empty when ready. */
1310
- notes: z7.array(z7.string())
1651
+ notes: z8.array(z8.string())
1311
1652
  }),
1312
1653
  /**
1313
- * Whether the plainmotion CLI is on `PATH`, for a scenario that wants to call
1314
- * `demo.explain()`. The same stance `hasAac`/`speech` above already take: absence is a
1315
- * *state*, not a fault — the overwhelming majority of scenarios never cut away to an
1316
- * explain scene, so `doctor` must not fail an otherwise-healthy installation over a binary
1317
- * most runs will never invoke. `assertPlainmotion` (`recorder-playwright`) is the same
1318
- * probe a run's own first `demo.explain()` pays, so `doctor` cannot say plainmotion is fine
1319
- * and then a run refuse it.
1654
+ * Whether a terminal scenario could be recorded right now: the vendored node-pty loads, and
1655
+ * xterm.js and the terminal font are in place. Never folded into `problems` — the same stance as
1656
+ * `speech` — because only a scenario declaring `terminal` needs any of it. Optional so a result
1657
+ * written before terminals existed parses.
1320
1658
  */
1321
- plainmotion: z7.object({
1322
- installed: z7.boolean(),
1323
- /** plainmotion's own `--version` output. Absent when not installed. */
1324
- version: z7.string().optional(),
1325
- /** Why the probe failed, in the same words a run's own refusal would use. Absent when installed. */
1326
- note: z7.string().optional()
1327
- })
1659
+ terminal: z8.object({
1660
+ ready: z8.boolean(),
1661
+ /** What is missing, in the words a refused run would use. Empty when ready. */
1662
+ notes: z8.array(z8.string())
1663
+ }).optional()
1328
1664
  });
1329
1665
  var okMatchesProblems = (value) => value.ok === (value.problems.length === 0);
1330
- var ActivateReasonSchema = z7.enum([
1666
+ var ActivateReasonSchema = z8.enum([
1331
1667
  "empty-key",
1332
1668
  "not-found",
1333
1669
  "revoked",
@@ -1335,16 +1671,16 @@ var ActivateReasonSchema = z7.enum([
1335
1671
  "unexpected-response",
1336
1672
  "not-configured"
1337
1673
  ]);
1338
- var ActivateResultSchema = z7.object({
1674
+ var ActivateResultSchema = z8.object({
1339
1675
  ...envelope("activate"),
1340
1676
  /** Present only when ok is false, so a machine reader can distinguish the classes. */
1341
1677
  reason: ActivateReasonSchema.optional(),
1342
1678
  /** Present only on success — a failed activation has no record. The key itself is
1343
1679
  * deliberately absent: the caller already has it, and stdout may be logged. */
1344
- licence: z7.object({
1345
- subject: z7.string().min(1),
1346
- activatedAt: z7.string().min(1),
1347
- uses: z7.number().int().nonnegative()
1680
+ licence: z8.object({
1681
+ subject: z8.string().min(1),
1682
+ activatedAt: z8.string().min(1),
1683
+ uses: z8.number().int().nonnegative()
1348
1684
  }).optional()
1349
1685
  }).refine(okMatchesProblems, "ok must be true iff problems is empty").refine(
1350
1686
  // Making the "present only when" comments above literally true, so a reader can
@@ -1352,25 +1688,25 @@ var ActivateResultSchema = z7.object({
1352
1688
  (value) => value.reason !== void 0 === !value.ok && (value.licence === void 0 || value.ok),
1353
1689
  "reason appears iff ok is false, and licence only on success"
1354
1690
  );
1355
- var LicenceResultSchema = z7.discriminatedUnion("tier", [
1356
- z7.object({
1691
+ var LicenceResultSchema = z8.discriminatedUnion("tier", [
1692
+ z8.object({
1357
1693
  ...envelope("licence"),
1358
- tier: z7.literal("free"),
1694
+ tier: z8.literal("free"),
1359
1695
  /**
1360
1696
  * Why a present-but-unusable record degraded to free, in readLicense's own words.
1361
1697
  * Must never quote a filesystem path: assertNoAbsolutePaths exempts only `problems`.
1362
1698
  */
1363
- problem: z7.string().optional()
1699
+ problem: z8.string().optional()
1364
1700
  }),
1365
- z7.object({
1701
+ z8.object({
1366
1702
  ...envelope("licence"),
1367
- tier: z7.literal("pro"),
1368
- subject: z7.string().min(1),
1369
- activatedAt: z7.string().min(1),
1370
- uses: z7.number().int().nonnegative()
1703
+ tier: z8.literal("pro"),
1704
+ subject: z8.string().min(1),
1705
+ activatedAt: z8.string().min(1),
1706
+ uses: z8.number().int().nonnegative()
1371
1707
  })
1372
1708
  ]).refine(okMatchesProblems, "ok must be true iff problems is empty");
1373
- var PublishReasonSchema = z7.enum([
1709
+ var PublishReasonSchema = z8.enum([
1374
1710
  "no-endpoint",
1375
1711
  "no-key",
1376
1712
  "invalid-store",
@@ -1380,39 +1716,82 @@ var PublishReasonSchema = z7.enum([
1380
1716
  "hash-mismatch",
1381
1717
  "length-mismatch",
1382
1718
  "conflict",
1719
+ "taken-down",
1383
1720
  "unexpected-response"
1384
1721
  ]);
1385
- var PublishResultSchema = z7.discriminatedUnion("ok", [
1386
- z7.object({
1722
+ var PublishResultSchema = z8.discriminatedUnion("ok", [
1723
+ z8.object({
1387
1724
  ...envelope("publish"),
1388
- ok: z7.literal(false),
1725
+ ok: z8.literal(false),
1389
1726
  reason: PublishReasonSchema
1390
1727
  }),
1391
- z7.object({
1728
+ z8.object({
1392
1729
  ...envelope("publish"),
1393
- ok: z7.literal(true),
1394
- videoId: z7.string().regex(/^[a-z2-7]{26}$/, "must be a 26-char base32 share id"),
1730
+ ok: z8.literal(true),
1731
+ videoId: z8.string().regex(/^[a-z2-7]{26}$/, "must be a 26-char base32 share id"),
1395
1732
  /** The page viewers open — the endpoint plus the id. */
1396
- url: z7.string().regex(/^https?:\/\/\S+$/, "must be an http or https URL"),
1733
+ url: z8.string().regex(/^https?:\/\/\S+$/, "must be an http or https URL"),
1397
1734
  /** False when the video was already on the service — nothing was uploaded. */
1398
- uploaded: z7.boolean(),
1735
+ uploaded: z8.boolean(),
1736
+ /**
1737
+ * True when the share had been unpublished (or had expired) and this publish brought
1738
+ * it back — `unpublish`'s undo. Defaults to false so a result written before the
1739
+ * field existed still parses.
1740
+ */
1741
+ restored: z8.boolean().default(false),
1399
1742
  /**
1400
1743
  * Present only when `--remember` was asked: true when the key was stored beside
1401
1744
  * license.json, false when the key file could not be written. The service's
1402
1745
  * proof of the key (an accepted PUT, or the identity check on an already-shared
1403
1746
  * run) is part of every success, so it is never the reason.
1404
1747
  */
1405
- remembered: z7.boolean().optional(),
1748
+ remembered: z8.boolean().optional(),
1406
1749
  /**
1407
1750
  * The ISO-8601 instant the share link now dies at — present only when an expiry
1408
1751
  * policy (`--expiry` or publish.json's `expiry`) was set. Absent means never
1409
1752
  * expires, exactly like a publish before the feature existed.
1410
1753
  */
1411
- expiresAt: z7.string().min(1).optional(),
1412
- captions: z7.boolean(),
1413
- chapters: z7.boolean(),
1754
+ expiresAt: z8.string().min(1).optional(),
1755
+ captions: z8.boolean(),
1756
+ chapters: z8.boolean(),
1414
1757
  /** False is a state, not a fault: no FFmpeg on PATH means no poster frame. */
1415
- poster: z7.boolean()
1758
+ poster: z8.boolean()
1759
+ })
1760
+ ]).refine(okMatchesProblems, "ok must be true iff problems is empty");
1761
+ var UnpublishReasonSchema = z8.enum([
1762
+ "no-endpoint",
1763
+ "no-key",
1764
+ "invalid-store",
1765
+ "not-a-bundle",
1766
+ "invalid-target",
1767
+ "endpoint-mismatch",
1768
+ "unauthorized",
1769
+ "forbidden",
1770
+ "not-found",
1771
+ "unreachable",
1772
+ "unexpected-response"
1773
+ ]);
1774
+ var UnpublishResultSchema = z8.discriminatedUnion("ok", [
1775
+ z8.object({
1776
+ ...envelope("unpublish"),
1777
+ ok: z8.literal(false),
1778
+ reason: UnpublishReasonSchema
1779
+ }),
1780
+ z8.object({
1781
+ ...envelope("unpublish"),
1782
+ ok: z8.literal(true),
1783
+ videoId: z8.string().regex(/^[a-z2-7]{26}$/, "must be a 26-char base32 share id"),
1784
+ /** The page that now answers 410. */
1785
+ url: z8.string().regex(/^https?:\/\/\S+$/, "must be an http or https URL"),
1786
+ /** When the share went dark; repeating an unpublish keeps the first instant. */
1787
+ deletedAt: z8.string().min(1),
1788
+ /**
1789
+ * Who holds the tombstone. `admin` means the service's operator had already taken
1790
+ * the share down — still a success (it is dark), but only the operator can undo it.
1791
+ */
1792
+ deletedBy: z8.enum(["producer", "admin", "expired"]),
1793
+ /** When retention may delete the bytes for good; a republish before then restores. */
1794
+ purgeAfter: z8.string().min(1)
1416
1795
  })
1417
1796
  ]).refine(okMatchesProblems, "ok must be true iff problems is empty");
1418
1797