@mengine/medeo-client 2.1.1-dsl.7 → 2.1.1-dsl.9

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 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-Ce8NDenQ.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
@@ -104,42 +104,27 @@ function prepareTextEdits(text, edits) {
104
104
  }
105
105
  return edit;
106
106
  }),
107
- body: text
107
+ text
108
108
  };
109
109
  }
110
- /** An empty body has no segments; otherwise each line, including a trailing empty one, is a segment. */
111
- function lines(body) {
112
- if (body === "") return [];
113
- const result = [];
114
- let start = 0;
115
- for (const text of body.split("\n")) {
116
- result.push({
117
- text,
118
- start,
119
- end: start + text.length
120
- });
121
- start += text.length + 1;
122
- }
123
- return result;
124
- }
125
- /** The language a whole line carries: one shared mark on every unit, else none. */
126
- function lineLanguage(text, line) {
127
- if (line.start === line.end) return void 0;
128
- const first = text.langs[line.start];
129
- for (let i = line.start + 1; i < line.end; i++) if (text.langs[i] !== first) return void 0;
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;
130
115
  return first;
131
116
  }
132
117
  /** Read language marks off a rich-text delta. */
133
118
  function langsFromDelta(delta) {
134
- let body = "";
119
+ let text = "";
135
120
  const langs = [];
136
121
  for (const run of delta) {
137
- body += run.insert;
122
+ text += run.insert;
138
123
  const language = typeof run.attributes?.language === "string" ? run.attributes.language : void 0;
139
124
  for (let i = 0; i < run.insert.length; i++) langs.push(language);
140
125
  }
141
126
  return {
142
- body,
127
+ text,
143
128
  langs
144
129
  };
145
130
  }
@@ -178,16 +163,23 @@ const referenceFields = {
178
163
  "phonetic-script-entities": ["phoneme"],
179
164
  "caption-entities": ["selection"]
180
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
+ }
181
173
  /**
182
174
  * Whether a single-valued relation, stored as a field of the entity that owns it, may hold this
183
175
  * value among these rows. The owner need not be stored yet; an absent value unlinks it.
184
- * A caption selection's range is checked against the live body by whoever holds it.
176
+ * A selection's run is checked against the segment's live text by whoever holds it.
185
177
  */
186
178
  function assertReferenceAdmitted(rows, table, id, field, value) {
187
179
  assertId(id);
188
180
  const key = `${table}.${field}`;
189
181
  if (value === void 0) {
190
- if (key === "clip-entities.source" || key === "phonetic-script-entities.phoneme") throw new Error(`Missing required relation: ${key}`);
182
+ if (key === "clip-entities.source") throw new Error(`Missing required relation: ${key}`);
191
183
  return;
192
184
  }
193
185
  switch (key) {
@@ -213,26 +205,49 @@ function assertReferenceAdmitted(rows, table, id, field, value) {
213
205
  break;
214
206
  }
215
207
  case "caption-entities.selection":
216
- assertCaptionContent(value);
217
- requireEntity(rows, "audio-script-entities", value.audioScriptId);
218
- break;
219
- case "phonetic-script-entities.phoneme":
220
- assertPhoneme(value);
221
- requireEntity(rows, "audio-script-entities", value.audioScriptId);
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);
222
213
  break;
214
+ }
223
215
  }
224
216
  }
225
- /** 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. */
226
218
  function assertCaptionEndpoints(rows, selection) {
227
219
  requireEntity(rows, "caption-entities", selection.captionId);
228
- 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);
229
226
  }
