@mengine/medeo-client 2.1.1-dsl.2 → 2.1.1-dsl.20

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.
@@ -1,4 +1,4 @@
1
- import { $ as assertRelation, A as hardenCidDescriptors, B as safeStringify, C as applySchemaToInferOptions, D as deepEqual, E as decodeNestedJsonValues, F as isObject, G as valuesEqual, H as stripUndefined, I as isObjectLike, J as getChildSchema, K as CID_KEY, L as isStateAndSchemaOfType, M as inferContainerTypeFromValue, N as insertChildToMap, O as defineCidProperty, P as isArrayLike, Q as assertId, R as isTreeID, S as applyEncode, T as containerIdToContainerType, U as tryInferContainerType, V as schemaToContainerType, W as tryUpdateToContainer, X as assertEntity, Y as getMapFieldSchema, Z as assertEntityPatch, _ as isLoroTreeSchema, at as assertRelationIdentity, b as validateSchema, c as isInvalidAtomic, ct as relationId, d as getDefaultValue, et as entityTables, f as isContainerSchema, g as isLoroMovableListSchema, h as isLoroMapSchema, it as validLanguage, j as hasTransform, k as getRootContainerByType, lt as resourceId, m as isLoroListSchema, n as textStyles, nt as mergeFields, o as medeoDslLoroShape, ot as assertResourceIdentity, p as isListLikeSchema, q as getChildContainerSchema, rt as relationTables, s as captionContentCodec, st as defaultIdFactory, t as DEFAULT_UNIT_TIME_MS, tt as isRecord, u as schema, v as isRootSchemaType, w as cidsEqual, x as applyDecode, y as schemaContainsIgnoreField, z as isValueOfContainerType } from "./video-draft-types-TXRv3wS6.js";
1
+ import { $ as assertEntityPatch, A as getRootContainerByType, B as isValueOfContainerType, C as applyEncode, D as decodeNestedJsonValues, E as containerIdToContainerType, F as isArrayLike, G as tryUpdateToContainer, H as schemaToContainerType, I as isObject, J as getChildContainerSchema, K as valuesEqual, L as isObjectLike, M as hasTransform, N as inferContainerTypeFromValue, O as deepEqual, P as insertChildToMap, Q as assertEntity, R as isStateAndSchemaOfType, S as applyDecode, T as cidsEqual, U as stripUndefined, V as safeStringify, W as tryInferContainerType, X as getMapFieldSchema, Y as getChildSchema, Z as assertCaptionContent, _ as isLoroMovableListSchema, _t as resourceId, at as assertTimeAnchor, b as schemaContainsIgnoreField, c as isInvalidAtomic, ct as isRecord, d as schema, dt as relationTables, et as assertId, f as getDefaultValue, ft as validLanguage, g as isLoroMapSchema, gt as relationId, h as isLoroListSchema, ht as defaultIdFactory, it as assertScriptSegment, j as hardenCidDescriptors, k as defineCidProperty, l as phonemeCodec, lt as mergeFields, m as isListLikeSchema, mt as assertResourceIdentity, n as textStyles, nt as assertPhoneticScript, o as medeoDslLoroShape, ot as atomicFields, p as isContainerSchema, pt as assertRelationIdentity, q as CID_KEY, rt as assertRelation, s as captionContentCodec, st as entityTables, t as DEFAULT_UNIT_TIME_MS, tt as assertPhoneme, ut as patchRow, v as isLoroTreeSchema, w as applySchemaToInferOptions, x as validateSchema, y as isRootSchemaType, z as isTreeID } from "./video-draft-types-DKo53mZ8.js";
2
2
  import { Cursor, LoroDoc, LoroList, LoroMap, LoroMovableList, LoroText, LoroTree, isContainer, isContainerId } from "loro-crdt";
3
3
  import { Immer, setAutoFreeze } from "immer";
4
4
  //#region ../medeo-dsl/src/text.ts
@@ -12,20 +12,15 @@ function range(text, start, end) {
12
12
  assertTextOffset(text, end);
13
13
  if (start >= end) throw new Error("Expected a nonempty text range");
14
14
  }
