@mengine/medeo-client 2.0.1-alpha.5 → 2.0.1-alpha.7

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/README.md CHANGED
@@ -112,3 +112,34 @@ Loro version vector remains the transport-sync cursor.
112
112
  `entities.get/list` return complete assembled fields. Consumers do not query base IDs or merge source payloads. `entities.update` patches supplied fields, preserves omitted fields, and routes inherited fields to their declaring entity. Explicit own-field declarations use `entities.declareFields`; these override unambiguous base fields without changing the base. Persisted entity rows and command payloads contain owned fields only. Base collisions are validated before own overrides.
113
113
 
114
114
  `prepareCaptionContent` accepts caption authoring intent and handles source composition, visible text selection, and preservation of existing intrinsic timing inside the SDK.
115
+ This package owns the shared `VideoDocument` contract, semantic editing layer, and runtime-neutral HTTP/SSE client used by medeo-web, agent-harness, and server-side validation code. UI adapters such as `DraftMutationDriver` stay in product repositories.
116
+
117
+ `MengineDocSession.start()` waits for DocManager to load a local snapshot, or for the first remote sync if no local snapshot exists, then validates the content. A cached document can open before SSE connects; a cold start waits for SSE and the existing sync cycle. Session does not prefetch or import an initial snapshot itself.
118
+
119
+ `getState().loaded` means the first local load completed. Once loaded, `loadedFromLocal` reports whether that load imported a snapshot, including a valid empty document. It stays unchanged by later edits and remote updates. Document readiness does not depend on metadata or nonempty tracks.
120
+
121
+ ## Session undo/redo
122
+
123
+ After `await session.start()`, `session.undo(options?)` and `session.redo(options?)` synchronously apply local history and return whether they produced a document update. `options` accepts the same actor/intent metadata as semantic edits. Read the resulting document through `session.snapshot()`; use `waitForServerAck()` only when durable confirmation is required.
124
+
125
+ `getUndoState()` returns the current `{ canUndo, canRedo }` immediately. `subscribeUndoState(callback)` immediately provides that state, then coalesces subsequent stack changes in a microtask after mutations settle. Several synchronous commands may produce only one availability notification; use the getter when reading directly after a command. Teardown publishes empty availability synchronously and cancels pending notifications. Dispose the returned subscription with the host. Loading and closed sessions report an empty history; history commands require a ready session.
126
+
127
+ History belongs to one document/session peer. The loaded baseline and remote imports are not recorded. Each semantic commit is a separate step (up to 100); a new local edit invalidates redo. Reconnecting preserves history, while reopening starts empty. Empty or ineffective history actions return false. Never reuse a peer ID across concurrent sessions.
128
+
129
+ Use `beginUndoGroup()` / `endUndoGroup()` to combine existing semantic operations into one undo step, or to combine repeated commits during one gesture. Always end in `finally`. Every constituent commit is immediately visible locally and enters synchronization independently; if a later operation fails, earlier commits remain applied. Conflicting remote imports may split/end a group early, and unrelated local edits in the same session would join an open group.
130
+
131
+ Mirror's draft callback and commit are synchronous. SemanticEditor methods return promises, but their document mutations currently finish synchronously before the promise resolves. Await external asset generation before opening a group, then compose the local semantic operations. Keeping a group open across asynchronous work does not provide transaction isolation, deferred publication, or rollback. If several changes must be atomic, implement one semantic operation with one synchronous draft callback. A gesture that uses only local previews and commits once on release already needs no group.
132
+
133
+ Flush pending local preview/debounce work before undo/redo. Mutating again from a document-update observer during a mutation is rejected; schedule the next command after the current one returns.
134
+
135
+ The session assembles SemanticEditor with MirrorVideoDocumentAdapter directly. DocumentUndoManager independently observes the same LoroDoc and owns undo/redo, grouping and audit metadata. Both write paths share a synchronous DocumentMutationGuard; the session closes it before teardown callbacks, and each component releases its own resources. Standalone adapters create their own guard by default and reject use after disposal while leaving the borrowed LoroDoc usable.
136
+
137
+ Undo/redo append ordinary synchronized updates and retain audit metadata (`Undo`/`Redo`, actor, and `payload.source` identifying an original local change). Existing revision bindings remain immutable. Redo reuses already materialized assets without repeating generation, billing, or attachment requests. Network failures do not automatically reverse history.
138
+
139
+ Loro history uses new writes: undo on a shared LWW field restores its value before the local edit, potentially overwriting a later remote write to the same field; redo restores the value visible before undo. Unrelated remote fields remain intact. Undoing a local insertion removes the object and makes dependent remote edits invisible until redo restores the object. Caption creation includes its style container, even when empty, so a later style edit only changes fields and undo preserves another peer's independently edited fields. Initial document seeding preserves supplied styles and creates empty maps for captions without styles. No write-time repair or migration of older CRDT documents is provided.
140
+
141
+ ## Large-update resynchronization
142
+
143
+ `MengineDocSession` handles SSE `event: resync` by scheduling the existing version-vector sync on the current connection. The event carries no Loro bytes and does not create an edit or undo step. A hint arriving while an HTTP sync is in flight schedules another diff so changes newer than that response are still fetched. Low-level users of `readMengineEventStream` can handle the same hint with `onResync`; `MedeoHttpDocStorage` exposes `subscribeResync`.
144
+
145
+ Deploy this client before enabling server-side resync notifications. Older clients ignore the event. Large HTTP pushes remain valid: the server chooses inline update or resync notification after persistence, rather than requiring client-side fragmentation. Base64 encoding uses `Uint8Array.toBase64()` where available, with a chunked fallback preserving the same padded wire representation.
@@ -1,5 +1,5 @@
1
1
  import { u as volumeSchema } from "./shared-iWnU2osE.js";
