@mengine/medeo-client 3.1.0 → 3.1.1-dsl.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -145,8 +145,12 @@ function assertCaptionContent(value) {
145
145
  }
146
146
  const phonemeFields = {
147
147
  ...selectionFields,
148
- phonemeScript: optional$1(string)
148
+ phonemeScript: optional$1(nonempty)
149
149
  };
150
+ /** D190: omitted pronunciation uses the selected source words; an authored pronunciation is never empty. */
151
+ function assertPhonemeScript(value) {
152
+ if (value !== void 0 && !nonempty(value)) throw new Error("phonemeScript must be nonempty when provided");
153
+ }
150
154
  function assertPhoneme(value) {
151
155
  if (!segmentSelection(phonemeFields)(value)) throw new Error("Invalid phoneme");
152
156
  }
@@ -174,7 +178,7 @@ const locator = {
174
178
  };
175
179
  /**
176
180
  * A placeholder medium is an Image with no file, standing in where a legacy VideoDraft placed a
177
- * visual clip the old editor drew as a blank (D196). The blank one stands in for a clip placed
181
+ * visual clip the old editor drew as a blank (D205). The blank one stands in for a clip placed
178
182
  * without media (`origin_media_id: ''`); one keyed by a memota media id stands in for media the
179
183
  * clip named but Director found missing, so the id survives. Importing keeps the clips' time on
180
184
  * them instead of dropping them. Only migration authors them.
@@ -608,9 +612,10 @@ function assertPhonemeSelection(rows, selection, text) {
608
612
  assertSegmentRange(selection, text);
609
613
  if (selection.phonemeScript !== void 0 && typeof selection.phonemeScript !== "string") throw new Error("phonemeScript is a string");
610
614
  }
611
- /** A caption's text comes from a selection of a script or from its own text, never both. */
615
+ /** Every new or rewritten Caption selects its words from one AudioScript segment. */
612
616
  function assertCaptionSource(caption, selected) {
613
- if (caption?.text !== void 0 && selected) throw new Error("Caption must have only one content source");
617
+ if (!selected) throw new Error("Caption must select a segment of an AudioScript");
618
+ if (caption?.text !== void 0) throw new Error("Caption must have only one content source");
614
619
  }
615
620
  //#endregion
616
621
  //#region ../medeo-dsl/src/contain.ts
@@ -670,6 +675,63 @@ function assertContainTree(rows, tree) {
670
675
  }
671
676
  }
672
677
  //#endregion
678
+ //#region ../medeo-dsl/src/clip-playback.ts
679
+ const playable = {
680
+ video_clip: ["video", "image"],
681
+ speech: ["audio", "voiceover-recording"],
682
+ caption: ["caption"],
683
+ bgm: ["audio"]
684
+ };
685
+ function supportsClipSpeed(source) {
686
+ return isRecord(source) && [
687
+ "video",
688
+ "audio",
689
+ "voiceover-recording"
690
+ ].includes(String(source.kind));
691
+ }
692
+ /** Content-kind admission is shared by Actions, script compilation and the final transaction. */
693
+ function assertClipSourceRole(role, kind) {
694
+ if (!playable[role]?.includes(kind)) throw new Error(`A ${role} Track does not play ${kind}`);
695
+ }
696
+ function assertClipWindow(window, duration) {
697
+ if (!Number.isSafeInteger(window.inMs) || !Number.isSafeInteger(window.outMs) || window.inMs < 0 || window.outMs <= window.inMs) throw new Error("Invalid Clip window");
698
+ if (duration !== void 0 && window.outMs > duration) throw new Error(`Invalid Clip window: ${window.outMs}ms is past its content's ${duration}ms`);
699
+ }
700
+ /** Check authored playback facts; a remote merge is still read by projection with diagnostics. */
701
+ function assertClipPlayback(clip, source, role) {
702
+ if (!isRecord(source)) throw new Error("Invalid Clip source");
703
+ const kind = source.kind ?? "caption";
704
+ const marker = clip.seqMarker;
705
+ assertSeqMarker(marker);
706
+ if (marker.outMs <= marker.inMs) throw new Error("Clip window must be nonempty");
707
+ if (role !== void 0) assertClipSourceRole(role, kind);
708
+ if (marker.rate !== void 0 && !supportsClipSpeed(source)) throw new Error("Only video and sound change speed");
709
+ const duration = typeof source.durationMs === "number" ? source.durationMs : void 0;
710
+ if (marker.fillTimeline) {
711
+ if (kind !== "audio") throw new Error("Only BGM audio fills the Timeline");
712
+ if (marker.inMs !== 0 || marker.rate !== void 0 && marker.rate !== 1 || marker.outMs !== duration) throw new Error(`A Clip that fills the Timeline plays its whole source at 1×: inMs 0, outMs ${duration ?? "its length"}, no rate; the Timeline's end cuts it`);
713
+ }
714
+ assertClipWindow(marker);
715
+ }
716
+ function clipParents(tree) {
717
+ const result = /* @__PURE__ */ new Map();
718
+ for (const track of tree) for (const clip of track.children ?? []) result.set(clip.entityId, track.entityId);
719
+ return result;
720
+ }
721
+ /** A new parent or Track role revalidates that binding, without blocking unrelated merged rows. */
722
+ function assertClipPlacements(before, after, previousTree, nextTree) {
723
+ const previous = clipParents(previousTree);
724
+ for (const [id, parent] of clipParents(nextTree)) {
725
+ const track = after["track-entities"][parent];
726
+ const previousTrack = before["track-entities"][parent];
727
+ if (previous.get(id) === parent && (track === void 0 || isRecord(track) && isRecord(previousTrack) && previousTrack.role === track.role)) continue;
728
+ const role = requireEntity(after, "track-entities", parent).role;
729
+ const clip = after["clip-entities"][id];
730
+ if (!clip) continue;
731
+ assertClipPlayback(clip, after["media-entities"][clip.source] ?? after["caption-entities"][clip.source], role);
732
+ }
733
+ }
734
+ //#endregion
673
735
  //#region ../medeo-dsl/src/plan.ts
