@plaintake/scenario 1.6.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
@@ -42,7 +42,27 @@ var DemoEventTypeSchema = z.enum([
42
42
  * Appended once, when the wait ends, so `tEndNs` is a real reading rather than a
43
43
  * placeholder: the `assertion.pass` shape, not the `step.start`/`step.finish` pair.
44
44
  */
45
- "human.wait"
45
+ "human.wait",
46
+ /*
47
+ * An actor hand-off: `demo.turn(actor, ...)` parking the outgoing actor's `BrowserContext`
48
+ * and activating the incoming one. A member of its own rather than folded into
49
+ * `step.start`/`step.finish`, because a hand-off is not a step — it has no `target` and
50
+ * narrates no action of its own. Verb steps taken during a turn keep using
51
+ * `step.start`/`step.finish` exactly as before; the `actor` field below records which
52
+ * actor performed the step.
53
+ */
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"
46
66
  ]);
47
67
  var TargetSchema = z.object({
48
68
  role: z.string().optional(),
@@ -60,7 +80,15 @@ var DemoEventSchema = z.object({
60
80
  title: z.string().optional(),
61
81
  subtitle: z.string().optional(),
62
82
  target: TargetSchema.optional(),
63
- payload: z.record(z.string(), z.unknown()).optional()
83
+ payload: z.record(z.string(), z.unknown()).optional(),
84
+ /**
85
+ * Which actor performed this event, for a `turn` hand-off or a verb step taken during one.
86
+ * Absent means the scenario's default actor — a single-actor event log never sets this, so
87
+ * it stays byte-identical to every log recorded before actors existed. No `.default()`
88
+ * here: the default actor's id is an application-layer notion the recorder resolves, not a
89
+ * value this schema should invent and materialise on parse.
90
+ */
91
+ actor: z.string().min(1).optional()
64
92
  });
65
93
 
66
94
  // ../schema/src/json-schema.ts
@@ -80,7 +108,34 @@ var ScenarioCameraSchema = z2.object({
80
108
  minDwellMs: z2.number().int().min(0, "camera.minDwellMs must be at least 0").max(5e3, "camera.minDwellMs must be at most 5000ms").optional()
81
109
  });
82
110
  var ScenarioSpeechSchema = z2.object({
83
- 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()
84
139
  });
85
140
  var ScenarioMetaSchema = z2.object({
86
141
  schema: z2.literal("agent-demo.scenario/v1"),
@@ -190,7 +245,14 @@ var BundleManifestSchema = z4.object({
190
245
  /** May be the literal "unknown" — never a fabricated version. */
191
246
  libass: z4.string(),
192
247
  fontSha256: Sha256,
193
- 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()
194
256
  }),
195
257
  environment: z4.object({
196
258
  os: z4.string(),
@@ -349,6 +411,30 @@ var CameraSchema = z5.object({
349
411
  path: ["shots"]
350
412
  }
351
413
  );
414
+ var ActorSchema = z5.object({
415
+ id: z5.string().min(1),
416
+ label: z5.string().min(1)
417
+ });
418
+ var SegmentSchema = z5.object({
419
+ id: z5.string().min(1),
420
+ actorId: z5.string().min(1),
421
+ startMs: z5.number().int().nonnegative(),
422
+ endMs: z5.number().int().positive()
423
+ });
424
+ var TransitionCardSchema = z5.object({
425
+ fromActorId: z5.string().min(1),
426
+ toActorId: z5.string().min(1),
427
+ durationMs: z5.number().int().positive().max(15e3),
428
+ lines: z5.array(z5.string().min(1)).min(1).max(2),
429
+ backgroundColor: HEX_COLOUR,
430
+ textColor: HEX_COLOUR,
431
+ assPath: z5.string().regex(/^captions\/turn-[0-9]+\.ass$/)
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
+ });
352
438
  var HighlightRectSchema = z5.object({
353
439
  id: z5.string().min(1),
354
440
  x: z5.number().int().min(0),
@@ -396,7 +482,23 @@ var SpeechClipSchema = z5.object({
396
482
  path: z5.string().regex(/^speech\/clips\/[A-Za-z0-9][A-Za-z0-9._-]*\.wav$/),
397
483
  atMs: z5.number().int().nonnegative(),
398
484
  durationMs: z5.number().int().positive(),
399
- 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()
400
502
  });
401
503
  var SpeechEngineSchema = z5.object({
402
504
  name: z5.string().min(1),
@@ -432,10 +534,12 @@ var SpeechSchema = z5.object({
432
534
  message: "clips must have unique ids and be laid out in order without overlapping",
433
535
  path: ["clips"]
434
536
  }
435
- ).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"), {
436
538
  // A synthesised clip with no record of what synthesised it is the one state this block
437
539
  // exists to prevent. The converse is fine: an engine recorded on an all-file track is
438
- // 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.
439
543
  message: "engine must be recorded whenever any clip was synthesised",
440
544
  path: ["engine"]
441
545
  });
@@ -625,8 +729,103 @@ var RenderPlanSchema = z5.object({
625
729
  cursor: CursorSchema.optional(),
626
730
  camera: CameraSchema.optional(),
627
731
  highlight: HighlightSchema.optional(),
628
- speech: SpeechSchema.optional()
629
- });
732
+ speech: SpeechSchema.optional(),
733
+ /**
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.
755
+ */
756
+ actors: z5.array(ActorSchema).min(2).optional(),
757
+ segments: z5.array(SegmentSchema).min(2).optional(),
758
+ transitionCards: z5.array(TransitionCardSchema).min(1).optional(),
759
+ badgesAssPath: z5.literal("captions/badges.ass").optional(),
760
+ explainSegments: z5.array(ExplainSegmentPlanSchema).min(1).optional()
761
+ }).refine(
762
+ (plan) => {
763
+ const castFields = [plan.actors, plan.transitionCards, plan.badgesAssPath];
764
+ return castFields.every((field) => field !== void 0) || castFields.every((field) => field === void 0);
765
+ },
766
+ {
767
+ // The same "carries its capability" discipline every optional block above already
768
+ // follows: a plan naming actors with nowhere to draw their badges (or vice versa)
769
+ // cannot render, so it is refused at the parse rather than discovered mid-encode.
770
+ message: "actors, transitionCards and badgesAssPath must be present together or absent together",
771
+ path: ["actors"]
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
+ }
813
+ ).refine(
814
+ (plan) => {
815
+ if (plan.actors === void 0 || plan.segments === void 0 || plan.transitionCards === void 0) {
816
+ return true;
817
+ }
818
+ const { actors, segments, transitionCards } = plan;
819
+ const namesDeclaredActor = (actorId) => actors.some((actor) => actor.id === actorId);
820
+ return segments.every((segment) => namesDeclaredActor(segment.actorId)) && transitionCards.every(
821
+ (card) => namesDeclaredActor(card.fromActorId) && namesDeclaredActor(card.toActorId)
822
+ );
823
+ },
824
+ {
825
+ message: "every segment.actorId, transitionCard.fromActorId and transitionCard.toActorId must name a declared actor",
826
+ path: ["segments"]
827
+ }
828
+ );
630
829
 
631
830
  // ../schema/src/results.ts
632
831
  import { z as z6 } from "zod";
@@ -760,7 +959,7 @@ var VerificationReportSchema = z6.object({
760
959
  bundleDir: DISPLAY_PATH,
761
960
  artifactCount: z6.number().int().nonnegative()
762
961
  });
763
- var DifferenceCategorySchema = z6.enum(["step", "assertion", "target", "timing", "caption"]);
962
+ var DifferenceCategorySchema = z6.enum(["step", "assertion", "target", "timing", "caption", "actor"]);
764
963
  var DiffCommandResultSchema = z6.object({
765
964
  ...envelope("diff"),
766
965
  bundleA: DISPLAY_PATH,
@@ -787,6 +986,19 @@ var PruneCommandResultSchema = z6.object({
787
986
  * be reclaimed if every candidate succeeded. */
788
987
  bytesReclaimed: z6.number().int().nonnegative()
789
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
+ });
790
1002
  var InspectResultSchema = z6.object({
791
1003
  ...envelope("inspect"),
792
1004
  bundleDir: DISPLAY_PATH,
@@ -846,6 +1058,51 @@ var InspectResultSchema = z6.object({
846
1058
  suppliedCount: z6.number().int().nonnegative(),
847
1059
  voice: z6.string().optional()
848
1060
  }).optional(),
1061
+ /**
1062
+ * The multi-actor demo's cast, or absent for a single-actor bundle — every bundle
1063
+ * recorded before actors existed, and still the default for most scenarios after, so
1064
+ * this stays absent rather than a zero-cast object, the same distinction `narration`
1065
+ * above draws between "silent" and "nothing to report".
1066
+ *
1067
+ * Sourced from the frozen plan's `actors`/`segments` fields, never the scenario file:
1068
+ * `inspectCommand` (`apps/cli/src/commands.ts`) reads only the render plan and manifest,
1069
+ * so a bundle whose scenario has since been edited or deleted still inspects correctly.
1070
+ * Not sourced from `plan.transitionCards`, even though `transitionCards.length` and
1071
+ * `segments.length - 1` are provably equal by the render plan's own refinement: `segments`
1072
+ * is the more primal fact — the capture's own cut points — so `turnCount` is derived from
1073
+ * the field a reader of this JSON would already trust as the timeline, not from a second
1074
+ * field that only agrees with it by a schema-level invariant this result does not surface.
1075
+ */
1076
+ actors: z6.object({
1077
+ /** `plan.actors.length`. Reported alongside `labels` rather than left for a caller to
1078
+ * compute, matching `narration.clipCount` sitting beside `narration` above for the
1079
+ * same reason: the count is the fact most callers want first, before the list. */
1080
+ count: z6.number().int().min(2),
1081
+ /** In `plan.actors` order — the cast list a badge or transition card would draw, not
1082
+ * deduplicated or sorted. */
1083
+ labels: z6.array(z6.string().min(1)).min(2),
1084
+ /** Hand-offs, not participants: one fewer than `plan.segments.length`, since a
1085
+ * segment is a window one actor already holds and a turn is the cut between two of
1086
+ * them — the same count `plan.transitionCards.length` carries, arrived at
1087
+ * independently from `segments` for the reason above. */
1088
+ turnCount: z6.number().int().nonnegative()
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(),
849
1106
  /** The rendered MP4s, from the manifest, so hashes are not recomputed. */
850
1107
  outputs: z6.array(ArtifactRefSchema),
851
1108
  assertions: z6.array(AssertionSchema),
@@ -892,6 +1149,22 @@ var DoctorResultSchema = z6.object({
892
1149
  voices: z6.array(z6.string()),
893
1150
  /** What is missing, in the same words a refused run would use. Empty when ready. */
894
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()
895
1168
  })
896
1169
  });
897
1170
  var okMatchesProblems = (value) => value.ok === (value.problems.length === 0);
@@ -9,6 +9,8 @@ export declare const DemoEventTypeSchema: z.ZodEnum<{
9
9
  "assertion.fail": "assertion.fail";
10
10
  "privacy.mask": "privacy.mask";
11
11
  "human.wait": "human.wait";
12
+ turn: "turn";
13
+ explain: "explain";
12
14
  }>;
13
15
  export declare const TargetSchema: z.ZodObject<{
14
16
  role: z.ZodOptional<z.ZodString>;
@@ -36,6 +38,8 @@ export declare const DemoEventSchema: z.ZodObject<{
36
38
  "assertion.fail": "assertion.fail";
37
39
  "privacy.mask": "privacy.mask";
38
40
  "human.wait": "human.wait";
41
+ turn: "turn";
42
+ explain: "explain";
39
43
  }>;
40
44
  stableId: z.ZodOptional<z.ZodString>;
41
45
  title: z.ZodOptional<z.ZodString>;
@@ -52,6 +56,7 @@ export declare const DemoEventSchema: z.ZodObject<{
52
56
  }, z.core.$strip>>;
53
57
  }, z.core.$strip>>;
54
58
  payload: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
59
+ actor: z.ZodOptional<z.ZodString>;
55
60
  }, z.core.$strip>;
56
61
  export type DemoEvent = z.infer<typeof DemoEventSchema>;
57
62
  export type DemoEventType = z.infer<typeof DemoEventTypeSchema>;
@@ -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;
@@ -190,6 +190,104 @@ export declare const CameraSchema: z.ZodObject<{
190
190
  }, z.core.$strip>;
191
191
  export type Camera = z.infer<typeof CameraSchema>;
192
192
  export type CameraShot = z.infer<typeof CameraShotSchema>;
193
+ /**
194
+ * One participant in a multi-actor demo, each with its own Playwright `BrowserContext` —
195
+ * `AGENTS.md` §1 constraint 3 guarantees at most one is ever active at a single instant.
196
+ * `id` is what `SegmentSchema.actorId` and `TransitionCardSchema.fromActorId`/`toActorId`
197
+ * reference; `label` is the human-readable name a badge or a transition card draws.
198
+ */
199
+ export declare const ActorSchema: z.ZodObject<{
200
+ id: z.ZodString;
201
+ label: z.ZodString;
202
+ }, z.core.$strip>;
203
+ export type Actor = z.infer<typeof ActorSchema>;
204
+ /**
205
+ * One contiguous window of `raw/session.webm` during which a single actor held the (one,
206
+ * at most) active `BrowserContext`. `startMs`/`endMs` are source-relative — the same space
207
+ * `source.durationMs` and the `trim=duration=...` FFmpeg argument already operate in — not
208
+ * a file path: the concatenation of raw capture segments into the one `raw/session.webm`
209
+ * this plan's `source` names has already happened by the time this schema is populated, so
210
+ * there is deliberately no `rawFile`/path field here, unlike what an earlier design draft
211
+ * suggested. A segment describes how that single file is internally structured, never a
212
+ * second file to read.
213
+ */
214
+ export declare const SegmentSchema: z.ZodObject<{
215
+ id: z.ZodString;
216
+ actorId: z.ZodString;
217
+ startMs: z.ZodNumber;
218
+ endMs: z.ZodNumber;
219
+ }, z.core.$strip>;
220
+ export type Segment = z.infer<typeof SegmentSchema>;
221
+ /**
222
+ * A full-frame card drawn between two actors' segments on a hand-off — structurally
223
+ * `Intro`/`Outro`'s sibling, down to the same `HEX_COLOUR` injection guard on its colours,
224
+ * plus which actor is handing off to which.
225
+ *
226
+ * Unlike the single well-known `captions/intro.ass`/`captions/outro.ass`, a demo can have
227
+ * many hand-offs, so `assPath` cannot be one literal. It is a regex-constrained family
228
+ * instead — `SpeechClipSchema.path`'s discipline applied to its own N-member family:
229
+ * constrained enough that no filtergraph metacharacter or path traversal is representable.
230
+ */
231
+ export declare const TransitionCardSchema: z.ZodObject<{
232
+ fromActorId: z.ZodString;
233
+ toActorId: z.ZodString;
234
+ durationMs: z.ZodNumber;
235
+ lines: z.ZodArray<z.ZodString>;
236
+ backgroundColor: z.ZodString;
237
+ textColor: z.ZodString;
238
+ assPath: z.ZodString;
239
+ }, z.core.$strip>;
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>;
268
+ /**
269
+ * How long a `demo.turn(actor, { card })` transition card is on screen when its own
270
+ * `durationMs` was omitted.
271
+ *
272
+ * Lives here, beside `TransitionCardSchema`, rather than in either package that resolves it:
273
+ * `packages/recorder-playwright/src/pipeline.ts`'s `transitionCardDurationMs` reads it at
274
+ * derivation time, before `transitionCards` exists, to offset later segments; the renderer
275
+ * that eventually draws a frozen `RenderPlanSchema.transitionCards` entry with no
276
+ * `durationMs` of its own needs the same number. `AGENTS.md` §2 forbids `renderer-ffmpeg`
277
+ * from importing `recorder-playwright` (the reverse edge already exists, so importing back
278
+ * would be the cycle `no-circular` refuses), so a bare constant defined in either package
279
+ * would be unreachable from the other — both already depend on `@plaintake/schema`, which is
280
+ * why it is defined here instead.
281
+ *
282
+ * Matches `DEFAULT_INTRO_MS` and `FREE_TIER_OUTRO.durationMs`
283
+ * (`packages/license/src/branding.ts`), both already 3000ms for the same shape of decision —
284
+ * a full-frame card with a line or two to read, defaulted absent an author- or
285
+ * configuration-supplied length. Not imported from `@plaintake/license`: that package
286
+ * resolves *licensed* branding, and a transition card is never licence-gated (the camera is
287
+ * the one capability still Pro-gated; see `AGENTS.md`), so a shared import would be the wrong
288
+ * fix for a coincidence, not a rule the two packages share.
289
+ */
290
+ export declare const TRANSITION_CARD_DEFAULT_MS = 3000;
193
291
  /**
194
292
  * One dimmed-frame-with-a-hole window: a step's measured target rect, frozen exactly as
195
293
  * `CursorPointSchema` freezes a target's centre, and for the same reason. `renderer-ffmpeg`'s
@@ -247,6 +345,14 @@ export type HighlightRect = z.infer<typeof HighlightRectSchema>;
247
345
  *
248
346
  * `source` distinguishes a synthesised clip from an author-supplied WAV, because they are the
249
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.
250
356
  */
251
357
  export declare const SpeechClipSchema: z.ZodObject<{
252
358
  id: z.ZodString;
@@ -254,9 +360,11 @@ export declare const SpeechClipSchema: z.ZodObject<{
254
360
  atMs: z.ZodNumber;
255
361
  durationMs: z.ZodNumber;
256
362
  source: z.ZodEnum<{
363
+ explain: "explain";
257
364
  file: "file";
258
365
  synth: "synth";
259
366
  }>;
367
+ voice: z.ZodOptional<z.ZodString>;
260
368
  }, z.core.$strip>;
261
369
  /**
262
370
  * What produced the synthesised clips. Evidence only — nothing reads it to make a decision,
@@ -298,9 +406,11 @@ export declare const SpeechSchema: z.ZodObject<{
298
406
  atMs: z.ZodNumber;
299
407
  durationMs: z.ZodNumber;
300
408
  source: z.ZodEnum<{
409
+ explain: "explain";
301
410
  file: "file";
302
411
  synth: "synth";
303
412
  }>;
413
+ voice: z.ZodOptional<z.ZodString>;
304
414
  }, z.core.$strip>>;
305
415
  engine: z.ZodOptional<z.ZodObject<{
306
416
  name: z.ZodString;
@@ -339,6 +449,14 @@ export type HighlightInput = Omit<Highlight, 'assPath'>;
339
449
  export type OutroInput = Omit<Outro, 'assPath'>;
340
450
  /** The opening card as the application layer supplies it, exactly as `OutroInput` works. */
341
451
  export type IntroInput = Omit<Intro, 'assPath'>;
452
+ /**
453
+ * The transition card decision as the application layer supplies it — `pipeline.ts`, in
454
+ * practice, which resolves a hand-off's concrete `durationMs` via `transitionCardDurationMs`
455
+ * before this is ever built, exactly as `intro`/`outro` durations are resolved before
456
+ * `buildRenderPlan` is called. `assPath` is filled in by `buildRenderPlan`, indexed by
457
+ * position in the array against the schema's own `captions/turn-[0-9]+\.ass` family.
458
+ */
459
+ export type TransitionCardInput = Omit<TransitionCard, 'assPath'>;
342
460
  /**
343
461
  * The one value a plan's `schema` field ever holds.
344
462
  *
@@ -532,9 +650,11 @@ export declare const RenderPlanSchema: z.ZodObject<{
532
650
  atMs: z.ZodNumber;
533
651
  durationMs: z.ZodNumber;
534
652
  source: z.ZodEnum<{
653
+ explain: "explain";
535
654
  file: "file";
536
655
  synth: "synth";
537
656
  }>;
657
+ voice: z.ZodOptional<z.ZodString>;
538
658
  }, z.core.$strip>>;
539
659
  engine: z.ZodOptional<z.ZodObject<{
540
660
  name: z.ZodString;
@@ -543,6 +663,31 @@ export declare const RenderPlanSchema: z.ZodObject<{
543
663
  voice: z.ZodString;
544
664
  }, z.core.$strip>>;
545
665
  }, z.core.$strip>>;
666
+ actors: z.ZodOptional<z.ZodArray<z.ZodObject<{
667
+ id: z.ZodString;
668
+ label: z.ZodString;
669
+ }, z.core.$strip>>>;
670
+ segments: z.ZodOptional<z.ZodArray<z.ZodObject<{
671
+ id: z.ZodString;
672
+ actorId: z.ZodString;
673
+ startMs: z.ZodNumber;
674
+ endMs: z.ZodNumber;
675
+ }, z.core.$strip>>>;
676
+ transitionCards: z.ZodOptional<z.ZodArray<z.ZodObject<{
677
+ fromActorId: z.ZodString;
678
+ toActorId: z.ZodString;
679
+ durationMs: z.ZodNumber;
680
+ lines: z.ZodArray<z.ZodString>;
681
+ backgroundColor: z.ZodString;
682
+ textColor: z.ZodString;
683
+ assPath: z.ZodString;
684
+ }, z.core.$strip>>>;
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>>>;
546
691
  }, z.core.$strip>;
547
692
  export type RenderPlan = z.infer<typeof RenderPlanSchema>;
548
693
  export type Cue = z.infer<typeof CueSchema>;
@@ -228,6 +228,7 @@ export declare const DiffCommandResultSchema: z.ZodObject<{
228
228
  category: z.ZodEnum<{
229
229
  assertion: "assertion";
230
230
  target: "target";
231
+ actor: "actor";
231
232
  step: "step";
232
233
  timing: "timing";
233
234
  caption: "caption";
@@ -280,6 +281,37 @@ export declare const PruneCommandResultSchema: z.ZodObject<{
280
281
  ok: z.ZodBoolean;
281
282
  problems: z.ZodArray<z.ZodString>;
282
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>;
283
315
  export declare const InspectResultSchema: z.ZodObject<{
284
316
  bundleDir: z.ZodString;
285
317
  status: z.ZodEnum<{
@@ -326,6 +358,15 @@ export declare const InspectResultSchema: z.ZodObject<{
326
358
  suppliedCount: z.ZodNumber;
327
359
  voice: z.ZodOptional<z.ZodString>;
328
360
  }, z.core.$strip>>;
361
+ actors: z.ZodOptional<z.ZodObject<{
362
+ count: z.ZodNumber;
363
+ labels: z.ZodArray<z.ZodString>;
364
+ turnCount: z.ZodNumber;
365
+ }, z.core.$strip>>;
366
+ explainScenes: z.ZodOptional<z.ZodArray<z.ZodObject<{
367
+ id: z.ZodString;
368
+ durationMs: z.ZodNumber;
369
+ }, z.core.$strip>>>;
329
370
  outputs: z.ZodArray<z.ZodObject<{
330
371
  path: z.ZodString;
331
372
  sha256: z.ZodString;
@@ -359,6 +400,11 @@ export declare const DoctorResultSchema: z.ZodObject<{
359
400
  voices: z.ZodArray<z.ZodString>;
360
401
  notes: z.ZodArray<z.ZodString>;
361
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>;
362
408
  schema: z.ZodLiteral<"agent-demo.result/v1">;
363
409
  kind: z.ZodLiteral<"doctor">;
364
410
  ok: z.ZodBoolean;
@@ -412,11 +458,12 @@ export type RenderCommandResult = z.infer<typeof RenderCommandResultSchema>;
412
458
  export type VerificationReport = z.infer<typeof VerificationReportSchema>;
413
459
  export type DiffCommandResult = z.infer<typeof DiffCommandResultSchema>;
414
460
  export type PruneCommandResult = z.infer<typeof PruneCommandResultSchema>;
461
+ export type ImportCommandResult = z.infer<typeof ImportCommandResultSchema>;
415
462
  export type InspectResult = z.infer<typeof InspectResultSchema>;
416
463
  export type DoctorResult = z.infer<typeof DoctorResultSchema>;
417
464
  export type ActivateResult = z.infer<typeof ActivateResultSchema>;
418
465
  export type LicenceResult = z.infer<typeof LicenceResultSchema>;
419
- 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;
420
467
  /**
421
468
  * Renders a filesystem location for display in a result, relative to `root` when it
422
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
@@ -1,12 +1,13 @@
1
1
  import type { ScenarioMeta } from './schema/index.js';
2
- import type { Locator, Page } from 'playwright-core';
2
+ import type { BrowserContextOptions, Locator, Page } from 'playwright-core';
3
3
  /**
4
4
  * What kind of interaction `run()` performs, declared rather than observed. Like
5
5
  * `target`, this is metadata the recorder records and never performs — it tells the
6
- * cursor track what to draw at the target (`click` a ripple, `type`/`point` a settle).
7
- * Absent means `point`: the cursor still moves to the target, nothing pulses.
6
+ * cursor track what to draw at the target (`click` a ripple, `type`/`point`/`scroll` a
7
+ * settle — none of the three pulse, only `click` does). Absent means `point`: the cursor
8
+ * still moves to the target, nothing pulses.
8
9
  */
9
- export type DemoAction = 'click' | 'type' | 'point';
10
+ export type DemoAction = 'click' | 'type' | 'point' | 'scroll';
10
11
  export type DemoStep = {
11
12
  id: string;
12
13
  title: string;
@@ -32,6 +33,20 @@ export type DemoStep = {
32
33
  highlight?: boolean | {
33
34
  label?: string;
34
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;
35
50
  /** Extra presentation hold after the action completes, in whole milliseconds. */
36
51
  holdMs?: number;
37
52
  run: () => Promise<unknown>;
@@ -124,6 +139,262 @@ export type DemoHandoff = PreflightHandoff & {
124
139
  */
125
140
  mask?: string;
126
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
+ };
278
+ /**
279
+ * Presentation fields shared by every `Actor` verb below — the same subset of `DemoStep`
280
+ * that is still the caller's to supply once `target` and `action` no longer are: a verb's
281
+ * argument list already gives the target (when it has one), and the verb's own name already
282
+ * gives the action. Only how the step presents is left to ask for.
283
+ *
284
+ * `title` is optional here where `DemoStep.title` is required, on purpose: a verb call that
285
+ * narrates nothing — a background click with no caption — is meant to stay a one-liner,
286
+ * not force every single interaction through the same captioning `demo.step()` requires.
287
+ */
288
+ export type ActorStepMeta = {
289
+ /**
290
+ * Stable id for the step this call records, the same role `DemoStep.id` plays and for the
291
+ * same reasons: it becomes the `stableId` on this step's `step.start`/`step.finish` events,
292
+ * the key a target-miss or an abort diagnostic names, and — via `clipFileName(id)` — the
293
+ * exact filename an author drops into `narrationDir` for a `--speech file` WAV. Absent
294
+ * means the runtime synthesizes one; supply your own when you need that identity to stay
295
+ * stable across runs — a hand-chosen id does not shift when an earlier call in the same
296
+ * turn is added, removed or reordered the way a synthesized one might.
297
+ */
298
+ id?: string;
299
+ title?: string;
300
+ subtitle?: string;
301
+ /** See `DemoStep.holdMs`. */
302
+ holdMs?: number;
303
+ /** See `DemoStep.highlight`, including the "requires a measurable target" rule. */
304
+ highlight?: boolean | {
305
+ label?: string;
306
+ };
307
+ /** See `DemoStep.voice` — one verb-call override, ahead of this actor's own `voice`. */
308
+ voice?: string;
309
+ };
310
+ /**
311
+ * A named participant in a multi-actor demo: its own Playwright `BrowserContext`, its own
312
+ * page, and a handle of verbs that each perform a Playwright action *and* record the
313
+ * render-track annotation that action needs — the pairing `demo.step()` otherwise leaves an
314
+ * author to assemble by hand (a `target`, a declared `action`, a `run`). `admin.click(button)`
315
+ * does both in one call; there is no separate bookkeeping step the way there is today.
316
+ *
317
+ * `page` is the same escape hatch `DemoRunArgs.page` already is: anything the named verbs
318
+ * below don't model — a Playwright call with no render-track equivalent — is still reachable
319
+ * through it, at the same cost reaching past `demo.step()` into raw Playwright already has
320
+ * today: nothing about the call is recorded automatically.
321
+ *
322
+ * Every verb that takes a `Locator` records that locator's measured rect on `step.start`, the
323
+ * same rect `DemoStep.target` records; a verb with nothing to point at (`goto`, and `scroll`'s
324
+ * delta form) records none, exactly as a `DemoStep` with no `target` does.
325
+ *
326
+ * Same name, different thing from `@plaintake/schema`'s `Actor`: that one is the frozen
327
+ * `{ id, label }` render-plan record a *finished* run leaves behind; this one is the live,
328
+ * verb-bearing handle a scenario's `run()` calls while a session is still recording.
329
+ */
330
+ export type Actor = {
331
+ /** Raw Playwright escape hatch. See this type's own doc comment. */
332
+ page: Page;
333
+ goto(url: string, meta?: ActorStepMeta): Promise<void>;
334
+ click(target: Locator, meta?: ActorStepMeta): Promise<void>;
335
+ /**
336
+ * Fills `target` with `text`. Masking a field's *value* once it exists is `mask`'s job,
337
+ * not this verb's — `type` only ever records that typing happened, never what was typed.
338
+ */
339
+ type(target: Locator, text: string, meta?: ActorStepMeta): Promise<void>;
340
+ /**
341
+ * Presses `key` while `target` holds focus — Playwright's `locator.press`, not the
342
+ * page-global `page.keyboard.press`. A demo step points at something; a key press with
343
+ * nothing to point at is exactly what `actor.page.keyboard.press(key)` remains for, at
344
+ * the same recorded-nothing cost as any other reach past a named verb.
345
+ */
346
+ press(target: Locator, key: string, meta?: ActorStepMeta): Promise<void>;
347
+ /**
348
+ * Scrolls the page or a specific element into view — two different Playwright calls,
349
+ * `locator.scrollIntoViewIfNeeded()` and `page.mouse.wheel(deltaX, deltaY)`, behind one
350
+ * verb, chosen by which shape `where` is. Named `where` rather than `target` because the
351
+ * delta form is not a target — nothing measurable sits behind a pixel offset — where every
352
+ * other verb's `target` names a `Locator` and nothing else. A `Locator` scrolls that element
353
+ * into view and records its rect exactly as `click`/`hover` do; a `{ deltaX?, deltaY? }`
354
+ * delta scrolls the viewport by that many pixels (either defaulting to 0) and records no
355
+ * target, exactly as `goto` does.
356
+ */
357
+ scroll(where: Locator | {
358
+ deltaX?: number;
359
+ deltaY?: number;
360
+ }, meta?: ActorStepMeta): Promise<void>;
361
+ hover(target: Locator, meta?: ActorStepMeta): Promise<void>;
362
+ /**
363
+ * Mirrors `locator.selectOption`'s common case: one value or several. Anything its fuller
364
+ * overload set covers that a plain value or value list cannot is `step`'s or `page`'s to
365
+ * reach instead.
366
+ */
367
+ select(target: Locator, value: string | string[], meta?: ActorStepMeta): Promise<void>;
368
+ /**
369
+ * Reuses `DemoAssertion` verbatim. The only thing this adds over `DemoContext.assert` is
370
+ * which actor the recorded event is attributed to.
371
+ */
372
+ assert(assertion: DemoAssertion): Promise<void>;
373
+ /**
374
+ * Escape hatch for an interaction none of the named verbs above model, bound to this actor
375
+ * exactly as `DemoContext.step` is bound to the default one.
376
+ */
377
+ step(step: DemoStep): Promise<void>;
378
+ /**
379
+ * Reuses `DemoMask` verbatim. Actor-scoped: applies to this actor's own `BrowserContext`
380
+ * only. Each actor's context is its own page, its own DOM and its own selectors, so a mask
381
+ * registered here has nothing to do in a context it was never registered against.
382
+ */
383
+ mask(mask: DemoMask): Promise<void>;
384
+ /**
385
+ * Another `Page` on this actor's own `BrowserContext` — the multi-tab counterpart to
386
+ * `demo.actor`'s multi-context one, still governed by the same one-active-page-at-a-time
387
+ * rule within this actor's own turn.
388
+ */
389
+ newTab(): Promise<Page>;
390
+ /**
391
+ * Hands the real browser window to a person during this actor's turn. Reuses `DemoHandoff`
392
+ * verbatim; the only difference from `DemoContext.handoff` is that a handoff can originate
393
+ * from any actor's turn, not only the default one a scenario would fall back to if none
394
+ * were ever named — a person can be handed the browser whoever currently holds it.
395
+ */
396
+ handoff(request: DemoHandoff): Promise<void>;
397
+ };
127
398
  /**
128
399
  * What a scenario may do before recording starts.
129
400
  *
@@ -153,6 +424,75 @@ export interface DemoContext extends PreflightContext {
153
424
  assert(assertion: DemoAssertion): Promise<void>;
154
425
  mask(mask: DemoMask): Promise<void>;
155
426
  pause(ms: number): Promise<void>;
427
+ /**
428
+ * Returns a handle for a named actor, standing up its `BrowserContext` and `Page` the first
429
+ * time `id` is seen. Async, not a synchronous handle: `browser.newContext()` and
430
+ * `context.newPage()` are themselves async Playwright calls, and there is no way to hand
431
+ * back an `Actor` whose `page` is already a real, working page without awaiting them first
432
+ * — so authors write `const admin = await demo.actor('admin')`. See `turn` for what makes
433
+ * an actor's context the active one.
434
+ *
435
+ * The first `id` a scenario ever calls this with reuses the implicit default context and
436
+ * page every scenario already runs on, rather than opening a second one nobody asked for,
437
+ * so in practice that call resolves immediately — there is no new `BrowserContext` to wait
438
+ * on. The signature stays `Promise<Actor>` for every actor regardless, rather than
439
+ * special-casing the first one: a single-actor scenario written before actors existed keeps
440
+ * working unchanged, and turning it into a two-actor one only costs the second
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.
448
+ */
449
+ actor(id: string, opts?: {
450
+ label?: string;
451
+ contextOptions?: BrowserContextOptions;
452
+ voice?: string;
453
+ }): Promise<Actor>;
454
+ /**
455
+ * Marks a hand-off from whichever actor currently holds the browser to `actor`, parking the
456
+ * outgoing `BrowserContext` and activating the incoming one. Async because, like `chapter`
457
+ * and `pause`, it is itself an event on the render timeline, not just a bookkeeping call.
458
+ *
459
+ * `opts.card`, when given, plays a full-frame transition card between the two actors'
460
+ * segments — the `lines` it names, an optional spoken `narration`, held for `durationMs`.
461
+ *
462
+ * Omitting `opts.card` is allowed, but still draws a card: `RenderPlanSchema.transitionCards`
463
+ * requires exactly one entry per hand-off with non-empty `lines`, so a card-less turn's card
464
+ * is synthesised at render time as `Now: <label>`, naming the incoming actor by the `label`
465
+ * it was given to `demo.actor()`, held for the same default duration an explicit card with no
466
+ * `durationMs` of its own gets. The persistent corner badge names the active actor throughout
467
+ * either way; `opts.card` only ever changes whether that hand-off also gets a full-frame beat
468
+ * of its own, and what it says.
469
+ */
470
+ turn(actor: Actor, opts?: {
471
+ card?: {
472
+ lines: string[];
473
+ narration?: string;
474
+ durationMs?: number;
475
+ };
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>;
156
496
  }
157
497
  export type DemoRunArgs = {
158
498
  page: Page;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plaintake/scenario",
3
- "version": "1.6.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",