@mengine/medeo-client 2.1.1-dsl.1 → 2.1.1-dsl.10

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, at as atomicFields, b as schemaContainsIgnoreField, c as isInvalidAtomic, ct as mergeFields, d as schema, dt as validLanguage, et as assertId, f as getDefaultValue, ft as assertRelationIdentity, g as isLoroMapSchema, gt as resourceId, h as isLoroListSchema, ht as relationId, it as assertTimeAnchor, j as hardenCidDescriptors, k as defineCidProperty, l as phonemeCodec, lt as patchRow, m as isListLikeSchema, mt as defaultIdFactory, n as textStyles, nt as assertPhoneticScript, o as medeoDslLoroShape, ot as entityTables, p as isContainerSchema, pt as assertResourceIdentity, q as CID_KEY, rt as assertRelation, s as captionContentCodec, st as isRecord, t as DEFAULT_UNIT_TIME_MS, tt as assertPhoneme, ut as relationTables, v as isLoroTreeSchema, w as applySchemaToInferOptions, x as validateSchema, y as isRootSchemaType, z as isTreeID } from "./video-draft-types-BnbfWrBx.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);
226
+ }
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");
185
240
  }
186
- /** A caption selects a nonempty run of its script's current body, in UTF-16 offsets. */
187
- function assertCaptionSelection(rows, selection, body) {
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,24 @@ 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);
4412
+ break;
4413
+ case "move-segment":
4414
+ tx.moveSegment(m.audioScriptId, m.segmentId, m.index);
4415
+ break;
4416
+ case "delete-segment":
4417
+ tx.deleteSegment(m.audioScriptId, m.segmentId);
4418
+ break;
4274
4419
  case "edit-text":
4275
- tx.editText(m.audioScriptId, m.edits, m.expectedBodyId);
4420
+ tx.editText(m.audioScriptId, m.segmentId, m.edits, m.expectedBodyId);
4276
4421
  break;
4277
4422
  case "edit-caption-text":
4278
4423
  tx.editCaptionText(m.captionId, m.edits, m.expectedBodyId);
@@ -4280,10 +4425,32 @@ function applyMutations(tx, mutations) {
4280
4425
  case "set-caption-content":
4281
4426
  tx.setCaptionContent(m);
4282
4427
  break;
4428
+ case "set-phoneme":
4429
+ tx.setPhoneme(m);
4430
+ break;
4283
4431
  default: changeTree(tx.draft["contain-relations"], m, tx.draft);
4284
4432
  }
4285
4433
  }
4286
4434
  }
4435
+ /**
4436
+ * A derived-from records its source's digest (D177): the one the writer names, otherwise the
4437
+ * source's digest as this transaction leaves it. A source with no digest records none.
4438
+ */
4439
+ function withSourceDigest(rows, table, value) {
4440
+ if (table !== "derived-from-relations") return value;
4441
+ const relation = value;
4442
+ if (relation.sourceDigest !== void 0) return value;
4443
+ const sourceDigest = digestOf(rows, relation.sourceId);
4444
+ return sourceDigest === void 0 ? value : {
4445
+ ...relation,
4446
+ sourceDigest
4447
+ };
4448
+ }
4449
+ /** A source's digest is its file's: a PhoneticScript's or a medium's Media File `digest`. */
4450
+ function digestOf(rows, entityId) {
4451
+ const digest = (rows["phonetic-script-entities"][entityId] ?? rows["media-entities"][entityId])?.digest;
4452
+ return typeof digest === "string" ? digest : void 0;
4453
+ }
4287
4454
  //#endregion
4288
4455
  //#region src/dsl/document/native-text.ts