230
- /** A caption selects a nonempty run of its script's current body, in UTF-16 offsets. */
231
- function assertCaptionSelection(rows, selection, body) {
227
+ /**
228
+ * A selection of a segment's current text: the whole segment, or a run of it in UTF-16 offsets
229
+ * with both ends stated. What it selects is never empty.
230
+ */
231
+ function assertSegmentRange(range, text) {
232
+ if (range.start === void 0 !== (range.end === void 0)) throw new Error("A run inside a segment states both its start and its end");
233
+ if (range.start === void 0 || range.end === void 0) {
234
+ if (!text.length) throw new Error("An empty segment cannot be selected");
235
+ return;
236
+ }
237
+ assertTextOffset(text, range.start);
238
+ assertTextOffset(text, range.end);
239
+ if (range.start >= range.end) throw new Error("Expected a nonempty range in the current segment");
240
+ }
241
+ /** A caption selects a segment of its script, or a nonempty run of it. */
242
+ function assertCaptionSelection(rows, selection, text) {
232
243
  assertCaptionEndpoints(rows, selection);
233
- assertTextOffset(body, selection.start);
234
- assertTextOffset(body, selection.end);
235
- 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");
236
251
  }
237
252
  /** A caption's text comes from a selection of a script or from its own text, never both. */
238
253
  function assertCaptionSource(caption, selected) {
@@ -3898,11 +3913,21 @@ function rootMap(doc, name) {
3898
3913
  const value = doc.getContainerById(id);
3899
3914
  return value?.kind() === "Map" ? value : void 0;
3900
3915
  }
3901
- function liveText(doc, scriptId, caption = false) {
3902
- const row = rootMap(doc, caption ? "caption-entities" : "audio-script-entities")?.get(scriptId);
3903
- if (!isContainer(row) || row.kind() !== "Map") return;
3904
- const body = row.get(caption ? "text" : "body");
3905
- 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
+ }
3906
3931
  }
3907
3932
  function readContainNodes(nodes) {
3908
3933
  const read = (nodes) => nodes.map((node) => ({
@@ -3912,19 +3937,41 @@ function readContainNodes(nodes) {
3912
3937
  }));
3913
3938
  return read(nodes);
3914
3939
  }
3940
+ /** What a selection holds now: its whole segment, or the run between its Cursors on that segment's Text. */
3915
3941
  function resolveContent(doc, content) {
3916
- const text = liveText(doc, content.audioScriptId);
3917
- 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;
3918
3955
  const start = doc.getCursorPos(content.start)?.offset;
3919
3956
  const end = doc.getCursorPos(content.end)?.offset;
3920
3957
  if (start === void 0 || end === void 0 || start > end) return;
3921
3958
  return {
3922
- text: text.toString().slice(start, end),
3959
+ text: whole.slice(start, end),
3923
3960
  start,
3924
3961
  end,
3925
3962
  bodyId: text.id
3926
3963
  };
3927
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
+ }
3928
3975
  /** Optional relation fields read apart from their row: an unreadable one is dropped, the row is kept. */
3929
3976
  const optionalReferences = {
3930
3977
  "clip-entities": ["anchor"],
@@ -3962,19 +4009,16 @@ function readMedeoDsl(doc, state) {
3962
4009
  }
3963
4010
  assertEntity(table, v);
3964
4011
  assertResourceIdentity(table, id, v);
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");
3965
4020
  entities[table][id] = v;
3966
- if (table === "audio-script-entities" || table === "caption-entities" && v.text !== void 0) {
3967
- const text = liveText(doc, id, table === "caption-entities");
3968
- if (!text) throw new Error("Missing live LoroText body");
3969
- richText[id] = {
3970
- bodyId: text.id,
3971
- plainText: text.toString(),
3972
- delta: text.toDelta().flatMap((run) => typeof run.insert === "string" ? [{
3973
- insert: run.insert,
3974
- ...run.attributes ? { attributes: run.attributes } : {}
3975
- }] : [])
3976
- };
3977
- }
4021
+ for (const [key, text] of texts) richText[key] = richTextOf(text);
3978
4022
  } catch (error) {
3979
4023
  delete entities[table][id];
3980
4024
  diagnostic("invalid-entity", `${table}/${id}`, error);
@@ -4033,17 +4077,26 @@ function readMedeoDsl(doc, state) {
4033
4077
  try {
4034
4078
  const resolved = resolveContent(doc, caption.selection);
4035
4079
  if (resolved) captionText[id] = resolved;
4036
- else diagnostic("invalid-caption-content", `caption-entities/${id}/selection`, "Cursor does not resolve in the current body");
4080
+ else diagnostic("invalid-caption-content", `caption-entities/${id}/selection`, "Selection does not resolve in its current segment");
4037
4081
  } catch (error) {
4038
4082
  diagnostic("invalid-caption-content", `caption-entities/${id}/selection`, error);
4039
4083
  }
4040
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);
4092
+ }
4041
4093
  return freezeSnapshot({
4042
4094
  ...entities,
4043
4095
  ...relations,
4044
4096
  "contain-relations": validTree(treeNodes),
4045
4097
  richText,
4046
4098
  captionText,
4099
+ phoneticText,
4047
4100
  diagnostics
4048
4101
  });
4049
4102
  }
@@ -4056,6 +4109,19 @@ function freezeSnapshot(value) {
4056
4109
  }
4057
4110
  //#endregion
4058
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);
4059
4125
  const entities = new Set(entityTables);
4060
4126
  const relations = new Set(relationTables);
