@mengine/medeo-client 2.0.1-alpha.6 → 2.0.1-alpha.8

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.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { $ as ensureLaneTrack, A as assembleEntityContent, B as hasSequence, C as projectEntityTimeline, Ct as base64ToBytes, D as decodeEntityRelationRows, E as EntityTimelineProjectionError, F as updateEntityFields, G as fromVideoDocument, H as buildInitialVideoDocument, I as variantBaseEntityIds, J as assertValidVideoDocument, K as toVideoDocument, L as isJsonObject, M as assembleScriptText, N as findComposedAudioScript, O as ScriptCompositionError, P as selectAudioScriptSegment, Q as LANE_KINDS_IN_STACK_ORDER, R as createEntityId, S as resolveFieldPath, St as videoDocumentMirrorSchema, T as resolveEntityTimelineLayout, U as buildSpeechHostMap, V as isMediaAssetVariantKind, W as derivePositionFromAbs, X as partUnionSchema, Y as validateVideoDocument, Z as videoDocumentSchema, _ as audioScriptAssetHash, _t as VIDEO_DOCUMENT_SCHEMA_VERSION, a as applyEntityTimelineProjection, at as fillMainTrackTimeGaps, b as applyFieldChanges, bt as partUnionToDraft, c as writeVideoDocumentToDraft, ct as resolveAllSpeechOverlaps, d as LoroEntityDocument, dt as TIMELINE_SKELETON_DURATION_MS, et as findLaneTrack, f as LoroEntityDraft, ft as isEmptyVideoClip, g as audioScriptAssetFields, gt as ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION, h as audioScriptAssetContent, ht as DEFAULT_UNIT_TIME_MS, i as MirrorVideoDocumentAdapter, it as arrangeMainTrackSeamlessly, j as assemblePhoneticScriptContent, k as assembleCaptionContent, l as readVideoDocumentFromDraft, lt as resolveSpeechOverlapByShiftingVideos, m as entityOrderGroups, mt as safeDurationMs, n as createPlainMemoryAdapter, nt as solveVideoDocument, o as createMirrorVideoDocument, ot as reassignSpeechesToVideoClipsByTime, p as readEntityDocument, pt as partDurationMs, q as VideoDocumentValidationError, r as generatePartId, rt as cascadeAfterVideoClipChanges, s as createMirrorVideoDocumentAdapter, st as recalculateTimelineDuration, t as PlainMemoryAdapter, tt as laneTrackId, u as LORO_ENTITY_SCHEMA, ut as syncAggregatedClipsTimePosition, v as hasAudioScriptAsset, vt as effectiveVideoClipDurationMs, w as EntityProjectionGraph, wt as bytesToBase64, x as assertFieldChanges, xt as recordEntries, y as equalJson, yt as speedOf, z as createRelationId } from "./document-N-8GwtOX.js";
1
+ import { $ as findLaneTrack, A as assemblePhoneticScriptContent, B as isMediaAssetVariantKind, C as EntityProjectionGraph, D as ScriptCompositionError, E as decodeEntityRelationRows, F as variantBaseEntityIds, G as toVideoDocument, H as buildSpeechHostMap, I as isJsonObject, J as validateVideoDocument, K as VideoDocumentValidationError, L as createEntityId, M as findComposedAudioScript, N as selectAudioScriptSegment, O as assembleCaptionContent, P as updateEntityFields, Q as ensureLaneTrack, R as createRelationId, S as projectEntityTimeline, St as bytesToBase64, T as EntityTimelineProjectionError, U as derivePositionFromAbs, V as buildInitialVideoDocument, W as fromVideoDocument, X as videoDocumentSchema, Y as partUnionSchema, Z as LANE_KINDS_IN_STACK_ORDER, _ as hasAudioScriptAsset, _t as speedOf, a as createMirrorVideoDocument, at as fillMainTrackTimeGaps, b as assertFieldChanges, bt as videoDocumentMirrorSchema, c as DocumentMutationGuard, ct as resolveAllSpeechOverlaps, d as LoroEntityDraft, dt as TIMELINE_SKELETON_DURATION_MS, et as hasResolvedPlacement, f as readEntityDocument, ft as isEmptyVideoClip, g as audioScriptAssetHash, gt as effectiveVideoClipDurationMs, h as audioScriptAssetFields, ht as DEFAULT_UNIT_TIME_MS, i as MirrorVideoDocumentAdapter, it as arrangeMainTrackSeamlessly, j as assembleScriptText, k as assembleEntityContent, l as LORO_ENTITY_SCHEMA, lt as resolveSpeechOverlapByShiftingVideos, m as audioScriptAssetContent, mt as safeDurationMs, n as createPlainMemoryAdapter, nt as solveVideoDocument, o as createMirrorVideoDocumentAdapter, ot as reassignSpeechesToVideoClipsByTime, p as entityOrderGroups, pt as partDurationMs, q as assertValidVideoDocument, r as generatePartId, rt as cascadeAfterVideoClipChanges, s as readVideoDocumentFromDraft, st as recalculateTimelineDuration, t as PlainMemoryAdapter, tt as laneTrackId, u as LoroEntityDocument, ut as syncAggregatedClipsTimePosition, v as equalJson, vt as partUnionToDraft, w as resolveEntityTimelineLayout, x as resolveFieldPath, xt as base64ToBytes, y as applyFieldChanges, yt as recordEntries, z as hasSequence } from "./document-DdwzfZ1g.js";
2
2
  import { addSpeechesInputSchema, addVideoClipsInputSchema, adjustBgmVolumeInputSchema, adjustSpeechVolumeInputSchema, adjustVideoClipDurationInputSchema, adjustVideoClipVolumeInputSchema, changeSpeechScriptInputSchema, changeSpeechVoiceInputSchema, deleteBgmInputSchema, deleteSpeechesInputSchema, deleteVideoClipsInputSchema, moveSpeechesInputSchema, moveVideoClipsByAnchorInputSchema, moveVideoClipsInputSchema, replaceVideoClipContentInputSchema, replaceVideoClipSequenceInputSchema, setBgmInputSchema, setCaptionStyleInputSchema, setCaptionVisibilityInputSchema, setVideoClipSpeedShiftInputSchema, t as schemas_exports } from "./schemas.js";
3
- import { LoroDoc, VersionVector } from "loro-crdt";
3
+ import { LoroDoc, UndoManager, VersionVector } from "loro-crdt";
4
4
  import { ClientServerSynchronizer, DocManager } from "@mengine/sync";
5
5
  import { DisposableSet, EventBus, Task } from "@mengine/utils";
6
6
  import { BaseDocStorage, DummyConnection } from "@mengine/storage";
