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

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)
@@ -1206,7 +1177,8 @@ function toAuthoritativePartLibrary(partLibrary) {
1206
1177
  if (typeof part !== "object" || part == null) continue;
1207
1178
  if (part.video_clip != null) out[partId] = { video_clip: withTrimWindowFromDuration(part.video_clip) };
1208
1179
  else if (part.speech != null) {
1209
- const { duration_ms, rest } = splitDurationMs(part.speech);
1180
+ const { origin_asset_system: _originAssetSystem, ...readSpeech } = part.speech;
1181
+ const { duration_ms, rest } = splitDurationMs(readSpeech);
1210
1182
  out[partId] = { speech: {
1211
1183
  ...rest,
1212
1184
  media_duration_ms: rest.media_duration_ms ?? duration_ms
@@ -1296,8 +1268,8 @@ function deriveItem(item, isMain, speechHost, partLibrary) {
1296
1268
  };
1297
1269
  }
1298
1270
  /**
1299
- * Project the authoritative `VideoDocument` back into the legacy `VideoDraft`
1300
- * read-view, solving each item's absolute position, the `part_aggregations`, and
1271
+ * Project `VideoDocument` into `VideoDraftContent`, solving each item's
1272
+ * absolute position, the `part_aggregations`, and
1301
1273
  * the total duration via the timeline-core cascade.
1302
1274
  *
1303
1275
  * The read-view is a compatibility contract, and the IDL declares
@@ -1311,33 +1283,26 @@ function deriveItem(item, isMain, speechHost, partLibrary) {
1311
1283
  * These two are defaultable because the projection knows their values on its own:
1312
1284
  * absent `unit_time_ms` means "no display granularity was ever stated" and absent
1313
1285
  * `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.
1286
+ * here — it belongs to Director assembly using the server envelope update_seq.
1287
+ * Neither business metadata nor a revision counter belongs in content projection.
1316
1288
  */
1317
- function fromVideoDocument(document) {
1289
+ function fromVideoDocument(document, options = {}) {
1318
1290
  assertValidVideoDocument(document);
1319
- const view = solveVideoDocument(document);
1291
+ const view = solveVideoDocument(document, options);
1320
1292
  const tracks = document.tracks ?? [];
1321
1293
  const mainTrack = tracks.find((t) => t.parts_kind === "video_clip");
1322
1294
  const aboveTracks = tracks.filter((t) => t.parts_kind === "caption");
1323
1295
  const belowTracks = tracks.filter((t) => t.parts_kind === "speech" || t.parts_kind === "bgm");
1324
1296
  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
1297
  timeline: {
1330
1298
  duration_ms: view.durationMs,
1331
1299
  unit_time_ms: document.timeline?.unit_time_ms ?? 33.333
1332
1300
  },
1333
- video_creation_settings: clone$1(document.meta.video_creation_settings),
1334
- chat_session_id: document.meta.chat_session_id,
1335
1301
  main_track: draftTrack(mainTrack, view.absByPartId),
1336
1302
  above_main_tracks: aboveTracks.map((t) => draftTrack(t, view.absByPartId)),
1337
1303
  below_main_tracks: belowTracks.map((t) => draftTrack(t, view.absByPartId)),
1338
1304
  part_aggregations: view.aggregations,
1339
- part_library: toReadViewPartLibrary(view.partLibrary, view.durationMs, view.derivedFillerPartIds),
1340
- version: document.meta.version
1305
+ part_library: toReadViewPartLibrary(view.partLibrary, view.durationMs, view.derivedFillerPartIds)
1341
1306
  };
1342
1307
  }
1343
1308
  /**
@@ -1444,7 +1409,7 @@ function clone$1(value) {
1444
1409
  //#region src/document/initial-document.ts
1445
1410
  /**
1446
1411
  * Build the `VideoDocument` a newly created project starts from: the caller's
1447
- * project facts, no parts, and one empty track per lane.
1412
+ * no parts and one empty track per lane. Business facts remain in Director.
1448
1413
  *
1449
1414
  * The empty lane tracks are the reason this function exists rather than callers
1450
1415
  * assembling a document inline. Lane tracks are otherwise minted lazily by the
@@ -1465,18 +1430,8 @@ function clone$1(value) {
1465
1430
  * supplies, and total duration is derived on read (RFC 02 §6), so a new document
1466
1431
  * has no timeline fact to state.
1467
1432
  */
1468
- function buildInitialVideoDocument(facts) {
1433
+ function buildInitialVideoDocument() {
1469
1434
  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
1435
  timeline: void 0,
1481
1436
  tracks: emptyLaneTracks(),
1482
1437
  part_library: {}
@@ -1493,36 +1448,101 @@ function emptyLaneTracks() {
1493
1448
  }
1494
1449
  //#endregion
1495
1450
  //#region ../medeo-dsl/src/entities.ts
1451
+ /**
1452
+ * What every known entity kind declares.
1453
+ *
1454
+ * `kinds` is a multi-declaration: every entry holds at the same time, and each
1455
+ * is a property shared with other entity kinds — what the object *is* comes
1456
+ * from `entityKind` and is never repeated here. The order carries no priority,
1457
+ * endpoint role, or edit sequence — an operation asks whether the entity
1458
+ * declares the kind it needs, never what its "main type" is. An entity kind
1459
+ * that shares nothing declares nothing.
1460
+ *
1461
+ * `Caption` declares `Sequence` on purpose: it owns the display timing of the
1462
+ * one AudioScript segment it shows, which is what admits it into a Clip.
1463
+ * `Voice` declares no `Sequence` — it is a timbre identity, not playable
1464
+ * content; rendered speech is a `voiceover` entity.
1465
+ *
1466
+ * `MediaFile` says only that an entity of that kind may be bound to stored bytes,
1467
+ * and asks nothing of its payload. Which bytes, and which way the content
1468
+ * flowed, is the `from-asset` / `to-asset` Relation's answer alone.
1469
+ */
1470
+ const ENTITY_KINDS = Object.freeze({
1471
+ axvideo: Object.freeze([
1472
+ "Container",
1473
+ "Sequence",
1474
+ "Visual",
1475
+ "Audible"
1476
+ ]),
1477
+ timeline: Object.freeze(["Container"]),
1478
+ track: Object.freeze(["Container"]),
1479
+ clip: Object.freeze(["Container"]),
1480
+ asset: Object.freeze([]),
1481
+ video: Object.freeze([
1482
+ "Sequence",
1483
+ "Visual",
1484
+ "Audible",
1485
+ "MediaFile"
1486
+ ]),
1487
+ audio: Object.freeze([
1488
+ "Sequence",
1489
+ "Audible",
1490
+ "MediaFile"
1491
+ ]),
1492
+ voiceover: Object.freeze([
1493
+ "Sequence",
1494
+ "Audible",
1495
+ "MediaFile"
1496
+ ]),
1497
+ image: Object.freeze([
1498
+ "Sequence",
1499
+ "Visual",
1500
+ "MediaFile"
1501
+ ]),
1502
+ voice: Object.freeze(["Container", "IPBound"]),
1503
+ "sequence-marker": Object.freeze([]),
1504
+ viewport: Object.freeze([]),
1505
+ "audio-script": Object.freeze(["SegmentText", "MediaFile"]),
1506
+ "phonetic-script": Object.freeze(["SegmentText", "MediaFile"]),
1507
+ caption: Object.freeze([
1508
+ "SegmentText",
1509
+ "Sequence",
1510
+ "MediaFile"
1511
+ ])
1512
+ });
1513
+ /** What a known kind declares; extension kinds declare nothing through this table. */
1514
+ function kindsOf(entityKind) {
1515
+ return isKnownEntityKind(entityKind) ? ENTITY_KINDS[entityKind] : [];
1516
+ }
1517
+ function declaresKind(entityKind, kind) {
1518
+ return kindsOf(entityKind).includes(kind);
1519
+ }
1520
+ /**
1521
+ * Sequence admission is a declaration, not a shape: a known kind is admitted
1522
+ * because its `kinds` say so, and a malformed payload is a separate, louder
1523
+ * failure than a silently non-Sequence entity. Extension kinds declare nothing
1524
+ * through the table and stay structurally detected.
1525
+ */
1496
1526
  function hasSequence(entity) {
1497
- if (isKnownNonSequenceKind(entity.entityKind) || isReservedEntityKind(entity.entityKind)) return false;
1527
+ if (isReservedEntityKind(entity.entityKind)) return false;
1528
+ if (isKnownEntityKind(entity.entityKind)) return declaresKind(entity.entityKind, "Sequence");
1498
1529
  return isSequenceFields(entity);
1499
1530
  }
1500
1531
  function isSequenceFields(value) {
1501
1532
  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";
1533
+ const duration = value.durationMs;
1534
+ return duration === null || typeof duration === "number" && Number.isSafeInteger(duration) && duration > 0;
1512
1535
  }
1513
1536
  function isMediaAssetVariantKind(kind) {
1514
- return kind === "video" || kind === "image" || kind === "audio" || kind === "voice";
1537
+ return kind === "video" || kind === "image" || kind === "audio" || kind === "voiceover";
1515
1538
  }
1516
1539
  function isKnownEntityKind(kind) {
1517
- return isKnownSequenceKind(kind) || isKnownNonSequenceKind(kind);
1540
+ return Object.hasOwn(ENTITY_KINDS, kind);
1518
1541
  }
1519
1542
  function isReservedEntityKind(kind) {
1520
1543
  const normalized = kind.toLowerCase().replaceAll("-", "").replaceAll("_", "");
1521
1544
  return normalized === "speech" || normalized === "videodocument";
1522
1545
  }
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
1546
  function isRecord$1(value) {
1527
1547
  return typeof value === "object" && value != null && !Array.isArray(value);
1528
1548
  }
@@ -1706,14 +1726,17 @@ function assembleCaptionContent(rows, captionEntityId) {
1706
1726
  requireEntityKind(rows, captionEntityId, "caption");
1707
1727
  const caption = assembleEntityContent(rows, captionEntityId);
1708
1728
  const script = findComposedAudioScript(rows, captionEntityId, "caption");
1709
- const segment = selectAudioScriptSegment({
1729
+ const selection = captionSelection(caption);
1730
+ const composed = {
1710
1731
  ...script,
1711
1732
  payload: caption.payload
1712
- }, captionSelection(caption));
1733
+ };
1734
+ const segment = selectAudioScriptSegment(composed, selection);
1713
1735
  return {
1714
1736
  caption,
1715
1737
  audioScript: script,
1716
1738
  segments: [segment],
1739
+ segmentIndex: scriptSegments(composed).findIndex((entry) => entry.segmentId === selection.segmentId),
1717
1740
  text: segment.text
1718
1741
  };
1719
1742
  }
@@ -1781,18 +1804,7 @@ function entityToRow(entity) {
1781
1804
  }
1782
1805
  //#endregion
1783
1806
  //#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
- }
1807
+ /** An entity is made from at most one Asset; two would leave its bytes ambiguous. */
1796
1808
  /** Validates one complete set of entities and its authoritative Relation rows. */
1797
1809
  function validateEntityRelationSet(entityRefs, index, options) {
1798
1810
  const { entities, issues } = collectRelatedEntities(entityRefs, index);
@@ -1814,7 +1826,6 @@ function validateEntityRelationSet(entityRefs, index, options) {
1814
1826
  const current = entity.current();
1815
1827
  const entityIssues = validateEntity(entity, entityIds);
1816
1828
  issues.push(...entityIssues);
1817
- if (isMediaAssetVariantKind(current.entityKind)) issues.push(...validateMediaAssetIdentity(entity, index));
1818
1829
  if (entityIssues.some((issue) => issue.code === "invalid_entity_payload")) continue;
1819
1830
  if (current.entityKind === "sequence-marker") {
1820
1831
  const marker = entity;
@@ -2039,24 +2050,25 @@ function validateMarkerSourceBounds(marker, index, compare) {
2039
2050
  const content = contentEdges[0]?.other(marker)?.deref()?.current();
2040
2051
  if (content == null || !hasSequence(content)) return [];
2041
2052
  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 [];
2053
+ const startVsOrigin = compare(sourceRange.start, 0);
2054
+ const endVsDuration = content.durationMs == null ? void 0 : compare(sourceRange.end, content.durationMs);
2055
+ if (!Number.isNaN(startVsOrigin) && startVsOrigin >= 0 && (endVsDuration == null || !Number.isNaN(endVsDuration) && endVsDuration <= 0)) return [];
2045
2056
  return [{
2046
2057
  code: "marker_source_out_of_bounds",
2047
2058
  entityId: marker.entityId,
2048
- message: `Sequence Marker sourceRange must stay within content "${content.entityId}" extent`
2059
+ message: `Sequence Marker sourceRange must stay within content "${content.entityId}" duration`
2049
2060
  }];
2050
2061
  }
2051
2062
  function validateSequenceComposition(entity) {
2052
2063
  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 [];
2064
+ const expected = expectedDurationShape(current.entityKind);
2065
+ if (expected === void 0) return [];
2066
+ const duration = current.durationMs;
2067
+ if ((duration === null ? "none" : isPositiveInteger(duration) ? "own" : "invalid") === expected) return [];
2056
2068
  return [{
2057
2069
  code: "invalid_sequence_composition",
2058
2070
  entityId: current.entityId,
2059
- message: `${current.entityKind} must compose ${expected.extent} / ${expected.sampling} Sequence semantics`
2071
+ message: expected === "own" ? `${current.entityKind} must state its own durationMs in whole milliseconds` : `${current.entityKind} has no intrinsic length; durationMs must be null`
2060
2072
  }];
2061
2073
  }
2062
2074
  function validateEntity(entity, entityIds = /* @__PURE__ */ new Set()) {
@@ -2087,6 +2099,7 @@ function validateKnownEntityPayload(entity) {
2087
2099
  if (value.lifecycle !== void 0 && !isRecord(value.lifecycle)) problems.push("lifecycle must be an object when present");
2088
2100
  if (entity.entityKind === "audio-script" || entity.entityKind === "phonetic-script") {
2089
2101
  for (const key of [
2102
+ "durationMs",
2090
2103
  "extent",
2091
2104
  "sampling",
2092
2105
  "coordinateSpace",
@@ -2095,7 +2108,7 @@ function validateKnownEntityPayload(entity) {
2095
2108
  "duration",
2096
2109
  "startMs",
2097
2110
  "endMs"
2098
- ]) if (Object.hasOwn(value, key)) problems.push(`${key} is not intrinsic script data; assign an external annotation Marker`);
2111
+ ]) if (Object.hasOwn(value, key)) problems.push(`${key} is not intrinsic script data; assign an annotation Marker instead`);
2099
2112
  }
2100
2113
  switch (entity.entityKind) {
2101
2114
  case "track":
@@ -2105,19 +2118,25 @@ function validateKnownEntityPayload(entity) {
2105
2118
  break;
2106
2119
  case "video":
2107
2120
  case "audio":
2108
- case "voice":
2121
+ validateDurationPayload(value, "own", problems);
2122
+ break;
2109
2123
  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);
2124
+ validateDurationPayload(value, "own", problems);
2125
+ validateCaptionPayload(value, problems);
2126
+ break;
2127
+ case "voice":
2128
+ validateVoicePayload(value, problems);
2114
2129
  break;
2115
2130
  case "image":
2116
- validateSequencePayload(value, "unbounded", "constant", problems);
2117
- validateMediaAssetFields(value, problems);
2131
+ validateDurationPayload(value, "none", problems);
2118
2132
  break;
2119
2133
  case "axvideo":
2120
- validateSequencePayload(value, "bounded", "derived", problems);
2134
+ for (const retired of [
2135
+ "extent",
2136
+ "sampling",
2137
+ "coordinateSpace",
2138
+ "durationMs"
2139
+ ]) if (Object.hasOwn(value, retired)) problems.push(`${retired} is derived from the Timeline, not stored`);
2121
2140
  break;
2122
2141
  case "sequence-marker":
2123
2142
  validateMarkerPayload(value, problems);
@@ -2142,17 +2161,27 @@ function validateKnownEntityPayload(entity) {
2142
2161
  }
2143
2162
  return problems;
2144
2163
  }
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");
2164
+ /**
2165
+ * Content states its own length and nothing else. The retired `extent` carried
2166
+ * a start as well, which had no meaning of its own and drifted into holding a
2167
+ * position; rejecting it keeps that mistake unwritable.
2168
+ */
2169
+ function validateDurationPayload(value, expected, problems) {
2170
+ for (const retired of [
2171
+ "extent",
2172
+ "sampling",
2173
+ "coordinateSpace"
2174
+ ]) if (Object.hasOwn(value, retired)) problems.push(`${retired} is retired; state durationMs instead`);
2175
+ if (!Object.hasOwn(value, "durationMs")) {
2176
+ problems.push("durationMs is required");
2177
+ return;
2153
2178
  }
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");
2179
+ const duration = value.durationMs;
2180
+ if (expected === "none") {
2181
+ if (duration !== null) problems.push("durationMs must be null; this content has no intrinsic length");
2182
+ return;
2183
+ }
2184
+ if (!isPositiveInteger(duration)) problems.push("durationMs must be positive whole milliseconds");
2156
2185
  }
2157
2186
  function validateMarkerPayload(value, problems) {
2158
2187
  validateRange(value.sourceRange, "sourceRange", true, problems);
@@ -2188,40 +2217,38 @@ function validateSegmentRanges(value, problems) {
2188
2217
  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
2218
  }
2190
2219
  }
2220
+ /** The Asset entity is the locator; it states where the bytes live and nothing else. */
2191
2221
  function validateAssetPayload(value, problems) {
2192
- validateExternalLocator(value, problems);
2193
- validateStorageKey(value, problems);
2222
+ if (value.system !== "memota" && value.system !== "memota-speech") problems.push("system must be \"memota\" or \"memota-speech\"");
2223
+ if (typeof value.key !== "string" || value.key.trim() === "") problems.push("key must be a non-empty string");
2194
2224
  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
2225
  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
2226
  }
2217
2227
  function validateStorageKey(value, problems) {
2218
2228
  if (value.storageKey !== void 0 && (typeof value.storageKey !== "string" || value.storageKey.trim() === "")) problems.push("storageKey must be a non-empty string when present");
2219
2229
  }
2230
+ /**
2231
+ * Voice is a timbre identity, not playable content: it owns the voice-library
2232
+ * descriptor and nothing that would make it look like media or a Sequence. The
2233
+ * rendered voiceover is an `audio` entity bound by a `voice-timbre` Relation.
2234
+ */
2220
2235
  function validateVoicePayload(value, problems) {
2236
+ for (const key of [
2237
+ "durationMs",
2238
+ "extent",
2239
+ "sampling",
2240
+ "coordinateSpace",
2241
+ "system",
2242
+ "key",
2243
+ "storageKey"
2244
+ ]) if (Object.hasOwn(value, key)) problems.push(`${key} belongs to the rendered Audio, not to the Voice identity`);
2221
2245
  const voice = value.voice;
2222
- if (voice === void 0) return;
2246
+ if (voice === void 0) {
2247
+ problems.push("voice is required; a Voice entity is its voice-library identity");
2248
+ return;
2249
+ }
2223
2250
  if (!isRecord(voice)) {
2224
- problems.push("voice must be an object when present");
2251
+ problems.push("voice must be an object");
2225
2252
  return;
2226
2253
  }
2227
2254
  if (voice.system !== "voice-library") problems.push("voice.system must be \"voice-library\"");
@@ -2229,7 +2256,6 @@ function validateVoicePayload(value, problems) {
2229
2256
  if (voice.name !== void 0 && typeof voice.name !== "string") problems.push("voice.name must be a string when present");
2230
2257
  }
2231
2258
  function validateCaptionPayload(value, problems) {
2232
- validateExternalLocator(value, problems);
2233
2259
  if (value.segmentRanges !== void 0) validateSegmentRanges(value.segmentRanges, problems);
2234
2260
  if (Object.hasOwn(value, "selections")) problems.push("selections is forbidden; Caption requires one selection object");
2235
2261
  const selection = value.selection;
@@ -2353,15 +2379,13 @@ function isOwnedLocalEntityValuePath(entityKind, path) {
2353
2379
  if ([
2354
2380
  "video",
2355
2381
  "audio",
2356
- "voice",
2357
2382
  "image",
2358
2383
  "caption",
2359
2384
  "axvideo"
2360
- ].includes(entityKind) && /^(?:sampling|coordinateSpace|coordinateSpace\.unit|extent\.kind)$/.test(path)) return true;
2385
+ ].includes(entityKind) && /^durationMs$/.test(path)) return true;
2361
2386
  if (entityKind === "sequence-marker" && /^(?:durationPolicy|duration\.mode|timeRemapping\.(?:kind|mode))$/.test(path)) return true;
2362
2387
  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;
2388
+ if (entityKind === "asset" && /^(?:system|key|storageKey)$/.test(path)) return true;
2365
2389
  if (entityKind === "voice" && /^voice\.(?:system|key|name)$/.test(path)) return true;
2366
2390
  if (entityKind === "caption" && (path.startsWith("style.") || path === "selection.segmentId")) return true;
2367
2391
  if ([
@@ -2416,19 +2440,16 @@ function collectRelatedEntities(entityRefs, index) {
2416
2440
  issues
2417
2441
  };
2418
2442
  }
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
- };
2443
+ /**
2444
+ * What a kind must state about its own length. An AXVideo is absent here on
2445
+ * purpose: its Timeline decides its length, so it stores none.
2446
+ */
2447
+ function expectedDurationShape(kind) {
2448
+ if (kind === "video" || kind === "audio" || kind === "caption") return "own";
2449
+ if (kind === "image") return "none";
2450
+ }
2451
+ function isPositiveInteger(value) {
2452
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
2432
2453
  }
2433
2454
  function ofKind(relations, kind) {
2434
2455
  return relations.filter((relation) => relation.kind === kind);
@@ -2445,9 +2466,38 @@ const markerContentRelationSpec = Object.freeze({
2445
2466
  validateEndpoints: (endpoints) => hasMarkerAndSequence(endpoints),
2446
2467
  validateMetadata: isEmptyMetadata
2447
2468
  });
2448
- const physicalAssetRelationSpec = Object.freeze({
2449
- kind: "physical-asset",
2450
- validateEndpoints: (endpoints) => hasAssetAndSequence(endpoints),
2469
+ /**
2470
+ * `from-asset(entity, asset)` — the entity was made from that stored resource.
2471
+ *
2472
+ * Eligibility is declared: the non-Asset endpoint must carry the `MediaFile` kind,
2473
+ * which says an entity of that kind can be bound to stored bytes and asks
2474
+ * nothing of its payload. The bytes stay named in exactly one place, so no
2475
+ * entity copies the locator into its own row and one Asset can back several
2476
+ * entities.
2477
+ */
2478
+ const fromAssetRelationSpec = Object.freeze({
2479
+ kind: "from-asset",
2480
+ validateEndpoints: (endpoints) => hasExactlyOneAsset(endpoints),
2481
+ validateMetadata: isAssetBinding
2482
+ });
2483
+ /**
2484
+ * `to-asset(entity, asset)` — the entity's content was written out to that
2485
+ * stored resource.
2486
+ *
2487
+ * The mirror of `from-asset`, and deliberately a separate relation kind rather
2488
+ * than a second declared kind: eligibility is the same either way, but which
2489
+ * way the content flowed is what says whether an entity may still be edited.
2490
+ * An entity reached by `from-asset` was produced from bytes it does not own,
2491
+ * so changing it would contradict them. An entity with a `to-asset` owns its
2492
+ * content and the resource is a snapshot of it, which is what goes stale and
2493
+ * gets written again. A transcript that is later edited carries both at once,
2494
+ * and only the distinct relation kinds keep them apart.
2495
+ *
2496
+ * Writing a snapshot is host assembly; nothing a script authors creates one.
2497
+ */
2498
+ const toAssetRelationSpec = Object.freeze({
2499
+ kind: "to-asset",
2500
+ validateEndpoints: (endpoints) => hasExactlyOneAsset(endpoints),
2451
2501
  validateMetadata: isAssetBinding
2452
2502
  });
2453
2503
  /** `generated(output, input)` means endpoint 0 was generated from endpoint 1. */
@@ -2458,7 +2508,7 @@ const generatedRelationSpec = Object.freeze({
2458
2508
  });
2459
2509
  const captionAlignmentRelationSpec = Object.freeze({
2460
2510
  kind: "caption-alignment",
2461
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["caption"]), new Set(["audio", "voice"])),
2511
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["caption"]), new Set(["voiceover"])),
2462
2512
  validateMetadata: isCaptionAlignmentMetadata
2463
2513
  });
2464
2514
  /** `clip-anchor(child, host)` means endpoint 0 follows endpoint 1. */
@@ -2467,10 +2517,27 @@ const clipAnchorRelationSpec = Object.freeze({
2467
2517
  validateEndpoints: (endpoints) => endpoints[0].current().entityKind === "clip" && endpoints[1].current().entityKind === "clip",
2468
2518
  validateMetadata: isEmptyMetadata
2469
2519
  });
2470
- /** Voice is generated from PhoneticScript; kinds determine roles regardless of endpoint positions. */
2520
+ /**
2521
+ * The Voiceover and the PhoneticScript it was spoken from; kinds determine
2522
+ * roles regardless of endpoint positions.
2523
+ *
2524
+ * Every Voiceover must carry one: which words were said, and how, is the only
2525
+ * thing that makes speech reproducible, and a Voiceover that cannot answer it
2526
+ * is refused when its plan is built.
2527
+ */
2471
2528
  const phoneticScriptRenderRelationSpec = Object.freeze({
2472
2529
  kind: "phonetic-script-render",
2473
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["voice"]), new Set(["phonetic-script"])),
2530
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["voiceover"]), new Set(["phonetic-script"])),
2531
+ validateMetadata: isEmptyMetadata
2532
+ });
2533
+ /**
2534
+ * The timbre identity a Voiceover was synthesized with. Voice stays an
2535
+ * identity: it never carries the audio itself, so the link between the two is a
2536
+ * Relation rather than one shared entity, and several takes can name one voice.
2537
+ */
2538
+ const voiceTimbreRelationSpec = Object.freeze({
2539
+ kind: "voice-timbre",
2540
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["voiceover"]), new Set(["voice"])),
2474
2541
  validateMetadata: isEmptyMetadata
2475
2542
  });
2476
2543
  /** AudioScript was transcribed from Audio, Video, or recorded Voice; kinds determine roles. */
@@ -2478,8 +2545,8 @@ const audioScriptSourceRelationSpec = Object.freeze({
2478
2545
  kind: "audio-script-source",
2479
2546
  validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["audio-script"]), new Set([
2480
2547
  "audio",
2481
- "video",
2482
- "voice"
2548
+ "voiceover",
2549
+ "video"
2483
2550
  ])),
2484
2551
  validateMetadata: isEmptyMetadata
2485
2552
  });
