@mengine/medeo-client 2.0.1-alpha.2 → 2.0.1-alpha.20

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 cascadeAfterVideoClipChanges, A as selectAudioScriptSegment, B as derivePositionFromAbs, C as decodeEntityRelationRows, D as assemblePhoneticScriptContent, E as assembleEntityContent, F as createRelationId, G as validateVideoDocument, H as toVideoDocument, I as hasSequence, J as LANE_KINDS_IN_STACK_ORDER, K as partUnionSchema, L as isMediaAssetVariantKind, M as variantBaseEntityIds, N as isJsonObject, O as assembleScriptText, P as createEntityId, Q as solveVideoDocument, R as buildInitialVideoDocument, S as EntityTimelineProjectionError, T as assembleCaptionContent, U as VideoDocumentValidationError, V as fromVideoDocument, W as assertValidVideoDocument, X as findLaneTrack, Y as ensureLaneTrack, Z as laneTrackId, _ as assertFieldChanges, _t as recordEntries, a as applyEntityTimelineProjection, at as resolveSpeechOverlapByShiftingVideos, b as EntityProjectionGraph, bt as bytesToBase64, c as writeVideoDocumentToDraft, ct as isEmptyVideoClip, d as LoroEntityDocument, dt as DEFAULT_UNIT_TIME_MS, et as arrangeMainTrackSeamlessly, f as LoroEntityDraft, ft as ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION, g as applyFieldChanges, gt as partUnionToDraft, h as equalJson, ht as speedOf, i as MirrorVideoDocumentAdapter, it as resolveAllSpeechOverlaps, j as updateEntityFields, k as findComposedAudioScript, l as readVideoDocumentFromDraft, lt as partDurationMs, m as entityOrderGroups, mt as effectiveVideoClipDurationMs, n as createPlainMemoryAdapter, nt as reassignSpeechesToVideoClipsByTime, o as createMirrorVideoDocument, ot as syncAggregatedClipsTimePosition, p as readEntityDocument, pt as VIDEO_DOCUMENT_SCHEMA_VERSION, q as videoDocumentSchema, r as generatePartId, rt as recalculateTimelineDuration, s as createMirrorVideoDocumentAdapter, st as TIMELINE_SKELETON_DURATION_MS, t as PlainMemoryAdapter, tt as fillMainTrackTimeGaps, u as LORO_ENTITY_SCHEMA, ut as safeDurationMs, v as resolveFieldPath, vt as videoDocumentMirrorSchema, w as ScriptCompositionError, x as resolveEntityTimelineLayout, y as projectEntityTimeline, yt as base64ToBytes, z as buildSpeechHostMap } from "./document-BSAUud6c.js";
1
+ import { $ as partUnionSchema, A as decodeEntityRelationRows, B as isJsonObject, C as applyFieldChanges, Ct as recordEntries, D as EntityProjectionGraph, E as projectEntityTimeline, Et as bytesToBase64, F as assembleScriptText, G as buildInitialVideoDocument, H as createRelationId, I as findComposedAudioScript, J as fromVideoDocument, K as buildSpeechHostMap, L as selectAudioScriptSegment, M as assembleCaptionContent, N as assembleEntityContent, O as resolveEntityTimelineLayout, P as assemblePhoneticScriptContent, Q as validateVideoDocument, R as updateEntityFields, S as equalJson, St as partUnionToDraft, T as resolveFieldPath, Tt as base64ToBytes, U as hasSequence, V as createEntityId, W as isMediaAssetVariantKind, X as VideoDocumentValidationError, Y as toVideoDocument, Z as assertValidVideoDocument, _ as audioScriptAssetOf, _t as partDurationMs, a as createMirrorVideoDocument, at as laneTrackId, b as phoneticScriptAssetOf, bt as effectiveVideoClipDurationMs, c as DocumentMutationGuard, ct as arrangeMainTrackSeamlessly, d as LoroEntityDraft, dt as recalculateTimelineDuration, et as videoDocumentSchema, f as readEntityDocument, ft as resolveAllSpeechOverlaps, g as audioScriptAssetHash, gt as isEmptyVideoClip, h as audioScriptAssetFields, ht as TIMELINE_SKELETON_DURATION_MS, i as MirrorVideoDocumentAdapter, it as hasResolvedPlacement, j as ScriptCompositionError, k as EntityTimelineProjectionError, l as LORO_ENTITY_SCHEMA, lt as fillMainTrackTimeGaps, m as audioScriptAssetContent, mt as syncAggregatedClipsTimePosition, n as createPlainMemoryAdapter, nt as ensureLaneTrack, o as createMirrorVideoDocumentAdapter, ot as solveVideoDocument, p as entityOrderGroups, pt as resolveSpeechOverlapByShiftingVideos, q as derivePositionFromAbs, r as generatePartId, rt as findLaneTrack, s as readVideoDocumentFromDraft, st as cascadeAfterVideoClipChanges, t as PlainMemoryAdapter, tt as LANE_KINDS_IN_STACK_ORDER, u as LoroEntityDocument, ut as reassignSpeechesToVideoClipsByTime, v as phoneticScriptAssetContent, vt as safeDurationMs, w as assertFieldChanges, wt as videoDocumentMirrorSchema, x as scriptSourceAssetOf, xt as speedOf, y as phoneticScriptAssetHash, yt as DEFAULT_UNIT_TIME_MS, z as variantBaseEntityIds } from "./document-yPiwEmkJ.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;
@@ -226,7 +221,8 @@ function compileEntityRows(snapshot, rows, validate) {
226
221
  }
227
222
  /** Compile an edit on an existing peer, retaining its native local undo history. */
228
223
  function applyEntityRows(editor, rows) {
229
- const before = editor.snapshot().rows;
224
+ const baseline = editor.snapshot();
225
+ const before = baseline.rows;
230
226
  const oldEntities = new Map(before.entities.map((row) => [row.entityId, row]));
231
227
  const newEntities = new Map(rows.entities.map((row) => [row.entityId, row]));
232
228
  const oldRelations = new Map(before.relations.map((row) => [row.relationId, row]));
@@ -242,10 +238,16 @@ function applyEntityRows(editor, rows) {
242
238
  if (!equalJson(row.metadata, next.metadata) || !equalJson(row.trace, next.trace)) draft.patchRelation(row.relationId, next.metadata, next.trace);
243
239
  }
244
240
  }