@@ -60,12 +60,6 @@ var MengineHttpClient = class {
60
60
  async fetchSnapshot() {
61
61
  return await this.requestJson("snapshot");
62
62
  }
63
- async bootstrapSnapshot(snapshot) {
64
- return await this.requestJson("bootstrap", {
65
- method: "POST",
66
- body: JSON.stringify({ snapshot: bytesToBase64(snapshot) })
67
- });
68
- }
69
63
  /**
70
64
  * Loro VV-diff pull: send the caller's oplog `VersionVector.encode` as `from`
71
65
  * (omit for a full pull) and receive exactly the updates it is missing plus
@@ -168,9 +162,6 @@ async function safeReadJson$1(response) {
168
162
  //#endregion
169
163
  //#region src/client/sse.ts
170
164
  async function readMengineEventStream(options) {
171
- const onUpdate = (event) => {
172
- options.onUpdate(event);
173
- };
174
165
  const response = await options.client.fetch(options.client.eventsUrl(), {
175
166
  headers: options.client.headers(),
176
167
  signal: options.signal
@@ -189,15 +180,19 @@ async function readMengineEventStream(options) {
189
180
  while (splitAt >= 0) {
190
181
  const frame = buffer.slice(0, splitAt);
191
182
  buffer = buffer.slice(buffer[splitAt] === "\r" ? splitAt + 4 : splitAt + 2);
192
- handleSseFrame(frame, onUpdate);
183
+ handleSseFrame(frame, options);
193
184
  splitAt = findSseFrameBoundary(buffer);
194
185
  }
195
186
  }
196
187
  }
197
- function handleSseFrame(frame, onUpdate) {
188
+ function handleSseFrame(frame, options) {
198
189
  const event = parseSseFrame(frame);
190
+ if (event.event === "resync") {
191
+ options.onResync?.();
192
+ return;
193
+ }
199
194
  if (event.event !== "update" || event.data.length === 0) return;
200
- onUpdate(JSON.parse(event.data.join("\n")));
195
+ options.onUpdate(JSON.parse(event.data.join("\n")));
201
196
  }
202
197
  function parseSseFrame(frame) {
203
198
  let event = null;
@@ -584,36 +579,55 @@ function importMediaAsset(initial, fact, idFactory = (prefix) => `${prefix}_${gl
584
579
  "image",
585
580
  "video",
586
581
  "audio",
587
- "voice"
582
+ "voiceover"
588
583
  ].includes(fact.kind)) throw new Error("Unsupported media kind");
589
584
  if (fact.kind !== "image" && (!Number.isFinite(fact.durationMs) || fact.durationMs <= 0)) throw new Error(`${fact.kind} ${fact.assetId} requires its factual positive duration`);
590
- if ((fact.kind === "audio" || fact.kind === "voice") && fact.storageKey.trim() === "") throw new Error(`${fact.kind} ${fact.assetId} requires its physical storage key`);
585
+ if ((fact.kind === "audio" || fact.kind === "voiceover") && fact.storageKey.trim() === "") throw new Error(`${fact.kind} ${fact.assetId} requires its physical storage key`);
591
586
  decodeEntityRelationRows(initial, numericCoordinates);
592
587
  assertCanonicalEditorResources(initial);
593
588
  const entities = structuredClone([...initial.entities]);
594
589
  const relations = structuredClone([...initial.relations]);
595
- const system = fact.kind === "voice" ? "memota-speech" : "memota";
590
+ const system = fact.kind === "voiceover" ? "memota-speech" : "memota";
591
+ const entityKind = fact.kind === "voiceover" ? "audio" : fact.kind;
592
+ const timbre = fact.kind === "voiceover" ? fact.voice : void 0;
596
593
  const existing = entities.filter((row) => {
597
594
  const external = row.payload.external;
598
595
  return isMediaAssetVariantKind(row.entityKind) && isJsonObject(external) && external.system === system && external.key === fact.assetId;
599
596
  });
597
+ const requireMatchingVariant = (row) => {
598
+ const extent = row.payload.extent;
599
+ if (row.entityKind !== entityKind || fact.kind !== "image" && (!isJsonObject(extent) || extent.start !== 0 || extent.end !== fact.durationMs)) throw new Error(`Media facts conflict with existing identity for ${fact.assetId}`);
600
+ if (fact.storageKey !== void 0 && row.payload.storageKey !== fact.storageKey) {
601
+ if (row.payload.storageKey !== void 0) throw new Error(`Media facts conflict with existing storage key for ${fact.assetId}`);
602
+ return {
603
+ ...row,
604
+ payload: {
605
+ ...row.payload,
606
+ storageKey: fact.storageKey
607
+ }
608
+ };
609
+ }
610
+ return row;
611
+ };
600
612
  if (existing.length > 1) throw new Error(`Duplicate media identities for ${fact.assetId}; reuse ${existing[0].entityId}`);
601
613
  if (existing[0] !== void 0) {
602
- const variant = requireMatchingVariant(existing[0], fact);
614
+ const variant = requireMatchingVariant(existing[0]);
603
615
  replaceEntity$1(entities, variant.entityId, variant);
616
+ const voiceEntityId = bindVoiceTimbre(entities, relations, variant.entityId, timbre, idFactory);
604
617
  return {
605
618
  rows: {
606
619
  entities,
607
620
  relations
608
621
  },
609
- contentEntityId: variant.entityId
622
+ contentEntityId: variant.entityId,
623
+ ...voiceEntityId
610
624
  };
611
625
  }
612
626
  const entityId = createEntityId(idFactory("entity"));
613
627
  if (entities.some((row) => row.entityId === entityId)) throw new Error(`Duplicate entity id ${entityId}`);
614
628
  const variant = {
615
629
  entityId,
616
- entityKind: fact.kind,
630
+ entityKind,
617
631
  payload: {
618
632
  extent: fact.kind === "image" ? {
619
633
  kind: "unbounded",
@@ -629,15 +643,11 @@ function importMediaAsset(initial, fact, idFactory = (prefix) => `${prefix}_${gl
629
643
  system,
630
644
  key: fact.assetId
631
645
  },
632
- ...fact.storageKey === void 0 ? {} : { storageKey: fact.storageKey },
633
- ...fact.kind === "voice" && fact.voice !== void 0 ? { voice: {
634
- system: fact.voice.system,
635
- key: fact.voice.key,
636
- ...fact.voice.name === void 0 ? {} : { name: fact.voice.name }
637
- } } : {}
646
+ ...fact.storageKey === void 0 ? {} : { storageKey: fact.storageKey }
638
647
  }
639
648
  };
640
649
  entities.push(variant);
650
+ const voiceEntityId = bindVoiceTimbre(entities, relations, entityId, timbre, idFactory);
641
651
  const rows = {
642
652
  entities,
643
653
  relations
@@ -646,33 +656,58 @@ function importMediaAsset(initial, fact, idFactory = (prefix) => `${prefix}_${gl
646
656
  assertCanonicalEditorResources(rows);
647
657
  return {
648
658
  rows,
649
- contentEntityId: entityId
659
+ contentEntityId: entityId,
660
+ ...voiceEntityId
650
661
  };
651
662
  }
652
- function requireMatchingVariant(row, fact) {
653
- const extent = row.payload.extent;
654
- if (row.entityKind !== fact.kind || fact.kind !== "image" && (!isJsonObject(extent) || extent.start !== 0 || extent.end !== fact.durationMs) || fact.kind === "voice" && !sameVoice(row.payload.voice, fact.voice)) throw new Error(`Media facts conflict with existing identity for ${fact.assetId}`);
655
- if (fact.storageKey !== void 0 && row.payload.storageKey !== fact.storageKey) {
656
- if (row.payload.storageKey !== void 0) throw new Error(`Media facts conflict with existing storage key for ${fact.assetId}`);
657
- return {
658
- ...row,
659
- payload: {
660
- ...row.payload,
661
- storageKey: fact.storageKey
662
- }
663
- };
663
+ /**
664
+ * Attach the timbre a synthesized voiceover was rendered with. One Voice
665
+ * identity is shared by every take naming the same library entry, so an
666
+ * existing row is reused rather than duplicated. Recorded media declares no
667
+ * timbre and keeps no link.
668
+ */
669
+ function bindVoiceTimbre(entities, relations, audioEntityId, descriptor, idFactory) {
670
+ const linked = relations.filter((relation) => relation.relationKind === "voice-timbre" && (relation.endpoint0EntityId === audioEntityId || relation.endpoint1EntityId === audioEntityId));
671
+ if (linked.length > 1) throw new Error(`Voiceover ${audioEntityId} has multiple Voice timbre relations`);
672
+ if (descriptor === void 0) {
673
+ if (linked.length > 0) throw new Error(`Recorded media ${audioEntityId} cannot keep a synthesized Voice timbre`);
674
+ return {};
664
675
  }
665
- return row;
676
+ const voice = entities.find((row) => row.entityKind === "voice" && sameVoiceDescriptor(row.payload.voice, descriptor)) ?? appendVoiceIdentity(entities, descriptor, idFactory);
677
+ const existing = linked[0];
678
+ if (existing === void 0) relations.push({
679
+ relationId: createRelationId(idFactory("relation")),
680
+ relationKind: "voice-timbre",
681
+ endpoint0EntityId: createEntityId(audioEntityId),
682
+ endpoint1EntityId: createEntityId(voice.entityId),
683
+ metadata: {},
684
+ trace: {}
685
+ });
686
+ else if (existing.endpoint0EntityId !== audioEntityId || existing.endpoint1EntityId !== voice.entityId) throw new Error(`Voiceover ${audioEntityId} already carries a different Voice timbre`);
687
+ return { voiceEntityId: voice.entityId };
688
+ }
689
+ function appendVoiceIdentity(entities, descriptor, idFactory) {
690
+ const voice = {
691
+ entityId: createEntityId(idFactory("entity")),
692
+ entityKind: "voice",
693
+ payload: { voice: {
694
+ system: descriptor.system,
695
+ key: descriptor.key,
696
+ ...descriptor.name === void 0 ? {} : { name: descriptor.name }
697
+ } }
698
+ };
699
+ entities.push(voice);
700
+ return voice;
701
+ }
702
+ /** Two takes share a Voice identity only when they name the same library entry. */
703
+ function sameVoiceDescriptor(stored, descriptor) {
704
+ return isJsonObject(stored) && stored.system === descriptor.system && stored.key === descriptor.key && stored.name === descriptor.name;
666
705
  }
667
706
  function replaceEntity$1(entities, entityId, replacement) {
668
707
  const index = entities.findIndex((row) => row.entityId === entityId);
669
708
  if (index < 0) throw new Error(`Entity id ${entityId} does not exist`);
670
709
  entities[index] = replacement;
671
710
  }
672
- function sameVoice(left, right) {
673
- if (right === void 0) return left === void 0;
674
- return isJsonObject(left) && left.system === right.system && left.key === right.key && left.name === right.name && Object.keys(left).every((key) => key === "system" || key === "key" || key === "name");
675
- }
676
711
  //#endregion
677
712
  //#region src/entity-editor/entity-timeline-editor.ts
678
713
  /**
@@ -1208,20 +1243,20 @@ var EntityTimelineEditor = class {
1208
1243
  hostClipEntityId: input.hostClipEntityId,
1209
1244
  anchorOffset: input.anchorOffset
1210
1245
  };
1211
- if (input.placement !== void 0 && (input.hostClipEntityId !== void 0 || input.anchorOffset !== void 0)) throw new Error("Specify Voice placement or hostClipEntityId/anchorOffset, not both");
1246
+ if (input.placement !== void 0 && (input.hostClipEntityId !== void 0 || input.anchorOffset !== void 0)) throw new Error("Specify voiceover placement or hostClipEntityId/anchorOffset, not both");
1212
1247
  requireVolume(input.volume);
1213
1248
  const imported = importMediaAsset(draft, input.media, this.idFactory);
1214
1249
  replaceRows(draft, imported.rows);
1215
- const voice = requireEntityKind(draft, imported.contentEntityId, "voice");
1250
+ const voiceover = requireEntityKind(draft, imported.contentEntityId, "audio");
1216
1251
  const phonetic = this.requireComposedPhoneticScript(draft, input.phoneticScriptEntityId);
1217
- this.ensurePhoneticRender(draft, voice.entityId, phonetic.entityId);
1252
+ this.ensurePhoneticRender(draft, voiceover.entityId, phonetic.entityId);
1218
1253
  const speechTrack = this.ensureRoleTrack(draft, input.timelineEntityId, "speech");
1219
1254
  const existing = draft.entities.find((row) => row.entityId === input.voiceoverClipEntityId);
1220
1255
  let voiceoverClipEntityId;
1221
1256
  if (existing === void 0) voiceoverClipEntityId = this.insertPlacedClipIntoDraft(draft, {
1222
1257
  clipEntityId: input.voiceoverClipEntityId,
1223
1258
  trackEntityId: speechTrack.entityId,
1224
- contentEntityId: voice.entityId,
1259
+ contentEntityId: voiceover.entityId,
1225
1260
  sourceRange: {
1226
1261
  start: 0,
1227
1262
  end: input.media.durationMs
@@ -1245,7 +1280,7 @@ var EntityTimelineEditor = class {
1245
1280
  });
1246
1281
  this.relinkRelation(draft, topology.markerContentRelation, {
1247
1282
  endpoint0EntityId: topology.marker.entityId,
1248
- endpoint1EntityId: voice.entityId
1283
+ endpoint1EntityId: voiceover.entityId
1249
1284
  });
1250
1285
  this.applyClipPlacement(draft, topology.clip.entityId, placement);
1251
1286
  const placed = requireClipTopology(draft, topology.clip.entityId);
@@ -1264,11 +1299,12 @@ var EntityTimelineEditor = class {
1264
1299
  }
1265
1300
  });
1266
1301
  }
1267
- this.reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, voice.entityId, phonetic.entityId, input.captions);
1302
+ this.reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, voiceover.entityId, phonetic.entityId, input.captions);
1268
1303
  this.resolveOneVoiceoverOverlap(draft);
1269
1304
  return {
1270
1305
  voiceoverClipEntityId,
1271
- voiceEntityId: voice.entityId,
1306
+ voiceoverAudioEntityId: voiceover.entityId,
1307
+ ...imported.voiceEntityId === void 0 ? {} : { voiceEntityId: imported.voiceEntityId },
1272
1308
  phoneticScriptEntityId: phonetic.entityId,
1273
1309
  audioScriptEntityId: requireComposedAudioScript(draft, phonetic.entityId),
1274
1310
  captionClipEntityIds: input.captions.map((caption) => caption.captionClipEntityId)
@@ -1474,23 +1510,23 @@ var EntityTimelineEditor = class {
1474
1510
  draft.relations.push(this.createRelation(draft, "timeline-track", timeline.entityId, track.entityId));
1475
1511
  return track;
1476
1512
  }
1477
- /** The existing pronunciation variant a Voice consumes; composition is required. */
1513
+ /** The existing pronunciation variant a voiceover consumes; composition is required. */
1478
1514
  requireComposedPhoneticScript(draft, phoneticScriptEntityId) {
1479
1515
  const phonetic = requireEntityKind(draft, phoneticScriptEntityId, "phonetic-script");
1480
1516
  requireComposedAudioScript(draft, phonetic.entityId);
1481
1517
  return phonetic;
1482
1518
  }
1483
1519
  /** Preserve the factual synthesis input, regardless of persisted endpoint positions. */
1484
- ensurePhoneticRender(draft, voiceEntityId, phoneticScriptEntityId) {
1485
- const renders = draft.relations.filter((relation) => relation.relationKind === "phonetic-script-render" && (relation.endpoint0EntityId === voiceEntityId || relation.endpoint1EntityId === voiceEntityId));
1486
- if (renders.length > 1) throw new Error(`Voice "${voiceEntityId}" has multiple PhoneticScript render relations`);
1520
+ ensurePhoneticRender(draft, voiceoverAudioEntityId, phoneticScriptEntityId) {
1521
+ const renders = draft.relations.filter((relation) => relation.relationKind === "phonetic-script-render" && (relation.endpoint0EntityId === voiceoverAudioEntityId || relation.endpoint1EntityId === voiceoverAudioEntityId));
1522
+ if (renders.length > 1) throw new Error(`Voiceover "${voiceoverAudioEntityId}" has multiple PhoneticScript render relations`);
1487
1523
  if (renders[0] !== void 0) {
1488
- if (otherEndpoint(renders[0], voiceEntityId) === phoneticScriptEntityId) return;
1489
- throw new Error(`Voice "${voiceEntityId}" already has a different PhoneticScript synthesis input`);
1524
+ if (otherEndpoint(renders[0], voiceoverAudioEntityId) === phoneticScriptEntityId) return;
1525
+ throw new Error(`Voiceover "${voiceoverAudioEntityId}" already has a different PhoneticScript synthesis input`);
1490
1526
  }
1491
- draft.relations.push(this.createRelation(draft, "phonetic-script-render", voiceEntityId, phoneticScriptEntityId));
1527
+ draft.relations.push(this.createRelation(draft, "phonetic-script-render", voiceoverAudioEntityId, phoneticScriptEntityId));
1492
1528
  }
1493
- reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, voiceEntityId, phoneticScriptEntityId, captions) {
1529
+ reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, voiceoverAudioEntityId, phoneticScriptEntityId, captions) {
1494
1530
  const audioScriptEntityId = requireComposedAudioScript(draft, phoneticScriptEntityId);
1495
1531
  const requested = new Set(captions.map((caption) => caption.captionClipEntityId));
1496
1532
  if (requested.size !== captions.length) throw new Error("Caption Clip ids must be unique within a voiceover take");
@@ -1521,7 +1557,7 @@ var EntityTimelineEditor = class {
1521
1557
  };
1522
1558
  draft.entities.push(content);
1523
1559
  assertCaptionScript(draft, content.entityId, audioScriptEntityId);
1524
- draft.relations.push(this.createRelation(draft, "caption-alignment", content.entityId, voiceEntityId, { alignment: {
1560
+ draft.relations.push(this.createRelation(draft, "caption-alignment", content.entityId, voiceoverAudioEntityId, { alignment: {
1525
1561
  start: startMs,
1526
1562
  end: startMs + durationMs
1527
1563
  } }));
@@ -1578,7 +1614,7 @@ var EntityTimelineEditor = class {
1578
1614
  }
1579
1615
  }
1580
1616
  });
1581
- this.replaceCaptionAlignment(draft, content.entityId, voiceEntityId, startMs, durationMs);
1617
+ this.replaceCaptionAlignment(draft, content.entityId, voiceoverAudioEntityId, startMs, durationMs);
1582
1618
  }
1583
1619
  }
1584
1620
  ensureRoleTrackForClip(draft, clipEntityId, role) {
@@ -1587,10 +1623,10 @@ var EntityTimelineEditor = class {
1587
1623
  const timeline = requireEntityKind(draft, otherEndpoint(requireOneRelation(draft, track.entityId, "timeline-track"), track.entityId), "timeline");
1588
1624
  return this.ensureRoleTrack(draft, timeline.entityId, role);
1589
1625
  }
1590
- replaceCaptionAlignment(draft, captionEntityId, voiceEntityId, startMs, durationMs) {
1626
+ replaceCaptionAlignment(draft, captionEntityId, voiceoverAudioEntityId, startMs, durationMs) {
1591
1627
  const removed = new Set(incidentRelations(draft, captionEntityId).filter((relation) => relation.relationKind === "caption-alignment").map((relation) => relation.relationId));
1592
1628
  draft.relations = draft.relations.filter((relation) => !removed.has(relation.relationId));
1593
- draft.relations.push(this.createRelation(draft, "caption-alignment", captionEntityId, voiceEntityId, { alignment: {
1629
+ draft.relations.push(this.createRelation(draft, "caption-alignment", captionEntityId, voiceoverAudioEntityId, { alignment: {
1594
1630
  start: startMs,
1595
1631
  end: startMs + durationMs
1596
1632
  } }));
@@ -1999,7 +2035,7 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
1999
2035
  }, idFactory = (prefix) => `${prefix}_${globalThis.crypto.randomUUID()}`) {
2000
2036
  assertSupportedLegacy(legacy);
2001
2037
  if (initial.entities.some((row) => row.entityKind === "timeline")) fail("Timeline already exists; migration is not an editing operation");
2002
- if (legacy.meta.schema_version === "video-document/entity-projection-v1") fail("An entity projection cannot be migrated as legacy state");
2038
+ if (hasResolvedPlacement(legacy)) fail("An entity projection cannot be migrated as legacy state");
2003
2039
  const normalizedMainRanges = overlapNormalizedMainRanges(legacy);
2004
2040
  let entities = structuredClone([...initial.entities]);
2005
2041
  let relations = structuredClone([...initial.relations]);
@@ -2053,7 +2089,7 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
2053
2089
  relations
2054
2090
  }, {
2055
2091
  assetId: speech.origin_speech_id,
2056
- kind: "voice",
2092
+ kind: "voiceover",
2057
2093
  durationMs: speech.media_duration_ms,
2058
2094
  storageKey: speech.audio_storage_key,
2059
2095
  voice: {
@@ -2141,8 +2177,8 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
2141
2177
  } else if (part.caption !== void 0) {
2142
2178
  const caption = part.caption;
2143
2179
  duration = caption.initial_duration_ms;
2144
- const voice = contentByPartId.get(caption.speech_part_id);
2145
- const render = relations.find((relation) => relation.relationKind === "phonetic-script-render" && relation.endpoint0EntityId === voice);
2180
+ const voiceover = contentByPartId.get(caption.speech_part_id);
2181
+ const render = relations.find((relation) => relation.relationKind === "phonetic-script-render" && relation.endpoint0EntityId === voiceover);
2146
2182
  const phonetic = entities.find((entity) => entity.entityId === render?.endpoint1EntityId);
2147
2183
  const script = phonetic === void 0 ? void 0 : composedAudioScript(phonetic, entities);
2148
2184
  if (script === void 0) fail("Legacy Caption has no composed AudioScript");
@@ -2169,7 +2205,7 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
2169
2205
  selection,
2170
2206
  ...caption.style === void 0 ? {} : { style: toEntityCaptionStyle(caption.style) }
2171
2207
  });
2172
- link("caption-alignment", content, voice, { alignment: {
2208
+ link("caption-alignment", content, voiceover, { alignment: {
2173
2209
  start: caption.start_ms,
2174
2210
  end: caption.start_ms + duration
2175
2211
  } });
@@ -2225,27 +2261,23 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
2225
2261
  entities,
2226
2262
  relations
2227
2263
  };
2228
- assertEntityProjectionPreservesLegacy(legacy, projectEntityTimeline(rows, legacy.meta), rows);
2264
+ assertEntityProjectionPreservesLegacy(legacy, projectEntityTimeline(rows), rows);
2229
2265
  return rows;
2230
2266
  }
2231
2267
  /** Server gate: a cutover may change representation, never the existing render facts. */
2232
2268
  function assertEntityProjectionPreservesLegacy(legacy, projection, rows) {
2233
2269
  assertSupportedLegacy(legacy);
2234
- if (projection.meta.schema_version !== "video-document/entity-projection-v1") fail("Expected an entity projection for migration");
2270
+ if (!hasResolvedPlacement(projection)) fail("Expected an entity projection for migration");
2235
2271
  const normalizedMainRanges = overlapNormalizedMainRanges(legacy);
2236
- const compatibilityFacts = (document) => {
2237
- const { version: _version, ...view } = fromVideoDocument(document);
2272
+ const compatibilityFacts = (document, placementResolved) => {
2273
+ const view = fromVideoDocument(document, { placementResolved });
2238
2274
  for (const part of Object.values(view.part_library ?? {})) {
2239
2275
  const speech = part.speech;
2240
2276
  if (speech?.phoneme_script === null) delete speech.phoneme_script;
2241
2277
  }
2242
- const { schema_version: _schemaVersion, version: _metaVersion, ...meta } = document.meta;
2243
- return {
2244
- meta,
2245
- view
2246
- };
2278
+ return { view };
2247
2279
  };
2248
- if (canonical(compatibilityFacts(legacy)) !== canonical(compatibilityFacts(projection))) fail("Entity migration must preserve all existing timeline facts; edit after cutover");
2280
+ if (canonical(compatibilityFacts(legacy, false)) !== canonical(compatibilityFacts(projection, true))) fail("Entity migration must preserve all existing timeline facts; edit after cutover");
2249
2281
  const trackOrders = [];
2250
2282
  for (const oldTrack of legacy.tracks ?? []) {
2251
2283
  const candidates = rows.entities.filter((row) => row.entityKind === "track" && (oldTrack.id === void 0 ? row.payload.role === oldTrack.parts_kind : row.entityId === oldTrack.id));
@@ -2512,21 +2544,21 @@ function requirePart(document, partId) {
2512
2544
  if (part === void 0) fail(`Placed Clip ${partId} has no part payload`);
2513
2545
  return part;
2514
2546
  }
2515
- function ensureSpeechPhoneticChain(voiceEntityId, audioScriptText, phonemeScript, entities, relations, addEntity, link, documentAudioScriptEntityId) {
2516
- const renders = relations.filter((relation) => relation.relationKind === "phonetic-script-render" && relation.endpoint0EntityId === voiceEntityId);
2517
- if (renders.length > 1) fail(`Voice ${voiceEntityId} has ambiguous PhoneticScript provenance`);
2547
+ function ensureSpeechPhoneticChain(voiceoverAudioEntityId, audioScriptText, phonemeScript, entities, relations, addEntity, link, documentAudioScriptEntityId) {
2548
+ const renders = relations.filter((relation) => relation.relationKind === "phonetic-script-render" && relation.endpoint0EntityId === voiceoverAudioEntityId);
2549
+ if (renders.length > 1) fail(`Voiceover ${voiceoverAudioEntityId} has ambiguous PhoneticScript provenance`);
2518
2550
  if (renders.length === 1) {
2519
2551
  const phonetic = entities.find((entity) => entity.entityId === renders[0].endpoint1EntityId && entity.entityKind === "phonetic-script");
2520
- if (phonetic === void 0) fail(`Voice ${voiceEntityId} has a dangling PhoneticScript render`);
2552
+ if (phonetic === void 0) fail(`Voiceover ${voiceoverAudioEntityId} has a dangling PhoneticScript render`);
2521
2553
  const script = composedAudioScript(phonetic, entities);
2522
- if (script === void 0 || scriptText(script) !== audioScriptText) fail(`Voice ${voiceEntityId} conflicts with the legacy Speech script`);
2554
+ if (script === void 0 || scriptText(script) !== audioScriptText) fail(`Voiceover ${voiceoverAudioEntityId} conflicts with the legacy Speech script`);
2523
2555
  return phonetic.entityId;
2524
2556
  }
2525
2557
  const script = entities.find((entity) => entity.entityId === documentAudioScriptEntityId);
2526
2558
  if (script === void 0 || script.entityKind !== "audio-script") fail("Migration must carry the fixed document AudioScript");
2527
2559
  const segments = script.payload.segments;
2528
2560
  if (Array.isArray(segments) && segments.length > 0) {
2529
- if (scriptText(script) !== audioScriptText) fail(`Voice ${voiceEntityId} conflicts with the project AudioScript text`);
2561
+ if (scriptText(script) !== audioScriptText) fail(`Voiceover ${voiceoverAudioEntityId} conflicts with the project AudioScript text`);
2530
2562
  } else {
2531
2563
  const scriptIndex = entities.findIndex((row) => row.entityId === script.entityId);
2532
2564
  entities[scriptIndex] = {
@@ -2541,7 +2573,7 @@ function ensureSpeechPhoneticChain(voiceEntityId, audioScriptText, phonemeScript
2541
2573
  baseEntityIds: [script.entityId],
2542
2574
  ...phonemeScript === void 0 ? {} : { phonemeScript }
2543
2575
  });
2544
- link("phonetic-script-render", voiceEntityId, phonetic);
2576
+ link("phonetic-script-render", voiceoverAudioEntityId, phonetic);
2545
2577
  return phonetic;
2546
2578
  }
2547
2579
  function composedAudioScript(phonetic, entities) {
@@ -3648,7 +3680,7 @@ function writeSpeechAssets(draft, assets) {
3648
3680
  if (caption == null) continue;
3649
3681
  const priorCaption = draft.part_library?.[captionId]?.caption;
3650
3682
  setPart(draft, captionId, { caption: {
3651
- ...priorCaption,
3683
+ ...priorCaption ?? { style: {} },
3652
3684
  id: captionId,
3653
3685
  kind: "caption",
3654
3686
  initial_duration_ms: caption.duration_ms,
@@ -4057,6 +4089,10 @@ var ManualSyncDoc = class ManualSyncDoc {
4057
4089
  client;
4058
4090
  doc;
4059
4091
  adapter;
4092
+ /** Whether this document's timeline is owned by the Entity graph. */
4093
+ isEntityDocument() {
4094
+ return this.adapter.isEntityDocument();
4095
+ }
4060
4096
  editor;
4061
4097
  /**
4062
4098
  * The version the server is known to hold. Starts empty (nothing confirmed)
@@ -4075,17 +4111,24 @@ var ManualSyncDoc = class ManualSyncDoc {
4075
4111
  * Fetches the snapshot up front rather than starting empty and converging: the
4076
4112
  * agent's first act is to read the document, so there is no useful state before
4077
4113
  * the snapshot lands. This also fails fast and loudly on a document that does
4078
- * not exist, instead of `MengineDocSession.waitForContent()`'s behavior of
4079
- * waiting forever for content that will never arrive.
4114
+ * not exist or whose first snapshot is incomplete or invalid.
4080
4115
  */
4081
4116
  static async open(options) {
4082
- const doc = new LoroDoc();
4083
- if (options.peerId != null) doc.setPeerId(options.peerId);
4084
4117
  const response = await options.client.fetchSnapshot();
4085
- doc.import(base64ToBytes(response.snapshot));
4086
- const manualSyncDoc = new ManualSyncDoc(options.client, doc);
4087
- manualSyncDoc.watermark = manualSyncDoc.serverVVFrom(response.version.server_vv) ?? doc.oplogVersion();
4088
- return manualSyncDoc;
4118
+ const doc = new LoroDoc();
4119
+ let manualSyncDoc;
4120
+ try {
4121
+ if (options.peerId != null) doc.setPeerId(options.peerId);
4122
+ if (doc.import(base64ToBytes(response.snapshot)).pending != null) throw new Error("Initial mengine snapshot has missing dependencies");
4123
+ manualSyncDoc = new ManualSyncDoc(options.client, doc);
4124
+ assertValidVideoDocument(manualSyncDoc.snapshot());
4125
+ manualSyncDoc.watermark = manualSyncDoc.serverVVFrom(response.version.server_vv) ?? doc.oplogVersion();
4126
+ return manualSyncDoc;
4127
+ } catch (error) {
4128
+ manualSyncDoc?.adapter.dispose();
4129
+ doc.free();
4130
+ throw error;
4131
+ }
4089
4132
  }
4090
4133
  /** Current document read model (authoritative shape). */
4091
4134
  snapshot() {
@@ -4104,9 +4147,9 @@ var ManualSyncDoc = class ManualSyncDoc {
4104
4147
  editorPeerId() {
4105
4148
  return this.doc.peerIdStr;
4106
4149
  }
4107
- /** Current document projected to the legacy `VideoDraft` read shape. */
4108
- draft() {
4109
- return fromVideoDocument(this.adapter.snapshot());
4150
+ /** Current content projected into the compatible track and part read shape. */
4151
+ content() {
4152
+ return fromVideoDocument(this.adapter.snapshot(), { placementResolved: this.isEntityDocument() });
4110
4153
  }
4111
4154
  /**
4112
4155
  * Replay a deterministic sandbox journal into this document.
@@ -4126,9 +4169,8 @@ var ManualSyncDoc = class ManualSyncDoc {
4126
4169
  *
4127
4170
  * This pair replaces the legacy integer-version comparison that
4128
4171
  * `DraftVersionDetector` used to tell the LLM "the draft was modified
4129
- * externally, reload before editing". Under mengine `meta.version` never
4130
- * changes, so that detector would go permanently silent; comparing version
4131
- * vectors restores the same capability.
4172
+ * externally, reload before editing". Comparing Loro version vectors covers
4173
+ * all collaborative edits without a business revision field in the document.
4132
4174
  *
4133
4175
  * Same capability, not a stronger one: like the legacy detector, this only
4134
4176
  * reports what changed between two moments the caller chose to sample.
@@ -4212,6 +4254,7 @@ var ManualSyncDoc = class ManualSyncDoc {
4212
4254
  if (serverVV != null) this.watermark = serverVV;
4213
4255
  return {
4214
4256
  kind: response.kind,
4257
+ mengineUpdateSeq: response.version?.update_seq ?? response.update_seq ?? void 0,
4215
4258
  updateSeq: response.update_seq ?? void 0,
4216
4259
  collaborated: serverVV != null && !covers(localVersion, serverVV)
4217
4260
  };
@@ -4250,22 +4293,24 @@ var ManualSyncDoc = class ManualSyncDoc {
4250
4293
  * recovers whatever the stream missed while it was down. Recovery therefore
4251
4294
  * lives in the synchronizer (keyed on the real doc version), not here.
4252
4295
  *
4253
- * Each SSE frame's updates are forwarded verbatim via `onUpdate`; this
4296
+ * Update frames are forwarded via `onUpdate`, resync hints via `onResync`; this
4254
4297
  * connection keeps no version cursor and does no catch-up of its own. Dedup is
4255
4298
  * unnecessary because `LoroDoc.import` is idempotent by OpId/VV.
4256
4299
  */
4257
4300
  var SseConnection = class {
4258
4301
  client;
4259
4302
  onUpdate;
4303
+ onResync;
4260
4304
  reconnectDelayMs;
4261
4305
  inner = void 0;
4262
4306
  event = new EventBus();
4263
4307
  streamTask = null;
4264
4308
  _status = "idle";
4265
4309
  _error;
4266
- constructor(client, onUpdate, reconnectDelayMs) {
4310
+ constructor(client, onUpdate, onResync, reconnectDelayMs) {
4267
4311
  this.client = client;
4268
4312
  this.onUpdate = onUpdate;
4313
+ this.onResync = onResync;
4269
4314
  this.reconnectDelayMs = reconnectDelayMs;
4270
4315
  }
4271
4316
  get status() {
@@ -4326,10 +4371,12 @@ var SseConnection = class {
4326
4371
  client: this.client,
4327
4372
  signal,
4328
4373
  onOpen: () => this.setStatus("connected"),
4374
+ onResync: this.onResync,
4329
4375
  onUpdate: (event) => {
4330
4376
  for (const update of event.updates) this.onUpdate(update);
4331
4377
  }
4332
4378
  });
4379
+ if (!signal.aborted) this.setStatus("connecting", /* @__PURE__ */ new Error("SSE stream closed"));
4333
4380
  } catch (error) {
4334
4381
  if (signal.aborted) break;
4335
4382
  this.setStatus("connecting", error instanceof Error ? error : new Error(String(error)));
@@ -4374,7 +4421,7 @@ var MedeoHttpDocStorage = class {
4374
4421
  this.options = options;
4375
4422
  this.client = options.client;
4376
4423
  this.docId = options.docId;
4377
- this.connection = new SseConnection(this.client, (update) => this.emitUpdate(update), options.sseReconnectDelayMs ?? 500);
4424
+ this.connection = new SseConnection(this.client, (update) => this.emitUpdate(update), () => this.events.emit("resync"), options.sseReconnectDelayMs ?? 500);
4378
4425
  }
4379
4426
  get isReadonly() {
4380
4427
  return this.options.readonlyMode ?? false;
@@ -4382,6 +4429,7 @@ var MedeoHttpDocStorage = class {
4382
4429
  async getDoc(docId) {
4383
4430
  this.assertDocId(docId);
4384
4431
  const snapshot = await this.client.fetchSnapshot();
4432
+ this.reportServerVersion(snapshot.version?.server_vv);
4385
4433
  const now = /* @__PURE__ */ new Date();
4386
4434
  return {
4387
4435
  docId,
@@ -4393,6 +4441,7 @@ var MedeoHttpDocStorage = class {
4393
4441
  async getDocDiff(docId, knownVersion) {
4394
4442
  this.assertDocId(docId);
4395
4443
  const response = await this.client.sync(knownVersion);
4444
+ this.reportServerVersion(response.server_vv);
4396
4445
  return {
4397
4446
  docId,
4398
4447
  missing: base64ToBytes(response.update),
@@ -4423,7 +4472,7 @@ var MedeoHttpDocStorage = class {
4423
4472
  if (update.data.byteLength === 0) return {};
4424
4473
  try {
4425
4474
  const response = await this.client.pushUpdate(update.data);
4426
- const serverVV = decodeServerVV(response.version?.server_vv);
4475
+ const serverVV = this.reportServerVersion(response.version?.server_vv);
4427
4476
  this.events.emit("pushOutcome", {
4428
4477
  kind: response.kind,
4429
4478
  updateSeq: response.update_seq ?? void 0,
@@ -4451,6 +4500,19 @@ var MedeoHttpDocStorage = class {
4451
4500
  subscribePushOutcome(callback) {
4452
4501
  return this.events.on("pushOutcome", callback);
4453
4502
  }
4503
+ /** Observe live hints that require VV catch-up without replacing the SSE connection. */
4504
+ subscribeResync(callback) {
4505
+ return this.events.on("resync", callback);
4506
+ }
4507
+ /** Authoritative HTTP reads and accepted pushes, never inferred from SSE bytes. */
4508
+ subscribeServerVersion(callback) {
4509
+ return this.events.on("serverVersion", callback);
4510
+ }
4511
+ reportServerVersion(encoded) {
4512
+ const version = decodeServerVV(encoded);
4513
+ if (version != null) this.events.emit("serverVersion", version);
4514
+ return version;
4515
+ }
4454
4516
  async deleteDoc(_docId) {
4455
4517
  throw new Error("MedeoHttpDocStorage does not support deleteDoc");
4456
4518
  }
@@ -4567,6 +4629,118 @@ var MemoryDocStorage = class extends BaseDocStorage {
4567
4629
  }
4568
4630
  };
4569
4631
  //#endregion
4632
+ //#region src/session/document-undo-manager.ts
4633
+ /**
4634
+ * Tracks the shared LoroDoc independently of semantic editing. Undo/redo use
4635
+ * the same mutation guard as the adapter and publish settled availability.
4636
+ */
4637
+ var DocumentUndoManager = class {
4638
+ doc;
4639
+ mutationGuard;
4640
+ onChange;
4641
+ manager;
4642
+ unsubscribe;
4643
+ disposed = false;
4644
+ notificationQueued = false;
4645
+ published = {
4646
+ canUndo: false,
4647
+ canRedo: false
4648
+ };
4649
+ activeSource = null;
4650
+ constructor(doc, mutationGuard, onChange) {
4651
+ this.doc = doc;
4652
+ this.mutationGuard = mutationGuard;
4653
+ this.onChange = onChange;
4654
+ const peer = doc.peerIdStr;
4655
+ this.manager = new UndoManager(doc, {
4656
+ mergeInterval: 0,
4657
+ maxUndoSteps: 100,
4658
+ excludeOriginPrefixes: ["mengine.bootstrap", "mengine.initialize"],
4659
+ onPush: (_isUndo, range) => {
4660
+ this.scheduleNotification();
4661
+ return {
4662
+ value: this.activeSource ?? {
4663
+ peer,
4664
+ counter: range.start
4665
+ },
4666
+ cursors: []
4667
+ };
4668
+ },
4669
+ onPop: () => this.scheduleNotification()
4670
+ });
4671
+ this.unsubscribe = doc.subscribe(() => this.scheduleNotification());
4672
+ }
4673
+ getState() {
4674
+ this.assertActive();
4675
+ return {
4676
+ canUndo: this.manager.canUndo(),
4677
+ canRedo: this.manager.canRedo()
4678
+ };
4679
+ }
4680
+ undo(options) {
4681
+ return this.applyUndoRedo("Undo", options);
4682
+ }
4683
+ redo(options) {
4684
+ return this.applyUndoRedo("Redo", options);
4685
+ }
4686
+ beginGroup() {
4687
+ this.assertActive();
4688
+ this.mutationGuard.run(() => this.manager.groupStart());
4689
+ }
4690
+ endGroup() {
4691
+ this.assertActive();
4692
+ this.mutationGuard.run(() => this.manager.groupEnd());
4693
+ this.scheduleNotification();
4694
+ }
4695
+ applyUndoRedo(kind, options) {
4696
+ this.assertActive();
4697
+ return this.mutationGuard.run(() => {
4698
+ if (kind === "Undo" ? !this.manager.canUndo() : !this.manager.canRedo()) return false;
4699
+ this.activeSource = kind === "Undo" ? this.manager.topUndoValue() : this.manager.topRedoValue();
4700
+ try {
4701
+ this.doc.setNextCommitOptions({ message: JSON.stringify({
4702
+ semantic_op: kind,
4703
+ payload: { source: this.activeSource },
4704
+ intent: options?.intent ?? null,
4705
+ actor: options?.actor ?? null
4706
+ }) });
4707
+ const before = this.doc.oplogFrontiers();
4708
+ return (kind === "Undo" ? this.manager.undo() : this.manager.redo()) && this.doc.cmpWithFrontiers(before) !== 0;
4709
+ } finally {
4710
+ this.doc.clearNextCommitOptions();
4711
+ this.activeSource = null;
4712
+ this.scheduleNotification();
4713
+ }
4714
+ });
4715
+ }
4716
+ dispose() {
4717
+ if (this.disposed) return;
4718
+ this.disposed = true;
4719
+ this.unsubscribe();
4720
+ this.manager.setOnPush(void 0);
4721
+ this.manager.setOnPop(void 0);
4722
+ this.manager.free();
4723
+ }
4724
+ scheduleNotification() {
4725
+ if (this.notificationQueued || this.disposed) return;
4726
+ this.notificationQueued = true;
4727
+ queueMicrotask(() => {
4728
+ this.notificationQueued = false;
4729
+ if (!this.disposed) this.publish();
4730
+ });
4731
+ }
4732
+ publish() {
4733
+ if (this.disposed) return;
4734
+ const state = this.getState();
4735
+ if (state.canUndo === this.published.canUndo && state.canRedo === this.published.canRedo) return;
4736
+ this.published = state;
4737
+ this.onChange();
4738
+ }
4739
+ assertActive() {
4740
+ if (this.disposed) throw new Error("mengine doc undo manager is disposed");
4741
+ }
4742
+ };
4743
+ //#endregion
4570
4744
  //#region src/session/mengine-doc-session.ts
4571
4745
  /**
4572
4746
  * Raised by {@link MengineDocSession.waitForServerAck} when the local edits it
@@ -4602,13 +4776,9 @@ var MengineAckFailedError = class extends Error {
4602
4776
  * A live editing session for one Medeo document — the single entry point clients
4603
4777
  * (FE draft driver, Agent, embeds) use to open, edit, and observe a document.
4604
4778
  *
4605
- * It assembles the engine sync stack around one Medeo document:
4606
- *
4607
- * local DocStorage ─┐
4608
- * ├─ ClientServerSynchronizer ── DocManager ── LoroDoc
4609
- * MedeoHttpDocStorage┘ │
4610
- * (remote = Rust mengine-server) ▼
4611
- * MirrorVideoDocumentAdapter + SemanticEditor
4779
+ * SemanticEditor writes through MirrorVideoDocumentAdapter directly, while
4780
+ * DocumentUndoManager observes the same LoroDoc independently. Both mutation
4781
+ * paths use the session's DocumentMutationGuard, closed before teardown.
4612
4782
  *
4613
4783
  * `DocManager` owns the `LoroDoc`: local edits committed on the adapter are
4614
4784
  * picked up via `subscribeLocalUpdates`, saved to local storage, then pushed to
@@ -4629,11 +4799,20 @@ var MengineDocSession = class {
4629
4799
  manager;
4630
4800
  events = new EventBus();
4631
4801
  disposables = new DisposableSet();
4802
+ mutationGuard = new DocumentMutationGuard();
4632
4803
  adapterValue = null;
4633
4804
  editorValue = null;
4805
+ undoManagerValue = null;
4634
4806
  started = false;
4807
+ startTask = null;
4808
+ destroyed = false;
4809
+ lifetime = null;
4810
+ hasConnected = false;
4811
+ initialVersionValue = null;
4812
+ localVersionValue = null;
4813
+ pushErrorValue = null;
4635
4814
  /**
4636
- * Latest server oplog version seen on a push outcome. Lets `waitForServerAck`
4815
+ * Latest server oplog version seen on an authoritative HTTP response. Lets `waitForServerAck`
4637
4816
  * return without waiting when the server is already known to cover the local
4638
4817
  * doc (e.g. nothing was edited since the last confirmed push).
4639
4818
  */
@@ -4662,14 +4841,14 @@ var MengineDocSession = class {
4662
4841
  return this.server.subscribePushOutcome(callback);
4663
4842
  }
4664
4843
  /**
4665
- * Resolve once the server has durably accepted the local document state as of
4844
+ * Resolve once the server has durably accepted the local commits as of
4666
4845
  * *now* — the capability an Agent tool needs to answer "did my edit land?"
4667
4846
  * before reporting success.
4668
4847
  *
4669
4848
  * Call it right after the editor ops whose durability matters. It snapshots the
4670
- * local doc's current version and resolves on the first push outcome whose
4671
- * `serverVV` covers that version, i.e. the server log is at least as advanced as
4672
- * the local doc was when this was called.
4849
+ * latest local commit's version (or the loaded baseline before any edit) and
4850
+ * resolves when an authoritative server version covers it. Remote-only
4851
+ * imports do not extend this target, matching the displayed confirmation state.
4673
4852
  *
4674
4853
  * Version coverage, not byte identity, is the acceptance test — for two reasons:
4675
4854
  * the ops may reach the server inside a merged blob rather than as the exact
@@ -4684,31 +4863,39 @@ var MengineDocSession = class {
4684
4863
  * @throws {MengineAckFailedError} on timeout, rejection, or transport failure.
4685
4864
  */
4686
4865
  async waitForServerAck(options = {}) {
4687
- if (this.adapterValue == null) throw new Error("mengine doc session is not started");
4866
+ if (this.adapterValue == null || this.lifetime == null) throw new Error("mengine doc session is not started");
4688
4867
  const timeoutMs = options.timeoutMs ?? 15e3;
4689
- const target = this.manager.connectDoc(this.docId).version();
4868
+ const target = this.confirmationTarget() ?? this.manager.connectDoc(this.docId).version();
4869
+ const signal = this.lifetime.signal;
4690
4870
  if (this.serverVVValue != null && serverCovers(this.serverVVValue, target)) return;
4691
4871
  await new Promise((resolve, reject) => {
4692
- let off = () => {};
4872
+ const cleanup = [];
4693
4873
  let done = false;
4694
4874
  const settle = (error) => {
4695
4875
  if (done) return;
4696
4876
  done = true;
4697
4877
  clearTimeout(timer);
4698
- off();
4878
+ cleanup.forEach((off) => off());
4699
4879
  if (error == null) resolve();
4700
4880
  else reject(error);
4701
4881
  };
4702
4882
  const timer = setTimeout(() => {
4703
4883
  settle(new MengineAckFailedError("timeout", void 0, void 0, `mengine did not acknowledge the update within ${timeoutMs}ms`));
4704
4884
  }, timeoutMs);
4705
- off = this.server.subscribePushOutcome((outcome) => {
4885
+ const onAbort = () => settle(new MengineAckFailedError("failed", void 0, void 0, "mengine doc session destroyed"));
4886
+ signal.addEventListener("abort", onAbort, { once: true });
4887
+ cleanup.push(() => signal.removeEventListener("abort", onAbort));
4888
+ cleanup.push(this.events.on("syncState", () => {
4889
+ if (this.serverVVValue != null && serverCovers(this.serverVVValue, target)) settle(void 0);
4890
+ }));
4891
+ cleanup.push(this.server.subscribePushOutcome((outcome) => {
4706
4892
  if (outcome.kind === "rejected" || outcome.kind === "failed") {
4707
4893
  settle(new MengineAckFailedError(outcome.kind, outcome.code, outcome.error, outcome.error?.message ?? `mengine push ${outcome.kind}`));
4708
4894
  return;
4709
4895
  }
4710
- if (outcome.serverVV != null && serverCovers(outcome.serverVV, target)) settle(void 0);
4711
- });
4896
+ }));
4897
+ if (signal.aborted) onAbort();
4898
+ else this.synchronizer.requestSync(this.docId);
4712
4899
  });
4713
4900
  }
4714
4901
  /**
@@ -4744,44 +4931,154 @@ var MengineDocSession = class {
4744
4931
  if (this.adapterValue == null) throw new Error("mengine doc session is not started");
4745
4932
  return this.adapterValue.snapshot();
4746
4933
  }
4934
+ /** Current-session undo/redo availability. Loading and closed sessions report false. */
4935
+ getUndoState() {
4936
+ return this.undoManagerValue?.getState() ?? {
4937
+ canUndo: false,
4938
+ canRedo: false
4939
+ };
4940
+ }
4941
+ /**
4942
+ * Supplies current availability immediately, then coalesces stack changes in a
4943
+ * microtask. Session teardown publishes empty availability synchronously.
4944
+ */
4945
+ subscribeUndoState(cb) {
4946
+ const notify = () => cb(this.getUndoState());
4947
+ const off = this.destroyed ? () => {} : this.events.on("undoState", notify);
4948
+ notify();
4949
+ return off;
4950
+ }
4951
+ /**
4952
+ * Undo a local edit immediately; server confirmation remains asynchronous.
4953
+ * On a shared LWW field this writes its prior value, potentially overwriting a
4954
+ * later remote edit to that same field. Returns false if no update is produced.
4955
+ */
4956
+ undo(options) {
4957
+ if (this.undoManagerValue == null) throw new Error("mengine doc undo manager is not ready");
4958
+ return this.undoManagerValue.undo(options);
4959
+ }
4960
+ /**
4961
+ * Restore the state visible before undo without rerunning external side effects.
4962
+ * Returns false if no update is produced; durable confirmation is separate.
4963
+ */
4964
+ redo(options) {
4965
+ if (this.undoManagerValue == null) throw new Error("mengine doc undo manager is not ready");
4966
+ return this.undoManagerValue.redo(options);
4967
+ }
4968
+ /**
4969
+ * Combine existing semantic operations (e.g. trim a clip and adjust its volume)
4970
+ * or repeated commits during one drag gesture into a single undo step. A gesture
4971
+ * that only previews locally and commits once on release needs no group.
4972
+ * Each commit is immediately visible locally and enters sync independently;
4973
+ * a later failure does not roll it back.
4974
+ * Always pair with endUndoGroup in a finally block. Conflicting remote imports
4975
+ * can end/split the group early, and unrelated local edits would join it.
4976
+ * Complete asynchronous asset preparation before opening the group.
4977
+ */
4978
+ beginUndoGroup() {
4979
+ if (this.undoManagerValue == null) throw new Error("mengine doc undo manager is not ready");
4980
+ this.undoManagerValue.beginGroup();
4981
+ }
4982
+ /** End a gesture, including one already split by a conflicting remote import. */
4983
+ endUndoGroup() {
4984
+ if (this.undoManagerValue == null) throw new Error("mengine doc undo manager is not ready");
4985
+ this.undoManagerValue.endGroup();
4986
+ }
4747
4987
  /**
4748
4988
  * Start the sync stack and connect the document.
4749
4989
  *
4750
- * Returns once the local snapshot has loaded into the Loro doc so callers can
4751
- * read an initial snapshot. Remote convergence continues in the background and
4752
- * surfaces through `subscribe`.
4990
+ * Returns after DocManager loads a local snapshot, or after the first remote
4991
+ * sync when no local snapshot exists. A valid empty document is ready too.
4992
+ * Cached documents can open before SSE connects; subsequent remote changes
4993
+ * surface through `subscribe`.
4753
4994
  */
4754
- async start() {
4755
- if (this.started) return this.snapshot();
4995
+ start() {
4996
+ if (this.destroyed) return Promise.reject(/* @__PURE__ */ new Error("mengine doc session destroyed"));
4997
+ if (this.startTask != null) return this.startTask;
4756
4998
  this.started = true;
4757
- this.local.connection.connect();
4758
- this.server.connection.connect();
4999
+ this.lifetime = new AbortController();
5000
+ this.startTask = this.open().catch((error) => {
5001
+ const wasDestroyed = this.destroyed;
5002
+ this.destroy();
5003
+ if (wasDestroyed) throw new Error("mengine doc session destroyed");
5004
+ throw error;
5005
+ });
5006
+ return this.startTask;
5007
+ }
5008
+ async open() {
5009
+ this.disposables.add(this.server.subscribeResync(() => this.synchronizer.requestSync(this.docId)));
5010
+ this.disposables.add(this.server.subscribeServerVersion((version) => {
5011
+ this.serverVVValue = version;
5012
+ if (this.getSyncState().confirmation === "confirmed") this.pushErrorValue = null;
5013
+ this.emitSyncState();
5014
+ }));
5015
+ this.disposables.add(this.server.subscribePushOutcome((outcome) => {
5016
+ this.pushErrorValue = outcome.kind === "failed" || outcome.kind === "rejected" ? outcome.error ?? /* @__PURE__ */ new Error(`mengine push ${outcome.kind}`) : null;
5017
+ this.emitSyncState();
5018
+ }));
5019
+ this.disposables.add(this.server.connection.onStatusChanged((status) => {
5020
+ if (status === "connected") this.hasConnected = true;
5021
+ this.emitSyncState();
5022
+ }));
4759
5023
  this.disposables.add(() => this.local.connection.disconnect());
4760
5024
  this.disposables.add(() => this.server.connection.disconnect());
5025
+ this.local.connection.connect();
5026
+ await this.local.connection.waitForConnected().abortOn(this.lifetime.signal);
5027
+ if (this.destroyed) throw new Error("mengine doc session destroyed");
5028
+ this.server.connection.connect();
4761
5029
  this.synchronizer.start();
4762
5030
  this.manager.start();
4763
5031
  this.disposables.add(() => this.synchronizer.stop());
4764
5032
  this.disposables.add(() => this.manager.stop());
4765
5033
  const doc = this.manager.connectDoc(this.docId);
4766
- if (this.options.peerId != null) doc.setPeerId(this.options.peerId);
4767
- this.adapterValue = new MirrorVideoDocumentAdapter(doc);
4768
- this.editorValue = new SemanticEditor(this.adapterValue);
4769
5034
  this.disposables.add(() => this.manager.disconnectDoc(this.docId));
5035
+ if (this.options.peerId != null) doc.setPeerId(this.options.peerId);
5036
+ this.adapterValue = new MirrorVideoDocumentAdapter(doc, this.mutationGuard);
4770
5037
  const unsubscribe = doc.subscribe((event) => {
4771
5038
  if (this.adapterValue == null) return;
5039
+ if (event.by === "local") this.localVersionValue = doc.version();
5040
+ this.emitSyncState();
4772
5041
  this.events.emit("update", {
4773
5042
  source: event.by === "local" ? "local" : "remote",
4774
5043
  snapshot: this.adapterValue.snapshot()
4775
5044
  });
4776
5045
  });
4777
5046
  this.disposables.add(unsubscribe);
4778
- this.disposables.add(this.manager.onDocStateChange(this.docId, (state) => this.events.emit("state", state)));
4779
- this.disposables.add(this.server.subscribePushOutcome((outcome) => {
4780
- if (outcome.serverVV != null) this.serverVVValue = outcome.serverVV;
5047
+ this.disposables.add(this.manager.onDocStateChange(this.docId, (state) => {
5048
+ this.events.emit("state", state);
5049
+ this.emitSyncState();
4781
5050
  }));
4782
- await this.waitForContent();
5051
+ await this.waitForInitialLoad();
5052
+ if (this.destroyed) throw new Error("mengine doc session destroyed");
5053
+ assertValidVideoDocument(this.snapshot());
5054
+ this.undoManagerValue = new DocumentUndoManager(doc, this.mutationGuard, () => this.events.emit("undoState"));
5055
+ this.editorValue = new SemanticEditor(this.adapterValue);
5056
+ this.initialVersionValue = doc.version();
5057
+ this.emitSyncState();
4783
5058
  return this.snapshot();
4784
5059
  }
5060
+ getSyncState() {
5061
+ const state = this.getState();
5062
+ const target = this.confirmationTarget();
5063
+ const confirmation = this.serverVVValue == null || target == null ? this.localVersionValue == null ? "unknown" : "pending" : serverCovers(this.serverVVValue, target) ? "confirmed" : "pending";
5064
+ const connection = !this.started ? "disconnected" : this.server.connection.status === "connected" ? "connected" : this.hasConnected || this.server.connection.error != null ? "disconnected" : "connecting";
5065
+ return {
5066
+ confirmation,
5067
+ phase: connection === "disconnected" ? "offline" : this.pushErrorValue != null || state.error != null ? "failed" : connection === "connecting" ? "connecting" : state.syncing ? "syncing" : "idle"
5068
+ };
5069
+ }
5070
+ confirmationTarget() {
5071
+ return this.localVersionValue ?? this.initialVersionValue;
5072
+ }
5073
+ /** Immediately supplies current state; unsubscribe when the host changes docs. */
5074
+ subscribeSyncState(cb) {
5075
+ const off = this.events.on("syncState", cb);
5076
+ cb(this.getSyncState());
5077
+ return off;
5078
+ }
5079
+ emitSyncState() {
5080
+ this.events.emit("syncState", this.getSyncState());
5081
+ }
4785
5082
  subscribe(cb) {
4786
5083
  return this.events.on("update", cb);
4787
5084
  }
@@ -4792,40 +5089,48 @@ var MengineDocSession = class {
4792
5089
  return this.manager.getDocState(this.docId);
4793
5090
  }
4794
5091
  destroy() {
5092
+ if (this.destroyed) return;
5093
+ this.destroyed = true;
5094
+ this.mutationGuard.close();
5095
+ this.started = false;
5096
+ this.lifetime?.abort();
5097
+ this.undoManagerValue?.dispose();
5098
+ this.undoManagerValue = null;
5099
+ this.events.emit("undoState");
4795
5100
  this.disposables.dispose();
5101
+ this.emitSyncState();
4796
5102
  this.events.clearAll();
5103
+ this.adapterValue?.dispose();
4797
5104
  this.adapterValue = null;
4798
5105
  this.editorValue = null;
4799
5106
  this.started = false;
4800
5107
  }
4801
- /**
4802
- * Resolve once the Loro doc holds the document root.
4803
- *
4804
- * `DocManager` loads from local storage, which starts empty for a fresh
4805
- * client; the server snapshot arrives asynchronously via the first sync job.
4806
- * `loaded` only means the local load ran, so we wait for actual content
4807
- * (populated schema roots) instead, surfaced by the doc subscription set up
4808
- * in `start()`.
4809
- */
4810
- async waitForContent() {
4811
- if (this.hasContent()) return;
4812
- await new Promise((resolve) => {
4813
- const off = this.events.on("update", () => {
4814
- if (this.hasContent()) {
4815
- off();
4816
- resolve();
4817
- }
4818
- });
5108
+ /** Local absence needs a remote sync; empty business content does not. */
5109
+ async waitForInitialLoad() {
5110
+ const signal = this.lifetime.signal;
5111
+ await new Promise((resolve, reject) => {
5112
+ let off = () => {};
5113
+ let offConnection = () => {};
5114
+ const finish = (error) => {
5115
+ off();
5116
+ offConnection();
5117
+ signal.removeEventListener("abort", onAbort);
5118
+ if (error != null) reject(error);
5119
+ else resolve();
5120
+ };
5121
+ const onAbort = () => finish(/* @__PURE__ */ new Error("mengine doc session destroyed"));
5122
+ const check = (state) => {
5123
+ if (state.loaded && (state.loadedFromLocal || state.synced)) finish();
5124
+ else if (state.error != null) finish(new Error(state.error));
5125
+ else if (state.loaded && this.server.connection.error != null) finish(this.server.connection.error);
5126
+ };
5127
+ off = this.onStateChange(check);
5128
+ offConnection = this.server.connection.onStatusChanged(() => check(this.getState()));
5129
+ signal.addEventListener("abort", onAbort, { once: true });
5130
+ if (signal.aborted) onAbort();
5131
+ else check(this.getState());
4819
5132
  });
4820
5133
  }
4821
- hasContent() {
4822
- if (this.adapterValue == null) return false;
4823
- try {
4824
- return this.adapterValue.hasContent();
4825
- } catch {
4826
- return false;
4827
- }
4828
- }
4829
5134
  };
4830
5135
  /**
4831
5136
  * True when the *encoded* server version `serverVV` includes everything in
@@ -4849,4 +5154,4 @@ function serverCovers(serverVV, target) {
4849
5154
  return covers(server, target);
4850
5155
  }
4851
5156
  //#endregion
4852
- export { DEFAULT_UNIT_TIME_MS, ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION, EntityGraphHttpClient, EntityTimelineEditor, EntityTimelineProjectionError, IMPLEMENTED_SEMANTIC_OP_KINDS, LANE_KINDS_IN_STACK_ORDER, LORO_ENTITY_SCHEMA, LoroEntityDocument, LoroEntityDraft, ManualSyncDoc, MedeoHttpDocStorage, MemoryDocStorage, MengineAckFailedError, MengineDocSession, MengineHttpClient, MengineHttpRequestError, MenginePushRejectedError, MirrorVideoDocumentAdapter, PlainMemoryAdapter, SchemaValidator, ScriptCompositionError, SemanticEditor, TIMELINE_SKELETON_DURATION_MS, VIDEO_DOCUMENT_SCHEMA_VERSION, ValidationError, VideoDocumentValidationError, applyEntityRows, applyEntityTimelineProjection, applyFieldChanges, arrangeMainTrackSeamlessly, assembleCaptionContent, assembleEntityContent, assemblePhoneticScriptContent, assembleScriptText, assertCanonicalEditorResources, assertEntityProjectionPreservesLegacy, assertFieldChanges, assertMediaAssetWritePolicy, assertValidVideoDocument, audioScriptAssetContent, audioScriptAssetFields, audioScriptAssetHash, base64ToBytes, buildInitialVideoDocument, buildSpeechHostMap, bytesToBase64, cascadeAfterVideoClipChanges, compileEntityRows, createEditSandbox, createMirrorVideoDocument, createMirrorVideoDocumentAdapter, createPlainMemoryAdapter, decodeDocVersionMark, derivePositionFromAbs, effectiveVideoClipDurationMs, encodeDocVersionMark, ensureEditorFoundation, ensureLaneTrack, fillMainTrackTimeGaps, findComposedAudioScript, findLaneTrack, fromVideoDocument, generatePartId, getAt, hasAudioScriptAsset, hostForAbsMs, importMediaAsset, isEmptyVideoClip, isImplementedSemanticOpKind, isMap, laneTrackId, mainTrackRanges, migrateLegacyTimelineToEntities, partDurationMs, partUnionSchema, prepareCaptionContent, projectEntityTimeline, readDocumentAudioScript, readEntityDocument, readMainTrackItems, readMengineEventStream, readPart, readPartDurationMs, readVideoDocumentFromDraft, reassignSpeechesToVideoClipsByTime, recalculateTimelineDuration, relativePositionForAbs, replayJournal, resolveAllSpeechOverlaps, resolveEntityTimelineLayout, resolveFieldPath, resolveSpeechOverlapByShiftingVideos, safeDurationMs, schemas_exports as schemas, selectAudioScriptSegment, snapshotToPlain, solveVideoDocument, speedOf, syncAggregatedClipsTimePosition, toVideoDocument, validateVideoDocument, videoDocumentMirrorSchema, videoDocumentSchema, writeVideoDocumentToDraft };
5157
+ export { DEFAULT_UNIT_TIME_MS, EntityGraphHttpClient, EntityTimelineEditor, EntityTimelineProjectionError, IMPLEMENTED_SEMANTIC_OP_KINDS, LANE_KINDS_IN_STACK_ORDER, LORO_ENTITY_SCHEMA, LoroEntityDocument, LoroEntityDraft, ManualSyncDoc, MedeoHttpDocStorage, MemoryDocStorage, MengineAckFailedError, MengineDocSession, MengineHttpClient, MengineHttpRequestError, MenginePushRejectedError, MirrorVideoDocumentAdapter, PlainMemoryAdapter, SchemaValidator, ScriptCompositionError, SemanticEditor, TIMELINE_SKELETON_DURATION_MS, ValidationError, VideoDocumentValidationError, applyEntityRows, applyFieldChanges, arrangeMainTrackSeamlessly, assembleCaptionContent, assembleEntityContent, assemblePhoneticScriptContent, assembleScriptText, assertCanonicalEditorResources, assertEntityProjectionPreservesLegacy, assertFieldChanges, assertMediaAssetWritePolicy, assertValidVideoDocument, audioScriptAssetContent, audioScriptAssetFields, audioScriptAssetHash, base64ToBytes, buildInitialVideoDocument, buildSpeechHostMap, bytesToBase64, cascadeAfterVideoClipChanges, compileEntityRows, createEditSandbox, createMirrorVideoDocument, createMirrorVideoDocumentAdapter, createPlainMemoryAdapter, decodeDocVersionMark, derivePositionFromAbs, effectiveVideoClipDurationMs, encodeDocVersionMark, ensureEditorFoundation, ensureLaneTrack, fillMainTrackTimeGaps, findComposedAudioScript, findLaneTrack, fromVideoDocument, generatePartId, getAt, hasAudioScriptAsset, hostForAbsMs, importMediaAsset, isEmptyVideoClip, isImplementedSemanticOpKind, isMap, laneTrackId, mainTrackRanges, migrateLegacyTimelineToEntities, partDurationMs, partUnionSchema, prepareCaptionContent, projectEntityTimeline, readDocumentAudioScript, readEntityDocument, readMainTrackItems, readMengineEventStream, readPart, readPartDurationMs, readVideoDocumentFromDraft, reassignSpeechesToVideoClipsByTime, recalculateTimelineDuration, relativePositionForAbs, replayJournal, resolveAllSpeechOverlaps, resolveEntityTimelineLayout, resolveFieldPath, resolveSpeechOverlapByShiftingVideos, safeDurationMs, schemas_exports as schemas, selectAudioScriptSegment, snapshotToPlain, solveVideoDocument, speedOf, syncAggregatedClipsTimePosition, toVideoDocument, validateVideoDocument, videoDocumentMirrorSchema, videoDocumentSchema };