@@ -2502,11 +2569,13 @@ const builtInRelationSpecs = Object.freeze([
2502
2569
  markerContentRelationSpec,
2503
2570
  axVideoMarkerRelationSpec,
2504
2571
  markerTimelineRelationSpec,
2505
- physicalAssetRelationSpec,
2572
+ fromAssetRelationSpec,
2573
+ toAssetRelationSpec,
2506
2574
  generatedRelationSpec,
2507
2575
  captionAlignmentRelationSpec,
2508
2576
  clipAnchorRelationSpec,
2509
2577
  phoneticScriptRenderRelationSpec,
2578
+ voiceTimbreRelationSpec,
2510
2579
  audioScriptSourceRelationSpec,
2511
2580
  audioScriptMarkerRelationSpec
2512
2581
  ]);
@@ -2530,13 +2599,15 @@ function hasMarkerAndSequence(endpoints) {
2530
2599
  const second = endpoints[1].current();
2531
2600
  return first.entityKind === "sequence-marker" && hasSequence(second) || second.entityKind === "sequence-marker" && hasSequence(first);
2532
2601
  }
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);
2602
+ /** Exactly one end is the Asset, so the other end is unambiguously the bound entity. */
2603
+ function hasExactlyOneAsset(endpoints) {
2604
+ const kinds = endpoints.map((endpoint) => endpoint.current().entityKind);
2605
+ const assets = kinds.map((kind) => kind === "asset");
2606
+ if (assets[0] === assets[1]) return false;
2607
+ return declaresKind(assets[0] ? kinds[1] : kinds[0], "MediaFile");
2537
2608
  }
