@mengine/medeo-client 3.0.1-tool-guardrails.0 → 3.1.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,12 +145,8 @@ function assertCaptionContent(value) {
145
145
  }
146
146
  const phonemeFields = {
147
147
  ...selectionFields,
148
- phonemeScript: optional$1(nonempty)
148
+ phonemeScript: optional$1(string)
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
- }
154
150
  function assertPhoneme(value) {
155
151
  if (!segmentSelection(phonemeFields)(value)) throw new Error("Invalid phoneme");
156
152
  }
@@ -173,9 +169,39 @@ function assertScriptSegment(value) {
173
169
  /** A script's segments: each with its own id, unique within the script. */
174
170
  const segments = (v) => Array.isArray(v) && v.every(segment) && new Set(v.map((s) => s.id)).size === v.length;
175
171
  const locator = {
176
- system: required(oneOf("memota", "memota-speech")),
172
+ system: required(oneOf("memota", "memota-speech", "placeholder")),
177
173
  key: required(nonempty)
178
174
  };
175
+ /**
176
+ * 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
178
+ * without media (`origin_media_id: ''`); one keyed by a memota media id stands in for media the
179
+ * clip named but Director found missing, so the id survives. Importing keeps the clips' time on
180
+ * them instead of dropping them. Only migration authors them.
181
+ */
182
+ const BLANK_PLACEHOLDER = {
183
+ system: "placeholder",
184
+ key: "blank",
185
+ kind: "image"
186
+ };
187
+ /**
188
+ * Only a memota media id may key a missing-media placeholder: visual parts name media and nothing
189
+ * else, and a key of this shape can never be mistaken for a file.
190
+ */
191
+ const MISSING_MEDIA_KEY = /^media_[0-9A-Za-z]+$/;
192
+ /** The placeholder for a memota media id Director found missing. */
193
+ function missingMediaPlaceholder(mediaId) {
194
+ return {
195
+ system: "placeholder",
196
+ key: mediaId,
197
+ kind: "image"
198
+ };
199
+ }
200
+ function assertPlaceholder(media) {
201
+ if (media.system !== "placeholder") return;
202
+ if (media.kind !== "image" || media.key !== BLANK_PLACEHOLDER.key && !(typeof media.key === "string" && MISSING_MEDIA_KEY.test(media.key))) throw new Error("A placeholder is the blank Image or one standing in for a missing memota media id");
203
+ if (media.storageKey !== void 0 || media.digest !== void 0) throw new Error("A placeholder has no file");
204
+ }
179
205
  const entityFields = {
180
206
  "timeline-entities": {},
181
207
  "track-entities": {
@@ -271,6 +297,7 @@ function assertEntity(table, value) {
271
297
  checkFields(value, entityFields[table]);
272
298
  if (table === "audio-script-entities") assertTranscriptFile(value);
273
299
  if (table === "media-entities") {
300
+ assertPlaceholder(value);
274
301
  if (value.kind === "image" && value.durationMs !== void 0) throw new Error("Image has no intrinsic duration");
275
302
  if (value.kind !== "image" && !positive(value.durationMs)) throw new Error("Finite media requires durationMs");
276
303
  if (["audio", "voiceover-recording"].includes(value.kind) && !nonempty(value.storageKey)) throw new Error("Audio requires storageKey");
@@ -299,6 +326,7 @@ function assertEntityPatch(table, patch, current) {
299
326
  ]) if (Object.hasOwn(patch, key) && patch[key] !== current[key]) throw new Error(`Resource identity field is immutable: ${key}`);
300
327
  }
301
328
  if (table === "media-entities") {
329
+ assertPlaceholder(next);
302
330
  if (Object.hasOwn(patch, "durationMs")) {
303
331
  if (next.kind === "image" ? next.durationMs !== void 0 : !positive(next.durationMs)) throw new Error("durationMs must match the media kind");
304
332
  }
@@ -580,10 +608,9 @@ function assertPhonemeSelection(rows, selection, text) {
580
608
  assertSegmentRange(selection, text);
581
609
  if (selection.phonemeScript !== void 0 && typeof selection.phonemeScript !== "string") throw new Error("phonemeScript is a string");
582
610
  }
583
- /** Every new or rewritten Caption selects its words from one AudioScript segment. */
611
+ /** A caption's text comes from a selection of a script or from its own text, never both. */
584
612
  function assertCaptionSource(caption, selected) {
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");
613
+ if (caption?.text !== void 0 && selected) throw new Error("Caption must have only one content source");
587
614
  }
588
615
  //#endregion
589
616
  //#region ../medeo-dsl/src/contain.ts
@@ -643,63 +670,6 @@ function assertContainTree(rows, tree) {
643
670
  }
644
671
  }
645
672
  //#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
703
673
  //#region ../medeo-dsl/src/plan.ts
704
674
  /** Include removed nested keys: a partial merge alone would conceal a deleted required field. */
705
675
  function replacementPatch(before, after) {
@@ -715,7 +685,6 @@ function replacementPatch(before, after) {
715
685
  */
716
686
  function assertChangeAdmitted(change) {
717
687
  const { before, after } = change;
718
- const parents = change.containment ? clipParents(change.containment.after) : void 0;
719
688
  for (const [table, rows] of change.entities) for (const [id, fields] of rows) {
720
689
  const value = after[table][id];
721
690
  if (value === void 0) continue;
@@ -730,17 +699,25 @@ function assertChangeAdmitted(change) {
730
699
  }
731
700
  const row = value;
732
701
  for (const field of referenceFields[table] ?? []) if (!old || fields.has(field)) assertReferenceAdmitted(after, table, id, field, row[field]);
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
- }
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);
737
705
  }
738
706
  if (change.entities.has("timeline-entities") && Object.keys(after["timeline-entities"]).length > 1) throw new Error("Document already has a Timeline");
739
707
  for (const [table, ids] of change.relations) for (const id of ids) {
740
708
  const value = after[table][id];
741
709
  if (value !== void 0) assertRelationAdmitted(after, table, id, value);
742
710
  }
743
- if (change.containment) assertClipPlacements(before, after, change.containment.before, change.containment.after);
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`);
744
721
  }
745
722
  //#endregion
746
723
  //#region src/dsl/projection/types.ts
@@ -976,11 +953,15 @@ function requireSource(s, id, role) {
976
953
  return media;
977
954
  }
978
955
  function assertMediaRole(media, role) {
979
- if (!media) throw new Error(`Missing source for ${role} Track`);
980
- assertClipSourceRole(role, media.kind);
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`);
981
962
  }
982
963
  function validateWindow(window, duration) {
983
- assertClipWindow(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");
984
965
  }
985
966
  function trackOf(s, id) {
986
967
  requireClip(s, id);
@@ -1268,8 +1249,7 @@ function replaceSourcePlan(s, clipId, input) {
1268
1249
  patch: {
1269
1250
  seqMarker: {
1270
1251
  inMs: input.inMs,
1271
- outMs: input.outMs,
1272
- ...supportsClipSpeed(source.source) ? {} : { rate: void 0 }
1252
+ outMs: input.outMs
1273
1253
  },
1274
1254
  source: source.id
1275
1255
  }
@@ -5897,30 +5877,6 @@ const phonemeCodec = codec((v) => {
5897
5877
  assertPhoneme(value);
5898
5878
  return value;
5899
5879
  });
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
- });
5924
5880
  /** A Clip's sequence marker is stored as one string, so it is taken or replaced whole. */
5925
5881
  const seqMarkerCodec = codec((v) => {
5926
5882
  assertSeqMarker(v);
@@ -5987,7 +5943,13 @@ const medeoDslLoroShape = schema({
5987
5943
  storageKey: schema.String(optional),
5988
5944
  digest: schema.String(optional)
5989
5945
  })),
5990
- "phonetic-script-entities": schema.LoroMapRecord(schema.LoroMap({ binding: atomicValue(phoneticBindingCodec) })),
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
+ })),
5991
5953
  "caption-entities": schema.LoroMapRecord(schema.LoroMap({
5992
5954
  selection: optionalAtomicValue(captionContentCodec),
5993
5955
  text: schema.LoroText(optional),
@@ -6039,78 +6001,20 @@ function documentFormat(doc) {
6039
6001
  }
6040
6002
  var UnsupportedDocumentFormatError = class extends Error {
6041
6003
  code = "unsupported_document_format";
6042
- constructor(message = "Expected a new DSL document; legacy, mixed or uninitialized content is not supported") {
6043
- super(message);
6004
+ constructor() {
6005
+ super("Expected a new DSL document; legacy, mixed or uninitialized content is not supported");
6044
6006
  this.name = "UnsupportedDocumentFormatError";
6045
6007
  }
6046
6008
  };
6047
6009
  /** Run after initial import, before opening an editable handle. Empty new documents still have one Timeline. */
6048
6010
  function assertDslDocument(doc) {
6049
6011
  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
- }
6060
6012
  }
6061
6013
  //#endregion
6062
6014
  //#region ../medeo-dsl/src/schema/text-styles.ts
6063
6015
  /** How rich-text marks extend at their edges: a language runs on into text typed after it. */
6064
6016
  const textStyles = { language: { expand: "after" } };
6065
6017
  //#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
6114
6018
  //#region src/dsl/document/read.ts
6115
6019
  /** Looking up a missing root must not create it. */
6116
6020
  function rootMap(doc, name) {
@@ -6194,7 +6098,6 @@ function referenceTargets(table, row) {
6194
6098
  }
6195
6099
  }
6196
6100
  function readMedeoDsl(doc, state) {
6197
- const logical = logicalDslState(state);
6198
6101
  const diagnostics = [];
6199
6102
  const diagnostic = (code, path, error) => diagnostics.push({
6200
6103
  code,
@@ -6204,7 +6107,7 @@ function readMedeoDsl(doc, state) {
6204
6107
  const entities = Object.fromEntries(entityTables.map((t) => [t, {}]));
6205
6108
  const relations = Object.fromEntries(relationTables.map((t) => [t, {}]));
6206
6109
  const richText = {};
6207
- for (const table of entityTables) for (const [id, raw] of Object.entries(logical[table] ?? {})) try {
6110
+ for (const table of entityTables) for (const [id, raw] of Object.entries(state[table] ?? {})) try {
6208
6111
  assertId(id);
6209
6112
  let v = raw;
6210
6113
  for (const field of optionalReferences[table] ?? []) {
@@ -6710,11 +6613,7 @@ function validateDraft(tracked) {
6710
6613
  before,
6711
6614
  after: state,
6712
6615
  entities: tracked.entities,
6713
- relations: tracked.relations,
6714
- containment: {
6715
- before: plainTree(before["contain-relations"] ?? []),
6716
- after: plainTree(state["contain-relations"] ?? [])
6717
- }
6616
+ relations: tracked.relations
6718
6617
  });
6719
6618
  if (!tracked.treeChanged) return;
6720
6619
  const oldNodes = nodeIndex(before["contain-relations"] ?? []);
@@ -6791,11 +6690,8 @@ function runTransaction(doc, mirror, edit, options, setReject) {
6791
6690
  const steps = [];
6792
6691
  const newCaptionTexts = /* @__PURE__ */ new Map();
6793
6692
  const bodies = /* @__PURE__ */ new Map();
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);
6693
+ const before = mirror.getState();
6694
+ mirror.setState((raw) => {
6799
6695
  const tracked = new TransactionDraft(raw, before);
6800
6696
  setReject((error) => tracked.fail(error));
6801
6697
  /** The current text of a Caption's own Text or a script segment, as this transaction leaves it so far. */
@@ -6987,15 +6883,6 @@ function runTransaction(doc, mirror, edit, options, setReject) {
6987
6883
  for (const step of steps) if (step.type === "text") if ("captionId" in step.target) requireEntity(raw, "caption-entities", step.target.captionId);
6988
6884
  else requireSegment(raw, step.target.audioScriptId, step.target.segmentId);
6989
6885
  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
- }
6999
6886
  for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) {
7000
6887
  const row = raw["caption-entities"][id];
7001
6888
  if (fields.has("text") && row?.text !== void 0 && before["caption-entities"][id]?.text === void 0) {
@@ -7004,7 +6891,6 @@ function runTransaction(doc, mirror, edit, options, setReject) {
7004
6891
  }
7005
6892
  }
7006
6893
  for (const table of Object.keys(selectionField)) {
7007
- if (table === "phonetic-script-entities") continue;
7008
6894
  const field = selectionField[table];
7009
6895
  for (const [id, fields] of tracked.entities.get(table) ?? []) {
7010
6896
  const row = raw[table][id];
@@ -7044,8 +6930,6 @@ function runTransaction(doc, mirror, edit, options, setReject) {
7044
6930
  applyTextEdits(text, step.edits);
7045
6931
  continue;
7046
6932
  }
7047
- const phonetic = step.table === "phonetic-script-entities" ? phoneticRows.get(step.id) : void 0;
7048
- if (step.table === "phonetic-script-entities" && !phonetic) continue;
7049
6933
  const row = entityRow(doc, step.table, step.id);
7050
6934
  const field = selectionField[step.table];
7051
6935
  if (step.type === "range") {
@@ -7064,21 +6948,12 @@ function runTransaction(doc, mirror, edit, options, setReject) {
7064
6948
  if (!value.start || !value.end) throw new Error("Unable to construct prepared range");
7065
6949
  }
7066
6950
  if (step.phonemeScript !== void 0) value.phonemeScript = step.phonemeScript;
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 {
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) {
7074
6954
  if (!row) throw new Error("Prepared selection owner is missing");
7075
6955
  row.set(field, codecs[step.table].encode(step.value));
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));
6956
+ } else if (row?.get(field) !== void 0) row.delete(field);
7082
6957
  }
7083
6958
  }
7084
6959
  });
@@ -7091,7 +6966,7 @@ function runTransaction(doc, mirror, edit, options, setReject) {
7091
6966
  function assertSelectionsAfter(raw, before, tracked, steps) {
7092
6967
  const selected = new Set(Object.entries(before["caption-entities"]).filter(([, caption]) => typeof caption === "object" && caption.selection !== void 0).map(([id]) => id));
7093
6968
  const changedCaptions = /* @__PURE__ */ new Set();
7094
- for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (!before["caption-entities"][id] || fields.has("text")) changedCaptions.add(id);
6969
+ for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (fields.has("text")) changedCaptions.add(id);
7095
6970
  const phonemes = /* @__PURE__ */ new Map();
7096
6971
  for (const step of steps) {
7097
6972
  if (step.type === "text") {
@@ -7125,7 +7000,7 @@ function assertSelectionsAfter(raw, before, tracked, steps) {
7125
7000
  if (!row) continue;
7126
7001
  const phoneme = phonemes.has(id) ? phonemes.get(id) : row.phoneme;
7127
7002
  const old = before["phonetic-script-entities"][id];
7128
- assertPhoneticScript(old ? { ...old } : void 0, {
7003
+ assertPhoneticScript(old, {
7129
7004
  ...row,
7130
7005
  phoneme: isRecord(phoneme) ? phoneme : void 0
7131
7006
  });
@@ -7164,7 +7039,6 @@ var MedeoDsl = class MedeoDsl {
7164
7039
  "tracks",
7165
7040
  "part_library"
7166
7041
  ].some((key) => Object.hasOwn(roots, key))) throw new Error("Expected a new DSL document, not a legacy content model");
7167
- assertPhoneticBindingFormat(doc);
7168
7042
  doc.configTextStyle(textStyles);
7169
7043
  this.mirror = new Mirror({
7170
7044
  doc,
@@ -7228,11 +7102,6 @@ var MedeoDsl = class MedeoDsl {
7228
7102
  editCaptionText(captionId, expectedBodyId, edits, audit) {
7229
7103
  this.transact((tx) => tx.editCaptionText(captionId, edits, expectedBodyId), audit);
7230
7104
  }
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
- }
7236
7105
  /**
7237
7106
  * A run inside one segment as native Cursors. Local ranges exclude insertions at their start and
7238
7107
  * include insertions at their end. K01 remains deferred.
@@ -7265,7 +7134,13 @@ var MedeoDsl = class MedeoDsl {
7265
7134
  if (this.ownsDoc) this.doc.free();
7266
7135
  }
7267
7136
  mutate(run) {
7268
- this.assertReadyToWrite();
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");
7269
7144
  const apply = () => {
7270
7145
  this.writing = true;
7271
7146
  try {
@@ -7280,15 +7155,6 @@ var MedeoDsl = class MedeoDsl {
7280
7155
  if (this.options.mutationGuard) this.options.mutationGuard.run(apply);
7281
7156
  else apply();
7282
7157
  }
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
- }
7292
7158
  notify() {
7293
7159
  if (this.disposed || !this.dirty) return;
7294
7160
  this.dirty = false;
@@ -7315,13 +7181,12 @@ function spokenScripts(snapshot, recordingId) {
7315
7181
  const found = /* @__PURE__ */ new Map();
7316
7182
  const seen = /* @__PURE__ */ new Set();
7317
7183
  const visit = (id, phoneticScriptId) => {
7184
+ if (seen.has(id)) return;
7185
+ seen.add(id);
7318
7186
  if (snapshot["audio-script-entities"][id]) {
7319
- if (!found.has(id)) found.set(id, /* @__PURE__ */ new Set());
7320
- if (phoneticScriptId !== void 0) found.get(id).add(phoneticScriptId);
7187
+ if (!found.has(id)) found.set(id, phoneticScriptId);
7321
7188
  return;
7322
7189
  }
7323
- if (seen.has(id)) return;
7324
- seen.add(id);
7325
7190
  const phonetic = snapshot["phonetic-script-entities"][id];
7326
7191
  if (phonetic) {
7327
7192
  visit(phonetic.phoneme.audioScriptId, id);
@@ -7331,18 +7196,11 @@ function spokenScripts(snapshot, recordingId) {
7331
7196
  };
7332
7197
  visit(recordingId);
7333
7198
  if (!found.size) {
7334
- for (const r of relations) if (r.sourceId === recordingId && snapshot["audio-script-entities"][r.entityId]) found.set(r.entityId, /* @__PURE__ */ new Set());
7199
+ for (const r of relations) if (r.sourceId === recordingId && snapshot["audio-script-entities"][r.entityId]) found.set(r.entityId, void 0);
7335
7200
  }
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 };
7201
+ return [...found].map(([audioScriptId, phoneticScriptId]) => phoneticScriptId === void 0 ? { audioScriptId } : {
7202
+ audioScriptId,
7203
+ phoneticScriptId
7346
7204
  });
7347
7205
  }
7348
7206
  /** Every AudioScript in the document with its PhoneticScripts, the Speech Clips saying it and the Caption Clips showing it. */
@@ -7387,6 +7245,7 @@ function toAudioScriptViews(snapshot) {
7387
7245
  });
7388
7246
  }
7389
7247
  const layout = resolveDslLayout(snapshot);
7248
+ for (const view of views.values()) placeTranscript(snapshot, layout, view);
7390
7249
  const timbres = Object.values(snapshot["voice-timbre-relations"]);
7391
7250
  for (const track of layout.tracks) for (const { clipId } of track.clips) {
7392
7251
  const source = snapshot["clip-entities"][clipId]?.source;
@@ -7410,7 +7269,6 @@ function toAudioScriptViews(snapshot) {
7410
7269
  clipId,
7411
7270
  recordingId: source,
7412
7271
  ...spoken.phoneticScriptId === void 0 ? {} : { phoneticScriptId: spoken.phoneticScriptId },
7413
- ...spoken.phoneticScriptIds === void 0 ? {} : { phoneticScriptIds: spoken.phoneticScriptIds },
7414
7272
  voices,
7415
7273
  ...applied === void 0 ? {} : { applied }
7416
7274
  });
@@ -7427,23 +7285,19 @@ function toAudioScriptViews(snapshot) {
7427
7285
  segment
7428
7286
  });
7429
7287
  }
7430
- for (const view of views.values()) placeTranscript(snapshot, layout, view);
7431
7288
  return [...views.values()];
7432
7289
  }
7433
7290
  /**
7434
7291
  * A transcript's segments on the Timeline: a segment's time is in the medium the script was read
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.
7292
+ * off, so it lands where the earliest Clip playing that medium holds that moment in its window.
7437
7293
  */
7438
7294
  function placeTranscript(snapshot, layout, view) {
7439
7295
  if (!view.sourceMediaIds.length) return;
7440
7296
  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);
7441
- for (const [index, segment] of view.segments.entries()) {
7297
+ for (const segment of view.segments) {
7442
7298
  const at = segment.startMs;
7443
7299
  if (at === void 0) continue;
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];
7300
+ const clip = plays.find((c) => c.inMs <= at && at < c.outMs);
7447
7301
  if (clip) segment.timelineMs = Math.round(clip.startMs + (at - clip.inMs) / clip.rate);
7448
7302
  }
7449
7303
  }
@@ -7454,26 +7308,14 @@ function placeTranscript(snapshot, layout, view) {
7454
7308
  * not what a take was synthesized from (D190).
7455
7309
  */
7456
7310
  function appliedSpeech(snapshot, recordingId, spoken) {
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;
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;
7467
7315
  }
7468
7316
  //#endregion
7469
7317
  //#region src/dsl/voiceover-actions.ts
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) {
7318
+ function voiceoverPlan(s, input, result) {
7477
7319
  if (requireTrack(s, input.trackId).role !== "speech" || input.media.kind !== "voiceover-recording") throw new Error("Voiceover requires a speech Track and a recording");
7478
7320
  if (input.captions.length && (!input.captionTrackId || requireTrack(s, input.captionTrackId).role !== "caption")) throw new Error("Caption Track is required");
7479
7321
  const existing = s["clip-entities"][result.clipId];
@@ -7512,44 +7354,15 @@ function voiceoverPlan(s, input, result, plainPhoneticIds) {
7512
7354
  inMs: 0,
7513
7355
  outMs: input.media.durationMs
7514
7356
  }, input.media.durationMs);
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
- }
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
+ });
7553
7366
  if (input.voice) {
7554
7367
  const id = resourceId("voice", input.voice.system, input.voice.key);
7555
7368
  mutations.push({
@@ -8046,20 +7859,10 @@ var DslEditor = class {
8046
7859
  captionClipIds: input.captions.map((c) => c.clipId ?? this.idFactory("clip")),
8047
7860
  captionIds: input.captions.map(() => this.idFactory("caption"))
8048
7861
  };
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
- }) : [];
8059
7862
  this.write("applyVoiceover", {
8060
7863
  input,
8061
7864
  result
8062
- }, voiceoverPlan(state, input, result, plainPhoneticIds), options);
7865
+ }, voiceoverPlan(this.dsl.getSnapshot(), input, result), options);
8063
7866
  return result;
8064
7867
  }
8065
7868
  voiceoverScriptId(script) {
@@ -8076,12 +7879,8 @@ var DslEditor = class {
8076
7879
  * it; an unchanged one keeps its file. Recordings already spoken from it keep their derived-from.
8077
7880
  */
8078
7881
  async setPhoneticScript(input, options) {
8079
- this.dsl.assertWritable();
8080
7882
  const id = input.phoneticScriptId ?? this.idFactory("phonetic-script");
8081
7883
  const range = segmentRange(input);
8082
- assertId(id);
8083
- assertSegmentRange(range, requireSegment(this.dsl.getSnapshot(), range.audioScriptId, range.segmentId).text);
8084
- assertPhonemeScript(input.phonemeScript);
8085
7884
  const file = input.phonemeScript === void 0 ? {} : await this.fileFor(id, input.phonemeScript);
8086
7885
  const exists = Object.hasOwn(this.dsl.getSnapshot()["phonetic-script-entities"], id);
8087
7886
  const cleared = {
@@ -8290,13 +8089,14 @@ function toVideoDraft(snapshot) {
8290
8089
  duration_ms: clip.durationMs
8291
8090
  };
8292
8091
  if (track.role === "video_clip") {
8293
- if (!source || !["image", "video"].includes(source.kind) || source.system !== "memota") fail("Visual playback requires a memota Image or Video");
8092
+ const placeholder = source?.system === "placeholder";
8093
+ if (!source || !["image", "video"].includes(source.kind) || !(source.system === "memota" || placeholder)) fail("Visual playback requires a memota Image or Video, or a placeholder Image");
8294
8094
  if (clip.fillTimeline) fail("VideoDraft cannot express timeline-filling visual content");
8295
8095
  parts[clip.clipId] = { video_clip: {
8296
8096
  ...common,
8297
8097
  play_in: clip.inMs,
8298
8098
  play_out: clip.outMs,
8299
- origin_media_id: source.key,
8099
+ origin_media_id: placeholder && source.key === BLANK_PLACEHOLDER.key ? "" : source.key,
8300
8100
  volume: clip.volume,
8301
8101
  ...snapshot["clip-entities"][clip.clipId].seqMarker.rate === void 0 ? {} : { speed_shift: {
8302
8102
  category: "linear",
@@ -8305,8 +8105,7 @@ function toVideoDraft(snapshot) {
8305
8105
  } }
8306
8106
  } };
8307
8107
  } else if (track.role === "speech") {
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");
8108
+ if (!source || source.kind !== "voiceover-recording" || source.system !== "memota-speech") fail("Legacy Speech requires a memota-speech VoiceoverRecording");
8310
8109
  requireWholeAudio(source, clip, fail);
8311
8110
  if (clip.fillTimeline) fail("Legacy Speech cannot express timeline fill");
8312
8111
  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]] : []);
@@ -8323,7 +8122,7 @@ function toVideoDraft(snapshot) {
8323
8122
  audio_storage_key: source.storageKey,
8324
8123
  origin_speech_id: source.key,
8325
8124
  volume: clip.volume,
8326
- audio_script: source.kind === "voiceover-recording" ? spokenText(snapshot, sourceId) : "",
8125
+ audio_script: spokenText(snapshot, sourceId),
8327
8126
  voice: voice ? {
8328
8127
  id: voice.key,
8329
8128
  name: voice.name
@@ -8419,15 +8218,6 @@ function spokenText(snapshot, recordingId) {
8419
8218
  if (!first) return "";
8420
8219
  if (first.phoneticScriptId !== void 0) return snapshot.phoneticText[first.phoneticScriptId]?.text ?? "";
8421
8220
  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
- }
8431
8221
  return script ? script.segments.map((segment) => segment.text).join("\n") : "";
8432
8222
  }
8433
8223
  //#endregion
@@ -8518,6 +8308,7 @@ function prepare(input, options) {
8518
8308
  if (facts.has(id)) fail("mediaFacts", `Duplicate facts for ${fact.system}/${fact.key}`);
8519
8309
  facts.set(id, fact);
8520
8310
  }
8311
+ for (const fact of facts.values()) if (fact.system === "placeholder" && facts.has(resourceId("media", "memota", fact.key))) fail("mediaFacts", `Media cannot be both present and missing: ${fact.key}`);
8521
8312
  const register = (media) => {
8522
8313
  const id = resourceId("media", media.system, media.key), old = imported.get(id);
8523
8314
  if (old) {
@@ -8543,6 +8334,15 @@ function prepare(input, options) {
8543
8334
  const mediaByPart = /* @__PURE__ */ new Map(), scriptByRecording = /* @__PURE__ */ new Map(), scriptsByPart = /* @__PURE__ */ new Map();
8544
8335
  const voices = /* @__PURE__ */ new Set();
8545
8336
  for (const [id, p] of Object.entries(library)) if (p.video_clip) {
8337
+ if (p.video_clip.origin_media_id === "") {
8338
+ mediaByPart.set(id, register(BLANK_PLACEHOLDER));
8339
+ continue;
8340
+ }
8341
+ const missing = p.video_clip.origin_media_id && facts.get(resourceId("media", "placeholder", p.video_clip.origin_media_id));
8342
+ if (missing) {
8343
+ mediaByPart.set(id, register(missing));
8344
+ continue;
8345
+ }
8546
8346
  const fact = factFor("memota", p.video_clip.origin_media_id);
8547
8347
  if (fact.kind !== "image" && fact.kind !== "video") fail(`part_library/${id}`, "Expected Image or Video facts");
8548
8348
  mediaByPart.set(id, register(fact));
@@ -8640,7 +8440,11 @@ function prepare(input, options) {
8640
8440
  start: start - segment.start,
8641
8441
  end: end - segment.start
8642
8442
  });
8643
- } else fail(`part_library/${captionId}/text`, "Caption must match a segment of its AudioScript");
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
+ });
8644
8448
  }
8645
8449
  }
8646
8450
  const hosts = /* @__PURE__ */ new Map();
@@ -8683,14 +8487,16 @@ function prepare(input, options) {
8683
8487
  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");
8684
8488
  sourceId = claim(mint("caption"));
8685
8489
  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");
8687
8490
  mutations.push({
8688
8491
  type: "create-entity",
8689
8492
  table: "caption-entities",
8690
8493
  id: sourceId,
8691
- value: style === void 0 ? {} : { style }
8494
+ value: {
8495
+ ...style === void 0 ? {} : { style },
8496
+ ...selected ? {} : { text: caption.text }
8497
+ }
8692
8498
  });
8693
- mutations.push({
8499
+ if (selected) mutations.push({
8694
8500
  type: "set-caption-content",
8695
8501
  captionId: sourceId,
8696
8502
  ...selected
@@ -8760,5 +8566,122 @@ function importedScript(id, text, mintSegment) {
8760
8566
  segments
8761
8567
  };
8762
8568
  }
8569
+ function normalizeLegacyVideoDraft(input, mediaFacts = []) {
8570
+ const draft = structuredClone(input);
8571
+ const diagnostics = [];
8572
+ const note = (code, path, message) => diagnostics.push({
8573
+ code,
8574
+ path,
8575
+ message
8576
+ });
8577
+ const library = draft.part_library ?? {};
8578
+ const tracks = [
8579
+ ...draft.above_main_tracks ?? [],
8580
+ ...draft.main_track ? [draft.main_track] : [],
8581
+ ...draft.below_main_tracks ?? []
8582
+ ];
8583
+ const facts = new Map(mediaFacts.map((fact) => [resourceId("media", fact.system, fact.key), fact]));
8584
+ const round = (owner, field, path) => {
8585
+ const fields = owner;
8586
+ const value = fields[field];
8587
+ if (typeof value !== "number" || !Number.isFinite(value) || Number.isSafeInteger(value)) return;
8588
+ const rounded = Math.round(value);
8589
+ fields[field] = rounded;
8590
+ note("legacy-time-rounded", `${path}/${field}`, `${value} → ${rounded}`);
8591
+ };
8592
+ for (const track of tracks) for (const item of track.items ?? []) round(item, "abs_time_position", `tracks/${track.id}/${item.part_id}`);
8593
+ for (const [id, part] of Object.entries(library)) {
8594
+ const path = `part_library/${id}`;
8595
+ if (part.video_clip) for (const f of [
8596
+ "play_in",
8597
+ "play_out",
8598
+ "duration_ms"
8599
+ ]) round(part.video_clip, f, path);
8600
+ if (part.speech) for (const f of ["duration_ms", "media_duration_ms"]) round(part.speech, f, path);
8601
+ if (part.caption) for (const f of [
8602
+ "duration_ms",
8603
+ "initial_duration_ms",
8604
+ "start_ms"
8605
+ ]) round(part.caption, f, path);
8606
+ if (part.bgm) round(part.bgm, "duration_ms", path);
8607
+ }
8608
+ for (const aggregation of draft.part_aggregations ?? []) for (const attachment of aggregation.attachments ?? []) round(attachment, "relative_time_position", `part_aggregations/${aggregation.body_part_id}/${attachment.part_id}`);
8609
+ const dropped = /* @__PURE__ */ new Set();
8610
+ const drop = (id, why) => {
8611
+ dropped.add(id);
8612
+ note("legacy-part-dropped", `part_library/${id}`, why);
8613
+ };
8614
+ for (const [id, part] of Object.entries(library)) {
8615
+ const path = `part_library/${id}`;
8616
+ const clip = part.video_clip;
8617
+ const sound = part.bgm ?? part.speech;
8618
+ const fact = part.bgm ? facts.get(resourceId("media", "memota", part.bgm.origin_media_id ?? "")) : part.speech && facts.get(resourceId("media", "memota-speech", part.speech.origin_speech_id ?? ""));
8619
+ if (sound && fact && "storageKey" in fact && fact.storageKey && fact.storageKey !== sound.audio_storage_key) {
8620
+ note("legacy-storage-key-current", `${path}/audio_storage_key`, `${sound.audio_storage_key} → ${fact.storageKey}`);
8621
+ sound.audio_storage_key = fact.storageKey;
8622
+ }
8623
+ if (!clip) continue;
8624
+ const rate = clip.speed_shift?.config?.linear?.speed ?? 1;
8625
+ const inMs = clip.play_in ?? 0;
8626
+ const outMs = clip.play_out ?? (clip.duration_ms === void 0 ? void 0 : inMs + Math.round(clip.duration_ms * rate));
8627
+ const source = facts.get(resourceId("media", "memota", clip.origin_media_id ?? ""));
8628
+ let windowEnd = outMs;
8629
+ if (source?.kind === "video" && outMs !== void 0 && outMs > source.durationMs) {
8630
+ windowEnd = source.durationMs;
8631
+ if (windowEnd > inMs) {
8632
+ note("legacy-window-cut", `${path}/play_out`, `${outMs} → ${windowEnd} (source length)`);
8633
+ clip.play_out = windowEnd;
8634
+ if (clip.duration_ms !== void 0) clip.duration_ms = Math.max(0, Math.round((windowEnd - inMs) / rate));
8635
+ }
8636
+ }
8637
+ if (windowEnd === void 0) continue;
8638
+ const length = windowEnd - inMs;
8639
+ if (length <= 0) drop(id, `Empty window ${inMs}–${windowEnd}`);
8640
+ else if (clip.origin_media_id === "" && length / rate < 40) drop(id, `Placeholder sliver of ${length / rate}ms`);
8641
+ }
8642
+ for (const id of dropped) delete library[id];
8643
+ for (const track of tracks) track.items = track.items?.filter((item) => !dropped.has(item.part_id ?? ""));
8644
+ const abs = /* @__PURE__ */ new Map();
8645
+ for (const track of tracks) for (const item of track.items ?? []) abs.set(item.part_id ?? "", item.abs_time_position ?? 0);
8646
+ const mainOrder = new Map((draft.main_track?.items ?? []).map((item, index) => [item.part_id ?? "", index]));
8647
+ const candidates = /* @__PURE__ */ new Map();
8648
+ for (const aggregation of draft.part_aggregations ?? []) {
8649
+ const host = aggregation.body_part_id ?? "";
8650
+ for (const attachment of aggregation.attachments ?? []) {
8651
+ const speech = attachment.part_id ?? "";
8652
+ const path = `part_aggregations/${host}/${speech}`;
8653
+ if (!library[speech]?.speech) note("legacy-attachment-dropped", path, "Not a placed Speech");
8654
+ else if (!library[host]?.video_clip || !mainOrder.has(host)) note("legacy-attachment-dropped", path, "Host is not a placed visual clip");
8655
+ else candidates.set(speech, [...candidates.get(speech) ?? [], {
8656
+ host,
8657
+ offset: attachment.relative_time_position ?? 0
8658
+ }]);
8659
+ }
8660
+ }
8661
+ const chosen = /* @__PURE__ */ new Map();
8662
+ for (const [speech, list] of candidates) {
8663
+ if (list.length === 1) {
8664
+ chosen.set(speech, list[0]);
8665
+ continue;
8666
+ }
8667
+ const playing = list.filter((c) => (abs.get(c.host) ?? 0) + c.offset === abs.get(speech)).sort((a, b) => mainOrder.get(a.host) - mainOrder.get(b.host));
8668
+ const pick = playing[0] ?? list[0];
8669
+ chosen.set(speech, pick);
8670
+ note(playing.length ? "legacy-speech-host-chosen" : "legacy-speech-host-unmatched", `part_aggregations/${pick.host}/${speech}`, `${list.length} hosts: ${list.map((c) => c.host).join(", ")}`);
8671
+ }
8672
+ const byHost = /* @__PURE__ */ new Map();
8673
+ for (const [speech, { host, offset }] of chosen) byHost.set(host, [...byHost.get(host) ?? [], {
8674
+ part_id: speech,
8675
+ relative_time_position: offset
8676
+ }]);
8677
+ if (draft.part_aggregations) draft.part_aggregations = [...new Set(draft.part_aggregations.map((a) => a.body_part_id ?? ""))].filter((host) => byHost.has(host)).map((host) => ({
8678
+ body_part_id: host,
8679
+ attachments: byHost.get(host)
8680
+ }));
8681
+ return {
8682
+ draft,
8683
+ diagnostics
8684
+ };
8685
+ }
8763
8686
  //#endregion
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 };
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 };