@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.
@@ -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: {}
@@ -1493,36 +1447,101 @@ function emptyLaneTracks() {
1493
1447
  }
1494
1448
  //#endregion
1495
1449
  //#region ../medeo-dsl/src/entities.ts
1450
+ /**
1451
+ * What every known entity kind declares.
1452
+ *
1453
+ * `kinds` is a multi-declaration: every entry holds at the same time, and each
1454
+ * is a property shared with other entity kinds — what the object *is* comes
1455
+ * from `entityKind` and is never repeated here. The order carries no priority,
1456
+ * endpoint role, or edit sequence — an operation asks whether the entity
1457
+ * declares the kind it needs, never what its "main type" is. An entity kind
1458
+ * that shares nothing declares nothing.
1459
+ *
1460
+ * `Caption` declares `Sequence` on purpose: it owns the display timing of the
1461
+ * one AudioScript segment it shows, which is what admits it into a Clip.
1462
+ * `Voice` declares no `Sequence` — it is a timbre identity, not playable
1463
+ * content; rendered speech is a `voiceover` entity.
1464
+ *
1465
+ * `MediaFile` says only that an entity of that kind may be bound to stored bytes,
1466
+ * and asks nothing of its payload. Which bytes, and which way the content
1467
+ * flowed, is the `from-asset` / `to-asset` Relation's answer alone.
1468
+ */
1469
+ const ENTITY_KINDS = Object.freeze({
1470
+ axvideo: Object.freeze([
1471
+ "Container",
1472
+ "Sequence",
1473
+ "Visual",
1474
+ "Audible"
1475
+ ]),
1476
+ timeline: Object.freeze(["Container"]),
1477
+ track: Object.freeze(["Container"]),
1478
+ clip: Object.freeze(["Container"]),
1479
+ asset: Object.freeze([]),
1480
+ video: Object.freeze([
1481
+ "Sequence",
1482
+ "Visual",
1483
+ "Audible",
1484
+ "MediaFile"
1485
+ ]),
1486
+ audio: Object.freeze([
1487
+ "Sequence",
1488
+ "Audible",
1489
+ "MediaFile"
1490
+ ]),
1491
+ voiceover: Object.freeze([
1492
+ "Sequence",
1493
+ "Audible",
1494
+ "MediaFile"
1495
+ ]),
1496
+ image: Object.freeze([
1497
+ "Sequence",
1498
+ "Visual",
1499
+ "MediaFile"
1500
+ ]),
1501
+ voice: Object.freeze(["Container", "IPBound"]),
1502
+ "sequence-marker": Object.freeze([]),
1503
+ viewport: Object.freeze([]),
1504
+ "audio-script": Object.freeze(["SegmentText", "MediaFile"]),
1505
+ "phonetic-script": Object.freeze(["SegmentText", "MediaFile"]),
1506
+ caption: Object.freeze([
1507
+ "SegmentText",
1508
+ "Sequence",
1509
+ "MediaFile"
1510
+ ])
1511
+ });
1512
+ /** What a known kind declares; extension kinds declare nothing through this table. */
1513
+ function kindsOf(entityKind) {
1514
+ return isKnownEntityKind(entityKind) ? ENTITY_KINDS[entityKind] : [];
1515
+ }
1516
+ function declaresKind(entityKind, kind) {
1517
+ return kindsOf(entityKind).includes(kind);
1518
+ }
1519
+ /**
1520
+ * Sequence admission is a declaration, not a shape: a known kind is admitted
1521
+ * because its `kinds` say so, and a malformed payload is a separate, louder
1522
+ * failure than a silently non-Sequence entity. Extension kinds declare nothing
1523
+ * through the table and stay structurally detected.
1524
+ */
1496
1525
  function hasSequence(entity) {
1497
- if (isKnownNonSequenceKind(entity.entityKind) || isReservedEntityKind(entity.entityKind)) return false;
1526
+ if (isReservedEntityKind(entity.entityKind)) return false;
1527
+ if (isKnownEntityKind(entity.entityKind)) return declaresKind(entity.entityKind, "Sequence");
1498
1528
  return isSequenceFields(entity);
1499
1529
  }
1500
1530
  function isSequenceFields(value) {
1501
1531
  if (!isRecord$1(value)) return false;
1502
- const extent = value.extent;
1503
- if (!isRecord$1(extent) || !("start" in extent) || extent.start === void 0) return false;
1504
- if (extent.kind === "bounded" && (!("end" in extent) || extent.end === void 0)) return false;
1505
- if (extent.kind === "unbounded" && "end" in extent) return false;
1506
- if (extent.kind !== "bounded" && extent.kind !== "unbounded") return false;
1507
- if (value.sampling !== "native" && value.sampling !== "constant" && value.sampling !== "derived") return false;
1508
- return "coordinateSpace" in value && value.coordinateSpace !== void 0;
1509
- }
1510
- function isKnownSequenceKind(kind) {
1511
- return isMediaAssetVariantKind(kind) || kind === "caption" || kind === "axvideo";
1532
+ const duration = value.durationMs;
1533
+ return duration === null || typeof duration === "number" && Number.isSafeInteger(duration) && duration > 0;
1512
1534
  }
1513
1535
  function isMediaAssetVariantKind(kind) {
1514
- return kind === "video" || kind === "image" || kind === "audio" || kind === "voice";
1536
+ return kind === "video" || kind === "image" || kind === "audio" || kind === "voiceover";
1515
1537
  }
1516
1538
  function isKnownEntityKind(kind) {
1517
- return isKnownSequenceKind(kind) || isKnownNonSequenceKind(kind);
1539
+ return Object.hasOwn(ENTITY_KINDS, kind);
1518
1540
  }
1519
1541
  function isReservedEntityKind(kind) {
1520
1542
  const normalized = kind.toLowerCase().replaceAll("-", "").replaceAll("_", "");
1521
1543
  return normalized === "speech" || normalized === "videodocument";
1522
1544
  }
1523
- function isKnownNonSequenceKind(kind) {
1524
- return kind === "timeline" || kind === "track" || kind === "clip" || kind === "asset" || kind === "sequence-marker" || kind === "viewport" || kind === "audio-script" || kind === "phonetic-script";
1525
- }
1526
1545
  function isRecord$1(value) {
1527
1546
  return typeof value === "object" && value != null && !Array.isArray(value);
1528
1547
  }
@@ -1706,14 +1725,17 @@ function assembleCaptionContent(rows, captionEntityId) {
1706
1725
  requireEntityKind(rows, captionEntityId, "caption");
1707
1726
  const caption = assembleEntityContent(rows, captionEntityId);
1708
1727
  const script = findComposedAudioScript(rows, captionEntityId, "caption");
1709
- const segment = selectAudioScriptSegment({
1728
+ const selection = captionSelection(caption);
1729
+ const composed = {
1710
1730
  ...script,
1711
1731
  payload: caption.payload
1712
- }, captionSelection(caption));
1732
+ };
1733
+ const segment = selectAudioScriptSegment(composed, selection);
1713
1734
  return {
1714
1735
  caption,
1715
1736
  audioScript: script,
1716
1737
  segments: [segment],
1738
+ segmentIndex: scriptSegments(composed).findIndex((entry) => entry.segmentId === selection.segmentId),
1717
1739
  text: segment.text
1718
1740
  };
1719
1741
  }
@@ -1781,18 +1803,7 @@ function entityToRow(entity) {
1781
1803
  }
1782
1804
  //#endregion
1783
1805
  //#region ../medeo-dsl/src/invariants.ts
1784
- /**
1785
- * Media variants own their Asset identity directly: a `physical-asset`
1786
- * Relation may not bind them. Caption may associate with a physical text Asset
1787
- * independently of its AudioScript composition.
1788
- */
1789
- function validateMediaAssetIdentity(entity, index) {
1790
- return [...index.relationsOf(entity)].filter((relation) => relation.kind === "physical-asset").map(() => ({
1791
- code: "media_physical_asset_forbidden",
1792
- entityId: entity.entityId,
1793
- message: `Media entity "${entity.entityId}" owns its Asset identity; remove its physical-asset Relation`
1794
- }));
1795
- }
1806
+ /** An entity is made from at most one Asset; two would leave its bytes ambiguous. */
1796
1807
  /** Validates one complete set of entities and its authoritative Relation rows. */