2538
2609
  function isGeneratedMedia(entity) {
2539
- return entity.entityKind === "video" || entity.entityKind === "image" || entity.entityKind === "audio" || entity.entityKind === "voice";
2610
+ return entity.entityKind === "video" || entity.entityKind === "image" || entity.entityKind === "audio" || entity.entityKind === "voiceover";
2540
2611
  }
2541
2612
  function isEmptyMetadata(value) {
2542
2613
  return isJsonObject(value) && Object.keys(value).length === 0;
@@ -2725,7 +2796,6 @@ var InvalidEntityRelationRowsError = class extends Error {
2725
2796
  this.name = "InvalidEntityRelationRowsError";
2726
2797
  }
2727
2798
  };
2728
- /** Decodes database rows into flat entities and validates the complete relation set. */
2729
2799
  function decodeEntityRelationRows(rows, options) {
2730
2800
  const issues = [];
2731
2801
  const entitiesById = /* @__PURE__ */ new Map();
@@ -2966,9 +3036,7 @@ function resolveGraphLayout(graph) {
2966
3036
  if (clip.entityKind !== "clip" || graph.adjacent(clip.entityId, "track-clip").length !== 1) fail(`Clip ${clip.entityId} must belong to exactly one Track`);
2967
3037
  const marker = graph.one(clip.entityId, "clip-marker", "sequence-marker");
2968
3038
  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`);
3039
+ assertTrackContent(graph, typedRole, content);
2972
3040
  const source = range(marker.payload.sourceRange, "sourceRange");
2973
3041
  const durationFact = marker.payload.duration;
2974
3042
  if (!object(durationFact)) fail(`Marker ${marker.entityId} requires duration`);
@@ -3045,7 +3113,7 @@ function resolveGraphLayout(graph) {
3045
3113
  let start = fact.absoluteStart ?? 0;
3046
3114
  if (fact.mode === "anchored") {
3047
3115
  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");
3116
+ 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
3117
  start = host.start + fact.anchorOffset;
3050
3118
  } else if (fact.predecessor !== void 0) {
3051
3119
  const prior = resolve(fact.predecessor);
@@ -3087,6 +3155,32 @@ function resolveGraphLayout(graph) {
3087
3155
  }))
3088
3156
  };
3089
3157
  }
3158
+ function assertTrackContent(graph, role, content) {
3159
+ if (role === "speech") {
3160
+ assertSpeechContent(graph, content);
3161
+ return;
3162
+ }
3163
+ if (!(role === "video_clip" ? ["image", "video"] : role === "caption" ? ["caption"] : ["audio"]).includes(content.entityKind)) fail(`unsupported compatibility content kind ${content.entityKind} on ${role} Track`);
3164
+ }
3165
+ function assertSpeechContent(graph, content) {
3166
+ const assetLinks = graph.rows.relations.filter((relation) => relation.relationKind === "from-asset" && (relation.endpoint0EntityId === content.entityId || relation.endpoint1EntityId === content.entityId));
3167
+ if (assetLinks.length !== 1 || assetLinks[0].endpoint0EntityId !== content.entityId) fail("Speech content requires one ordered from-asset binding");
3168
+ const asset = graph.entity(assetLinks[0].endpoint1EntityId, "asset");
3169
+ if (content.entityKind === "voiceover") {
3170
+ const renders = graph.rows.relations.filter((relation) => relation.relationKind === "phonetic-script-render" && (relation.endpoint0EntityId === content.entityId || relation.endpoint1EntityId === content.entityId));
3171
+ if (renders.length !== 1 || renders[0].endpoint0EntityId !== content.entityId || graph.entity(renders[0].endpoint1EntityId).entityKind !== "phonetic-script") fail("Synthesized Speech Voiceover requires one ordered PhoneticScript render");
3172
+ if (asset.payload.system !== "memota-speech") fail("Synthesized Speech Voiceover requires an explicit memota-speech key");
3173
+ return;
3174
+ }
3175
+ if (content.entityKind === "audio") {
3176
+ const sources = graph.rows.relations.filter((relation) => relation.relationKind === "audio-script-source" && (relation.endpoint0EntityId === content.entityId || relation.endpoint1EntityId === content.entityId));
3177
+ if (sources.length > 1) fail("Recorded speech has an ambiguous AudioScript source receipt");
3178
+ if (sources.length === 1 && (sources[0].endpoint1EntityId !== content.entityId || graph.entity(sources[0].endpoint0EntityId).entityKind !== "audio-script")) fail("Recorded Speech AudioScript source must use ordered endpoints");
3179
+ if (asset.payload.system !== "memota") fail("Recorded Speech Audio requires an explicit memota key");
3180
+ return;
3181
+ }
3182
+ fail(`unsupported compatibility content kind ${content.entityKind} on speech Track`);
3183
+ }
3090
3184
  //#endregion
3091
3185
  //#region src/document/entity-projection.ts
3092
3186
  /**
@@ -3094,7 +3188,7 @@ function resolveGraphLayout(graph) {
3094
3188
  * consulted as an editing fact. Peer IDs appear only in this derived read
3095
3189
  * contract, reconstructed from relations; they are not stored in payloads.
3096
3190
  */
3097
- function projectEntityTimeline(rows, meta) {
3191
+ function projectEntityTimeline(rows) {
3098
3192
  const graph = new EntityProjectionGraph(rows);
3099
3193
  const layout = resolveGraphLayout(graph);
3100
3194
  for (const track of layout.tracks) {
@@ -3128,17 +3222,21 @@ function projectEntityTimeline(rows, meta) {
3128
3222
  } else if (track.role === "speech") {
3129
3223
  const asset = mediaAssetFacts(graph, content);
3130
3224
  requireUntrimmedAudio(content, marker, placement);
3131
- 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;
3225
+ const timbreEdges = graph.adjacent(content.entityId, "voice-timbre");
3226
+ const isSynthesized = content.entityKind === "voiceover";
3227
+ const external = externalLocator(asset, isSynthesized ? "memota-speech" : "memota");
3228
+ const assembled = isSynthesized ? assemblePhoneticScriptContent(rows, graph.one(content.entityId, "phonetic-script-render", "phonetic-script").entityId) : void 0;
3229
+ if (timbreEdges.length > 1) fail("A voiceover resolves at most one Voice identity");
3230
+ const voice = timbreEdges.length === 0 ? void 0 : graph.one(content.entityId, "voice-timbre", "voice").payload.voice;
3134
3231
  if (voice !== void 0 && (!object(voice) || voice.system !== "voice-library" || typeof voice.key !== "string")) fail("Voice must use an explicit voice-library locator");
3135
3232
  const captionIds = layout.tracks.flatMap((candidate) => candidate.role === "caption" ? candidate.clips.filter((caption) => caption.anchorClipEntityId === clip.entityId).map((caption) => caption.clipEntityId) : []);
3136
- parts[clip.entityId] = { speech: {
3233
+ const speech = {
3137
3234
  id: clip.entityId,
3138
3235
  kind: "speech",
3139
3236
  media_duration_ms: placement.duration,
3140
3237
  audio_storage_key: storageKey(asset),
3141
3238
  origin_speech_id: external.key,
3239
+ origin_asset_system: external.system,
3142
3240
  audio_script: assembled?.text ?? (graph.adjacent(content.entityId, "audio-script-source").length > 0 ? assembleScriptText(graph.one(content.entityId, "audio-script-source", "audio-script")) : ""),
3143
3241
  volume: volume.data,
3144
3242
  voice: object(voice) ? {
@@ -3146,7 +3244,8 @@ function projectEntityTimeline(rows, meta) {
3146
3244
  name: typeof voice.name === "string" ? voice.name : void 0
3147
3245
  } : void 0,
3148
3246
  caption_ids: captionIds
3149
- } };
3247
+ };
3248
+ parts[clip.entityId] = { speech };
3150
3249
  } else if (track.role === "caption") {
3151
3250
  const assembled = assembleCaptionContent(rows, content.entityId);
3152
3251
  const text = assembled.text;
@@ -3171,8 +3270,7 @@ function projectEntityTimeline(rows, meta) {
3171
3270
  if (marker.payload.durationPolicy !== "timeline") fail("Background Audio requires an explicit timeline-fill durationPolicy");
3172
3271
  if (marker.payload.timeRemapping !== void 0) fail("BGM timeRemapping is not supported by this reader");
3173
3272
  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");
3273
+ if (source.start !== 0 || content.payload.durationMs !== source.end) fail("Background Audio compatibility requires the complete source");
3176
3274
  parts[clip.entityId] = { bgm: {
3177
3275
  id: clip.entityId,
3178
3276
  kind: "bgm",
@@ -3183,10 +3281,6 @@ function projectEntityTimeline(rows, meta) {
3183
3281
  }
3184
3282
  }
3185
3283
  const document = {
3186
- meta: {
3187
- ...structuredClone(meta),
3188
- schema_version: ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION
3189
- },
3190
3284
  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
3285
  tracks: layout.tracks.map((track) => ({
3192
3286
  id: track.entityId,
@@ -3216,29 +3310,26 @@ function projectEntityTimeline(rows, meta) {
3216
3310
  function trackRoleOf(layout, trackEntityId) {
3217
3311
  return layout.tracks.find((track) => track.entityId === trackEntityId)?.role;
3218
3312
  }
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
- */
3313
+ /** The Asset a media entity was made from; the bytes are named there alone. */
3225
3314
  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");
3315
+ const asset = graph.one(content.entityId, "from-asset", "asset");
3228
3316
  return {
3229
- entityId: String(content.entityId),
3230
- payload: content.payload
3317
+ entityId: String(asset.entityId),
3318
+ payload: asset.payload
3231
3319
  };
3232
3320
  }
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
3321
  function storageKey(asset) {
3239
3322
  if (typeof asset.payload.storageKey !== "string" || asset.payload.storageKey.trim() === "") fail("Asset " + asset.entityId + " requires its factual storageKey");
3240
3323
  return asset.payload.storageKey;
3241
3324
  }
3325
+ function externalLocator(asset, system) {
3326
+ const { system: found, key } = asset.payload;
3327
+ if (found !== system || typeof key !== "string" || key.trim() === "") fail("Asset " + asset.entityId + " requires an explicit " + system + " key");
3328
+ return {
3329
+ key,
3330
+ system
3331
+ };
3332
+ }
3242
3333
  function captionStyle(value) {
3243
3334
  if (value === void 0) return void 0;
3244
3335
  if (!object(value)) fail("Caption style must be an object");
@@ -3270,8 +3361,7 @@ function captionStyle(value) {
3270
3361
  }
3271
3362
  function requireUntrimmedAudio(content, marker, placement) {
3272
3363
  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");
3364
+ 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
3365
  }
3276
3366
  function videoSpeed(content, marker, placement) {
3277
3367
  const remapping = marker.payload.timeRemapping;
@@ -3329,12 +3419,11 @@ const atomicFields = new Set([
3329
3419
  "sourceRange",
3330
3420
  "targetRange",
3331
3421
  "duration",
3332
- "extent",
3422
+ "durationMs",
3333
3423
  "timeRemapping",
3334
3424
  "textRange",
3335
3425
  "selection",
3336
- "external",
3337
- "coordinateSpace"
3426
+ "external"
3338
3427
  ]);
3339
3428
  const textFields = new Set(["text", "phonemeScript"]);
3340
3429
  const orderedFields = new Set([
@@ -3561,6 +3650,73 @@ function applyNativeListChange(list, change) {
3561
3650
  }
3562
3651
  }
3563
3652
  //#endregion
3653
+ //#region src/entity-crdt/audio-script-assets.ts
3654
+ /** A resource is a snapshot of text; the native Loro text remains the editing authority. */
3655
+ function audioScriptAssetContent(payload) {
3656
+ assembleScriptText({
3657
+ entityId: createEntityId("script-validation"),
3658
+ entityKind: "audio-script",
3659
+ payload
3660
+ });
3661
+ return { segments: structuredClone(payload.segments) };
3662
+ }
3663
+ function audioScriptAssetHash(payload) {
3664
+ return bytesToHex(sha256(new TextEncoder().encode(canonicalJson(audioScriptAssetContent(payload)))));
3665
+ }
3666
+ /**
3667
+ * The Asset a script was written out to. It is a snapshot the script owns, so
3668
+ * it is reached by `to-asset` — not `from-asset`, which would say the script
3669
+ * was produced from bytes it cannot change.
3670
+ */
3671
+ function audioScriptAssetOf(rows, entityId) {
3672
+ return boundAsset(rows, entityId, "to-asset");
3673
+ }
3674
+ /**
3675
+ * The Asset a script was made from — a transcript it does not own. A script
3676
+ * that has one never writes a snapshot of its own: the bytes it would describe
3677
+ * are already named, and rewriting them would contradict their source.
3678
+ */
3679
+ function scriptSourceAssetOf(rows, entityId) {
3680
+ return boundAsset(rows, entityId, "from-asset");
3681
+ }
3682
+ function boundAsset(rows, entityId, relationKind) {
3683
+ const link = rows.relations.find((relation) => relation.relationKind === relationKind && (String(relation.endpoint0EntityId) === entityId || String(relation.endpoint1EntityId) === entityId));
3684
+ if (link === void 0) return void 0;
3685
+ const otherId = String(link.endpoint0EntityId) === entityId ? link.endpoint1EntityId : link.endpoint0EntityId;
3686
+ return rows.entities.find((row) => String(row.entityId) === String(otherId) && row.entityKind === "asset");
3687
+ }
3688
+ /** Internal fingerprint prevents a resource from silently describing an older text revision. */
3689
+ function audioScriptAssetFields(payload) {
3690
+ return { assetContentHash: audioScriptAssetHash(payload) };
3691
+ }
3692
+ /**
3693
+ * What a PhoneticScript hands to synthesis: the base AudioScript's text as the
3694
+ * variant pronounces it, plus the variant's own phoneme and prosody control.
3695
+ *
3696
+ * The variant's content is written by a model — deciding how words are said is
3697
+ * text work, not a mechanical copy — but the bytes a synthesizer reads have to
3698
+ * leave the entity graph to reach it, because synthesis does not handle
3699
+ * entities. Writing this resource down is that crossing, and it is why speech
3700
+ * can only come from a PhoneticScript.
3701
+ */
3702
+ function phoneticScriptAssetContent(rows, entityId) {
3703
+ const assembled = assemblePhoneticScriptContent(rows, createEntityId(entityId));
3704
+ const own = assembled.phoneticScript.payload;
3705
+ return {
3706
+ text: assembled.text,
3707
+ segments: structuredClone(assembled.segments),
3708
+ ...typeof own.phonemeScript === "string" ? { phonemeScript: own.phonemeScript } : {},
3709
+ ...isJsonObject(own.prosody) ? { prosody: structuredClone(own.prosody) } : {}
3710
+ };
3711
+ }
3712
+ function phoneticScriptAssetHash(rows, entityId) {
3713
+ return bytesToHex(sha256(new TextEncoder().encode(canonicalJson(phoneticScriptAssetContent(rows, entityId)))));
3714
+ }
3715
+ /** The Asset a PhoneticScript was written out to, read from the graph. */
3716
+ function phoneticScriptAssetOf(rows, entityId) {
3717
+ return audioScriptAssetOf(rows, entityId);
3718
+ }
3719
+ //#endregion
3564
3720
  //#region src/entity-crdt/ordering.ts
3565
3721
  /** Domain order is scoped by membership; relation storage has no global order. */
3566
3722
  function entityOrderGroups(rows) {
@@ -3816,6 +3972,29 @@ var LoroEntityDocument = class LoroEntityDocument {
3816
3972
  if (id === void 0) return [];
3817
3973
  return this.snapshot().rows.relations.filter((row) => row.endpoint0EntityId === id || row.endpoint1EntityId === id);
3818
3974
  }
3975
+ /** Read unlinked Relation records for durable effect receipts; never part of the current semantic graph. */
3976
+ tombstonedRelations() {
3977
+ const result = [];
3978
+ for (const key of section(this.doc, "relations").keys().sort((a, b) => a < b ? -1 : a > b ? 1 : 0)) {
3979
+ const map = section(this.doc, "relations").get(key);
3980
+ if (!(map instanceof LoroMap) || map.get("deleted") !== true) continue;
3981
+ const identity = map.get("identity");
3982
+ if (!identity || identity.endpoints.length !== 2) throw new Error(`Invalid tombstoned Relation ${key}`);
3983
+ result.push({
3984
+ relationId: createRelationId(key),
3985
+ relationKind: identity.kind,
3986
+ endpoint0EntityId: createEntityId(identity.endpoints[0]),
3987
+ endpoint1EntityId: createEntityId(identity.endpoints[1]),
3988
+ metadata: requireMap(map, "metadata").toJSON(),
3989
+ trace: requireMap(map, "trace").toJSON()
3990
+ });
3991
+ }
3992
+ return result;
3993
+ }
3994
+ /** Resolve a queued edit target through its native identity without changing history. */
3995
+ resolveCurrentEntityId(id) {
3996
+ return currentId(this.doc, id);
3997
+ }
3819
3998
  getVersion(id) {
3820
3999
  const entity = section(this.doc, "entities").get(id);
3821
4000
  if (!isJsonObject(entity)) return void 0;
@@ -3856,6 +4035,52 @@ var LoroEntityDocument = class LoroEntityDocument {
3856
4035
  materialize(this.doc);
3857
4036
  this.doc.commit({ origin: MATERIALIZE_ORIGIN });
3858
4037
  }
4038
+ /**
4039
+ * Bind a saved snapshot without rewriting text operations or resolving
4040
+ * selection conflicts. The script names the bytes the same way everything
4041
+ * else does — an Asset entity plus a Relation — and keeps only the
4042
+ * fingerprint that tells a stale snapshot from a current one. The Relation is
4043
+ * `to-asset`: these bytes are the script's own content written out, not a
4044
+ * source it was made from.
4045
+ */
4046
+ bindAudioScriptAsset(entityId, assetId, expectedContentHash) {
4047
+ const key = family(this.doc, entityId);
4048
+ const map = workspace(this.doc, key);
4049
+ if (map.get("kind") !== "audio-script" || map.get("deleted") === true) throw new Error("Resource binding requires an active AudioScript");
4050
+ const payload = entityContent(this.doc, key);
4051
+ if (audioScriptAssetHash(payload) !== expectedContentHash) throw new Error("AudioScript changed while saving its resource");
4052
+ if (!assetId.trim()) throw new Error("AudioScript resource writer returned an empty asset ID");
4053
+ const before = this.doc.oplogVersion();
4054
+ const existing = readEntityDocument(this.doc, true).rows.entities.find((row) => row.entityKind === "asset" && row.payload.system === "memota" && row.payload.key === assetId);
4055
+ const assetEntityId = existing?.entityId ?? createEntityId(`asset_${assetId}`);
4056
+ if (existing === void 0) this.transact((draft) => draft.create({
4057
+ entityId: assetEntityId,
4058
+ entityKind: "asset",
4059
+ payload: {
4060
+ system: "memota",
4061
+ key: assetId
4062
+ }
4063
+ }));
4064
+ this.transact((draft) => {
4065
+ const current = readEntityDocument(this.doc, true).rows;
4066
+ if (audioScriptAssetOf(current, entityId)?.entityId !== assetEntityId) {
4067
+ 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));
4068
+ draft.link({
4069
+ relationId: createRelationId(`relation_to-asset_${entityId}_${assetId}`),
4070
+ relationKind: "to-asset",
4071
+ endpoint0EntityId: createEntityId(entityId),
4072
+ endpoint1EntityId: assetEntityId,
4073
+ metadata: {},
4074
+ trace: {}
4075
+ });
4076
+ }
4077
+ draft.declare(entityId, audioScriptAssetFields(payload));
4078
+ });
4079
+ return this.doc.export({
4080
+ mode: "update",
4081
+ from: before
4082
+ });
4083
+ }
3859
4084
  canUndo() {
3860
4085
  return this.undoManager.canUndo();
3861
4086
  }
@@ -3869,12 +4094,12 @@ var LoroEntityDocument = class LoroEntityDocument {
3869
4094
  return this.historyEdit(() => this.undoManager.redo());
3870
4095
  }
3871
4096
  historyEdit(run) {
3872
- const before = this.snapshot();
4097
+ const before = new Map(this.snapshot().rows.entities.map((row) => [family(this.doc, row.entityId), row]));
3873
4098
  if (!run()) return false;
3874
4099
  const after = this.snapshot();
3875
4100
  for (const row of after.rows.entities) {
3876
4101
  const id = family(this.doc, row.entityId);
3877
- const previous = before.rows.entities.find((item) => family(this.doc, item.entityId) === id);
4102
+ const previous = before.get(id);
3878
4103
  if (previous && !equalJson(owned(previous.payload), owned(row.payload))) workspace(this.doc, id).ensureMergeableMap("changes").set(globalThis.crypto.randomUUID(), true);
3879
4104
  }
3880
4105
  materialize(this.doc);
@@ -4197,8 +4422,8 @@ function assertState(state) {
4197
4422
  const ids = new Set(state.rows.entities.map((row) => String(row.entityId)));
4198
4423
  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
4424
  }
4200
- function materialize(doc) {
4201
- const state = readEntityDocument(doc);
4425
+ function materialize(doc, allowSelectionConflicts = false) {
4426
+ const state = readEntityDocument(doc, allowSelectionConflicts);
4202
4427
  const byId = section(doc, "entities");
4203
4428
  const ids = currentIds(doc);
4204
4429
  for (const [source, id] of ids) {
@@ -4219,13 +4444,37 @@ function materialize(doc) {
4219
4444
  for (const [slot, id] of Object.entries(state.project)) if (project.get(slot) !== id) project.set(slot, id);
4220
4445
  }
4221
4446
  //#endregion
4447
+ //#region src/document/document-mutation-guard.ts
4448
+ /**
4449
+ * @internal
4450
+ * Shared by synchronous mutation entry points for one document. The session
4451
+ * closes it before teardown callbacks can attempt another write.
4452
+ */
4453
+ var DocumentMutationGuard = class {
4454
+ running = false;
4455
+ closed = false;
4456
+ run(mutation) {
4457
+ if (this.closed) throw new Error("mengine document is disposed");
4458
+ if (this.running) throw new Error("mengine document mutation is already running");
4459
+ this.running = true;
4460
+ try {
4461
+ return mutation();
4462
+ } finally {
4463
+ this.running = false;
4464
+ }
4465
+ }
4466
+ close() {
4467
+ this.closed = true;
4468
+ }
4469
+ };
4470
+ //#endregion
4222
4471
  //#region src/document/mirror-read.ts
4223
4472
  /**
4224
4473
  * Project the mirror state (`VideoDocumentDraft`) into the authoritative
4225
4474
  * `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
4475
+ * §4, reference/17 §4: timeline + a single `tracks` list + part_library), so this
4227
4476
  * is a near-identity — it reads `time_position` / `fallback_abs_ms` JSON blobs
4228
- * back into structured values and trims empty strings, nothing more.
4477
+ * back into structured values and selects content fields. Legacy meta is ignored.
4229
4478
  *
4230
4479
  * It maps only authoritative facts (RFC 02 §6): `part_id` + `time_position`. The
4231
4480
  * projection-derived `VideoDraft` read-view (absolute time, `part_aggregations`,
@@ -4242,20 +4491,8 @@ function readVideoDocumentFromDraft(draft) {
4242
4491
  if (part != null) partLibrary[partId] = part;
4243
4492
  }
4244
4493
  const timeline = draft.timeline;
4245
- const hasTimeline = timeline?.unit_time_ms != null;
4246
- const meta = draft.meta;
4247
4494
  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,
4495
+ timeline: timeline?.unit_time_ms != null ? { unit_time_ms: timeline.unit_time_ms } : void 0,
4259
4496
  tracks: rowsToTracks(draft.tracks),
4260
4497
  part_library: partLibrary
4261
4498
  };
@@ -4279,9 +4516,6 @@ function rowToTrack(row) {
4279
4516
  })
