@mengine/medeo-client 3.0.0 → 3.0.1-caption-source.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
  }
@@ -576,9 +580,10 @@ function assertPhonemeSelection(rows, selection, text) {
576
580
  assertSegmentRange(selection, text);
577
581
  if (selection.phonemeScript !== void 0 && typeof selection.phonemeScript !== "string") throw new Error("phonemeScript is a string");
578
582
  }
579
- /** A caption's text comes from a selection of a script or from its own text, never both. */
583
+ /** Every new or rewritten Caption selects its words from one AudioScript segment. */
580
584
  function assertCaptionSource(caption, selected) {
581
- if (caption?.text !== void 0 && selected) throw new Error("Caption must have only one content source");
585
+ if (!selected) throw new Error("Caption must select a segment of an AudioScript");
586
+ if (caption?.text !== void 0) throw new Error("Caption must have only one content source");
582
587
  }
583
588
  //#endregion
584
589
  //#region ../medeo-dsl/src/contain.ts
@@ -638,6 +643,63 @@ function assertContainTree(rows, tree) {
638
643
  }
639
644
  }
640
645
  //#endregion
646
+ //#region ../medeo-dsl/src/clip-playback.ts
647
+ const playable = {
648
+ video_clip: ["video", "image"],
649
+ speech: ["audio", "voiceover-recording"],
650
+ caption: ["caption"],
651
+ bgm: ["audio"]
652
+ };
653
+ function supportsClipSpeed(source) {
654
+ return isRecord(source) && [
655
+ "video",
656
+ "audio",
657
+ "voiceover-recording"
658
+ ].includes(String(source.kind));
659
+ }
660
+ /** Content-kind admission is shared by Actions, script compilation and the final transaction. */
661
+ function assertClipSourceRole(role, kind) {
662
+ if (!playable[role]?.includes(kind)) throw new Error(`A ${role} Track does not play ${kind}`);
663
+ }
664
+ function assertClipWindow(window, duration) {
665
+ if (!Number.isSafeInteger(window.inMs) || !Number.isSafeInteger(window.outMs) || window.inMs < 0 || window.outMs <= window.inMs) throw new Error("Invalid Clip window");
666
+ if (duration !== void 0 && window.outMs > duration) throw new Error(`Invalid Clip window: ${window.outMs}ms is past its content's ${duration}ms`);
667
+ }
668
+ /** Check authored playback facts; a remote merge is still read by projection with diagnostics. */
669
+ function assertClipPlayback(clip, source, role) {
670
+ if (!isRecord(source)) throw new Error("Invalid Clip source");
671
+ const kind = source.kind ?? "caption";
672
+ const marker = clip.seqMarker;
673
+ assertSeqMarker(marker);
674
+ if (marker.outMs <= marker.inMs) throw new Error("Clip window must be nonempty");
675
+ if (role !== void 0) assertClipSourceRole(role, kind);
676
+ if (marker.rate !== void 0 && !supportsClipSpeed(source)) throw new Error("Only video and sound change speed");
677
+ const duration = typeof source.durationMs === "number" ? source.durationMs : void 0;
678
+ if (marker.fillTimeline) {
679
+ if (kind !== "audio") throw new Error("Only BGM audio fills the Timeline");
680
+ 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`);
681
+ }
682
+ assertClipWindow(marker);
683
+ }
684
+ function clipParents(tree) {
685
+ const result = /* @__PURE__ */ new Map();
686
+ for (const track of tree) for (const clip of track.children ?? []) result.set(clip.entityId, track.entityId);
687
+ return result;
688
+ }
689
+ /** A new parent or Track role revalidates that binding, without blocking unrelated merged rows. */
690
+ function assertClipPlacements(before, after, previousTree, nextTree) {
691
+ const previous = clipParents(previousTree);
692
+ for (const [id, parent] of clipParents(nextTree)) {
693
+ const track = after["track-entities"][parent];
694
+ const previousTrack = before["track-entities"][parent];
695
+ if (previous.get(id) === parent && (track === void 0 || isRecord(track) && isRecord(previousTrack) && previousTrack.role === track.role)) continue;
696
+ const role = requireEntity(after, "track-entities", parent).role;
697
+ const clip = after["clip-entities"][id];
698
+ if (!clip) continue;
699
+ assertClipPlayback(clip, after["media-entities"][clip.source] ?? after["caption-entities"][clip.source], role);
700
+ }
701
+ }
702
+ //#endregion
641
703
  //#region ../medeo-dsl/src/plan.ts
642
704
  /** Include removed nested keys: a partial merge alone would conceal a deleted required field. */
643
705
  function replacementPatch(before, after) {
@@ -653,6 +715,7 @@ function replacementPatch(before, after) {
653
715
  */
654
716
  function assertChangeAdmitted(change) {
655
717
  const { before, after } = change;
718
+ const parents = change.containment ? clipParents(change.containment.after) : void 0;
656
719
  for (const [table, rows] of change.entities) for (const [id, fields] of rows) {
657
720
  const value = after[table][id];
658
721
  if (value === void 0) continue;
@@ -667,25 +730,17 @@ function assertChangeAdmitted(change) {
667
730
  }
668
731
  const row = value;
669
732
  for (const field of referenceFields[table] ?? []) if (!old || fields.has(field)) assertReferenceAdmitted(after, table, id, field, row[field]);
670
- const marker = row.seqMarker;
671
- if (table === "clip-entities" && (!old || fields.has("seqMarker")) && marker?.outMs <= marker?.inMs) throw new Error("Clip window must be nonempty");
672
- if (table === "clip-entities" && (!old || fields.has("seqMarker") || fields.has("source"))) assertFillsWithWholeSource(after, marker, row.source);
733
+ if (table === "clip-entities" && (!old || fields.has("seqMarker") || fields.has("source"))) {
734
+ const parent = parents?.get(id);
735
+ assertClipPlayback(row, after["media-entities"][row.source] ?? after["caption-entities"][row.source], parent === void 0 ? void 0 : requireEntity(after, "track-entities", parent).role);
736
+ }
673
737
  }
674
738
  if (change.entities.has("timeline-entities") && Object.keys(after["timeline-entities"]).length > 1) throw new Error("Document already has a Timeline");
675
739
  for (const [table, ids] of change.relations) for (const id of ids) {
676
740
  const value = after[table][id];
677
741
  if (value !== void 0) assertRelationAdmitted(after, table, id, value);
678
742
  }
679
- }
680
- /**
681
- * A Clip that fills the Timeline plays its whole source at 1×: the player loops a short source and
682
- * cuts a long one at the Timeline's end (RFC 02 §3.3), so a window or speed of its own has no meaning.
683
- */
684
- function assertFillsWithWholeSource(rows, marker, source) {
685
- if (marker?.fillTimeline !== true) return;
686
- const media = typeof source === "string" ? rows["media-entities"][source] : void 0;
687
- const durationMs = typeof media?.durationMs === "number" ? media.durationMs : void 0;
688
- 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`);
743
+ if (change.containment) assertClipPlacements(before, after, change.containment.before, change.containment.after);
689
744
  }
