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