@mengine/medeo-tool 1.2.1-alpha.0 → 1.2.1-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -6
- package/dist/entity-contract-B3txrzTt.d.mts +174 -0
- package/dist/index.d.mts +87 -17
- package/dist/index.mjs +740 -149
- package/dist/index.mjs.map +1 -1
- package/dist/sandbox-api.d.mts +176 -3
- package/dist/script-session-BF44uKv_.mjs +1501 -0
- package/dist/script-session-BF44uKv_.mjs.map +1 -0
- package/dist/worker-entry.d.mts +2 -0
- package/dist/worker-entry.mjs +23 -4
- package/dist/worker-entry.mjs.map +1 -1
- package/package.json +4 -3
- package/dist/script-session-DdPA4tTf.mjs +0 -447
- package/dist/script-session-DdPA4tTf.mjs.map +0 -1
|
@@ -0,0 +1,1501 @@
|
|
|
1
|
+
import { SchemaValidator, SemanticEditor, createEditSandbox, effectiveVideoClipDurationMs, fromVideoDocument, solveVideoDocument, speedOf } from "@mengine/medeo-client";
|
|
2
|
+
//#region src/document/compact-projection.ts
|
|
3
|
+
const DEFAULT_TEXT_PREVIEW_LENGTH = 24;
|
|
4
|
+
/** Kind tag shown in the first column (`video_clip` → `clip`). */
|
|
5
|
+
function kindTag(kind) {
|
|
6
|
+
return kind === "video_clip" ? "clip" : kind;
|
|
7
|
+
}
|
|
8
|
+
/** Lane label: `video_clip` tracks display as `main`, otherwise `parts_kind`. */
|
|
9
|
+
function laneLabel(partsKind) {
|
|
10
|
+
return partsKind === "video_clip" ? "main" : partsKind;
|
|
11
|
+
}
|
|
12
|
+
/** Speed token: absent → `1`; linear → numeric multiplier; anything else → `custom`. */
|
|
13
|
+
function speedToken(speedShift) {
|
|
14
|
+
if (speedShift == null) return "1";
|
|
15
|
+
if (speedShift.category === "linear") return String(speedOf(speedShift));
|
|
16
|
+
return "custom";
|
|
17
|
+
}
|
|
18
|
+
function effectiveDurationMs(part, timelineDurationMs) {
|
|
19
|
+
if (part.video_clip != null) return effectiveVideoClipDurationMs(part.video_clip);
|
|
20
|
+
if (part.speech != null) return part.speech.media_duration_ms ?? 0;
|
|
21
|
+
if (part.caption != null) return part.caption.initial_duration_ms ?? 0;
|
|
22
|
+
if (part.bgm != null) return timelineDurationMs;
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
function truncateText(text, budget) {
|
|
26
|
+
if (text.length <= budget) return text;
|
|
27
|
+
return `${text.slice(0, budget)}…`;
|
|
28
|
+
}
|
|
29
|
+
function anchorToken(timePosition) {
|
|
30
|
+
if (timePosition.mode === "anchored") return `anchor=${timePosition.anchorPartId}+${timePosition.offsetMs}`;
|
|
31
|
+
if (timePosition.mode === "absolute") return "anchor=abs";
|
|
32
|
+
return "anchor=abs";
|
|
33
|
+
}
|
|
34
|
+
function clipAttrs(clip) {
|
|
35
|
+
const playIn = clip.play_in ?? 0;
|
|
36
|
+
const playOut = clip.play_out ?? 0;
|
|
37
|
+
return `media=${clip.origin_media_id ?? ""} trim=${playIn}-${playOut} speed=${speedToken(clip.speed_shift)} vol=${clip.volume ?? 0}`;
|
|
38
|
+
}
|
|
39
|
+
function partAttrs(part, item, textPreviewLength) {
|
|
40
|
+
if (part.video_clip != null) return clipAttrs(part.video_clip);
|
|
41
|
+
if (part.speech != null) return `${anchorToken(item.time_position)} dur=${part.speech.media_duration_ms ?? 0}`;
|
|
42
|
+
if (part.caption != null) {
|
|
43
|
+
const preview = truncateText(part.caption.text ?? "", textPreviewLength);
|
|
44
|
+
return `${anchorToken(item.time_position)} text="${preview}"`;
|
|
45
|
+
}
|
|
46
|
+
if (part.bgm != null) return `vol=${part.bgm.volume ?? 0}`;
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Render a `VideoDocument` as compact text: one header line plus one row per
|
|
51
|
+
* timeline part (optionally filtered by `onlyPartIds`). Deterministic and
|
|
52
|
+
* side-effect free — same document always yields the same string.
|
|
53
|
+
*/
|
|
54
|
+
function renderCompactProjection(document, options) {
|
|
55
|
+
const onlyPartIds = options?.onlyPartIds;
|
|
56
|
+
const textPreviewLength = options?.textPreviewLength ?? DEFAULT_TEXT_PREVIEW_LENGTH;
|
|
57
|
+
const solved = solveVideoDocument(document);
|
|
58
|
+
const library = document.part_library ?? {};
|
|
59
|
+
const tracks = document.tracks ?? [];
|
|
60
|
+
let totalParts = 0;
|
|
61
|
+
const rows = [];
|
|
62
|
+
for (const track of tracks) {
|
|
63
|
+
const partsKind = track.parts_kind;
|
|
64
|
+
if (partsKind == null) continue;
|
|
65
|
+
const lane = laneLabel(partsKind);
|
|
66
|
+
const tag = kindTag(partsKind);
|
|
67
|
+
for (const item of track.items ?? []) {
|
|
68
|
+
totalParts += 1;
|
|
69
|
+
const partId = item.part_id;
|
|
70
|
+
if (onlyPartIds != null && !onlyPartIds.has(partId)) continue;
|
|
71
|
+
const part = library[partId];
|
|
72
|
+
if (part == null) continue;
|
|
73
|
+
const abs = solved.absByPartId.get(partId) ?? 0;
|
|
74
|
+
const dur = effectiveDurationMs(part, solved.durationMs);
|
|
75
|
+
const attrs = partAttrs(part, item, textPreviewLength);
|
|
76
|
+
rows.push(`${tag} ${partId} ${lane} [${abs},${abs + dur}) ${attrs}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [`# draft=${document.meta.draft_id ?? ""} v=${document.meta.version ?? 0} duration=${solved.durationMs} parts=${totalParts} shown=${rows.length}`, ...rows].join("\n");
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/sandbox/preview.ts
|
|
83
|
+
/**
|
|
84
|
+
* Collect part ids referenced by a journal for compact preview filtering.
|
|
85
|
+
* Walks known id-shaped payload keys (宁多勿少) and unions `generated_ids`.
|
|
86
|
+
*/
|
|
87
|
+
const PART_ID_KEYS = new Set([
|
|
88
|
+
"clip_id",
|
|
89
|
+
"clip_ids",
|
|
90
|
+
"before_clip_id",
|
|
91
|
+
"after_clip_id",
|
|
92
|
+
"speech_id",
|
|
93
|
+
"speech_ids",
|
|
94
|
+
"speech_part_id",
|
|
95
|
+
"caption_id",
|
|
96
|
+
"caption_ids",
|
|
97
|
+
"bgm_id",
|
|
98
|
+
"anchor_part_id",
|
|
99
|
+
"part_id",
|
|
100
|
+
"body_part_id"
|
|
101
|
+
]);
|
|
102
|
+
/** Extract every part id a journal entry touches (payload refs + minted ids). */
|
|
103
|
+
function collectAffectedPartIds(journal) {
|
|
104
|
+
const ids = /* @__PURE__ */ new Set();
|
|
105
|
+
for (const entry of journal) {
|
|
106
|
+
for (const generated of entry.generated_ids ?? []) if (generated.length > 0) ids.add(generated);
|
|
107
|
+
collectFromValue(entry.payload, ids);
|
|
108
|
+
}
|
|
109
|
+
return ids;
|
|
110
|
+
}
|
|
111
|
+
function collectFromValue(value, ids) {
|
|
112
|
+
if (value == null) return;
|
|
113
|
+
if (Array.isArray(value)) {
|
|
114
|
+
for (const item of value) collectFromValue(item, ids);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (typeof value !== "object") return;
|
|
118
|
+
for (const [key, child] of Object.entries(value)) {
|
|
119
|
+
if (PART_ID_KEYS.has(key)) {
|
|
120
|
+
if (typeof child === "string" && child.length > 0) ids.add(child);
|
|
121
|
+
else if (Array.isArray(child)) {
|
|
122
|
+
for (const item of child) if (typeof item === "string" && item.length > 0) ids.add(item);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
collectFromValue(child, ids);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Render a ChangePlan preview: header + rows for journal-affected parts only.
|
|
130
|
+
* Empty journal → empty `onlyPartIds` (header alone), matching the T2 contract.
|
|
131
|
+
*/
|
|
132
|
+
function renderPreview(document, journal) {
|
|
133
|
+
return renderCompactProjection(document, { onlyPartIds: journal.length === 0 ? /* @__PURE__ */ new Set() : collectAffectedPartIds(journal) });
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region ../medeo-dsl/src/entities.ts
|
|
137
|
+
function hasSequence(entity) {
|
|
138
|
+
if (isKnownNonSequenceKind(entity.entityKind) || isReservedEntityKind(entity.entityKind)) return false;
|
|
139
|
+
return isSequenceFields(entity);
|
|
140
|
+
}
|
|
141
|
+
function isSequenceFields(value) {
|
|
142
|
+
if (!isRecord$1(value)) return false;
|
|
143
|
+
const extent = value.extent;
|
|
144
|
+
if (!isRecord$1(extent) || !("start" in extent) || extent.start === void 0) return false;
|
|
145
|
+
if (extent.kind === "bounded" && (!("end" in extent) || extent.end === void 0)) return false;
|
|
146
|
+
if (extent.kind === "unbounded" && "end" in extent) return false;
|
|
147
|
+
if (extent.kind !== "bounded" && extent.kind !== "unbounded") return false;
|
|
148
|
+
if (value.sampling !== "native" && value.sampling !== "constant" && value.sampling !== "derived") return false;
|
|
149
|
+
return "coordinateSpace" in value && value.coordinateSpace !== void 0;
|
|
150
|
+
}
|
|
151
|
+
function isKnownSequenceKind(kind) {
|
|
152
|
+
return kind === "video" || kind === "audio" || kind === "voice" || kind === "image" || kind === "caption" || kind === "axvideo";
|
|
153
|
+
}
|
|
154
|
+
function isKnownEntityKind(kind) {
|
|
155
|
+
return isKnownSequenceKind(kind) || isKnownNonSequenceKind(kind);
|
|
156
|
+
}
|
|
157
|
+
function isReservedEntityKind(kind) {
|
|
158
|
+
const normalized = kind.toLowerCase().replaceAll("-", "").replaceAll("_", "");
|
|
159
|
+
return normalized === "speech" || normalized === "videodocument";
|
|
160
|
+
}
|
|
161
|
+
function isKnownNonSequenceKind(kind) {
|
|
162
|
+
return kind === "timeline" || kind === "track" || kind === "clip" || kind === "asset" || kind === "sequence-marker" || kind === "viewport" || kind === "audio-script" || kind === "phonetic-script";
|
|
163
|
+
}
|
|
164
|
+
function isRecord$1(value) {
|
|
165
|
+
return typeof value === "object" && value != null && !Array.isArray(value);
|
|
166
|
+
}
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region ../medeo-dsl/src/ids.ts
|
|
169
|
+
function createEntityId(value) {
|
|
170
|
+
return createId(value, "EntityId");
|
|
171
|
+
}
|
|
172
|
+
function createRelationId(value) {
|
|
173
|
+
return createId(value, "RelationId");
|
|
174
|
+
}
|
|
175
|
+
function createId(value, label) {
|
|
176
|
+
if (value.length === 0 || value.trim() !== value) throw new Error(`${label} must be a non-empty trimmed string`);
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region ../medeo-dsl/src/invariants.ts
|
|
181
|
+
/** Validates one complete set of entities and its authoritative Relation rows. */
|
|
182
|
+
function validateEntityRelationSet(entityRefs, index, options) {
|
|
183
|
+
const { entities, issues } = collectRelatedEntities(entityRefs, index);
|
|
184
|
+
const entityIds = new Set(entities.map((entity) => entity.entityId));
|
|
185
|
+
for (const entity of entities) {
|
|
186
|
+
const current = entity.current();
|
|
187
|
+
const entityIssues = validateEntity(entity, entityIds);
|
|
188
|
+
issues.push(...entityIssues);
|
|
189
|
+
if (entityIssues.some((issue) => issue.code === "invalid_entity_payload")) continue;
|
|
190
|
+
if (current.entityKind === "sequence-marker") {
|
|
191
|
+
const marker = entity;
|
|
192
|
+
issues.push(...validateMarkerUse(entity, index));
|
|
193
|
+
issues.push(...validateMarkerRanges(marker, {
|
|
194
|
+
source: (left, right) => options.compareMarkerPoints(marker, "source", left, right),
|
|
195
|
+
target: (left, right) => options.compareMarkerPoints(marker, "target", left, right)
|
|
196
|
+
}));
|
|
197
|
+
issues.push(...validateMarkerSourceBounds(marker, index, (left, right) => options.compareMarkerPoints(marker, "source", left, right)));
|
|
198
|
+
}
|
|
199
|
+
if (current.entityKind === "clip") issues.push(...validateClipAdmission(entity, index));
|
|
200
|
+
if (current.entityKind === "axvideo") issues.push(...validateAXVideoAdmission(entity, index));
|
|
201
|
+
if (current.entityKind === "caption") issues.push(...validateCaptionAsset(entity, index));
|
|
202
|
+
issues.push(...validateSequenceComposition(entity));
|
|
203
|
+
}
|
|
204
|
+
return issues;
|
|
205
|
+
}
|
|
206
|
+
function validateMarkerUse(marker, index) {
|
|
207
|
+
const relations = [...index.relationsOf(marker)];
|
|
208
|
+
const clipEdges = ofKind(relations, "clip-marker");
|
|
209
|
+
const axVideoEdges = ofKind(relations, "axvideo-marker");
|
|
210
|
+
const contentEdges = ofKind(relations, "marker-content");
|
|
211
|
+
const timelineEdges = ofKind(relations, "marker-timeline");
|
|
212
|
+
const issues = [];
|
|
213
|
+
if (clipEdges.length + axVideoEdges.length !== 1) issues.push({
|
|
214
|
+
code: "marker_container_xor",
|
|
215
|
+
entityId: marker.entityId,
|
|
216
|
+
message: "Sequence Marker must have exactly one Clip XOR AXVideo container relation"
|
|
217
|
+
});
|
|
218
|
+
if (contentEdges.length + timelineEdges.length !== 1) issues.push({
|
|
219
|
+
code: "marker_content_xor",
|
|
220
|
+
entityId: marker.entityId,
|
|
221
|
+
message: "Sequence Marker must have exactly one Sequence content XOR Timeline relation"
|
|
222
|
+
});
|
|
223
|
+
if (clipEdges.length === 1 && timelineEdges.length === 1 || axVideoEdges.length === 1 && contentEdges.length === 1) issues.push({
|
|
224
|
+
code: "marker_pair_mismatch",
|
|
225
|
+
entityId: marker.entityId,
|
|
226
|
+
message: "Only Clip+Content or AXVideo+Timeline Marker relation pairs are valid"
|
|
227
|
+
});
|
|
228
|
+
return issues;
|
|
229
|
+
}
|
|
230
|
+
function validateClipAdmission(clip, index) {
|
|
231
|
+
const markerEdges = ofKind([...index.relationsOf(clip)], "clip-marker");
|
|
232
|
+
if (markerEdges.length !== 1) return [{
|
|
233
|
+
code: "clip_marker_cardinality",
|
|
234
|
+
entityId: clip.entityId,
|
|
235
|
+
message: "Clip must have exactly one authoritative Clip-Marker Relation"
|
|
236
|
+
}];
|
|
237
|
+
const marker = markerEdges[0]?.other(clip)?.deref();
|
|
238
|
+
if (marker == null) return [{
|
|
239
|
+
code: "clip_content_cardinality",
|
|
240
|
+
entityId: clip.entityId,
|
|
241
|
+
message: "Clip must resolve one live Sequence Marker and one content Relation"
|
|
242
|
+
}];
|
|
243
|
+
const contentEdges = ofKind([...index.relationsOf(marker)], "marker-content");
|
|
244
|
+
if (contentEdges.length !== 1) return [{
|
|
245
|
+
code: "clip_content_cardinality",
|
|
246
|
+
entityId: clip.entityId,
|
|
247
|
+
message: "Clip Marker must resolve exactly one content Relation"
|
|
248
|
+
}];
|
|
249
|
+
const content = contentEdges[0]?.other(marker)?.deref()?.current();
|
|
250
|
+
if (content == null) return [{
|
|
251
|
+
code: "clip_content_cardinality",
|
|
252
|
+
entityId: clip.entityId,
|
|
253
|
+
message: "Clip Marker content Relation must resolve one live entity"
|
|
254
|
+
}];
|
|
255
|
+
if (!hasSequence(content)) return [{
|
|
256
|
+
code: "clip_content_not_sequence",
|
|
257
|
+
entityId: clip.entityId,
|
|
258
|
+
message: `Clip Marker resolves to non-Sequence entity "${content.entityId}"`
|
|
259
|
+
}];
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
function validateAXVideoAdmission(axVideo, index) {
|
|
263
|
+
const markerEdges = ofKind([...index.relationsOf(axVideo)], "axvideo-marker");
|
|
264
|
+
if (markerEdges.length === 1 && markerEdges[0]?.other(axVideo)?.deref() != null) return [];
|
|
265
|
+
return [{
|
|
266
|
+
code: "axvideo_marker_cardinality",
|
|
267
|
+
entityId: axVideo.entityId,
|
|
268
|
+
message: "AXVideo must have exactly one live AXVideo-Marker Relation"
|
|
269
|
+
}];
|
|
270
|
+
}
|
|
271
|
+
function validateCaptionAsset(caption, index) {
|
|
272
|
+
if (ofKind([...index.relationsOf(caption)], "physical-asset").some((relation) => relation.other(caption)?.deref()?.current().entityKind === "asset")) return [];
|
|
273
|
+
return [{
|
|
274
|
+
code: "caption_asset_required",
|
|
275
|
+
entityId: caption.entityId,
|
|
276
|
+
message: "Caption must have a Physical Asset Relation"
|
|
277
|
+
}];
|
|
278
|
+
}
|
|
279
|
+
function validateMarkerRanges(marker, compare) {
|
|
280
|
+
const current = marker.current();
|
|
281
|
+
const issues = [];
|
|
282
|
+
if (!(compare.source(current.sourceRange.start, current.sourceRange.end) < 0)) issues.push({
|
|
283
|
+
code: "marker_source_range_empty",
|
|
284
|
+
entityId: marker.entityId,
|
|
285
|
+
message: "Sequence Marker sourceRange must be a non-empty half-open interval"
|
|
286
|
+
});
|
|
287
|
+
if (current.targetRange != null && !(compare.target(current.targetRange.start, current.targetRange.end) < 0)) issues.push({
|
|
288
|
+
code: "marker_target_range_empty",
|
|
289
|
+
entityId: marker.entityId,
|
|
290
|
+
message: "Sequence Marker targetRange must be a non-empty half-open interval"
|
|
291
|
+
});
|
|
292
|
+
return issues;
|
|
293
|
+
}
|
|
294
|
+
function validateMarkerSourceBounds(marker, index, compare) {
|
|
295
|
+
const contentEdges = ofKind([...index.relationsOf(marker)], "marker-content");
|
|
296
|
+
if (contentEdges.length !== 1) return [];
|
|
297
|
+
const content = contentEdges[0]?.other(marker)?.deref()?.current();
|
|
298
|
+
if (content == null || !hasSequence(content)) return [];
|
|
299
|
+
const sourceRange = marker.current().sourceRange;
|
|
300
|
+
const startVsExtent = compare(sourceRange.start, content.extent.start);
|
|
301
|
+
const endVsExtent = content.extent.kind === "bounded" ? compare(sourceRange.end, content.extent.end) : void 0;
|
|
302
|
+
if (!Number.isNaN(startVsExtent) && startVsExtent >= 0 && (endVsExtent == null || !Number.isNaN(endVsExtent) && endVsExtent <= 0)) return [];
|
|
303
|
+
return [{
|
|
304
|
+
code: "marker_source_out_of_bounds",
|
|
305
|
+
entityId: marker.entityId,
|
|
306
|
+
message: `Sequence Marker sourceRange must stay within content "${content.entityId}" extent`
|
|
307
|
+
}];
|
|
308
|
+
}
|
|
309
|
+
function validateSequenceComposition(entity) {
|
|
310
|
+
const current = entity.current();
|
|
311
|
+
const expected = expectedSequenceShape(current.entityKind);
|
|
312
|
+
if (expected == null) return [];
|
|
313
|
+
if (hasSequence(current) && current.extent.kind === expected.extent && current.sampling === expected.sampling) return [];
|
|
314
|
+
return [{
|
|
315
|
+
code: "invalid_sequence_composition",
|
|
316
|
+
entityId: current.entityId,
|
|
317
|
+
message: `${current.entityKind} must compose ${expected.extent} / ${expected.sampling} Sequence semantics`
|
|
318
|
+
}];
|
|
319
|
+
}
|
|
320
|
+
function validateEntity(entity, entityIds = /* @__PURE__ */ new Set()) {
|
|
321
|
+
const current = entity.current();
|
|
322
|
+
const issues = [];
|
|
323
|
+
if (isReservedEntityKind(current.entityKind)) issues.push({
|
|
324
|
+
code: "forbidden_entity_kind",
|
|
325
|
+
entityId: current.entityId,
|
|
326
|
+
message: `Entity kind "${current.entityKind}" is explicitly outside the Medeo DSL`
|
|
327
|
+
});
|
|
328
|
+
for (const problem of validateKnownEntityPayload(current)) issues.push({
|
|
329
|
+
code: "invalid_entity_payload",
|
|
330
|
+
entityId: current.entityId,
|
|
331
|
+
message: `Entity "${current.entityKind}" ${problem}`
|
|
332
|
+
});
|
|
333
|
+
const peerIdPaths = [...collectPeerEntityIdPaths(current, current.entityKind), ...collectPeerEntityValuePaths(current, current.entityId, entityIds)];
|
|
334
|
+
const uniquePeerIdPaths = [...new Set(peerIdPaths)].sort();
|
|
335
|
+
if (uniquePeerIdPaths.length > 0) issues.push({
|
|
336
|
+
code: "peer_entity_id_field",
|
|
337
|
+
entityId: current.entityId,
|
|
338
|
+
message: `Entity embeds forbidden peer-ID field/value path(s): ${uniquePeerIdPaths.join(", ")}`
|
|
339
|
+
});
|
|
340
|
+
return issues;
|
|
341
|
+
}
|
|
342
|
+
function validateKnownEntityPayload(entity) {
|
|
343
|
+
const value = entity;
|
|
344
|
+
const problems = [];
|
|
345
|
+
if (value.lifecycle !== void 0 && !isRecord(value.lifecycle)) problems.push("lifecycle must be an object when present");
|
|
346
|
+
switch (entity.entityKind) {
|
|
347
|
+
case "track":
|
|
348
|
+
validateOptionalField(value, "hidden", "boolean", problems);
|
|
349
|
+
validateOptionalField(value, "role", "string", problems);
|
|
350
|
+
break;
|
|
351
|
+
case "video":
|
|
352
|
+
case "audio":
|
|
353
|
+
case "voice":
|
|
354
|
+
case "caption":
|
|
355
|
+
validateSequencePayload(value, "bounded", "native", problems);
|
|
356
|
+
break;
|
|
357
|
+
case "image":
|
|
358
|
+
validateSequencePayload(value, "unbounded", "constant", problems);
|
|
359
|
+
break;
|
|
360
|
+
case "axvideo":
|
|
361
|
+
validateSequencePayload(value, "bounded", "derived", problems);
|
|
362
|
+
break;
|
|
363
|
+
case "sequence-marker":
|
|
364
|
+
validateMarkerPayload(value, problems);
|
|
365
|
+
break;
|
|
366
|
+
case "audio-script":
|
|
367
|
+
case "phonetic-script":
|
|
368
|
+
validateScriptPayload(value, problems);
|
|
369
|
+
break;
|
|
370
|
+
case "timeline":
|
|
371
|
+
case "clip":
|
|
372
|
+
case "asset":
|
|
373
|
+
case "viewport": break;
|
|
374
|
+
default: break;
|
|
375
|
+
}
|
|
376
|
+
return problems;
|
|
377
|
+
}
|
|
378
|
+
function validateSequencePayload(value, expectedExtent, expectedSampling, problems) {
|
|
379
|
+
const extent = value.extent;
|
|
380
|
+
if (!isRecord(extent)) problems.push("extent must be an object");
|
|
381
|
+
else {
|
|
382
|
+
if (extent.kind !== expectedExtent) problems.push(`extent.kind must be "${expectedExtent}"`);
|
|
383
|
+
if (!Object.hasOwn(extent, "start") || extent.start === void 0) problems.push("extent.start is required");
|
|
384
|
+
if (expectedExtent === "bounded" && (!Object.hasOwn(extent, "end") || extent.end === void 0)) problems.push("extent.end is required for bounded sequences");
|
|
385
|
+
if (expectedExtent === "unbounded" && Object.hasOwn(extent, "end")) problems.push("extent.end is forbidden for unbounded sequences");
|
|
386
|
+
}
|
|
387
|
+
if (value.sampling !== expectedSampling) problems.push(`sampling must be "${expectedSampling}"`);
|
|
388
|
+
if (!Object.hasOwn(value, "coordinateSpace") || value.coordinateSpace === void 0) problems.push("coordinateSpace is required");
|
|
389
|
+
}
|
|
390
|
+
function validateMarkerPayload(value, problems) {
|
|
391
|
+
validateRange(value.sourceRange, "sourceRange", true, problems);
|
|
392
|
+
if (Object.hasOwn(value, "targetRange") && value.targetRange !== void 0) validateRange(value.targetRange, "targetRange", true, problems);
|
|
393
|
+
const duration = value.duration;
|
|
394
|
+
if (!isRecord(duration)) problems.push("duration must be an object");
|
|
395
|
+
else if (duration.mode === "fixed") {
|
|
396
|
+
if (!Object.hasOwn(duration, "value") || duration.value === void 0) problems.push("duration.value is required when duration.mode is \"fixed\"");
|
|
397
|
+
} else if (duration.mode !== "from-source") problems.push("duration.mode must be \"from-source\" or \"fixed\"");
|
|
398
|
+
}
|
|
399
|
+
function validateRange(value, path, required, problems) {
|
|
400
|
+
if (!isRecord(value)) {
|
|
401
|
+
if (required) problems.push(`${path} must be an object`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (!Object.hasOwn(value, "start") || value.start === void 0) problems.push(`${path}.start is required`);
|
|
405
|
+
if (!Object.hasOwn(value, "end") || value.end === void 0) problems.push(`${path}.end is required`);
|
|
406
|
+
}
|
|
407
|
+
function validateScriptPayload(value, problems) {
|
|
408
|
+
if (!Array.isArray(value.segments)) {
|
|
409
|
+
problems.push("segments must be an array");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
for (const [index, segment] of value.segments.entries()) {
|
|
413
|
+
if (!isRecord(segment)) {
|
|
414
|
+
problems.push(`segments[${index}] must be an object`);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (typeof segment.segmentId !== "string") problems.push(`segments[${index}].segmentId must be a string`);
|
|
418
|
+
if (typeof segment.text !== "string") problems.push(`segments[${index}].text must be a string`);
|
|
419
|
+
if (segment.language !== void 0 && typeof segment.language !== "string") problems.push(`segments[${index}].language must be a string when present`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function validateOptionalField(value, key, expectedType, problems) {
|
|
423
|
+
if (value[key] !== void 0 && typeof value[key] !== expectedType) problems.push(`${key} must be a ${expectedType} when present`);
|
|
424
|
+
}
|
|
425
|
+
function collectPeerEntityIdPaths(value, entityKind) {
|
|
426
|
+
const paths = [];
|
|
427
|
+
visitPeerEntityIdPaths(value, entityKind, "", /* @__PURE__ */ new Set(), paths);
|
|
428
|
+
return paths;
|
|
429
|
+
}
|
|
430
|
+
function visitPeerEntityIdPaths(value, entityKind, parentPath, ancestors, paths) {
|
|
431
|
+
if (typeof value !== "object" || value == null) return;
|
|
432
|
+
if (ancestors.has(value)) return;
|
|
433
|
+
ancestors.add(value);
|
|
434
|
+
if (Array.isArray(value)) for (const [index, item] of value.entries()) visitPeerEntityIdPaths(item, entityKind, `${parentPath}[${index}]`, ancestors, paths);
|
|
435
|
+
else for (const [key, child] of Object.entries(value)) {
|
|
436
|
+
const path = parentPath.length === 0 ? key : `${parentPath}.${key}`;
|
|
437
|
+
if (!(parentPath.length === 0 && key === "entityId") && !isOwnedLocalIdPath(entityKind, path) && isEntityIdFieldName(key)) paths.push(path);
|
|
438
|
+
visitPeerEntityIdPaths(child, entityKind, path, ancestors, paths);
|
|
439
|
+
}
|
|
440
|
+
ancestors.delete(value);
|
|
441
|
+
}
|
|
442
|
+
function collectPeerEntityValuePaths(value, ownEntityId, entityIds) {
|
|
443
|
+
const paths = [];
|
|
444
|
+
visitPeerEntityValues(value, ownEntityId, entityIds, "", /* @__PURE__ */ new Set(), paths);
|
|
445
|
+
return paths;
|
|
446
|
+
}
|
|
447
|
+
function visitPeerEntityValues(value, ownEntityId, entityIds, path, ancestors, paths) {
|
|
448
|
+
if (typeof value === "string") {
|
|
449
|
+
if (value !== ownEntityId && entityIds.has(value)) paths.push(path);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (typeof value !== "object" || value == null || ancestors.has(value)) return;
|
|
453
|
+
ancestors.add(value);
|
|
454
|
+
if (Array.isArray(value)) for (const [index, item] of value.entries()) visitPeerEntityValues(item, ownEntityId, entityIds, `${path}[${index}]`, ancestors, paths);
|
|
455
|
+
else for (const [key, child] of Object.entries(value)) {
|
|
456
|
+
if (path.length === 0 && (key === "entityId" || key === "entityKind")) continue;
|
|
457
|
+
visitPeerEntityValues(child, ownEntityId, entityIds, path.length === 0 ? key : `${path}.${key}`, ancestors, paths);
|
|
458
|
+
}
|
|
459
|
+
ancestors.delete(value);
|
|
460
|
+
}
|
|
461
|
+
function isOwnedLocalIdPath(entityKind, path) {
|
|
462
|
+
if (path === "lifecycle.actorId") return true;
|
|
463
|
+
if ((entityKind === "audio-script" || entityKind === "phonetic-script") && /^segments\[\d+\]\.segmentId$/.test(path)) return true;
|
|
464
|
+
if (entityKind === "asset" && /^(?:tracks\[\d+\]\.trackId|renditions\[\d+\]\.renditionId)$/.test(path)) return true;
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
function isEntityIdFieldName(key) {
|
|
468
|
+
return key.endsWith("Id") || key.endsWith("Ids") || key.endsWith("ID") || key.endsWith("IDs") || /_ids?$/i.test(key);
|
|
469
|
+
}
|
|
470
|
+
function isRecord(value) {
|
|
471
|
+
return typeof value === "object" && value != null && !Array.isArray(value);
|
|
472
|
+
}
|
|
473
|
+
function collectRelatedEntities(entityRefs, index) {
|
|
474
|
+
const byId = /* @__PURE__ */ new Map();
|
|
475
|
+
const queue = [...entityRefs];
|
|
476
|
+
const issues = [];
|
|
477
|
+
while (queue.length > 0) {
|
|
478
|
+
const entity = queue.shift();
|
|
479
|
+
if (entity == null) continue;
|
|
480
|
+
const existing = byId.get(entity.entityId);
|
|
481
|
+
if (existing != null) {
|
|
482
|
+
if (existing !== entity) issues.push({
|
|
483
|
+
code: "duplicate_entity_id",
|
|
484
|
+
entityId: entity.entityId,
|
|
485
|
+
message: `Entity id "${entity.entityId}" has more than one live EntityRef`
|
|
486
|
+
});
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
byId.set(entity.entityId, entity);
|
|
490
|
+
for (const relation of index.relationsOf(entity)) for (const endpoint of relation.endpoints) {
|
|
491
|
+
const ref = endpoint.deref();
|
|
492
|
+
if (ref != null && !byId.has(ref.entityId)) queue.push(ref);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
entities: [...byId.values()],
|
|
497
|
+
issues
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
function expectedSequenceShape(kind) {
|
|
501
|
+
if (kind === "video" || kind === "audio" || kind === "voice" || kind === "caption") return {
|
|
502
|
+
extent: "bounded",
|
|
503
|
+
sampling: "native"
|
|
504
|
+
};
|
|
505
|
+
if (kind === "image") return {
|
|
506
|
+
extent: "unbounded",
|
|
507
|
+
sampling: "constant"
|
|
508
|
+
};
|
|
509
|
+
if (kind === "axvideo") return {
|
|
510
|
+
extent: "bounded",
|
|
511
|
+
sampling: "derived"
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
function ofKind(relations, kind) {
|
|
515
|
+
return relations.filter((relation) => relation.kind === kind);
|
|
516
|
+
}
|
|
517
|
+
//#endregion
|
|
518
|
+
//#region ../medeo-dsl/src/json-values.ts
|
|
519
|
+
function isJsonObject(value) {
|
|
520
|
+
return isJsonValue(value, /* @__PURE__ */ new Set()) && !Array.isArray(value) && value !== null;
|
|
521
|
+
}
|
|
522
|
+
function isJsonValue(value, ancestors) {
|
|
523
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
524
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
525
|
+
if (typeof value !== "object") return false;
|
|
526
|
+
const prototype = Object.getPrototypeOf(value);
|
|
527
|
+
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return false;
|
|
528
|
+
if (ancestors.has(value)) return false;
|
|
529
|
+
ancestors.add(value);
|
|
530
|
+
const valid = Array.isArray(value) ? value.every((item) => isJsonValue(item, ancestors)) : Object.values(value).every((item) => isJsonValue(item, ancestors));
|
|
531
|
+
ancestors.delete(value);
|
|
532
|
+
return valid;
|
|
533
|
+
}
|
|
534
|
+
//#endregion
|
|
535
|
+
//#region ../medeo-dsl/src/relation-specs.ts
|
|
536
|
+
const timelineTrackRelationSpec = emptySpec("timeline-track", "timeline", "track");
|
|
537
|
+
const trackClipRelationSpec = emptySpec("track-clip", "track", "clip");
|
|
538
|
+
const clipMarkerRelationSpec = emptySpec("clip-marker", "clip", "sequence-marker");
|
|
539
|
+
const axVideoMarkerRelationSpec = emptySpec("axvideo-marker", "axvideo", "sequence-marker");
|
|
540
|
+
const markerTimelineRelationSpec = emptySpec("marker-timeline", "sequence-marker", "timeline");
|
|
541
|
+
const markerContentRelationSpec = Object.freeze({
|
|
542
|
+
kind: "marker-content",
|
|
543
|
+
validateEndpoints: (endpoints) => hasMarkerAndSequence(endpoints),
|
|
544
|
+
validateMetadata: isEmptyMetadata
|
|
545
|
+
});
|
|
546
|
+
const physicalAssetRelationSpec = Object.freeze({
|
|
547
|
+
kind: "physical-asset",
|
|
548
|
+
validateEndpoints: (endpoints) => hasAssetAndSequence(endpoints),
|
|
549
|
+
validateMetadata: isAssetBinding
|
|
550
|
+
});
|
|
551
|
+
/** `generated(output, input)` means endpoint 0 was generated from endpoint 1. */
|
|
552
|
+
const generatedRelationSpec = Object.freeze({
|
|
553
|
+
kind: "generated",
|
|
554
|
+
validateEndpoints: (endpoints) => endpoints.every((endpoint) => isGeneratedMedia(endpoint.current())),
|
|
555
|
+
validateMetadata: isEmptyMetadata
|
|
556
|
+
});
|
|
557
|
+
const phoneticScriptProvenanceRelationSpec = metadataSpec("phonetic-script-provenance", "phonetic-script", "audio-script", isSegmentAlignmentMetadata);
|
|
558
|
+
const captionProvenanceRelationSpec = metadataSpec("caption-provenance", "caption", "audio-script", isSegmentAlignmentMetadata);
|
|
559
|
+
const captionAlignmentRelationSpec = Object.freeze({
|
|
560
|
+
kind: "caption-alignment",
|
|
561
|
+
validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["caption"]), new Set(["audio", "voice"])),
|
|
562
|
+
validateMetadata: isCaptionAlignmentMetadata
|
|
563
|
+
});
|
|
564
|
+
/** Built-in kinds are reserved; callers may add specs only under new names. */
|
|
565
|
+
const builtInRelationSpecs = Object.freeze([
|
|
566
|
+
timelineTrackRelationSpec,
|
|
567
|
+
trackClipRelationSpec,
|
|
568
|
+
clipMarkerRelationSpec,
|
|
569
|
+
markerContentRelationSpec,
|
|
570
|
+
axVideoMarkerRelationSpec,
|
|
571
|
+
markerTimelineRelationSpec,
|
|
572
|
+
physicalAssetRelationSpec,
|
|
573
|
+
generatedRelationSpec,
|
|
574
|
+
phoneticScriptProvenanceRelationSpec,
|
|
575
|
+
captionProvenanceRelationSpec,
|
|
576
|
+
captionAlignmentRelationSpec
|
|
577
|
+
]);
|
|
578
|
+
function emptySpec(kind, a, b) {
|
|
579
|
+
return metadataSpec(kind, a, b, isEmptyMetadata);
|
|
580
|
+
}
|
|
581
|
+
function metadataSpec(kind, a, b, validateMetadata) {
|
|
582
|
+
return Object.freeze({
|
|
583
|
+
kind,
|
|
584
|
+
validateEndpoints: (endpoints) => hasKinds(endpoints, new Set([a]), new Set([b])),
|
|
585
|
+
validateMetadata
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
function hasKinds(endpoints, aKinds, bKinds) {
|
|
589
|
+
const first = endpoints[0].current().entityKind;
|
|
590
|
+
const second = endpoints[1].current().entityKind;
|
|
591
|
+
return aKinds.has(first) && bKinds.has(second) || aKinds.has(second) && bKinds.has(first);
|
|
592
|
+
}
|
|
593
|
+
function hasMarkerAndSequence(endpoints) {
|
|
594
|
+
const first = endpoints[0].current();
|
|
595
|
+
const second = endpoints[1].current();
|
|
596
|
+
return first.entityKind === "sequence-marker" && hasSequence(second) || second.entityKind === "sequence-marker" && hasSequence(first);
|
|
597
|
+
}
|
|
598
|
+
function hasAssetAndSequence(endpoints) {
|
|
599
|
+
const first = endpoints[0].current();
|
|
600
|
+
const second = endpoints[1].current();
|
|
601
|
+
return first.entityKind === "asset" && hasSequence(second) || second.entityKind === "asset" && hasSequence(first);
|
|
602
|
+
}
|
|
603
|
+
function isGeneratedMedia(entity) {
|
|
604
|
+
return entity.entityKind === "video" || entity.entityKind === "image" || entity.entityKind === "audio" || entity.entityKind === "voice";
|
|
605
|
+
}
|
|
606
|
+
function isEmptyMetadata(value) {
|
|
607
|
+
return isJsonObject(value) && Object.keys(value).length === 0;
|
|
608
|
+
}
|
|
609
|
+
function isAssetBinding(value) {
|
|
610
|
+
if (!isJsonObject(value)) return false;
|
|
611
|
+
const orderingKey = /(order|ordinal|position|rank|index|z[_-]?index)/i;
|
|
612
|
+
return Object.keys(value).every((key) => !orderingKey.test(key));
|
|
613
|
+
}
|
|
614
|
+
function isSegmentAlignmentMetadata(value) {
|
|
615
|
+
return isJsonObject(value) && Object.hasOwn(value, "segmentAlignment");
|
|
616
|
+
}
|
|
617
|
+
function isCaptionAlignmentMetadata(value) {
|
|
618
|
+
return isJsonObject(value) && Object.hasOwn(value, "alignment");
|
|
619
|
+
}
|
|
620
|
+
//#endregion
|
|
621
|
+
//#region ../medeo-dsl/src/relations.ts
|
|
622
|
+
function createEntityRef(entity) {
|
|
623
|
+
return {
|
|
624
|
+
entityId: entity.entityId,
|
|
625
|
+
current: () => entity
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
//#endregion
|
|
629
|
+
//#region ../medeo-dsl/src/relation-index.ts
|
|
630
|
+
var RelationEdge = class {
|
|
631
|
+
relationId;
|
|
632
|
+
kind;
|
|
633
|
+
metadata;
|
|
634
|
+
trace;
|
|
635
|
+
endpoints;
|
|
636
|
+
constructor(relationId, kind, endpoints, metadata, trace) {
|
|
637
|
+
this.relationId = relationId;
|
|
638
|
+
this.kind = kind;
|
|
639
|
+
this.metadata = metadata;
|
|
640
|
+
this.trace = trace;
|
|
641
|
+
this.endpoints = [new WeakRef(endpoints[0]), new WeakRef(endpoints[1])];
|
|
642
|
+
}
|
|
643
|
+
other(entity) {
|
|
644
|
+
const first = this.endpoints[0].deref();
|
|
645
|
+
const second = this.endpoints[1].deref();
|
|
646
|
+
if (first === entity) return this.endpoints[1];
|
|
647
|
+
if (second === entity) return this.endpoints[0];
|
|
648
|
+
}
|
|
649
|
+
toRow() {
|
|
650
|
+
const first = this.endpoints[0].deref();
|
|
651
|
+
const second = this.endpoints[1].deref();
|
|
652
|
+
if (first == null || second == null) return void 0;
|
|
653
|
+
return {
|
|
654
|
+
relationId: this.relationId,
|
|
655
|
+
endpoint0EntityId: first.entityId,
|
|
656
|
+
endpoint1EntityId: second.entityId,
|
|
657
|
+
relationKind: this.kind,
|
|
658
|
+
metadata: this.metadata,
|
|
659
|
+
trace: this.trace
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
isStale() {
|
|
663
|
+
return this.endpoints[0].deref() == null || this.endpoints[1].deref() == null;
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
/** Endpoint-agnostic secondary index. Relation rows remain the persistence authority. */
|
|
667
|
+
var BiRelationIndex = class {
|
|
668
|
+
byEntity = /* @__PURE__ */ new WeakMap();
|
|
669
|
+
canonicalRefs = /* @__PURE__ */ new Map();
|
|
670
|
+
byRelationId = /* @__PURE__ */ new Map();
|
|
671
|
+
link(input) {
|
|
672
|
+
if (input.spec.kind === "generated") throw new Error("Author generated Relations with linkGenerated({ output, input })");
|
|
673
|
+
return this.linkValidated(input);
|
|
674
|
+
}
|
|
675
|
+
/** Author `generated(output, input)` without exposing positional arguments. */
|
|
676
|
+
linkGenerated(input) {
|
|
677
|
+
return this.linkValidated({
|
|
678
|
+
relationId: input.relationId,
|
|
679
|
+
spec: generatedRelationSpec,
|
|
680
|
+
endpoints: [input.output, input.input],
|
|
681
|
+
metadata: {},
|
|
682
|
+
trace: input.trace
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Rehydrate a persisted row after resolving its spec and endpoint refs.
|
|
687
|
+
*
|
|
688
|
+
* This is a storage-boundary escape hatch, not an authoring API: persisted
|
|
689
|
+
* positions already are the semantic assertion made by their relation kind.
|
|
690
|
+
*/
|
|
691
|
+
linkRuntime(input) {
|
|
692
|
+
return this.linkValidated(input);
|
|
693
|
+
}
|
|
694
|
+
linkValidated(input) {
|
|
695
|
+
const [first, second] = input.endpoints;
|
|
696
|
+
if (first.entityId === second.entityId) throw new Error("A Relation cannot connect an entity to itself");
|
|
697
|
+
this.assertRelationIdAvailable(input.relationId);
|
|
698
|
+
const builtInSpec = builtInSpecByKind.get(input.spec.kind);
|
|
699
|
+
if (builtInSpec != null && builtInSpec !== input.spec) throw new Error(`Relation kind "${input.spec.kind}" must use its built-in specification`);
|
|
700
|
+
if (isForbiddenAuthoritativeRelation(input.spec.kind, input.endpoints)) throw new Error("A direct Clip-Content Relation is derived-only and cannot be authoritative");
|
|
701
|
+
if (!input.spec.validateEndpoints(input.endpoints)) throw new Error(`Relation "${input.spec.kind}" received invalid endpoints`);
|
|
702
|
+
if (!input.spec.validateMetadata(input.metadata)) throw new Error(`Relation "${input.spec.kind}" received invalid metadata`);
|
|
703
|
+
this.assertCanonicalRefAvailable(first);
|
|
704
|
+
this.assertCanonicalRefAvailable(second);
|
|
705
|
+
const relation = new RelationEdge(input.relationId, input.spec.kind, input.endpoints, input.metadata, input.trace ?? {});
|
|
706
|
+
this.rememberCanonicalRef(first);
|
|
707
|
+
this.rememberCanonicalRef(second);
|
|
708
|
+
this.add(first, relation);
|
|
709
|
+
this.add(second, relation);
|
|
710
|
+
this.byRelationId.set(input.relationId, new WeakRef(relation));
|
|
711
|
+
return relation;
|
|
712
|
+
}
|
|
713
|
+
relationsOf(entity) {
|
|
714
|
+
this.registerCanonicalRef(entity);
|
|
715
|
+
const relations = this.byEntity.get(entity);
|
|
716
|
+
if (relations == null) return /* @__PURE__ */ new Set();
|
|
717
|
+
for (const relation of relations) if (relation instanceof RelationEdge && relation.isStale()) this.unlink(relation);
|
|
718
|
+
return new Set(relations);
|
|
719
|
+
}
|
|
720
|
+
unlink(relation) {
|
|
721
|
+
for (const endpoint of relation.endpoints) {
|
|
722
|
+
const ref = endpoint.deref();
|
|
723
|
+
if (ref != null) this.byEntity.get(ref)?.delete(relation);
|
|
724
|
+
}
|
|
725
|
+
if (this.byRelationId.get(relation.relationId)?.deref() === relation) this.byRelationId.delete(relation.relationId);
|
|
726
|
+
}
|
|
727
|
+
add(entity, relation) {
|
|
728
|
+
const relations = this.byEntity.get(entity) ?? /* @__PURE__ */ new Set();
|
|
729
|
+
relations.add(relation);
|
|
730
|
+
this.byEntity.set(entity, relations);
|
|
731
|
+
}
|
|
732
|
+
registerCanonicalRef(entity) {
|
|
733
|
+
this.assertCanonicalRefAvailable(entity);
|
|
734
|
+
this.rememberCanonicalRef(entity);
|
|
735
|
+
}
|
|
736
|
+
assertCanonicalRefAvailable(entity) {
|
|
737
|
+
const existing = this.canonicalRefs.get(entity.entityId)?.deref();
|
|
738
|
+
if (existing != null && existing !== entity) throw new Error(`Entity "${entity.entityId}" already has a live canonical EntityRef`);
|
|
739
|
+
}
|
|
740
|
+
rememberCanonicalRef(entity) {
|
|
741
|
+
this.canonicalRefs.set(entity.entityId, new WeakRef(entity));
|
|
742
|
+
}
|
|
743
|
+
assertRelationIdAvailable(relationId) {
|
|
744
|
+
if (this.byRelationId.get(relationId)?.deref() != null) throw new Error(`Relation id "${relationId}" already exists`);
|
|
745
|
+
this.byRelationId.delete(relationId);
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
const builtInSpecByKind = new Map(builtInRelationSpecs.map((spec) => [spec.kind, spec]));
|
|
749
|
+
function isForbiddenAuthoritativeRelation(kind, endpoints) {
|
|
750
|
+
if (kind === "clip-content") return true;
|
|
751
|
+
const first = endpoints[0].current();
|
|
752
|
+
const second = endpoints[1].current();
|
|
753
|
+
return first.entityKind === "clip" && hasSequence(second) || second.entityKind === "clip" && hasSequence(first);
|
|
754
|
+
}
|
|
755
|
+
//#endregion
|
|
756
|
+
//#region ../medeo-dsl/src/entity-relation-rows.ts
|
|
757
|
+
var InvalidEntityRelationRowsError = class extends Error {
|
|
758
|
+
issues;
|
|
759
|
+
constructor(issues) {
|
|
760
|
+
super(`Invalid Medeo entity/relation rows:\n- ${issues.join("\n- ")}`);
|
|
761
|
+
this.issues = issues;
|
|
762
|
+
this.name = "InvalidEntityRelationRowsError";
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
/** Decodes database rows into flat entities and validates the complete relation set. */
|
|
766
|
+
function decodeEntityRelationRows(rows, options) {
|
|
767
|
+
const issues = [];
|
|
768
|
+
const entitiesById = /* @__PURE__ */ new Map();
|
|
769
|
+
const extensionEntityKinds = new Set(options.entityKinds ?? []);
|
|
770
|
+
for (const row of rows.entities) {
|
|
771
|
+
const entity = decodeEntityRow(row, extensionEntityKinds, issues);
|
|
772
|
+
if (entity == null) continue;
|
|
773
|
+
const ref = createEntityRef(entity);
|
|
774
|
+
if (entitiesById.has(ref.entityId)) {
|
|
775
|
+
issues.push(`duplicate entity id "${ref.entityId}"`);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
entitiesById.set(ref.entityId, ref);
|
|
779
|
+
}
|
|
780
|
+
const specsByKind = collectRelationSpecs(options.relationSpecs ?? [], issues);
|
|
781
|
+
const relationIndex = new BiRelationIndex();
|
|
782
|
+
const relations = [];
|
|
783
|
+
const relationIds = /* @__PURE__ */ new Set();
|
|
784
|
+
for (const row of rows.relations) {
|
|
785
|
+
let relationId;
|
|
786
|
+
try {
|
|
787
|
+
relationId = createRelationId(row.relationId);
|
|
788
|
+
} catch (error) {
|
|
789
|
+
issues.push(errorMessage(error));
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (relationIds.has(relationId)) {
|
|
793
|
+
issues.push(`duplicate relation id "${relationId}"`);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
relationIds.add(relationId);
|
|
797
|
+
if (!isTrimmedNonEmpty(row.relationKind)) {
|
|
798
|
+
issues.push(`relation "${relationId}" has an empty or untrimmed kind`);
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
const spec = specsByKind.get(row.relationKind);
|
|
802
|
+
if (spec == null) {
|
|
803
|
+
issues.push(`relation "${relationId}" has no registered spec for kind "${row.relationKind}"`);
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
let endpoint0EntityId;
|
|
807
|
+
let endpoint1EntityId;
|
|
808
|
+
try {
|
|
809
|
+
endpoint0EntityId = createEntityId(row.endpoint0EntityId);
|
|
810
|
+
endpoint1EntityId = createEntityId(row.endpoint1EntityId);
|
|
811
|
+
} catch (error) {
|
|
812
|
+
issues.push(errorMessage(error));
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
const endpoint0 = entitiesById.get(endpoint0EntityId);
|
|
816
|
+
const endpoint1 = entitiesById.get(endpoint1EntityId);
|
|
817
|
+
if (endpoint0 == null || endpoint1 == null) {
|
|
818
|
+
const missing = [endpoint0 == null ? endpoint0EntityId : void 0, endpoint1 == null ? endpoint1EntityId : void 0].filter((value) => value != null).join(", ");
|
|
819
|
+
issues.push(`relation "${relationId}" references missing entity id(s): ${missing}`);
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (!isJsonObject(row.metadata) || !isJsonObject(row.trace)) {
|
|
823
|
+
issues.push(`relation "${relationId}" metadata and trace must contain only JSON values`);
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
try {
|
|
827
|
+
relations.push(relationIndex.linkRuntime({
|
|
828
|
+
relationId,
|
|
829
|
+
spec,
|
|
830
|
+
endpoints: [endpoint0, endpoint1],
|
|
831
|
+
metadata: row.metadata,
|
|
832
|
+
trace: row.trace
|
|
833
|
+
}));
|
|
834
|
+
} catch (error) {
|
|
835
|
+
issues.push(errorMessage(error));
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (issues.length === 0) for (const issue of validateEntityRelationSet([...entitiesById.values()], relationIndex, options)) issues.push(`${issue.code}: ${issue.message}`);
|
|
839
|
+
if (issues.length > 0) throw new InvalidEntityRelationRowsError(issues);
|
|
840
|
+
return {
|
|
841
|
+
entitiesById,
|
|
842
|
+
relationIndex,
|
|
843
|
+
relations
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
function decodeEntityRow(row, extensionEntityKinds, issues) {
|
|
847
|
+
let entityId;
|
|
848
|
+
try {
|
|
849
|
+
entityId = createEntityId(row.entityId);
|
|
850
|
+
} catch (error) {
|
|
851
|
+
issues.push(errorMessage(error));
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (!isTrimmedNonEmpty(row.entityKind)) {
|
|
855
|
+
issues.push(`entity "${entityId}" has an empty or untrimmed kind`);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (isReservedEntityKind(row.entityKind)) {
|
|
859
|
+
issues.push(`entity "${entityId}" uses reserved kind "${row.entityKind}"`);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (!isKnownEntityKind(row.entityKind) && !extensionEntityKinds.has(row.entityKind)) {
|
|
863
|
+
issues.push(`entity "${entityId}" has unregistered extension kind "${row.entityKind}"`);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (!isJsonObject(row.payload)) {
|
|
867
|
+
issues.push(`entity "${entityId}" payload must contain only JSON values`);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
const reserved = ["entityId", "entityKind"].filter((key) => Object.hasOwn(row.payload, key));
|
|
871
|
+
if (reserved.length > 0) {
|
|
872
|
+
issues.push(`entity "${entityId}" payload contains reserved field(s): ${reserved.join(", ")}`);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
return {
|
|
876
|
+
...row.payload,
|
|
877
|
+
entityId,
|
|
878
|
+
entityKind: row.entityKind
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
function collectRelationSpecs(extensionSpecs, issues) {
|
|
882
|
+
const specs = /* @__PURE__ */ new Map();
|
|
883
|
+
for (const spec of builtInRelationSpecs) specs.set(spec.kind, spec);
|
|
884
|
+
for (const spec of extensionSpecs) {
|
|
885
|
+
if (!isTrimmedNonEmpty(spec.kind)) {
|
|
886
|
+
issues.push("extension relation spec has an empty or untrimmed kind");
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (specs.has(spec.kind)) {
|
|
890
|
+
issues.push(`relation spec kind "${spec.kind}" is already registered`);
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
specs.set(spec.kind, spec);
|
|
894
|
+
}
|
|
895
|
+
return specs;
|
|
896
|
+
}
|
|
897
|
+
function isTrimmedNonEmpty(value) {
|
|
898
|
+
return value.length > 0 && value.trim() === value;
|
|
899
|
+
}
|
|
900
|
+
function errorMessage(error) {
|
|
901
|
+
return error instanceof Error ? error.message : String(error);
|
|
902
|
+
}
|
|
903
|
+
//#endregion
|
|
904
|
+
//#region ../medeo-dsl/src/rows.ts
|
|
905
|
+
function entityToRow(entity) {
|
|
906
|
+
const { entityId, entityKind, ...payload } = entity;
|
|
907
|
+
if (!isJsonObject(payload)) throw new Error(`Entity "${entityId}" payload must contain only JSON values`);
|
|
908
|
+
return {
|
|
909
|
+
entityId,
|
|
910
|
+
entityKind,
|
|
911
|
+
payload
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region src/entity/entity-sandbox.ts
|
|
916
|
+
const ASSET_SOURCE_KEY = "external";
|
|
917
|
+
const MEMOTA_SYSTEM = "memota";
|
|
918
|
+
/** Mutable entity/relation draft whose only durable product is an explicit command plan. */
|
|
919
|
+
var EntitySandbox = class {
|
|
920
|
+
original;
|
|
921
|
+
idFactory;
|
|
922
|
+
onCommand;
|
|
923
|
+
onTruncate;
|
|
924
|
+
state;
|
|
925
|
+
commands = [];
|
|
926
|
+
entities;
|
|
927
|
+
relations;
|
|
928
|
+
constructor(options) {
|
|
929
|
+
this.original = cloneSnapshot(options.state ?? {
|
|
930
|
+
revision: 0,
|
|
931
|
+
entities: [],
|
|
932
|
+
relations: []
|
|
933
|
+
});
|
|
934
|
+
this.state = cloneSnapshot(this.original);
|
|
935
|
+
this.idFactory = options.idFactory;
|
|
936
|
+
this.onCommand = options.onCommand;
|
|
937
|
+
this.onTruncate = options.onTruncate;
|
|
938
|
+
this.entities = this.buildEntityFacade();
|
|
939
|
+
this.relations = this.buildRelationFacade();
|
|
940
|
+
}
|
|
941
|
+
get commandCount() {
|
|
942
|
+
return this.commands.length;
|
|
943
|
+
}
|
|
944
|
+
getCommands() {
|
|
945
|
+
return this.commands;
|
|
946
|
+
}
|
|
947
|
+
rollbackTo(index) {
|
|
948
|
+
if (!Number.isInteger(index) || index < 0 || index > this.commands.length) throw new Error(`rollbackTo: entity checkpoint index ${index} is past journal length ${this.commands.length}`);
|
|
949
|
+
const prefix = this.commands.slice(0, index);
|
|
950
|
+
this.state = cloneSnapshot(this.original);
|
|
951
|
+
for (const command of prefix) this.apply(command, false);
|
|
952
|
+
this.commands.length = 0;
|
|
953
|
+
this.commands.push(...prefix);
|
|
954
|
+
this.onTruncate?.(index);
|
|
955
|
+
}
|
|
956
|
+
buildPlan() {
|
|
957
|
+
decodeEntityRelationRows(toDslRows(this.state), numericMarkerComparators);
|
|
958
|
+
return {
|
|
959
|
+
base_revision: this.original.revision,
|
|
960
|
+
commands: this.commands.slice(),
|
|
961
|
+
rows: cloneSnapshot(this.state)
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
renderPreview() {
|
|
965
|
+
const lines = [`Entity plan: base_revision=${this.original.revision} commands=${this.commands.length} entities=${this.state.entities.length} relations=${this.state.relations.length}`];
|
|
966
|
+
for (const command of this.commands) if (command.kind === "create-entity") lines.push(`+ entity ${command.entity.entity_id} kind=${command.entity.entity_kind}`);
|
|
967
|
+
else if (command.relation.relation_kind === "generated") lines.push(`+ relation ${command.relation.relation_id} generated(output=${command.relation.endpoint_0_entity_id}, input=${command.relation.endpoint_1_entity_id})`);
|
|
968
|
+
else lines.push(`+ relation ${command.relation.relation_id} kind=${command.relation.relation_kind} endpoints=${command.relation.endpoint_0_entity_id},${command.relation.endpoint_1_entity_id}`);
|
|
969
|
+
return lines.join("\n");
|
|
970
|
+
}
|
|
971
|
+
buildEntityFacade() {
|
|
972
|
+
return {
|
|
973
|
+
list: () => clone(this.state.entities),
|
|
974
|
+
get: (entityId) => {
|
|
975
|
+
const entity = this.state.entities.find((candidate) => candidate.entity_id === entityId);
|
|
976
|
+
return entity == null ? null : clone(entity);
|
|
977
|
+
},
|
|
978
|
+
findByAssetId: (assetId) => {
|
|
979
|
+
assertTrimmed(assetId, "assetId");
|
|
980
|
+
return clone(this.state.entities.filter((entity) => entity.entity_kind === "asset" && isImportedMemotaAsset(entity.payload, assetId)));
|
|
981
|
+
},
|
|
982
|
+
create: (input) => this.createEntity(input),
|
|
983
|
+
importAsset: (input) => this.importAsset(input)
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
buildRelationFacade() {
|
|
987
|
+
return {
|
|
988
|
+
list: () => clone(this.state.relations),
|
|
989
|
+
of: (entityId, relationKind) => {
|
|
990
|
+
assertTrimmed(entityId, "entityId");
|
|
991
|
+
if (relationKind !== void 0 && !builtInRelationSpecs.some((spec) => spec.kind === relationKind)) throw new Error(`Unknown Relation kind "${relationKind}"`);
|
|
992
|
+
return clone(this.state.relations.filter((relation) => (relation.endpoint_0_entity_id === entityId || relation.endpoint_1_entity_id === entityId) && (relationKind === void 0 || relation.relation_kind === relationKind)));
|
|
993
|
+
},
|
|
994
|
+
link: (input) => this.link(input),
|
|
995
|
+
linkGenerated: (input) => this.linkGenerated(input)
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
createEntity(input) {
|
|
999
|
+
if (!isKnownEntityKind(input.entity_kind)) throw new Error(`Unknown or extension Entity kind "${String(input.entity_kind)}"`);
|
|
1000
|
+
const payload = clone(input.payload);
|
|
1001
|
+
if (!isJsonObject(payload)) throw new Error("Entity payload must contain only JSON values");
|
|
1002
|
+
const entityId = input.entity_id ?? this.idFactory("entity");
|
|
1003
|
+
assertTrimmed(entityId, "entity_id");
|
|
1004
|
+
const entity = {
|
|
1005
|
+
entity_id: entityId,
|
|
1006
|
+
entity_kind: input.entity_kind,
|
|
1007
|
+
payload
|
|
1008
|
+
};
|
|
1009
|
+
entityToRow({
|
|
1010
|
+
...entity.payload,
|
|
1011
|
+
entityId: createEntityId(entityId),
|
|
1012
|
+
entityKind: entity.entity_kind
|
|
1013
|
+
});
|
|
1014
|
+
this.record({
|
|
1015
|
+
kind: "create-entity",
|
|
1016
|
+
entity
|
|
1017
|
+
});
|
|
1018
|
+
return entityId;
|
|
1019
|
+
}
|
|
1020
|
+
importAsset(input) {
|
|
1021
|
+
assertTrimmed(input.asset_id, "asset_id");
|
|
1022
|
+
const payload = input.payload === void 0 ? {} : clone(input.payload);
|
|
1023
|
+
if (!isJsonObject(payload)) throw new Error("Asset payload must contain only JSON values");
|
|
1024
|
+
return this.createEntity({
|
|
1025
|
+
...input.entity_id !== void 0 ? { entity_id: input.entity_id } : {},
|
|
1026
|
+
entity_kind: "asset",
|
|
1027
|
+
payload: {
|
|
1028
|
+
...payload,
|
|
1029
|
+
[ASSET_SOURCE_KEY]: {
|
|
1030
|
+
system: MEMOTA_SYSTEM,
|
|
1031
|
+
key: input.asset_id
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
link(input) {
|
|
1037
|
+
if (input.relation_kind === "generated") throw new Error("Author generated Relations with relations.linkGenerated({ output_entity_id, input_entity_id })");
|
|
1038
|
+
const spec = builtInRelationSpecs.find((candidate) => candidate.kind === input.relation_kind);
|
|
1039
|
+
if (spec == null) throw new Error(`Unknown Relation kind "${String(input.relation_kind)}"`);
|
|
1040
|
+
const relation = this.relationFromInput(input, input.relation_kind);
|
|
1041
|
+
const [first, second] = this.refsFor(relation);
|
|
1042
|
+
new BiRelationIndex().link({
|
|
1043
|
+
relationId: createRelationId(relation.relation_id),
|
|
1044
|
+
spec,
|
|
1045
|
+
endpoints: [first, second],
|
|
1046
|
+
metadata: relation.metadata,
|
|
1047
|
+
trace: relation.trace
|
|
1048
|
+
});
|
|
1049
|
+
this.record({
|
|
1050
|
+
kind: "link-relation",
|
|
1051
|
+
relation
|
|
1052
|
+
});
|
|
1053
|
+
return relation.relation_id;
|
|
1054
|
+
}
|
|
1055
|
+
linkGenerated(input) {
|
|
1056
|
+
const relation = this.relationFromInput({
|
|
1057
|
+
...input.relation_id !== void 0 ? { relation_id: input.relation_id } : {},
|
|
1058
|
+
endpoint_0_entity_id: input.output_entity_id,
|
|
1059
|
+
endpoint_1_entity_id: input.input_entity_id,
|
|
1060
|
+
metadata: {},
|
|
1061
|
+
...input.trace !== void 0 ? { trace: input.trace } : {}
|
|
1062
|
+
}, "generated");
|
|
1063
|
+
const [output, source] = this.refsFor(relation);
|
|
1064
|
+
new BiRelationIndex().linkGenerated({
|
|
1065
|
+
relationId: createRelationId(relation.relation_id),
|
|
1066
|
+
output,
|
|
1067
|
+
input: source,
|
|
1068
|
+
trace: relation.trace
|
|
1069
|
+
});
|
|
1070
|
+
this.record({
|
|
1071
|
+
kind: "link-relation",
|
|
1072
|
+
relation
|
|
1073
|
+
});
|
|
1074
|
+
return relation.relation_id;
|
|
1075
|
+
}
|
|
1076
|
+
relationFromInput(input, relationKind) {
|
|
1077
|
+
const relationId = input.relation_id ?? this.idFactory("relation");
|
|
1078
|
+
assertTrimmed(relationId, "relation_id");
|
|
1079
|
+
assertTrimmed(input.endpoint_0_entity_id, "endpoint_0_entity_id");
|
|
1080
|
+
assertTrimmed(input.endpoint_1_entity_id, "endpoint_1_entity_id");
|
|
1081
|
+
const metadata = clone(input.metadata ?? {});
|
|
1082
|
+
const trace = clone(input.trace ?? {});
|
|
1083
|
+
if (!isJsonObject(metadata) || !isJsonObject(trace)) throw new Error("Relation metadata and trace must contain only JSON values");
|
|
1084
|
+
return {
|
|
1085
|
+
relation_id: relationId,
|
|
1086
|
+
relation_kind: relationKind,
|
|
1087
|
+
endpoint_0_entity_id: input.endpoint_0_entity_id,
|
|
1088
|
+
endpoint_1_entity_id: input.endpoint_1_entity_id,
|
|
1089
|
+
metadata,
|
|
1090
|
+
trace
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
refsFor(relation) {
|
|
1094
|
+
const first = this.state.entities.find((entity) => entity.entity_id === relation.endpoint_0_entity_id);
|
|
1095
|
+
const second = this.state.entities.find((entity) => entity.entity_id === relation.endpoint_1_entity_id);
|
|
1096
|
+
if (first == null || second == null) {
|
|
1097
|
+
const missing = [first == null ? relation.endpoint_0_entity_id : null, second == null ? relation.endpoint_1_entity_id : null].filter((value) => value != null).join(", ");
|
|
1098
|
+
throw new Error(`Relation references missing Entity id(s): ${missing}`);
|
|
1099
|
+
}
|
|
1100
|
+
return [createEntityRef(toDslEntity(first)), createEntityRef(toDslEntity(second))];
|
|
1101
|
+
}
|
|
1102
|
+
record(command) {
|
|
1103
|
+
this.apply(command, true);
|
|
1104
|
+
this.commands.push(clone(command));
|
|
1105
|
+
this.onCommand?.(clone(command));
|
|
1106
|
+
}
|
|
1107
|
+
apply(command, enforceIdentity) {
|
|
1108
|
+
if (command.kind === "create-entity") {
|
|
1109
|
+
if (enforceIdentity && this.state.entities.some((entity) => entity.entity_id === command.entity.entity_id)) throw new Error(`Entity id "${command.entity.entity_id}" already exists`);
|
|
1110
|
+
this.state.entities.push(clone(command.entity));
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (enforceIdentity && this.state.relations.some((relation) => relation.relation_id === command.relation.relation_id)) throw new Error(`Relation id "${command.relation.relation_id}" already exists`);
|
|
1114
|
+
this.state.relations.push(clone(command.relation));
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
function isImportedMemotaAsset(payload, assetId) {
|
|
1118
|
+
const external = payload[ASSET_SOURCE_KEY];
|
|
1119
|
+
return external != null && !Array.isArray(external) && typeof external === "object" && external.system === MEMOTA_SYSTEM && external.key === assetId;
|
|
1120
|
+
}
|
|
1121
|
+
function toDslEntity(entity) {
|
|
1122
|
+
return {
|
|
1123
|
+
...clone(entity.payload),
|
|
1124
|
+
entityId: createEntityId(entity.entity_id),
|
|
1125
|
+
entityKind: entity.entity_kind
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
function toDslRows(state) {
|
|
1129
|
+
return {
|
|
1130
|
+
entities: state.entities.map((entity) => ({
|
|
1131
|
+
entityId: createEntityId(entity.entity_id),
|
|
1132
|
+
entityKind: entity.entity_kind,
|
|
1133
|
+
payload: clone(entity.payload)
|
|
1134
|
+
})),
|
|
1135
|
+
relations: state.relations.map((relation) => ({
|
|
1136
|
+
relationId: createRelationId(relation.relation_id),
|
|
1137
|
+
relationKind: relation.relation_kind,
|
|
1138
|
+
endpoint0EntityId: createEntityId(relation.endpoint_0_entity_id),
|
|
1139
|
+
endpoint1EntityId: createEntityId(relation.endpoint_1_entity_id),
|
|
1140
|
+
metadata: clone(relation.metadata),
|
|
1141
|
+
trace: clone(relation.trace)
|
|
1142
|
+
}))
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
function cloneSnapshot(state) {
|
|
1146
|
+
return clone(state);
|
|
1147
|
+
}
|
|
1148
|
+
function clone(value) {
|
|
1149
|
+
return structuredClone(value);
|
|
1150
|
+
}
|
|
1151
|
+
function assertTrimmed(value, label) {
|
|
1152
|
+
if (typeof value !== "string" || value.length === 0 || value.trim() !== value) throw new Error(`${label} must be a non-empty trimmed string`);
|
|
1153
|
+
}
|
|
1154
|
+
const numericMarkerComparators = { compareMarkerPoints: (_marker, _range, left, right) => {
|
|
1155
|
+
if (typeof left !== "number" || !Number.isFinite(left) || typeof right !== "number" || !Number.isFinite(right)) throw new Error("Sequence Marker points require finite numeric coordinates in the entity sandbox");
|
|
1156
|
+
return left - right;
|
|
1157
|
+
} };
|
|
1158
|
+
//#endregion
|
|
1159
|
+
//#region src/sandbox/script-session.ts
|
|
1160
|
+
const LOG_LINE_MAX = 2e3;
|
|
1161
|
+
const LOG_LINE_CAP = 1e3;
|
|
1162
|
+
const LOG_BYTE_CAP = 64 * 1024;
|
|
1163
|
+
const TRUNCATE_MARK = "[truncated]";
|
|
1164
|
+
const LOG_TRUNCATED = "[log truncated]";
|
|
1165
|
+
const ENTITY_CHECKPOINT_INDEX = Symbol("entityCheckpointIndex");
|
|
1166
|
+
/** Session core for one forked document; globals stay identity-stable across rollback. */
|
|
1167
|
+
var EditSandboxSession = class {
|
|
1168
|
+
original;
|
|
1169
|
+
idFactory;
|
|
1170
|
+
onEntry;
|
|
1171
|
+
onLog;
|
|
1172
|
+
onTruncate;
|
|
1173
|
+
entitySandbox;
|
|
1174
|
+
current;
|
|
1175
|
+
/** Adapter journal length already accounted for — new slices are real commits. */
|
|
1176
|
+
adapterJournalSeen = 0;
|
|
1177
|
+
entries = [];
|
|
1178
|
+
logs = [];
|
|
1179
|
+
logBytes = 0;
|
|
1180
|
+
logCapped = false;
|
|
1181
|
+
edit;
|
|
1182
|
+
timeline;
|
|
1183
|
+
entities;
|
|
1184
|
+
relations;
|
|
1185
|
+
console;
|
|
1186
|
+
checkpoint;
|
|
1187
|
+
rollbackTo;
|
|
1188
|
+
constructor(document, options) {
|
|
1189
|
+
this.original = structuredClone(document);
|
|
1190
|
+
this.idFactory = options?.idFactory;
|
|
1191
|
+
this.onEntry = options?.onEntry;
|
|
1192
|
+
this.onLog = options?.onLog;
|
|
1193
|
+
this.onTruncate = options?.onTruncate;
|
|
1194
|
+
this.entitySandbox = new EntitySandbox({
|
|
1195
|
+
state: options?.entityState,
|
|
1196
|
+
idFactory: options?.domainIdFactory ?? (() => {
|
|
1197
|
+
throw new Error("Entity id factory is unavailable in this sandbox host");
|
|
1198
|
+
}),
|
|
1199
|
+
onCommand: options?.onEntityCommand,
|
|
1200
|
+
onTruncate: options?.onEntityTruncate
|
|
1201
|
+
});
|
|
1202
|
+
this.current = this.boot(structuredClone(this.original));
|
|
1203
|
+
this.adapterJournalSeen = this.current.adapter.journal.length;
|
|
1204
|
+
this.edit = this.buildEditFacade();
|
|
1205
|
+
this.timeline = this.buildTimelineFacade();
|
|
1206
|
+
this.entities = this.entitySandbox.entities;
|
|
1207
|
+
this.relations = this.entitySandbox.relations;
|
|
1208
|
+
this.console = this.buildConsoleShim();
|
|
1209
|
+
this.checkpoint = () => {
|
|
1210
|
+
const checkpoint = { index: this.entries.length };
|
|
1211
|
+
Object.defineProperty(checkpoint, ENTITY_CHECKPOINT_INDEX, {
|
|
1212
|
+
value: this.entitySandbox.commandCount,
|
|
1213
|
+
enumerable: false
|
|
1214
|
+
});
|
|
1215
|
+
return checkpoint;
|
|
1216
|
+
};
|
|
1217
|
+
this.rollbackTo = (cp) => this.doRollbackTo(cp);
|
|
1218
|
+
}
|
|
1219
|
+
/** Assemble a ChangePlan from the self-maintained journal + current preview. */
|
|
1220
|
+
buildPlan(baseVersion) {
|
|
1221
|
+
const entityCommands = this.entitySandbox.getCommands();
|
|
1222
|
+
if (this.entries.length > 0 && entityCommands.length > 0) throw new Error("A sandbox plan cannot mix timeline and entity mutations; run and commit them as separate plans");
|
|
1223
|
+
const entityPlan = this.entitySandbox.buildPlan();
|
|
1224
|
+
const planKind = entityCommands.length > 0 ? "entities" : "timeline";
|
|
1225
|
+
return {
|
|
1226
|
+
plan_kind: planKind,
|
|
1227
|
+
doc_id: this.original.meta.draft_id ?? "",
|
|
1228
|
+
base_version: baseVersion,
|
|
1229
|
+
ops: this.entries.slice(),
|
|
1230
|
+
entity_base_revision: entityPlan.base_revision,
|
|
1231
|
+
entity_commands: entityPlan.commands,
|
|
1232
|
+
...planKind === "entities" ? { entity_rows: entityPlan.rows } : {},
|
|
1233
|
+
preview: planKind === "entities" ? this.entitySandbox.renderPreview() : renderPreview(this.current.adapter.snapshot(), this.entries),
|
|
1234
|
+
logs: this.logs.slice()
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
getEntries() {
|
|
1238
|
+
return this.entries;
|
|
1239
|
+
}
|
|
1240
|
+
getLogs() {
|
|
1241
|
+
return this.logs;
|
|
1242
|
+
}
|
|
1243
|
+
boot(document) {
|
|
1244
|
+
const sandbox = createEditSandbox(document, this.idFactory != null ? { idFactory: this.idFactory } : void 0);
|
|
1245
|
+
return {
|
|
1246
|
+
adapter: sandbox.adapter,
|
|
1247
|
+
editor: sandbox.editor
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
doRollbackTo(cp) {
|
|
1251
|
+
if (cp.index > this.entries.length) throw new Error(`rollbackTo: checkpoint index ${cp.index} is past journal length ${this.entries.length}`);
|
|
1252
|
+
const prefix = this.entries.slice(0, cp.index);
|
|
1253
|
+
const next = this.boot(structuredClone(this.original));
|
|
1254
|
+
replayJournalSync(next.adapter, prefix);
|
|
1255
|
+
this.entries.length = 0;
|
|
1256
|
+
this.entries.push(...prefix);
|
|
1257
|
+
this.current = next;
|
|
1258
|
+
this.adapterJournalSeen = next.adapter.journal.length;
|
|
1259
|
+
this.onTruncate?.(prefix.length);
|
|
1260
|
+
const entityIndex = cp[ENTITY_CHECKPOINT_INDEX];
|
|
1261
|
+
if (entityIndex !== void 0) this.entitySandbox.rollbackTo(entityIndex);
|
|
1262
|
+
}
|
|
1263
|
+
captureNewEntries() {
|
|
1264
|
+
const journal = this.current.adapter.journal;
|
|
1265
|
+
if (journal.length <= this.adapterJournalSeen) return;
|
|
1266
|
+
const fresh = journal.slice(this.adapterJournalSeen);
|
|
1267
|
+
this.adapterJournalSeen = journal.length;
|
|
1268
|
+
for (const entry of fresh) {
|
|
1269
|
+
this.entries.push(entry);
|
|
1270
|
+
this.onEntry?.(entry);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
appendLog(line) {
|
|
1274
|
+
if (this.logCapped) return;
|
|
1275
|
+
if (this.logs.length >= LOG_LINE_CAP || this.logBytes >= LOG_BYTE_CAP) {
|
|
1276
|
+
this.logs.push(LOG_TRUNCATED);
|
|
1277
|
+
this.logCapped = true;
|
|
1278
|
+
this.onLog?.(LOG_TRUNCATED);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
let out = line;
|
|
1282
|
+
if (out.length > LOG_LINE_MAX) out = `${out.slice(0, LOG_LINE_MAX - 11)}${TRUNCATE_MARK}`;
|
|
1283
|
+
this.logs.push(out);
|
|
1284
|
+
this.logBytes += out.length;
|
|
1285
|
+
this.onLog?.(out);
|
|
1286
|
+
}
|
|
1287
|
+
buildConsoleShim() {
|
|
1288
|
+
const write = (...args) => {
|
|
1289
|
+
this.appendLog(args.map(formatLogArg).join(" "));
|
|
1290
|
+
};
|
|
1291
|
+
return {
|
|
1292
|
+
log: write,
|
|
1293
|
+
info: write,
|
|
1294
|
+
warn: write,
|
|
1295
|
+
error: write
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
buildEditFacade() {
|
|
1299
|
+
const wrap = (method) => async (input) => {
|
|
1300
|
+
await method(this.current.editor, input);
|
|
1301
|
+
this.captureNewEntries();
|
|
1302
|
+
};
|
|
1303
|
+
return {
|
|
1304
|
+
addSpeeches: wrap((e, i) => e.addSpeeches(i)),
|
|
1305
|
+
addVideoClips: async (input) => {
|
|
1306
|
+
const needsAppend = input.before_clip_id == null && input.after_clip_id == null && input.clips.some((clip) => clip.start_ms == null);
|
|
1307
|
+
const appendAt = this.timeline.snapshot().timeline?.duration_ms ?? 0;
|
|
1308
|
+
const normalized = needsAppend ? {
|
|
1309
|
+
...input,
|
|
1310
|
+
clips: input.clips.map((clip) => clip.start_ms == null ? {
|
|
1311
|
+
...clip,
|
|
1312
|
+
start_ms: appendAt
|
|
1313
|
+
} : clip)
|
|
1314
|
+
} : input;
|
|
1315
|
+
await this.current.editor.addVideoClips(normalized);
|
|
1316
|
+
this.captureNewEntries();
|
|
1317
|
+
},
|
|
1318
|
+
adjustBgmVolume: wrap((e, i) => e.adjustBgmVolume(i)),
|
|
1319
|
+
adjustSpeechVolume: wrap((e, i) => e.adjustSpeechVolume(i)),
|
|
1320
|
+
adjustVideoClipDuration: wrap((e, i) => e.adjustVideoClipDuration(i)),
|
|
1321
|
+
adjustVideoClipVolume: wrap((e, i) => e.adjustVideoClipVolume(i)),
|
|
1322
|
+
changeSpeechScript: wrap((e, i) => e.changeSpeechScript(i)),
|
|
1323
|
+
changeSpeechVoice: wrap((e, i) => e.changeSpeechVoice(i)),
|
|
1324
|
+
deleteBgm: wrap((e, i) => e.deleteBgm(i)),
|
|
1325
|
+
deleteSpeeches: wrap((e, i) => e.deleteSpeeches(i)),
|
|
1326
|
+
deleteVideoClips: wrap((e, i) => e.deleteVideoClips(i)),
|
|
1327
|
+
moveSpeeches: wrap((e, i) => e.moveSpeeches(i)),
|
|
1328
|
+
moveVideoClips: wrap((e, i) => e.moveVideoClips(i)),
|
|
1329
|
+
moveVideoClipsByAnchor: wrap((e, i) => e.moveVideoClipsByAnchor(i)),
|
|
1330
|
+
replaceVideoClipContent: wrap((e, i) => e.replaceVideoClipContent(i)),
|
|
1331
|
+
replaceVideoClipSequence: wrap((e, i) => e.replaceVideoClipSequence(i)),
|
|
1332
|
+
setBgm: wrap((e, i) => e.setBgm(i)),
|
|
1333
|
+
setCaptionStyle: wrap((e, i) => e.setCaptionStyle(i)),
|
|
1334
|
+
setCaptionVisibility: wrap((e, i) => e.setCaptionVisibility(i)),
|
|
1335
|
+
setVideoClipSpeedShift: wrap((e, i) => e.setVideoClipSpeedShift(i))
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
buildTimelineFacade() {
|
|
1339
|
+
return {
|
|
1340
|
+
snapshot: () => fromVideoDocument(this.current.adapter.snapshot()),
|
|
1341
|
+
clipsInRange: (startMs, endMs) => this.clipsInRange(startMs, endMs),
|
|
1342
|
+
part: (id) => this.part(id)
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
clipsInRange(startMs, endMs) {
|
|
1346
|
+
const document = this.current.adapter.snapshot();
|
|
1347
|
+
const solved = solveVideoDocument(document);
|
|
1348
|
+
const library = document.part_library ?? {};
|
|
1349
|
+
const main = document.tracks?.find((track) => track.parts_kind === "video_clip");
|
|
1350
|
+
const out = [];
|
|
1351
|
+
for (const item of main?.items ?? []) {
|
|
1352
|
+
const id = item.part_id;
|
|
1353
|
+
if (id == null) continue;
|
|
1354
|
+
const clip = library[id]?.video_clip;
|
|
1355
|
+
if (clip == null) continue;
|
|
1356
|
+
const start = solved.absByPartId.get(id) ?? 0;
|
|
1357
|
+
const duration = effectiveVideoClipDurationMs(clip);
|
|
1358
|
+
const end = start + duration;
|
|
1359
|
+
const mid = start + duration / 2;
|
|
1360
|
+
if (!(mid >= startMs && mid < endMs)) continue;
|
|
1361
|
+
out.push({
|
|
1362
|
+
id,
|
|
1363
|
+
start_ms: start,
|
|
1364
|
+
end_ms: end,
|
|
1365
|
+
duration_ms: duration,
|
|
1366
|
+
speed_shift: clip.speed_shift,
|
|
1367
|
+
volume: clip.volume,
|
|
1368
|
+
media_id: clip.origin_media_id
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
return out;
|
|
1372
|
+
}
|
|
1373
|
+
part(id) {
|
|
1374
|
+
const document = this.current.adapter.snapshot();
|
|
1375
|
+
const part = (document.part_library ?? {})[id];
|
|
1376
|
+
if (part == null) return null;
|
|
1377
|
+
let lane = "main";
|
|
1378
|
+
let kind = "video_clip";
|
|
1379
|
+
for (const track of document.tracks ?? []) {
|
|
1380
|
+
if (!(track.items ?? []).some((item) => item.part_id === id)) continue;
|
|
1381
|
+
const partsKind = track.parts_kind ?? "video_clip";
|
|
1382
|
+
kind = partsKind;
|
|
1383
|
+
lane = partsKind === "video_clip" ? "main" : partsKind;
|
|
1384
|
+
break;
|
|
1385
|
+
}
|
|
1386
|
+
const solved = solveVideoDocument(document);
|
|
1387
|
+
const start = solved.absByPartId.get(id) ?? 0;
|
|
1388
|
+
let duration = 0;
|
|
1389
|
+
if (part.video_clip != null) duration = effectiveVideoClipDurationMs(part.video_clip);
|
|
1390
|
+
else if (part.speech != null) duration = part.speech.media_duration_ms ?? 0;
|
|
1391
|
+
else if (part.caption != null) duration = part.caption.initial_duration_ms ?? 0;
|
|
1392
|
+
else if (part.bgm != null) duration = solved.durationMs;
|
|
1393
|
+
return {
|
|
1394
|
+
id,
|
|
1395
|
+
kind,
|
|
1396
|
+
lane,
|
|
1397
|
+
start_ms: start,
|
|
1398
|
+
end_ms: start + duration,
|
|
1399
|
+
duration_ms: duration,
|
|
1400
|
+
part
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
};
|
|
1404
|
+
function formatLogArg(value) {
|
|
1405
|
+
if (typeof value === "string") return value;
|
|
1406
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null || value === void 0) return String(value);
|
|
1407
|
+
try {
|
|
1408
|
+
return JSON.stringify(value);
|
|
1409
|
+
} catch {
|
|
1410
|
+
return "[unstringifiable]";
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Synchronous journal replay for rollback. Editor methods are `async` only for
|
|
1415
|
+
* interface uniformity — their bodies complete before the Promise is returned,
|
|
1416
|
+
* so voiding the call applies mutations in-order without yielding.
|
|
1417
|
+
*/
|
|
1418
|
+
function replayJournalSync(adapter, journal) {
|
|
1419
|
+
const queue = [];
|
|
1420
|
+
const idFactory = (_prefix) => {
|
|
1421
|
+
const id = queue.shift();
|
|
1422
|
+
if (id == null) throw new Error("unrecorded id");
|
|
1423
|
+
return id;
|
|
1424
|
+
};
|
|
1425
|
+
const editor = new SemanticEditor(adapter, new SchemaValidator(), idFactory);
|
|
1426
|
+
for (const entry of journal) {
|
|
1427
|
+
queue.push(...entry.generated_ids ?? []);
|
|
1428
|
+
const payload = entry.payload;
|
|
1429
|
+
switch (entry.kind) {
|
|
1430
|
+
case "MoveVideoClips":
|
|
1431
|
+
editor.moveVideoClips(payload);
|
|
1432
|
+
break;
|
|
1433
|
+
case "MoveVideoClipsByAnchor":
|
|
1434
|
+
editor.moveVideoClipsByAnchor(payload);
|
|
1435
|
+
break;
|
|
1436
|
+
case "DeleteVideoClips":
|
|
1437
|
+
editor.deleteVideoClips(payload);
|
|
1438
|
+
break;
|
|
1439
|
+
case "AddVideoClips":
|
|
1440
|
+
editor.addVideoClips(payload);
|
|
1441
|
+
break;
|
|
1442
|
+
case "AdjustVideoClipVolume":
|
|
1443
|
+
editor.adjustVideoClipVolume(payload);
|
|
1444
|
+
break;
|
|
1445
|
+
case "SetVideoClipSpeedShift":
|
|
1446
|
+
editor.setVideoClipSpeedShift(payload);
|
|
1447
|
+
break;
|
|
1448
|
+
case "ReplaceVideoClipContent":
|
|
1449
|
+
editor.replaceVideoClipContent(payload);
|
|
1450
|
+
break;
|
|
1451
|
+
case "ReplaceVideoClipSequence":
|
|
1452
|
+
editor.replaceVideoClipSequence(payload);
|
|
1453
|
+
break;
|
|
1454
|
+
case "AdjustVideoClipDuration":
|
|
1455
|
+
editor.adjustVideoClipDuration(payload);
|
|
1456
|
+
break;
|
|
1457
|
+
case "AddSpeeches":
|
|
1458
|
+
editor.addSpeeches(payload);
|
|
1459
|
+
break;
|
|
1460
|
+
case "DeleteSpeeches":
|
|
1461
|
+
editor.deleteSpeeches(payload);
|
|
1462
|
+
break;
|
|
1463
|
+
case "MoveSpeeches":
|
|
1464
|
+
editor.moveSpeeches(payload);
|
|
1465
|
+
break;
|
|
1466
|
+
case "ChangeSpeechScript":
|
|
1467
|
+
editor.changeSpeechScript(payload);
|
|
1468
|
+
break;
|
|
1469
|
+
case "ChangeSpeechVoice":
|
|
1470
|
+
editor.changeSpeechVoice(payload);
|
|
1471
|
+
break;
|
|
1472
|
+
case "AdjustSpeechVolume":
|
|
1473
|
+
editor.adjustSpeechVolume(payload);
|
|
1474
|
+
break;
|
|
1475
|
+
case "SetCaptionVisibility":
|
|
1476
|
+
editor.setCaptionVisibility(payload);
|
|
1477
|
+
break;
|
|
1478
|
+
case "SetCaptionStyle":
|
|
1479
|
+
editor.setCaptionStyle(payload);
|
|
1480
|
+
break;
|
|
1481
|
+
case "SetBgm":
|
|
1482
|
+
editor.setBgm(payload);
|
|
1483
|
+
break;
|
|
1484
|
+
case "DeleteBgm":
|
|
1485
|
+
editor.deleteBgm(payload);
|
|
1486
|
+
break;
|
|
1487
|
+
case "AdjustBgmVolume":
|
|
1488
|
+
editor.adjustBgmVolume(payload);
|
|
1489
|
+
break;
|
|
1490
|
+
default: {
|
|
1491
|
+
const _exhaustive = entry.kind;
|
|
1492
|
+
throw new Error(`replayJournalSync: unsupported kind ${String(_exhaustive)}`);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
if (queue.length > 0) throw new Error("unconsumed ids");
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
//#endregion
|
|
1499
|
+
export { renderCompactProjection as i, collectAffectedPartIds as n, renderPreview as r, EditSandboxSession as t };
|
|
1500
|
+
|
|
1501
|
+
//# sourceMappingURL=script-session-BF44uKv_.mjs.map
|