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

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 assertId, 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 assertEntityPatch, R as isTreeID, S as applyEncode, T as containerIdToContainerType, U as tryInferContainerType, V as schemaToContainerType, W as tryUpdateToContainer, X as assertCaptionContent, Y as getMapFieldSchema, Z as assertEntity, _ as isLoroTreeSchema, at as isRecord, b as validateSchema, c as isInvalidAtomic, ct as relationTables, d as getDefaultValue, dt as assertResourceIdentity, et as assertPhoneme, f as isContainerSchema, ft as defaultIdFactory, g as isLoroMovableListSchema, h as isLoroMapSchema, it as entityTables, j as hasTransform, k as getRootContainerByType, lt as validLanguage, m as isLoroListSchema, mt as resourceId, n as textStyles, nt as assertTimeAnchor, o as medeoDslLoroShape, ot as mergeFields, p as isListLikeSchema, pt as relationId, q as getChildContainerSchema, rt as atomicFields, s as captionContentCodec, st as patchRow, t as DEFAULT_UNIT_TIME_MS, tt as assertRelation, u as schema, ut as assertRelationIdentity, v as isRootSchemaType, w as cidsEqual, x as applyDecode, y as schemaContainsIgnoreField, z as isValueOfContainerType } from "./video-draft-types-DvPeam87.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);
@@ -122,60 +117,73 @@ function requireEntity(rows, table, id) {
122
117
  return row;
123
118
  }
124
119
  /**
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}.
120
+ * Whether one relation row may stand among these rows: its fields, its derived ID, and what its
121
+ * kind requires of each end. A row being linked need not be in `rows` yet. Relations an entity
122
+ * holds at most one of are fields of that entity; see {@link assertReferenceAdmitted}.
128
123
  */
129
124
  function assertRelationAdmitted(rows, table, id, value) {
130
125
  assertId(id);
131
126
  assertRelation(table, value);
132
127
  assertRelationIdentity(table, id, value);
133
128
  const v = value;
134
- const require = (endpoint, id) => requireEntity(rows, endpoint, id);
135
129
  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
130
  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);
131
+ if (requireEntity(rows, "media-entities", v.recordingId).kind !== "voiceover-recording") throw new Error("Timbre requires a voiceover recording");
132
+ requireEntity(rows, "voice-entities", v.voiceId);
168
133
  break;
169
134
  case "derived-from-relations":
170
135
  for (const end of [v.entityId, v.sourceId]) if (!entityTables.some((t) => Object.hasOwn(rows[t], end))) throw new Error("Missing provenance endpoint");
171
136
  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");
137
+ }
138
+ }
139
+ /** Entity fields that hold a single-valued relation, by the entity that owns it. */
140
+ const referenceFields = {
141
+ "clip-entities": ["source", "anchor"],
142
+ "phonetic-script-entities": ["phoneme"],
143
+ "caption-entities": ["selection"]
144
+ };
145
+ /**
146
+ * Whether a single-valued relation, stored as a field of the entity that owns it, may hold this
147
+ * value among these rows. The owner need not be stored yet; an absent value unlinks it.
148
+ * A caption selection's range is checked against the live body by whoever holds it.
149
+ */
150
+ function assertReferenceAdmitted(rows, table, id, field, value) {
151
+ assertId(id);
152
+ const key = `${table}.${field}`;
153
+ if (value === void 0) {
154
+ if (key === "clip-entities.source" || key === "phonetic-script-entities.phoneme") throw new Error(`Missing required relation: ${key}`);
155
+ return;
156
+ }
157
+ switch (key) {
158
+ case "clip-entities.source":
159
+ assertId(value);
160
+ if (!Object.hasOwn(rows["media-entities"], value) && !Object.hasOwn(rows["caption-entities"], value)) throw new Error("Invalid Clip source");
161
+ break;
162
+ case "clip-entities.anchor": {
163
+ assertTimeAnchor(value);
164
+ const anchor = value;
165
+ if (Object.hasOwn(rows["timeline-entities"], anchor.to)) {
166
+ if (anchor.offsetMs < 0) throw new Error("Absolute offset must be nonnegative");
167
+ break;
168
+ }
169
+ requireEntity(rows, "clip-entities", anchor.to);
170
+ const visited = new Set([id]);
171
+ for (let next = anchor.to; typeof next === "string";) {
172
+ if (visited.has(next)) throw new Error("Time anchor cycle");
173
+ visited.add(next);
174
+ const clip = rows["clip-entities"][next];
175
+ next = isRecord(clip) && isRecord(clip.anchor) ? clip.anchor.to : void 0;
176
+ }
177
177
  break;
178
178
  }
179
+ case "caption-entities.selection":
180
+ assertCaptionContent(value);
181
+ requireEntity(rows, "audio-script-entities", value.audioScriptId);
182
+ break;
183
+ case "phonetic-script-entities.phoneme":
184
+ assertPhoneme(value);
185
+ requireEntity(rows, "audio-script-entities", value.audioScriptId);
186
+ break;
179
187
  }
180
188
  }
181
189
  /** Both ends of a caption's selection exist: the caption and the script it selects from. */