690
745
  //#endregion
691
746
  //#region src/dsl/projection/types.ts
@@ -921,15 +976,11 @@ function requireSource(s, id, role) {
921
976
  return media;
922
977
  }
923
978
  function assertMediaRole(media, role) {
924
- if (!media || !{
925
- video_clip: ["video", "image"],
926
- speech: ["audio", "voiceover-recording"],
927
- bgm: ["audio"],
928
- caption: []
929
- }[role].includes(media.kind)) throw new Error(`Source is not allowed on ${role} Track`);
979
+ if (!media) throw new Error(`Missing source for ${role} Track`);
980
+ assertClipSourceRole(role, media.kind);
930
981
  }
931
982
  function validateWindow(window, duration) {
932
- 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");
983
+ assertClipWindow(window, duration);
933
984
  }
934
985
  function trackOf(s, id) {
935
986
  requireClip(s, id);
@@ -1217,7 +1268,8 @@ function replaceSourcePlan(s, clipId, input) {
1217
1268
  patch: {
1218
1269
  seqMarker: {
1219
1270
  inMs: input.inMs,
1220
- outMs: input.outMs
1271
+ outMs: input.outMs,
1272
+ ...supportsClipSpeed(source.source) ? {} : { rate: void 0 }
1221
1273
  },
1222
1274
  source: source.id
1223
1275
  }
@@ -5845,6 +5897,30 @@ const phonemeCodec = codec((v) => {
5845
5897
  assertPhoneme(value);
5846
5898
  return value;
5847
5899
  });
5900
+ /** A row-owned binding couples the selection, pronunciation and its immutable file in one LWW value. */
5901
+ const phoneticBindingCodec = codec((value) => {
5902
+ assertEntity("phonetic-script-entities", value);
5903
+ assertPhoneticScript(void 0, { ...value });
5904
+ return JSON.stringify({
5905
+ phoneme: JSON.parse(phonemeCodec.encode(value.phoneme)),
5906
+ system: value.system,
5907
+ key: value.key,
5908
+ storageKey: value.storageKey,
5909
+ digest: value.digest
5910
+ });
5911
+ }, (raw) => {
5912
+ const value = JSON.parse(raw);
5913
+ if (!isRecord(value)) throw new Error("Expected a PhoneticScript binding");
5914
+ const phoneme = phonemeCodec.decode(JSON.stringify(value.phoneme));
5915
+ if (isInvalidAtomic(phoneme)) throw new Error(phoneme.reason);
5916
+ const binding = {
5917
+ ...value,
5918
+ phoneme
5919
+ };
5920
+ assertEntity("phonetic-script-entities", binding);
5921
+ assertPhoneticScript(void 0, binding);
5922
+ return binding;
5923
+ });
5848
5924
  /** A Clip's sequence marker is stored as one string, so it is taken or replaced whole. */
5849
5925
  const seqMarkerCodec = codec((v) => {
5850
5926
  assertSeqMarker(v);
@@ -5911,13 +5987,7 @@ const medeoDslLoroShape = schema({
5911
5987
  storageKey: schema.String(optional),
5912
5988
  digest: schema.String(optional)
5913
5989
  })),
5914
- "phonetic-script-entities": schema.LoroMapRecord(schema.LoroMap({
5915
- phoneme: atomicValue(phonemeCodec),
5916
- system: schema.String(optional),
5917
- key: schema.String(optional),
5918
- storageKey: schema.String(optional),
5919
- digest: schema.String(optional)
5920
- })),
5990
+ "phonetic-script-entities": schema.LoroMapRecord(schema.LoroMap({ binding: atomicValue(phoneticBindingCodec) })),
5921
5991
  "caption-entities": schema.LoroMapRecord(schema.LoroMap({
5922
5992
  selection: optionalAtomicValue(captionContentCodec),
5923
5993
  text: schema.LoroText(optional),
@@ -5969,20 +6039,78 @@ function documentFormat(doc) {
5969
6039
  }
5970
6040
  var UnsupportedDocumentFormatError = class extends Error {
5971
6041
  code = "unsupported_document_format";
5972
- constructor() {
5973
- super("Expected a new DSL document; legacy, mixed or uninitialized content is not supported");
6042
+ constructor(message = "Expected a new DSL document; legacy, mixed or uninitialized content is not supported") {
6043
+ super(message);
5974
6044
  this.name = "UnsupportedDocumentFormatError";
5975
6045
  }
5976
6046
  };
5977
6047
  /** Run after initial import, before opening an editable handle. Empty new documents still have one Timeline. */
5978
6048
  function assertDslDocument(doc) {
5979
6049
  if (documentFormat(doc) !== "dsl" || doc.getMap("timeline-entities").keys().length !== 1) throw new UnsupportedDocumentFormatError();
6050
+ assertPhoneticBindingFormat(doc);
6051
+ }
6052
+ /** This storage cutover has no field-per-fact reader or mixed-version write path. */
6053
+ function assertPhoneticBindingFormat(doc) {
6054
+ if (!Object.hasOwn(doc.getShallowValue(), "phonetic-script-entities")) return;
6055
+ const table = doc.getMap("phonetic-script-entities");
6056
+ for (const id of table.keys()) {
6057
+ const row = table.get(id);
6058
+ 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");
6059
+ }
5980
6060
  }
5981
6061
  //#endregion
5982
6062
  //#region ../medeo-dsl/src/schema/text-styles.ts
5983
6063
  /** How rich-text marks extend at their edges: a language runs on into text typed after it. */
5984
6064
  const textStyles = { language: { expand: "after" } };
5985
6065
  //#endregion
6066
+ //#region src/dsl/document/phonetic-binding.ts
6067
+ /**
6068
+ * Actions and the transaction guard keep their domain-shaped rows. The native schema stores
6069
+ * a PhoneticScript's facts under one binding; this lens applies draft edits to that single value.
6070
+ * It never reads the retired field-per-fact representation.
6071
+ */
6072
+ function logicalDslState(native) {
6073
+ const phonetics = new Proxy({}, {
6074
+ get: (_target, key) => typeof key === "string" ? native["phonetic-script-entities"]?.[key]?.binding : void 0,
6075
+ set: (_target, key, value) => {
6076
+ if (typeof key !== "string") throw new Error("Invalid PhoneticScript key");
6077
+ native["phonetic-script-entities"] ??= {};
6078
+ const existing = native["phonetic-script-entities"][key];
6079
+ if (existing) existing.binding = value;
6080
+ else native["phonetic-script-entities"][key] = { binding: value };
6081
+ return true;
6082
+ },
6083
+ deleteProperty: (_target, key) => {
6084
+ if (typeof key === "string") delete native["phonetic-script-entities"][key];
6085
+ return true;
6086
+ },
6087
+ ownKeys: () => Object.keys(native["phonetic-script-entities"] ?? {}),
6088
+ has: (_target, key) => typeof key === "string" && Object.hasOwn(native["phonetic-script-entities"] ?? {}, key),
6089
+ getOwnPropertyDescriptor: (_target, key) => typeof key === "string" && Object.hasOwn(native["phonetic-script-entities"] ?? {}, key) ? {
6090
+ configurable: true,
6091
+ enumerable: true,
6092
+ writable: true,
6093
+ value: native["phonetic-script-entities"][key].binding
6094
+ } : void 0
6095
+ });
6096
+ const view = {};
6097
+ for (const key of new Set([
6098
+ ...Object.keys(native),
6099
+ ...entityTables,
6100
+ ...relationTables,
6101
+ "contain-relations"
6102
+ ])) Object.defineProperty(view, key, {
6103
+ configurable: true,
6104
+ enumerable: true,
6105
+ get: () => key === "phonetic-script-entities" ? phonetics : Reflect.get(native, key),
6106
+ set: (value) => {
6107
+ if (key === "phonetic-script-entities") throw new Error("Cannot replace the PhoneticScript table");
6108
+ Reflect.set(native, key, value);
6109
+ }
6110
+ });
6111
+ return view;
6112
+ }
6113
+ //#endregion
5986
6114
  //#region src/dsl/document/read.ts
5987
6115
  /** Looking up a missing root must not create it. */
5988
6116
  function rootMap(doc, name) {
@@ -6066,6 +6194,7 @@ function referenceTargets(table, row) {
6066
6194
  }
6067
6195
  }
6068
6196
  function readMedeoDsl(doc, state) {
6197
+ const logical = logicalDslState(state);
6069
6198
  const diagnostics = [];
6070
6199
  const diagnostic = (code, path, error) => diagnostics.push({
6071
6200
  code,
@@ -6075,7 +6204,7 @@ function readMedeoDsl(doc, state) {
6075
6204
  const entities = Object.fromEntries(entityTables.map((t) => [t, {}]));
6076
6205
  const relations = Object.fromEntries(relationTables.map((t) => [t, {}]));
6077
6206
  const richText = {};
6078
- for (const table of entityTables) for (const [id, raw] of Object.entries(state[table] ?? {})) try {
6207
+ for (const table of entityTables) for (const [id, raw] of Object.entries(logical[table] ?? {})) try {
6079
6208
  assertId(id);
6080
6209
  let v = raw;
6081
6210
  for (const field of optionalReferences[table] ?? []) {
@@ -6581,7 +6710,11 @@ function validateDraft(tracked) {
6581
6710
  before,
6582
6711
  after: state,
6583
6712
  entities: tracked.entities,
6584
- relations: tracked.relations
6713
+ relations: tracked.relations,
6714
+ containment: {
6715
+ before: plainTree(before["contain-relations"] ?? []),
6716
+ after: plainTree(state["contain-relations"] ?? [])
6717
+ }
6585
6718
  });
6586
6719
  if (!tracked.treeChanged) return;
6587
6720
  const oldNodes = nodeIndex(before["contain-relations"] ?? []);
@@ -6658,8 +6791,11 @@ function runTransaction(doc, mirror, edit, options, setReject) {
6658
6791
  const steps = [];
6659
6792
  const newCaptionTexts = /* @__PURE__ */ new Map();
6660
6793
  const bodies = /* @__PURE__ */ new Map();
6661
- const before = mirror.getState();
6662
- mirror.setState((raw) => {
6794
+ const nativeBefore = mirror.getState();
6795
+ const before = logicalDslState(nativeBefore);
6796
+ const phoneticRows = /* @__PURE__ */ new Map();
6797
+ mirror.setState((native) => {
6798
+ const raw = logicalDslState(native);
6663
6799
  const tracked = new TransactionDraft(raw, before);
6664
6800
  setReject((error) => tracked.fail(error));
6665
6801
  /** The current text of a Caption's own Text or a script segment, as this transaction leaves it so far. */
@@ -6851,6 +6987,15 @@ function runTransaction(doc, mirror, edit, options, setReject) {
6851
6987
  for (const step of steps) if (step.type === "text") if ("captionId" in step.target) requireEntity(raw, "caption-entities", step.target.captionId);
6852
6988
  else requireSegment(raw, step.target.audioScriptId, step.target.segmentId);
6853
6989
  assertSelectionsAfter(raw, before, tracked, steps);
6990
+ const changedPhonetics = new Set([...tracked.entities.get("phonetic-script-entities")?.keys() ?? [], ...steps.flatMap((step) => step.type !== "text" && step.table === "phonetic-script-entities" ? [step.id] : [])]);
6991
+ for (const id of changedPhonetics) {
6992
+ const row = raw["phonetic-script-entities"][id];
6993
+ phoneticRows.set(id, row ? copyInput(row) : void 0);
6994
+ if (!row) continue;
6995
+ const old = nativeBefore["phonetic-script-entities"][id];
6996
+ if (old) native["phonetic-script-entities"][id] = old;
6997
+ else delete native["phonetic-script-entities"][id];
6998
+ }
6854
6999
  for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) {
6855
7000
  const row = raw["caption-entities"][id];
6856
7001
  if (fields.has("text") && row?.text !== void 0 && before["caption-entities"][id]?.text === void 0) {
@@ -6859,6 +7004,7 @@ function runTransaction(doc, mirror, edit, options, setReject) {
6859
7004
  }
6860
7005
  }
6861
7006
  for (const table of Object.keys(selectionField)) {
7007
+ if (table === "phonetic-script-entities") continue;
6862
7008
  const field = selectionField[table];
6863
7009
  for (const [id, fields] of tracked.entities.get(table) ?? []) {
6864
7010
  const row = raw[table][id];
@@ -6898,6 +7044,8 @@ function runTransaction(doc, mirror, edit, options, setReject) {
6898
7044
  applyTextEdits(text, step.edits);
6899
7045
  continue;
6900
7046
  }
7047
+ const phonetic = step.table === "phonetic-script-entities" ? phoneticRows.get(step.id) : void 0;
7048
+ if (step.table === "phonetic-script-entities" && !phonetic) continue;
6901
7049
  const row = entityRow(doc, step.table, step.id);
6902
7050
  const field = selectionField[step.table];
6903
7051
  if (step.type === "range") {
@@ -6916,12 +7064,21 @@ function runTransaction(doc, mirror, edit, options, setReject) {
6916
7064
  if (!value.start || !value.end) throw new Error("Unable to construct prepared range");
6917
7065
  }
6918
7066
  if (step.phonemeScript !== void 0) value.phonemeScript = step.phonemeScript;
6919
- if (!row) throw new Error("Unable to construct prepared range");
6920
- row.set(field, codecs[step.table].encode(value));
6921
- } else if (step.value !== void 0) {
7067
+ if (phonetic) phonetic.phoneme = value;
7068
+ else {
7069
+ if (!row) throw new Error("Unable to construct prepared range");
7070
+ row.set(field, codecs[step.table].encode(value));
7071
+ }
7072
+ } else if (step.value !== void 0) if (phonetic) phonetic.phoneme = step.value;
7073
+ else {
6922
7074
  if (!row) throw new Error("Prepared selection owner is missing");
6923
7075
  row.set(field, codecs[step.table].encode(step.value));
6924
- } else if (row?.get(field) !== void 0) row.delete(field);
7076
+ }
7077
+ else if (row?.get(field) !== void 0) row.delete(field);
7078
+ }
7079
+ for (const [id, binding] of phoneticRows) {
7080
+ if (!binding) continue;
7081
+ (entityRow(doc, "phonetic-script-entities", id) ?? doc.getMap("phonetic-script-entities").setContainer(id, new LoroMap())).set("binding", phoneticBindingCodec.encode(binding));
6925
7082
  }
6926
7083
  }
6927
7084
  });
@@ -6934,7 +7091,7 @@ function runTransaction(doc, mirror, edit, options, setReject) {
6934
7091
  function assertSelectionsAfter(raw, before, tracked, steps) {
6935
7092
  const selected = new Set(Object.entries(before["caption-entities"]).filter(([, caption]) => typeof caption === "object" && caption.selection !== void 0).map(([id]) => id));
6936
7093
  const changedCaptions = /* @__PURE__ */ new Set();
6937
- for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (fields.has("text")) changedCaptions.add(id);
7094
+ for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (!before["caption-entities"][id] || fields.has("text")) changedCaptions.add(id);
6938
7095
  const phonemes = /* @__PURE__ */ new Map();
6939
7096
  for (const step of steps) {
6940
7097
  if (step.type === "text") {
@@ -6968,7 +7125,7 @@ function assertSelectionsAfter(raw, before, tracked, steps) {
6968
7125
  if (!row) continue;
6969
7126
  const phoneme = phonemes.has(id) ? phonemes.get(id) : row.phoneme;
6970
7127
  const old = before["phonetic-script-entities"][id];
6971
- assertPhoneticScript(old, {
7128
+ assertPhoneticScript(old ? { ...old } : void 0, {
6972
7129
  ...row,
6973
7130
  phoneme: isRecord(phoneme) ? phoneme : void 0
6974
7131
  });
@@ -7007,6 +7164,7 @@ var MedeoDsl = class MedeoDsl {
7007
7164
  "tracks",
7008
7165
  "part_library"
7009
7166
  ].some((key) => Object.hasOwn(roots, key))) throw new Error("Expected a new DSL document, not a legacy content model");
7167
+ assertPhoneticBindingFormat(doc);
7010
7168
  doc.configTextStyle(textStyles);
7011
7169
  this.mirror = new Mirror({
7012
7170
  doc,
@@ -7070,6 +7228,11 @@ var MedeoDsl = class MedeoDsl {
7070
7228
  editCaptionText(captionId, expectedBodyId, edits, audit) {
7071
7229
  this.transact((tx) => tx.editCaptionText(captionId, edits, expectedBodyId), audit);
7072
7230
  }
7231
+ /** Check the session's write capability before an Action starts host-owned resource I/O. */
7232
+ assertWritable() {
7233
+ this.assertReadyToWrite();
7234
+ this.options.mutationGuard?.run(() => {});
7235
+ }
7073
7236
  /**
7074
7237
  * A run inside one segment as native Cursors. Local ranges exclude insertions at their start and
7075
7238
  * include insertions at their end. K01 remains deferred.
@@ -7102,13 +7265,7 @@ var MedeoDsl = class MedeoDsl {
7102
7265
  if (this.ownsDoc) this.doc.free();
7103
7266
  }
7104
7267
  mutate(run) {
7105
- this.assertActive();
7106
- if (this.writing || this.notifying) {
7107
- const error = /* @__PURE__ */ new Error("A DSL mutation is already running");
7108
- this.rejectTransaction?.(error);
7109
- throw error;
7110
- }
7111
- if (this.doc.getPendingTxnLength() !== 0) throw new Error("Commit external native edits before a DSL mutation");
7268
+ this.assertReadyToWrite();
7112
7269
  const apply = () => {
7113
7270
  this.writing = true;
7114
7271
  try {
@@ -7123,6 +7280,15 @@ var MedeoDsl = class MedeoDsl {
7123
7280
  if (this.options.mutationGuard) this.options.mutationGuard.run(apply);
7124
7281
  else apply();
7125
7282
  }
7283
+ assertReadyToWrite() {
7284
+ this.assertActive();
7285
+ if (this.writing || this.notifying) {
7286
+ const error = /* @__PURE__ */ new Error("A DSL mutation is already running");
7287
+ this.rejectTransaction?.(error);
7288
+ throw error;
7289
+ }
7290
+ if (this.doc.getPendingTxnLength() !== 0) throw new Error("Commit external native edits before a DSL mutation");
7291
+ }
7126
7292
  notify() {
7127
7293
  if (this.disposed || !this.dirty) return;
7128
7294
  this.dirty = false;
@@ -7149,12 +7315,13 @@ function spokenScripts(snapshot, recordingId) {
7149
7315
  const found = /* @__PURE__ */ new Map();
7150
7316
  const seen = /* @__PURE__ */ new Set();
7151
7317
  const visit = (id, phoneticScriptId) => {
7152
- if (seen.has(id)) return;
7153
- seen.add(id);
7154
7318
  if (snapshot["audio-script-entities"][id]) {
7155
- if (!found.has(id)) found.set(id, phoneticScriptId);
7319
+ if (!found.has(id)) found.set(id, /* @__PURE__ */ new Set());
7320
+ if (phoneticScriptId !== void 0) found.get(id).add(phoneticScriptId);
7156
7321
  return;
7157
7322
  }
7323
+ if (seen.has(id)) return;
7324
+ seen.add(id);
7158
7325
  const phonetic = snapshot["phonetic-script-entities"][id];
7159
7326
  if (phonetic) {
7160
7327
  visit(phonetic.phoneme.audioScriptId, id);
@@ -7164,11 +7331,18 @@ function spokenScripts(snapshot, recordingId) {
7164
7331
  };
7165
7332
  visit(recordingId);
7166
7333
  if (!found.size) {
7167
- for (const r of relations) if (r.sourceId === recordingId && snapshot["audio-script-entities"][r.entityId]) found.set(r.entityId, void 0);
7334
+ for (const r of relations) if (r.sourceId === recordingId && snapshot["audio-script-entities"][r.entityId]) found.set(r.entityId, /* @__PURE__ */ new Set());
7168
7335
  }
7169
- return [...found].map(([audioScriptId, phoneticScriptId]) => phoneticScriptId === void 0 ? { audioScriptId } : {
7170
- audioScriptId,
7171
- phoneticScriptId
7336
+ return [...found].map(([audioScriptId, ids]) => {
7337
+ const phoneticScriptIds = [...ids];
7338
+ if (phoneticScriptIds.length === 1) return {
7339
+ audioScriptId,
7340
+ phoneticScriptId: phoneticScriptIds[0]
7341
+ };
7342
+ return phoneticScriptIds.length ? {
7343
+ audioScriptId,
7344
+ phoneticScriptIds
7345
+ } : { audioScriptId };
7172
7346
  });
7173
7347
  }
7174
7348
  /** Every AudioScript in the document with its PhoneticScripts, the Speech Clips saying it and the Caption Clips showing it. */
@@ -7213,7 +7387,6 @@ function toAudioScriptViews(snapshot) {
7213
7387
  });
7214
7388
  }
7215
7389
  const layout = resolveDslLayout(snapshot);
7216
- for (const view of views.values()) placeTranscript(snapshot, layout, view);
7217
7390
  const timbres = Object.values(snapshot["voice-timbre-relations"]);
7218
7391
  for (const track of layout.tracks) for (const { clipId } of track.clips) {
7219
7392
  const source = snapshot["clip-entities"][clipId]?.source;
@@ -7237,6 +7410,7 @@ function toAudioScriptViews(snapshot) {
7237
7410
  clipId,
7238
7411
  recordingId: source,
7239
7412
  ...spoken.phoneticScriptId === void 0 ? {} : { phoneticScriptId: spoken.phoneticScriptId },
7413
+ ...spoken.phoneticScriptIds === void 0 ? {} : { phoneticScriptIds: spoken.phoneticScriptIds },
7240
7414
  voices,
7241
7415
  ...applied === void 0 ? {} : { applied }
7242
7416
  });
@@ -7253,19 +7427,23 @@ function toAudioScriptViews(snapshot) {
7253
7427
  segment
7254
7428
  });
7255
7429
  }
7430
+ for (const view of views.values()) placeTranscript(snapshot, layout, view);
7256
7431
  return [...views.values()];
7257
7432
  }
7258
7433
  /**
7259
7434
  * A transcript's segments on the Timeline: a segment's time is in the medium the script was read
7260
- * off, so it lands where the earliest Clip playing that medium holds that moment in its window.
7435
+ * off. An authored Caption anchor identifies its use of that medium; otherwise the earliest
7436
+ * Clip holding that source moment is used. The source time is never taken from the Caption.
7261
7437
  */
7262
7438
  function placeTranscript(snapshot, layout, view) {
7263
7439
  if (!view.sourceMediaIds.length) return;
7264
7440
  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);
7265
- for (const segment of view.segments) {
7441
+ for (const [index, segment] of view.segments.entries()) {
7266
7442
  const at = segment.startMs;
7267
7443
  if (at === void 0) continue;
7268
- const clip = plays.find((c) => c.inMs <= at && at < c.outMs);
7444
+ const anchoredUses = new Set(view.captions.filter((caption) => caption.segment === index).map((caption) => layout.clips[caption.clipId]?.anchorClipId));
7445
+ const candidates = plays.filter((c) => c.inMs <= at && at < c.outMs);
7446
+ const clip = candidates.find((c) => anchoredUses.has(c.clipId)) ?? candidates[0];
7269
7447
  if (clip) segment.timelineMs = Math.round(clip.startMs + (at - clip.inMs) / clip.rate);
7270
7448
  }
7271
7449
  }
@@ -7276,14 +7454,26 @@ function placeTranscript(snapshot, layout, view) {
7276
7454
  * not what a take was synthesized from (D190).
7277
7455
  */
7278
7456
  function appliedSpeech(snapshot, recordingId, spoken) {
7279
- const sourceId = spoken.phoneticScriptId ?? spoken.audioScriptId;
7280
- const recorded = Object.values(snapshot["derived-from-relations"]).find((r) => r.entityId === recordingId && r.sourceId === sourceId)?.sourceDigest;
7281
- const current = spoken.phoneticScriptId === void 0 ? void 0 : snapshot["phonetic-script-entities"][sourceId]?.digest;
7282
- return recorded === void 0 || current === void 0 ? void 0 : recorded === current;
7457
+ const ids = spoken.phoneticScriptIds ?? (spoken.phoneticScriptId === void 0 ? [] : [spoken.phoneticScriptId]);
7458
+ if (!ids.length) return void 0;
7459
+ let unknown = false;
7460
+ for (const sourceId of ids) {
7461
+ const recorded = Object.values(snapshot["derived-from-relations"]).find((r) => r.entityId === recordingId && r.sourceId === sourceId)?.sourceDigest;
7462
+ const current = snapshot["phonetic-script-entities"][sourceId]?.digest;
7463
+ if (recorded === void 0 || current === void 0) unknown = true;
7464
+ else if (recorded !== current) return false;
7465
+ }
7466
+ return unknown ? void 0 : true;
7283
7467
  }
7284
7468
  //#endregion
7285
7469
  //#region src/dsl/voiceover-actions.ts
7286
- function voiceoverPlan(s, input, result) {
7470
+ function matchesPlainPhoneticOrigin(s, id, scriptId, segmentId) {
7471
+ const phonetic = s["phonetic-script-entities"][id];
7472
+ const run = s.phoneticText[id];
7473
+ const segment = s["audio-script-entities"][scriptId]?.segments.find((item) => item.id === segmentId);
7474
+ return phonetic?.phoneme.audioScriptId === scriptId && phonetic.phoneme.segmentId === segmentId && phonetic.phoneme.phonemeScript === void 0 && run?.start === 0 && run.end === segment?.text.length;
7475
+ }
7476
+ function voiceoverPlan(s, input, result, plainPhoneticIds) {
7287
7477
  if (requireTrack(s, input.trackId).role !== "speech" || input.media.kind !== "voiceover-recording") throw new Error("Voiceover requires a speech Track and a recording");
7288
7478
  if (input.captions.length && (!input.captionTrackId || requireTrack(s, input.captionTrackId).role !== "caption")) throw new Error("Caption Track is required");
7289
7479
  const existing = s["clip-entities"][result.clipId];
@@ -7322,15 +7512,44 @@ function voiceoverPlan(s, input, result) {
7322
7512
  inMs: 0,
7323
7513
  outMs: input.media.durationMs
7324
7514
  }, input.media.durationMs);
7325
- mutations.push({
7326
- type: "set-relation",
7327
- table: "derived-from-relations",
7328
- value: {
7329
- entityId: result.mediaId,
7330
- sourceId: phoneticScriptId ?? result.audioScriptId,
7331
- ...input.sourceDigest === void 0 ? {} : { sourceDigest: input.sourceDigest }
7332
- }
7333
- });
7515
+ if (phoneticScriptId !== void 0) {
7516
+ const recorded = Object.values(s["derived-from-relations"]).find((origin) => origin.entityId === result.mediaId && origin.sourceId === phoneticScriptId);
7517
+ if (recorded?.sourceDigest !== void 0 && input.sourceDigest !== void 0 && recorded.sourceDigest !== input.sourceDigest) throw new Error("A recording already has a different synthesis digest");
7518
+ if (!recorded || recorded.sourceDigest === void 0 && input.sourceDigest !== void 0) mutations.push({
7519
+ type: "set-relation",
7520
+ table: "derived-from-relations",
7521
+ value: {
7522
+ entityId: result.mediaId,
7523
+ sourceId: phoneticScriptId,
7524
+ ...input.sourceDigest === void 0 ? {} : { sourceDigest: input.sourceDigest }
7525
+ }
7526
+ });
7527
+ } else {
7528
+ if (input.sourceDigest !== void 0) throw new Error("A synthesis digest requires its PhoneticScript");
7529
+ if (plainPhoneticIds.length !== segmentIds.length) throw new Error("Expected one PhoneticScript id per plain segment");
7530
+ segmentIds.forEach((segmentId, i) => {
7531
+ const id = plainPhoneticIds[i];
7532
+ if (s["phonetic-script-entities"][id]) {
7533
+ if (!matchesPlainPhoneticOrigin(s, id, result.audioScriptId, segmentId)) throw new Error("PhoneticScript id already names another synthesis origin");
7534
+ } else mutations.push({
7535
+ type: "create-entity",
7536
+ table: "phonetic-script-entities",
7537
+ id,
7538
+ value: { phoneme: {
7539
+ audioScriptId: result.audioScriptId,
7540
+ segmentId
7541
+ } }
7542
+ });
7543
+ if (!Object.values(s["derived-from-relations"]).some((origin) => origin.entityId === result.mediaId && origin.sourceId === id)) mutations.push({
7544
+ type: "set-relation",
7545
+ table: "derived-from-relations",
7546
+ value: {
7547
+ entityId: result.mediaId,
7548
+ sourceId: id
7549
+ }
7550
+ });
7551
+ });
7552
+ }
7334
7553
  if (input.voice) {
7335
7554
  const id = resourceId("voice", input.voice.system, input.voice.key);
7336
7555
  mutations.push({
@@ -7827,10 +8046,20 @@ var DslEditor = class {
7827
8046
  captionClipIds: input.captions.map((c) => c.clipId ?? this.idFactory("clip")),
7828
8047
  captionIds: input.captions.map(() => this.idFactory("caption"))
7829
8048
  };
8049
+ const state = this.dsl.getSnapshot();
8050
+ const plainSegments = input.script.segments === void 0 ? state["audio-script-entities"][result.audioScriptId]?.segments.map((segment) => segment.id) ?? [] : result.segmentIds;
8051
+ const plainPhoneticIds = input.script.phoneticScriptId === void 0 ? plainSegments.map((segmentId) => {
8052
+ const origins = Object.values(state["derived-from-relations"]).filter((origin) => {
8053
+ if (origin.entityId !== result.mediaId) return false;
8054
+ return matchesPlainPhoneticOrigin(state, origin.sourceId, result.audioScriptId, segmentId);
8055
+ });
8056
+ if (origins.length > 1) throw new Error("Ambiguous plain recording origin");
8057
+ return origins[0]?.sourceId ?? this.idFactory("phonetic-script");
8058
+ }) : [];
7830
8059
  this.write("applyVoiceover", {
7831
8060
  input,
7832
8061
  result
7833
- }, voiceoverPlan(this.dsl.getSnapshot(), input, result), options);
8062
+ }, voiceoverPlan(state, input, result, plainPhoneticIds), options);
7834
8063
  return result;
7835
8064
  }
7836
8065
  voiceoverScriptId(script) {
@@ -7847,8 +8076,12 @@ var DslEditor = class {
7847
8076
  * it; an unchanged one keeps its file. Recordings already spoken from it keep their derived-from.
7848
8077
  */
7849
8078
  async setPhoneticScript(input, options) {
8079
+ this.dsl.assertWritable();
7850
8080
  const id = input.phoneticScriptId ?? this.idFactory("phonetic-script");
7851
8081
  const range = segmentRange(input);
8082
+ assertId(id);
8083
+ assertSegmentRange(range, requireSegment(this.dsl.getSnapshot(), range.audioScriptId, range.segmentId).text);
8084
+ assertPhonemeScript(input.phonemeScript);
7852
8085
  const file = input.phonemeScript === void 0 ? {} : await this.fileFor(id, input.phonemeScript);
7853
8086
  const exists = Object.hasOwn(this.dsl.getSnapshot()["phonetic-script-entities"], id);
7854
8087
  const cleared = {
@@ -8072,7 +8305,8 @@ function toVideoDraft(snapshot) {
8072
8305
  } }
8073
8306
  } };
8074
8307
  } else if (track.role === "speech") {
8075
- if (!source || source.kind !== "voiceover-recording" || source.system !== "memota-speech") fail("Legacy Speech requires a memota-speech VoiceoverRecording");
8308
+ if (!source || source.kind !== "audio" && source.kind !== "voiceover-recording") fail("Speech playback requires an Audio or VoiceoverRecording");
8309
+ if (source.system !== "memota" && !(source.kind === "voiceover-recording" && source.system === "memota-speech")) fail("Speech playback requires a Memota audio file");
8076
8310
  requireWholeAudio(source, clip, fail);
8077
8311
  if (clip.fillTimeline) fail("Legacy Speech cannot express timeline fill");
8078
8312
  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]] : []);
@@ -8089,7 +8323,7 @@ function toVideoDraft(snapshot) {
8089
8323
  audio_storage_key: source.storageKey,
8090
8324
  origin_speech_id: source.key,
8091
8325
  volume: clip.volume,
8092
- audio_script: spokenText(snapshot, sourceId),
8326
+ audio_script: source.kind === "voiceover-recording" ? spokenText(snapshot, sourceId) : "",
8093
8327
  voice: voice ? {
8094
8328
  id: voice.key,
8095
8329
  name: voice.name
@@ -8185,6 +8419,15 @@ function spokenText(snapshot, recordingId) {
8185
8419
  if (!first) return "";
8186
8420
  if (first.phoneticScriptId !== void 0) return snapshot.phoneticText[first.phoneticScriptId]?.text ?? "";
8187
8421
  const script = snapshot["audio-script-entities"][first.audioScriptId];
8422
+ if (first.phoneticScriptIds !== void 0 && script) {
8423
+ const ids = first.phoneticScriptIds;
8424
+ const selections = ids.map((id) => snapshot["phonetic-script-entities"][id]);
8425
+ if (!(ids.length === script.segments.length && script.segments.every((segment) => {
8426
+ const matching = ids.filter((id) => snapshot["phonetic-script-entities"][id]?.phoneme.segmentId === segment.id);
8427
+ const run = matching.length === 1 ? snapshot.phoneticText[matching[0]] : void 0;
8428
+ return run?.start === 0 && run.end === segment.text.length;
8429
+ })) || selections.some((selection) => selection.phoneme.phonemeScript !== void 0)) projectionError("ambiguous-script", `media-entities/${recordingId}`, "Multiple phonetic sources require complete plain selections of one script");
8430
+ }
8188
8431
  return script ? script.segments.map((segment) => segment.text).join("\n") : "";
8189
8432
  }
8190
8433
  //#endregion
@@ -8397,11 +8640,7 @@ function prepare(input, options) {
8397
8640
  start: start - segment.start,
8398
8641
  end: end - segment.start
8399
8642
  });
8400
- } else diagnostics.push({
8401
- code: "caption-text-unmatched",
8402
- path: `part_library/${captionId}/text`,
8403
- message: "No forward exact match; preserved as independent Caption text"
8404
- });
8643
+ } else fail(`part_library/${captionId}/text`, "Caption must match a segment of its AudioScript");
8405
8644
  }
8406
8645
  }
8407
8646
  const hosts = /* @__PURE__ */ new Map();
@@ -8444,16 +8683,14 @@ function prepare(input, options) {
8444
8683
  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");
8445
8684
  sourceId = claim(mint("caption"));
8446
8685
  const selected = selections.get(id), style = fromDraftStyle(caption.style);
8686
+ if (!selected) fail(`part_library/${id}/text`, "Caption must select a segment of an AudioScript");
8447
8687
  mutations.push({
8448
8688
  type: "create-entity",
8449
8689
  table: "caption-entities",
8450
8690
  id: sourceId,
8451
- value: {
8452
- ...style === void 0 ? {} : { style },
8453
- ...selected ? {} : { text: caption.text }
8454
- }
8691
+ value: style === void 0 ? {} : { style }
8455
8692
  });
8456
- if (selected) mutations.push({
8693
+ mutations.push({
8457
8694
  type: "set-caption-content",
8458
8695
  captionId: sourceId,
8459
8696
  ...selected
@@ -8524,4 +8761,4 @@ function importedScript(id, text, mintSegment) {
8524
8761
  };
8525
8762
  }
8526
8763
  //#endregion
8527
- export { defaultIdFactory as _, DslEditor as a, tupleHash as b, UnsupportedDocumentFormatError as c, medeoDslLoroShape as d, captionContentCodec as f, DslProjectionError as g, resolveDslLayout as h, createInitialMedeoDsl as i, assertDslDocument as l, timeAnchorCodec as m, toVideoDraft as n, toAudioScriptViews as o, isInvalidAtomic as p, DEFAULT_UNIT_TIME_MS as r, MedeoDsl as s, fromVideoDraft as t, documentFormat as u, relationId as v, resourceId as y };
8764
+ export { tupleHash as S, resolveDslLayout as _, DslEditor as a, relationId as b, MedeoDsl as c, assertPhoneticBindingFormat as d, documentFormat as f, timeAnchorCodec as g, isInvalidAtomic as h, createInitialMedeoDsl as i, UnsupportedDocumentFormatError as l, captionContentCodec as m, toVideoDraft as n, spokenScripts as o, medeoDslLoroShape as p, DEFAULT_UNIT_TIME_MS as r, toAudioScriptViews as s, fromVideoDraft as t, assertDslDocument as u, DslProjectionError as v, resourceId as x, defaultIdFactory as y };