@plaintake/scenario 1.7.0 → 1.9.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
@@ -51,7 +51,18 @@ var DemoEventTypeSchema = z.enum([
51
51
  * `step.start`/`step.finish` exactly as before; the `actor` field below records which
52
52
  * actor performed the step.
53
53
  */
54
- "turn"
54
+ "turn",
55
+ /*
56
+ * 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
58
+ * on the same page, same actor. A member of its own for the same reason `turn` is one: a
59
+ * cut-away is not a step — no target, no verb, no hold — and its `payload` carries the
60
+ * whole authored request (id, narration, scene props, cues) because the scene is compiled
61
+ * from that payload *after* capture ends, never drawn live. The event marks the boundary
62
+ * the way a `turn` marks its hand-off, so a run that dies mid-explain leaves a log that
63
+ * says where the cut was going to fall.
64
+ */
65
+ "explain"
55
66
  ]);
56
67
  var TargetSchema = z.object({
57
68
  role: z.string().optional(),
@@ -97,7 +108,34 @@ var ScenarioCameraSchema = z2.object({
97
108
  minDwellMs: z2.number().int().min(0, "camera.minDwellMs must be at least 0").max(5e3, "camera.minDwellMs must be at most 5000ms").optional()
98
109
  });
99
110
  var ScenarioSpeechSchema = z2.object({
100
- 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()
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(),
112
+ /*
113
+ * The voices this scenario may switch to mid-run, beyond the run-wide default `--voice`
114
+ * picks. A step names one with `demo.step({ voice })` or an actor with `demo.actor(id,
115
+ * { voice })`; a name that is not the default and not in this list is refused at record
116
+ * time with the fix in the message.
117
+ *
118
+ * Declared up front rather than discovered as steps run, for the same reason `handoff`
119
+ * is: the worker is configured with its voice list once, before Chromium starts, and an
120
+ * unknown voice found mid-recording would be minutes of footage thrown away over a list
121
+ * the scenario could have stated in its metadata. Declaring them also lets `openNarrator`
122
+ * preflight every voice's `.bin` eagerly, so a missing download is a refusal before the
123
+ * browser opens, not a dead step 40 seconds into a capture.
124
+ *
125
+ * Memory is why the list is the author's to keep short: the engine pre-loads every voice
126
+ * in this list the moment it opens (the first synthesised step) and holds them until the
127
+ * narrator closes. One voice measures ~500 MB resident, a second loaded alongside it
128
+ * ~750 MB — roughly +250 MB per additional voice, not 500 MB times the count. A scenario
129
+ * that declares voices but supplies a WAV for every step never opens the engine at all and
130
+ * pays none of this. `min(1)` because an empty list can only be a mistake — it is
131
+ * indistinguishable from declaring nothing, which is `undefined`'s job.
132
+ *
133
+ * One language per run: every voice named here, the default included, must map to the same
134
+ * language, derived from the voice's prefix (`speechLanguageOfRun` in `@plaintake/speech`).
135
+ * A mix is refused at record time with both sides named — the run's pronunciation
136
+ * dictionary is configured once, for the one language it speaks.
137
+ */
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()
101
139
  });
102
140
  var ScenarioMetaSchema = z2.object({
103
141
  schema: z2.literal("agent-demo.scenario/v1"),
@@ -207,7 +245,14 @@ var BundleManifestSchema = z4.object({
207
245
  /** May be the literal "unknown" — never a fabricated version. */
208
246
  libass: z4.string(),
209
247
  fontSha256: Sha256,
210
- containerImage: z4.string().optional()
248
+ containerImage: z4.string().optional(),
249
+ /**
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.
254
+ */
255
+ plainmotion: z4.string().optional()
211
256
  }),
212
257
  environment: z4.object({
213
258
  os: z4.string(),
@@ -385,6 +430,11 @@ var TransitionCardSchema = z5.object({
385
430
  textColor: HEX_COLOUR,
386
431
  assPath: z5.string().regex(/^captions\/turn-[0-9]+\.ass$/)
387
432
  });
433
+ var ExplainSegmentPlanSchema = z5.object({
434
+ id: z5.string().min(1),
435
+ segmentPath: z5.string().regex(/^explain\/[a-z0-9][a-z0-9-]*\/segment\.mp4$/),
436
+ durationMs: z5.number().int().positive()
437
+ });
388
438
  var HighlightRectSchema = z5.object({
389
439
  id: z5.string().min(1),
390
440
  x: z5.number().int().min(0),
@@ -432,7 +482,23 @@ var SpeechClipSchema = z5.object({
432
482
  path: z5.string().regex(/^speech\/clips\/[A-Za-z0-9][A-Za-z0-9._-]*\.wav$/),
433
483
  atMs: z5.number().int().nonnegative(),
434
484
  durationMs: z5.number().int().positive(),
435
- source: z5.enum(["synth", "file"])
485
+ source: z5.enum(["synth", "file", "explain"]),
486
+ /*
487
+ * Which voice synthesised this clip, when it was not the plan-wide default that
488
+ * `engine.voice` names. Absent on every clip of a one-voice recording and on every
489
+ * author-supplied clip — a `file` clip has no synthesiser, so there is no voice to record,
490
+ * and inventing the engine's would be the same lie `SpeechEngineSchema`'s comment refuses
491
+ * to tell about `modelSha256`.
492
+ *
493
+ * Evidence only, like the whole `engine` block: nothing reads it to make a decision. It is
494
+ * the per-clip answer to "this clip sounds different from its neighbours, why", which a
495
+ * mixed-voice bundle could not otherwise give — the engine block names one voice, and the
496
+ * WAV cannot be asked.
497
+ *
498
+ * Optional and additive: every bundle frozen before per-step voices existed carries no
499
+ * `voice` on any clip and must keep verifying, which is what `optional()` buys.
500
+ */
501
+ voice: z5.string().min(1).optional()
436
502
  });
437
503
  var SpeechEngineSchema = z5.object({
438
504
  name: z5.string().min(1),
@@ -468,10 +534,12 @@ var SpeechSchema = z5.object({
468
534
  message: "clips must have unique ids and be laid out in order without overlapping",
469
535
  path: ["clips"]
470
536
  }
471
- ).refine(({ clips, engine }) => engine !== void 0 || clips.every((clip) => clip.source === "file"), {
537
+ ).refine(({ clips, engine }) => engine !== void 0 || clips.every((clip) => clip.source !== "synth"), {
472
538
  // A synthesised clip with no record of what synthesised it is the one state this block
473
539
  // exists to prevent. The converse is fine: an engine recorded on an all-file track is
474
- // merely redundant, not misleading.
540
+ // merely redundant, not misleading. An `explain` clip is exempt rather than overlooked:
541
+ // it was synthesised by plainmotion, whose identity the manifest's toolchain block
542
+ // carries — the `engine` here would name a synthesiser that never touched it.
475
543
  message: "engine must be recorded whenever any clip was synthesised",
476
544
  path: ["engine"]
477
545
  });
@@ -663,49 +731,98 @@ var RenderPlanSchema = z5.object({
663
731
  highlight: HighlightSchema.optional(),
664
732
  speech: SpeechSchema.optional(),
665
733
  /**
666
- * The multi-actor demo's cast, its capture timeline cut into per-actor windows, the
667
- * cards drawn at each hand-off, and the badge track naming the active actor throughout —
668
- * four fields that carry one capability and so, like every other optional block above,
669
- * are present or absent together (enforced by the first refinement below). Absent means
670
- * a single-actor demo, which is every plan before this and the default after it.
734
+ * The capture timeline cut into windows — of a genuine multi-actor cast, of an implicit
735
+ * single actor pausing for explain cut-aways, or both at once. `segments` is the one
736
+ * field of the five below that a segmented capture *always* carries; the other four are
737
+ * two independent, narrower capabilities layered on top of it:
738
+ *
739
+ * - `actors`/`transitionCards`/`badgesAssPath` — the multi-actor demo's cast, the cards
740
+ * drawn at each hand-off, and the badge track naming the active actor throughout. Three
741
+ * fields that carry one capability and so, like every other optional block above, are
742
+ * present or absent together (the first refinement below) — and never without
743
+ * `segments`, which they reference by `actorId` (the second). Absent means no genuine
744
+ * cast: every explain-only demo, and every plan before multi-actor demos existed.
745
+ * - `explainSegments` — the frozen scenes cut into the gaps between `segments`, present
746
+ * whenever a scenario called `demo.explain()` at all, with or without a cast alongside
747
+ * it — never without `segments` either (the third refinement), for the same reason.
748
+ *
749
+ * Every gap between adjacent `segments` is claimed by exactly one occupant, a card or an
750
+ * explain scene, never both and never neither (the fifth refinement) — which is also why
751
+ * `segments` can appear with `transitionCards` and `explainSegments` both absent only when
752
+ * `segments.length === 1` never occurs (`SegmentSchema`'s own `.min(2)` on the array rules
753
+ * it out): a lone, unsegmented capture has no gap to claim in the first place, and needs
754
+ * none of these five fields at all.
671
755
  */
672
756
  actors: z5.array(ActorSchema).min(2).optional(),
673
757
  segments: z5.array(SegmentSchema).min(2).optional(),
674
758
  transitionCards: z5.array(TransitionCardSchema).min(1).optional(),
675
- badgesAssPath: z5.literal("captions/badges.ass").optional()
759
+ badgesAssPath: z5.literal("captions/badges.ass").optional(),
760
+ explainSegments: z5.array(ExplainSegmentPlanSchema).min(1).optional()
676
761
  }).refine(
677
762
  (plan) => {
678
- const capabilityFields = [plan.actors, plan.segments, plan.transitionCards, plan.badgesAssPath];
679
- return capabilityFields.every((field) => field !== void 0) || capabilityFields.every((field) => field === void 0);
763
+ const castFields = [plan.actors, plan.transitionCards, plan.badgesAssPath];
764
+ return castFields.every((field) => field !== void 0) || castFields.every((field) => field === void 0);
680
765
  },
681
766
  {
682
767
  // The same "carries its capability" discipline every optional block above already
683
768
  // follows: a plan naming actors with nowhere to draw their badges (or vice versa)
684
769
  // cannot render, so it is refused at the parse rather than discovered mid-encode.
685
- message: "actors, segments, transitionCards and badgesAssPath must be present together or absent together",
770
+ message: "actors, transitionCards and badgesAssPath must be present together or absent together",
686
771
  path: ["actors"]
687
772
  }
773
+ ).refine((plan) => plan.actors === void 0 || plan.segments !== void 0, {
774
+ // A cast with nowhere to draw its own windows — `segment.actorId` is what a badge or a
775
+ // transition card is drawn against — cannot render.
776
+ message: "actors requires segments \u2014 a cast with no capture windows of its own cannot render",
777
+ path: ["actors"]
778
+ }).refine((plan) => plan.explainSegments === void 0 || plan.segments !== void 0, {
779
+ // An explain scene occupies a gap between two segments; with no segments there is no
780
+ // gap for it to occupy.
781
+ message: "explainSegments requires segments \u2014 an explain scene with no gap to occupy cannot render",
782
+ path: ["explainSegments"]
783
+ }).refine(
784
+ (plan) => {
785
+ if (plan.segments === void 0) return true;
786
+ const { segments } = plan;
787
+ return segments[0]?.startMs === 0 && segments.every(
788
+ (segment, index) => segment.endMs > segment.startMs && (index === 0 || segments[index - 1]?.endMs === segment.startMs)
789
+ );
790
+ },
791
+ {
792
+ // Mirrors `ChaptersSchema`'s own tiling refine: segments must start at 0 and tile the
793
+ // source contiguously, the same reason a gap or overlap in chapter marks is refused
794
+ // rather than tolerated — true of every segmented capture, cast or castless alike.
795
+ message: "segments must tile raw/session.webm contiguously from 0",
796
+ path: ["segments"]
797
+ }
798
+ ).refine(
799
+ (plan) => {
800
+ if (plan.segments === void 0) return true;
801
+ const gapCount = (plan.transitionCards?.length ?? 0) + (plan.explainSegments?.length ?? 0);
802
+ return gapCount === plan.segments.length - 1;
803
+ },
804
+ {
805
+ // The count a hand-off or an explain between each pair of adjacent segments implies —
806
+ // every gap claimed by exactly one occupant, restated here in one refine because
807
+ // `transitionCards` and `explainSegments` share the gaps between the very same
808
+ // `segments` array (see `ExplainSegmentPlanSchema`'s own doc on how the renderer tells
809
+ // the two apart without either array carrying an explicit gap index).
810
+ message: "transitionCards.length plus explainSegments.length must equal segments.length - 1 \u2014 every gap between segments must be claimed by exactly one card or explain scene",
811
+ path: ["segments"]
812
+ }
688
813
  ).refine(
689
814
  (plan) => {
690
- if (plan.actors === void 0 || plan.segments === void 0 || plan.transitionCards === void 0 || plan.badgesAssPath === void 0) {
815
+ if (plan.actors === void 0 || plan.segments === void 0 || plan.transitionCards === void 0) {
691
816
  return true;
692
817
  }
693
818
  const { actors, segments, transitionCards } = plan;
694
819
  const namesDeclaredActor = (actorId) => actors.some((actor) => actor.id === actorId);
695
- return transitionCards.length === segments.length - 1 && segments[0]?.startMs === 0 && segments.every(
696
- (segment, index) => segment.endMs > segment.startMs && (index === 0 || segments[index - 1]?.endMs === segment.startMs) && namesDeclaredActor(segment.actorId)
697
- ) && transitionCards.every(
820
+ return segments.every((segment) => namesDeclaredActor(segment.actorId)) && transitionCards.every(
698
821
  (card) => namesDeclaredActor(card.fromActorId) && namesDeclaredActor(card.toActorId)
699
822
  );
700
823
  },
701
824
  {
702
- // Mirrors `ChaptersSchema`'s tiling refine: segments must start at 0 and tile the
703
- // source contiguously, the same reason a gap or overlap in chapter marks is refused
704
- // rather than tolerated. `transitionCards.length === segments.length - 1` is the
705
- // count a hand-off between each pair of adjacent segments implies, and every
706
- // `segment.actorId`/`transitionCard.fromActorId`/`transitionCard.toActorId` must name
707
- // a declared actor or there is nobody for the recorder to have handed the context to.
708
- message: "segments must tile raw/session.webm contiguously from 0, transitionCards.length must equal segments.length - 1, and every segment.actorId, transitionCard.fromActorId and transitionCard.toActorId must name a declared actor",
825
+ message: "every segment.actorId, transitionCard.fromActorId and transitionCard.toActorId must name a declared actor",
709
826
  path: ["segments"]
710
827
  }
711
828
  );
@@ -869,6 +986,19 @@ var PruneCommandResultSchema = z6.object({
869
986
  * be reclaimed if every candidate succeeded. */
870
987
  bytesReclaimed: z6.number().int().nonnegative()
871
988
  });
989
+ var ImportCommandResultSchema = z6.object({
990
+ ...envelope("import"),
991
+ tracePath: DISPLAY_PATH,
992
+ outputPath: DISPLAY_PATH,
993
+ /** How many `demo.step` calls the draft carries. Zero only on the failure path. */
994
+ stepCount: z6.number().int().nonnegative(),
995
+ /** Whether the written draft already passes `plaintake validate`. Best-effort: a draft
996
+ * that fails is still written, still a success, and its problems are warnings here. */
997
+ validates: z6.boolean(),
998
+ /** Author-facing advice: multi-page traces, redactions, unimported trace calls, and the
999
+ * standing review-for-secrets line. Never empty on success. */
1000
+ warnings: z6.array(z6.string())
1001
+ });
872
1002
  var InspectResultSchema = z6.object({
873
1003
  ...envelope("inspect"),
874
1004
  bundleDir: DISPLAY_PATH,
@@ -957,6 +1087,22 @@ var InspectResultSchema = z6.object({
957
1087
  * independently from `segments` for the reason above. */
958
1088
  turnCount: z6.number().int().nonnegative()
959
1089
  }).refine((value) => value.count === value.labels.length, "actors.count must equal actors.labels.length").optional(),
1090
+ /**
1091
+ * The explain scenes this bundle cut away to, in composite order, or absent for a bundle
1092
+ * with none — every bundle recorded before `demo.explain()` existed, and still the default
1093
+ * after it, so this stays absent rather than an empty array, the same distinction `actors`
1094
+ * above and `narration` further up both draw.
1095
+ *
1096
+ * Sourced from `plan.explainSegments` alone, never the scenario file, for the same reason
1097
+ * `actors` is sourced from the plan rather than re-read from `demo.explain()` calls: a
1098
+ * bundle whose scenario has since been edited or deleted still inspects correctly.
1099
+ */
1100
+ explainScenes: z6.array(
1101
+ z6.object({
1102
+ id: z6.string().min(1),
1103
+ durationMs: z6.number().int().positive()
1104
+ })
1105
+ ).min(1).optional(),
960
1106
  /** The rendered MP4s, from the manifest, so hashes are not recomputed. */
961
1107
  outputs: z6.array(ArtifactRefSchema),
962
1108
  assertions: z6.array(AssertionSchema),
@@ -1003,6 +1149,22 @@ var DoctorResultSchema = z6.object({
1003
1149
  voices: z6.array(z6.string()),
1004
1150
  /** What is missing, in the same words a refused run would use. Empty when ready. */
1005
1151
  notes: z6.array(z6.string())
1152
+ }),
1153
+ /**
1154
+ * Whether the plainmotion CLI is on `PATH`, for a scenario that wants to call
1155
+ * `demo.explain()`. The same stance `hasAac`/`speech` above already take: absence is a
1156
+ * *state*, not a fault — the overwhelming majority of scenarios never cut away to an
1157
+ * explain scene, so `doctor` must not fail an otherwise-healthy installation over a binary
1158
+ * most runs will never invoke. `assertPlainmotion` (`recorder-playwright`) is the same
1159
+ * probe a run's own first `demo.explain()` pays, so `doctor` cannot say plainmotion is fine
1160
+ * and then a run refuse it.
1161
+ */
1162
+ plainmotion: z6.object({
1163
+ installed: z6.boolean(),
1164
+ /** plainmotion's own `--version` output. Absent when not installed. */
1165
+ version: z6.string().optional(),
1166
+ /** Why the probe failed, in the same words a run's own refusal would use. Absent when installed. */
1167
+ note: z6.string().optional()
1006
1168
  })
1007
1169
  });
1008
1170
  var okMatchesProblems = (value) => value.ok === (value.problems.length === 0);
@@ -10,6 +10,7 @@ export declare const DemoEventTypeSchema: z.ZodEnum<{
10
10
  "privacy.mask": "privacy.mask";
11
11
  "human.wait": "human.wait";
12
12
  turn: "turn";
13
+ explain: "explain";
13
14
  }>;
14
15
  export declare const TargetSchema: z.ZodObject<{
15
16
  role: z.ZodOptional<z.ZodString>;
@@ -38,6 +39,7 @@ export declare const DemoEventSchema: z.ZodObject<{
38
39
  "privacy.mask": "privacy.mask";
39
40
  "human.wait": "human.wait";
40
41
  turn: "turn";
42
+ explain: "explain";
41
43
  }>;
42
44
  stableId: z.ZodOptional<z.ZodString>;
43
45
  title: z.ZodOptional<z.ZodString>;
@@ -17,6 +17,7 @@ export declare const BundleManifestSchema: z.ZodObject<{
17
17
  libass: z.ZodString;
18
18
  fontSha256: z.ZodString;
19
19
  containerImage: z.ZodOptional<z.ZodString>;
20
+ plainmotion: z.ZodOptional<z.ZodString>;
20
21
  }, z.core.$strip>;
21
22
  environment: z.ZodObject<{
22
23
  os: z.ZodString;
@@ -238,6 +238,33 @@ export declare const TransitionCardSchema: z.ZodObject<{
238
238
  assPath: z.ZodString;
239
239
  }, z.core.$strip>;
240
240
  export type TransitionCard = z.infer<typeof TransitionCardSchema>;
241
+ /**
242
+ * One frozen explain scene's real render file, occupying one of the gaps between `segments`.
243
+ *
244
+ * **Never carries an explicit gap index, for the same reason `TransitionCardSchema` never
245
+ * has: both arrays are purely positional among their own homogeneous class of gap, in
246
+ * left-to-right gap-encounter order — the same discipline `transitionCards` already keeps.**
247
+ * Which of the `segments.length - 1` gaps is a card and which is an explain scene follows
248
+ * deterministically from whether the two segments either side of it share an `actorId`: a
249
+ * `demo.turn()` boundary always changes who is filming (`runtime.ts` refuses a turn to the
250
+ * actor already holding the browser), and a `demo.explain()` boundary never does (the same
251
+ * actor's capture merely pauses for the cut-away) — so an "actor changed" gap is always a
252
+ * card, and an "actor unchanged" gap is always an explain, and the renderer recovers the
253
+ * interleaving from that invariant rather than from a stored index. `segments` may also
254
+ * carry no `actorId` at all — the no-cast, explain-only case — in which every gap is,
255
+ * necessarily, an explain (there is no hand-off to draw a card for).
256
+ *
257
+ * `segmentPath` is the one thing a card's own `assPath` is not: a real file to decode as an
258
+ * extra input, not a generated `color=` source — `durationMs` is carried alongside for
259
+ * validation and tooling (`plaintake inspect`), but the file itself is the render's
260
+ * authority on its own frame count.
261
+ */
262
+ export declare const ExplainSegmentPlanSchema: z.ZodObject<{
263
+ id: z.ZodString;
264
+ segmentPath: z.ZodString;
265
+ durationMs: z.ZodNumber;
266
+ }, z.core.$strip>;
267
+ export type ExplainSegmentPlan = z.infer<typeof ExplainSegmentPlanSchema>;
241
268
  /**
242
269
  * How long a `demo.turn(actor, { card })` transition card is on screen when its own
243
270
  * `durationMs` was omitted.
@@ -318,6 +345,14 @@ export type HighlightRect = z.infer<typeof HighlightRectSchema>;
318
345
  *
319
346
  * `source` distinguishes a synthesised clip from an author-supplied WAV, because they are the
320
347
  * same bytes by the time they reach here and the distinction is not otherwise recoverable.
348
+ *
349
+ * `explain` is the third provenance: a cut-away scene's narration, synthesised by *plainmotion*
350
+ * at record time rather than by this project's own narrator. It never requires the `engine`
351
+ * block below, because that block describes one synthesiser — the recorder's kokoro worker —
352
+ * and these clips were made by the other one; plainmotion's identity is recorded where the
353
+ * rest of its contribution is, the manifest's toolchain block. The rule the refine below
354
+ * actually states is "every clip must be accounted for": `synth` is accounted for by `engine`,
355
+ * `file` by the author who supplied it, `explain` by the toolchain.
321
356
  */
322
357
  export declare const SpeechClipSchema: z.ZodObject<{
323
358
  id: z.ZodString;
@@ -325,9 +360,11 @@ export declare const SpeechClipSchema: z.ZodObject<{
325
360
  atMs: z.ZodNumber;
326
361
  durationMs: z.ZodNumber;
327
362
  source: z.ZodEnum<{
363
+ explain: "explain";
328
364
  file: "file";
329
365
  synth: "synth";
330
366
  }>;
367
+ voice: z.ZodOptional<z.ZodString>;
331
368
  }, z.core.$strip>;
332
369
  /**
333
370
  * What produced the synthesised clips. Evidence only — nothing reads it to make a decision,
@@ -369,9 +406,11 @@ export declare const SpeechSchema: z.ZodObject<{
369
406
  atMs: z.ZodNumber;
370
407
  durationMs: z.ZodNumber;
371
408
  source: z.ZodEnum<{
409
+ explain: "explain";
372
410
  file: "file";
373
411
  synth: "synth";
374
412
  }>;
413
+ voice: z.ZodOptional<z.ZodString>;
375
414
  }, z.core.$strip>>;
376
415
  engine: z.ZodOptional<z.ZodObject<{
377
416
  name: z.ZodString;
@@ -611,9 +650,11 @@ export declare const RenderPlanSchema: z.ZodObject<{
611
650
  atMs: z.ZodNumber;
612
651
  durationMs: z.ZodNumber;
613
652
  source: z.ZodEnum<{
653
+ explain: "explain";
614
654
  file: "file";
615
655
  synth: "synth";
616
656
  }>;
657
+ voice: z.ZodOptional<z.ZodString>;
617
658
  }, z.core.$strip>>;
618
659
  engine: z.ZodOptional<z.ZodObject<{
619
660
  name: z.ZodString;
@@ -642,6 +683,11 @@ export declare const RenderPlanSchema: z.ZodObject<{
642
683
  assPath: z.ZodString;
643
684
  }, z.core.$strip>>>;
644
685
  badgesAssPath: z.ZodOptional<z.ZodLiteral<"captions/badges.ass">>;
686
+ explainSegments: z.ZodOptional<z.ZodArray<z.ZodObject<{
687
+ id: z.ZodString;
688
+ segmentPath: z.ZodString;
689
+ durationMs: z.ZodNumber;
690
+ }, z.core.$strip>>>;
645
691
  }, z.core.$strip>;
646
692
  export type RenderPlan = z.infer<typeof RenderPlanSchema>;
647
693
  export type Cue = z.infer<typeof CueSchema>;
@@ -281,6 +281,37 @@ export declare const PruneCommandResultSchema: z.ZodObject<{
281
281
  ok: z.ZodBoolean;
282
282
  problems: z.ZodArray<z.ZodString>;
283
283
  }, z.core.$strip>;
284
+ /**
285
+ * `import <trace.zip> --output <draft.demo.ts>`'s result. The trace is a Playwright
286
+ * recording from any suite, not necessarily PlainTake's own; the output is a *draft*
287
+ * scenario the author is expected to edit, and every field here exists to say how far
288
+ * short of finished it is.
289
+ *
290
+ * **`ok`/`problems` mean "a draft was written", nothing more.** A draft that does not
291
+ * parse yet is still a successful import — `validates: false` carries that verdict, the
292
+ * same way `diff`'s `ok` is deliberately untied from `identical`. The two honest failure
293
+ * classes are an unreadable or corrupt zip (exit 6, `diff`'s code for "artifacts could
294
+ * not be read back out") and a readable trace with no importable action in it (exit 1 —
295
+ * the content failed, not the container).
296
+ *
297
+ * `warnings` is deliberately not `problems`: multi-page traces, redacted values,
298
+ * unimported methods and the standing review-for-secrets note are things the author must
299
+ * know to act on, not faults that made the command lie about having run. Every result
300
+ * carries at least one warning — "the trace records everything typed, review before
301
+ * committing" — because a draft that silently embedded a typed password would be worse
302
+ * than one that loudly asks to be read.
303
+ */
304
+ export declare const ImportCommandResultSchema: z.ZodObject<{
305
+ tracePath: z.ZodString;
306
+ outputPath: z.ZodString;
307
+ stepCount: z.ZodNumber;
308
+ validates: z.ZodBoolean;
309
+ warnings: z.ZodArray<z.ZodString>;
310
+ schema: z.ZodLiteral<"agent-demo.result/v1">;
311
+ kind: z.ZodLiteral<"import">;
312
+ ok: z.ZodBoolean;
313
+ problems: z.ZodArray<z.ZodString>;
314
+ }, z.core.$strip>;
284
315
  export declare const InspectResultSchema: z.ZodObject<{
285
316
  bundleDir: z.ZodString;
286
317
  status: z.ZodEnum<{
@@ -332,6 +363,10 @@ export declare const InspectResultSchema: z.ZodObject<{
332
363
  labels: z.ZodArray<z.ZodString>;
333
364
  turnCount: z.ZodNumber;
334
365
  }, z.core.$strip>>;
366
+ explainScenes: z.ZodOptional<z.ZodArray<z.ZodObject<{
367
+ id: z.ZodString;
368
+ durationMs: z.ZodNumber;
369
+ }, z.core.$strip>>>;
335
370
  outputs: z.ZodArray<z.ZodObject<{
336
371
  path: z.ZodString;
337
372
  sha256: z.ZodString;
@@ -365,6 +400,11 @@ export declare const DoctorResultSchema: z.ZodObject<{
365
400
  voices: z.ZodArray<z.ZodString>;
366
401
  notes: z.ZodArray<z.ZodString>;
367
402
  }, z.core.$strip>;
403
+ plainmotion: z.ZodObject<{
404
+ installed: z.ZodBoolean;
405
+ version: z.ZodOptional<z.ZodString>;
406
+ note: z.ZodOptional<z.ZodString>;
407
+ }, z.core.$strip>;
368
408
  schema: z.ZodLiteral<"agent-demo.result/v1">;
369
409
  kind: z.ZodLiteral<"doctor">;
370
410
  ok: z.ZodBoolean;
@@ -418,11 +458,12 @@ export type RenderCommandResult = z.infer<typeof RenderCommandResultSchema>;
418
458
  export type VerificationReport = z.infer<typeof VerificationReportSchema>;
419
459
  export type DiffCommandResult = z.infer<typeof DiffCommandResultSchema>;
420
460
  export type PruneCommandResult = z.infer<typeof PruneCommandResultSchema>;
461
+ export type ImportCommandResult = z.infer<typeof ImportCommandResultSchema>;
421
462
  export type InspectResult = z.infer<typeof InspectResultSchema>;
422
463
  export type DoctorResult = z.infer<typeof DoctorResultSchema>;
423
464
  export type ActivateResult = z.infer<typeof ActivateResultSchema>;
424
465
  export type LicenceResult = z.infer<typeof LicenceResultSchema>;
425
- export type DemoResult = ValidationResult | RunCommandResult | CheckCommandResult | RenderCommandResult | VerificationReport | DiffCommandResult | PruneCommandResult | InspectResult | DoctorResult | ActivateResult | LicenceResult;
466
+ export type DemoResult = ValidationResult | RunCommandResult | CheckCommandResult | RenderCommandResult | VerificationReport | DiffCommandResult | PruneCommandResult | ImportCommandResult | InspectResult | DoctorResult | ActivateResult | LicenceResult;
426
467
  /**
427
468
  * Renders a filesystem location for display in a result, relative to `root` when it
428
469
  * lies inside it.
@@ -52,20 +52,15 @@ export declare const ScenarioCameraSchema: z.ZodObject<{
52
52
  }, z.core.$strip>;
53
53
  export type ScenarioCamera = z.infer<typeof ScenarioCameraSchema>;
54
54
  /**
55
- * Scenario-level narration speed — the one dial this DSL exposes onto the voice, and
56
- * deliberately the only one. No pitch, no per-step override, no SSML: see
57
- * `docs/release-checklist.md`'s "no speed, no pitch, no per-step voice, no per-step override,
58
- * and no SSML" note, of which this closes exactly the first item and nothing else. A per-step
59
- * override would mean preloading more than one voice at once; `engine.ts` measured a single
60
- * voice at ~500 MB resident, rising to ~750 MB with a second loaded alongside it — roughly
61
- * +250 MB per *additional* voice, not 500 MB multiplied by the voice count — a real
62
- * architecture change this is deliberately not taking on.
55
+ * Scenario-level narration controls. Two dials, no more: the reading speed, and the set of
56
+ * voices the scenario is allowed to switch between mid-run. No pitch, no SSML — see
57
+ * `docs/release-checklist.md`'s "no speed, no pitch, no SSML" note.
63
58
  *
64
- * Nested under `speech` rather than a bare top-level `speed` field: it names what is being
65
- * tuned (the narration) apart from the DSL's other top-level concerns, matching how `camera`
66
- * groups framing rather than adding `maxZoom` directly to the metadata object, and it leaves
67
- * room to grow if narration ever gains a second scenario-level knob without a second unrelated
68
- * top-level field appearing beside it.
59
+ * Nested under `speech` rather than bare top-level fields: it names what is being tuned (the
60
+ * narration) apart from the DSL's other top-level concerns, matching how `camera` groups
61
+ * framing rather than adding `maxZoom` directly to the metadata object, and it leaves room to
62
+ * grow if narration ever gains another scenario-level knob without another unrelated top-level
63
+ * field appearing beside it.
69
64
  *
70
65
  * `speed` has no measured bound anywhere in this codebase — grepped before picking one: only a
71
66
  * hardcoded `speed: 1` default and one `speed: 1.1` in a cache test existed before this field
@@ -77,6 +72,7 @@ export type ScenarioCamera = z.infer<typeof ScenarioCameraSchema>;
77
72
  */
78
73
  export declare const ScenarioSpeechSchema: z.ZodObject<{
79
74
  speed: z.ZodOptional<z.ZodNumber>;
75
+ voices: z.ZodOptional<z.ZodArray<z.ZodString>>;
80
76
  }, z.core.$strip>;
81
77
  export type ScenarioSpeech = z.infer<typeof ScenarioSpeechSchema>;
82
78
  export declare const ScenarioMetaSchema: z.ZodObject<{
@@ -113,6 +109,7 @@ export declare const ScenarioMetaSchema: z.ZodObject<{
113
109
  }, z.core.$strip>>;
114
110
  speech: z.ZodOptional<z.ZodObject<{
115
111
  speed: z.ZodOptional<z.ZodNumber>;
112
+ voices: z.ZodOptional<z.ZodArray<z.ZodString>>;
116
113
  }, z.core.$strip>>;
117
114
  pronunciations: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
118
115
  }, z.core.$strip>;
package/dist/types.d.ts CHANGED
@@ -33,6 +33,20 @@ export type DemoStep = {
33
33
  highlight?: boolean | {
34
34
  label?: string;
35
35
  };
36
+ /**
37
+ * Speaks this step's subtitle with a different voice than the run-wide default `--voice`
38
+ * picks. The voice must be one this run declared: either the default, or an entry in the
39
+ * scenario metadata's `speech.voices` list — anything else is refused at record time with
40
+ * the declaration named as the fix, rather than surfacing as an unsupported voice minutes
41
+ * into a recording.
42
+ *
43
+ * Precedence is step over actor over default: `demo.actor(id, { voice })` sets the voice
44
+ * for that actor's every step, and this field overrides it for one step alone. Applies
45
+ * only to synthesised narration (`--speech on`): an author-supplied WAV (`--speech file`,
46
+ * or a file in `narration/` that shadows this step) is already recorded audio, and no voice
47
+ * changes it.
48
+ */
49
+ voice?: string;
36
50
  /** Extra presentation hold after the action completes, in whole milliseconds. */
37
51
  holdMs?: number;
38
52
  run: () => Promise<unknown>;
@@ -125,6 +139,142 @@ export type DemoHandoff = PreflightHandoff & {
125
139
  */
126
140
  mask?: string;
127
141
  };
142
+ /**
143
+ * A node in a `process` explain scene: a step in the flow being drawn. The `id` is the
144
+ * entity a cue's `target` names, so it is authored and stable — never generated.
145
+ */
146
+ export type ExplainNode = {
147
+ id: string;
148
+ label: string;
149
+ };
150
+ /** A directed edge between two `process` nodes, drawn as the flow it names. */
151
+ export type ExplainConnection = {
152
+ id: string;
153
+ from: string;
154
+ to: string;
155
+ label?: string;
156
+ };
157
+ /** One side of a `comparison` explain scene: what this alternative is called, and its points. */
158
+ export type ExplainColumn = {
159
+ heading: string;
160
+ points: string[];
161
+ };
162
+ /** A named group of imported svg parts a `diagram` scene's cue can target as one thing. */
163
+ export type ExplainRegion = {
164
+ id: string;
165
+ of: string[];
166
+ };
167
+ /**
168
+ * The five motion-graphics scenes an explain cut-away can be, mirroring PlainMotion's scene
169
+ * registry prop-for-prop (`title`/`process`/`comparison`/`diagram`/`recap`). PlainTake does
170
+ * not draw these — it compiles each one through the `plainmotion` CLI and splices the frozen
171
+ * result into the recording — so this union is a structural mirror of PlainMotion's own
172
+ * schemas, and the deep validation (character caps, list bounds, phrase resolution) belongs
173
+ * to PlainMotion at run time, not to this package's types. What `validate` checks without a
174
+ * browser is the shape around the scene: id, narration, a known `type`.
175
+ *
176
+ * Geometry is deliberately absent. Where a pixel appears is the scene layout's decision,
177
+ * exactly as it is in a `plainmotion.yaml`; an author says what exists and what the
178
+ * narration points at, and nothing about inches.
179
+ */
180
+ export type ExplainScene = {
181
+ type: 'title';
182
+ headline: string;
183
+ subtitle?: string;
184
+ } | {
185
+ type: 'process';
186
+ /** Defaults to `horizontal`. */
187
+ direction?: 'horizontal' | 'vertical';
188
+ /** Two to five nodes. */
189
+ nodes: ExplainNode[];
190
+ connections?: ExplainConnection[];
191
+ /** A line above the row, naming the flow. */
192
+ title?: string;
193
+ /** A supporting line beneath the row. */
194
+ caption?: string;
195
+ } | {
196
+ type: 'comparison';
197
+ title?: string;
198
+ left: ExplainColumn;
199
+ right: ExplainColumn;
200
+ } | {
201
+ type: 'diagram';
202
+ /** Path to an svg, relative to the scenario file. Imported as-drawn; only annotated here. */
203
+ src: string;
204
+ title?: string;
205
+ caption?: string;
206
+ regions?: ExplainRegion[];
207
+ } | {
208
+ type: 'recap';
209
+ title?: string;
210
+ items: string[];
211
+ };
212
+ /**
213
+ * What a cue does to the viewer's attention. The same vocabulary PlainMotion defines —
214
+ * deliberately small, and deliberately not easing curves or tween internals: an author says
215
+ * what should happen; how that is expressed is the renderer's job.
216
+ */
217
+ export type ExplainCueAction = 'reveal' | 'hide' | 'emphasize' | 'deemphasize' | 'draw' | 'focus';
218
+ /**
219
+ * When a cue fires: bound to words in the narration, resolved against the timings the speech
220
+ * model measures. Rewriting a sentence moves the animation with it instead of silently
221
+ * desynchronising — which is why there is no numeric `at` offset here the way PlainMotion's
222
+ * own cue schema allows one. An explain scene always speaks (its narration is the scene's
223
+ * clock), so a phrase is the only anchor that survives a rewording, and an offset that could
224
+ * not is not offered.
225
+ *
226
+ * A phrase that does not occur in the narration is a validation error at run time, never a
227
+ * no-op.
228
+ */
229
+ export type ExplainCue = {
230
+ atPhrase: string;
231
+ action: ExplainCueAction;
232
+ target: string;
233
+ };
234
+ /**
235
+ * A full-frame motion-graphics cut-away in the middle of a browser demo — "explain while
236
+ * demoing". `demo.explain()` stops the screencast, splices in a PlainMotion-rendered scene
237
+ * (`title`/`process`/`comparison`/`diagram`/`recap`), and resumes the recording on the same
238
+ * page: the cut-away is a boundary on the timeline, the same mechanism a multi-actor `turn`
239
+ * uses, so nothing about the recording's determinism or re-renderability changes. The scene
240
+ * is compiled and rendered after capture finishes, from a generated one-scene project — the
241
+ * only inputs that matter are the ones right here.
242
+ *
243
+ * Free on every tier, like every authoring verb. Requires the `plainmotion` CLI on `PATH`
244
+ * at record time (`plaintake doctor` reports whether it is there); a bundle that carries an
245
+ * explain segment re-renders without it, the same way it re-renders without a browser.
246
+ */
247
+ export type DemoExplain = {
248
+ /**
249
+ * Stable, authored — the same rule every other id here follows. It becomes the directory
250
+ * the frozen artifacts live in (`explain/<id>/segment.mp4` and its plainmotion plan) and
251
+ * the generated scene's own id, so it must read as plainmotion ids do: lower-case
252
+ * alphanumeric and hyphens, starting alphanumeric. Checked at run time, with the offending
253
+ * id named.
254
+ */
255
+ id: string;
256
+ /** Human label for the cut-away, shown in `inspect` and diagnostics. Not drawn in the video. */
257
+ title: string;
258
+ /**
259
+ * What the scene says. The source of truth for both the audio and the caption — there is
260
+ * no separate caption field, for the same reason PlainMotion has none: two strings that
261
+ * are supposed to say the same thing will eventually disagree, and the one the viewer
262
+ * hears is the one that matters. The measured speech is also the scene's duration: the
263
+ * cut-away lasts exactly as long as it takes to say this, plus its lead-in and lead-out.
264
+ */
265
+ narration: string;
266
+ /** Which motion-graphics scene to draw, and its content. See `ExplainScene`. */
267
+ scene: ExplainScene;
268
+ /** Optional animation cues, bound to phrases of `narration`. See `ExplainCue`. */
269
+ cues?: ExplainCue[];
270
+ /**
271
+ * Speaks this scene's narration with a different voice than the run-wide default `--voice`
272
+ * picks — the same contract as `DemoStep.voice`: the voice must be one this run declared
273
+ * (the default, or an entry in `speech.voices`), or the run is refused with the fix named.
274
+ * Applies only when the engine is doing the speaking (`--speech on`).
275
+ */
276
+ voice?: string;
277
+ };
128
278
  /**
129
279
  * Presentation fields shared by every `Actor` verb below — the same subset of `DemoStep`
130
280
  * that is still the caller's to supply once `target` and `action` no longer are: a verb's
@@ -154,6 +304,8 @@ export type ActorStepMeta = {
154
304
  highlight?: boolean | {
155
305
  label?: string;
156
306
  };
307
+ /** See `DemoStep.voice` — one verb-call override, ahead of this actor's own `voice`. */
308
+ voice?: string;
157
309
  };
158
310
  /**
159
311
  * A named participant in a multi-actor demo: its own Playwright `BrowserContext`, its own
@@ -287,10 +439,17 @@ export interface DemoContext extends PreflightContext {
287
439
  * special-casing the first one: a single-actor scenario written before actors existed keeps
288
440
  * working unchanged, and turning it into a two-actor one only costs the second
289
441
  * `demo.actor()` call, not a rewrite of the first actor's steps.
442
+ *
443
+ * `voice` is the narration half of giving an actor an identity: every step this actor's
444
+ * verbs record speaks with it, unless the step itself names a different one (see
445
+ * `DemoStep.voice` for precedence and the `speech.voices` declaration requirement). Like
446
+ * the first call's other options, it is the one that counts — a second `demo.actor` with
447
+ * the same id returns the existing actor unchanged.
290
448
  */
291
449
  actor(id: string, opts?: {
292
450
  label?: string;
293
451
  contextOptions?: BrowserContextOptions;
452
+ voice?: string;
294
453
  }): Promise<Actor>;
295
454
  /**
296
455
  * Marks a hand-off from whichever actor currently holds the browser to `actor`, parking the
@@ -315,6 +474,25 @@ export interface DemoContext extends PreflightContext {
315
474
  durationMs?: number;
316
475
  };
317
476
  }): Promise<void>;
477
+ /**
478
+ * Cuts away from the browser recording to a full-frame motion-graphics scene — a diagram
479
+ * of where the key lives, a process flow, a recap — then back to the same page, same
480
+ * actor, same state. The screencast stops at the boundary and resumes after it, exactly as
481
+ * a `turn` does minus the actor switch, so the cut-away occupies its own slot on the
482
+ * timeline and nothing it covers is ever captured.
483
+ *
484
+ * The scene is not drawn live: it is compiled and rendered by the `plainmotion` CLI after
485
+ * capture finishes, from the request's own fields, and spliced in at render time as a
486
+ * frozen 1920×1080@30 segment — which is what keeps the bundle re-renderable without
487
+ * `plainmotion`, without a browser, and byte-identically. Its narration is spoken by the
488
+ * same engine as the rest of the recording (`--speech on`), lands in the same caption
489
+ * track, and is the scene's clock: the cut-away lasts exactly as long as it takes to say.
490
+ *
491
+ * Like a `turn`, an explain is a boundary, never an excision — nothing around it is cut —
492
+ * and it does not create a chapter; call `demo.chapter()` where you want one. Free on
493
+ * every tier.
494
+ */
495
+ explain(request: DemoExplain): Promise<void>;
318
496
  }
319
497
  export type DemoRunArgs = {
320
498
  page: Page;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plaintake/scenario",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Authoring SDK for PlainTake demo scenarios: defineDemo and the scenario DSL types. Install for editor autocomplete; the PlainTake binary ships a runtime fallback.",
5
5
  "license": "MIT",
6
6
  "type": "module",