15
- function assertMark(key, value, selected) {
16
- if (key !== "language" && key !== "pauseMs") throw new Error(`Unsupported mark: ${key}`);
17
- if (value === null) return;
18
- if (key === "language" && !validLanguage(value)) throw new Error("Invalid language");
19
- if (key === "pauseMs" && (selected !== " " || typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) throw new Error("pauseMs requires a nonnegative integer on one ordinary space");
15
+ /** A script marks only what language a run is written in; how to say it belongs to its phonetic script. */
16
+ function assertMark(key, value) {
17
+ if (key !== "language") throw new Error(`Unsupported mark: ${key}`);
18
+ if (value !== null && !validLanguage(value)) throw new Error("Invalid language");
20
19
  }
21
- function attributes(attrs, selected) {
20
+ function attributes(attrs) {
22
21
  if (!attrs) return;
23
- const result = {};
24
- for (const [key, value] of Object.entries(attrs)) {
25
- assertMark(key, value, selected);
26
- result[key] = key === "pauseMs" && value === 0 ? null : value;
27
- }
28
- return result;
22
+ for (const [key, value] of Object.entries(attrs)) assertMark(key, value);
23
+ return { ...attrs };
29
24
  }
30
25
  function previewDelta(text, delta) {
31
26
  let offset = 0;
@@ -44,7 +39,7 @@ function previewDelta(text, delta) {
44
39
  ].includes(key))) throw new Error("Invalid text Delta operation");
45
40
  if ("insert" in op) {
46
41
  if (typeof op.insert !== "string") throw new Error("Only text insertions are supported");
47
- const attrs = attributes(op.attributes, op.insert);
42
+ const attrs = attributes(op.attributes);
48
43
  text = text.slice(0, offset) + op.insert + text.slice(offset);
49
44
  normalized.push({
50
45
  insert: op.insert,
@@ -56,7 +51,7 @@ function previewDelta(text, delta) {
56
51
  if (!Number.isSafeInteger(length) || length < 0) throw new Error("Invalid Delta length");
57
52
  assertTextOffset(text, offset + length);
58
53
  if ("retain" in op) {
59
- const attrs = attributes(op.attributes, text.slice(offset, offset + length));
54
+ const attrs = attributes(op.attributes);
60
55
  normalized.push({
61
56
  retain: length,
62
57
  ...attrs ? { attributes: attrs } : {}
@@ -91,11 +86,11 @@ function prepareTextEdits(text, edits) {
91
86
  break;
92
87
  case "mark":
93
88
  range(text, edit.start, edit.end);
94
- assertMark(edit.key, edit.value, text.slice(edit.start, edit.end));
89
+ assertMark(edit.key, edit.value);
95
90
  break;
96
91
  case "unmark":
97
92
  range(text, edit.start, edit.end);
98
- assertMark(edit.key, null, "");
93
+ assertMark(edit.key, null);
99
94
  break;
100
95
  case "delta": {
101
96
  const result = previewDelta(text, edit.delta);
@@ -109,7 +104,28 @@ function prepareTextEdits(text, edits) {
109
104
  }
110
105
  return edit;
111
106
  }),
112
- body: text
107
+ text
108
+ };
109
+ }
110
+ /** The language a whole segment carries: one shared mark on every unit, else none. */
111
+ function markedLanguage(text) {
112
+ if (!text.text.length) return void 0;
113
+ const first = text.langs[0];
114
+ for (let i = 1; i < text.text.length; i++) if (text.langs[i] !== first) return void 0;
115
+ return first;
116
+ }
117
+ /** Read language marks off a rich-text delta. */
118
+ function langsFromDelta(delta) {
119
+ let text = "";
120
+ const langs = [];
121
+ for (const run of delta) {
122
+ text += run.insert;
123
+ const language = typeof run.attributes?.language === "string" ? run.attributes.language : void 0;
124
+ for (let i = 0; i < run.insert.length; i++) langs.push(language);
125
+ }
126
+ return {
127
+ text,
128
+ langs
113
129
  };
114
130
  }
115
131
  //#endregion
@@ -122,73 +138,116 @@ function requireEntity(rows, table, id) {
122
138
  return row;
123
139
  }
124
140
  /**
125
- * Whether one relation row may stand among these rows: its fields, its derived ID, and what
126
- * its kind requires of each end. A row being linked need not be in `rows` yet. Caption content
127
- * selects text, so its range is checked against the body with {@link assertCaptionSelection}.
141
+ * Whether one relation row may stand among these rows: its fields, its derived ID, and what its
142
+ * kind requires of each end. A row being linked need not be in `rows` yet. Relations an entity
143
+ * holds at most one of are fields of that entity; see {@link assertReferenceAdmitted}.
128
144
  */
129
145
  function assertRelationAdmitted(rows, table, id, value) {
130
146
  assertId(id);
131
147
  assertRelation(table, value);
132
148
  assertRelationIdentity(table, id, value);
133
149
  const v = value;
134
- const require = (endpoint, id) => requireEntity(rows, endpoint, id);
135
150
  switch (table) {
136
- case "clip-source-relations":
137
- require("clip-entities", v.clipId);
138
- if (!rows["media-entities"][v.sourceId] && !rows["caption-entities"][v.sourceId]) throw new Error("Invalid Clip source");
139
- break;
140
- case "time-anchor-relations": {
141
- require("clip-entities", v.from);
142
- if (rows["timeline-entities"][v.to]) {
143
- if (v.offsetMs < 0) throw new Error("Absolute offset must be nonnegative");
144
- } else require("clip-entities", v.to);
145
- const visited = /* @__PURE__ */ new Set();
146
- let from = v.from;
147
- while (true) {
148
- if (visited.has(from)) throw new Error("Time anchor cycle");
149
- visited.add(from);
150
- const anchor = from === v.from ? v : rows["time-anchor-relations"][`ta:${from}`];
151
- if (!isRecord(anchor) || typeof anchor.to !== "string") break;
152
- from = anchor.to;
153
- }
154
- break;
155
- }
156
- case "caption-content-relations":
157
- assertCaptionEndpoints(rows, v);
158
- break;
159
- case "caption-alignment-relations": {
160
- require("caption-entities", v.captionId);
161
- const media = require("media-entities", v.mediaId);
162
- if (media.kind === "image" || v.startMs >= v.endMs || v.endMs > media.durationMs) throw new Error("Invalid source alignment");
163
- break;
164
- }
165
151
  case "voice-timbre-relations":
166
- if (require("media-entities", v.recordingId).kind !== "voiceover-recording") throw new Error("Timbre requires a voiceover recording");
167
- require("voice-entities", v.voiceId);
152
+ if (requireEntity(rows, "media-entities", v.recordingId).kind !== "voiceover-recording") throw new Error("Timbre requires a voiceover recording");
153
+ requireEntity(rows, "voice-entities", v.voiceId);
168
154
  break;
169
155
  case "derived-from-relations":
170
156
  for (const end of [v.entityId, v.sourceId]) if (!entityTables.some((t) => Object.hasOwn(rows[t], end))) throw new Error("Missing provenance endpoint");
171
157
  break;
172
- case "from-asset-relations":
173
- case "to-asset-relations": {
174
- require("audio-script-entities", v.audioScriptId);
175
- const asset = require("asset-entities", v.assetId);
176
- if (table === "to-asset-relations" && !asset.digest) throw new Error("Export Asset requires digest");
158
+ }
159
+ }
160
+ /** Entity fields that hold a single-valued relation, by the entity that owns it. */
161
+ const referenceFields = {
162
+ "clip-entities": ["source", "anchor"],
163
+ "phonetic-script-entities": ["phoneme"],
164
+ "caption-entities": ["selection"]
165
+ };
166
+ /** The segment a selection names: refused when its script or the segment is missing. */
167
+ function requireSegment(rows, audioScriptId, segmentId) {
168
+ const script = requireEntity(rows, "audio-script-entities", audioScriptId);
169
+ const segment = Array.isArray(script.segments) ? script.segments.find((s) => isRecord(s) && s.id === segmentId) : void 0;
170
+ if (!segment) throw new Error(`Missing segment ${String(segmentId)} of ${String(audioScriptId)}`);
171
+ return segment;
172
+ }
173
+ /**
174
+ * Whether a single-valued relation, stored as a field of the entity that owns it, may hold this
175
+ * value among these rows. The owner need not be stored yet; an absent value unlinks it.
176
+ * A selection's run is checked against the segment's live text by whoever holds it.
177
+ */
178
+ function assertReferenceAdmitted(rows, table, id, field, value) {
179
+ assertId(id);
180
+ const key = `${table}.${field}`;
181
+ if (value === void 0) {
182
+ if (key === "clip-entities.source") throw new Error(`Missing required relation: ${key}`);
183
+ return;
184
+ }
185
+ switch (key) {
186
+ case "clip-entities.source":
187
+ assertId(value);
188
+ if (!Object.hasOwn(rows["media-entities"], value) && !Object.hasOwn(rows["caption-entities"], value)) throw new Error("Invalid Clip source");
189
+ break;
190
+ case "clip-entities.anchor": {
191
+ assertTimeAnchor(value);
192
+ const anchor = value;
193
+ if (Object.hasOwn(rows["timeline-entities"], anchor.to)) {
194
+ if (anchor.offsetMs < 0) throw new Error("Absolute offset must be nonnegative");
195
+ break;
196
+ }
197
+ requireEntity(rows, "clip-entities", anchor.to);
198
+ const visited = new Set([id]);
199
+ for (let next = anchor.to; typeof next === "string";) {
200
+ if (visited.has(next)) throw new Error("Time anchor cycle");
201
+ visited.add(next);
202
+ const clip = rows["clip-entities"][next];
203
+ next = isRecord(clip) && isRecord(clip.anchor) ? clip.anchor.to : void 0;
204
+ }
205
+ break;
206
+ }
207
+ case "caption-entities.selection":
208
+ case "phonetic-script-entities.phoneme": {
209
+ if (key === "caption-entities.selection") assertCaptionContent(value);
210
+ else assertPhoneme(value);
211
+ const { audioScriptId, segmentId } = value;
212
+ requireSegment(rows, audioScriptId, segmentId);
177
213
  break;
178
214
  }
179
215
  }
180
216
  }
181
- /** Both ends of a caption's selection exist: the caption and the script it selects from. */
217
+ /** Both ends of a caption's selection exist: the caption and the segment it selects. */
182
218
  function assertCaptionEndpoints(rows, selection) {
183
219
  requireEntity(rows, "caption-entities", selection.captionId);
184
- requireEntity(rows, "audio-script-entities", selection.audioScriptId);
220
+ requireSegment(rows, selection.audioScriptId, selection.segmentId);
221
+ }
222
+ /** Both ends of a phoneme exist: the PhoneticScript and the segment it is written for. */
223
+ function assertPhonemeEndpoints(rows, selection) {
224
+ requireEntity(rows, "phonetic-script-entities", selection.phoneticScriptId);
225
+ requireSegment(rows, selection.audioScriptId, selection.segmentId);
185
226
  }
186
- /** A caption selects a nonempty run of its script's current body, in UTF-16 offsets. */
187
- function assertCaptionSelection(rows, selection, body) {
227
+ /**
228
+ * A selection of a segment's current text: the whole segment, or a run of it in UTF-16 offsets
229
+ * with both ends stated. What it selects is never empty.
230
+ */
231
+ function assertSegmentRange(range, text) {
232
+ if (range.start === void 0 !== (range.end === void 0)) throw new Error("A run inside a segment states both its start and its end");
233
+ if (range.start === void 0 || range.end === void 0) {
234
+ if (!text.length) throw new Error("An empty segment cannot be selected");
235
+ return;
236
+ }
237
+ assertTextOffset(text, range.start);
238
+ assertTextOffset(text, range.end);
239
+ if (range.start >= range.end) throw new Error("Expected a nonempty range in the current segment");
240
+ }
241
+ /** A caption selects a segment of its script, or a nonempty run of it. */
242
+ function assertCaptionSelection(rows, selection, text) {
188
243
  assertCaptionEndpoints(rows, selection);
189
- assertTextOffset(body, selection.start);
190
- assertTextOffset(body, selection.end);
191
- if (selection.start >= selection.end) throw new Error("Expected a nonempty range in the current body");
244
+ assertSegmentRange(selection, text);
245
+ }
246
+ /** A PhoneticScript is written for a segment of its script, or a nonempty run of it, as a Caption selects one. */
247
+ function assertPhonemeSelection(rows, selection, text) {
248
+ assertPhonemeEndpoints(rows, selection);
249
+ assertSegmentRange(selection, text);
250
+ if (selection.phonemeScript !== void 0 && typeof selection.phonemeScript !== "string") throw new Error("phonemeScript is a string");
192
251
  }
193
252
  /** A caption's text comes from a selection of a script or from its own text, never both. */
194
253
  function assertCaptionSource(caption, selected) {
@@ -261,8 +320,9 @@ function replacementPatch(before, after) {
261
320
  /**
262
321
  * Admission of one whole change, asked once before it is published. Only what the change touched
263
322
  * is checked, so an unrelated edit still lands in a merged document that holds invalid rows.
264
- * Containment and caption selections are checked by whoever holds the tree and the script bodies,
265
- * with `assertContainTree` and `assertCaptionSelection`.
323
+ * Single-valued relations are fields of their owner and are checked when the owner's field is
324
+ * touched. Containment and caption ranges are checked by whoever holds the tree and the script
325
+ * bodies, with `assertContainTree` and `assertCaptionSelection`.
266
326
  */
267
327
  function assertChangeAdmitted(change) {
268
328
  const { before, after } = change;
@@ -278,18 +338,28 @@ function assertChangeAdmitted(change) {
278
338
  if (!isRecord(value)) throw new Error("Expected an entity object");
279
339
  assertEntityPatch(table, Object.fromEntries([...fields].map((field) => [field, replacementPatch(old[field], value[field])])), old);
280
340
  }
281
- const marker = value.seqMarker;
341
+ const row = value;
342
+ for (const field of referenceFields[table] ?? []) if (!old || fields.has(field)) assertReferenceAdmitted(after, table, id, field, row[field]);
343
+ const marker = row.seqMarker;
282
344
  if (table === "clip-entities" && (!old || fields.has("seqMarker")) && marker?.outMs <= marker?.inMs) throw new Error("Clip window must be nonempty");
345
+ if (table === "clip-entities" && (!old || fields.has("seqMarker") || fields.has("source"))) assertFillsWithWholeSource(after, marker, row.source);
283
346
  }
284
347
  if (change.entities.has("timeline-entities") && Object.keys(after["timeline-entities"]).length > 1) throw new Error("Document already has a Timeline");
285
- for (const [table, ids] of change.relations) {
286
- if (table === "caption-content-relations") continue;
287
- for (const id of ids) {
288
- const value = after[table][id];
289
- if (value !== void 0) assertRelationAdmitted(after, table, id, value);
290
- }
348
+ for (const [table, ids] of change.relations) for (const id of ids) {
349
+ const value = after[table][id];
350
+ if (value !== void 0) assertRelationAdmitted(after, table, id, value);
291
351
  }
292
352
  }
353
+ /**
354
+ * A Clip that fills the Timeline plays its whole source at 1×: the player loops a short source and
355
+ * cuts a long one at the Timeline's end (RFC 02 §3.3), so a window or speed of its own has no meaning.
356
+ */
357
+ function assertFillsWithWholeSource(rows, marker, source) {
358
+ if (marker?.fillTimeline !== true) return;
359
+ const media = typeof source === "string" ? rows["media-entities"][source] : void 0;
360
+ const durationMs = typeof media?.durationMs === "number" ? media.durationMs : void 0;
361
+ if (marker.inMs !== 0 || marker.rate !== void 0 && marker.rate !== 1 || durationMs !== void 0 && marker.outMs !== durationMs) throw new Error(`A Clip that fills the Timeline plays its whole source at 1×: inMs 0, outMs ${durationMs ?? "its length"}, no rate; the Timeline's end cuts it`);
362
+ }
293
363
  //#endregion
294
364
  //#region src/dsl/projection/types.ts
295
365
  /** Unsupported legacy representations fail explicitly; recoverable CRDT state is returned as diagnostics. */
@@ -331,9 +401,9 @@ function resolveDslLayout(snapshot) {
331
401
  const clips = [];
332
402
  for (const child of node.children) {
333
403
  const id = child.entityId, clip = snapshot["clip-entities"][id];
334
- const sourceId = snapshot["clip-source-relations"][`cs:${id}`]?.sourceId;
335
- const media = sourceId === void 0 ? void 0 : snapshot["media-entities"][sourceId];
336
- const caption = sourceId === void 0 ? void 0 : snapshot["caption-entities"][sourceId];
404
+ const sourceId = clip.source;
405
+ const media = snapshot["media-entities"][sourceId];
406
+ const caption = snapshot["caption-entities"][sourceId];
337
407
  const marker = clip.seqMarker;
338
408
  let inMs = marker.inMs, outMs = marker.outMs;
339
409
  const present = media !== void 0 || caption !== void 0;
@@ -347,7 +417,7 @@ function resolveDslLayout(snapshot) {
347
417
  const rate = marker.rate ?? 1;
348
418
  const duration = present ? Math.max(0, Math.round((outMs - inMs) / rate)) : 0;
349
419
  if (!Number.isSafeInteger(duration)) projectionError("time-overflow", `clip-entities/${id}`, "Duration exceeds safe integer milliseconds");
350
- const anchor = snapshot["time-anchor-relations"][`ta:${id}`];
420
+ const anchor = clip.anchor;
351
421
  const placement = {
352
422
  clipId: id,
353
423
  trackId: node.entityId,
@@ -384,7 +454,7 @@ function resolveDslLayout(snapshot) {
384
454
  }
385
455
  seen.set(current, path.length);
386
456
  path.push(current);
387
- current = snapshot["time-anchor-relations"][`ta:${current}`]?.to;
457
+ current = snapshot["clip-entities"][current]?.anchor?.to;
388
458
  }
389
459
  for (const member of path) done.add(member);
390
460
  }
@@ -393,7 +463,7 @@ function resolveDslLayout(snapshot) {
393
463
  const position = (id) => {
394
464
  const known = positions.get(id);
395
465
  if (known) return known;
396
- const clip = result.clips[id], anchor = snapshot["time-anchor-relations"][`ta:${id}`];
466
+ const clip = result.clips[id], anchor = snapshot["clip-entities"][id]?.anchor;
397
467
  let value;
398
468
  if (anchor && clip.mode === "anchored" && !cycle.has(id) && result.clips[anchor.to]) {
399
469
  const parent = position(anchor.to);
@@ -500,14 +570,6 @@ function resolveDslLayout(snapshot) {
500
570
  const a = track.clips[i];
501
571
  for (const b of track.clips.slice(i + 1)) if (overlap(a, b)) diagnose("unresolved-overlap", b.clipId, `Overlaps ${a.clipId}; fixed positions, anchors and complete windows are retained`);
502
572
  }
503
- for (const [id, relation] of Object.entries(snapshot["caption-alignment-relations"])) {
504
- const media = snapshot["media-entities"][relation.mediaId];
505
- if (!media || media.kind === "image" || relation.endMs <= relation.startMs || relation.endMs > media.durationMs) result.diagnostics.push({
506
- code: "invalid-caption-alignment",
507
- path: `caption-alignment-relations/${id}`,
508
- message: "No valid source alignment; the placed display window is retained"
509
- });
510
- }
511
573
  return result;
512
574
  }
513
575
  function overlap(a, b) {
@@ -563,9 +625,10 @@ function timelineId(s) {
563
625
  /** Placement facts are authored only by explicit actions; projection never writes them back. */
564
626
  function placementMutations(s, clipId, placement) {
565
627
  if (placement.kind === "sequential") return [{
566
- type: "delete-relation",
567
- table: "time-anchor-relations",
568
- id: `ta:${clipId}`
628
+ type: "patch-entity",
629
+ table: "clip-entities",
630
+ id: clipId,
631
+ patch: { anchor: void 0 }
569
632
  }];
570
633
  const offset = placement.kind === "absolute" ? placement.startMs : placement.offsetMs;
571
634
  if (!Number.isSafeInteger(offset) || placement.kind === "absolute" && offset < 0) throw new Error("Invalid placement time");
@@ -578,27 +641,31 @@ function placementMutations(s, clipId, placement) {
578
641
  while (next && s["clip-entities"][next]) {
579
642
  if (visited.has(next)) throw new Error("Cyclic time anchor");
580
643
  visited.add(next);
581
- next = s["time-anchor-relations"][`ta:${next}`]?.to;
644
+ next = s["clip-entities"][next]?.anchor?.to;
582
645
  }
583
646
  fallback = Math.max(0, resolveDslLayout(s).clips[target].startMs + offset);
584
647
  }
585
648
  if (!Number.isSafeInteger(fallback)) throw new Error("Invalid fallback time");
586
649
  return [{
587
- type: "set-relation",
588
- table: "time-anchor-relations",
589
- value: {
590
- from: clipId,
650
+ type: "patch-entity",
651
+ table: "clip-entities",
652
+ id: clipId,
653
+ patch: { anchor: {
591
654
  to: target,
592
655
  offsetMs: offset,
593
656
  fallback_abs_ms: fallback
594
- }
657
+ } }
595
658
  }];
596
659
  }
660
+ /** Clips whose time anchor points at a Clip in `ids`, with that anchor. */
661
+ function anchoredTo(s, ids) {
662
+ return Object.entries(s["clip-entities"]).flatMap(([id, clip]) => clip.anchor && ids.has(clip.anchor.to) ? [id] : []);
663
+ }
597
664
  function detachChildren(s, ids) {
598
665
  const layout = resolveDslLayout(s);
599
- return Object.values(s["time-anchor-relations"]).flatMap((r) => ids.has(r.to) && !ids.has(r.from) && layout.clips[r.from] ? placementMutations(s, r.from, {
666
+ return anchoredTo(s, ids).flatMap((id) => !ids.has(id) && layout.clips[id] ? placementMutations(s, id, {
600
667
  kind: "absolute",
601
- startMs: layout.clips[r.from].startMs
668
+ startMs: layout.clips[id].startMs
602
669
  }) : []);
603
670
  }
604
671
  function deleteClips(s, ids, policy) {
@@ -608,9 +675,9 @@ function deleteClips(s, ids, policy) {
608
675
  const removed = new Set(ids);
609
676
  if (policy === "cascade") {
610
677
  const queue = [...ids];
611
- for (let i = 0; i < queue.length; i++) for (const r of Object.values(s["time-anchor-relations"])) if (r.to === queue[i] && s["clip-entities"][r.from] && !removed.has(r.from)) {
612
- removed.add(r.from);
613
- queue.push(r.from);
678
+ for (let i = 0; i < queue.length; i++) for (const id of anchoredTo(s, new Set([queue[i]]))) if (!removed.has(id)) {
679
+ removed.add(id);
680
+ queue.push(id);
614
681
  }
615
682
  }
616
683
  const mutations = policy === "detach" ? detachChildren(s, removed) : [];
@@ -620,14 +687,6 @@ function deleteClips(s, ids, policy) {
620
687
  entityId: id
621
688
  });
622
689
  mutations.push({
623
- type: "delete-relation",
624
- table: "time-anchor-relations",
625
- id: `ta:${id}`
626
- }, {
627
- type: "delete-relation",
628
- table: "clip-source-relations",
629
- id: `cs:${id}`
630
- }, {
631
690
  type: "delete-entity",
632
691
  table: "clip-entities",
633
692
  id
@@ -640,11 +699,10 @@ function deleteClips(s, ids, policy) {
640
699
  /** Batch edits validate against preceding prepared facts without touching the live document. */
641
700
  function updateClipsPlan(base, updates) {
642
701
  uniqueIds(updates.map((update) => update.clipId));
643
- const clips = { ...base["clip-entities"] }, anchors = { ...base["time-anchor-relations"] };
702
+ const clips = { ...base["clip-entities"] };
644
703
  const preview = {
645
704
  ...base,
646
- "clip-entities": clips,
647
- "time-anchor-relations": anchors
705
+ "clip-entities": clips
648
706
  };
649
707
  const mutations = [];
650
708
  for (const update of updates) {
@@ -660,9 +718,7 @@ function updateClipsPlan(base, updates) {
660
718
  if (clip.seqMarker.fillTimeline && (windowChanged || Object.hasOwn(marker, "rate"))) throw new Error("BGM trim and speed are not enabled");
661
719
  const next = mergeFields(clip, patch);
662
720
  if (windowChanged) {
663
- const sourceId = preview["clip-source-relations"][`cs:${update.clipId}`]?.sourceId;
664
- if (!sourceId) throw new Error("Missing Clip source");
665
- const source = requireSource(preview, sourceId, requireTrack(preview, trackOf(preview, update.clipId)).role);
721
+ const source = requireSource(preview, clip.source, requireTrack(preview, trackOf(preview, update.clipId)).role);
666
722
  validateWindow(next.seqMarker, "durationMs" in source ? source.durationMs : void 0);
667
723
  }
668
724
  if (Object.keys(patch).length) mutations.push({
@@ -676,8 +732,7 @@ function updateClipsPlan(base, updates) {
676
732
  trackOf(preview, update.clipId);
677
733
  const changes = placementMutations(preview, update.clipId, update.placement);
678
734
  mutations.push(...changes);
679
- for (const change of changes) if (change.type === "set-relation" && change.table === "time-anchor-relations") anchors[`ta:${update.clipId}`] = change.value;
680
- else if (change.type === "delete-relation") delete anchors[change.id];
735
+ for (const change of changes) if (change.type === "patch-entity" && change.table === "clip-entities") clips[update.clipId] = mergeFields(clips[update.clipId], change.patch);
681
736
  }
682
737
  }
683
738
  return mutations;
@@ -724,7 +779,10 @@ function addClipsPlan(s, input, ids) {
724
779
  marker.fillTimeline = true;
725
780
  }
726
781
  validateWindow(marker, duration);
727
- const clip = { seqMarker: marker };
782
+ const clip = {
783
+ seqMarker: marker,
784
+ source: source.id
785
+ };
728
786
  if (item.volume !== void 0) clip.volume = item.volume;
729
787
  const id = ids[offset];
730
788
  const result = [
@@ -735,14 +793,6 @@ function addClipsPlan(s, input, ids) {
735
793
  id,
736
794
  value: clip
737
795
  },
738
- {
739
- type: "set-relation",
740
- table: "clip-source-relations",
741
- value: {
742
- clipId: id,
743
- sourceId: source.id
744
- }
745
- },
746
796
  {
747
797
  type: "insert-node",
748
798
  entityId: id,
@@ -762,10 +812,9 @@ function moveClipsPlan(s, input) {
762
812
  if (!Number.isSafeInteger(input.index) || input.index < 0 || input.index > target.length) throw new Error("Invalid insertion index");
763
813
  for (const id of input.clipIds) {
764
814
  trackOf(s, id);
765
- if (s["time-anchor-relations"][`ta:${id}`]) throw new Error("Sequential reorder requires sequential Clips");
766
- const source = s["clip-source-relations"][`cs:${id}`];
767
- if (!source) throw new Error("Missing Clip source");
768
- requireSource(s, source.sourceId, requireTrack(s, input.trackId).role);
815
+ const clip = requireClip(s, id);
816
+ if (clip.anchor) throw new Error("Sequential reorder requires sequential Clips");
817
+ requireSource(s, clip.source, requireTrack(s, input.trackId).role);
769
818
  }
770
819
  const mutations = input.onAnchored === "keepAbsolute" ? detachChildren(s, moved) : [];
771
820
  const order = [...target];
@@ -783,7 +832,7 @@ function replaceSequencePlan(s, input, ids) {
783
832
  uniqueIds(input.clipIds);
784
833
  if (!input.clipIds.length || !input.clips.length) throw new Error("Replacement requires nonempty runs");
785
834
  const trackId = trackOf(s, input.clipIds[0]), order = children(s, trackId), index = order.indexOf(input.clipIds[0]);
786
- if (input.clipIds.some((id, i) => order[index + i] !== id || s["time-anchor-relations"][`ta:${id}`])) throw new Error("Expected an ordered contiguous sequential run");
835
+ if (input.clipIds.some((id, i) => order[index + i] !== id || s["clip-entities"][id]?.anchor)) throw new Error("Expected an ordered contiguous sequential run");
787
836
  if (input.clips.some((c) => c.placement && c.placement.kind !== "sequential")) throw new Error("Replacement Clips must be sequential");
788
837
  const mutations = addClipsPlan(s, {
789
838
  trackId,
@@ -791,7 +840,7 @@ function replaceSequencePlan(s, input, ids) {
791
840
  index
792
841
  }, ids);
793
842
  const removed = new Set(input.clipIds);
794
- const anchors = { ...s["time-anchor-relations"] };
843
+ const clips = { ...s["clip-entities"] };
795
844
  if (input.onAnchored === "remap") {
796
845
  const begin = resolveDslLayout(s).clips[input.clipIds[0]].startMs;
797
846
  const starts = [];
@@ -803,26 +852,30 @@ function replaceSequencePlan(s, input, ids) {
803
852
  const marker = creation.value.seqMarker;
804
853
  end += Math.round((marker.outMs - marker.inMs) / (marker.rate ?? 1));
805
854
  }
806
- for (const r of Object.values(s["time-anchor-relations"])) if (removed.has(r.to) && !removed.has(r.from)) {
807
- const slot = input.clipIds.indexOf(r.to);
808
- if (slot >= ids.length) continue;
855
+ for (const child of anchoredTo(s, removed)) {
856
+ const anchor = s["clip-entities"][child].anchor;
857
+ const slot = input.clipIds.indexOf(anchor.to);
858
+ if (removed.has(child) || slot >= ids.length) continue;
809
859
  const value = {
810
- from: r.from,
811
860
  to: ids[slot],
812
- offsetMs: r.offsetMs,
813
- fallback_abs_ms: Math.max(0, starts[slot] + r.offsetMs)
861
+ offsetMs: anchor.offsetMs,
862
+ fallback_abs_ms: Math.max(0, starts[slot] + anchor.offsetMs)
814
863
  };
815
864
  mutations.push({
816
- type: "set-relation",
817
- table: "time-anchor-relations",
818
- value
865
+ type: "patch-entity",
866
+ table: "clip-entities",
867
+ id: child,
868
+ patch: { anchor: value }
819
869
  });
820
- anchors[`ta:${r.from}`] = value;
870
+ clips[child] = {
871
+ ...clips[child],
872
+ anchor: value
873
+ };
821
874
  }
822
875
  }
823
876
  mutations.push(...deleteClips({
824
877
  ...s,
825
- "time-anchor-relations": anchors
878
+ "clip-entities": clips
826
879
  }, input.clipIds, "cascade"));
827
880
  return mutations;
828
881
  }
@@ -830,26 +883,18 @@ function replaceSourcePlan(s, clipId, input) {
830
883
  if (requireClip(s, clipId).seqMarker.fillTimeline) throw new Error("Use setBgm to replace a fill Clip");
831
884
  const source = sourcePlan(s, input, trackOf(s, clipId));
832
885
  validateWindow(input, "durationMs" in source.source ? source.source.durationMs : void 0);
833
- return [
834
- ...source.mutations,
835
- {
836
- type: "patch-entity",
837
- table: "clip-entities",
838
- id: clipId,
839
- patch: { seqMarker: {
886
+ return [...source.mutations, {
887
+ type: "patch-entity",
888
+ table: "clip-entities",
889
+ id: clipId,
890
+ patch: {
891
+ seqMarker: {
840
892
  inMs: input.inMs,
841
893
  outMs: input.outMs
842
- } }
843
- },
844
- {
845
- type: "set-relation",
846
- table: "clip-source-relations",
847
- value: {
848
- clipId,
849
- sourceId: source.id
850
- }
894
+ },
895
+ source: source.id
851
896
  }
852
- ];
897
+ }];
853
898
  }
854
899
  const produce = new Immer({
855
900
  autoFreeze: false,
@@ -3868,11 +3913,21 @@ function rootMap(doc, name) {
3868
3913
  const value = doc.getContainerById(id);
3869
3914
  return value?.kind() === "Map" ? value : void 0;
3870
3915
  }
3871
- function liveText(doc, scriptId, caption = false) {
3872
- const row = rootMap(doc, caption ? "caption-entities" : "audio-script-entities")?.get(scriptId);
3873
- if (!isContainer(row) || row.kind() !== "Map") return;
3874
- const body = row.get(caption ? "text" : "body");
3875
- return isContainer(body) && body.kind() === "Text" && !body.isDeleted() ? body : void 0;
3916
+ function asMap(value) {
3917
+ return isContainer(value) && value.kind() === "Map" ? value : void 0;
3918
+ }
3919
+ function asText(value) {
3920
+ return isContainer(value) && value.kind() === "Text" && !value.isDeleted() ? value : void 0;
3921
+ }
3922
+ /** The live Text a target names: a Caption's own, or a script segment's. */
3923
+ function liveText(doc, target) {
3924
+ if ("captionId" in target) return asText(asMap(rootMap(doc, "caption-entities")?.get(target.captionId))?.get("text"));
3925
+ const segments = asMap(rootMap(doc, "audio-script-entities")?.get(target.audioScriptId))?.get("segments");
3926
+ if (!isContainer(segments) || segments.kind() !== "MovableList") return;
3927
+ for (const item of segments.toArray()) {
3928
+ const segment = asMap(item);
3929
+ if (segment?.get("id") === target.segmentId) return asText(segment.get("text"));
3930
+ }
3876
3931
  }
3877
3932
  function readContainNodes(nodes) {
3878
3933
  const read = (nodes) => nodes.map((node) => ({
@@ -3882,19 +3937,56 @@ function readContainNodes(nodes) {
3882
3937
  }));
3883
3938
  return read(nodes);
3884
3939
  }
3940
+ /** What a selection holds now: its whole segment, or the run between its Cursors on that segment's Text. */
3885
3941
  function resolveContent(doc, content) {
3886
- const text = liveText(doc, content.audioScriptId);
3887
- if (!text || content.start.containerId() !== text.id || content.end.containerId() !== text.id) return;
3942
+ const text = liveText(doc, {
3943
+ audioScriptId: content.audioScriptId,
3944
+ segmentId: content.segmentId
3945
+ });
3946
+ if (!text) return;
3947
+ const whole = text.toString();
3948
+ if (content.start === void 0 || content.end === void 0) return {
3949
+ text: whole,
3950
+ start: 0,
3951
+ end: whole.length,
3952
+ bodyId: text.id
3953
+ };
3954
+ if (content.start.containerId() !== text.id || content.end.containerId() !== text.id) return;
3888
3955
  const start = doc.getCursorPos(content.start)?.offset;
3889
3956
  const end = doc.getCursorPos(content.end)?.offset;
3890
3957
  if (start === void 0 || end === void 0 || start > end) return;
3891
3958
  return {
3892
- text: text.toString().slice(start, end),
3959
+ text: whole.slice(start, end),
3893
3960
  start,
3894
3961
  end,
3895
3962
  bodyId: text.id
3896
3963
  };
3897
3964
  }
3965
+ function richTextOf(text) {
3966
+ return {
3967
+ bodyId: text.id,
3968
+ plainText: text.toString(),
3969
+ delta: text.toDelta().flatMap((run) => typeof run.insert === "string" ? [{
3970
+ insert: run.insert,
3971
+ ...run.attributes ? { attributes: run.attributes } : {}
3972
+ }] : [])
3973
+ };
3974
+ }
3975
+ /** Optional relation fields read apart from their row: an unreadable one is dropped, the row is kept. */
3976
+ const optionalReferences = {
3977
+ "clip-entities": ["anchor"],
3978
+ "caption-entities": ["selection"]
3979
+ };
3980
+ /** The entity a single-valued relation field points at, by the field's path. */
3981
+ function referenceTargets(table, row) {
3982
+ const nested = (field, key) => isRecord(row[field]) ? row[field][key] : void 0;
3983
+ switch (table) {
3984
+ case "clip-entities": return [["source", row.source], ["anchor/to", nested("anchor", "to")]];
3985
+ case "caption-entities": return [["selection/audioScriptId", nested("selection", "audioScriptId")]];
3986
+ case "phonetic-script-entities": return [["phoneme/audioScriptId", nested("phoneme", "audioScriptId")]];
3987
+ default: return [];
3988
+ }
3989
+ }
3898
3990
  function readMedeoDsl(doc, state) {
3899
3991
  const diagnostics = [];
3900
3992
  const diagnostic = (code, path, error) => diagnostics.push({
@@ -3907,44 +3999,42 @@ function readMedeoDsl(doc, state) {
3907
3999
  const richText = {};
3908
4000
  for (const table of entityTables) for (const [id, raw] of Object.entries(state[table] ?? {})) try {
3909
4001
  assertId(id);
3910
- assertEntity(table, raw);
3911
- const v = raw;
4002
+ let v = raw;
4003
+ for (const field of optionalReferences[table] ?? []) {
4004
+ const value = v[field];
4005
+ if (!isInvalidAtomic(value)) continue;
4006
+ diagnostic("invalid-relation", `${table}/${id}/${field}`, value.reason);
4007
+ const { [field]: _dropped, ...rest } = v;
4008
+ v = rest;
4009
+ }
4010
+ assertEntity(table, v);
3912
4011
  assertResourceIdentity(table, id, v);
3913
- entities[table][id] = raw;
3914
- if (table === "audio-script-entities" || table === "caption-entities" && v.text !== void 0) {
3915
- const text = liveText(doc, id, table === "caption-entities");
3916
- if (!text) throw new Error("Missing live LoroText body");
3917
- richText[id] = {
3918
- bodyId: text.id,
3919
- plainText: text.toString(),
3920
- delta: text.toDelta().flatMap((run) => typeof run.insert === "string" ? [{
3921
- insert: run.insert,
3922
- ...run.attributes ? { attributes: run.attributes } : {}
3923
- }] : [])
3924
- };
3925
- }
4012
+ if (table === "phonetic-script-entities") assertPhoneticScript(void 0, v);
4013
+ const texts = [];
4014
+ if (table === "audio-script-entities") for (const segment of v.segments) texts.push([segment.id, liveText(doc, {
4015
+ audioScriptId: id,
4016
+ segmentId: segment.id
4017
+ })]);
4018
+ if (table === "caption-entities" && v.text !== void 0) texts.push([id, liveText(doc, { captionId: id })]);
4019
+ for (const [, text] of texts) if (!text) throw new Error("Missing live LoroText");
4020
+ entities[table][id] = v;
4021
+ for (const [key, text] of texts) richText[key] = richTextOf(text);
3926
4022
  } catch (error) {
3927
4023
  delete entities[table][id];
3928
4024
  diagnostic("invalid-entity", `${table}/${id}`, error);
3929
4025
  }
3930
4026
  const entityIds = new Set(entityTables.flatMap((t) => Object.keys(entities[t])));
4027
+ for (const table of entityTables) for (const [id, row] of Object.entries(entities[table])) for (const [path, target] of referenceTargets(table, row)) if (typeof target === "string" && !entityIds.has(target)) diagnostic("dangling-reference", `${table}/${id}/${path}`, `Missing entity ${target}`);
3931
4028
  for (const table of relationTables) for (const [id, value] of Object.entries(state[table] ?? {})) try {
3932
4029
  if (isInvalidAtomic(value)) throw new Error(value.reason);
3933
4030
  assertRelation(table, value);
3934
4031
  assertRelationIdentity(table, id, value);
3935
4032
  relations[table][id] = value;
3936
4033
  for (const key of [
3937
- "clipId",
3938
- "sourceId",
3939
- "from",
3940
- "to",
3941
- "captionId",
3942
- "audioScriptId",
3943
- "mediaId",
3944
4034
  "recordingId",
3945
4035
  "voiceId",
3946
4036
  "entityId",
3947
- "assetId"
4037
+ "sourceId"
3948
4038
  ]) {
3949
4039
  const endpoint = value[key];
3950
4040
  if (typeof endpoint === "string" && !entityIds.has(endpoint)) diagnostic("dangling-reference", `${table}/${id}/${key}`, `Missing entity ${endpoint}`);
@@ -3972,22 +4062,33 @@ function readMedeoDsl(doc, state) {
3972
4062
  }];
3973
4063
  });
3974
4064
  const captionText = {};
3975
- for (const [id, content] of Object.entries(relations["caption-content-relations"])) try {
3976
- const resolved = resolveContent(doc, content);
3977
- if (resolved) captionText[content.captionId] = resolved;
3978
- else diagnostic("invalid-caption-content", `caption-content-relations/${id}`, "Cursor does not resolve in the current body");
3979
- } catch (error) {
3980
- diagnostic("invalid-caption-content", `caption-content-relations/${id}`, error);
3981
- }
3982
4065
  for (const [id, caption] of Object.entries(entities["caption-entities"])) {
3983
- const hasReference = Object.hasOwn(relations["caption-content-relations"], `cc:${id}`) || Object.hasOwn(state["caption-content-relations"] ?? {}, `cc:${id}`);
3984
- if (hasReference && caption.text !== void 0) diagnostic("multiple-caption-sources", `caption-entities/${id}`, "Content reference takes precedence over inline text");
3985
- if (!hasReference && caption.text !== void 0 && richText[id]) captionText[id] = {
3986
- text: caption.text,
3987
- start: 0,
3988
- end: caption.text.length,
3989
- bodyId: richText[id].bodyId
3990
- };
4066
+ if (caption.text !== void 0) {
4067
+ if (Object.hasOwn(state["caption-entities"]?.[id] ?? {}, "selection")) diagnostic("multiple-caption-sources", `caption-entities/${id}`, "Inline text takes precedence over the content reference");
4068
+ if (richText[id]) captionText[id] = {
4069
+ text: caption.text,
4070
+ start: 0,
4071
+ end: caption.text.length,
4072
+ bodyId: richText[id].bodyId
4073
+ };
4074
+ continue;
4075
+ }
4076
+ if (!caption.selection) continue;
4077
+ try {
4078
+ const resolved = resolveContent(doc, caption.selection);
4079
+ if (resolved) captionText[id] = resolved;
4080
+ else diagnostic("invalid-caption-content", `caption-entities/${id}/selection`, "Selection does not resolve in its current segment");
4081
+ } catch (error) {
4082
+ diagnostic("invalid-caption-content", `caption-entities/${id}/selection`, error);
4083
+ }
4084
+ }
4085
+ const phoneticText = {};
4086
+ for (const [id, phonetic] of Object.entries(entities["phonetic-script-entities"])) try {
4087
+ const resolved = resolveContent(doc, phonetic.phoneme);
4088
+ if (resolved) phoneticText[id] = resolved;
4089
+ else diagnostic("invalid-phoneme", `phonetic-script-entities/${id}/phoneme`, "Selection does not resolve in its current segment");
4090
+ } catch (error) {
4091
+ diagnostic("invalid-phoneme", `phonetic-script-entities/${id}/phoneme`, error);
3991
4092
  }
3992
4093
  return freezeSnapshot({
3993
4094
  ...entities,
@@ -3995,6 +4096,7 @@ function readMedeoDsl(doc, state) {
3995
4096
  "contain-relations": validTree(treeNodes),
3996
4097
  richText,
3997
4098
  captionText,
4099
+ phoneticText,
3998
4100
  diagnostics
3999
4101
  });
4000
4102
  }
@@ -4007,6 +4109,19 @@ function freezeSnapshot(value) {
4007
4109
  }
4008
4110
  //#endregion
4009
4111
  //#region src/dsl/document/draft.ts
4112
+ function textKey(target) {
4113
+ return "captionId" in target ? JSON.stringify(["caption", target.captionId]) : JSON.stringify([
4114
+ "segment",
4115
+ target.audioScriptId,
4116
+ target.segmentId
4117
+ ]);
4118
+ }
4119
+ /** Rows holding a segment selection, by the field that holds it (D181). */
4120
+ const selectionField = {
4121
+ "caption-entities": "selection",
4122
+ "phonetic-script-entities": "phoneme"
4123
+ };
4124
+ const isSelectionTable = (table) => Object.hasOwn(selectionField, table);
4010
4125
  const entities = new Set(entityTables);
4011
4126
  const relations = new Set(relationTables);
4012
4127
  const reserved = new Set([
@@ -4039,8 +4154,10 @@ var TransactionDraft = class {
4039
4154
  entities = /* @__PURE__ */ new Map();
4040
4155
  relations = /* @__PURE__ */ new Map();
4041
4156
  deleted = /* @__PURE__ */ new Set();
4157
+ /** Texts this write edits, by {@link textKey}, as its edits so far leave them. */
4042
4158
  textPreviews = /* @__PURE__ */ new Map();
4043
- dirtyCaptions = /* @__PURE__ */ new Set();
4159
+ /** Selections the draft wrote, by table then row; they are replayed natively in order with text edits. */
4160
+ dirty = /* @__PURE__ */ new Map();
4044
4161
  removedCaptionTexts = /* @__PURE__ */ new Set();
4045
4162
  treeChanged = false;
4046
4163
  active = true;
@@ -4071,13 +4188,30 @@ var TransactionDraft = class {
4071
4188
  close() {
4072
4189
  this.active = false;
4073
4190
  }
4191
+ dirtySelections() {
4192
+ return [...this.dirty].flatMap(([table, ids]) => [...ids].map((id) => [table, id]));
4193
+ }
4194
+ clearDirtySelections() {
4195
+ this.dirty.clear();
4196
+ }
4197
+ /** Whether this write edits any segment of a script. */
4198
+ usesScript(scriptId) {
4199
+ const prefix = JSON.stringify(["segment", scriptId]).slice(0, -1) + ",";
4200
+ return [...this.textPreviews.keys()].some((key) => key.startsWith(prefix));
4201
+ }
4074
4202
  wrap(value, path) {
4075
4203
  if (!value || typeof value !== "object" || value instanceof Cursor) return value;
4076
4204
  const cached = this.wrappers.get(value);
4077
4205
  if (cached) return cached;
4078
4206
  const proxy = new Proxy(value, {
4079
4207
  get: (target, key) => this.run(() => {
4080
- if ((path[0] === "audio-script-entities" && key === "body" || path[0] === "caption-entities" && key === "text") && path.length === 2 && this.textPreviews.has(path[1])) return this.textPreviews.get(path[1]);
4208
+ if (key === "text") {
4209
+ const preview = path[0] === "caption-entities" && path.length === 2 ? this.textPreviews.get(textKey({ captionId: path[1] })) : path[0] === "audio-script-entities" && path[2] === "segments" && path.length === 4 ? this.textPreviews.get(textKey({
4210
+ audioScriptId: path[1],
4211
+ segmentId: String(target.id)
4212
+ })) : void 0;
4213
+ if (preview !== void 0) return preview;
4214
+ }
4081
4215
  if (!path.length && key === "contain-relations" && target[key] === void 0) target[key] = [];
4082
4216
  return this.wrap(Reflect.get(target, key), [...path, String(key)]);
4083
4217
  }),
@@ -4132,24 +4266,27 @@ var TransactionDraft = class {
4132
4266
  if (path.length === 1 && (remove || next === void 0)) this.deleted.add(lifetime);
4133
4267
  else if (this.deleted.has(lifetime)) throw new Error("Recreate an entity in a separate transaction");
4134
4268
  if (table === "audio-script-entities") {
4135
- if (path.length === 2 && key === "body" && (initial || this.textPreviews.has(rowId))) throw new Error("Edit body through the Text adapter");
4136
- if (path.length === 1 && initial && isRecord(next) && next.body !== initial.body) throw new Error("Edit body through the Text adapter");
4137
- if (path.length === 1 && this.textPreviews.has(rowId)) throw new Error("Cannot delete or replace a body used in this transaction");
4269
+ if ((path.length === 2 && key === "segments" || path[2] === "segments" || path.length === 1 && initial && isRecord(next) && !sameSegments(next.segments, initial.segments)) && (initial || this.usesScript(rowId))) throw new Error("Change segments with segment mutations and edit their text through the Text adapter");
4270
+ if (path.length === 1 && this.usesScript(rowId)) throw new Error("Cannot delete or replace a Text used in this transaction");
4271
+ }
4272
+ if (isSelectionTable(table)) {
4273
+ const field = selectionField[table];
4274
+ if (path.length === 1 && isRecord(next) && Object.hasOwn(next, field) || path.length === 2 && key === field || path[2] === field) {
4275
+ let ids = this.dirty.get(table);
4276
+ if (!ids) this.dirty.set(table, ids = /* @__PURE__ */ new Set());
4277
+ ids.add(rowId);
4278
+ }
4138
4279
  }
4139
4280
  if (table === "caption-entities") {
4140
4281
  const changesText = path.length === 1 || path.length === 2 && key === "text";
4141
4282
  const nextText = path.length === 1 ? isRecord(next) ? next.text : void 0 : next;
4142
4283
  if (changesText) {
4143
- if (this.textPreviews.has(rowId)) throw new Error("Cannot delete or replace a body used in this transaction");
4284
+ if (this.textPreviews.has(textKey({ captionId: rowId }))) throw new Error("Cannot delete or replace a Text used in this transaction");
4144
4285
  if (nextText !== void 0 && (this.removedCaptionTexts.has(rowId) || initial?.text !== void 0 && nextText !== initial.text)) throw new Error("Edit Caption text through the Text adapter");
4145
4286
  if (initial?.text !== void 0 && nextText === void 0) this.removedCaptionTexts.add(rowId);
4146
4287
  }
4147
4288
  }
4148
- if (initial && [
4149
- "media-entities",
4150
- "asset-entities",
4151
- "voice-entities"
4152
- ].includes(table)) {
4289
+ if (initial && ["media-entities", "voice-entities"].includes(table)) {
4153
4290
  for (const field of [
4154
4291
  "system",
4155
4292
  "key",
@@ -4161,7 +4298,7 @@ var TransactionDraft = class {
4161
4298
  let fields = rows.get(rowId);
4162
4299
  if (!fields) rows.set(rowId, fields = /* @__PURE__ */ new Set());
4163
4300
  if (path.length === 1) for (const field of [...Object.keys(initial ?? {}), ...Object.keys(isRecord(next) ? next : {})]) {
4164
- if ((field === "body" && table === "audio-script-entities" || field === "text" && table === "caption-entities") && initial && isRecord(next) && next[field] === initial[field]) continue;
4301
+ if (initial && isRecord(next) && (field === "segments" && table === "audio-script-entities" && sameSegments(next[field], initial[field]) || field === "text" && table === "caption-entities" && next[field] === initial[field])) continue;
4165
4302
  fields.add(field);
4166
4303
  }
4167
4304
  else fields.add(path[2] ?? key);
@@ -4169,10 +4306,12 @@ var TransactionDraft = class {
4169
4306
  let ids = this.relations.get(table);
4170
4307
  if (!ids) this.relations.set(table, ids = /* @__PURE__ */ new Set());
4171
4308
  ids.add(rowId);
4172
- if (table === "caption-content-relations") this.dirtyCaptions.add(rowId);
4173
4309
  }
4174
4310
  }
4175
4311
  };
4312
+ function sameSegments(a, b) {
4313
+ return JSON.stringify(a) === JSON.stringify(b);
4314
+ }
4176
4315
  //#endregion
4177
4316
  //#region src/dsl/document/draft-operations.ts
4178
4317
  function nodeIndex(nodes) {
@@ -4224,6 +4363,7 @@ function changeTree(nodes, mutation, rows) {
4224
4363
  }
4225
4364
  //#endregion
4226
4365
  //#region src/dsl/document/mutations.ts
4366
+ const resourceTables = ["media-entities", "voice-entities"];
4227
4367
  /** Mutations are a convenience vocabulary over the same guarded draft and native command queue. */
4228
4368
  function applyMutations(tx, mutations) {
4229
4369
  const rows = tx.draft;
@@ -4233,11 +4373,7 @@ function applyMutations(tx, mutations) {
4233
4373
  case "create-entity": {
4234
4374
  assertEntity(m.table, m.value);
4235
4375
  const old = rows[m.table][m.id];
4236
- if (old && ![
4237
- "media-entities",
4238
- "voice-entities",
4239
- "asset-entities"
4240
- ].includes(m.table)) throw new Error(`Entity already exists: ${m.id}`);
4376
+ if (old && !resourceTables.includes(m.table)) throw new Error(`Entity already exists: ${m.id}`);
4241
4377
  if (old) {
4242
4378
  assertEntityPatch(m.table, m.value, old);
4243
4379
  applyPatch(old, m.value);
@@ -4247,12 +4383,15 @@ function applyMutations(tx, mutations) {
4247
4383
  case "patch-entity": {
4248
4384
  const current = rows[m.table][m.id];
4249
4385
  if (!current) throw new Error(`Missing entity: ${m.id}`);
4250
- assertEntityPatch(m.table, m.patch, current);
4251
- if (m.table === "clip-entities" && Object.hasOwn(m.patch, "seqMarker")) {
4252
- const { seqMarker, ...rest } = m.patch;
4253
- current.seqMarker = mergeFields(current.seqMarker, seqMarker ?? {});
4254
- applyPatch(current, rest);
4255
- } else applyPatch(current, m.patch);
4386
+ const patch = m.patch;
4387
+ assertEntityPatch(m.table, patch, current);
4388
+ const atomic = atomicFields[m.table] ?? [];
4389
+ const next = patchRow(m.table, current, patch);
4390
+ const ordinary = {};
4391
+ for (const key of Object.keys(patch)) if (!atomic.includes(key)) ordinary[key] = patch[key];
4392
+ else if (next[key] === void 0) delete current[key];
4393
+ else current[key] = next[key];
4394
+ applyPatch(current, ordinary);
4256
4395
  break;
4257
4396
  }
4258
4397
  case "delete-entity":
@@ -4261,18 +4400,27 @@ function applyMutations(tx, mutations) {
4261
4400
  case "set-relation": {
4262
4401
  assertRelation(m.table, m.value);
4263
4402
  const id = relationId(m.table, m.value);
4264
- if (m.table === "time-anchor-relations" || m.table === "caption-content-relations") tx.draft[m.table][id] = m.value;
4265
- else {
4266
- rows[m.table][id] ??= {};
4267
- applyPatch(rows[m.table][id], m.value);
4268
- }
4403
+ rows[m.table][id] ??= {};
4404
+ applyPatch(rows[m.table][id], withSourceDigest(rows, m.table, m.value));
4269
4405
  break;
4270
4406
  }
4271
4407
  case "delete-relation":
4272
4408
  delete rows[m.table][m.id];
4273
4409
  break;
4410
+ case "insert-segment":
4411
+ tx.insertSegment(m.audioScriptId, m.segmentId, m.text, m.index, m.startMs === void 0 && m.endMs === void 0 ? void 0 : {
4412
+ startMs: m.startMs,
4413
+ endMs: m.endMs
4414
+ });
4415
+ break;
4416
+ case "move-segment":
4417
+ tx.moveSegment(m.audioScriptId, m.segmentId, m.index);
4418
+ break;
4419
+ case "delete-segment":
4420
+ tx.deleteSegment(m.audioScriptId, m.segmentId);
4421
+ break;
4274
4422
  case "edit-text":
4275
- tx.editText(m.audioScriptId, m.edits, m.expectedBodyId);
4423
+ tx.editText(m.audioScriptId, m.segmentId, m.edits, m.expectedBodyId);
4276
4424
  break;
4277
4425
  case "edit-caption-text":
4278
4426
  tx.editCaptionText(m.captionId, m.edits, m.expectedBodyId);
@@ -4280,10 +4428,32 @@ function applyMutations(tx, mutations) {
4280
4428
  case "set-caption-content":
4281
4429
  tx.setCaptionContent(m);
4282
4430
  break;
4431
+ case "set-phoneme":
4432
+ tx.setPhoneme(m);
4433
+ break;
4283
4434
  default: changeTree(tx.draft["contain-relations"], m, tx.draft);
4284
4435
  }
4285
4436
  }
4286
4437
  }
4438
+ /**
4439
+ * A derived-from records its source's digest (D177): the one the writer names, otherwise the
4440
+ * source's digest as this transaction leaves it. A source with no digest records none.
4441
+ */
4442
+ function withSourceDigest(rows, table, value) {
4443
+ if (table !== "derived-from-relations") return value;
4444
+ const relation = value;
4445
+ if (relation.sourceDigest !== void 0) return value;
4446
+ const sourceDigest = digestOf(rows, relation.sourceId);
4447
+ return sourceDigest === void 0 ? value : {
4448
+ ...relation,
4449
+ sourceDigest
4450
+ };
4451
+ }
4452
+ /** A source's digest is its file's: a PhoneticScript's or a medium's Media File `digest`. */
4453
+ function digestOf(rows, entityId) {
4454
+ const digest = (rows["phonetic-script-entities"][entityId] ?? rows["media-entities"][entityId])?.digest;
4455
+ return typeof digest === "string" ? digest : void 0;
4456
+ }
4287
4457
  //#endregion
4288
4458
  //#region src/dsl/document/native-text.ts
4289
4459
  function applyTextEdits(text, edits) {
@@ -4295,11 +4465,7 @@ function applyTextEdits(text, edits) {
4295
4465
  text.delete(edit.at, edit.length);
4296
4466
  break;
4297
4467
  case "mark":
4298
- if (edit.key === "pauseMs" && edit.value === 0) text.unmark({
4299
- start: edit.start,
4300
- end: edit.end
4301
- }, edit.key);
4302
- else text.mark({
4468
+ text.mark({
4303
4469
  start: edit.start,
4304
4470
  end: edit.end
4305
4471
  }, edit.key, edit.value);
@@ -4317,13 +4483,15 @@ function applyTextEdits(text, edits) {
4317
4483
  }
4318
4484
  //#endregion
4319
4485
  //#region src/dsl/document/validation.ts
4320
- /** One relation row by the DSL's rules; caption content must also resolve in the live body. */
4321
- function validateRelation(doc, state, table, id, value) {
4322
- assertRelationAdmitted(state, table, id, value);
4323
- if (table !== "caption-content-relations") return;
4324
- const resolved = resolveContent(doc, value);
4325
- if (!resolved || resolved.start === resolved.end) throw new Error("Expected a nonempty range in the current body");
4326
- captionContentCodec.encode(value);
4486
+ /**
4487
+ * A selection the draft wrote whole, by the DSL's rules: its segment must exist and what it
4488
+ * selects must not be empty. A run's Cursors must lie on that segment's live Text.
4489
+ */
4490
+ function validateSelection(doc, state, table, id, value) {
4491
+ assertReferenceAdmitted(state, table, id, selectionField[table], value);
4492
+ const selection = value;
4493
+ if (!(selection.start === void 0 ? requireSegment(state, selection.audioScriptId, selection.segmentId).text : resolveContent(doc, selection)?.text)) throw new Error("Expected a nonempty selection of the current segment");
4494
+ (table === "caption-entities" ? captionContentCodec : phonemeCodec).encode(selection);
4327
4495
  }
4328
4496
  /**
4329
4497
  * The DSL's admission of the whole draft, then what only this document can check: containment
@@ -4355,6 +4523,14 @@ function validateDraft(tracked) {
4355
4523
  }
4356
4524
  //#endregion
4357
4525
  //#region src/dsl/document/transact.ts
4526
+ const codecs = {
4527
+ "caption-entities": captionContentCodec,
4528
+ "phonetic-script-entities": phonemeCodec
4529
+ };
4530
+ function entityRow(doc, table, id) {
4531
+ const row = doc.getMap(table).get(id);
4532
+ return isContainer(row) && row.kind() === "Map" ? row : void 0;
4533
+ }
4358
4534
  /** Serialization happens before preparing any writes, so invalid/cyclic audit data cannot partly commit. */
4359
4535
  function auditOptions(audit) {
4360
4536
  if (!audit || typeof audit.semantic_op !== "string" || !audit.semantic_op.length || !Object.hasOwn(audit, "payload")) throw new Error("A structured DSL audit is required");
@@ -4373,8 +4549,8 @@ const nativeHookSupport = /* @__PURE__ */ new WeakMap();
4373
4549
  /**
4374
4550
  * Every write relies on the patched Mirror's `applyNative` (patches/loro-mirror@2.3.3.patch). An
4375
4551
  * unpatched Mirror ignores the option and commits only the draft, silently dropping Text, ranges
4376
- * and Caption relations. The patch runs the hook even for an empty draft without committing, so
4377
- * probe each Mirror class once on a scratch document and refuse before the first real write.
4552
+ * and selections. The patch runs the hook even for an empty draft without committing, so probe
4553
+ * each Mirror class once on a scratch document and refuse before the first real write.
4378
4554
  */
4379
4555
  function assertNativeHook(mirror) {
4380
4556
  const Impl = mirror.constructor;
@@ -4408,50 +4584,75 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4408
4584
  mirror.setState((raw) => {
4409
4585
  const tracked = new TransactionDraft(raw, before);
4410
4586
  setReject((error) => tracked.fail(error));
4411
- const useBody = (scriptId, caption = false) => {
4412
- assertId(scriptId);
4413
- const entity = requireEntity(tracked.draft, caption ? "caption-entities" : "audio-script-entities", scriptId);
4414
- if (tracked.deleted.has(scriptId)) throw new Error("Cannot use a deleted body");
4415
- const body = entity[caption ? "text" : "body"];
4416
- if (typeof body !== "string") throw new Error("Missing Text body");
4417
- if (caption && tracked.removedCaptionTexts.has(scriptId)) throw new Error("Cannot edit a removed Text");
4418
- tracked.textPreviews.set(scriptId, body);
4419
- return body;
4587
+ /** The current text of a Caption's own Text or a script segment, as this transaction leaves it so far. */
4588
+ const useText = (target) => {
4589
+ let text;
4590
+ if ("captionId" in target) {
4591
+ assertId(target.captionId);
4592
+ const entity = requireEntity(tracked.draft, "caption-entities", target.captionId);
4593
+ if (tracked.deleted.has(target.captionId)) throw new Error("Cannot use a deleted Text");
4594
+ if (tracked.removedCaptionTexts.has(target.captionId)) throw new Error("Cannot edit a removed Text");
4595
+ text = entity.text;
4596
+ } else {
4597
+ assertId(target.audioScriptId);
4598
+ if (tracked.deleted.has(target.audioScriptId)) throw new Error("Cannot use a deleted Text");
4599
+ text = requireSegment(tracked.draft, target.audioScriptId, target.segmentId).text;
4600
+ }
4601
+ if (typeof text !== "string") throw new Error("Missing Text");
4602
+ tracked.textPreviews.set(textKey(target), text);
4603
+ return text;
4420
4604
  };
4421
- const flushCaptions = () => {
4422
- for (const id of tracked.dirtyCaptions) {
4423
- const value = raw["caption-content-relations"][id];
4424
- if (value !== void 0) {
4425
- validateRelation(doc, raw, "caption-content-relations", id, value);
4426
- useBody(value.audioScriptId);
4605
+ const textBefore = (target) => {
4606
+ if ("captionId" in target) return before["caption-entities"][target.captionId]?.text;
4607
+ return before["audio-script-entities"][target.audioScriptId]?.segments?.find((segment) => segment.id === target.segmentId)?.text;
4608
+ };
4609
+ const flushSelections = () => {
4610
+ for (const [table, id] of tracked.dirtySelections()) {
4611
+ const row = raw[table][id];
4612
+ if (!row) continue;
4613
+ const field = selectionField[table];
4614
+ const value = row[field];
4615
+ const old = before[table][id]?.[field];
4616
+ if (isInvalidAtomic(value)) {
4617
+ if (isInvalidAtomic(old) && old.raw === value.raw) continue;
4618
+ throw new Error(`Invalid ${field}`);
4427
4619
  }
4620
+ if (value !== void 0) validateSelection(doc, raw, table, id, value);
4428
4621
  steps.push({
4429
- type: "caption",
4622
+ type: "selection",
4623
+ table,
4430
4624
  id,
4431
4625
  value: copyInput(value)
4432
4626
  });
4433
4627
  }
4434
- tracked.dirtyCaptions.clear();
4628
+ tracked.clearDirtySelections();
4435
4629
  };
4436
- const editBody = (scriptId, edits, expectedBodyId, caption) => {
4630
+ const editBody = (target, edits, expectedBodyId) => {
4437
4631
  tracked.run(() => {
4438
- flushCaptions();
4439
- const body = useBody(scriptId, caption);
4440
- if (!bodies.has(scriptId)) bodies.set(scriptId, liveText(doc, scriptId, caption));
4441
- const native = bodies.get(scriptId);
4632
+ flushSelections();
4633
+ const text = useText(target);
4634
+ const key = textKey(target);
4635
+ if (!bodies.has(key)) bodies.set(key, liveText(doc, target));
4636
+ const native = bodies.get(key);
4442
4637
  if (expectedBodyId !== void 0 && native?.id !== expectedBodyId) throw new Error("Stale or missing Text body");
4443
- if ((caption ? before["caption-entities"][scriptId]?.text : before["audio-script-entities"][scriptId]?.body) !== void 0 && !native) throw new Error("Missing live Text body");
4444
- const prepared = prepareTextEdits(body, copyInput(edits));
4445
- tracked.textPreviews.set(scriptId, prepared.body);
4638
+ if (textBefore(target) !== void 0 && !native) throw new Error("Missing live Text body");
4639
+ const prepared = prepareTextEdits(text, copyInput(edits));
4640
+ tracked.textPreviews.set(key, prepared.text);
4446
4641
  steps.push({
4447
4642
  type: "text",
4448
- scriptId,
4449
- caption,
4643
+ target,
4450
4644
  expectedBodyId: native?.id,
4451
4645
  edits: prepared.edits
4452
4646
  });
4453
4647
  });
4454
4648
  };
4649
+ /** A script's segments as this transaction leaves them; never a Mirror-tracked write. */
4650
+ const segmentsOf = (audioScriptId) => {
4651
+ assertId(audioScriptId);
4652
+ requireEntity(tracked.draft, "audio-script-entities", audioScriptId);
4653
+ if (tracked.deleted.has(audioScriptId)) throw new Error("Cannot change a deleted AudioScript");
4654
+ return raw["audio-script-entities"][audioScriptId].segments;
4655
+ };
4455
4656
  const tx = Object.freeze({
4456
4657
  get draft() {
4457
4658
  return tracked.run(() => tracked.draft);
@@ -4459,29 +4660,98 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4459
4660
  applyMutations(mutations) {
4460
4661
  tracked.run(() => applyMutations(tx, mutations));
4461
4662
  },
4462
- editText(scriptId, edits, expectedBodyId) {
4463
- editBody(scriptId, edits, expectedBodyId, false);
4663
+ editText(audioScriptId, segmentId, edits, expectedBodyId) {
4664
+ editBody({
4665
+ audioScriptId,
4666
+ segmentId
4667
+ }, edits, expectedBodyId);
4464
4668
  },
4465
4669
  editCaptionText(captionId, edits, expectedBodyId) {
4466
- editBody(captionId, edits, expectedBodyId, true);
4670
+ editBody({ captionId }, edits, expectedBodyId);
4671
+ },
4672
+ insertSegment(audioScriptId, segmentId, text, index, time) {
4673
+ tracked.run(() => {
4674
+ assertId(segmentId);
4675
+ if (typeof text !== "string") throw new Error("Segment text is a string");
4676
+ const segment = {
4677
+ id: segmentId,
4678
+ text,
4679
+ ...time
4680
+ };
4681
+ assertScriptSegment(segment);
4682
+ const segments = segmentsOf(audioScriptId);
4683
+ if (segments.some((segment) => segment.id === segmentId)) throw new Error(`Segment already exists: ${segmentId}`);
4684
+ const at = index ?? segments.length;
4685
+ if (!Number.isSafeInteger(at) || at < 0 || at > segments.length) throw new Error("Invalid segment index");
4686
+ segments.splice(at, 0, segment);
4687
+ });
4688
+ },
4689
+ moveSegment(audioScriptId, segmentId, index) {
4690
+ tracked.run(() => {
4691
+ const segments = segmentsOf(audioScriptId);
4692
+ const from = segments.findIndex((segment) => segment.id === segmentId);
4693
+ if (from < 0) throw new Error(`Missing segment ${segmentId} of ${audioScriptId}`);
4694
+ if (!Number.isSafeInteger(index) || index < 0 || index >= segments.length) throw new Error("Invalid segment index");
4695
+ const [segment] = segments.splice(from, 1);
4696
+ segments.splice(index, 0, segment);
4697
+ });
4698
+ },
4699
+ deleteSegment(audioScriptId, segmentId) {
4700
+ tracked.run(() => {
4701
+ const segments = segmentsOf(audioScriptId);
4702
+ const at = segments.findIndex((segment) => segment.id === segmentId);
4703
+ if (at < 0) throw new Error(`Missing segment ${segmentId} of ${audioScriptId}`);
4704
+ if (tracked.textPreviews.has(textKey({
4705
+ audioScriptId,
4706
+ segmentId
4707
+ }))) throw new Error("Cannot delete a segment whose text this transaction edits");
4708
+ segments.splice(at, 1);
4709
+ });
4467
4710
  },
4468
4711
  setCaptionContent(selection) {
4469
4712
  tracked.run(() => {
4470
- flushCaptions();
4471
- const { captionId, audioScriptId, start, end } = selection;
4713
+ flushSelections();
4714
+ const { captionId, audioScriptId, segmentId, start, end } = selection;
4472
4715
  assertId(captionId);
4473
- assertCaptionEndpoints(raw, selection);
4474
- assertCaptionSelection(raw, selection, useBody(audioScriptId));
4716
+ assertCaptionSelection(raw, selection, useText({
4717
+ audioScriptId,
4718
+ segmentId
4719
+ }));
4475
4720
  steps.push({
4476
4721
  type: "range",
4477
- selection: {
4478
- captionId,
4722
+ table: "caption-entities",
4723
+ id: captionId,
4724
+ range: {
4479
4725
  audioScriptId,
4726
+ segmentId,
4480
4727
  start,
4481
4728
  end
4482
4729
  }
4483
4730
  });
4484
4731
  });
4732
+ },
4733
+ setPhoneme(selection) {
4734
+ tracked.run(() => {
4735
+ flushSelections();
4736
+ const { phoneticScriptId, audioScriptId, segmentId, start, end, phonemeScript } = selection;
4737
+ assertId(phoneticScriptId);
4738
+ assertPhonemeSelection(raw, selection, useText({
4739
+ audioScriptId,
4740
+ segmentId
4741
+ }));
4742
+ steps.push({
4743
+ type: "range",
4744
+ table: "phonetic-script-entities",
4745
+ id: phoneticScriptId,
4746
+ range: {
4747
+ audioScriptId,
4748
+ segmentId,
4749
+ start,
4750
+ end
4751
+ },
4752
+ ...phonemeScript === void 0 ? {} : { phonemeScript }
4753
+ });
4754
+ });
4485
4755
  }
4486
4756
  });
4487
4757
  try {
@@ -4494,32 +4764,15 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4494
4764
  }
4495
4765
  tracked.assertValid();
4496
4766
  if (doc.getPendingTxnLength()) throw new Error("Native writes are not allowed during transaction preparation");
4497
- flushCaptions();
4767
+ flushSelections();
4498
4768
  for (const id of tracked.entities.get("caption-entities")?.keys() ?? []) {
4499
4769
  const caption = tracked.draft["caption-entities"][id];
4500
4770
  if (caption) caption.style ??= {};
4501
4771
  }
4502
4772
  validateDraft(tracked);
4503
- for (const step of steps) if (step.type === "text") requireEntity(raw, step.caption ? "caption-entities" : "audio-script-entities", step.scriptId);
4504
- const referenceIds = new Set(Object.keys(raw["caption-content-relations"]).map((id) => id.slice(3)));
4505
- const changedCaptions = /* @__PURE__ */ new Set();
4506
- for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (fields.has("text")) changedCaptions.add(id);
4507
- for (const step of steps) {
4508
- if (step.type === "range") {
4509
- referenceIds.add(step.selection.captionId);
4510
- changedCaptions.add(step.selection.captionId);
4511
- }
4512
- if (step.type === "caption") {
4513
- const id = step.id.slice(3);
4514
- changedCaptions.add(id);
4515
- if (step.value) referenceIds.add(id);
4516
- else referenceIds.delete(id);
4517
- }
4518
- if (step.type === "text" && step.caption) changedCaptions.add(step.scriptId);
4519
- }
4520
- for (const id of changedCaptions) assertCaptionSource(raw["caption-entities"][id], referenceIds.has(id));
4521
- for (const step of steps) if (step.type === "range") assertCaptionEndpoints(raw, step.selection);
4522
- else if (step.type === "caption" && step.value) assertCaptionEndpoints(raw, step.value);
4773
+ for (const step of steps) if (step.type === "text") if ("captionId" in step.target) requireEntity(raw, "caption-entities", step.target.captionId);
4774
+ else requireSegment(raw, step.target.audioScriptId, step.target.segmentId);
4775
+ assertSelectionsAfter(raw, before, tracked, steps);
4523
4776
  for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) {
4524
4777
  const row = raw["caption-entities"][id];
4525
4778
  if (fields.has("text") && row?.text !== void 0 && before["caption-entities"][id]?.text === void 0) {
@@ -4527,8 +4780,16 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4527
4780
  delete row.text;
4528
4781
  }
4529
4782
  }
4530
- for (const id of tracked.relations.get("caption-content-relations") ?? []) if (before["caption-content-relations"][id] === void 0) delete raw["caption-content-relations"][id];
4531
- else raw["caption-content-relations"][id] = before["caption-content-relations"][id];
4783
+ for (const table of Object.keys(selectionField)) {
4784
+ const field = selectionField[table];
4785
+ for (const [id, fields] of tracked.entities.get(table) ?? []) {
4786
+ const row = raw[table][id];
4787
+ if (!row || !fields.has(field)) continue;
4788
+ const old = before[table][id]?.[field];
4789
+ if (old === void 0) delete row[field];
4790
+ else row[field] = old;
4791
+ }
4792
+ }
4532
4793
  });
4533
4794
  } finally {
4534
4795
  tracked.close();
@@ -4537,44 +4798,104 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4537
4798
  }, {
4538
4799
  ...options,
4539
4800
  applyNative: (doc) => {
4540
- const bodyFor = (scriptId, caption = false) => {
4541
- let text = bodies.get(scriptId);
4801
+ const textFor = (target) => {
4802
+ const key = textKey(target);
4803
+ let text = bodies.get(key);
4542
4804
  if (!text) {
4543
- text = liveText(doc, scriptId, caption);
4544
- bodies.set(scriptId, text);
4805
+ text = liveText(doc, target);
4806
+ bodies.set(key, text);
4545
4807
  }
4546
- if (!text || text.isDeleted()) throw new Error("Prepared Text body is missing");
4808
+ if (!text || text.isDeleted()) throw new Error("Prepared Text is missing");
4547
4809
  return text;
4548
4810
  };
4549
4811
  for (const [id, value] of newCaptionTexts) {
4550
- const body = doc.getMap("caption-entities").get(id).setContainer("text", new LoroText());
4551
- if (value.length) body.insert(0, value);
4552
- bodies.set(id, body);
4812
+ const text = doc.getMap("caption-entities").get(id).setContainer("text", new LoroText());
4813
+ if (value.length) text.insert(0, value);
4814
+ bodies.set(textKey({ captionId: id }), text);
4553
4815
  }
4554
- for (const step of steps) if (step.type === "text") {
4555
- const text = bodyFor(step.scriptId, step.caption);
4556
- if (step.expectedBodyId && text.id !== step.expectedBodyId) throw new Error("Stale Text body");
4557
- applyTextEdits(text, step.edits);
4558
- } else if (step.type === "range") {
4559
- const { captionId, audioScriptId, start, end } = step.selection;
4560
- const text = bodyFor(audioScriptId);
4561
- const from = text?.getCursor(start, 0), to = text?.getCursor(end, 0);
4562
- if (!from || !to) throw new Error("Unable to construct prepared range");
4563
- doc.getMap("caption-content-relations").set(`cc:${captionId}`, captionContentCodec.encode({
4564
- captionId,
4565
- audioScriptId,
4566
- start: from,
4567
- end: to
4568
- }));
4569
- } else {
4570
- const relations = doc.getMap("caption-content-relations");
4571
- if (step.value === void 0) {
4572
- if (relations.get(step.id) !== void 0) relations.delete(step.id);
4573
- } else relations.set(step.id, captionContentCodec.encode(step.value));
4816
+ for (const step of steps) {
4817
+ if (step.type === "text") {
4818
+ const text = textFor(step.target);
4819
+ if (step.expectedBodyId && text.id !== step.expectedBodyId) throw new Error("Stale Text body");
4820
+ applyTextEdits(text, step.edits);
4821
+ continue;
4822
+ }
4823
+ const row = entityRow(doc, step.table, step.id);
4824
+ const field = selectionField[step.table];
4825
+ if (step.type === "range") {
4826
+ const { audioScriptId, segmentId, start, end } = step.range;
4827
+ const value = {
4828
+ audioScriptId,
4829
+ segmentId
4830
+ };
4831
+ if (start !== void 0 && end !== void 0) {
4832
+ const text = textFor({
4833
+ audioScriptId,
4834
+ segmentId
4835
+ });
4836
+ value.start = text.getCursor(start, 0);
4837
+ value.end = text.getCursor(end, 0);
4838
+ if (!value.start || !value.end) throw new Error("Unable to construct prepared range");
4839
+ }
4840
+ if (step.phonemeScript !== void 0) value.phonemeScript = step.phonemeScript;
4841
+ if (!row) throw new Error("Unable to construct prepared range");
4842
+ row.set(field, codecs[step.table].encode(value));
4843
+ } else if (step.value !== void 0) {
4844
+ if (!row) throw new Error("Prepared selection owner is missing");
4845
+ row.set(field, codecs[step.table].encode(step.value));
4846
+ } else if (row?.get(field) !== void 0) row.delete(field);
4574
4847
  }
4575
4848
  }
4576
4849
  });
4577
4850
  }
4851
+ /**
4852
+ * What only the whole write can say about selections, with the native steps counted in order:
4853
+ * each Caption keeps one source of words, each selection's segment still exists, and every
4854
+ * PhoneticScript the write touched is written for a segment and stores its text as a file.
4855
+ */
4856
+ function assertSelectionsAfter(raw, before, tracked, steps) {
4857
+ const selected = new Set(Object.entries(before["caption-entities"]).filter(([, caption]) => typeof caption === "object" && caption.selection !== void 0).map(([id]) => id));
4858
+ const changedCaptions = /* @__PURE__ */ new Set();
4859
+ for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (fields.has("text")) changedCaptions.add(id);
4860
+ const phonemes = /* @__PURE__ */ new Map();
4861
+ for (const step of steps) {
4862
+ if (step.type === "text") {
4863
+ if ("captionId" in step.target) changedCaptions.add(step.target.captionId);
4864
+ continue;
4865
+ }
4866
+ const value = step.type === "range" ? {
4867
+ ...step.range,
4868
+ ...step.phonemeScript === void 0 ? {} : { phonemeScript: step.phonemeScript }
4869
+ } : step.value;
4870
+ if (step.table === "caption-entities") {
4871
+ changedCaptions.add(step.id);
4872
+ if (value) selected.add(step.id);
4873
+ else selected.delete(step.id);
4874
+ if (value) assertCaptionEndpoints(raw, {
4875
+ captionId: step.id,
4876
+ ...value
4877
+ });
4878
+ } else {
4879
+ phonemes.set(step.id, value);
4880
+ if (value) assertPhonemeEndpoints(raw, {
4881
+ phoneticScriptId: step.id,
4882
+ ...value
4883
+ });
4884
+ }
4885
+ }
4886
+ for (const id of changedCaptions) if (raw["caption-entities"][id]) assertCaptionSource(raw["caption-entities"][id], selected.has(id));
4887
+ const phonetics = new Set([...tracked.entities.get("phonetic-script-entities")?.keys() ?? [], ...phonemes.keys()]);
4888
+ for (const id of phonetics) {
4889
+ const row = raw["phonetic-script-entities"][id];
4890
+ if (!row) continue;
4891
+ const phoneme = phonemes.has(id) ? phonemes.get(id) : row.phoneme;
4892
+ const old = before["phonetic-script-entities"][id];
4893
+ assertPhoneticScript(old, {
4894
+ ...row,
4895
+ phoneme: isRecord(phoneme) ? phoneme : void 0
4896
+ });
4897
+ }
4898
+ }
4578
4899
  //#endregion
4579
4900
  //#region src/dsl/document/medeo-dsl.ts
4580
4901
  const contentRoots = new Set([
@@ -4665,17 +4986,23 @@ var MedeoDsl = class MedeoDsl {
4665
4986
  applyMutations(mutations, audit) {
4666
4987
  this.transact((tx) => tx.applyMutations(mutations), audit);
4667
4988
  }
4668
- editText(scriptId, expectedBodyId, edits, audit) {
4669
- this.transact((tx) => tx.editText(scriptId, edits, expectedBodyId), audit);
4989
+ editText(scriptId, segmentId, expectedBodyId, edits, audit) {
4990
+ this.transact((tx) => tx.editText(scriptId, segmentId, edits, expectedBodyId), audit);
4670
4991
  }
4671
4992
  editCaptionText(captionId, expectedBodyId, edits, audit) {
4672
4993
  this.transact((tx) => tx.editCaptionText(captionId, edits, expectedBodyId), audit);
4673
4994
  }
4674
- /** Local ranges exclude insertions at their start and include insertions at their end. K01 remains deferred. */
4675
- textRange(scriptId, start, end) {
4995
+ /**
4996
+ * A run inside one segment as native Cursors. Local ranges exclude insertions at their start and
4997
+ * include insertions at their end. K01 remains deferred.
4998
+ */
4999
+ textRange(scriptId, segmentId, start, end) {
4676
5000
  this.assertActive();
4677
- const text = liveText(this.doc, scriptId);
4678
- if (!text) throw new Error("Missing AudioScript body");
5001
+ const text = liveText(this.doc, {
5002
+ audioScriptId: scriptId,
5003
+ segmentId
5004
+ });
5005
+ if (!text) throw new Error("Missing AudioScript segment");
4679
5006
  assertTextOffset(text.toString(), start);
4680
5007
  assertTextOffset(text.toString(), end);
4681
5008
  if (start >= end) throw new Error("Expected a nonempty range");
@@ -4683,6 +5010,7 @@ var MedeoDsl = class MedeoDsl {
4683
5010
  if (!from || !to) throw new Error("Unable to construct text range");
4684
5011
  return {
4685
5012
  audioScriptId: scriptId,
5013
+ segmentId,
4686
5014
  start: from,
4687
5015
  end: to
4688
5016
  };
@@ -4732,6 +5060,150 @@ var MedeoDsl = class MedeoDsl {
4732
5060
  }
4733
5061
  };
4734
5062
  //#endregion
5063
+ //#region src/dsl/projection/audio-scripts.ts
5064
+ /**
5065
+ * The scripts a recording says. It follows derived-from from the recording; a PhoneticScript is a
5066
+ * variant, so its words are the base its phoneme names, and derived-from is not followed past it.
5067
+ * When nothing is found that way, a script read off the recording itself is what it says.
5068
+ */
5069
+ function spokenScripts(snapshot, recordingId) {
5070
+ const relations = Object.values(snapshot["derived-from-relations"]);
5071
+ const found = /* @__PURE__ */ new Map();
5072
+ const seen = /* @__PURE__ */ new Set();
5073
+ const visit = (id, phoneticScriptId) => {
5074
+ if (seen.has(id)) return;
5075
+ seen.add(id);
5076
+ if (snapshot["audio-script-entities"][id]) {
5077
+ if (!found.has(id)) found.set(id, phoneticScriptId);
5078
+ return;
5079
+ }
5080
+ const phonetic = snapshot["phonetic-script-entities"][id];
5081
+ if (phonetic) {
5082
+ visit(phonetic.phoneme.audioScriptId, id);
5083
+ return;
5084
+ }
5085
+ for (const r of relations) if (r.entityId === id) visit(r.sourceId, phoneticScriptId);
5086
+ };
5087
+ visit(recordingId);
5088
+ if (!found.size) {
5089
+ for (const r of relations) if (r.sourceId === recordingId && snapshot["audio-script-entities"][r.entityId]) found.set(r.entityId, void 0);
5090
+ }
5091
+ return [...found].map(([audioScriptId, phoneticScriptId]) => phoneticScriptId === void 0 ? { audioScriptId } : {
5092
+ audioScriptId,
5093
+ phoneticScriptId
5094
+ });
5095
+ }
5096
+ /** Every AudioScript in the document with its PhoneticScripts, the Speech Clips saying it and the Caption Clips showing it. */
5097
+ function toAudioScriptViews(snapshot) {
5098
+ const views = /* @__PURE__ */ new Map();
5099
+ const segmentIndex = (scriptId, segmentId) => {
5100
+ const index = snapshot["audio-script-entities"][scriptId]?.segments.findIndex((s) => s.id === segmentId) ?? -1;
5101
+ return index < 0 ? void 0 : index;
5102
+ };
5103
+ for (const [id, script] of Object.entries(snapshot["audio-script-entities"])) views.set(id, {
5104
+ id,
5105
+ segments: script.segments.map((segment) => {
5106
+ const rich = snapshot.richText[segment.id];
5107
+ const language = rich ? markedLanguage(langsFromDelta(rich.delta)) : void 0;
5108
+ return {
5109
+ id: segment.id,
5110
+ text: segment.text,
5111
+ ...language === void 0 ? {} : { language },
5112
+ ...segment.startMs === void 0 ? {} : {
5113
+ startMs: segment.startMs,
5114
+ endMs: segment.endMs
5115
+ }
5116
+ };
5117
+ }),
5118
+ sourceMediaIds: [],
5119
+ phoneticScripts: [],
5120
+ speeches: [],
5121
+ captions: []
5122
+ });
5123
+ for (const r of Object.values(snapshot["derived-from-relations"])) if (snapshot["media-entities"][r.sourceId]) views.get(r.entityId)?.sourceMediaIds.push(r.sourceId);
5124
+ for (const [id, phonetic] of Object.entries(snapshot["phonetic-script-entities"])) {
5125
+ const { audioScriptId, segmentId, phonemeScript } = phonetic.phoneme;
5126
+ const { digest } = phonetic;
5127
+ const segment = segmentIndex(audioScriptId, segmentId);
5128
+ const text = snapshot.phoneticText[id]?.text;
5129
+ views.get(audioScriptId)?.phoneticScripts.push({
5130
+ id,
5131
+ ...segment === void 0 ? {} : { segment },
5132
+ ...text === void 0 ? {} : { text },
5133
+ ...phonemeScript === void 0 ? {} : { phonemeScript },
5134
+ ...digest === void 0 ? {} : { digest }
5135
+ });
5136
+ }
5137
+ const layout = resolveDslLayout(snapshot);
5138
+ for (const view of views.values()) placeTranscript(snapshot, layout, view);
5139
+ const timbres = Object.values(snapshot["voice-timbre-relations"]);
5140
+ for (const track of layout.tracks) for (const { clipId } of track.clips) {
5141
+ const source = snapshot["clip-entities"][clipId]?.source;
5142
+ if (!source) continue;
5143
+ if (snapshot["media-entities"][source]?.kind === "voiceover-recording") {
5144
+ const voices = timbres.filter((r) => r.recordingId === source).flatMap((r) => {
5145
+ const voice = snapshot["voice-entities"][r.voiceId];
5146
+ if (!voice) return [];
5147
+ return [voice.name === void 0 ? {
5148
+ voiceId: r.voiceId,
5149
+ key: voice.key
5150
+ } : {
5151
+ voiceId: r.voiceId,
5152
+ key: voice.key,
5153
+ name: voice.name
5154
+ }];
5155
+ });
5156
+ for (const spoken of spokenScripts(snapshot, source)) {
5157
+ const applied = appliedSpeech(snapshot, source, spoken);
5158
+ views.get(spoken.audioScriptId)?.speeches.push({
5159
+ clipId,
5160
+ recordingId: source,
5161
+ ...spoken.phoneticScriptId === void 0 ? {} : { phoneticScriptId: spoken.phoneticScriptId },
5162
+ voices,
5163
+ ...applied === void 0 ? {} : { applied }
5164
+ });
5165
+ }
5166
+ continue;
5167
+ }
5168
+ const caption = snapshot["caption-entities"][source];
5169
+ if (!caption?.selection || caption.text !== void 0 || !snapshot.captionText[source]) continue;
5170
+ const { audioScriptId, segmentId } = caption.selection;
5171
+ const segment = segmentIndex(audioScriptId, segmentId);
5172
+ if (segment !== void 0) views.get(audioScriptId)?.captions.push({
5173
+ clipId,
5174
+ captionId: source,
5175
+ segment
5176
+ });
5177
+ }
5178
+ return [...views.values()];
5179
+ }
5180
+ /**
5181
+ * A transcript's segments on the Timeline: a segment's time is in the medium the script was read
5182
+ * off, so it lands where the earliest Clip playing that medium holds that moment in its window.
5183
+ */
5184
+ function placeTranscript(snapshot, layout, view) {
5185
+ if (!view.sourceMediaIds.length) return;
5186
+ const plays = layout.tracks.flatMap((track) => track.clips).filter((clip) => view.sourceMediaIds.includes(snapshot["clip-entities"][clip.clipId]?.source ?? "")).sort((a, b) => a.startMs - b.startMs);
5187
+ for (const segment of view.segments) {
5188
+ const at = segment.startMs;
5189
+ if (at === void 0) continue;
5190
+ const clip = plays.find((c) => c.inMs <= at && at < c.outMs);
5191
+ if (clip) segment.timelineMs = Math.round(clip.startMs + (at - clip.inMs) / clip.rate);
5192
+ }
5193
+ }
5194
+ /**
5195
+ * Whether a recording still matches what it was synthesized from: the digest its derived-from
5196
+ * records against the source's file digest now. A recording derived from a script directly is
5197
+ * not judged: a written script has no file, and a transcript's file is what it was read from,
5198
+ * not what a take was synthesized from (D190).
5199
+ */
5200
+ function appliedSpeech(snapshot, recordingId, spoken) {
5201
+ const sourceId = spoken.phoneticScriptId ?? spoken.audioScriptId;
5202
+ const recorded = Object.values(snapshot["derived-from-relations"]).find((r) => r.entityId === recordingId && r.sourceId === sourceId)?.sourceDigest;
5203
+ const current = spoken.phoneticScriptId === void 0 ? void 0 : snapshot["phonetic-script-entities"][sourceId]?.digest;
5204
+ return recorded === void 0 || current === void 0 ? void 0 : recorded === current;
5205
+ }
5206
+ //#endregion
4735
5207
  //#region src/dsl/voiceover-actions.ts
4736
5208
  function voiceoverPlan(s, input, result) {
4737
5209
  if (requireTrack(s, input.trackId).role !== "speech" || input.media.kind !== "voiceover-recording") throw new Error("Voiceover requires a speech Track and a recording");
@@ -4744,16 +5216,30 @@ function voiceoverPlan(s, input, result) {
4744
5216
  id: result.mediaId,
4745
5217
  value: input.media
4746
5218
  }];
4747
- if (input.script.body !== void 0) mutations.push({
4748
- type: "create-entity",
4749
- table: "audio-script-entities",
4750
- id: result.audioScriptId,
4751
- value: {
4752
- body: input.script.body,
4753
- ..."defaultLanguage" in input.script && input.script.defaultLanguage !== void 0 ? { defaultLanguage: input.script.defaultLanguage } : {}
4754
- }
4755
- });
4756
- else if (!s["audio-script-entities"][result.audioScriptId]) throw new Error("Missing AudioScript");
5219
+ let segmentIds;
5220
+ if (input.script.segments !== void 0) {
5221
+ if (result.segmentIds.length !== input.script.segments.length) throw new Error("Expected one id per segment");
5222
+ const segments = input.script.segments;
5223
+ segmentIds = result.segmentIds;
5224
+ mutations.push({
5225
+ type: "create-entity",
5226
+ table: "audio-script-entities",
5227
+ id: result.audioScriptId,
5228
+ value: {
5229
+ segments: segments.map((text, i) => ({
5230
+ id: segmentIds[i],
5231
+ text
5232
+ })),
5233
+ ..."defaultLanguage" in input.script && input.script.defaultLanguage !== void 0 ? { defaultLanguage: input.script.defaultLanguage } : {}
5234
+ }
5235
+ });
5236
+ } else {
5237
+ const script = s["audio-script-entities"][result.audioScriptId];
5238
+ if (!script) throw new Error("Missing AudioScript");
5239
+ segmentIds = script.segments.map((segment) => segment.id);
5240
+ }
5241
+ const phoneticScriptId = input.script.phoneticScriptId;
5242
+ if (phoneticScriptId !== void 0 && s["phonetic-script-entities"][phoneticScriptId]?.phoneme.audioScriptId !== result.audioScriptId) throw new Error("The PhoneticScript must name this AudioScript as its base");
4757
5243
  validateWindow({
4758
5244
  inMs: 0,
4759
5245
  outMs: input.media.durationMs
@@ -4763,7 +5249,8 @@ function voiceoverPlan(s, input, result) {
4763
5249
  table: "derived-from-relations",
4764
5250
  value: {
4765
5251
  entityId: result.mediaId,
4766
- sourceId: result.audioScriptId
5252
+ sourceId: phoneticScriptId ?? result.audioScriptId,
5253
+ ...input.sourceDigest === void 0 ? {} : { sourceDigest: input.sourceDigest }
4767
5254
  }
4768
5255
  });
4769
5256
  if (input.voice) {
@@ -4793,6 +5280,7 @@ function voiceoverPlan(s, input, result) {
4793
5280
  rate: void 0,
4794
5281
  fillTimeline: void 0
4795
5282
  },
5283
+ source: result.mediaId,
4796
5284
  ...input.volume === void 0 ? {} : { volume: input.volume }
4797
5285
  }
4798
5286
  } : {
@@ -4804,15 +5292,9 @@ function voiceoverPlan(s, input, result) {
4804
5292
  inMs: 0,
4805
5293
  outMs: input.media.durationMs
4806
5294
  },
5295
+ source: result.mediaId,
4807
5296
  ...input.volume === void 0 ? {} : { volume: input.volume }
4808
5297
  }
4809
- }, {
4810
- type: "set-relation",
4811
- table: "clip-source-relations",
4812
- value: {
4813
- clipId: result.clipId,
4814
- sourceId: result.mediaId
4815
- }
4816
5298
  });
4817
5299
  if (!existing || trackOf(s, result.clipId) !== input.trackId) mutations.push({
4818
5300
  type: existing ? "move-node" : "insert-node",
@@ -4822,13 +5304,14 @@ function voiceoverPlan(s, input, result) {
4822
5304
  mutations.push(...placementMutations(s, result.clipId, input.placement));
4823
5305
  uniqueIds(result.captionClipIds);
4824
5306
  const retained = new Set(result.captionClipIds);
4825
- const oldCaptions = Object.values(s["time-anchor-relations"]).filter((r) => r.to === result.clipId && s["clip-entities"][r.from] && requireTrack(s, trackOf(s, r.from)).role === "caption").map((r) => r.from);
5307
+ const oldCaptions = existing ? captionsShowing(s, existing.source) : [];
4826
5308
  mutations.push(...deleteClips(s, oldCaptions.filter((id) => !retained.has(id)), "cascade"));
4827
- const anchor = mutations.find((m) => m.type === "set-relation" && m.table === "time-anchor-relations" && m.value.from === result.clipId);
5309
+ const placement = mutations.find((m) => m.type === "patch-entity" && m.table === "clip-entities" && m.id === result.clipId && "anchor" in m.patch);
5310
+ const anchor = placement?.type === "patch-entity" && placement.table === "clip-entities" ? placement.patch.anchor : void 0;
4828
5311
  const trackClips = resolveDslLayout(s).tracks.find((track) => track.trackId === input.trackId).clips;
4829
5312
  const slot = trackClips.findIndex((clip) => clip.clipId === result.clipId);
4830
5313
  const predecessor = trackClips.slice(0, slot < 0 ? trackClips.length : slot).findLast((clip) => clip.mode === "sequential" && !clip.fillTimeline);
4831
- const speechStart = anchor?.type === "set-relation" && anchor.table === "time-anchor-relations" ? anchor.value.fallback_abs_ms : predecessor ? predecessor.startMs + predecessor.durationMs : 0;
5314
+ const speechStart = anchor?.fallback_abs_ms !== void 0 ? anchor.fallback_abs_ms : predecessor ? predecessor.startMs + predecessor.durationMs : 0;
4832
5315
  input.captions.forEach((caption, i) => {
4833
5316
  const clipId = result.captionClipIds[i], captionId = result.captionIds[i];
4834
5317
  validateWindow({
@@ -4837,7 +5320,9 @@ function voiceoverPlan(s, input, result) {
4837
5320
  }, input.media.durationMs);
4838
5321
  const previous = s["clip-entities"][clipId];
4839
5322
  if (previous && !oldCaptions.includes(clipId)) throw new Error("Cannot overwrite another use’s Caption Clip");
4840
- const previousSource = s["clip-source-relations"][`cs:${clipId}`]?.sourceId;
5323
+ const segmentId = segmentIds[caption.segment];
5324
+ if (segmentId === void 0) throw new Error(`Missing segment ${caption.segment} of the voiceover's script`);
5325
+ const previousSource = previous?.source;
4841
5326
  const style = caption.style ?? (previousSource ? s["caption-entities"][previousSource]?.style : void 0);
4842
5327
  mutations.push({
4843
5328
  type: "create-entity",
@@ -4848,48 +5333,38 @@ function voiceoverPlan(s, input, result) {
4848
5333
  type: "set-caption-content",
4849
5334
  captionId,
4850
5335
  audioScriptId: result.audioScriptId,
4851
- start: caption.range.start,
4852
- end: caption.range.end
4853
- }, {
4854
- type: "set-relation",
4855
- table: "caption-alignment-relations",
4856
- value: {
4857
- captionId,
4858
- mediaId: result.mediaId,
4859
- startMs: caption.startMs,
4860
- endMs: caption.startMs + caption.durationMs
5336
+ segmentId,
5337
+ ...caption.range === void 0 ? {} : {
5338
+ start: caption.range.start,
5339
+ end: caption.range.end
4861
5340
  }
4862
- }, previous ? {
5341
+ });
5342
+ const anchor = {
5343
+ to: result.clipId,
5344
+ offsetMs: caption.startMs,
5345
+ fallback_abs_ms: speechStart + caption.startMs
5346
+ };
5347
+ const seqMarker = {
5348
+ inMs: 0,
5349
+ outMs: caption.durationMs
5350
+ };
5351
+ mutations.push(previous ? {
4863
5352
  type: "patch-entity",
4864
5353
  table: "clip-entities",
4865
5354
  id: clipId,
4866
- patch: { seqMarker: {
4867
- inMs: 0,
4868
- outMs: caption.durationMs
4869
- } }
5355
+ patch: {
5356
+ seqMarker,
5357
+ source: captionId,
5358
+ anchor
5359
+ }
4870
5360
  } : {
4871
5361
  type: "create-entity",
4872
5362
  table: "clip-entities",
4873
5363
  id: clipId,
4874
- value: { seqMarker: {
4875
- inMs: 0,
4876
- outMs: caption.durationMs
4877
- } }
4878
- }, {
4879
- type: "set-relation",
4880
- table: "clip-source-relations",
4881
- value: {
4882
- clipId,
4883
- sourceId: captionId
4884
- }
4885
- }, {
4886
- type: "set-relation",
4887
- table: "time-anchor-relations",
4888
5364
  value: {
4889
- from: clipId,
4890
- to: result.clipId,
4891
- offsetMs: caption.startMs,
4892
- fallback_abs_ms: speechStart + caption.startMs
5365
+ seqMarker,
5366
+ source: captionId,
5367
+ anchor
4893
5368
  }
4894
5369
  });
4895
5370
  if (!previous || trackOf(s, clipId) !== input.captionTrackId) mutations.push({
@@ -4900,15 +5375,44 @@ function voiceoverPlan(s, input, result) {
4900
5375
  });
4901
5376
  return mutations;
4902
5377
  }
5378
+ /**
5379
+ * Caption Clips showing what a recording says (D183): their Caption selects the run of the segment
5380
+ * the PhoneticScript it was spoken from is written for, or any segment of the script it was read
5381
+ * from directly.
5382
+ */
5383
+ function captionsShowing(s, recordingId) {
5384
+ const said = spokenScripts(s, recordingId).map(({ audioScriptId, phoneticScriptId }) => {
5385
+ if (phoneticScriptId === void 0) return { audioScriptId };
5386
+ const { segmentId } = s["phonetic-script-entities"][phoneticScriptId].phoneme;
5387
+ return {
5388
+ audioScriptId,
5389
+ segmentId,
5390
+ run: s.phoneticText[phoneticScriptId]
5391
+ };
5392
+ });
5393
+ return Object.entries(s["clip-entities"]).flatMap(([id, clip]) => {
5394
+ const selection = s["caption-entities"][clip.source]?.selection;
5395
+ const shown = s.captionText[clip.source];
5396
+ if (!selection) return [];
5397
+ return said.some((words) => {
5398
+ if (selection.audioScriptId !== words.audioScriptId) return false;
5399
+ if (!("segmentId" in words)) return true;
5400
+ if (selection.segmentId !== words.segmentId || !shown || !words.run) return false;
5401
+ return shown.start < words.run.end && words.run.start < shown.end;
5402
+ }) && requireTrack(s, trackOf(s, id)).role === "caption" ? [id] : [];
5403
+ });
5404
+ }
4903
5405
  //#endregion
4904
5406
  //#region src/dsl/dsl-editor.ts
4905
5407
  /** Business edits write DSL facts in one audited transaction; layout is derived on read. */
4906
5408
  var DslEditor = class {
4907
5409
  dsl;
4908
5410
  idFactory;
4909
- constructor(dsl, idFactory = defaultIdFactory) {
5411
+ uploadText;
5412
+ constructor(dsl, idFactory = defaultIdFactory, uploadText) {
4910
5413
  this.dsl = dsl;
4911
5414
  this.idFactory = idFactory;
5415
+ this.uploadText = uploadText;
4912
5416
  }
4913
5417
  createTrack(role, options) {
4914
5418
  const id = this.idFactory("track");
@@ -4950,11 +5454,10 @@ var DslEditor = class {
4950
5454
  /** index is measured in the target's children after removing the moved Clip. */
4951
5455
  moveClip(clipId, trackId, index, options) {
4952
5456
  const snapshot = this.dsl.getSnapshot();
4953
- requireClip(snapshot, clipId);
5457
+ const clip = requireClip(snapshot, clipId);
4954
5458
  const track = snapshot["track-entities"][trackId];
4955
- const source = snapshot["clip-source-relations"][`cs:${clipId}`];
4956
- if (!track || !source) throw new Error("Missing target Track or Clip source");
4957
- requireSource(snapshot, source.sourceId, track.role);
5459
+ if (!track) throw new Error("Missing target Track or Clip source");
5460
+ requireSource(snapshot, clip.source, track.role);
4958
5461
  this.write("moveClip", {
4959
5462
  clipId,
4960
5463
  trackId,
@@ -4969,8 +5472,7 @@ var DslEditor = class {
4969
5472
  trimClip(clipId, window, options) {
4970
5473
  const snapshot = this.dsl.getSnapshot(), clip = requireClip(snapshot, clipId);
4971
5474
  if (clip.seqMarker.fillTimeline) throw new Error("BGM trim is not enabled");
4972
- const sourceId = snapshot["clip-source-relations"][`cs:${clipId}`]?.sourceId;
4973
- const source = sourceId ? snapshot["media-entities"][sourceId] ?? snapshot["caption-entities"][sourceId] : void 0;
5475
+ const source = snapshot["media-entities"][clip.source] ?? snapshot["caption-entities"][clip.source];
4974
5476
  if (!source) throw new Error("Cannot trim a Clip with a missing source");
4975
5477
  const patch = {};
4976
5478
  if (window.inMs !== void 0) patch.inMs = window.inMs;
@@ -5013,30 +5515,76 @@ var DslEditor = class {
5013
5515
  patch: { volume }
5014
5516
  }], options);
5015
5517
  }
5016
- createAudioScript(body, defaultLanguage, options) {
5518
+ /** A script of these segments, in order; each segment gets its own id. */
5519
+ createAudioScript(segments, defaultLanguage, options) {
5017
5520
  const id = this.idFactory("audio-script");
5521
+ const value = {
5522
+ segments: segments.map((text) => ({
5523
+ id: this.idFactory("segment"),
5524
+ text
5525
+ })),
5526
+ ...defaultLanguage === void 0 ? {} : { defaultLanguage }
5527
+ };
5018
5528
  this.write("createAudioScript", {
5019
5529
  id,
5020
- body,
5021
- defaultLanguage
5530
+ ...value
5022
5531
  }, [{
5023
5532
  type: "create-entity",
5024
5533
  table: "audio-script-entities",
5025
5534
  id,
5026
- value: {
5027
- body,
5028
- ...defaultLanguage === void 0 ? {} : { defaultLanguage }
5029
- }
5535
+ value
5030
5536
  }], options);
5031
5537
  return id;
5032
5538
  }
5033
- editAudioScript(scriptId, expectedBodyId, edits, options) {
5034
- this.dsl.editText(scriptId, expectedBodyId, edits, audit("editAudioScript", {
5539
+ editAudioScript(scriptId, segmentId, expectedBodyId, edits, options) {
5540
+ this.dsl.editText(scriptId, segmentId, expectedBodyId, edits, audit("editAudioScript", {
5035
5541
  scriptId,
5542
+ segmentId,
5036
5543
  expectedBodyId,
5037
5544
  edits
5038
5545
  }, options));
5039
5546
  }
5547
+ /** Adds a segment at `index`, the end by default, and returns its id. */
5548
+ insertScriptSegment(scriptId, text, index, options) {
5549
+ const segmentId = this.idFactory("segment");
5550
+ this.write("insertScriptSegment", {
5551
+ scriptId,
5552
+ segmentId,
5553
+ text,
5554
+ index
5555
+ }, [{
5556
+ type: "insert-segment",
5557
+ audioScriptId: scriptId,
5558
+ segmentId,
5559
+ text,
5560
+ ...index === void 0 ? {} : { index }
5561
+ }], options);
5562
+ return segmentId;
5563
+ }
5564
+ /** `index` is measured after removing the segment. */
5565
+ moveScriptSegment(scriptId, segmentId, index, options) {
5566
+ this.write("moveScriptSegment", {
5567
+ scriptId,
5568
+ segmentId,
5569
+ index
5570
+ }, [{
5571
+ type: "move-segment",
5572
+ audioScriptId: scriptId,
5573
+ segmentId,
5574
+ index
5575
+ }], options);
5576
+ }
5577
+ /** Captions and PhoneticScripts selecting the segment keep their selection, which then shows nothing (D72). */
5578
+ deleteScriptSegment(scriptId, segmentId, options) {
5579
+ this.write("deleteScriptSegment", {
5580
+ scriptId,
5581
+ segmentId
5582
+ }, [{
5583
+ type: "delete-segment",
5584
+ audioScriptId: scriptId,
5585
+ segmentId
5586
+ }], options);
5587
+ }
5040
5588
  createCaption(input, options) {
5041
5589
  const id = this.idFactory("caption");
5042
5590
  this.write("createCaption", {
@@ -5053,18 +5601,16 @@ var DslEditor = class {
5053
5601
  }, ...input.text !== void 0 ? [] : [{
5054
5602
  type: "set-caption-content",
5055
5603
  captionId: id,
5056
- audioScriptId: input.audioScriptId,
5057
- start: input.start,
5058
- end: input.end
5604
+ ...segmentRange(input)
5059
5605
  }]], options);
5060
5606
  return id;
5061
5607
  }
5062
- setCaptionContent(captionId, audioScriptId, start, end, options) {
5608
+ /** Show a segment of a script, or a run of it, in place of the Caption's own text. */
5609
+ setCaptionContent(captionId, selection, options) {
5610
+ const range = segmentRange(selection);
5063
5611
  this.write("setCaptionContent", {
5064
5612
  captionId,
5065
- audioScriptId,
5066
- start,
5067
- end
5613
+ ...range
5068
5614
  }, [{
5069
5615
  type: "patch-entity",
5070
5616
  table: "caption-entities",
@@ -5073,9 +5619,7 @@ var DslEditor = class {
5073
5619
  }, {
5074
5620
  type: "set-caption-content",
5075
5621
  captionId,
5076
- audioScriptId,
5077
- start,
5078
- end
5622
+ ...range
5079
5623
  }], options);
5080
5624
  }
5081
5625
  setCaptionText(captionId, text, options) {
@@ -5083,9 +5627,10 @@ var DslEditor = class {
5083
5627
  if (!caption) throw new Error("Missing Caption");
5084
5628
  this.dsl.transact((tx) => {
5085
5629
  tx.applyMutations([{
5086
- type: "delete-relation",
5087
- table: "caption-content-relations",
5088
- id: `cc:${captionId}`
5630
+ type: "patch-entity",
5631
+ table: "caption-entities",
5632
+ id: captionId,
5633
+ patch: { selection: void 0 }
5089
5634
  }]);
5090
5635
  if (caption.text === void 0) tx.draft["caption-entities"][captionId].text = text;
5091
5636
  else if (caption.text !== text) tx.editCaptionText(captionId, [...caption.text.length ? [{
@@ -5199,7 +5744,8 @@ var DslEditor = class {
5199
5744
  const result = {
5200
5745
  clipId: input.clipId ?? this.idFactory("clip"),
5201
5746
  mediaId: resourceId("media", input.media.system, input.media.key),
5202
- audioScriptId: input.script.audioScriptId ?? this.idFactory("audio-script"),
5747
+ audioScriptId: this.voiceoverScriptId(input.script),
5748
+ segmentIds: input.script.segments?.map(() => this.idFactory("segment")) ?? [],
5203
5749
  captionClipIds: input.captions.map((c) => c.clipId ?? this.idFactory("clip")),
5204
5750
  captionIds: input.captions.map(() => this.idFactory("caption"))
5205
5751
  };
@@ -5209,6 +5755,68 @@ var DslEditor = class {
5209
5755
  }, voiceoverPlan(this.dsl.getSnapshot(), input, result), options);
5210
5756
  return result;
5211
5757
  }
5758
+ voiceoverScriptId(script) {
5759
+ if (script.audioScriptId !== void 0) return script.audioScriptId;
5760
+ if (script.phoneticScriptId === void 0) return this.idFactory("audio-script");
5761
+ const phonetic = this.dsl.getSnapshot()["phonetic-script-entities"][script.phoneticScriptId];
5762
+ if (!phonetic) throw new Error("Missing PhoneticScript");
5763
+ return phonetic.phoneme.audioScriptId;
5764
+ }
5765
+ /**
5766
+ * Write a PhoneticScript: create one for a segment of a script, or a run of it, or, given
5767
+ * `phoneticScriptId`, replace its selection and what to synthesize together (D181). A
5768
+ * `phonemeScript` is stored as a Memota text file first, and the file's facts are written with
5769
+ * it; an unchanged one keeps its file. Recordings already spoken from it keep their derived-from.
5770
+ */
5771
+ async setPhoneticScript(input, options) {
5772
+ const id = input.phoneticScriptId ?? this.idFactory("phonetic-script");
5773
+ const range = segmentRange(input);
5774
+ const file = input.phonemeScript === void 0 ? {} : await this.fileFor(id, input.phonemeScript);
5775
+ const exists = Object.hasOwn(this.dsl.getSnapshot()["phonetic-script-entities"], id);
5776
+ const cleared = {
5777
+ system: void 0,
5778
+ key: void 0,
5779
+ storageKey: void 0,
5780
+ digest: void 0
5781
+ };
5782
+ const phonemeScript = input.phonemeScript === void 0 ? {} : { phonemeScript: input.phonemeScript };
5783
+ this.write("setPhoneticScript", {
5784
+ id,
5785
+ ...range,
5786
+ ...phonemeScript,
5787
+ ...cleared,
5788
+ ...file
5789
+ }, [exists ? {
5790
+ type: "patch-entity",
5791
+ table: "phonetic-script-entities",
5792
+ id,
5793
+ patch: {
5794
+ ...cleared,
5795
+ ...file
5796
+ }
5797
+ } : {
5798
+ type: "create-entity",
5799
+ table: "phonetic-script-entities",
5800
+ id,
5801
+ value: file
5802
+ }, {
5803
+ type: "set-phoneme",
5804
+ phoneticScriptId: id,
5805
+ ...range,
5806
+ ...phonemeScript
5807
+ }], options);
5808
+ return id;
5809
+ }
5810
+ /** The file holding `text`: the one the PhoneticScript keeps while its text is unchanged, else a new upload. */
5811
+ async fileFor(id, text) {
5812
+ const current = this.dsl.getSnapshot()["phonetic-script-entities"][id];
5813
+ if (current?.phoneme.phonemeScript === text) {
5814
+ const { phoneme: _, ...file } = current;
5815
+ return file;
5816
+ }
5817
+ if (!this.uploadText) throw new Error("This editor stores no files");
5818
+ return this.uploadText(text);
5819
+ }
5212
5820
  setBgm(input, options) {
5213
5821
  const s = this.dsl.getSnapshot();
5214
5822
  if (requireTrack(s, input.trackId).role !== "bgm") throw new Error("Expected a BGM Track");
@@ -5227,37 +5835,28 @@ var DslEditor = class {
5227
5835
  fillTimeline: true
5228
5836
  };
5229
5837
  const volume = input.volume === void 0 ? {} : { volume: input.volume };
5230
- const mutations = [
5231
- ...source.mutations,
5232
- old ? {
5233
- type: "patch-entity",
5234
- table: "clip-entities",
5235
- id,
5236
- patch: {
5237
- seqMarker: {
5238
- ...seqMarker,
5239
- rate: void 0
5240
- },
5241
- ...volume
5242
- }
5243
- } : {
5244
- type: "create-entity",
5245
- table: "clip-entities",
5246
- id,
5247
- value: {
5248
- seqMarker,
5249
- ...volume
5250
- }
5251
- },
5252
- {
5253
- type: "set-relation",
5254
- table: "clip-source-relations",
5255
- value: {
5256
- clipId: id,
5257
- sourceId: source.id
5258
- }
5838
+ const mutations = [...source.mutations, old ? {
5839
+ type: "patch-entity",
5840
+ table: "clip-entities",
5841
+ id,
5842
+ patch: {
5843
+ seqMarker: {
5844
+ ...seqMarker,
5845
+ rate: void 0
5846
+ },
5847
+ source: source.id,
5848
+ ...volume
5259
5849
  }
5260
- ];
5850
+ } : {
5851
+ type: "create-entity",
5852
+ table: "clip-entities",
5853
+ id,
5854
+ value: {
5855
+ seqMarker,
5856
+ source: source.id,
5857
+ ...volume
5858
+ }
5859
+ }];
5261
5860
  if (!old) mutations.push({
5262
5861
  type: "insert-node",
5263
5862
  entityId: id,
@@ -5273,6 +5872,15 @@ var DslEditor = class {
5273
5872
  this.dsl.applyMutations(mutations, audit(action, payload, options));
5274
5873
  }
5275
5874
  };
5875
+ /** A selection as a write states it: a run only when both its ends are given. */
5876
+ function segmentRange(input) {
5877
+ return {
5878
+ audioScriptId: input.audioScriptId,
5879
+ segmentId: input.segmentId,
5880
+ ...input.start === void 0 ? {} : { start: input.start },
5881
+ ...input.end === void 0 ? {} : { end: input.end }
5882
+ };
5883
+ }
5276
5884
  function audit(action, payload, options) {
5277
5885
  return {
5278
5886
  semantic_op: action,
@@ -5485,25 +6093,18 @@ function toVideoDraft(snapshot) {
5485
6093
  function requireWholeAudio(media, clip, fail) {
5486
6094
  if (media.kind === "image" || clip.inMs !== 0 || clip.outMs !== media.durationMs || clip.rate !== 1) fail("VideoDraft audio has no trim or speed fields");
5487
6095
  }
6096
+ /**
6097
+ * What a recording says: what the selection of the PhoneticScript it was spoken from holds now,
6098
+ * or, for one derived from a script directly, the whole script, one segment per line.
6099
+ */
5488
6100
  function spokenText(snapshot, recordingId) {
5489
- const relations = Object.values(snapshot["derived-from-relations"]);
5490
- const scripts = /* @__PURE__ */ new Set(), seen = /* @__PURE__ */ new Set();
5491
- const visit = (id) => {
5492
- if (seen.has(id)) return;
5493
- seen.add(id);
5494
- if (snapshot["audio-script-entities"][id]) {
5495
- scripts.add(id);
5496
- return;
5497
- }
5498
- for (const r of relations) if (r.entityId === id) visit(r.sourceId);
5499
- };
5500
- visit(recordingId);
5501
- if (!scripts.size) {
5502
- for (const r of relations) if (r.sourceId === recordingId && snapshot["audio-script-entities"][r.entityId]) scripts.add(r.entityId);
5503
- }
5504
- if (scripts.size > 1) projectionError("ambiguous-script", `media-entities/${recordingId}`, "Multiple scripts cannot be silently concatenated into audio_script");
5505
- const id = [...scripts][0];
5506
- return id ? snapshot.richText[id]?.plainText ?? "" : "";
6101
+ const spoken = spokenScripts(snapshot, recordingId);
6102
+ if (new Set(spoken.map((s) => s.phoneticScriptId ?? s.audioScriptId)).size > 1) projectionError("ambiguous-script", `media-entities/${recordingId}`, "Multiple scripts cannot be silently concatenated into audio_script");
6103
+ const [first] = spoken;
6104
+ if (!first) return "";
6105
+ if (first.phoneticScriptId !== void 0) return snapshot.phoneticText[first.phoneticScriptId]?.text ?? "";
6106
+ const script = snapshot["audio-script-entities"][first.audioScriptId];
6107
+ return script ? script.segments.map((segment) => segment.text).join("\n") : "";
5507
6108
  }
5508
6109
  //#endregion
5509
6110
  //#region src/dsl/projection/from-video-draft.ts
@@ -5645,16 +6246,16 @@ function prepare(input, options) {
5645
6246
  let script = scriptByRecording.get(recordingId);
5646
6247
  if (script && script.text !== speech.audio_script) fail(`part_library/${id}/audio_script`, "One recording cannot acquire conflicting source scripts during import");
5647
6248
  if (!script) {
5648
- script = {
5649
- id: claim(mint("audio-script")),
5650
- text: speech.audio_script
5651
- };
6249
+ script = importedScript(claim(mint("audio-script")), speech.audio_script, () => mint("segment"));
5652
6250
  scriptByRecording.set(recordingId, script);
5653
6251
  mutations.push({
5654
6252
  type: "create-entity",
5655
6253
  table: "audio-script-entities",
5656
6254
  id: script.id,
5657
- value: { body: script.text }
6255
+ value: { segments: script.segments.map(({ id, text }) => ({
6256
+ id,
6257
+ text
6258
+ })) }
5658
6259
  }, {
5659
6260
  type: "set-relation",
5660
6261
  table: "derived-from-relations",
@@ -5702,12 +6303,18 @@ function prepare(input, options) {
5702
6303
  const text = caption.text;
5703
6304
  if (typeof text !== "string") fail(`part_library/${captionId}/text`, "Expected Caption text");
5704
6305
  const start = text.length ? script.text.indexOf(text, cursor) : -1;
5705
- if (start >= 0) {
5706
- cursor = start + text.length;
5707
- selections.set(captionId, {
6306
+ const end = start + text.length;
6307
+ const segment = script.segments.find((s) => start >= s.start && end <= s.start + s.text.length);
6308
+ if (start >= 0 && segment) {
6309
+ cursor = end;
6310
+ selections.set(captionId, text === segment.text ? {
6311
+ audioScriptId: script.id,
6312
+ segmentId: segment.id
6313
+ } : {
5708
6314
  audioScriptId: script.id,
5709
- start,
5710
- end: cursor
6315
+ segmentId: segment.id,
6316
+ start: start - segment.start,
6317
+ end: end - segment.start
5711
6318
  });
5712
6319
  } else diagnostics.push({
5713
6320
  code: "caption-text-unmatched",
@@ -5770,28 +6377,19 @@ function prepare(input, options) {
5770
6377
  captionId: sourceId,
5771
6378
  ...selected
5772
6379
  });
5773
- const speechId = caption.speech_part_id, mediaId = speechId ? mediaByPart.get(speechId) : void 0;
5774
- if (mediaId && caption.start_ms !== void 0) {
5775
- const startMs = milliseconds(caption.start_ms, `part_library/${id}/start_ms`), endMs = startMs + (caption.initial_duration_ms ?? outMs);
5776
- const media = imported.get(mediaId);
5777
- if ("durationMs" in media && endMs > startMs && endMs <= media.durationMs) mutations.push({
5778
- type: "set-relation",
5779
- table: "caption-alignment-relations",
5780
- value: {
5781
- captionId: sourceId,
5782
- mediaId,
5783
- startMs,
5784
- endMs
5785
- }
5786
- });
5787
- else diagnostics.push({
5788
- code: "invalid-caption-alignment",
5789
- path: `part_library/${id}`,
5790
- message: "Source interval is invalid; display window is retained without alignment"
5791
- });
5792
- }
5793
6380
  }
5794
6381
  if (outMs <= inMs || !sourceId) fail(`part_library/${id}`, "Expected a nonempty source window");
6382
+ let anchor;
6383
+ if (placement.role !== "video_clip") {
6384
+ const host = hosts.get(id);
6385
+ const speechId = part.caption?.speech_part_id;
6386
+ const offsetMs = host?.offset ?? (speechId ? milliseconds(part.caption?.start_ms ?? 0, `part_library/${id}/start_ms`) : placement.abs);
6387
+ anchor = {
6388
+ to: host?.to ?? speechId ?? timelineId,
6389
+ offsetMs,
6390
+ fallback_abs_ms: placement.abs
6391
+ };
6392
+ }
5795
6393
  mutations.push({
5796
6394
  type: "create-entity",
5797
6395
  table: "clip-entities",
@@ -5803,36 +6401,15 @@ function prepare(input, options) {
5803
6401
  ...speed ? { rate: speed.rate } : {},
5804
6402
  ...part.bgm ? { fillTimeline: true } : {}
5805
6403
  },
6404
+ source: sourceId,
6405
+ ...anchor === void 0 ? {} : { anchor },
5806
6406
  ...volume === void 0 ? {} : { volume }
5807
6407
  }
5808
- }, {
5809
- type: "set-relation",
5810
- table: "clip-source-relations",
5811
- value: {
5812
- clipId: id,
5813
- sourceId
5814
- }
5815
6408
  }, {
5816
6409
  type: "insert-node",
5817
6410
  entityId: id,
5818
6411
  parentId: placement.trackId
5819
6412
  });
5820
- if (placement.role !== "video_clip") {
5821
- const host = hosts.get(id);
5822
- const speechId = part.caption?.speech_part_id;
5823
- const to = host?.to ?? speechId ?? timelineId;
5824
- const offsetMs = host?.offset ?? (speechId ? milliseconds(part.caption?.start_ms ?? 0, `part_library/${id}/start_ms`) : placement.abs);
5825
- mutations.push({
5826
- type: "set-relation",
5827
- table: "time-anchor-relations",
5828
- value: {
5829
- from: id,
5830
- to,
5831
- offsetMs,
5832
- fallback_abs_ms: placement.abs
5833
- }
5834
- });
5835
- }
5836
6413
  }
5837
6414
  return {
5838
6415
  timelineId,
@@ -5848,5 +6425,22 @@ function milliseconds(value, path, positive = false, signed = false) {
5848
6425
  if (typeof value !== "number" || !Number.isSafeInteger(value) || !signed && value < 0 || positive && value <= 0) projectionError("invalid-time", path, "Expected safe integer milliseconds in the valid range");
5849
6426
  return value;
5850
6427
  }
6428
+ function importedScript(id, text, mintSegment) {
6429
+ const segments = [];
6430
+ let start = 0;
6431
+ if (text.length) for (const line of text.split("\n")) {
6432
+ segments.push({
6433
+ id: mintSegment(),
6434
+ text: line,
6435
+ start
6436
+ });
6437
+ start += line.length + 1;
6438
+ }
6439
+ return {
6440
+ id,
6441
+ text,
6442
+ segments
6443
+ };
6444
+ }
5851
6445
  //#endregion
5852
- export { MedeoDsl as a, DslEditor as i, toVideoDraft as n, resolveDslLayout as o, createInitialMedeoDsl as r, DslProjectionError as s, fromVideoDraft as t };
6446
+ export { toAudioScriptViews as a, DslProjectionError as c, DslEditor as i, toVideoDraft as n, MedeoDsl as o, createInitialMedeoDsl as r, resolveDslLayout as s, fromVideoDraft as t };