4289
4456
  function applyTextEdits(text, edits) {
@@ -4295,11 +4462,7 @@ function applyTextEdits(text, edits) {
4295
4462
  text.delete(edit.at, edit.length);
4296
4463
  break;
4297
4464
  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({
4465
+ text.mark({
4303
4466
  start: edit.start,
4304
4467
  end: edit.end
4305
4468
  }, edit.key, edit.value);
@@ -4317,13 +4480,15 @@ function applyTextEdits(text, edits) {
4317
4480
  }
4318
4481
  //#endregion
4319
4482
  //#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);
4483
+ /**
4484
+ * A selection the draft wrote whole, by the DSL's rules: its segment must exist and what it
4485
+ * selects must not be empty. A run's Cursors must lie on that segment's live Text.
4486
+ */
4487
+ function validateSelection(doc, state, table, id, value) {
4488
+ assertReferenceAdmitted(state, table, id, selectionField[table], value);
4489
+ const selection = value;
4490
+ 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");
4491
+ (table === "caption-entities" ? captionContentCodec : phonemeCodec).encode(selection);
4327
4492
  }
4328
4493
  /**
4329
4494
  * The DSL's admission of the whole draft, then what only this document can check: containment
@@ -4355,6 +4520,14 @@ function validateDraft(tracked) {
4355
4520
  }
4356
4521
  //#endregion
4357
4522
  //#region src/dsl/document/transact.ts
4523
+ const codecs = {
4524
+ "caption-entities": captionContentCodec,
4525
+ "phonetic-script-entities": phonemeCodec
4526
+ };
4527
+ function entityRow(doc, table, id) {
4528
+ const row = doc.getMap(table).get(id);
4529
+ return isContainer(row) && row.kind() === "Map" ? row : void 0;
4530
+ }
4358
4531
  /** Serialization happens before preparing any writes, so invalid/cyclic audit data cannot partly commit. */
4359
4532
  function auditOptions(audit) {
4360
4533
  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 +4546,8 @@ const nativeHookSupport = /* @__PURE__ */ new WeakMap();
4373
4546
  /**
4374
4547
  * Every write relies on the patched Mirror's `applyNative` (patches/loro-mirror@2.3.3.patch). An
4375
4548
  * 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.
4549
+ * and selections. The patch runs the hook even for an empty draft without committing, so probe
4550
+ * each Mirror class once on a scratch document and refuse before the first real write.
4378
4551
  */
4379
4552
  function assertNativeHook(mirror) {
4380
4553
  const Impl = mirror.constructor;
@@ -4408,50 +4581,75 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4408
4581
  mirror.setState((raw) => {
4409
4582
  const tracked = new TransactionDraft(raw, before);
4410
4583
  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;
4584
+ /** The current text of a Caption's own Text or a script segment, as this transaction leaves it so far. */
4585
+ const useText = (target) => {
4586
+ let text;
4587
+ if ("captionId" in target) {
4588
+ assertId(target.captionId);
4589
+ const entity = requireEntity(tracked.draft, "caption-entities", target.captionId);
4590
+ if (tracked.deleted.has(target.captionId)) throw new Error("Cannot use a deleted Text");
4591
+ if (tracked.removedCaptionTexts.has(target.captionId)) throw new Error("Cannot edit a removed Text");
4592
+ text = entity.text;
4593
+ } else {
4594
+ assertId(target.audioScriptId);
4595
+ if (tracked.deleted.has(target.audioScriptId)) throw new Error("Cannot use a deleted Text");
4596
+ text = requireSegment(tracked.draft, target.audioScriptId, target.segmentId).text;
4597
+ }
4598
+ if (typeof text !== "string") throw new Error("Missing Text");
4599
+ tracked.textPreviews.set(textKey(target), text);
4600
+ return text;
4420
4601
  };
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);
4602
+ const textBefore = (target) => {
4603
+ if ("captionId" in target) return before["caption-entities"][target.captionId]?.text;
4604
+ return before["audio-script-entities"][target.audioScriptId]?.segments?.find((segment) => segment.id === target.segmentId)?.text;
4605
+ };
4606
+ const flushSelections = () => {
4607
+ for (const [table, id] of tracked.dirtySelections()) {
4608
+ const row = raw[table][id];
4609
+ if (!row) continue;
4610
+ const field = selectionField[table];
4611
+ const value = row[field];
4612
+ const old = before[table][id]?.[field];
4613
+ if (isInvalidAtomic(value)) {
4614
+ if (isInvalidAtomic(old) && old.raw === value.raw) continue;
4615
+ throw new Error(`Invalid ${field}`);
4427
4616
  }
4617
+ if (value !== void 0) validateSelection(doc, raw, table, id, value);
4428
4618
  steps.push({
4429
- type: "caption",
4619
+ type: "selection",
4620
+ table,
4430
4621
  id,
4431
4622
  value: copyInput(value)
4432
4623
  });
4433
4624
  }
4434
- tracked.dirtyCaptions.clear();
4625
+ tracked.clearDirtySelections();
4435
4626
  };
4436
- const editBody = (scriptId, edits, expectedBodyId, caption) => {
4627
+ const editBody = (target, edits, expectedBodyId) => {
4437
4628
  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);
4629
+ flushSelections();
4630
+ const text = useText(target);
4631
+ const key = textKey(target);
4632
+ if (!bodies.has(key)) bodies.set(key, liveText(doc, target));
4633
+ const native = bodies.get(key);
4442
4634
  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);
4635
+ if (textBefore(target) !== void 0 && !native) throw new Error("Missing live Text body");
4636
+ const prepared = prepareTextEdits(text, copyInput(edits));
4637
+ tracked.textPreviews.set(key, prepared.text);
4446
4638
  steps.push({
4447
4639
  type: "text",
4448
- scriptId,
4449
- caption,
4640
+ target,
4450
4641
  expectedBodyId: native?.id,
4451
4642
  edits: prepared.edits
4452
4643
  });
4453
4644
  });
4454
4645
  };
4646
+ /** A script's segments as this transaction leaves them; never a Mirror-tracked write. */
4647
+ const segmentsOf = (audioScriptId) => {
4648
+ assertId(audioScriptId);
4649
+ requireEntity(tracked.draft, "audio-script-entities", audioScriptId);
4650
+ if (tracked.deleted.has(audioScriptId)) throw new Error("Cannot change a deleted AudioScript");
4651
+ return raw["audio-script-entities"][audioScriptId].segments;
4652
+ };
4455
4653
  const tx = Object.freeze({
4456
4654
  get draft() {
4457
4655
  return tracked.run(() => tracked.draft);
@@ -4459,29 +4657,95 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4459
4657
  applyMutations(mutations) {
4460
4658
  tracked.run(() => applyMutations(tx, mutations));
4461
4659
  },
4462
- editText(scriptId, edits, expectedBodyId) {
4463
- editBody(scriptId, edits, expectedBodyId, false);
4660
+ editText(audioScriptId, segmentId, edits, expectedBodyId) {
4661
+ editBody({
4662
+ audioScriptId,
4663
+ segmentId
4664
+ }, edits, expectedBodyId);
4464
4665
  },
4465
4666
  editCaptionText(captionId, edits, expectedBodyId) {
4466
- editBody(captionId, edits, expectedBodyId, true);
4667
+ editBody({ captionId }, edits, expectedBodyId);
4668
+ },
4669
+ insertSegment(audioScriptId, segmentId, text, index) {
4670
+ tracked.run(() => {
4671
+ assertId(segmentId);
4672
+ if (typeof text !== "string") throw new Error("Segment text is a string");
4673
+ const segments = segmentsOf(audioScriptId);
4674
+ if (segments.some((segment) => segment.id === segmentId)) throw new Error(`Segment already exists: ${segmentId}`);
4675
+ const at = index ?? segments.length;
4676
+ if (!Number.isSafeInteger(at) || at < 0 || at > segments.length) throw new Error("Invalid segment index");
4677
+ segments.splice(at, 0, {
4678
+ id: segmentId,
4679
+ text
4680
+ });
4681
+ });
4682
+ },
4683
+ moveSegment(audioScriptId, segmentId, index) {
4684
+ tracked.run(() => {
4685
+ const segments = segmentsOf(audioScriptId);
4686
+ const from = segments.findIndex((segment) => segment.id === segmentId);
4687
+ if (from < 0) throw new Error(`Missing segment ${segmentId} of ${audioScriptId}`);
4688
+ if (!Number.isSafeInteger(index) || index < 0 || index >= segments.length) throw new Error("Invalid segment index");
4689
+ const [segment] = segments.splice(from, 1);
4690
+ segments.splice(index, 0, segment);
4691
+ });
4692
+ },
4693
+ deleteSegment(audioScriptId, segmentId) {
4694
+ tracked.run(() => {
4695
+ const segments = segmentsOf(audioScriptId);
4696
+ const at = segments.findIndex((segment) => segment.id === segmentId);
4697
+ if (at < 0) throw new Error(`Missing segment ${segmentId} of ${audioScriptId}`);
4698
+ if (tracked.textPreviews.has(textKey({
4699
+ audioScriptId,
4700
+ segmentId
4701
+ }))) throw new Error("Cannot delete a segment whose text this transaction edits");
4702
+ segments.splice(at, 1);
4703
+ });
4467
4704
  },
4468
4705
  setCaptionContent(selection) {
4469
4706
  tracked.run(() => {
4470
- flushCaptions();
4471
- const { captionId, audioScriptId, start, end } = selection;
4707
+ flushSelections();
4708
+ const { captionId, audioScriptId, segmentId, start, end } = selection;
4472
4709
  assertId(captionId);
4473
- assertCaptionEndpoints(raw, selection);
4474
- assertCaptionSelection(raw, selection, useBody(audioScriptId));
4710
+ assertCaptionSelection(raw, selection, useText({
4711
+ audioScriptId,
4712
+ segmentId
4713
+ }));
4475
4714
  steps.push({
4476
4715
  type: "range",
4477
- selection: {
4478
- captionId,
4716
+ table: "caption-entities",
4717
+ id: captionId,
4718
+ range: {
4479
4719
  audioScriptId,
4720
+ segmentId,
4480
4721
  start,
4481
4722
  end
4482
4723
  }
4483
4724
  });
4484
4725
  });
4726
+ },
4727
+ setPhoneme(selection) {
4728
+ tracked.run(() => {
4729
+ flushSelections();
4730
+ const { phoneticScriptId, audioScriptId, segmentId, start, end, phonemeScript } = selection;
4731
+ assertId(phoneticScriptId);
4732
+ assertPhonemeSelection(raw, selection, useText({
4733
+ audioScriptId,
4734
+ segmentId
4735
+ }));
4736
+ steps.push({
4737
+ type: "range",
4738
+ table: "phonetic-script-entities",
4739
+ id: phoneticScriptId,
4740
+ range: {
4741
+ audioScriptId,
4742
+ segmentId,
4743
+ start,
4744
+ end
4745
+ },
4746
+ ...phonemeScript === void 0 ? {} : { phonemeScript }
4747
+ });
4748
+ });
4485
4749
  }
4486
4750
  });
4487
4751
  try {
@@ -4494,32 +4758,15 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4494
4758
  }
4495
4759
  tracked.assertValid();
4496
4760
  if (doc.getPendingTxnLength()) throw new Error("Native writes are not allowed during transaction preparation");
4497
- flushCaptions();
4761
+ flushSelections();
4498
4762
  for (const id of tracked.entities.get("caption-entities")?.keys() ?? []) {
4499
4763
  const caption = tracked.draft["caption-entities"][id];
4500
4764
  if (caption) caption.style ??= {};
4501
4765
  }
4502
4766
  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);
4767
+ for (const step of steps) if (step.type === "text") if ("captionId" in step.target) requireEntity(raw, "caption-entities", step.target.captionId);
4768
+ else requireSegment(raw, step.target.audioScriptId, step.target.segmentId);
4769
+ assertSelectionsAfter(raw, before, tracked, steps);
4523
4770
  for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) {
4524
4771
  const row = raw["caption-entities"][id];
4525
4772
  if (fields.has("text") && row?.text !== void 0 && before["caption-entities"][id]?.text === void 0) {
@@ -4527,8 +4774,16 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4527
4774
  delete row.text;
4528
4775
  }
4529
4776
  }
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];
4777
+ for (const table of Object.keys(selectionField)) {
4778
+ const field = selectionField[table];
4779
+ for (const [id, fields] of tracked.entities.get(table) ?? []) {
4780
+ const row = raw[table][id];
4781
+ if (!row || !fields.has(field)) continue;
4782
+ const old = before[table][id]?.[field];
4783
+ if (old === void 0) delete row[field];
4784
+ else row[field] = old;
4785
+ }
4786
+ }
4532
4787
  });
4533
4788
  } finally {
4534
4789
  tracked.close();
@@ -4537,44 +4792,104 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4537
4792
  }, {
4538
4793
  ...options,
4539
4794
  applyNative: (doc) => {
4540
- const bodyFor = (scriptId, caption = false) => {
4541
- let text = bodies.get(scriptId);
4795
+ const textFor = (target) => {
4796
+ const key = textKey(target);
4797
+ let text = bodies.get(key);
4542
4798
  if (!text) {
4543
- text = liveText(doc, scriptId, caption);
4544
- bodies.set(scriptId, text);
4799
+ text = liveText(doc, target);
4800
+ bodies.set(key, text);
4545
4801
  }
4546
- if (!text || text.isDeleted()) throw new Error("Prepared Text body is missing");
4802
+ if (!text || text.isDeleted()) throw new Error("Prepared Text is missing");
4547
4803
  return text;
4548
4804
  };
4549
4805
  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);
4806
+ const text = doc.getMap("caption-entities").get(id).setContainer("text", new LoroText());
4807
+ if (value.length) text.insert(0, value);
4808
+ bodies.set(textKey({ captionId: id }), text);
4553
4809
  }
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));
4810
+ for (const step of steps) {
4811
+ if (step.type === "text") {
4812
+ const text = textFor(step.target);
4813
+ if (step.expectedBodyId && text.id !== step.expectedBodyId) throw new Error("Stale Text body");
4814
+ applyTextEdits(text, step.edits);
4815
+ continue;
4816
+ }
4817
+ const row = entityRow(doc, step.table, step.id);
4818
+ const field = selectionField[step.table];
4819
+ if (step.type === "range") {
4820
+ const { audioScriptId, segmentId, start, end } = step.range;
4821
+ const value = {
4822
+ audioScriptId,
4823
+ segmentId
4824
+ };
4825
+ if (start !== void 0 && end !== void 0) {
4826
+ const text = textFor({
4827
+ audioScriptId,
4828
+ segmentId
4829
+ });
4830
+ value.start = text.getCursor(start, 0);
4831
+ value.end = text.getCursor(end, 0);
4832
+ if (!value.start || !value.end) throw new Error("Unable to construct prepared range");
4833
+ }
4834
+ if (step.phonemeScript !== void 0) value.phonemeScript = step.phonemeScript;
4835
+ if (!row) throw new Error("Unable to construct prepared range");
4836
+ row.set(field, codecs[step.table].encode(value));
4837
+ } else if (step.value !== void 0) {
4838
+ if (!row) throw new Error("Prepared selection owner is missing");
4839
+ row.set(field, codecs[step.table].encode(step.value));
4840
+ } else if (row?.get(field) !== void 0) row.delete(field);
4574
4841
  }