4061
4127
  const reserved = new Set([
@@ -4088,9 +4154,10 @@ var TransactionDraft = class {
4088
4154
  entities = /* @__PURE__ */ new Map();
4089
4155
  relations = /* @__PURE__ */ new Map();
4090
4156
  deleted = /* @__PURE__ */ new Set();
4157
+ /** Texts this write edits, by {@link textKey}, as its edits so far leave them. */
4091
4158
  textPreviews = /* @__PURE__ */ new Map();
4092
- /** Captions whose selection the draft wrote; they are replayed natively in order with text edits. */
4093
- 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();
4094
4161
  removedCaptionTexts = /* @__PURE__ */ new Set();
4095
4162
  treeChanged = false;
4096
4163
  active = true;
@@ -4121,13 +4188,30 @@ var TransactionDraft = class {
4121
4188
  close() {
4122
4189
  this.active = false;
4123
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
+ }
4124
4202
  wrap(value, path) {
4125
4203
  if (!value || typeof value !== "object" || value instanceof Cursor) return value;
4126
4204
  const cached = this.wrappers.get(value);
4127
4205
  if (cached) return cached;
4128
4206
  const proxy = new Proxy(value, {
4129
4207
  get: (target, key) => this.run(() => {
4130
- 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
+ }
4131
4215
  if (!path.length && key === "contain-relations" && target[key] === void 0) target[key] = [];
4132
4216
  return this.wrap(Reflect.get(target, key), [...path, String(key)]);
4133
4217
  }),
@@ -4182,16 +4266,22 @@ var TransactionDraft = class {
4182
4266
  if (path.length === 1 && (remove || next === void 0)) this.deleted.add(lifetime);
4183
4267
  else if (this.deleted.has(lifetime)) throw new Error("Recreate an entity in a separate transaction");
4184
4268
  if (table === "audio-script-entities") {
4185
- if (path.length === 2 && key === "body" && (initial || this.textPreviews.has(rowId))) throw new Error("Edit body through the Text adapter");
4186
- if (path.length === 1 && initial && isRecord(next) && next.body !== initial.body) throw new Error("Edit body through the Text adapter");
4187
- 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
+ }
4188
4279
  }
4189
4280
  if (table === "caption-entities") {
4190
- if (path.length === 1 && isRecord(next) && Object.hasOwn(next, "selection") || path.length === 2 && key === "selection" || path[2] === "selection") this.dirtyCaptions.add(rowId);
4191
4281
  const changesText = path.length === 1 || path.length === 2 && key === "text";
4192
4282
  const nextText = path.length === 1 ? isRecord(next) ? next.text : void 0 : next;
4193
4283
  if (changesText) {
4194
- 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");
4195
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");
4196
4286
  if (initial?.text !== void 0 && nextText === void 0) this.removedCaptionTexts.add(rowId);
4197
4287
  }
@@ -4208,7 +4298,7 @@ var TransactionDraft = class {
4208
4298
  let fields = rows.get(rowId);
4209
4299
  if (!fields) rows.set(rowId, fields = /* @__PURE__ */ new Set());
4210
4300
  if (path.length === 1) for (const field of [...Object.keys(initial ?? {}), ...Object.keys(isRecord(next) ? next : {})]) {
4211
- 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;
4212
4302
  fields.add(field);
4213
4303
  }
4214
4304
  else fields.add(path[2] ?? key);
@@ -4219,6 +4309,9 @@ var TransactionDraft = class {
4219
4309
  }
4220
4310
  }
4221
4311
  };
4312
+ function sameSegments(a, b) {
4313
+ return JSON.stringify(a) === JSON.stringify(b);
4314
+ }
4222
4315
  //#endregion
4223
4316
  //#region src/dsl/document/draft-operations.ts
4224
4317
  function nodeIndex(nodes) {
@@ -4314,8 +4407,17 @@ function applyMutations(tx, mutations) {
4314
4407
  case "delete-relation":
4315
4408
  delete rows[m.table][m.id];
4316
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;
4317
4419
  case "edit-text":
4318
- tx.editText(m.audioScriptId, m.edits, m.expectedBodyId);
4420
+ tx.editText(m.audioScriptId, m.segmentId, m.edits, m.expectedBodyId);
4319
4421
  break;
4320
4422
  case "edit-caption-text":
4321
4423
  tx.editCaptionText(m.captionId, m.edits, m.expectedBodyId);
@@ -4323,6 +4425,9 @@ function applyMutations(tx, mutations) {
4323
4425
  case "set-caption-content":
4324
4426
  tx.setCaptionContent(m);
4325
4427
  break;
4428
+ case "set-phoneme":
4429
+ tx.setPhoneme(m);
4430
+ break;
4326
4431
  default: changeTree(tx.draft["contain-relations"], m, tx.draft);
4327
4432
  }
4328
4433
  }
@@ -4375,12 +4480,15 @@ function applyTextEdits(text, edits) {
4375
4480
  }
4376
4481
  //#endregion
4377
4482
  //#region src/dsl/document/validation.ts
4378
- /** A caption selection by the DSL's rules, which must also select a nonempty run of the live body. */
4379
- function validateSelection(doc, state, captionId, value) {
4380
- assertReferenceAdmitted(state, "caption-entities", captionId, "selection", value);
4381
- const resolved = resolveContent(doc, value);
4382
- if (!resolved || resolved.start === resolved.end) throw new Error("Expected a nonempty range in the current body");
4383
- 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);
4384
4492
  }
4385
4493
  /**
4386
4494
  * The DSL's admission of the whole draft, then what only this document can check: containment
@@ -4412,8 +4520,12 @@ function validateDraft(tracked) {
4412
4520
  }
4413
4521
  //#endregion
4414
4522
  //#region src/dsl/document/transact.ts
4415
- function captionRow(doc, captionId) {
4416
- const row = doc.getMap("caption-entities").get(captionId);
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);
4417
4529
  return isContainer(row) && row.kind() === "Map" ? row : void 0;
4418
4530
  }
4419
4531
  /** Serialization happens before preparing any writes, so invalid/cyclic audit data cannot partly commit. */
@@ -4434,8 +4546,8 @@ const nativeHookSupport = /* @__PURE__ */ new WeakMap();
4434
4546
  /**
4435
4547
  * Every write relies on the patched Mirror's `applyNative` (patches/loro-mirror@2.3.3.patch). An
4436
4548
  * unpatched Mirror ignores the option and commits only the draft, silently dropping Text, ranges
4437
- * and Caption selections. The patch runs the hook even for an empty draft without committing, so
4438
- * 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.
4439
4551
  */
4440
4552
  function assertNativeHook(mirror) {
4441
4553
  const Impl = mirror.constructor;
@@ -4469,57 +4581,75 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4469
4581
  mirror.setState((raw) => {
4470
4582
  const tracked = new TransactionDraft(raw, before);
4471
4583
  setReject((error) => tracked.fail(error));
4472
- const useBody = (scriptId, caption = false) => {
4473
- assertId(scriptId);
4474
- const entity = requireEntity(tracked.draft, caption ? "caption-entities" : "audio-script-entities", scriptId);
4475
- if (tracked.deleted.has(scriptId)) throw new Error("Cannot use a deleted body");
4476
- const body = entity[caption ? "text" : "body"];
4477
- if (typeof body !== "string") throw new Error("Missing Text body");
4478
- if (caption && tracked.removedCaptionTexts.has(scriptId)) throw new Error("Cannot edit a removed Text");
4479
- tracked.textPreviews.set(scriptId, body);
4480
- 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;
4601
+ };
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;
4481
4605
  };
4482
- const flushCaptions = () => {
4483
- for (const id of tracked.dirtyCaptions) {
4484
- const row = raw["caption-entities"][id];
4606
+ const flushSelections = () => {
4607
+ for (const [table, id] of tracked.dirtySelections()) {
4608
+ const row = raw[table][id];
4485
4609
  if (!row) continue;
4486
- const value = row.selection;
4487
- const old = before["caption-entities"][id]?.selection;
4610
+ const field = selectionField[table];
4611
+ const value = row[field];
4612
+ const old = before[table][id]?.[field];
4488
4613
  if (isInvalidAtomic(value)) {
4489
4614
  if (isInvalidAtomic(old) && old.raw === value.raw) continue;
4490
- throw new Error("Invalid Caption selection");
4491
- }
4492
- if (value !== void 0) {
4493
- validateSelection(doc, raw, id, value);
4494
- useBody(value.audioScriptId);
4615
+ throw new Error(`Invalid ${field}`);
4495
4616
  }
4617
+ if (value !== void 0) validateSelection(doc, raw, table, id, value);
4496
4618
  steps.push({
4497
- type: "caption",
4619
+ type: "selection",
4620
+ table,
4498
4621
  id,
4499
4622
  value: copyInput(value)
4500
4623
  });
4501
4624
  }
4502
- tracked.dirtyCaptions.clear();
4625
+ tracked.clearDirtySelections();
4503
4626
  };
4504
- const editBody = (scriptId, edits, expectedBodyId, caption) => {
4627
+ const editBody = (target, edits, expectedBodyId) => {
4505
4628
  tracked.run(() => {
4506
- flushCaptions();
4507
- const body = useBody(scriptId, caption);
4508
- if (!bodies.has(scriptId)) bodies.set(scriptId, liveText(doc, scriptId, caption));
4509
- 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);
4510
4634
  if (expectedBodyId !== void 0 && native?.id !== expectedBodyId) throw new Error("Stale or missing Text body");
4511
- if ((caption ? before["caption-entities"][scriptId]?.text : before["audio-script-entities"][scriptId]?.body) !== void 0 && !native) throw new Error("Missing live Text body");
4512
- const prepared = prepareTextEdits(body, copyInput(edits));
4513
- 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);
4514
4638
  steps.push({
4515
4639
  type: "text",
4516
- scriptId,
4517
- caption,
4640
+ target,
4518
4641
  expectedBodyId: native?.id,
4519
4642
  edits: prepared.edits
4520
4643
  });
4521
4644
  });
4522
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
+ };
4523
4653
  const tx = Object.freeze({
4524
4654
  get draft() {
4525
4655
  return tracked.run(() => tracked.draft);
@@ -4527,29 +4657,95 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4527
4657
  applyMutations(mutations) {
4528
4658
  tracked.run(() => applyMutations(tx, mutations));
4529
4659
  },
4530
- editText(scriptId, edits, expectedBodyId) {
4531
- editBody(scriptId, edits, expectedBodyId, false);
4660
+ editText(audioScriptId, segmentId, edits, expectedBodyId) {
4661
+ editBody({
4662
+ audioScriptId,
4663
+ segmentId
4664
+ }, edits, expectedBodyId);
4532
4665
  },
4533
4666
  editCaptionText(captionId, edits, expectedBodyId) {
4534
- 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
+ });
4535
4704
  },
4536
4705
  setCaptionContent(selection) {
4537
4706
  tracked.run(() => {
4538
- flushCaptions();
4539
- const { captionId, audioScriptId, start, end } = selection;
4707
+ flushSelections();
4708
+ const { captionId, audioScriptId, segmentId, start, end } = selection;
4540
4709
  assertId(captionId);
4541
- assertCaptionEndpoints(raw, selection);
4542
- assertCaptionSelection(raw, selection, useBody(audioScriptId));
4710
+ assertCaptionSelection(raw, selection, useText({
4711
+ audioScriptId,
4712
+ segmentId
4713
+ }));
4543
4714
  steps.push({
4544
4715
  type: "range",
4545
- selection: {
4546
- captionId,
4716
+ table: "caption-entities",
4717
+ id: captionId,
4718
+ range: {
4547
4719
  audioScriptId,
4720
+ segmentId,
4548
4721
  start,
4549
4722
  end
4550
4723
  }
4551
4724
  });
4552
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
+ });
4553
4749
  }
4554
4750
  });
4555
4751
  try {
@@ -4562,34 +4758,15 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4562
4758
  }
4563
4759
  tracked.assertValid();
4564
4760
  if (doc.getPendingTxnLength()) throw new Error("Native writes are not allowed during transaction preparation");
4565
- flushCaptions();
4761
+ flushSelections();
4566
4762
  for (const id of tracked.entities.get("caption-entities")?.keys() ?? []) {
4567
4763
  const caption = tracked.draft["caption-entities"][id];
4568
4764
  if (caption) caption.style ??= {};
4569
4765
  }
4570
4766
  validateDraft(tracked);
4571
- for (const step of steps) if (step.type === "text") requireEntity(raw, step.caption ? "caption-entities" : "audio-script-entities", step.scriptId);
4572
- const referenceIds = new Set(Object.entries(before["caption-entities"]).filter(([, caption]) => typeof caption === "object" && caption.selection !== void 0).map(([id]) => id));
4573
- const changedCaptions = /* @__PURE__ */ new Set();
4574
- for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (fields.has("text")) changedCaptions.add(id);
4575
- for (const step of steps) {
4576
- if (step.type === "range") {
4577
- referenceIds.add(step.selection.captionId);
4578
- changedCaptions.add(step.selection.captionId);
4579
- }
4580
- if (step.type === "caption") {
4581
- changedCaptions.add(step.id);
4582
- if (step.value) referenceIds.add(step.id);
4583
- else referenceIds.delete(step.id);
4584
- }
4585
- if (step.type === "text" && step.caption) changedCaptions.add(step.scriptId);
4586
- }
4587
- for (const id of changedCaptions) if (raw["caption-entities"][id]) assertCaptionSource(raw["caption-entities"][id], referenceIds.has(id));
4588
- for (const step of steps) if (step.type === "range") assertCaptionEndpoints(raw, step.selection);
4589
- else if (step.type === "caption" && step.value) assertCaptionEndpoints(raw, {
4590
- captionId: step.id,
4591
- audioScriptId: step.value.audioScriptId
4592
- });
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);
4593
4770
  for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) {
4594
4771
  const row = raw["caption-entities"][id];
4595
4772
  if (fields.has("text") && row?.text !== void 0 && before["caption-entities"][id]?.text === void 0) {
@@ -4597,12 +4774,15 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4597
4774
  delete row.text;
4598
4775
  }
4599
4776
  }
4600
- for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) {
4601
- const row = raw["caption-entities"][id];
4602
- if (!row || !fields.has("selection")) continue;
4603
- const old = before["caption-entities"][id];
4604
- if (old?.selection === void 0) delete row.selection;
4605
- else row.selection = old.selection;
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
+ }
4606
4786
  }
4607
4787
  });
4608
4788
  } finally {
@@ -4612,45 +4792,104 @@ function runTransaction(doc, mirror, edit, options, setReject) {
4612
4792
  }, {
4613
4793
  ...options,
4614
4794
  applyNative: (doc) => {
4615
- const bodyFor = (scriptId, caption = false) => {
4616
- let text = bodies.get(scriptId);
4795
+ const textFor = (target) => {
4796
+ const key = textKey(target);
4797
+ let text = bodies.get(key);
4617
4798
  if (!text) {
4618
- text = liveText(doc, scriptId, caption);
4619
- bodies.set(scriptId, text);
4799
+ text = liveText(doc, target);
4800
+ bodies.set(key, text);
4620
4801
  }
4621
- if (!text || text.isDeleted()) throw new Error("Prepared Text body is missing");
4802
+ if (!text || text.isDeleted()) throw new Error("Prepared Text is missing");
4622
4803
  return text;
4623
4804
  };
4624
4805
  for (const [id, value] of newCaptionTexts) {
4625
- const body = doc.getMap("caption-entities").get(id).setContainer("text", new LoroText());
4626
- if (value.length) body.insert(0, value);
4627
- 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);
4628
4809
  }
4629
- for (const step of steps) if (step.type === "text") {
4630
- const text = bodyFor(step.scriptId, step.caption);
4631
- if (step.expectedBodyId && text.id !== step.expectedBodyId) throw new Error("Stale Text body");
4632
- applyTextEdits(text, step.edits);
4633
- } else if (step.type === "range") {
4634
- const { captionId, audioScriptId, start, end } = step.selection;
4635
- const text = bodyFor(audioScriptId);
4636
- const from = text?.getCursor(start, 0), to = text?.getCursor(end, 0);
4637
- const row = captionRow(doc, captionId);
4638
- if (!from || !to || !row) throw new Error("Unable to construct prepared range");
4639
- row.set("selection", captionContentCodec.encode({
4640
- audioScriptId,
4641
- start: from,
4642
- end: to
4643
- }));
4644
- } else {
4645
- const row = captionRow(doc, step.id);
4646
- if (step.value !== void 0) {
4647
- if (!row) throw new Error("Prepared Caption is missing");
4648
- row.set("selection", captionContentCodec.encode(step.value));
4649
- } else if (row?.get("selection") !== void 0) row.delete("selection");
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);
4650
4841
  }
4651
4842
  }
4652
4843
  });
4653
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
+ }
4654
4893
  //#endregion
4655
4894
  //#region src/dsl/document/medeo-dsl.ts
4656
4895
  const contentRoots = new Set([
@@ -4741,17 +4980,23 @@ var MedeoDsl = class MedeoDsl {
4741
4980
  applyMutations(mutations, audit) {
4742
4981
  this.transact((tx) => tx.applyMutations(mutations), audit);
4743
4982
  }
4744
- editText(scriptId, expectedBodyId, edits, audit) {
4745
- 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);
4746
4985
  }
4747
4986
  editCaptionText(captionId, expectedBodyId, edits, audit) {
4748
4987
  this.transact((tx) => tx.editCaptionText(captionId, edits, expectedBodyId), audit);
4749
4988
  }
4750
- /** Local ranges exclude insertions at their start and include insertions at their end. K01 remains deferred. */
4751
- 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) {
4752
4994
  this.assertActive();
4753
- const text = liveText(this.doc, scriptId);
4754
- 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");
4755
5000
  assertTextOffset(text.toString(), start);
4756
5001
  assertTextOffset(text.toString(), end);
4757
5002
  if (start >= end) throw new Error("Expected a nonempty range");
@@ -4759,6 +5004,7 @@ var MedeoDsl = class MedeoDsl {
4759
5004
  if (!from || !to) throw new Error("Unable to construct text range");
4760
5005
  return {
4761
5006
  audioScriptId: scriptId,
5007
+ segmentId,
4762
5008
  start: from,
4763
5009
  end: to
4764
5010
  };
@@ -4820,16 +5066,28 @@ function voiceoverPlan(s, input, result) {
4820
5066
  id: result.mediaId,
4821
5067
  value: input.media
4822
5068
  }];
4823
- if (input.script.body !== void 0) mutations.push({
4824
- type: "create-entity",
4825
- table: "audio-script-entities",
4826
- id: result.audioScriptId,
4827
- value: {
4828
- body: input.script.body,
4829
- ..."defaultLanguage" in input.script && input.script.defaultLanguage !== void 0 ? { defaultLanguage: input.script.defaultLanguage } : {}
4830
- }
4831
- });
4832
- 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
+ }
4833
5091
  const phoneticScriptId = input.script.phoneticScriptId;
4834
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");
4835
5093
  validateWindow({
@@ -4912,6 +5170,8 @@ function voiceoverPlan(s, input, result) {
4912
5170
  }, input.media.durationMs);
4913
5171
  const previous = s["clip-entities"][clipId];
4914
5172
  if (previous && !oldCaptions.includes(clipId)) throw new Error("Cannot overwrite another use’s Caption Clip");
5173
+ const segmentId = segmentIds[caption.segment];
5174
+ if (segmentId === void 0) throw new Error(`Missing segment ${caption.segment} of the voiceover's script`);
4915
5175
  const previousSource = previous?.source;
4916
5176
  const style = caption.style ?? (previousSource ? s["caption-entities"][previousSource]?.style : void 0);
4917
5177
  mutations.push({
@@ -4923,8 +5183,11 @@ function voiceoverPlan(s, input, result) {
4923
5183
  type: "set-caption-content",
4924
5184
  captionId,
4925
5185
  audioScriptId: result.audioScriptId,
4926
- start: caption.range.start,
4927
- end: caption.range.end
5186
+ segmentId,
5187
+ ...caption.range === void 0 ? {} : {
5188
+ start: caption.range.start,
5189
+ end: caption.range.end
5190
+ }
4928
5191
  });
4929
5192
  const anchor = {
4930
5193
  to: result.clipId,
@@ -5075,30 +5338,76 @@ var DslEditor = class {
5075
5338
  patch: { volume }
5076
5339
  }], options);
5077
5340
  }
5078
- createAudioScript(body, defaultLanguage, options) {
5341
+ /** A script of these segments, in order; each segment gets its own id. */
5342
+ createAudioScript(segments, defaultLanguage, options) {
5079
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
+ };
5080
5351
  this.write("createAudioScript", {
5081
5352
  id,
5082
- body,
5083
- defaultLanguage
5353
+ ...value
5084
5354
  }, [{
5085
5355
  type: "create-entity",
5086
5356
  table: "audio-script-entities",
5087
5357
  id,
5088
- value: {
5089
- body,
5090
- ...defaultLanguage === void 0 ? {} : { defaultLanguage }
5091
- }
5358
+ value
5092
5359
  }], options);
5093
5360
  return id;
5094
5361
  }
5095
- editAudioScript(scriptId, expectedBodyId, edits, options) {
5096
- 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", {
5097
5364
  scriptId,
5365
+ segmentId,
5098
5366
  expectedBodyId,
5099
5367
  edits
5100
5368
  }, options));
