@mengine/medeo-client 2.0.1 → 2.1.1-dsl.0

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.
@@ -0,0 +1,2814 @@
1
+ import { C as resourceId, S as relationId, _ as relationTables, b as assertResourceIdentity, c as isInvalidAtomic, d as assertEntityPatch, f as assertId, g as mergeFields, h as isRecord, m as entityTables, n as textStyles, o as medeoDslLoroShape, p as assertRelation, s as captionContentCodec, t as DEFAULT_UNIT_TIME_MS, u as assertEntity, v as validLanguage, x as defaultIdFactory, y as assertRelationIdentity } from "./video-draft-types-DTXxvPt-.js";
2
+ import { Cursor, LoroDoc, LoroText, isContainer } from "loro-crdt";
3
+ import { Mirror } from "loro-mirror";
4
+ //#region ../medeo-dsl/src/text.ts
5
+ function assertTextOffset(text, at) {
6
+ if (!Number.isSafeInteger(at) || at < 0 || at > text.length) throw new Error("Invalid UTF-16 text offset");
7
+ const left = text.charCodeAt(at - 1), right = text.charCodeAt(at);
8
+ if (left >= 55296 && left <= 56319 && right >= 56320 && right <= 57343) throw new Error("Offset splits a surrogate pair");
9
+ }
10
+ function range(text, start, end) {
11
+ assertTextOffset(text, start);
12
+ assertTextOffset(text, end);
13
+ if (start >= end) throw new Error("Expected a nonempty text range");
14
+ }
15
+ function assertMark(key, value, selected) {
16
+ if (key !== "language" && key !== "pauseMs") throw new Error(`Unsupported mark: ${key}`);
17
+ if (value === null) return;
18
+ if (key === "language" && !validLanguage(value)) throw new Error("Invalid language");
19
+ if (key === "pauseMs" && (selected !== " " || typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) throw new Error("pauseMs requires a nonnegative integer on one ordinary space");
20
+ }
21
+ function attributes(attrs, selected) {
22
+ if (!attrs) return;
23
+ const result = {};
24
+ for (const [key, value] of Object.entries(attrs)) {
25
+ assertMark(key, value, selected);
26
+ result[key] = key === "pauseMs" && value === 0 ? null : value;
27
+ }
28
+ return result;
29
+ }
30
+ function previewDelta(text, delta) {
31
+ let offset = 0;
32
+ const normalized = [];
33
+ for (const op of delta) {
34
+ const keys = Object.keys(op);
35
+ if (keys.filter((key) => [
36
+ "insert",
37
+ "retain",
38
+ "delete"
39
+ ].includes(key)).length !== 1 || keys.some((key) => ![
40
+ "insert",
41
+ "retain",
42
+ "delete",
43
+ "attributes"
44
+ ].includes(key))) throw new Error("Invalid text Delta operation");
45
+ if ("insert" in op) {
46
+ if (typeof op.insert !== "string") throw new Error("Only text insertions are supported");
47
+ const attrs = attributes(op.attributes, op.insert);
48
+ text = text.slice(0, offset) + op.insert + text.slice(offset);
49
+ normalized.push({
50
+ insert: op.insert,
51
+ ...attrs ? { attributes: attrs } : {}
52
+ });
53
+ offset += op.insert.length;
54
+ } else {
55
+ const length = "retain" in op ? op.retain : op.delete;
56
+ if (!Number.isSafeInteger(length) || length < 0) throw new Error("Invalid Delta length");
57
+ assertTextOffset(text, offset + length);
58
+ if ("retain" in op) {
59
+ const attrs = attributes(op.attributes, text.slice(offset, offset + length));
60
+ normalized.push({
61
+ retain: length,
62
+ ...attrs ? { attributes: attrs } : {}
63
+ });
64
+ offset += length;
65
+ } else {
66
+ if ("attributes" in op) throw new Error("Delete cannot have attributes");
67
+ text = text.slice(0, offset) + text.slice(offset + length);
68
+ normalized.push({ delete: length });
69
+ }
70
+ }
71
+ }
72
+ return {
73
+ text,
74
+ delta: normalized
75
+ };
76
+ }
77
+ function prepareTextEdits(text, edits) {
78
+ return {
79
+ edits: edits.map((edit) => {
80
+ switch (edit.type) {
81
+ case "insert":
82
+ assertTextOffset(text, edit.at);
83
+ if (typeof edit.text !== "string") throw new Error("Expected text");
84
+ text = text.slice(0, edit.at) + edit.text + text.slice(edit.at);
85
+ break;
86
+ case "delete":
87
+ assertTextOffset(text, edit.at);
88
+ if (!Number.isSafeInteger(edit.length) || edit.length < 0) throw new Error("Invalid delete length");
89
+ assertTextOffset(text, edit.at + edit.length);
90
+ text = text.slice(0, edit.at) + text.slice(edit.at + edit.length);
91
+ break;
92
+ case "mark":
93
+ range(text, edit.start, edit.end);
94
+ assertMark(edit.key, edit.value, text.slice(edit.start, edit.end));
95
+ break;
96
+ case "unmark":
97
+ range(text, edit.start, edit.end);
98
+ assertMark(edit.key, null, "");
99
+ break;
100
+ case "delta": {
101
+ const result = previewDelta(text, edit.delta);
102
+ text = result.text;
103
+ return {
104
+ type: "delta",
105
+ delta: result.delta
106
+ };
107
+ }
108
+ default: throw new Error("Unknown text edit");
109
+ }
110
+ return edit;
111
+ }),
112
+ body: text
113
+ };
114
+ }
115
+ //#endregion
116
+ //#region ../medeo-dsl/src/relations.ts
117
+ /** The row an endpoint names; refused when it is missing. */
118
+ function requireEntity(rows, table, id) {
119
+ if (typeof id !== "string" || !Object.hasOwn(rows[table], id)) throw new Error(`Missing ${table} endpoint: ${String(id)}`);
120
+ const row = rows[table][id];
121
+ if (!isRecord(row)) throw new Error(`Missing ${table} endpoint: ${id}`);
122
+ return row;
123
+ }
124
+ /**
125
+ * Whether one relation row may stand among these rows: its fields, its derived ID, and what
126
+ * its kind requires of each end. A row being linked need not be in `rows` yet. Caption content
127
+ * selects text, so its range is checked against the body with {@link assertCaptionSelection}.
128
+ */
129
+ function assertRelationAdmitted(rows, table, id, value) {
130
+ assertId(id);
131
+ assertRelation(table, value);
132
+ assertRelationIdentity(table, id, value);
133
+ const v = value;
134
+ const require = (endpoint, id) => requireEntity(rows, endpoint, id);
135
+ switch (table) {
136
+ case "clip-source-relations":
137
+ require("clip-entities", v.clipId);
138
+ if (!rows["media-entities"][v.sourceId] && !rows["caption-entities"][v.sourceId]) throw new Error("Invalid Clip source");
139
+ break;
140
+ case "time-anchor-relations": {
141
+ require("clip-entities", v.from);
142
+ if (rows["timeline-entities"][v.to]) {
143
+ if (v.offsetMs < 0) throw new Error("Absolute offset must be nonnegative");
144
+ } else require("clip-entities", v.to);
145
+ const visited = /* @__PURE__ */ new Set();
146
+ let from = v.from;
147
+ while (true) {
148
+ if (visited.has(from)) throw new Error("Time anchor cycle");
149
+ visited.add(from);
150
+ const anchor = from === v.from ? v : rows["time-anchor-relations"][`ta:${from}`];
151
+ if (!isRecord(anchor) || typeof anchor.to !== "string") break;
152
+ from = anchor.to;
153
+ }
154
+ break;
155
+ }
156
+ case "caption-content-relations":
157
+ assertCaptionEndpoints(rows, v);
158
+ break;
159
+ case "caption-alignment-relations": {
160
+ require("caption-entities", v.captionId);
161
+ const media = require("media-entities", v.mediaId);
162
+ if (media.kind === "image" || v.startMs >= v.endMs || v.endMs > media.durationMs) throw new Error("Invalid source alignment");
163
+ break;
164
+ }
165
+ case "voice-timbre-relations":
166
+ if (require("media-entities", v.recordingId).kind !== "voiceover-recording") throw new Error("Timbre requires a voiceover recording");
167
+ require("voice-entities", v.voiceId);
168
+ break;
169
+ case "derived-from-relations":
170
+ for (const end of [v.entityId, v.sourceId]) if (!entityTables.some((t) => Object.hasOwn(rows[t], end))) throw new Error("Missing provenance endpoint");
171
+ break;
172
+ case "from-asset-relations":
173
+ case "to-asset-relations": {
174
+ require("audio-script-entities", v.audioScriptId);
175
+ const asset = require("asset-entities", v.assetId);
176
+ if (table === "to-asset-relations" && !asset.digest) throw new Error("Export Asset requires digest");
177
+ break;
178
+ }
179
+ }
180
+ }
181
+ /** Both ends of a caption's selection exist: the caption and the script it selects from. */
182
+ function assertCaptionEndpoints(rows, selection) {
183
+ requireEntity(rows, "caption-entities", selection.captionId);
184
+ requireEntity(rows, "audio-script-entities", selection.audioScriptId);
185
+ }
186
+ /** A caption selects a nonempty run of its script's current body, in UTF-16 offsets. */
187
+ function assertCaptionSelection(rows, selection, body) {
188
+ assertCaptionEndpoints(rows, selection);
189
+ assertTextOffset(body, selection.start);
190
+ assertTextOffset(body, selection.end);
191
+ if (selection.start >= selection.end) throw new Error("Expected a nonempty range in the current body");
192
+ }
193
+ /** A caption's text comes from a selection of a script or from its own text, never both. */
194
+ function assertCaptionSource(caption, selected) {
195
+ if (caption?.text !== void 0 && selected) throw new Error("Caption must have only one content source");
196
+ }
197
+ //#endregion
198
+ //#region ../medeo-dsl/src/contain.ts
199
+ /** Whether an entity may sit here: a root is a Track, and a root Track holds Clips. */
200
+ function containAdmits(rows, parentId, entityId) {
201
+ return parentId === void 0 ? Object.hasOwn(rows["track-entities"], entityId) : Object.hasOwn(rows["track-entities"], parentId) && Object.hasOwn(rows["clip-entities"], entityId);
202
+ }
203
+ function locate(tree) {
204
+ const placed = /* @__PURE__ */ new Map();
205
+ const walk = (nodes, parentId, depth) => {
206
+ for (const node of nodes) {
207
+ if (placed.has(node.entityId)) throw new Error(`Duplicate containment: ${node.entityId}`);
208
+ placed.set(node.entityId, {
209
+ parentId,
210
+ depth,
211
+ children: node.children?.length ?? 0
212
+ });
213
+ walk(node.children ?? [], node.entityId, depth + 1);
214
+ }
215
+ };
216
+ walk(tree, void 0, 0);
217
+ return placed;
218
+ }
219
+ /** One containment edit against the current tree, refused before anything moves. */
220
+ function assertContainChange(rows, tree, mutation) {
221
+ if (![
222
+ "insert-node",
223
+ "move-node",
224
+ "delete-node"
225
+ ].includes(mutation.type)) throw new Error("Unknown Tree mutation");
226
+ const placed = locate(tree);
227
+ const current = placed.get(mutation.entityId);
228
+ if (mutation.type === "delete-node") {
229
+ if (!current) throw new Error("Containment node does not exist");
230
+ return;
231
+ }
232
+ const parent = mutation.parentId === void 0 ? void 0 : placed.get(mutation.parentId);
233
+ if (mutation.parentId !== void 0 && (!parent || !rows["track-entities"][mutation.parentId] || parent.parentId !== void 0)) throw new Error("Expected a live root Track parent");
234
+ if (!(parent ? rows["clip-entities"][mutation.entityId] : rows["track-entities"][mutation.entityId])) throw new Error("Containment must be Track → Clip");
235
+ if (mutation.type === "insert-node" && current) throw new Error("Entity is already contained");
236
+ if (mutation.type === "move-node" && !current) throw new Error("Containment node does not exist");
237
+ const siblings = parent ? parent.children : tree.length;
238
+ const length = current && current.parentId === mutation.parentId ? siblings - 1 : siblings;
239
+ const at = mutation.index ?? length;
240
+ if (!Number.isSafeInteger(at) || at < 0 || at > length) throw new Error("Invalid insertion index");
241
+ }
242
+ /** The whole tree: Track roots holding Clips, every entity contained once. */
243
+ function assertContainTree(rows, tree) {
244
+ for (const [entityId, at] of locate(tree)) {
245
+ if (at.parentId === void 0) {
246
+ requireEntity(rows, "track-entities", entityId);
247
+ continue;
248
+ }
249
+ requireEntity(rows, "track-entities", at.parentId);
250
+ requireEntity(rows, "clip-entities", entityId);
251
+ if (at.children || at.depth !== 1) throw new Error("Containment must be Track → Clip");
252
+ }
253
+ }
254
+ //#endregion
255
+ //#region ../medeo-dsl/src/plan.ts
256
+ /** Include removed nested keys: a partial merge alone would conceal a deleted required field. */
257
+ function replacementPatch(before, after) {
258
+ if (!isRecord(before) || !isRecord(after)) return after;
259
+ return Object.fromEntries([...new Set([...Object.keys(before), ...Object.keys(after)])].map((key) => [key, replacementPatch(before[key], after[key])]));
260
+ }
261
+ /**
262
+ * Admission of one whole change, asked once before it is published. Only what the change touched
263
+ * is checked, so an unrelated edit still lands in a merged document that holds invalid rows.
264
+ * Containment and caption selections are checked by whoever holds the tree and the script bodies,
265
+ * with `assertContainTree` and `assertCaptionSelection`.
266
+ */
267
+ function assertChangeAdmitted(change) {
268
+ const { before, after } = change;
269
+ for (const [table, rows] of change.entities) for (const [id, fields] of rows) {
270
+ const value = after[table][id];
271
+ if (value === void 0) continue;
272
+ const old = before[table][id];
273
+ if (!old) {
274
+ assertEntity(table, value);
275
+ if (entityTables.some((t) => t !== table && Object.hasOwn(after[t], id))) throw new Error("Entity ID is already used in another table");
276
+ assertResourceIdentity(table, id, value);
277
+ } else {
278
+ if (!isRecord(value)) throw new Error("Expected an entity object");
279
+ assertEntityPatch(table, Object.fromEntries([...fields].map((field) => [field, replacementPatch(old[field], value[field])])), old);
280
+ }
281
+ const marker = value.seqMarker;
282
+ if (table === "clip-entities" && (!old || fields.has("seqMarker")) && marker?.outMs <= marker?.inMs) throw new Error("Clip window must be nonempty");
283
+ }
284
+ if (change.entities.has("timeline-entities") && Object.keys(after["timeline-entities"]).length > 1) throw new Error("Document already has a Timeline");
285
+ for (const [table, ids] of change.relations) {
286
+ if (table === "caption-content-relations") continue;
287
+ for (const id of ids) {
288
+ const value = after[table][id];
289
+ if (value !== void 0) assertRelationAdmitted(after, table, id, value);
290
+ }
291
+ }
292
+ }
293
+ //#endregion
294
+ //#region src/dsl/projection/types.ts
295
+ /** Unsupported legacy representations fail explicitly; recoverable CRDT state is returned as diagnostics. */
296
+ var DslProjectionError = class extends Error {
297
+ issues;
298
+ constructor(issues) {
299
+ super(issues.map((issue) => `${issue.path}: ${issue.message}`).join("; "));
300
+ this.issues = issues;
301
+ this.name = "DslProjectionError";
302
+ }
303
+ };
304
+ function projectionError(code, path, message) {
305
+ throw new DslProjectionError([{
306
+ code,
307
+ path,
308
+ message
309
+ }]);
310
+ }
311
+ //#endregion
312
+ //#region src/dsl/projection/layout.ts
313
+ /** Pure deterministic solve. Constraints only move sequential roots; anchors retain their exact offsets. */
314
+ function resolveDslLayout(snapshot) {
315
+ const timelines = Object.keys(snapshot["timeline-entities"]);
316
+ if (timelines.length !== 1) projectionError("invalid-timeline", "timeline-entities", "Expected exactly one Timeline");
317
+ const result = {
318
+ timelineId: timelines[0],
319
+ tracks: [],
320
+ clips: {},
321
+ durationMs: 0,
322
+ diagnostics: [...snapshot.diagnostics]
323
+ };
324
+ const diagnose = (code, id, message) => result.diagnostics.push({
325
+ code,
326
+ path: `clip-entities/${id}`,
327
+ message
328
+ });
329
+ for (const node of snapshot["contain-relations"]) {
330
+ const track = snapshot["track-entities"][node.entityId];
331
+ const clips = [];
332
+ for (const child of node.children) {
333
+ const id = child.entityId, clip = snapshot["clip-entities"][id];
334
+ const sourceId = snapshot["clip-source-relations"][`cs:${id}`]?.sourceId;
335
+ const media = sourceId === void 0 ? void 0 : snapshot["media-entities"][sourceId];
336
+ const caption = sourceId === void 0 ? void 0 : snapshot["caption-entities"][sourceId];
337
+ const marker = clip.seqMarker;
338
+ let inMs = marker.inMs, outMs = marker.outMs;
339
+ const present = media !== void 0 || caption !== void 0;
340
+ if (media?.kind !== "image" && media?.durationMs !== void 0) {
341
+ inMs = Math.min(inMs, media.durationMs);
342
+ outMs = Math.min(outMs, media.durationMs);
343
+ }
344
+ if (inMs !== marker.inMs || outMs !== marker.outMs) diagnose("clamped-window", id, "Window intersects the available source range");
345
+ if (!present) diagnose("missing-source", id, "Missing source; placement has zero duration");
346
+ if (outMs <= inMs) diagnose("empty-window", id, "Window is empty; endpoints are not swapped");
347
+ const rate = marker.rate ?? 1;
348
+ const duration = present ? Math.max(0, Math.round((outMs - inMs) / rate)) : 0;
349
+ if (!Number.isSafeInteger(duration)) projectionError("time-overflow", `clip-entities/${id}`, "Duration exceeds safe integer milliseconds");
350
+ const anchor = snapshot["time-anchor-relations"][`ta:${id}`];
351
+ const placement = {
352
+ clipId: id,
353
+ trackId: node.entityId,
354
+ sourceId,
355
+ inMs,
356
+ outMs,
357
+ rate,
358
+ volume: clip.volume ?? 0,
359
+ startMs: 0,
360
+ durationMs: duration,
361
+ fillTimeline: present && duration > 0 && marker.fillTimeline === true,
362
+ mode: anchor ? anchor.to === result.timelineId ? "absolute" : "anchored" : "sequential"
363
+ };
364
+ if (placement.fillTimeline) placement.durationMs = 0;
365
+ clips.push(placement);
366
+ result.clips[id] = placement;
367
+ }
368
+ result.tracks.push({
369
+ trackId: node.entityId,
370
+ role: track.role,
371
+ hidden: track.hidden ?? false,
372
+ clips
373
+ });
374
+ }
375
+ const cycle = /* @__PURE__ */ new Set(), done = /* @__PURE__ */ new Set();
376
+ for (const id of Object.keys(result.clips)) {
377
+ const path = [], seen = /* @__PURE__ */ new Map();
378
+ let current = id;
379
+ while (current && result.clips[current] && !done.has(current)) {
380
+ const at = seen.get(current);
381
+ if (at !== void 0) {
382
+ for (const member of path.slice(at)) cycle.add(member);
383
+ break;
384
+ }
385
+ seen.set(current, path.length);
386
+ path.push(current);
387
+ current = snapshot["time-anchor-relations"][`ta:${current}`]?.to;
388
+ }
389
+ for (const member of path) done.add(member);
390
+ }
391
+ const positions = /* @__PURE__ */ new Map(), roots = /* @__PURE__ */ new Map();
392
+ const movable = /* @__PURE__ */ new Set();
393
+ const position = (id) => {
394
+ const known = positions.get(id);
395
+ if (known) return known;
396
+ const clip = result.clips[id], anchor = snapshot["time-anchor-relations"][`ta:${id}`];
397
+ let value;
398
+ if (anchor && clip.mode === "anchored" && !cycle.has(id) && result.clips[anchor.to]) {
399
+ const parent = position(anchor.to);
400
+ value = {
401
+ root: parent.root,
402
+ offset: parent.offset + anchor.offsetMs,
403
+ floor: Math.max(0, parent.floor + anchor.offsetMs)
404
+ };
405
+ clip.anchorClipId = anchor.to;
406
+ } else {
407
+ let start = 0;
408
+ if (anchor) if (clip.mode === "absolute") start = Math.max(0, anchor.offsetMs);
409
+ else {
410
+ clip.mode = "fallback";
411
+ start = anchor.fallback_abs_ms;
412
+ diagnose(cycle.has(id) ? "anchor-cycle" : "missing-anchor", id, "Using the recorded fallback position");
413
+ }
414
+ else movable.add(id);
415
+ roots.set(id, start);
416
+ value = {
417
+ root: id,
418
+ offset: 0,
419
+ floor: 0
420
+ };
421
+ }
422
+ positions.set(id, value);
423
+ return value;
424
+ };
425
+ for (const id of Object.keys(result.clips)) position(id);
426
+ const edges = [];
427
+ for (const track of result.tracks) {
428
+ let previous;
429
+ for (const clip of track.clips) if (clip.mode === "sequential") {
430
+ if (previous) edges.push({
431
+ from: previous.clipId,
432
+ to: clip.clipId,
433
+ distance: previous.durationMs
434
+ });
435
+ previous = clip;
436
+ }
437
+ }
438
+ const solve = (constraints) => {
439
+ const values = new Map(roots);
440
+ for (let pass = 0; pass <= roots.size; pass++) {
441
+ let changed = false;
442
+ for (const edge of constraints) {
443
+ const bound = (edge.from === void 0 ? 0 : values.get(edge.from)) + edge.distance;
444
+ if (bound > values.get(edge.to)) {
445
+ values.set(edge.to, bound);
446
+ changed = true;
447
+ }
448
+ }
449
+ if (!changed) return values;
450
+ }
451
+ };
452
+ const apply = (values) => {
453
+ for (const [id, p] of positions) {
454
+ const start = Math.max(p.floor, values.get(p.root) + p.offset);
455
+ if (!Number.isSafeInteger(start)) projectionError("time-overflow", `clip-entities/${id}`, "Position exceeds safe integer milliseconds");
456
+ result.clips[id].startMs = start;
457
+ }
458
+ };
459
+ apply(solve(edges));
460
+ const pairs = result.tracks.flatMap((track) => {
461
+ const sorted = track.clips.filter((c) => !c.fillTimeline && c.durationMs > 0).toSorted((a, b) => a.startMs - b.startMs);
462
+ return sorted.flatMap((left, i) => sorted.slice(i + 1).map((right) => ({
463
+ left,
464
+ right,
465
+ attempted: false
466
+ })));
467
+ });
468
+ let added;
469
+ do {
470
+ added = false;
471
+ for (const pair of pairs) {
472
+ const { left, right } = pair;
473
+ if (pair.attempted || !overlap(left, right)) continue;
474
+ pair.attempted = true;
475
+ const a = positions.get(left.clipId), b = positions.get(right.clipId);
476
+ if (a.root === b.root || !movable.has(b.root)) continue;
477
+ const candidate = [
478
+ ...edges,
479
+ {
480
+ from: a.root,
481
+ to: b.root,
482
+ distance: a.offset + left.durationMs - b.offset
483
+ },
484
+ {
485
+ to: b.root,
486
+ distance: a.floor + left.durationMs - b.offset
487
+ }
488
+ ];
489
+ const solved = solve(candidate);
490
+ if (!solved) continue;
491
+ edges.push(...candidate.slice(edges.length));
492
+ apply(solved);
493
+ added = true;
494
+ }
495
+ } while (added);
496
+ for (const clip of Object.values(result.clips)) if (!clip.fillTimeline && clip.durationMs > 0) result.durationMs = Math.max(result.durationMs, clip.startMs + clip.durationMs);
497
+ if (!Number.isSafeInteger(result.durationMs)) projectionError("time-overflow", "timeline", "Timeline exceeds safe integer milliseconds");
498
+ for (const clip of Object.values(result.clips)) if (clip.fillTimeline) clip.durationMs = Math.max(0, result.durationMs - clip.startMs);
499
+ for (const track of result.tracks) for (let i = 0; i < track.clips.length; i++) {
500
+ const a = track.clips[i];
501
+ for (const b of track.clips.slice(i + 1)) if (overlap(a, b)) diagnose("unresolved-overlap", b.clipId, `Overlaps ${a.clipId}; fixed positions, anchors and complete windows are retained`);
502
+ }
503
+ for (const [id, relation] of Object.entries(snapshot["caption-alignment-relations"])) {
504
+ const media = snapshot["media-entities"][relation.mediaId];
505
+ if (!media || media.kind === "image" || relation.endMs <= relation.startMs || relation.endMs > media.durationMs) result.diagnostics.push({
506
+ code: "invalid-caption-alignment",
507
+ path: `caption-alignment-relations/${id}`,
508
+ message: "No valid source alignment; the placed display window is retained"
509
+ });
510
+ }
511
+ return result;
512
+ }
513
+ function overlap(a, b) {
514
+ return a.durationMs > 0 && b.durationMs > 0 && a.startMs < b.startMs + b.durationMs && b.startMs < a.startMs + a.durationMs;
515
+ }
516
+ //#endregion
517
+ //#region src/dsl/action-context.ts
518
+ function requireClip(s, id) {
519
+ const clip = s["clip-entities"][id];
520
+ if (!clip) throw new Error(`Missing Clip: ${id}`);
521
+ return clip;
522
+ }
523
+ function requireTrack(s, id) {
524
+ const track = s["track-entities"][id];
525
+ if (!track || !s["contain-relations"].some((n) => n.entityId === id)) throw new Error(`Missing placed Track: ${id}`);
526
+ return track;
527
+ }
528
+ function requireSource(s, id, role) {
529
+ if (role === "caption" && s["caption-entities"][id]) return s["caption-entities"][id];
530
+ const media = s["media-entities"][id];
531
+ assertMediaRole(media, role);
532
+ return media;
533
+ }
534
+ function assertMediaRole(media, role) {
535
+ if (!media || !{
536
+ video_clip: ["video", "image"],
537
+ speech: ["audio", "voiceover-recording"],
538
+ bgm: ["audio"],
539
+ caption: []
540
+ }[role].includes(media.kind)) throw new Error(`Source is not allowed on ${role} Track`);
541
+ }
542
+ function validateWindow(window, duration) {
543
+ if (!Number.isSafeInteger(window.inMs) || !Number.isSafeInteger(window.outMs) || window.inMs < 0 || window.outMs <= window.inMs || duration !== void 0 && window.outMs > duration) throw new Error("Invalid Clip window");
544
+ }
545
+ function trackOf(s, id) {
546
+ requireClip(s, id);
547
+ const track = s["contain-relations"].find((n) => n.children.some((c) => c.entityId === id));
548
+ if (!track) throw new Error(`Clip is not placed: ${id}`);
549
+ return track.entityId;
550
+ }
551
+ function children(s, trackId) {
552
+ requireTrack(s, trackId);
553
+ return s["contain-relations"].find((n) => n.entityId === trackId).children.map((n) => n.entityId);
554
+ }
555
+ function uniqueIds(ids) {
556
+ if (new Set(ids).size !== ids.length) throw new Error("Duplicate Clip IDs");
557
+ }
558
+ function timelineId(s) {
559
+ const ids = Object.keys(s["timeline-entities"]);
560
+ if (ids.length !== 1) throw new Error("Expected exactly one Timeline");
561
+ return ids[0];
562
+ }
563
+ /** Placement facts are authored only by explicit actions; projection never writes them back. */
564
+ function placementMutations(s, clipId, placement) {
565
+ if (placement.kind === "sequential") return [{
566
+ type: "delete-relation",
567
+ table: "time-anchor-relations",
568
+ id: `ta:${clipId}`
569
+ }];
570
+ const offset = placement.kind === "absolute" ? placement.startMs : placement.offsetMs;
571
+ if (!Number.isSafeInteger(offset) || placement.kind === "absolute" && offset < 0) throw new Error("Invalid placement time");
572
+ const target = placement.kind === "absolute" ? timelineId(s) : placement.clipId;
573
+ let fallback = offset;
574
+ if (placement.kind === "anchored") {
575
+ trackOf(s, target);
576
+ const visited = new Set([clipId]);
577
+ let next = target;
578
+ while (next && s["clip-entities"][next]) {
579
+ if (visited.has(next)) throw new Error("Cyclic time anchor");
580
+ visited.add(next);
581
+ next = s["time-anchor-relations"][`ta:${next}`]?.to;
582
+ }
583
+ fallback = Math.max(0, resolveDslLayout(s).clips[target].startMs + offset);
584
+ }
585
+ if (!Number.isSafeInteger(fallback)) throw new Error("Invalid fallback time");
586
+ return [{
587
+ type: "set-relation",
588
+ table: "time-anchor-relations",
589
+ value: {
590
+ from: clipId,
591
+ to: target,
592
+ offsetMs: offset,
593
+ fallback_abs_ms: fallback
594
+ }
595
+ }];
596
+ }
597
+ function detachChildren(s, ids) {
598
+ const layout = resolveDslLayout(s);
599
+ return Object.values(s["time-anchor-relations"]).flatMap((r) => ids.has(r.to) && !ids.has(r.from) && layout.clips[r.from] ? placementMutations(s, r.from, {
600
+ kind: "absolute",
601
+ startMs: layout.clips[r.from].startMs
602
+ }) : []);
603
+ }
604
+ function deleteClips(s, ids, policy) {
605
+ if (!["cascade", "detach"].includes(policy)) throw new Error("Invalid anchored deletion policy");
606
+ uniqueIds(ids);
607
+ ids.forEach((id) => trackOf(s, id));
608
+ const removed = new Set(ids);
609
+ if (policy === "cascade") {
610
+ const queue = [...ids];
611
+ for (let i = 0; i < queue.length; i++) for (const r of Object.values(s["time-anchor-relations"])) if (r.to === queue[i] && s["clip-entities"][r.from] && !removed.has(r.from)) {
612
+ removed.add(r.from);
613
+ queue.push(r.from);
614
+ }
615
+ }
616
+ const mutations = policy === "detach" ? detachChildren(s, removed) : [];
617
+ for (const id of removed) {
618
+ if (s["contain-relations"].some((n) => n.children.some((c) => c.entityId === id))) mutations.push({
619
+ type: "delete-node",
620
+ entityId: id
621
+ });
622
+ mutations.push({
623
+ type: "delete-relation",
624
+ table: "time-anchor-relations",
625
+ id: `ta:${id}`
626
+ }, {
627
+ type: "delete-relation",
628
+ table: "clip-source-relations",
629
+ id: `cs:${id}`
630
+ }, {
631
+ type: "delete-entity",
632
+ table: "clip-entities",
633
+ id
634
+ });
635
+ }
636
+ return mutations;
637
+ }
638
+ //#endregion
639
+ //#region src/dsl/clip-actions.ts
640
+ /** Batch edits validate against preceding prepared facts without touching the live document. */
641
+ function updateClipsPlan(base, updates) {
642
+ uniqueIds(updates.map((update) => update.clipId));
643
+ const clips = { ...base["clip-entities"] }, anchors = { ...base["time-anchor-relations"] };
644
+ const preview = {
645
+ ...base,
646
+ "clip-entities": clips,
647
+ "time-anchor-relations": anchors
648
+ };
649
+ const mutations = [];
650
+ for (const update of updates) {
651
+ const clip = requireClip(preview, update.clipId);
652
+ const marker = {};
653
+ if (Object.hasOwn(update, "inMs")) marker.inMs = update.inMs;
654
+ if (Object.hasOwn(update, "outMs")) marker.outMs = update.outMs;
655
+ if (Object.hasOwn(update, "speed")) marker.rate = update.speed?.rate;
656
+ const patch = {};
657
+ if (Object.keys(marker).length) patch.seqMarker = marker;
658
+ if (Object.hasOwn(update, "volume")) patch.volume = update.volume;
659
+ const windowChanged = Object.hasOwn(marker, "inMs") || Object.hasOwn(marker, "outMs");
660
+ if (clip.seqMarker.fillTimeline && (windowChanged || Object.hasOwn(marker, "rate"))) throw new Error("BGM trim and speed are not enabled");
661
+ const next = mergeFields(clip, patch);
662
+ if (windowChanged) {
663
+ const sourceId = preview["clip-source-relations"][`cs:${update.clipId}`]?.sourceId;
664
+ if (!sourceId) throw new Error("Missing Clip source");
665
+ const source = requireSource(preview, sourceId, requireTrack(preview, trackOf(preview, update.clipId)).role);
666
+ validateWindow(next.seqMarker, "durationMs" in source ? source.durationMs : void 0);
667
+ }
668
+ if (Object.keys(patch).length) mutations.push({
669
+ type: "patch-entity",
670
+ table: "clip-entities",
671
+ id: update.clipId,
672
+ patch
673
+ });
674
+ clips[update.clipId] = next;
675
+ if (update.placement) {
676
+ trackOf(preview, update.clipId);
677
+ const changes = placementMutations(preview, update.clipId, update.placement);
678
+ mutations.push(...changes);
679
+ for (const change of changes) if (change.type === "set-relation" && change.table === "time-anchor-relations") anchors[`ta:${update.clipId}`] = change.value;
680
+ else if (change.type === "delete-relation") delete anchors[change.id];
681
+ }
682
+ }
683
+ return mutations;
684
+ }
685
+ function sourcePlan(s, input, trackId) {
686
+ const role = requireTrack(s, trackId).role;
687
+ if (input.media) {
688
+ assertMediaRole(input.media, role);
689
+ const id = resourceId("media", input.media.system, input.media.key);
690
+ return {
691
+ id,
692
+ source: input.media,
693
+ mutations: [{
694
+ type: "create-entity",
695
+ table: "media-entities",
696
+ id,
697
+ value: input.media
698
+ }]
699
+ };
700
+ }
701
+ return {
702
+ id: input.sourceId,
703
+ source: requireSource(s, input.sourceId, role),
704
+ mutations: []
705
+ };
706
+ }
707
+ function addClipsPlan(s, input, ids) {
708
+ const length = children(s, input.trackId).length;
709
+ const index = input.index ?? length;
710
+ if (!Number.isSafeInteger(index) || index < 0 || index > length) throw new Error("Invalid insertion index");
711
+ return input.clips.flatMap((item, offset) => {
712
+ const source = sourcePlan(s, item, input.trackId);
713
+ const duration = "durationMs" in source.source ? source.source.durationMs : void 0;
714
+ const outMs = item.outMs ?? duration;
715
+ if (outMs === void 0) throw new Error("Image and Caption require an explicit display window");
716
+ const marker = {
717
+ inMs: item.inMs ?? 0,
718
+ outMs
719
+ };
720
+ if (item.speed !== void 0) marker.rate = item.speed.rate;
721
+ if (item.fillTimeline) {
722
+ if (!("kind" in source.source) || !["audio", "voiceover-recording"].includes(source.source.kind)) throw new Error("Timeline fill requires audio");
723
+ if (item.inMs !== void 0 || item.outMs !== void 0 || item.speed !== void 0) throw new Error("BGM trim and speed are not enabled");
724
+ marker.fillTimeline = true;
725
+ }
726
+ validateWindow(marker, duration);
727
+ const clip = { seqMarker: marker };
728
+ if (item.volume !== void 0) clip.volume = item.volume;
729
+ const id = ids[offset];
730
+ const result = [
731
+ ...source.mutations,
732
+ {
733
+ type: "create-entity",
734
+ table: "clip-entities",
735
+ id,
736
+ value: clip
737
+ },
738
+ {
739
+ type: "set-relation",
740
+ table: "clip-source-relations",
741
+ value: {
742
+ clipId: id,
743
+ sourceId: source.id
744
+ }
745
+ },
746
+ {
747
+ type: "insert-node",
748
+ entityId: id,
749
+ parentId: input.trackId,
750
+ index: index + offset
751
+ }
752
+ ];
753
+ if (item.placement) result.push(...placementMutations(s, id, item.placement));
754
+ return result;
755
+ });
756
+ }
757
+ function moveClipsPlan(s, input) {
758
+ if (!["follow", "keepAbsolute"].includes(input.onAnchored)) throw new Error("Invalid anchored movement policy");
759
+ uniqueIds(input.clipIds);
760
+ const moved = new Set(input.clipIds);
761
+ const target = children(s, input.trackId).filter((id) => !moved.has(id));
762
+ if (!Number.isSafeInteger(input.index) || input.index < 0 || input.index > target.length) throw new Error("Invalid insertion index");
763
+ for (const id of input.clipIds) {
764
+ trackOf(s, id);
765
+ if (s["time-anchor-relations"][`ta:${id}`]) throw new Error("Sequential reorder requires sequential Clips");
766
+ const source = s["clip-source-relations"][`cs:${id}`];
767
+ if (!source) throw new Error("Missing Clip source");
768
+ requireSource(s, source.sourceId, requireTrack(s, input.trackId).role);
769
+ }
770
+ const mutations = input.onAnchored === "keepAbsolute" ? detachChildren(s, moved) : [];
771
+ const order = [...target];
772
+ order.splice(input.index, 0, ...input.clipIds);
773
+ for (let index = 0; index < order.length; index++) mutations.push({
774
+ type: "move-node",
775
+ entityId: order[index],
776
+ parentId: input.trackId,
777
+ index
778
+ });
779
+ return mutations;
780
+ }
781
+ function replaceSequencePlan(s, input, ids) {
782
+ if (!["remap", "cascade"].includes(input.onAnchored)) throw new Error("Invalid anchored replacement policy");
783
+ uniqueIds(input.clipIds);
784
+ if (!input.clipIds.length || !input.clips.length) throw new Error("Replacement requires nonempty runs");
785
+ const trackId = trackOf(s, input.clipIds[0]), order = children(s, trackId), index = order.indexOf(input.clipIds[0]);
786
+ if (input.clipIds.some((id, i) => order[index + i] !== id || s["time-anchor-relations"][`ta:${id}`])) throw new Error("Expected an ordered contiguous sequential run");
787
+ if (input.clips.some((c) => c.placement && c.placement.kind !== "sequential")) throw new Error("Replacement Clips must be sequential");
788
+ const mutations = addClipsPlan(s, {
789
+ trackId,
790
+ clips: input.clips,
791
+ index
792
+ }, ids);
793
+ const removed = new Set(input.clipIds);
794
+ const anchors = { ...s["time-anchor-relations"] };
795
+ if (input.onAnchored === "remap") {
796
+ const begin = resolveDslLayout(s).clips[input.clipIds[0]].startMs;
797
+ const starts = [];
798
+ let end = begin;
799
+ for (const id of ids) {
800
+ const creation = mutations.find((m) => m.type === "create-entity" && m.table === "clip-entities" && m.id === id);
801
+ if (!creation || creation.type !== "create-entity" || creation.table !== "clip-entities") throw new Error("Missing replacement");
802
+ starts.push(end);
803
+ const marker = creation.value.seqMarker;
804
+ end += Math.round((marker.outMs - marker.inMs) / (marker.rate ?? 1));
805
+ }
806
+ for (const r of Object.values(s["time-anchor-relations"])) if (removed.has(r.to) && !removed.has(r.from)) {
807
+ const slot = input.clipIds.indexOf(r.to);
808
+ if (slot >= ids.length) continue;
809
+ const value = {
810
+ from: r.from,
811
+ to: ids[slot],
812
+ offsetMs: r.offsetMs,
813
+ fallback_abs_ms: Math.max(0, starts[slot] + r.offsetMs)
814
+ };
815
+ mutations.push({
816
+ type: "set-relation",
817
+ table: "time-anchor-relations",
818
+ value
819
+ });
820
+ anchors[`ta:${r.from}`] = value;
821
+ }
822
+ }
823
+ mutations.push(...deleteClips({
824
+ ...s,
825
+ "time-anchor-relations": anchors
826
+ }, input.clipIds, "cascade"));
827
+ return mutations;
828
+ }
829
+ function replaceSourcePlan(s, clipId, input) {
830
+ if (requireClip(s, clipId).seqMarker.fillTimeline) throw new Error("Use setBgm to replace a fill Clip");
831
+ const source = sourcePlan(s, input, trackOf(s, clipId));
832
+ validateWindow(input, "durationMs" in source.source ? source.source.durationMs : void 0);
833
+ return [
834
+ ...source.mutations,
835
+ {
836
+ type: "patch-entity",
837
+ table: "clip-entities",
838
+ id: clipId,
839
+ patch: { seqMarker: {
840
+ inMs: input.inMs,
841
+ outMs: input.outMs
842
+ } }
843
+ },
844
+ {
845
+ type: "set-relation",
846
+ table: "clip-source-relations",
847
+ value: {
848
+ clipId,
849
+ sourceId: source.id
850
+ }
851
+ }
852
+ ];
853
+ }
854
+ //#endregion
855
+ //#region src/dsl/document/read.ts
856
+ /** Looking up a missing root must not create it. */
857
+ function rootMap(doc, name) {
858
+ const id = doc.getShallowValue()[name];
859
+ if (typeof id !== "string") return;
860
+ const value = doc.getContainerById(id);
861
+ return value?.kind() === "Map" ? value : void 0;
862
+ }
863
+ function liveText(doc, scriptId, caption = false) {
864
+ const row = rootMap(doc, caption ? "caption-entities" : "audio-script-entities")?.get(scriptId);
865
+ if (!isContainer(row) || row.kind() !== "Map") return;
866
+ const body = row.get(caption ? "text" : "body");
867
+ return isContainer(body) && body.kind() === "Text" && !body.isDeleted() ? body : void 0;
868
+ }
869
+ function readContainNodes(nodes) {
870
+ const read = (nodes) => nodes.map((node) => ({
871
+ treeId: node.id,
872
+ entityId: node.data.entityId,
873
+ children: read(node.children ?? [])
874
+ }));
875
+ return read(nodes);
876
+ }
877
+ function resolveContent(doc, content) {
878
+ const text = liveText(doc, content.audioScriptId);
879
+ if (!text || content.start.containerId() !== text.id || content.end.containerId() !== text.id) return;
880
+ const start = doc.getCursorPos(content.start)?.offset;
881
+ const end = doc.getCursorPos(content.end)?.offset;
882
+ if (start === void 0 || end === void 0 || start > end) return;
883
+ return {
884
+ text: text.toString().slice(start, end),
885
+ start,
886
+ end,
887
+ bodyId: text.id
888
+ };
889
+ }
890
+ function readMedeoDsl(doc, state) {
891
+ const diagnostics = [];
892
+ const diagnostic = (code, path, error) => diagnostics.push({
893
+ code,
894
+ path,
895
+ message: String(error)
896
+ });
897
+ const entities = Object.fromEntries(entityTables.map((t) => [t, {}]));
898
+ const relations = Object.fromEntries(relationTables.map((t) => [t, {}]));
899
+ const richText = {};
900
+ for (const table of entityTables) for (const [id, raw] of Object.entries(state[table] ?? {})) try {
901
+ assertId(id);
902
+ assertEntity(table, raw);
903
+ const v = raw;
904
+ assertResourceIdentity(table, id, v);
905
+ entities[table][id] = raw;
906
+ if (table === "audio-script-entities" || table === "caption-entities" && v.text !== void 0) {
907
+ const text = liveText(doc, id, table === "caption-entities");
908
+ if (!text) throw new Error("Missing live LoroText body");
909
+ richText[id] = {
910
+ bodyId: text.id,
911
+ plainText: text.toString(),
912
+ delta: text.toDelta().flatMap((run) => typeof run.insert === "string" ? [{
913
+ insert: run.insert,
914
+ ...run.attributes ? { attributes: run.attributes } : {}
915
+ }] : [])
916
+ };
917
+ }
918
+ } catch (error) {
919
+ delete entities[table][id];
920
+ diagnostic("invalid-entity", `${table}/${id}`, error);
921
+ }
922
+ const entityIds = new Set(entityTables.flatMap((t) => Object.keys(entities[t])));
923
+ for (const table of relationTables) for (const [id, value] of Object.entries(state[table] ?? {})) try {
924
+ if (isInvalidAtomic(value)) throw new Error(value.reason);
925
+ assertRelation(table, value);
926
+ assertRelationIdentity(table, id, value);
927
+ relations[table][id] = value;
928
+ for (const key of [
929
+ "clipId",
930
+ "sourceId",
931
+ "from",
932
+ "to",
933
+ "captionId",
934
+ "audioScriptId",
935
+ "mediaId",
936
+ "recordingId",
937
+ "voiceId",
938
+ "entityId",
939
+ "assetId"
940
+ ]) {
941
+ const endpoint = value[key];
942
+ if (typeof endpoint === "string" && !entityIds.has(endpoint)) diagnostic("dangling-reference", `${table}/${id}/${key}`, `Missing entity ${endpoint}`);
943
+ }
944
+ } catch (error) {
945
+ diagnostic("invalid-relation", `${table}/${id}`, error);
946
+ }
947
+ const treeNodes = readContainNodes(state["contain-relations"] ?? []);
948
+ const occurrences = /* @__PURE__ */ new Map();
949
+ const count = (nodes) => {
950
+ for (const node of nodes) {
951
+ occurrences.set(node.entityId, (occurrences.get(node.entityId) ?? 0) + 1);
952
+ count(node.children);
953
+ }
954
+ };
955
+ count(treeNodes);
956
+ const validTree = (nodes, parentId) => nodes.flatMap((node) => {
957
+ if (!containAdmits(entities, parentId, node.entityId) || occurrences.get(node.entityId) > 1) {
958
+ diagnostic(occurrences.get(node.entityId) > 1 ? "duplicate-containment" : "invalid-containment", `contain-relations/${node.treeId}`, node.entityId);
959
+ return [];
960
+ }
961
+ return [{
962
+ ...node,
963
+ children: validTree(node.children, node.entityId)
964
+ }];
965
+ });
966
+ const captionText = {};
967
+ for (const [id, content] of Object.entries(relations["caption-content-relations"])) try {
968
+ const resolved = resolveContent(doc, content);
969
+ if (resolved) captionText[content.captionId] = resolved;
970
+ else diagnostic("invalid-caption-content", `caption-content-relations/${id}`, "Cursor does not resolve in the current body");
971
+ } catch (error) {
972
+ diagnostic("invalid-caption-content", `caption-content-relations/${id}`, error);
973
+ }
974
+ for (const [id, caption] of Object.entries(entities["caption-entities"])) {
975
+ const hasReference = Object.hasOwn(relations["caption-content-relations"], `cc:${id}`) || Object.hasOwn(state["caption-content-relations"] ?? {}, `cc:${id}`);
976
+ if (hasReference && caption.text !== void 0) diagnostic("multiple-caption-sources", `caption-entities/${id}`, "Content reference takes precedence over inline text");
977
+ if (!hasReference && caption.text !== void 0 && richText[id]) captionText[id] = {
978
+ text: caption.text,
979
+ start: 0,
980
+ end: caption.text.length,
981
+ bodyId: richText[id].bodyId
982
+ };
983
+ }
984
+ return freezeSnapshot({
985
+ ...entities,
986
+ ...relations,
987
+ "contain-relations": validTree(treeNodes),
988
+ richText,
989
+ captionText,
990
+ diagnostics
991
+ });
992
+ }
993
+ function freezeSnapshot(value) {
994
+ if (value && typeof value === "object" && !(value instanceof Cursor)) {
995
+ for (const child of Object.values(value)) freezeSnapshot(child);
996
+ Object.freeze(value);
997
+ }
998
+ return value;
999
+ }
1000
+ //#endregion
1001
+ //#region src/dsl/document/draft.ts
1002
+ const entities = new Set(entityTables);
1003
+ const relations = new Set(relationTables);
1004
+ const reserved = new Set([
1005
+ "$cid",
1006
+ "__proto__",
1007
+ "constructor",
1008
+ "prototype"
1009
+ ]);
1010
+ /** Copy caller-owned inputs so later aliases cannot bypass draft validation or change queued edits. */
1011
+ function copyInput(value, unwrap = (v) => v, seen = /* @__PURE__ */ new Set()) {
1012
+ if (!value || typeof value !== "object") return value;
1013
+ const object = unwrap(value);
1014
+ if (object instanceof Cursor) return Cursor.decode(object.encode());
1015
+ if (seen.has(object)) throw new Error("Cyclic draft input");
1016
+ if (!Array.isArray(object) && ![Object.prototype, null].includes(Object.getPrototypeOf(object))) throw new Error("Expected plain draft data");
1017
+ seen.add(object);
1018
+ const result = Array.isArray(object) ? [] : {};
1019
+ for (const key of Object.keys(object)) {
1020
+ if (reserved.has(key)) throw new Error(`Reserved field: ${key}`);
1021
+ result[key] = copyInput(object[key], unwrap, seen);
1022
+ }
1023
+ seen.delete(object);
1024
+ return result;
1025
+ }
1026
+ /** Wrap the live Immer draft, tracking attempted writes as well as final values. */
1027
+ var TransactionDraft = class {
1028
+ raw;
1029
+ before;
1030
+ draft;
1031
+ entities = /* @__PURE__ */ new Map();
1032
+ relations = /* @__PURE__ */ new Map();
1033
+ deleted = /* @__PURE__ */ new Set();
1034
+ textPreviews = /* @__PURE__ */ new Map();
1035
+ dirtyCaptions = /* @__PURE__ */ new Set();
1036
+ removedCaptionTexts = /* @__PURE__ */ new Set();
1037
+ treeChanged = false;
1038
+ active = true;
1039
+ failure;
1040
+ wrappers = /* @__PURE__ */ new WeakMap();
1041
+ originals = /* @__PURE__ */ new WeakMap();
1042
+ constructor(raw, before) {
1043
+ this.raw = raw;
1044
+ this.before = before;
1045
+ this.draft = this.wrap(raw, []);
1046
+ }
1047
+ run(fn) {
1048
+ if (!this.active) throw new Error("DSL transaction context has expired");
1049
+ if (this.failure) throw this.failure.error;
1050
+ try {
1051
+ return fn();
1052
+ } catch (error) {
1053
+ return this.fail(error);
1054
+ }
1055
+ }
1056
+ fail(error) {
1057
+ this.failure ??= { error };
1058
+ throw error;
1059
+ }
1060
+ assertValid() {
1061
+ if (this.failure) throw this.failure.error;
1062
+ }
1063
+ close() {
1064
+ this.active = false;
1065
+ }
1066
+ wrap(value, path) {
1067
+ if (!value || typeof value !== "object" || value instanceof Cursor) return value;
1068
+ const cached = this.wrappers.get(value);
1069
+ if (cached) return cached;
1070
+ const proxy = new Proxy(value, {
1071
+ get: (target, key) => this.run(() => {
1072
+ if ((path[0] === "audio-script-entities" && key === "body" || path[0] === "caption-entities" && key === "text") && path.length === 2 && this.textPreviews.has(path[1])) return this.textPreviews.get(path[1]);
1073
+ if (!path.length && key === "contain-relations" && target[key] === void 0) target[key] = [];
1074
+ return this.wrap(Reflect.get(target, key), [...path, String(key)]);
1075
+ }),
1076
+ set: (target, key, next) => this.run(() => {
1077
+ this.changing(path, String(key), next, target, false);
1078
+ const unwrap = (v) => this.originals.get(v) ?? v;
1079
+ const copied = path[0] === "contain-relations" && next && typeof next === "object" && this.originals.has(next) ? unwrap(next) : copyInput(next, unwrap);
1080
+ return Reflect.set(target, key, copied);
1081
+ }),
1082
+ deleteProperty: (target, key) => this.run(() => {
1083
+ this.changing(path, String(key), void 0, target, true);
1084
+ return Reflect.deleteProperty(target, key);
1085
+ }),
1086
+ ownKeys: (target) => this.run(() => Reflect.ownKeys(target)),
1087
+ has: (target, key) => this.run(() => Reflect.has(target, key)),
1088
+ getOwnPropertyDescriptor: (target, key) => this.run(() => {
1089
+ const descriptor = Reflect.getOwnPropertyDescriptor(target, key);
1090
+ if (descriptor && "value" in descriptor) return {
1091
+ ...descriptor,
1092
+ value: this.wrap(descriptor.value, [...path, String(key)])
1093
+ };
1094
+ return descriptor;
1095
+ }),
1096
+ defineProperty: () => this.fail(/* @__PURE__ */ new Error("Define draft fields by assignment, not descriptors")),
1097
+ setPrototypeOf: () => this.fail(/* @__PURE__ */ new Error("Cannot change draft prototypes")),
1098
+ preventExtensions: () => this.fail(/* @__PURE__ */ new Error("Cannot freeze a transaction draft"))
1099
+ });
1100
+ this.wrappers.set(value, proxy);
1101
+ this.originals.set(proxy, value);
1102
+ return proxy;
1103
+ }
1104
+ changing(path, key, next, target, remove) {
1105
+ if (reserved.has(key)) throw new Error(`Reserved field: ${key}`);
1106
+ if (!path.length) {
1107
+ if (key !== "contain-relations" || remove) throw new Error("Cannot replace or delete a DSL root");
1108
+ this.treeChanged = true;
1109
+ return;
1110
+ }
1111
+ const [table, id] = path;
1112
+ if (table === "contain-relations") {
1113
+ this.treeChanged = true;
1114
+ if (key === "id" || key === "entityId" && target[key] !== void 0 && next !== target[key]) throw new Error("Containment identity cannot be changed");
1115
+ return;
1116
+ }
1117
+ if (!entities.has(table) && !relations.has(table)) throw new Error(`Unknown writable root: ${table}`);
1118
+ const rowId = path.length === 1 ? key : id;
1119
+ assertId(rowId);
1120
+ if (entities.has(table)) {
1121
+ const typed = table;
1122
+ const initial = this.before[typed][rowId];
1123
+ const lifetime = rowId;
1124
+ if (path.length === 1 && (remove || next === void 0)) this.deleted.add(lifetime);
1125
+ else if (this.deleted.has(lifetime)) throw new Error("Recreate an entity in a separate transaction");
1126
+ if (table === "audio-script-entities") {
1127
+ if (path.length === 2 && key === "body" && (initial || this.textPreviews.has(rowId))) throw new Error("Edit body through the Text adapter");
1128
+ if (path.length === 1 && initial && isRecord(next) && next.body !== initial.body) throw new Error("Edit body through the Text adapter");
1129
+ if (path.length === 1 && this.textPreviews.has(rowId)) throw new Error("Cannot delete or replace a body used in this transaction");
1130
+ }
1131
+ if (table === "caption-entities") {
1132
+ const changesText = path.length === 1 || path.length === 2 && key === "text";
1133
+ const nextText = path.length === 1 ? isRecord(next) ? next.text : void 0 : next;
1134
+ if (changesText) {
1135
+ if (this.textPreviews.has(rowId)) throw new Error("Cannot delete or replace a body used in this transaction");
1136
+ if (nextText !== void 0 && (this.removedCaptionTexts.has(rowId) || initial?.text !== void 0 && nextText !== initial.text)) throw new Error("Edit Caption text through the Text adapter");
1137
+ if (initial?.text !== void 0 && nextText === void 0) this.removedCaptionTexts.add(rowId);
1138
+ }
1139
+ }
1140
+ if (initial && [
1141
+ "media-entities",
1142
+ "asset-entities",
1143
+ "voice-entities"
1144
+ ].includes(table)) {
1145
+ for (const field of [
1146
+ "system",
1147
+ "key",
1148
+ "kind"
1149
+ ]) if (path.length === 2 && key === field && next !== initial[field] || path.length === 1 && isRecord(next) && next[field] !== initial[field]) throw new Error(`Resource identity field is immutable: ${field}`);
1150
+ }
1151
+ let rows = this.entities.get(typed);
1152
+ if (!rows) this.entities.set(typed, rows = /* @__PURE__ */ new Map());
1153
+ let fields = rows.get(rowId);
1154
+ if (!fields) rows.set(rowId, fields = /* @__PURE__ */ new Set());
1155
+ if (path.length === 1) for (const field of [...Object.keys(initial ?? {}), ...Object.keys(isRecord(next) ? next : {})]) {
1156
+ if ((field === "body" && table === "audio-script-entities" || field === "text" && table === "caption-entities") && initial && isRecord(next) && next[field] === initial[field]) continue;
1157
+ fields.add(field);
1158
+ }
1159
+ else fields.add(path[2] ?? key);
1160
+ } else {
1161
+ let ids = this.relations.get(table);
1162
+ if (!ids) this.relations.set(table, ids = /* @__PURE__ */ new Set());
1163
+ ids.add(rowId);
1164
+ if (table === "caption-content-relations") this.dirtyCaptions.add(rowId);
1165
+ }
1166
+ }
1167
+ };
1168
+ //#endregion
1169
+ //#region src/dsl/document/draft-operations.ts
1170
+ function nodeIndex(nodes) {
1171
+ const result = /* @__PURE__ */ new Map();
1172
+ const seen = /* @__PURE__ */ new Set();
1173
+ const walk = (siblings, parent) => {
1174
+ if (!Array.isArray(siblings)) throw new Error("Expected containment children");
1175
+ for (const node of siblings) {
1176
+ if (!node || !isRecord(node.data) || seen.has(node)) throw new Error("Invalid or cyclic containment");
1177
+ seen.add(node);
1178
+ if (result.has(node.data.entityId)) throw new Error(`Duplicate containment: ${node.data.entityId}`);
1179
+ result.set(node.data.entityId, {
1180
+ node,
1181
+ siblings,
1182
+ parent
1183
+ });
1184
+ walk(node.children ?? [], node);
1185
+ }
1186
+ };
1187
+ walk(nodes);
1188
+ return result;
1189
+ }
1190
+ function applyPatch(target, patch) {
1191
+ for (const [key, value] of Object.entries(patch)) if (value === void 0) delete target[key];
1192
+ else if (isRecord(value)) {
1193
+ if (!isRecord(target[key])) target[key] = {};
1194
+ applyPatch(target[key], value);
1195
+ } else target[key] = value;
1196
+ }
1197
+ /** The draft tree as plain nodes, for the DSL's containment constraints. */
1198
+ function plainTree(nodes) {
1199
+ return nodes.map((node) => ({
1200
+ entityId: node.data.entityId,
1201
+ children: plainTree(node.children ?? [])
1202
+ }));
1203
+ }
1204
+ function changeTree(nodes, mutation, rows) {
1205
+ const index = nodeIndex(nodes);
1206
+ assertContainChange(rows, plainTree(nodes), mutation);
1207
+ const current = index.get(mutation.entityId);
1208
+ if (current) current.siblings.splice(current.siblings.indexOf(current.node), 1);
1209
+ if (mutation.type === "delete-node") return;
1210
+ const parent = mutation.parentId === void 0 ? void 0 : index.get(mutation.parentId);
1211
+ const siblings = parent ? parent.node.children ??= [] : nodes;
1212
+ siblings.splice(mutation.index ?? siblings.length, 0, current?.node ?? {
1213
+ data: { entityId: mutation.entityId },
1214
+ children: []
1215
+ });
1216
+ }
1217
+ //#endregion
1218
+ //#region src/dsl/document/mutations.ts
1219
+ /** Mutations are a convenience vocabulary over the same guarded draft and native command queue. */
1220
+ function applyMutations(tx, mutations) {
1221
+ const rows = tx.draft;
1222
+ for (const m of mutations) {
1223
+ if ("id" in m) assertId(m.id);
1224
+ switch (m.type) {
1225
+ case "create-entity": {
1226
+ assertEntity(m.table, m.value);
1227
+ const old = rows[m.table][m.id];
1228
+ if (old && ![
1229
+ "media-entities",
1230
+ "voice-entities",
1231
+ "asset-entities"
1232
+ ].includes(m.table)) throw new Error(`Entity already exists: ${m.id}`);
1233
+ if (old) {
1234
+ assertEntityPatch(m.table, m.value, old);
1235
+ applyPatch(old, m.value);
1236
+ } else rows[m.table][m.id] = m.value;
1237
+ break;
1238
+ }
1239
+ case "patch-entity": {
1240
+ const current = rows[m.table][m.id];
1241
+ if (!current) throw new Error(`Missing entity: ${m.id}`);
1242
+ assertEntityPatch(m.table, m.patch, current);
1243
+ if (m.table === "clip-entities" && Object.hasOwn(m.patch, "seqMarker")) {
1244
+ const { seqMarker, ...rest } = m.patch;
1245
+ current.seqMarker = mergeFields(current.seqMarker, seqMarker ?? {});
1246
+ applyPatch(current, rest);
1247
+ } else applyPatch(current, m.patch);
1248
+ break;
1249
+ }
1250
+ case "delete-entity":
1251
+ delete rows[m.table][m.id];
1252
+ break;
1253
+ case "set-relation": {
1254
+ assertRelation(m.table, m.value);
1255
+ const id = relationId(m.table, m.value);
1256
+ if (m.table === "time-anchor-relations" || m.table === "caption-content-relations") tx.draft[m.table][id] = m.value;
1257
+ else {
1258
+ rows[m.table][id] ??= {};
1259
+ applyPatch(rows[m.table][id], m.value);
1260
+ }
1261
+ break;
1262
+ }
1263
+ case "delete-relation":
1264
+ delete rows[m.table][m.id];
1265
+ break;
1266
+ case "edit-text":
1267
+ tx.editText(m.audioScriptId, m.edits, m.expectedBodyId);
1268
+ break;
1269
+ case "edit-caption-text":
1270
+ tx.editCaptionText(m.captionId, m.edits, m.expectedBodyId);
1271
+ break;
1272
+ case "set-caption-content":
1273
+ tx.setCaptionContent(m);
1274
+ break;
1275
+ default: changeTree(tx.draft["contain-relations"], m, tx.draft);
1276
+ }
1277
+ }
1278
+ }
1279
+ //#endregion
1280
+ //#region src/dsl/document/native-text.ts
1281
+ function applyTextEdits(text, edits) {
1282
+ for (const edit of edits) switch (edit.type) {
1283
+ case "insert":
1284
+ text.insert(edit.at, edit.text);
1285
+ break;
1286
+ case "delete":
1287
+ text.delete(edit.at, edit.length);
1288
+ break;
1289
+ case "mark":
1290
+ if (edit.key === "pauseMs" && edit.value === 0) text.unmark({
1291
+ start: edit.start,
1292
+ end: edit.end
1293
+ }, edit.key);
1294
+ else text.mark({
1295
+ start: edit.start,
1296
+ end: edit.end
1297
+ }, edit.key, edit.value);
1298
+ break;
1299
+ case "unmark":
1300
+ text.unmark({
1301
+ start: edit.start,
1302
+ end: edit.end
1303
+ }, edit.key);
1304
+ break;
1305
+ case "delta":
1306
+ text.applyDelta(edit.delta);
1307
+ break;
1308
+ }
1309
+ }
1310
+ //#endregion
1311
+ //#region src/dsl/document/validation.ts
1312
+ /** One relation row by the DSL's rules; caption content must also resolve in the live body. */
1313
+ function validateRelation(doc, state, table, id, value) {
1314
+ assertRelationAdmitted(state, table, id, value);
1315
+ if (table !== "caption-content-relations") return;
1316
+ const resolved = resolveContent(doc, value);
1317
+ if (!resolved || resolved.start === resolved.end) throw new Error("Expected a nonempty range in the current body");
1318
+ captionContentCodec.encode(value);
1319
+ }
1320
+ /**
1321
+ * The DSL's admission of the whole draft, then what only this document can check: containment
1322
+ * nodes keep their Tree identities, and Mirror cannot yet move a node under a parent it creates.
1323
+ */
1324
+ function validateDraft(tracked) {
1325
+ const state = tracked.raw, before = tracked.before;
1326
+ assertChangeAdmitted({
1327
+ before,
1328
+ after: state,
1329
+ entities: tracked.entities,
1330
+ relations: tracked.relations
1331
+ });
1332
+ if (!tracked.treeChanged) return;
1333
+ const oldNodes = nodeIndex(before["contain-relations"] ?? []);
1334
+ const nodes = state["contain-relations"] ?? [];
1335
+ for (const [entityId, { node, parent }] of nodeIndex(nodes)) {
1336
+ assertId(entityId);
1337
+ if (Object.keys(node).some((k) => ![
1338
+ "id",
1339
+ "data",
1340
+ "children"
1341
+ ].includes(k)) || Object.keys(node.data).some((k) => k !== "entityId")) throw new Error("Unknown containment field");
1342
+ const old = oldNodes.get(entityId)?.node;
1343
+ if (old && node.id !== old.id || !old && node.id !== void 0) throw new Error("Containment identity cannot be changed");
1344
+ if (node.id && parent && !parent.id) throw new Error("Moving an existing node into a new parent requires a native Action");
1345
+ }
1346
+ assertContainTree(state, plainTree(nodes));
1347
+ }
1348
+ //#endregion
1349
+ //#region src/dsl/document/transact.ts
1350
+ /** Serialization happens before preparing any writes, so invalid/cyclic audit data cannot partly commit. */
1351
+ function auditOptions(audit) {
1352
+ if (!audit || typeof audit.semantic_op !== "string" || !audit.semantic_op.length || !Object.hasOwn(audit, "payload")) throw new Error("A structured DSL audit is required");
1353
+ if (audit.actor != null && (typeof audit.actor.user_id !== "string" || !audit.actor.user_id.length || !["user", "agent"].includes(audit.actor.role))) throw new Error("Invalid audit actor");
1354
+ return {
1355
+ origin: "mengine.semantic_editor",
1356
+ message: JSON.stringify({
1357
+ semantic_op: audit.semantic_op,
1358
+ payload: audit.payload ?? null,
1359
+ intent: audit.intent ?? null,
1360
+ actor: audit.actor ?? null
1361
+ })
1362
+ };
1363
+ }
1364
+ /** The only bridge from a prepared DSL edit to Mirror's single native commit. */
1365
+ function runTransaction(doc, mirror, edit, options, setReject) {
1366
+ const steps = [];
1367
+ const newCaptionTexts = /* @__PURE__ */ new Map();
1368
+ const bodies = /* @__PURE__ */ new Map();
1369
+ const before = mirror.getState();
1370
+ mirror.setState((raw) => {
1371
+ const tracked = new TransactionDraft(raw, before);
1372
+ setReject((error) => tracked.fail(error));
1373
+ const useBody = (scriptId, caption = false) => {
1374
+ assertId(scriptId);
1375
+ const entity = requireEntity(tracked.draft, caption ? "caption-entities" : "audio-script-entities", scriptId);
1376
+ if (tracked.deleted.has(scriptId)) throw new Error("Cannot use a deleted body");
1377
+ const body = entity[caption ? "text" : "body"];
1378
+ if (typeof body !== "string") throw new Error("Missing Text body");
1379
+ if (caption && tracked.removedCaptionTexts.has(scriptId)) throw new Error("Cannot edit a removed Text");
1380
+ tracked.textPreviews.set(scriptId, body);
1381
+ return body;
1382
+ };
1383
+ const flushCaptions = () => {
1384
+ for (const id of tracked.dirtyCaptions) {
1385
+ const value = raw["caption-content-relations"][id];
1386
+ if (value !== void 0) {
1387
+ validateRelation(doc, raw, "caption-content-relations", id, value);
1388
+ useBody(value.audioScriptId);
1389
+ }
1390
+ steps.push({
1391
+ type: "caption",
1392
+ id,
1393
+ value: copyInput(value)
1394
+ });
1395
+ }
1396
+ tracked.dirtyCaptions.clear();
1397
+ };
1398
+ const editBody = (scriptId, edits, expectedBodyId, caption) => {
1399
+ tracked.run(() => {
1400
+ flushCaptions();
1401
+ const body = useBody(scriptId, caption);
1402
+ if (!bodies.has(scriptId)) bodies.set(scriptId, liveText(doc, scriptId, caption));
1403
+ const native = bodies.get(scriptId);
1404
+ if (expectedBodyId !== void 0 && native?.id !== expectedBodyId) throw new Error("Stale or missing Text body");
1405
+ if ((caption ? before["caption-entities"][scriptId]?.text : before["audio-script-entities"][scriptId]?.body) !== void 0 && !native) throw new Error("Missing live Text body");
1406
+ const prepared = prepareTextEdits(body, copyInput(edits));
1407
+ tracked.textPreviews.set(scriptId, prepared.body);
1408
+ steps.push({
1409
+ type: "text",
1410
+ scriptId,
1411
+ caption,
1412
+ expectedBodyId: native?.id,
1413
+ edits: prepared.edits
1414
+ });
1415
+ });
1416
+ };
1417
+ const tx = Object.freeze({
1418
+ get draft() {
1419
+ return tracked.run(() => tracked.draft);
1420
+ },
1421
+ applyMutations(mutations) {
1422
+ tracked.run(() => applyMutations(tx, mutations));
1423
+ },
1424
+ editText(scriptId, edits, expectedBodyId) {
1425
+ editBody(scriptId, edits, expectedBodyId, false);
1426
+ },
1427
+ editCaptionText(captionId, edits, expectedBodyId) {
1428
+ editBody(captionId, edits, expectedBodyId, true);
1429
+ },
1430
+ setCaptionContent(selection) {
1431
+ tracked.run(() => {
1432
+ flushCaptions();
1433
+ const { captionId, audioScriptId, start, end } = selection;
1434
+ assertId(captionId);
1435
+ assertCaptionEndpoints(raw, selection);
1436
+ assertCaptionSelection(raw, selection, useBody(audioScriptId));
1437
+ steps.push({
1438
+ type: "range",
1439
+ selection: {
1440
+ captionId,
1441
+ audioScriptId,
1442
+ start,
1443
+ end
1444
+ }
1445
+ });
1446
+ });
1447
+ }
1448
+ });
1449
+ try {
1450
+ tracked.run(() => {
1451
+ if (Object.prototype.toString.call(edit) === "[object AsyncFunction]") throw new Error("DSL transact must be synchronous");
1452
+ const result = edit(tx);
1453
+ if (result && typeof result === "object" && "then" in result) {
1454
+ Promise.resolve(result).catch(() => {});
1455
+ throw new Error("DSL transact must be synchronous");
1456
+ }
1457
+ tracked.assertValid();
1458
+ if (doc.getPendingTxnLength()) throw new Error("Native writes are not allowed during transaction preparation");
1459
+ flushCaptions();
1460
+ for (const id of tracked.entities.get("caption-entities")?.keys() ?? []) {
1461
+ const caption = tracked.draft["caption-entities"][id];
1462
+ if (caption) caption.style ??= {};
1463
+ }
1464
+ validateDraft(tracked);
1465
+ for (const step of steps) if (step.type === "text") requireEntity(raw, step.caption ? "caption-entities" : "audio-script-entities", step.scriptId);
1466
+ const referenceIds = new Set(Object.keys(raw["caption-content-relations"]).map((id) => id.slice(3)));
1467
+ const changedCaptions = /* @__PURE__ */ new Set();
1468
+ for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (fields.has("text")) changedCaptions.add(id);
1469
+ for (const step of steps) {
1470
+ if (step.type === "range") {
1471
+ referenceIds.add(step.selection.captionId);
1472
+ changedCaptions.add(step.selection.captionId);
1473
+ }
1474
+ if (step.type === "caption") {
1475
+ const id = step.id.slice(3);
1476
+ changedCaptions.add(id);
1477
+ if (step.value) referenceIds.add(id);
1478
+ else referenceIds.delete(id);
1479
+ }
1480
+ if (step.type === "text" && step.caption) changedCaptions.add(step.scriptId);
1481
+ }
1482
+ for (const id of changedCaptions) assertCaptionSource(raw["caption-entities"][id], referenceIds.has(id));
1483
+ for (const step of steps) if (step.type === "range") assertCaptionEndpoints(raw, step.selection);
1484
+ else if (step.type === "caption" && step.value) assertCaptionEndpoints(raw, step.value);
1485
+ for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) {
1486
+ const row = raw["caption-entities"][id];
1487
+ if (fields.has("text") && row?.text !== void 0 && before["caption-entities"][id]?.text === void 0) {
1488
+ newCaptionTexts.set(id, row.text);
1489
+ delete row.text;
1490
+ }
1491
+ }
1492
+ for (const id of tracked.relations.get("caption-content-relations") ?? []) if (before["caption-content-relations"][id] === void 0) delete raw["caption-content-relations"][id];
1493
+ else raw["caption-content-relations"][id] = before["caption-content-relations"][id];
1494
+ });
1495
+ } finally {
1496
+ tracked.close();
1497
+ setReject(void 0);
1498
+ }
1499
+ }, {
1500
+ ...options,
1501
+ applyNative: (doc) => {
1502
+ const bodyFor = (scriptId, caption = false) => {
1503
+ let text = bodies.get(scriptId);
1504
+ if (!text) {
1505
+ text = liveText(doc, scriptId, caption);
1506
+ bodies.set(scriptId, text);
1507
+ }
1508
+ if (!text || text.isDeleted()) throw new Error("Prepared Text body is missing");
1509
+ return text;
1510
+ };
1511
+ for (const [id, value] of newCaptionTexts) {
1512
+ const body = doc.getMap("caption-entities").get(id).setContainer("text", new LoroText());
1513
+ if (value.length) body.insert(0, value);
1514
+ bodies.set(id, body);
1515
+ }
1516
+ for (const step of steps) if (step.type === "text") {
1517
+ const text = bodyFor(step.scriptId, step.caption);
1518
+ if (step.expectedBodyId && text.id !== step.expectedBodyId) throw new Error("Stale Text body");
1519
+ applyTextEdits(text, step.edits);
1520
+ } else if (step.type === "range") {
1521
+ const { captionId, audioScriptId, start, end } = step.selection;
1522
+ const text = bodyFor(audioScriptId);
1523
+ const from = text?.getCursor(start, 0), to = text?.getCursor(end, 0);
1524
+ if (!from || !to) throw new Error("Unable to construct prepared range");
1525
+ doc.getMap("caption-content-relations").set(`cc:${captionId}`, captionContentCodec.encode({
1526
+ captionId,
1527
+ audioScriptId,
1528
+ start: from,
1529
+ end: to
1530
+ }));
1531
+ } else {
1532
+ const relations = doc.getMap("caption-content-relations");
1533
+ if (step.value === void 0) {
1534
+ if (relations.get(step.id) !== void 0) relations.delete(step.id);
1535
+ } else relations.set(step.id, captionContentCodec.encode(step.value));
1536
+ }
1537
+ }
1538
+ });
1539
+ }
1540
+ //#endregion
1541
+ //#region src/dsl/document/medeo-dsl.ts
1542
+ const contentRoots = new Set([
1543
+ ...entityTables,
1544
+ ...relationTables,
1545
+ "contain-relations"
1546
+ ]);
1547
+ /** One live DSL document. Business Actions belong to medeo-client, not this storage interface. */
1548
+ var MedeoDsl = class MedeoDsl {
1549
+ doc;
1550
+ options;
1551
+ disposed = false;
1552
+ writing = false;
1553
+ rejectTransaction;
1554
+ listeners = /* @__PURE__ */ new Set();
1555
+ changeSource = "remote";
1556
+ snapshot;
1557
+ dirty = false;
1558
+ notifying = false;
1559
+ mirror;
1560
+ unsubscribe;
1561
+ ownsDoc = false;
1562
+ /** Opens a borrowed LoroDoc; dispose releases subscriptions but does not free the caller's doc. */
1563
+ constructor(doc, options = {}) {
1564
+ this.doc = doc;
1565
+ this.options = options;
1566
+ const roots = doc.getShallowValue();
1567
+ if ([
1568
+ "medeo",
1569
+ "timeline",
1570
+ "tracks",
1571
+ "part_library"
1572
+ ].some((key) => Object.hasOwn(roots, key))) throw new Error("Expected a new DSL document, not a legacy content model");
1573
+ doc.configTextStyle(textStyles);
1574
+ this.mirror = new Mirror({
1575
+ doc,
1576
+ schema: medeoDslLoroShape,
1577
+ validateUpdates: false,
1578
+ ignoreUnknownProperties: true
1579
+ });
1580
+ this.unsubscribe = doc.subscribe((event) => {
1581
+ if (!event.events.some((change) => contentRoots.has(String(change.path[0])))) return;
1582
+ this.snapshot = void 0;
1583
+ this.changeSource = event.by === "local" ? "local" : "remote";
1584
+ this.dirty = true;
1585
+ if (!this.writing) this.notify();
1586
+ });
1587
+ }
1588
+ /** Creates and owns a fresh document; this handle frees it on dispose. */
1589
+ static create(options = {}) {
1590
+ const doc = new LoroDoc();
1591
+ if (options.peerId !== void 0) doc.setPeerId(options.peerId);
1592
+ const dsl = new MedeoDsl(doc);
1593
+ dsl.ownsDoc = true;
1594
+ try {
1595
+ dsl.mutate(() => runTransaction(doc, dsl.mirror, (tx) => tx.applyMutations([{
1596
+ type: "create-entity",
1597
+ table: "timeline-entities",
1598
+ id: options.timelineId ?? (options.idFactory ?? defaultIdFactory)("timeline"),
1599
+ value: {}
1600
+ }]), { origin: "medeo.dsl.initialize" }, (reject) => {
1601
+ dsl.rejectTransaction = reject;
1602
+ }));
1603
+ return dsl;
1604
+ } catch (error) {
1605
+ dsl.dispose();
1606
+ throw error;
1607
+ }
1608
+ }
1609
+ getSnapshot() {
1610
+ this.assertActive();
1611
+ return this.snapshot ??= readMedeoDsl(this.doc, this.mirror.getState());
1612
+ }
1613
+ subscribe(listener) {
1614
+ this.assertActive();
1615
+ this.listeners.add(listener);
1616
+ return () => {
1617
+ this.listeners.delete(listener);
1618
+ };
1619
+ }
1620
+ /** One synchronous, audited edit. Preparation errors discard the whole draft before native writes. */
1621
+ transact(edit, audit) {
1622
+ this.mutate(() => runTransaction(this.doc, this.mirror, edit, auditOptions(audit), (reject) => {
1623
+ this.rejectTransaction = reject;
1624
+ }));
1625
+ }
1626
+ /** Preferred vocabulary for ordinary entity/relation/Tree edits and serializable native commands. */
1627
+ applyMutations(mutations, audit) {
1628
+ this.transact((tx) => tx.applyMutations(mutations), audit);
1629
+ }
1630
+ editText(scriptId, expectedBodyId, edits, audit) {
1631
+ this.transact((tx) => tx.editText(scriptId, edits, expectedBodyId), audit);
1632
+ }
1633
+ editCaptionText(captionId, expectedBodyId, edits, audit) {
1634
+ this.transact((tx) => tx.editCaptionText(captionId, edits, expectedBodyId), audit);
1635
+ }
1636
+ /** Local ranges exclude insertions at their start and include insertions at their end. K01 remains deferred. */
1637
+ textRange(scriptId, start, end) {
1638
+ this.assertActive();
1639
+ const text = liveText(this.doc, scriptId);
1640
+ if (!text) throw new Error("Missing AudioScript body");
1641
+ assertTextOffset(text.toString(), start);
1642
+ assertTextOffset(text.toString(), end);
1643
+ if (start >= end) throw new Error("Expected a nonempty range");
1644
+ const from = text.getCursor(start, 0), to = text.getCursor(end, 0);
1645
+ if (!from || !to) throw new Error("Unable to construct text range");
1646
+ return {
1647
+ audioScriptId: scriptId,
1648
+ start: from,
1649
+ end: to
1650
+ };
1651
+ }
1652
+ dispose() {
1653
+ if (this.disposed) return;
1654
+ this.disposed = true;
1655
+ this.unsubscribe();
1656
+ this.mirror.dispose();
1657
+ this.listeners.clear();
1658
+ if (this.ownsDoc) this.doc.free();
1659
+ }
1660
+ mutate(run) {
1661
+ this.assertActive();
1662
+ if (this.writing || this.notifying) {
1663
+ const error = /* @__PURE__ */ new Error("A DSL mutation is already running");
1664
+ this.rejectTransaction?.(error);
1665
+ throw error;
1666
+ }
1667
+ if (this.doc.getPendingTxnLength() !== 0) throw new Error("Commit external native edits before a DSL mutation");
1668
+ const apply = () => {
1669
+ this.writing = true;
1670
+ try {
1671
+ run();
1672
+ } finally {
1673
+ this.rejectTransaction = void 0;
1674
+ this.writing = false;
1675
+ if (this.dirty) this.snapshot = void 0;
1676
+ this.notify();
1677
+ }
1678
+ };
1679
+ if (this.options.mutationGuard) this.options.mutationGuard.run(apply);
1680
+ else apply();
1681
+ }
1682
+ notify() {
1683
+ if (this.disposed || !this.dirty) return;
1684
+ this.dirty = false;
1685
+ this.notifying = true;
1686
+ try {
1687
+ for (const listener of this.listeners) listener(this.changeSource);
1688
+ } finally {
1689
+ this.notifying = false;
1690
+ }
1691
+ }
1692
+ assertActive() {
1693
+ if (this.disposed) throw new Error("MedeoDsl is disposed");
1694
+ }
1695
+ };
1696
+ //#endregion
1697
+ //#region src/dsl/voiceover-actions.ts
1698
+ function voiceoverPlan(s, input, result) {
1699
+ if (requireTrack(s, input.trackId).role !== "speech" || input.media.kind !== "voiceover-recording") throw new Error("Voiceover requires a speech Track and a recording");
1700
+ if (input.captions.length && (!input.captionTrackId || requireTrack(s, input.captionTrackId).role !== "caption")) throw new Error("Caption Track is required");
1701
+ const existing = s["clip-entities"][result.clipId];
1702
+ if (existing && requireTrack(s, trackOf(s, result.clipId)).role !== "speech") throw new Error("Expected a speech Clip");
1703
+ const mutations = [{
1704
+ type: "create-entity",
1705
+ table: "media-entities",
1706
+ id: result.mediaId,
1707
+ value: input.media
1708
+ }];
1709
+ if (input.script.body !== void 0) mutations.push({
1710
+ type: "create-entity",
1711
+ table: "audio-script-entities",
1712
+ id: result.audioScriptId,
1713
+ value: {
1714
+ body: input.script.body,
1715
+ ..."defaultLanguage" in input.script && input.script.defaultLanguage !== void 0 ? { defaultLanguage: input.script.defaultLanguage } : {}
1716
+ }
1717
+ });
1718
+ else if (!s["audio-script-entities"][result.audioScriptId]) throw new Error("Missing AudioScript");
1719
+ validateWindow({
1720
+ inMs: 0,
1721
+ outMs: input.media.durationMs
1722
+ }, input.media.durationMs);
1723
+ mutations.push({
1724
+ type: "set-relation",
1725
+ table: "derived-from-relations",
1726
+ value: {
1727
+ entityId: result.mediaId,
1728
+ sourceId: result.audioScriptId
1729
+ }
1730
+ });
1731
+ if (input.voice) {
1732
+ const id = resourceId("voice", input.voice.system, input.voice.key);
1733
+ mutations.push({
1734
+ type: "create-entity",
1735
+ table: "voice-entities",
1736
+ id,
1737
+ value: input.voice
1738
+ }, {
1739
+ type: "set-relation",
1740
+ table: "voice-timbre-relations",
1741
+ value: {
1742
+ recordingId: result.mediaId,
1743
+ voiceId: id
1744
+ }
1745
+ });
1746
+ }
1747
+ mutations.push(existing ? {
1748
+ type: "patch-entity",
1749
+ table: "clip-entities",
1750
+ id: result.clipId,
1751
+ patch: {
1752
+ seqMarker: {
1753
+ inMs: 0,
1754
+ outMs: input.media.durationMs,
1755
+ rate: void 0,
1756
+ fillTimeline: void 0
1757
+ },
1758
+ ...input.volume === void 0 ? {} : { volume: input.volume }
1759
+ }
1760
+ } : {
1761
+ type: "create-entity",
1762
+ table: "clip-entities",
1763
+ id: result.clipId,
1764
+ value: {
1765
+ seqMarker: {
1766
+ inMs: 0,
1767
+ outMs: input.media.durationMs
1768
+ },
1769
+ ...input.volume === void 0 ? {} : { volume: input.volume }
1770
+ }
1771
+ }, {
1772
+ type: "set-relation",
1773
+ table: "clip-source-relations",
1774
+ value: {
1775
+ clipId: result.clipId,
1776
+ sourceId: result.mediaId
1777
+ }
1778
+ });
1779
+ if (!existing || trackOf(s, result.clipId) !== input.trackId) mutations.push({
1780
+ type: existing ? "move-node" : "insert-node",
1781
+ entityId: result.clipId,
1782
+ parentId: input.trackId
1783
+ });
1784
+ mutations.push(...placementMutations(s, result.clipId, input.placement));
1785
+ uniqueIds(result.captionClipIds);
1786
+ const retained = new Set(result.captionClipIds);
1787
+ const oldCaptions = Object.values(s["time-anchor-relations"]).filter((r) => r.to === result.clipId && s["clip-entities"][r.from] && requireTrack(s, trackOf(s, r.from)).role === "caption").map((r) => r.from);
1788
+ mutations.push(...deleteClips(s, oldCaptions.filter((id) => !retained.has(id)), "cascade"));
1789
+ const anchor = mutations.find((m) => m.type === "set-relation" && m.table === "time-anchor-relations" && m.value.from === result.clipId);
1790
+ const trackClips = resolveDslLayout(s).tracks.find((track) => track.trackId === input.trackId).clips;
1791
+ const slot = trackClips.findIndex((clip) => clip.clipId === result.clipId);
1792
+ const predecessor = trackClips.slice(0, slot < 0 ? trackClips.length : slot).findLast((clip) => clip.mode === "sequential" && !clip.fillTimeline);
1793
+ const speechStart = anchor?.type === "set-relation" && anchor.table === "time-anchor-relations" ? anchor.value.fallback_abs_ms : predecessor ? predecessor.startMs + predecessor.durationMs : 0;
1794
+ input.captions.forEach((caption, i) => {
1795
+ const clipId = result.captionClipIds[i], captionId = result.captionIds[i];
1796
+ validateWindow({
1797
+ inMs: caption.startMs,
1798
+ outMs: caption.startMs + caption.durationMs
1799
+ }, input.media.durationMs);
1800
+ const previous = s["clip-entities"][clipId];
1801
+ if (previous && !oldCaptions.includes(clipId)) throw new Error("Cannot overwrite another use’s Caption Clip");
1802
+ const previousSource = s["clip-source-relations"][`cs:${clipId}`]?.sourceId;
1803
+ const style = caption.style ?? (previousSource ? s["caption-entities"][previousSource]?.style : void 0);
1804
+ mutations.push({
1805
+ type: "create-entity",
1806
+ table: "caption-entities",
1807
+ id: captionId,
1808
+ value: style ? { style } : {}
1809
+ }, {
1810
+ type: "set-caption-content",
1811
+ captionId,
1812
+ audioScriptId: result.audioScriptId,
1813
+ start: caption.range.start,
1814
+ end: caption.range.end
1815
+ }, {
1816
+ type: "set-relation",
1817
+ table: "caption-alignment-relations",
1818
+ value: {
1819
+ captionId,
1820
+ mediaId: result.mediaId,
1821
+ startMs: caption.startMs,
1822
+ endMs: caption.startMs + caption.durationMs
1823
+ }
1824
+ }, previous ? {
1825
+ type: "patch-entity",
1826
+ table: "clip-entities",
1827
+ id: clipId,
1828
+ patch: { seqMarker: {
1829
+ inMs: 0,
1830
+ outMs: caption.durationMs
1831
+ } }
1832
+ } : {
1833
+ type: "create-entity",
1834
+ table: "clip-entities",
1835
+ id: clipId,
1836
+ value: { seqMarker: {
1837
+ inMs: 0,
1838
+ outMs: caption.durationMs
1839
+ } }
1840
+ }, {
1841
+ type: "set-relation",
1842
+ table: "clip-source-relations",
1843
+ value: {
1844
+ clipId,
1845
+ sourceId: captionId
1846
+ }
1847
+ }, {
1848
+ type: "set-relation",
1849
+ table: "time-anchor-relations",
1850
+ value: {
1851
+ from: clipId,
1852
+ to: result.clipId,
1853
+ offsetMs: caption.startMs,
1854
+ fallback_abs_ms: speechStart + caption.startMs
1855
+ }
1856
+ });
1857
+ if (!previous || trackOf(s, clipId) !== input.captionTrackId) mutations.push({
1858
+ type: previous ? "move-node" : "insert-node",
1859
+ entityId: clipId,
1860
+ parentId: input.captionTrackId
1861
+ });
1862
+ });
1863
+ return mutations;
1864
+ }
1865
+ //#endregion
1866
+ //#region src/dsl/dsl-editor.ts
1867
+ /** Business edits write DSL facts in one audited transaction; layout is derived on read. */
1868
+ var DslEditor = class {
1869
+ dsl;
1870
+ idFactory;
1871
+ constructor(dsl, idFactory = defaultIdFactory) {
1872
+ this.dsl = dsl;
1873
+ this.idFactory = idFactory;
1874
+ }
1875
+ createTrack(role, options) {
1876
+ const id = this.idFactory("track");
1877
+ this.write("createTrack", {
1878
+ id,
1879
+ role
1880
+ }, [{
1881
+ type: "create-entity",
1882
+ table: "track-entities",
1883
+ id,
1884
+ value: { role }
1885
+ }, {
1886
+ type: "insert-node",
1887
+ entityId: id
1888
+ }], options);
1889
+ return id;
1890
+ }
1891
+ registerMedia(media, options) {
1892
+ const id = resourceId("media", media.system, media.key);
1893
+ this.write("registerMedia", {
1894
+ id,
1895
+ media
1896
+ }, [{
1897
+ type: "create-entity",
1898
+ table: "media-entities",
1899
+ id,
1900
+ value: media
1901
+ }], options);
1902
+ return id;
1903
+ }
1904
+ addClips(input, options) {
1905
+ const ids = input.clips.map(() => this.idFactory("clip"));
1906
+ this.write("addClips", {
1907
+ ...input,
1908
+ ids
1909
+ }, addClipsPlan(this.dsl.getSnapshot(), input, ids), options);
1910
+ return ids;
1911
+ }
1912
+ /** index is measured in the target's children after removing the moved Clip. */
1913
+ moveClip(clipId, trackId, index, options) {
1914
+ const snapshot = this.dsl.getSnapshot();
1915
+ requireClip(snapshot, clipId);
1916
+ const track = snapshot["track-entities"][trackId];
1917
+ const source = snapshot["clip-source-relations"][`cs:${clipId}`];
1918
+ if (!track || !source) throw new Error("Missing target Track or Clip source");
1919
+ requireSource(snapshot, source.sourceId, track.role);
1920
+ this.write("moveClip", {
1921
+ clipId,
1922
+ trackId,
1923
+ index
1924
+ }, [{
1925
+ type: "move-node",
1926
+ entityId: clipId,
1927
+ parentId: trackId,
1928
+ index
1929
+ }], options);
1930
+ }
1931
+ trimClip(clipId, window, options) {
1932
+ const snapshot = this.dsl.getSnapshot(), clip = requireClip(snapshot, clipId);
1933
+ if (clip.seqMarker.fillTimeline) throw new Error("BGM trim is not enabled");
1934
+ const sourceId = snapshot["clip-source-relations"][`cs:${clipId}`]?.sourceId;
1935
+ const source = sourceId ? snapshot["media-entities"][sourceId] ?? snapshot["caption-entities"][sourceId] : void 0;
1936
+ if (!source) throw new Error("Cannot trim a Clip with a missing source");
1937
+ const patch = {};
1938
+ if (window.inMs !== void 0) patch.inMs = window.inMs;
1939
+ if (window.outMs !== void 0) patch.outMs = window.outMs;
1940
+ validateWindow({
1941
+ ...clip.seqMarker,
1942
+ ...patch
1943
+ }, "durationMs" in source ? source.durationMs : void 0);
1944
+ this.write("trimClip", {
1945
+ clipId,
1946
+ window: patch
1947
+ }, [{
1948
+ type: "patch-entity",
1949
+ table: "clip-entities",
1950
+ id: clipId,
1951
+ patch: { seqMarker: patch }
1952
+ }], options);
1953
+ }
1954
+ setClipSpeed(clipId, speed, options) {
1955
+ if (requireClip(this.dsl.getSnapshot(), clipId).seqMarker.fillTimeline) throw new Error("BGM speed is not enabled");
1956
+ this.write("setClipSpeed", {
1957
+ clipId,
1958
+ speed: speed ?? null
1959
+ }, [{
1960
+ type: "patch-entity",
1961
+ table: "clip-entities",
1962
+ id: clipId,
1963
+ patch: { seqMarker: { rate: speed?.rate } }
1964
+ }], options);
1965
+ }
1966
+ setClipVolume(clipId, volume, options) {
1967
+ requireClip(this.dsl.getSnapshot(), clipId);
1968
+ this.write("setClipVolume", {
1969
+ clipId,
1970
+ volume: volume ?? null
1971
+ }, [{
1972
+ type: "patch-entity",
1973
+ table: "clip-entities",
1974
+ id: clipId,
1975
+ patch: { volume }
1976
+ }], options);
1977
+ }
1978
+ createAudioScript(body, defaultLanguage, options) {
1979
+ const id = this.idFactory("audio-script");
1980
+ this.write("createAudioScript", {
1981
+ id,
1982
+ body,
1983
+ defaultLanguage
1984
+ }, [{
1985
+ type: "create-entity",
1986
+ table: "audio-script-entities",
1987
+ id,
1988
+ value: {
1989
+ body,
1990
+ ...defaultLanguage === void 0 ? {} : { defaultLanguage }
1991
+ }
1992
+ }], options);
1993
+ return id;
1994
+ }
1995
+ editAudioScript(scriptId, expectedBodyId, edits, options) {
1996
+ this.dsl.editText(scriptId, expectedBodyId, edits, audit("editAudioScript", {
1997
+ scriptId,
1998
+ expectedBodyId,
1999
+ edits
2000
+ }, options));
2001
+ }
2002
+ createCaption(input, options) {
2003
+ const id = this.idFactory("caption");
2004
+ this.write("createCaption", {
2005
+ id,
2006
+ ...input
2007
+ }, [{
2008
+ type: "create-entity",
2009
+ table: "caption-entities",
2010
+ id,
2011
+ value: {
2012
+ ...input.style === void 0 ? {} : { style: input.style },
2013
+ ...input.text === void 0 ? {} : { text: input.text }
2014
+ }
2015
+ }, ...input.text !== void 0 ? [] : [{
2016
+ type: "set-caption-content",
2017
+ captionId: id,
2018
+ audioScriptId: input.audioScriptId,
2019
+ start: input.start,
2020
+ end: input.end
2021
+ }]], options);
2022
+ return id;
2023
+ }
2024
+ setCaptionContent(captionId, audioScriptId, start, end, options) {
2025
+ this.write("setCaptionContent", {
2026
+ captionId,
2027
+ audioScriptId,
2028
+ start,
2029
+ end
2030
+ }, [{
2031
+ type: "patch-entity",
2032
+ table: "caption-entities",
2033
+ id: captionId,
2034
+ patch: { text: void 0 }
2035
+ }, {
2036
+ type: "set-caption-content",
2037
+ captionId,
2038
+ audioScriptId,
2039
+ start,
2040
+ end
2041
+ }], options);
2042
+ }
2043
+ setCaptionText(captionId, text, options) {
2044
+ const caption = this.dsl.getSnapshot()["caption-entities"][captionId];
2045
+ if (!caption) throw new Error("Missing Caption");
2046
+ this.dsl.transact((tx) => {
2047
+ tx.applyMutations([{
2048
+ type: "delete-relation",
2049
+ table: "caption-content-relations",
2050
+ id: `cc:${captionId}`
2051
+ }]);
2052
+ if (caption.text === void 0) tx.draft["caption-entities"][captionId].text = text;
2053
+ else if (caption.text !== text) tx.editCaptionText(captionId, [...caption.text.length ? [{
2054
+ type: "delete",
2055
+ at: 0,
2056
+ length: caption.text.length
2057
+ }] : [], ...text.length ? [{
2058
+ type: "insert",
2059
+ at: 0,
2060
+ text
2061
+ }] : []]);
2062
+ }, audit("setCaptionText", {
2063
+ captionId,
2064
+ text
2065
+ }, options));
2066
+ }
2067
+ editCaptionText(captionId, expectedBodyId, edits, options) {
2068
+ this.dsl.editCaptionText(captionId, expectedBodyId, edits, audit("editCaptionText", {
2069
+ captionId,
2070
+ expectedBodyId,
2071
+ edits
2072
+ }, options));
2073
+ }
2074
+ moveTrack(trackId, index, options) {
2075
+ requireTrack(this.dsl.getSnapshot(), trackId);
2076
+ this.write("moveTrack", {
2077
+ trackId,
2078
+ index
2079
+ }, [{
2080
+ type: "move-node",
2081
+ entityId: trackId,
2082
+ index
2083
+ }], options);
2084
+ }
2085
+ setTrackHidden(trackId, hidden, options) {
2086
+ requireTrack(this.dsl.getSnapshot(), trackId);
2087
+ this.write("setTrackHidden", {
2088
+ trackId,
2089
+ hidden: hidden ?? null
2090
+ }, [{
2091
+ type: "patch-entity",
2092
+ table: "track-entities",
2093
+ id: trackId,
2094
+ patch: { hidden }
2095
+ }], options);
2096
+ }
2097
+ deleteTrack(trackId, onAnchored, options) {
2098
+ const s = this.dsl.getSnapshot();
2099
+ this.write("deleteTrack", {
2100
+ trackId,
2101
+ onAnchored
2102
+ }, [
2103
+ ...deleteClips(s, children(s, trackId), onAnchored),
2104
+ {
2105
+ type: "delete-node",
2106
+ entityId: trackId
2107
+ },
2108
+ {
2109
+ type: "delete-entity",
2110
+ table: "track-entities",
2111
+ id: trackId
2112
+ }
2113
+ ], options);
2114
+ }
2115
+ setClipPlacement(clipId, placement, options) {
2116
+ const s = this.dsl.getSnapshot();
2117
+ trackOf(s, clipId);
2118
+ this.write("setClipPlacement", {
2119
+ clipId,
2120
+ placement
2121
+ }, placementMutations(s, clipId, placement), options);
2122
+ }
2123
+ moveClips(input, options) {
2124
+ this.write("moveClips", input, moveClipsPlan(this.dsl.getSnapshot(), input), options);
2125
+ }
2126
+ deleteClips(clipIds, onAnchored, options) {
2127
+ this.write("deleteClips", {
2128
+ clipIds,
2129
+ onAnchored
2130
+ }, deleteClips(this.dsl.getSnapshot(), clipIds, onAnchored), options);
2131
+ }
2132
+ replaceClipSource(clipId, input, options) {
2133
+ this.write("replaceClipSource", {
2134
+ clipId,
2135
+ ...input
2136
+ }, replaceSourcePlan(this.dsl.getSnapshot(), clipId, input), options);
2137
+ }
2138
+ replaceClips(input, options) {
2139
+ const ids = input.clips.map(() => this.idFactory("clip"));
2140
+ this.write("replaceClips", {
2141
+ ...input,
2142
+ ids
2143
+ }, replaceSequencePlan(this.dsl.getSnapshot(), input, ids), options);
2144
+ return ids;
2145
+ }
2146
+ updateClips(updates, options) {
2147
+ this.write("updateClips", { updates }, updateClipsPlan(this.dsl.getSnapshot(), updates), options);
2148
+ }
2149
+ patchCaptionStyle(captionIds, patch, options) {
2150
+ this.write("patchCaptionStyle", {
2151
+ captionIds,
2152
+ patch
2153
+ }, captionIds.map((id) => ({
2154
+ type: "patch-entity",
2155
+ table: "caption-entities",
2156
+ id,
2157
+ patch: { style: patch }
2158
+ })), options);
2159
+ }
2160
+ applyVoiceover(input, options) {
2161
+ const result = {
2162
+ clipId: input.clipId ?? this.idFactory("clip"),
2163
+ mediaId: resourceId("media", input.media.system, input.media.key),
2164
+ audioScriptId: input.script.audioScriptId ?? this.idFactory("audio-script"),
2165
+ captionClipIds: input.captions.map((c) => c.clipId ?? this.idFactory("clip")),
2166
+ captionIds: input.captions.map(() => this.idFactory("caption"))
2167
+ };
2168
+ this.write("applyVoiceover", {
2169
+ input,
2170
+ result
2171
+ }, voiceoverPlan(this.dsl.getSnapshot(), input, result), options);
2172
+ return result;
2173
+ }
2174
+ setBgm(input, options) {
2175
+ const s = this.dsl.getSnapshot();
2176
+ if (requireTrack(s, input.trackId).role !== "bgm") throw new Error("Expected a BGM Track");
2177
+ const id = input.clipId ?? this.idFactory("clip");
2178
+ const old = s["clip-entities"][id];
2179
+ if (old && trackOf(s, id) !== input.trackId) throw new Error("BGM use belongs to another Track");
2180
+ const source = sourcePlan(s, input, input.trackId);
2181
+ if (!("durationMs" in source.source)) throw new Error("BGM requires full source duration");
2182
+ validateWindow({
2183
+ inMs: 0,
2184
+ outMs: source.source.durationMs
2185
+ }, source.source.durationMs);
2186
+ const seqMarker = {
2187
+ inMs: 0,
2188
+ outMs: source.source.durationMs,
2189
+ fillTimeline: true
2190
+ };
2191
+ const volume = input.volume === void 0 ? {} : { volume: input.volume };
2192
+ const mutations = [
2193
+ ...source.mutations,
2194
+ old ? {
2195
+ type: "patch-entity",
2196
+ table: "clip-entities",
2197
+ id,
2198
+ patch: {
2199
+ seqMarker: {
2200
+ ...seqMarker,
2201
+ rate: void 0
2202
+ },
2203
+ ...volume
2204
+ }
2205
+ } : {
2206
+ type: "create-entity",
2207
+ table: "clip-entities",
2208
+ id,
2209
+ value: {
2210
+ seqMarker,
2211
+ ...volume
2212
+ }
2213
+ },
2214
+ {
2215
+ type: "set-relation",
2216
+ table: "clip-source-relations",
2217
+ value: {
2218
+ clipId: id,
2219
+ sourceId: source.id
2220
+ }
2221
+ }
2222
+ ];
2223
+ if (!old) mutations.push({
2224
+ type: "insert-node",
2225
+ entityId: id,
2226
+ parentId: input.trackId
2227
+ });
2228
+ this.write("setBgm", {
2229
+ ...input,
2230
+ clipId: id
2231
+ }, mutations, options);
2232
+ return id;
2233
+ }
2234
+ write(action, payload, mutations, options) {
2235
+ this.dsl.applyMutations(mutations, audit(action, payload, options));
2236
+ }
2237
+ };
2238
+ function audit(action, payload, options) {
2239
+ return {
2240
+ semantic_op: action,
2241
+ payload: JSON.parse(JSON.stringify(payload, (_key, value) => value === void 0 ? null : value)),
2242
+ intent: options?.intent ?? null,
2243
+ actor: options?.actor ?? null
2244
+ };
2245
+ }
2246
+ //#endregion
2247
+ //#region src/dsl/initial-document.ts
2248
+ /** One server/host creates the foundation; peers import its binary identity instead of initializing independently. */
2249
+ function createInitialMedeoDsl(options = {}) {
2250
+ const dsl = MedeoDsl.create(options);
2251
+ try {
2252
+ const tracks = [
2253
+ ["caption_track", "caption"],
2254
+ ["main_track", "video_clip"],
2255
+ ["speech_track", "speech"],
2256
+ ["bgm_track", "bgm"]
2257
+ ];
2258
+ dsl.applyMutations(tracks.flatMap(([id, role]) => [{
2259
+ type: "create-entity",
2260
+ table: "track-entities",
2261
+ id,
2262
+ value: { role }
2263
+ }, {
2264
+ type: "insert-node",
2265
+ entityId: id
2266
+ }]), {
2267
+ semantic_op: "initializeDocument",
2268
+ payload: { tracks: tracks.map(([id]) => id) }
2269
+ });
2270
+ return dsl;
2271
+ } catch (error) {
2272
+ dsl.dispose();
2273
+ throw error;
2274
+ }
2275
+ }
2276
+ //#endregion
2277
+ //#region src/dsl/projection/caption-style.ts
2278
+ const fields = {
2279
+ fontSize: "font_size",
2280
+ fontColor: "font_color",
2281
+ fontWeight: "font_weight",
2282
+ entranceAnimation: "entrance_animation",
2283
+ entranceAnimationDurationMs: "entrance_animation_duration_ms",
2284
+ strokeColor: "stroke_color",
2285
+ strokeWidth: "stroke_width",
2286
+ positionX: "position_x",
2287
+ positionY: "position_y"
2288
+ };
2289
+ function toDraftStyle(style) {
2290
+ if (style === void 0) return;
2291
+ return {
2292
+ ...Object.fromEntries(Object.entries(fields).flatMap(([key, legacy]) => {
2293
+ const value = style[key];
2294
+ return value === void 0 ? [] : [[legacy, value]];
2295
+ })),
2296
+ ...style.font ? { font_id: style.font.key } : {}
2297
+ };
2298
+ }
2299
+ function fromDraftStyle(style) {
2300
+ if (style === void 0) return;
2301
+ return {
2302
+ ...Object.fromEntries(Object.entries(fields).flatMap(([key, legacy]) => {
2303
+ const value = style[legacy];
2304
+ return value === void 0 ? [] : [[key, value]];
2305
+ })),
2306
+ ...style.font_id ? { font: {
2307
+ system: "font-library",
2308
+ key: style.font_id
2309
+ } } : {}
2310
+ };
2311
+ }
2312
+ //#endregion
2313
+ //#region src/dsl/projection/to-video-draft.ts
2314
+ /** A content read view, not an archive of authoring identities, rich-text marks or provenance. */
2315
+ function toVideoDraft(snapshot) {
2316
+ const layout = resolveDslLayout(snapshot), diagnostics = [...layout.diagnostics];
2317
+ const visual = layout.tracks.filter((t) => t.role === "video_clip");
2318
+ if (visual.length > 1) projectionError("unsupported-visual-tracks", "contain-relations", "VideoDraft has only one main visual Track");
2319
+ const parts = {};
2320
+ const tracks = layout.tracks.map((track) => {
2321
+ const items = [];
2322
+ for (const clip of track.clips) {
2323
+ if (!clip.durationMs) continue;
2324
+ const path = `clip-entities/${clip.clipId}`, sourceId = clip.sourceId;
2325
+ const source = snapshot["media-entities"][sourceId];
2326
+ const fail = (message) => projectionError("unsupported-legacy-content", path, message);
2327
+ const common = {
2328
+ id: clip.clipId,
2329
+ kind: track.role,
2330
+ duration_ms: clip.durationMs
2331
+ };
2332
+ if (track.role === "video_clip") {
2333
+ if (!source || !["image", "video"].includes(source.kind) || source.system !== "memota") fail("Visual playback requires a memota Image or Video");
2334
+ if (clip.fillTimeline) fail("VideoDraft cannot express timeline-filling visual content");
2335
+ parts[clip.clipId] = { video_clip: {
2336
+ ...common,
2337
+ play_in: clip.inMs,
2338
+ play_out: clip.outMs,
2339
+ origin_media_id: source.key,
2340
+ volume: clip.volume,
2341
+ ...snapshot["clip-entities"][clip.clipId].seqMarker.rate === void 0 ? {} : { speed_shift: {
2342
+ category: "linear",
2343
+ mode: void 0,
2344
+ config: { linear: { speed: clip.rate } }
2345
+ } }
2346
+ } };
2347
+ } else if (track.role === "speech") {
2348
+ if (!source || source.kind !== "voiceover-recording" || source.system !== "memota-speech") fail("Legacy Speech requires a memota-speech VoiceoverRecording");
2349
+ requireWholeAudio(source, clip, fail);
2350
+ if (clip.fillTimeline) fail("Legacy Speech cannot express timeline fill");
2351
+ const voices = Object.values(snapshot["voice-timbre-relations"]).filter((r) => r.recordingId === sourceId).flatMap((r) => snapshot["voice-entities"][r.voiceId] ? [snapshot["voice-entities"][r.voiceId]] : []);
2352
+ if (voices.length > 1) diagnostics.push({
2353
+ code: "multiple-voice-summary",
2354
+ path,
2355
+ message: "Multiple Voices retained in DSL; the singular legacy summary is omitted"
2356
+ });
2357
+ const voice = voices.length === 1 ? voices[0] : void 0;
2358
+ const captionIds = layout.tracks.filter((t) => t.role === "caption").flatMap((t) => t.clips).filter((c) => c.durationMs > 0 && c.anchorClipId === clip.clipId).map((c) => c.clipId);
2359
+ parts[clip.clipId] = { speech: {
2360
+ ...common,
2361
+ media_duration_ms: source.durationMs,
2362
+ audio_storage_key: source.storageKey,
2363
+ origin_speech_id: source.key,
2364
+ volume: clip.volume,
2365
+ audio_script: spokenText(snapshot, sourceId),
2366
+ voice: voice ? {
2367
+ id: voice.key,
2368
+ name: voice.name
2369
+ } : void 0,
2370
+ caption_ids: captionIds
2371
+ } };
2372
+ } else if (track.role === "caption") {
2373
+ const caption = snapshot["caption-entities"][sourceId];
2374
+ if (!caption) fail("Caption Track requires Caption content");
2375
+ if (clip.fillTimeline) fail("Legacy Caption cannot express timeline fill");
2376
+ const anchor = clip.anchorClipId ? layout.clips[clip.anchorClipId] : void 0;
2377
+ const speechAnchor = anchor && layout.tracks.find((t) => t.trackId === anchor.trackId)?.role === "speech" && anchor.durationMs > 0 ? anchor : void 0;
2378
+ parts[clip.clipId] = { caption: {
2379
+ ...common,
2380
+ text: snapshot.captionText[sourceId]?.text ?? "",
2381
+ speech_part_id: speechAnchor?.clipId,
2382
+ start_ms: speechAnchor ? clip.startMs - speechAnchor.startMs : 0,
2383
+ ...caption.style === void 0 ? {} : { style: toDraftStyle(caption.style) }
2384
+ } };
2385
+ } else {
2386
+ if (!source || source.kind !== "audio" || source.system !== "memota") fail("Legacy BGM requires memota Audio");
2387
+ requireWholeAudio(source, clip, fail);
2388
+ if (!clip.fillTimeline) fail("Legacy BGM requires explicit fillTimeline intent");
2389
+ if (clip.startMs !== 0) diagnostics.push({
2390
+ code: "legacy-consumer-bgm-start",
2391
+ path,
2392
+ message: "Some existing players ignore BGM start; the VideoDraft position is preserved"
2393
+ });
2394
+ parts[clip.clipId] = { bgm: {
2395
+ ...common,
2396
+ audio_storage_key: source.storageKey,
2397
+ origin_media_id: source.key,
2398
+ volume: clip.volume
2399
+ } };
2400
+ }
2401
+ items.push({
2402
+ part_id: clip.clipId,
2403
+ abs_time_position: clip.startMs
2404
+ });
2405
+ }
2406
+ return {
2407
+ id: track.trackId,
2408
+ parts_kind: track.role,
2409
+ is_hidden: track.hidden,
2410
+ items
2411
+ };
2412
+ });
2413
+ const aggregations = visual.flatMap((t) => t.clips).filter((c) => parts[c.clipId]).flatMap((host) => {
2414
+ const attachments = layout.tracks.filter((t) => t.role === "speech").flatMap((t) => t.clips).filter((c) => c.anchorClipId === host.clipId && parts[c.clipId]).map((c) => ({
2415
+ part_id: c.clipId,
2416
+ relative_time_position: c.startMs - host.startMs
2417
+ }));
2418
+ return attachments.length ? [{
2419
+ body_part_id: host.clipId,
2420
+ attachments
2421
+ }] : [];
2422
+ });
2423
+ for (const role of [
2424
+ "caption",
2425
+ "speech",
2426
+ "bgm"
2427
+ ]) if (tracks.filter((t) => t.parts_kind === role).length > 1) diagnostics.push({
2428
+ code: "legacy-consumer-multiple-tracks",
2429
+ path: "contain-relations",
2430
+ message: `All ${role} Tracks are preserved; some existing consumers read only the first`
2431
+ });
2432
+ return {
2433
+ content: {
2434
+ timeline: {
2435
+ duration_ms: layout.durationMs,
2436
+ unit_time_ms: DEFAULT_UNIT_TIME_MS
2437
+ },
2438
+ main_track: tracks.find((t) => t.parts_kind === "video_clip"),
2439
+ above_main_tracks: tracks.filter((t) => t.parts_kind === "caption"),
2440
+ below_main_tracks: tracks.filter((t) => t.parts_kind === "speech" || t.parts_kind === "bgm"),
2441
+ part_library: parts,
2442
+ part_aggregations: aggregations
2443
+ },
2444
+ diagnostics
2445
+ };
2446
+ }
2447
+ function requireWholeAudio(media, clip, fail) {
2448
+ if (media.kind === "image" || clip.inMs !== 0 || clip.outMs !== media.durationMs || clip.rate !== 1) fail("VideoDraft audio has no trim or speed fields");
2449
+ }
2450
+ function spokenText(snapshot, recordingId) {
2451
+ const relations = Object.values(snapshot["derived-from-relations"]);
2452
+ const scripts = /* @__PURE__ */ new Set(), seen = /* @__PURE__ */ new Set();
2453
+ const visit = (id) => {
2454
+ if (seen.has(id)) return;
2455
+ seen.add(id);
2456
+ if (snapshot["audio-script-entities"][id]) {
2457
+ scripts.add(id);
2458
+ return;
2459
+ }
2460
+ for (const r of relations) if (r.entityId === id) visit(r.sourceId);
2461
+ };
2462
+ visit(recordingId);
2463
+ if (!scripts.size) {
2464
+ for (const r of relations) if (r.sourceId === recordingId && snapshot["audio-script-entities"][r.entityId]) scripts.add(r.entityId);
2465
+ }
2466
+ if (scripts.size > 1) projectionError("ambiguous-script", `media-entities/${recordingId}`, "Multiple scripts cannot be silently concatenated into audio_script");
2467
+ const id = [...scripts][0];
2468
+ return id ? snapshot.richText[id]?.plainText ?? "" : "";
2469
+ }
2470
+ //#endregion
2471
+ //#region src/dsl/projection/from-video-draft.ts
2472
+ /** One-time import into a newly owned document; never reverse-diff a live collaborative document. */
2473
+ function fromVideoDraft(input, options) {
2474
+ const { timelineId, mutations, diagnostics } = prepare(input, options);
2475
+ const dsl = MedeoDsl.create({
2476
+ ...options,
2477
+ timelineId
2478
+ });
2479
+ try {
2480
+ dsl.applyMutations(mutations, options.audit);
2481
+ return {
2482
+ dsl,
2483
+ diagnostics
2484
+ };
2485
+ } catch (error) {
2486
+ dsl.dispose();
2487
+ throw error;
2488
+ }
2489
+ }
2490
+ function prepare(input, options) {
2491
+ const diagnostics = [], mutations = [];
2492
+ const ids = /* @__PURE__ */ new Set(), mint = options.idFactory ?? defaultIdFactory;
2493
+ const claim = (id) => {
2494
+ try {
2495
+ assertId(id);
2496
+ } catch {
2497
+ projectionError("invalid-id", id, "Expected a non-reserved entity ID");
2498
+ }
2499
+ if (ids.has(id)) projectionError("duplicate-id", id, "Entity identity appears more than once");
2500
+ ids.add(id);
2501
+ return id;
2502
+ };
2503
+ const timelineId = claim(options.timelineId ?? mint("timeline"));
2504
+ const fail = (path, message) => projectionError("invalid-video-draft", path, message);
2505
+ if (!input || typeof input !== "object") fail("video_draft", "Expected content");
2506
+ const library = input.part_library ?? {}, placements = /* @__PURE__ */ new Map();
2507
+ const tracks = [
2508
+ ...input.above_main_tracks ?? [],
2509
+ ...input.main_track ? [input.main_track] : [],
2510
+ ...input.below_main_tracks ?? []
2511
+ ];
2512
+ for (const track of tracks) {
2513
+ const role = track.parts_kind;
2514
+ if (!role || ![
2515
+ "video_clip",
2516
+ "speech",
2517
+ "caption",
2518
+ "bgm"
2519
+ ].includes(role)) fail("tracks", "Unknown Track role");
2520
+ if (role === "video_clip" && track !== input.main_track) fail("tracks", "Legacy visual content must use main_track");
2521
+ const trackId = claim(track.id ?? mint("track"));
2522
+ mutations.push({
2523
+ type: "create-entity",
2524
+ table: "track-entities",
2525
+ id: trackId,
2526
+ value: {
2527
+ role,
2528
+ ...track.is_hidden === void 0 ? {} : { hidden: track.is_hidden }
2529
+ }
2530
+ }, {
2531
+ type: "insert-node",
2532
+ entityId: trackId
2533
+ });
2534
+ for (const item of track.items ?? []) {
2535
+ const partId = claim(item.part_id ?? "");
2536
+ if (!Object.hasOwn(library, partId)) fail(`part_library/${partId}`, "Missing placed part");
2537
+ placements.set(partId, {
2538
+ partId,
2539
+ trackId,
2540
+ role,
2541
+ abs: milliseconds(item.abs_time_position ?? 0, `tracks/${trackId}/${partId}`)
2542
+ });
2543
+ }
2544
+ }
2545
+ for (const [id, part] of Object.entries(library)) {
2546
+ if (!placements.has(id)) fail(`part_library/${id}`, "Unplaced legacy parts require an explicit placement before import");
2547
+ if (!part || typeof part !== "object" || Object.keys(part).filter((k) => part[k] !== void 0).length !== 1) fail(`part_library/${id}`, "Expected exactly one known part kind");
2548
+ const role = placements.get(id).role, payload = part[role];
2549
+ if (!payload || payload.id !== id || payload.kind !== role) fail(`part_library/${id}`, "Part identity and kind must agree with its placement");
2550
+ }
2551
+ const facts = /* @__PURE__ */ new Map(), imported = /* @__PURE__ */ new Map();
2552
+ for (const fact of options.mediaFacts ?? []) {
2553
+ assertEntity("media-entities", fact);
2554
+ const id = resourceId("media", fact.system, fact.key);
2555
+ if (facts.has(id)) fail("mediaFacts", `Duplicate facts for ${fact.system}/${fact.key}`);
2556
+ facts.set(id, fact);
2557
+ }
2558
+ const register = (media) => {
2559
+ const id = resourceId("media", media.system, media.key), old = imported.get(id);
2560
+ if (old) {
2561
+ for (const key of new Set([...Object.keys(old), ...Object.keys(media)])) if (old[key] !== media[key]) fail(`mediaFacts/${id}`, "Conflicting resource facts");
2562
+ return id;
2563
+ }
2564
+ assertEntity("media-entities", media);
2565
+ claim(id);
2566
+ imported.set(id, media);
2567
+ mutations.push({
2568
+ type: "create-entity",
2569
+ table: "media-entities",
2570
+ id,
2571
+ value: media
2572
+ });
2573
+ return id;
2574
+ };
2575
+ const factFor = (system, key) => {
2576
+ const fact = key ? facts.get(resourceId("media", system, key)) : void 0;
2577
+ if (!fact) return projectionError("missing-media-facts", `mediaFacts/${system}/${key ?? ""}`, "Complete source kind and duration are required");
2578
+ return fact;
2579
+ };
2580
+ const mediaByPart = /* @__PURE__ */ new Map(), scriptByRecording = /* @__PURE__ */ new Map(), scriptsByPart = /* @__PURE__ */ new Map();
2581
+ const voices = /* @__PURE__ */ new Set();
2582
+ for (const [id, p] of Object.entries(library)) if (p.video_clip) {
2583
+ const fact = factFor("memota", p.video_clip.origin_media_id);
2584
+ if (fact.kind !== "image" && fact.kind !== "video") fail(`part_library/${id}`, "Expected Image or Video facts");
2585
+ mediaByPart.set(id, register(fact));
2586
+ } else if (p.bgm) {
2587
+ const fact = factFor("memota", p.bgm.origin_media_id);
2588
+ if (fact.kind !== "audio" || fact.storageKey !== p.bgm.audio_storage_key) fail(`part_library/${id}`, "BGM requires matching Audio facts and storageKey");
2589
+ mediaByPart.set(id, register(fact));
2590
+ } else if (p.speech) {
2591
+ const speech = p.speech;
2592
+ const key = speech.origin_speech_id;
2593
+ if (!key || !speech.audio_storage_key || typeof speech.audio_script !== "string") fail(`part_library/${id}`, "Speech requires its external identity, storageKey and audio_script");
2594
+ const supplied = facts.get(resourceId("media", "memota-speech", key));
2595
+ const intrinsic = speech.media_duration_ms ?? speech.duration_ms;
2596
+ if (speech.duration_ms !== void 0 && intrinsic !== speech.duration_ms) fail(`part_library/${id}/duration_ms`, "Untrimmed Speech duration must agree with its source duration");
2597
+ const fact = supplied ?? {
2598
+ kind: "voiceover-recording",
2599
+ system: "memota-speech",
2600
+ key,
2601
+ storageKey: speech.audio_storage_key,
2602
+ durationMs: milliseconds(intrinsic, `part_library/${id}/duration_ms`, true)
2603
+ };
2604
+ if (fact.kind !== "voiceover-recording" || fact.storageKey !== speech.audio_storage_key || "durationMs" in fact && intrinsic !== void 0 && fact.durationMs !== intrinsic) fail(`part_library/${id}`, "Speech source facts must agree with its untrimmed recording");
2605
+ const recordingId = register(fact);
2606
+ mediaByPart.set(id, recordingId);
2607
+ let script = scriptByRecording.get(recordingId);
2608
+ if (script && script.text !== speech.audio_script) fail(`part_library/${id}/audio_script`, "One recording cannot acquire conflicting source scripts during import");
2609
+ if (!script) {
2610
+ script = {
2611
+ id: claim(mint("audio-script")),
2612
+ text: speech.audio_script
2613
+ };
2614
+ scriptByRecording.set(recordingId, script);
2615
+ mutations.push({
2616
+ type: "create-entity",
2617
+ table: "audio-script-entities",
2618
+ id: script.id,
2619
+ value: { body: script.text }
2620
+ }, {
2621
+ type: "set-relation",
2622
+ table: "derived-from-relations",
2623
+ value: {
2624
+ entityId: recordingId,
2625
+ sourceId: script.id
2626
+ }
2627
+ });
2628
+ }
2629
+ scriptsByPart.set(id, script);
2630
+ if (speech.voice) {
2631
+ if (!speech.voice.id) fail(`part_library/${id}/voice`, "Voice requires its library key");
2632
+ const voiceId = resourceId("voice", "voice-library", speech.voice.id);
2633
+ if (!voices.has(voiceId)) {
2634
+ claim(voiceId);
2635
+ voices.add(voiceId);
2636
+ mutations.push({
2637
+ type: "create-entity",
2638
+ table: "voice-entities",
2639
+ id: voiceId,
2640
+ value: {
2641
+ system: "voice-library",
2642
+ key: speech.voice.id,
2643
+ ...speech.voice.name === void 0 ? {} : { name: speech.voice.name }
2644
+ }
2645
+ });
2646
+ }
2647
+ mutations.push({
2648
+ type: "set-relation",
2649
+ table: "voice-timbre-relations",
2650
+ value: {
2651
+ recordingId,
2652
+ voiceId
2653
+ }
2654
+ });
2655
+ }
2656
+ }
2657
+ const owner = /* @__PURE__ */ new Map(), selections = /* @__PURE__ */ new Map();
2658
+ for (const [speechId, script] of scriptsByPart) {
2659
+ let cursor = 0;
2660
+ for (const captionId of library[speechId].speech.caption_ids ?? []) {
2661
+ const caption = library[captionId]?.caption;
2662
+ if (!caption || owner.has(captionId) || caption.speech_part_id !== void 0 && caption.speech_part_id !== speechId) fail(`part_library/${speechId}/caption_ids`, "Caption list must contain unique, consistently owned Caption parts");
2663
+ owner.set(captionId, speechId);
2664
+ const text = caption.text;
2665
+ if (typeof text !== "string") fail(`part_library/${captionId}/text`, "Expected Caption text");
2666
+ const start = text.length ? script.text.indexOf(text, cursor) : -1;
2667
+ if (start >= 0) {
2668
+ cursor = start + text.length;
2669
+ selections.set(captionId, {
2670
+ audioScriptId: script.id,
2671
+ start,
2672
+ end: cursor
2673
+ });
2674
+ } else diagnostics.push({
2675
+ code: "caption-text-unmatched",
2676
+ path: `part_library/${captionId}/text`,
2677
+ message: "No forward exact match; preserved as independent Caption text"
2678
+ });
2679
+ }
2680
+ }
2681
+ const hosts = /* @__PURE__ */ new Map();
2682
+ for (const aggregation of input.part_aggregations ?? []) {
2683
+ const host = aggregation.body_part_id;
2684
+ if (!host || !library[host]?.video_clip) fail("part_aggregations", "Expected a placed visual host");
2685
+ for (const attachment of aggregation.attachments ?? []) {
2686
+ const id = attachment.part_id;
2687
+ if (!id || !library[id]?.speech || hosts.has(id)) fail("part_aggregations", "Expected a uniquely anchored Speech");
2688
+ hosts.set(id, {
2689
+ to: host,
2690
+ offset: milliseconds(attachment.relative_time_position ?? 0, "part_aggregations/offset", false, true)
2691
+ });
2692
+ }
2693
+ }
2694
+ for (const [id, placement] of placements) {
2695
+ const part = library[id];
2696
+ let sourceId = mediaByPart.get(id), inMs = 0, outMs = 0, speed;
2697
+ const volume = part.video_clip?.volume ?? part.speech?.volume ?? part.bgm?.volume;
2698
+ if (part.video_clip) {
2699
+ const clip = part.video_clip;
2700
+ if (clip.speed_shift !== void 0) {
2701
+ const rate = clip.speed_shift.config?.linear?.speed;
2702
+ if (clip.speed_shift.category !== "linear" || typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) projectionError("unsupported-speed", `part_library/${id}/speed_shift`, "Only positive linear speed is supported");
2703
+ speed = { rate };
2704
+ }
2705
+ inMs = milliseconds(clip.play_in ?? 0, `part_library/${id}/play_in`);
2706
+ const media = imported.get(sourceId);
2707
+ const legacyWindow = clip.duration_ms === void 0 ? void 0 : Math.round(clip.duration_ms * (speed?.rate ?? 1));
2708
+ outMs = milliseconds(clip.play_out ?? (legacyWindow === void 0 ? "durationMs" in media ? media.durationMs : void 0 : inMs + legacyWindow), `part_library/${id}/play_out`);
2709
+ if ("durationMs" in media && outMs > media.durationMs) fail(`part_library/${id}/play_out`, "Window exceeds factual source duration");
2710
+ } else if (part.speech || part.bgm) {
2711
+ const media = imported.get(sourceId);
2712
+ outMs = "durationMs" in media ? media.durationMs : 0;
2713
+ } else if (part.caption) {
2714
+ const caption = part.caption;
2715
+ outMs = milliseconds(caption.duration_ms ?? caption.initial_duration_ms, `part_library/${id}/duration_ms`, true);
2716
+ if (typeof caption.text !== "string") fail(`part_library/${id}/text`, "Expected Caption text");
2717
+ if (caption.display_cues?.length) projectionError("unsupported-caption-cues", `part_library/${id}/display_cues`, "Timed sub-cues require an explicit conversion into separate Captions");
2718
+ if (caption.speech_part_id !== void 0 && owner.get(id) !== caption.speech_part_id) fail(`part_library/${id}/speech_part_id`, "Caption ownership must agree with ordered speech.caption_ids");
2719
+ sourceId = claim(mint("caption"));
2720
+ const selected = selections.get(id), style = fromDraftStyle(caption.style);
2721
+ mutations.push({
2722
+ type: "create-entity",
2723
+ table: "caption-entities",
2724
+ id: sourceId,
2725
+ value: {
2726
+ ...style === void 0 ? {} : { style },
2727
+ ...selected ? {} : { text: caption.text }
2728
+ }
2729
+ });
2730
+ if (selected) mutations.push({
2731
+ type: "set-caption-content",
2732
+ captionId: sourceId,
2733
+ ...selected
2734
+ });
2735
+ const speechId = caption.speech_part_id, mediaId = speechId ? mediaByPart.get(speechId) : void 0;
2736
+ if (mediaId && caption.start_ms !== void 0) {
2737
+ const startMs = milliseconds(caption.start_ms, `part_library/${id}/start_ms`), endMs = startMs + (caption.initial_duration_ms ?? outMs);
2738
+ const media = imported.get(mediaId);
2739
+ if ("durationMs" in media && endMs > startMs && endMs <= media.durationMs) mutations.push({
2740
+ type: "set-relation",
2741
+ table: "caption-alignment-relations",
2742
+ value: {
2743
+ captionId: sourceId,
2744
+ mediaId,
2745
+ startMs,
2746
+ endMs
2747
+ }
2748
+ });
2749
+ else diagnostics.push({
2750
+ code: "invalid-caption-alignment",
2751
+ path: `part_library/${id}`,
2752
+ message: "Source interval is invalid; display window is retained without alignment"
2753
+ });
2754
+ }
2755
+ }
2756
+ if (outMs <= inMs || !sourceId) fail(`part_library/${id}`, "Expected a nonempty source window");
2757
+ mutations.push({
2758
+ type: "create-entity",
2759
+ table: "clip-entities",
2760
+ id,
2761
+ value: {
2762
+ seqMarker: {
2763
+ inMs,
2764
+ outMs,
2765
+ ...speed ? { rate: speed.rate } : {},
2766
+ ...part.bgm ? { fillTimeline: true } : {}
2767
+ },
2768
+ ...volume === void 0 ? {} : { volume }
2769
+ }
2770
+ }, {
2771
+ type: "set-relation",
2772
+ table: "clip-source-relations",
2773
+ value: {
2774
+ clipId: id,
2775
+ sourceId
2776
+ }
2777
+ }, {
2778
+ type: "insert-node",
2779
+ entityId: id,
2780
+ parentId: placement.trackId
2781
+ });
2782
+ if (placement.role !== "video_clip") {
2783
+ const host = hosts.get(id);
2784
+ const speechId = part.caption?.speech_part_id;
2785
+ const to = host?.to ?? speechId ?? timelineId;
2786
+ const offsetMs = host?.offset ?? (speechId ? milliseconds(part.caption?.start_ms ?? 0, `part_library/${id}/start_ms`) : placement.abs);
2787
+ mutations.push({
2788
+ type: "set-relation",
2789
+ table: "time-anchor-relations",
2790
+ value: {
2791
+ from: id,
2792
+ to,
2793
+ offsetMs,
2794
+ fallback_abs_ms: placement.abs
2795
+ }
2796
+ });
2797
+ }
2798
+ }
2799
+ return {
2800
+ timelineId,
2801
+ mutations: orderMutations(mutations),
2802
+ diagnostics
2803
+ };
2804
+ }
2805
+ function orderMutations(mutations) {
2806
+ const rank = (m) => m.type === "create-entity" ? 0 : m.type === "insert-node" ? 1 : 2;
2807
+ return mutations.toSorted((a, b) => rank(a) - rank(b));
2808
+ }
2809
+ function milliseconds(value, path, positive = false, signed = false) {
2810
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || !signed && value < 0 || positive && value <= 0) projectionError("invalid-time", path, "Expected safe integer milliseconds in the valid range");
2811
+ return value;
2812
+ }
2813
+ //#endregion
2814
+ export { MedeoDsl as a, DslEditor as i, toVideoDraft as n, resolveDslLayout as o, createInitialMedeoDsl as r, DslProjectionError as s, fromVideoDraft as t };