@plaintake/scenario 1.7.0 → 1.10.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,13 +534,16 @@ 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
  });
546
+ var ThemeSchema = z5.object({ accentColor: HEX_COLOUR });
478
547
  var RENDER_PLAN_SCHEMA = "agent-demo.render/v1";
479
548
  var RenderPlanSchema = z5.object({
480
549
  schema: z5.literal(RENDER_PLAN_SCHEMA),
@@ -663,49 +732,123 @@ var RenderPlanSchema = z5.object({
663
732
  highlight: HighlightSchema.optional(),
664
733
  speech: SpeechSchema.optional(),
665
734
  /**
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.
735
+ * The branding decision, resolved once from the licence and configuration at run time
736
+ * and frozen whole — the accent the cursor's fill, its click-ripple ring and the
737
+ * highlight label's text all draw. See `ThemeSchema`.
738
+ *
739
+ * Optional and **never defaulted**. The plan's own JSON is a weaker claim than the
740
+ * render — the schema doc above records how a Zod `.default()` materialises on parse,
741
+ * so `stableStringify` writes the field into `render/render-plan.json` and diverges the
742
+ * committed golden plan, which is what `style`'s defaults already did once. A default
743
+ * here would do it again on every parse; absent means off, and the object appears only
744
+ * when a theme was actually resolved.
745
+ *
746
+ * Top-level — a sibling of `intro`/`outro`/`cursor`/`highlight`, not a key inside one
747
+ * of them — so `recutPlan`'s `{...plan}` spread carries it through `--aspect` re-cuts
748
+ * unchanged; a nested key would need explicit handling there.
749
+ *
750
+ * The plan is not `.strict()`, so an older binary strips a `theme` it has never heard
751
+ * of — but a plain re-render is unaffected by that: `renderBundle` executes the frozen
752
+ * themed `cursor.ass`/`highlight.ass` verbatim and never regenerates them, exactly as
753
+ * an older build ignoring `speech` still muxes the frozen WAV. The hardcoded colours
754
+ * return only where derived files are *redrawn* — a fresh freeze, or a `render
755
+ * --aspect` re-cut, whose `writeCaptions(recut)` regenerates the tracks from a plan the
756
+ * older binary's own re-parse has stripped `theme` out of.
757
+ */
758
+ theme: ThemeSchema.optional(),
759
+ /**
760
+ * The capture timeline cut into windows — of a genuine multi-actor cast, of an implicit
761
+ * single actor pausing for explain cut-aways, or both at once. `segments` is the one
762
+ * field of the five below that a segmented capture *always* carries; the other four are
763
+ * two independent, narrower capabilities layered on top of it:
764
+ *
765
+ * - `actors`/`transitionCards`/`badgesAssPath` — the multi-actor demo's cast, the cards
766
+ * drawn at each hand-off, and the badge track naming the active actor throughout. Three
767
+ * fields that carry one capability and so, like every other optional block above, are
768
+ * present or absent together (the first refinement below) — and never without
769
+ * `segments`, which they reference by `actorId` (the second). Absent means no genuine
770
+ * cast: every explain-only demo, and every plan before multi-actor demos existed.
771
+ * - `explainSegments` — the frozen scenes cut into the gaps between `segments`, present
772
+ * whenever a scenario called `demo.explain()` at all, with or without a cast alongside
773
+ * it — never without `segments` either (the third refinement), for the same reason.
774
+ *
775
+ * Every gap between adjacent `segments` is claimed by exactly one occupant, a card or an
776
+ * explain scene, never both and never neither (the fifth refinement) — which is also why
777
+ * `segments` can appear with `transitionCards` and `explainSegments` both absent only when
778
+ * `segments.length === 1` never occurs (`SegmentSchema`'s own `.min(2)` on the array rules
779
+ * it out): a lone, unsegmented capture has no gap to claim in the first place, and needs
780
+ * none of these five fields at all.
671
781
  */
672
782
  actors: z5.array(ActorSchema).min(2).optional(),
673
783
  segments: z5.array(SegmentSchema).min(2).optional(),
674
784
  transitionCards: z5.array(TransitionCardSchema).min(1).optional(),
675
- badgesAssPath: z5.literal("captions/badges.ass").optional()
785
+ badgesAssPath: z5.literal("captions/badges.ass").optional(),
786
+ explainSegments: z5.array(ExplainSegmentPlanSchema).min(1).optional()
676
787
  }).refine(
677
788
  (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);
789
+ const castFields = [plan.actors, plan.transitionCards, plan.badgesAssPath];
790
+ return castFields.every((field) => field !== void 0) || castFields.every((field) => field === void 0);
680
791
  },
681
792
  {
682
793
  // The same "carries its capability" discipline every optional block above already
683
794
  // follows: a plan naming actors with nowhere to draw their badges (or vice versa)
684
795
  // 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",
796
+ message: "actors, transitionCards and badgesAssPath must be present together or absent together",
686
797
  path: ["actors"]
687
798
  }
799
+ ).refine((plan) => plan.actors === void 0 || plan.segments !== void 0, {
800
+ // A cast with nowhere to draw its own windows — `segment.actorId` is what a badge or a
801
+ // transition card is drawn against — cannot render.
802
+ message: "actors requires segments \u2014 a cast with no capture windows of its own cannot render",
803
+ path: ["actors"]
804
+ }).refine((plan) => plan.explainSegments === void 0 || plan.segments !== void 0, {
805
+ // An explain scene occupies a gap between two segments; with no segments there is no
806
+ // gap for it to occupy.
807
+ message: "explainSegments requires segments \u2014 an explain scene with no gap to occupy cannot render",
808
+ path: ["explainSegments"]
809
+ }).refine(
810
+ (plan) => {
811
+ if (plan.segments === void 0) return true;
812
+ const { segments } = plan;
813
+ return segments[0]?.startMs === 0 && segments.every(
814
+ (segment, index) => segment.endMs > segment.startMs && (index === 0 || segments[index - 1]?.endMs === segment.startMs)
815
+ );
816
+ },
817
+ {
818
+ // Mirrors `ChaptersSchema`'s own tiling refine: segments must start at 0 and tile the
819
+ // source contiguously, the same reason a gap or overlap in chapter marks is refused
820
+ // rather than tolerated — true of every segmented capture, cast or castless alike.
821
+ message: "segments must tile raw/session.webm contiguously from 0",
822
+ path: ["segments"]
823
+ }
688
824
  ).refine(
689
825
  (plan) => {
690
- if (plan.actors === void 0 || plan.segments === void 0 || plan.transitionCards === void 0 || plan.badgesAssPath === void 0) {
826
+ if (plan.segments === void 0) return true;
827
+ const gapCount = (plan.transitionCards?.length ?? 0) + (plan.explainSegments?.length ?? 0);
828
+ return gapCount === plan.segments.length - 1;
829
+ },
830
+ {
831
+ // The count a hand-off or an explain between each pair of adjacent segments implies —
832
+ // every gap claimed by exactly one occupant, restated here in one refine because
833
+ // `transitionCards` and `explainSegments` share the gaps between the very same
834
+ // `segments` array (see `ExplainSegmentPlanSchema`'s own doc on how the renderer tells
835
+ // the two apart without either array carrying an explicit gap index).
836
+ 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",
837
+ path: ["segments"]
838
+ }
839
+ ).refine(
840
+ (plan) => {
841
+ if (plan.actors === void 0 || plan.segments === void 0 || plan.transitionCards === void 0) {
691
842
  return true;
692
843
  }
693
844
  const { actors, segments, transitionCards } = plan;
694
845
  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(
846
+ return segments.every((segment) => namesDeclaredActor(segment.actorId)) && transitionCards.every(
698
847
  (card) => namesDeclaredActor(card.fromActorId) && namesDeclaredActor(card.toActorId)
699
848
  );
700
849
  },
701
850
  {
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",
851
+ message: "every segment.actorId, transitionCard.fromActorId and transitionCard.toActorId must name a declared actor",
709
852
  path: ["segments"]
710
853
  }
711
854
  );
@@ -869,6 +1012,19 @@ var PruneCommandResultSchema = z6.object({
869
1012
  * be reclaimed if every candidate succeeded. */
870
1013
  bytesReclaimed: z6.number().int().nonnegative()
871
1014
  });
1015
+ var ImportCommandResultSchema = z6.object({
1016
+ ...envelope("import"),
1017
+ tracePath: DISPLAY_PATH,
1018
+ outputPath: DISPLAY_PATH,
1019
+ /** How many `demo.step` calls the draft carries. Zero only on the failure path. */
1020
+ stepCount: z6.number().int().nonnegative(),
1021
+ /** Whether the written draft already passes `plaintake validate`. Best-effort: a draft
1022
+ * that fails is still written, still a success, and its problems are warnings here. */
1023
+ validates: z6.boolean(),
1024
+ /** Author-facing advice: multi-page traces, redactions, unimported trace calls, and the
1025
+ * standing review-for-secrets line. Never empty on success. */
1026
+ warnings: z6.array(z6.string())
1027
+ });
872
1028
  var InspectResultSchema = z6.object({
873
1029
  ...envelope("inspect"),
874
1030
  bundleDir: DISPLAY_PATH,
@@ -957,6 +1113,22 @@ var InspectResultSchema = z6.object({
957
1113
  * independently from `segments` for the reason above. */
958
1114
  turnCount: z6.number().int().nonnegative()
959
1115
  }).refine((value) => value.count === value.labels.length, "actors.count must equal actors.labels.length").optional(),
1116
+ /**
1117
+ * The explain scenes this bundle cut away to, in composite order, or absent for a bundle
1118
+ * with none — every bundle recorded before `demo.explain()` existed, and still the default
1119
+ * after it, so this stays absent rather than an empty array, the same distinction `actors`
1120
+ * above and `narration` further up both draw.
1121
+ *
1122
+ * Sourced from `plan.explainSegments` alone, never the scenario file, for the same reason
1123
+ * `actors` is sourced from the plan rather than re-read from `demo.explain()` calls: a
1124
+ * bundle whose scenario has since been edited or deleted still inspects correctly.
1125
+ */
1126
+ explainScenes: z6.array(
1127
+ z6.object({
1128
+ id: z6.string().min(1),
1129
+ durationMs: z6.number().int().positive()
1130
+ })
1131
+ ).min(1).optional(),
960
1132
  /** The rendered MP4s, from the manifest, so hashes are not recomputed. */
961
1133
  outputs: z6.array(ArtifactRefSchema),
962
1134
  assertions: z6.array(AssertionSchema),
@@ -1003,6 +1175,22 @@ var DoctorResultSchema = z6.object({
1003
1175
  voices: z6.array(z6.string()),
1004
1176
  /** What is missing, in the same words a refused run would use. Empty when ready. */
1005
1177
  notes: z6.array(z6.string())
1178
+ }),
1179
+ /**
1180
+ * Whether the plainmotion CLI is on `PATH`, for a scenario that wants to call
1181
+ * `demo.explain()`. The same stance `hasAac`/`speech` above already take: absence is a
1182
+ * *state*, not a fault — the overwhelming majority of scenarios never cut away to an
1183
+ * explain scene, so `doctor` must not fail an otherwise-healthy installation over a binary
1184
+ * most runs will never invoke. `assertPlainmotion` (`recorder-playwright`) is the same
1185
+ * probe a run's own first `demo.explain()` pays, so `doctor` cannot say plainmotion is fine
1186
+ * and then a run refuse it.
1187
+ */
1188
+ plainmotion: z6.object({
1189
+ installed: z6.boolean(),
1190
+ /** plainmotion's own `--version` output. Absent when not installed. */
1191
+ version: z6.string().optional(),
1192
+ /** Why the probe failed, in the same words a run's own refusal would use. Absent when installed. */
1193
+ note: z6.string().optional()
1006
1194
  })
1007
1195
  });
1008
1196
  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.
@@ -269,9 +296,11 @@ export declare const TRANSITION_CARD_DEFAULT_MS = 3000;
269
296
  * to be a pure function of the plan, so the rect a step measured has to survive plan freeze
270
297
  * as a rect, not merely as a decision to draw one.
271
298
  *
272
- * `startMs`/`endMs` bound the whole step, not a retimed lead/rest/glide the way a cursor
273
- * point is — a spotlight has no motion to choreograph, so unlike `CursorPointSchema` there is
274
- * nothing here to pull earlier or hold open.
299
+ * `startMs`/`endMs` carry a window the derivation already retimed, the way a cursor point's
300
+ * do: a click's brackets the press — opening when the pointer parks and closing as the click
301
+ * ripple ends — while a continuous action's spans its whole step. The times are frozen as
302
+ * computed, so unlike `CursorPointSchema` (whose `rippleMs` the emitter re-derives fades
303
+ * around) there is no further choreography left for the render to do.
275
304
  *
276
305
  * `label` is the optional callout text a scenario may attach (`highlight: { label: '...' }`).
277
306
  * Absent means the spotlight alone, exactly what `highlight: true` (no object) asks for.
@@ -318,6 +347,14 @@ export type HighlightRect = z.infer<typeof HighlightRectSchema>;
318
347
  *
319
348
  * `source` distinguishes a synthesised clip from an author-supplied WAV, because they are the
320
349
  * same bytes by the time they reach here and the distinction is not otherwise recoverable.
350
+ *
351
+ * `explain` is the third provenance: a cut-away scene's narration, synthesised by *plainmotion*
352
+ * at record time rather than by this project's own narrator. It never requires the `engine`
353
+ * block below, because that block describes one synthesiser — the recorder's kokoro worker —
354
+ * and these clips were made by the other one; plainmotion's identity is recorded where the
355
+ * rest of its contribution is, the manifest's toolchain block. The rule the refine below
356
+ * actually states is "every clip must be accounted for": `synth` is accounted for by `engine`,
357
+ * `file` by the author who supplied it, `explain` by the toolchain.
321
358
  */
322
359
  export declare const SpeechClipSchema: z.ZodObject<{
323
360
  id: z.ZodString;
@@ -325,9 +362,11 @@ export declare const SpeechClipSchema: z.ZodObject<{
325
362
  atMs: z.ZodNumber;
326
363
  durationMs: z.ZodNumber;
327
364
  source: z.ZodEnum<{
365
+ explain: "explain";
328
366
  file: "file";
329
367
  synth: "synth";
330
368
  }>;
369
+ voice: z.ZodOptional<z.ZodString>;
331
370
  }, z.core.$strip>;
332
371
  /**
333
372
  * What produced the synthesised clips. Evidence only — nothing reads it to make a decision,
@@ -369,9 +408,11 @@ export declare const SpeechSchema: z.ZodObject<{
369
408
  atMs: z.ZodNumber;
370
409
  durationMs: z.ZodNumber;
371
410
  source: z.ZodEnum<{
411
+ explain: "explain";
372
412
  file: "file";
373
413
  synth: "synth";
374
414
  }>;
415
+ voice: z.ZodOptional<z.ZodString>;
375
416
  }, z.core.$strip>>;
376
417
  engine: z.ZodOptional<z.ZodObject<{
377
418
  name: z.ZodString;
@@ -418,6 +459,54 @@ export type IntroInput = Omit<Intro, 'assPath'>;
418
459
  * position in the array against the schema's own `captions/turn-[0-9]+\.ass` family.
419
460
  */
420
461
  export type TransitionCardInput = Omit<TransitionCard, 'assPath'>;
462
+ /**
463
+ * The brand accent: one colour shared by the cursor's fill, its click-ripple ring and the
464
+ * highlight label's text. One field rather than one per consumer, because the cursor and
465
+ * the highlight should never disagree about the brand — the same "one block, one decision"
466
+ * shape as every other optional capability on the plan.
467
+ *
468
+ * Present only when a theme actually resolved — Pro tier, branding enabled, at least one
469
+ * colour named. Absent means every hardcoded colour the emitters already draw, so
470
+ * an unbranded run's `cursor.ass`/`highlight.ass` are exactly what they always were.
471
+ * Caption colours deliberately do not live here: `RenderPlanSchema.style` already carries
472
+ * `textColor`/`outlineColor`/`boxColor` as the single source of `captions/captions.ass`,
473
+ * and theming captions is a change to what gets frozen into those, not a second place a
474
+ * colour can hide.
475
+ *
476
+ * `accentColor` is `HEX_COLOUR` for the reason every plan colour is: the value reaches
477
+ * `assColour` and from there an ASS override tag, and `#RRGGBB` admits no ASS
478
+ * metacharacter. It is stored in the form the author wrote and converted to `&HAABBGGRR`
479
+ * only at emission, so the bundle shows the colour, not its encoding.
480
+ *
481
+ * Like the inputs above, this is the decision as the application layer supplies it — but
482
+ * it is the narrow end of a wider input: `buildRenderPlan` takes the four-field
483
+ * `ThemeInput` below and freezes only the accent here, because the caption colours have
484
+ * somewhere else to go.
485
+ */
486
+ export declare const ThemeSchema: z.ZodObject<{
487
+ accentColor: z.ZodString;
488
+ }, z.core.$strip>;
489
+ export type Theme = z.infer<typeof ThemeSchema>;
490
+ /**
491
+ * The theme decision as the application layer supplies it — all four colours a licence can
492
+ * resolve, in the same shape `@plaintake/license`'s `resolveTheme` returns, exactly as
493
+ * `OutroInput`/`IntroInput` are the shapes its other resolvers return. Only the fields the
494
+ * user actually configured appear; each absent field keeps the hardcoded default at the
495
+ * freeze, so recolouring `accentColor` alone is a complete theme.
496
+ *
497
+ * Wider than `ThemeSchema` on purpose. The plan-level block carries only `accentColor`
498
+ * because the three caption colours freeze into `RenderPlanSchema.style` — the single
499
+ * source `captions.ass` already reads — while the accent has no style field to freeze
500
+ * into and needs the plan key. A theme with no accent therefore freezes no `theme` key at
501
+ * all: the schema requires one, and defaulting it would recolour cursor surfaces the
502
+ * author never asked about.
503
+ */
504
+ export type ThemeInput = {
505
+ accentColor?: string | undefined;
506
+ captionTextColor?: string | undefined;
507
+ captionOutlineColor?: string | undefined;
508
+ captionBoxColor?: string | undefined;
509
+ };
421
510
  /**
422
511
  * The one value a plan's `schema` field ever holds.
423
512
  *
@@ -611,9 +700,11 @@ export declare const RenderPlanSchema: z.ZodObject<{
611
700
  atMs: z.ZodNumber;
612
701
  durationMs: z.ZodNumber;
613
702
  source: z.ZodEnum<{
703
+ explain: "explain";
614
704
  file: "file";
615
705
  synth: "synth";
616
706
  }>;
707
+ voice: z.ZodOptional<z.ZodString>;
617
708
  }, z.core.$strip>>;
618
709
  engine: z.ZodOptional<z.ZodObject<{
619
710
  name: z.ZodString;
@@ -622,6 +713,9 @@ export declare const RenderPlanSchema: z.ZodObject<{
622
713
  voice: z.ZodString;
623
714
  }, z.core.$strip>>;
624
715
  }, z.core.$strip>>;
716
+ theme: z.ZodOptional<z.ZodObject<{
717
+ accentColor: z.ZodString;
718
+ }, z.core.$strip>>;
625
719
  actors: z.ZodOptional<z.ZodArray<z.ZodObject<{
626
720
  id: z.ZodString;
627
721
  label: z.ZodString;
@@ -642,6 +736,11 @@ export declare const RenderPlanSchema: z.ZodObject<{
642
736
  assPath: z.ZodString;
643
737
  }, z.core.$strip>>>;
644
738
  badgesAssPath: z.ZodOptional<z.ZodLiteral<"captions/badges.ass">>;
739
+ explainSegments: z.ZodOptional<z.ZodArray<z.ZodObject<{
740
+ id: z.ZodString;
741
+ segmentPath: z.ZodString;
742
+ durationMs: z.ZodNumber;
743
+ }, z.core.$strip>>>;
645
744
  }, z.core.$strip>;
646
745
  export type RenderPlan = z.infer<typeof RenderPlanSchema>;
647
746
  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.10.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",