@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.
- package/README.md +133 -18
- package/dist/base64-D-jd-dr5.js +16 -0
- package/dist/dsl-Dkecq_2c.js +2814 -0
- package/dist/dsl.d.ts +3 -0
- package/dist/dsl.js +3 -0
- package/dist/index-B7fC7q7P.d.ts +728 -0
- package/dist/index-DKDMTXUe.d.ts +605 -0
- package/dist/index-DQZUkrpg.d.ts +197 -0
- package/dist/index.d.ts +5 -1122
- package/dist/index.js +109 -2556
- package/dist/legacy.d.ts +1044 -0
- package/dist/legacy.js +4349 -0
- package/dist/{loro-relay-doc-cJSY-uau.js → loro-relay-doc-Bi6JKqtQ.js} +11 -7
- package/dist/relay.js +1 -1
- package/dist/schemas.d.ts +606 -0
- package/dist/schemas.js +442 -0
- package/dist/shared-iWnU2osE.js +39 -0
- package/dist/storage-BW3tI_ER.js +801 -0
- package/dist/testing.d.ts +22 -9
- package/dist/testing.js +168 -50
- package/dist/video-draft-types-DTXxvPt-.js +449 -0
- package/dist/video-draft-types-c_shaRyq.d.ts +809 -0
- package/package.json +13 -6
- package/dist/chunk-D7D4PA-g.js +0 -13
- package/dist/document-B_JQwrC5.js +0 -1630
- package/dist/index-CnZ9l3rb.d.ts +0 -2077
|
@@ -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 };
|