2
- import { Mirror, schema } from "loro-mirror";
2
+ import { Mirror, schema, validateSchema } from "loro-mirror";
3
3
  import { z } from "zod";
4
4
  import { LoroDoc, LoroMap, LoroMovableList, LoroText, UndoManager } from "loro-crdt";
5
5
  import { sha256 } from "@noble/hashes/sha2.js";
@@ -7,9 +7,11 @@ import { bytesToHex } from "@noble/hashes/utils.js";
7
7
  import { produce } from "immer";
8
8
  //#region src/client/base64.ts
9
9
  function bytesToBase64(bytes) {
10
- let binary = "";
11
- for (const byte of bytes) binary += String.fromCharCode(byte);
12
- return btoa(binary);
10
+ const native = bytes.toBase64;
11
+ if (typeof native === "function") return native.call(bytes);
12
+ const chunks = [];
13
+ for (let offset = 0; offset < bytes.length; offset += 8192) chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 8192)));
14
+ return btoa(chunks.join(""));
13
15
  }
14
16
  function base64ToBytes(base64) {
15
17
  const binary = atob(base64);
@@ -47,10 +49,6 @@ function base64ToBytes(base64) {
47
49
  * value, last-writer-wins). The derived `fallback_abs_ms` snapshot sits beside
48
50
  * it as its own number field — a separate LWW unit so a projection refresh
49
51
  * never clobbers a `time_position` edit.
50
- * - meta lives in a `meta` LoroMap: the mirror root only accepts container
51
- * schemas, so meta scalars can't sit at the root as bare values. The domain
52
- * `VideoDocument` keeps the same `meta` wrapper, so the shapes are isomorphic
53
- * and the read/seed mapping is a near-identity.
54
52
  * - `part_library` values are nested `LoroMap`s: the value is a `LoroMap` with
55
53
  * four mutually-exclusive optional part sub-maps (`video_clip` / `speech` /
56
54
  * `caption` / `bgm`), each a `LoroMap` whose fields are stored as real Loro
@@ -64,16 +62,10 @@ function base64ToBytes(base64) {
64
62
  * as opaque JSON-blob sub-keys via `transform`. The four sub-keys are optional,
65
63
  * so the "exactly one part kind" union invariant is not enforced by the schema
66
64
  * type; it is rebuilt by `draftToPartUnion` on read and gated by zod on write.
67
- * - a caption's `style` is the one lazily-created optional child container, so
68
- * `captionPart` sets `mergeableMapChildContainers: true` — two peers can
69
- * concurrently first-create the same caption's `style` (one attribute each), and
70
- * only a mergeable child converges instead of last-writer-wins dropping one. All
71
- * other nested maps are keyed by a unique id (`part_library`) or created
72
- * atomically with their parent (`partValue`'s part sub-map), so they never hit
73
- * that concurrent-first-create fork and stay on plain `setContainer`. See the
74
- * note on `captionPart` below.
75
- * - `video_creation_settings` is an opaque JSON blob carried in a string via
76
- * `transform`: a whole value, last-writer-wins (no field-level concurrent edits).
65
+ * - a caption's `style` is a mergeable child map for independent style fields.
66
+ * Creation paths materialize it with the caption, even when empty, so undoing
67
+ * a later style edit never removes the parent reference. Other nested maps
68
+ * keep their regular container identities from creation.
77
69
  */
78
70
  /**
79
71
  * JSON-blob transform for an opaque, last-writer-wins value carried in a Loro
@@ -154,16 +146,6 @@ const partValue = schema.LoroMap({
154
146
  bgm: bgmPart
155
147
  });
156
148
  const videoDocumentMirrorSchema = schema({
157
- meta: schema.LoroMap({
158
- schema_version: schema.String({ required: false }),
159
- draft_id: schema.String({ required: false }),
160
- project_id: schema.String({ required: false }),
161
- owner_id: schema.String({ required: false }),
162
- thumbnail_storage_key: schema.String({ required: false }),
163
- chat_session_id: schema.String({ required: false }),
164
- video_creation_settings: schema.String({ required: false }).transform(jsonTransform()),
165
- version: schema.Number({ required: false })
166
- }),
167
149
  timeline: schema.LoroMap({ unit_time_ms: schema.Number({ required: false }) }),
168
150
  tracks: schema.LoroMovableList(track, (t) => t.id),
169
151
  part_library: schema.LoroMapRecord(partValue)
@@ -213,9 +195,6 @@ function recordEntries(record) {
213
195
  }
214
196
  //#endregion
215
197
  //#region src/document/types.ts
216
- const VIDEO_DOCUMENT_SCHEMA_VERSION = "video-document/v0";
217
- /** A server-derived compatibility view, never an editor write authority. */
218
- const ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION = "video-document/entity-projection-v1";
219
198
  /** The linear speed multiplier of a `speed_shift`, defaulting to 1 (original). */
220
199
  function speedOf(speedShift) {
221
200
  const speed = speedShift?.config?.linear?.speed;
@@ -549,12 +528,12 @@ function cascadeAfterVideoClipChanges(doc, makeEmptyPart) {
549
528
  * read-view: absolute time per item, `part_aggregations`, and total duration.
550
529
  * This is the read side of the single-directional flow — never written back.
551
530
  */
552
- function solveVideoDocument(document) {
531
+ function solveVideoDocument(document, options = {}) {
553
532
  const doc = videoDocumentToTimelineDoc(document);
554
533
  let counter = 0;
555
534
  const derivedFillerPartIds = /* @__PURE__ */ new Set();
556
- if (document.meta.schema_version === "video-document/entity-projection-v1") {
557
- for (const track of document.tracks ?? []) if (track.items?.some((item) => item.time_position.mode === "sequential" || item.time_position.mode === "anchored" && !Number.isSafeInteger(item.fallback_abs_ms))) throw new Error("An entity timeline projection must contain resolved placement facts");
535
+ if (options.placementResolved === true) {
536
+ assertResolvedPlacement(document);
558
537
  recalculateTimelineDuration(doc);
559
538
  } else cascadeAfterVideoClipChanges(doc, () => {
560
539
  const partId = `empty_${counter++}`;
@@ -759,6 +738,19 @@ function ensureLaneTrack(draft, kind) {
759
738
  function findLaneTrack(draft, kind) {
760
739
  return (draft.tracks ?? []).find((t) => t?.parts_kind === kind);
761
740
  }
741
+ /**
742
+ * Whether every item already states a position that can be read without running
743
+ * the cascade: nothing `sequential`, and every `anchored` item carrying the
744
+ * absolute landing its writer stamped. This is a verification predicate, not a
745
+ * provenance test — see `SolveVideoDocumentOptions.placementResolved`.
746
+ */
747
+ function hasResolvedPlacement(document) {
748
+ return (document.tracks ?? []).every((track) => (track.items ?? []).every((item) => item.time_position.mode !== "sequential" && (item.time_position.mode !== "anchored" || Number.isSafeInteger(item.fallback_abs_ms))));
749
+ }
750
+ /** A producer that claims resolved placement must actually have resolved it. */
751
+ function assertResolvedPlacement(document) {
752
+ if (!hasResolvedPlacement(document)) throw new Error("An entity timeline projection must contain resolved placement facts");
753
+ }
762
754
  //#endregion
763
755
  //#region src/document/zod-schema.ts
764
756
  const partKindSchema = z.enum([
@@ -859,18 +851,7 @@ const partUnionSchema = z.union([
859
851
  "bgm"
860
852
  ].filter((k) => p[k] != null).length === 1;
861
853
  }, { message: "A part_library value must have exactly one of video_clip / speech / caption / bgm" });
862
- const videoDocumentMetaSchema = z.object({
863
- schema_version: z.enum([VIDEO_DOCUMENT_SCHEMA_VERSION, ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION]),
864
- draft_id: z.string().optional(),
865
- project_id: z.string().optional(),
866
- owner_id: z.string().optional(),
867
- thumbnail_storage_key: z.string().optional(),
868
- chat_session_id: z.string().optional(),
869
- video_creation_settings: z.unknown().optional(),
870
- version: finiteNumber.optional()
871
- }).passthrough();
872
854
  const videoDocumentSchema = z.object({
873
- meta: videoDocumentMetaSchema,
874
855
  timeline: z.object({ unit_time_ms: finiteNumber.optional() }).passthrough().optional(),
875
856
  tracks: z.array(trackSchema).optional(),
876
857
  part_library: z.record(z.string(), partUnionSchema).optional()
@@ -1140,13 +1121,13 @@ function zodPath(path) {
1140
1121
  //#endregion
1141
1122
  //#region src/document/projection.ts
1142
1123
  /**
1143
- * Projection between the authoritative `VideoDocument` and the legacy
1144
- * `VideoDraft` read-view (RFC 02 §5/§7). Both directions live here:
1124
+ * Projection between authoritative `VideoDocument` and compatible content
1125
+ * layouts (RFC 02 §5/§7). Both directions live here:
1145
1126
  *
1146
1127
  * - `toVideoDocument` ingests a `VideoDraft`, deriving each item's `position`
1147
1128
  * from the legacy absolute layout + aggregations; derived values (abs time,
1148
1129
  * `part_aggregations`, total duration) are dropped.
1149
- * - `fromVideoDocument` solves a `VideoDocument` back into a `VideoDraft` via the
1130
+ * - `fromVideoDocument` solves a `VideoDocument` into `VideoDraftContent` via the
1150
1131
  * timeline-core cascade, re-deriving exactly those values.
1151
1132
  *
1152
1133
  * Business validation lives in `validation.ts`; `fromVideoDocument` asserts a
@@ -1177,16 +1158,6 @@ function toVideoDocument(draft) {
1177
1158
  ...(draft.below_main_tracks ?? []).map((t) => toTrack(t, false))
1178
1159
  ];
1179
1160
  return {
1180
- meta: {
1181
- schema_version: VIDEO_DOCUMENT_SCHEMA_VERSION,
1182
- draft_id: draft.id,
1183
- project_id: draft.project_id,
1184
- owner_id: draft.owner_id,
1185
- thumbnail_storage_key: draft.thumbnail_storage_key,
1186
- chat_session_id: draft.chat_session_id,
1187
- video_creation_settings: clone$1(draft.video_creation_settings),
1188
- version: draft.version
1189
- },
1190
1161
  timeline: draft.timeline == null ? void 0 : { unit_time_ms: draft.timeline.unit_time_ms },
1191
1162
  tracks,
1192
1163
  part_library: toAuthoritativePartLibrary(draft.part_library)
@@ -1296,8 +1267,8 @@ function deriveItem(item, isMain, speechHost, partLibrary) {
1296
1267
  };
1297
1268
  }
1298
1269
  /**
1299
- * Project the authoritative `VideoDocument` back into the legacy `VideoDraft`
1300
- * read-view, solving each item's absolute position, the `part_aggregations`, and
1270
+ * Project `VideoDocument` into `VideoDraftContent`, solving each item's
1271
+ * absolute position, the `part_aggregations`, and
1301
1272
  * the total duration via the timeline-core cascade.
1302
1273
  *
1303
1274
  * The read-view is a compatibility contract, and the IDL declares
@@ -1311,33 +1282,26 @@ function deriveItem(item, isMain, speechHost, partLibrary) {
1311
1282
  * These two are defaultable because the projection knows their values on its own:
1312
1283
  * absent `unit_time_ms` means "no display granularity was ever stated" and absent
1313
1284
  * `is_hidden` means "this track was never hidden". `version` is NOT defaultable
1314
- * here — its only honest value is server state (`update_seq`) that a
1315
- * `VideoDocument` cannot see, so it stays absent and the HTTP layer fills it.
1285
+ * here — it belongs to Director assembly using the server envelope update_seq.
1286
+ * Neither business metadata nor a revision counter belongs in content projection.
1316
1287
  */
1317
- function fromVideoDocument(document) {
1288
+ function fromVideoDocument(document, options = {}) {
1318
1289
  assertValidVideoDocument(document);
1319
- const view = solveVideoDocument(document);
1290
+ const view = solveVideoDocument(document, options);
1320
1291
  const tracks = document.tracks ?? [];
1321
1292
  const mainTrack = tracks.find((t) => t.parts_kind === "video_clip");
1322
1293
  const aboveTracks = tracks.filter((t) => t.parts_kind === "caption");
1323
1294
  const belowTracks = tracks.filter((t) => t.parts_kind === "speech" || t.parts_kind === "bgm");
1324
1295
  return {
1325
- id: document.meta.draft_id,
1326
- project_id: document.meta.project_id,
1327
- owner_id: document.meta.owner_id,
1328
- thumbnail_storage_key: document.meta.thumbnail_storage_key,
1329
1296
  timeline: {
1330
1297
  duration_ms: view.durationMs,
1331
1298
  unit_time_ms: document.timeline?.unit_time_ms ?? 33.333
1332
1299
  },
1333
- video_creation_settings: clone$1(document.meta.video_creation_settings),
1334
- chat_session_id: document.meta.chat_session_id,
1335
1300
  main_track: draftTrack(mainTrack, view.absByPartId),
1336
1301
  above_main_tracks: aboveTracks.map((t) => draftTrack(t, view.absByPartId)),
1337
1302
  below_main_tracks: belowTracks.map((t) => draftTrack(t, view.absByPartId)),
1338
1303
  part_aggregations: view.aggregations,
1339
- part_library: toReadViewPartLibrary(view.partLibrary, view.durationMs, view.derivedFillerPartIds),
1340
- version: document.meta.version
1304
+ part_library: toReadViewPartLibrary(view.partLibrary, view.durationMs, view.derivedFillerPartIds)
1341
1305
  };
1342
1306
  }
1343
1307
  /**
@@ -1444,7 +1408,7 @@ function clone$1(value) {
1444
1408
  //#region src/document/initial-document.ts
1445
1409
  /**
1446
1410
  * Build the `VideoDocument` a newly created project starts from: the caller's
1447
- * project facts, no parts, and one empty track per lane.
1411
+ * no parts and one empty track per lane. Business facts remain in Director.
1448
1412
  *
1449
1413
  * The empty lane tracks are the reason this function exists rather than callers
1450
1414
  * assembling a document inline. Lane tracks are otherwise minted lazily by the
@@ -1465,18 +1429,8 @@ function clone$1(value) {
1465
1429
  * supplies, and total duration is derived on read (RFC 02 §6), so a new document
1466
1430
  * has no timeline fact to state.
1467
1431
  */
1468
- function buildInitialVideoDocument(facts) {
1432
+ function buildInitialVideoDocument() {
1469
1433
  return {
1470
- meta: {
1471
- schema_version: VIDEO_DOCUMENT_SCHEMA_VERSION,
1472
- draft_id: facts.draftId,
1473
- project_id: facts.projectId,
1474
- owner_id: facts.ownerId,
1475
- thumbnail_storage_key: void 0,
1476
- chat_session_id: facts.chatSessionId,
1477
- video_creation_settings: facts.videoCreationSettings,
1478
- version: void 0
1479
- },
1480
1434
  timeline: void 0,
1481
1435
  tracks: emptyLaneTracks(),
1482
1436
  part_library: {}
@@ -3094,7 +3048,7 @@ function resolveGraphLayout(graph) {
3094
3048
  * consulted as an editing fact. Peer IDs appear only in this derived read
3095
3049
  * contract, reconstructed from relations; they are not stored in payloads.
3096
3050
  */
3097
- function projectEntityTimeline(rows, meta) {
3051
+ function projectEntityTimeline(rows) {
3098
3052
  const graph = new EntityProjectionGraph(rows);
3099
3053
  const layout = resolveGraphLayout(graph);
3100
3054
  for (const track of layout.tracks) {
@@ -3183,10 +3137,6 @@ function projectEntityTimeline(rows, meta) {
3183
3137
  }
3184
3138
  }
3185
3139
  const document = {
3186
- meta: {
3187
- ...structuredClone(meta),
3188
- schema_version: ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION
3189
- },
3190
3140
  timeline: graph.timeline.payload.unit_time_ms === void 0 ? void 0 : { unit_time_ms: positive(graph.timeline.payload.unit_time_ms, "unit_time_ms") },
3191
3141
  tracks: layout.tracks.map((track) => ({
3192
3142
  id: track.entityId,
@@ -3844,6 +3794,10 @@ var LoroEntityDocument = class LoroEntityDocument {
3844
3794
  if (id === void 0) return [];
3845
3795
  return this.snapshot().rows.relations.filter((row) => row.endpoint0EntityId === id || row.endpoint1EntityId === id);
3846
3796
  }
3797
+ /** Resolve a queued edit target through its native identity without changing history. */
3798
+ resolveCurrentEntityId(id) {
3799
+ return currentId(this.doc, id);
3800
+ }
3847
3801
  getVersion(id) {
3848
3802
  const entity = section(this.doc, "entities").get(id);
3849
3803
  if (!isJsonObject(entity)) return void 0;
@@ -3917,12 +3871,12 @@ var LoroEntityDocument = class LoroEntityDocument {
3917
3871
  return this.historyEdit(() => this.undoManager.redo());
3918
3872
  }
3919
3873
  historyEdit(run) {
3920
- const before = this.snapshot();
3874
+ const before = new Map(this.snapshot().rows.entities.map((row) => [family(this.doc, row.entityId), row]));
3921
3875
  if (!run()) return false;
3922
3876
  const after = this.snapshot();
3923
3877
  for (const row of after.rows.entities) {
3924
3878
  const id = family(this.doc, row.entityId);
3925
- const previous = before.rows.entities.find((item) => family(this.doc, item.entityId) === id);
3879
+ const previous = before.get(id);
3926
3880
  if (previous && !equalJson(owned(previous.payload), owned(row.payload))) workspace(this.doc, id).ensureMergeableMap("changes").set(globalThis.crypto.randomUUID(), true);
3927
3881
  }
3928
3882
  materialize(this.doc);
@@ -4267,13 +4221,37 @@ function materialize(doc, allowSelectionConflicts = false) {
4267
4221
  for (const [slot, id] of Object.entries(state.project)) if (project.get(slot) !== id) project.set(slot, id);
4268
4222
  }
4269
4223
  //#endregion
4224
+ //#region src/document/document-mutation-guard.ts
4225
+ /**
4226
+ * @internal
4227
+ * Shared by synchronous mutation entry points for one document. The session
4228
+ * closes it before teardown callbacks can attempt another write.
4229
+ */
4230
+ var DocumentMutationGuard = class {
4231
+ running = false;
4232
+ closed = false;
4233
+ run(mutation) {
4234
+ if (this.closed) throw new Error("mengine document is disposed");
4235
+ if (this.running) throw new Error("mengine document mutation is already running");
4236
+ this.running = true;
4237
+ try {
4238
+ return mutation();
4239
+ } finally {
4240
+ this.running = false;
4241
+ }
4242
+ }
4243
+ close() {
4244
+ this.closed = true;
4245
+ }
4246
+ };
4247
+ //#endregion
4270
4248
  //#region src/document/mirror-read.ts
4271
4249
  /**
4272
4250
  * Project the mirror state (`VideoDocumentDraft`) into the authoritative
4273
4251
  * `VideoDocument`. The storage shape is isomorphic to the domain shape (RFC 03
4274
- * §4, reference/17 §4: meta map + a single `tracks` list + part_library), so this
4252
+ * §4, reference/17 §4: timeline + a single `tracks` list + part_library), so this
4275
4253
  * is a near-identity — it reads `time_position` / `fallback_abs_ms` JSON blobs
4276
- * back into structured values and trims empty strings, nothing more.
4254
+ * back into structured values and selects content fields. Legacy meta is ignored.
4277
4255
  *
4278
4256
  * It maps only authoritative facts (RFC 02 §6): `part_id` + `time_position`. The
4279
4257
  * projection-derived `VideoDraft` read-view (absolute time, `part_aggregations`,
@@ -4290,20 +4268,8 @@ function readVideoDocumentFromDraft(draft) {
4290
4268
  if (part != null) partLibrary[partId] = part;
4291
4269
  }
4292
4270
  const timeline = draft.timeline;
4293
- const hasTimeline = timeline?.unit_time_ms != null;
4294
- const meta = draft.meta;
4295
4271
  return {
4296
- meta: {
4297
- schema_version: meta?.schema_version === "video-document/entity-projection-v1" ? ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION : VIDEO_DOCUMENT_SCHEMA_VERSION,
4298
- draft_id: emptyToUndefined(meta?.draft_id),
4299
- project_id: emptyToUndefined(meta?.project_id),
4300
- owner_id: emptyToUndefined(meta?.owner_id),
4301
- thumbnail_storage_key: emptyToUndefined(meta?.thumbnail_storage_key),
4302
- chat_session_id: emptyToUndefined(meta?.chat_session_id),
4303
- video_creation_settings: meta?.video_creation_settings ?? void 0,
4304
- version: meta?.version ?? void 0
4305
- },
4306
- timeline: hasTimeline ? { unit_time_ms: timeline.unit_time_ms } : void 0,
4272
+ timeline: timeline?.unit_time_ms != null ? { unit_time_ms: timeline.unit_time_ms } : void 0,
4307
4273
  tracks: rowsToTracks(draft.tracks),
4308
4274
  part_library: partLibrary
4309
4275
  };
@@ -4327,9 +4293,6 @@ function rowToTrack(row) {
4327
4293
  })
4328
4294
  };
4329
4295
  }
4330
- function emptyToUndefined(value) {
4331
- return value == null || value === "" ? void 0 : value;
4332
- }
4333
4296
  //#endregion
4334
4297
  //#region src/document/mirror-adapter.ts
4335
4298
  /**
@@ -4342,33 +4305,50 @@ function emptyToUndefined(value) {
4342
4305
  * - `transact(edit, audit)` runs the whole op in one `mirror.setState` callback:
4343
4306
  * one diff, one `doc.commit` carrying the audit message. The callback edits an
4344
4307
  * immer draft, so a throw inside it discards the draft and never touches Loro
4345
- * (natural rollback) — no `guard` / `rollback` / `openTransaction` machinery.
4308
+ * (natural rollback) without a document checkout or compensating commit.
4346
4309
  * - mirror's `idSelector` (track items keyed by `part_id`) diffs reorders to
4347
4310
  * real Loro `move` ops on every lane, so per-item CRDT identity survives on
4348
4311
  * main and secondary tracks alike.
4349
4312
  */
4350
4313
  var MirrorVideoDocumentAdapter = class {
4351
4314
  doc;
4315
+ mutationGuard;
4352
4316
  mirror;
4353
- constructor(doc) {
4317
+ disposed = false;
4318
+ constructor(doc, mutationGuard = new DocumentMutationGuard()) {
4354
4319
  this.doc = doc;
4320
+ this.mutationGuard = mutationGuard;
4355
4321
  this.mirror = new Mirror({
4356
4322
  doc,
4357
- schema: videoDocumentMirrorSchema
4323
+ schema: videoDocumentMirrorSchema,
4324
+ validateUpdates: false
4358
4325
  });
4359
4326
  }
4360
4327
  snapshot() {
4361
- const document = readVideoDocumentFromDraft(this.mirror.getState());
4362
- if (this.doc.getMap("medeo").get("schema") === "medeo.entities.loro.v1") return projectEntityTimeline(readEntityDocument(this.doc).rows, document.meta);
4363
- return document;
4328
+ this.assertActive();
4329
+ if (this.isEntityDocument()) return projectEntityTimeline(readEntityDocument(this.doc).rows);
4330
+ return readVideoDocumentFromDraft(this.mirror.getState());
4364
4331
  }
4365
4332
  /**
4366
- * True once the doc holds real document content. A fresh mirror over an empty
4367
- * doc still reports defaulted root maps, so probe the stored `schema_version`
4368
- * (empty until a snapshot is bootstrapped or synced in).
4333
+ * Native Entity authority: placement is already solved by the Entity graph.
4334
+ *
4335
+ * The root check goes through `getShallowValue` first because `getMap` creates
4336
+ * the container on access — probing a legacy document for `medeo` would
4337
+ * otherwise mutate it, which a read must never do.
4369
4338
  */
4370
- hasContent() {
4371
- return (this.mirror.getState().meta?.schema_version ?? "") !== "";
4339
+ isEntityDocument() {
4340
+ const roots = this.doc.getShallowValue();
4341
+ if (!Object.hasOwn(roots, "medeo")) return false;
4342
+ return this.doc.getMap("medeo").get("schema") === LORO_ENTITY_SCHEMA;
4343
+ }
4344
+ /**
4345
+ * Release mirror subscriptions; do not reuse this adapter afterwards.
4346
+ * The caller still owns the borrowed LoroDoc.
4347
+ */
4348
+ dispose() {
4349
+ if (this.disposed) return;
4350
+ this.disposed = true;
4351
+ this.mirror.dispose();
4372
4352
  }
4373
4353
  /**
4374
4354
  * Apply one op as a single transaction. `edit` mutates the immer draft; mirror
@@ -4377,80 +4357,71 @@ var MirrorVideoDocumentAdapter = class {
4377
4357
  * skips the commit — matching the prior "empty op leaves no audit" behavior.
4378
4358
  */
4379
4359
  transact(edit, audit) {
4380
- if (this.snapshot().meta.schema_version === "video-document/entity-projection-v1") throw new Error("Entity timeline projections are read-only; edit the Medeo entities instead");
4381
- this.mirror.setState((draft) => {
4382
- edit(draft);
4383
- }, {
4384
- origin: "mengine.semantic_editor",
4385
- message: JSON.stringify({
4386
- semantic_op: audit.kind,
4387
- payload: audit.payload,
4388
- intent: audit.intent ?? null,
4389
- actor: audit.actor ?? null
4390
- })
4360
+ this.assertActive();
4361
+ if (this.isEntityDocument()) throw new Error("Entity documents are read-only through this adapter; edit the Medeo entities instead");
4362
+ this.mutationGuard.run(() => {
4363
+ this.mirror.setState((draft) => {
4364
+ edit(draft);
4365
+ const result = validateSchema(videoDocumentMirrorSchema, {
4366
+ timeline: draft.timeline,
4367
+ tracks: draft.tracks,
4368
+ part_library: draft.part_library
4369
+ });
4370
+ if (!result.valid) throw new Error(`Invalid content update: ${result.errors?.join("; ")}`);
4371
+ }, {
4372
+ origin: "mengine.semantic_editor",
4373
+ message: JSON.stringify({
4374
+ semantic_op: audit.kind,
4375
+ payload: audit.payload,
4376
+ intent: audit.intent ?? null,
4377
+ actor: audit.actor ?? null
4378
+ })
4379
+ });
4391
4380
  });
4392
4381
  }
4382
+ assertActive() {
4383
+ if (this.disposed) throw new Error("mengine document adapter is disposed");
4384
+ }
4393
4385
  };
4394
4386
  /** Build a fresh Loro doc seeded with `document` through the mirror. */
4395
4387
  function createMirrorVideoDocument(document, options = {}) {
4396
4388
  assertValidVideoDocument(document);
4397
4389
  const doc = new LoroDoc();
4398
- if (options.peerId != null) doc.setPeerId(options.peerId);
4399
- new Mirror({
4400
- doc,
4401
- schema: videoDocumentMirrorSchema
4402
- }).setState((draft) => {
4403
- writeVideoDocumentToDraft(draft, document);
4404
- }, { origin: options.origin ?? "mengine.bootstrap" });
4405
- return doc;
4390
+ try {
4391
+ if (options.peerId != null) doc.setPeerId(options.peerId);
4392
+ const mirror = new Mirror({
4393
+ doc,
4394
+ schema: videoDocumentMirrorSchema
4395
+ });
4396
+ try {
4397
+ mirror.setState((draft) => {
4398
+ seedDraft(draft, document);
4399
+ }, { origin: options.origin ?? "mengine.bootstrap" });
4400
+ return doc;
4401
+ } finally {
4402
+ mirror.dispose();
4403
+ }
4404
+ } catch (error) {
4405
+ doc.free();
4406
+ throw error;
4407
+ }
4406
4408
  }
4407
4409
  /** Build a `MirrorVideoDocumentAdapter` over a fresh doc seeded with `document`. */
4408
4410
  function createMirrorVideoDocumentAdapter(document, options) {
4409
4411
  return new MirrorVideoDocumentAdapter(createMirrorVideoDocument(document, options));
4410
4412
  }
4411
- /** Host-only replacement of a derived view, retaining the existing Loro history. */
4412
- function applyEntityTimelineProjection(doc, projection, entityRevision) {
4413
- if (projection.meta.schema_version !== "video-document/entity-projection-v1") throw new Error("Expected an entity timeline projection");
4414
- if (!Number.isSafeInteger(entityRevision) || entityRevision < 1) throw new Error("Expected a positive entity revision");
4415
- assertValidVideoDocument(projection);
4416
- const from = doc.oplogVersion();
4417
- new Mirror({
4418
- doc,
4419
- schema: videoDocumentMirrorSchema
4420
- }).setState((draft) => writeVideoDocumentToDraft(draft, {
4421
- ...projection,
4422
- meta: {
4423
- ...projection.meta,
4424
- version: entityRevision
4425
- }
4426
- }), {
4427
- origin: "mengine.entity_projection",
4428
- message: JSON.stringify({
4429
- semantic_op: "EntityTimelineProjection",
4430
- entity_revision: entityRevision
4431
- })
4432
- });
4433
- return doc.export({
4434
- mode: "update",
4435
- from
4436
- });
4437
- }
4438
- /** Write a whole `VideoDocument` into a draft (seed a fresh doc / plain-memory state). */
4439
- function writeVideoDocumentToDraft(draft, document) {
4440
- draft.meta = {
4441
- schema_version: document.meta.schema_version,
4442
- draft_id: document.meta.draft_id ?? void 0,
4443
- project_id: document.meta.project_id ?? void 0,
4444
- owner_id: document.meta.owner_id ?? void 0,
4445
- thumbnail_storage_key: document.meta.thumbnail_storage_key ?? void 0,
4446
- chat_session_id: document.meta.chat_session_id ?? void 0,
4447
- video_creation_settings: document.meta.video_creation_settings ?? void 0,
4448
- version: document.meta.version ?? void 0
4449
- };
4413
+ /** Write a whole `VideoDocument` into the mirror draft (seed a fresh doc / plain-memory state). */
4414
+ function seedDraft(draft, document) {
4450
4415
  draft.timeline = { unit_time_ms: document.timeline?.unit_time_ms ?? void 0 };
4451
4416
  draft.tracks = (document.tracks ?? []).map(toTrackRow);
4452
4417
  draft.part_library = {};
4453
- for (const [partId, part] of Object.entries(document.part_library ?? {})) draft.part_library[partId] = partUnionToDraft(part);
4418
+ for (const [partId, part] of Object.entries(document.part_library ?? {})) {
4419
+ const createdPart = part.caption != null ? { caption: {
4420
+ ...part.caption,
4421
+ style: part.caption.style ?? {}
4422
+ } } : part;
4423
+ draft.part_library[partId] = partUnionToDraft(createdPart);
4424
+ }
4454
4425
  }
4455
4426
  /** Map a domain `Track` to a draft track row (no lane/lane_order — see schema). */
4456
4427
  function toTrackRow(track) {
@@ -4534,10 +4505,12 @@ var PlainMemoryAdapter = class {
4534
4505
  * use this so every minted id is appended to the current transact's list.
4535
4506
  */
4536
4507
  idFactory;
4508
+ readOnly;
4537
4509
  constructor(document, options) {
4538
4510
  assertValidVideoDocument(document);
4511
+ this.readOnly = options?.readOnly === true;
4539
4512
  const draft = {};
4540
- writeVideoDocumentToDraft(draft, document);
4513
+ seedDraft(draft, document);
4541
4514
  this.state = draft;
4542
4515
  this.baseIdFactory = options?.idFactory ?? generatePartId;
4543
4516
  this.idFactory = (prefix) => {
@@ -4550,7 +4523,7 @@ var PlainMemoryAdapter = class {
4550
4523
  return this._journal;
4551
4524
  }
4552
4525
  hasContent() {
4553
- return (this.state.meta?.schema_version ?? "") !== "";
4526
+ return this.state.tracks !== void 0 || this.state.timeline !== void 0;
4554
4527
  }
4555
4528
  snapshot() {
4556
4529
  return readVideoDocumentFromDraft(this.state);
@@ -4561,7 +4534,7 @@ var PlainMemoryAdapter = class {
4561
4534
  * no structural change — skip journal, matching mirror "no change, no commit".
4562
4535
  */
4563
4536
  transact(edit, audit) {
4564
- if (this.state.meta?.schema_version === "video-document/entity-projection-v1") throw new Error("Entity timeline projections are read-only; edit the Medeo entities instead");
4537
+ if (this.readOnly) throw new Error("Entity documents are read-only here; edit the Medeo entities instead");
4565
4538
  this.pendingIds = [];
4566
4539
  try {
4567
4540
  const next = produce(this.state, edit);
@@ -4582,4 +4555,4 @@ function createPlainMemoryAdapter(document, options) {
4582
4555
  return new PlainMemoryAdapter(document, options);
4583
4556
  }
4584
4557
  //#endregion
4585
- export { ensureLaneTrack as $, assembleEntityContent as A, hasSequence as B, projectEntityTimeline as C, base64ToBytes as Ct, decodeEntityRelationRows as D, EntityTimelineProjectionError as E, updateEntityFields as F, fromVideoDocument as G, buildInitialVideoDocument as H, variantBaseEntityIds as I, assertValidVideoDocument as J, toVideoDocument as K, isJsonObject as L, assembleScriptText as M, findComposedAudioScript as N, ScriptCompositionError as O, selectAudioScriptSegment as P, LANE_KINDS_IN_STACK_ORDER as Q, createEntityId as R, resolveFieldPath as S, videoDocumentMirrorSchema as St, resolveEntityTimelineLayout as T, buildSpeechHostMap as U, isMediaAssetVariantKind as V, derivePositionFromAbs as W, partUnionSchema as X, validateVideoDocument as Y, videoDocumentSchema as Z, audioScriptAssetHash as _, VIDEO_DOCUMENT_SCHEMA_VERSION as _t, applyEntityTimelineProjection as a, fillMainTrackTimeGaps as at, applyFieldChanges as b, partUnionToDraft as bt, writeVideoDocumentToDraft as c, resolveAllSpeechOverlaps as ct, LoroEntityDocument as d, TIMELINE_SKELETON_DURATION_MS as dt, findLaneTrack as et, LoroEntityDraft as f, isEmptyVideoClip as ft, audioScriptAssetFields as g, ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION as gt, audioScriptAssetContent as h, DEFAULT_UNIT_TIME_MS as ht, MirrorVideoDocumentAdapter as i, arrangeMainTrackSeamlessly as it, assemblePhoneticScriptContent as j, assembleCaptionContent as k, readVideoDocumentFromDraft as l, resolveSpeechOverlapByShiftingVideos as lt, entityOrderGroups as m, safeDurationMs as mt, createPlainMemoryAdapter as n, solveVideoDocument as nt, createMirrorVideoDocument as o, reassignSpeechesToVideoClipsByTime as ot, readEntityDocument as p, partDurationMs as pt, VideoDocumentValidationError as q, generatePartId as r, cascadeAfterVideoClipChanges as rt, createMirrorVideoDocumentAdapter as s, recalculateTimelineDuration as st, PlainMemoryAdapter as t, laneTrackId as tt, LORO_ENTITY_SCHEMA as u, syncAggregatedClipsTimePosition as ut, hasAudioScriptAsset as v, effectiveVideoClipDurationMs as vt, EntityProjectionGraph as w, bytesToBase64 as wt, assertFieldChanges as x, recordEntries as xt, equalJson as y, speedOf as yt, createRelationId as z };
4558
+ export { findLaneTrack as $, assemblePhoneticScriptContent as A, isMediaAssetVariantKind as B, EntityProjectionGraph as C, ScriptCompositionError as D, decodeEntityRelationRows as E, variantBaseEntityIds as F, toVideoDocument as G, buildSpeechHostMap as H, isJsonObject as I, validateVideoDocument as J, VideoDocumentValidationError as K, createEntityId as L, findComposedAudioScript as M, selectAudioScriptSegment as N, assembleCaptionContent as O, updateEntityFields as P, ensureLaneTrack as Q, createRelationId as R, projectEntityTimeline as S, bytesToBase64 as St, EntityTimelineProjectionError as T, derivePositionFromAbs as U, buildInitialVideoDocument as V, fromVideoDocument as W, videoDocumentSchema as X, partUnionSchema as Y, LANE_KINDS_IN_STACK_ORDER as Z, hasAudioScriptAsset as _, speedOf as _t, createMirrorVideoDocument as a, fillMainTrackTimeGaps as at, assertFieldChanges as b, videoDocumentMirrorSchema as bt, DocumentMutationGuard as c, resolveAllSpeechOverlaps as ct, LoroEntityDraft as d, TIMELINE_SKELETON_DURATION_MS as dt, hasResolvedPlacement as et, readEntityDocument as f, isEmptyVideoClip as ft, audioScriptAssetHash as g, effectiveVideoClipDurationMs as gt, audioScriptAssetFields as h, DEFAULT_UNIT_TIME_MS as ht, MirrorVideoDocumentAdapter as i, arrangeMainTrackSeamlessly as it, assembleScriptText as j, assembleEntityContent as k, LORO_ENTITY_SCHEMA as l, resolveSpeechOverlapByShiftingVideos as lt, audioScriptAssetContent as m, safeDurationMs as mt, createPlainMemoryAdapter as n, solveVideoDocument as nt, createMirrorVideoDocumentAdapter as o, reassignSpeechesToVideoClipsByTime as ot, entityOrderGroups as p, partDurationMs as pt, assertValidVideoDocument as q, generatePartId as r, cascadeAfterVideoClipChanges as rt, readVideoDocumentFromDraft as s, recalculateTimelineDuration as st, PlainMemoryAdapter as t, laneTrackId as tt, LoroEntityDocument as u, syncAggregatedClipsTimePosition as ut, equalJson as v, partUnionToDraft as vt, resolveEntityTimelineLayout as w, resolveFieldPath as x, base64ToBytes as xt, applyFieldChanges as y, recordEntries as yt, hasSequence as z };