4575
4842
  }
4576
4843
  });
4577
4844
  }
4845
+ /**
4846
+ * What only the whole write can say about selections, with the native steps counted in order:
4847
+ * each Caption keeps one source of words, each selection's segment still exists, and every
4848
+ * PhoneticScript the write touched is written for a segment and stores its text as a file.
4849
+ */
4850
+ function assertSelectionsAfter(raw, before, tracked, steps) {
4851
+ const selected = new Set(Object.entries(before["caption-entities"]).filter(([, caption]) => typeof caption === "object" && caption.selection !== void 0).map(([id]) => id));
4852
+ const changedCaptions = /* @__PURE__ */ new Set();
4853
+ for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (fields.has("text")) changedCaptions.add(id);
4854
+ const phonemes = /* @__PURE__ */ new Map();
4855
+ for (const step of steps) {
4856
+ if (step.type === "text") {
4857
+ if ("captionId" in step.target) changedCaptions.add(step.target.captionId);
4858
+ continue;
4859
+ }
4860
+ const value = step.type === "range" ? {
4861
+ ...step.range,
4862
+ ...step.phonemeScript === void 0 ? {} : { phonemeScript: step.phonemeScript }
4863
+ } : step.value;
4864
+ if (step.table === "caption-entities") {
4865
+ changedCaptions.add(step.id);
4866
+ if (value) selected.add(step.id);
4867
+ else selected.delete(step.id);
4868
+ if (value) assertCaptionEndpoints(raw, {
4869
+ captionId: step.id,
4870
+ ...value
4871
+ });
4872
+ } else {
4873
+ phonemes.set(step.id, value);
4874
+ if (value) assertPhonemeEndpoints(raw, {
4875
+ phoneticScriptId: step.id,
4876
+ ...value
4877
+ });
4878
+ }
4879
+ }
4880
+ for (const id of changedCaptions) if (raw["caption-entities"][id]) assertCaptionSource(raw["caption-entities"][id], selected.has(id));
4881
+ const phonetics = new Set([...tracked.entities.get("phonetic-script-entities")?.keys() ?? [], ...phonemes.keys()]);
4882
+ for (const id of phonetics) {
4883
+ const row = raw["phonetic-script-entities"][id];
4884
+ if (!row) continue;
4885
+ const phoneme = phonemes.has(id) ? phonemes.get(id) : row.phoneme;
4886
+ const old = before["phonetic-script-entities"][id];
4887
+ assertPhoneticScript(old, {
4888
+ ...row,
4889
+ phoneme: isRecord(phoneme) ? phoneme : void 0
4890
+ });
4891
+ }
4892
+ }
4578
4893
  //#endregion
