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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { u as volumeSchema } from "./shared-iWnU2osE.js";
1
2
  import { Mirror, schema } from "loro-mirror";
2
3
  import { z } from "zod";
3
4
  import { LoroDoc } from "loro-crdt";
@@ -114,7 +115,7 @@ const speechPart = schema.LoroMap({
114
115
  voice: schema.String().transform(jsonTransform()),
115
116
  caption_ids: schema.String().transform(jsonTransform())
116
117
  }, { required: false });
117
- const captionStyle = schema.LoroMap({
118
+ const captionStyle$1 = schema.LoroMap({
118
119
  font_id: schema.String({ required: false }),
119
120
  font_size: schema.Number({ required: false }),
120
121
  font_color: schema.String({ required: false }),
@@ -132,7 +133,7 @@ const captionPart = schema.LoroMap({
132
133
  speech_part_id: schema.String(),
133
134
  text: schema.String(),
134
135
  start_ms: schema.Number(),
135
- style: captionStyle
136
+ style: captionStyle$1
136
137
  }, {
137
138
  required: false,
138
139
  mergeableMapChildContainers: true
@@ -210,6 +211,8 @@ function recordEntries(record) {
210
211
  //#endregion
211
212
  //#region src/document/types.ts
212
213
  const VIDEO_DOCUMENT_SCHEMA_VERSION = "video-document/v0";
214
+ /** A server-derived compatibility view, never an editor write authority. */
215
+ const ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION = "video-document/entity-projection-v1";
213
216
  /** The linear speed multiplier of a `speed_shift`, defaulting to 1 (original). */
214
217
  function speedOf(speedShift) {
215
218
  const speed = speedShift?.config?.linear?.speed;
@@ -547,7 +550,10 @@ function solveVideoDocument(document) {
547
550
  const doc = videoDocumentToTimelineDoc(document);
548
551
  let counter = 0;
549
552
  const derivedFillerPartIds = /* @__PURE__ */ new Set();
550
- cascadeAfterVideoClipChanges(doc, () => {
553
+ if (document.meta.schema_version === "video-document/entity-projection-v1") {
554
+ 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");
555
+ recalculateTimelineDuration(doc);
556
+ } else cascadeAfterVideoClipChanges(doc, () => {
551
557
  const partId = `empty_${counter++}`;
552
558
  derivedFillerPartIds.add(partId);
553
559
  return { partId };
@@ -851,7 +857,7 @@ const partUnionSchema = z.union([
851
857
  ].filter((k) => p[k] != null).length === 1;
852
858
  }, { message: "A part_library value must have exactly one of video_clip / speech / caption / bgm" });
853
859
  const videoDocumentMetaSchema = z.object({
854
- schema_version: z.literal(VIDEO_DOCUMENT_SCHEMA_VERSION),
860
+ schema_version: z.enum([VIDEO_DOCUMENT_SCHEMA_VERSION, ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION]),
855
861
  draft_id: z.string().optional(),
856
862
  project_id: z.string().optional(),
857
863
  owner_id: z.string().optional(),
@@ -1483,6 +1489,1407 @@ function emptyLaneTracks() {
1483
1489
  }));
1484
1490
  }
1485
1491
  //#endregion
1492
+ //#region src/document/entity-projection-values.ts
1493
+ /** A graph cannot be represented faithfully by the compatibility reader. */
1494
+ var EntityTimelineProjectionError = class extends Error {
1495
+ constructor(message) {
1496
+ super(message);
1497
+ this.name = "EntityTimelineProjectionError";
1498
+ }
1499
+ };
1500
+ function fail(message) {
1501
+ throw new EntityTimelineProjectionError(message);
1502
+ }
1503
+ function object(value) {
1504
+ return value !== void 0 && value !== null && typeof value === "object" && !Array.isArray(value);
1505
+ }
1506
+ function finite(value, label) {
1507
+ if (typeof value !== "number" || !Number.isFinite(value)) fail(`${label} must be finite`);
1508
+ return value;
1509
+ }
1510
+ function positive(value, label) {
1511
+ const result = finite(value, label);
1512
+ if (result <= 0) fail(`${label} must be positive`);
1513
+ return result;
1514
+ }
1515
+ function milliseconds(value, label) {
1516
+ const result = finite(value, label);
1517
+ if (!Number.isSafeInteger(result) || result < 0) fail(`${label} must be non-negative whole milliseconds`);
1518
+ return result;
1519
+ }
1520
+ function range(value, label) {
1521
+ if (!object(value)) fail(`${label} must be a numeric range`);
1522
+ const start = finite(value.start, `${label}.start`);
1523
+ const end = finite(value.end, `${label}.end`);
1524
+ if (start >= end) fail(`${label} must be non-empty`);
1525
+ return {
1526
+ start,
1527
+ end
1528
+ };
1529
+ }
1530
+ //#endregion
1531
+ //#region ../medeo-dsl/src/entities.ts
1532
+ function hasSequence(entity) {
1533
+ if (isKnownNonSequenceKind(entity.entityKind) || isReservedEntityKind(entity.entityKind)) return false;
1534
+ return isSequenceFields(entity);
1535
+ }
1536
+ function isSequenceFields(value) {
1537
+ if (!isRecord$1(value)) return false;
1538
+ const extent = value.extent;
1539
+ if (!isRecord$1(extent) || !("start" in extent) || extent.start === void 0) return false;
1540
+ if (extent.kind === "bounded" && (!("end" in extent) || extent.end === void 0)) return false;
1541
+ if (extent.kind === "unbounded" && "end" in extent) return false;
1542
+ if (extent.kind !== "bounded" && extent.kind !== "unbounded") return false;
1543
+ if (value.sampling !== "native" && value.sampling !== "constant" && value.sampling !== "derived") return false;
1544
+ return "coordinateSpace" in value && value.coordinateSpace !== void 0;
1545
+ }
1546
+ function isKnownSequenceKind(kind) {
1547
+ return kind === "video" || kind === "audio" || kind === "voice" || kind === "image" || kind === "caption" || kind === "axvideo";
1548
+ }
1549
+ function isKnownEntityKind(kind) {
1550
+ return isKnownSequenceKind(kind) || isKnownNonSequenceKind(kind);
1551
+ }
1552
+ function isReservedEntityKind(kind) {
1553
+ const normalized = kind.toLowerCase().replaceAll("-", "").replaceAll("_", "");
1554
+ return normalized === "speech" || normalized === "videodocument";
1555
+ }
1556
+ function isKnownNonSequenceKind(kind) {
1557
+ return kind === "timeline" || kind === "track" || kind === "clip" || kind === "asset" || kind === "sequence-marker" || kind === "viewport" || kind === "audio-script" || kind === "phonetic-script";
1558
+ }
1559
+ function isRecord$1(value) {
1560
+ return typeof value === "object" && value != null && !Array.isArray(value);
1561
+ }
1562
+ //#endregion
1563
+ //#region ../medeo-dsl/src/ids.ts
1564
+ function createEntityId(value) {
1565
+ return createId(value, "EntityId");
1566
+ }
1567
+ function createRelationId(value) {
1568
+ return createId(value, "RelationId");
1569
+ }
1570
+ function createId(value, label) {
1571
+ if (value.length === 0 || value.trim() !== value) throw new Error(`${label} must be a non-empty trimmed string`);
1572
+ return value;
1573
+ }
1574
+ //#endregion
1575
+ //#region ../medeo-dsl/src/invariants.ts
1576
+ /** Validates one complete set of entities and its authoritative Relation rows. */
1577
+ function validateEntityRelationSet(entityRefs, index, options) {
1578
+ const { entities, issues } = collectRelatedEntities(entityRefs, index);
1579
+ const entityIds = new Set(entities.map((entity) => entity.entityId));
1580
+ for (const entity of entities) {
1581
+ const current = entity.current();
1582
+ const entityIssues = validateEntity(entity, entityIds);
1583
+ issues.push(...entityIssues);
1584
+ if (entityIssues.some((issue) => issue.code === "invalid_entity_payload")) continue;
1585
+ if (current.entityKind === "sequence-marker") {
1586
+ const marker = entity;
1587
+ issues.push(...validateMarkerUse(entity, index));
1588
+ issues.push(...validateMarkerRanges(marker, {
1589
+ source: (left, right) => options.compareMarkerPoints(marker, "source", left, right),
1590
+ target: (left, right) => options.compareMarkerPoints(marker, "target", left, right)
1591
+ }));
1592
+ issues.push(...validateMarkerSourceBounds(marker, index, (left, right) => options.compareMarkerPoints(marker, "source", left, right)));
1593
+ }
1594
+ if (current.entityKind === "clip") {
1595
+ issues.push(...validateClipAdmission(entity, index));
1596
+ issues.push(...validateClipPlacement(entity, index));
1597
+ }
1598
+ if (current.entityKind === "axvideo") issues.push(...validateAXVideoAdmission(entity, index));
1599
+ if (current.entityKind === "caption") issues.push(...validateCaptionAsset(entity, index));
1600
+ issues.push(...validateSequenceComposition(entity));
1601
+ }
1602
+ issues.push(...validateClipAnchorCycles(entities, index));
1603
+ return issues;
1604
+ }
1605
+ function validateMarkerUse(marker, index) {
1606
+ const relations = [...index.relationsOf(marker)];
1607
+ const clipEdges = ofKind(relations, "clip-marker");
1608
+ const axVideoEdges = ofKind(relations, "axvideo-marker");
1609
+ const contentEdges = ofKind(relations, "marker-content");
1610
+ const timelineEdges = ofKind(relations, "marker-timeline");
1611
+ const issues = [];
1612
+ if (clipEdges.length + axVideoEdges.length !== 1) issues.push({
1613
+ code: "marker_container_xor",
1614
+ entityId: marker.entityId,
1615
+ message: "Sequence Marker must have exactly one Clip XOR AXVideo container relation"
1616
+ });
1617
+ if (contentEdges.length + timelineEdges.length !== 1) issues.push({
1618
+ code: "marker_content_xor",
1619
+ entityId: marker.entityId,
1620
+ message: "Sequence Marker must have exactly one Sequence content XOR Timeline relation"
1621
+ });
1622
+ if (clipEdges.length === 1 && timelineEdges.length === 1 || axVideoEdges.length === 1 && contentEdges.length === 1) issues.push({
1623
+ code: "marker_pair_mismatch",
1624
+ entityId: marker.entityId,
1625
+ message: "Only Clip+Content or AXVideo+Timeline Marker relation pairs are valid"
1626
+ });
1627
+ return issues;
1628
+ }
1629
+ function validateClipAdmission(clip, index) {
1630
+ const markerEdges = ofKind([...index.relationsOf(clip)], "clip-marker");
1631
+ if (markerEdges.length !== 1) return [{
1632
+ code: "clip_marker_cardinality",
1633
+ entityId: clip.entityId,
1634
+ message: "Clip must have exactly one authoritative Clip-Marker Relation"
1635
+ }];
1636
+ const marker = markerEdges[0]?.other(clip)?.deref();
1637
+ if (marker == null) return [{
1638
+ code: "clip_content_cardinality",
1639
+ entityId: clip.entityId,
1640
+ message: "Clip must resolve one live Sequence Marker and one content Relation"
1641
+ }];
1642
+ const contentEdges = ofKind([...index.relationsOf(marker)], "marker-content");
1643
+ if (contentEdges.length !== 1) return [{
1644
+ code: "clip_content_cardinality",
1645
+ entityId: clip.entityId,
1646
+ message: "Clip Marker must resolve exactly one content Relation"
1647
+ }];
1648
+ const content = contentEdges[0]?.other(marker)?.deref()?.current();
1649
+ if (content == null) return [{
1650
+ code: "clip_content_cardinality",
1651
+ entityId: clip.entityId,
1652
+ message: "Clip Marker content Relation must resolve one live entity"
1653
+ }];
1654
+ if (!hasSequence(content)) return [{
1655
+ code: "clip_content_not_sequence",
1656
+ entityId: clip.entityId,
1657
+ message: `Clip Marker resolves to non-Sequence entity "${content.entityId}"`
1658
+ }];
1659
+ return [];
1660
+ }
1661
+ function validateAXVideoAdmission(axVideo, index) {
1662
+ const markerEdges = ofKind([...index.relationsOf(axVideo)], "axvideo-marker");
1663
+ if (markerEdges.length === 1 && markerEdges[0]?.other(axVideo)?.deref() != null) return [];
1664
+ return [{
1665
+ code: "axvideo_marker_cardinality",
1666
+ entityId: axVideo.entityId,
1667
+ message: "AXVideo must have exactly one live AXVideo-Marker Relation"
1668
+ }];
1669
+ }
1670
+ function validateCaptionAsset(caption, index) {
1671
+ const assets = ofKind([...index.relationsOf(caption)], "physical-asset").map((relation) => relation.other(caption)?.deref()?.current()).filter((entity) => entity?.entityKind === "asset");
1672
+ if (assets.length > 0) {
1673
+ const captionText = caption.current().text;
1674
+ if (!assets.some((asset) => {
1675
+ const inline = asset.inline;
1676
+ return isRecord(inline) && inline.mediaType === "text/plain" && inline.text !== captionText;
1677
+ })) return [];
1678
+ return [{
1679
+ code: "caption_inline_asset_mismatch",
1680
+ entityId: caption.entityId,
1681
+ message: "Caption text must match the text/plain inline Physical Asset"
1682
+ }];
1683
+ }
1684
+ return [{
1685
+ code: "caption_asset_required",
1686
+ entityId: caption.entityId,
1687
+ message: "Caption must have a Physical Asset Relation"
1688
+ }];
1689
+ }
1690
+ function validateClipPlacement(clip, index) {
1691
+ const marker = ofKind([...index.relationsOf(clip)], "clip-marker")[0]?.other(clip)?.deref();
1692
+ if (marker?.current().entityKind !== "sequence-marker") return [];
1693
+ const markerValue = marker.current();
1694
+ const anchors = ofKind([...index.relationsOf(clip)], "clip-anchor").filter((relation) => relation.endpoints[0].deref() === clip);
1695
+ const hasOrder = clip.current().order !== void 0;
1696
+ const hasTarget = markerValue.targetRange !== void 0;
1697
+ const hasAnchorOffset = markerValue.anchorOffset !== void 0;
1698
+ const issues = [];
1699
+ if (anchors.length > 1) issues.push({
1700
+ code: "clip_anchor_cardinality",
1701
+ entityId: clip.entityId,
1702
+ message: "A Clip may follow at most one host Clip"
1703
+ });
1704
+ const hasAnchor = anchors.length === 1;
1705
+ if (hasAnchor && (!hasAnchorOffset || hasOrder || hasTarget) || !hasAnchor && (hasAnchorOffset || Number(hasOrder) + Number(hasTarget) !== 1)) issues.push({
1706
+ code: "clip_placement_invalid",
1707
+ entityId: clip.entityId,
1708
+ message: "Clip placement must be exactly one of Clip.order, Marker.targetRange, or clip-anchor with Marker.anchorOffset"
1709
+ });
1710
+ if (markerValue.durationPolicy === "timeline") {
1711
+ const content = ofKind([...index.relationsOf(marker)], "marker-content")[0]?.other(marker)?.deref()?.current();
1712
+ const track = ofKind([...index.relationsOf(clip)], "track-clip")[0]?.other(clip)?.deref()?.current();
1713
+ const trackRole = track?.entityKind === "track" ? track.role : void 0;
1714
+ if (content?.entityKind !== "audio" || trackRole !== "bgm" || !hasOrder) issues.push({
1715
+ code: "timeline_duration_policy_invalid",
1716
+ entityId: marker.entityId,
1717
+ message: "Marker durationPolicy \"timeline\" is only valid for ordered Audio Clips on the bgm Track"
1718
+ });
1719
+ }
1720
+ return issues;
1721
+ }
1722
+ function validateClipAnchorCycles(entities, index) {
1723
+ const hostByChild = /* @__PURE__ */ new Map();
1724
+ for (const entity of entities) {
1725
+ if (entity.current().entityKind !== "clip") continue;
1726
+ for (const relation of ofKind([...index.relationsOf(entity)], "clip-anchor")) {
1727
+ if (relation.endpoints[0].deref() !== entity) continue;
1728
+ const host = relation.endpoints[1].deref();
1729
+ if (host != null) hostByChild.set(entity.entityId, host.entityId);
1730
+ }
1731
+ }
1732
+ const issues = [];
1733
+ for (const child of hostByChild.keys()) {
1734
+ const seen = /* @__PURE__ */ new Set();
1735
+ let current = child;
1736
+ while (current !== void 0 && !seen.has(current)) {
1737
+ seen.add(current);
1738
+ current = hostByChild.get(current);
1739
+ }
1740
+ if (current === void 0) continue;
1741
+ issues.push({
1742
+ code: "clip_anchor_cycle",
1743
+ entityId: child,
1744
+ message: "clip-anchor Relations must form an acyclic dependency graph"
1745
+ });
1746
+ }
1747
+ return issues;
1748
+ }
1749
+ function validateMarkerRanges(marker, compare) {
1750
+ const current = marker.current();
1751
+ const issues = [];
1752
+ if (!(compare.source(current.sourceRange.start, current.sourceRange.end) < 0)) issues.push({
1753
+ code: "marker_source_range_empty",
1754
+ entityId: marker.entityId,
1755
+ message: "Sequence Marker sourceRange must be a non-empty half-open interval"
1756
+ });
1757
+ if (current.targetRange != null && !(compare.target(current.targetRange.start, current.targetRange.end) < 0)) issues.push({
1758
+ code: "marker_target_range_empty",
1759
+ entityId: marker.entityId,
1760
+ message: "Sequence Marker targetRange must be a non-empty half-open interval"
1761
+ });
1762
+ return issues;
1763
+ }
1764
+ function validateMarkerSourceBounds(marker, index, compare) {
1765
+ const contentEdges = ofKind([...index.relationsOf(marker)], "marker-content");
1766
+ if (contentEdges.length !== 1) return [];
1767
+ const content = contentEdges[0]?.other(marker)?.deref()?.current();
1768
+ if (content == null || !hasSequence(content)) return [];
1769
+ const sourceRange = marker.current().sourceRange;
1770
+ const startVsExtent = compare(sourceRange.start, content.extent.start);
1771
+ const endVsExtent = content.extent.kind === "bounded" ? compare(sourceRange.end, content.extent.end) : void 0;
1772
+ if (!Number.isNaN(startVsExtent) && startVsExtent >= 0 && (endVsExtent == null || !Number.isNaN(endVsExtent) && endVsExtent <= 0)) return [];
1773
+ return [{
1774
+ code: "marker_source_out_of_bounds",
1775
+ entityId: marker.entityId,
1776
+ message: `Sequence Marker sourceRange must stay within content "${content.entityId}" extent`
1777
+ }];
1778
+ }
1779
+ function validateSequenceComposition(entity) {
1780
+ const current = entity.current();
1781
+ const expected = expectedSequenceShape(current.entityKind);
1782
+ if (expected == null) return [];
1783
+ if (hasSequence(current) && current.extent.kind === expected.extent && current.sampling === expected.sampling) return [];
1784
+ return [{
1785
+ code: "invalid_sequence_composition",
1786
+ entityId: current.entityId,
1787
+ message: `${current.entityKind} must compose ${expected.extent} / ${expected.sampling} Sequence semantics`
1788
+ }];
1789
+ }
1790
+ function validateEntity(entity, entityIds = /* @__PURE__ */ new Set()) {
1791
+ const current = entity.current();
1792
+ const issues = [];
1793
+ if (isReservedEntityKind(current.entityKind)) issues.push({
1794
+ code: "forbidden_entity_kind",
1795
+ entityId: current.entityId,
1796
+ message: `Entity kind "${current.entityKind}" is explicitly outside the Medeo DSL`
1797
+ });
1798
+ for (const problem of validateKnownEntityPayload(current)) issues.push({
1799
+ code: "invalid_entity_payload",
1800
+ entityId: current.entityId,
1801
+ message: `Entity "${current.entityKind}" ${problem}`
1802
+ });
1803
+ const peerIdPaths = [...collectPeerEntityIdPaths(current, current.entityKind), ...collectPeerEntityValuePaths(current, current.entityKind, current.entityId, entityIds)];
1804
+ const uniquePeerIdPaths = [...new Set(peerIdPaths)].sort();
1805
+ if (uniquePeerIdPaths.length > 0) issues.push({
1806
+ code: "peer_entity_id_field",
1807
+ entityId: current.entityId,
1808
+ message: `Entity embeds forbidden peer-ID field/value path(s): ${uniquePeerIdPaths.join(", ")}`
1809
+ });
1810
+ return issues;
1811
+ }
1812
+ function validateKnownEntityPayload(entity) {
1813
+ const value = entity;
1814
+ const problems = [];
1815
+ if (value.lifecycle !== void 0 && !isRecord(value.lifecycle)) problems.push("lifecycle must be an object when present");
1816
+ switch (entity.entityKind) {
1817
+ case "track":
1818
+ validateOptionalField(value, "hidden", "boolean", problems);
1819
+ validateOptionalField(value, "role", "string", problems);
1820
+ validateOptionalFiniteNumber(value, "order", problems);
1821
+ break;
1822
+ case "video":
1823
+ case "audio":
1824
+ case "voice":
1825
+ case "caption":
1826
+ validateSequencePayload(value, "bounded", "native", problems);
1827
+ if (entity.entityKind === "voice") validateVoicePayload(value, problems);
1828
+ if (entity.entityKind === "caption") validateCaptionPayload(value, problems);
1829
+ break;
1830
+ case "image":
1831
+ validateSequencePayload(value, "unbounded", "constant", problems);
1832
+ break;
1833
+ case "axvideo":
1834
+ validateSequencePayload(value, "bounded", "derived", problems);
1835
+ break;
1836
+ case "sequence-marker":
1837
+ validateMarkerPayload(value, problems);
1838
+ break;
1839
+ case "audio-script":
1840
+ case "phonetic-script":
1841
+ validateScriptPayload(value, problems);
1842
+ break;
1843
+ case "timeline":
1844
+ case "viewport": break;
1845
+ case "clip":
1846
+ validateOptionalFiniteNumber(value, "order", problems);
1847
+ validateOptionalFiniteNumber(value, "volume", problems);
1848
+ if (typeof value.volume === "number" && (value.volume < -60 || value.volume > 20)) problems.push("volume must be decibels between -60 and 20");
1849
+ break;
1850
+ case "asset":
1851
+ validateAssetPayload(value, problems);
1852
+ break;
1853
+ default: break;
1854
+ }
1855
+ return problems;
1856
+ }
1857
+ function validateSequencePayload(value, expectedExtent, expectedSampling, problems) {
1858
+ const extent = value.extent;
1859
+ if (!isRecord(extent)) problems.push("extent must be an object");
1860
+ else {
1861
+ if (extent.kind !== expectedExtent) problems.push(`extent.kind must be "${expectedExtent}"`);
1862
+ if (!Object.hasOwn(extent, "start") || extent.start === void 0) problems.push("extent.start is required");
1863
+ if (expectedExtent === "bounded" && (!Object.hasOwn(extent, "end") || extent.end === void 0)) problems.push("extent.end is required for bounded sequences");
1864
+ if (expectedExtent === "unbounded" && Object.hasOwn(extent, "end")) problems.push("extent.end is forbidden for unbounded sequences");
1865
+ }
1866
+ if (value.sampling !== expectedSampling) problems.push(`sampling must be "${expectedSampling}"`);
1867
+ if (!Object.hasOwn(value, "coordinateSpace") || value.coordinateSpace === void 0) problems.push("coordinateSpace is required");
1868
+ }
1869
+ function validateMarkerPayload(value, problems) {
1870
+ validateRange(value.sourceRange, "sourceRange", true, problems);
1871
+ if (Object.hasOwn(value, "targetRange") && value.targetRange !== void 0) validateRange(value.targetRange, "targetRange", true, problems);
1872
+ const duration = value.duration;
1873
+ if (!isRecord(duration)) problems.push("duration must be an object");
1874
+ else if (duration.mode === "fixed") {
1875
+ if (!Object.hasOwn(duration, "value") || duration.value === void 0) problems.push("duration.value is required when duration.mode is \"fixed\"");
1876
+ } else if (duration.mode !== "from-source") problems.push("duration.mode must be \"from-source\" or \"fixed\"");
1877
+ if (value.anchorOffset === void 0 && Object.hasOwn(value, "anchorOffset")) problems.push("anchorOffset cannot be undefined when present");
1878
+ if (value.durationPolicy !== void 0 && value.durationPolicy !== "timeline") problems.push("durationPolicy must be \"timeline\" when present");
1879
+ }
1880
+ function validateAssetPayload(value, problems) {
1881
+ const external = value.external;
1882
+ if (external !== void 0) if (!isRecord(external)) problems.push("external must be an object when present");
1883
+ else {
1884
+ if (external.system !== "memota" && external.system !== "memota-speech") problems.push("external.system must be \"memota\" or \"memota-speech\"");
1885
+ if (typeof external.key !== "string" || external.key.trim() === "") problems.push("external.key must be a non-empty string");
1886
+ }
1887
+ if (value.storageKey !== void 0 && (typeof value.storageKey !== "string" || value.storageKey.trim() === "")) problems.push("storageKey must be a non-empty string when present");
1888
+ const inline = value.inline;
1889
+ if (inline !== void 0) if (!isRecord(inline)) problems.push("inline must be an object when present");
1890
+ else {
1891
+ if (inline.mediaType !== "text/plain") problems.push("inline.mediaType must be \"text/plain\"");
1892
+ if (typeof inline.text !== "string") problems.push("inline.text must be a string");
1893
+ }
1894
+ if (external !== void 0 && inline !== void 0) problems.push("Asset must not combine external and inline physical locations");
1895
+ }
1896
+ function validateVoicePayload(value, problems) {
1897
+ const voice = value.voice;
1898
+ if (voice === void 0) return;
1899
+ if (!isRecord(voice)) {
1900
+ problems.push("voice must be an object when present");
1901
+ return;
1902
+ }
1903
+ if (voice.system !== "voice-library") problems.push("voice.system must be \"voice-library\"");
1904
+ if (typeof voice.key !== "string" || voice.key.trim() === "") problems.push("voice.key must be a non-empty string");
1905
+ if (voice.name !== void 0 && typeof voice.name !== "string") problems.push("voice.name must be a string when present");
1906
+ }
1907
+ function validateCaptionPayload(value, problems) {
1908
+ if (value.text !== void 0 && typeof value.text !== "string") problems.push("text must be a string when present");
1909
+ const style = value.style;
1910
+ if (style === void 0) return;
1911
+ if (!isRecord(style)) {
1912
+ problems.push("style must be an object when present");
1913
+ return;
1914
+ }
1915
+ const font = style.font;
1916
+ if (font !== void 0) if (!isRecord(font)) problems.push("style.font must be an object when present");
1917
+ else {
1918
+ if (font.system !== "font-library") problems.push("style.font.system must be \"font-library\"");
1919
+ if (typeof font.key !== "string" || font.key.trim() === "") problems.push("style.font.key must be a non-empty string");
1920
+ }
1921
+ for (const key of [
1922
+ "fontSize",
1923
+ "fontWeight",
1924
+ "entranceAnimationDurationMs",
1925
+ "strokeWidth",
1926
+ "positionX",
1927
+ "positionY"
1928
+ ]) validateOptionalFiniteNumber(style, key, problems, `style.${key}`);
1929
+ for (const key of [
1930
+ "fontColor",
1931
+ "entranceAnimation",
1932
+ "strokeColor"
1933
+ ]) if (style[key] !== void 0 && typeof style[key] !== "string") problems.push(`style.${key} must be a string`);
1934
+ }
1935
+ function validateRange(value, path, required, problems) {
1936
+ if (!isRecord(value)) {
1937
+ if (required) problems.push(`${path} must be an object`);
1938
+ return;
1939
+ }
1940
+ if (!Object.hasOwn(value, "start") || value.start === void 0) problems.push(`${path}.start is required`);
1941
+ if (!Object.hasOwn(value, "end") || value.end === void 0) problems.push(`${path}.end is required`);
1942
+ }
1943
+ function validateScriptPayload(value, problems) {
1944
+ if (!Array.isArray(value.segments)) {
1945
+ problems.push("segments must be an array");
1946
+ return;
1947
+ }
1948
+ for (const [index, segment] of value.segments.entries()) {
1949
+ if (!isRecord(segment)) {
1950
+ problems.push(`segments[${index}] must be an object`);
1951
+ continue;
1952
+ }
1953
+ if (typeof segment.segmentId !== "string") problems.push(`segments[${index}].segmentId must be a string`);
1954
+ if (typeof segment.text !== "string") problems.push(`segments[${index}].text must be a string`);
1955
+ if (segment.language !== void 0 && typeof segment.language !== "string") problems.push(`segments[${index}].language must be a string when present`);
1956
+ }
1957
+ }
1958
+ function validateOptionalField(value, key, expectedType, problems) {
1959
+ if (value[key] !== void 0 && typeof value[key] !== expectedType) problems.push(`${key} must be a ${expectedType} when present`);
1960
+ }
1961
+ function validateOptionalFiniteNumber(value, key, problems, label = key) {
1962
+ if (value[key] !== void 0 && (typeof value[key] !== "number" || !Number.isFinite(value[key]))) problems.push(`${label} must be a finite number when present`);
1963
+ }
1964
+ function collectPeerEntityIdPaths(value, entityKind) {
1965
+ const paths = [];
1966
+ visitPeerEntityIdPaths(value, entityKind, "", /* @__PURE__ */ new Set(), paths);
1967
+ return paths;
1968
+ }
1969
+ function visitPeerEntityIdPaths(value, entityKind, parentPath, ancestors, paths) {
1970
+ if (typeof value !== "object" || value == null) return;
1971
+ if (ancestors.has(value)) return;
1972
+ ancestors.add(value);
1973
+ if (Array.isArray(value)) for (const [index, item] of value.entries()) visitPeerEntityIdPaths(item, entityKind, `${parentPath}[${index}]`, ancestors, paths);
1974
+ else for (const [key, child] of Object.entries(value)) {
1975
+ const path = parentPath.length === 0 ? key : `${parentPath}.${key}`;
1976
+ if (!(parentPath.length === 0 && key === "entityId") && !isOwnedLocalIdPath(entityKind, path) && isEntityIdFieldName(key)) paths.push(path);
1977
+ visitPeerEntityIdPaths(child, entityKind, path, ancestors, paths);
1978
+ }
1979
+ ancestors.delete(value);
1980
+ }
1981
+ function collectPeerEntityValuePaths(value, entityKind, ownEntityId, entityIds) {
1982
+ const paths = [];
1983
+ visitPeerEntityValues(value, entityKind, ownEntityId, entityIds, "", /* @__PURE__ */ new Set(), paths);
1984
+ return paths;
1985
+ }
1986
+ function visitPeerEntityValues(value, entityKind, ownEntityId, entityIds, path, ancestors, paths) {
1987
+ if (typeof value === "string") {
1988
+ if (value !== ownEntityId && entityIds.has(value) && !isOwnedLocalEntityValuePath(entityKind, path)) paths.push(path);
1989
+ return;
1990
+ }
1991
+ if (typeof value !== "object" || value == null || ancestors.has(value)) return;
1992
+ ancestors.add(value);
1993
+ if (Array.isArray(value)) for (const [index, item] of value.entries()) visitPeerEntityValues(item, entityKind, ownEntityId, entityIds, `${path}[${index}]`, ancestors, paths);
1994
+ else for (const [key, child] of Object.entries(value)) {
1995
+ if (path.length === 0 && (key === "entityId" || key === "entityKind")) continue;
1996
+ visitPeerEntityValues(child, entityKind, ownEntityId, entityIds, path.length === 0 ? key : `${path}.${key}`, ancestors, paths);
1997
+ }
1998
+ ancestors.delete(value);
1999
+ }
2000
+ function isOwnedLocalEntityValuePath(entityKind, path) {
2001
+ if (entityKind === "track" && path === "role") return true;
2002
+ if ([
2003
+ "video",
2004
+ "audio",
2005
+ "voice",
2006
+ "image",
2007
+ "caption",
2008
+ "axvideo"
2009
+ ].includes(entityKind) && /^(?:sampling|coordinateSpace|coordinateSpace\.unit|extent\.kind)$/.test(path)) return true;
2010
+ if (entityKind === "sequence-marker" && /^(?:durationPolicy|duration\.mode|timeRemapping\.(?:kind|mode))$/.test(path)) return true;
2011
+ if (entityKind === "asset" && /^(?:external\.(?:system|key)|storageKey|inline\.(?:mediaType|text))$/.test(path)) return true;
2012
+ if (entityKind === "voice" && /^voice\.(?:system|key|name)$/.test(path)) return true;
2013
+ if (entityKind === "caption" && (path === "text" || path.startsWith("style."))) return true;
2014
+ if ((entityKind === "audio-script" || entityKind === "phonetic-script") && /^segments\[\d+\]\.(?:segmentId|text|language)$/.test(path)) return true;
2015
+ return false;
2016
+ }
2017
+ function isOwnedLocalIdPath(entityKind, path) {
2018
+ if (path === "lifecycle.actorId") return true;
2019
+ if ((entityKind === "audio-script" || entityKind === "phonetic-script") && /^segments\[\d+\]\.segmentId$/.test(path)) return true;
2020
+ if (entityKind === "asset" && /^(?:tracks\[\d+\]\.trackId|renditions\[\d+\]\.renditionId)$/.test(path)) return true;
2021
+ return false;
2022
+ }
2023
+ function isEntityIdFieldName(key) {
2024
+ return key.endsWith("Id") || key.endsWith("Ids") || key.endsWith("ID") || key.endsWith("IDs") || /_ids?$/i.test(key);
2025
+ }
2026
+ function isRecord(value) {
2027
+ return typeof value === "object" && value != null && !Array.isArray(value);
2028
+ }
2029
+ function collectRelatedEntities(entityRefs, index) {
2030
+ const byId = /* @__PURE__ */ new Map();
2031
+ const queue = [...entityRefs];
2032
+ const issues = [];
2033
+ while (queue.length > 0) {
2034
+ const entity = queue.shift();
2035
+ if (entity == null) continue;
2036
+ const existing = byId.get(entity.entityId);
2037
+ if (existing != null) {
2038
+ if (existing !== entity) issues.push({
2039
+ code: "duplicate_entity_id",
2040
+ entityId: entity.entityId,
2041
+ message: `Entity id "${entity.entityId}" has more than one live EntityRef`
2042
+ });
2043
+ continue;
2044
+ }
2045
+ byId.set(entity.entityId, entity);
2046
+ for (const relation of index.relationsOf(entity)) for (const endpoint of relation.endpoints) {
2047
+ const ref = endpoint.deref();
2048
+ if (ref != null && !byId.has(ref.entityId)) queue.push(ref);
2049
+ }
2050
+ }
2051
+ return {
2052
+ entities: [...byId.values()],
2053
+ issues
2054
+ };
2055
+ }
2056
+ function expectedSequenceShape(kind) {
2057
+ if (kind === "video" || kind === "audio" || kind === "voice" || kind === "caption") return {
2058
+ extent: "bounded",
2059
+ sampling: "native"
2060
+ };
2061
+ if (kind === "image") return {
2062
+ extent: "unbounded",
2063
+ sampling: "constant"
2064
+ };
2065
+ if (kind === "axvideo") return {
2066
+ extent: "bounded",
2067
+ sampling: "derived"
2068
+ };
2069
+ }
2070
+ function ofKind(relations, kind) {
2071
+ return relations.filter((relation) => relation.kind === kind);
2072
+ }
2073
+ //#endregion
2074
+ //#region ../medeo-dsl/src/json-values.ts
2075
+ const nativeObjectConstructorSource = Function.prototype.toString.call(Object);
2076
+ function isJsonObject(value) {
2077
+ return isJsonValue(value, /* @__PURE__ */ new Set()) && !Array.isArray(value) && value !== null;
2078
+ }
2079
+ function isJsonValue(value, ancestors) {
2080
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
2081
+ if (typeof value === "number") return Number.isFinite(value);
2082
+ if (typeof value !== "object") return false;
2083
+ if (!Array.isArray(value) && !isPlainObject(value)) return false;
2084
+ if (ancestors.has(value)) return false;
2085
+ ancestors.add(value);
2086
+ const valid = Array.isArray(value) ? value.every((item) => isJsonValue(item, ancestors)) : Object.values(value).every((item) => isJsonValue(item, ancestors));
2087
+ ancestors.delete(value);
2088
+ return valid;
2089
+ }
2090
+ /** Recognize an ordinary object from any VM realm without admitting class instances. */
2091
+ function isPlainObject(value) {
2092
+ try {
2093
+ const prototype = Object.getPrototypeOf(value);
2094
+ if (prototype === null) return true;
2095
+ if (Object.getPrototypeOf(prototype) !== null) return false;
2096
+ const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor")?.value;
2097
+ return typeof constructor === "function" && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === nativeObjectConstructorSource;
2098
+ } catch {
2099
+ return false;
2100
+ }
2101
+ }
2102
+ //#endregion
2103
+ //#region ../medeo-dsl/src/relation-specs.ts
2104
+ const timelineTrackRelationSpec = emptySpec("timeline-track", "timeline", "track");
2105
+ const trackClipRelationSpec = emptySpec("track-clip", "track", "clip");
2106
+ const clipMarkerRelationSpec = emptySpec("clip-marker", "clip", "sequence-marker");
2107
+ const axVideoMarkerRelationSpec = emptySpec("axvideo-marker", "axvideo", "sequence-marker");
2108
+ const markerTimelineRelationSpec = emptySpec("marker-timeline", "sequence-marker", "timeline");
2109
+ const markerContentRelationSpec = Object.freeze({
2110
+ kind: "marker-content",
2111
+ validateEndpoints: (endpoints) => hasMarkerAndSequence(endpoints),
2112
+ validateMetadata: isEmptyMetadata
2113
+ });
2114
+ const physicalAssetRelationSpec = Object.freeze({
2115
+ kind: "physical-asset",
2116
+ validateEndpoints: (endpoints) => hasAssetAndSequence(endpoints),
2117
+ validateMetadata: isAssetBinding
2118
+ });
2119
+ /** `generated(output, input)` means endpoint 0 was generated from endpoint 1. */
2120
+ const generatedRelationSpec = Object.freeze({
2121
+ kind: "generated",
2122
+ validateEndpoints: (endpoints) => endpoints.every((endpoint) => isGeneratedMedia(endpoint.current())),
2123
+ validateMetadata: isEmptyMetadata
2124
+ });
2125
+ const phoneticScriptProvenanceRelationSpec = metadataSpec("phonetic-script-provenance", "phonetic-script", "audio-script", isSegmentAlignmentMetadata);
2126
+ const captionProvenanceRelationSpec = metadataSpec("caption-provenance", "caption", "audio-script", isSegmentAlignmentMetadata);
2127
+ const captionAlignmentRelationSpec = Object.freeze({
2128
+ kind: "caption-alignment",
2129
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["caption"]), new Set(["audio", "voice"])),
2130
+ validateMetadata: isCaptionAlignmentMetadata
2131
+ });
2132
+ /** `clip-anchor(child, host)` means endpoint 0 follows endpoint 1. */
2133
+ const clipAnchorRelationSpec = Object.freeze({
2134
+ kind: "clip-anchor",
2135
+ validateEndpoints: (endpoints) => endpoints[0].current().entityKind === "clip" && endpoints[1].current().entityKind === "clip",
2136
+ validateMetadata: isEmptyMetadata
2137
+ });
2138
+ /** `audio-script-render(output, script)` means endpoint 0 was rendered from endpoint 1. */
2139
+ const audioScriptRenderRelationSpec = Object.freeze({
2140
+ kind: "audio-script-render",
2141
+ validateEndpoints: (endpoints) => {
2142
+ const outputKind = endpoints[0].current().entityKind;
2143
+ return (outputKind === "audio" || outputKind === "voice") && endpoints[1].current().entityKind === "audio-script";
2144
+ },
2145
+ validateMetadata: isEmptyMetadata
2146
+ });
2147
+ /** Built-in kinds are reserved; callers may add specs only under new names. */
2148
+ const builtInRelationSpecs = Object.freeze([
2149
+ timelineTrackRelationSpec,
2150
+ trackClipRelationSpec,
2151
+ clipMarkerRelationSpec,
2152
+ markerContentRelationSpec,
2153
+ axVideoMarkerRelationSpec,
2154
+ markerTimelineRelationSpec,
2155
+ physicalAssetRelationSpec,
2156
+ generatedRelationSpec,
2157
+ phoneticScriptProvenanceRelationSpec,
2158
+ captionProvenanceRelationSpec,
2159
+ captionAlignmentRelationSpec,
2160
+ clipAnchorRelationSpec,
2161
+ audioScriptRenderRelationSpec
2162
+ ]);
2163
+ function emptySpec(kind, a, b) {
2164
+ return metadataSpec(kind, a, b, isEmptyMetadata);
2165
+ }
2166
+ function metadataSpec(kind, a, b, validateMetadata) {
2167
+ return Object.freeze({
2168
+ kind,
2169
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set([a]), new Set([b])),
2170
+ validateMetadata
2171
+ });
2172
+ }
2173
+ function hasKinds(endpoints, aKinds, bKinds) {
2174
+ const first = endpoints[0].current().entityKind;
2175
+ const second = endpoints[1].current().entityKind;
2176
+ return aKinds.has(first) && bKinds.has(second) || aKinds.has(second) && bKinds.has(first);
2177
+ }
2178
+ function hasMarkerAndSequence(endpoints) {
2179
+ const first = endpoints[0].current();
2180
+ const second = endpoints[1].current();
2181
+ return first.entityKind === "sequence-marker" && hasSequence(second) || second.entityKind === "sequence-marker" && hasSequence(first);
2182
+ }
2183
+ function hasAssetAndSequence(endpoints) {
2184
+ const first = endpoints[0].current();
2185
+ const second = endpoints[1].current();
2186
+ return first.entityKind === "asset" && hasSequence(second) || second.entityKind === "asset" && hasSequence(first);
2187
+ }
2188
+ function isGeneratedMedia(entity) {
2189
+ return entity.entityKind === "video" || entity.entityKind === "image" || entity.entityKind === "audio" || entity.entityKind === "voice";
2190
+ }
2191
+ function isEmptyMetadata(value) {
2192
+ return isJsonObject(value) && Object.keys(value).length === 0;
2193
+ }
2194
+ function isAssetBinding(value) {
2195
+ if (!isJsonObject(value)) return false;
2196
+ const orderingKey = /(order|ordinal|position|rank|index|z[_-]?index)/i;
2197
+ return Object.keys(value).every((key) => !orderingKey.test(key));
2198
+ }
2199
+ function isSegmentAlignmentMetadata(value) {
2200
+ return isJsonObject(value) && Object.hasOwn(value, "segmentAlignment");
2201
+ }
2202
+ function isCaptionAlignmentMetadata(value) {
2203
+ return isJsonObject(value) && Object.hasOwn(value, "alignment");
2204
+ }
2205
+ //#endregion
2206
+ //#region ../medeo-dsl/src/relations.ts
2207
+ function createEntityRef(entity) {
2208
+ return {
2209
+ entityId: entity.entityId,
2210
+ current: () => entity
2211
+ };
2212
+ }
2213
+ //#endregion
2214
+ //#region ../medeo-dsl/src/relation-index.ts
2215
+ var RelationEdge = class {
2216
+ relationId;
2217
+ kind;
2218
+ metadata;
2219
+ trace;
2220
+ endpoints;
2221
+ constructor(relationId, kind, endpoints, metadata, trace) {
2222
+ this.relationId = relationId;
2223
+ this.kind = kind;
2224
+ this.metadata = metadata;
2225
+ this.trace = trace;
2226
+ this.endpoints = [new WeakRef(endpoints[0]), new WeakRef(endpoints[1])];
2227
+ }
2228
+ other(entity) {
2229
+ const first = this.endpoints[0].deref();
2230
+ const second = this.endpoints[1].deref();
2231
+ if (first === entity) return this.endpoints[1];
2232
+ if (second === entity) return this.endpoints[0];
2233
+ }
2234
+ toRow() {
2235
+ const first = this.endpoints[0].deref();
2236
+ const second = this.endpoints[1].deref();
2237
+ if (first == null || second == null) return void 0;
2238
+ return {
2239
+ relationId: this.relationId,
2240
+ endpoint0EntityId: first.entityId,
2241
+ endpoint1EntityId: second.entityId,
2242
+ relationKind: this.kind,
2243
+ metadata: this.metadata,
2244
+ trace: this.trace
2245
+ };
2246
+ }
2247
+ isStale() {
2248
+ return this.endpoints[0].deref() == null || this.endpoints[1].deref() == null;
2249
+ }
2250
+ };
2251
+ /** Endpoint-agnostic secondary index. Relation rows remain the persistence authority. */
2252
+ var BiRelationIndex = class {
2253
+ byEntity = /* @__PURE__ */ new WeakMap();
2254
+ canonicalRefs = /* @__PURE__ */ new Map();
2255
+ byRelationId = /* @__PURE__ */ new Map();
2256
+ link(input) {
2257
+ if (input.spec.kind === "generated") throw new Error("Author generated Relations with linkGenerated({ output, input })");
2258
+ if (input.spec.kind === "clip-anchor" || input.spec.kind === "audio-script-render") throw new Error(`Author ordered ${input.spec.kind} Relations with the dedicated role-named method`);
2259
+ return this.linkValidated(input);
2260
+ }
2261
+ /** Author `generated(output, input)` without exposing positional arguments. */
2262
+ linkGenerated(input) {
2263
+ return this.linkValidated({
2264
+ relationId: input.relationId,
2265
+ spec: generatedRelationSpec,
2266
+ endpoints: [input.output, input.input],
2267
+ metadata: {},
2268
+ trace: input.trace
2269
+ });
2270
+ }
2271
+ /** Author `clip-anchor(child, host)` without exposing positional arguments. */
2272
+ linkClipAnchor(input) {
2273
+ return this.linkValidated({
2274
+ relationId: input.relationId,
2275
+ spec: clipAnchorRelationSpec,
2276
+ endpoints: [input.child, input.host],
2277
+ metadata: {},
2278
+ trace: input.trace
2279
+ });
2280
+ }
2281
+ /** Author `audio-script-render(output, script)` without exposing positional arguments. */
2282
+ linkAudioScriptRender(input) {
2283
+ return this.linkValidated({
2284
+ relationId: input.relationId,
2285
+ spec: audioScriptRenderRelationSpec,
2286
+ endpoints: [input.output, input.script],
2287
+ metadata: {},
2288
+ trace: input.trace
2289
+ });
2290
+ }
2291
+ /**
2292
+ * Rehydrate a persisted row after resolving its spec and endpoint refs.
2293
+ *
2294
+ * This is a storage-boundary escape hatch, not an authoring API: persisted
2295
+ * positions already are the semantic assertion made by their relation kind.
2296
+ */
2297
+ linkRuntime(input) {
2298
+ return this.linkValidated(input);
2299
+ }
2300
+ linkValidated(input) {
2301
+ const [first, second] = input.endpoints;
2302
+ if (first.entityId === second.entityId) throw new Error("A Relation cannot connect an entity to itself");
2303
+ this.assertRelationIdAvailable(input.relationId);
2304
+ const builtInSpec = builtInSpecByKind.get(input.spec.kind);
2305
+ if (builtInSpec != null && builtInSpec !== input.spec) throw new Error(`Relation kind "${input.spec.kind}" must use its built-in specification`);
2306
+ if (isForbiddenAuthoritativeRelation(input.spec.kind, input.endpoints)) throw new Error("A direct Clip-Content Relation is derived-only and cannot be authoritative");
2307
+ if (!input.spec.validateEndpoints(input.endpoints)) throw new Error(`Relation "${input.spec.kind}" received invalid endpoints`);
2308
+ if (!input.spec.validateMetadata(input.metadata)) throw new Error(`Relation "${input.spec.kind}" received invalid metadata`);
2309
+ this.assertCanonicalRefAvailable(first);
2310
+ this.assertCanonicalRefAvailable(second);
2311
+ const relation = new RelationEdge(input.relationId, input.spec.kind, input.endpoints, input.metadata, input.trace ?? {});
2312
+ this.rememberCanonicalRef(first);
2313
+ this.rememberCanonicalRef(second);
2314
+ this.add(first, relation);
2315
+ this.add(second, relation);
2316
+ this.byRelationId.set(input.relationId, new WeakRef(relation));
2317
+ return relation;
2318
+ }
2319
+ relationsOf(entity) {
2320
+ this.registerCanonicalRef(entity);
2321
+ const relations = this.byEntity.get(entity);
2322
+ if (relations == null) return /* @__PURE__ */ new Set();
2323
+ for (const relation of relations) if (relation instanceof RelationEdge && relation.isStale()) this.unlink(relation);
2324
+ return new Set(relations);
2325
+ }
2326
+ unlink(relation) {
2327
+ for (const endpoint of relation.endpoints) {
2328
+ const ref = endpoint.deref();
2329
+ if (ref != null) this.byEntity.get(ref)?.delete(relation);
2330
+ }
2331
+ if (this.byRelationId.get(relation.relationId)?.deref() === relation) this.byRelationId.delete(relation.relationId);
2332
+ }
2333
+ add(entity, relation) {
2334
+ const relations = this.byEntity.get(entity) ?? /* @__PURE__ */ new Set();
2335
+ relations.add(relation);
2336
+ this.byEntity.set(entity, relations);
2337
+ }
2338
+ registerCanonicalRef(entity) {
2339
+ this.assertCanonicalRefAvailable(entity);
2340
+ this.rememberCanonicalRef(entity);
2341
+ }
2342
+ assertCanonicalRefAvailable(entity) {
2343
+ const existing = this.canonicalRefs.get(entity.entityId)?.deref();
2344
+ if (existing != null && existing !== entity) throw new Error(`Entity "${entity.entityId}" already has a live canonical EntityRef`);
2345
+ }
2346
+ rememberCanonicalRef(entity) {
2347
+ this.canonicalRefs.set(entity.entityId, new WeakRef(entity));
2348
+ }
2349
+ assertRelationIdAvailable(relationId) {
2350
+ if (this.byRelationId.get(relationId)?.deref() != null) throw new Error(`Relation id "${relationId}" already exists`);
2351
+ this.byRelationId.delete(relationId);
2352
+ }
2353
+ };
2354
+ const builtInSpecByKind = new Map(builtInRelationSpecs.map((spec) => [spec.kind, spec]));
2355
+ function isForbiddenAuthoritativeRelation(kind, endpoints) {
2356
+ if (kind === "clip-content") return true;
2357
+ const first = endpoints[0].current();
2358
+ const second = endpoints[1].current();
2359
+ return first.entityKind === "clip" && hasSequence(second) || second.entityKind === "clip" && hasSequence(first);
2360
+ }
2361
+ //#endregion
2362
+ //#region ../medeo-dsl/src/entity-relation-rows.ts
2363
+ var InvalidEntityRelationRowsError = class extends Error {
2364
+ issues;
2365
+ constructor(issues) {
2366
+ super(`Invalid Medeo entity/relation rows:\n- ${issues.join("\n- ")}`);
2367
+ this.issues = issues;
2368
+ this.name = "InvalidEntityRelationRowsError";
2369
+ }
2370
+ };
2371
+ /** Decodes database rows into flat entities and validates the complete relation set. */
2372
+ function decodeEntityRelationRows(rows, options) {
2373
+ const issues = [];
2374
+ const entitiesById = /* @__PURE__ */ new Map();
2375
+ const extensionEntityKinds = new Set(options.entityKinds ?? []);
2376
+ for (const row of rows.entities) {
2377
+ const entity = decodeEntityRow(row, extensionEntityKinds, issues);
2378
+ if (entity == null) continue;
2379
+ const ref = createEntityRef(entity);
2380
+ if (entitiesById.has(ref.entityId)) {
2381
+ issues.push(`duplicate entity id "${ref.entityId}"`);
2382
+ continue;
2383
+ }
2384
+ entitiesById.set(ref.entityId, ref);
2385
+ }
2386
+ const specsByKind = collectRelationSpecs(options.relationSpecs ?? [], issues);
2387
+ const relationIndex = new BiRelationIndex();
2388
+ const relations = [];
2389
+ const relationIds = /* @__PURE__ */ new Set();
2390
+ for (const row of rows.relations) {
2391
+ let relationId;
2392
+ try {
2393
+ relationId = createRelationId(row.relationId);
2394
+ } catch (error) {
2395
+ issues.push(errorMessage(error));
2396
+ continue;
2397
+ }
2398
+ if (relationIds.has(relationId)) {
2399
+ issues.push(`duplicate relation id "${relationId}"`);
2400
+ continue;
2401
+ }
2402
+ relationIds.add(relationId);
2403
+ if (!isTrimmedNonEmpty(row.relationKind)) {
2404
+ issues.push(`relation "${relationId}" has an empty or untrimmed kind`);
2405
+ continue;
2406
+ }
2407
+ const spec = specsByKind.get(row.relationKind);
2408
+ if (spec == null) {
2409
+ issues.push(`relation "${relationId}" has no registered spec for kind "${row.relationKind}"`);
2410
+ continue;
2411
+ }
2412
+ let endpoint0EntityId;
2413
+ let endpoint1EntityId;
2414
+ try {
2415
+ endpoint0EntityId = createEntityId(row.endpoint0EntityId);
2416
+ endpoint1EntityId = createEntityId(row.endpoint1EntityId);
2417
+ } catch (error) {
2418
+ issues.push(errorMessage(error));
2419
+ continue;
2420
+ }
2421
+ const endpoint0 = entitiesById.get(endpoint0EntityId);
2422
+ const endpoint1 = entitiesById.get(endpoint1EntityId);
2423
+ if (endpoint0 == null || endpoint1 == null) {
2424
+ const missing = [endpoint0 == null ? endpoint0EntityId : void 0, endpoint1 == null ? endpoint1EntityId : void 0].filter((value) => value != null).join(", ");
2425
+ issues.push(`relation "${relationId}" references missing entity id(s): ${missing}`);
2426
+ continue;
2427
+ }
2428
+ if (!isJsonObject(row.metadata) || !isJsonObject(row.trace)) {
2429
+ issues.push(`relation "${relationId}" metadata and trace must contain only JSON values`);
2430
+ continue;
2431
+ }
2432
+ try {
2433
+ relations.push(relationIndex.linkRuntime({
2434
+ relationId,
2435
+ spec,
2436
+ endpoints: [endpoint0, endpoint1],
2437
+ metadata: row.metadata,
2438
+ trace: row.trace
2439
+ }));
2440
+ } catch (error) {
2441
+ issues.push(errorMessage(error));
2442
+ }
2443
+ }
2444
+ if (issues.length === 0) for (const issue of validateEntityRelationSet([...entitiesById.values()], relationIndex, options)) issues.push(`${issue.code}: ${issue.message}`);
2445
+ if (issues.length > 0) throw new InvalidEntityRelationRowsError(issues);
2446
+ return {
2447
+ entitiesById,
2448
+ relationIndex,
2449
+ relations
2450
+ };
2451
+ }
2452
+ function decodeEntityRow(row, extensionEntityKinds, issues) {
2453
+ let entityId;
2454
+ try {
2455
+ entityId = createEntityId(row.entityId);
2456
+ } catch (error) {
2457
+ issues.push(errorMessage(error));
2458
+ return;
2459
+ }
2460
+ if (!isTrimmedNonEmpty(row.entityKind)) {
2461
+ issues.push(`entity "${entityId}" has an empty or untrimmed kind`);
2462
+ return;
2463
+ }
2464
+ if (isReservedEntityKind(row.entityKind)) {
2465
+ issues.push(`entity "${entityId}" uses reserved kind "${row.entityKind}"`);
2466
+ return;
2467
+ }
2468
+ if (!isKnownEntityKind(row.entityKind) && !extensionEntityKinds.has(row.entityKind)) {
2469
+ issues.push(`entity "${entityId}" has unregistered extension kind "${row.entityKind}"`);
2470
+ return;
2471
+ }
2472
+ if (!isJsonObject(row.payload)) {
2473
+ issues.push(`entity "${entityId}" payload must contain only JSON values`);
2474
+ return;
2475
+ }
2476
+ const reserved = ["entityId", "entityKind"].filter((key) => Object.hasOwn(row.payload, key));
2477
+ if (reserved.length > 0) {
2478
+ issues.push(`entity "${entityId}" payload contains reserved field(s): ${reserved.join(", ")}`);
2479
+ return;
2480
+ }
2481
+ return {
2482
+ ...row.payload,
2483
+ entityId,
2484
+ entityKind: row.entityKind
2485
+ };
2486
+ }
2487
+ function collectRelationSpecs(extensionSpecs, issues) {
2488
+ const specs = /* @__PURE__ */ new Map();
2489
+ for (const spec of builtInRelationSpecs) specs.set(spec.kind, spec);
2490
+ for (const spec of extensionSpecs) {
2491
+ if (!isTrimmedNonEmpty(spec.kind)) {
2492
+ issues.push("extension relation spec has an empty or untrimmed kind");
2493
+ continue;
2494
+ }
2495
+ if (specs.has(spec.kind)) {
2496
+ issues.push(`relation spec kind "${spec.kind}" is already registered`);
2497
+ continue;
2498
+ }
2499
+ specs.set(spec.kind, spec);
2500
+ }
2501
+ return specs;
2502
+ }
2503
+ function isTrimmedNonEmpty(value) {
2504
+ return value.length > 0 && value.trim() === value;
2505
+ }
2506
+ function errorMessage(error) {
2507
+ return error instanceof Error ? error.message : String(error);
2508
+ }
2509
+ //#endregion
2510
+ //#region src/document/entity-timeline-layout.ts
2511
+ /** Validated graph lookup shared by the compatibility layout and projection. */
2512
+ var EntityProjectionGraph = class {
2513
+ rows;
2514
+ entities;
2515
+ timeline;
2516
+ constructor(rows) {
2517
+ this.rows = rows;
2518
+ decodeEntityRelationRows(rows, { compareMarkerPoints: (_marker, _range, left, right) => finite(left, "range point") - finite(right, "range point") });
2519
+ this.entities = new Map(rows.entities.map((row) => [String(row.entityId), row]));
2520
+ const roots = rows.entities.filter((row) => row.entityKind === "timeline");
2521
+ if (roots.length !== 1) fail("compatibility projection requires exactly one Timeline");
2522
+ this.timeline = roots[0];
2523
+ }
2524
+ entity(id, kind) {
2525
+ const row = this.entities.get(id);
2526
+ if (row === void 0 || kind !== void 0 && row.entityKind !== kind) fail(`Expected ${kind ?? "Entity"} ${id}`);
2527
+ return row;
2528
+ }
2529
+ adjacent(id, kind) {
2530
+ return [...new Set(this.rows.relations.filter((row) => row.relationKind === kind && (row.endpoint0EntityId === id || row.endpoint1EntityId === id)).map((row) => row.endpoint0EntityId === id ? row.endpoint1EntityId : row.endpoint0EntityId))].map((entityId) => this.entity(entityId));
2531
+ }
2532
+ one(id, relation, kind) {
2533
+ const values = this.adjacent(id, relation);
2534
+ if (values.length !== 1 || kind !== void 0 && values[0]?.entityKind !== kind) fail(`entity ${id} requires exactly one ${relation}${kind === void 0 ? "" : ` to ${kind}`}`);
2535
+ return values[0];
2536
+ }
2537
+ };
2538
+ const roleOrder = {
2539
+ caption: 0,
2540
+ video_clip: 1,
2541
+ speech: 2,
2542
+ bgm: 3
2543
+ };
2544
+ /**
2545
+ * Resolve only entity-owned placement facts. This does not choose new anchors,
2546
+ * manufacture clips, or mutate a graph while reading. Write-side operations
2547
+ * must express any detach/reparent/overlap decision in their entity plan.
2548
+ */
2549
+ function resolveEntityTimelineLayout(rows) {
2550
+ return resolveGraphLayout(new EntityProjectionGraph(rows));
2551
+ }
2552
+ function resolveGraphLayout(graph) {
2553
+ const facts = /* @__PURE__ */ new Map();
2554
+ const seenRoles = /* @__PURE__ */ new Set();
2555
+ const tracks = graph.adjacent(graph.timeline.entityId, "timeline-track").map((track) => {
2556
+ const role = track.payload.role;
2557
+ if (track.entityKind !== "track" || typeof role !== "string" || !Object.hasOwn(roleOrder, role)) fail(`unsupported compatibility Track role on ${track.entityId}`);
2558
+ if (seenRoles.has(role)) fail(`The compatibility editor supports only one ${role} Track`);
2559
+ seenRoles.add(role);
2560
+ const typedRole = role;
2561
+ if (track.payload.hidden !== void 0 && typeof track.payload.hidden !== "boolean") fail("Track hidden must be boolean");
2562
+ const order = track.payload.order === void 0 ? roleOrder[typedRole] : finite(track.payload.order, "Track order");
2563
+ const trackFacts = graph.adjacent(track.entityId, "track-clip").map((clip) => {
2564
+ if (clip.entityKind !== "clip" || graph.adjacent(clip.entityId, "track-clip").length !== 1) fail(`Clip ${clip.entityId} must belong to exactly one Track`);
2565
+ const marker = graph.one(clip.entityId, "clip-marker", "sequence-marker");
2566
+ const content = graph.one(marker.entityId, "marker-content");
2567
+ 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`);
2568
+ const space = content.payload.coordinateSpace;
2569
+ if (space !== "ms" && space !== "milliseconds" && (!object(space) || space.unit !== "ms" && space.unit !== "milliseconds")) fail(`media ${content.entityId} must state millisecond coordinates for this reader`);
2570
+ const source = range(marker.payload.sourceRange, "sourceRange");
2571
+ const durationFact = marker.payload.duration;
2572
+ if (!object(durationFact)) fail(`Marker ${marker.entityId} requires duration`);
2573
+ const duration = positive(durationFact.mode === "from-source" ? source.end - source.start : durationFact.value, "duration");
2574
+ if (!Number.isSafeInteger(duration)) fail("Compatibility duration must be whole milliseconds");
2575
+ const fillTimeline = marker.payload.durationPolicy === "timeline";
2576
+ if (marker.payload.durationPolicy !== void 0 && (!fillTimeline || typedRole !== "bgm")) fail("durationPolicy is only supported for timeline-filling background Audio");
2577
+ const anchors = graph.rows.relations.filter((row) => row.relationKind === "clip-anchor" && row.endpoint0EntityId === clip.entityId);
2578
+ if (anchors.length > 1) fail(`Clip ${clip.entityId} has multiple anchors`);
2579
+ const target = marker.payload.targetRange === void 0 ? void 0 : range(marker.payload.targetRange, "targetRange");
2580
+ const anchorId = anchors[0]?.endpoint1EntityId;
2581
+ let mode;
2582
+ let absoluteStart;
2583
+ let anchorOffset;
2584
+ let clipOrder;
2585
+ if (anchorId !== void 0) {
2586
+ if (target !== void 0) fail(`Anchored Marker ${marker.entityId} cannot also have targetRange`);
2587
+ mode = "anchored";
2588
+ anchorOffset = milliseconds(marker.payload.anchorOffset, "anchorOffset");
2589
+ } else {
2590
+ if (marker.payload.anchorOffset !== void 0) fail(`Marker ${marker.entityId} has anchorOffset without a clip-anchor Relation`);
2591
+ if (target !== void 0) {
2592
+ mode = "absolute";
2593
+ absoluteStart = milliseconds(target.start, "target start");
2594
+ milliseconds(target.end, "target end");
2595
+ if (Math.abs(target.end - target.start - duration) > 1e-6) fail(`Marker ${marker.entityId} targetRange must match its effective duration`);
2596
+ } else {
2597
+ mode = "sequential";
2598
+ if (clip.payload.order === void 0) fail(`Clip ${clip.entityId} has no placement: targetRange or explicit order is required`);
2599
+ clipOrder = finite(clip.payload.order, "Clip order");
2600
+ }
2601
+ }
2602
+ const fact = {
2603
+ clip,
2604
+ marker,
2605
+ content,
2606
+ trackId: track.entityId,
2607
+ role: typedRole,
2608
+ duration,
2609
+ mode,
2610
+ order: clipOrder,
2611
+ absoluteStart,
2612
+ anchorId,
2613
+ anchorOffset,
2614
+ fillTimeline
2615
+ };
2616
+ facts.set(clip.entityId, fact);
2617
+ return fact;
2618
+ });
2619
+ if (typedRole === "bgm" && trackFacts.length > 1) fail("The compatibility editor supports only one background Audio Clip");
2620
+ const sequential = trackFacts.filter((fact) => fact.mode === "sequential").sort((left, right) => left.order - right.order);
2621
+ if (new Set(sequential.map((fact) => fact.order)).size !== sequential.length) fail("sequential Clips must have distinct order values");
2622
+ sequential.forEach((fact, index) => {
2623
+ fact.predecessor = sequential[index - 1]?.clip.entityId;
2624
+ });
2625
+ return {
2626
+ entityId: String(track.entityId),
2627
+ role: typedRole,
2628
+ order,
2629
+ hidden: track.payload.hidden === true,
2630
+ trackFacts
2631
+ };
2632
+ });
2633
+ tracks.sort((left, right) => left.order - right.order || left.entityId.localeCompare(right.entityId));
2634
+ const resolved = /* @__PURE__ */ new Map();
2635
+ const visiting = /* @__PURE__ */ new Set();
2636
+ const resolve = (id) => {
2637
+ const cached = resolved.get(id);
2638
+ if (cached !== void 0) return cached;
2639
+ if (visiting.has(id)) fail(`Cyclic Clip placement dependency at ${id}`);
2640
+ visiting.add(id);
2641
+ const fact = facts.get(id);
2642
+ if (fact === void 0) fail(`Placement refers to unplaced Clip ${id}`);
2643
+ let start = fact.absoluteStart ?? 0;
2644
+ if (fact.mode === "anchored") {
2645
+ const host = resolve(fact.anchorId);
2646
+ if (fact.role === "speech" && facts.get(host.clipEntityId).role !== "video_clip") fail("An anchored Voice must use a visual Clip as its host");
2647
+ start = host.start + fact.anchorOffset;
2648
+ } else if (fact.predecessor !== void 0) {
2649
+ const prior = resolve(fact.predecessor);
2650
+ start = prior.start + prior.duration;
2651
+ }
2652
+ milliseconds(start + fact.duration, "Clip end");
2653
+ const result = {
2654
+ clipEntityId: id,
2655
+ markerEntityId: fact.marker.entityId,
2656
+ contentEntityId: fact.content.entityId,
2657
+ trackEntityId: fact.trackId,
2658
+ start: milliseconds(start, "Clip start"),
2659
+ duration: fact.duration,
2660
+ mode: fact.mode,
2661
+ ...fact.anchorId === void 0 ? {} : {
2662
+ anchorClipEntityId: fact.anchorId,
2663
+ anchorOffset: fact.anchorOffset
2664
+ }
2665
+ };
2666
+ visiting.delete(id);
2667
+ resolved.set(id, result);
2668
+ return result;
2669
+ };
2670
+ for (const id of facts.keys()) resolve(id);
2671
+ const durationMs = Math.max(0, ...[...resolved.values()].filter((clip) => facts.get(clip.clipEntityId).role !== "bgm").map((clip) => clip.start + clip.duration));
2672
+ for (const [id, fact] of facts) if (fact.fillTimeline) {
2673
+ const clip = resolved.get(id);
2674
+ if (clip.start !== 0) fail("Timeline-filling BGM must start at zero");
2675
+ resolved.set(id, {
2676
+ ...clip,
2677
+ duration: durationMs
2678
+ });
2679
+ }
2680
+ return {
2681
+ durationMs,
2682
+ clips: resolved,
2683
+ tracks: tracks.map(({ trackFacts, ...track }) => ({
2684
+ ...track,
2685
+ clips: trackFacts.map((fact) => resolved.get(fact.clip.entityId)).sort((left, right) => left.start - right.start || left.clipEntityId.localeCompare(right.clipEntityId))
2686
+ }))
2687
+ };
2688
+ }
2689
+ //#endregion
2690
+ //#region src/document/entity-projection.ts
2691
+ /**
2692
+ * One-way compatibility view over the Entity graph. A legacy part is never
2693
+ * consulted as an editing fact. Peer IDs appear only in this derived read
2694
+ * contract, reconstructed from relations; they are not stored in payloads.
2695
+ */
2696
+ function projectEntityTimeline(rows, meta) {
2697
+ const graph = new EntityProjectionGraph(rows);
2698
+ const layout = resolveGraphLayout(graph);
2699
+ for (const track of layout.tracks) {
2700
+ if (track.role !== "video_clip") continue;
2701
+ for (let index = 1; index < track.clips.length; index += 1) {
2702
+ const previous = track.clips[index - 1];
2703
+ if (track.clips[index].start < previous.start + previous.duration) fail("Overlapping visual Clips require an explicit overlay adapter; the current editor supports one visual lane");
2704
+ }
2705
+ }
2706
+ const parts = {};
2707
+ for (const track of layout.tracks) for (const placement of track.clips) {
2708
+ const clip = graph.entity(placement.clipEntityId, "clip");
2709
+ const marker = graph.entity(placement.markerEntityId, "sequence-marker");
2710
+ const content = graph.entity(placement.contentEntityId);
2711
+ const volume = volumeSchema.safeParse(clip.payload.volume ?? 0);
2712
+ if (!volume.success) fail("Clip volume must be decibels between -60 and 20");
2713
+ const asset = graph.one(content.entityId, "physical-asset", "asset");
2714
+ if (track.role === "video_clip") {
2715
+ const source = range(marker.payload.sourceRange, "sourceRange");
2716
+ const external = externalLocator(asset, "memota");
2717
+ const speed = videoSpeed(content, marker, placement);
2718
+ parts[clip.entityId] = { video_clip: {
2719
+ id: clip.entityId,
2720
+ kind: "video_clip",
2721
+ origin_media_id: external.key,
2722
+ play_in: content.entityKind === "image" && speed === void 0 ? 0 : source.start,
2723
+ play_out: content.entityKind === "image" && speed === void 0 ? placement.duration : source.end,
2724
+ volume: volume.data,
2725
+ ...speed === void 0 ? {} : { speed_shift: speed }
2726
+ } };
2727
+ } else if (track.role === "speech") {
2728
+ requireUntrimmedAudio(content, marker, placement);
2729
+ const external = externalLocator(asset, "memota-speech");
2730
+ const script = graph.one(content.entityId, "audio-script-render", "audio-script");
2731
+ const voice = content.payload.voice;
2732
+ if (voice !== void 0 && (!object(voice) || voice.system !== "voice-library" || typeof voice.key !== "string")) fail("Voice must use an explicit voice-library locator");
2733
+ const captionIds = layout.tracks.flatMap((candidate) => candidate.role === "caption" ? candidate.clips.filter((caption) => caption.anchorClipEntityId === clip.entityId).map((caption) => caption.clipEntityId) : []);
2734
+ parts[clip.entityId] = { speech: {
2735
+ id: clip.entityId,
2736
+ kind: "speech",
2737
+ media_duration_ms: placement.duration,
2738
+ audio_storage_key: storageKey(asset),
2739
+ origin_speech_id: external.key,
2740
+ audio_script: scriptText(script),
2741
+ volume: volume.data,
2742
+ voice: object(voice) ? {
2743
+ id: typeof voice.key === "string" ? voice.key : void 0,
2744
+ name: typeof voice.name === "string" ? voice.name : void 0
2745
+ } : void 0,
2746
+ caption_ids: captionIds
2747
+ } };
2748
+ } else if (track.role === "caption") {
2749
+ const text = content.payload.text;
2750
+ if (typeof text !== "string") fail("Caption " + content.entityId + " requires its text");
2751
+ const inline = asset.payload.inline;
2752
+ if (inline !== void 0 && (!object(inline) || inline.mediaType !== "text/plain" || inline.text !== text)) fail("Inline Caption Asset and Caption text must agree");
2753
+ if (inline === void 0 && !object(asset.payload.external)) fail("Caption Asset must identify physical text or contain actual inline text");
2754
+ if (placement.anchorClipEntityId === void 0) fail("Caption placement requires an explicit voice Clip anchor");
2755
+ const anchor = layout.clips.get(placement.anchorClipEntityId);
2756
+ if (anchor === void 0 || graph.entity(anchor.contentEntityId).entityKind !== "voice") fail("Caption anchor must resolve a placed Voice");
2757
+ if (graph.one(content.entityId, "caption-alignment").entityId !== anchor.contentEntityId) fail("Caption alignment must agree with its placed Voice");
2758
+ const script = graph.one(content.entityId, "caption-provenance", "audio-script");
2759
+ if (graph.one(anchor.contentEntityId, "audio-script-render", "audio-script").entityId !== script.entityId) fail("Caption provenance must agree with the Voice AudioScript");
2760
+ if (marker.payload.timeRemapping !== void 0) fail("Caption timeRemapping is not supported by this reader");
2761
+ const style = captionStyle(content.payload.style);
2762
+ parts[clip.entityId] = { caption: {
2763
+ id: clip.entityId,
2764
+ kind: "caption",
2765
+ text,
2766
+ speech_part_id: placement.anchorClipEntityId,
2767
+ start_ms: placement.anchorOffset,
2768
+ initial_duration_ms: placement.duration,
2769
+ ...style === void 0 ? {} : { style }
2770
+ } };
2771
+ } else {
2772
+ const external = externalLocator(asset, "memota");
2773
+ if (marker.payload.durationPolicy !== "timeline") fail("Background Audio requires an explicit timeline-fill durationPolicy");
2774
+ if (marker.payload.timeRemapping !== void 0) fail("BGM timeRemapping is not supported by this reader");
2775
+ const source = range(marker.payload.sourceRange, "sourceRange");
2776
+ const extent = content.payload.extent;
2777
+ if (!object(extent) || extent.start !== source.start || extent.end !== source.end) fail("Background Audio compatibility requires the complete source");
2778
+ parts[clip.entityId] = { bgm: {
2779
+ id: clip.entityId,
2780
+ kind: "bgm",
2781
+ audio_storage_key: storageKey(asset),
2782
+ origin_media_id: external.key,
2783
+ volume: volume.data
2784
+ } };
2785
+ }
2786
+ }
2787
+ const document = {
2788
+ meta: {
2789
+ ...structuredClone(meta),
2790
+ schema_version: ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION
2791
+ },
2792
+ timeline: graph.timeline.payload.unit_time_ms === void 0 ? void 0 : { unit_time_ms: positive(graph.timeline.payload.unit_time_ms, "unit_time_ms") },
2793
+ tracks: layout.tracks.map((track) => ({
2794
+ id: track.entityId,
2795
+ parts_kind: track.role,
2796
+ is_hidden: track.hidden,
2797
+ items: track.clips.map((clip) => clip.mode === "anchored" ? {
2798
+ part_id: clip.clipEntityId,
2799
+ time_position: {
2800
+ mode: "anchored",
2801
+ anchorPartId: clip.anchorClipEntityId,
2802
+ offsetMs: clip.anchorOffset
2803
+ },
2804
+ fallback_abs_ms: clip.start
2805
+ } : {
2806
+ part_id: clip.clipEntityId,
2807
+ time_position: {
2808
+ mode: "absolute",
2809
+ offsetMs: clip.start
2810
+ }
2811
+ })
2812
+ })),
2813
+ part_library: parts
2814
+ };
2815
+ assertValidVideoDocument(document);
2816
+ return document;
2817
+ }
2818
+ function externalLocator(asset, system) {
2819
+ const value = asset.payload.external;
2820
+ if (!object(value) || value.system !== system || typeof value.key !== "string" || value.key.trim() === "") fail("Asset " + asset.entityId + " requires an explicit " + system + " external key");
2821
+ return { key: value.key };
2822
+ }
2823
+ function storageKey(asset) {
2824
+ if (typeof asset.payload.storageKey !== "string" || asset.payload.storageKey.trim() === "") fail("Asset " + asset.entityId + " requires its factual storageKey");
2825
+ return asset.payload.storageKey;
2826
+ }
2827
+ function scriptText(script) {
2828
+ const segments = script.payload.segments;
2829
+ if (!Array.isArray(segments)) fail("AudioScript requires segments");
2830
+ return segments.map((segment) => {
2831
+ if (!object(segment) || typeof segment.text !== "string") fail("AudioScript segment requires text");
2832
+ return segment.text;
2833
+ }).join("");
2834
+ }
2835
+ function captionStyle(value) {
2836
+ if (value === void 0) return void 0;
2837
+ if (!object(value)) fail("Caption style must be an object");
2838
+ const { font, ...owned } = structuredClone(value);
2839
+ const fields = {
2840
+ fontSize: "font_size",
2841
+ fontColor: "font_color",
2842
+ fontWeight: "font_weight",
2843
+ entranceAnimation: "entrance_animation",
2844
+ entranceAnimationDurationMs: "entrance_animation_duration_ms",
2845
+ strokeColor: "stroke_color",
2846
+ strokeWidth: "stroke_width",
2847
+ positionX: "position_x",
2848
+ positionY: "position_y"
2849
+ };
2850
+ const style = {};
2851
+ for (const [key, field] of Object.entries(owned)) {
2852
+ if (!Object.hasOwn(fields, key)) fail("Unsupported Caption style field " + key);
2853
+ style[fields[key]] = field;
2854
+ }
2855
+ if (font !== void 0) {
2856
+ if (!object(font) || font.system !== "font-library" || typeof font.key !== "string") fail("Caption font requires a font-library locator");
2857
+ return {
2858
+ ...style,
2859
+ font_id: font.key
2860
+ };
2861
+ }
2862
+ return style;
2863
+ }
2864
+ function requireUntrimmedAudio(content, marker, placement) {
2865
+ const source = range(marker.payload.sourceRange, "sourceRange");
2866
+ const extent = content.payload.extent;
2867
+ 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");
2868
+ }
2869
+ function videoSpeed(content, marker, placement) {
2870
+ const remapping = marker.payload.timeRemapping;
2871
+ if (content.entityKind === "image" && remapping === void 0) return void 0;
2872
+ const source = range(marker.payload.sourceRange, "sourceRange");
2873
+ if (remapping !== void 0) {
2874
+ if (!object(remapping) || remapping.kind !== "linear") fail("unsupported timeRemapping on " + marker.entityId);
2875
+ if (Object.keys(remapping).some((key) => key !== "kind" && key !== "rate" && key !== "mode")) fail("Unsupported linear timeRemapping fields");
2876
+ const rate = positive(remapping.rate, "linear remapping rate");
2877
+ if (Math.round((source.end - source.start) / rate) !== placement.duration) fail("timeRemapping on " + marker.entityId + " must agree with Marker duration");
2878
+ if (remapping.mode !== void 0 && typeof remapping.mode !== "string") fail("Linear remapping mode must be a string");
2879
+ return {
2880
+ category: "linear",
2881
+ mode: typeof remapping.mode === "string" ? remapping.mode : "constant",
2882
+ config: { linear: { speed: rate } }
2883
+ };
2884
+ }
2885
+ const rate = (source.end - source.start) / placement.duration;
2886
+ return rate === 1 ? void 0 : {
2887
+ category: "linear",
2888
+ mode: "constant",
2889
+ config: { linear: { speed: rate } }
2890
+ };
2891
+ }
2892
+ //#endregion
1486
2893
  //#region src/document/mirror-read.ts
1487
2894
  /**
1488
2895
  * Project the mirror state (`VideoDocumentDraft`) into the authoritative
@@ -1510,7 +2917,7 @@ function readVideoDocumentFromDraft(draft) {
1510
2917
  const meta = draft.meta;
1511
2918
  return {
1512
2919
  meta: {
1513
- schema_version: VIDEO_DOCUMENT_SCHEMA_VERSION,
2920
+ schema_version: meta?.schema_version === "video-document/entity-projection-v1" ? ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION : VIDEO_DOCUMENT_SCHEMA_VERSION,
1514
2921
  draft_id: emptyToUndefined(meta?.draft_id),
1515
2922
  project_id: emptyToUndefined(meta?.project_id),
1516
2923
  owner_id: emptyToUndefined(meta?.owner_id),
@@ -1591,6 +2998,7 @@ var MirrorVideoDocumentAdapter = class {
1591
2998
  * skips the commit — matching the prior "empty op leaves no audit" behavior.
1592
2999
  */
1593
3000
  transact(edit, audit) {
3001
+ 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");
1594
3002
  this.mirror.setState((draft) => {
1595
3003
  edit(draft);
1596
3004
  }, {
@@ -1621,6 +3029,33 @@ function createMirrorVideoDocument(document, options = {}) {
1621
3029
  function createMirrorVideoDocumentAdapter(document, options) {
1622
3030
  return new MirrorVideoDocumentAdapter(createMirrorVideoDocument(document, options));
1623
3031
  }
3032
+ /** Host-only replacement of a derived view, retaining the existing Loro history. */
3033
+ function applyEntityTimelineProjection(doc, projection, entityRevision) {
3034
+ if (projection.meta.schema_version !== "video-document/entity-projection-v1") throw new Error("Expected an entity timeline projection");
3035
+ if (!Number.isSafeInteger(entityRevision) || entityRevision < 1) throw new Error("Expected a positive entity revision");
3036
+ assertValidVideoDocument(projection);
3037
+ const from = doc.oplogVersion();
3038
+ new Mirror({
3039
+ doc,
3040
+ schema: videoDocumentMirrorSchema
3041
+ }).setState((draft) => writeVideoDocumentToDraft(draft, {
3042
+ ...projection,
3043
+ meta: {
3044
+ ...projection.meta,
3045
+ version: entityRevision
3046
+ }
3047
+ }), {
3048
+ origin: "mengine.entity_projection",
3049
+ message: JSON.stringify({
3050
+ semantic_op: "EntityTimelineProjection",
3051
+ entity_revision: entityRevision
3052
+ })
3053
+ });
3054
+ return doc.export({
3055
+ mode: "update",
3056
+ from
3057
+ });
3058
+ }
1624
3059
  /** Write a whole `VideoDocument` into a draft (seed a fresh doc / plain-memory state). */
1625
3060
  function writeVideoDocumentToDraft(draft, document) {
1626
3061
  draft.meta = {
@@ -1747,6 +3182,7 @@ var PlainMemoryAdapter = class {
1747
3182
  * no structural change — skip journal, matching mirror "no change, no commit".
1748
3183
  */
1749
3184
  transact(edit, audit) {
3185
+ 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");
1750
3186
  this.pendingIds = [];
1751
3187
  try {
1752
3188
  const next = produce(this.state, edit);
@@ -1767,4 +3203,4 @@ function createPlainMemoryAdapter(document, options) {
1767
3203
  return new PlainMemoryAdapter(document, options);
1768
3204
  }
1769
3205
  //#endregion
1770
- export { resolveSpeechOverlapByShiftingVideos as A, partUnionToDraft as B, solveVideoDocument as C, reassignSpeechesToVideoClipsByTime as D, fillMainTrackTimeGaps as E, safeDurationMs as F, videoDocumentMirrorSchema as H, DEFAULT_UNIT_TIME_MS as I, VIDEO_DOCUMENT_SCHEMA_VERSION as L, TIMELINE_SKELETON_DURATION_MS as M, isEmptyVideoClip as N, recalculateTimelineDuration as O, partDurationMs as P, effectiveVideoClipDurationMs as R, laneTrackId as S, arrangeMainTrackSeamlessly as T, base64ToBytes as U, recordEntries as V, bytesToBase64 as W, partUnionSchema as _, createMirrorVideoDocument as a, ensureLaneTrack as b, readVideoDocumentFromDraft as c, derivePositionFromAbs as d, fromVideoDocument as f, validateVideoDocument as g, assertValidVideoDocument as h, MirrorVideoDocumentAdapter as i, syncAggregatedClipsTimePosition as j, resolveAllSpeechOverlaps as k, buildInitialVideoDocument as l, VideoDocumentValidationError as m, createPlainMemoryAdapter as n, createMirrorVideoDocumentAdapter as o, toVideoDocument as p, generatePartId as r, writeVideoDocumentToDraft as s, PlainMemoryAdapter as t, buildSpeechHostMap as u, videoDocumentSchema as v, cascadeAfterVideoClipChanges as w, findLaneTrack as x, LANE_KINDS_IN_STACK_ORDER as y, speedOf as z };
3206
+ export { base64ToBytes as $, findLaneTrack as A, syncAggregatedClipsTimePosition as B, VideoDocumentValidationError as C, videoDocumentSchema as D, partUnionSchema as E, fillMainTrackTimeGaps as F, DEFAULT_UNIT_TIME_MS as G, isEmptyVideoClip as H, reassignSpeechesToVideoClipsByTime as I, effectiveVideoClipDurationMs as J, ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION as K, recalculateTimelineDuration as L, solveVideoDocument as M, cascadeAfterVideoClipChanges as N, LANE_KINDS_IN_STACK_ORDER as O, arrangeMainTrackSeamlessly as P, videoDocumentMirrorSchema as Q, resolveAllSpeechOverlaps as R, toVideoDocument as S, validateVideoDocument as T, partDurationMs as U, TIMELINE_SKELETON_DURATION_MS as V, safeDurationMs as W, partUnionToDraft as X, speedOf as Y, recordEntries as Z, EntityTimelineProjectionError as _, applyEntityTimelineProjection as a, derivePositionFromAbs as b, writeVideoDocumentToDraft as c, resolveEntityTimelineLayout as d, bytesToBase64 as et, decodeEntityRelationRows as f, hasSequence as g, createRelationId as h, MirrorVideoDocumentAdapter as i, laneTrackId as j, ensureLaneTrack as k, readVideoDocumentFromDraft as l, createEntityId as m, createPlainMemoryAdapter as n, createMirrorVideoDocument as o, isJsonObject as p, VIDEO_DOCUMENT_SCHEMA_VERSION as q, generatePartId as r, createMirrorVideoDocumentAdapter as s, PlainMemoryAdapter as t, projectEntityTimeline as u, buildInitialVideoDocument as v, assertValidVideoDocument as w, fromVideoDocument as x, buildSpeechHostMap as y, resolveSpeechOverlapByShiftingVideos as z };