241
+ let attached = baseline.project.audioScriptEntityId !== void 0;
245
242
  for (const row of rows.entities) {
246
243
  const previous = oldEntities.get(row.entityId);
247
- if (!previous) draft.create(row);
248
- else {
244
+ if (!previous) {
245
+ draft.create(row);
246
+ if (!attached && row.entityKind === "audio-script") {
247
+ draft.attach("audioScriptEntityId", String(row.entityId));
248
+ attached = true;
249
+ }
250
+ } else {
249
251
  if (previous.entityKind !== row.entityKind) throw new Error("Entity kind cannot change");
250
252
  if (!equalJson(previous.payload, row.payload)) draft.replaceOwned(row.entityId, row.payload);
251
253
  }
@@ -288,66 +290,59 @@ const roleOrder$1 = {
288
290
  /**
289
291
  * MEngine editor policy, not a cardinality restriction on the general-purpose DSL.
290
292
  *
291
- * Media variants share one entity identity with their Asset: every
292
- * Image/Video/Audio/Voice Entity must carry its own external locator, every
293
- * external asset id has exactly one claimant, and no media variant may be
294
- * bound through a legacy `physical-asset` Relation. Dual-entity media graphs
295
- * are rejected, not migrated. Caption composes AudioScript text and may associate with a physical text Asset.
293
+ * Resources live on Asset entities and are reached through `from-asset`: no
294
+ * other entity carries a locator, one Asset claims each `(system, key)` pair,
295
+ * and media reaches at most one Asset. Media whose bytes are not named through
296
+ * that Relation is rejected rather than guessed at.
296
297
  */
297
298
  function assertCanonicalEditorResources(rows) {
298
299
  if (rows.entities.filter((row) => row.entityKind === "timeline").length > 1) throw new EntityTimelineProjectionError("Editor requires exactly one Timeline");
299
300
  for (const role of Object.keys(roleOrder$1)) if (rows.entities.filter((row) => row.entityKind === "track" && row.payload.role === role).length > 1) throw new EntityTimelineProjectionError(`Editor has multiple ${role} Tracks`);
300
301
  const identityByAssetKey = /* @__PURE__ */ new Map();
301
- const kinds = new Map(rows.entities.map((row) => [String(row.entityId), row.entityKind]));
302
302
  for (const entity of rows.entities) {
303
- if (isMediaAssetVariantKind(entity.entityKind) && memotaLocator(entity.payload.external) === void 0) throw new EntityTimelineProjectionError(`Media Entity ${entity.entityId} must carry its own Asset external identity`);
304
- if (entity.entityKind !== "asset" && !isMediaAssetVariantKind(entity.entityKind)) continue;
305
- const external = entity.payload.external;
306
- if (!isJsonObject(external) || external.system !== "memota" && external.system !== "memota-speech" || typeof external.key !== "string") continue;
307
- const key = JSON.stringify([external.system, external.key]);
308
- const previous = identityByAssetKey.get(key);
309
- if (previous !== void 0 && previous !== entity.entityId) throw new EntityTimelineProjectionError(`Duplicate Asset for ${key}; reuse ${previous}`);
310
- identityByAssetKey.set(key, entity.entityId);
303
+ if (entity.entityKind === "asset") {
304
+ const locator = memotaLocator(entity.payload);
305
+ if (locator === void 0) throw new EntityTimelineProjectionError(`Asset ${entity.entityId} must state where its bytes live`);
306
+ const key = JSON.stringify([locator.system, locator.key]);
307
+ const previous = identityByAssetKey.get(key);
308
+ if (previous !== void 0 && previous !== entity.entityId) throw new EntityTimelineProjectionError(`Duplicate Asset for ${key}; reuse ${previous}`);
309
+ identityByAssetKey.set(key, entity.entityId);
310
+ continue;
311
+ }
312
+ if (memotaLocator(entity.payload) !== void 0) throw new EntityTimelineProjectionError(`Entity ${entity.entityId} must not carry a locator; link its Asset with from-asset`);
311
313
  }
312
- for (const relation of rows.relations) {
313
- if (relation.relationKind !== "physical-asset") continue;
314
- const mediaEndpoint = [relation.endpoint0EntityId, relation.endpoint1EntityId].find((endpoint) => isMediaAssetVariantKind(kinds.get(String(endpoint)) ?? ""));
315
- if (mediaEndpoint !== void 0) throw new EntityTimelineProjectionError(`Cannot bind media Entity ${mediaEndpoint} through physical-asset Relation ${relation.relationId}; media owns its Asset identity directly`);
314
+ for (const entity of rows.entities) {
315
+ if (!isMediaAssetVariantKind(entity.entityKind)) continue;
316
+ if (assetIdsOf(rows, entity.entityId).length !== 1) throw new EntityTimelineProjectionError(`Media Entity ${entity.entityId} requires exactly one from-asset Asset`);
316
317
  }
317
318
  }
319
+ /** The Asset ids one entity reaches, in row order. */
320
+ function assetIdsOf(rows, entityId) {
321
+ const assets = new Set(rows.entities.filter((row) => row.entityKind === "asset").map((row) => String(row.entityId)));
322
+ return rows.relations.filter((relation) => relation.relationKind === "from-asset").map((relation) => String(relation.endpoint0EntityId) === entityId ? String(relation.endpoint1EntityId) : String(relation.endpoint1EntityId) === entityId ? String(relation.endpoint0EntityId) : void 0).filter((id) => id !== void 0 && assets.has(id));
323
+ }
318
324
  /**
319
325
  * Commit-transition policy shared by the editor, the HTTP commit boundary, the
320
326
  * host tool, and the entity stores. It compares the authoritative before and
321
- * after rows of one CAS commit and enforces the single-identity write rules
322
- * on the complete after state, with no exemption for historical graphs:
327
+ * after rows of one CAS commit and enforces the resource rules on the complete
328
+ * after state, with no exemption for historical graphs:
323
329
  *
324
- * 1. every Image/Video/Audio/Voice Entity carries its own Asset `external`
325
- * identity;
326
- * 2. no `physical-asset` Relation may bind a media variant;
327
- * 3. a self-carried media identity is immutable — it cannot be rewritten to
328
- * another asset id.
330
+ * 1. every media Entity reaches exactly one Asset through `from-asset`;
331
+ * 2. no entity other than an Asset carries a locator;
332
+ * 3. an Asset's locator is immutable — pointing it at other bytes would
333
+ * silently rewrite every entity already made from it.
329
334
  *
330
- * Dual-entity media graphs are rejected instead of being migrated or merged
331
- * implicitly. Deleting media is explicit and allowed.
335
+ * Deleting media is explicit and allowed.
332
336
  */
333
337
  function assertMediaAssetWritePolicy(before, after) {
334
338
  const beforeEntities = new Map(before.entities.map((row) => [String(row.entityId), row]));
335
- const afterKinds = new Map(after.entities.map((row) => [String(row.entityId), row.entityKind]));
339
+ assertCanonicalEditorResources(after);
336
340
  for (const row of after.entities) {
337
- if (!isMediaAssetVariantKind(row.entityKind)) continue;
338
- if (memotaLocator(row.payload.external) === void 0) throw new EntityTimelineProjectionError(`Media Entity ${row.entityId} must carry its own Asset external identity`);
339
- }
340
- for (const relation of after.relations) {
341
- if (relation.relationKind !== "physical-asset") continue;
342
- const mediaEndpoint = [relation.endpoint0EntityId, relation.endpoint1EntityId].find((endpoint) => isMediaAssetVariantKind(afterKinds.get(String(endpoint)) ?? ""));
343
- if (mediaEndpoint !== void 0) throw new EntityTimelineProjectionError(`Cannot bind media Entity ${mediaEndpoint} through physical-asset Relation ${relation.relationId}; media owns its Asset identity directly`);
344
- }
345
- for (const row of after.entities) {
346
- if (!isMediaAssetVariantKind(row.entityKind)) continue;
347
- const priorExternal = memotaLocator(beforeEntities.get(String(row.entityId))?.payload.external);
348
- if (priorExternal === void 0) continue;
349
- const nextExternal = memotaLocator(row.payload.external);
350
- if (nextExternal !== void 0 && (nextExternal.system !== priorExternal.system || nextExternal.key !== priorExternal.key)) throw new EntityTimelineProjectionError(`Media Entity ${row.entityId} cannot rewrite its Asset external identity; create a new Entity instead`);
341
+ if (row.entityKind !== "asset") continue;
342
+ const prior = memotaLocator(beforeEntities.get(String(row.entityId))?.payload);
343
+ if (prior === void 0) continue;
344
+ const next = memotaLocator(row.payload);
345
+ if (next !== void 0 && (next.system !== prior.system || next.key !== prior.key)) throw new EntityTimelineProjectionError(`Asset ${row.entityId} cannot be pointed at other bytes; create a new Asset instead`);
351
346
  }
352
347
  }
353
348
  function memotaLocator(value) {
@@ -362,23 +357,14 @@ function memotaLocator(value) {
362
357
  * explicit project attachment, multiple candidates are ambiguous. Existing
363
358
  * content IDs are reused here; Loro editing advances their immutable versions.
364
359
  */
365
- function ensureDocumentAudioScript(entities, idFactory, selectedEntityId) {
360
+ function ensureDocumentAudioScript(entities, selectedEntityId) {
366
361
  if (selectedEntityId != null) {
367
362
  if (!entities.some((row) => row.entityKind === "audio-script" && row.entityId === selectedEntityId)) throw new EntityTimelineProjectionError(`Selected AudioScript ${selectedEntityId} does not exist`);
368
363
  return selectedEntityId;
369
364
  }
370
365
  const scripts = entities.filter((row) => row.entityKind === "audio-script");
371
366
  if (scripts.length > 1) throw new EntityTimelineProjectionError("Select an AudioScript for the editor panel when multiple scripts exist");
372
- const existing = scripts[0];
373
- if (existing !== void 0) return existing.entityId;
374
- const id = createEntityId(idFactory("entity"));
375
- if (entities.some((row) => row.entityId === id)) throw new Error(`Duplicate entity id ${id}`);
376
- entities.push({
377
- entityId: id,
378
- entityKind: "audio-script",
379
- payload: { segments: [] }
380
- });
381
- return id;
367
+ return scripts[0]?.entityId ?? null;
382
368
  }
383
369
  /** Add only missing editor structure; existing identities and owned settings are never replaced. */
384
370
  function ensureEditorFoundation(initial, idFactory = (prefix) => `${prefix}_${globalThis.crypto.randomUUID()}`, timelinePayload = {}, selectedAudioScriptEntityId) {
@@ -432,7 +418,7 @@ function ensureEditorFoundation(initial, idFactory = (prefix) => `${prefix}_${gl
432
418
  }
433
419
  trackEntityIds[role] = track.entityId;
434
420
  }
435
- const audioScriptEntityId = ensureDocumentAudioScript(entities, idFactory, selectedAudioScriptEntityId);
421
+ const audioScriptEntityId = ensureDocumentAudioScript(entities, selectedAudioScriptEntityId);
436
422
  return {
437
423
  rows: {
438
424
  entities,
@@ -500,15 +486,16 @@ function parseWireState(value, expectedDocId) {
500
486
  if (value.doc_id !== expectedDocId) throw new Error(`Entity-state response doc_id mismatch: expected "${expectedDocId}"`);
501
487
  const rows = parseWireRows(value.rows);
502
488
  decodeEntityRelationRows(rows, numericMarkerValidation$1);
503
- if (typeof value.audio_script_entity_id !== "string") throw new Error("Document AudioScript is not initialized");
489
+ const attached = value.audio_script_entity_id;
490
+ if (attached !== null && typeof attached !== "string") throw new Error("Invalid document AudioScript identity");
504
491
  if (typeof value.loro_snapshot !== "string") throw new Error("Missing causal Loro snapshot");
505
492
  const state = {
506
493
  revision: value.revision,
507
494
  loroSnapshot: value.loro_snapshot,
508
495
  rows,
509
- audioScriptEntityId: value.audio_script_entity_id
496
+ audioScriptEntityId: attached
510
497
  };
511
- readDocumentAudioScript(state);
498
+ if (attached !== null) readDocumentAudioScript(state);
512
499
  return state;
513
500
  }
514
501
  function parseWireRows(value) {
@@ -571,12 +558,10 @@ const numericCoordinates = { compareMarkerPoints: (_marker, _range, left, right)
571
558
  * Import at the editor boundary, not at asset generation time. Existing
572
559
  * provenance is retained.
573
560
  *
574
- * A media variant is a logical facet of its Asset and shares one entity
575
- * identity: the import mints exactly one entity of the media kind that carries
576
- * the external locator itself, and deduplicates only against existing variants
577
- * that already carry the same `(system, key)`. Legacy dual-entity graphs
578
- * (`asset` row plus a `physical-asset` binding) are not reused and are
579
- * rejected by the canonical resource policy instead of being migrated.
561
+ * The bytes are named once, on an Asset entity, and the media entity reaches
562
+ * them through a `from-asset` Relation. Re-importing the same `(system, key)`
563
+ * reuses that Asset and the media already made from it, so one stored resource
564
+ * is never copied into several rows.
580
565
  */
581
566
  function importMediaAsset(initial, fact, idFactory = (prefix) => `${prefix}_${globalThis.crypto.randomUUID()}`) {
582
567
  if (!fact.assetId || fact.assetId.trim() !== fact.assetId) throw new Error("Expected an asset id");
@@ -584,60 +569,60 @@ function importMediaAsset(initial, fact, idFactory = (prefix) => `${prefix}_${gl
584
569
  "image",
585
570
  "video",
586
571
  "audio",
587
- "voice"
572
+ "voiceover"
588
573
  ].includes(fact.kind)) throw new Error("Unsupported media kind");
589
574
  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`);
575
+ if ((fact.kind === "audio" || fact.kind === "voiceover") && fact.storageKey.trim() === "") throw new Error(`${fact.kind} ${fact.assetId} requires its physical storage key`);
591
576
  decodeEntityRelationRows(initial, numericCoordinates);
592
577
  assertCanonicalEditorResources(initial);
593
578
  const entities = structuredClone([...initial.entities]);
594
579
  const relations = structuredClone([...initial.relations]);
595
- const system = fact.kind === "voice" ? "memota-speech" : "memota";
596
- const existing = entities.filter((row) => {
597
- const external = row.payload.external;
598
- return isMediaAssetVariantKind(row.entityKind) && isJsonObject(external) && external.system === system && external.key === fact.assetId;
599
- });
600
- if (existing.length > 1) throw new Error(`Duplicate media identities for ${fact.assetId}; reuse ${existing[0].entityId}`);
601
- if (existing[0] !== void 0) {
602
- const variant = requireMatchingVariant(existing[0], fact);
603
- replaceEntity$1(entities, variant.entityId, variant);
580
+ const system = fact.kind === "voiceover" ? "memota-speech" : "memota";
581
+ const entityKind = fact.kind;
582
+ const timbre = fact.kind === "voiceover" ? fact.voice : void 0;
583
+ const durationMs = fact.kind === "image" ? null : fact.durationMs;
584
+ const asset = ensureAsset(entities, {
585
+ system,
586
+ key: fact.assetId,
587
+ storageKey: fact.storageKey
588
+ }, idFactory);
589
+ const madeFromAsset = entities.filter((row) => row.entityId !== asset.entityId && assetIdOf(relations, row.entityId) === asset.entityId);
590
+ if (madeFromAsset.length > 1) throw new Error(`Duplicate media identities for ${fact.assetId}; reuse ${madeFromAsset[0].entityId}`);
591
+ const existing = madeFromAsset[0];
592
+ if (existing !== void 0) {
593
+ if (existing.entityKind !== entityKind || existing.payload.durationMs !== durationMs) throw new Error(`Media facts conflict with existing identity for ${fact.assetId}`);
594
+ const voiceEntityId = bindVoiceTimbre(entities, relations, existing.entityId, timbre, idFactory);
604
595
  return {
605
596
  rows: {
606
597
  entities,
607
598
  relations
608
599
  },
609
- contentEntityId: variant.entityId
600
+ contentEntityId: existing.entityId,
601
+ assetEntityId: asset.entityId,
602
+ ...voiceEntityId
610
603
  };
611
604
  }
612
605
  const entityId = createEntityId(idFactory("entity"));
613
606
  if (entities.some((row) => row.entityId === entityId)) throw new Error(`Duplicate entity id ${entityId}`);
614
- const variant = {
607
+ entities.push({
615
608
  entityId,
616
- entityKind: fact.kind,
617
- payload: {
618
- extent: fact.kind === "image" ? {
619
- kind: "unbounded",
620
- start: 0
621
- } : {
622
- kind: "bounded",
623
- start: 0,
624
- end: fact.durationMs
625
- },
626
- sampling: fact.kind === "image" ? "constant" : "native",
627
- coordinateSpace: "milliseconds",
628
- external: {
629
- system,
630
- key: fact.assetId
631
- },
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
- } } : {}
638
- }
639
- };
640
- entities.push(variant);
609
+ entityKind,
610
+ payload: { durationMs }
611
+ });
612
+ relations.push(assetLink(entityId, asset.entityId, idFactory));
613
+ if (fact.kind === "voiceover" && fact.phoneticScriptEntityId !== void 0) {
614
+ const phonetic = entities.find((row) => String(row.entityId) === fact.phoneticScriptEntityId);
615
+ if (phonetic?.entityKind !== "phonetic-script") throw new Error(`Voiceover ${fact.assetId} requires the PhoneticScript it was spoken from`);
616
+ relations.push({
617
+ relationId: createRelationId(idFactory("relation")),
618
+ relationKind: "phonetic-script-render",
619
+ endpoint0EntityId: entityId,
620
+ endpoint1EntityId: phonetic.entityId,
621
+ metadata: {},
622
+ trace: {}
623
+ });
624
+ }
625
+ const voiceEntityId = bindVoiceTimbre(entities, relations, entityId, timbre, idFactory);
641
626
  const rows = {
642
627
  entities,
643
628
  relations
@@ -646,33 +631,102 @@ function importMediaAsset(initial, fact, idFactory = (prefix) => `${prefix}_${gl
646
631
  assertCanonicalEditorResources(rows);
647
632
  return {
648
633
  rows,
649
- contentEntityId: entityId
634
+ contentEntityId: entityId,
635
+ assetEntityId: asset.entityId,
636
+ ...voiceEntityId
650
637
  };
651
638
  }
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,
639
+ /** One Asset per `(system, key)`: the bytes are named in exactly one row. */
640
+ function ensureAsset(entities, locator, idFactory) {
641
+ const found = entities.find((row) => row.entityKind === "asset" && row.payload.system === locator.system && row.payload.key === locator.key);
642
+ if (found !== void 0) {
643
+ if (locator.storageKey === void 0 || found.payload.storageKey === locator.storageKey) return found;
644
+ if (found.payload.storageKey !== void 0) throw new Error(`Media facts conflict with existing storage key for ${locator.key}`);
645
+ const filled = {
646
+ ...found,
659
647
  payload: {
660
- ...row.payload,
661
- storageKey: fact.storageKey
648
+ ...found.payload,
649
+ storageKey: locator.storageKey
662
650
  }
663
651
  };
652
+ replaceEntity$1(entities, found.entityId, filled);
653
+ return filled;
664
654
  }
665
- return row;
655
+ const asset = {
656
+ entityId: createEntityId(idFactory("entity")),
657
+ entityKind: "asset",
658
+ payload: {
659
+ system: locator.system,
660
+ key: locator.key,
661
+ ...locator.storageKey === void 0 ? {} : { storageKey: locator.storageKey }
662
+ }
663
+ };
664
+ entities.push(asset);
665
+ return asset;
666
+ }
667
+ function assetLink(entityId, assetEntityId, idFactory) {
668
+ return {
669
+ relationId: createRelationId(idFactory("relation")),
670
+ relationKind: "from-asset",
671
+ endpoint0EntityId: createEntityId(entityId),
672
+ endpoint1EntityId: createEntityId(assetEntityId),
673
+ metadata: {},
674
+ trace: {}
675
+ };
676
+ }
677
+ function assetIdOf(relations, entityId) {
678
+ const link = relations.find((relation) => relation.relationKind === "from-asset" && (relation.endpoint0EntityId === entityId || relation.endpoint1EntityId === entityId));
679
+ if (link === void 0) return void 0;
680
+ return String(link.endpoint0EntityId === entityId ? link.endpoint1EntityId : link.endpoint0EntityId);
681
+ }
682
+ /**
683
+ * Attach the timbre a synthesized voiceover was rendered with. One Voice
684
+ * identity is shared by every take naming the same library entry, so an
685
+ * existing row is reused rather than duplicated. Recorded media declares no
686
+ * timbre and keeps no link.
687
+ */
688
+ function bindVoiceTimbre(entities, relations, audioEntityId, descriptor, idFactory) {
689
+ const linked = relations.filter((relation) => relation.relationKind === "voice-timbre" && (relation.endpoint0EntityId === audioEntityId || relation.endpoint1EntityId === audioEntityId));
690
+ if (linked.length > 1) throw new Error(`Voiceover ${audioEntityId} has multiple Voice timbre relations`);
691
+ if (descriptor === void 0) {
692
+ if (linked.length > 0) throw new Error(`Recorded media ${audioEntityId} cannot keep a synthesized Voice timbre`);
693
+ return {};
694
+ }
695
+ const voice = entities.find((row) => row.entityKind === "voice" && sameVoiceDescriptor(row.payload.voice, descriptor)) ?? appendVoiceIdentity(entities, descriptor, idFactory);
696
+ const existing = linked[0];
697
+ if (existing === void 0) relations.push({
698
+ relationId: createRelationId(idFactory("relation")),
699
+ relationKind: "voice-timbre",
700
+ endpoint0EntityId: createEntityId(audioEntityId),
701
+ endpoint1EntityId: createEntityId(voice.entityId),
702
+ metadata: {},
703
+ trace: {}
704
+ });
705
+ else if (existing.endpoint0EntityId !== audioEntityId || existing.endpoint1EntityId !== voice.entityId) throw new Error(`Voiceover ${audioEntityId} already carries a different Voice timbre`);
706
+ return { voiceEntityId: voice.entityId };
707
+ }
708
+ function appendVoiceIdentity(entities, descriptor, idFactory) {
709
+ const voice = {
710
+ entityId: createEntityId(idFactory("entity")),
711
+ entityKind: "voice",
712
+ payload: { voice: {
713
+ system: descriptor.system,
714
+ key: descriptor.key,
715
+ ...descriptor.name === void 0 ? {} : { name: descriptor.name }
716
+ } }
717
+ };
718
+ entities.push(voice);
719
+ return voice;
720
+ }
721
+ /** Two takes share a Voice identity only when they name the same library entry. */
722
+ function sameVoiceDescriptor(stored, descriptor) {
723
+ return isJsonObject(stored) && stored.system === descriptor.system && stored.key === descriptor.key && stored.name === descriptor.name;
666
724
  }
667
725
  function replaceEntity$1(entities, entityId, replacement) {
668
726
  const index = entities.findIndex((row) => row.entityId === entityId);
669
727
  if (index < 0) throw new Error(`Entity id ${entityId} does not exist`);
670
728
  entities[index] = replacement;
671
729
  }
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
730
  //#endregion
677
731
  //#region src/entity-editor/entity-timeline-editor.ts
678
732
  /**
@@ -1208,20 +1262,20 @@ var EntityTimelineEditor = class {
1208
1262
  hostClipEntityId: input.hostClipEntityId,
1209
1263
  anchorOffset: input.anchorOffset
1210
1264
  };
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");
1265
+ 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
1266
  requireVolume(input.volume);
1213
1267
  const imported = importMediaAsset(draft, input.media, this.idFactory);
1214
1268
  replaceRows(draft, imported.rows);
1215
- const voice = requireEntityKind(draft, imported.contentEntityId, "voice");
1269
+ const voiceover = requireEntityKind(draft, imported.contentEntityId, "voiceover");
1216
1270
  const phonetic = this.requireComposedPhoneticScript(draft, input.phoneticScriptEntityId);
1217
- this.ensurePhoneticRender(draft, voice.entityId, phonetic.entityId);
1271
+ this.ensurePhoneticRender(draft, voiceover.entityId, phonetic.entityId);
1218
1272
  const speechTrack = this.ensureRoleTrack(draft, input.timelineEntityId, "speech");
1219
1273
  const existing = draft.entities.find((row) => row.entityId === input.voiceoverClipEntityId);
1220
1274
  let voiceoverClipEntityId;
1221
1275
  if (existing === void 0) voiceoverClipEntityId = this.insertPlacedClipIntoDraft(draft, {
1222
1276
  clipEntityId: input.voiceoverClipEntityId,
1223
1277
  trackEntityId: speechTrack.entityId,
1224
- contentEntityId: voice.entityId,
1278
+ contentEntityId: voiceover.entityId,
1225
1279
  sourceRange: {
1226
1280
  start: 0,
1227
1281
  end: input.media.durationMs
@@ -1245,7 +1299,7 @@ var EntityTimelineEditor = class {
1245
1299
  });
1246
1300
  this.relinkRelation(draft, topology.markerContentRelation, {
1247
1301
  endpoint0EntityId: topology.marker.entityId,
1248
- endpoint1EntityId: voice.entityId
1302
+ endpoint1EntityId: voiceover.entityId
1249
1303
  });
1250
1304
  this.applyClipPlacement(draft, topology.clip.entityId, placement);
1251
1305
  const placed = requireClipTopology(draft, topology.clip.entityId);
@@ -1264,11 +1318,12 @@ var EntityTimelineEditor = class {
1264
1318
  }
1265
1319
  });
1266
1320
  }
1267
- this.reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, voice.entityId, phonetic.entityId, input.captions);
1321
+ this.reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, voiceover.entityId, phonetic.entityId, input.captions);
1268
1322
  this.resolveOneVoiceoverOverlap(draft);
1269
1323
  return {
1270
1324
  voiceoverClipEntityId,
1271
- voiceEntityId: voice.entityId,
1325
+ voiceoverAudioEntityId: voiceover.entityId,
1326
+ ...imported.voiceEntityId === void 0 ? {} : { voiceEntityId: imported.voiceEntityId },
1272
1327
  phoneticScriptEntityId: phonetic.entityId,
1273
1328
  audioScriptEntityId: requireComposedAudioScript(draft, phonetic.entityId),
1274
1329
  captionClipEntityIds: input.captions.map((caption) => caption.captionClipEntityId)
@@ -1383,13 +1438,7 @@ var EntityTimelineEditor = class {
1383
1438
  entityId: input.captionEntityId === void 0 ? this.mintEntityId(draft) : requireAvailableEntityId(draft, input.captionEntityId),
1384
1439
  entityKind: "caption",
1385
1440
  payload: {
1386
- extent: {
1387
- kind: "bounded",
1388
- start: 0,
1389
- end: durationMs
1390
- },
1391
- sampling: "native",
1392
- coordinateSpace: "milliseconds",
1441
+ durationMs,
1393
1442
  baseEntityIds: captionBaseIds(input.baseEntityIds),
1394
1443
  selection,
1395
1444
  ...input.style === void 0 ? {} : { style: cloneJsonObject(input.style, "Caption style") }
@@ -1474,23 +1523,23 @@ var EntityTimelineEditor = class {
1474
1523
  draft.relations.push(this.createRelation(draft, "timeline-track", timeline.entityId, track.entityId));
1475
1524
  return track;
1476
1525
  }
1477
- /** The existing pronunciation variant a Voice consumes; composition is required. */
1526
+ /** The existing pronunciation variant a voiceover consumes; composition is required. */
1478
1527
  requireComposedPhoneticScript(draft, phoneticScriptEntityId) {
1479
1528
  const phonetic = requireEntityKind(draft, phoneticScriptEntityId, "phonetic-script");
1480
1529
  requireComposedAudioScript(draft, phonetic.entityId);
1481
1530
  return phonetic;
1482
1531
  }
1483
1532
  /** 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`);
1533
+ ensurePhoneticRender(draft, voiceoverAudioEntityId, phoneticScriptEntityId) {
1534
+ const renders = draft.relations.filter((relation) => relation.relationKind === "phonetic-script-render" && (relation.endpoint0EntityId === voiceoverAudioEntityId || relation.endpoint1EntityId === voiceoverAudioEntityId));
1535
+ if (renders.length > 1) throw new Error(`Voiceover "${voiceoverAudioEntityId}" has multiple PhoneticScript render relations`);
1487
1536
  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`);
1537
+ if (otherEndpoint(renders[0], voiceoverAudioEntityId) === phoneticScriptEntityId) return;
1538
+ throw new Error(`Voiceover "${voiceoverAudioEntityId}" already has a different PhoneticScript synthesis input`);
1490
1539
  }
1491
- draft.relations.push(this.createRelation(draft, "phonetic-script-render", voiceEntityId, phoneticScriptEntityId));
1540
+ draft.relations.push(this.createRelation(draft, "phonetic-script-render", voiceoverAudioEntityId, phoneticScriptEntityId));
1492
1541
  }
1493
- reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, voiceEntityId, phoneticScriptEntityId, captions) {
1542
+ reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, voiceoverAudioEntityId, phoneticScriptEntityId, captions) {
1494
1543
  const audioScriptEntityId = requireComposedAudioScript(draft, phoneticScriptEntityId);
1495
1544
  const requested = new Set(captions.map((caption) => caption.captionClipEntityId));
1496
1545
  if (requested.size !== captions.length) throw new Error("Caption Clip ids must be unique within a voiceover take");
@@ -1507,13 +1556,7 @@ var EntityTimelineEditor = class {
1507
1556
  entityId: this.mintEntityId(draft),
1508
1557
  entityKind: "caption",
1509
1558
  payload: {
1510
- extent: {
1511
- kind: "bounded",
1512
- start: 0,
1513
- end: durationMs
1514
- },
1515
- sampling: "native",
1516
- coordinateSpace: "milliseconds",
1559
+ durationMs,
1517
1560
  baseEntityIds,
1518
1561
  selection,
1519
1562
  ...caption.style === void 0 ? {} : { style: cloneJsonObject(caption.style, "Caption style") }
@@ -1521,7 +1564,7 @@ var EntityTimelineEditor = class {
1521
1564
  };
1522
1565
  draft.entities.push(content);
1523
1566
  assertCaptionScript(draft, content.entityId, audioScriptEntityId);
1524
- draft.relations.push(this.createRelation(draft, "caption-alignment", content.entityId, voiceEntityId, { alignment: {
1567
+ draft.relations.push(this.createRelation(draft, "caption-alignment", content.entityId, voiceoverAudioEntityId, { alignment: {
1525
1568
  start: startMs,
1526
1569
  end: startMs + durationMs
1527
1570
  } }));
@@ -1578,7 +1621,7 @@ var EntityTimelineEditor = class {
1578
1621
  }
1579
1622
  }
1580
1623
  });
1581
- this.replaceCaptionAlignment(draft, content.entityId, voiceEntityId, startMs, durationMs);
1624
+ this.replaceCaptionAlignment(draft, content.entityId, voiceoverAudioEntityId, startMs, durationMs);
1582
1625
  }
1583
1626
  }
1584
1627
  ensureRoleTrackForClip(draft, clipEntityId, role) {
@@ -1587,10 +1630,10 @@ var EntityTimelineEditor = class {
1587
1630
  const timeline = requireEntityKind(draft, otherEndpoint(requireOneRelation(draft, track.entityId, "timeline-track"), track.entityId), "timeline");
1588
1631
  return this.ensureRoleTrack(draft, timeline.entityId, role);
1589
1632
  }
1590
- replaceCaptionAlignment(draft, captionEntityId, voiceEntityId, startMs, durationMs) {
1633
+ replaceCaptionAlignment(draft, captionEntityId, voiceoverAudioEntityId, startMs, durationMs) {
1591
1634
  const removed = new Set(incidentRelations(draft, captionEntityId).filter((relation) => relation.relationKind === "caption-alignment").map((relation) => relation.relationId));
1592
1635
  draft.relations = draft.relations.filter((relation) => !removed.has(relation.relationId));
1593
- draft.relations.push(this.createRelation(draft, "caption-alignment", captionEntityId, voiceEntityId, { alignment: {
1636
+ draft.relations.push(this.createRelation(draft, "caption-alignment", captionEntityId, voiceoverAudioEntityId, { alignment: {
1594
1637
  start: startMs,
1595
1638
  end: startMs + durationMs
1596
1639
  } }));
@@ -1999,11 +2042,19 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
1999
2042
  }, idFactory = (prefix) => `${prefix}_${globalThis.crypto.randomUUID()}`) {
2000
2043
  assertSupportedLegacy(legacy);
2001
2044
  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");
2045
+ if (hasResolvedPlacement(legacy)) fail("An entity projection cannot be migrated as legacy state");
2003
2046
  const normalizedMainRanges = overlapNormalizedMainRanges(legacy);
2004
2047
  let entities = structuredClone([...initial.entities]);
2005
2048
  let relations = structuredClone([...initial.relations]);
2006
- const documentAudioScriptEntityId = ensureDocumentAudioScript(entities, idFactory);
2049
+ const documentAudioScriptEntityId = ensureDocumentAudioScript(entities) ?? (() => {
2050
+ const id = createEntityId(idFactory("entity"));
2051
+ entities.push({
2052
+ entityId: id,
2053
+ entityKind: "audio-script",
2054
+ payload: { segments: [] }
2055
+ });
2056
+ return String(id);
2057
+ })();
2007
2058
  const addEntity = (kind, payload, existingId) => {
2008
2059
  const entityId = createEntityId(existingId ?? idFactory("entity"));
2009
2060
  if (entities.some((row) => row.entityId === entityId)) fail(`Migration entity identity collision: ${entityId}`);
@@ -2053,7 +2104,7 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
2053
2104
  relations
2054
2105
  }, {
2055
2106
  assetId: speech.origin_speech_id,
2056
- kind: "voice",
2107
+ kind: "voiceover",
2057
2108
  durationMs: speech.media_duration_ms,
2058
2109
  storageKey: speech.audio_storage_key,
2059
2110
  voice: {
@@ -2141,8 +2192,8 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
2141
2192
  } else if (part.caption !== void 0) {
2142
2193
  const caption = part.caption;
2143
2194
  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);
2195
+ const voiceover = contentByPartId.get(caption.speech_part_id);
2196
+ const render = relations.find((relation) => relation.relationKind === "phonetic-script-render" && relation.endpoint0EntityId === voiceover);
2146
2197
  const phonetic = entities.find((entity) => entity.entityId === render?.endpoint1EntityId);
2147
2198
  const script = phonetic === void 0 ? void 0 : composedAudioScript(phonetic, entities);
2148
2199
  if (script === void 0) fail("Legacy Caption has no composed AudioScript");
@@ -2158,18 +2209,12 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
2158
2209
  } }
2159
2210
  };
2160
2211
  content = addEntity("caption", {
2161
- extent: {
2162
- kind: "bounded",
2163
- start: 0,
2164
- end: duration
2165
- },
2166
- sampling: "native",
2167
- coordinateSpace: "milliseconds",
2212
+ durationMs: duration,
2168
2213
  baseEntityIds: [script.entityId],
2169
2214
  selection,
2170
2215
  ...caption.style === void 0 ? {} : { style: toEntityCaptionStyle(caption.style) }
2171
2216
  });
2172
- link("caption-alignment", content, voice, { alignment: {
2217
+ link("caption-alignment", content, voiceover, { alignment: {
2173
2218
  start: caption.start_ms,
2174
2219
  end: caption.start_ms + duration
2175
2220
  } });
@@ -2225,27 +2270,23 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
2225
2270
  entities,
2226
2271
  relations
2227
2272
  };
2228
- assertEntityProjectionPreservesLegacy(legacy, projectEntityTimeline(rows, legacy.meta), rows);
2273
+ assertEntityProjectionPreservesLegacy(legacy, projectEntityTimeline(rows), rows);
2229
2274
  return rows;
2230
2275
  }
2231
2276
  /** Server gate: a cutover may change representation, never the existing render facts. */
2232
2277
  function assertEntityProjectionPreservesLegacy(legacy, projection, rows) {
2233
2278
  assertSupportedLegacy(legacy);
2234
- if (projection.meta.schema_version !== "video-document/entity-projection-v1") fail("Expected an entity projection for migration");
2279
+ if (!hasResolvedPlacement(projection)) fail("Expected an entity projection for migration");
2235
2280
  const normalizedMainRanges = overlapNormalizedMainRanges(legacy);
2236
- const compatibilityFacts = (document) => {
2237
- const { version: _version, ...view } = fromVideoDocument(document);
2281
+ const compatibilityFacts = (document, placementResolved) => {
2282
+ const view = fromVideoDocument(document, { placementResolved });
2238
2283
  for (const part of Object.values(view.part_library ?? {})) {
2239
2284
  const speech = part.speech;
2240
2285
  if (speech?.phoneme_script === null) delete speech.phoneme_script;
2241
2286
  }
2242
- const { schema_version: _schemaVersion, version: _metaVersion, ...meta } = document.meta;
2243
- return {
2244
- meta,
2245
- view
2246
- };
2287
+ return { view };
2247
2288
  };
2248
- if (canonical(compatibilityFacts(legacy)) !== canonical(compatibilityFacts(projection))) fail("Entity migration must preserve all existing timeline facts; edit after cutover");
2289
+ if (canonical(compatibilityFacts(legacy, false)) !== canonical(compatibilityFacts(projection, true))) fail("Entity migration must preserve all existing timeline facts; edit after cutover");
2249
2290
  const trackOrders = [];
2250
2291
  for (const oldTrack of legacy.tracks ?? []) {
2251
2292
  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 +2553,21 @@ function requirePart(document, partId) {
2512
2553
  if (part === void 0) fail(`Placed Clip ${partId} has no part payload`);
2513
2554
  return part;
2514
2555
  }
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`);
2556
+ function ensureSpeechPhoneticChain(voiceoverAudioEntityId, audioScriptText, phonemeScript, entities, relations, addEntity, link, documentAudioScriptEntityId) {
2557
+ const renders = relations.filter((relation) => relation.relationKind === "phonetic-script-render" && relation.endpoint0EntityId === voiceoverAudioEntityId);
2558
+ if (renders.length > 1) fail(`Voiceover ${voiceoverAudioEntityId} has ambiguous PhoneticScript provenance`);
2518
2559
  if (renders.length === 1) {
2519
2560
  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`);
2561
+ if (phonetic === void 0) fail(`Voiceover ${voiceoverAudioEntityId} has a dangling PhoneticScript render`);
2521
2562
  const script = composedAudioScript(phonetic, entities);
2522
- if (script === void 0 || scriptText(script) !== audioScriptText) fail(`Voice ${voiceEntityId} conflicts with the legacy Speech script`);
2563
+ if (script === void 0 || scriptText(script) !== audioScriptText) fail(`Voiceover ${voiceoverAudioEntityId} conflicts with the legacy Speech script`);
2523
2564
  return phonetic.entityId;
2524
2565
  }
2525
2566
  const script = entities.find((entity) => entity.entityId === documentAudioScriptEntityId);
2526
2567
  if (script === void 0 || script.entityKind !== "audio-script") fail("Migration must carry the fixed document AudioScript");
2527
2568
  const segments = script.payload.segments;
2528
2569
  if (Array.isArray(segments) && segments.length > 0) {
2529
- if (scriptText(script) !== audioScriptText) fail(`Voice ${voiceEntityId} conflicts with the project AudioScript text`);
2570
+ if (scriptText(script) !== audioScriptText) fail(`Voiceover ${voiceoverAudioEntityId} conflicts with the project AudioScript text`);
2530
2571
  } else {
2531
2572
  const scriptIndex = entities.findIndex((row) => row.entityId === script.entityId);
2532
2573
  entities[scriptIndex] = {
@@ -2541,7 +2582,7 @@ function ensureSpeechPhoneticChain(voiceEntityId, audioScriptText, phonemeScript
2541
2582
  baseEntityIds: [script.entityId],
2542
2583
  ...phonemeScript === void 0 ? {} : { phonemeScript }
2543
2584
  });
2544
- link("phonetic-script-render", voiceEntityId, phonetic);
2585
+ link("phonetic-script-render", voiceoverAudioEntityId, phonetic);
2545
2586
  return phonetic;
2546
2587
  }
2547
2588
  function composedAudioScript(phonetic, entities) {
@@ -2659,13 +2700,7 @@ function prepareCaptionContent(rows, input) {
2659
2700
  payload: {
2660
2701
  ...previous?.payload,
2661
2702
  baseEntityIds,
2662
- extent: previous?.payload.extent ?? {
2663
- kind: "bounded",
2664
- start: 0,
2665
- end: input.durationMs
2666
- },
2667
- sampling: previous?.payload.sampling ?? "native",
2668
- coordinateSpace: previous?.payload.coordinateSpace ?? "milliseconds"
2703
+ durationMs: previous?.payload.durationMs ?? input.durationMs
2669
2704
  }
2670
2705
  };
2671
2706
  const graph = {
@@ -3648,7 +3683,7 @@ function writeSpeechAssets(draft, assets) {
3648
3683
  if (caption == null) continue;
3649
3684
  const priorCaption = draft.part_library?.[captionId]?.caption;
3650
3685
  setPart(draft, captionId, { caption: {
3651
- ...priorCaption,
3686
+ ...priorCaption ?? { style: {} },
3652
3687
  id: captionId,
3653
3688
  kind: "caption",
3654
3689
  initial_duration_ms: caption.duration_ms,
@@ -4057,6 +4092,10 @@ var ManualSyncDoc = class ManualSyncDoc {
4057
4092
  client;
4058
4093
  doc;
4059
4094
  adapter;
4095
+ /** Whether this document's timeline is owned by the Entity graph. */
4096
+ isEntityDocument() {
4097
+ return this.adapter.isEntityDocument();
4098
+ }
4060
4099
  editor;
4061
4100
  /**
4062
4101
  * The version the server is known to hold. Starts empty (nothing confirmed)
@@ -4075,17 +4114,24 @@ var ManualSyncDoc = class ManualSyncDoc {
4075
4114
  * Fetches the snapshot up front rather than starting empty and converging: the
4076
4115
  * agent's first act is to read the document, so there is no useful state before
4077
4116
  * 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.
4117
+ * not exist or whose first snapshot is incomplete or invalid.
4080
4118
  */
4081
4119
  static async open(options) {
4082
- const doc = new LoroDoc();
4083
- if (options.peerId != null) doc.setPeerId(options.peerId);
4084
4120
  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;
4121
+ const doc = new LoroDoc();
4122
+ let manualSyncDoc;
4123
+ try {
4124
+ if (options.peerId != null) doc.setPeerId(options.peerId);
4125
+ if (doc.import(base64ToBytes(response.snapshot)).pending != null) throw new Error("Initial mengine snapshot has missing dependencies");
4126
+ manualSyncDoc = new ManualSyncDoc(options.client, doc);
4127
+ assertValidVideoDocument(manualSyncDoc.snapshot());
4128
+ manualSyncDoc.watermark = manualSyncDoc.serverVVFrom(response.version.server_vv) ?? doc.oplogVersion();
4129
+ return manualSyncDoc;
4130
+ } catch (error) {
4131
+ manualSyncDoc?.adapter.dispose();
4132
+ doc.free();
4133
+ throw error;
4134
+ }
4089
4135
  }
4090
4136
  /** Current document read model (authoritative shape). */
4091
4137
  snapshot() {
@@ -4104,9 +4150,9 @@ var ManualSyncDoc = class ManualSyncDoc {
4104
4150
  editorPeerId() {
4105
4151
  return this.doc.peerIdStr;
4106
4152
  }
4107
- /** Current document projected to the legacy `VideoDraft` read shape. */
4108
- draft() {
4109
- return fromVideoDocument(this.adapter.snapshot());
4153
+ /** Current content projected into the compatible track and part read shape. */
4154
+ content() {
4155
+ return fromVideoDocument(this.adapter.snapshot(), { placementResolved: this.isEntityDocument() });
4110
4156
  }
4111
4157
  /**
4112
4158
  * Replay a deterministic sandbox journal into this document.
@@ -4126,9 +4172,8 @@ var ManualSyncDoc = class ManualSyncDoc {
4126
4172
  *
4127
4173
  * This pair replaces the legacy integer-version comparison that
4128
4174
  * `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.
4175
+ * externally, reload before editing". Comparing Loro version vectors covers
4176
+ * all collaborative edits without a business revision field in the document.
4132
4177
  *
4133
4178
  * Same capability, not a stronger one: like the legacy detector, this only
4134
4179
  * reports what changed between two moments the caller chose to sample.
@@ -4212,6 +4257,7 @@ var ManualSyncDoc = class ManualSyncDoc {
4212
4257
  if (serverVV != null) this.watermark = serverVV;
4213
4258
  return {
4214
4259
  kind: response.kind,
4260
+ mengineUpdateSeq: response.version?.update_seq ?? response.update_seq ?? void 0,
4215
4261
  updateSeq: response.update_seq ?? void 0,
4216
4262
  collaborated: serverVV != null && !covers(localVersion, serverVV)
4217
4263
  };
@@ -4250,22 +4296,24 @@ var ManualSyncDoc = class ManualSyncDoc {
4250
4296
  * recovers whatever the stream missed while it was down. Recovery therefore
4251
4297
  * lives in the synchronizer (keyed on the real doc version), not here.
4252
4298
  *
4253
- * Each SSE frame's updates are forwarded verbatim via `onUpdate`; this
4299
+ * Update frames are forwarded via `onUpdate`, resync hints via `onResync`; this
4254
4300
  * connection keeps no version cursor and does no catch-up of its own. Dedup is
4255
4301
  * unnecessary because `LoroDoc.import` is idempotent by OpId/VV.
4256
4302
  */
4257
4303
  var SseConnection = class {
4258
4304
  client;
4259
4305
  onUpdate;
4306
+ onResync;
4260
4307
  reconnectDelayMs;
4261
4308
  inner = void 0;
4262
4309
  event = new EventBus();
4263
4310
  streamTask = null;
4264
4311
  _status = "idle";
4265
4312
  _error;
4266
- constructor(client, onUpdate, reconnectDelayMs) {
4313
+ constructor(client, onUpdate, onResync, reconnectDelayMs) {
4267
4314
  this.client = client;
4268
4315
  this.onUpdate = onUpdate;
4316
+ this.onResync = onResync;
4269
4317
  this.reconnectDelayMs = reconnectDelayMs;
4270
4318
  }
4271
4319
  get status() {
@@ -4326,10 +4374,12 @@ var SseConnection = class {
4326
4374
  client: this.client,
4327
4375
  signal,
4328
4376
  onOpen: () => this.setStatus("connected"),
4377
+ onResync: this.onResync,
4329
4378
  onUpdate: (event) => {
4330
4379
  for (const update of event.updates) this.onUpdate(update);
4331
4380
  }
4332
4381
  });
4382
+ if (!signal.aborted) this.setStatus("connecting", /* @__PURE__ */ new Error("SSE stream closed"));
4333
4383
  } catch (error) {
4334
4384
  if (signal.aborted) break;
4335
4385
  this.setStatus("connecting", error instanceof Error ? error : new Error(String(error)));
@@ -4374,7 +4424,7 @@ var MedeoHttpDocStorage = class {
4374
4424
  this.options = options;
4375
4425
  this.client = options.client;
4376
4426
  this.docId = options.docId;
4377
- this.connection = new SseConnection(this.client, (update) => this.emitUpdate(update), options.sseReconnectDelayMs ?? 500);
4427
+ this.connection = new SseConnection(this.client, (update) => this.emitUpdate(update), () => this.events.emit("resync"), options.sseReconnectDelayMs ?? 500);
4378
4428
  }
4379
4429
  get isReadonly() {
4380
4430
  return this.options.readonlyMode ?? false;
@@ -4382,6 +4432,7 @@ var MedeoHttpDocStorage = class {
4382
4432
  async getDoc(docId) {
4383
4433
  this.assertDocId(docId);
4384
4434
  const snapshot = await this.client.fetchSnapshot();
4435
+ this.reportServerVersion(snapshot.version?.server_vv);
4385
4436
  const now = /* @__PURE__ */ new Date();
4386
4437
  return {
4387
4438
  docId,
@@ -4393,6 +4444,7 @@ var MedeoHttpDocStorage = class {
4393
4444
  async getDocDiff(docId, knownVersion) {
4394
4445
  this.assertDocId(docId);
4395
4446
  const response = await this.client.sync(knownVersion);
4447
+ this.reportServerVersion(response.server_vv);
4396
4448
  return {
4397
4449
  docId,
4398
4450
  missing: base64ToBytes(response.update),
@@ -4423,7 +4475,7 @@ var MedeoHttpDocStorage = class {
4423
4475
  if (update.data.byteLength === 0) return {};
4424
4476
  try {
4425
4477
  const response = await this.client.pushUpdate(update.data);
4426
- const serverVV = decodeServerVV(response.version?.server_vv);
4478
+ const serverVV = this.reportServerVersion(response.version?.server_vv);
4427
4479
  this.events.emit("pushOutcome", {
4428
4480
  kind: response.kind,
4429
4481
  updateSeq: response.update_seq ?? void 0,
@@ -4451,6 +4503,19 @@ var MedeoHttpDocStorage = class {
4451
4503
  subscribePushOutcome(callback) {
4452
4504
  return this.events.on("pushOutcome", callback);
4453
4505
  }
4506
+ /** Observe live hints that require VV catch-up without replacing the SSE connection. */
4507
+ subscribeResync(callback) {
4508
+ return this.events.on("resync", callback);
4509
+ }
4510
+ /** Authoritative HTTP reads and accepted pushes, never inferred from SSE bytes. */
4511
+ subscribeServerVersion(callback) {
4512
+ return this.events.on("serverVersion", callback);
4513
+ }
4514
+ reportServerVersion(encoded) {
4515
+ const version = decodeServerVV(encoded);
4516
+ if (version != null) this.events.emit("serverVersion", version);
4517
+ return version;
4518
+ }
4454
4519
  async deleteDoc(_docId) {
4455
4520
  throw new Error("MedeoHttpDocStorage does not support deleteDoc");
4456
4521
  }
@@ -4567,6 +4632,118 @@ var MemoryDocStorage = class extends BaseDocStorage {
4567
4632
  }
4568
4633
  };
4569
4634
  //#endregion
4635
+ //#region src/session/document-undo-manager.ts
4636
+ /**
4637
+ * Tracks the shared LoroDoc independently of semantic editing. Undo/redo use
4638
+ * the same mutation guard as the adapter and publish settled availability.
4639
+ */
4640
+ var DocumentUndoManager = class {
4641
+ doc;
4642
+ mutationGuard;
4643
+ onChange;
4644
+ manager;
4645
+ unsubscribe;
4646
+ disposed = false;
4647
+ notificationQueued = false;
4648
+ published = {
4649
+ canUndo: false,
4650
+ canRedo: false
4651
+ };
4652
+ activeSource = null;
4653
+ constructor(doc, mutationGuard, onChange) {
4654
+ this.doc = doc;
4655
+ this.mutationGuard = mutationGuard;
4656
+ this.onChange = onChange;
4657
+ const peer = doc.peerIdStr;
4658
+ this.manager = new UndoManager(doc, {
4659
+ mergeInterval: 0,
4660
+ maxUndoSteps: 100,
4661
+ excludeOriginPrefixes: ["mengine.bootstrap", "mengine.initialize"],
4662
+ onPush: (_isUndo, range) => {
4663
+ this.scheduleNotification();
4664
+ return {
4665
+ value: this.activeSource ?? {
4666
+ peer,
4667
+ counter: range.start
4668
+ },
4669
+ cursors: []
4670
+ };
4671
+ },
4672
+ onPop: () => this.scheduleNotification()
4673
+ });
4674
+ this.unsubscribe = doc.subscribe(() => this.scheduleNotification());
4675
+ }
4676
+ getState() {
4677
+ this.assertActive();
4678
+ return {
4679
+ canUndo: this.manager.canUndo(),
4680
+ canRedo: this.manager.canRedo()
4681
+ };
4682
+ }
4683
+ undo(options) {
4684
+ return this.applyUndoRedo("Undo", options);
4685
+ }
4686
+ redo(options) {
4687
+ return this.applyUndoRedo("Redo", options);
4688
+ }
4689
+ beginGroup() {
4690
+ this.assertActive();
4691
+ this.mutationGuard.run(() => this.manager.groupStart());
4692
+ }
4693
+ endGroup() {
4694
+ this.assertActive();
4695
+ this.mutationGuard.run(() => this.manager.groupEnd());
4696
+ this.scheduleNotification();
4697
+ }
4698
+ applyUndoRedo(kind, options) {
4699
+ this.assertActive();
4700
+ return this.mutationGuard.run(() => {
4701
+ if (kind === "Undo" ? !this.manager.canUndo() : !this.manager.canRedo()) return false;
4702
+ this.activeSource = kind === "Undo" ? this.manager.topUndoValue() : this.manager.topRedoValue();
4703
+ try {
4704
+ this.doc.setNextCommitOptions({ message: JSON.stringify({
4705
+ semantic_op: kind,
4706
+ payload: { source: this.activeSource },
4707
+ intent: options?.intent ?? null,
4708
+ actor: options?.actor ?? null
4709
+ }) });
4710
+ const before = this.doc.oplogFrontiers();
4711
+ return (kind === "Undo" ? this.manager.undo() : this.manager.redo()) && this.doc.cmpWithFrontiers(before) !== 0;
4712
+ } finally {
4713
+ this.doc.clearNextCommitOptions();
4714
+ this.activeSource = null;
4715
+ this.scheduleNotification();
4716
+ }
4717
+ });
4718
+ }
4719
+ dispose() {
4720
+ if (this.disposed) return;
4721
+ this.disposed = true;
4722
+ this.unsubscribe();
4723
+ this.manager.setOnPush(void 0);
4724
+ this.manager.setOnPop(void 0);
4725
+ this.manager.free();
4726
+ }
4727
+ scheduleNotification() {
4728
+ if (this.notificationQueued || this.disposed) return;
4729
+ this.notificationQueued = true;
4730
+ queueMicrotask(() => {
4731
+ this.notificationQueued = false;
4732
+ if (!this.disposed) this.publish();
4733
+ });
4734
+ }
4735
+ publish() {
4736
+ if (this.disposed) return;
4737
+ const state = this.getState();
4738
+ if (state.canUndo === this.published.canUndo && state.canRedo === this.published.canRedo) return;
4739
+ this.published = state;
4740
+ this.onChange();
4741
+ }
4742
+ assertActive() {
4743
+ if (this.disposed) throw new Error("mengine doc undo manager is disposed");
4744
+ }
4745
+ };
4746
+ //#endregion
4570
4747
  //#region src/session/mengine-doc-session.ts
4571
4748
  /**
4572
4749
  * Raised by {@link MengineDocSession.waitForServerAck} when the local edits it
@@ -4602,13 +4779,9 @@ var MengineAckFailedError = class extends Error {
4602
4779
  * A live editing session for one Medeo document — the single entry point clients
4603
4780
  * (FE draft driver, Agent, embeds) use to open, edit, and observe a document.
4604
4781
  *
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
4782
+ * SemanticEditor writes through MirrorVideoDocumentAdapter directly, while
4783
+ * DocumentUndoManager observes the same LoroDoc independently. Both mutation
4784
+ * paths use the session's DocumentMutationGuard, closed before teardown.
4612
4785
  *
4613
4786
  * `DocManager` owns the `LoroDoc`: local edits committed on the adapter are
4614
4787
  * picked up via `subscribeLocalUpdates`, saved to local storage, then pushed to
@@ -4629,11 +4802,20 @@ var MengineDocSession = class {
4629
4802
  manager;
4630
4803
  events = new EventBus();
4631
4804
  disposables = new DisposableSet();
4805
+ mutationGuard = new DocumentMutationGuard();
4632
4806
  adapterValue = null;
4633
4807
  editorValue = null;
4808
+ undoManagerValue = null;
4634
4809
  started = false;
4810
+ startTask = null;
4811
+ destroyed = false;
4812
+ lifetime = null;
4813
+ hasConnected = false;
4814
+ initialVersionValue = null;
4815
+ localVersionValue = null;
4816
+ pushErrorValue = null;
4635
4817
  /**
4636
- * Latest server oplog version seen on a push outcome. Lets `waitForServerAck`
4818
+ * Latest server oplog version seen on an authoritative HTTP response. Lets `waitForServerAck`
4637
4819
  * return without waiting when the server is already known to cover the local
4638
4820
  * doc (e.g. nothing was edited since the last confirmed push).
4639
4821
  */
@@ -4662,14 +4844,14 @@ var MengineDocSession = class {
4662
4844
  return this.server.subscribePushOutcome(callback);
4663
4845
  }
4664
4846
  /**
4665
- * Resolve once the server has durably accepted the local document state as of
4847
+ * Resolve once the server has durably accepted the local commits as of
4666
4848
  * *now* — the capability an Agent tool needs to answer "did my edit land?"
4667
4849
  * before reporting success.
4668
4850
  *
4669
4851
  * 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.
4852
+ * latest local commit's version (or the loaded baseline before any edit) and
4853
+ * resolves when an authoritative server version covers it. Remote-only
4854
+ * imports do not extend this target, matching the displayed confirmation state.
4673
4855
  *
4674
4856
  * Version coverage, not byte identity, is the acceptance test — for two reasons:
4675
4857
  * the ops may reach the server inside a merged blob rather than as the exact
@@ -4684,31 +4866,39 @@ var MengineDocSession = class {
4684
4866
  * @throws {MengineAckFailedError} on timeout, rejection, or transport failure.
4685
4867
  */
4686
4868
  async waitForServerAck(options = {}) {
4687
- if (this.adapterValue == null) throw new Error("mengine doc session is not started");
4869
+ if (this.adapterValue == null || this.lifetime == null) throw new Error("mengine doc session is not started");
4688
4870
  const timeoutMs = options.timeoutMs ?? 15e3;
4689
- const target = this.manager.connectDoc(this.docId).version();
4871
+ const target = this.confirmationTarget() ?? this.manager.connectDoc(this.docId).version();
4872
+ const signal = this.lifetime.signal;
4690
4873
  if (this.serverVVValue != null && serverCovers(this.serverVVValue, target)) return;
4691
4874
  await new Promise((resolve, reject) => {
4692
- let off = () => {};
4875
+ const cleanup = [];
4693
4876
  let done = false;
4694
4877
  const settle = (error) => {
4695
4878
  if (done) return;
4696
4879
  done = true;
4697
4880
  clearTimeout(timer);
4698
- off();
4881
+ cleanup.forEach((off) => off());
4699
4882
  if (error == null) resolve();
4700
4883
  else reject(error);
4701
4884
  };
4702
4885
  const timer = setTimeout(() => {
4703
4886
  settle(new MengineAckFailedError("timeout", void 0, void 0, `mengine did not acknowledge the update within ${timeoutMs}ms`));
4704
4887
  }, timeoutMs);
4705
- off = this.server.subscribePushOutcome((outcome) => {
4888
+ const onAbort = () => settle(new MengineAckFailedError("failed", void 0, void 0, "mengine doc session destroyed"));
4889
+ signal.addEventListener("abort", onAbort, { once: true });
4890
+ cleanup.push(() => signal.removeEventListener("abort", onAbort));
4891
+ cleanup.push(this.events.on("syncState", () => {
4892
+ if (this.serverVVValue != null && serverCovers(this.serverVVValue, target)) settle(void 0);
4893
+ }));
4894
+ cleanup.push(this.server.subscribePushOutcome((outcome) => {
4706
4895
  if (outcome.kind === "rejected" || outcome.kind === "failed") {
4707
4896
  settle(new MengineAckFailedError(outcome.kind, outcome.code, outcome.error, outcome.error?.message ?? `mengine push ${outcome.kind}`));
4708
4897
  return;
4709
4898
  }
4710
- if (outcome.serverVV != null && serverCovers(outcome.serverVV, target)) settle(void 0);
4711
- });
4899
+ }));
4900
+ if (signal.aborted) onAbort();
4901
+ else this.synchronizer.requestSync(this.docId);
4712
4902
  });
4713
4903
  }
4714
4904
  /**
@@ -4744,44 +4934,154 @@ var MengineDocSession = class {
4744
4934
  if (this.adapterValue == null) throw new Error("mengine doc session is not started");
4745
4935
  return this.adapterValue.snapshot();
4746
4936
  }
4937
+ /** Current-session undo/redo availability. Loading and closed sessions report false. */
4938
+ getUndoState() {
4939
+ return this.undoManagerValue?.getState() ?? {
4940
+ canUndo: false,
4941
+ canRedo: false
4942
+ };
4943
+ }
4944
+ /**
4945
+ * Supplies current availability immediately, then coalesces stack changes in a
4946
+ * microtask. Session teardown publishes empty availability synchronously.
4947
+ */
4948
+ subscribeUndoState(cb) {
4949
+ const notify = () => cb(this.getUndoState());
4950
+ const off = this.destroyed ? () => {} : this.events.on("undoState", notify);
4951
+ notify();
4952
+ return off;
4953
+ }
4954
+ /**
4955
+ * Undo a local edit immediately; server confirmation remains asynchronous.
4956
+ * On a shared LWW field this writes its prior value, potentially overwriting a
4957
+ * later remote edit to that same field. Returns false if no update is produced.
4958
+ */
4959
+ undo(options) {
4960
+ if (this.undoManagerValue == null) throw new Error("mengine doc undo manager is not ready");
4961
+ return this.undoManagerValue.undo(options);
4962
+ }
4963
+ /**
4964
+ * Restore the state visible before undo without rerunning external side effects.
4965
+ * Returns false if no update is produced; durable confirmation is separate.
4966
+ */
4967
+ redo(options) {
4968
+ if (this.undoManagerValue == null) throw new Error("mengine doc undo manager is not ready");
4969
+ return this.undoManagerValue.redo(options);
4970
+ }
4971
+ /**
4972
+ * Combine existing semantic operations (e.g. trim a clip and adjust its volume)
4973
+ * or repeated commits during one drag gesture into a single undo step. A gesture
4974
+ * that only previews locally and commits once on release needs no group.
4975
+ * Each commit is immediately visible locally and enters sync independently;
4976
+ * a later failure does not roll it back.
4977
+ * Always pair with endUndoGroup in a finally block. Conflicting remote imports
4978
+ * can end/split the group early, and unrelated local edits would join it.
4979
+ * Complete asynchronous asset preparation before opening the group.
4980
+ */
4981
+ beginUndoGroup() {
4982
+ if (this.undoManagerValue == null) throw new Error("mengine doc undo manager is not ready");
4983
+ this.undoManagerValue.beginGroup();
4984
+ }
4985
+ /** End a gesture, including one already split by a conflicting remote import. */
4986
+ endUndoGroup() {
4987
+ if (this.undoManagerValue == null) throw new Error("mengine doc undo manager is not ready");
4988
+ this.undoManagerValue.endGroup();
4989
+ }
4747
4990
  /**
4748
4991
  * Start the sync stack and connect the document.
4749
4992
  *
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`.
4993
+ * Returns after DocManager loads a local snapshot, or after the first remote
4994
+ * sync when no local snapshot exists. A valid empty document is ready too.
4995
+ * Cached documents can open before SSE connects; subsequent remote changes
4996
+ * surface through `subscribe`.
4753
4997
  */
4754
- async start() {
4755
- if (this.started) return this.snapshot();
4998
+ start() {
4999
+ if (this.destroyed) return Promise.reject(/* @__PURE__ */ new Error("mengine doc session destroyed"));
5000
+ if (this.startTask != null) return this.startTask;
4756
5001
  this.started = true;
4757
- this.local.connection.connect();
4758
- this.server.connection.connect();
5002
+ this.lifetime = new AbortController();
5003
+ this.startTask = this.open().catch((error) => {
5004
+ const wasDestroyed = this.destroyed;
5005
+ this.destroy();
5006
+ if (wasDestroyed) throw new Error("mengine doc session destroyed");
5007
+ throw error;
5008
+ });
5009
+ return this.startTask;
5010
+ }
5011
+ async open() {
5012
+ this.disposables.add(this.server.subscribeResync(() => this.synchronizer.requestSync(this.docId)));
5013
+ this.disposables.add(this.server.subscribeServerVersion((version) => {
5014
+ this.serverVVValue = version;
5015
+ if (this.getSyncState().confirmation === "confirmed") this.pushErrorValue = null;
5016
+ this.emitSyncState();
5017
+ }));
5018
+ this.disposables.add(this.server.subscribePushOutcome((outcome) => {
5019
+ this.pushErrorValue = outcome.kind === "failed" || outcome.kind === "rejected" ? outcome.error ?? /* @__PURE__ */ new Error(`mengine push ${outcome.kind}`) : null;
5020
+ this.emitSyncState();
5021
+ }));
5022
+ this.disposables.add(this.server.connection.onStatusChanged((status) => {
5023
+ if (status === "connected") this.hasConnected = true;
5024
+ this.emitSyncState();
5025
+ }));
4759
5026
  this.disposables.add(() => this.local.connection.disconnect());
4760
5027
  this.disposables.add(() => this.server.connection.disconnect());
5028
+ this.local.connection.connect();
5029
+ await this.local.connection.waitForConnected().abortOn(this.lifetime.signal);
5030
+ if (this.destroyed) throw new Error("mengine doc session destroyed");
5031
+ this.server.connection.connect();
4761
5032
  this.synchronizer.start();
4762
5033
  this.manager.start();
4763
5034
  this.disposables.add(() => this.synchronizer.stop());
4764
5035
  this.disposables.add(() => this.manager.stop());
4765
5036
  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
5037
  this.disposables.add(() => this.manager.disconnectDoc(this.docId));
5038
+ if (this.options.peerId != null) doc.setPeerId(this.options.peerId);
5039
+ this.adapterValue = new MirrorVideoDocumentAdapter(doc, this.mutationGuard);
4770
5040
  const unsubscribe = doc.subscribe((event) => {
4771
5041
  if (this.adapterValue == null) return;
5042
+ if (event.by === "local") this.localVersionValue = doc.version();
5043
+ this.emitSyncState();
4772
5044
  this.events.emit("update", {
4773
5045
  source: event.by === "local" ? "local" : "remote",
4774
5046
  snapshot: this.adapterValue.snapshot()
4775
5047
  });
4776
5048
  });
4777
5049
  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;
5050
+ this.disposables.add(this.manager.onDocStateChange(this.docId, (state) => {
5051
+ this.events.emit("state", state);
5052
+ this.emitSyncState();
4781
5053
  }));
4782
- await this.waitForContent();
5054
+ await this.waitForInitialLoad();
5055
+ if (this.destroyed) throw new Error("mengine doc session destroyed");
5056
+ assertValidVideoDocument(this.snapshot());
5057
+ this.undoManagerValue = new DocumentUndoManager(doc, this.mutationGuard, () => this.events.emit("undoState"));
5058
+ this.editorValue = new SemanticEditor(this.adapterValue);
5059
+ this.initialVersionValue = doc.version();
5060
+ this.emitSyncState();
4783
5061
  return this.snapshot();
4784
5062
  }
5063
+ getSyncState() {
5064
+ const state = this.getState();
5065
+ const target = this.confirmationTarget();
5066
+ const confirmation = this.serverVVValue == null || target == null ? this.localVersionValue == null ? "unknown" : "pending" : serverCovers(this.serverVVValue, target) ? "confirmed" : "pending";
5067
+ const connection = !this.started ? "disconnected" : this.server.connection.status === "connected" ? "connected" : this.hasConnected || this.server.connection.error != null ? "disconnected" : "connecting";
5068
+ return {
5069
+ confirmation,
5070
+ phase: connection === "disconnected" ? "offline" : this.pushErrorValue != null || state.error != null ? "failed" : connection === "connecting" ? "connecting" : state.syncing ? "syncing" : "idle"
5071
+ };
5072
+ }
5073
+ confirmationTarget() {
5074
+ return this.localVersionValue ?? this.initialVersionValue;
5075
+ }
5076
+ /** Immediately supplies current state; unsubscribe when the host changes docs. */
5077
+ subscribeSyncState(cb) {
5078
+ const off = this.events.on("syncState", cb);
5079
+ cb(this.getSyncState());
5080
+ return off;
5081
+ }
5082
+ emitSyncState() {
5083
+ this.events.emit("syncState", this.getSyncState());
5084
+ }
4785
5085
  subscribe(cb) {
4786
5086
  return this.events.on("update", cb);
4787
5087
  }
@@ -4792,40 +5092,48 @@ var MengineDocSession = class {
4792
5092
  return this.manager.getDocState(this.docId);
4793
5093
  }
4794
5094
  destroy() {
5095
+ if (this.destroyed) return;
5096
+ this.destroyed = true;
5097
+ this.mutationGuard.close();
5098
+ this.started = false;
5099
+ this.lifetime?.abort();
5100
+ this.undoManagerValue?.dispose();
5101
+ this.undoManagerValue = null;
5102
+ this.events.emit("undoState");
4795
5103
  this.disposables.dispose();
5104
+ this.emitSyncState();
4796
5105
  this.events.clearAll();
5106
+ this.adapterValue?.dispose();
4797
5107
  this.adapterValue = null;
4798
5108
  this.editorValue = null;
4799
5109
  this.started = false;
4800
5110
  }
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
- });
5111
+ /** Local absence needs a remote sync; empty business content does not. */
5112
+ async waitForInitialLoad() {
5113
+ const signal = this.lifetime.signal;
5114
+ await new Promise((resolve, reject) => {
5115
+ let off = () => {};
5116
+ let offConnection = () => {};
5117
+ const finish = (error) => {
5118
+ off();
5119
+ offConnection();
5120
+ signal.removeEventListener("abort", onAbort);
5121
+ if (error != null) reject(error);
5122
+ else resolve();
5123
+ };
5124
+ const onAbort = () => finish(/* @__PURE__ */ new Error("mengine doc session destroyed"));
5125
+ const check = (state) => {
5126
+ if (state.loaded && (state.loadedFromLocal || state.synced)) finish();
5127
+ else if (state.error != null) finish(new Error(state.error));
5128
+ else if (state.loaded && this.server.connection.error != null) finish(this.server.connection.error);
5129
+ };
5130
+ off = this.onStateChange(check);
5131
+ offConnection = this.server.connection.onStatusChanged(() => check(this.getState()));
5132
+ signal.addEventListener("abort", onAbort, { once: true });
5133
+ if (signal.aborted) onAbort();
5134
+ else check(this.getState());
4819
5135
  });
4820
5136
  }
4821
- hasContent() {
4822
- if (this.adapterValue == null) return false;
4823
- try {
4824
- return this.adapterValue.hasContent();
4825
- } catch {
4826
- return false;
4827
- }
4828
- }
4829
5137
  };
4830
5138
  /**
4831
5139
  * True when the *encoded* server version `serverVV` includes everything in
@@ -4849,4 +5157,4 @@ function serverCovers(serverVV, target) {
4849
5157
  return covers(server, target);
4850
5158
  }
4851
5159
  //#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, base64ToBytes, buildInitialVideoDocument, buildSpeechHostMap, bytesToBase64, cascadeAfterVideoClipChanges, compileEntityRows, createEditSandbox, createMirrorVideoDocument, createMirrorVideoDocumentAdapter, createPlainMemoryAdapter, decodeDocVersionMark, derivePositionFromAbs, effectiveVideoClipDurationMs, encodeDocVersionMark, ensureEditorFoundation, ensureLaneTrack, fillMainTrackTimeGaps, findComposedAudioScript, findLaneTrack, fromVideoDocument, generatePartId, getAt, 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 };
5160
+ 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, audioScriptAssetOf, base64ToBytes, buildInitialVideoDocument, buildSpeechHostMap, bytesToBase64, cascadeAfterVideoClipChanges, compileEntityRows, createEditSandbox, createMirrorVideoDocument, createMirrorVideoDocumentAdapter, createPlainMemoryAdapter, decodeDocVersionMark, derivePositionFromAbs, effectiveVideoClipDurationMs, encodeDocVersionMark, ensureEditorFoundation, ensureLaneTrack, fillMainTrackTimeGaps, findComposedAudioScript, findLaneTrack, fromVideoDocument, generatePartId, getAt, hostForAbsMs, importMediaAsset, isEmptyVideoClip, isImplementedSemanticOpKind, isMap, laneTrackId, mainTrackRanges, migrateLegacyTimelineToEntities, partDurationMs, partUnionSchema, phoneticScriptAssetContent, phoneticScriptAssetHash, phoneticScriptAssetOf, prepareCaptionContent, projectEntityTimeline, readDocumentAudioScript, readEntityDocument, readMainTrackItems, readMengineEventStream, readPart, readPartDurationMs, readVideoDocumentFromDraft, reassignSpeechesToVideoClipsByTime, recalculateTimelineDuration, relativePositionForAbs, replayJournal, resolveAllSpeechOverlaps, resolveEntityTimelineLayout, resolveFieldPath, resolveSpeechOverlapByShiftingVideos, safeDurationMs, schemas_exports as schemas, scriptSourceAssetOf, selectAudioScriptSegment, snapshotToPlain, solveVideoDocument, speedOf, syncAggregatedClipsTimePosition, toVideoDocument, validateVideoDocument, videoDocumentMirrorSchema, videoDocumentSchema };