@@ -261,8 +269,9 @@ function replacementPatch(before, after) {
261
269
  /**
262
270
  * Admission of one whole change, asked once before it is published. Only what the change touched
263
271
  * 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`.
272
+ * Single-valued relations are fields of their owner and are checked when the owner's field is
273
+ * touched. Containment and caption ranges are checked by whoever holds the tree and the script
274
+ * bodies, with `assertContainTree` and `assertCaptionSelection`.
266
275
  */
267
276
  function assertChangeAdmitted(change) {
268
277
  const { before, after } = change;
@@ -278,16 +287,15 @@ function assertChangeAdmitted(change) {
278
287
  if (!isRecord(value)) throw new Error("Expected an entity object");
279
288
  assertEntityPatch(table, Object.fromEntries([...fields].map((field) => [field, replacementPatch(old[field], value[field])])), old);
280
289
  }
281
- const marker = value.seqMarker;
290
+ const row = value;
291
+ for (const field of referenceFields[table] ?? []) if (!old || fields.has(field)) assertReferenceAdmitted(after, table, id, field, row[field]);
292
+ const marker = row.seqMarker;
282
293
  if (table === "clip-entities" && (!old || fields.has("seqMarker")) && marker?.outMs <= marker?.inMs) throw new Error("Clip window must be nonempty");
283
294
  }
284
295
  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
- }
296
+ for (const [table, ids] of change.relations) for (const id of ids) {
297
+ const value = after[table][id];
298
+ if (value !== void 0) assertRelationAdmitted(after, table, id, value);
291
299
  }
292
300
  }
293
301
  //#endregion
