@mengine/medeo-client 1.3.1-alpha.2 → 1.3.1-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3623 +0,0 @@
1
- import { u as volumeSchema } from "./shared-iWnU2osE.js";
2
- import { Mirror, schema } from "loro-mirror";
3
- import { z } from "zod";
4
- import { LoroDoc } from "loro-crdt";
5
- import { produce } from "immer";
6
- //#region src/client/base64.ts
7
- function bytesToBase64(bytes) {
8
- let binary = "";
9
- for (const byte of bytes) binary += String.fromCharCode(byte);
10
- return btoa(binary);
11
- }
12
- function base64ToBytes(base64) {
13
- const binary = atob(base64);
14
- const bytes = new Uint8Array(binary.length);
15
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
16
- return bytes;
17
- }
18
- //#endregion
19
- //#region src/document/mirror-schema.ts
20
- /**
21
- * Declarative `loro-mirror` schema for `VideoDocument` — the canonical structural
22
- * SSOT and live storage wire (replaces the hand-rolled `@mengine/schema`
23
- * definition + adapter, see ADR 0008).
24
- *
25
- * Mirror gives an in-memory immutable state synced to the Loro doc by declarative
26
- * diff, so the storage layer no longer hand-writes per-region reconcile or a
27
- * transaction state machine. The shapes here store only authoritative facts
28
- * (RFC 02 §6): per-item absolute time, `part_aggregations`, and total duration
29
- * are projection-derived (cascade-solved at read time), so they are absent here.
30
- *
31
- * - tracks live in a single `tracks` `LoroMovableList` keyed by track id
32
- * (reference/17 §4). Lane is expressed by each track's `parts_kind`, not by
33
- * which container it lives in; order is the movable-list order itself. There is
34
- * no `lane` / `lane_order` field, so two peers inserting tracks never collide
35
- * on an order integer — list moves are conflict-free (RFC 03 §4). The legacy
36
- * keyed-map (`LoroMapRecord` + an integer `lane_order`) and the prior three
37
- * named containers are gone; the `main` / `above` / `below` three-pane view is
38
- * rebuilt by the projection from `parts_kind`.
39
- * - each track's `items` is a `LoroMovableList` keyed by `part_id`: a part is
40
- * placed at most once per lane, so `part_id` is the stable placement identity.
41
- * Reorder diffs to a real Loro `move`, preserving per-item CRDT identity on
42
- * every lane (main and secondary alike).
43
- * - `time_position` is the authoritative positioning fact (RFC 02 §4,
44
- * reference/17 §3), carried as an opaque JSON blob string (a whole tagged-union
45
- * value, last-writer-wins). The derived `fallback_abs_ms` snapshot sits beside
46
- * it as its own number field — a separate LWW unit so a projection refresh
47
- * never clobbers a `time_position` edit.
48
- * - meta lives in a `meta` LoroMap: the mirror root only accepts container
49
- * schemas, so meta scalars can't sit at the root as bare values. The domain
50
- * `VideoDocument` keeps the same `meta` wrapper, so the shapes are isomorphic
51
- * and the read/seed mapping is a near-identity.
52
- * - `part_library` values are nested `LoroMap`s: the value is a `LoroMap` with
53
- * four mutually-exclusive optional part sub-maps (`video_clip` / `speech` /
54
- * `caption` / `bgm`), each a `LoroMap` whose fields are stored as real Loro
55
- * sub-keys. This makes each field its own CRDT unit, so two peers editing
56
- * different fields of the same part (e.g. one volume, one play_out) merge
57
- * field-by-field instead of one whole-value overwrite. Field `required` mirrors
58
- * the Smithy `@required` contract (see `video_draft_comp.smithy`). `kind` is not
59
- * stored (the present sub-key names the kind); `duration_ms` is not stored (it
60
- * is derived, reference/17 §5). Nested values that the engine does not edit
61
- * field-by-field — `speed_shift` (a tagged union), `voice`, `caption_ids` — stay
62
- * as opaque JSON-blob sub-keys via `transform`. The four sub-keys are optional,
63
- * so the "exactly one part kind" union invariant is not enforced by the schema
64
- * type; it is rebuilt by `draftToPartUnion` on read and gated by zod on write.
65
- * - a caption's `style` is the one lazily-created optional child container, so
66
- * `captionPart` sets `mergeableMapChildContainers: true` — two peers can
67
- * concurrently first-create the same caption's `style` (one attribute each), and
68
- * only a mergeable child converges instead of last-writer-wins dropping one. All
69
- * other nested maps are keyed by a unique id (`part_library`) or created
70
- * atomically with their parent (`partValue`'s part sub-map), so they never hit
71
- * that concurrent-first-create fork and stay on plain `setContainer`. See the
72
- * note on `captionPart` below.
73
- * - `video_creation_settings` is an opaque JSON blob carried in a string via
74
- * `transform`: a whole value, last-writer-wins (no field-level concurrent edits).
75
- */
76
- /**
77
- * JSON-blob transform for an opaque, last-writer-wins value carried in a Loro
78
- * string. The field must be declared `required: false`: an absent field decodes
79
- * to `undefined` (mirror never calls `decode`/`encode` for null/undefined),
80
- * which is how we represent "no value" instead of encoding a `null` sentinel.
81
- */
82
- function jsonTransform() {
83
- return {
84
- decode: (value) => JSON.parse(value),
85
- encode: (value) => JSON.stringify(value),
86
- isEqual: "encoded-value-equality"
87
- };
88
- }
89
- const trackItem = schema.LoroMap({
90
- part_id: schema.String(),
91
- time_position: schema.String().transform(jsonTransform()),
92
- fallback_abs_ms: schema.Number({ required: false })
93
- });
94
- const track = schema.LoroMap({
95
- id: schema.String(),
96
- parts_kind: schema.String({ required: false }),
97
- is_hidden: schema.Boolean({ required: false }),
98
- items: schema.LoroMovableList(trackItem, (item) => item.part_id)
99
- });
100
- const videoClipPart = schema.LoroMap({
101
- id: schema.String(),
102
- play_in: schema.Number(),
103
- play_out: schema.Number(),
104
- volume: schema.Number(),
105
- origin_media_id: schema.String(),
106
- speed_shift: schema.String({ required: false }).transform(jsonTransform())
107
- }, { required: false });
108
- const speechPart = schema.LoroMap({
109
- id: schema.String(),
110
- media_duration_ms: schema.Number(),
111
- audio_script: schema.String(),
112
- volume: schema.Number(),
113
- audio_storage_key: schema.String(),
114
- origin_speech_id: schema.String(),
115
- voice: schema.String().transform(jsonTransform()),
116
- caption_ids: schema.String().transform(jsonTransform())
117
- }, { required: false });
118
- const captionStyle$1 = schema.LoroMap({
119
- font_id: schema.String({ required: false }),
120
- font_size: schema.Number({ required: false }),
121
- font_color: schema.String({ required: false }),
122
- font_weight: schema.Number({ required: false }),
123
- entrance_animation: schema.String({ required: false }),
124
- entrance_animation_duration_ms: schema.Number({ required: false }),
125
- stroke_color: schema.String({ required: false }),
126
- stroke_width: schema.Number({ required: false }),
127
- position_x: schema.Number({ required: false }),
128
- position_y: schema.Number({ required: false })
129
- }, { required: false });
130
- const captionPart = schema.LoroMap({
131
- id: schema.String(),
132
- initial_duration_ms: schema.Number(),
133
- speech_part_id: schema.String({ required: false }),
134
- text: schema.String(),
135
- start_ms: schema.Number(),
136
- style: captionStyle$1
137
- }, {
138
- required: false,
139
- mergeableMapChildContainers: true
140
- });
141
- const bgmPart = schema.LoroMap({
142
- id: schema.String(),
143
- audio_storage_key: schema.String(),
144
- volume: schema.Number(),
145
- origin_media_id: schema.String()
146
- }, { required: false });
147
- const partValue = schema.LoroMap({
148
- video_clip: videoClipPart,
149
- speech: speechPart,
150
- caption: captionPart,
151
- bgm: bgmPart
152
- });
153
- const videoDocumentMirrorSchema = schema({
154
- meta: schema.LoroMap({
155
- schema_version: schema.String({ required: false }),
156
- draft_id: schema.String({ required: false }),
157
- project_id: schema.String({ required: false }),
158
- owner_id: schema.String({ required: false }),
159
- thumbnail_storage_key: schema.String({ required: false }),
160
- chat_session_id: schema.String({ required: false }),
161
- video_creation_settings: schema.String({ required: false }).transform(jsonTransform()),
162
- version: schema.Number({ required: false })
163
- }),
164
- timeline: schema.LoroMap({ unit_time_ms: schema.Number({ required: false }) }),
165
- tracks: schema.LoroMovableList(track, (t) => t.id),
166
- part_library: schema.LoroMapRecord(partValue)
167
- });
168
- /** Project a discriminated `PartUnion` into the draft's four-optional shape. */
169
- function partUnionToDraft(part) {
170
- if (part.video_clip != null) return { video_clip: omitKind(part.video_clip) };
171
- if (part.speech != null) return { speech: omitKind(part.speech) };
172
- if (part.caption != null) return { caption: omitKind(part.caption) };
173
- return { bgm: omitKind(part.bgm) };
174
- }
175
- /**
176
- * Rebuild a discriminated `PartUnion` from a draft part value: pick the one
177
- * present sub-key, restore its `kind`, and drop the `$cid`. Returns `undefined`
178
- * when no part sub-key is present (an empty/placeholder value).
179
- */
180
- function draftToPartUnion(value) {
181
- if (value == null) return void 0;
182
- const v = value;
183
- if (v.video_clip != null) return { video_clip: withKind(v.video_clip, "video_clip") };
184
- if (v.speech != null) return { speech: withKind(v.speech, "speech") };
185
- if (v.caption != null) return { caption: withKind(v.caption, "caption") };
186
- if (v.bgm != null) return { bgm: withKind(v.bgm, "bgm") };
187
- }
188
- /** Drop `kind` (not stored) and `$cid` from a part payload for the draft. */
189
- function omitKind(part) {
190
- const { kind: _kind, $cid: _cid, ...rest } = part;
191
- return rest;
192
- }
193
- /** Restore `kind` (and drop `$cid`) on a part sub-map read from the draft. */
194
- function withKind(part, kind) {
195
- const { $cid: _cid, ...rest } = part;
196
- return {
197
- ...rest,
198
- kind
199
- };
200
- }
201
- /** Entries of a draft record (e.g. `tracks`) with `$cid` stripped. */
202
- function recordEntries(record) {
203
- if (record == null) return [];
204
- const out = [];
205
- for (const [key, value] of Object.entries(record)) {
206
- if (key === "$cid") continue;
207
- out.push([key, value]);
208
- }
209
- return out;
210
- }
211
- //#endregion
212
- //#region src/document/types.ts
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";
216
- /** The linear speed multiplier of a `speed_shift`, defaulting to 1 (original). */
217
- function speedOf(speedShift) {
218
- const speed = speedShift?.config?.linear?.speed;
219
- return typeof speed === "number" && Number.isFinite(speed) && speed > 0 ? speed : 1;
220
- }
221
- /**
222
- * A video clip's effective timeline duration, derived from authoritative facts
223
- * (RFC 02, `reference/16` §4, reference/17 §5): the trim window
224
- * `play_out - play_in` divided by the speed multiplier, rounded to integer ms.
225
- * `play_in` / `play_out` are optional in the IDL but every write path sets them
226
- * (defaulting to the whole media), and the legacy-ingest projection backfills the
227
- * window from a legacy `duration_ms` — so an authoritative clip always carries a
228
- * trim window and there is no stored `duration_ms` to fall back to. A clip with
229
- * neither bound yields 0. Speeds the clip up (>1× → shorter) or down (<1×).
230
- */
231
- function effectiveVideoClipDurationMs(clip) {
232
- const speed = speedOf(clip.speed_shift);
233
- const sourceMs = (clip.play_out ?? 0) - (clip.play_in ?? 0);
234
- const effective = Math.round(sourceMs / speed);
235
- return Number.isFinite(effective) && effective > 0 ? effective : 0;
236
- }
237
- /**
238
- * The `timeline.unit_time_ms` the read-view states when the document states none.
239
- *
240
- * One frame at 30fps. A document legitimately has no such fact (display
241
- * granularity is the editor's, not the timeline's), but the read-view IDL marks
242
- * the field `@required`, so the projection has to name a value. This one is a
243
- * usable granularity; `0` — which a downstream consumer had been defaulting to —
244
- * is not, and passes any `>= 0` guard on its way to an editor that cannot use it.
245
- */
246
- const DEFAULT_UNIT_TIME_MS = 33.333;
247
- //#endregion
248
- //#region src/timeline-core/types.ts
249
- /** Total document duration when there is no real content (matches FE bgm fallback). */
250
- const TIMELINE_SKELETON_DURATION_MS = 2e4;
251
- /** Empty placeholder clip marker: a video_clip part with no backing media. */
252
- function isEmptyVideoClip(part) {
253
- const clip = part?.video_clip;
254
- if (clip == null) return false;
255
- return clip.origin_media_id == null || clip.origin_media_id === "";
256
- }
257
- /** Clamp a duration to a non-negative integer (NaN/Infinity/negative → 0). */
258
- function safeDurationMs(value) {
259
- const n = Number(value);
260
- if (!Number.isFinite(n) || n <= 0) return 0;
261
- return Math.round(n);
262
- }
263
- function partDurationMs(doc, partId) {
264
- const part = doc.part_library[partId];
265
- if (part?.video_clip != null) return safeDurationMs(effectiveVideoClipDurationMs(part.video_clip));
266
- if (part?.bgm != null) return doc.timeline.duration_ms > 0 ? doc.timeline.duration_ms : TIMELINE_SKELETON_DURATION_MS;
267
- if (part?.speech != null) return safeDurationMs(part.speech.media_duration_ms);
268
- if (part?.caption != null) return safeDurationMs(part.caption.initial_duration_ms);
269
- return 0;
270
- }
271
- //#endregion
272
- //#region src/timeline-core/cascade.ts
273
- /**
274
- * Canonical timeline cascade primitives (ADR 0009).
275
- *
276
- * Single source of truth for "how an edit's connected regions move": main-track
277
- * seamless layout, aggregation position sync + reassignment, total-duration
278
- * recompute, speech-overlap resolution, gap filling. Reconciled per ADR 0009 §4:
279
- *
280
- * - product behavior follows the FE current implementation;
281
- * - all times are integer ms — positions/durations are rounded, never floated;
282
- * - function decomposition follows agent-harness (the Python-derived structure).
283
- *
284
- * Every function mutates the `TimelineDoc` in place (the editor runs them inside
285
- * one immer `transact`, so in-place edits diff correctly).
286
- */
287
- /**
288
- * Lay the main track out head-to-tail from 0, rewriting each item's
289
- * `abs_time_position`. Items whose part is missing from the library are dropped.
290
- */
291
- function arrangeMainTrackSeamlessly(doc) {
292
- const kept = [];
293
- let cursor = 0;
294
- for (const item of doc.main_track) {
295
- if (doc.part_library[item.part_id] == null) continue;
296
- item.abs_time_position = cursor;
297
- cursor += partDurationMs(doc, item.part_id);
298
- kept.push(item);
299
- }
300
- doc.main_track = kept;
301
- }
302
- /**
303
- * Sync attached parts' absolute positions from their host:
304
- * speech.abs = host_video.abs + relative_time_position
305
- * caption.abs = speech.abs + caption.start_ms
306
- */
307
- function syncAggregatedClipsTimePosition(doc) {
308
- const videoByPart = indexByPart(doc.main_track);
309
- const speechByPart = indexByPart(doc.speech_track);
310
- for (const aggregation of doc.aggregations) {
311
- const videoItem = videoByPart.get(aggregation.body_part_id);
312
- if (videoItem == null) continue;
313
- for (const attachment of aggregation.attachments) {
314
- const speechItem = speechByPart.get(attachment.part_id);
315
- if (speechItem == null) continue;
316
- speechItem.abs_time_position = videoItem.abs_time_position + attachment.relative_time_position;
317
- }
318
- }
319
- for (const captionItem of doc.caption_track) {
320
- const captionPart = doc.part_library[captionItem.part_id]?.caption;
321
- if (captionPart == null) continue;
322
- const speechItem = captionPart.speech_part_id == null ? void 0 : speechByPart.get(captionPart.speech_part_id);
323
- if (speechItem == null) continue;
324
- captionItem.abs_time_position = speechItem.abs_time_position + safeDurationMs(captionPart.start_ms);
325
- }
326
- }
327
- /**
328
- * Reassign each speech to the video clip whose time range contains its start,
329
- * rebuilding `aggregations`. A speech before the first clip or after the last
330
- * falls back to the first / last clip respectively (FE: see §4 note — FE falls
331
- * back to last only; we keep the harness two-sided fallback because a speech
332
- * dragged before clip 0 belonging to the last clip is clearly wrong, and the FE
333
- * single-sided rule is an acknowledged rough edge). `relative_time_position` is
334
- * clamped to a non-negative integer.
335
- */
336
- function reassignSpeechesToVideoClipsByTime(doc) {
337
- const ranges = doc.main_track.filter((item) => doc.part_library[item.part_id] != null).map((item) => ({
338
- part_id: item.part_id,
339
- start: item.abs_time_position,
340
- end: item.abs_time_position + partDurationMs(doc, item.part_id)
341
- }));
342
- if (ranges.length === 0) {
343
- doc.aggregations = [];
344
- return;
345
- }
346
- const rebuilt = /* @__PURE__ */ new Map();
347
- for (const speechItem of doc.speech_track) {
348
- const start = speechItem.abs_time_position;
349
- const targetRange = ranges.find((r) => r.start <= start && start < r.end) ?? (start < ranges[0].start ? ranges[0] : ranges[ranges.length - 1]);
350
- const relative = Math.max(0, Math.round(start - targetRange.start));
351
- const targetId = targetRange.part_id;
352
- let aggregation = rebuilt.get(targetId);
353
- if (aggregation == null) {
354
- aggregation = {
355
- body_part_id: targetId,
356
- attachments: []
357
- };
358
- rebuilt.set(targetId, aggregation);
359
- }
360
- aggregation.attachments.push({
361
- part_id: speechItem.part_id,
362
- relative_time_position: relative
363
- });
364
- }
365
- doc.aggregations = ranges.map((r) => rebuilt.get(r.part_id)).filter((a) => a != null);
366
- }
367
- /**
368
- * Recompute `timeline.duration_ms` as the max end (abs + duration) across main,
369
- * speech, and caption lanes (BGM does not extend the timeline).
370
- *
371
- * BGM has no authoritative duration (RFC 02 / `reference/16` §0b): its effective
372
- * length is always the timeline total, so it is not written back here — the
373
- * projection derives it from `timeline.duration_ms` on read (`partDurationMs`
374
- * returns the timeline total for a bgm part). The empty-document 20s skeleton is
375
- * applied at that read step, not stored.
376
- */
377
- function recalculateTimelineDuration(doc) {
378
- const max = Math.max(laneEndMs(doc, doc.main_track), laneEndMs(doc, doc.speech_track), laneEndMs(doc, doc.caption_track));
379
- doc.timeline.duration_ms = max;
380
- }
381
- /**
382
- * Resolve one speech overlap by shifting the overlapping speech's host video
383
- * (and every clip after it) right. Returns true when one overlap was resolved;
384
- * callers loop until it returns false. The compared range is the speech merged
385
- * with its captions: start = min(speech.start, captions.start) (FE behavior),
386
- * end = max(speech.end, captions.end).
387
- */
388
- function resolveSpeechOverlapByShiftingVideos(doc) {
389
- const speeches = doc.speech_track;
390
- for (let i = 1; i < speeches.length; i++) {
391
- const prev = speechWithCaptionsRange(doc, speeches[i - 1]);
392
- const curr = speechWithCaptionsRange(doc, speeches[i]);
393
- if (prev == null || curr == null) continue;
394
- if (curr.start >= prev.end) continue;
395
- const overlapMs = Math.round(prev.end - curr.start);
396
- const hostId = hostVideoOf(doc, speeches[i].part_id);
397
- if (hostId == null) continue;
398
- const fromIndex = doc.main_track.findIndex((item) => item.part_id === hostId);
399
- if (fromIndex < 0) continue;
400
- for (let idx = fromIndex; idx < doc.main_track.length; idx++) doc.main_track[idx].abs_time_position += overlapMs;
401
- syncAggregatedClipsTimePosition(doc);
402
- return true;
403
- }
404
- return false;
405
- }
406
- /**
407
- * Resolve speech overlaps for one cascade pass. Mirrors the authoritative FE
408
- * `ensureNoOverlappingClips`, which is documented to resolve AT MOST ONE overlap
409
- * per cascade and is invoked exactly once at every FE call site — the supported
410
- * ops each produce at most one new overlap. It is NOT a fixpoint loop: shifting a
411
- * host right also moves every speech anchored to it, so two speeches sharing a
412
- * host can never be separated by shifting. Looping to a "fixed point" there does
413
- * not converge — it accumulates the same overlap every iteration and pushes the
414
- * clip arbitrarily far right (e.g. a sped-up clip landing at ~287k ms instead of
415
- * its seamless slot). A single pass matches FE product behavior and terminates.
416
- */
417
- function resolveAllSpeechOverlaps(doc) {
418
- resolveSpeechOverlapByShiftingVideos(doc);
419
- }
420
- /**
421
- * Make the main track gapless by adjusting/merging empty placeholder clips or
422
- * inserting new ones between real clips. Mirrors the harness four-case rule, but
423
- * the merge of two adjacent empty clips keeps the earlier clip (harness Case 4).
424
- * Requires `makeEmptyPart` to mint a placeholder part (the editor supplies an
425
- * id generator).
426
- */
427
- function fillMainTrackTimeGaps(doc, makeEmptyPart) {
428
- const items = doc.main_track;
429
- let i = 0;
430
- while (i < items.length) {
431
- const current = items[i];
432
- const currentPart = doc.part_library[current.part_id];
433
- if (currentPart == null) {
434
- i += 1;
435
- continue;
436
- }
437
- const currentEnd = current.abs_time_position + partDurationMs(doc, current.part_id);
438
- if (i + 1 >= items.length) break;
439
- const next = items[i + 1];
440
- const nextPart = doc.part_library[next.part_id];
441
- if (nextPart == null) {
442
- i += 1;
443
- continue;
444
- }
445
- const gap = next.abs_time_position - currentEnd;
446
- if (gap > 0) {
447
- if (isEmptyVideoClip(currentPart)) {
448
- extendEmptyClip(currentPart.video_clip, gap);
449
- continue;
450
- }
451
- if (isEmptyVideoClip(nextPart)) {
452
- next.abs_time_position -= gap;
453
- extendEmptyClip(nextPart.video_clip, gap);
454
- i += 1;
455
- continue;
456
- }
457
- const { partId } = makeEmptyPart(gap);
458
- doc.part_library[partId] = { video_clip: {
459
- id: partId,
460
- kind: "video_clip",
461
- play_in: 0,
462
- play_out: gap,
463
- volume: 0,
464
- origin_media_id: ""
465
- } };
466
- items.splice(i + 1, 0, {
467
- part_id: partId,
468
- time_position: { mode: "sequential" },
469
- abs_time_position: currentEnd
470
- });
471
- i += 1;
472
- continue;
473
- }
474
- if (gap === 0 && isEmptyVideoClip(currentPart) && isEmptyVideoClip(nextPart)) {
475
- extendEmptyClip(currentPart.video_clip, partDurationMs(doc, next.part_id));
476
- items.splice(i + 1, 1);
477
- delete doc.part_library[next.part_id];
478
- continue;
479
- }
480
- i += 1;
481
- }
482
- }
483
- function indexByPart(items) {
484
- const map = /* @__PURE__ */ new Map();
485
- for (const item of items) map.set(item.part_id, item);
486
- return map;
487
- }
488
- function laneEndMs(doc, items) {
489
- let max = 0;
490
- for (const item of items) {
491
- if (doc.part_library[item.part_id] == null) continue;
492
- const end = item.abs_time_position + partDurationMs(doc, item.part_id);
493
- if (end > max) max = end;
494
- }
495
- return max;
496
- }
497
- function extendEmptyClip(clip, byMs) {
498
- clip.play_out = safeDurationMs(clip.play_out) + byMs;
499
- }
500
- /** speech merged with its captions: start = min, end = max. */
501
- function speechWithCaptionsRange(doc, speechItem) {
502
- const speech = doc.part_library[speechItem.part_id]?.speech;
503
- if (speech == null) return null;
504
- let start = speechItem.abs_time_position;
505
- let end = speechItem.abs_time_position + safeDurationMs(speech.media_duration_ms);
506
- for (const captionId of captionIdsOf(speech)) {
507
- const captionItem = doc.caption_track.find((item) => item.part_id === captionId);
508
- const captionPart = doc.part_library[captionId]?.caption;
509
- if (captionItem == null || captionPart == null) continue;
510
- const cStart = captionItem.abs_time_position;
511
- const cEnd = captionItem.abs_time_position + safeDurationMs(captionPart.initial_duration_ms);
512
- if (cStart < start) start = cStart;
513
- if (cEnd > end) end = cEnd;
514
- }
515
- return {
516
- start,
517
- end
518
- };
519
- }
520
- function captionIdsOf(speech) {
521
- return (speech.caption_ids ?? []).filter((id) => id != null);
522
- }
523
- function hostVideoOf(doc, speechPartId) {
524
- for (const aggregation of doc.aggregations) if (aggregation.attachments.some((att) => att.part_id === speechPartId)) return aggregation.body_part_id;
525
- return null;
526
- }
527
- //#endregion
528
- //#region src/timeline-core/entrypoints.ts
529
- /**
530
- * The full solve pipeline: arrange → sync → reassign → resolve-overlap →
531
- * fill-gaps → recalc. The single cascade the read-side projection runs; ops
532
- * never call it (they write only facts — RFC 02 §7/§10).
533
- */
534
- function cascadeAfterVideoClipChanges(doc, makeEmptyPart) {
535
- arrangeMainTrackSeamlessly(doc);
536
- syncAggregatedClipsTimePosition(doc);
537
- reassignSpeechesToVideoClipsByTime(doc);
538
- resolveAllSpeechOverlaps(doc);
539
- fillMainTrackTimeGaps(doc, makeEmptyPart);
540
- recalculateTimelineDuration(doc);
541
- }
542
- //#endregion
543
- //#region src/timeline-core/bridge.ts
544
- /**
545
- * Solve a `VideoDocument` (authoritative, position-only) into its derived
546
- * read-view: absolute time per item, `part_aggregations`, and total duration.
547
- * This is the read side of the single-directional flow — never written back.
548
- */
549
- function solveVideoDocument(document) {
550
- const doc = videoDocumentToTimelineDoc(document);
551
- let counter = 0;
552
- const derivedFillerPartIds = /* @__PURE__ */ new Set();
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, () => {
557
- const partId = `empty_${counter++}`;
558
- derivedFillerPartIds.add(partId);
559
- return { partId };
560
- });
561
- const absByPartId = /* @__PURE__ */ new Map();
562
- for (const item of doc.main_track) absByPartId.set(item.part_id, item.abs_time_position);
563
- for (const item of doc.speech_track) absByPartId.set(item.part_id, item.abs_time_position);
564
- for (const item of doc.caption_track) absByPartId.set(item.part_id, item.abs_time_position);
565
- for (const item of doc.bgm_track) absByPartId.set(item.part_id, item.abs_time_position);
566
- return {
567
- absByPartId,
568
- aggregations: doc.aggregations.map((aggregation) => ({
569
- body_part_id: aggregation.body_part_id,
570
- attachments: aggregation.attachments.map((a) => ({
571
- part_id: a.part_id,
572
- relative_time_position: a.relative_time_position
573
- }))
574
- })),
575
- durationMs: doc.timeline.duration_ms,
576
- partLibrary: doc.part_library,
577
- derivedFillerPartIds
578
- };
579
- }
580
- function videoDocumentToTimelineDoc(document) {
581
- const partLibrary = {};
582
- for (const [partId, part] of Object.entries(document.part_library ?? {})) partLibrary[partId] = part;
583
- const laneItems = (track) => (track?.items ?? []).map((item) => ({
584
- part_id: item.part_id,
585
- time_position: item.time_position,
586
- abs_time_position: seedAbs(item.time_position, item.fallback_abs_ms),
587
- fallback_abs_ms: item.fallback_abs_ms
588
- }));
589
- const tracks = document.tracks ?? [];
590
- let mainItems = [];
591
- let speechItems = [];
592
- let bgmItems = [];
593
- let captionItems = [];
594
- for (const t of tracks) switch (t.parts_kind) {
595
- case "video_clip":
596
- mainItems = mainItems.concat(laneItems(t));
597
- break;
598
- case "speech":
599
- speechItems = speechItems.concat(laneItems(t));
600
- break;
601
- case "bgm":
602
- bgmItems = bgmItems.concat(laneItems(t));
603
- break;
604
- case "caption":
605
- captionItems = captionItems.concat(laneItems(t));
606
- break;
607
- }
608
- return {
609
- main_track: mainItems,
610
- speech_track: speechItems,
611
- caption_track: captionItems,
612
- bgm_track: bgmItems,
613
- part_library: partLibrary,
614
- aggregations: seedAggregations(mainItems, speechItems),
615
- timeline: {
616
- duration_ms: 0,
617
- unit_time_ms: document.timeline?.unit_time_ms ?? 0
618
- }
619
- };
620
- }
621
- /**
622
- * Seed an item's solve-variable `abs_time_position` from its `time_position` (and
623
- * the `fallback_abs_ms` snapshot carried beside it): `absolute` → `offsetMs`;
624
- * `anchored` → `fallbackAbsMs` (last solved position, also the orphan-recovery
625
- * anchor when the host is gone); `sequential` → 0 (the cascade lays the main
626
- * track out head-to-tail). The cascade then resolves anchored items against their
627
- * host, so this seed only needs to put each item somewhere plausible for the
628
- * first reassign/overlap pass.
629
- */
630
- function seedAbs(position, fallbackAbsMs) {
631
- switch (position.mode) {
632
- case "absolute": return Math.round(position.offsetMs);
633
- case "anchored": return Math.round(fallbackAbsMs ?? 0);
634
- case "sequential": return 0;
635
- }
636
- }
637
- /**
638
- * Build the cascade `aggregations` parent-map from anchored items: an anchored
639
- * item's `anchorPartId` is the host's `part_id` (= `body_part_id`), `offsetMs`
640
- * is `relative_time_position`. Only main-track (video) hosts form aggregations;
641
- * caption→speech relations are recomputed by the cascade from `caption.start_ms`.
642
- */
643
- function seedAggregations(mainItems, speechItems) {
644
- const mainPartIds = new Set(mainItems.map((item) => item.part_id));
645
- const byHost = /* @__PURE__ */ new Map();
646
- const order = [];
647
- for (const speech of speechItems) {
648
- if (speech.time_position.mode !== "anchored") continue;
649
- const host = speech.time_position.anchorPartId;
650
- if (!mainPartIds.has(host)) continue;
651
- let aggregation = byHost.get(host);
652
- if (aggregation == null) {
653
- aggregation = {
654
- body_part_id: host,
655
- attachments: []
656
- };
657
- byHost.set(host, aggregation);
658
- order.push(host);
659
- }
660
- aggregation.attachments.push({
661
- part_id: speech.part_id,
662
- relative_time_position: speech.time_position.offsetMs
663
- });
664
- }
665
- return order.map((host) => byHost.get(host));
666
- }
667
- /**
668
- * The four lanes in top-to-bottom stack order — the order a `tracks` list holds
669
- * them in (see {@link laneRank}).
670
- *
671
- * Exported so a document can be seeded with all four lanes up front. That seed is
672
- * not cosmetic: {@link ensureLaneTrack} is find-then-mint over a `LoroMovableList`,
673
- * so two concurrent writers that each mint the same absent lane both keep their
674
- * row, and the merged document holds two tracks for one lane. For the main lane
675
- * that is fatal — `videoDocumentSchema` allows at most one `video_clip` track, so
676
- * the merged document stops being projectable at all, symmetrically on both
677
- * replicas. Pre-seeding every lane makes `ensureLaneTrack` always take its find
678
- * branch, which removes the race by construction rather than by detection.
679
- *
680
- * What closes the race is that a track with the lane's `parts_kind` EXISTS — the
681
- * lookup is by kind, not by id. So a seed is only safe if it covers every lane:
682
- * a partial seed leaves the uncovered lanes exactly as exposed as before.
683
- */
684
- const LANE_KINDS_IN_STACK_ORDER = [
685
- "caption",
686
- "video_clip",
687
- "speech",
688
- "bgm"
689
- ];
690
- /**
691
- * The conventional track id for a lane (`main_track`, `<kind>_track`). Shared by
692
- * the up-front seed and {@link ensureLaneTrack}'s lazy mint so a document's lane
693
- * ids do not depend on which of the two created the track.
694
- *
695
- * Ids are cosmetic to the merge itself — lane lookup goes by `parts_kind`, so
696
- * drifting them apart would not reopen the concurrent-mint race. They matter to
697
- * readers that address a lane by id (the FE editor's panes, fixtures), which is
698
- * why there is one convention rather than two.
699
- */
700
- function laneTrackId(kind) {
701
- return kind === "video_clip" ? "main_track" : `${kind}_track`;
702
- }
703
- /**
704
- * Lane-stacking rank for the single `tracks` list (reference/17 §4): caption
705
- * (above) sits before the video_clip main track, which sits before speech / bgm
706
- * (below). The `tracks` array is kept in this top-to-bottom order so a freshly
707
- * minted track lands in the right place and the projection's three-pane rebuild
708
- * stays deterministic.
709
- */
710
- function laneRank(kind) {
711
- switch (kind) {
712
- case "caption": return 0;
713
- case "video_clip": return 1;
714
- case "speech":
715
- case "bgm": return 2;
716
- default: return 3;
717
- }
718
- }
719
- /**
720
- * Insert a freshly-minted track into `tracks` at the position that keeps the
721
- * lane-stacking order (caption → main → speech/bgm). Inserts before the first
722
- * track whose rank is strictly greater, so same-rank tracks keep insertion order.
723
- */
724
- function insertTrackByLaneOrder(tracks, track) {
725
- const rank = laneRank(track.parts_kind);
726
- const at = tracks.findIndex((t) => laneRank(t?.parts_kind) > rank);
727
- if (at < 0) tracks.push(track);
728
- else tracks.splice(at, 0, track);
729
- }
730
- /**
731
- * Locate a lane's track row in the single `tracks` list by kind, minting an empty
732
- * row in lane-stacking order if absent (reference/17 §4: lane = `parts_kind`).
733
- * Ops use this to write authoritative items onto the right lane. The track id
734
- * comes from {@link laneTrackId}, shared with the up-front seed.
735
- *
736
- * The mint branch is a concurrency hazard, not a convenience: see
737
- * {@link LANE_KINDS_IN_STACK_ORDER}. A document seeded with all four lanes never
738
- * reaches it.
739
- */
740
- function ensureLaneTrack(draft, kind) {
741
- draft.tracks ??= [];
742
- let track = draft.tracks.find((t) => t?.parts_kind === kind);
743
- if (track == null) {
744
- track = {
745
- id: laneTrackId(kind),
746
- parts_kind: kind,
747
- is_hidden: void 0,
748
- items: []
749
- };
750
- insertTrackByLaneOrder(draft.tracks, track);
751
- }
752
- track.items ??= [];
753
- return track;
754
- }
755
- /** Find a secondary lane's track row without minting it. */
756
- function findLaneTrack(draft, kind) {
757
- return (draft.tracks ?? []).find((t) => t?.parts_kind === kind);
758
- }
759
- //#endregion
760
- //#region src/document/zod-schema.ts
761
- const partKindSchema = z.enum([
762
- "video_clip",
763
- "speech",
764
- "caption",
765
- "bgm"
766
- ]);
767
- const finiteNumber = z.number().finite();
768
- const trackItemTimePositionSchema = z.discriminatedUnion("mode", [
769
- z.object({ mode: z.literal("sequential") }).passthrough(),
770
- z.object({
771
- mode: z.literal("anchored"),
772
- anchorPartId: z.string(),
773
- offsetMs: finiteNumber
774
- }).passthrough(),
775
- z.object({
776
- mode: z.literal("absolute"),
777
- offsetMs: finiteNumber
778
- }).passthrough()
779
- ]);
780
- const trackItemSchema = z.object({
781
- part_id: z.string(),
782
- time_position: trackItemTimePositionSchema,
783
- fallback_abs_ms: finiteNumber.optional()
784
- }).passthrough();
785
- const trackSchema = z.object({
786
- id: z.string().optional(),
787
- parts_kind: partKindSchema.optional(),
788
- is_hidden: z.boolean().optional(),
789
- items: z.array(trackItemSchema).optional()
790
- }).passthrough();
791
- const speedShiftSchema = z.object({
792
- category: z.string().optional(),
793
- mode: z.string().optional(),
794
- config: z.object({ linear: z.object({ speed: finiteNumber.optional() }).passthrough().optional() }).passthrough().optional()
795
- }).passthrough();
796
- const videoClipPartSchema = z.object({
797
- id: z.string().optional(),
798
- kind: z.literal("video_clip"),
799
- duration_ms: finiteNumber.optional(),
800
- play_in: finiteNumber,
801
- play_out: finiteNumber,
802
- volume: finiteNumber,
803
- origin_media_id: z.string(),
804
- speed_shift: speedShiftSchema.optional()
805
- }).passthrough();
806
- const speechPartSchema = z.object({
807
- id: z.string().optional(),
808
- kind: z.literal("speech"),
809
- media_duration_ms: finiteNumber,
810
- duration_ms: finiteNumber.optional(),
811
- audio_script: z.string(),
812
- volume: finiteNumber,
813
- audio_storage_key: z.string(),
814
- origin_speech_id: z.string(),
815
- voice: z.unknown(),
816
- caption_ids: z.array(z.string())
817
- }).passthrough();
818
- const captionPartSchema = z.object({
819
- id: z.string().optional(),
820
- kind: z.literal("caption"),
821
- initial_duration_ms: finiteNumber,
822
- speech_part_id: z.string().optional(),
823
- text: z.string(),
824
- start_ms: finiteNumber,
825
- style: z.object({
826
- font_id: z.string().optional(),
827
- font_size: finiteNumber.optional(),
828
- font_color: z.string().optional(),
829
- font_weight: finiteNumber.optional(),
830
- entrance_animation: z.string().optional(),
831
- entrance_animation_duration_ms: finiteNumber.optional(),
832
- stroke_color: z.string().optional(),
833
- stroke_width: finiteNumber.optional(),
834
- position_x: finiteNumber.optional(),
835
- position_y: finiteNumber.optional()
836
- }).passthrough().optional()
837
- }).passthrough();
838
- const bgmPartSchema = z.object({
839
- id: z.string().optional(),
840
- kind: z.literal("bgm"),
841
- audio_storage_key: z.string(),
842
- volume: finiteNumber,
843
- origin_media_id: z.string()
844
- }).passthrough();
845
- const partUnionSchema = z.union([
846
- z.object({ video_clip: videoClipPartSchema }).passthrough(),
847
- z.object({ speech: speechPartSchema }).passthrough(),
848
- z.object({ caption: captionPartSchema }).passthrough(),
849
- z.object({ bgm: bgmPartSchema }).passthrough()
850
- ]).refine((part) => {
851
- const p = part;
852
- return [
853
- "video_clip",
854
- "speech",
855
- "caption",
856
- "bgm"
857
- ].filter((k) => p[k] != null).length === 1;
858
- }, { message: "A part_library value must have exactly one of video_clip / speech / caption / bgm" });
859
- const videoDocumentMetaSchema = z.object({
860
- schema_version: z.enum([VIDEO_DOCUMENT_SCHEMA_VERSION, ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION]),
861
- draft_id: z.string().optional(),
862
- project_id: z.string().optional(),
863
- owner_id: z.string().optional(),
864
- thumbnail_storage_key: z.string().optional(),
865
- chat_session_id: z.string().optional(),
866
- video_creation_settings: z.unknown().optional(),
867
- version: finiteNumber.optional()
868
- }).passthrough();
869
- const videoDocumentSchema = z.object({
870
- meta: videoDocumentMetaSchema,
871
- timeline: z.object({ unit_time_ms: finiteNumber.optional() }).passthrough().optional(),
872
- tracks: z.array(trackSchema).optional(),
873
- part_library: z.record(z.string(), partUnionSchema).optional()
874
- }).passthrough().refine((doc) => (doc.tracks ?? []).filter((t) => t.parts_kind === "video_clip").length <= 1, {
875
- message: "A VideoDocument may have at most one video_clip (main) track",
876
- path: ["tracks"]
877
- });
878
- //#endregion
879
- //#region src/document/validation.ts
880
- /**
881
- * Business-level schema guard for `VideoDocument` (RFC 03 §9). It is the gate
882
- * that decides whether an arbitrary value is a *legal* `VideoDocument` before it
883
- * is written into Loro — distinct from two neighbours:
884
- *
885
- * - `zod-schema.ts` (`videoDocumentSchema`) checks structure/shape only; this
886
- * file layers the business rules on top (part-kind match, reference integrity,
887
- * `position.anchorPartId` targets, value ranges, identity uniqueness).
888
- * - loro-mirror's own `validateSchema` (run on every `setState`) only checks the
889
- * storage structure, never these business invariants.
890
- *
891
- * Projection (`projection.ts`) calls `assertValidVideoDocument` before solving a
892
- * document into the legacy `VideoDraft`.
893
- */
894
- var VideoDocumentValidationError = class extends Error {
895
- issues;
896
- constructor(issues) {
897
- super(`Invalid VideoDocument: ${issues.map((issue) => issue.message).join("; ")}`);
898
- this.issues = issues;
899
- this.name = "VideoDocumentValidationError";
900
- }
901
- };
902
- /**
903
- * Assert the document is legal enough to project. Only `error`-severity issues
904
- * hard-reject; `recoverable` ones (dangling anchor / orphan, RFC 02 §11.1) are
905
- * left for the projection to heal on read and do NOT throw. Use
906
- * `validateVideoDocument` directly to inspect recoverable issues too.
907
- */
908
- function assertValidVideoDocument(document) {
909
- const blocking = validateVideoDocument(document).filter((issue) => (issue.severity ?? "error") === "error");
910
- if (blocking.length > 0) throw new VideoDocumentValidationError(blocking);
911
- }
912
- function validateVideoDocument(document) {
913
- const parsed = videoDocumentSchema.safeParse(document);
914
- if (!parsed.success) return parsed.error.issues.map((issue) => ({
915
- code: "invalid_schema",
916
- path: zodPath(issue.path),
917
- message: issue.message
918
- }));
919
- const doc = parsed.data;
920
- const issues = [];
921
- const partLibrary = doc.part_library ?? {};
922
- for (const [partId, part] of Object.entries(partLibrary)) {
923
- if (!partUnionSchema.safeParse(part).success) {
924
- issues.push({
925
- code: "unknown_part_kind",
926
- path: `/part_library/${partId}`,
927
- message: `Part "${partId}" is not a supported part union`
928
- });
929
- continue;
930
- }
931
- const wrapperKind = getPartUnionKind(part);
932
- const innerKind = getPartKind(part);
933
- if (wrapperKind != null && innerKind != null && wrapperKind !== innerKind) issues.push({
934
- code: "part_kind_mismatch",
935
- path: `/part_library/${partId}`,
936
- message: `Part "${partId}" wrapper kind "${wrapperKind}" does not match inner kind "${innerKind}"`
937
- });
938
- validatePartValues(partId, part, issues);
939
- }
940
- for (const [idx, track] of (doc.tracks ?? []).entries()) validateTrack(track, `tracks/${idx}`, partLibrary, issues, track.parts_kind === "video_clip");
941
- validateLaneTrackUniqueness(doc, issues);
942
- validateSpeechCaptionReferences(partLibrary, issues);
943
- validatePositionReferences(doc, partLibrary, issues);
944
- return issues;
945
- }
946
- /**
947
- * Report a duplicated lane: two tracks sharing the same `id`, or two secondary
948
- * tracks of the same `parts_kind`.
949
- *
950
- * Found by the Phase 6 M0/S2 concurrency probe. `ensureLaneTrack` locates a lane
951
- * by `parts_kind` and mints `<kind>_track` when absent, so two replicas that both
952
- * start without (say) a caption track each create one; `tracks` is a
953
- * `LoroMovableList`, so the merge keeps BOTH. The lane is then split across two
954
- * same-id tracks, and the projection's `find`-by-kind returns only one of them —
955
- * so an item can be authoritative yet invisible in the pane a reader looks at.
956
- *
957
- * Flagged **recoverable**, not `error`, for two reasons. It is a reachable
958
- * outcome of ordinary concurrent editing, and hard-rejecting would make an
959
- * unavoidable merge result un-projectable — the same argument §11.1 makes for
960
- * orphans. And the projection already tolerates it: `solveVideoDocument`
961
- * concatenates every track of a kind, so the cascade sees all items regardless.
962
- * The defect is a *reader-visible* split, so the right response is to surface it
963
- * (loudly, in a channel someone reads), not to block the document.
964
- *
965
- * The main track is exempt from the kind check: `parts_kind === 'video_clip'` may
966
- * legitimately appear on several tracks (the projection treats only the first as
967
- * the main pane), so only its `id` collision is reported.
968
- */
969
- function validateLaneTrackUniqueness(doc, issues) {
970
- const tracks = doc.tracks ?? [];
971
- const seenIds = /* @__PURE__ */ new Set();
972
- const seenSecondaryKinds = /* @__PURE__ */ new Set();
973
- for (const [idx, track] of tracks.entries()) {
974
- const id = track?.id;
975
- if (id != null && id !== "") if (seenIds.has(id)) issues.push({
976
- code: "duplicate_lane_track",
977
- path: `/tracks/${idx}/id`,
978
- message: `Track "tracks/${idx}" reuses track id "${id}"`,
979
- severity: "recoverable"
980
- });
981
- else seenIds.add(id);
982
- const kind = track?.parts_kind;
983
- if (kind == null || kind === "video_clip") continue;
984
- if (seenSecondaryKinds.has(kind)) issues.push({
985
- code: "duplicate_lane_track",
986
- path: `/tracks/${idx}/parts_kind`,
987
- message: `Lane "${kind}" is split across more than one track (at "tracks/${idx}")`,
988
- severity: "recoverable"
989
- });
990
- else seenSecondaryKinds.add(kind);
991
- }
992
- }
993
- /**
994
- * An `anchored` item whose `anchorPartId` no longer exists in the library is the
995
- * orphan condition (RFC 02 §4/§11.1) — e.g. a speech whose host video, or a
996
- * caption whose host speech, was concurrently deleted while this item was being
997
- * reparented in. This is flagged as a **recoverable** issue, NOT a hard error:
998
- * the projection heals it on read (the item's `fallback_abs_ms` snapshot seeds
999
- * its absolute position, then the cascade reassigns it to the nearest available
1000
- * host, or it stays put as an absolute item). Reporting it here keeps the orphan
1001
- * observable without blocking projection — the opposite of a hard reject, which
1002
- * would make an inevitable concurrent-delete outcome un-projectable (§11.1).
1003
- */
1004
- function validatePositionReferences(doc, partLibrary, issues) {
1005
- const tracks = (doc.tracks ?? []).map((track, idx) => ({
1006
- track,
1007
- path: `tracks/${idx}`
1008
- }));
1009
- for (const { track, path } of tracks) for (const [idx, item] of (track?.items ?? []).entries()) {
1010
- if (item.time_position.mode !== "anchored") continue;
1011
- if (partLibrary[item.time_position.anchorPartId] == null) issues.push({
1012
- code: "invalid_position_anchor",
1013
- path: `/${path}/items/${idx}/time_position/anchorPartId`,
1014
- message: `Track item "${path}/${idx}" anchors to missing part "${item.time_position.anchorPartId}"`,
1015
- severity: "recoverable"
1016
- });
1017
- }
1018
- }
1019
- function validatePartValues(partId, part, issues) {
1020
- const payload = part.video_clip ?? part.speech ?? part.caption ?? part.bgm ?? void 0;
1021
- if (payload == null) return;
1022
- if ("duration_ms" in payload && payload.duration_ms != null && payload.duration_ms < 0) issues.push({
1023
- code: "invalid_part_value",
1024
- path: `/part_library/${partId}/duration_ms`,
1025
- message: `Part "${partId}" has negative duration_ms`
1026
- });
1027
- if ("volume" in payload && payload.volume != null && !Number.isFinite(payload.volume)) issues.push({
1028
- code: "invalid_part_value",
1029
- path: `/part_library/${partId}/volume`,
1030
- message: `Part "${partId}" has non-finite volume`
1031
- });
1032
- if (part.video_clip != null) {
1033
- const { play_in, play_out } = part.video_clip;
1034
- if (play_in != null && play_in < 0) issues.push({
1035
- code: "invalid_part_value",
1036
- path: `/part_library/${partId}/play_in`,
1037
- message: `Video clip "${partId}" has negative play_in`
1038
- });
1039
- if (play_out != null && play_in != null && play_out < play_in) issues.push({
1040
- code: "invalid_part_value",
1041
- path: `/part_library/${partId}/play_out`,
1042
- message: `Video clip "${partId}" has play_out before play_in`
1043
- });
1044
- }
1045
- }
1046
- function validateTrack(track, path, partLibrary, issues, isMainTrack) {
1047
- if (track == null) return;
1048
- const seenPartIds = /* @__PURE__ */ new Set();
1049
- for (const [idx, item] of (track.items ?? []).entries()) {
1050
- const partId = item.part_id;
1051
- if (partId != null && partId !== "") if (seenPartIds.has(partId)) issues.push({
1052
- code: "duplicate_track_item_identity",
1053
- path: `/${path}/items/${idx}/part_id`,
1054
- message: `Track item "${path}/${idx}" reuses part_id "${partId}"`
1055
- });
1056
- else seenPartIds.add(partId);
1057
- if (partId == null || partId === "") {
1058
- issues.push({
1059
- code: "missing_part_reference",
1060
- path: `/${path}/items/${idx}/part_id`,
1061
- message: `Track item "${path}/${idx}" has no part_id`
1062
- });
1063
- continue;
1064
- }
1065
- const part = partLibrary[partId];
1066
- if (part == null) {
1067
- issues.push({
1068
- code: "missing_part_reference",
1069
- path: `/${path}/items/${idx}/part_id`,
1070
- message: `Track item "${path}/${idx}" references missing part "${partId}"`
1071
- });
1072
- continue;
1073
- }
1074
- const partKind = getPartKind(part);
1075
- if (isMainTrack && partKind !== "video_clip") issues.push({
1076
- code: "main_track_non_video_clip",
1077
- path: `/${path}/items/${idx}/part_id`,
1078
- message: `Main track item "${path}/${idx}" references non-video part "${partId}"`
1079
- });
1080
- if (track.parts_kind != null && partKind != null && track.parts_kind !== partKind) issues.push({
1081
- code: "track_kind_mismatch",
1082
- path: `/${path}/items/${idx}/part_id`,
1083
- message: `Track "${path}" expects "${track.parts_kind}" but part "${partId}" is "${partKind}"`
1084
- });
1085
- }
1086
- }
1087
- /**
1088
- * Check speech↔caption references. A reference to a *missing* part (speech's
1089
- * caption_ids → deleted caption, or caption's speech_part_id → deleted speech)
1090
- * is the orphan condition (RFC 02 §11.1): reported as **recoverable** so the
1091
- * projection can heal it on read, not block. A *back-pointer mismatch* (caption
1092
- * exists but does not point back) is data corruption, kept as a hard error.
1093
- */
1094
- function validateSpeechCaptionReferences(partLibrary, issues) {
1095
- for (const [partId, part] of Object.entries(partLibrary)) {
1096
- if (part.speech != null) for (const captionId of part.speech.caption_ids ?? []) {
1097
- const caption = partLibrary[captionId]?.caption;
1098
- if (caption == null) {
1099
- issues.push({
1100
- code: "invalid_speech_caption_reference",
1101
- path: `/part_library/${partId}/speech/caption_ids`,
1102
- message: `Speech "${partId}" references missing caption "${captionId}"`,
1103
- severity: "recoverable"
1104
- });
1105
- continue;
1106
- }
1107
- if (caption.speech_part_id !== partId) issues.push({
1108
- code: "invalid_speech_caption_reference",
1109
- path: `/part_library/${captionId}/caption/speech_part_id`,
1110
- message: `Caption "${captionId}" does not point back to speech "${partId}"`
1111
- });
1112
- }
1113
- if (part.caption != null) {
1114
- const speechId = part.caption.speech_part_id;
1115
- if (speechId != null && partLibrary[speechId]?.speech == null) issues.push({
1116
- code: "invalid_speech_caption_reference",
1117
- path: `/part_library/${partId}/caption/speech_part_id`,
1118
- message: `Caption "${partId}" references missing speech "${speechId}"`,
1119
- severity: "recoverable"
1120
- });
1121
- }
1122
- }
1123
- }
1124
- function getPartUnionKind(part) {
1125
- if (part.video_clip != null) return "video_clip";
1126
- if (part.speech != null) return "speech";
1127
- if (part.caption != null) return "caption";
1128
- if (part.bgm != null) return "bgm";
1129
- }
1130
- function getPartKind(part) {
1131
- return part.video_clip?.kind ?? part.speech?.kind ?? part.caption?.kind ?? part.bgm?.kind;
1132
- }
1133
- function zodPath(path) {
1134
- if (path.length === 0) return "/";
1135
- return `/${path.map(String).join("/")}`;
1136
- }
1137
- //#endregion
1138
- //#region src/document/projection.ts
1139
- /**
1140
- * Projection between the authoritative `VideoDocument` and the legacy
1141
- * `VideoDraft` read-view (RFC 02 §5/§7). Both directions live here:
1142
- *
1143
- * - `toVideoDocument` ingests a `VideoDraft`, deriving each item's `position`
1144
- * from the legacy absolute layout + aggregations; derived values (abs time,
1145
- * `part_aggregations`, total duration) are dropped.
1146
- * - `fromVideoDocument` solves a `VideoDocument` back into a `VideoDraft` via the
1147
- * timeline-core cascade, re-deriving exactly those values.
1148
- *
1149
- * Business validation lives in `validation.ts`; `fromVideoDocument` asserts a
1150
- * valid document before solving.
1151
- */
1152
- /**
1153
- * Ingest the legacy `VideoDraft` into the authoritative `VideoDocument`,
1154
- * deriving each item's `time_position` from the legacy absolute layout +
1155
- * aggregations (RFC 02 §4/§5). Absolute time, `part_aggregations`, and total
1156
- * duration are dropped — they are re-derived by the projection.
1157
- */
1158
- function toVideoDocument(draft) {
1159
- const speechHost = buildSpeechHostMap(draft.part_aggregations);
1160
- const partLibrary = draft.part_library ?? {};
1161
- const toTrack = (track, isMain) => {
1162
- if (track == null) return void 0;
1163
- return {
1164
- id: track.id,
1165
- parts_kind: track.parts_kind,
1166
- is_hidden: track.is_hidden,
1167
- items: (track.items ?? []).map((item) => deriveItem(item, isMain, speechHost, partLibrary))
1168
- };
1169
- };
1170
- const mainTrack = toTrack(draft.main_track, true);
1171
- const tracks = [
1172
- ...(draft.above_main_tracks ?? []).map((t) => toTrack(t, false)),
1173
- ...mainTrack == null ? [] : [mainTrack],
1174
- ...(draft.below_main_tracks ?? []).map((t) => toTrack(t, false))
1175
- ];
1176
- return {
1177
- meta: {
1178
- schema_version: VIDEO_DOCUMENT_SCHEMA_VERSION,
1179
- draft_id: draft.id,
1180
- project_id: draft.project_id,
1181
- owner_id: draft.owner_id,
1182
- thumbnail_storage_key: draft.thumbnail_storage_key,
1183
- chat_session_id: draft.chat_session_id,
1184
- video_creation_settings: clone(draft.video_creation_settings),
1185
- version: draft.version
1186
- },
1187
- timeline: draft.timeline == null ? void 0 : { unit_time_ms: draft.timeline.unit_time_ms },
1188
- tracks,
1189
- part_library: toAuthoritativePartLibrary(draft.part_library)
1190
- };
1191
- }
1192
- /**
1193
- * Map the legacy `VideoDraft` part library into the authoritative shape
1194
- * (reference/17 §5): the authoritative parts store no derived part-level
1195
- * duration, so the read-view `duration_ms` is dropped from video / speech / bgm.
1196
- * For a caption the legacy `duration_ms` is the generation-time length, stored
1197
- * authoritatively as `initial_duration_ms`.
1198
- */
1199
- function toAuthoritativePartLibrary(partLibrary) {
1200
- if (partLibrary == null) return void 0;
1201
- const out = {};
1202
- for (const [partId, part] of Object.entries(partLibrary)) {
1203
- if (typeof part !== "object" || part == null) continue;
1204
- if (part.video_clip != null) out[partId] = { video_clip: withTrimWindowFromDuration(part.video_clip) };
1205
- else if (part.speech != null) {
1206
- const { duration_ms, rest } = splitDurationMs(part.speech);
1207
- out[partId] = { speech: {
1208
- ...rest,
1209
- media_duration_ms: rest.media_duration_ms ?? duration_ms
1210
- } };
1211
- } else if (part.caption != null) {
1212
- const { duration_ms, rest } = splitDurationMs(part.caption);
1213
- out[partId] = { caption: {
1214
- ...rest,
1215
- initial_duration_ms: rest.initial_duration_ms ?? duration_ms
1216
- } };
1217
- } else if (part.bgm != null) out[partId] = { bgm: omitDurationMs(part.bgm) };
1218
- }
1219
- return out;
1220
- }
1221
- /**
1222
- * Build a speech-host map from a part_aggregations list. Used by
1223
- * `toVideoDocument` (legacy VideoDraft → VideoDocument) to recover each speech's
1224
- * host video clip and relative offset. Aggregation items with null ids are
1225
- * skipped (malformed input tolerance).
1226
- */
1227
- function buildSpeechHostMap(aggregations) {
1228
- const map = /* @__PURE__ */ new Map();
1229
- for (const aggregation of aggregations ?? []) {
1230
- const host = aggregation.body_part_id;
1231
- if (host == null) continue;
1232
- for (const attachment of aggregation.attachments ?? []) {
1233
- if (attachment.part_id == null) continue;
1234
- map.set(attachment.part_id, {
1235
- hostPartId: host,
1236
- offsetMs: Math.round(attachment.relative_time_position ?? 0)
1237
- });
1238
- }
1239
- }
1240
- return map;
1241
- }
1242
- /**
1243
- * Derive `time_position` (and `fallbackAbsMs` for anchored items) from a legacy
1244
- * absolute time + pre-built speech-host map + part library. The three-branch
1245
- * rule (RFC 02 §4/§5, reference/17 §3):
1246
- *
1247
- * 1. main-track → `sequential`
1248
- * 2. speech/attachment (part_id in speechHost) → `anchored(host, offsetMs)`
1249
- * 3. caption (part has `speech_part_id`) → `anchored(speech, start_ms)`
1250
- * 4. everything else → `absolute(abs)`
1251
- *
1252
- * `partLibrary` values may be `PartUnion | string | undefined` (draft raw
1253
- * form); only object-typed entries are inspected for `caption`.
1254
- */
1255
- function derivePositionFromAbs(partId, abs, isMain, speechHost, partLibrary) {
1256
- if (isMain) return { timePosition: { mode: "sequential" } };
1257
- const host = speechHost.get(partId);
1258
- if (host != null) return {
1259
- timePosition: {
1260
- mode: "anchored",
1261
- anchorPartId: host.hostPartId,
1262
- offsetMs: host.offsetMs
1263
- },
1264
- fallbackAbsMs: abs
1265
- };
1266
- const part = partLibrary[partId];
1267
- const captionPart = typeof part === "object" && part != null ? part.caption : void 0;
1268
- const speechId = captionPart?.speech_part_id ?? void 0;
1269
- if (speechId != null) return {
1270
- timePosition: {
1271
- mode: "anchored",
1272
- anchorPartId: speechId,
1273
- offsetMs: Math.round(captionPart?.start_ms ?? 0)
1274
- },
1275
- fallbackAbsMs: abs
1276
- };
1277
- return { timePosition: {
1278
- mode: "absolute",
1279
- offsetMs: abs
1280
- } };
1281
- }
1282
- /** Derive one item's authoritative `time_position` from its legacy abs + aggregation. */
1283
- function deriveItem(item, isMain, speechHost, partLibrary) {
1284
- const partId = item.part_id ?? "";
1285
- const { timePosition, fallbackAbsMs } = derivePositionFromAbs(partId, Math.round(item.abs_time_position ?? 0), isMain, speechHost, partLibrary);
1286
- return fallbackAbsMs == null ? {
1287
- part_id: partId,
1288
- time_position: timePosition
1289
- } : {
1290
- part_id: partId,
1291
- time_position: timePosition,
1292
- fallback_abs_ms: fallbackAbsMs
1293
- };
1294
- }
1295
- /**
1296
- * Project the authoritative `VideoDocument` back into the legacy `VideoDraft`
1297
- * read-view, solving each item's absolute position, the `part_aggregations`, and
1298
- * the total duration via the timeline-core cascade.
1299
- *
1300
- * The read-view is a compatibility contract, and the IDL declares
1301
- * `Timeline.unit_time_ms` and `Track.is_hidden` `@required`
1302
- * (`video_draft_comp.smithy`) — so a projection that omitted them handed every
1303
- * consumer a payload that violated the shape it claims to satisfy, leaving each
1304
- * one to invent its own default. Two already had, and they disagreed: the agent
1305
- * harness used `unit_time_ms: 0`, director used a frame at 30fps. Defaulting here
1306
- * makes the contract true at the one place that produces it.
1307
- *
1308
- * These two are defaultable because the projection knows their values on its own:
1309
- * absent `unit_time_ms` means "no display granularity was ever stated" and absent
1310
- * `is_hidden` means "this track was never hidden". `version` is NOT defaultable
1311
- * here — its only honest value is server state (`update_seq`) that a
1312
- * `VideoDocument` cannot see, so it stays absent and the HTTP layer fills it.
1313
- */
1314
- function fromVideoDocument(document) {
1315
- assertValidVideoDocument(document);
1316
- const view = solveVideoDocument(document);
1317
- const tracks = document.tracks ?? [];
1318
- const mainTrack = tracks.find((t) => t.parts_kind === "video_clip");
1319
- const aboveTracks = tracks.filter((t) => t.parts_kind === "caption");
1320
- const belowTracks = tracks.filter((t) => t.parts_kind === "speech" || t.parts_kind === "bgm");
1321
- return {
1322
- id: document.meta.draft_id,
1323
- project_id: document.meta.project_id,
1324
- owner_id: document.meta.owner_id,
1325
- thumbnail_storage_key: document.meta.thumbnail_storage_key,
1326
- timeline: {
1327
- duration_ms: view.durationMs,
1328
- unit_time_ms: document.timeline?.unit_time_ms ?? 33.333
1329
- },
1330
- video_creation_settings: clone(document.meta.video_creation_settings),
1331
- chat_session_id: document.meta.chat_session_id,
1332
- main_track: draftTrack(mainTrack, view.absByPartId),
1333
- above_main_tracks: aboveTracks.map((t) => draftTrack(t, view.absByPartId)),
1334
- below_main_tracks: belowTracks.map((t) => draftTrack(t, view.absByPartId)),
1335
- part_aggregations: view.aggregations,
1336
- part_library: toReadViewPartLibrary(view.partLibrary, view.durationMs, view.derivedFillerPartIds),
1337
- version: document.meta.version
1338
- };
1339
- }
1340
- /**
1341
- * Map the authoritative part library into the `VideoDraft` read-view shape,
1342
- * re-injecting the derived effective `duration_ms` downstream expects
1343
- * (reference/17 §5/§7) — the authoritative parts store no part-level duration:
1344
- *
1345
- * - video clip → `(play_out - play_in) / speed` (`effectiveVideoClipDurationMs`)
1346
- * - speech → its intrinsic `media_duration_ms` (no trim/speed)
1347
- * - caption → its generation-time `initial_duration_ms`
1348
- * - bgm → the timeline total (`durationMs`)
1349
- *
1350
- * Parts are deep-cloned; the engine extensions (`media_duration_ms`,
1351
- * `initial_duration_ms`) are kept alongside the injected `duration_ms`.
1352
- *
1353
- * `derivedFillerPartIds` are skipped: the solve minted them to lay the track out
1354
- * and they are not part of the document (see `fromVideoDocument`). An empty clip a
1355
- * writer placed deliberately is NOT in that set and is projected normally.
1356
- */
1357
- function toReadViewPartLibrary(partLibrary, durationMs, derivedFillerPartIds) {
1358
- const out = {};
1359
- for (const [partId, part] of Object.entries(partLibrary)) {
1360
- if (derivedFillerPartIds.has(partId)) continue;
1361
- if (part.video_clip != null) {
1362
- const clip = clone(part.video_clip);
1363
- out[partId] = { video_clip: {
1364
- ...clip,
1365
- duration_ms: effectiveVideoClipDurationMs(clip)
1366
- } };
1367
- } else if (part.speech != null) {
1368
- const speech = clone(part.speech);
1369
- out[partId] = { speech: {
1370
- ...speech,
1371
- duration_ms: speech.media_duration_ms
1372
- } };
1373
- } else if (part.caption != null) {
1374
- const caption = clone(part.caption);
1375
- out[partId] = { caption: {
1376
- ...caption,
1377
- duration_ms: caption.initial_duration_ms
1378
- } };
1379
- } else if (part.bgm != null) out[partId] = { bgm: {
1380
- ...clone(part.bgm),
1381
- duration_ms: durationMs
1382
- } };
1383
- }
1384
- return out;
1385
- }
1386
- /** Deep-clone a part payload, dropping the derived read-view `duration_ms`. */
1387
- function omitDurationMs(part) {
1388
- const { duration_ms: _drop, ...rest } = clone(part);
1389
- return rest;
1390
- }
1391
- /**
1392
- * Migrate a legacy video clip into the authoritative trim-window-only shape: the
1393
- * authoritative part stores no `duration_ms`, so it is dropped — but a legacy
1394
- * clip with no explicit trim window (`play_in` / `play_out` absent) would then
1395
- * compute an effective length of 0 and vanish from the layout. When that clip
1396
- * carried a legacy `duration_ms`, backfill it as the trim window (`play_in` 0,
1397
- * `play_out = duration_ms`, no speed) so the effective length is preserved, then
1398
- * drop `duration_ms`. A clip that already has a trim window keeps it verbatim.
1399
- */
1400
- function withTrimWindowFromDuration(clip) {
1401
- const stripped = omitDurationMs(clip);
1402
- if (clip.play_in != null || clip.play_out != null) return stripped;
1403
- const legacyDuration = clip.duration_ms;
1404
- if (legacyDuration == null || !Number.isFinite(legacyDuration)) return stripped;
1405
- return {
1406
- ...stripped,
1407
- play_in: 0,
1408
- play_out: legacyDuration
1409
- };
1410
- }
1411
- /** Deep-clone a part payload, returning its `duration_ms` separately from the rest. */
1412
- function splitDurationMs(part) {
1413
- const { duration_ms, ...rest } = clone(part);
1414
- return {
1415
- duration_ms,
1416
- rest
1417
- };
1418
- }
1419
- function draftTrack(track, absByPartId) {
1420
- if (track == null) return void 0;
1421
- return {
1422
- id: track.id,
1423
- parts_kind: track.parts_kind,
1424
- is_hidden: track.is_hidden ?? false,
1425
- items: (track.items ?? []).map((item) => ({
1426
- part_id: item.part_id,
1427
- abs_time_position: absByPartId.get(item.part_id) ?? 0
1428
- }))
1429
- };
1430
- }
1431
- function clone(value) {
1432
- if (Array.isArray(value)) return value.map((item) => clone(item));
1433
- if (value != null && typeof value === "object") {
1434
- const result = {};
1435
- for (const [key, child] of Object.entries(value)) result[key] = clone(child);
1436
- return result;
1437
- }
1438
- return value;
1439
- }
1440
- //#endregion
1441
- //#region src/document/initial-document.ts
1442
- /**
1443
- * Build the `VideoDocument` a newly created project starts from: the caller's
1444
- * project facts, no parts, and one empty track per lane.
1445
- *
1446
- * The empty lane tracks are the reason this function exists rather than callers
1447
- * assembling a document inline. Lane tracks are otherwise minted lazily by the
1448
- * first op that needs one (`ensureLaneTrack`), which is find-then-mint over a
1449
- * CRDT list — so if the Agent's first generation and a user edit race on an
1450
- * empty document, each mints its own copy of the same lane and the merge keeps
1451
- * both. Two video_clip tracks violate the at-most-one-main-track rule, and the
1452
- * merged document becomes unprojectable for *every* reader (editor, render,
1453
- * share) with no winning replica to fall back on. Seeding all four lanes up
1454
- * front means `ensureLaneTrack` only ever finds, never mints, so the race cannot
1455
- * happen on a document created here.
1456
- *
1457
- * All four, not just the main lane: the secondary lanes fail less loudly (a
1458
- * duplicate pane rather than an unreadable document), but they fail by the same
1459
- * mechanism, and covering only the fatal one would leave three live races.
1460
- *
1461
- * `timeline` is left absent: `unit_time_ms` is a display concern the editor
1462
- * supplies, and total duration is derived on read (RFC 02 §6), so a new document
1463
- * has no timeline fact to state.
1464
- */
1465
- function buildInitialVideoDocument(facts) {
1466
- return {
1467
- meta: {
1468
- schema_version: VIDEO_DOCUMENT_SCHEMA_VERSION,
1469
- draft_id: facts.draftId,
1470
- project_id: facts.projectId,
1471
- owner_id: facts.ownerId,
1472
- thumbnail_storage_key: void 0,
1473
- chat_session_id: facts.chatSessionId,
1474
- video_creation_settings: facts.videoCreationSettings,
1475
- version: void 0
1476
- },
1477
- timeline: void 0,
1478
- tracks: emptyLaneTracks(),
1479
- part_library: {}
1480
- };
1481
- }
1482
- /** One empty track per lane, in lane-stacking order, with the conventional ids. */
1483
- function emptyLaneTracks() {
1484
- return LANE_KINDS_IN_STACK_ORDER.map((kind) => ({
1485
- id: laneTrackId(kind),
1486
- parts_kind: kind,
1487
- is_hidden: void 0,
1488
- items: []
1489
- }));
1490
- }
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/json-values.ts
1540
- const nativeObjectConstructorSource = Function.prototype.toString.call(Object);
1541
- function isJsonObject(value) {
1542
- return isJsonValue(value, /* @__PURE__ */ new Set()) && !Array.isArray(value) && value !== null;
1543
- }
1544
- function isJsonValue(value, ancestors) {
1545
- if (value === null || typeof value === "string" || typeof value === "boolean") return true;
1546
- if (typeof value === "number") return Number.isFinite(value);
1547
- if (typeof value !== "object") return false;
1548
- if (!Array.isArray(value) && !isPlainObject(value)) return false;
1549
- if (ancestors.has(value)) return false;
1550
- ancestors.add(value);
1551
- const valid = Array.isArray(value) ? value.every((item) => isJsonValue(item, ancestors)) : Object.values(value).every((item) => isJsonValue(item, ancestors));
1552
- ancestors.delete(value);
1553
- return valid;
1554
- }
1555
- /** Recognize an ordinary object from any VM realm without admitting class instances. */
1556
- function isPlainObject(value) {
1557
- try {
1558
- const prototype = Object.getPrototypeOf(value);
1559
- if (prototype === null) return true;
1560
- if (Object.getPrototypeOf(prototype) !== null) return false;
1561
- const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor")?.value;
1562
- return typeof constructor === "function" && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === nativeObjectConstructorSource;
1563
- } catch {
1564
- return false;
1565
- }
1566
- }
1567
- //#endregion
1568
- //#region ../medeo-dsl/src/composition.ts
1569
- /** Assembly fails explicitly when declared bases cannot provide valid, unambiguous content. */
1570
- var ScriptCompositionError = class extends Error {
1571
- code;
1572
- entityId;
1573
- constructor(code, entityId, message) {
1574
- super(message);
1575
- this.code = code;
1576
- this.entityId = entityId;
1577
- this.name = "ScriptCompositionError";
1578
- }
1579
- };
1580
- /** Direct base IDs are structural fields, never ordinary Relation endpoints. */
1581
- function variantBaseEntityIds(entity) {
1582
- if (!Object.hasOwn(entity.payload, "baseEntityIds")) return [];
1583
- const ids = entity.payload.baseEntityIds;
1584
- if (!Array.isArray(ids) || ids.length === 0 || !ids.every((id) => typeof id === "string" && id.trim() === id && id.length > 0) || new Set(ids).size !== ids.length) throw new ScriptCompositionError("invalid_bases", entity.entityId, `Entity "${entity.entityId}" requires non-empty, unique baseEntityIds`);
1585
- return ids.map(createEntityId);
1586
- }
1587
- /** Validate all base providers before applying explicit own fields; ordering cannot resolve ambiguity. */
1588
- function assembleEntityContent(rows, entityId) {
1589
- const byId = /* @__PURE__ */ new Map();
1590
- for (const row of rows.entities) {
1591
- if (byId.has(row.entityId)) throw new ScriptCompositionError("composition_ambiguous", row.entityId, `duplicate entity id "${row.entityId}"`);
1592
- byId.set(row.entityId, row);
1593
- }
1594
- const active = /* @__PURE__ */ new Set();
1595
- const cache = /* @__PURE__ */ new Map();
1596
- const visit = (id) => {
1597
- const cached = cache.get(id);
1598
- if (cached !== void 0) return cached;
1599
- if (active.has(id)) throw new ScriptCompositionError("composition_cycle", id, `Cyclic baseEntityIds at "${id}"`);
1600
- const row = byId.get(id);
1601
- if (row === void 0) throw new ScriptCompositionError("composition_dangling", id, `Missing base entity "${id}" in this document`);
1602
- if (!isJsonObject(row.payload) || Object.hasOwn(row.payload, "entityId") || Object.hasOwn(row.payload, "entityKind")) throw new ScriptCompositionError("invalid_bases", id, `Entity "${id}" requires JSON own fields without reserved identity fields`);
1603
- active.add(id);
1604
- const inherited = {};
1605
- const providers = /* @__PURE__ */ new Map();
1606
- for (const baseId of variantBaseEntityIds(row)) {
1607
- const base = visit(baseId);
1608
- for (const [field, value] of Object.entries(base.payload)) {
1609
- if (field === "baseEntityIds") continue;
1610
- const previous = providers.get(field);
1611
- if (previous !== void 0) throw new ScriptCompositionError("field_conflict", id, `Entity "${id}" field "${field}" conflicts between bases "${previous}" and "${baseId}"; own fields cannot resolve base ambiguity`);
1612
- providers.set(field, baseId);
1613
- Object.defineProperty(inherited, field, {
1614
- value,
1615
- enumerable: true,
1616
- configurable: true,
1617
- writable: true
1618
- });
1619
- }
1620
- }
1621
- const assembled = {
1622
- ...row,
1623
- payload: JSON.parse(JSON.stringify({
1624
- ...inherited,
1625
- ...row.payload
1626
- }))
1627
- };
1628
- active.delete(id);
1629
- cache.set(id, assembled);
1630
- return assembled;
1631
- };
1632
- return visit(entityId);
1633
- }
1634
- /** Resolve a field's declaring entity after validating the entire composition. */
1635
- function resolveEntityFieldOwner(rows, entityId, field) {
1636
- assembleEntityContent(rows, entityId);
1637
- const byId = new Map(rows.entities.map((row) => [row.entityId, row]));
1638
- const find = (id) => {
1639
- const row = byId.get(id);
1640
- if (Object.hasOwn(row.payload, field)) return id;
1641
- if (field === "baseEntityIds") return void 0;
1642
- for (const baseId of variantBaseEntityIds(row)) {
1643
- const owner = find(baseId);
1644
- if (owner !== void 0) return owner;
1645
- }
1646
- };
1647
- return find(entityId) ?? entityId;
1648
- }
1649
- /** Patch assembled fields without copying inherited fields into the variant's stored payload. */
1650
- function updateEntityFields(rows, entityId, fields) {
1651
- assembleEntityContent(rows, entityId);
1652
- if (!isJsonObject(fields) || Object.hasOwn(fields, "entityId") || Object.hasOwn(fields, "entityKind")) throw new ScriptCompositionError("invalid_bases", entityId, "Field updates require JSON without reserved identity fields");
1653
- const updates = /* @__PURE__ */ new Map();
1654
- for (const [field, value] of Object.entries(fields)) {
1655
- const ownerId = resolveEntityFieldOwner(rows, entityId, field);
1656
- const owner = updates.get(ownerId) ?? rows.entities.find((row) => row.entityId === ownerId);
1657
- updates.set(ownerId, {
1658
- ...owner,
1659
- payload: {
1660
- ...owner.payload,
1661
- [field]: value
1662
- }
1663
- });
1664
- }
1665
- return [...updates.values()].map((row) => JSON.parse(JSON.stringify(row)));
1666
- }
1667
- /** Find the actual AudioScript text owner through direct variant bases. */
1668
- function findComposedAudioScript(rows, variantEntityId, variantKind) {
1669
- const variant = requireEntityKind(rows, variantEntityId, variantKind);
1670
- assembleEntityContent(rows, variantEntityId);
1671
- const sources = /* @__PURE__ */ new Map();
1672
- const seen = /* @__PURE__ */ new Set();
1673
- const visit = (row) => {
1674
- for (const id of variantBaseEntityIds(row)) {
1675
- if (seen.has(id)) continue;
1676
- seen.add(id);
1677
- const base = rows.entities.find((candidate) => candidate.entityId === id);
1678
- if (base.entityKind === "audio-script") sources.set(id, assembleEntityContent(rows, id));
1679
- else visit(base);
1680
- }
1681
- };
1682
- visit(variant);
1683
- if (sources.size !== 1) throw new ScriptCompositionError(sources.size === 0 ? "composition_missing" : "composition_ambiguous", variantEntityId, `${variantKind} "${variantEntityId}" baseEntityIds must resolve one AudioScript text owner, got ${sources.size}`);
1684
- return [...sources.values()][0];
1685
- }
1686
- /** Join the whole AudioScript text in segment order. */
1687
- function assembleScriptText(script) {
1688
- return scriptSegments(script).map((segment) => segment.text).join("");
1689
- }
1690
- function scriptSegments(script) {
1691
- const segments = script.payload.segments;
1692
- if (!Array.isArray(segments)) throw new ScriptCompositionError("composition_dangling", script.entityId, `AudioScript "${script.entityId}" requires segments`);
1693
- if (!segments.every(isScriptSegment) || new Set(segments.map((s) => s.segmentId)).size !== segments.length) throw new ScriptCompositionError("invalid_script", script.entityId, "AudioScript requires valid segments with unique local segmentId and text");
1694
- return segments;
1695
- }
1696
- function isScriptSegment(value) {
1697
- if (typeof value !== "object" || value == null || Array.isArray(value)) return false;
1698
- const segment = value;
1699
- return typeof segment.segmentId === "string" && segment.segmentId.trim() !== "" && typeof segment.text === "string" && (segment.language === void 0 || typeof segment.language === "string");
1700
- }
1701
- /** Assemble selected text after base-field validation and explicit variant overrides. */
1702
- function assembleCaptionContent(rows, captionEntityId) {
1703
- requireEntityKind(rows, captionEntityId, "caption");
1704
- const caption = assembleEntityContent(rows, captionEntityId);
1705
- const script = findComposedAudioScript(rows, captionEntityId, "caption");
1706
- const segments = selectAudioScriptSegments({
1707
- ...script,
1708
- payload: caption.payload
1709
- }, captionSelections(caption));
1710
- return {
1711
- caption,
1712
- audioScript: script,
1713
- segments,
1714
- text: segments.map((segment) => segment.text).join("")
1715
- };
1716
- }
1717
- /**
1718
- * Assemble the complete Phonetic Script content for TTS: the composed
1719
- * AudioScript's base text plus the variant's own phoneme and prosody fields.
1720
- */
1721
- function assemblePhoneticScriptContent(rows, phoneticScriptEntityId) {
1722
- requireEntityKind(rows, phoneticScriptEntityId, "phonetic-script");
1723
- const phoneticScript = assembleEntityContent(rows, phoneticScriptEntityId);
1724
- const audioScript = findComposedAudioScript(rows, phoneticScriptEntityId, "phonetic-script");
1725
- const segments = scriptSegments({
1726
- ...audioScript,
1727
- payload: phoneticScript.payload
1728
- });
1729
- return {
1730
- phoneticScript,
1731
- audioScript,
1732
- segments,
1733
- text: segments.map((segment) => segment.text).join("")
1734
- };
1735
- }
1736
- function captionSelections(caption) {
1737
- const selections = caption.payload.selections;
1738
- if (!Array.isArray(selections) || selections.length === 0) throw new ScriptCompositionError("empty_selection", caption.entityId, `Caption "${caption.entityId}" requires a non-empty segment selection into its AudioScript`);
1739
- if (!selections.every(isSegmentSelection)) throw new ScriptCompositionError("invalid_selection", caption.entityId, "Caption selections require a segmentId and optional textRange");
1740
- return selections;
1741
- }
1742
- function isSegmentSelection(value) {
1743
- return typeof value === "object" && value != null && !Array.isArray(value) && typeof value.segmentId === "string" && value.segmentId.trim() !== "" && Object.keys(value).every((key) => key === "segmentId" || key === "textRange");
1744
- }
1745
- /** Select source text without creating another authoritative text field. */
1746
- function selectAudioScriptSegments(script, selections) {
1747
- if (!Array.isArray(selections) || selections.length === 0 || !selections.every(isSegmentSelection) || new Set(selections.map((s) => s.segmentId)).size !== selections.length) throw new ScriptCompositionError("invalid_selection", script.entityId, "Caption selections must be non-empty and name unique source segments");
1748
- const bySegmentId = new Map(scriptSegments(script).map((segment) => [segment.segmentId, segment]));
1749
- const selected = selections.map((selection) => {
1750
- const segment = bySegmentId.get(selection.segmentId);
1751
- if (segment === void 0) throw new ScriptCompositionError("unknown_segment", script.entityId, `Caption selection "${selection.segmentId}" does not name a segment of AudioScript "${script.entityId}"`);
1752
- if (selection.textRange === void 0) return segment;
1753
- const range = selection.textRange;
1754
- const points = Array.from(segment.text);
1755
- if (typeof range !== "object" || range === null || !Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.end) || range.start < 0 || range.end <= range.start || range.end > points.length || Object.keys(range).some((key) => key !== "start" && key !== "end")) throw new ScriptCompositionError("invalid_selection", script.entityId, "Caption textRange must be a non-empty half-open Unicode code-point range within its source segment");
1756
- return {
1757
- ...segment,
1758
- text: points.slice(range.start, range.end).join("")
1759
- };
1760
- });
1761
- if (!selected.map((segment) => segment.text).join("").trim()) throw new ScriptCompositionError("empty_selection", script.entityId, "Caption selection must contain visible source text");
1762
- return selected;
1763
- }
1764
- function requireEntityKind(rows, entityIdValue, entityKind) {
1765
- const entity = rows.entities.find((candidate) => candidate.entityId === entityIdValue);
1766
- if (entity === void 0) throw new ScriptCompositionError("composition_dangling", entityIdValue, `Entity "${entityIdValue}" does not exist`);
1767
- if (entity.entityKind !== entityKind) throw new ScriptCompositionError("composition_dangling", entityIdValue, `Entity "${entityIdValue}" must have kind "${entityKind}", got "${entity.entityKind}"`);
1768
- return entity;
1769
- }
1770
- //#endregion
1771
- //#region ../medeo-dsl/src/rows.ts
1772
- function entityToRow(entity) {
1773
- const { entityId, entityKind, ...payload } = entity;
1774
- if (!isJsonObject(payload)) throw new Error(`Entity "${entityId}" payload must contain only JSON values`);
1775
- return {
1776
- entityId,
1777
- entityKind,
1778
- payload
1779
- };
1780
- }
1781
- //#endregion
1782
- //#region ../medeo-dsl/src/invariants.ts
1783
- /**
1784
- * Media variants own their Asset identity directly: a `physical-asset`
1785
- * Relation may not bind them. Caption text is composed domain content, so
1786
- * Captions do not bind Physical Assets either; the Relation vocabulary stays
1787
- * open for future physical pipeline semantics.
1788
- */
1789
- function validateMediaAssetIdentity(entity, index) {
1790
- return [...index.relationsOf(entity)].filter((relation) => relation.kind === "physical-asset").map(() => ({
1791
- code: "media_physical_asset_forbidden",
1792
- entityId: entity.entityId,
1793
- message: `Media entity "${entity.entityId}" owns its Asset identity; remove its physical-asset Relation`
1794
- }));
1795
- }
1796
- /** Validates one complete set of entities and its authoritative Relation rows. */
1797
- function validateEntityRelationSet(entityRefs, index, options) {
1798
- const { entities, issues } = collectRelatedEntities(entityRefs, index);
1799
- const entityIds = new Set(entities.map((entity) => entity.entityId));
1800
- const rows = {
1801
- entities: entities.map((entity) => entityToRow(entity.current())),
1802
- relations: []
1803
- };
1804
- for (const entity of entities) try {
1805
- assembleEntityContent(rows, entity.entityId);
1806
- } catch (error) {
1807
- issues.push({
1808
- code: "invalid_variant_composition",
1809
- entityId: entity.entityId,
1810
- message: error instanceof Error ? error.message : "Invalid variant composition"
1811
- });
1812
- }
1813
- for (const entity of entities) {
1814
- const current = entity.current();
1815
- const entityIssues = validateEntity(entity, entityIds);
1816
- issues.push(...entityIssues);
1817
- if (isMediaAssetVariantKind(current.entityKind)) issues.push(...validateMediaAssetIdentity(entity, index));
1818
- if (entityIssues.some((issue) => issue.code === "invalid_entity_payload")) continue;
1819
- if (current.entityKind === "sequence-marker") {
1820
- const marker = entity;
1821
- issues.push(...validateMarkerUse(entity, index));
1822
- issues.push(...validateMarkerRanges(marker, {
1823
- source: (left, right) => options.compareMarkerPoints(marker, "source", left, right),
1824
- target: (left, right) => options.compareMarkerPoints(marker, "target", left, right)
1825
- }));
1826
- issues.push(...validateMarkerSourceBounds(marker, index, (left, right) => options.compareMarkerPoints(marker, "source", left, right)));
1827
- }
1828
- if (current.entityKind === "clip") {
1829
- issues.push(...validateClipAdmission(entity, index));
1830
- issues.push(...validateClipPlacement(entity, index));
1831
- }
1832
- if (current.entityKind === "axvideo") issues.push(...validateAXVideoAdmission(entity, index));
1833
- if (current.entityKind === "caption") issues.push(...validateCaptionComposition(entity, index, entities));
1834
- if (current.entityKind === "phonetic-script") issues.push(...validatePhoneticComposition(entity, index, entities));
1835
- issues.push(...validateSequenceComposition(entity));
1836
- }
1837
- issues.push(...validateClipAnchorCycles(entities, index));
1838
- return issues;
1839
- }
1840
- function validateMarkerUse(marker, index) {
1841
- const relations = [...index.relationsOf(marker)];
1842
- const clipEdges = ofKind(relations, "clip-marker");
1843
- const axVideoEdges = ofKind(relations, "axvideo-marker");
1844
- const contentEdges = ofKind(relations, "marker-content");
1845
- const timelineEdges = ofKind(relations, "marker-timeline");
1846
- const scriptEdges = ofKind(relations, "audio-script-marker");
1847
- const issues = [];
1848
- if (scriptEdges.length > 0) {
1849
- if (clipEdges.length + axVideoEdges.length + contentEdges.length + timelineEdges.length > 0) issues.push({
1850
- code: "marker_container_xor",
1851
- entityId: marker.entityId,
1852
- message: "An AudioScript annotation Marker must not carry Clip, AXVideo, content, or Timeline relations"
1853
- });
1854
- const ranges = marker.current().segmentRanges;
1855
- if (!ranges?.length) issues.push({
1856
- code: "invalid_entity_payload",
1857
- entityId: marker.entityId,
1858
- message: "An AudioScript annotation Marker requires segmentRanges"
1859
- });
1860
- for (const relation of scriptEdges) {
1861
- const script = relation.other(marker)?.deref()?.current();
1862
- if (script?.entityKind !== "audio-script") continue;
1863
- const segments = script.segments;
1864
- if (!Array.isArray(segments)) continue;
1865
- const segmentIds = new Set(segments.map((segment) => segment.segmentId));
1866
- if (ranges?.some((range) => !segmentIds.has(range.segmentId))) issues.push({
1867
- code: "invalid_entity_payload",
1868
- entityId: marker.entityId,
1869
- message: "Annotation segmentRanges must name segments of the related AudioScript"
1870
- });
1871
- }
1872
- return issues;
1873
- }
1874
- if (marker.current().segmentRanges !== void 0) issues.push({
1875
- code: "invalid_entity_payload",
1876
- entityId: marker.entityId,
1877
- message: "segmentRanges belongs to an AudioScript annotation Marker, not a display Marker"
1878
- });
1879
- if (clipEdges.length + axVideoEdges.length !== 1) issues.push({
1880
- code: "marker_container_xor",
1881
- entityId: marker.entityId,
1882
- message: "Sequence Marker must have exactly one Clip XOR AXVideo container relation"
1883
- });
1884
- if (contentEdges.length + timelineEdges.length !== 1) issues.push({
1885
- code: "marker_content_xor",
1886
- entityId: marker.entityId,
1887
- message: "Sequence Marker must have exactly one Sequence content XOR Timeline relation"
1888
- });
1889
- if (clipEdges.length === 1 && timelineEdges.length === 1 || axVideoEdges.length === 1 && contentEdges.length === 1) issues.push({
1890
- code: "marker_pair_mismatch",
1891
- entityId: marker.entityId,
1892
- message: "Only Clip+Content or AXVideo+Timeline Marker relation pairs are valid"
1893
- });
1894
- return issues;
1895
- }
1896
- function validateClipAdmission(clip, index) {
1897
- const markerEdges = ofKind([...index.relationsOf(clip)], "clip-marker");
1898
- if (markerEdges.length !== 1) return [{
1899
- code: "clip_marker_cardinality",
1900
- entityId: clip.entityId,
1901
- message: "Clip must have exactly one authoritative Clip-Marker Relation"
1902
- }];
1903
- const marker = markerEdges[0]?.other(clip)?.deref();
1904
- if (marker == null) return [{
1905
- code: "clip_content_cardinality",
1906
- entityId: clip.entityId,
1907
- message: "Clip must resolve one live Sequence Marker and one content Relation"
1908
- }];
1909
- const contentEdges = ofKind([...index.relationsOf(marker)], "marker-content");
1910
- if (contentEdges.length !== 1) return [{
1911
- code: "clip_content_cardinality",
1912
- entityId: clip.entityId,
1913
- message: "Clip Marker must resolve exactly one content Relation"
1914
- }];
1915
- const content = contentEdges[0]?.other(marker)?.deref()?.current();
1916
- if (content == null) return [{
1917
- code: "clip_content_cardinality",
1918
- entityId: clip.entityId,
1919
- message: "Clip Marker content Relation must resolve one live entity"
1920
- }];
1921
- if (!hasSequence(content)) return [{
1922
- code: "clip_content_not_sequence",
1923
- entityId: clip.entityId,
1924
- message: `Clip Marker resolves to non-Sequence entity "${content.entityId}"`
1925
- }];
1926
- return [];
1927
- }
1928
- function validateAXVideoAdmission(axVideo, index) {
1929
- const markerEdges = ofKind([...index.relationsOf(axVideo)], "axvideo-marker");
1930
- if (markerEdges.length === 1 && markerEdges[0]?.other(axVideo)?.deref() != null) return [];
1931
- return [{
1932
- code: "axvideo_marker_cardinality",
1933
- entityId: axVideo.entityId,
1934
- message: "AXVideo must have exactly one live AXVideo-Marker Relation"
1935
- }];
1936
- }
1937
- /** Validate a Caption against the complete entity set, including its directly held bases. */
1938
- function validateCaptionComposition(caption, index, entities = []) {
1939
- return validateVariantComposition(caption, index, entities, "caption");
1940
- }
1941
- function validatePhoneticComposition(phonetic, index, entities = []) {
1942
- return validateVariantComposition(phonetic, index, entities, "phonetic-script");
1943
- }
1944
- function validateVariantComposition(variant, index, entities, kind) {
1945
- const issues = [];
1946
- if (kind === "caption" && ofKind([...index.relationsOf(variant)], "physical-asset").length > 0) issues.push({
1947
- code: "caption_physical_asset_forbidden",
1948
- entityId: variant.entityId,
1949
- message: "Caption text is composed from its AudioScript; Caption must not bind a Physical Asset"
1950
- });
1951
- const rows = {
1952
- entities: collectRelatedEntities([...entities, variant], index).entities.map((ref) => entityToRow(ref.current())),
1953
- relations: []
1954
- };
1955
- try {
1956
- if (kind === "caption") assembleCaptionContent(rows, variant.entityId);
1957
- else assemblePhoneticScriptContent(rows, variant.entityId);
1958
- } catch (error) {
1959
- issues.push({
1960
- code: kind === "caption" ? "caption_composition_required" : "phonetic_script_composition_required",
1961
- entityId: variant.entityId,
1962
- message: error instanceof Error ? error.message : "Invalid variant composition"
1963
- });
1964
- }
1965
- return issues;
1966
- }
1967
- function validateClipPlacement(clip, index) {
1968
- const marker = ofKind([...index.relationsOf(clip)], "clip-marker")[0]?.other(clip)?.deref();
1969
- if (marker?.current().entityKind !== "sequence-marker") return [];
1970
- const markerValue = marker.current();
1971
- const anchors = ofKind([...index.relationsOf(clip)], "clip-anchor").filter((relation) => relation.endpoints[0].deref() === clip);
1972
- const hasOrder = clip.current().order !== void 0;
1973
- const hasTarget = markerValue.targetRange !== void 0;
1974
- const hasAnchorOffset = markerValue.anchorOffset !== void 0;
1975
- const issues = [];
1976
- if (anchors.length > 1) issues.push({
1977
- code: "clip_anchor_cardinality",
1978
- entityId: clip.entityId,
1979
- message: "A Clip may follow at most one host Clip"
1980
- });
1981
- const hasAnchor = anchors.length === 1;
1982
- if (hasAnchor && (!hasAnchorOffset || hasOrder || hasTarget) || !hasAnchor && (hasAnchorOffset || Number(hasOrder) + Number(hasTarget) !== 1)) issues.push({
1983
- code: "clip_placement_invalid",
1984
- entityId: clip.entityId,
1985
- message: "Clip placement must be exactly one of Clip.order, Marker.targetRange, or clip-anchor with Marker.anchorOffset"
1986
- });
1987
- if (markerValue.durationPolicy === "timeline") {
1988
- const content = ofKind([...index.relationsOf(marker)], "marker-content")[0]?.other(marker)?.deref()?.current();
1989
- const track = ofKind([...index.relationsOf(clip)], "track-clip")[0]?.other(clip)?.deref()?.current();
1990
- const trackRole = track?.entityKind === "track" ? track.role : void 0;
1991
- if (content?.entityKind !== "audio" || trackRole !== "bgm" || !hasOrder && !hasTarget) issues.push({
1992
- code: "timeline_duration_policy_invalid",
1993
- entityId: marker.entityId,
1994
- message: "Marker durationPolicy \"timeline\" is only valid for ordered or absolutely placed Audio Clips on the bgm Track"
1995
- });
1996
- }
1997
- return issues;
1998
- }
1999
- function validateClipAnchorCycles(entities, index) {
2000
- const hostByChild = /* @__PURE__ */ new Map();
2001
- for (const entity of entities) {
2002
- if (entity.current().entityKind !== "clip") continue;
2003
- for (const relation of ofKind([...index.relationsOf(entity)], "clip-anchor")) {
2004
- if (relation.endpoints[0].deref() !== entity) continue;
2005
- const host = relation.endpoints[1].deref();
2006
- if (host != null) hostByChild.set(entity.entityId, host.entityId);
2007
- }
2008
- }
2009
- const issues = [];
2010
- for (const child of hostByChild.keys()) {
2011
- const seen = /* @__PURE__ */ new Set();
2012
- let current = child;
2013
- while (current !== void 0 && !seen.has(current)) {
2014
- seen.add(current);
2015
- current = hostByChild.get(current);
2016
- }
2017
- if (current === void 0) continue;
2018
- issues.push({
2019
- code: "clip_anchor_cycle",
2020
- entityId: child,
2021
- message: "clip-anchor Relations must form an acyclic dependency graph"
2022
- });
2023
- }
2024
- return issues;
2025
- }
2026
- function validateMarkerRanges(marker, compare) {
2027
- const current = marker.current();
2028
- const issues = [];
2029
- if (!(compare.source(current.sourceRange.start, current.sourceRange.end) < 0)) issues.push({
2030
- code: "marker_source_range_empty",
2031
- entityId: marker.entityId,
2032
- message: "Sequence Marker sourceRange must be a non-empty half-open interval"
2033
- });
2034
- if (current.targetRange != null && !(compare.target(current.targetRange.start, current.targetRange.end) < 0)) issues.push({
2035
- code: "marker_target_range_empty",
2036
- entityId: marker.entityId,
2037
- message: "Sequence Marker targetRange must be a non-empty half-open interval"
2038
- });
2039
- return issues;
2040
- }
2041
- function validateMarkerSourceBounds(marker, index, compare) {
2042
- const contentEdges = ofKind([...index.relationsOf(marker)], "marker-content");
2043
- if (contentEdges.length !== 1) return [];
2044
- const content = contentEdges[0]?.other(marker)?.deref()?.current();
2045
- if (content == null || !hasSequence(content)) return [];
2046
- const sourceRange = marker.current().sourceRange;
2047
- const startVsExtent = compare(sourceRange.start, content.extent.start);
2048
- const endVsExtent = content.extent.kind === "bounded" ? compare(sourceRange.end, content.extent.end) : void 0;
2049
- if (!Number.isNaN(startVsExtent) && startVsExtent >= 0 && (endVsExtent == null || !Number.isNaN(endVsExtent) && endVsExtent <= 0)) return [];
2050
- return [{
2051
- code: "marker_source_out_of_bounds",
2052
- entityId: marker.entityId,
2053
- message: `Sequence Marker sourceRange must stay within content "${content.entityId}" extent`
2054
- }];
2055
- }
2056
- function validateSequenceComposition(entity) {
2057
- const current = entity.current();
2058
- const expected = expectedSequenceShape(current.entityKind);
2059
- if (expected == null) return [];
2060
- if (hasSequence(current) && current.extent.kind === expected.extent && current.sampling === expected.sampling) return [];
2061
- return [{
2062
- code: "invalid_sequence_composition",
2063
- entityId: current.entityId,
2064
- message: `${current.entityKind} must compose ${expected.extent} / ${expected.sampling} Sequence semantics`
2065
- }];
2066
- }
2067
- function validateEntity(entity, entityIds = /* @__PURE__ */ new Set()) {
2068
- const current = entity.current();
2069
- const issues = [];
2070
- if (isReservedEntityKind(current.entityKind)) issues.push({
2071
- code: "forbidden_entity_kind",
2072
- entityId: current.entityId,
2073
- message: `Entity kind "${current.entityKind}" is explicitly outside the Medeo DSL`
2074
- });
2075
- for (const problem of validateKnownEntityPayload(current)) issues.push({
2076
- code: "invalid_entity_payload",
2077
- entityId: current.entityId,
2078
- message: `Entity "${current.entityKind}" ${problem}`
2079
- });
2080
- const peerIdPaths = [...collectPeerEntityIdPaths(current, current.entityKind), ...collectPeerEntityValuePaths(current, current.entityKind, current.entityId, entityIds)];
2081
- const uniquePeerIdPaths = [...new Set(peerIdPaths)].sort();
2082
- if (uniquePeerIdPaths.length > 0) issues.push({
2083
- code: "peer_entity_id_field",
2084
- entityId: current.entityId,
2085
- message: `Entity embeds forbidden peer-ID field/value path(s): ${uniquePeerIdPaths.join(", ")}`
2086
- });
2087
- return issues;
2088
- }
2089
- function validateKnownEntityPayload(entity) {
2090
- const value = entity;
2091
- const problems = [];
2092
- if (value.lifecycle !== void 0 && !isRecord(value.lifecycle)) problems.push("lifecycle must be an object when present");
2093
- if (entity.entityKind === "audio-script" || entity.entityKind === "phonetic-script") {
2094
- for (const key of [
2095
- "extent",
2096
- "sampling",
2097
- "coordinateSpace",
2098
- "sourceRange",
2099
- "targetRange",
2100
- "duration",
2101
- "startMs",
2102
- "endMs"
2103
- ]) if (Object.hasOwn(value, key)) problems.push(`${key} is not intrinsic script data; assign an external annotation Marker`);
2104
- }
2105
- switch (entity.entityKind) {
2106
- case "track":
2107
- validateOptionalField(value, "hidden", "boolean", problems);
2108
- validateOptionalField(value, "role", "string", problems);
2109
- validateOptionalFiniteNumber(value, "order", problems);
2110
- break;
2111
- case "video":
2112
- case "audio":
2113
- case "voice":
2114
- case "caption":
2115
- validateSequencePayload(value, "bounded", "native", problems);
2116
- if (entity.entityKind !== "caption") validateMediaAssetFields(value, problems);
2117
- if (entity.entityKind === "voice") validateVoicePayload(value, problems);
2118
- if (entity.entityKind === "caption") validateCaptionPayload(value, problems);
2119
- break;
2120
- case "image":
2121
- validateSequencePayload(value, "unbounded", "constant", problems);
2122
- validateMediaAssetFields(value, problems);
2123
- break;
2124
- case "axvideo":
2125
- validateSequencePayload(value, "bounded", "derived", problems);
2126
- break;
2127
- case "sequence-marker":
2128
- validateMarkerPayload(value, problems);
2129
- break;
2130
- case "audio-script":
2131
- validateScriptPayload(value, problems);
2132
- break;
2133
- case "phonetic-script":
2134
- validatePhoneticScriptPayload(value, problems);
2135
- break;
2136
- case "timeline":
2137
- case "viewport": break;
2138
- case "clip":
2139
- validateOptionalFiniteNumber(value, "order", problems);
2140
- validateOptionalFiniteNumber(value, "volume", problems);
2141
- if (typeof value.volume === "number" && (value.volume < -60 || value.volume > 20)) problems.push("volume must be decibels between -60 and 20");
2142
- break;
2143
- case "asset":
2144
- validateAssetPayload(value, problems);
2145
- break;
2146
- default: break;
2147
- }
2148
- return problems;
2149
- }
2150
- function validateSequencePayload(value, expectedExtent, expectedSampling, problems) {
2151
- const extent = value.extent;
2152
- if (!isRecord(extent)) problems.push("extent must be an object");
2153
- else {
2154
- if (extent.kind !== expectedExtent) problems.push(`extent.kind must be "${expectedExtent}"`);
2155
- if (!Object.hasOwn(extent, "start") || extent.start === void 0) problems.push("extent.start is required");
2156
- if (expectedExtent === "bounded" && (!Object.hasOwn(extent, "end") || extent.end === void 0)) problems.push("extent.end is required for bounded sequences");
2157
- if (expectedExtent === "unbounded" && Object.hasOwn(extent, "end")) problems.push("extent.end is forbidden for unbounded sequences");
2158
- }
2159
- if (value.sampling !== expectedSampling) problems.push(`sampling must be "${expectedSampling}"`);
2160
- if (!Object.hasOwn(value, "coordinateSpace") || value.coordinateSpace === void 0) problems.push("coordinateSpace is required");
2161
- }
2162
- function validateMarkerPayload(value, problems) {
2163
- validateRange(value.sourceRange, "sourceRange", true, problems);
2164
- if (Object.hasOwn(value, "targetRange") && value.targetRange !== void 0) validateRange(value.targetRange, "targetRange", true, problems);
2165
- const duration = value.duration;
2166
- if (!isRecord(duration)) problems.push("duration must be an object");
2167
- else if (duration.mode === "fixed") {
2168
- if (!Object.hasOwn(duration, "value") || duration.value === void 0) problems.push("duration.value is required when duration.mode is \"fixed\"");
2169
- } else if (duration.mode !== "from-source") problems.push("duration.mode must be \"from-source\" or \"fixed\"");
2170
- if (value.anchorOffset === void 0 && Object.hasOwn(value, "anchorOffset")) problems.push("anchorOffset cannot be undefined when present");
2171
- if (value.durationPolicy !== void 0 && value.durationPolicy !== "timeline") problems.push("durationPolicy must be \"timeline\" when present");
2172
- if (value.segmentRanges !== void 0) validateSegmentRanges(value.segmentRanges, problems);
2173
- }
2174
- /** Annotation Marker time values are directly assigned facts; no cross-references are allowed. */
2175
- function validateSegmentRanges(value, problems) {
2176
- if (!Array.isArray(value)) {
2177
- problems.push("segmentRanges must be an array when present");
2178
- return;
2179
- }
2180
- const seen = /* @__PURE__ */ new Set();
2181
- for (const [index, entry] of value.entries()) {
2182
- if (!isRecord(entry)) {
2183
- problems.push(`segmentRanges[${index}] must be an object`);
2184
- continue;
2185
- }
2186
- if (typeof entry.segmentId !== "string") problems.push(`segmentRanges[${index}].segmentId must be a string`);
2187
- else if (seen.has(entry.segmentId)) problems.push(`segmentRanges[${index}].segmentId must be unique within the Marker`);
2188
- else seen.add(entry.segmentId);
2189
- for (const key of ["startMs", "endMs"]) {
2190
- const point = entry[key];
2191
- if (typeof point !== "number" || !Number.isFinite(point)) problems.push(`segmentRanges[${index}].${key} must be a finite number`);
2192
- }
2193
- if (typeof entry.startMs === "number" && Number.isFinite(entry.startMs) && typeof entry.endMs === "number" && Number.isFinite(entry.endMs) && entry.startMs > entry.endMs) problems.push(`segmentRanges[${index}].startMs must not exceed segmentRanges[${index}].endMs`);
2194
- }
2195
- }
2196
- function validateAssetPayload(value, problems) {
2197
- validateExternalLocator(value, problems);
2198
- validateStorageKey(value, problems);
2199
- if (value.inline !== void 0) problems.push("inline is not a physical location; text is domain content owned by its AudioScript");
2200
- }
2201
- /**
2202
- * Media variants own their Asset locator directly: `external` is required and
2203
- * textual domain content is owned by AudioScript; the physical-location
2204
- * vocabularies cannot mix.
2205
- */
2206
- function validateMediaAssetFields(value, problems) {
2207
- if (value.external === void 0) problems.push("external is required; media variants own their Asset identity");
2208
- else validateExternalLocator(value, problems);
2209
- validateStorageKey(value, problems);
2210
- if (value.inline !== void 0) problems.push("media variants use external locators, not inline domain text");
2211
- }
2212
- function validateExternalLocator(value, problems) {
2213
- const external = value.external;
2214
- if (external === void 0) return;
2215
- if (!isRecord(external)) {
2216
- problems.push("external must be an object when present");
2217
- return;
2218
- }
2219
- if (external.system !== "memota" && external.system !== "memota-speech") problems.push("external.system must be \"memota\" or \"memota-speech\"");
2220
- if (typeof external.key !== "string" || external.key.trim() === "") problems.push("external.key must be a non-empty string");
2221
- }
2222
- function validateStorageKey(value, problems) {
2223
- if (value.storageKey !== void 0 && (typeof value.storageKey !== "string" || value.storageKey.trim() === "")) problems.push("storageKey must be a non-empty string when present");
2224
- }
2225
- function validateVoicePayload(value, problems) {
2226
- const voice = value.voice;
2227
- if (voice === void 0) return;
2228
- if (!isRecord(voice)) {
2229
- problems.push("voice must be an object when present");
2230
- return;
2231
- }
2232
- if (voice.system !== "voice-library") problems.push("voice.system must be \"voice-library\"");
2233
- if (typeof voice.key !== "string" || voice.key.trim() === "") problems.push("voice.key must be a non-empty string");
2234
- if (voice.name !== void 0 && typeof voice.name !== "string") problems.push("voice.name must be a string when present");
2235
- }
2236
- function validateCaptionPayload(value, problems) {
2237
- const selections = value.selections;
2238
- if (!Array.isArray(selections)) problems.push("selections must be a non-empty array of AudioScript segment selections");
2239
- else {
2240
- if (selections.length === 0) problems.push("selections must name at least one AudioScript segment");
2241
- const seen = /* @__PURE__ */ new Set();
2242
- for (const [index, selection] of selections.entries()) {
2243
- if (!isRecord(selection)) {
2244
- problems.push(`selections[${index}] must be an object`);
2245
- continue;
2246
- }
2247
- if (typeof selection.segmentId !== "string" || selection.segmentId.trim() === "") problems.push(`selections[${index}].segmentId must be a string`);
2248
- else if (seen.has(selection.segmentId)) problems.push(`selections[${index}].segmentId must be unique within the Caption`);
2249
- else seen.add(selection.segmentId);
2250
- if (Object.keys(selection).some((key) => key !== "segmentId" && key !== "textRange")) problems.push(`selections[${index}] may only contain segmentId and textRange`);
2251
- if (selection.textRange !== void 0) {
2252
- const range = selection.textRange;
2253
- if (!isRecord(range) || !Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.end) || range.start < 0 || range.end <= range.start || Object.keys(range).some((key) => key !== "start" && key !== "end")) problems.push(`selections[${index}].textRange must be a non-empty half-open code-point range`);
2254
- }
2255
- }
2256
- }
2257
- const style = value.style;
2258
- if (style === void 0) return;
2259
- if (!isRecord(style)) {
2260
- problems.push("style must be an object when present");
2261
- return;
2262
- }
2263
- const font = style.font;
2264
- if (font !== void 0) if (!isRecord(font)) problems.push("style.font must be an object when present");
2265
- else {
2266
- if (font.system !== "font-library") problems.push("style.font.system must be \"font-library\"");
2267
- if (typeof font.key !== "string" || font.key.trim() === "") problems.push("style.font.key must be a non-empty string");
2268
- }
2269
- for (const key of [
2270
- "fontSize",
2271
- "fontWeight",
2272
- "entranceAnimationDurationMs",
2273
- "strokeWidth",
2274
- "positionX",
2275
- "positionY"
2276
- ]) validateOptionalFiniteNumber(style, key, problems, `style.${key}`);
2277
- for (const key of [
2278
- "fontColor",
2279
- "entranceAnimation",
2280
- "strokeColor"
2281
- ]) if (style[key] !== void 0 && typeof style[key] !== "string") problems.push(`style.${key} must be a string`);
2282
- }
2283
- function validateRange(value, path, required, problems) {
2284
- if (!isRecord(value)) {
2285
- if (required) problems.push(`${path} must be an object`);
2286
- return;
2287
- }
2288
- if (!Object.hasOwn(value, "start") || value.start === void 0) problems.push(`${path}.start is required`);
2289
- if (!Object.hasOwn(value, "end") || value.end === void 0) problems.push(`${path}.end is required`);
2290
- }
2291
- /** Phonetic variants store pronunciation fields only; base text stays in the AudioScript. */
2292
- function validatePhoneticScriptPayload(value, problems) {
2293
- if (value.segments !== void 0) validateScriptPayload(value, problems);
2294
- if (value.phonemeScript !== void 0 && (typeof value.phonemeScript !== "string" || value.phonemeScript.trim() === "")) problems.push("phonemeScript must be a non-empty string when present");
2295
- if (value.prosody !== void 0 && !isRecord(value.prosody)) problems.push("prosody must be an object when present");
2296
- }
2297
- function validateScriptPayload(value, problems) {
2298
- if (!Array.isArray(value.segments)) {
2299
- problems.push("segments must be an array");
2300
- return;
2301
- }
2302
- for (const [index, segment] of value.segments.entries()) {
2303
- if (!isRecord(segment)) {
2304
- problems.push(`segments[${index}] must be an object`);
2305
- continue;
2306
- }
2307
- for (const key of [
2308
- "startMs",
2309
- "endMs",
2310
- "start_ms",
2311
- "end_ms",
2312
- "sourceRange",
2313
- "targetRange",
2314
- "duration"
2315
- ]) if (Object.hasOwn(segment, key)) problems.push(`segments[${index}].${key} is timing; attach an AudioScript annotation Marker instead`);
2316
- if (typeof segment.segmentId !== "string") problems.push(`segments[${index}].segmentId must be a string`);
2317
- if (typeof segment.text !== "string") problems.push(`segments[${index}].text must be a string`);
2318
- if (segment.language !== void 0 && typeof segment.language !== "string") problems.push(`segments[${index}].language must be a string when present`);
2319
- }
2320
- }
2321
- function validateOptionalField(value, key, expectedType, problems) {
2322
- if (value[key] !== void 0 && typeof value[key] !== expectedType) problems.push(`${key} must be a ${expectedType} when present`);
2323
- }
2324
- function validateOptionalFiniteNumber(value, key, problems, label = key) {
2325
- if (value[key] !== void 0 && (typeof value[key] !== "number" || !Number.isFinite(value[key]))) problems.push(`${label} must be a finite number when present`);
2326
- }
2327
- function collectPeerEntityIdPaths(value, entityKind) {
2328
- const paths = [];
2329
- visitPeerEntityIdPaths(value, entityKind, "", /* @__PURE__ */ new Set(), paths);
2330
- return paths;
2331
- }
2332
- function visitPeerEntityIdPaths(value, entityKind, parentPath, ancestors, paths) {
2333
- if (typeof value !== "object" || value == null) return;
2334
- if (ancestors.has(value)) return;
2335
- ancestors.add(value);
2336
- if (Array.isArray(value)) for (const [index, item] of value.entries()) visitPeerEntityIdPaths(item, entityKind, `${parentPath}[${index}]`, ancestors, paths);
2337
- else for (const [key, child] of Object.entries(value)) {
2338
- const path = parentPath.length === 0 ? key : `${parentPath}.${key}`;
2339
- if (parentPath.length === 0 && key === "baseEntityIds") continue;
2340
- if (!(parentPath.length === 0 && key === "entityId") && !isOwnedLocalIdPath(entityKind, path) && isEntityIdFieldName(key)) paths.push(path);
2341
- visitPeerEntityIdPaths(child, entityKind, path, ancestors, paths);
2342
- }
2343
- ancestors.delete(value);
2344
- }
2345
- function collectPeerEntityValuePaths(value, entityKind, ownEntityId, entityIds) {
2346
- const paths = [];
2347
- visitPeerEntityValues(value, entityKind, ownEntityId, entityIds, "", /* @__PURE__ */ new Set(), paths);
2348
- return paths;
2349
- }
2350
- function visitPeerEntityValues(value, entityKind, ownEntityId, entityIds, path, ancestors, paths) {
2351
- if (typeof value === "string") {
2352
- if (value !== ownEntityId && entityIds.has(value) && !isOwnedLocalEntityValuePath(entityKind, path)) paths.push(path);
2353
- return;
2354
- }
2355
- if (typeof value !== "object" || value == null || ancestors.has(value)) return;
2356
- ancestors.add(value);
2357
- if (Array.isArray(value)) for (const [index, item] of value.entries()) visitPeerEntityValues(item, entityKind, ownEntityId, entityIds, `${path}[${index}]`, ancestors, paths);
2358
- else for (const [key, child] of Object.entries(value)) {
2359
- if (path.length === 0 && (key === "entityId" || key === "entityKind" || key === "baseEntityIds")) continue;
2360
- visitPeerEntityValues(child, entityKind, ownEntityId, entityIds, path.length === 0 ? key : `${path}.${key}`, ancestors, paths);
2361
- }
2362
- ancestors.delete(value);
2363
- }
2364
- function isOwnedLocalEntityValuePath(entityKind, path) {
2365
- if (entityKind === "track" && path === "role") return true;
2366
- if ([
2367
- "video",
2368
- "audio",
2369
- "voice",
2370
- "image",
2371
- "caption",
2372
- "axvideo"
2373
- ].includes(entityKind) && /^(?:sampling|coordinateSpace|coordinateSpace\.unit|extent\.kind)$/.test(path)) return true;
2374
- if (entityKind === "sequence-marker" && /^(?:durationPolicy|duration\.mode|timeRemapping\.(?:kind|mode))$/.test(path)) return true;
2375
- if (entityKind === "sequence-marker" && /^segmentRanges\[\d+\]\.segmentId$/.test(path)) return true;
2376
- if (entityKind === "asset" && /^(?:external\.(?:system|key)|storageKey)$/.test(path)) return true;
2377
- if (isMediaAssetVariantKind(entityKind) && /^(?:external\.(?:system|key)|storageKey)$/.test(path)) return true;
2378
- if (entityKind === "voice" && /^voice\.(?:system|key|name)$/.test(path)) return true;
2379
- if (entityKind === "caption" && (path.startsWith("style.") || /^selections\[\d+\]\.segmentId$/.test(path))) return true;
2380
- if ([
2381
- "audio-script",
2382
- "caption",
2383
- "phonetic-script"
2384
- ].includes(entityKind) && /^segments\[\d+\]\.(?:segmentId|text|language)$/.test(path)) return true;
2385
- return false;
2386
- }
2387
- function isOwnedLocalIdPath(entityKind, path) {
2388
- if (path === "lifecycle.actorId") return true;
2389
- if ([
2390
- "audio-script",
2391
- "caption",
2392
- "phonetic-script"
2393
- ].includes(entityKind) && /^segments\[\d+\]\.segmentId$/.test(path)) return true;
2394
- if (entityKind === "caption" && /^selections\[\d+\]\.segmentId$/.test(path)) return true;
2395
- if (entityKind === "sequence-marker" && /^segmentRanges\[\d+\]\.segmentId$/.test(path)) return true;
2396
- if (entityKind === "asset" && /^(?:tracks\[\d+\]\.trackId|renditions\[\d+\]\.renditionId)$/.test(path)) return true;
2397
- return false;
2398
- }
2399
- function isEntityIdFieldName(key) {
2400
- return key.endsWith("Id") || key.endsWith("Ids") || key.endsWith("ID") || key.endsWith("IDs") || /_ids?$/i.test(key);
2401
- }
2402
- function isRecord(value) {
2403
- return typeof value === "object" && value != null && !Array.isArray(value);
2404
- }
2405
- function collectRelatedEntities(entityRefs, index) {
2406
- const byId = /* @__PURE__ */ new Map();
2407
- const queue = [...entityRefs];
2408
- const issues = [];
2409
- while (queue.length > 0) {
2410
- const entity = queue.shift();
2411
- if (entity == null) continue;
2412
- const existing = byId.get(entity.entityId);
2413
- if (existing != null) {
2414
- if (existing !== entity) issues.push({
2415
- code: "duplicate_entity_id",
2416
- entityId: entity.entityId,
2417
- message: `Entity id "${entity.entityId}" has more than one live EntityRef`
2418
- });
2419
- continue;
2420
- }
2421
- byId.set(entity.entityId, entity);
2422
- for (const relation of index.relationsOf(entity)) for (const endpoint of relation.endpoints) {
2423
- const ref = endpoint.deref();
2424
- if (ref != null && !byId.has(ref.entityId)) queue.push(ref);
2425
- }
2426
- }
2427
- return {
2428
- entities: [...byId.values()],
2429
- issues
2430
- };
2431
- }
2432
- function expectedSequenceShape(kind) {
2433
- if (kind === "video" || kind === "audio" || kind === "voice" || kind === "caption") return {
2434
- extent: "bounded",
2435
- sampling: "native"
2436
- };
2437
- if (kind === "image") return {
2438
- extent: "unbounded",
2439
- sampling: "constant"
2440
- };
2441
- if (kind === "axvideo") return {
2442
- extent: "bounded",
2443
- sampling: "derived"
2444
- };
2445
- }
2446
- function ofKind(relations, kind) {
2447
- return relations.filter((relation) => relation.kind === kind);
2448
- }
2449
- //#endregion
2450
- //#region ../medeo-dsl/src/relation-specs.ts
2451
- const timelineTrackRelationSpec = emptySpec("timeline-track", "timeline", "track");
2452
- const trackClipRelationSpec = emptySpec("track-clip", "track", "clip");
2453
- const clipMarkerRelationSpec = emptySpec("clip-marker", "clip", "sequence-marker");
2454
- const axVideoMarkerRelationSpec = emptySpec("axvideo-marker", "axvideo", "sequence-marker");
2455
- const markerTimelineRelationSpec = emptySpec("marker-timeline", "sequence-marker", "timeline");
2456
- const markerContentRelationSpec = Object.freeze({
2457
- kind: "marker-content",
2458
- validateEndpoints: (endpoints) => hasMarkerAndSequence(endpoints),
2459
- validateMetadata: isEmptyMetadata
2460
- });
2461
- const physicalAssetRelationSpec = Object.freeze({
2462
- kind: "physical-asset",
2463
- validateEndpoints: (endpoints) => hasAssetAndSequence(endpoints),
2464
- validateMetadata: isAssetBinding
2465
- });
2466
- /** `generated(output, input)` means endpoint 0 was generated from endpoint 1. */
2467
- const generatedRelationSpec = Object.freeze({
2468
- kind: "generated",
2469
- validateEndpoints: (endpoints) => endpoints.every((endpoint) => isGeneratedMedia(endpoint.current())),
2470
- validateMetadata: isEmptyMetadata
2471
- });
2472
- const captionAlignmentRelationSpec = Object.freeze({
2473
- kind: "caption-alignment",
2474
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["caption"]), new Set(["audio", "voice"])),
2475
- validateMetadata: isCaptionAlignmentMetadata
2476
- });
2477
- /** `clip-anchor(child, host)` means endpoint 0 follows endpoint 1. */
2478
- const clipAnchorRelationSpec = Object.freeze({
2479
- kind: "clip-anchor",
2480
- validateEndpoints: (endpoints) => endpoints[0].current().entityKind === "clip" && endpoints[1].current().entityKind === "clip",
2481
- validateMetadata: isEmptyMetadata
2482
- });
2483
- /** Voice is generated from PhoneticScript; kinds determine roles regardless of endpoint positions. */
2484
- const phoneticScriptRenderRelationSpec = Object.freeze({
2485
- kind: "phonetic-script-render",
2486
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["voice"]), new Set(["phonetic-script"])),
2487
- validateMetadata: isEmptyMetadata
2488
- });
2489
- /** AudioScript was transcribed from Audio, Video, or recorded Voice; kinds determine roles. */
2490
- const audioScriptSourceRelationSpec = Object.freeze({
2491
- kind: "audio-script-source",
2492
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["audio-script"]), new Set([
2493
- "audio",
2494
- "video",
2495
- "voice"
2496
- ])),
2497
- validateMetadata: isEmptyMetadata
2498
- });
2499
- /**
2500
- * `audio-script-marker(script, marker)` attaches an annotation Sequence Marker
2501
- * whose `segmentRanges` hold directly assigned per-Segment time values. The
2502
- * script keeps no Sequence and no Clip admission; annotation Markers cannot
2503
- * enter Clip/AXVideo use chains (enforced by the marker-use invariant).
2504
- */
2505
- const audioScriptMarkerRelationSpec = Object.freeze({
2506
- kind: "audio-script-marker",
2507
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["audio-script"]), new Set(["sequence-marker"])),
2508
- validateMetadata: isEmptyMetadata
2509
- });
2510
- /** Built-in kinds are reserved; callers may add specs only under new names. */
2511
- const builtInRelationSpecs = Object.freeze([
2512
- timelineTrackRelationSpec,
2513
- trackClipRelationSpec,
2514
- clipMarkerRelationSpec,
2515
- markerContentRelationSpec,
2516
- axVideoMarkerRelationSpec,
2517
- markerTimelineRelationSpec,
2518
- physicalAssetRelationSpec,
2519
- generatedRelationSpec,
2520
- captionAlignmentRelationSpec,
2521
- clipAnchorRelationSpec,
2522
- phoneticScriptRenderRelationSpec,
2523
- audioScriptSourceRelationSpec,
2524
- audioScriptMarkerRelationSpec
2525
- ]);
2526
- function emptySpec(kind, a, b) {
2527
- return metadataSpec(kind, a, b, isEmptyMetadata);
2528
- }
2529
- function metadataSpec(kind, a, b, validateMetadata) {
2530
- return Object.freeze({
2531
- kind,
2532
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set([a]), new Set([b])),
2533
- validateMetadata
2534
- });
2535
- }
2536
- function hasKinds(endpoints, aKinds, bKinds) {
2537
- const first = endpoints[0].current().entityKind;
2538
- const second = endpoints[1].current().entityKind;
2539
- return aKinds.has(first) && bKinds.has(second) || aKinds.has(second) && bKinds.has(first);
2540
- }
2541
- function hasMarkerAndSequence(endpoints) {
2542
- const first = endpoints[0].current();
2543
- const second = endpoints[1].current();
2544
- return first.entityKind === "sequence-marker" && hasSequence(second) || second.entityKind === "sequence-marker" && hasSequence(first);
2545
- }
2546
- function hasAssetAndSequence(endpoints) {
2547
- const first = endpoints[0].current();
2548
- const second = endpoints[1].current();
2549
- return first.entityKind === "asset" && hasSequence(second) || second.entityKind === "asset" && hasSequence(first);
2550
- }
2551
- function isGeneratedMedia(entity) {
2552
- return entity.entityKind === "video" || entity.entityKind === "image" || entity.entityKind === "audio" || entity.entityKind === "voice";
2553
- }
2554
- function isEmptyMetadata(value) {
2555
- return isJsonObject(value) && Object.keys(value).length === 0;
2556
- }
2557
- function isAssetBinding(value) {
2558
- if (!isJsonObject(value)) return false;
2559
- const orderingKey = /(order|ordinal|position|rank|index|z[_-]?index)/i;
2560
- return Object.keys(value).every((key) => !orderingKey.test(key));
2561
- }
2562
- function isCaptionAlignmentMetadata(value) {
2563
- return isJsonObject(value) && Object.hasOwn(value, "alignment");
2564
- }
2565
- //#endregion
2566
- //#region ../medeo-dsl/src/relations.ts
2567
- function createEntityRef(entity) {
2568
- return {
2569
- entityId: entity.entityId,
2570
- current: () => entity
2571
- };
2572
- }
2573
- //#endregion
2574
- //#region ../medeo-dsl/src/relation-index.ts
2575
- var RelationEdge = class {
2576
- relationId;
2577
- kind;
2578
- metadata;
2579
- trace;
2580
- endpoints;
2581
- constructor(relationId, kind, endpoints, metadata, trace) {
2582
- this.relationId = relationId;
2583
- this.kind = kind;
2584
- this.metadata = metadata;
2585
- this.trace = trace;
2586
- this.endpoints = [new WeakRef(endpoints[0]), new WeakRef(endpoints[1])];
2587
- }
2588
- other(entity) {
2589
- const first = this.endpoints[0].deref();
2590
- const second = this.endpoints[1].deref();
2591
- if (first === entity) return this.endpoints[1];
2592
- if (second === entity) return this.endpoints[0];
2593
- }
2594
- toRow() {
2595
- const first = this.endpoints[0].deref();
2596
- const second = this.endpoints[1].deref();
2597
- if (first == null || second == null) return void 0;
2598
- return {
2599
- relationId: this.relationId,
2600
- endpoint0EntityId: first.entityId,
2601
- endpoint1EntityId: second.entityId,
2602
- relationKind: this.kind,
2603
- metadata: this.metadata,
2604
- trace: this.trace
2605
- };
2606
- }
2607
- isStale() {
2608
- return this.endpoints[0].deref() == null || this.endpoints[1].deref() == null;
2609
- }
2610
- };
2611
- /** Endpoint-agnostic secondary index. Relation rows remain the persistence authority. */
2612
- var BiRelationIndex = class {
2613
- byEntity = /* @__PURE__ */ new WeakMap();
2614
- canonicalRefs = /* @__PURE__ */ new Map();
2615
- byRelationId = /* @__PURE__ */ new Map();
2616
- link(input) {
2617
- if (input.spec.kind === "generated") throw new Error("Author generated Relations with linkGenerated({ output, input })");
2618
- if (input.spec.kind === "clip-anchor" || input.spec.kind === "phonetic-script-render" || input.spec.kind === "audio-script-source") throw new Error(`Author ordered ${input.spec.kind} Relations with the dedicated role-named method`);
2619
- return this.linkValidated(input);
2620
- }
2621
- /** Author `generated(output, input)` without exposing positional arguments. */
2622
- linkGenerated(input) {
2623
- return this.linkValidated({
2624
- relationId: input.relationId,
2625
- spec: generatedRelationSpec,
2626
- endpoints: [input.output, input.input],
2627
- metadata: {},
2628
- trace: input.trace
2629
- });
2630
- }
2631
- /** Author `clip-anchor(child, host)` without exposing positional arguments. */
2632
- linkClipAnchor(input) {
2633
- return this.linkValidated({
2634
- relationId: input.relationId,
2635
- spec: clipAnchorRelationSpec,
2636
- endpoints: [input.child, input.host],
2637
- metadata: {},
2638
- trace: input.trace
2639
- });
2640
- }
2641
- /** Author `phonetic-script-render(output, phoneticScript)` without exposing positional arguments. */
2642
- linkPhoneticScriptRender(input) {
2643
- return this.linkValidated({
2644
- relationId: input.relationId,
2645
- spec: phoneticScriptRenderRelationSpec,
2646
- endpoints: [input.output, input.phoneticScript],
2647
- metadata: {},
2648
- trace: input.trace
2649
- });
2650
- }
2651
- /** Author `audio-script-source(script, source)` without exposing positional arguments. */
2652
- linkAudioScriptSource(input) {
2653
- return this.linkValidated({
2654
- relationId: input.relationId,
2655
- spec: audioScriptSourceRelationSpec,
2656
- endpoints: [input.script, input.source],
2657
- metadata: {},
2658
- trace: input.trace
2659
- });
2660
- }
2661
- /**
2662
- * Rehydrate a persisted row after resolving its spec and endpoint refs.
2663
- *
2664
- * This is a storage-boundary escape hatch, not an authoring API: persisted
2665
- * positions already are the semantic assertion made by their relation kind.
2666
- */
2667
- linkRuntime(input) {
2668
- return this.linkValidated(input);
2669
- }
2670
- linkValidated(input) {
2671
- const [first, second] = input.endpoints;
2672
- if (first.entityId === second.entityId) throw new Error("A Relation cannot connect an entity to itself");
2673
- this.assertRelationIdAvailable(input.relationId);
2674
- const builtInSpec = builtInSpecByKind.get(input.spec.kind);
2675
- if (builtInSpec != null && builtInSpec !== input.spec) throw new Error(`Relation kind "${input.spec.kind}" must use its built-in specification`);
2676
- if (isForbiddenAuthoritativeRelation(input.spec.kind, input.endpoints)) throw new Error("A direct Clip-Content Relation is derived-only and cannot be authoritative");
2677
- if (!input.spec.validateEndpoints(input.endpoints)) throw new Error(`Relation "${input.spec.kind}" received invalid endpoints`);
2678
- if (!input.spec.validateMetadata(input.metadata)) throw new Error(`Relation "${input.spec.kind}" received invalid metadata`);
2679
- this.assertCanonicalRefAvailable(first);
2680
- this.assertCanonicalRefAvailable(second);
2681
- const relation = new RelationEdge(input.relationId, input.spec.kind, input.endpoints, input.metadata, input.trace ?? {});
2682
- this.rememberCanonicalRef(first);
2683
- this.rememberCanonicalRef(second);
2684
- this.add(first, relation);
2685
- this.add(second, relation);
2686
- this.byRelationId.set(input.relationId, new WeakRef(relation));
2687
- return relation;
2688
- }
2689
- relationsOf(entity) {
2690
- this.registerCanonicalRef(entity);
2691
- const relations = this.byEntity.get(entity);
2692
- if (relations == null) return /* @__PURE__ */ new Set();
2693
- for (const relation of relations) if (relation instanceof RelationEdge && relation.isStale()) this.unlink(relation);
2694
- return new Set(relations);
2695
- }
2696
- unlink(relation) {
2697
- for (const endpoint of relation.endpoints) {
2698
- const ref = endpoint.deref();
2699
- if (ref != null) this.byEntity.get(ref)?.delete(relation);
2700
- }
2701
- if (this.byRelationId.get(relation.relationId)?.deref() === relation) this.byRelationId.delete(relation.relationId);
2702
- }
2703
- add(entity, relation) {
2704
- const relations = this.byEntity.get(entity) ?? /* @__PURE__ */ new Set();
2705
- relations.add(relation);
2706
- this.byEntity.set(entity, relations);
2707
- }
2708
- registerCanonicalRef(entity) {
2709
- this.assertCanonicalRefAvailable(entity);
2710
- this.rememberCanonicalRef(entity);
2711
- }
2712
- assertCanonicalRefAvailable(entity) {
2713
- const existing = this.canonicalRefs.get(entity.entityId)?.deref();
2714
- if (existing != null && existing !== entity) throw new Error(`Entity "${entity.entityId}" already has a live canonical EntityRef`);
2715
- }
2716
- rememberCanonicalRef(entity) {
2717
- this.canonicalRefs.set(entity.entityId, new WeakRef(entity));
2718
- }
2719
- assertRelationIdAvailable(relationId) {
2720
- if (this.byRelationId.get(relationId)?.deref() != null) throw new Error(`Relation id "${relationId}" already exists`);
2721
- this.byRelationId.delete(relationId);
2722
- }
2723
- };
2724
- const builtInSpecByKind = new Map(builtInRelationSpecs.map((spec) => [spec.kind, spec]));
2725
- function isForbiddenAuthoritativeRelation(kind, endpoints) {
2726
- if (kind === "clip-content") return true;
2727
- const first = endpoints[0].current();
2728
- const second = endpoints[1].current();
2729
- return first.entityKind === "clip" && hasSequence(second) || second.entityKind === "clip" && hasSequence(first);
2730
- }
2731
- //#endregion
2732
- //#region ../medeo-dsl/src/entity-relation-rows.ts
2733
- var InvalidEntityRelationRowsError = class extends Error {
2734
- issues;
2735
- constructor(issues) {
2736
- super(`Invalid Medeo entity/relation rows:\n- ${issues.join("\n- ")}`);
2737
- this.issues = issues;
2738
- this.name = "InvalidEntityRelationRowsError";
2739
- }
2740
- };
2741
- /** Decodes database rows into flat entities and validates the complete relation set. */
2742
- function decodeEntityRelationRows(rows, options) {
2743
- const issues = [];
2744
- const entitiesById = /* @__PURE__ */ new Map();
2745
- const extensionEntityKinds = new Set(options.entityKinds ?? []);
2746
- for (const row of rows.entities) {
2747
- let entity = decodeEntityRow(row, extensionEntityKinds, issues);
2748
- if (entity != null) try {
2749
- entity = decodeEntityRow(assembleEntityContent(rows, row.entityId), extensionEntityKinds, issues);
2750
- } catch (error) {
2751
- issues.push(errorMessage(error));
2752
- continue;
2753
- }
2754
- if (entity == null) continue;
2755
- const ref = createEntityRef(entity);
2756
- if (entitiesById.has(ref.entityId)) {
2757
- issues.push(`duplicate entity id "${ref.entityId}"`);
2758
- continue;
2759
- }
2760
- entitiesById.set(ref.entityId, ref);
2761
- }
2762
- const specsByKind = collectRelationSpecs(options.relationSpecs ?? [], issues);
2763
- const relationIndex = new BiRelationIndex();
2764
- const relations = [];
2765
- const relationIds = /* @__PURE__ */ new Set();
2766
- for (const row of rows.relations) {
2767
- let relationId;
2768
- try {
2769
- relationId = createRelationId(row.relationId);
2770
- } catch (error) {
2771
- issues.push(errorMessage(error));
2772
- continue;
2773
- }
2774
- if (relationIds.has(relationId)) {
2775
- issues.push(`duplicate relation id "${relationId}"`);
2776
- continue;
2777
- }
2778
- relationIds.add(relationId);
2779
- if (!isTrimmedNonEmpty(row.relationKind)) {
2780
- issues.push(`relation "${relationId}" has an empty or untrimmed kind`);
2781
- continue;
2782
- }
2783
- const spec = specsByKind.get(row.relationKind);
2784
- if (spec == null) {
2785
- issues.push(`relation "${relationId}" has no registered spec for kind "${row.relationKind}"`);
2786
- continue;
2787
- }
2788
- let endpoint0EntityId;
2789
- let endpoint1EntityId;
2790
- try {
2791
- endpoint0EntityId = createEntityId(row.endpoint0EntityId);
2792
- endpoint1EntityId = createEntityId(row.endpoint1EntityId);
2793
- } catch (error) {
2794
- issues.push(errorMessage(error));
2795
- continue;
2796
- }
2797
- const endpoint0 = entitiesById.get(endpoint0EntityId);
2798
- const endpoint1 = entitiesById.get(endpoint1EntityId);
2799
- if (endpoint0 == null || endpoint1 == null) {
2800
- const missing = [endpoint0 == null ? endpoint0EntityId : void 0, endpoint1 == null ? endpoint1EntityId : void 0].filter((value) => value != null).join(", ");
2801
- issues.push(`relation "${relationId}" references missing entity id(s): ${missing}`);
2802
- continue;
2803
- }
2804
- if (!isJsonObject(row.metadata) || !isJsonObject(row.trace)) {
2805
- issues.push(`relation "${relationId}" metadata and trace must contain only JSON values`);
2806
- continue;
2807
- }
2808
- try {
2809
- relations.push(relationIndex.linkRuntime({
2810
- relationId,
2811
- spec,
2812
- endpoints: [endpoint0, endpoint1],
2813
- metadata: row.metadata,
2814
- trace: row.trace
2815
- }));
2816
- } catch (error) {
2817
- issues.push(errorMessage(error));
2818
- }
2819
- }
2820
- if (issues.length === 0) for (const issue of validateEntityRelationSet([...entitiesById.values()], relationIndex, options)) issues.push(`${issue.code}: ${issue.message}`);
2821
- if (issues.length > 0) throw new InvalidEntityRelationRowsError(issues);
2822
- return {
2823
- entitiesById,
2824
- relationIndex,
2825
- relations
2826
- };
2827
- }
2828
- function decodeEntityRow(row, extensionEntityKinds, issues) {
2829
- let entityId;
2830
- try {
2831
- entityId = createEntityId(row.entityId);
2832
- } catch (error) {
2833
- issues.push(errorMessage(error));
2834
- return;
2835
- }
2836
- if (!isTrimmedNonEmpty(row.entityKind)) {
2837
- issues.push(`entity "${entityId}" has an empty or untrimmed kind`);
2838
- return;
2839
- }
2840
- if (isReservedEntityKind(row.entityKind)) {
2841
- issues.push(`entity "${entityId}" uses reserved kind "${row.entityKind}"`);
2842
- return;
2843
- }
2844
- if (!isKnownEntityKind(row.entityKind) && !extensionEntityKinds.has(row.entityKind)) {
2845
- issues.push(`entity "${entityId}" has unregistered extension kind "${row.entityKind}"`);
2846
- return;
2847
- }
2848
- if (!isJsonObject(row.payload)) {
2849
- issues.push(`entity "${entityId}" payload must contain only JSON values`);
2850
- return;
2851
- }
2852
- const reserved = ["entityId", "entityKind"].filter((key) => Object.hasOwn(row.payload, key));
2853
- if (reserved.length > 0) {
2854
- issues.push(`entity "${entityId}" payload contains reserved field(s): ${reserved.join(", ")}`);
2855
- return;
2856
- }
2857
- return {
2858
- ...row.payload,
2859
- entityId,
2860
- entityKind: row.entityKind
2861
- };
2862
- }
2863
- function collectRelationSpecs(extensionSpecs, issues) {
2864
- const specs = /* @__PURE__ */ new Map();
2865
- for (const spec of builtInRelationSpecs) specs.set(spec.kind, spec);
2866
- for (const spec of extensionSpecs) {
2867
- if (!isTrimmedNonEmpty(spec.kind)) {
2868
- issues.push("extension relation spec has an empty or untrimmed kind");
2869
- continue;
2870
- }
2871
- if (specs.has(spec.kind)) {
2872
- issues.push(`relation spec kind "${spec.kind}" is already registered`);
2873
- continue;
2874
- }
2875
- specs.set(spec.kind, spec);
2876
- }
2877
- return specs;
2878
- }
2879
- function isTrimmedNonEmpty(value) {
2880
- return value.length > 0 && value.trim() === value;
2881
- }
2882
- function errorMessage(error) {
2883
- return error instanceof Error ? error.message : String(error);
2884
- }
2885
- //#endregion
2886
- //#region src/document/entity-projection-values.ts
2887
- /** A graph cannot be represented faithfully by the compatibility reader. */
2888
- var EntityTimelineProjectionError = class extends Error {
2889
- constructor(message) {
2890
- super(message);
2891
- this.name = "EntityTimelineProjectionError";
2892
- }
2893
- };
2894
- function fail(message) {
2895
- throw new EntityTimelineProjectionError(message);
2896
- }
2897
- function object(value) {
2898
- return value !== void 0 && value !== null && typeof value === "object" && !Array.isArray(value);
2899
- }
2900
- function finite(value, label) {
2901
- if (typeof value !== "number" || !Number.isFinite(value)) fail(`${label} must be finite`);
2902
- return value;
2903
- }
2904
- function positive(value, label) {
2905
- const result = finite(value, label);
2906
- if (result <= 0) fail(`${label} must be positive`);
2907
- return result;
2908
- }
2909
- function milliseconds(value, label) {
2910
- const result = finite(value, label);
2911
- if (!Number.isSafeInteger(result) || result < 0) fail(`${label} must be non-negative whole milliseconds`);
2912
- return result;
2913
- }
2914
- function range(value, label) {
2915
- if (!object(value)) fail(`${label} must be a numeric range`);
2916
- const start = finite(value.start, `${label}.start`);
2917
- const end = finite(value.end, `${label}.end`);
2918
- if (start >= end) fail(`${label} must be non-empty`);
2919
- return {
2920
- start,
2921
- end
2922
- };
2923
- }
2924
- //#endregion
2925
- //#region src/document/entity-timeline-layout.ts
2926
- /** Validated graph lookup shared by the compatibility layout and projection. */
2927
- var EntityProjectionGraph = class {
2928
- rows;
2929
- entities;
2930
- timeline;
2931
- constructor(rows) {
2932
- this.rows = rows;
2933
- const decoded = decodeEntityRelationRows(rows, { compareMarkerPoints: (_marker, _range, left, right) => finite(left, "range point") - finite(right, "range point") });
2934
- this.entities = new Map([...decoded.entitiesById.values()].map((ref) => [String(ref.entityId), entityToRow(ref.current())]));
2935
- const roots = [...this.entities.values()].filter((row) => row.entityKind === "timeline");
2936
- if (roots.length !== 1) fail("compatibility projection requires exactly one Timeline");
2937
- this.timeline = roots[0];
2938
- }
2939
- entity(id, kind) {
2940
- const row = this.entities.get(id);
2941
- if (row === void 0 || kind !== void 0 && row.entityKind !== kind) fail(`Expected ${kind ?? "Entity"} ${id}`);
2942
- return row;
2943
- }
2944
- adjacent(id, kind) {
2945
- 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));
2946
- }
2947
- one(id, relation, kind) {
2948
- const values = this.adjacent(id, relation);
2949
- if (values.length !== 1 || kind !== void 0 && values[0]?.entityKind !== kind) fail(`entity ${id} requires exactly one ${relation}${kind === void 0 ? "" : ` to ${kind}`}`);
2950
- return values[0];
2951
- }
2952
- };
2953
- const roleOrder = {
2954
- caption: 0,
2955
- video_clip: 1,
2956
- speech: 2,
2957
- bgm: 3
2958
- };
2959
- /**
2960
- * Resolve only entity-owned placement facts. This does not choose new anchors,
2961
- * manufacture clips, or mutate a graph while reading. Write-side operations
2962
- * must express any detach/reparent/overlap decision in their entity plan.
2963
- */
2964
- function resolveEntityTimelineLayout(rows) {
2965
- return resolveGraphLayout(new EntityProjectionGraph(rows));
2966
- }
2967
- function resolveGraphLayout(graph) {
2968
- const facts = /* @__PURE__ */ new Map();
2969
- const seenRoles = /* @__PURE__ */ new Set();
2970
- const tracks = graph.adjacent(graph.timeline.entityId, "timeline-track").map((track) => {
2971
- const role = track.payload.role;
2972
- if (track.entityKind !== "track" || typeof role !== "string" || !Object.hasOwn(roleOrder, role)) fail(`unsupported compatibility Track role on ${track.entityId}`);
2973
- if (seenRoles.has(role)) fail(`The compatibility editor supports only one ${role} Track`);
2974
- seenRoles.add(role);
2975
- const typedRole = role;
2976
- if (track.payload.hidden !== void 0 && typeof track.payload.hidden !== "boolean") fail("Track hidden must be boolean");
2977
- const order = track.payload.order === void 0 ? roleOrder[typedRole] : finite(track.payload.order, "Track order");
2978
- const trackFacts = graph.adjacent(track.entityId, "track-clip").map((clip) => {
2979
- if (clip.entityKind !== "clip" || graph.adjacent(clip.entityId, "track-clip").length !== 1) fail(`Clip ${clip.entityId} must belong to exactly one Track`);
2980
- const marker = graph.one(clip.entityId, "clip-marker", "sequence-marker");
2981
- const content = graph.one(marker.entityId, "marker-content");
2982
- 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`);
2983
- const space = content.payload.coordinateSpace;
2984
- if (space !== "ms" && space !== "milliseconds" && (!object(space) || space.unit !== "ms" && space.unit !== "milliseconds")) fail(`media ${content.entityId} must state millisecond coordinates for this reader`);
2985
- const source = range(marker.payload.sourceRange, "sourceRange");
2986
- const durationFact = marker.payload.duration;
2987
- if (!object(durationFact)) fail(`Marker ${marker.entityId} requires duration`);
2988
- const duration = positive(durationFact.mode === "from-source" ? source.end - source.start : durationFact.value, "duration");
2989
- if (!Number.isSafeInteger(duration)) fail("Compatibility duration must be whole milliseconds");
2990
- const fillTimeline = marker.payload.durationPolicy === "timeline";
2991
- if (marker.payload.durationPolicy !== void 0 && (!fillTimeline || typedRole !== "bgm")) fail("durationPolicy is only supported for timeline-filling background Audio");
2992
- const anchors = graph.rows.relations.filter((row) => row.relationKind === "clip-anchor" && row.endpoint0EntityId === clip.entityId);
2993
- if (anchors.length > 1) fail(`Clip ${clip.entityId} has multiple anchors`);
2994
- const target = marker.payload.targetRange === void 0 ? void 0 : range(marker.payload.targetRange, "targetRange");
2995
- const anchorId = anchors[0]?.endpoint1EntityId;
2996
- let mode;
2997
- let absoluteStart;
2998
- let anchorOffset;
2999
- let clipOrder;
3000
- if (anchorId !== void 0) {
3001
- if (target !== void 0) fail(`Anchored Marker ${marker.entityId} cannot also have targetRange`);
3002
- mode = "anchored";
3003
- anchorOffset = milliseconds(marker.payload.anchorOffset, "anchorOffset");
3004
- } else {
3005
- if (marker.payload.anchorOffset !== void 0) fail(`Marker ${marker.entityId} has anchorOffset without a clip-anchor Relation`);
3006
- if (target !== void 0) {
3007
- mode = "absolute";
3008
- absoluteStart = milliseconds(target.start, "target start");
3009
- milliseconds(target.end, "target end");
3010
- if (Math.abs(target.end - target.start - duration) > 1e-6) fail(`Marker ${marker.entityId} targetRange must match its effective duration`);
3011
- } else {
3012
- mode = "sequential";
3013
- if (clip.payload.order === void 0) fail(`Clip ${clip.entityId} has no placement: targetRange or explicit order is required`);
3014
- clipOrder = finite(clip.payload.order, "Clip order");
3015
- }
3016
- }
3017
- const fact = {
3018
- clip,
3019
- marker,
3020
- content,
3021
- trackId: track.entityId,
3022
- role: typedRole,
3023
- duration,
3024
- mode,
3025
- order: clipOrder,
3026
- absoluteStart,
3027
- anchorId,
3028
- anchorOffset,
3029
- fillTimeline
3030
- };
3031
- facts.set(clip.entityId, fact);
3032
- return fact;
3033
- });
3034
- if (typedRole === "bgm" && trackFacts.length > 1) fail("The compatibility editor supports only one background Audio Clip");
3035
- const sequential = trackFacts.filter((fact) => fact.mode === "sequential").sort((left, right) => left.order - right.order);
3036
- if (new Set(sequential.map((fact) => fact.order)).size !== sequential.length) fail("sequential Clips must have distinct order values");
3037
- sequential.forEach((fact, index) => {
3038
- fact.predecessor = sequential[index - 1]?.clip.entityId;
3039
- });
3040
- return {
3041
- entityId: String(track.entityId),
3042
- role: typedRole,
3043
- order,
3044
- hidden: track.payload.hidden === true,
3045
- trackFacts
3046
- };
3047
- });
3048
- tracks.sort((left, right) => left.order - right.order || left.entityId.localeCompare(right.entityId));
3049
- const resolved = /* @__PURE__ */ new Map();
3050
- const visiting = /* @__PURE__ */ new Set();
3051
- const resolve = (id) => {
3052
- const cached = resolved.get(id);
3053
- if (cached !== void 0) return cached;
3054
- if (visiting.has(id)) fail(`Cyclic Clip placement dependency at ${id}`);
3055
- visiting.add(id);
3056
- const fact = facts.get(id);
3057
- if (fact === void 0) fail(`Placement refers to unplaced Clip ${id}`);
3058
- let start = fact.absoluteStart ?? 0;
3059
- if (fact.mode === "anchored") {
3060
- const host = resolve(fact.anchorId);
3061
- if (fact.role === "speech" && !["video_clip", "bgm"].includes(facts.get(host.clipEntityId).role)) fail("An anchored Voice must use a visual or BGM Clip as its host");
3062
- start = host.start + fact.anchorOffset;
3063
- } else if (fact.predecessor !== void 0) {
3064
- const prior = resolve(fact.predecessor);
3065
- start = prior.start + prior.duration;
3066
- }
3067
- milliseconds(start + fact.duration, "Clip end");
3068
- const result = {
3069
- clipEntityId: id,
3070
- markerEntityId: fact.marker.entityId,
3071
- contentEntityId: fact.content.entityId,
3072
- trackEntityId: fact.trackId,
3073
- start: milliseconds(start, "Clip start"),
3074
- duration: fact.duration,
3075
- mode: fact.mode,
3076
- ...fact.anchorId === void 0 ? {} : {
3077
- anchorClipEntityId: fact.anchorId,
3078
- anchorOffset: fact.anchorOffset
3079
- }
3080
- };
3081
- visiting.delete(id);
3082
- resolved.set(id, result);
3083
- return result;
3084
- };
3085
- for (const id of facts.keys()) resolve(id);
3086
- const durationMs = Math.max(0, ...[...resolved.values()].filter((clip) => facts.get(clip.clipEntityId).role !== "bgm").map((clip) => clip.start + clip.duration));
3087
- for (const [id, fact] of facts) if (fact.fillTimeline) {
3088
- const clip = resolved.get(id);
3089
- resolved.set(id, {
3090
- ...clip,
3091
- duration: durationMs
3092
- });
3093
- }
3094
- return {
3095
- durationMs,
3096
- clips: resolved,
3097
- tracks: tracks.map(({ trackFacts, ...track }) => ({
3098
- ...track,
3099
- clips: trackFacts.map((fact) => resolved.get(fact.clip.entityId)).sort((left, right) => left.start - right.start || left.clipEntityId.localeCompare(right.clipEntityId))
3100
- }))
3101
- };
3102
- }
3103
- //#endregion
3104
- //#region src/document/entity-projection.ts
3105
- /**
3106
- * One-way compatibility view over the Entity graph. A legacy part is never
3107
- * consulted as an editing fact. Peer IDs appear only in this derived read
3108
- * contract, reconstructed from relations; they are not stored in payloads.
3109
- */
3110
- function projectEntityTimeline(rows, meta) {
3111
- const graph = new EntityProjectionGraph(rows);
3112
- const layout = resolveGraphLayout(graph);
3113
- for (const track of layout.tracks) {
3114
- if (track.role !== "video_clip") continue;
3115
- for (let index = 1; index < track.clips.length; index += 1) {
3116
- const previous = track.clips[index - 1];
3117
- 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");
3118
- }
3119
- }
3120
- const parts = {};
3121
- for (const track of layout.tracks) for (const placement of track.clips) {
3122
- const clip = graph.entity(placement.clipEntityId, "clip");
3123
- const marker = graph.entity(placement.markerEntityId, "sequence-marker");
3124
- const content = graph.entity(placement.contentEntityId);
3125
- const volume = volumeSchema.safeParse(clip.payload.volume ?? 0);
3126
- if (!volume.success) fail("Clip volume must be decibels between -60 and 20");
3127
- if (track.role === "video_clip") {
3128
- const asset = mediaAssetFacts(graph, content);
3129
- const source = range(marker.payload.sourceRange, "sourceRange");
3130
- const external = externalLocator(asset, "memota");
3131
- const speed = videoSpeed(content, marker, placement);
3132
- parts[clip.entityId] = { video_clip: {
3133
- id: clip.entityId,
3134
- kind: "video_clip",
3135
- origin_media_id: external.key,
3136
- play_in: content.entityKind === "image" && speed === void 0 ? 0 : source.start,
3137
- play_out: content.entityKind === "image" && speed === void 0 ? placement.duration : source.end,
3138
- volume: volume.data,
3139
- ...speed === void 0 ? {} : { speed_shift: speed }
3140
- } };
3141
- } else if (track.role === "speech") {
3142
- const asset = mediaAssetFacts(graph, content);
3143
- requireUntrimmedAudio(content, marker, placement);
3144
- const external = externalLocator(asset, "memota-speech");
3145
- const assembled = graph.adjacent(content.entityId, "phonetic-script-render").length > 0 || content.payload.voice !== void 0 ? assemblePhoneticScriptContent(rows, graph.one(content.entityId, "phonetic-script-render", "phonetic-script").entityId) : void 0;
3146
- const voice = content.payload.voice;
3147
- if (voice !== void 0 && (!object(voice) || voice.system !== "voice-library" || typeof voice.key !== "string")) fail("Voice must use an explicit voice-library locator");
3148
- const captionIds = layout.tracks.flatMap((candidate) => candidate.role === "caption" ? candidate.clips.filter((caption) => caption.anchorClipEntityId === clip.entityId).map((caption) => caption.clipEntityId) : []);
3149
- parts[clip.entityId] = { speech: {
3150
- id: clip.entityId,
3151
- kind: "speech",
3152
- media_duration_ms: placement.duration,
3153
- audio_storage_key: storageKey(asset),
3154
- origin_speech_id: external.key,
3155
- audio_script: assembled?.text ?? (graph.adjacent(content.entityId, "audio-script-source").length > 0 ? assembleScriptText(graph.one(content.entityId, "audio-script-source", "audio-script")) : ""),
3156
- volume: volume.data,
3157
- voice: object(voice) ? {
3158
- id: typeof voice.key === "string" ? voice.key : void 0,
3159
- name: typeof voice.name === "string" ? voice.name : void 0
3160
- } : void 0,
3161
- caption_ids: captionIds
3162
- } };
3163
- } else if (track.role === "caption") {
3164
- const text = assembleCaptionContent(rows, content.entityId).text;
3165
- if (marker.payload.timeRemapping !== void 0) fail("Caption timeRemapping is not supported by this reader");
3166
- const style = captionStyle(content.payload.style);
3167
- const anchor = placement.anchorClipEntityId === void 0 ? void 0 : layout.clips.get(placement.anchorClipEntityId);
3168
- const anchoredToSpeech = anchor !== void 0 && trackRoleOf(layout, anchor.trackEntityId) === "speech";
3169
- parts[clip.entityId] = { caption: {
3170
- id: clip.entityId,
3171
- kind: "caption",
3172
- text,
3173
- speech_part_id: anchoredToSpeech ? placement.anchorClipEntityId : void 0,
3174
- start_ms: anchoredToSpeech ? placement.anchorOffset : 0,
3175
- initial_duration_ms: placement.duration,
3176
- ...style === void 0 ? {} : { style }
3177
- } };
3178
- } else {
3179
- const asset = mediaAssetFacts(graph, content);
3180
- const external = externalLocator(asset, "memota");
3181
- if (marker.payload.durationPolicy !== "timeline") fail("Background Audio requires an explicit timeline-fill durationPolicy");
3182
- if (marker.payload.timeRemapping !== void 0) fail("BGM timeRemapping is not supported by this reader");
3183
- const source = range(marker.payload.sourceRange, "sourceRange");
3184
- const extent = content.payload.extent;
3185
- if (!object(extent) || extent.start !== source.start || extent.end !== source.end) fail("Background Audio compatibility requires the complete source");
3186
- parts[clip.entityId] = { bgm: {
3187
- id: clip.entityId,
3188
- kind: "bgm",
3189
- audio_storage_key: storageKey(asset),
3190
- origin_media_id: external.key,
3191
- volume: volume.data
3192
- } };
3193
- }
3194
- }
3195
- const document = {
3196
- meta: {
3197
- ...structuredClone(meta),
3198
- schema_version: ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION
3199
- },
3200
- timeline: graph.timeline.payload.unit_time_ms === void 0 ? void 0 : { unit_time_ms: positive(graph.timeline.payload.unit_time_ms, "unit_time_ms") },
3201
- tracks: layout.tracks.map((track) => ({
3202
- id: track.entityId,
3203
- parts_kind: track.role,
3204
- is_hidden: track.hidden,
3205
- items: track.clips.map((clip) => clip.mode === "anchored" ? {
3206
- part_id: clip.clipEntityId,
3207
- time_position: {
3208
- mode: "anchored",
3209
- anchorPartId: clip.anchorClipEntityId,
3210
- offsetMs: clip.anchorOffset
3211
- },
3212
- fallback_abs_ms: clip.start
3213
- } : {
3214
- part_id: clip.clipEntityId,
3215
- time_position: {
3216
- mode: "absolute",
3217
- offsetMs: clip.start
3218
- }
3219
- })
3220
- })),
3221
- part_library: parts
3222
- };
3223
- assertValidVideoDocument(document);
3224
- return document;
3225
- }
3226
- function trackRoleOf(layout, trackEntityId) {
3227
- return layout.tracks.find((track) => track.entityId === trackEntityId)?.role;
3228
- }
3229
- /**
3230
- * Media facts are read from the variant itself: a media variant owns its
3231
- * Asset identity and carries `external`/`storageKey` directly. Dual-entity
3232
- * media graphs are rejected instead of being resolved through a legacy
3233
- * `physical-asset` binding.
3234
- */
3235
- function mediaAssetFacts(graph, content) {
3236
- if (graph.adjacent(content.entityId, "physical-asset").length > 0) fail("Media " + content.entityId + " owns its Asset identity; remove its physical-asset Relation");
3237
- if (!object(content.payload.external)) fail("Media " + content.entityId + " must carry its own Asset external identity");
3238
- return {
3239
- entityId: String(content.entityId),
3240
- payload: content.payload
3241
- };
3242
- }
3243
- function externalLocator(asset, system) {
3244
- const value = asset.payload.external;
3245
- if (!object(value) || value.system !== system || typeof value.key !== "string" || value.key.trim() === "") fail("Asset " + asset.entityId + " requires an explicit " + system + " external key");
3246
- return { key: value.key };
3247
- }
3248
- function storageKey(asset) {
3249
- if (typeof asset.payload.storageKey !== "string" || asset.payload.storageKey.trim() === "") fail("Asset " + asset.entityId + " requires its factual storageKey");
3250
- return asset.payload.storageKey;
3251
- }
3252
- function captionStyle(value) {
3253
- if (value === void 0) return void 0;
3254
- if (!object(value)) fail("Caption style must be an object");
3255
- const { font, ...owned } = structuredClone(value);
3256
- const fields = {
3257
- fontSize: "font_size",
3258
- fontColor: "font_color",
3259
- fontWeight: "font_weight",
3260
- entranceAnimation: "entrance_animation",
3261
- entranceAnimationDurationMs: "entrance_animation_duration_ms",
3262
- strokeColor: "stroke_color",
3263
- strokeWidth: "stroke_width",
3264
- positionX: "position_x",
3265
- positionY: "position_y"
3266
- };
3267
- const style = {};
3268
- for (const [key, field] of Object.entries(owned)) {
3269
- if (!Object.hasOwn(fields, key)) fail("Unsupported Caption style field " + key);
3270
- style[fields[key]] = field;
3271
- }
3272
- if (font !== void 0) {
3273
- if (!object(font) || font.system !== "font-library" || typeof font.key !== "string") fail("Caption font requires a font-library locator");
3274
- return {
3275
- ...style,
3276
- font_id: font.key
3277
- };
3278
- }
3279
- return style;
3280
- }
3281
- function requireUntrimmedAudio(content, marker, placement) {
3282
- const source = range(marker.payload.sourceRange, "sourceRange");
3283
- const extent = content.payload.extent;
3284
- 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");
3285
- }
3286
- function videoSpeed(content, marker, placement) {
3287
- const remapping = marker.payload.timeRemapping;
3288
- if (content.entityKind === "image" && remapping === void 0) return void 0;
3289
- const source = range(marker.payload.sourceRange, "sourceRange");
3290
- if (remapping !== void 0) {
3291
- if (!object(remapping) || remapping.kind !== "linear") fail("unsupported timeRemapping on " + marker.entityId);
3292
- if (Object.keys(remapping).some((key) => key !== "kind" && key !== "rate" && key !== "mode")) fail("Unsupported linear timeRemapping fields");
3293
- const rate = positive(remapping.rate, "linear remapping rate");
3294
- if (Math.round((source.end - source.start) / rate) !== placement.duration) fail("timeRemapping on " + marker.entityId + " must agree with Marker duration");
3295
- if (remapping.mode !== void 0 && typeof remapping.mode !== "string") fail("Linear remapping mode must be a string");
3296
- return {
3297
- category: "linear",
3298
- mode: typeof remapping.mode === "string" ? remapping.mode : "constant",
3299
- config: { linear: { speed: rate } }
3300
- };
3301
- }
3302
- const rate = (source.end - source.start) / placement.duration;
3303
- return rate === 1 ? void 0 : {
3304
- category: "linear",
3305
- mode: "constant",
3306
- config: { linear: { speed: rate } }
3307
- };
3308
- }
3309
- //#endregion
3310
- //#region src/document/mirror-read.ts
3311
- /**
3312
- * Project the mirror state (`VideoDocumentDraft`) into the authoritative
3313
- * `VideoDocument`. The storage shape is isomorphic to the domain shape (RFC 03
3314
- * §4, reference/17 §4: meta map + a single `tracks` list + part_library), so this
3315
- * is a near-identity — it reads `time_position` / `fallback_abs_ms` JSON blobs
3316
- * back into structured values and trims empty strings, nothing more.
3317
- *
3318
- * It maps only authoritative facts (RFC 02 §6): `part_id` + `time_position`. The
3319
- * projection-derived `VideoDraft` read-view (absolute time, `part_aggregations`,
3320
- * total duration) is solved separately by the cascade, not here.
3321
- *
3322
- * It reads in-memory mirror state (O(n) over the document), not a Loro
3323
- * `toJSON()` FFI rebuild — the cost the prior schema adapter paid on every
3324
- * `snapshot()`.
3325
- */
3326
- function readVideoDocumentFromDraft(draft) {
3327
- const partLibrary = {};
3328
- for (const [partId, payload] of recordEntries(draft.part_library)) {
3329
- const part = draftToPartUnion(payload);
3330
- if (part != null) partLibrary[partId] = part;
3331
- }
3332
- const timeline = draft.timeline;
3333
- const hasTimeline = timeline?.unit_time_ms != null;
3334
- const meta = draft.meta;
3335
- return {
3336
- meta: {
3337
- schema_version: meta?.schema_version === "video-document/entity-projection-v1" ? ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION : VIDEO_DOCUMENT_SCHEMA_VERSION,
3338
- draft_id: emptyToUndefined(meta?.draft_id),
3339
- project_id: emptyToUndefined(meta?.project_id),
3340
- owner_id: emptyToUndefined(meta?.owner_id),
3341
- thumbnail_storage_key: emptyToUndefined(meta?.thumbnail_storage_key),
3342
- chat_session_id: emptyToUndefined(meta?.chat_session_id),
3343
- video_creation_settings: meta?.video_creation_settings ?? void 0,
3344
- version: meta?.version ?? void 0
3345
- },
3346
- timeline: hasTimeline ? { unit_time_ms: timeline.unit_time_ms } : void 0,
3347
- tracks: rowsToTracks(draft.tracks),
3348
- part_library: partLibrary
3349
- };
3350
- }
3351
- /** Map a movable-list of track rows (`$cid`-bearing) into domain tracks, dropping empty-id placeholders. */
3352
- function rowsToTracks(rows) {
3353
- return (rows ?? []).filter((row) => row.id != null && row.id !== "").map(rowToTrack);
3354
- }
3355
- function rowToTrack(row) {
3356
- return {
3357
- id: row.id,
3358
- parts_kind: row.parts_kind ?? void 0,
3359
- is_hidden: row.is_hidden ?? void 0,
3360
- items: (row.items ?? []).map((item) => {
3361
- const result = {
3362
- part_id: item.part_id ?? "",
3363
- time_position: item.time_position
3364
- };
3365
- if (item.fallback_abs_ms != null) result.fallback_abs_ms = item.fallback_abs_ms;
3366
- return result;
3367
- })
3368
- };
3369
- }
3370
- function emptyToUndefined(value) {
3371
- return value == null || value === "" ? void 0 : value;
3372
- }
3373
- //#endregion
3374
- //#region src/document/mirror-adapter.ts
3375
- /**
3376
- * Storage-layer adapter that backs a `VideoDocument` with `loro-mirror` (ADR
3377
- * 0008). The mirror holds an in-memory immutable state synced to the `LoroDoc`
3378
- * by declarative diff, replacing the hand-rolled `@mengine/schema` adapter:
3379
- *
3380
- * - `snapshot()` projects the mirror's in-memory state to the read model — no
3381
- * per-call `toJSON()` FFI rebuild.
3382
- * - `transact(edit, audit)` runs the whole op in one `mirror.setState` callback:
3383
- * one diff, one `doc.commit` carrying the audit message. The callback edits an
3384
- * immer draft, so a throw inside it discards the draft and never touches Loro
3385
- * (natural rollback) — no `guard` / `rollback` / `openTransaction` machinery.
3386
- * - mirror's `idSelector` (track items keyed by `part_id`) diffs reorders to
3387
- * real Loro `move` ops on every lane, so per-item CRDT identity survives on
3388
- * main and secondary tracks alike.
3389
- */
3390
- var MirrorVideoDocumentAdapter = class {
3391
- doc;
3392
- mirror;
3393
- constructor(doc) {
3394
- this.doc = doc;
3395
- this.mirror = new Mirror({
3396
- doc,
3397
- schema: videoDocumentMirrorSchema
3398
- });
3399
- }
3400
- snapshot() {
3401
- return readVideoDocumentFromDraft(this.mirror.getState());
3402
- }
3403
- /**
3404
- * True once the doc holds real document content. A fresh mirror over an empty
3405
- * doc still reports defaulted root maps, so probe the stored `schema_version`
3406
- * (empty until a snapshot is bootstrapped or synced in).
3407
- */
3408
- hasContent() {
3409
- return (this.mirror.getState().meta?.schema_version ?? "") !== "";
3410
- }
3411
- /**
3412
- * Apply one op as a single transaction. `edit` mutates the immer draft; mirror
3413
- * diffs the result and commits once with the audit `message`. A throw in `edit`
3414
- * discards the draft (Loro untouched). When `edit` produces no change, mirror
3415
- * skips the commit — matching the prior "empty op leaves no audit" behavior.
3416
- */
3417
- transact(edit, audit) {
3418
- 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");
3419
- this.mirror.setState((draft) => {
3420
- edit(draft);
3421
- }, {
3422
- origin: "mengine.semantic_editor",
3423
- message: JSON.stringify({
3424
- semantic_op: audit.kind,
3425
- payload: audit.payload,
3426
- intent: audit.intent ?? null,
3427
- actor: audit.actor ?? null
3428
- })
3429
- });
3430
- }
3431
- };
3432
- /** Build a fresh Loro doc seeded with `document` through the mirror. */
3433
- function createMirrorVideoDocument(document, options = {}) {
3434
- assertValidVideoDocument(document);
3435
- const doc = new LoroDoc();
3436
- if (options.peerId != null) doc.setPeerId(options.peerId);
3437
- new Mirror({
3438
- doc,
3439
- schema: videoDocumentMirrorSchema
3440
- }).setState((draft) => {
3441
- writeVideoDocumentToDraft(draft, document);
3442
- }, { origin: options.origin ?? "mengine.bootstrap" });
3443
- return doc;
3444
- }
3445
- /** Build a `MirrorVideoDocumentAdapter` over a fresh doc seeded with `document`. */
3446
- function createMirrorVideoDocumentAdapter(document, options) {
3447
- return new MirrorVideoDocumentAdapter(createMirrorVideoDocument(document, options));
3448
- }
3449
- /** Host-only replacement of a derived view, retaining the existing Loro history. */
3450
- function applyEntityTimelineProjection(doc, projection, entityRevision) {
3451
- if (projection.meta.schema_version !== "video-document/entity-projection-v1") throw new Error("Expected an entity timeline projection");
3452
- if (!Number.isSafeInteger(entityRevision) || entityRevision < 1) throw new Error("Expected a positive entity revision");
3453
- assertValidVideoDocument(projection);
3454
- const from = doc.oplogVersion();
3455
- new Mirror({
3456
- doc,
3457
- schema: videoDocumentMirrorSchema
3458
- }).setState((draft) => writeVideoDocumentToDraft(draft, {
3459
- ...projection,
3460
- meta: {
3461
- ...projection.meta,
3462
- version: entityRevision
3463
- }
3464
- }), {
3465
- origin: "mengine.entity_projection",
3466
- message: JSON.stringify({
3467
- semantic_op: "EntityTimelineProjection",
3468
- entity_revision: entityRevision
3469
- })
3470
- });
3471
- return doc.export({
3472
- mode: "update",
3473
- from
3474
- });
3475
- }
3476
- /** Write a whole `VideoDocument` into a draft (seed a fresh doc / plain-memory state). */
3477
- function writeVideoDocumentToDraft(draft, document) {
3478
- draft.meta = {
3479
- schema_version: document.meta.schema_version,
3480
- draft_id: document.meta.draft_id ?? void 0,
3481
- project_id: document.meta.project_id ?? void 0,
3482
- owner_id: document.meta.owner_id ?? void 0,
3483
- thumbnail_storage_key: document.meta.thumbnail_storage_key ?? void 0,
3484
- chat_session_id: document.meta.chat_session_id ?? void 0,
3485
- video_creation_settings: document.meta.video_creation_settings ?? void 0,
3486
- version: document.meta.version ?? void 0
3487
- };
3488
- draft.timeline = { unit_time_ms: document.timeline?.unit_time_ms ?? void 0 };
3489
- draft.tracks = (document.tracks ?? []).map(toTrackRow);
3490
- draft.part_library = {};
3491
- for (const [partId, part] of Object.entries(document.part_library ?? {})) draft.part_library[partId] = partUnionToDraft(part);
3492
- }
3493
- /** Map a domain `Track` to a draft track row (no lane/lane_order — see schema). */
3494
- function toTrackRow(track) {
3495
- return {
3496
- id: track.id ?? "",
3497
- parts_kind: track.parts_kind ?? void 0,
3498
- is_hidden: track.is_hidden ?? void 0,
3499
- items: (track.items ?? []).map((item) => ({
3500
- part_id: item.part_id,
3501
- time_position: item.time_position,
3502
- fallback_abs_ms: item.fallback_abs_ms
3503
- }))
3504
- };
3505
- }
3506
- //#endregion
3507
- //#region src/editor/id-gen.ts
3508
- /**
3509
- * Part-id generation, aligned with the online ecosystem.
3510
- *
3511
- * The authoritative online producers — agent-harness (`@harness/shared`
3512
- * `genObjId`) and director.v2 (`common/obj_id.py` `gen_obj_id`) — both mint part
3513
- * ids as `` `${prefix}_${ulid()}` ``, and real captured drafts use exactly that
3514
- * shape (`clip_…` / `spe_…` / `cap_…` / `bgm_…`, each a 26-char ULID). The engine
3515
- * previously emitted `vc_<base36 timestamp><6 random>`, a different prefix AND a
3516
- * different encoding — the sole cross-repo id divergence. This module removes it
3517
- * by emitting the same `<prefix>_<ULID>` bytes.
3518
- *
3519
- * The ULID is generated inline (Crockford Base32, 48-bit time + 80-bit random)
3520
- * rather than pulling the `ulid` npm package: the randomness class matches the
3521
- * old generator (both `Math.random`-based) and it keeps `@mengine/medeo-client`
3522
- * dependency-free for a purely mechanical id string. Part ids only need to be
3523
- * unique and lexicographically time-sortable, which this satisfies.
3524
- */
3525
- /** Crockford Base32 alphabet (no I, L, O, U), per the ULID spec. */
3526
- const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
3527
- const TIME_LEN = 10;
3528
- const RANDOM_LEN = 16;
3529
- function encodeTime(now) {
3530
- let out = "";
3531
- let ms = now;
3532
- for (let i = TIME_LEN - 1; i >= 0; i--) {
3533
- const mod = ms % 32;
3534
- out = CROCKFORD[mod] + out;
3535
- ms = (ms - mod) / 32;
3536
- }
3537
- return out;
3538
- }
3539
- function encodeRandom() {
3540
- let out = "";
3541
- for (let i = 0; i < RANDOM_LEN; i++) out += CROCKFORD[Math.floor(Math.random() * 32)];
3542
- return out;
3543
- }
3544
- /** A 26-char Crockford Base32 ULID (10-char time + 16-char random). */
3545
- function ulid() {
3546
- return encodeTime(Date.now()) + encodeRandom();
3547
- }
3548
- function generatePartId(prefix) {
3549
- return `${prefix}_${ulid()}`;
3550
- }
3551
- //#endregion
3552
- //#region src/document/plain-memory-adapter.ts
3553
- /**
3554
- * Pure in-memory `SemanticDocumentAdapter` — no Loro/WASM. Holds a
3555
- * `VideoDocumentDraft` object and applies each `transact` via immer `produce`,
3556
- * journaling every audit that actually mutated state (plus any ids minted
3557
- * during that transact).
3558
- *
3559
- * Known benign difference vs `MirrorVideoDocumentAdapter`: the editor's
3560
- * `setPart` assigns a fresh part object on every call, so a same-value rewrite
3561
- * produces a new immer state and IS journaled here, while the mirror's deep
3562
- * diff emits no commit. Terminal `snapshot()` stays equal; replay is idempotent.
3563
- */
3564
- var PlainMemoryAdapter = class {
3565
- state;
3566
- _journal = [];
3567
- baseIdFactory;
3568
- /** Non-null only while a `transact` edit callback is running. */
3569
- pendingIds = null;
3570
- /**
3571
- * Recording wrapper around the underlying factory. Callers (sandbox editor)
3572
- * use this so every minted id is appended to the current transact's list.
3573
- */
3574
- idFactory;
3575
- constructor(document, options) {
3576
- assertValidVideoDocument(document);
3577
- const draft = {};
3578
- writeVideoDocumentToDraft(draft, document);
3579
- this.state = draft;
3580
- this.baseIdFactory = options?.idFactory ?? generatePartId;
3581
- this.idFactory = (prefix) => {
3582
- const id = this.baseIdFactory(prefix);
3583
- if (this.pendingIds != null) this.pendingIds.push(id);
3584
- return id;
3585
- };
3586
- }
3587
- get journal() {
3588
- return this._journal;
3589
- }
3590
- hasContent() {
3591
- return (this.state.meta?.schema_version ?? "") !== "";
3592
- }
3593
- snapshot() {
3594
- return readVideoDocumentFromDraft(this.state);
3595
- }
3596
- /**
3597
- * Apply one op via immer. A throw in `edit` discards the draft (state and
3598
- * journal unchanged). When `produce` returns the same reference, there was
3599
- * no structural change — skip journal, matching mirror "no change, no commit".
3600
- */
3601
- transact(edit, audit) {
3602
- 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");
3603
- this.pendingIds = [];
3604
- try {
3605
- const next = produce(this.state, edit);
3606
- if (next !== this.state) {
3607
- this.state = next;
3608
- this._journal.push({
3609
- ...audit,
3610
- generated_ids: this.pendingIds
3611
- });
3612
- }
3613
- } finally {
3614
- this.pendingIds = null;
3615
- }
3616
- }
3617
- };
3618
- /** Build a `PlainMemoryAdapter` seeded with `document`. */
3619
- function createPlainMemoryAdapter(document, options) {
3620
- return new PlainMemoryAdapter(document, options);
3621
- }
3622
- //#endregion
3623
- export { isEmptyVideoClip as $, buildSpeechHostMap as A, ensureLaneTrack as B, variantBaseEntityIds as C, hasSequence as D, createRelationId as E, assertValidVideoDocument as F, arrangeMainTrackSeamlessly as G, laneTrackId as H, validateVideoDocument as I, recalculateTimelineDuration as J, fillMainTrackTimeGaps as K, partUnionSchema as L, fromVideoDocument as M, toVideoDocument as N, isMediaAssetVariantKind as O, VideoDocumentValidationError as P, TIMELINE_SKELETON_DURATION_MS as Q, videoDocumentSchema as R, updateEntityFields as S, createEntityId as T, solveVideoDocument as U, findLaneTrack as V, cascadeAfterVideoClipChanges as W, resolveSpeechOverlapByShiftingVideos as X, resolveAllSpeechOverlaps as Y, syncAggregatedClipsTimePosition as Z, assembleEntityContent as _, applyEntityTimelineProjection as a, effectiveVideoClipDurationMs as at, findComposedAudioScript as b, writeVideoDocumentToDraft as c, recordEntries as ct, EntityProjectionGraph as d, bytesToBase64 as dt, partDurationMs as et, resolveEntityTimelineLayout as f, assembleCaptionContent as g, ScriptCompositionError as h, MirrorVideoDocumentAdapter as i, VIDEO_DOCUMENT_SCHEMA_VERSION as it, derivePositionFromAbs as j, buildInitialVideoDocument as k, readVideoDocumentFromDraft as l, videoDocumentMirrorSchema as lt, decodeEntityRelationRows as m, createPlainMemoryAdapter as n, DEFAULT_UNIT_TIME_MS as nt, createMirrorVideoDocument as o, speedOf as ot, EntityTimelineProjectionError as p, reassignSpeechesToVideoClipsByTime as q, generatePartId as r, ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION as rt, createMirrorVideoDocumentAdapter as s, partUnionToDraft as st, PlainMemoryAdapter as t, safeDurationMs as tt, projectEntityTimeline as u, base64ToBytes as ut, assemblePhoneticScriptContent as v, isJsonObject as w, selectAudioScriptSegments as x, assembleScriptText as y, LANE_KINDS_IN_STACK_ORDER as z };