1797
1808
  function validateEntityRelationSet(entityRefs, index, options) {
1798
1809
  const { entities, issues } = collectRelatedEntities(entityRefs, index);
@@ -1814,7 +1825,6 @@ function validateEntityRelationSet(entityRefs, index, options) {
1814
1825
  const current = entity.current();
1815
1826
  const entityIssues = validateEntity(entity, entityIds);
1816
1827
  issues.push(...entityIssues);
1817
- if (isMediaAssetVariantKind(current.entityKind)) issues.push(...validateMediaAssetIdentity(entity, index));
1818
1828
  if (entityIssues.some((issue) => issue.code === "invalid_entity_payload")) continue;
1819
1829
  if (current.entityKind === "sequence-marker") {
1820
1830
  const marker = entity;
@@ -2039,24 +2049,25 @@ function validateMarkerSourceBounds(marker, index, compare) {
2039
2049
  const content = contentEdges[0]?.other(marker)?.deref()?.current();
2040
2050
  if (content == null || !hasSequence(content)) return [];
2041
2051
  const sourceRange = marker.current().sourceRange;
2042
- const startVsExtent = compare(sourceRange.start, content.extent.start);
2043
- const endVsExtent = content.extent.kind === "bounded" ? compare(sourceRange.end, content.extent.end) : void 0;
2044
- if (!Number.isNaN(startVsExtent) && startVsExtent >= 0 && (endVsExtent == null || !Number.isNaN(endVsExtent) && endVsExtent <= 0)) return [];
2052
+ const startVsOrigin = compare(sourceRange.start, 0);
2053
+ const endVsDuration = content.durationMs == null ? void 0 : compare(sourceRange.end, content.durationMs);
2054
+ if (!Number.isNaN(startVsOrigin) && startVsOrigin >= 0 && (endVsDuration == null || !Number.isNaN(endVsDuration) && endVsDuration <= 0)) return [];
2045
2055
  return [{
2046
2056
  code: "marker_source_out_of_bounds",
2047
2057
  entityId: marker.entityId,
2048
- message: `Sequence Marker sourceRange must stay within content "${content.entityId}" extent`
2058
+ message: `Sequence Marker sourceRange must stay within content "${content.entityId}" duration`
2049
2059
  }];
2050
2060
  }
2051
2061
  function validateSequenceComposition(entity) {
2052
2062
  const current = entity.current();
2053
- const expected = expectedSequenceShape(current.entityKind);
2054
- if (expected == null) return [];
2055
- if (hasSequence(current) && current.extent.kind === expected.extent && current.sampling === expected.sampling) return [];
2063
+ const expected = expectedDurationShape(current.entityKind);
2064
+ if (expected === void 0) return [];
2065
+ const duration = current.durationMs;
2066
+ if ((duration === null ? "none" : isPositiveInteger(duration) ? "own" : "invalid") === expected) return [];
2056
2067
  return [{
2057
2068
  code: "invalid_sequence_composition",
2058
2069
  entityId: current.entityId,
2059
- message: `${current.entityKind} must compose ${expected.extent} / ${expected.sampling} Sequence semantics`
2070
+ message: expected === "own" ? `${current.entityKind} must state its own durationMs in whole milliseconds` : `${current.entityKind} has no intrinsic length; durationMs must be null`
2060
2071
  }];
2061
2072
  }
2062
2073
  function validateEntity(entity, entityIds = /* @__PURE__ */ new Set()) {
@@ -2087,6 +2098,7 @@ function validateKnownEntityPayload(entity) {
2087
2098
  if (value.lifecycle !== void 0 && !isRecord(value.lifecycle)) problems.push("lifecycle must be an object when present");
2088
2099
  if (entity.entityKind === "audio-script" || entity.entityKind === "phonetic-script") {
2089
2100
  for (const key of [
2101
+ "durationMs",
2090
2102
  "extent",
2091
2103
  "sampling",
2092
2104
  "coordinateSpace",
@@ -2095,7 +2107,7 @@ function validateKnownEntityPayload(entity) {
2095
2107
  "duration",
2096
2108
  "startMs",
2097
2109
  "endMs"
2098
- ]) if (Object.hasOwn(value, key)) problems.push(`${key} is not intrinsic script data; assign an external annotation Marker`);
2110
+ ]) if (Object.hasOwn(value, key)) problems.push(`${key} is not intrinsic script data; assign an annotation Marker instead`);
2099
2111
  }
2100
2112
  switch (entity.entityKind) {
2101
2113
  case "track":
@@ -2105,19 +2117,25 @@ function validateKnownEntityPayload(entity) {
2105
2117
  break;
2106
2118
  case "video":
2107
2119
  case "audio":
2108
- case "voice":
2120
+ validateDurationPayload(value, "own", problems);
2121
+ break;
2109
2122
  case "caption":
2110
- validateSequencePayload(value, "bounded", "native", problems);
2111
- if (entity.entityKind !== "caption") validateMediaAssetFields(value, problems);
2112
- if (entity.entityKind === "voice") validateVoicePayload(value, problems);
2113
- if (entity.entityKind === "caption") validateCaptionPayload(value, problems);
2123
+ validateDurationPayload(value, "own", problems);
2124
+ validateCaptionPayload(value, problems);
2125
+ break;
2126
+ case "voice":
2127
+ validateVoicePayload(value, problems);
2114
2128
  break;
2115
2129
  case "image":
2116
- validateSequencePayload(value, "unbounded", "constant", problems);
2117
- validateMediaAssetFields(value, problems);
2130
+ validateDurationPayload(value, "none", problems);
2118
2131
  break;
2119
2132
  case "axvideo":
2120
- validateSequencePayload(value, "bounded", "derived", problems);
2133
+ for (const retired of [
2134
+ "extent",
2135
+ "sampling",
2136
+ "coordinateSpace",
2137
+ "durationMs"
2138
+ ]) if (Object.hasOwn(value, retired)) problems.push(`${retired} is derived from the Timeline, not stored`);
2121
2139
  break;
2122
2140
  case "sequence-marker":
2123
2141
  validateMarkerPayload(value, problems);
@@ -2142,17 +2160,27 @@ function validateKnownEntityPayload(entity) {
2142
2160
  }
2143
2161
  return problems;
2144
2162
  }
2145
- function validateSequencePayload(value, expectedExtent, expectedSampling, problems) {
2146
- const extent = value.extent;
2147
- if (!isRecord(extent)) problems.push("extent must be an object");
2148
- else {
2149
- if (extent.kind !== expectedExtent) problems.push(`extent.kind must be "${expectedExtent}"`);
2150
- if (!Object.hasOwn(extent, "start") || extent.start === void 0) problems.push("extent.start is required");
2151
- if (expectedExtent === "bounded" && (!Object.hasOwn(extent, "end") || extent.end === void 0)) problems.push("extent.end is required for bounded sequences");
2152
- if (expectedExtent === "unbounded" && Object.hasOwn(extent, "end")) problems.push("extent.end is forbidden for unbounded sequences");
2163
+ /**
2164
+ * Content states its own length and nothing else. The retired `extent` carried
2165
+ * a start as well, which had no meaning of its own and drifted into holding a
2166
+ * position; rejecting it keeps that mistake unwritable.
2167
+ */
2168
+ function validateDurationPayload(value, expected, problems) {
2169
+ for (const retired of [
2170
+ "extent",
2171
+ "sampling",
2172
+ "coordinateSpace"
2173
+ ]) if (Object.hasOwn(value, retired)) problems.push(`${retired} is retired; state durationMs instead`);
2174
+ if (!Object.hasOwn(value, "durationMs")) {
2175
+ problems.push("durationMs is required");
2176
+ return;
2153
2177
  }
2154
- if (value.sampling !== expectedSampling) problems.push(`sampling must be "${expectedSampling}"`);
2155
- if (!Object.hasOwn(value, "coordinateSpace") || value.coordinateSpace === void 0) problems.push("coordinateSpace is required");
2178
+ const duration = value.durationMs;
2179
+ if (expected === "none") {
2180
+ if (duration !== null) problems.push("durationMs must be null; this content has no intrinsic length");
2181
+ return;
2182
+ }
2183
+ if (!isPositiveInteger(duration)) problems.push("durationMs must be positive whole milliseconds");
2156
2184
  }
2157
2185
  function validateMarkerPayload(value, problems) {
2158
2186
  validateRange(value.sourceRange, "sourceRange", true, problems);
@@ -2188,40 +2216,38 @@ function validateSegmentRanges(value, problems) {
2188
2216
  if (typeof entry.startMs === "number" && Number.isFinite(entry.startMs) && typeof entry.endMs === "number" && Number.isFinite(entry.endMs) && entry.startMs > entry.endMs) problems.push(`segmentRanges[${index}].startMs must not exceed segmentRanges[${index}].endMs`);
2189
2217
  }
2190
2218
  }
2219
+ /** The Asset entity is the locator; it states where the bytes live and nothing else. */
2191
2220
  function validateAssetPayload(value, problems) {
2192
- validateExternalLocator(value, problems);
2193
- validateStorageKey(value, problems);
2221
+ if (value.system !== "memota" && value.system !== "memota-speech") problems.push("system must be \"memota\" or \"memota-speech\"");
2222
+ if (typeof value.key !== "string" || value.key.trim() === "") problems.push("key must be a non-empty string");
2194
2223
  if (value.inline !== void 0) problems.push("inline is not a physical location; text is domain content owned by its AudioScript");
2195
- }
2196
- /**
2197
- * Media variants own their Asset locator directly: `external` is required and
2198
- * textual domain content is owned by AudioScript; the physical-location
2199
- * vocabularies cannot mix.
2200
- */
2201
- function validateMediaAssetFields(value, problems) {
2202
- if (value.external === void 0) problems.push("external is required; media variants own their Asset identity");
2203
- else validateExternalLocator(value, problems);
2204
2224
  validateStorageKey(value, problems);
2205
- if (value.inline !== void 0) problems.push("media variants use external locators, not inline domain text");
2206
- }
2207
- function validateExternalLocator(value, problems) {
2208
- const external = value.external;
2209
- if (external === void 0) return;
2210
- if (!isRecord(external)) {
2211
- problems.push("external must be an object when present");
2212
- return;
2213
- }
2214
- if (external.system !== "memota" && external.system !== "memota-speech") problems.push("external.system must be \"memota\" or \"memota-speech\"");
2215
- if (typeof external.key !== "string" || external.key.trim() === "") problems.push("external.key must be a non-empty string");
2216
2225
  }
2217
2226
  function validateStorageKey(value, problems) {
2218
2227
  if (value.storageKey !== void 0 && (typeof value.storageKey !== "string" || value.storageKey.trim() === "")) problems.push("storageKey must be a non-empty string when present");
2219
2228
  }
2229
+ /**
2230
+ * Voice is a timbre identity, not playable content: it owns the voice-library
2231
+ * descriptor and nothing that would make it look like media or a Sequence. The
2232
+ * rendered voiceover is an `audio` entity bound by a `voice-timbre` Relation.
2233
+ */
2220
2234
  function validateVoicePayload(value, problems) {
2235
+ for (const key of [
2236
+ "durationMs",
2237
+ "extent",
2238
+ "sampling",
2239
+ "coordinateSpace",
2240
+ "system",
2241
+ "key",
2242
+ "storageKey"
2243
+ ]) if (Object.hasOwn(value, key)) problems.push(`${key} belongs to the rendered Audio, not to the Voice identity`);
2221
2244
  const voice = value.voice;
2222
- if (voice === void 0) return;
2245
+ if (voice === void 0) {
2246
+ problems.push("voice is required; a Voice entity is its voice-library identity");
2247
+ return;
2248
+ }
2223
2249
  if (!isRecord(voice)) {
2224
- problems.push("voice must be an object when present");
2250
+ problems.push("voice must be an object");
2225
2251
  return;
2226
2252
  }
2227
2253
  if (voice.system !== "voice-library") problems.push("voice.system must be \"voice-library\"");
@@ -2229,7 +2255,6 @@ function validateVoicePayload(value, problems) {
2229
2255
  if (voice.name !== void 0 && typeof voice.name !== "string") problems.push("voice.name must be a string when present");
2230
2256
  }
2231
2257
  function validateCaptionPayload(value, problems) {
2232
- validateExternalLocator(value, problems);
2233
2258
  if (value.segmentRanges !== void 0) validateSegmentRanges(value.segmentRanges, problems);
2234
2259
  if (Object.hasOwn(value, "selections")) problems.push("selections is forbidden; Caption requires one selection object");
2235
2260
  const selection = value.selection;
@@ -2353,15 +2378,13 @@ function isOwnedLocalEntityValuePath(entityKind, path) {
2353
2378
  if ([
2354
2379
  "video",
2355
2380
  "audio",
2356
- "voice",
2357
2381
  "image",
2358
2382
  "caption",
2359
2383
  "axvideo"
2360
- ].includes(entityKind) && /^(?:sampling|coordinateSpace|coordinateSpace\.unit|extent\.kind)$/.test(path)) return true;
2384
+ ].includes(entityKind) && /^durationMs$/.test(path)) return true;
2361
2385
  if (entityKind === "sequence-marker" && /^(?:durationPolicy|duration\.mode|timeRemapping\.(?:kind|mode))$/.test(path)) return true;
2362
2386
  if ((entityKind === "sequence-marker" || entityKind === "caption") && /^segmentRanges\[\d+\]\.segmentId$/.test(path)) return true;
2363
- if (entityKind === "asset" && /^(?:external\.(?:system|key)|storageKey)$/.test(path)) return true;
2364
- if (isMediaAssetVariantKind(entityKind) && /^(?:external\.(?:system|key)|storageKey)$/.test(path)) return true;
2387
+ if (entityKind === "asset" && /^(?:system|key|storageKey)$/.test(path)) return true;
2365
2388
  if (entityKind === "voice" && /^voice\.(?:system|key|name)$/.test(path)) return true;
2366
2389
  if (entityKind === "caption" && (path.startsWith("style.") || path === "selection.segmentId")) return true;
2367
2390
  if ([
@@ -2416,19 +2439,16 @@ function collectRelatedEntities(entityRefs, index) {
2416
2439
  issues
2417
2440
  };
2418
2441
  }
2419
- function expectedSequenceShape(kind) {
2420
- if (kind === "video" || kind === "audio" || kind === "voice" || kind === "caption") return {
2421
- extent: "bounded",
2422
- sampling: "native"
2423
- };
2424
- if (kind === "image") return {
2425
- extent: "unbounded",
2426
- sampling: "constant"
2427
- };
2428
- if (kind === "axvideo") return {
2429
- extent: "bounded",
2430
- sampling: "derived"
2431
- };
2442
+ /**
2443
+ * What a kind must state about its own length. An AXVideo is absent here on
2444
+ * purpose: its Timeline decides its length, so it stores none.
2445
+ */
2446
+ function expectedDurationShape(kind) {
2447
+ if (kind === "video" || kind === "audio" || kind === "caption") return "own";
2448
+ if (kind === "image") return "none";
2449
+ }
2450
+ function isPositiveInteger(value) {
2451
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
2432
2452
  }
2433
2453
  function ofKind(relations, kind) {
2434
2454
  return relations.filter((relation) => relation.kind === kind);
@@ -2445,9 +2465,38 @@ const markerContentRelationSpec = Object.freeze({
2445
2465
  validateEndpoints: (endpoints) => hasMarkerAndSequence(endpoints),
2446
2466
  validateMetadata: isEmptyMetadata
2447
2467
  });
2448
- const physicalAssetRelationSpec = Object.freeze({
2449
- kind: "physical-asset",
2450
- validateEndpoints: (endpoints) => hasAssetAndSequence(endpoints),
2468
+ /**
2469
+ * `from-asset(entity, asset)` — the entity was made from that stored resource.
2470
+ *
2471
+ * Eligibility is declared: the non-Asset endpoint must carry the `MediaFile` kind,
2472
+ * which says an entity of that kind can be bound to stored bytes and asks
2473
+ * nothing of its payload. The bytes stay named in exactly one place, so no
2474
+ * entity copies the locator into its own row and one Asset can back several
2475
+ * entities.
2476
+ */
2477
+ const fromAssetRelationSpec = Object.freeze({
2478
+ kind: "from-asset",
2479
+ validateEndpoints: (endpoints) => hasExactlyOneAsset(endpoints),
2480
+ validateMetadata: isAssetBinding
2481
+ });
2482
+ /**
2483
+ * `to-asset(entity, asset)` — the entity's content was written out to that
2484
+ * stored resource.
2485
+ *
2486
+ * The mirror of `from-asset`, and deliberately a separate relation kind rather
2487
+ * than a second declared kind: eligibility is the same either way, but which
2488
+ * way the content flowed is what says whether an entity may still be edited.
2489
+ * An entity reached by `from-asset` was produced from bytes it does not own,
2490
+ * so changing it would contradict them. An entity with a `to-asset` owns its
2491
+ * content and the resource is a snapshot of it, which is what goes stale and
2492
+ * gets written again. A transcript that is later edited carries both at once,
2493
+ * and only the distinct relation kinds keep them apart.
2494
+ *
2495
+ * Writing a snapshot is host assembly; nothing a script authors creates one.
2496
+ */
2497
+ const toAssetRelationSpec = Object.freeze({
2498
+ kind: "to-asset",
2499
+ validateEndpoints: (endpoints) => hasExactlyOneAsset(endpoints),
2451
2500
  validateMetadata: isAssetBinding
2452
2501
  });
2453
2502
  /** `generated(output, input)` means endpoint 0 was generated from endpoint 1. */
@@ -2458,7 +2507,7 @@ const generatedRelationSpec = Object.freeze({
2458
2507
  });
2459
2508
  const captionAlignmentRelationSpec = Object.freeze({
2460
2509
  kind: "caption-alignment",
2461
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["caption"]), new Set(["audio", "voice"])),
2510
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["caption"]), new Set(["voiceover"])),
2462
2511
  validateMetadata: isCaptionAlignmentMetadata
2463
2512
  });
2464
2513
  /** `clip-anchor(child, host)` means endpoint 0 follows endpoint 1. */
@@ -2467,10 +2516,27 @@ const clipAnchorRelationSpec = Object.freeze({
2467
2516
  validateEndpoints: (endpoints) => endpoints[0].current().entityKind === "clip" && endpoints[1].current().entityKind === "clip",
2468
2517
  validateMetadata: isEmptyMetadata
2469
2518
  });
2470
- /** Voice is generated from PhoneticScript; kinds determine roles regardless of endpoint positions. */
2519
+ /**
2520
+ * The Voiceover and the PhoneticScript it was spoken from; kinds determine
2521
+ * roles regardless of endpoint positions.
2522
+ *
2523
+ * Every Voiceover must carry one: which words were said, and how, is the only
2524
+ * thing that makes speech reproducible, and a Voiceover that cannot answer it
2525
+ * is refused when its plan is built.
2526
+ */
2471
2527
  const phoneticScriptRenderRelationSpec = Object.freeze({
2472
2528
  kind: "phonetic-script-render",
2473
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["voice"]), new Set(["phonetic-script"])),
2529
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["voiceover"]), new Set(["phonetic-script"])),
2530
+ validateMetadata: isEmptyMetadata
2531
+ });
2532
+ /**
2533
+ * The timbre identity a Voiceover was synthesized with. Voice stays an
2534
+ * identity: it never carries the audio itself, so the link between the two is a
2535
+ * Relation rather than one shared entity, and several takes can name one voice.
2536
+ */
2537
+ const voiceTimbreRelationSpec = Object.freeze({
2538
+ kind: "voice-timbre",
2539
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["voiceover"]), new Set(["voice"])),
2474
2540
  validateMetadata: isEmptyMetadata
2475
2541
  });
2476
2542
  /** AudioScript was transcribed from Audio, Video, or recorded Voice; kinds determine roles. */
@@ -2478,8 +2544,8 @@ const audioScriptSourceRelationSpec = Object.freeze({
2478
2544
  kind: "audio-script-source",
2479
2545
  validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["audio-script"]), new Set([
2480
2546
  "audio",
2481
- "video",
2482
- "voice"
2547
+ "voiceover",
2548
+ "video"
2483
2549
  ])),
2484
2550
  validateMetadata: isEmptyMetadata
2485
2551
  });
@@ -2502,11 +2568,13 @@ const builtInRelationSpecs = Object.freeze([
2502
2568
  markerContentRelationSpec,
2503
2569
  axVideoMarkerRelationSpec,
2504
2570
  markerTimelineRelationSpec,
2505
- physicalAssetRelationSpec,
2571
+ fromAssetRelationSpec,
2572
+ toAssetRelationSpec,
2506
2573
  generatedRelationSpec,
2507
2574
  captionAlignmentRelationSpec,
2508
2575
  clipAnchorRelationSpec,
2509
2576
  phoneticScriptRenderRelationSpec,
2577
+ voiceTimbreRelationSpec,
2510
2578
  audioScriptSourceRelationSpec,
2511
2579
  audioScriptMarkerRelationSpec
2512
2580
  ]);
@@ -2530,13 +2598,15 @@ function hasMarkerAndSequence(endpoints) {
2530
2598
  const second = endpoints[1].current();
2531
2599
  return first.entityKind === "sequence-marker" && hasSequence(second) || second.entityKind === "sequence-marker" && hasSequence(first);
2532
2600
  }
2533
- function hasAssetAndSequence(endpoints) {
2534
- const first = endpoints[0].current();
2535
- const second = endpoints[1].current();
2536
- return first.entityKind === "asset" && hasSequence(second) || second.entityKind === "asset" && hasSequence(first);
2601
+ /** Exactly one end is the Asset, so the other end is unambiguously the bound entity. */
2602
+ function hasExactlyOneAsset(endpoints) {
2603
+ const kinds = endpoints.map((endpoint) => endpoint.current().entityKind);
2604
+ const assets = kinds.map((kind) => kind === "asset");
2605
+ if (assets[0] === assets[1]) return false;
2606
+ return declaresKind(assets[0] ? kinds[1] : kinds[0], "MediaFile");
2537
2607
  }
2538
2608
  function isGeneratedMedia(entity) {
2539
- return entity.entityKind === "video" || entity.entityKind === "image" || entity.entityKind === "audio" || entity.entityKind === "voice";
2609
+ return entity.entityKind === "video" || entity.entityKind === "image" || entity.entityKind === "audio" || entity.entityKind === "voiceover";
2540
2610
  }
2541
2611
  function isEmptyMetadata(value) {
2542
2612
  return isJsonObject(value) && Object.keys(value).length === 0;
@@ -2725,7 +2795,6 @@ var InvalidEntityRelationRowsError = class extends Error {
2725
2795
  this.name = "InvalidEntityRelationRowsError";
2726
2796
  }
2727
2797
  };
2728
- /** Decodes database rows into flat entities and validates the complete relation set. */
2729
2798
  function decodeEntityRelationRows(rows, options) {
2730
2799
  const issues = [];
2731
2800
  const entitiesById = /* @__PURE__ */ new Map();
@@ -2966,9 +3035,7 @@ function resolveGraphLayout(graph) {
2966
3035
  if (clip.entityKind !== "clip" || graph.adjacent(clip.entityId, "track-clip").length !== 1) fail(`Clip ${clip.entityId} must belong to exactly one Track`);
2967
3036
  const marker = graph.one(clip.entityId, "clip-marker", "sequence-marker");
2968
3037
  const content = graph.one(marker.entityId, "marker-content");
2969
- if (!(typedRole === "video_clip" ? ["image", "video"] : typedRole === "speech" ? ["voice"] : typedRole === "caption" ? ["caption"] : ["audio"]).includes(content.entityKind)) fail(`unsupported compatibility content kind ${content.entityKind} on ${typedRole} Track`);
2970
- const space = content.payload.coordinateSpace;
2971
- if (space !== "ms" && space !== "milliseconds" && (!object(space) || space.unit !== "ms" && space.unit !== "milliseconds")) fail(`media ${content.entityId} must state millisecond coordinates for this reader`);
3038
+ if (!(typedRole === "video_clip" ? ["image", "video"] : typedRole === "speech" ? ["voiceover"] : typedRole === "caption" ? ["caption"] : ["audio"]).includes(content.entityKind)) fail(`unsupported compatibility content kind ${content.entityKind} on ${typedRole} Track`);
2972
3039
  const source = range(marker.payload.sourceRange, "sourceRange");
2973
3040
  const durationFact = marker.payload.duration;
2974
3041
  if (!object(durationFact)) fail(`Marker ${marker.entityId} requires duration`);
@@ -3045,7 +3112,7 @@ function resolveGraphLayout(graph) {
3045
3112
  let start = fact.absoluteStart ?? 0;
3046
3113
  if (fact.mode === "anchored") {
3047
3114
  const host = resolve(fact.anchorId);
3048
- if (fact.role === "speech" && !["video_clip", "bgm"].includes(facts.get(host.clipEntityId).role)) fail("An anchored Voice must use a visual or BGM Clip as its host");
3115
+ if (fact.role === "speech" && !["video_clip", "bgm"].includes(facts.get(host.clipEntityId).role)) fail("An anchored voiceover must use a visual or BGM Clip as its host");
3049
3116
  start = host.start + fact.anchorOffset;
3050
3117
  } else if (fact.predecessor !== void 0) {
3051
3118
  const prior = resolve(fact.predecessor);
@@ -3094,7 +3161,7 @@ function resolveGraphLayout(graph) {
3094
3161
  * consulted as an editing fact. Peer IDs appear only in this derived read
3095
3162
  * contract, reconstructed from relations; they are not stored in payloads.
3096
3163
  */
3097
- function projectEntityTimeline(rows, meta) {
3164
+ function projectEntityTimeline(rows) {
3098
3165
  const graph = new EntityProjectionGraph(rows);
3099
3166
  const layout = resolveGraphLayout(graph);
3100
3167
  for (const track of layout.tracks) {
@@ -3129,8 +3196,11 @@ function projectEntityTimeline(rows, meta) {
3129
3196
  const asset = mediaAssetFacts(graph, content);
3130
3197
  requireUntrimmedAudio(content, marker, placement);
3131
3198
  const external = externalLocator(asset, "memota-speech");
3132
- const assembled = graph.adjacent(content.entityId, "phonetic-script-render").length > 0 || content.payload.voice !== void 0 ? assemblePhoneticScriptContent(rows, graph.one(content.entityId, "phonetic-script-render", "phonetic-script").entityId) : void 0;
3133
- const voice = content.payload.voice;
3199
+ const renderEdges = graph.adjacent(content.entityId, "phonetic-script-render");
3200
+ const timbreEdges = graph.adjacent(content.entityId, "voice-timbre");
3201
+ const assembled = renderEdges.length > 0 || timbreEdges.length > 0 ? assemblePhoneticScriptContent(rows, graph.one(content.entityId, "phonetic-script-render", "phonetic-script").entityId) : void 0;
3202
+ if (timbreEdges.length > 1) fail("A voiceover resolves at most one Voice identity");
3203
+ const voice = timbreEdges.length === 0 ? void 0 : graph.one(content.entityId, "voice-timbre", "voice").payload.voice;
3134
3204
  if (voice !== void 0 && (!object(voice) || voice.system !== "voice-library" || typeof voice.key !== "string")) fail("Voice must use an explicit voice-library locator");
3135
3205
  const captionIds = layout.tracks.flatMap((candidate) => candidate.role === "caption" ? candidate.clips.filter((caption) => caption.anchorClipEntityId === clip.entityId).map((caption) => caption.clipEntityId) : []);
3136
3206
  parts[clip.entityId] = { speech: {
@@ -3171,8 +3241,7 @@ function projectEntityTimeline(rows, meta) {
3171
3241
  if (marker.payload.durationPolicy !== "timeline") fail("Background Audio requires an explicit timeline-fill durationPolicy");
3172
3242
  if (marker.payload.timeRemapping !== void 0) fail("BGM timeRemapping is not supported by this reader");
3173
3243
  const source = range(marker.payload.sourceRange, "sourceRange");
3174
- const extent = content.payload.extent;
3175
- if (!object(extent) || extent.start !== source.start || extent.end !== source.end) fail("Background Audio compatibility requires the complete source");
3244
+ if (source.start !== 0 || content.payload.durationMs !== source.end) fail("Background Audio compatibility requires the complete source");
3176
3245
  parts[clip.entityId] = { bgm: {
3177
3246
  id: clip.entityId,
3178
3247
  kind: "bgm",
@@ -3183,10 +3252,6 @@ function projectEntityTimeline(rows, meta) {
3183
3252
  }
3184
3253
  }
3185
3254
  const document = {
3186
- meta: {
3187
- ...structuredClone(meta),
3188
- schema_version: ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION
3189
- },
3190
3255
  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
3256
  tracks: layout.tracks.map((track) => ({
3192
3257
  id: track.entityId,
@@ -3216,29 +3281,23 @@ function projectEntityTimeline(rows, meta) {
3216
3281
  function trackRoleOf(layout, trackEntityId) {
3217
3282
  return layout.tracks.find((track) => track.entityId === trackEntityId)?.role;
3218
3283
  }
3219
- /**
3220
- * Media facts are read from the variant itself: a media variant owns its
3221
- * Asset identity and carries `external`/`storageKey` directly. Dual-entity
3222
- * media graphs are rejected instead of being resolved through a legacy
3223
- * `physical-asset` binding.
3224
- */
3284
+ /** The Asset a media entity was made from; the bytes are named there alone. */
3225
3285
  function mediaAssetFacts(graph, content) {
3226
- if (graph.adjacent(content.entityId, "physical-asset").length > 0) fail("Media " + content.entityId + " owns its Asset identity; remove its physical-asset Relation");
3227
- if (!object(content.payload.external)) fail("Media " + content.entityId + " must carry its own Asset external identity");
3286
+ const asset = graph.one(content.entityId, "from-asset", "asset");
3228
3287
  return {
3229
- entityId: String(content.entityId),
3230
- payload: content.payload
3288
+ entityId: String(asset.entityId),
3289
+ payload: asset.payload
3231
3290
  };
3232
3291
  }
3233
- function externalLocator(asset, system) {
3234
- const value = asset.payload.external;
3235
- if (!object(value) || value.system !== system || typeof value.key !== "string" || value.key.trim() === "") fail("Asset " + asset.entityId + " requires an explicit " + system + " external key");
3236
- return { key: value.key };
3237
- }
3238
3292
  function storageKey(asset) {
3239
3293
  if (typeof asset.payload.storageKey !== "string" || asset.payload.storageKey.trim() === "") fail("Asset " + asset.entityId + " requires its factual storageKey");
3240
3294
  return asset.payload.storageKey;
3241
3295
  }
3296
+ function externalLocator(asset, system) {
3297
+ const { system: found, key } = asset.payload;
3298
+ if (found !== system || typeof key !== "string" || key.trim() === "") fail("Asset " + asset.entityId + " requires an explicit " + system + " key");
3299
+ return { key };
3300
+ }
3242
3301
  function captionStyle(value) {
3243
3302
  if (value === void 0) return void 0;
3244
3303
  if (!object(value)) fail("Caption style must be an object");
@@ -3270,8 +3329,7 @@ function captionStyle(value) {
3270
3329
  }
3271
3330
  function requireUntrimmedAudio(content, marker, placement) {
3272
3331
  const source = range(marker.payload.sourceRange, "sourceRange");
3273
- const extent = content.payload.extent;
3274
- if (!object(extent) || extent.start !== source.start || extent.end !== source.end || placement.duration !== source.end - source.start || marker.payload.timeRemapping !== void 0) fail("Voice compatibility requires the complete, unremapped take");
3332
+ if (source.start !== 0 || content.payload.durationMs !== source.end || placement.duration !== source.end || marker.payload.timeRemapping !== void 0) fail("Voiceover compatibility requires the complete, unremapped take");
3275
3333
  }
3276
3334
  function videoSpeed(content, marker, placement) {
3277
3335
  const remapping = marker.payload.timeRemapping;
@@ -3329,12 +3387,11 @@ const atomicFields = new Set([
3329
3387
  "sourceRange",
3330
3388
  "targetRange",
3331
3389
  "duration",
3332
- "extent",
3390
+ "durationMs",
3333
3391
  "timeRemapping",
3334
3392
  "textRange",
3335
3393
  "selection",
3336
- "external",
3337
- "coordinateSpace"
3394
+ "external"
3338
3395
  ]);
3339
3396
  const textFields = new Set(["text", "phonemeScript"]);
3340
3397
  const orderedFields = new Set([
@@ -3561,6 +3618,73 @@ function applyNativeListChange(list, change) {
3561
3618
  }
3562
3619
  }
3563
3620
  //#endregion
3621
+ //#region src/entity-crdt/audio-script-assets.ts
3622
+ /** A resource is a snapshot of text; the native Loro text remains the editing authority. */
3623
+ function audioScriptAssetContent(payload) {
3624
+ assembleScriptText({
3625
+ entityId: createEntityId("script-validation"),
3626
+ entityKind: "audio-script",
3627
+ payload
3628
+ });
3629
+ return { segments: structuredClone(payload.segments) };
3630
+ }
3631
+ function audioScriptAssetHash(payload) {
3632
+ return bytesToHex(sha256(new TextEncoder().encode(canonicalJson(audioScriptAssetContent(payload)))));
3633
+ }
3634
+ /**
3635
+ * The Asset a script was written out to. It is a snapshot the script owns, so
3636
+ * it is reached by `to-asset` — not `from-asset`, which would say the script
3637
+ * was produced from bytes it cannot change.
3638
+ */
3639
+ function audioScriptAssetOf(rows, entityId) {
3640
+ return boundAsset(rows, entityId, "to-asset");
3641
+ }
3642
+ /**
3643
+ * The Asset a script was made from — a transcript it does not own. A script
3644
+ * that has one never writes a snapshot of its own: the bytes it would describe
3645
+ * are already named, and rewriting them would contradict their source.
3646
+ */
3647
+ function scriptSourceAssetOf(rows, entityId) {
3648
+ return boundAsset(rows, entityId, "from-asset");
3649
+ }
3650
+ function boundAsset(rows, entityId, relationKind) {
3651
+ const link = rows.relations.find((relation) => relation.relationKind === relationKind && (String(relation.endpoint0EntityId) === entityId || String(relation.endpoint1EntityId) === entityId));
3652
+ if (link === void 0) return void 0;
3653
+ const otherId = String(link.endpoint0EntityId) === entityId ? link.endpoint1EntityId : link.endpoint0EntityId;
3654
+ return rows.entities.find((row) => String(row.entityId) === String(otherId) && row.entityKind === "asset");
3655
+ }
3656
+ /** Internal fingerprint prevents a resource from silently describing an older text revision. */
3657
+ function audioScriptAssetFields(payload) {
3658
+ return { assetContentHash: audioScriptAssetHash(payload) };
3659
+ }
3660
+ /**
3661
+ * What a PhoneticScript hands to synthesis: the base AudioScript's text as the
3662
+ * variant pronounces it, plus the variant's own phoneme and prosody control.
3663
+ *
3664
+ * The variant's content is written by a model — deciding how words are said is
3665
+ * text work, not a mechanical copy — but the bytes a synthesizer reads have to
3666
+ * leave the entity graph to reach it, because synthesis does not handle
3667
+ * entities. Writing this resource down is that crossing, and it is why speech
3668
+ * can only come from a PhoneticScript.
3669
+ */
3670
+ function phoneticScriptAssetContent(rows, entityId) {
3671
+ const assembled = assemblePhoneticScriptContent(rows, createEntityId(entityId));
3672
+ const own = assembled.phoneticScript.payload;
3673
+ return {
3674
+ text: assembled.text,
3675
+ segments: structuredClone(assembled.segments),
3676
+ ...typeof own.phonemeScript === "string" ? { phonemeScript: own.phonemeScript } : {},
3677
+ ...isJsonObject(own.prosody) ? { prosody: structuredClone(own.prosody) } : {}
3678
+ };
3679
+ }
3680
+ function phoneticScriptAssetHash(rows, entityId) {
3681
+ return bytesToHex(sha256(new TextEncoder().encode(canonicalJson(phoneticScriptAssetContent(rows, entityId)))));
3682
+ }
3683
+ /** The Asset a PhoneticScript was written out to, read from the graph. */
3684
+ function phoneticScriptAssetOf(rows, entityId) {
3685
+ return audioScriptAssetOf(rows, entityId);
3686
+ }
3687
+ //#endregion
3564
3688
  //#region src/entity-crdt/ordering.ts
3565
3689
  /** Domain order is scoped by membership; relation storage has no global order. */
3566
3690
  function entityOrderGroups(rows) {
@@ -3816,6 +3940,10 @@ var LoroEntityDocument = class LoroEntityDocument {
3816
3940
  if (id === void 0) return [];
3817
3941
  return this.snapshot().rows.relations.filter((row) => row.endpoint0EntityId === id || row.endpoint1EntityId === id);
3818
3942
  }
3943
+ /** Resolve a queued edit target through its native identity without changing history. */
3944
+ resolveCurrentEntityId(id) {
3945
+ return currentId(this.doc, id);
3946
+ }
3819
3947
  getVersion(id) {
3820
3948
  const entity = section(this.doc, "entities").get(id);
3821
3949
  if (!isJsonObject(entity)) return void 0;
@@ -3856,6 +3984,52 @@ var LoroEntityDocument = class LoroEntityDocument {
3856
3984
  materialize(this.doc);
3857
3985
  this.doc.commit({ origin: MATERIALIZE_ORIGIN });
3858
3986
  }
3987
+ /**
3988
+ * Bind a saved snapshot without rewriting text operations or resolving
3989
+ * selection conflicts. The script names the bytes the same way everything
3990
+ * else does — an Asset entity plus a Relation — and keeps only the
3991
+ * fingerprint that tells a stale snapshot from a current one. The Relation is
3992
+ * `to-asset`: these bytes are the script's own content written out, not a
3993
+ * source it was made from.
3994
+ */
3995
+ bindAudioScriptAsset(entityId, assetId, expectedContentHash) {
3996
+ const key = family(this.doc, entityId);
3997
+ const map = workspace(this.doc, key);
3998
+ if (map.get("kind") !== "audio-script" || map.get("deleted") === true) throw new Error("Resource binding requires an active AudioScript");
3999
+ const payload = entityContent(this.doc, key);
4000
+ if (audioScriptAssetHash(payload) !== expectedContentHash) throw new Error("AudioScript changed while saving its resource");
4001
+ if (!assetId.trim()) throw new Error("AudioScript resource writer returned an empty asset ID");
4002
+ const before = this.doc.oplogVersion();
4003
+ const existing = readEntityDocument(this.doc, true).rows.entities.find((row) => row.entityKind === "asset" && row.payload.system === "memota" && row.payload.key === assetId);
4004
+ const assetEntityId = existing?.entityId ?? createEntityId(`asset_${assetId}`);
4005
+ if (existing === void 0) this.transact((draft) => draft.create({
4006
+ entityId: assetEntityId,
4007
+ entityKind: "asset",
4008
+ payload: {
4009
+ system: "memota",
4010
+ key: assetId
4011
+ }
4012
+ }));
4013
+ this.transact((draft) => {
4014
+ const current = readEntityDocument(this.doc, true).rows;
4015
+ if (audioScriptAssetOf(current, entityId)?.entityId !== assetEntityId) {
4016
+ for (const relation of current.relations) if (relation.relationKind === "to-asset" && (String(relation.endpoint0EntityId) === String(entityId) || String(relation.endpoint1EntityId) === String(entityId))) draft.unlink(String(relation.relationId));
4017
+ draft.link({
4018
+ relationId: createRelationId(`relation_to-asset_${entityId}_${assetId}`),
4019
+ relationKind: "to-asset",
4020
+ endpoint0EntityId: createEntityId(entityId),
4021
+ endpoint1EntityId: assetEntityId,
4022
+ metadata: {},
4023
+ trace: {}
4024
+ });
4025
+ }
4026
+ draft.declare(entityId, audioScriptAssetFields(payload));
4027
+ });
4028
+ return this.doc.export({
4029
+ mode: "update",
4030
+ from: before
4031
+ });
4032
+ }
3859
4033
  canUndo() {
3860
4034
  return this.undoManager.canUndo();
3861
4035
  }
@@ -3869,12 +4043,12 @@ var LoroEntityDocument = class LoroEntityDocument {
3869
4043
  return this.historyEdit(() => this.undoManager.redo());
3870
4044
  }
3871
4045
  historyEdit(run) {
3872
- const before = this.snapshot();
4046
+ const before = new Map(this.snapshot().rows.entities.map((row) => [family(this.doc, row.entityId), row]));
3873
4047
  if (!run()) return false;
3874
4048
  const after = this.snapshot();
3875
4049
  for (const row of after.rows.entities) {
3876
4050
  const id = family(this.doc, row.entityId);
3877
- const previous = before.rows.entities.find((item) => family(this.doc, item.entityId) === id);
4051
+ const previous = before.get(id);
3878
4052
  if (previous && !equalJson(owned(previous.payload), owned(row.payload))) workspace(this.doc, id).ensureMergeableMap("changes").set(globalThis.crypto.randomUUID(), true);
3879
4053
  }
3880
4054
  materialize(this.doc);
@@ -4197,8 +4371,8 @@ function assertState(state) {
4197
4371
  const ids = new Set(state.rows.entities.map((row) => String(row.entityId)));
4198
4372
  for (const row of state.rows.relations) if (!ids.has(row.endpoint0EntityId) || !ids.has(row.endpoint1EntityId)) throw new Error("Relation has a missing endpoint");
4199
4373
  }
4200
- function materialize(doc) {
4201
- const state = readEntityDocument(doc);
4374
+ function materialize(doc, allowSelectionConflicts = false) {
4375
+ const state = readEntityDocument(doc, allowSelectionConflicts);
4202
4376
  const byId = section(doc, "entities");
4203
4377
  const ids = currentIds(doc);
4204
4378
  for (const [source, id] of ids) {
@@ -4219,13 +4393,37 @@ function materialize(doc) {
4219
4393
  for (const [slot, id] of Object.entries(state.project)) if (project.get(slot) !== id) project.set(slot, id);
4220
4394
  }
4221
4395
  //#endregion
4396
+ //#region src/document/document-mutation-guard.ts
4397
+ /**
4398
+ * @internal
4399
+ * Shared by synchronous mutation entry points for one document. The session
4400
+ * closes it before teardown callbacks can attempt another write.
4401
+ */
4402
+ var DocumentMutationGuard = class {
4403
+ running = false;
4404
+ closed = false;
4405
+ run(mutation) {
4406
+ if (this.closed) throw new Error("mengine document is disposed");
4407
+ if (this.running) throw new Error("mengine document mutation is already running");
4408
+ this.running = true;
4409
+ try {
4410
+ return mutation();
4411
+ } finally {
4412
+ this.running = false;
4413
+ }
4414
+ }
4415
+ close() {
4416
+ this.closed = true;
4417
+ }
4418
+ };
4419
+ //#endregion
4222
4420
  //#region src/document/mirror-read.ts
4223
4421
  /**
4224
4422
  * Project the mirror state (`VideoDocumentDraft`) into the authoritative
4225
4423
  * `VideoDocument`. The storage shape is isomorphic to the domain shape (RFC 03
4226
- * §4, reference/17 §4: meta map + a single `tracks` list + part_library), so this
4424
+ * §4, reference/17 §4: timeline + a single `tracks` list + part_library), so this
4227
4425
  * is a near-identity — it reads `time_position` / `fallback_abs_ms` JSON blobs
4228
- * back into structured values and trims empty strings, nothing more.
4426
+ * back into structured values and selects content fields. Legacy meta is ignored.
4229
4427
  *
4230
4428
  * It maps only authoritative facts (RFC 02 §6): `part_id` + `time_position`. The
4231
4429
  * projection-derived `VideoDraft` read-view (absolute time, `part_aggregations`,
@@ -4242,20 +4440,8 @@ function readVideoDocumentFromDraft(draft) {
4242
4440
  if (part != null) partLibrary[partId] = part;
4243
4441
  }
4244
4442
  const timeline = draft.timeline;
4245
- const hasTimeline = timeline?.unit_time_ms != null;
4246
- const meta = draft.meta;
4247
4443
  return {
4248
- meta: {
4249
- schema_version: meta?.schema_version === "video-document/entity-projection-v1" ? ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION : VIDEO_DOCUMENT_SCHEMA_VERSION,
4250
- draft_id: emptyToUndefined(meta?.draft_id),
4251
- project_id: emptyToUndefined(meta?.project_id),
4252
- owner_id: emptyToUndefined(meta?.owner_id),
4253
- thumbnail_storage_key: emptyToUndefined(meta?.thumbnail_storage_key),
4254
- chat_session_id: emptyToUndefined(meta?.chat_session_id),
4255
- video_creation_settings: meta?.video_creation_settings ?? void 0,
4256
- version: meta?.version ?? void 0
4257
- },
4258
- timeline: hasTimeline ? { unit_time_ms: timeline.unit_time_ms } : void 0,
4444
+ timeline: timeline?.unit_time_ms != null ? { unit_time_ms: timeline.unit_time_ms } : void 0,
4259
4445
  tracks: rowsToTracks(draft.tracks),
4260
4446
  part_library: partLibrary
4261
4447
  };
@@ -4279,9 +4465,6 @@ function rowToTrack(row) {
4279
4465
  })
4280
4466
  };
4281
4467
  }
4282
- function emptyToUndefined(value) {
4283
- return value == null || value === "" ? void 0 : value;
4284
- }
4285
4468
  //#endregion
4286
4469
  //#region src/document/mirror-adapter.ts
4287
4470
  /**
@@ -4294,33 +4477,50 @@ function emptyToUndefined(value) {
4294
4477
  * - `transact(edit, audit)` runs the whole op in one `mirror.setState` callback:
4295
4478
  * one diff, one `doc.commit` carrying the audit message. The callback edits an
4296
4479
  * immer draft, so a throw inside it discards the draft and never touches Loro
4297
- * (natural rollback) — no `guard` / `rollback` / `openTransaction` machinery.
4480
+ * (natural rollback) without a document checkout or compensating commit.
4298
4481
  * - mirror's `idSelector` (track items keyed by `part_id`) diffs reorders to
4299
4482
  * real Loro `move` ops on every lane, so per-item CRDT identity survives on
4300
4483
  * main and secondary tracks alike.
4301
4484
  */
4302
4485
  var MirrorVideoDocumentAdapter = class {
4303
4486
  doc;
4487
+ mutationGuard;
4304
4488
  mirror;
4305
- constructor(doc) {
4489
+ disposed = false;
4490
+ constructor(doc, mutationGuard = new DocumentMutationGuard()) {
4306
4491
  this.doc = doc;
4492
+ this.mutationGuard = mutationGuard;
4307
4493
  this.mirror = new Mirror({
4308
4494
  doc,
4309
- schema: videoDocumentMirrorSchema
4495
+ schema: videoDocumentMirrorSchema,
4496
+ validateUpdates: false
4310
4497
  });
4311
4498
  }
4312
4499
  snapshot() {
4313
- const document = readVideoDocumentFromDraft(this.mirror.getState());
4314
- if (this.doc.getMap("medeo").get("schema") === "medeo.entities.loro.v1") return projectEntityTimeline(readEntityDocument(this.doc).rows, document.meta);
4315
- return document;
4500
+ this.assertActive();
4501
+ if (this.isEntityDocument()) return projectEntityTimeline(readEntityDocument(this.doc).rows);
4502
+ return readVideoDocumentFromDraft(this.mirror.getState());
4316
4503
  }
4317
4504
  /**
4318
- * True once the doc holds real document content. A fresh mirror over an empty
4319
- * doc still reports defaulted root maps, so probe the stored `schema_version`
4320
- * (empty until a snapshot is bootstrapped or synced in).
4505
+ * Native Entity authority: placement is already solved by the Entity graph.
4506
+ *
4507
+ * The root check goes through `getShallowValue` first because `getMap` creates
4508
+ * the container on access — probing a legacy document for `medeo` would
4509
+ * otherwise mutate it, which a read must never do.
4321
4510
  */
4322
- hasContent() {
4323
- return (this.mirror.getState().meta?.schema_version ?? "") !== "";
4511
+ isEntityDocument() {
4512
+ const roots = this.doc.getShallowValue();
4513
+ if (!Object.hasOwn(roots, "medeo")) return false;
4514
+ return this.doc.getMap("medeo").get("schema") === LORO_ENTITY_SCHEMA;
4515
+ }
4516
+ /**
4517
+ * Release mirror subscriptions; do not reuse this adapter afterwards.
4518
+ * The caller still owns the borrowed LoroDoc.
4519
+ */
4520
+ dispose() {
4521
+ if (this.disposed) return;
4522
+ this.disposed = true;
4523
+ this.mirror.dispose();
4324
4524
  }
4325
4525
  /**
4326
4526
  * Apply one op as a single transaction. `edit` mutates the immer draft; mirror
@@ -4329,80 +4529,71 @@ var MirrorVideoDocumentAdapter = class {
4329
4529
  * skips the commit — matching the prior "empty op leaves no audit" behavior.
4330
4530
  */
4331
4531
  transact(edit, audit) {
4332
- 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");
4333
- this.mirror.setState((draft) => {
4334
- edit(draft);
4335
- }, {
4336
- origin: "mengine.semantic_editor",
4337
- message: JSON.stringify({
4338
- semantic_op: audit.kind,
4339
- payload: audit.payload,
4340
- intent: audit.intent ?? null,
4341
- actor: audit.actor ?? null
4342
- })
4532
+ this.assertActive();
4533
+ if (this.isEntityDocument()) throw new Error("Entity documents are read-only through this adapter; edit the Medeo entities instead");
4534
+ this.mutationGuard.run(() => {
4535
+ this.mirror.setState((draft) => {
4536
+ edit(draft);
4537
+ const result = validateSchema(videoDocumentMirrorSchema, {
4538
+ timeline: draft.timeline,
4539
+ tracks: draft.tracks,
4540
+ part_library: draft.part_library
4541
+ });
4542
+ if (!result.valid) throw new Error(`Invalid content update: ${result.errors?.join("; ")}`);
4543
+ }, {
4544
+ origin: "mengine.semantic_editor",
4545
+ message: JSON.stringify({
4546
+ semantic_op: audit.kind,
4547
+ payload: audit.payload,
4548
+ intent: audit.intent ?? null,
4549
+ actor: audit.actor ?? null
4550
+ })
4551
+ });
4343
4552
  });
4344
4553
  }
4554
+ assertActive() {
4555
+ if (this.disposed) throw new Error("mengine document adapter is disposed");
4556
+ }
4345
4557
  };
4346
4558
  /** Build a fresh Loro doc seeded with `document` through the mirror. */
4347
4559
  function createMirrorVideoDocument(document, options = {}) {
4348
4560
  assertValidVideoDocument(document);
4349
4561
  const doc = new LoroDoc();
4350
- if (options.peerId != null) doc.setPeerId(options.peerId);
4351
- new Mirror({
4352
- doc,
4353
- schema: videoDocumentMirrorSchema
4354
- }).setState((draft) => {
4355
- writeVideoDocumentToDraft(draft, document);
4356
- }, { origin: options.origin ?? "mengine.bootstrap" });
4357
- return doc;
4562
+ try {
4563
+ if (options.peerId != null) doc.setPeerId(options.peerId);
4564
+ const mirror = new Mirror({
4565
+ doc,
4566
+ schema: videoDocumentMirrorSchema
4567
+ });
4568
+ try {
4569
+ mirror.setState((draft) => {
4570
+ seedDraft(draft, document);
4571
+ }, { origin: options.origin ?? "mengine.bootstrap" });
4572
+ return doc;
4573
+ } finally {
4574
+ mirror.dispose();
4575
+ }
4576
+ } catch (error) {
4577
+ doc.free();
4578
+ throw error;
4579
+ }
4358
4580
  }
4359
4581
  /** Build a `MirrorVideoDocumentAdapter` over a fresh doc seeded with `document`. */
4360
4582
  function createMirrorVideoDocumentAdapter(document, options) {
4361
4583
  return new MirrorVideoDocumentAdapter(createMirrorVideoDocument(document, options));
4362
4584
  }
4363
- /** Host-only replacement of a derived view, retaining the existing Loro history. */
4364
- function applyEntityTimelineProjection(doc, projection, entityRevision) {
4365
- if (projection.meta.schema_version !== "video-document/entity-projection-v1") throw new Error("Expected an entity timeline projection");
4366
- if (!Number.isSafeInteger(entityRevision) || entityRevision < 1) throw new Error("Expected a positive entity revision");
4367
- assertValidVideoDocument(projection);
4368
- const from = doc.oplogVersion();
4369
- new Mirror({
4370
- doc,
4371
- schema: videoDocumentMirrorSchema
4372
- }).setState((draft) => writeVideoDocumentToDraft(draft, {
4373
- ...projection,
4374
- meta: {
4375
- ...projection.meta,
4376
- version: entityRevision
4377
- }
4378
- }), {
4379
- origin: "mengine.entity_projection",
4380
- message: JSON.stringify({
4381
- semantic_op: "EntityTimelineProjection",
4382
- entity_revision: entityRevision
4383
- })
4384
- });
4385
- return doc.export({
4386
- mode: "update",
4387
- from
4388
- });
4389
- }
4390
- /** Write a whole `VideoDocument` into a draft (seed a fresh doc / plain-memory state). */
4391
- function writeVideoDocumentToDraft(draft, document) {
4392
- draft.meta = {
4393
- schema_version: document.meta.schema_version,
4394
- draft_id: document.meta.draft_id ?? void 0,
4395
- project_id: document.meta.project_id ?? void 0,
4396
- owner_id: document.meta.owner_id ?? void 0,
4397
- thumbnail_storage_key: document.meta.thumbnail_storage_key ?? void 0,
4398
- chat_session_id: document.meta.chat_session_id ?? void 0,
4399
- video_creation_settings: document.meta.video_creation_settings ?? void 0,
4400
- version: document.meta.version ?? void 0
4401
- };
4585
+ /** Write a whole `VideoDocument` into the mirror draft (seed a fresh doc / plain-memory state). */
4586
+ function seedDraft(draft, document) {
4402
4587
  draft.timeline = { unit_time_ms: document.timeline?.unit_time_ms ?? void 0 };
4403
4588
  draft.tracks = (document.tracks ?? []).map(toTrackRow);
4404
4589
  draft.part_library = {};
4405
- for (const [partId, part] of Object.entries(document.part_library ?? {})) draft.part_library[partId] = partUnionToDraft(part);
4590
+ for (const [partId, part] of Object.entries(document.part_library ?? {})) {
4591
+ const createdPart = part.caption != null ? { caption: {
4592
+ ...part.caption,
4593
+ style: part.caption.style ?? {}
4594
+ } } : part;
4595
+ draft.part_library[partId] = partUnionToDraft(createdPart);
4596
+ }
4406
4597
  }
4407
4598
  /** Map a domain `Track` to a draft track row (no lane/lane_order — see schema). */
4408
4599
  function toTrackRow(track) {
@@ -4486,10 +4677,12 @@ var PlainMemoryAdapter = class {
4486
4677
  * use this so every minted id is appended to the current transact's list.
4487
4678
  */
4488
4679
  idFactory;
4680
+ readOnly;
4489
4681
  constructor(document, options) {
4490
4682
  assertValidVideoDocument(document);
4683
+ this.readOnly = options?.readOnly === true;
4491
4684
  const draft = {};
4492
- writeVideoDocumentToDraft(draft, document);
4685
+ seedDraft(draft, document);
4493
4686
  this.state = draft;
4494
4687
  this.baseIdFactory = options?.idFactory ?? generatePartId;
4495
4688
  this.idFactory = (prefix) => {
@@ -4502,7 +4695,7 @@ var PlainMemoryAdapter = class {
4502
4695
  return this._journal;
4503
4696
  }
4504
4697
  hasContent() {
4505
- return (this.state.meta?.schema_version ?? "") !== "";
4698
+ return this.state.tracks !== void 0 || this.state.timeline !== void 0;
4506
4699
  }
4507
4700
  snapshot() {
4508
4701
  return readVideoDocumentFromDraft(this.state);
@@ -4513,7 +4706,7 @@ var PlainMemoryAdapter = class {
4513
4706
  * no structural change — skip journal, matching mirror "no change, no commit".
4514
4707
  */
4515
4708
  transact(edit, audit) {
4516
- 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");
4709
+ if (this.readOnly) throw new Error("Entity documents are read-only here; edit the Medeo entities instead");
4517
4710
  this.pendingIds = [];
4518
4711
  try {
4519
4712
  const next = produce(this.state, edit);
@@ -4534,4 +4727,4 @@ function createPlainMemoryAdapter(document, options) {
4534
4727
  return new PlainMemoryAdapter(document, options);
4535
4728
  }
4536
4729
  //#endregion
4537
- export { cascadeAfterVideoClipChanges as $, selectAudioScriptSegment as A, derivePositionFromAbs as B, decodeEntityRelationRows as C, assemblePhoneticScriptContent as D, assembleEntityContent as E, createRelationId as F, validateVideoDocument as G, toVideoDocument as H, hasSequence as I, LANE_KINDS_IN_STACK_ORDER as J, partUnionSchema as K, isMediaAssetVariantKind as L, variantBaseEntityIds as M, isJsonObject as N, assembleScriptText as O, createEntityId as P, solveVideoDocument as Q, buildInitialVideoDocument as R, EntityTimelineProjectionError as S, assembleCaptionContent as T, VideoDocumentValidationError as U, fromVideoDocument as V, assertValidVideoDocument as W, findLaneTrack as X, ensureLaneTrack as Y, laneTrackId as Z, assertFieldChanges as _, recordEntries as _t, applyEntityTimelineProjection as a, resolveSpeechOverlapByShiftingVideos as at, EntityProjectionGraph as b, bytesToBase64 as bt, writeVideoDocumentToDraft as c, isEmptyVideoClip as ct, LoroEntityDocument as d, DEFAULT_UNIT_TIME_MS as dt, arrangeMainTrackSeamlessly as et, LoroEntityDraft as f, ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION as ft, applyFieldChanges as g, partUnionToDraft as gt, equalJson as h, speedOf as ht, MirrorVideoDocumentAdapter as i, resolveAllSpeechOverlaps as it, updateEntityFields as j, findComposedAudioScript as k, readVideoDocumentFromDraft as l, partDurationMs as lt, entityOrderGroups as m, effectiveVideoClipDurationMs as mt, createPlainMemoryAdapter as n, reassignSpeechesToVideoClipsByTime as nt, createMirrorVideoDocument as o, syncAggregatedClipsTimePosition as ot, readEntityDocument as p, VIDEO_DOCUMENT_SCHEMA_VERSION as pt, videoDocumentSchema as q, generatePartId as r, recalculateTimelineDuration as rt, createMirrorVideoDocumentAdapter as s, TIMELINE_SKELETON_DURATION_MS as st, PlainMemoryAdapter as t, fillMainTrackTimeGaps as tt, LORO_ENTITY_SCHEMA as u, safeDurationMs as ut, resolveFieldPath as v, videoDocumentMirrorSchema as vt, ScriptCompositionError as w, resolveEntityTimelineLayout as x, projectEntityTimeline as y, base64ToBytes as yt, buildSpeechHostMap as z };
4730
+ export { partUnionSchema as $, decodeEntityRelationRows as A, isJsonObject as B, applyFieldChanges as C, recordEntries as Ct, EntityProjectionGraph as D, projectEntityTimeline as E, bytesToBase64 as Et, assembleScriptText as F, buildInitialVideoDocument as G, createRelationId as H, findComposedAudioScript as I, fromVideoDocument as J, buildSpeechHostMap as K, selectAudioScriptSegment as L, assembleCaptionContent as M, assembleEntityContent as N, resolveEntityTimelineLayout as O, assemblePhoneticScriptContent as P, validateVideoDocument as Q, updateEntityFields as R, equalJson as S, partUnionToDraft as St, resolveFieldPath as T, base64ToBytes as Tt, hasSequence as U, createEntityId as V, isMediaAssetVariantKind as W, VideoDocumentValidationError as X, toVideoDocument as Y, assertValidVideoDocument as Z, audioScriptAssetOf as _, partDurationMs as _t, createMirrorVideoDocument as a, laneTrackId as at, phoneticScriptAssetOf as b, effectiveVideoClipDurationMs as bt, DocumentMutationGuard as c, arrangeMainTrackSeamlessly as ct, LoroEntityDraft as d, recalculateTimelineDuration as dt, videoDocumentSchema as et, readEntityDocument as f, resolveAllSpeechOverlaps as ft, audioScriptAssetHash as g, isEmptyVideoClip as gt, audioScriptAssetFields as h, TIMELINE_SKELETON_DURATION_MS as ht, MirrorVideoDocumentAdapter as i, hasResolvedPlacement as it, ScriptCompositionError as j, EntityTimelineProjectionError as k, LORO_ENTITY_SCHEMA as l, fillMainTrackTimeGaps as lt, audioScriptAssetContent as m, syncAggregatedClipsTimePosition as mt, createPlainMemoryAdapter as n, ensureLaneTrack as nt, createMirrorVideoDocumentAdapter as o, solveVideoDocument as ot, entityOrderGroups as p, resolveSpeechOverlapByShiftingVideos as pt, derivePositionFromAbs as q, generatePartId as r, findLaneTrack as rt, readVideoDocumentFromDraft as s, cascadeAfterVideoClipChanges as st, PlainMemoryAdapter as t, LANE_KINDS_IN_STACK_ORDER as tt, LoroEntityDocument as u, reassignSpeechesToVideoClipsByTime as ut, phoneticScriptAssetContent as v, safeDurationMs as vt, assertFieldChanges as w, videoDocumentMirrorSchema as wt, scriptSourceAssetOf as x, speedOf as xt, phoneticScriptAssetHash as y, DEFAULT_UNIT_TIME_MS as yt, variantBaseEntityIds as z };