4579
4894
  //#region src/dsl/document/medeo-dsl.ts
4580
4895
  const contentRoots = new Set([
@@ -4665,17 +4980,23 @@ var MedeoDsl = class MedeoDsl {
4665
4980
  applyMutations(mutations, audit) {
4666
4981
  this.transact((tx) => tx.applyMutations(mutations), audit);
4667
4982
  }
4668
- editText(scriptId, expectedBodyId, edits, audit) {
4669
- this.transact((tx) => tx.editText(scriptId, edits, expectedBodyId), audit);
4983
+ editText(scriptId, segmentId, expectedBodyId, edits, audit) {
4984
+ this.transact((tx) => tx.editText(scriptId, segmentId, edits, expectedBodyId), audit);
4670
4985
  }
4671
4986
  editCaptionText(captionId, expectedBodyId, edits, audit) {
4672
4987
  this.transact((tx) => tx.editCaptionText(captionId, edits, expectedBodyId), audit);
4673
4988
  }
4674
- /** Local ranges exclude insertions at their start and include insertions at their end. K01 remains deferred. */
4675
- textRange(scriptId, start, end) {
4989
+ /**
4990
+ * A run inside one segment as native Cursors. Local ranges exclude insertions at their start and
4991
+ * include insertions at their end. K01 remains deferred.
4992
+ */
4993
+ textRange(scriptId, segmentId, start, end) {
4676
4994
  this.assertActive();
4677
- const text = liveText(this.doc, scriptId);
4678
- if (!text) throw new Error("Missing AudioScript body");
4995
+ const text = liveText(this.doc, {
4996
+ audioScriptId: scriptId,
4997
+ segmentId
4998
+ });
4999
+ if (!text) throw new Error("Missing AudioScript segment");
4679
5000
  assertTextOffset(text.toString(), start);
4680
5001
  assertTextOffset(text.toString(), end);
4681
5002
  if (start >= end) throw new Error("Expected a nonempty range");
@@ -4683,6 +5004,7 @@ var MedeoDsl = class MedeoDsl {
4683
5004
  if (!from || !to) throw new Error("Unable to construct text range");
4684
5005
  return {
4685
5006
  audioScriptId: scriptId,
5007
+ segmentId,
4686
5008
  start: from,
4687
5009
  end: to
4688
5010
  };
@@ -4744,16 +5066,30 @@ function voiceoverPlan(s, input, result) {
4744
5066
  id: result.mediaId,
4745
5067
  value: input.media
4746
5068
  }];
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");
5069
+ let segmentIds;
5070
+ if (input.script.segments !== void 0) {
5071
+ if (result.segmentIds.length !== input.script.segments.length) throw new Error("Expected one id per segment");
5072
+ const segments = input.script.segments;
5073
+ segmentIds = result.segmentIds;
5074
+ mutations.push({
5075
+ type: "create-entity",
5076
+ table: "audio-script-entities",
5077
+ id: result.audioScriptId,
5078
+ value: {
5079
+ segments: segments.map((text, i) => ({
5080
+ id: segmentIds[i],
5081
+ text
5082
+ })),
5083
+ ..."defaultLanguage" in input.script && input.script.defaultLanguage !== void 0 ? { defaultLanguage: input.script.defaultLanguage } : {}
5084
+ }
5085
+ });
5086
+ } else {
5087
+ const script = s["audio-script-entities"][result.audioScriptId];
5088
+ if (!script) throw new Error("Missing AudioScript");
5089
+ segmentIds = script.segments.map((segment) => segment.id);
5090
+ }
5091
+ const phoneticScriptId = input.script.phoneticScriptId;
5092
+ 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
5093
  validateWindow({
4758
5094
  inMs: 0,
4759
5095
  outMs: input.media.durationMs
@@ -4763,7 +5099,8 @@ function voiceoverPlan(s, input, result) {
4763
5099
  table: "derived-from-relations",
4764
5100
  value: {
4765
5101
  entityId: result.mediaId,
4766
- sourceId: result.audioScriptId
5102
+ sourceId: phoneticScriptId ?? result.audioScriptId,
5103
+ ...input.sourceDigest === void 0 ? {} : { sourceDigest: input.sourceDigest }
4767
5104
  }
4768
5105
  });
4769
5106
  if (input.voice) {
@@ -4793,6 +5130,7 @@ function voiceoverPlan(s, input, result) {
4793
5130
  rate: void 0,
4794
5131
  fillTimeline: void 0
4795
5132
  },
5133
+ source: result.mediaId,
4796
5134
  ...input.volume === void 0 ? {} : { volume: input.volume }
4797
5135
  }
4798
5136
  } : {
@@ -4804,15 +5142,9 @@ function voiceoverPlan(s, input, result) {
4804
5142
  inMs: 0,
4805
5143
  outMs: input.media.durationMs
4806
5144
  },
5145
+ source: result.mediaId,
4807
5146
  ...input.volume === void 0 ? {} : { volume: input.volume }
4808
5147
  }
4809
- }, {
4810
- type: "set-relation",
4811
- table: "clip-source-relations",
4812
- value: {
4813
- clipId: result.clipId,
4814
- sourceId: result.mediaId
4815
- }
4816
5148
  });
4817
5149
  if (!existing || trackOf(s, result.clipId) !== input.trackId) mutations.push({
4818
5150
  type: existing ? "move-node" : "insert-node",
@@ -4822,13 +5154,14 @@ function voiceoverPlan(s, input, result) {
4822
5154
  mutations.push(...placementMutations(s, result.clipId, input.placement));
4823
5155
  uniqueIds(result.captionClipIds);
4824
5156
  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);
5157
+ const oldCaptions = anchoredTo(s, new Set([result.clipId])).filter((id) => requireTrack(s, trackOf(s, id)).role === "caption");
4826
5158
  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);
5159
+ const placement = mutations.find((m) => m.type === "patch-entity" && m.table === "clip-entities" && m.id === result.clipId && "anchor" in m.patch);
5160
+ const anchor = placement?.type === "patch-entity" && placement.table === "clip-entities" ? placement.patch.anchor : void 0;
4828
5161
  const trackClips = resolveDslLayout(s).tracks.find((track) => track.trackId === input.trackId).clips;
4829
5162
  const slot = trackClips.findIndex((clip) => clip.clipId === result.clipId);
4830
5163
  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;
5164
+ const speechStart = anchor?.fallback_abs_ms !== void 0 ? anchor.fallback_abs_ms : predecessor ? predecessor.startMs + predecessor.durationMs : 0;
4832
5165
  input.captions.forEach((caption, i) => {
4833
5166
  const clipId = result.captionClipIds[i], captionId = result.captionIds[i];
4834
5167
  validateWindow({
@@ -4837,7 +5170,9 @@ function voiceoverPlan(s, input, result) {
4837
5170
  }, input.media.durationMs);
4838
5171
  const previous = s["clip-entities"][clipId];
4839
5172
  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;
5173
+ const segmentId = segmentIds[caption.segment];
5174
+ if (segmentId === void 0) throw new Error(`Missing segment ${caption.segment} of the voiceover's script`);
5175
+ const previousSource = previous?.source;
4841
5176
  const style = caption.style ?? (previousSource ? s["caption-entities"][previousSource]?.style : void 0);
4842
5177
  mutations.push({
4843
5178
  type: "create-entity",
@@ -4848,48 +5183,38 @@ function voiceoverPlan(s, input, result) {
4848
5183
  type: "set-caption-content",
4849
5184
  captionId,
4850
5185
  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
5186
+ segmentId,
5187
+ ...caption.range === void 0 ? {} : {
5188
+ start: caption.range.start,
5189
+ end: caption.range.end
4861
5190
  }
4862
- }, previous ? {
5191
+ });
5192
+ const anchor = {
5193
+ to: result.clipId,
5194
+ offsetMs: caption.startMs,
5195
+ fallback_abs_ms: speechStart + caption.startMs
5196
+ };
5197
+ const seqMarker = {
5198
+ inMs: 0,
5199
+ outMs: caption.durationMs
5200
+ };
5201
+ mutations.push(previous ? {
4863
5202
  type: "patch-entity",
4864
5203
  table: "clip-entities",
4865
5204
  id: clipId,
4866
- patch: { seqMarker: {
4867
- inMs: 0,
4868
- outMs: caption.durationMs
4869
- } }
5205
+ patch: {
5206
+ seqMarker,
5207
+ source: captionId,
5208
+ anchor
5209
+ }
4870
5210
  } : {
4871
5211
  type: "create-entity",
4872
5212
  table: "clip-entities",
4873
5213
  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
5214
  value: {
4889
- from: clipId,
4890
- to: result.clipId,
4891
- offsetMs: caption.startMs,
4892
- fallback_abs_ms: speechStart + caption.startMs
5215
+ seqMarker,
5216
+ source: captionId,
5217
+ anchor
4893
5218
  }
4894
5219
  });
4895
5220
  if (!previous || trackOf(s, clipId) !== input.captionTrackId) mutations.push({
@@ -4906,9 +5231,11 @@ function voiceoverPlan(s, input, result) {
4906
5231
  var DslEditor = class {
4907
5232
  dsl;
4908
5233
  idFactory;
4909
- constructor(dsl, idFactory = defaultIdFactory) {
5234
+ uploadText;
5235
+ constructor(dsl, idFactory = defaultIdFactory, uploadText) {
4910
5236
  this.dsl = dsl;
4911
5237
  this.idFactory = idFactory;
5238
+ this.uploadText = uploadText;
4912
5239
  }
4913
5240
  createTrack(role, options) {
4914
5241
  const id = this.idFactory("track");
@@ -4950,11 +5277,10 @@ var DslEditor = class {
4950
5277
  /** index is measured in the target's children after removing the moved Clip. */
4951
5278
  moveClip(clipId, trackId, index, options) {
4952
5279
  const snapshot = this.dsl.getSnapshot();
4953
- requireClip(snapshot, clipId);
5280
+ const clip = requireClip(snapshot, clipId);
4954
5281
  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);
5282
+ if (!track) throw new Error("Missing target Track or Clip source");
5283
+ requireSource(snapshot, clip.source, track.role);
4958
5284
  this.write("moveClip", {
4959
5285
  clipId,
4960
5286
  trackId,
@@ -4969,8 +5295,7 @@ var DslEditor = class {
4969
5295
  trimClip(clipId, window, options) {
4970
5296
  const snapshot = this.dsl.getSnapshot(), clip = requireClip(snapshot, clipId);
4971
5297
  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;
5298
+ const source = snapshot["media-entities"][clip.source] ?? snapshot["caption-entities"][clip.source];
4974
5299
  if (!source) throw new Error("Cannot trim a Clip with a missing source");
4975
5300
  const patch = {};
4976
5301
  if (window.inMs !== void 0) patch.inMs = window.inMs;
@@ -5013,30 +5338,76 @@ var DslEditor = class {
5013
5338
  patch: { volume }
5014
5339
  }], options);
5015
5340
  }
5016
- createAudioScript(body, defaultLanguage, options) {
5341
+ /** A script of these segments, in order; each segment gets its own id. */
5342
+ createAudioScript(segments, defaultLanguage, options) {
5017
5343
  const id = this.idFactory("audio-script");
5344
+ const value = {
5345
+ segments: segments.map((text) => ({
5346
+ id: this.idFactory("segment"),
5347
+ text
5348
+ })),
5349
+ ...defaultLanguage === void 0 ? {} : { defaultLanguage }
5350
+ };
5018
5351
  this.write("createAudioScript", {
5019
5352
  id,
5020
- body,
5021
- defaultLanguage
5353
+ ...value
5022
5354
  }, [{
5023
5355
  type: "create-entity",
5024
5356
  table: "audio-script-entities",
5025
5357
  id,
5026
- value: {
5027
- body,
5028
- ...defaultLanguage === void 0 ? {} : { defaultLanguage }
5029
- }
5358
+ value
5030
5359
  }], options);
5031
5360
  return id;
5032
5361
  }
5033
- editAudioScript(scriptId, expectedBodyId, edits, options) {
5034
- this.dsl.editText(scriptId, expectedBodyId, edits, audit("editAudioScript", {
5362
+ editAudioScript(scriptId, segmentId, expectedBodyId, edits, options) {
5363
+ this.dsl.editText(scriptId, segmentId, expectedBodyId, edits, audit("editAudioScript", {
5035
5364
  scriptId,
5365
+ segmentId,
5036
5366
  expectedBodyId,
5037
5367
  edits
5038
5368
  }, options));
5039
5369
  }
5370
+ /** Adds a segment at `index`, the end by default, and returns its id. */
5371
+ insertScriptSegment(scriptId, text, index, options) {
5372
+ const segmentId = this.idFactory("segment");
5373
+ this.write("insertScriptSegment", {
5374
+ scriptId,
5375
+ segmentId,
5376
+ text,
5377
+ index
5378
+ }, [{
5379
+ type: "insert-segment",
5380
+ audioScriptId: scriptId,
5381
+ segmentId,
5382
+ text,
5383
+ ...index === void 0 ? {} : { index }
5384
+ }], options);
5385
+ return segmentId;
5386
+ }
5387
+ /** `index` is measured after removing the segment. */
5388
+ moveScriptSegment(scriptId, segmentId, index, options) {
5389
+ this.write("moveScriptSegment", {
5390
+ scriptId,
5391
+ segmentId,
5392
+ index
5393
+ }, [{
5394
+ type: "move-segment",
5395
+ audioScriptId: scriptId,
5396
+ segmentId,
5397
+ index
5398
+ }], options);
5399
+ }
5400
+ /** Captions and PhoneticScripts selecting the segment keep their selection, which then shows nothing (D72). */
5401
+ deleteScriptSegment(scriptId, segmentId, options) {
5402
+ this.write("deleteScriptSegment", {
5403
+ scriptId,
5404
+ segmentId
5405
+ }, [{
5406
+ type: "delete-segment",
5407
+ audioScriptId: scriptId,
5408
+ segmentId
5409
+ }], options);
5410
+ }
5040
5411
  createCaption(input, options) {
5041
5412
  const id = this.idFactory("caption");
5042
5413
  this.write("createCaption", {
@@ -5053,18 +5424,16 @@ var DslEditor = class {
5053
5424
  }, ...input.text !== void 0 ? [] : [{
5054
5425
  type: "set-caption-content",
5055
5426
  captionId: id,
5056
- audioScriptId: input.audioScriptId,
5057
- start: input.start,
5058
- end: input.end
5427
+ ...segmentRange(input)
5059
5428
  }]], options);
5060
5429
  return id;
5061
5430
  }
5062
- setCaptionContent(captionId, audioScriptId, start, end, options) {
5431
+ /** Show a segment of a script, or a run of it, in place of the Caption's own text. */
5432
+ setCaptionContent(captionId, selection, options) {
5433
+ const range = segmentRange(selection);
5063
5434
  this.write("setCaptionContent", {
5064
5435
  captionId,
5065
- audioScriptId,
5066
- start,
5067
- end
5436
+ ...range
5068
5437
  }, [{
5069
5438
  type: "patch-entity",
5070
5439
  table: "caption-entities",
@@ -5073,9 +5442,7 @@ var DslEditor = class {
5073
5442
  }, {
5074
5443
  type: "set-caption-content",
5075
5444
  captionId,
5076
- audioScriptId,
5077
- start,
5078
- end
5445
+ ...range
5079
5446
  }], options);
5080
5447
  }
5081
5448
  setCaptionText(captionId, text, options) {
@@ -5083,9 +5450,10 @@ var DslEditor = class {
5083
5450
  if (!caption) throw new Error("Missing Caption");
5084
5451
  this.dsl.transact((tx) => {
5085
5452
  tx.applyMutations([{
5086
- type: "delete-relation",
5087
- table: "caption-content-relations",
5088
- id: `cc:${captionId}`
5453
+ type: "patch-entity",
5454
+ table: "caption-entities",
5455
+ id: captionId,
5456
+ patch: { selection: void 0 }
5089
5457
  }]);
5090
5458
  if (caption.text === void 0) tx.draft["caption-entities"][captionId].text = text;
5091
5459
  else if (caption.text !== text) tx.editCaptionText(captionId, [...caption.text.length ? [{
@@ -5199,7 +5567,8 @@ var DslEditor = class {
5199
5567
  const result = {
5200
5568
  clipId: input.clipId ?? this.idFactory("clip"),
5201
5569
  mediaId: resourceId("media", input.media.system, input.media.key),
5202
- audioScriptId: input.script.audioScriptId ?? this.idFactory("audio-script"),
5570
+ audioScriptId: this.voiceoverScriptId(input.script),
5571
+ segmentIds: input.script.segments?.map(() => this.idFactory("segment")) ?? [],
5203
5572
  captionClipIds: input.captions.map((c) => c.clipId ?? this.idFactory("clip")),
5204
5573
  captionIds: input.captions.map(() => this.idFactory("caption"))
5205
5574
  };
@@ -5209,6 +5578,68 @@ var DslEditor = class {
5209
5578
  }, voiceoverPlan(this.dsl.getSnapshot(), input, result), options);
5210
5579
  return result;
5211
5580
  }
5581
+ voiceoverScriptId(script) {
5582
+ if (script.audioScriptId !== void 0) return script.audioScriptId;
5583
+ if (script.phoneticScriptId === void 0) return this.idFactory("audio-script");
5584
+ const phonetic = this.dsl.getSnapshot()["phonetic-script-entities"][script.phoneticScriptId];
5585
+ if (!phonetic) throw new Error("Missing PhoneticScript");
5586
+ return phonetic.phoneme.audioScriptId;
5587
+ }
5588
+ /**
5589
+ * Write a PhoneticScript: create one for a segment of a script, or a run of it, or, given
5590
+ * `phoneticScriptId`, replace its selection and what to synthesize together (D181). A
5591
+ * `phonemeScript` is stored as a Memota text file first, and the file's facts are written with
5592
+ * it; an unchanged one keeps its file. Recordings already spoken from it keep their derived-from.
5593
+ */
5594
+ async setPhoneticScript(input, options) {
5595
+ const id = input.phoneticScriptId ?? this.idFactory("phonetic-script");
5596
+ const range = segmentRange(input);
5597
+ const file = input.phonemeScript === void 0 ? {} : await this.fileFor(id, input.phonemeScript);
5598
+ const exists = Object.hasOwn(this.dsl.getSnapshot()["phonetic-script-entities"], id);
5599
+ const cleared = {
5600
+ system: void 0,
5601
+ key: void 0,
5602
+ storageKey: void 0,
5603
+ digest: void 0
5604
+ };
5605
+ const phonemeScript = input.phonemeScript === void 0 ? {} : { phonemeScript: input.phonemeScript };
5606
+ this.write("setPhoneticScript", {
5607
+ id,
5608
+ ...range,
5609
+ ...phonemeScript,
5610
+ ...cleared,
5611
+ ...file
5612
+ }, [exists ? {
5613
+ type: "patch-entity",
5614
+ table: "phonetic-script-entities",
5615
+ id,
5616
+ patch: {
5617
+ ...cleared,
5618
+ ...file
5619
+ }
5620
+ } : {
5621
+ type: "create-entity",
5622
+ table: "phonetic-script-entities",
5623
+ id,
5624
+ value: file
5625
+ }, {
5626
+ type: "set-phoneme",
5627
+ phoneticScriptId: id,
5628
+ ...range,
5629
+ ...phonemeScript
5630
+ }], options);
5631
+ return id;
5632
+ }
5633
+ /** The file holding `text`: the one the PhoneticScript keeps while its text is unchanged, else a new upload. */
5634
+ async fileFor(id, text) {
5635
+ const current = this.dsl.getSnapshot()["phonetic-script-entities"][id];
5636
+ if (current?.phoneme.phonemeScript === text) {
5637
+ const { phoneme: _, ...file } = current;
5638
+ return file;
5639
+ }
5640
+ if (!this.uploadText) throw new Error("This editor stores no files");
5641
+ return this.uploadText(text);
5642
+ }
5212
5643
  setBgm(input, options) {
5213
5644
  const s = this.dsl.getSnapshot();
5214
5645
  if (requireTrack(s, input.trackId).role !== "bgm") throw new Error("Expected a BGM Track");
@@ -5227,37 +5658,28 @@ var DslEditor = class {
5227
5658
  fillTimeline: true
5228
5659
  };
5229
5660
  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
- }
5661
+ const mutations = [...source.mutations, old ? {
5662
+ type: "patch-entity",
5663
+ table: "clip-entities",
5664
+ id,
5665
+ patch: {
5666
+ seqMarker: {
5667
+ ...seqMarker,
5668
+ rate: void 0
5669
+ },
5670
+ source: source.id,
5671
+ ...volume
5259
5672
  }
5260
- ];
5673
+ } : {
5674
+ type: "create-entity",
5675
+ table: "clip-entities",
5676
+ id,
5677
+ value: {
5678
+ seqMarker,
5679
+ source: source.id,
5680
+ ...volume
5681
+ }
5682
+ }];
5261
5683
  if (!old) mutations.push({
5262
5684
  type: "insert-node",
5263
5685
  entityId: id,
@@ -5273,6 +5695,15 @@ var DslEditor = class {
5273
5695
  this.dsl.applyMutations(mutations, audit(action, payload, options));
5274
5696
  }
5275
5697
  };
5698
+ /** A selection as a write states it: a run only when both its ends are given. */
5699
+ function segmentRange(input) {
5700
+ return {
5701
+ audioScriptId: input.audioScriptId,
5702
+ segmentId: input.segmentId,
5703
+ ...input.start === void 0 ? {} : { start: input.start },
5704
+ ...input.end === void 0 ? {} : { end: input.end }
5705
+ };
5706
+ }
5276
5707
  function audit(action, payload, options) {
5277
5708
  return {
5278
5709
  semantic_op: action,
@@ -5312,6 +5743,132 @@ function createInitialMedeoDsl(options = {}) {
5312
5743
  }
5313
5744
  }
5314
5745
  //#endregion
5746
+ //#region src/dsl/projection/audio-scripts.ts
5747
+ /**
5748
+ * The scripts a recording says. It follows derived-from from the recording; a PhoneticScript is a
5749
+ * variant, so its words are the base its phoneme names, and derived-from is not followed past it.
5750
+ * When nothing is found that way, a script read off the recording itself is what it says.
5751
+ */
5752
+ function spokenScripts(snapshot, recordingId) {
5753
+ const relations = Object.values(snapshot["derived-from-relations"]);
5754
+ const found = /* @__PURE__ */ new Map();
5755
+ const seen = /* @__PURE__ */ new Set();
5756
+ const visit = (id, phoneticScriptId) => {
5757
+ if (seen.has(id)) return;
5758
+ seen.add(id);
5759
+ if (snapshot["audio-script-entities"][id]) {
5760
+ if (!found.has(id)) found.set(id, phoneticScriptId);
5761
+ return;
5762
+ }
5763
+ const phonetic = snapshot["phonetic-script-entities"][id];
5764
+ if (phonetic) {
5765
+ visit(phonetic.phoneme.audioScriptId, id);
5766
+ return;
5767
+ }
5768
+ for (const r of relations) if (r.entityId === id) visit(r.sourceId, phoneticScriptId);
5769
+ };
5770
+ visit(recordingId);
5771
+ if (!found.size) {
5772
+ for (const r of relations) if (r.sourceId === recordingId && snapshot["audio-script-entities"][r.entityId]) found.set(r.entityId, void 0);
5773
+ }
5774
+ return [...found].map(([audioScriptId, phoneticScriptId]) => phoneticScriptId === void 0 ? { audioScriptId } : {
5775
+ audioScriptId,
5776
+ phoneticScriptId
5777
+ });
5778
+ }
5779
+ /** Every AudioScript in the document with its PhoneticScripts, the Speech Clips saying it and the Caption Clips showing it. */
5780
+ function toAudioScriptViews(snapshot) {
5781
+ const views = /* @__PURE__ */ new Map();
5782
+ const segmentIndex = (scriptId, segmentId) => {
5783
+ const index = snapshot["audio-script-entities"][scriptId]?.segments.findIndex((s) => s.id === segmentId) ?? -1;
5784
+ return index < 0 ? void 0 : index;
5785
+ };
5786
+ for (const [id, script] of Object.entries(snapshot["audio-script-entities"])) views.set(id, {
5787
+ id,
5788
+ segments: script.segments.map((segment) => {
5789
+ const rich = snapshot.richText[segment.id];
5790
+ const language = rich ? markedLanguage(langsFromDelta(rich.delta)) : void 0;
5791
+ return language === void 0 ? {
5792
+ id: segment.id,
5793
+ text: segment.text
5794
+ } : {
5795
+ id: segment.id,
5796
+ text: segment.text,
5797
+ language
5798
+ };
5799
+ }),
5800
+ sourceMediaIds: [],
5801
+ phoneticScripts: [],
5802
+ speeches: [],
5803
+ captions: []
5804
+ });
5805
+ for (const r of Object.values(snapshot["derived-from-relations"])) if (snapshot["media-entities"][r.sourceId]) views.get(r.entityId)?.sourceMediaIds.push(r.sourceId);
5806
+ for (const [id, phonetic] of Object.entries(snapshot["phonetic-script-entities"])) {
5807
+ const { audioScriptId, segmentId, phonemeScript } = phonetic.phoneme;
5808
+ const { digest } = phonetic;
5809
+ const segment = segmentIndex(audioScriptId, segmentId);
5810
+ const text = snapshot.phoneticText[id]?.text;
5811
+ views.get(audioScriptId)?.phoneticScripts.push({
5812
+ id,
5813
+ ...segment === void 0 ? {} : { segment },
5814
+ ...text === void 0 ? {} : { text },
5815
+ ...phonemeScript === void 0 ? {} : { phonemeScript },
5816
+ ...digest === void 0 ? {} : { digest }
5817
+ });
5818
+ }
5819
+ const timbres = Object.values(snapshot["voice-timbre-relations"]);
5820
+ for (const track of resolveDslLayout(snapshot).tracks) for (const { clipId } of track.clips) {
5821
+ const source = snapshot["clip-entities"][clipId]?.source;
5822
+ if (!source) continue;
5823
+ if (snapshot["media-entities"][source]?.kind === "voiceover-recording") {
5824
+ const voices = timbres.filter((r) => r.recordingId === source).flatMap((r) => {
5825
+ const voice = snapshot["voice-entities"][r.voiceId];
5826
+ if (!voice) return [];
5827
+ return [voice.name === void 0 ? {
5828
+ voiceId: r.voiceId,
5829
+ key: voice.key
5830
+ } : {
5831
+ voiceId: r.voiceId,
5832
+ key: voice.key,
5833
+ name: voice.name
5834
+ }];
5835
+ });
5836
+ for (const spoken of spokenScripts(snapshot, source)) {
5837
+ const applied = appliedSpeech(snapshot, source, spoken);
5838
+ views.get(spoken.audioScriptId)?.speeches.push({
5839
+ clipId,
5840
+ recordingId: source,
5841
+ ...spoken.phoneticScriptId === void 0 ? {} : { phoneticScriptId: spoken.phoneticScriptId },
5842
+ voices,
5843
+ ...applied === void 0 ? {} : { applied }
5844
+ });
5845
+ }
5846
+ continue;
5847
+ }
5848
+ const caption = snapshot["caption-entities"][source];
5849
+ if (!caption?.selection || caption.text !== void 0 || !snapshot.captionText[source]) continue;
5850
+ const { audioScriptId, segmentId } = caption.selection;
5851
+ const segment = segmentIndex(audioScriptId, segmentId);
5852
+ if (segment !== void 0) views.get(audioScriptId)?.captions.push({
5853
+ clipId,
5854
+ captionId: source,
5855
+ segment
5856
+ });
5857
+ }
5858
+ return [...views.values()];
5859
+ }
5860
+ /**
5861
+ * Whether a recording still matches what it was synthesized from: the digest its derived-from
5862
+ * records against the source's file digest now. An AudioScript has no file, so a recording
5863
+ * derived from a script directly is not judged.
5864
+ */
5865
+ function appliedSpeech(snapshot, recordingId, spoken) {
5866
+ const sourceId = spoken.phoneticScriptId ?? spoken.audioScriptId;
5867
+ const recorded = Object.values(snapshot["derived-from-relations"]).find((r) => r.entityId === recordingId && r.sourceId === sourceId)?.sourceDigest;
5868
+ const current = spoken.phoneticScriptId === void 0 ? void 0 : snapshot["phonetic-script-entities"][sourceId]?.digest;
5869
+ return recorded === void 0 || current === void 0 ? void 0 : recorded === current;
5870
+ }
5871
+ //#endregion
5315
5872
  //#region src/dsl/projection/caption-style.ts
5316
5873
  const fields = {
5317
5874
  fontSize: "font_size",
@@ -5485,25 +6042,18 @@ function toVideoDraft(snapshot) {
5485
6042
  function requireWholeAudio(media, clip, fail) {
5486
6043
  if (media.kind === "image" || clip.inMs !== 0 || clip.outMs !== media.durationMs || clip.rate !== 1) fail("VideoDraft audio has no trim or speed fields");
5487
6044
  }
6045
+ /**
6046
+ * What a recording says: what the selection of the PhoneticScript it was spoken from holds now,
6047
+ * or, for one derived from a script directly, the whole script, one segment per line.
6048
+ */
5488
6049
  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 ?? "" : "";
6050
+ const spoken = spokenScripts(snapshot, recordingId);
6051
+ 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");
6052
+ const [first] = spoken;
6053
+ if (!first) return "";
6054
+ if (first.phoneticScriptId !== void 0) return snapshot.phoneticText[first.phoneticScriptId]?.text ?? "";
6055
+ const script = snapshot["audio-script-entities"][first.audioScriptId];
6056
+ return script ? script.segments.map((segment) => segment.text).join("\n") : "";
5507
6057
  }
5508
6058
  //#endregion
5509
6059
  //#region src/dsl/projection/from-video-draft.ts
@@ -5645,16 +6195,16 @@ function prepare(input, options) {
5645
6195
  let script = scriptByRecording.get(recordingId);
5646
6196
  if (script && script.text !== speech.audio_script) fail(`part_library/${id}/audio_script`, "One recording cannot acquire conflicting source scripts during import");
5647
6197
  if (!script) {
5648
- script = {
5649
- id: claim(mint("audio-script")),
5650
- text: speech.audio_script
5651
- };
6198
+ script = importedScript(claim(mint("audio-script")), speech.audio_script, () => mint("segment"));
5652
6199
  scriptByRecording.set(recordingId, script);
5653
6200
  mutations.push({
5654
6201
  type: "create-entity",
5655
6202
  table: "audio-script-entities",
5656
6203
  id: script.id,
5657
- value: { body: script.text }
6204
+ value: { segments: script.segments.map(({ id, text }) => ({
6205
+ id,
6206
+ text
6207
+ })) }
5658
6208
  }, {
5659
6209
  type: "set-relation",
5660
6210
  table: "derived-from-relations",
@@ -5702,12 +6252,18 @@ function prepare(input, options) {
5702
6252
  const text = caption.text;
5703
6253
  if (typeof text !== "string") fail(`part_library/${captionId}/text`, "Expected Caption text");
5704
6254
  const start = text.length ? script.text.indexOf(text, cursor) : -1;
5705
- if (start >= 0) {
5706
- cursor = start + text.length;
5707
- selections.set(captionId, {
6255
+ const end = start + text.length;
6256
+ const segment = script.segments.find((s) => start >= s.start && end <= s.start + s.text.length);
6257
+ if (start >= 0 && segment) {
6258
+ cursor = end;
6259
+ selections.set(captionId, text === segment.text ? {
5708
6260
  audioScriptId: script.id,
5709
- start,
5710
- end: cursor
6261
+ segmentId: segment.id
6262
+ } : {
6263
+ audioScriptId: script.id,
6264
+ segmentId: segment.id,
6265
+ start: start - segment.start,
6266
+ end: end - segment.start
5711
6267
  });
5712
6268
  } else diagnostics.push({
5713
6269
  code: "caption-text-unmatched",
@@ -5770,28 +6326,19 @@ function prepare(input, options) {
5770
6326
  captionId: sourceId,
5771
6327
  ...selected
5772
6328
  });
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
6329
  }
5794
6330
  if (outMs <= inMs || !sourceId) fail(`part_library/${id}`, "Expected a nonempty source window");
6331
+ let anchor;
6332
+ if (placement.role !== "video_clip") {
6333
+ const host = hosts.get(id);
6334
+ const speechId = part.caption?.speech_part_id;
6335
+ const offsetMs = host?.offset ?? (speechId ? milliseconds(part.caption?.start_ms ?? 0, `part_library/${id}/start_ms`) : placement.abs);
6336
+ anchor = {
6337
+ to: host?.to ?? speechId ?? timelineId,
6338
+ offsetMs,
6339
+ fallback_abs_ms: placement.abs
6340
+ };
6341
+ }
5795
6342
  mutations.push({
5796
6343
  type: "create-entity",
5797
6344
  table: "clip-entities",
@@ -5803,36 +6350,15 @@ function prepare(input, options) {
5803
6350
  ...speed ? { rate: speed.rate } : {},
5804
6351
  ...part.bgm ? { fillTimeline: true } : {}
5805
6352
  },
6353
+ source: sourceId,
6354
+ ...anchor === void 0 ? {} : { anchor },
5806
6355
  ...volume === void 0 ? {} : { volume }
5807
6356
  }
5808
- }, {
5809
- type: "set-relation",
5810
- table: "clip-source-relations",
5811
- value: {
5812
- clipId: id,
5813
- sourceId
5814
- }
5815
6357
  }, {
5816
6358
  type: "insert-node",
5817
6359
  entityId: id,
5818
6360
  parentId: placement.trackId
5819
6361
  });
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
6362
  }
5837
6363
  return {
5838
6364
  timelineId,
@@ -5848,5 +6374,22 @@ function milliseconds(value, path, positive = false, signed = false) {
5848
6374
  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
6375
  return value;
5850
6376
  }
6377
+ function importedScript(id, text, mintSegment) {
6378
+ const segments = [];
6379
+ let start = 0;
6380
+ if (text.length) for (const line of text.split("\n")) {
6381
+ segments.push({
6382
+ id: mintSegment(),
6383
+ text: line,
6384
+ start
6385
+ });
6386
+ start += line.length + 1;
6387
+ }
6388
+ return {
6389
+ id,
6390
+ text,
6391
+ segments
6392
+ };
6393
+ }
5851
6394
  //#endregion
5852
- export { MedeoDsl as a, DslEditor as i, toVideoDraft as n, resolveDslLayout as o, createInitialMedeoDsl as r, DslProjectionError as s, fromVideoDraft as t };
6395
+ export { DslEditor as a, DslProjectionError as c, createInitialMedeoDsl as i, toVideoDraft as n, MedeoDsl as o, toAudioScriptViews as r, resolveDslLayout as s, fromVideoDraft as t };