4280
4517
  };
4281
4518
  }
4282
- function emptyToUndefined(value) {
4283
- return value == null || value === "" ? void 0 : value;
4284
- }
4285
4519
  //#endregion
4286
4520
  //#region src/document/mirror-adapter.ts
4287
4521
  /**
@@ -4294,33 +4528,50 @@ function emptyToUndefined(value) {
4294
4528
  * - `transact(edit, audit)` runs the whole op in one `mirror.setState` callback:
4295
4529
  * one diff, one `doc.commit` carrying the audit message. The callback edits an
4296
4530
  * immer draft, so a throw inside it discards the draft and never touches Loro
4297
- * (natural rollback) — no `guard` / `rollback` / `openTransaction` machinery.
4531
+ * (natural rollback) without a document checkout or compensating commit.
4298
4532
  * - mirror's `idSelector` (track items keyed by `part_id`) diffs reorders to
4299
4533
  * real Loro `move` ops on every lane, so per-item CRDT identity survives on
4300
4534
  * main and secondary tracks alike.
4301
4535
  */
4302
4536
  var MirrorVideoDocumentAdapter = class {
4303
4537
  doc;
4538
+ mutationGuard;
4304
4539
  mirror;
4305
- constructor(doc) {
4540
+ disposed = false;
4541
+ constructor(doc, mutationGuard = new DocumentMutationGuard()) {
4306
4542
  this.doc = doc;
4543
+ this.mutationGuard = mutationGuard;
4307
4544
  this.mirror = new Mirror({
4308
4545
  doc,
4309
- schema: videoDocumentMirrorSchema
4546
+ schema: videoDocumentMirrorSchema,
4547
+ validateUpdates: false
4310
4548
  });
4311
4549
  }
4312
4550
  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;
4551
+ this.assertActive();
4552
+ if (this.isEntityDocument()) return projectEntityTimeline(readEntityDocument(this.doc).rows);
4553
+ return readVideoDocumentFromDraft(this.mirror.getState());
4316
4554
  }
4317
4555
  /**
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).
4556
+ * Native Entity authority: placement is already solved by the Entity graph.
4557
+ *
4558
+ * The root check goes through `getShallowValue` first because `getMap` creates
4559
+ * the container on access — probing a legacy document for `medeo` would
4560
+ * otherwise mutate it, which a read must never do.
4321
4561
  */
4322
- hasContent() {
4323
- return (this.mirror.getState().meta?.schema_version ?? "") !== "";
4562
+ isEntityDocument() {
4563
+ const roots = this.doc.getShallowValue();
4564
+ if (!Object.hasOwn(roots, "medeo")) return false;
4565
+ return this.doc.getMap("medeo").get("schema") === LORO_ENTITY_SCHEMA;
4566
+ }
4567
+ /**
4568
+ * Release mirror subscriptions; do not reuse this adapter afterwards.
4569
+ * The caller still owns the borrowed LoroDoc.
4570
+ */
4571
+ dispose() {
4572
+ if (this.disposed) return;
4573
+ this.disposed = true;
4574
+ this.mirror.dispose();
4324
4575
  }
4325
4576
  /**
4326
4577
  * Apply one op as a single transaction. `edit` mutates the immer draft; mirror
@@ -4329,80 +4580,71 @@ var MirrorVideoDocumentAdapter = class {
4329
4580
  * skips the commit — matching the prior "empty op leaves no audit" behavior.
4330
4581
  */
4331
4582
  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
- })
4583
+ this.assertActive();
4584
+ if (this.isEntityDocument()) throw new Error("Entity documents are read-only through this adapter; edit the Medeo entities instead");
4585
+ this.mutationGuard.run(() => {
4586
+ this.mirror.setState((draft) => {
4587
+ edit(draft);
4588
+ const result = validateSchema(videoDocumentMirrorSchema, {
4589
+ timeline: draft.timeline,
4590
+ tracks: draft.tracks,
4591
+ part_library: draft.part_library
4592
+ });
4593
+ if (!result.valid) throw new Error(`Invalid content update: ${result.errors?.join("; ")}`);
4594
+ }, {
4595
+ origin: "mengine.semantic_editor",
4596
+ message: JSON.stringify({
4597
+ semantic_op: audit.kind,
4598
+ payload: audit.payload,
4599
+ intent: audit.intent ?? null,
4600
+ actor: audit.actor ?? null
4601
+ })
4602
+ });
4343
4603
  });