674
736
  /** Include removed nested keys: a partial merge alone would conceal a deleted required field. */
675
737
  function replacementPatch(before, after) {
@@ -685,6 +747,7 @@ function replacementPatch(before, after) {
685
747
  */
686
748
  function assertChangeAdmitted(change) {
687
749
  const { before, after } = change;
750
+ const parents = change.containment ? clipParents(change.containment.after) : void 0;
688
751
  for (const [table, rows] of change.entities) for (const [id, fields] of rows) {
689
752
  const value = after[table][id];
690
753
  if (value === void 0) continue;
@@ -699,25 +762,17 @@ function assertChangeAdmitted(change) {
699
762
  }
700
763
  const row = value;
701
764
  for (const field of referenceFields[table] ?? []) if (!old || fields.has(field)) assertReferenceAdmitted(after, table, id, field, row[field]);
702
- const marker = row.seqMarker;
703
- if (table === "clip-entities" && (!old || fields.has("seqMarker")) && marker?.outMs <= marker?.inMs) throw new Error("Clip window must be nonempty");
704
- if (table === "clip-entities" && (!old || fields.has("seqMarker") || fields.has("source"))) assertFillsWithWholeSource(after, marker, row.source);
765
+ if (table === "clip-entities" && (!old || fields.has("seqMarker") || fields.has("source"))) {
766
+ const parent = parents?.get(id);
767
+ assertClipPlayback(row, after["media-entities"][row.source] ?? after["caption-entities"][row.source], parent === void 0 ? void 0 : requireEntity(after, "track-entities", parent).role);
768
+ }
705
769
  }
706
770
  if (change.entities.has("timeline-entities") && Object.keys(after["timeline-entities"]).length > 1) throw new Error("Document already has a Timeline");
707
771
  for (const [table, ids] of change.relations) for (const id of ids) {
708
772
  const value = after[table][id];
709
773
  if (value !== void 0) assertRelationAdmitted(after, table, id, value);
710
774
  }
711
- }
712
- /**
713
- * A Clip that fills the Timeline plays its whole source at 1×: the player loops a short source and
714
- * cuts a long one at the Timeline's end (RFC 02 §3.3), so a window or speed of its own has no meaning.
715
- */
716
- function assertFillsWithWholeSource(rows, marker, source) {
717
- if (marker?.fillTimeline !== true) return;
718
- const media = typeof source === "string" ? rows["media-entities"][source] : void 0;
719
- const durationMs = typeof media?.durationMs === "number" ? media.durationMs : void 0;
720
- if (marker.inMs !== 0 || marker.rate !== void 0 && marker.rate !== 1 || durationMs !== void 0 && marker.outMs !== durationMs) throw new Error(`A Clip that fills the Timeline plays its whole source at 1×: inMs 0, outMs ${durationMs ?? "its length"}, no rate; the Timeline's end cuts it`);
775
+ if (change.containment) assertClipPlacements(before, after, change.containment.before, change.containment.after);
721
776
  }
722
777
  //#endregion
723
778
  //#region src/dsl/projection/types.ts
@@ -953,15 +1008,11 @@ function requireSource(s, id, role) {
953
1008
  return media;
954
1009
  }
955
1010
  function assertMediaRole(media, role) {
956
- if (!media || !{
957
- video_clip: ["video", "image"],
958
- speech: ["audio", "voiceover-recording"],
959
- bgm: ["audio"],
960
- caption: []
961
- }[role].includes(media.kind)) throw new Error(`Source is not allowed on ${role} Track`);
1011
+ if (!media) throw new Error(`Missing source for ${role} Track`);
1012
+ assertClipSourceRole(role, media.kind);
962
1013
  }
963
1014
  function validateWindow(window, duration) {
964
- if (!Number.isSafeInteger(window.inMs) || !Number.isSafeInteger(window.outMs) || window.inMs < 0 || window.outMs <= window.inMs || duration !== void 0 && window.outMs > duration) throw new Error("Invalid Clip window");
1015
+ assertClipWindow(window, duration);
965
1016
  }
966
1017
  function trackOf(s, id) {
967
1018
  requireClip(s, id);
@@ -1249,7 +1300,8 @@ function replaceSourcePlan(s, clipId, input) {
1249
1300
  patch: {
1250
1301
  seqMarker: {
1251
1302
  inMs: input.inMs,
1252
- outMs: input.outMs
1303
+ outMs: input.outMs,
1304
+ ...supportsClipSpeed(source.source) ? {} : { rate: void 0 }
1253
1305
  },
1254
1306
  source: source.id
1255
1307
  }
@@ -5877,6 +5929,30 @@ const phonemeCodec = codec((v) => {
5877
5929
  assertPhoneme(value);
5878
5930
  return value;
5879
5931
  });
5932
+ /** A row-owned binding couples the selection, pronunciation and its immutable file in one LWW value. */
5933
+ const phoneticBindingCodec = codec((value) => {
5934
+ assertEntity("phonetic-script-entities", value);
5935
+ assertPhoneticScript(void 0, { ...value });
5936
+ return JSON.stringify({
5937
+ phoneme: JSON.parse(phonemeCodec.encode(value.phoneme)),
5938
+ system: value.system,
5939
+ key: value.key,
5940
+ storageKey: value.storageKey,
5941
+ digest: value.digest
5942
+ });
5943
+ }, (raw) => {
5944
+ const value = JSON.parse(raw);
5945
+ if (!isRecord(value)) throw new Error("Expected a PhoneticScript binding");
5946
+ const phoneme = phonemeCodec.decode(JSON.stringify(value.phoneme));
5947
+ if (isInvalidAtomic(phoneme)) throw new Error(phoneme.reason);
5948
+ const binding = {
5949
+ ...value,
5950
+ phoneme
5951
+ };
5952
+ assertEntity("phonetic-script-entities", binding);
5953
+ assertPhoneticScript(void 0, binding);
5954
+ return binding;
5955
+ });
5880
5956
  /** A Clip's sequence marker is stored as one string, so it is taken or replaced whole. */
5881
5957
  const seqMarkerCodec = codec((v) => {
5882
5958
  assertSeqMarker(v);
@@ -5943,13 +6019,7 @@ const medeoDslLoroShape = schema({
5943
6019
  storageKey: schema.String(optional),
5944
6020
  digest: schema.String(optional)
5945
6021
  })),
5946
- "phonetic-script-entities": schema.LoroMapRecord(schema.LoroMap({
5947
- phoneme: atomicValue(phonemeCodec),
5948
- system: schema.String(optional),
5949
- key: schema.String(optional),
5950
- storageKey: schema.String(optional),
5951
- digest: schema.String(optional)
5952
- })),
6022
+ "phonetic-script-entities": schema.LoroMapRecord(schema.LoroMap({ binding: atomicValue(phoneticBindingCodec) })),
5953
6023
  "caption-entities": schema.LoroMapRecord(schema.LoroMap({
5954
6024
  selection: optionalAtomicValue(captionContentCodec),
5955
6025
  text: schema.LoroText(optional),
@@ -6001,20 +6071,78 @@ function documentFormat(doc) {
6001
6071
  }
6002
6072
  var UnsupportedDocumentFormatError = class extends Error {
6003
6073
  code = "unsupported_document_format";
6004
- constructor() {
6005
- super("Expected a new DSL document; legacy, mixed or uninitialized content is not supported");
6074
+ constructor(message = "Expected a new DSL document; legacy, mixed or uninitialized content is not supported") {
6075
+ super(message);
6006
6076
  this.name = "UnsupportedDocumentFormatError";
6007
6077
  }
6008
6078
  };
6009
6079
  /** Run after initial import, before opening an editable handle. Empty new documents still have one Timeline. */
6010
6080
  function assertDslDocument(doc) {
6011
6081
  if (documentFormat(doc) !== "dsl" || doc.getMap("timeline-entities").keys().length !== 1) throw new UnsupportedDocumentFormatError();
6082
+ assertPhoneticBindingFormat(doc);
6083
+ }
6084
+ /** This storage cutover has no field-per-fact reader or mixed-version write path. */
6085
+ function assertPhoneticBindingFormat(doc) {
6086
+ if (!Object.hasOwn(doc.getShallowValue(), "phonetic-script-entities")) return;
6087
+ const table = doc.getMap("phonetic-script-entities");
6088
+ for (const id of table.keys()) {
6089
+ const row = table.get(id);
6090
+ if (!isContainer(row) || row.kind() !== "Map" || row.keys().some((key) => key !== "binding") || typeof row.get("binding") !== "string") throw new UnsupportedDocumentFormatError("PhoneticScript storage requires one atomic binding; old DSL documents need explicit migration");
6091
+ }
6012
6092
  }
6013
6093
  //#endregion
6014
6094
  //#region ../medeo-dsl/src/schema/text-styles.ts
6015
6095
  /** How rich-text marks extend at their edges: a language runs on into text typed after it. */
6016
6096
  const textStyles = { language: { expand: "after" } };
6017
6097
  //#endregion
6098
+ //#region src/dsl/document/phonetic-binding.ts
6099
+ /**
6100
+ * Actions and the transaction guard keep their domain-shaped rows. The native schema stores
6101
+ * a PhoneticScript's facts under one binding; this lens applies draft edits to that single value.
6102
+ * It never reads the retired field-per-fact representation.
6103
+ */
6104
+ function logicalDslState(native) {
6105
+ const phonetics = new Proxy({}, {
6106
+ get: (_target, key) => typeof key === "string" ? native["phonetic-script-entities"]?.[key]?.binding : void 0,
6107
+ set: (_target, key, value) => {
6108
+ if (typeof key !== "string") throw new Error("Invalid PhoneticScript key");
6109
+ native["phonetic-script-entities"] ??= {};
6110
+ const existing = native["phonetic-script-entities"][key];
6111
+ if (existing) existing.binding = value;
6112
+ else native["phonetic-script-entities"][key] = { binding: value };
6113
+ return true;
6114
+ },
6115
+ deleteProperty: (_target, key) => {
6116
+ if (typeof key === "string") delete native["phonetic-script-entities"][key];
6117
+ return true;
6118
+ },
6119
+ ownKeys: () => Object.keys(native["phonetic-script-entities"] ?? {}),
6120
+ has: (_target, key) => typeof key === "string" && Object.hasOwn(native["phonetic-script-entities"] ?? {}, key),
6121
+ getOwnPropertyDescriptor: (_target, key) => typeof key === "string" && Object.hasOwn(native["phonetic-script-entities"] ?? {}, key) ? {
6122
+ configurable: true,
6123
+ enumerable: true,
6124
+ writable: true,
6125
+ value: native["phonetic-script-entities"][key].binding
6126
+ } : void 0
6127
+ });
6128
+ const view = {};
6129
+ for (const key of new Set([
6130
+ ...Object.keys(native),
6131
+ ...entityTables,
6132
+ ...relationTables,
6133
+ "contain-relations"
6134
+ ])) Object.defineProperty(view, key, {
6135
+ configurable: true,
6136
+ enumerable: true,
6137
+ get: () => key === "phonetic-script-entities" ? phonetics : Reflect.get(native, key),
6138
+ set: (value) => {
6139
+ if (key === "phonetic-script-entities") throw new Error("Cannot replace the PhoneticScript table");
6140
+ Reflect.set(native, key, value);
6141
+ }
6142
+ });
6143
+ return view;
6144
+ }
6145
+ //#endregion
6018
6146
  //#region src/dsl/document/read.ts
6019
6147
  /** Looking up a missing root must not create it. */
6020
6148
  function rootMap(doc, name) {
@@ -6098,6 +6226,7 @@ function referenceTargets(table, row) {
6098
6226
  }
6099
6227
  }
6100
6228
  function readMedeoDsl(doc, state) {
6229
+ const logical = logicalDslState(state);
6101
6230
  const diagnostics = [];
6102
6231
  const diagnostic = (code, path, error) => diagnostics.push({
6103
6232
  code,
@@ -6107,7 +6236,7 @@ function readMedeoDsl(doc, state) {
6107
6236
  const entities = Object.fromEntries(entityTables.map((t) => [t, {}]));
6108
6237
  const relations = Object.fromEntries(relationTables.map((t) => [t, {}]));
6109
6238
  const richText = {};
6110
- for (const table of entityTables) for (const [id, raw] of Object.entries(state[table] ?? {})) try {
6239
+ for (const table of entityTables) for (const [id, raw] of Object.entries(logical[table] ?? {})) try {
6111
6240
  assertId(id);
6112
6241
  let v = raw;
6113
6242
  for (const field of optionalReferences[table] ?? []) {
@@ -6613,7 +6742,11 @@ function validateDraft(tracked) {
6613
6742
  before,
6614
6743
  after: state,
6615
6744
  entities: tracked.entities,
6616
- relations: tracked.relations
6745
+ relations: tracked.relations,
6746
+ containment: {
6747
+ before: plainTree(before["contain-relations"] ?? []),
6748
+ after: plainTree(state["contain-relations"] ?? [])
6749
+ }
6617
6750
  });
6618
6751
  if (!tracked.treeChanged) return;
6619
6752
  const oldNodes = nodeIndex(before["contain-relations"] ?? []);
@@ -6690,8 +6823,11 @@ function runTransaction(doc, mirror, edit, options, setReject) {
6690
6823
  const steps = [];
6691
6824
  const newCaptionTexts = /* @__PURE__ */ new Map();
6692
6825
  const bodies = /* @__PURE__ */ new Map();
6693
- const before = mirror.getState();
6694
- mirror.setState((raw) => {
6826
+ const nativeBefore = mirror.getState();
6827
+ const before = logicalDslState(nativeBefore);
6828
+ const phoneticRows = /* @__PURE__ */ new Map();
6829
+ mirror.setState((native) => {
6830
+ const raw = logicalDslState(native);
6695
6831
  const tracked = new TransactionDraft(raw, before);
6696
6832
  setReject((error) => tracked.fail(error));
6697
6833
  /** The current text of a Caption's own Text or a script segment, as this transaction leaves it so far. */
@@ -6883,6 +7019,15 @@ function runTransaction(doc, mirror, edit, options, setReject) {
6883
7019
  for (const step of steps) if (step.type === "text") if ("captionId" in step.target) requireEntity(raw, "caption-entities", step.target.captionId);
6884
7020
  else requireSegment(raw, step.target.audioScriptId, step.target.segmentId);
6885
7021
  assertSelectionsAfter(raw, before, tracked, steps);
7022
+ const changedPhonetics = new Set([...tracked.entities.get("phonetic-script-entities")?.keys() ?? [], ...steps.flatMap((step) => step.type !== "text" && step.table === "phonetic-script-entities" ? [step.id] : [])]);
7023
+ for (const id of changedPhonetics) {
7024
+ const row = raw["phonetic-script-entities"][id];
7025
+ phoneticRows.set(id, row ? copyInput(row) : void 0);
7026
+ if (!row) continue;
7027
+ const old = nativeBefore["phonetic-script-entities"][id];
7028
+ if (old) native["phonetic-script-entities"][id] = old;
7029
+ else delete native["phonetic-script-entities"][id];
7030
+ }
6886
7031
  for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) {
6887
7032
  const row = raw["caption-entities"][id];
6888
7033
  if (fields.has("text") && row?.text !== void 0 && before["caption-entities"][id]?.text === void 0) {
@@ -6891,6 +7036,7 @@ function runTransaction(doc, mirror, edit, options, setReject) {
6891
7036
  }
6892
7037
  }
6893
7038
  for (const table of Object.keys(selectionField)) {
7039
+ if (table === "phonetic-script-entities") continue;
6894
7040
  const field = selectionField[table];
6895
7041
  for (const [id, fields] of tracked.entities.get(table) ?? []) {
6896
7042
  const row = raw[table][id];
@@ -6930,6 +7076,8 @@ function runTransaction(doc, mirror, edit, options, setReject) {
6930
7076
  applyTextEdits(text, step.edits);
6931
7077
  continue;
6932
7078
  }
7079
+ const phonetic = step.table === "phonetic-script-entities" ? phoneticRows.get(step.id) : void 0;
7080
+ if (step.table === "phonetic-script-entities" && !phonetic) continue;
6933
7081
  const row = entityRow(doc, step.table, step.id);
6934
7082
  const field = selectionField[step.table];
6935
7083
  if (step.type === "range") {
@@ -6948,12 +7096,21 @@ function runTransaction(doc, mirror, edit, options, setReject) {
6948
7096
  if (!value.start || !value.end) throw new Error("Unable to construct prepared range");
6949
7097
  }
6950
7098
  if (step.phonemeScript !== void 0) value.phonemeScript = step.phonemeScript;
6951
- if (!row) throw new Error("Unable to construct prepared range");
6952
- row.set(field, codecs[step.table].encode(value));
6953
- } else if (step.value !== void 0) {
7099
+ if (phonetic) phonetic.phoneme = value;
7100
+ else {
7101
+ if (!row) throw new Error("Unable to construct prepared range");
7102
+ row.set(field, codecs[step.table].encode(value));
7103
+ }
7104
+ } else if (step.value !== void 0) if (phonetic) phonetic.phoneme = step.value;
7105
+ else {
6954
7106
  if (!row) throw new Error("Prepared selection owner is missing");
6955
7107
  row.set(field, codecs[step.table].encode(step.value));
6956
- } else if (row?.get(field) !== void 0) row.delete(field);
7108
+ }
7109
+ else if (row?.get(field) !== void 0) row.delete(field);
7110
+ }
7111
+ for (const [id, binding] of phoneticRows) {
7112
+ if (!binding) continue;
7113
+ (entityRow(doc, "phonetic-script-entities", id) ?? doc.getMap("phonetic-script-entities").setContainer(id, new LoroMap())).set("binding", phoneticBindingCodec.encode(binding));
6957
7114
  }
6958
7115
  }
6959
7116
  });
@@ -6966,7 +7123,7 @@ function runTransaction(doc, mirror, edit, options, setReject) {
6966
7123
  function assertSelectionsAfter(raw, before, tracked, steps) {
6967
7124
  const selected = new Set(Object.entries(before["caption-entities"]).filter(([, caption]) => typeof caption === "object" && caption.selection !== void 0).map(([id]) => id));
6968
7125
  const changedCaptions = /* @__PURE__ */ new Set();
6969
- for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (fields.has("text")) changedCaptions.add(id);
7126
+ for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (!before["caption-entities"][id] || fields.has("text")) changedCaptions.add(id);
6970
7127
  const phonemes = /* @__PURE__ */ new Map();
6971
7128
  for (const step of steps) {
6972
7129
  if (step.type === "text") {
@@ -7000,7 +7157,7 @@ function assertSelectionsAfter(raw, before, tracked, steps) {
7000
7157
  if (!row) continue;
7001
7158
  const phoneme = phonemes.has(id) ? phonemes.get(id) : row.phoneme;
7002
7159
  const old = before["phonetic-script-entities"][id];
7003
- assertPhoneticScript(old, {
7160
+ assertPhoneticScript(old ? { ...old } : void 0, {
7004
7161
  ...row,
7005
7162
  phoneme: isRecord(phoneme) ? phoneme : void 0
7006
7163
  });
@@ -7039,6 +7196,7 @@ var MedeoDsl = class MedeoDsl {
7039
7196
  "tracks",
7040
7197
  "part_library"
7041
7198
  ].some((key) => Object.hasOwn(roots, key))) throw new Error("Expected a new DSL document, not a legacy content model");
7199
+ assertPhoneticBindingFormat(doc);
7042
7200
  doc.configTextStyle(textStyles);
7043
7201
  this.mirror = new Mirror({
7044
7202
  doc,
@@ -7102,6 +7260,11 @@ var MedeoDsl = class MedeoDsl {
7102
7260
  editCaptionText(captionId, expectedBodyId, edits, audit) {
7103
7261
  this.transact((tx) => tx.editCaptionText(captionId, edits, expectedBodyId), audit);
7104
7262
  }
7263
+ /** Check the session's write capability before an Action starts host-owned resource I/O. */
7264
+ assertWritable() {
7265
+ this.assertReadyToWrite();
7266
+ this.options.mutationGuard?.run(() => {});
7267
+ }
7105
7268
  /**
7106
7269
  * A run inside one segment as native Cursors. Local ranges exclude insertions at their start and
7107
7270
  * include insertions at their end. K01 remains deferred.
@@ -7134,13 +7297,7 @@ var MedeoDsl = class MedeoDsl {
7134
7297
  if (this.ownsDoc) this.doc.free();
7135
7298
  }
7136
7299
  mutate(run) {
7137
- this.assertActive();
7138
- if (this.writing || this.notifying) {
7139
- const error = /* @__PURE__ */ new Error("A DSL mutation is already running");
7140
- this.rejectTransaction?.(error);
7141
- throw error;
7142
- }
7143
- if (this.doc.getPendingTxnLength() !== 0) throw new Error("Commit external native edits before a DSL mutation");
7300
+ this.assertReadyToWrite();
7144
7301
  const apply = () => {
7145
7302
  this.writing = true;
7146
7303
  try {
@@ -7155,6 +7312,15 @@ var MedeoDsl = class MedeoDsl {
7155
7312
  if (this.options.mutationGuard) this.options.mutationGuard.run(apply);
7156
7313
  else apply();
7157
7314
  }
7315
+ assertReadyToWrite() {
7316
+ this.assertActive();
7317
+ if (this.writing || this.notifying) {
7318
+ const error = /* @__PURE__ */ new Error("A DSL mutation is already running");
7319
+ this.rejectTransaction?.(error);
7320
+ throw error;
7321
+ }
7322
+ if (this.doc.getPendingTxnLength() !== 0) throw new Error("Commit external native edits before a DSL mutation");
7323
+ }
7158
7324
  notify() {
7159
7325
  if (this.disposed || !this.dirty) return;
7160
7326
  this.dirty = false;
@@ -7181,12 +7347,13 @@ function spokenScripts(snapshot, recordingId) {
7181
7347
  const found = /* @__PURE__ */ new Map();
7182
7348
  const seen = /* @__PURE__ */ new Set();
7183
7349
  const visit = (id, phoneticScriptId) => {
7184
- if (seen.has(id)) return;
7185
- seen.add(id);
7186
7350
  if (snapshot["audio-script-entities"][id]) {
7187
- if (!found.has(id)) found.set(id, phoneticScriptId);
7351
+ if (!found.has(id)) found.set(id, /* @__PURE__ */ new Set());
7352
+ if (phoneticScriptId !== void 0) found.get(id).add(phoneticScriptId);
7188
7353
  return;
7189
7354
  }
7355
+ if (seen.has(id)) return;
7356
+ seen.add(id);
7190
7357
  const phonetic = snapshot["phonetic-script-entities"][id];
7191
7358
  if (phonetic) {
7192
7359
  visit(phonetic.phoneme.audioScriptId, id);
@@ -7196,11 +7363,18 @@ function spokenScripts(snapshot, recordingId) {
7196
7363
  };
7197
7364
  visit(recordingId);
7198
7365
  if (!found.size) {
7199
- for (const r of relations) if (r.sourceId === recordingId && snapshot["audio-script-entities"][r.entityId]) found.set(r.entityId, void 0);
7366
+ for (const r of relations) if (r.sourceId === recordingId && snapshot["audio-script-entities"][r.entityId]) found.set(r.entityId, /* @__PURE__ */ new Set());
7200
7367
  }
7201
- return [...found].map(([audioScriptId, phoneticScriptId]) => phoneticScriptId === void 0 ? { audioScriptId } : {
7202
- audioScriptId,
7203
- phoneticScriptId
7368
+ return [...found].map(([audioScriptId, ids]) => {
7369
+ const phoneticScriptIds = [...ids];
7370
+ if (phoneticScriptIds.length === 1) return {
7371
+ audioScriptId,
7372
+ phoneticScriptId: phoneticScriptIds[0]
7373
+ };
7374
+ return phoneticScriptIds.length ? {
7375
+ audioScriptId,
7376
+ phoneticScriptIds
7377
+ } : { audioScriptId };
7204
7378
  });
7205
7379
  }
7206
7380
  /** Every AudioScript in the document with its PhoneticScripts, the Speech Clips saying it and the Caption Clips showing it. */
@@ -7245,7 +7419,6 @@ function toAudioScriptViews(snapshot) {
7245
7419
  });
7246
7420
  }
7247
7421
  const layout = resolveDslLayout(snapshot);
7248
- for (const view of views.values()) placeTranscript(snapshot, layout, view);
7249
7422
  const timbres = Object.values(snapshot["voice-timbre-relations"]);
7250
7423
  for (const track of layout.tracks) for (const { clipId } of track.clips) {
7251
7424
  const source = snapshot["clip-entities"][clipId]?.source;
@@ -7269,6 +7442,7 @@ function toAudioScriptViews(snapshot) {
7269
7442
  clipId,
7270
7443
  recordingId: source,
7271
7444
  ...spoken.phoneticScriptId === void 0 ? {} : { phoneticScriptId: spoken.phoneticScriptId },
7445
+ ...spoken.phoneticScriptIds === void 0 ? {} : { phoneticScriptIds: spoken.phoneticScriptIds },
7272
7446
  voices,
7273
7447
  ...applied === void 0 ? {} : { applied }
7274
7448
  });
@@ -7285,19 +7459,23 @@ function toAudioScriptViews(snapshot) {
7285
7459
  segment
7286
7460
  });
7287
7461
  }
7462
+ for (const view of views.values()) placeTranscript(snapshot, layout, view);
7288
7463
  return [...views.values()];
7289
7464
  }
7290
7465
  /**
7291
7466
  * A transcript's segments on the Timeline: a segment's time is in the medium the script was read
7292
- * off, so it lands where the earliest Clip playing that medium holds that moment in its window.
7467
+ * off. An authored Caption anchor identifies its use of that medium; otherwise the earliest
7468
+ * Clip holding that source moment is used. The source time is never taken from the Caption.
7293
7469
  */
7294
7470
  function placeTranscript(snapshot, layout, view) {
7295
7471
  if (!view.sourceMediaIds.length) return;
7296
7472
  const plays = layout.tracks.flatMap((track) => track.clips).filter((clip) => view.sourceMediaIds.includes(snapshot["clip-entities"][clip.clipId]?.source ?? "")).sort((a, b) => a.startMs - b.startMs);
7297
- for (const segment of view.segments) {
7473
+ for (const [index, segment] of view.segments.entries()) {
7298
7474
  const at = segment.startMs;
7299
7475
  if (at === void 0) continue;
7300
- const clip = plays.find((c) => c.inMs <= at && at < c.outMs);
7476
+ const anchoredUses = new Set(view.captions.filter((caption) => caption.segment === index).map((caption) => layout.clips[caption.clipId]?.anchorClipId));
7477
+ const candidates = plays.filter((c) => c.inMs <= at && at < c.outMs);
7478
+ const clip = candidates.find((c) => anchoredUses.has(c.clipId)) ?? candidates[0];
7301
7479
  if (clip) segment.timelineMs = Math.round(clip.startMs + (at - clip.inMs) / clip.rate);
7302
7480
  }
7303
7481
  }
@@ -7308,14 +7486,26 @@ function placeTranscript(snapshot, layout, view) {
7308
7486
  * not what a take was synthesized from (D190).
7309
7487
  */
7310
7488
  function appliedSpeech(snapshot, recordingId, spoken) {
7311
- const sourceId = spoken.phoneticScriptId ?? spoken.audioScriptId;
7312
- const recorded = Object.values(snapshot["derived-from-relations"]).find((r) => r.entityId === recordingId && r.sourceId === sourceId)?.sourceDigest;
7313
- const current = spoken.phoneticScriptId === void 0 ? void 0 : snapshot["phonetic-script-entities"][sourceId]?.digest;
7314
- return recorded === void 0 || current === void 0 ? void 0 : recorded === current;
7489
+ const ids = spoken.phoneticScriptIds ?? (spoken.phoneticScriptId === void 0 ? [] : [spoken.phoneticScriptId]);
7490
+ if (!ids.length) return void 0;
7491
+ let unknown = false;
7492
+ for (const sourceId of ids) {
7493
+ const recorded = Object.values(snapshot["derived-from-relations"]).find((r) => r.entityId === recordingId && r.sourceId === sourceId)?.sourceDigest;
7494
+ const current = snapshot["phonetic-script-entities"][sourceId]?.digest;
7495
+ if (recorded === void 0 || current === void 0) unknown = true;
7496
+ else if (recorded !== current) return false;
7497
+ }
7498
+ return unknown ? void 0 : true;
7315
7499
  }
7316
7500
  //#endregion
7317
7501
  //#region src/dsl/voiceover-actions.ts
7318
- function voiceoverPlan(s, input, result) {
7502
+ function matchesPlainPhoneticOrigin(s, id, scriptId, segmentId) {
7503
+ const phonetic = s["phonetic-script-entities"][id];
7504
+ const run = s.phoneticText[id];
7505
+ const segment = s["audio-script-entities"][scriptId]?.segments.find((item) => item.id === segmentId);
7506
+ return phonetic?.phoneme.audioScriptId === scriptId && phonetic.phoneme.segmentId === segmentId && phonetic.phoneme.phonemeScript === void 0 && run?.start === 0 && run.end === segment?.text.length;
7507
+ }
7508
+ function voiceoverPlan(s, input, result, plainPhoneticIds) {
7319
7509
  if (requireTrack(s, input.trackId).role !== "speech" || input.media.kind !== "voiceover-recording") throw new Error("Voiceover requires a speech Track and a recording");
7320
7510
  if (input.captions.length && (!input.captionTrackId || requireTrack(s, input.captionTrackId).role !== "caption")) throw new Error("Caption Track is required");
7321
7511
  const existing = s["clip-entities"][result.clipId];
@@ -7354,15 +7544,44 @@ function voiceoverPlan(s, input, result) {
7354
7544
  inMs: 0,
7355
7545
  outMs: input.media.durationMs
7356
7546
  }, input.media.durationMs);
7357
- mutations.push({
7358
- type: "set-relation",
7359
- table: "derived-from-relations",
7360
- value: {
7361
- entityId: result.mediaId,
7362
- sourceId: phoneticScriptId ?? result.audioScriptId,
7363
- ...input.sourceDigest === void 0 ? {} : { sourceDigest: input.sourceDigest }
7364
- }
7365
- });
7547
+ if (phoneticScriptId !== void 0) {
7548
+ const recorded = Object.values(s["derived-from-relations"]).find((origin) => origin.entityId === result.mediaId && origin.sourceId === phoneticScriptId);
7549
+ if (recorded?.sourceDigest !== void 0 && input.sourceDigest !== void 0 && recorded.sourceDigest !== input.sourceDigest) throw new Error("A recording already has a different synthesis digest");
7550
+ if (!recorded || recorded.sourceDigest === void 0 && input.sourceDigest !== void 0) mutations.push({
7551
+ type: "set-relation",
7552
+ table: "derived-from-relations",
7553
+ value: {
7554
+ entityId: result.mediaId,
7555
+ sourceId: phoneticScriptId,
7556
+ ...input.sourceDigest === void 0 ? {} : { sourceDigest: input.sourceDigest }
7557
+ }
7558
+ });
7559
+ } else {
7560
+ if (input.sourceDigest !== void 0) throw new Error("A synthesis digest requires its PhoneticScript");
7561
+ if (plainPhoneticIds.length !== segmentIds.length) throw new Error("Expected one PhoneticScript id per plain segment");
7562
+ segmentIds.forEach((segmentId, i) => {
7563
+ const id = plainPhoneticIds[i];
7564
+ if (s["phonetic-script-entities"][id]) {
7565
+ if (!matchesPlainPhoneticOrigin(s, id, result.audioScriptId, segmentId)) throw new Error("PhoneticScript id already names another synthesis origin");
7566
+ } else mutations.push({
7567
+ type: "create-entity",
7568
+ table: "phonetic-script-entities",
7569
+ id,
7570
+ value: { phoneme: {
7571
+ audioScriptId: result.audioScriptId,
7572
+ segmentId
7573
+ } }
7574
+ });
7575
+ if (!Object.values(s["derived-from-relations"]).some((origin) => origin.entityId === result.mediaId && origin.sourceId === id)) mutations.push({
7576
+ type: "set-relation",
7577
+ table: "derived-from-relations",
7578
+ value: {
7579
+ entityId: result.mediaId,
7580
+ sourceId: id
7581
+ }
7582
+ });
7583
+ });
7584
+ }
7366
7585
  if (input.voice) {
7367
7586
  const id = resourceId("voice", input.voice.system, input.voice.key);
7368
7587
  mutations.push({
@@ -7859,10 +8078,20 @@ var DslEditor = class {
7859
8078
  captionClipIds: input.captions.map((c) => c.clipId ?? this.idFactory("clip")),
7860
8079
  captionIds: input.captions.map(() => this.idFactory("caption"))
7861
8080
  };
8081
+ const state = this.dsl.getSnapshot();
8082
+ const plainSegments = input.script.segments === void 0 ? state["audio-script-entities"][result.audioScriptId]?.segments.map((segment) => segment.id) ?? [] : result.segmentIds;
8083
+ const plainPhoneticIds = input.script.phoneticScriptId === void 0 ? plainSegments.map((segmentId) => {
8084
+ const origins = Object.values(state["derived-from-relations"]).filter((origin) => {
8085
+ if (origin.entityId !== result.mediaId) return false;
8086
+ return matchesPlainPhoneticOrigin(state, origin.sourceId, result.audioScriptId, segmentId);
8087
+ });
8088
+ if (origins.length > 1) throw new Error("Ambiguous plain recording origin");
8089
+ return origins[0]?.sourceId ?? this.idFactory("phonetic-script");
8090
+ }) : [];
7862
8091
  this.write("applyVoiceover", {
7863
8092
  input,
7864
8093
  result
7865
- }, voiceoverPlan(this.dsl.getSnapshot(), input, result), options);
8094
+ }, voiceoverPlan(state, input, result, plainPhoneticIds), options);
7866
8095
  return result;
7867
8096
  }
7868
8097
  voiceoverScriptId(script) {
@@ -7879,8 +8108,12 @@ var DslEditor = class {
7879
8108
  * it; an unchanged one keeps its file. Recordings already spoken from it keep their derived-from.
7880
8109
  */
7881
8110
  async setPhoneticScript(input, options) {
8111
+ this.dsl.assertWritable();
7882
8112
  const id = input.phoneticScriptId ?? this.idFactory("phonetic-script");
7883
8113
  const range = segmentRange(input);
8114
+ assertId(id);
8115
+ assertSegmentRange(range, requireSegment(this.dsl.getSnapshot(), range.audioScriptId, range.segmentId).text);
8116
+ assertPhonemeScript(input.phonemeScript);
7884
8117
  const file = input.phonemeScript === void 0 ? {} : await this.fileFor(id, input.phonemeScript);
7885
8118
  const exists = Object.hasOwn(this.dsl.getSnapshot()["phonetic-script-entities"], id);
7886
8119
  const cleared = {
@@ -8105,7 +8338,8 @@ function toVideoDraft(snapshot) {
8105
8338
  } }
8106
8339
  } };
8107
8340
  } else if (track.role === "speech") {
8108
- if (!source || source.kind !== "voiceover-recording" || source.system !== "memota-speech") fail("Legacy Speech requires a memota-speech VoiceoverRecording");
8341
+ if (!source || source.kind !== "audio" && source.kind !== "voiceover-recording") fail("Speech playback requires an Audio or VoiceoverRecording");
8342
+ if (source.system !== "memota" && !(source.kind === "voiceover-recording" && source.system === "memota-speech")) fail("Speech playback requires a Memota audio file");
8109
8343
  requireWholeAudio(source, clip, fail);
8110
8344
  if (clip.fillTimeline) fail("Legacy Speech cannot express timeline fill");
8111
8345
  const voices = Object.values(snapshot["voice-timbre-relations"]).filter((r) => r.recordingId === sourceId).flatMap((r) => snapshot["voice-entities"][r.voiceId] ? [snapshot["voice-entities"][r.voiceId]] : []);
@@ -8122,7 +8356,7 @@ function toVideoDraft(snapshot) {
8122
8356
  audio_storage_key: source.storageKey,
8123
8357
  origin_speech_id: source.key,
8124
8358
  volume: clip.volume,
8125
- audio_script: spokenText(snapshot, sourceId),
8359
+ audio_script: source.kind === "voiceover-recording" ? spokenText(snapshot, sourceId) : "",
8126
8360
  voice: voice ? {
8127
8361
  id: voice.key,
8128
8362
  name: voice.name
@@ -8218,6 +8452,15 @@ function spokenText(snapshot, recordingId) {
8218
8452
  if (!first) return "";
8219
8453
  if (first.phoneticScriptId !== void 0) return snapshot.phoneticText[first.phoneticScriptId]?.text ?? "";
8220
8454
  const script = snapshot["audio-script-entities"][first.audioScriptId];
8455
+ if (first.phoneticScriptIds !== void 0 && script) {
8456
+ const ids = first.phoneticScriptIds;
8457
+ const selections = ids.map((id) => snapshot["phonetic-script-entities"][id]);
8458
+ if (!(ids.length === script.segments.length && script.segments.every((segment) => {
8459
+ const matching = ids.filter((id) => snapshot["phonetic-script-entities"][id]?.phoneme.segmentId === segment.id);
8460
+ const run = matching.length === 1 ? snapshot.phoneticText[matching[0]] : void 0;
8461
+ return run?.start === 0 && run.end === segment.text.length;
8462
+ })) || selections.some((selection) => selection.phoneme.phonemeScript !== void 0)) projectionError("ambiguous-script", `media-entities/${recordingId}`, "Multiple phonetic sources require complete plain selections of one script");
8463
+ }
8221
8464
  return script ? script.segments.map((segment) => segment.text).join("\n") : "";
8222
8465
  }
8223
8466
  //#endregion
@@ -8440,11 +8683,7 @@ function prepare(input, options) {
8440
8683
  start: start - segment.start,
8441
8684
  end: end - segment.start
8442
8685
  });
8443
- } else diagnostics.push({
8444
- code: "caption-text-unmatched",
8445
- path: `part_library/${captionId}/text`,
8446
- message: "No forward exact match; preserved as independent Caption text"
8447
- });
8686
+ } else fail(`part_library/${captionId}/text`, "Caption must match a segment of its AudioScript");
8448
8687
  }
8449
8688
  }
8450
8689
  const hosts = /* @__PURE__ */ new Map();
@@ -8487,16 +8726,14 @@ function prepare(input, options) {
8487
8726
  if (caption.speech_part_id !== void 0 && owner.get(id) !== caption.speech_part_id) fail(`part_library/${id}/speech_part_id`, "Caption ownership must agree with ordered speech.caption_ids");
8488
8727
  sourceId = claim(mint("caption"));
8489
8728
  const selected = selections.get(id), style = fromDraftStyle(caption.style);
8729
+ if (!selected) fail(`part_library/${id}/text`, "Caption must select a segment of an AudioScript");
8490
8730
  mutations.push({
8491
8731
  type: "create-entity",
8492
8732
  table: "caption-entities",
8493
8733
  id: sourceId,
8494
- value: {
8495
- ...style === void 0 ? {} : { style },
8496
- ...selected ? {} : { text: caption.text }
8497
- }
8734
+ value: style === void 0 ? {} : { style }
8498
8735
  });
8499
- if (selected) mutations.push({
8736
+ mutations.push({
8500
8737
  type: "set-caption-content",
8501
8738
  captionId: sourceId,
8502
8739
  ...selected
@@ -8684,4 +8921,4 @@ function normalizeLegacyVideoDraft(input, mediaFacts = []) {
8684
8921
  };
8685
8922
  }
8686
8923
  //#endregion
8687
- export { tupleHash as C, resourceId as S, DslProjectionError as _, createInitialMedeoDsl as a, defaultIdFactory as b, MedeoDsl as c, documentFormat as d, medeoDslLoroShape as f, resolveDslLayout as g, timeAnchorCodec as h, DEFAULT_UNIT_TIME_MS as i, UnsupportedDocumentFormatError as l, isInvalidAtomic as m, fromVideoDraft as n, DslEditor as o, captionContentCodec as p, toVideoDraft as r, toAudioScriptViews as s, normalizeLegacyVideoDraft as t, assertDslDocument as u, BLANK_PLACEHOLDER as v, relationId as x, missingMediaPlaceholder as y };
8924
+ export { relationId as C, defaultIdFactory as S, tupleHash as T, timeAnchorCodec as _, createInitialMedeoDsl as a, BLANK_PLACEHOLDER as b, toAudioScriptViews as c, assertDslDocument as d, assertPhoneticBindingFormat as f, isInvalidAtomic as g, captionContentCodec as h, DEFAULT_UNIT_TIME_MS as i, MedeoDsl as l, medeoDslLoroShape as m, fromVideoDraft as n, DslEditor as o, documentFormat as p, toVideoDraft as r, spokenScripts as s, normalizeLegacyVideoDraft as t, UnsupportedDocumentFormatError as u, resolveDslLayout as v, resourceId as w, missingMediaPlaceholder as x, DslProjectionError as y };