5101
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
+ }
5102
5411
  createCaption(input, options) {
5103
5412
  const id = this.idFactory("caption");
5104
5413
  this.write("createCaption", {
@@ -5115,18 +5424,16 @@ var DslEditor = class {
5115
5424
  }, ...input.text !== void 0 ? [] : [{
5116
5425
  type: "set-caption-content",
5117
5426
  captionId: id,
5118
- audioScriptId: input.audioScriptId,
5119
- start: input.start,
5120
- end: input.end
5427
+ ...segmentRange(input)
5121
5428
  }]], options);
5122
5429
  return id;
5123
5430
  }
5124
- 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);
5125
5434
  this.write("setCaptionContent", {
5126
5435
  captionId,
5127
- audioScriptId,
5128
- start,
5129
- end
5436
+ ...range
5130
5437
  }, [{
5131
5438
  type: "patch-entity",
5132
5439
  table: "caption-entities",
@@ -5135,9 +5442,7 @@ var DslEditor = class {
5135
5442
  }, {
5136
5443
  type: "set-caption-content",
5137
5444
  captionId,
5138
- audioScriptId,
5139
- start,
5140
- end
5445
+ ...range
5141
5446
  }], options);
5142
5447
  }
5143
5448
  setCaptionText(captionId, text, options) {
@@ -5263,6 +5568,7 @@ var DslEditor = class {
5263
5568
  clipId: input.clipId ?? this.idFactory("clip"),
5264
5569
  mediaId: resourceId("media", input.media.system, input.media.key),
5265
5570
  audioScriptId: this.voiceoverScriptId(input.script),
5571
+ segmentIds: input.script.segments?.map(() => this.idFactory("segment")) ?? [],
5266
5572
  captionClipIds: input.captions.map((c) => c.clipId ?? this.idFactory("clip")),
5267
5573
  captionIds: input.captions.map(() => this.idFactory("caption"))
5268
5574
  };
@@ -5280,17 +5586,14 @@ var DslEditor = class {
5280
5586
  return phonetic.phoneme.audioScriptId;
5281
5587
  }
5282
5588
  /**
5283
- * Write a PhoneticScript: create one for `audioScriptId`, or, given `phoneticScriptId`, replace its
5284
- * base and pronunciation together. A `phonemeScript` is stored as a Memota text file first, and
5285
- * the file's facts are written with it; an unchanged one keeps its file. Recordings already spoken
5286
- * from it keep their derived-from.
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.
5287
5593
  */
5288
5594
  async setPhoneticScript(input, options) {
5289
5595
  const id = input.phoneticScriptId ?? this.idFactory("phonetic-script");
5290
- const phoneme = {
5291
- audioScriptId: input.audioScriptId,
5292
- ...input.phonemeScript === void 0 ? {} : { phonemeScript: input.phonemeScript }
5293
- };
5596
+ const range = segmentRange(input);
5294
5597
  const file = input.phonemeScript === void 0 ? {} : await this.fileFor(id, input.phonemeScript);
5295
5598
  const exists = Object.hasOwn(this.dsl.getSnapshot()["phonetic-script-entities"], id);
5296
5599
  const cleared = {
@@ -5299,9 +5602,11 @@ var DslEditor = class {
5299
5602
  storageKey: void 0,
5300
5603
  digest: void 0
5301
5604
  };
5605
+ const phonemeScript = input.phonemeScript === void 0 ? {} : { phonemeScript: input.phonemeScript };
5302
5606
  this.write("setPhoneticScript", {
5303
5607
  id,
5304
- phoneme,
5608
+ ...range,
5609
+ ...phonemeScript,
5305
5610
  ...cleared,
5306
5611
  ...file
5307
5612
  }, [exists ? {
@@ -5309,10 +5614,6 @@ var DslEditor = class {
5309
5614
  table: "phonetic-script-entities",
5310
5615
  id,
5311
5616
  patch: {
5312
- phoneme: {
5313
- ...phoneme,
5314
- phonemeScript: input.phonemeScript
5315
- },
5316
5617
  ...cleared,
5317
5618
  ...file
5318
5619
  }
@@ -5320,10 +5621,12 @@ var DslEditor = class {
5320
5621
  type: "create-entity",
5321
5622
  table: "phonetic-script-entities",
5322
5623
  id,
5323
- value: {
5324
- phoneme,
5325
- ...file
5326
- }
5624
+ value: file
5625
+ }, {
5626
+ type: "set-phoneme",
5627
+ phoneticScriptId: id,
5628
+ ...range,
5629
+ ...phonemeScript
5327
5630
  }], options);
5328
5631
  return id;
5329
5632
  }
@@ -5392,6 +5695,15 @@ var DslEditor = class {
5392
5695
  this.dsl.applyMutations(mutations, audit(action, payload, options));
5393
5696
  }
5394
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
+ }
5395
5707
  function audit(action, payload, options) {
5396
5708
  return {
5397
5709
  semantic_op: action,
@@ -5467,35 +5779,39 @@ function spokenScripts(snapshot, recordingId) {
5467
5779
  /** Every AudioScript in the document with its PhoneticScripts, the Speech Clips saying it and the Caption Clips showing it. */
5468
5780
  function toAudioScriptViews(snapshot) {
5469
5781
  const views = /* @__PURE__ */ new Map();
5470
- const bodies = /* @__PURE__ */ new Map();
5471
- for (const [id, script] of Object.entries(snapshot["audio-script-entities"])) {
5472
- const rich = snapshot.richText[id];
5473
- const text = rich ? langsFromDelta(rich.delta) : {
5474
- body: script.body,
5475
- langs: []
5476
- };
5477
- bodies.set(id, text.body);
5478
- views.set(id, {
5479
- id,
5480
- segments: lines(text.body).map((line) => {
5481
- const language = lineLanguage(text, line);
5482
- return language === void 0 ? { text: line.text } : {
5483
- text: line.text,
5484
- language
5485
- };
5486
- }),
5487
- sourceMediaIds: [],
5488
- phoneticScripts: [],
5489
- speeches: [],
5490
- captions: []
5491
- });
5492
- }
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
+ });
5493
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);
5494
5806
  for (const [id, phonetic] of Object.entries(snapshot["phonetic-script-entities"])) {
5495
- const { phonemeScript } = phonetic.phoneme;
5807
+ const { audioScriptId, segmentId, phonemeScript } = phonetic.phoneme;
5496
5808
  const { digest } = phonetic;
5497
- views.get(phonetic.phoneme.audioScriptId)?.phoneticScripts.push({
5809
+ const segment = segmentIndex(audioScriptId, segmentId);
5810
+ const text = snapshot.phoneticText[id]?.text;
5811
+ views.get(audioScriptId)?.phoneticScripts.push({
5498
5812
  id,
5813
+ ...segment === void 0 ? {} : { segment },
5814
+ ...text === void 0 ? {} : { text },
5499
5815
  ...phonemeScript === void 0 ? {} : { phonemeScript },
5500
5816
  ...digest === void 0 ? {} : { digest }
5501
5817
  });
@@ -5530,13 +5846,10 @@ function toAudioScriptViews(snapshot) {
5530
5846
  continue;
5531
5847
  }
5532
5848
  const caption = snapshot["caption-entities"][source];
5533
- const shown = snapshot.captionText[source];
5534
- if (!caption?.selection || caption.text !== void 0 || !shown) continue;
5535
- const scriptId = caption.selection.audioScriptId;
5536
- const body = bodies.get(scriptId);
5537
- if (body === void 0) continue;
5538
- const segment = lines(body).findIndex((line) => shown.start >= line.start && shown.end <= line.end);
5539
- if (segment >= 0) views.get(scriptId)?.captions.push({
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({
5540
5853
  clipId,
5541
5854
  captionId: source,
5542
5855
  segment
@@ -5644,7 +5957,7 @@ function toVideoDraft(snapshot) {
5644
5957
  audio_storage_key: source.storageKey,
5645
5958
  origin_speech_id: source.key,
5646
5959
  volume: clip.volume,
5647
- audio_script: spokenText(snapshot, sourceId, captionIds),
5960
+ audio_script: spokenText(snapshot, sourceId),
5648
5961
  voice: voice ? {
5649
5962
  id: voice.key,
5650
5963
  name: voice.name
@@ -5730,29 +6043,17 @@ function requireWholeAudio(media, clip, fail) {
5730
6043
  if (media.kind === "image" || clip.inMs !== 0 || clip.outMs !== media.durationMs || clip.rate !== 1) fail("VideoDraft audio has no trim or speed fields");
5731
6044
  }
5732
6045
  /**
5733
- * The words a speech Clip says. A script voiced in several takes is said one part per take, and
5734
- * the Captions following the Clip select that part: its words are the segments they select, in
5735
- * script order. A Clip no Caption of its script follows says the whole script.
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.
5736
6048
  */
5737
- function spokenText(snapshot, recordingId, captionClipIds) {
5738
- const scripts = new Set(spokenScripts(snapshot, recordingId).map((spoken) => spoken.audioScriptId));
5739
- if (scripts.size > 1) projectionError("ambiguous-script", `media-entities/${recordingId}`, "Multiple scripts cannot be silently concatenated into audio_script");
5740
- const id = [...scripts][0];
5741
- if (!id) return "";
5742
- const body = snapshot.richText[id]?.plainText ?? "";
5743
- const segments = lines(body);
5744
- const said = /* @__PURE__ */ new Set();
5745
- for (const clipId of captionClipIds) {
5746
- const captionId = snapshot["clip-entities"][clipId]?.source;
5747
- const caption = captionId === void 0 ? void 0 : snapshot["caption-entities"][captionId];
5748
- const shown = captionId === void 0 ? void 0 : snapshot.captionText[captionId];
5749
- if (caption?.selection?.audioScriptId !== id || caption.text !== void 0 || !shown) continue;
5750
- segments.forEach((line, index) => {
5751
- if (shown.start < Math.max(line.end, line.start + 1) && shown.end > line.start) said.add(index);
5752
- });
5753
- }
5754
- if (!said.size) return body;
5755
- return [...said].sort((a, b) => a - b).map((index) => segments[index].text).join("\n");
6049
+ function spokenText(snapshot, recordingId) {
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") : "";
5756
6057
  }
5757
6058
  //#endregion
5758
6059
  //#region src/dsl/projection/from-video-draft.ts
@@ -5894,16 +6195,16 @@ function prepare(input, options) {
5894
6195
  let script = scriptByRecording.get(recordingId);
5895
6196
  if (script && script.text !== speech.audio_script) fail(`part_library/${id}/audio_script`, "One recording cannot acquire conflicting source scripts during import");
5896
6197
  if (!script) {
5897
- script = {
5898
- id: claim(mint("audio-script")),
5899
- text: speech.audio_script
5900
- };
6198
+ script = importedScript(claim(mint("audio-script")), speech.audio_script, () => mint("segment"));
5901
6199
  scriptByRecording.set(recordingId, script);
5902
6200
  mutations.push({
5903
6201
  type: "create-entity",
5904
6202
  table: "audio-script-entities",
5905
6203
  id: script.id,
5906
- value: { body: script.text }
6204
+ value: { segments: script.segments.map(({ id, text }) => ({
6205
+ id,
6206
+ text
6207
+ })) }
5907
6208
  }, {
5908
6209
  type: "set-relation",
5909
6210
  table: "derived-from-relations",
@@ -5951,12 +6252,18 @@ function prepare(input, options) {
5951
6252
  const text = caption.text;
5952
6253
  if (typeof text !== "string") fail(`part_library/${captionId}/text`, "Expected Caption text");
5953
6254
  const start = text.length ? script.text.indexOf(text, cursor) : -1;
5954
- if (start >= 0) {
5955
- cursor = start + text.length;
5956
- 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 ? {
6260
+ audioScriptId: script.id,
6261
+ segmentId: segment.id
6262
+ } : {
5957
6263
  audioScriptId: script.id,
5958
- start,
5959
- end: cursor
6264
+ segmentId: segment.id,
6265
+ start: start - segment.start,
6266
+ end: end - segment.start
5960
6267
  });
5961
6268
  } else diagnostics.push({
5962
6269
  code: "caption-text-unmatched",
@@ -6067,5 +6374,22 @@ function milliseconds(value, path, positive = false, signed = false) {
6067
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");
6068
6375
  return value;
6069
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
+ }
6070
6394
  //#endregion
6071
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 };