@@ -331,9 +339,9 @@ function resolveDslLayout(snapshot) {
331
339
  const clips = [];
332
340
  for (const child of node.children) {
333
341
  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];
342
+ const sourceId = clip.source;
343
+ const media = snapshot["media-entities"][sourceId];
344
+ const caption = snapshot["caption-entities"][sourceId];
337
345
  const marker = clip.seqMarker;
338
346
  let inMs = marker.inMs, outMs = marker.outMs;
339
347
  const present = media !== void 0 || caption !== void 0;
@@ -347,7 +355,7 @@ function resolveDslLayout(snapshot) {
347
355
  const rate = marker.rate ?? 1;
348
356
  const duration = present ? Math.max(0, Math.round((outMs - inMs) / rate)) : 0;
349
357
  if (!Number.isSafeInteger(duration)) projectionError("time-overflow", `clip-entities/${id}`, "Duration exceeds safe integer milliseconds");
350
- const anchor = snapshot["time-anchor-relations"][`ta:${id}`];
358
+ const anchor = clip.anchor;
351
359
  const placement = {
352
360
  clipId: id,
353
361
  trackId: node.entityId,
@@ -384,7 +392,7 @@ function resolveDslLayout(snapshot) {
384
392
  }
385
393
  seen.set(current, path.length);
386
394
  path.push(current);
387
- current = snapshot["time-anchor-relations"][`ta:${current}`]?.to;
395
+ current = snapshot["clip-entities"][current]?.anchor?.to;
388
396
  }
389
397
  for (const member of path) done.add(member);
390
398
  }
@@ -393,7 +401,7 @@ function resolveDslLayout(snapshot) {
393
401
  const position = (id) => {
394
402
  const known = positions.get(id);
395
403
  if (known) return known;
396
- const clip = result.clips[id], anchor = snapshot["time-anchor-relations"][`ta:${id}`];
404
+ const clip = result.clips[id], anchor = snapshot["clip-entities"][id]?.anchor;
397
405
  let value;
398
406
  if (anchor && clip.mode === "anchored" && !cycle.has(id) && result.clips[anchor.to]) {
399
407
  const parent = position(anchor.to);
@@ -500,14 +508,6 @@ function resolveDslLayout(snapshot) {
500
508
  const a = track.clips[i];
501
509
  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
510
  }
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
511
  return result;
512
512
  }
513
513
  function overlap(a, b) {
@@ -563,9 +563,10 @@ function timelineId(s) {
563
563
  /** Placement facts are authored only by explicit actions; projection never writes them back. */
564
564
  function placementMutations(s, clipId, placement) {
565
565
  if (placement.kind === "sequential") return [{
566
- type: "delete-relation",
567
- table: "time-anchor-relations",
568
- id: `ta:${clipId}`
566
+ type: "patch-entity",
567
+ table: "clip-entities",
568
+ id: clipId,
569
+ patch: { anchor: void 0 }
569
570
  }];
570
571
  const offset = placement.kind === "absolute" ? placement.startMs : placement.offsetMs;
571
572
  if (!Number.isSafeInteger(offset) || placement.kind === "absolute" && offset < 0) throw new Error("Invalid placement time");
@@ -578,27 +579,31 @@ function placementMutations(s, clipId, placement) {
578
579
  while (next && s["clip-entities"][next]) {
579
580
  if (visited.has(next)) throw new Error("Cyclic time anchor");
580
581
  visited.add(next);
581
- next = s["time-anchor-relations"][`ta:${next}`]?.to;
582
+ next = s["clip-entities"][next]?.anchor?.to;
582
583
  }
583
584
  fallback = Math.max(0, resolveDslLayout(s).clips[target].startMs + offset);
584
585
  }
585
586
  if (!Number.isSafeInteger(fallback)) throw new Error("Invalid fallback time");
586
587
  return [{
587
- type: "set-relation",
588
- table: "time-anchor-relations",
589
- value: {
590
- from: clipId,
588
+ type: "patch-entity",
589
+ table: "clip-entities",
590
+ id: clipId,
591
+ patch: { anchor: {
591
592
  to: target,
592
593
  offsetMs: offset,
593
594
  fallback_abs_ms: fallback
594
- }
595
+ } }
595
596
  }];
596
597
  }
598
+ /** Clips whose time anchor points at a Clip in `ids`, with that anchor. */
599
+ function anchoredTo(s, ids) {
600
+ return Object.entries(s["clip-entities"]).flatMap(([id, clip]) => clip.anchor && ids.has(clip.anchor.to) ? [id] : []);
601
+ }
597
602
  function detachChildren(s, ids) {
598
603
  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, {
604
+ return anchoredTo(s, ids).flatMap((id) => !ids.has(id) && layout.clips[id] ? placementMutations(s, id, {
600
605
  kind: "absolute",
601
- startMs: layout.clips[r.from].startMs
606
+ startMs: layout.clips[id].startMs
602
607
  }) : []);
603
608
  }
604
609
  function deleteClips(s, ids, policy) {
@@ -608,9 +613,9 @@ function deleteClips(s, ids, policy) {
608
613
  const removed = new Set(ids);
609
614
  if (policy === "cascade") {
610
615
  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);
616
+ for (let i = 0; i < queue.length; i++) for (const id of anchoredTo(s, new Set([queue[i]]))) if (!removed.has(id)) {
617
+ removed.add(id);
618
+ queue.push(id);
614
619
  }
615
620
  }
616
621
  const mutations = policy === "detach" ? detachChildren(s, removed) : [];
@@ -620,14 +625,6 @@ function deleteClips(s, ids, policy) {
620
625
  entityId: id
621
626
  });
622
627
  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
628
  type: "delete-entity",
632
629
  table: "clip-entities",
633
630
  id
@@ -640,11 +637,10 @@ function deleteClips(s, ids, policy) {
640
637
  /** Batch edits validate against preceding prepared facts without touching the live document. */
641
638
  function updateClipsPlan(base, updates) {
642
639
  uniqueIds(updates.map((update) => update.clipId));
643
- const clips = { ...base["clip-entities"] }, anchors = { ...base["time-anchor-relations"] };
640
+ const clips = { ...base["clip-entities"] };
644
641
  const preview = {
645
642
  ...base,
646
- "clip-entities": clips,
647
- "time-anchor-relations": anchors
643
+ "clip-entities": clips
648
644
  };
649
645
  const mutations = [];
650
646
  for (const update of updates) {
@@ -660,9 +656,7 @@ function updateClipsPlan(base, updates) {
660
656
  if (clip.seqMarker.fillTimeline && (windowChanged || Object.hasOwn(marker, "rate"))) throw new Error("BGM trim and speed are not enabled");
661
657
  const next = mergeFields(clip, patch);
662
658
  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);
659
+ const source = requireSource(preview, clip.source, requireTrack(preview, trackOf(preview, update.clipId)).role);
666
660
  validateWindow(next.seqMarker, "durationMs" in source ? source.durationMs : void 0);
667
661
  }
668
662
  if (Object.keys(patch).length) mutations.push({
@@ -676,8 +670,7 @@ function updateClipsPlan(base, updates) {
676
670
  trackOf(preview, update.clipId);
677
671
  const changes = placementMutations(preview, update.clipId, update.placement);
678
672
  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];
673
+ for (const change of changes) if (change.type === "patch-entity" && change.table === "clip-entities") clips[update.clipId] = mergeFields(clips[update.clipId], change.patch);
681
674
  }
682
675
  }
683
676
  return mutations;
@@ -724,7 +717,10 @@ function addClipsPlan(s, input, ids) {
724
717
  marker.fillTimeline = true;
725
718
  }
726
719
  validateWindow(marker, duration);
727
- const clip = { seqMarker: marker };
720
+ const clip = {
721
+ seqMarker: marker,
722
+ source: source.id
723
+ };
728
724
  if (item.volume !== void 0) clip.volume = item.volume;
729
725
  const id = ids[offset];
730
726
  const result = [
@@ -735,14 +731,6 @@ function addClipsPlan(s, input, ids) {
735
731
  id,
736
732
  value: clip
737
733
  },
738
- {
739
- type: "set-relation",
740
- table: "clip-source-relations",
741
- value: {
742
- clipId: id,
743
- sourceId: source.id
744
- }
745
- },
746
734
  {
747
735
  type: "insert-node",
748
736
  entityId: id,
@@ -762,10 +750,9 @@ function moveClipsPlan(s, input) {
762
750
  if (!Number.isSafeInteger(input.index) || input.index < 0 || input.index > target.length) throw new Error("Invalid insertion index");
763
751
  for (const id of input.clipIds) {
764
752
  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);
753
+ const clip = requireClip(s, id);
754
+ if (clip.anchor) throw new Error("Sequential reorder requires sequential Clips");
755
+ requireSource(s, clip.source, requireTrack(s, input.trackId).role);
769
756
  }
770
757
  const mutations = input.onAnchored === "keepAbsolute" ? detachChildren(s, moved) : [];
771
758
  const order = [...target];
@@ -783,7 +770,7 @@ function replaceSequencePlan(s, input, ids) {
783
770
  uniqueIds(input.clipIds);
784
771
  if (!input.clipIds.length || !input.clips.length) throw new Error("Replacement requires nonempty runs");
785
772
  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");
773
+ 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
774
  if (input.clips.some((c) => c.placement && c.placement.kind !== "sequential")) throw new Error("Replacement Clips must be sequential");
788
775
  const mutations = addClipsPlan(s, {
789
776
  trackId,
@@ -791,7 +778,7 @@ function replaceSequencePlan(s, input, ids) {
791
778
  index
792
779
  }, ids);
793
780
  const removed = new Set(input.clipIds);
794
- const anchors = { ...s["time-anchor-relations"] };
781
+ const clips = { ...s["clip-entities"] };
795
782
  if (input.onAnchored === "remap") {
796
783
  const begin = resolveDslLayout(s).clips[input.clipIds[0]].startMs;
797
784
  const starts = [];
@@ -803,26 +790,30 @@ function replaceSequencePlan(s, input, ids) {
803
790
  const marker = creation.value.seqMarker;
804
791
  end += Math.round((marker.outMs - marker.inMs) / (marker.rate ?? 1));
805
792
  }
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;
793
+ for (const child of anchoredTo(s, removed)) {
794
+ const anchor = s["clip-entities"][child].anchor;
795
+ const slot = input.clipIds.indexOf(anchor.to);
796
+ if (removed.has(child) || slot >= ids.length) continue;
809
797
  const value = {
810
- from: r.from,
811
798
  to: ids[slot],
812
- offsetMs: r.offsetMs,
813
- fallback_abs_ms: Math.max(0, starts[slot] + r.offsetMs)
799
+ offsetMs: anchor.offsetMs,
800
+ fallback_abs_ms: Math.max(0, starts[slot] + anchor.offsetMs)
814
801
  };
815
802
  mutations.push({
816
- type: "set-relation",
817
- table: "time-anchor-relations",
818
- value
803
+ type: "patch-entity",
804
+ table: "clip-entities",
805
+ id: child,
806
+ patch: { anchor: value }
819
807
  });
820
- anchors[`ta:${r.from}`] = value;
808
+ clips[child] = {
809
+ ...clips[child],
810
+ anchor: value
811
+ };
821
812
  }
822
813
  }
823
814
  mutations.push(...deleteClips({
824
815
  ...s,
825
- "time-anchor-relations": anchors
816
+ "clip-entities": clips
826
817
  }, input.clipIds, "cascade"));
827
818
  return mutations;
828
819
  }
@@ -830,26 +821,18 @@ function replaceSourcePlan(s, clipId, input) {
830
821
  if (requireClip(s, clipId).seqMarker.fillTimeline) throw new Error("Use setBgm to replace a fill Clip");
831
822
  const source = sourcePlan(s, input, trackOf(s, clipId));
832
823
  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: {
824
+ return [...source.mutations, {
825
+ type: "patch-entity",
826
+ table: "clip-entities",
827
+ id: clipId,
828
+ patch: {
829
+ seqMarker: {
840
830
  inMs: input.inMs,
841
831
  outMs: input.outMs
842
- } }
843
- },
844
- {
845
- type: "set-relation",
846
- table: "clip-source-relations",
847
- value: {
848
- clipId,
849
- sourceId: source.id
850
- }
832
+ },
833
+ source: source.id
851
834
  }
852
- ];
835
+ }];
853
836
  }
854
837
  const produce = new Immer({
855
838
  autoFreeze: false,
@@ -3895,6 +3878,21 @@ function resolveContent(doc, content) {
3895
3878
  bodyId: text.id
3896
3879
  };
3897
3880
  }
3881
+ /** Optional relation fields read apart from their row: an unreadable one is dropped, the row is kept. */
3882
+ const optionalReferences = {
3883
+ "clip-entities": ["anchor"],
3884
+ "caption-entities": ["selection"]
3885
+ };
3886
+ /** The entity a single-valued relation field points at, by the field's path. */
3887
+ function referenceTargets(table, row) {
3888
+ const nested = (field, key) => isRecord(row[field]) ? row[field][key] : void 0;
3889
+ switch (table) {
3890
+ case "clip-entities": return [["source", row.source], ["anchor/to", nested("anchor", "to")]];
3891
+ case "caption-entities": return [["selection/audioScriptId", nested("selection", "audioScriptId")]];
3892
+ case "phonetic-script-entities": return [["phoneme/audioScriptId", nested("phoneme", "audioScriptId")]];
3893
+ default: return [];
3894
+ }
3895
+ }
3898
3896
  function readMedeoDsl(doc, state) {
3899
3897
  const diagnostics = [];
3900
3898
  const diagnostic = (code, path, error) => diagnostics.push({
@@ -3907,10 +3905,17 @@ function readMedeoDsl(doc, state) {
3907
3905
  const richText = {};
3908
3906
  for (const table of entityTables) for (const [id, raw] of Object.entries(state[table] ?? {})) try {
3909
3907
  assertId(id);
3910
- assertEntity(table, raw);
3911
- const v = raw;
3908
+ let v = raw;
3909
+ for (const field of optionalReferences[table] ?? []) {
3910
+ const value = v[field];
3911
+ if (!isInvalidAtomic(value)) continue;
3912
+ diagnostic("invalid-relation", `${table}/${id}/${field}`, value.reason);
3913
+ const { [field]: _dropped, ...rest } = v;
3914
+ v = rest;
3915
+ }
3916
+ assertEntity(table, v);
3912
3917
  assertResourceIdentity(table, id, v);
3913
- entities[table][id] = raw;
3918
+ entities[table][id] = v;
3914
3919
  if (table === "audio-script-entities" || table === "caption-entities" && v.text !== void 0) {
3915
3920
  const text = liveText(doc, id, table === "caption-entities");
3916
3921
  if (!text) throw new Error("Missing live LoroText body");
@@ -3928,23 +3933,17 @@ function readMedeoDsl(doc, state) {
3928
3933
  diagnostic("invalid-entity", `${table}/${id}`, error);
3929
3934
  }
3930
3935
  const entityIds = new Set(entityTables.flatMap((t) => Object.keys(entities[t])));
3936
+ 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
3937
  for (const table of relationTables) for (const [id, value] of Object.entries(state[table] ?? {})) try {
3932
3938
  if (isInvalidAtomic(value)) throw new Error(value.reason);
3933
3939
  assertRelation(table, value);
3934
3940
  assertRelationIdentity(table, id, value);
3935
3941
  relations[table][id] = value;
3936
3942
  for (const key of [
3937
- "clipId",
3938
- "sourceId",
3939
- "from",
3940
- "to",
3941
- "captionId",
3942
- "audioScriptId",
3943
- "mediaId",
3944
3943
  "recordingId",
3945
3944
  "voiceId",
3946
3945
  "entityId",
3947
- "assetId"
3946
+ "sourceId"
3948
3947
  ]) {
3949
3948
  const endpoint = value[key];
3950
3949
  if (typeof endpoint === "string" && !entityIds.has(endpoint)) diagnostic("dangling-reference", `${table}/${id}/${key}`, `Missing entity ${endpoint}`);
@@ -3972,22 +3971,25 @@ function readMedeoDsl(doc, state) {
3972
3971
  }];
3973
3972
  });
3974
3973
  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
3974
  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
- };
3975
+ if (caption.text !== void 0) {
3976
+ if (Object.hasOwn(state["caption-entities"]?.[id] ?? {}, "selection")) diagnostic("multiple-caption-sources", `caption-entities/${id}`, "Inline text takes precedence over the content reference");
3977
+ if (richText[id]) captionText[id] = {
3978
+ text: caption.text,
3979
+ start: 0,
3980
+ end: caption.text.length,
3981
+ bodyId: richText[id].bodyId
3982
+ };
3983
+ continue;
3984
+ }
3985
+ if (!caption.selection) continue;
3986
+ try {
3987
+ const resolved = resolveContent(doc, caption.selection);
3988
+ if (resolved) captionText[id] = resolved;
3989
+ else diagnostic("invalid-caption-content", `caption-entities/${id}/selection`, "Cursor does not resolve in the current body");
3990
+ } catch (error) {
3991
+ diagnostic("invalid-caption-content", `caption-entities/${id}/selection`, error);
3992
+ }
3991
3993
  }
3992
3994
  return freezeSnapshot({
3993
3995
  ...entities,
@@ -4040,6 +4042,7 @@ var TransactionDraft = class {
4040
4042
  relations = /* @__PURE__ */ new Map();
4041
4043
  deleted = /* @__PURE__ */ new Set();
4042
4044
  textPreviews = /* @__PURE__ */ new Map();
4045
+ /** Captions whose selection the draft wrote; they are replayed natively in order with text edits. */
4043
4046
  dirtyCaptions = /* @__PURE__ */ new Set();
4044
4047
  removedCaptionTexts = /* @__PURE__ */ new Set();
4045
4048
  treeChanged = false;
@@ -4137,6 +4140,7 @@ var TransactionDraft = class {
4137
4140
  if (path.length === 1 && this.textPreviews.has(rowId)) throw new Error("Cannot delete or replace a body used in this transaction");
4138
4141
  }
4139
4142
  if (table === "caption-entities") {
4143
+ if (path.length === 1 && isRecord(next) && Object.hasOwn(next, "selection") || path.length === 2 && key === "selection" || path[2] === "selection") this.dirtyCaptions.add(rowId);
4140
4144
  const changesText = path.length === 1 || path.length === 2 && key === "text";
4141
4145
  const nextText = path.length === 1 ? isRecord(next) ? next.text : void 0 : next;
4142
4146
  if (changesText) {
@@ -4145,11 +4149,7 @@ var TransactionDraft = class {
4145
4149
  if (initial?.text !== void 0 && nextText === void 0) this.removedCaptionTexts.add(rowId);
4146
4150
  }
4147
4151
  }
4148
- if (initial && [
4149
- "media-entities",
4150
- "asset-entities",
4151
- "voice-entities"
4152
- ].includes(table)) {
4152
+ if (initial && ["media-entities", "voice-entities"].includes(table)) {
4153
4153
  for (const field of [
4154
4154
  "system",
4155
4155
  "key",
@@ -4169,7 +4169,6 @@ var TransactionDraft = class {
4169
4169
  let ids = this.relations.get(table);
4170
4170
  if (!ids) this.relations.set(table, ids = /* @__PURE__ */ new Set());
4171
4171
  ids.add(rowId);
4172
- if (table === "caption-content-relations") this.dirtyCaptions.add(rowId);
4173
4172
  }
4174
4173
  }
4175
4174
  };
@@ -4224,6 +4223,7 @@ function changeTree(nodes, mutation, rows) {
4224
4223
  }
4225
4224
  //#endregion
4226
4225
  //#region src/dsl/document/mutations.ts
4226
+ const resourceTables = ["media-entities", "voice-entities"];
4227
4227
  /** Mutations are a convenience vocabulary over the same guarded draft and native command queue. */
4228
4228
  function applyMutations(tx, mutations) {
4229
4229
  const rows = tx.draft;
@@ -4233,11 +4233,7 @@ function applyMutations(tx, mutations) {
4233
4233
  case "create-entity": {
4234
4234
  assertEntity(m.table, m.value);
4235
4235
  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}`);
4236
+ if (old && !resourceTables.includes(m.table)) throw new Error(`Entity already exists: ${m.id}`);
4241
4237
  if (old) {
4242
4238
  assertEntityPatch(m.table, m.value, old);
4243
4239
  applyPatch(old, m.value);
@@ -4247,12 +4243,15 @@ function applyMutations(tx, mutations) {
4247
4243
  case "patch-entity": {
4248
4244
  const current = rows[m.table][m.id];
4249
4245
  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);
4246
+ const patch = m.patch;
4247
+ assertEntityPatch(m.table, patch, current);
4248
+ const atomic = atomicFields[m.table] ?? [];
4249
+ const next = patchRow(m.table, current, patch);
4250
+ const ordinary = {};
4251
+ for (const key of Object.keys(patch)) if (!atomic.includes(key)) ordinary[key] = patch[key];
4252
+ else if (next[key] === void 0) delete current[key];
4253
+ else current[key] = next[key];
4254
+ applyPatch(current, ordinary);
4256
4255
  break;
4257
4256
  }
4258
4257
  case "delete-entity":
@@ -4261,11 +4260,8 @@ function applyMutations(tx, mutations) {
4261
4260
  case "set-relation": {
4262
4261
  assertRelation(m.table, m.value);
4263
4262
  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
- }
4263
+ rows[m.table][id] ??= {};
4264
+ applyPatch(rows[m.table][id], m.value);
4269
4265
  break;
4270
4266
  }
4271
4267
  case "delete-relation":
@@ -4295,11 +4291,7 @@ function applyTextEdits(text, edits) {
4295
4291
  text.delete(edit.at, edit.length);
4296
4292
  break;
4297
4293
  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({
4294
+ text.mark({
4303
4295
  start: edit.start,
4304
4296
  end: edit.end
4305
4297
  }, edit.key, edit.value);
@@ -4317,10 +4309,9 @@ function applyTextEdits(text, edits) {
4317
4309
  }
4318
4310
  //#endregion
4319
4311
  //#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;
4312
+ /** A caption selection by the DSL's rules, which must also select a nonempty run of the live body. */
4313
+ function validateSelection(doc, state, captionId, value) {
4314
+ assertReferenceAdmitted(state, "caption-entities", captionId, "selection", value);
4324
4315
  const resolved = resolveContent(doc, value);
4325
4316
  if (!resolved || resolved.start === resolved.end) throw new Error("Expected a nonempty range in the current body");
4326
4317
  captionContentCodec.encode(value);
@@ -4355,6 +4346,10 @@ function validateDraft(tracked) {
4355
4346
  }
4356
4347
  //#endregion
4357
4348
  //#region src/dsl/document/transact.ts
4349
+ function captionRow(doc, captionId) {
4350
+ const row = doc.getMap("caption-entities").get(captionId);
4351
+ return isContainer(row) && row.kind() === "Map" ? row : void 0;
4352
+ }
4358
4353
  /** Serialization happens before preparing any writes, so invalid/cyclic audit data cannot partly commit. */
4359
4354
  function auditOptions(audit) {
4360
4355
  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,7 +4368,7 @@ const nativeHookSupport = /* @__PURE__ */ new WeakMap();
4373
4368
  /**
4374
4369
  * Every write relies on the patched Mirror's `applyNative` (patches/loro-mirror@2.3.3.patch). An
4375
4370
  * 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
4371
+ * and Caption selections. The patch runs the hook even for an empty draft without committing, so
4377
4372
  * probe each Mirror class once on a scratch document and refuse before the first real write.
4378
4373
  */
4379
4374
  function assertNativeHook(mirror) {
@@ -4420,9 +4415,16 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4420
4415
  };
4421
4416
  const flushCaptions = () => {
4422
4417
  for (const id of tracked.dirtyCaptions) {
4423
- const value = raw["caption-content-relations"][id];
4418
+ const row = raw["caption-entities"][id];
4419
+ if (!row) continue;
4420
+ const value = row.selection;
4421
+ const old = before["caption-entities"][id]?.selection;
4422
+ if (isInvalidAtomic(value)) {
4423
+ if (isInvalidAtomic(old) && old.raw === value.raw) continue;
4424
+ throw new Error("Invalid Caption selection");
4425
+ }
4424
4426
  if (value !== void 0) {
4425
- validateRelation(doc, raw, "caption-content-relations", id, value);
4427
+ validateSelection(doc, raw, id, value);
4426
4428
  useBody(value.audioScriptId);
4427
4429
  }
4428
4430
  steps.push({
@@ -4501,7 +4503,7 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4501
4503
  }
4502
4504
  validateDraft(tracked);
4503
4505
  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)));
4506
+ const referenceIds = new Set(Object.entries(before["caption-entities"]).filter(([, caption]) => typeof caption === "object" && caption.selection !== void 0).map(([id]) => id));
4505
4507
  const changedCaptions = /* @__PURE__ */ new Set();
4506
4508
  for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (fields.has("text")) changedCaptions.add(id);
4507
4509
  for (const step of steps) {
@@ -4510,16 +4512,18 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4510
4512
  changedCaptions.add(step.selection.captionId);
4511
4513
  }
4512
4514
  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);
4515
+ changedCaptions.add(step.id);
4516
+ if (step.value) referenceIds.add(step.id);
4517
+ else referenceIds.delete(step.id);
4517
4518
  }
4518
4519
  if (step.type === "text" && step.caption) changedCaptions.add(step.scriptId);
4519
4520
  }
4520
- for (const id of changedCaptions) assertCaptionSource(raw["caption-entities"][id], referenceIds.has(id));
4521
+ for (const id of changedCaptions) if (raw["caption-entities"][id]) assertCaptionSource(raw["caption-entities"][id], referenceIds.has(id));
4521
4522
  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);
4523
+ else if (step.type === "caption" && step.value) assertCaptionEndpoints(raw, {
4524
+ captionId: step.id,
4525
+ audioScriptId: step.value.audioScriptId
4526
+ });
4523
4527
  for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) {
4524
4528
  const row = raw["caption-entities"][id];
4525
4529
  if (fields.has("text") && row?.text !== void 0 && before["caption-entities"][id]?.text === void 0) {
@@ -4527,8 +4531,13 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4527
4531
  delete row.text;
4528
4532
  }
4529
4533
  }
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];
4534
+ for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) {
4535
+ const row = raw["caption-entities"][id];
4536
+ if (!row || !fields.has("selection")) continue;
4537
+ const old = before["caption-entities"][id];
4538
+ if (old?.selection === void 0) delete row.selection;
4539
+ else row.selection = old.selection;
4540
+ }
4532
4541
  });
4533
4542
  } finally {
4534
4543
  tracked.close();
@@ -4559,18 +4568,19 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4559
4568
  const { captionId, audioScriptId, start, end } = step.selection;
4560
4569
  const text = bodyFor(audioScriptId);
4561
4570
  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,
4571
+ const row = captionRow(doc, captionId);
4572
+ if (!from || !to || !row) throw new Error("Unable to construct prepared range");
4573
+ row.set("selection", captionContentCodec.encode({
4565
4574
  audioScriptId,
4566
4575
  start: from,
4567
4576
  end: to
4568
4577
  }));
4569
4578
  } 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));
4579
+ const row = captionRow(doc, step.id);
4580
+ if (step.value !== void 0) {
4581
+ if (!row) throw new Error("Prepared Caption is missing");
4582
+ row.set("selection", captionContentCodec.encode(step.value));
4583
+ } else if (row?.get("selection") !== void 0) row.delete("selection");
4574
4584
  }
4575
4585
  }
4576
4586
  });
@@ -4793,6 +4803,7 @@ function voiceoverPlan(s, input, result) {
4793
4803
  rate: void 0,
4794
4804
  fillTimeline: void 0
4795
4805
  },
4806
+ source: result.mediaId,
4796
4807
  ...input.volume === void 0 ? {} : { volume: input.volume }
4797
4808
  }
4798
4809
  } : {
@@ -4804,15 +4815,9 @@ function voiceoverPlan(s, input, result) {
4804
4815
  inMs: 0,
4805
4816
  outMs: input.media.durationMs
4806
4817
  },
4818
+ source: result.mediaId,
4807
4819
  ...input.volume === void 0 ? {} : { volume: input.volume }
4808
4820
  }
4809
- }, {
4810
- type: "set-relation",
4811
- table: "clip-source-relations",
4812
- value: {
4813
- clipId: result.clipId,
4814
- sourceId: result.mediaId
4815
- }
4816
4821
  });
4817
4822
  if (!existing || trackOf(s, result.clipId) !== input.trackId) mutations.push({
4818
4823
  type: existing ? "move-node" : "insert-node",
@@ -4822,13 +4827,14 @@ function voiceoverPlan(s, input, result) {
4822
4827
  mutations.push(...placementMutations(s, result.clipId, input.placement));
4823
4828
  uniqueIds(result.captionClipIds);
4824
4829
  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);
4830
+ const oldCaptions = anchoredTo(s, new Set([result.clipId])).filter((id) => requireTrack(s, trackOf(s, id)).role === "caption");
4826
4831
  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);
4832
+ const placement = mutations.find((m) => m.type === "patch-entity" && m.table === "clip-entities" && m.id === result.clipId && "anchor" in m.patch);
4833
+ const anchor = placement?.type === "patch-entity" && placement.table === "clip-entities" ? placement.patch.anchor : void 0;
4828
4834
  const trackClips = resolveDslLayout(s).tracks.find((track) => track.trackId === input.trackId).clips;
4829
4835
  const slot = trackClips.findIndex((clip) => clip.clipId === result.clipId);
4830
4836
  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;
4837
+ const speechStart = anchor?.fallback_abs_ms !== void 0 ? anchor.fallback_abs_ms : predecessor ? predecessor.startMs + predecessor.durationMs : 0;
4832
4838
  input.captions.forEach((caption, i) => {
4833
4839
  const clipId = result.captionClipIds[i], captionId = result.captionIds[i];
4834
4840
  validateWindow({
@@ -4837,7 +4843,7 @@ function voiceoverPlan(s, input, result) {
4837
4843
  }, input.media.durationMs);
4838
4844
  const previous = s["clip-entities"][clipId];
4839
4845
  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;
4846
+ const previousSource = previous?.source;
4841
4847
  const style = caption.style ?? (previousSource ? s["caption-entities"][previousSource]?.style : void 0);
4842
4848
  mutations.push({
4843
4849
  type: "create-entity",
@@ -4850,46 +4856,33 @@ function voiceoverPlan(s, input, result) {
4850
4856
  audioScriptId: result.audioScriptId,
4851
4857
  start: caption.range.start,
4852
4858
  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
4861
- }
4862
- }, previous ? {
4859
+ });
4860
+ const anchor = {
4861
+ to: result.clipId,
4862
+ offsetMs: caption.startMs,
4863
+ fallback_abs_ms: speechStart + caption.startMs
4864
+ };
4865
+ const seqMarker = {
4866
+ inMs: 0,
4867
+ outMs: caption.durationMs
4868
+ };
4869
+ mutations.push(previous ? {
4863
4870
  type: "patch-entity",
4864
4871
  table: "clip-entities",
4865
4872
  id: clipId,
4866
- patch: { seqMarker: {
4867
- inMs: 0,
4868
- outMs: caption.durationMs
4869
- } }
4873
+ patch: {
4874
+ seqMarker,
4875
+ source: captionId,
4876
+ anchor
4877
+ }
4870
4878
  } : {
4871
4879
  type: "create-entity",
4872
4880
  table: "clip-entities",
4873
4881
  id: clipId,
4874
- value: { seqMarker: {
4875
- inMs: 0,
4876
- outMs: caption.durationMs
4877
- } }
4878
- }, {
4879
- type: "set-relation",
4880
- table: "clip-source-relations",
4881
4882
  value: {
4882
- clipId,
4883
- sourceId: captionId
4884
- }
4885
- }, {
4886
- type: "set-relation",
4887
- table: "time-anchor-relations",
4888
- value: {
4889
- from: clipId,
4890
- to: result.clipId,
4891
- offsetMs: caption.startMs,
4892
- fallback_abs_ms: speechStart + caption.startMs
4883
+ seqMarker,
4884
+ source: captionId,
4885
+ anchor
4893
4886
  }
4894
4887
  });
4895
4888
  if (!previous || trackOf(s, clipId) !== input.captionTrackId) mutations.push({
@@ -4950,11 +4943,10 @@ var DslEditor = class {
4950
4943
  /** index is measured in the target's children after removing the moved Clip. */
4951
4944
  moveClip(clipId, trackId, index, options) {
4952
4945
  const snapshot = this.dsl.getSnapshot();
4953
- requireClip(snapshot, clipId);
4946
+ const clip = requireClip(snapshot, clipId);
4954
4947
  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);
4948
+ if (!track) throw new Error("Missing target Track or Clip source");
4949
+ requireSource(snapshot, clip.source, track.role);
4958
4950
  this.write("moveClip", {
4959
4951
  clipId,
4960
4952
  trackId,
@@ -4969,8 +4961,7 @@ var DslEditor = class {
4969
4961
  trimClip(clipId, window, options) {
4970
4962
  const snapshot = this.dsl.getSnapshot(), clip = requireClip(snapshot, clipId);
4971
4963
  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;
4964
+ const source = snapshot["media-entities"][clip.source] ?? snapshot["caption-entities"][clip.source];
4974
4965
  if (!source) throw new Error("Cannot trim a Clip with a missing source");
4975
4966
  const patch = {};
4976
4967
  if (window.inMs !== void 0) patch.inMs = window.inMs;
@@ -5083,9 +5074,10 @@ var DslEditor = class {
5083
5074
  if (!caption) throw new Error("Missing Caption");
5084
5075
  this.dsl.transact((tx) => {
5085
5076
  tx.applyMutations([{
5086
- type: "delete-relation",
5087
- table: "caption-content-relations",
5088
- id: `cc:${captionId}`
5077
+ type: "patch-entity",
5078
+ table: "caption-entities",
5079
+ id: captionId,
5080
+ patch: { selection: void 0 }
5089
5081
  }]);
5090
5082
  if (caption.text === void 0) tx.draft["caption-entities"][captionId].text = text;
5091
5083
  else if (caption.text !== text) tx.editCaptionText(captionId, [...caption.text.length ? [{
@@ -5227,37 +5219,28 @@ var DslEditor = class {
5227
5219
  fillTimeline: true
5228
5220
  };
5229
5221
  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
- }
5222
+ const mutations = [...source.mutations, old ? {
5223
+ type: "patch-entity",
5224
+ table: "clip-entities",
5225
+ id,
5226
+ patch: {
5227
+ seqMarker: {
5228
+ ...seqMarker,
5229
+ rate: void 0
5230
+ },
5231
+ source: source.id,
5232
+ ...volume
5259
5233
  }
5260
- ];
5234
+ } : {
5235
+ type: "create-entity",
5236
+ table: "clip-entities",
5237
+ id,
5238
+ value: {
5239
+ seqMarker,
5240
+ source: source.id,
5241
+ ...volume
5242
+ }
5243
+ }];
5261
5244
  if (!old) mutations.push({
5262
5245
  type: "insert-node",
5263
5246
  entityId: id,
@@ -5770,28 +5753,19 @@ function prepare(input, options) {
5770
5753
  captionId: sourceId,
5771
5754
  ...selected
5772
5755
  });
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
5756
  }
5794
5757
  if (outMs <= inMs || !sourceId) fail(`part_library/${id}`, "Expected a nonempty source window");
5758
+ let anchor;
5759
+ if (placement.role !== "video_clip") {
5760
+ const host = hosts.get(id);
5761
+ const speechId = part.caption?.speech_part_id;
5762
+ const offsetMs = host?.offset ?? (speechId ? milliseconds(part.caption?.start_ms ?? 0, `part_library/${id}/start_ms`) : placement.abs);
5763
+ anchor = {
5764
+ to: host?.to ?? speechId ?? timelineId,
5765
+ offsetMs,
5766
+ fallback_abs_ms: placement.abs
5767
+ };
5768
+ }
5795
5769
  mutations.push({
5796
5770
  type: "create-entity",
5797
5771
  table: "clip-entities",
@@ -5803,36 +5777,15 @@ function prepare(input, options) {
5803
5777
  ...speed ? { rate: speed.rate } : {},
5804
5778
  ...part.bgm ? { fillTimeline: true } : {}
5805
5779
  },
5780
+ source: sourceId,
5781
+ ...anchor === void 0 ? {} : { anchor },
5806
5782
  ...volume === void 0 ? {} : { volume }
5807
5783
  }
5808
- }, {
5809
- type: "set-relation",
5810
- table: "clip-source-relations",
5811
- value: {
5812
- clipId: id,
5813
- sourceId
5814
- }
5815
5784
  }, {
5816
5785
  type: "insert-node",
5817
5786
  entityId: id,
5818
5787
  parentId: placement.trackId
5819
5788
  });
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
5789
  }
5837
5790
  return {
5838
5791
  timelineId,