4344
4604
  }
4605
+ assertActive() {
4606
+ if (this.disposed) throw new Error("mengine document adapter is disposed");
4607
+ }
4345
4608
  };
4346
4609
  /** Build a fresh Loro doc seeded with `document` through the mirror. */
4347
4610
  function createMirrorVideoDocument(document, options = {}) {
4348
4611
  assertValidVideoDocument(document);
4349
4612
  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;
4613
+ try {
4614
+ if (options.peerId != null) doc.setPeerId(options.peerId);
4615
+ const mirror = new Mirror({
4616
+ doc,
4617
+ schema: videoDocumentMirrorSchema
4618
+ });
4619
+ try {
4620
+ mirror.setState((draft) => {
4621
+ seedDraft(draft, document);
4622
+ }, { origin: options.origin ?? "mengine.bootstrap" });
4623
+ return doc;
4624
+ } finally {
4625
+ mirror.dispose();
4626
+ }
4627
+ } catch (error) {
4628
+ doc.free();
4629
+ throw error;
4630
+ }
4358
4631
  }
4359
4632
  /** Build a `MirrorVideoDocumentAdapter` over a fresh doc seeded with `document`. */
4360
4633
  function createMirrorVideoDocumentAdapter(document, options) {
4361
4634
  return new MirrorVideoDocumentAdapter(createMirrorVideoDocument(document, options));
4362
4635
  }
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
- };
4636
+ /** Write a whole `VideoDocument` into the mirror draft (seed a fresh doc / plain-memory state). */
4637
+ function seedDraft(draft, document) {
4402
4638
  draft.timeline = { unit_time_ms: document.timeline?.unit_time_ms ?? void 0 };
4403
4639
  draft.tracks = (document.tracks ?? []).map(toTrackRow);
4404
4640
  draft.part_library = {};
4405
- for (const [partId, part] of Object.entries(document.part_library ?? {})) draft.part_library[partId] = partUnionToDraft(part);
4641
+ for (const [partId, part] of Object.entries(document.part_library ?? {})) {
4642
+ const createdPart = part.caption != null ? { caption: {
4643
+ ...part.caption,
4644
+ style: part.caption.style ?? {}
4645
+ } } : part;
4646
+ draft.part_library[partId] = partUnionToDraft(createdPart);
4647
+ }
4406
4648
  }
4407
4649
  /** Map a domain `Track` to a draft track row (no lane/lane_order — see schema). */
4408
4650
  function toTrackRow(track) {
@@ -4486,10 +4728,12 @@ var PlainMemoryAdapter = class {
4486
4728
  * use this so every minted id is appended to the current transact's list.
4487
4729
  */
4488
4730
  idFactory;
4731
+ readOnly;
4489
4732
  constructor(document, options) {
4490
4733
  assertValidVideoDocument(document);
4734
+ this.readOnly = options?.readOnly === true;
4491
4735
  const draft = {};
4492
- writeVideoDocumentToDraft(draft, document);
4736
+ seedDraft(draft, document);
4493
4737
  this.state = draft;
4494
4738
  this.baseIdFactory = options?.idFactory ?? generatePartId;
4495
4739
  this.idFactory = (prefix) => {
@@ -4502,7 +4746,7 @@ var PlainMemoryAdapter = class {
4502
4746
  return this._journal;
4503
4747
  }
4504
4748
  hasContent() {
4505
- return (this.state.meta?.schema_version ?? "") !== "";
4749
+ return this.state.tracks !== void 0 || this.state.timeline !== void 0;
4506
4750
  }
4507
4751
  snapshot() {
4508
4752
  return readVideoDocumentFromDraft(this.state);
@@ -4513,7 +4757,7 @@ var PlainMemoryAdapter = class {
4513
4757
  * no structural change — skip journal, matching mirror "no change, no commit".
4514
4758
  */
4515
4759
  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");
4760
+ if (this.readOnly) throw new Error("Entity documents are read-only here; edit the Medeo entities instead");
4517
4761
  this.pendingIds = [];
4518
4762
  try {
4519
4763
  const next = produce(this.state, edit);
@@ -4534,4 +4778,4 @@ function createPlainMemoryAdapter(document, options) {
4534
4778
  return new PlainMemoryAdapter(document, options);
4535
4779
  }
4536
4780
  //#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 };
4781
+ export { validateVideoDocument as $, EntityTimelineProjectionError as A, variantBaseEntityIds as B, equalJson as C, partUnionToDraft as Ct, projectEntityTimeline as D, bytesToBase64 as Dt, resolveFieldPath as E, base64ToBytes as Et, assemblePhoneticScriptContent as F, isMediaAssetVariantKind as G, createEntityId as H, assembleScriptText as I, derivePositionFromAbs as J, buildInitialVideoDocument as K, findComposedAudioScript as L, ScriptCompositionError as M, assembleCaptionContent as N, EntityProjectionGraph as O, assembleEntityContent as P, assertValidVideoDocument as Q, selectAudioScriptSegment as R, canonicalJson as S, speedOf as St, assertFieldChanges as T, videoDocumentMirrorSchema as Tt, createRelationId as U, isJsonObject as V, hasSequence as W, toVideoDocument as X, fromVideoDocument as Y, VideoDocumentValidationError as Z, audioScriptAssetOf as _, isEmptyVideoClip as _t, createMirrorVideoDocument as a, hasResolvedPlacement as at, phoneticScriptAssetOf as b, DEFAULT_UNIT_TIME_MS as bt, DocumentMutationGuard as c, cascadeAfterVideoClipChanges as ct, LoroEntityDraft as d, reassignSpeechesToVideoClipsByTime as dt, partUnionSchema as et, readEntityDocument as f, recalculateTimelineDuration as ft, audioScriptAssetHash as g, TIMELINE_SKELETON_DURATION_MS as gt, audioScriptAssetFields as h, syncAggregatedClipsTimePosition as ht, MirrorVideoDocumentAdapter as i, findLaneTrack as it, decodeEntityRelationRows as j, resolveEntityTimelineLayout as k, LORO_ENTITY_SCHEMA as l, arrangeMainTrackSeamlessly as lt, audioScriptAssetContent as m, resolveSpeechOverlapByShiftingVideos as mt, createPlainMemoryAdapter as n, LANE_KINDS_IN_STACK_ORDER as nt, createMirrorVideoDocumentAdapter as o, laneTrackId as ot, entityOrderGroups as p, resolveAllSpeechOverlaps as pt, buildSpeechHostMap as q, generatePartId as r, ensureLaneTrack as rt, readVideoDocumentFromDraft as s, solveVideoDocument as st, PlainMemoryAdapter as t, videoDocumentSchema as tt, LoroEntityDocument as u, fillMainTrackTimeGaps as ut, phoneticScriptAssetContent as v, partDurationMs as vt, applyFieldChanges as w, recordEntries as wt, scriptSourceAssetOf as x, effectiveVideoClipDurationMs as xt, phoneticScriptAssetHash as y, safeDurationMs as yt, updateEntityFields as z };