@mengine/medeo-client 2.1.0 → 2.1.1-dsl.1
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 +126 -25
- package/dist/base64-D-jd-dr5.js +16 -0
- package/dist/dsl-EV6kCdr6.js +5852 -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-Cu7SdKN5.d.ts +940 -0
- package/dist/index-yifEtZSP.d.ts +197 -0
- package/dist/index.d.ts +5 -1141
- package/dist/index.js +109 -2606
- 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-TXRv3wS6.js +1419 -0
- package/dist/video-draft-types-c_shaRyq.d.ts +809 -0
- package/package.json +16 -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
package/dist/legacy.js
ADDED
|
@@ -0,0 +1,4349 @@
|
|
|
1
|
+
import { n as bytesToBase64, t as base64ToBytes } from "./base64-D-jd-dr5.js";
|
|
2
|
+
import { c as readMengineEventStream, d as MenginePushRejectedError, i as openManualDocument, l as MengineHttpClient, n as MedeoHttpDocStorage, o as decodeDocVersionMark, r as ManualSyncTransport, s as encodeDocVersionMark, t as MemoryDocStorage, u as MengineHttpRequestError } from "./storage-BW3tI_ER.js";
|
|
3
|
+
import { a as documentFormat, r as UnsupportedDocumentFormatError, t as DEFAULT_UNIT_TIME_MS } from "./video-draft-types-TXRv3wS6.js";
|
|
4
|
+
import { u as volumeSchema } from "./shared-iWnU2osE.js";
|
|
5
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
6
|
+
import { LoroDoc, LoroMap, LoroMovableList, LoroText, UndoManager } from "loro-crdt";
|
|
7
|
+
import { bytesToHex } from "@noble/hashes/utils.js";
|
|
8
|
+
//#region ../medeo-dsl/depreacted/src/entities/types.ts
|
|
9
|
+
function entityToRow(entity) {
|
|
10
|
+
const { entityId, entityKind, ...payload } = entity;
|
|
11
|
+
return {
|
|
12
|
+
entityId,
|
|
13
|
+
entityKind,
|
|
14
|
+
payload
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region ../medeo-dsl/depreacted/src/entities/specs.ts
|
|
19
|
+
/**
|
|
20
|
+
* What every known entity kind declares.
|
|
21
|
+
*
|
|
22
|
+
* Every question of the form "which kinds are X" is answered here and nowhere
|
|
23
|
+
* else: code asks `declaresKind` and never lists kinds of its own. A kind that
|
|
24
|
+
* shares nothing declares nothing; the order of a declaration carries no
|
|
25
|
+
* meaning.
|
|
26
|
+
*
|
|
27
|
+
* `Caption` declares `Sequence` on purpose: it owns the display timing of the
|
|
28
|
+
* one AudioScript segment it shows, which is what admits it into a Clip.
|
|
29
|
+
* `Voice` declares no `Sequence` — it is a timbre identity, not playable
|
|
30
|
+
* content; rendered speech is a `voiceover-recording` entity.
|
|
31
|
+
*/
|
|
32
|
+
const ENTITY_KINDS = Object.freeze({
|
|
33
|
+
timeline: Object.freeze([]),
|
|
34
|
+
track: Object.freeze([]),
|
|
35
|
+
clip: Object.freeze([]),
|
|
36
|
+
asset: Object.freeze([]),
|
|
37
|
+
video: Object.freeze([
|
|
38
|
+
"Sequence",
|
|
39
|
+
"Visual",
|
|
40
|
+
"Audible",
|
|
41
|
+
"MediaFile"
|
|
42
|
+
]),
|
|
43
|
+
audio: Object.freeze([
|
|
44
|
+
"Sequence",
|
|
45
|
+
"Audible",
|
|
46
|
+
"MediaFile"
|
|
47
|
+
]),
|
|
48
|
+
"voiceover-recording": Object.freeze([
|
|
49
|
+
"Sequence",
|
|
50
|
+
"Audible",
|
|
51
|
+
"MediaFile"
|
|
52
|
+
]),
|
|
53
|
+
image: Object.freeze([
|
|
54
|
+
"Sequence",
|
|
55
|
+
"Visual",
|
|
56
|
+
"MediaFile"
|
|
57
|
+
]),
|
|
58
|
+
voice: Object.freeze([]),
|
|
59
|
+
"sequence-marker": Object.freeze([]),
|
|
60
|
+
"audio-script": Object.freeze(["SegmentText", "MediaFile"]),
|
|
61
|
+
"phonetic-script": Object.freeze([
|
|
62
|
+
"SegmentText",
|
|
63
|
+
"Composed",
|
|
64
|
+
"MediaFile"
|
|
65
|
+
]),
|
|
66
|
+
caption: Object.freeze([
|
|
67
|
+
"SegmentText",
|
|
68
|
+
"Composed",
|
|
69
|
+
"Sequence",
|
|
70
|
+
"MediaFile"
|
|
71
|
+
])
|
|
72
|
+
});
|
|
73
|
+
function isKnownEntityKind(kind) {
|
|
74
|
+
return Object.hasOwn(ENTITY_KINDS, kind);
|
|
75
|
+
}
|
|
76
|
+
/** Whether a kind declares a trait. */
|
|
77
|
+
function declaresKind(entityKind, kind) {
|
|
78
|
+
return isKnownEntityKind(entityKind) && ENTITY_KINDS[entityKind].includes(kind);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Sequence admission is a declaration, not a shape: a kind is admitted because
|
|
82
|
+
* it declares `Sequence`, and a malformed payload is a separate, louder failure
|
|
83
|
+
* than a silently non-Sequence entity.
|
|
84
|
+
*/
|
|
85
|
+
function hasSequence(entity) {
|
|
86
|
+
return declaresKind(entity.entityKind, "Sequence");
|
|
87
|
+
}
|
|
88
|
+
function isMediaAssetVariantKind(kind) {
|
|
89
|
+
return declaresKind(kind, "Visual") || declaresKind(kind, "Audible");
|
|
90
|
+
}
|
|
91
|
+
const required = (check) => ({
|
|
92
|
+
required: true,
|
|
93
|
+
check
|
|
94
|
+
});
|
|
95
|
+
const optional = (check) => ({
|
|
96
|
+
required: false,
|
|
97
|
+
check
|
|
98
|
+
});
|
|
99
|
+
const must = (holds, be) => (value, path) => holds(value) ? [] : [`${path} must ${be}`];
|
|
100
|
+
const isWhole = (value) => typeof value === "number" && Number.isSafeInteger(value);
|
|
101
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
102
|
+
const string = must((value) => typeof value === "string", "be a string");
|
|
103
|
+
const name = must((value) => typeof value === "string" && value.trim() !== "", "be a non-empty string");
|
|
104
|
+
const boolean = must((value) => typeof value === "boolean", "be a boolean");
|
|
105
|
+
const finite$1 = must((value) => typeof value === "number" && Number.isFinite(value), "be a finite number");
|
|
106
|
+
const wholeMs = must(isWhole, "be whole milliseconds");
|
|
107
|
+
const wholeMsFromZero = must((value) => isWhole(value) && value >= 0, "be whole milliseconds at or after zero");
|
|
108
|
+
const count = must((value) => isWhole(value) && value >= 0, "be a whole code-point count at or after zero");
|
|
109
|
+
const positive$1 = must((value) => typeof value === "number" && value > 0, "be a positive number");
|
|
110
|
+
const onlyTrue = must((value) => value === true, "be true when stated");
|
|
111
|
+
const duration = must((value) => value === null || isWhole(value) && value > 0, "be positive whole milliseconds, or null for infinite");
|
|
112
|
+
const decibels = must((value) => typeof value === "number" && value >= -60 && value <= 20, "be decibels between -60 and 20");
|
|
113
|
+
const exactly = (constant) => must((value) => value === constant, `be "${constant}"`);
|
|
114
|
+
/** Composition judges `bases` for the whole document; see `checkComposition`. */
|
|
115
|
+
const bases$1 = () => [];
|
|
116
|
+
/** An object stating exactly the fields of `T`: each checked, the required ones present, nothing else. */
|
|
117
|
+
function fields(rules) {
|
|
118
|
+
return (value, path) => isRecord$1(value) ? problemsIn(rules, value, path, true) : [`${path} must be an object`];
|
|
119
|
+
}
|
|
120
|
+
const list = (item) => (value, path) => Array.isArray(value) ? value.flatMap((entry, index) => item(entry, `${path}[${index}]`)) : [`${path} must be a list`];
|
|
121
|
+
function problemsIn(rules, value, path, closed) {
|
|
122
|
+
const at = (field) => path === "" ? field : `${path}.${field}`;
|
|
123
|
+
const problems = closed ? Object.keys(value).filter((field) => !Object.hasOwn(rules, field)).map((field) => `${at(field)} is not a field here`) : [];
|
|
124
|
+
for (const [field, rule] of Object.entries(rules)) if (value[field] === void 0) {
|
|
125
|
+
if (rule.required) problems.push(`${at(field)} is required`);
|
|
126
|
+
} else problems.push(...rule.check(value[field], at(field)));
|
|
127
|
+
return problems;
|
|
128
|
+
}
|
|
129
|
+
const segmentTime = fields({
|
|
130
|
+
segmentId: required(string),
|
|
131
|
+
startMs: required(wholeMsFromZero),
|
|
132
|
+
endMs: required(wholeMsFromZero)
|
|
133
|
+
});
|
|
134
|
+
const segmentRanges = (value, path) => {
|
|
135
|
+
if (!Array.isArray(value)) return [`${path} must be a list`];
|
|
136
|
+
const seen = /* @__PURE__ */ new Set();
|
|
137
|
+
return value.flatMap((range, index) => {
|
|
138
|
+
const at = `${path}[${index}]`;
|
|
139
|
+
const problems = [...segmentTime(range, at)];
|
|
140
|
+
const segmentId = isRecord$1(range) ? range.segmentId : void 0;
|
|
141
|
+
if (typeof segmentId === "string") {
|
|
142
|
+
if (seen.has(segmentId)) problems.push(`${at}.segmentId must be unique`);
|
|
143
|
+
seen.add(segmentId);
|
|
144
|
+
}
|
|
145
|
+
if (problems.length === 0 && range.startMs > range.endMs) problems.push(`${at}.startMs must not exceed endMs`);
|
|
146
|
+
return problems;
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
const textRange = (value, path) => {
|
|
150
|
+
const problems = fields({
|
|
151
|
+
start: required(count),
|
|
152
|
+
end: required(count)
|
|
153
|
+
})(value, path);
|
|
154
|
+
if (problems.length > 0 || value.end > value.start) return problems;
|
|
155
|
+
return [`${path} must be a non-empty half-open code-point range`];
|
|
156
|
+
};
|
|
157
|
+
const style = fields({
|
|
158
|
+
font: optional(fields({
|
|
159
|
+
system: required(exactly("font-library")),
|
|
160
|
+
key: required(name)
|
|
161
|
+
})),
|
|
162
|
+
fontSize: optional(finite$1),
|
|
163
|
+
fontColor: optional(string),
|
|
164
|
+
fontWeight: optional(finite$1),
|
|
165
|
+
entranceAnimation: optional(string),
|
|
166
|
+
entranceAnimationDurationMs: optional(finite$1),
|
|
167
|
+
strokeColor: optional(string),
|
|
168
|
+
strokeWidth: optional(finite$1),
|
|
169
|
+
positionX: optional(finite$1),
|
|
170
|
+
positionY: optional(finite$1)
|
|
171
|
+
});
|
|
172
|
+
const media = { durationMs: required(duration) };
|
|
173
|
+
/**
|
|
174
|
+
* The fields every known kind stores, and how each is checked. The table is
|
|
175
|
+
* held to the contract: it names every field a kind states, and no other.
|
|
176
|
+
* `bases` is composition's to judge for every kind that may name it.
|
|
177
|
+
*/
|
|
178
|
+
const ENTITY_FIELDS = Object.freeze({
|
|
179
|
+
timeline: {},
|
|
180
|
+
track: {
|
|
181
|
+
role: required(string),
|
|
182
|
+
hidden: optional(boolean)
|
|
183
|
+
},
|
|
184
|
+
clip: { volume: optional(decibels) },
|
|
185
|
+
"sequence-marker": {
|
|
186
|
+
inMs: required(wholeMsFromZero),
|
|
187
|
+
outMs: required(wholeMs),
|
|
188
|
+
startMs: optional(wholeMsFromZero),
|
|
189
|
+
rate: optional(positive$1),
|
|
190
|
+
fillTimeline: optional(onlyTrue),
|
|
191
|
+
segmentRanges: optional(segmentRanges)
|
|
192
|
+
},
|
|
193
|
+
video: media,
|
|
194
|
+
audio: media,
|
|
195
|
+
"voiceover-recording": media,
|
|
196
|
+
image: media,
|
|
197
|
+
voice: { voice: required(fields({
|
|
198
|
+
system: required(exactly("voice-library")),
|
|
199
|
+
key: required(name),
|
|
200
|
+
name: optional(string)
|
|
201
|
+
})) },
|
|
202
|
+
"audio-script": { segments: required(list(fields({
|
|
203
|
+
segmentId: required(name),
|
|
204
|
+
text: required(string),
|
|
205
|
+
language: optional(string)
|
|
206
|
+
}))) },
|
|
207
|
+
"phonetic-script": {
|
|
208
|
+
bases: required(bases$1),
|
|
209
|
+
phonemeScript: optional(name)
|
|
210
|
+
},
|
|
211
|
+
caption: {
|
|
212
|
+
durationMs: required(duration),
|
|
213
|
+
bases: required(bases$1),
|
|
214
|
+
selection: required(fields({
|
|
215
|
+
segmentId: required(name),
|
|
216
|
+
textRange: optional(textRange)
|
|
217
|
+
})),
|
|
218
|
+
style: optional(style),
|
|
219
|
+
segmentRanges: optional(segmentRanges)
|
|
220
|
+
},
|
|
221
|
+
asset: {
|
|
222
|
+
system: required(must((value) => value === "memota" || value === "memota-speech", "be \"memota\" or \"memota-speech\"")),
|
|
223
|
+
key: required(name),
|
|
224
|
+
storageKey: optional(name),
|
|
225
|
+
digest: optional(name),
|
|
226
|
+
writable: optional(onlyTrue)
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
/**
|
|
230
|
+
* What is wrong with one entity's fields. Pass the assembled entity: a
|
|
231
|
+
* variant's required fields may come from its bases. Whether the stored row
|
|
232
|
+
* states a field its kind does not have is `unknownFields`' question.
|
|
233
|
+
*/
|
|
234
|
+
function validateEntity(entity) {
|
|
235
|
+
if (!isKnownEntityKind(entity.entityKind)) return [];
|
|
236
|
+
const value = entity;
|
|
237
|
+
const problems = problemsIn(ENTITY_FIELDS[entity.entityKind], value, "", false);
|
|
238
|
+
if (problems.length === 0) problems.push(...crossFieldProblems(entity.entityKind, value));
|
|
239
|
+
return problems.map((problem) => `Entity "${entity.entityKind}" ${problem}`);
|
|
240
|
+
}
|
|
241
|
+
/** The fields a stored row states that its kind does not have. `bases` is composition's to judge. */
|
|
242
|
+
function unknownFields(entityKind, payload) {
|
|
243
|
+
if (!isKnownEntityKind(entityKind)) return [];
|
|
244
|
+
return Object.keys(payload).filter((field) => field !== "bases" && !Object.hasOwn(ENTITY_FIELDS[entityKind], field));
|
|
245
|
+
}
|
|
246
|
+
function crossFieldProblems(entityKind, value) {
|
|
247
|
+
if (entityKind === "sequence-marker" && value.outMs <= value.inMs) return ["outMs must be greater than inMs"];
|
|
248
|
+
if (entityKind === "caption" && Array.isArray(value.segmentRanges)) {
|
|
249
|
+
const [only, ...rest] = value.segmentRanges;
|
|
250
|
+
if (rest.length > 0 || only?.segmentId !== value.selection.segmentId) return ["segmentRanges must contain only the selected segment timing"];
|
|
251
|
+
}
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
const FIELD_MERGE = Object.freeze({
|
|
255
|
+
/** One segment reference, narrowed by an optional code-point range. */
|
|
256
|
+
selection: "atomic",
|
|
257
|
+
textRange: "atomic",
|
|
258
|
+
/** Collaborative text, merged by code point. */
|
|
259
|
+
text: "text",
|
|
260
|
+
phonemeScript: "text",
|
|
261
|
+
/** Element identity is the segment id, so inserts and moves survive each other. */
|
|
262
|
+
segments: "list",
|
|
263
|
+
segmentRanges: "list",
|
|
264
|
+
bases: "list"
|
|
265
|
+
});
|
|
266
|
+
//#endregion
|
|
267
|
+
//#region ../medeo-dsl/depreacted/src/ids.ts
|
|
268
|
+
function createEntityId(value) {
|
|
269
|
+
return createId(value, "EntityId");
|
|
270
|
+
}
|
|
271
|
+
function createRelationId(value) {
|
|
272
|
+
return createId(value, "RelationId");
|
|
273
|
+
}
|
|
274
|
+
function createId(value, label) {
|
|
275
|
+
if (value.length === 0 || value.trim() !== value) throw new Error(`${label} must be a non-empty trimmed string`);
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
279
|
+
//#region ../medeo-dsl/depreacted/src/entities/runtime.ts
|
|
280
|
+
var ScriptCompositionError = class extends Error {
|
|
281
|
+
code;
|
|
282
|
+
entityId;
|
|
283
|
+
constructor(code, entityId, message) {
|
|
284
|
+
super(message);
|
|
285
|
+
this.code = code;
|
|
286
|
+
this.entityId = entityId;
|
|
287
|
+
this.name = "ScriptCompositionError";
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
/** A row's fields by name, for the walks below that read whatever field they are asked for. */
|
|
291
|
+
function payloadOf(row) {
|
|
292
|
+
return row.payload;
|
|
293
|
+
}
|
|
294
|
+
/** The ids a variant composes. Their shape is checked when rows compile, not on read. */
|
|
295
|
+
function basesOf(entity) {
|
|
296
|
+
const ids = payloadOf(entity).bases;
|
|
297
|
+
return Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* An entity read through its `bases`: an own field wins, otherwise the field
|
|
301
|
+
* comes from the base that has it, however deep.
|
|
302
|
+
*
|
|
303
|
+
* The payload is a live, read-only view. Reads follow edits to any base; writes
|
|
304
|
+
* are refused, and go through `updateEntityFields`, which finds the entity that
|
|
305
|
+
* actually owns the field.
|
|
306
|
+
*
|
|
307
|
+
* Reading never judges the composition. Missing bases, cycles and two bases
|
|
308
|
+
* supplying one field are compile errors, reported by `checkComposition`; on
|
|
309
|
+
* uncompiled rows a read skips what it cannot reach and takes the first base
|
|
310
|
+
* that has the field.
|
|
311
|
+
*
|
|
312
|
+
* Naming the kind the caller expects narrows the row to that kind's fields.
|
|
313
|
+
*/
|
|
314
|
+
function assembleEntityContent(rows, entityId, kind) {
|
|
315
|
+
const byId = new Map(rows.entities.map((row) => [row.entityId, row]));
|
|
316
|
+
const row = byId.get(entityId);
|
|
317
|
+
if (row === void 0) throw new ScriptCompositionError("composition_dangling", entityId, `Entity "${entityId}" does not exist`);
|
|
318
|
+
if (kind !== void 0 && row.entityKind !== kind) throw new Error(`Entity "${entityId}" is a ${row.entityKind}, not a ${kind}`);
|
|
319
|
+
return {
|
|
320
|
+
...row,
|
|
321
|
+
payload: composedPayload(row, byId)
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function composedPayload(row, byId) {
|
|
325
|
+
const read = (key, entity, path) => {
|
|
326
|
+
if (Object.hasOwn(entity.payload, key)) return {
|
|
327
|
+
found: true,
|
|
328
|
+
value: payloadOf(entity)[key]
|
|
329
|
+
};
|
|
330
|
+
if (key === "bases") return { found: false };
|
|
331
|
+
for (const id of basesOf(entity)) {
|
|
332
|
+
const base = byId.get(id);
|
|
333
|
+
if (base === void 0 || path.has(id)) continue;
|
|
334
|
+
const hit = read(key, base, new Set(path).add(id));
|
|
335
|
+
if (hit.found) return hit;
|
|
336
|
+
}
|
|
337
|
+
return { found: false };
|
|
338
|
+
};
|
|
339
|
+
const collect = (entity, path, keys) => {
|
|
340
|
+
for (const key of Object.keys(entity.payload)) if (entity === row || key !== "bases") keys.add(key);
|
|
341
|
+
for (const id of basesOf(entity)) {
|
|
342
|
+
const base = byId.get(id);
|
|
343
|
+
if (base !== void 0 && !path.has(id)) collect(base, new Set(path).add(id), keys);
|
|
344
|
+
}
|
|
345
|
+
return keys;
|
|
346
|
+
};
|
|
347
|
+
const root = new Set([row.entityId]);
|
|
348
|
+
const hit = (key) => typeof key === "string" ? read(key, row, root) : { found: false };
|
|
349
|
+
return new Proxy({}, {
|
|
350
|
+
get: (target, key) => {
|
|
351
|
+
const found = hit(key);
|
|
352
|
+
return found.found ? found.value : Reflect.get(target, key);
|
|
353
|
+
},
|
|
354
|
+
has: (target, key) => hit(key).found || Reflect.has(target, key),
|
|
355
|
+
ownKeys: () => [...collect(row, root, /* @__PURE__ */ new Set())],
|
|
356
|
+
getOwnPropertyDescriptor: (_, key) => {
|
|
357
|
+
const found = hit(key);
|
|
358
|
+
return found.found ? {
|
|
359
|
+
value: found.value,
|
|
360
|
+
enumerable: true,
|
|
361
|
+
configurable: true,
|
|
362
|
+
writable: false
|
|
363
|
+
} : void 0;
|
|
364
|
+
},
|
|
365
|
+
set: () => false,
|
|
366
|
+
defineProperty: () => false,
|
|
367
|
+
deleteProperty: () => false
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Check every variant's `bases` for the whole document, once, when rows compile.
|
|
372
|
+
*
|
|
373
|
+
* Every kind but an Asset may state `bases`. Each is a non-empty list
|
|
374
|
+
* of distinct ids of entities in these rows; no entity reaches itself through
|
|
375
|
+
* them; and no two bases supply one field — not even with equal values, and not
|
|
376
|
+
* even when the variant states the field itself. An own field overrides a
|
|
377
|
+
* base; it cannot choose between two.
|
|
378
|
+
*/
|
|
379
|
+
function checkComposition(rows) {
|
|
380
|
+
const byId = new Map(rows.entities.map((row) => [row.entityId, row]));
|
|
381
|
+
const issues = [];
|
|
382
|
+
for (const row of rows.entities) {
|
|
383
|
+
if (!Object.hasOwn(row.payload, "bases")) continue;
|
|
384
|
+
if (row.entityKind === "asset") {
|
|
385
|
+
issues.push({
|
|
386
|
+
code: "invalid_bases",
|
|
387
|
+
entityId: row.entityId,
|
|
388
|
+
message: `Entity "${row.entityId}" is an asset: stored bytes have no bases`
|
|
389
|
+
});
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const ids = payloadOf(row).bases;
|
|
393
|
+
if (!Array.isArray(ids) || ids.length === 0 || !ids.every((id) => typeof id === "string" && id.length > 0 && id.trim() === id) || new Set(ids).size !== ids.length) {
|
|
394
|
+
issues.push({
|
|
395
|
+
code: "invalid_bases",
|
|
396
|
+
entityId: row.entityId,
|
|
397
|
+
message: `Entity "${row.entityId}" requires non-empty, unique bases`
|
|
398
|
+
});
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
for (const id of ids) if (!byId.has(id)) issues.push({
|
|
402
|
+
code: "composition_dangling",
|
|
403
|
+
entityId: row.entityId,
|
|
404
|
+
message: `Entity "${row.entityId}" names missing base entity "${id}" in this document`
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
const done = /* @__PURE__ */ new Set();
|
|
408
|
+
const stack = /* @__PURE__ */ new Set();
|
|
409
|
+
const walk = (id) => {
|
|
410
|
+
if (done.has(id)) return;
|
|
411
|
+
if (stack.has(id)) {
|
|
412
|
+
issues.push({
|
|
413
|
+
code: "composition_cycle",
|
|
414
|
+
entityId: id,
|
|
415
|
+
message: `Cyclic bases at "${id}"`
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
stack.add(id);
|
|
420
|
+
for (const baseId of basesOf(byId.get(id))) if (byId.has(baseId)) walk(baseId);
|
|
421
|
+
stack.delete(id);
|
|
422
|
+
done.add(id);
|
|
423
|
+
};
|
|
424
|
+
for (const row of rows.entities) walk(row.entityId);
|
|
425
|
+
if (issues.length > 0) return issues;
|
|
426
|
+
const supplied = /* @__PURE__ */ new Map();
|
|
427
|
+
const fieldsOf = (id) => {
|
|
428
|
+
const known = supplied.get(id);
|
|
429
|
+
if (known !== void 0) return known;
|
|
430
|
+
const row = byId.get(id);
|
|
431
|
+
const fields = new Set(Object.keys(row.payload).filter((key) => key !== "bases"));
|
|
432
|
+
for (const baseId of basesOf(row)) for (const field of fieldsOf(baseId)) fields.add(field);
|
|
433
|
+
supplied.set(id, fields);
|
|
434
|
+
return fields;
|
|
435
|
+
};
|
|
436
|
+
for (const row of rows.entities) {
|
|
437
|
+
const providers = /* @__PURE__ */ new Map();
|
|
438
|
+
for (const baseId of basesOf(row)) for (const field of fieldsOf(baseId)) {
|
|
439
|
+
const previous = providers.get(field);
|
|
440
|
+
if (previous === void 0) {
|
|
441
|
+
providers.set(field, baseId);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
issues.push({
|
|
445
|
+
code: "field_conflict",
|
|
446
|
+
entityId: row.entityId,
|
|
447
|
+
message: `Entity "${row.entityId}" field "${field}" conflicts between bases "${previous}" and "${baseId}"; own fields cannot resolve base ambiguity`
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return issues;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* A kind that holds text as segments and composes reads them through its
|
|
455
|
+
* bases, so what it composes has to supply them; a Caption's `selection` has
|
|
456
|
+
* to name one of them. Run it over a composition `checkComposition` accepted.
|
|
457
|
+
*/
|
|
458
|
+
function checkComposedText(rows) {
|
|
459
|
+
const issues = [];
|
|
460
|
+
for (const row of rows.entities) {
|
|
461
|
+
if (!declaresKind(row.entityKind, "SegmentText") || !Object.hasOwn(row.payload, "bases")) continue;
|
|
462
|
+
const assembled = assembleEntityContent(rows, row.entityId);
|
|
463
|
+
try {
|
|
464
|
+
segmentsOf(assembled);
|
|
465
|
+
if (row.entityKind === "caption") selectedSegment(assembled);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (!(error instanceof ScriptCompositionError)) throw error;
|
|
468
|
+
issues.push({
|
|
469
|
+
code: error.code,
|
|
470
|
+
entityId: row.entityId,
|
|
471
|
+
message: error.message
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return issues;
|
|
476
|
+
}
|
|
477
|
+
/** The entity whose own payload holds `field` for this entity — itself or a base — or undefined when none does. */
|
|
478
|
+
function resolveEntityFieldOwner(rows, entityId, field) {
|
|
479
|
+
const byId = new Map(rows.entities.map((row) => [row.entityId, row]));
|
|
480
|
+
const find = (id, path) => {
|
|
481
|
+
const row = byId.get(id);
|
|
482
|
+
if (row === void 0) return void 0;
|
|
483
|
+
if (Object.hasOwn(row.payload, field)) return id;
|
|
484
|
+
if (field === "bases") return void 0;
|
|
485
|
+
for (const baseId of basesOf(row)) {
|
|
486
|
+
if (path.has(baseId)) continue;
|
|
487
|
+
const owner = find(baseId, new Set(path).add(baseId));
|
|
488
|
+
if (owner !== void 0) return owner;
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
return find(entityId, new Set([entityId]));
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* The entity that supplies `field` to this one through its bases — never the
|
|
495
|
+
* entity itself, so an own override does not hide where the field comes from —
|
|
496
|
+
* or undefined when no base supplies it.
|
|
497
|
+
*/
|
|
498
|
+
function baseFieldOwner(rows, entityId, field) {
|
|
499
|
+
const row = rows.entities.find((candidate) => candidate.entityId === entityId);
|
|
500
|
+
if (row === void 0) return void 0;
|
|
501
|
+
for (const baseId of basesOf(row)) {
|
|
502
|
+
const owner = resolveEntityFieldOwner(rows, baseId, field);
|
|
503
|
+
if (owner !== void 0) return owner;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* The Asset whose bytes an entity is made from. An Asset is its own; any other
|
|
508
|
+
* entity reaches one through its own `from-asset`, else through its bases, the
|
|
509
|
+
* way it reads a field it does not state.
|
|
510
|
+
*/
|
|
511
|
+
function assetOf(rows, entityId) {
|
|
512
|
+
const byId = new Map(rows.entities.map((row) => [row.entityId, row]));
|
|
513
|
+
const find = (id, path) => {
|
|
514
|
+
const row = byId.get(id);
|
|
515
|
+
if (row === void 0) return void 0;
|
|
516
|
+
if (row.entityKind === "asset") return row;
|
|
517
|
+
const own = rows.relations.find((relation) => relation.relationKind === "from-asset" && relation.endpoint0EntityId === id);
|
|
518
|
+
if (own !== void 0) {
|
|
519
|
+
const asset = byId.get(own.endpoint1EntityId);
|
|
520
|
+
return asset?.entityKind === "asset" ? asset : void 0;
|
|
521
|
+
}
|
|
522
|
+
for (const baseId of basesOf(row)) {
|
|
523
|
+
if (path.has(baseId)) continue;
|
|
524
|
+
const found = find(baseId, new Set(path).add(baseId));
|
|
525
|
+
if (found !== void 0) return found;
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
return find(entityId, new Set([entityId]));
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Write assembled fields to the entities that own them. A field no one owns yet
|
|
532
|
+
* becomes the entity's own; an inherited field is edited where it lives, so
|
|
533
|
+
* every variant reading it sees the edit, and nothing is copied down.
|
|
534
|
+
*/
|
|
535
|
+
function updateEntityFields(rows, entityId, fields) {
|
|
536
|
+
if (Object.hasOwn(fields, "entityId") || Object.hasOwn(fields, "entityKind")) throw new ScriptCompositionError("invalid_bases", entityId, "Field updates cannot rewrite an identity field");
|
|
537
|
+
const updates = /* @__PURE__ */ new Map();
|
|
538
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
539
|
+
const ownerId = resolveEntityFieldOwner(rows, entityId, field) ?? entityId;
|
|
540
|
+
const owner = updates.get(ownerId) ?? rows.entities.find((row) => row.entityId === ownerId);
|
|
541
|
+
updates.set(ownerId, {
|
|
542
|
+
...owner,
|
|
543
|
+
payload: {
|
|
544
|
+
...owner.payload,
|
|
545
|
+
[field]: value
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
return [...updates.values()].map((row) => JSON.parse(JSON.stringify(row)));
|
|
550
|
+
}
|
|
551
|
+
/** The whole text a row carries — its own segments or those it reads through its bases — in segment order. */
|
|
552
|
+
function assembleScriptText(row) {
|
|
553
|
+
return segmentsOf(row).map((segment) => segment.text).join("");
|
|
554
|
+
}
|
|
555
|
+
/** A row's segments, own or composed; pass an assembled row to read through its bases. */
|
|
556
|
+
function segmentsOf(row) {
|
|
557
|
+
const segments = payloadOf(row).segments;
|
|
558
|
+
if (!Array.isArray(segments)) throw new ScriptCompositionError("composition_dangling", row.entityId, `Entity "${row.entityId}" carries no segments`);
|
|
559
|
+
if (!segments.every(isScriptSegment) || new Set(segments.map((s) => s.segmentId)).size !== segments.length) throw new ScriptCompositionError("invalid_script", row.entityId, "Segments require unique local segmentId and text");
|
|
560
|
+
return segments;
|
|
561
|
+
}
|
|
562
|
+
function isScriptSegment(value) {
|
|
563
|
+
if (typeof value !== "object" || value == null || Array.isArray(value)) return false;
|
|
564
|
+
const segment = value;
|
|
565
|
+
return typeof segment.segmentId === "string" && segment.segmentId.trim() !== "" && typeof segment.text === "string" && (segment.language === void 0 || typeof segment.language === "string");
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* The text a Caption shows: the segment its `selection` names, among the
|
|
569
|
+
* segments it reads through its bases, narrowed by `textRange`. Pass the
|
|
570
|
+
* assembled Caption; a stored Caption holds no segments of its own.
|
|
571
|
+
*/
|
|
572
|
+
function selectedSegment(caption) {
|
|
573
|
+
const selection = payloadOf(caption).selection;
|
|
574
|
+
if (Object.hasOwn(caption.payload, "selections") || !isSegmentSelection(selection)) throw new ScriptCompositionError("invalid_selection", caption.entityId, "Caption requires one selection object with segmentId and optional textRange; selections arrays are forbidden");
|
|
575
|
+
return selectSegment(caption, selection);
|
|
576
|
+
}
|
|
577
|
+
function isSegmentSelection(value) {
|
|
578
|
+
return typeof value === "object" && value != null && !Array.isArray(value) && typeof value.segmentId === "string" && value.segmentId.trim() !== "" && Object.keys(value).every((key) => key === "segmentId" || key === "textRange");
|
|
579
|
+
}
|
|
580
|
+
/** Select from any row carrying segments, without creating another authoritative text field. */
|
|
581
|
+
function selectSegment(row, selection) {
|
|
582
|
+
if (!isSegmentSelection(selection)) throw new ScriptCompositionError("invalid_selection", row.entityId, "Caption requires one selection object with segmentId and optional textRange");
|
|
583
|
+
const segment = segmentsOf(row).find((entry) => entry.segmentId === selection.segmentId);
|
|
584
|
+
if (segment === void 0) throw new ScriptCompositionError("unknown_segment", row.entityId, `Caption selection "${selection.segmentId}" names no segment that "${row.entityId}" reads`);
|
|
585
|
+
const selected = (() => {
|
|
586
|
+
if (selection.textRange === void 0) return segment;
|
|
587
|
+
const range = selection.textRange;
|
|
588
|
+
const points = Array.from(segment.text);
|
|
589
|
+
if (typeof range !== "object" || range === null || !Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.end) || range.start < 0 || range.end <= range.start || range.end > points.length || Object.keys(range).some((key) => key !== "start" && key !== "end")) throw new ScriptCompositionError("invalid_selection", row.entityId, "Caption textRange must be a non-empty half-open Unicode code-point range within its source segment");
|
|
590
|
+
return {
|
|
591
|
+
...segment,
|
|
592
|
+
text: points.slice(range.start, range.end).join("")
|
|
593
|
+
};
|
|
594
|
+
})();
|
|
595
|
+
if (!selected.text.trim()) throw new ScriptCompositionError("empty_selection", row.entityId, "Caption selection must contain visible source text");
|
|
596
|
+
return selected;
|
|
597
|
+
}
|
|
598
|
+
//#endregion
|
|
599
|
+
//#region ../medeo-dsl/depreacted/src/relations/types.ts
|
|
600
|
+
function createEntityRef(entity) {
|
|
601
|
+
return {
|
|
602
|
+
entityId: entity.entityId,
|
|
603
|
+
current: () => entity
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region ../medeo-dsl/depreacted/src/relations/specs.ts
|
|
608
|
+
/**
|
|
609
|
+
* Every Relation kind and the pairs it admits.
|
|
610
|
+
*
|
|
611
|
+
* As in `ENTITY_KINDS`, a question of the form "which kinds are X" is answered
|
|
612
|
+
* by a trait; an end names a kind only when the pair is about that one kind.
|
|
613
|
+
*/
|
|
614
|
+
const RELATION_KINDS = {
|
|
615
|
+
/**
|
|
616
|
+
* What a Container holds: a Timeline holds Tracks, a Track holds Clips, and
|
|
617
|
+
* a Clip — the placement itself — holds content that declares Sequence. A
|
|
618
|
+
* Track's Clips play in the order their Relations sit in.
|
|
619
|
+
*/
|
|
620
|
+
contain: {
|
|
621
|
+
admits: [
|
|
622
|
+
["timeline", "track"],
|
|
623
|
+
["track", "clip"],
|
|
624
|
+
["clip", "Sequence"]
|
|
625
|
+
],
|
|
626
|
+
sequenced: true
|
|
627
|
+
},
|
|
628
|
+
/**
|
|
629
|
+
* A point marked on endpoint 0's timeline. A Clip takes a display Marker —
|
|
630
|
+
* the window and placement it plays its content through; an AudioScript
|
|
631
|
+
* takes an annotation Marker — assigned per-Segment times. What the Marker
|
|
632
|
+
* means is the marked entity's own business.
|
|
633
|
+
*/
|
|
634
|
+
marker: { admits: [[["clip", "audio-script"], "sequence-marker"]] },
|
|
635
|
+
/**
|
|
636
|
+
* `from-asset(entity, asset)` — the entity was made from that stored
|
|
637
|
+
* resource. Any entity with the `MediaFile` trait may be bound; the bytes
|
|
638
|
+
* stay named in exactly one place, so no entity copies the locator into its
|
|
639
|
+
* own row and one Asset can back several entities.
|
|
640
|
+
*/
|
|
641
|
+
"from-asset": { admits: [["MediaFile", "asset"]] },
|
|
642
|
+
/**
|
|
643
|
+
* `to-asset(entity, asset)` — the entity's content was written out to that
|
|
644
|
+
* stored resource.
|
|
645
|
+
*
|
|
646
|
+
* The mirror of `from-asset`, and deliberately a separate relation kind:
|
|
647
|
+
* eligibility is the same either way, but which way the content flowed is
|
|
648
|
+
* what says whether an entity may still be edited. An entity reached by
|
|
649
|
+
* `from-asset` was produced from bytes it does not own, so changing it would
|
|
650
|
+
* contradict them. An entity with a `to-asset` owns its content and the
|
|
651
|
+
* resource is a snapshot of it, which is what goes stale and gets written
|
|
652
|
+
* again. A transcript that is later edited carries both at once, and only
|
|
653
|
+
* the distinct relation kinds keep them apart.
|
|
654
|
+
*
|
|
655
|
+
* Writing a snapshot is the host's; a script reads these, and never writes one.
|
|
656
|
+
*/
|
|
657
|
+
"to-asset": { admits: [["MediaFile", "asset"]] },
|
|
658
|
+
/**
|
|
659
|
+
* What a thing was made from; endpoint 0 is the product, endpoint 1 what it
|
|
660
|
+
* came from. A generated medium is made from anything seen or heard, a
|
|
661
|
+
* Voiceover Recording from the Phonetic Script it was spoken from, and a
|
|
662
|
+
* transcribed Audio Script from media it was read off.
|
|
663
|
+
*/
|
|
664
|
+
"derived-from": { admits: [
|
|
665
|
+
[["Visual", "Audible"], ["Visual", "Audible"]],
|
|
666
|
+
["voiceover-recording", "phonetic-script"],
|
|
667
|
+
["audio-script", "Audible"]
|
|
668
|
+
] },
|
|
669
|
+
/** The Voiceover Recording a Caption follows. */
|
|
670
|
+
"caption-alignment": { admits: [["caption", "voiceover-recording"]] },
|
|
671
|
+
/** `clip-anchor(child, host)`: endpoint 0 follows endpoint 1. */
|
|
672
|
+
"clip-anchor": { admits: [["clip", "clip"]] },
|
|
673
|
+
/**
|
|
674
|
+
* The timbre identity a Voiceover was synthesized with. Voice stays an
|
|
675
|
+
* identity: it never carries the audio itself, so several takes can name
|
|
676
|
+
* one voice.
|
|
677
|
+
*/
|
|
678
|
+
"voice-timbre": { admits: [["voiceover-recording", "voice"]] }
|
|
679
|
+
};
|
|
680
|
+
function isRelationKind(kind) {
|
|
681
|
+
return Object.hasOwn(RELATION_KINDS, kind);
|
|
682
|
+
}
|
|
683
|
+
/** Whether a kind relates these two entities, in these positions. */
|
|
684
|
+
function admits(kind, [first, second]) {
|
|
685
|
+
return RELATION_KINDS[kind].admits.some(([a, b]) => isAt(a, first.current()) && isAt(b, second.current()));
|
|
686
|
+
}
|
|
687
|
+
/** Whether this kind's Relations are ordered among themselves inside one container. */
|
|
688
|
+
function isSequencedRelationKind(kind) {
|
|
689
|
+
return isRelationKind(kind) && RELATION_KINDS[kind].sequenced === true;
|
|
690
|
+
}
|
|
691
|
+
/** Whether an entity sits at an end: its kind is the one named there, or it declares the trait named there. */
|
|
692
|
+
function isAt(end, entity) {
|
|
693
|
+
if (typeof end !== "string") return end.some((one) => isAt(one, entity));
|
|
694
|
+
return isKnownEntityKind(end) ? entity.entityKind === end : declaresKind(entity.entityKind, end);
|
|
695
|
+
}
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region ../medeo-dsl/depreacted/src/relations/runtime.ts
|
|
698
|
+
/** Check one Relation: two distinct entities, in positions its kind admits. */
|
|
699
|
+
function checkRelation(kind, endpoints) {
|
|
700
|
+
if (endpoints[0].entityId === endpoints[1].entityId) throw new Error("A Relation cannot connect an entity to itself");
|
|
701
|
+
if (!admits(kind, endpoints)) throw new Error(`Relation "${kind}" received invalid endpoints`);
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Every Relation of a compiled document, found from either endpoint. It is
|
|
705
|
+
* built once when rows compile and never changes; the rows stay the
|
|
706
|
+
* persistence authority.
|
|
707
|
+
*/
|
|
708
|
+
var RelationIndex = class {
|
|
709
|
+
byEntity = /* @__PURE__ */ new Map();
|
|
710
|
+
relationIds = /* @__PURE__ */ new Set();
|
|
711
|
+
/** Check a Relation and add it; a Relation id appears once per index. */
|
|
712
|
+
link({ relationId, kind, endpoints }) {
|
|
713
|
+
if (this.relationIds.has(relationId)) throw new Error(`Relation id "${relationId}" already exists`);
|
|
714
|
+
checkRelation(kind, endpoints);
|
|
715
|
+
const [first, second] = endpoints;
|
|
716
|
+
const relation = {
|
|
717
|
+
relationId,
|
|
718
|
+
kind,
|
|
719
|
+
endpoints,
|
|
720
|
+
other: (entity) => entity.entityId === first.entityId ? second : entity.entityId === second.entityId ? first : void 0
|
|
721
|
+
};
|
|
722
|
+
this.relationIds.add(relationId);
|
|
723
|
+
for (const { entityId } of endpoints) {
|
|
724
|
+
const relations = this.byEntity.get(entityId) ?? /* @__PURE__ */ new Set();
|
|
725
|
+
relations.add(relation);
|
|
726
|
+
this.byEntity.set(entityId, relations);
|
|
727
|
+
}
|
|
728
|
+
return relation;
|
|
729
|
+
}
|
|
730
|
+
/** The Relations touching an entity, on either side. */
|
|
731
|
+
relationsOf(entity) {
|
|
732
|
+
return this.byEntity.get(entity.entityId) ?? /* @__PURE__ */ new Set();
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
/** Relations of `kind` where `entityId` is endpoint 0 — the container, the product, the child. */
|
|
736
|
+
function relationsFrom(relations, kind, entityId) {
|
|
737
|
+
return relations.filter((row) => row.relationKind === kind && row.endpoint0EntityId === entityId);
|
|
738
|
+
}
|
|
739
|
+
/** Relations of `kind` where `entityId` is endpoint 1 — what is contained, the source, the host. */
|
|
740
|
+
function relationsTo(relations, kind, entityId) {
|
|
741
|
+
return relations.filter((row) => row.relationKind === kind && row.endpoint1EntityId === entityId);
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* What has to hold across Relations once every entity's own fields are valid:
|
|
745
|
+
* how Markers, Clips and their content fit together, and where Clips sit.
|
|
746
|
+
*/
|
|
747
|
+
function validateRelationGraph(entities, index) {
|
|
748
|
+
const issues = [];
|
|
749
|
+
for (const entity of entities) {
|
|
750
|
+
const kind = entity.current().entityKind;
|
|
751
|
+
if (kind === "sequence-marker") {
|
|
752
|
+
const marker = entity;
|
|
753
|
+
issues.push(...validateMarkerUse(marker, index), ...validateMarkerSourceBounds(marker, index));
|
|
754
|
+
}
|
|
755
|
+
if (kind === "clip") {
|
|
756
|
+
const clip = entity;
|
|
757
|
+
issues.push(...validateClipAdmission(clip, index), ...validateClipPlacement(clip, index));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
issues.push(...validateClipAnchorCycles(entities, index));
|
|
761
|
+
return issues;
|
|
762
|
+
}
|
|
763
|
+
function validateMarkerUse(marker, index) {
|
|
764
|
+
const markerEdges = ofKind([...index.relationsOf(marker)], "marker");
|
|
765
|
+
const markedKind = (relation) => relation.other(marker)?.current().entityKind;
|
|
766
|
+
const clipEdges = markerEdges.filter((relation) => markedKind(relation) === "clip");
|
|
767
|
+
const scriptEdges = markerEdges.filter((relation) => markedKind(relation) === "audio-script");
|
|
768
|
+
const issues = [];
|
|
769
|
+
if (scriptEdges.length > 0) {
|
|
770
|
+
if (clipEdges.length > 0) issues.push({
|
|
771
|
+
code: "marker_container_xor",
|
|
772
|
+
entityId: marker.entityId,
|
|
773
|
+
message: "An AudioScript annotation Marker must not carry Clip or content relations"
|
|
774
|
+
});
|
|
775
|
+
const ranges = marker.current().segmentRanges;
|
|
776
|
+
if (!ranges?.length) issues.push({
|
|
777
|
+
code: "marker_annotation_invalid",
|
|
778
|
+
entityId: marker.entityId,
|
|
779
|
+
message: "An AudioScript annotation Marker requires segmentRanges"
|
|
780
|
+
});
|
|
781
|
+
for (const relation of scriptEdges) {
|
|
782
|
+
const segments = relation.other(marker).current().segments;
|
|
783
|
+
if (!Array.isArray(segments)) continue;
|
|
784
|
+
const segmentIds = new Set(segments.map((segment) => segment.segmentId));
|
|
785
|
+
if (ranges?.some((range) => !segmentIds.has(range.segmentId))) issues.push({
|
|
786
|
+
code: "marker_annotation_invalid",
|
|
787
|
+
entityId: marker.entityId,
|
|
788
|
+
message: "Annotation segmentRanges must name segments of the related AudioScript"
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
return issues;
|
|
792
|
+
}
|
|
793
|
+
if (marker.current().segmentRanges !== void 0) issues.push({
|
|
794
|
+
code: "marker_annotation_invalid",
|
|
795
|
+
entityId: marker.entityId,
|
|
796
|
+
message: "segmentRanges belongs to an AudioScript annotation Marker, not a display Marker"
|
|
797
|
+
});
|
|
798
|
+
if (clipEdges.length !== 1) issues.push({
|
|
799
|
+
code: "marker_container_xor",
|
|
800
|
+
entityId: marker.entityId,
|
|
801
|
+
message: "A display Sequence Marker belongs to exactly one Clip"
|
|
802
|
+
});
|
|
803
|
+
return issues;
|
|
804
|
+
}
|
|
805
|
+
function validateClipAdmission(clip, index) {
|
|
806
|
+
if (markersOn(clip, index).length !== 1) return [{
|
|
807
|
+
code: "clip_marker_cardinality",
|
|
808
|
+
entityId: clip.entityId,
|
|
809
|
+
message: "Clip must have exactly one authoritative Clip-Marker Relation"
|
|
810
|
+
}];
|
|
811
|
+
const contentEdges = holds(clip, index);
|
|
812
|
+
if (contentEdges.length !== 1) return [{
|
|
813
|
+
code: "clip_content_cardinality",
|
|
814
|
+
entityId: clip.entityId,
|
|
815
|
+
message: "Clip must contain exactly one content entity"
|
|
816
|
+
}];
|
|
817
|
+
const content = contentEdges[0].other(clip).current();
|
|
818
|
+
if (!hasSequence(content)) return [{
|
|
819
|
+
code: "clip_content_not_sequence",
|
|
820
|
+
entityId: clip.entityId,
|
|
821
|
+
message: `Clip contains non-Sequence entity "${content.entityId}"`
|
|
822
|
+
}];
|
|
823
|
+
return [];
|
|
824
|
+
}
|
|
825
|
+
function validateClipPlacement(clip, index) {
|
|
826
|
+
const marker = markersOn(clip, index)[0]?.other(clip);
|
|
827
|
+
if (marker === void 0) return [];
|
|
828
|
+
const markerValue = marker.current();
|
|
829
|
+
const anchors = ofKind([...index.relationsOf(clip)], "clip-anchor").filter((relation) => relation.endpoints[0].entityId === clip.entityId);
|
|
830
|
+
const hasStart = markerValue.startMs !== void 0;
|
|
831
|
+
const issues = [];
|
|
832
|
+
if (anchors.length > 1) issues.push({
|
|
833
|
+
code: "clip_anchor_cardinality",
|
|
834
|
+
entityId: clip.entityId,
|
|
835
|
+
message: "A Clip may follow at most one host Clip"
|
|
836
|
+
});
|
|
837
|
+
if (anchors.length > 0 && !hasStart) issues.push({
|
|
838
|
+
code: "clip_placement_invalid",
|
|
839
|
+
entityId: clip.entityId,
|
|
840
|
+
message: "A Clip following a host states startMs, the offset inside that host"
|
|
841
|
+
});
|
|
842
|
+
if (markerValue.fillTimeline === true) {
|
|
843
|
+
const content = holds(clip, index)[0]?.other(clip)?.current();
|
|
844
|
+
const track = heldBy(clip, index)[0]?.other(clip)?.current();
|
|
845
|
+
const trackRole = track?.entityKind === "track" ? track.role : void 0;
|
|
846
|
+
if (content?.entityKind !== "audio" || trackRole !== "bgm") issues.push({
|
|
847
|
+
code: "timeline_duration_policy_invalid",
|
|
848
|
+
entityId: marker.entityId,
|
|
849
|
+
message: "Marker fillTimeline is only valid for ordered or absolutely placed Audio Clips on the bgm Track"
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
return issues;
|
|
853
|
+
}
|
|
854
|
+
function validateClipAnchorCycles(entities, index) {
|
|
855
|
+
const hostByChild = /* @__PURE__ */ new Map();
|
|
856
|
+
for (const entity of entities) {
|
|
857
|
+
if (entity.current().entityKind !== "clip") continue;
|
|
858
|
+
for (const relation of ofKind([...index.relationsOf(entity)], "clip-anchor")) if (relation.endpoints[0].entityId === entity.entityId) hostByChild.set(entity.entityId, relation.endpoints[1].entityId);
|
|
859
|
+
}
|
|
860
|
+
const issues = [];
|
|
861
|
+
for (const child of hostByChild.keys()) {
|
|
862
|
+
const seen = /* @__PURE__ */ new Set();
|
|
863
|
+
let current = child;
|
|
864
|
+
while (current !== void 0 && !seen.has(current)) {
|
|
865
|
+
seen.add(current);
|
|
866
|
+
current = hostByChild.get(current);
|
|
867
|
+
}
|
|
868
|
+
if (current === void 0) continue;
|
|
869
|
+
issues.push({
|
|
870
|
+
code: "clip_anchor_cycle",
|
|
871
|
+
entityId: child,
|
|
872
|
+
message: "clip-anchor Relations must form an acyclic dependency graph"
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
return issues;
|
|
876
|
+
}
|
|
877
|
+
function validateMarkerSourceBounds(marker, index) {
|
|
878
|
+
const clip = markedBy(marker, index)[0]?.other(marker);
|
|
879
|
+
if (clip == null) return [];
|
|
880
|
+
const contentEdges = holds(clip, index);
|
|
881
|
+
if (contentEdges.length !== 1) return [];
|
|
882
|
+
const content = contentEdges[0]?.other(clip)?.current();
|
|
883
|
+
if (content == null || !hasSequence(content)) return [];
|
|
884
|
+
const { inMs, outMs } = marker.current();
|
|
885
|
+
if (inMs >= 0 && (content.durationMs == null || outMs <= content.durationMs)) return [];
|
|
886
|
+
return [{
|
|
887
|
+
code: "marker_source_out_of_bounds",
|
|
888
|
+
entityId: marker.entityId,
|
|
889
|
+
message: "Sequence Marker in and out points must stay inside the length its content states"
|
|
890
|
+
}];
|
|
891
|
+
}
|
|
892
|
+
/** Marker edges where this entity is what the Marker is marked on. */
|
|
893
|
+
function markersOn(target, index) {
|
|
894
|
+
return ofKind([...index.relationsOf(target)], "marker").filter((relation) => relation.endpoints[0].entityId === target.entityId);
|
|
895
|
+
}
|
|
896
|
+
/** Marker edges seen from the Marker itself. */
|
|
897
|
+
function markedBy(marker, index) {
|
|
898
|
+
return ofKind([...index.relationsOf(marker)], "marker").filter((relation) => relation.endpoints[1].entityId === marker.entityId);
|
|
899
|
+
}
|
|
900
|
+
/** Contain edges where this entity is the Container. */
|
|
901
|
+
function holds(container, index) {
|
|
902
|
+
return ofKind([...index.relationsOf(container)], "contain").filter((relation) => relation.endpoints[0].entityId === container.entityId);
|
|
903
|
+
}
|
|
904
|
+
/** Contain edges where this entity is what a Container holds. */
|
|
905
|
+
function heldBy(contained, index) {
|
|
906
|
+
return ofKind([...index.relationsOf(contained)], "contain").filter((relation) => relation.endpoints[1].entityId === contained.entityId);
|
|
907
|
+
}
|
|
908
|
+
function ofKind(relations, kind) {
|
|
909
|
+
return relations.filter((relation) => relation.kind === kind);
|
|
910
|
+
}
|
|
911
|
+
//#endregion
|
|
912
|
+
//#region ../medeo-dsl/depreacted/src/compile.ts
|
|
913
|
+
/** Every problem a document has, reported together. */
|
|
914
|
+
var CompileError = class extends Error {
|
|
915
|
+
issues;
|
|
916
|
+
constructor(issues) {
|
|
917
|
+
super(`Medeo document does not compile:\n- ${issues.join("\n- ")}`);
|
|
918
|
+
this.issues = issues;
|
|
919
|
+
this.name = "CompileError";
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
/**
|
|
923
|
+
* Compile a document's stored rows into checked entities and relations. Every
|
|
924
|
+
* problem found is thrown together in one `CompileError`; nothing partial is
|
|
925
|
+
* returned.
|
|
926
|
+
*/
|
|
927
|
+
function compile(rows) {
|
|
928
|
+
const entitiesById = /* @__PURE__ */ new Map();
|
|
929
|
+
const issues = checkComposition(rows).map((issue) => issue.message);
|
|
930
|
+
for (const row of rows.entities) {
|
|
931
|
+
const entityId = checkEntityRow(row, issues);
|
|
932
|
+
if (entityId === void 0) continue;
|
|
933
|
+
if (entitiesById.has(entityId)) {
|
|
934
|
+
issues.push(`duplicate entity id "${entityId}"`);
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
const { payload } = assembleEntityContent(rows, entityId);
|
|
938
|
+
entitiesById.set(entityId, createEntityRef({
|
|
939
|
+
...payload,
|
|
940
|
+
entityId,
|
|
941
|
+
entityKind: row.entityKind
|
|
942
|
+
}));
|
|
943
|
+
}
|
|
944
|
+
const relationIndex = new RelationIndex();
|
|
945
|
+
const relations = [];
|
|
946
|
+
const relationIds = /* @__PURE__ */ new Set();
|
|
947
|
+
for (const row of rows.relations) {
|
|
948
|
+
let relationId;
|
|
949
|
+
try {
|
|
950
|
+
relationId = createRelationId(row.relationId);
|
|
951
|
+
} catch (error) {
|
|
952
|
+
issues.push(errorMessage(error));
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
if (relationIds.has(relationId)) {
|
|
956
|
+
issues.push(`duplicate relation id "${relationId}"`);
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
relationIds.add(relationId);
|
|
960
|
+
if (!isRelationKind(row.relationKind)) {
|
|
961
|
+
issues.push(`relation "${relationId}" has unknown kind "${String(row.relationKind)}"`);
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
let endpoint0EntityId;
|
|
965
|
+
let endpoint1EntityId;
|
|
966
|
+
try {
|
|
967
|
+
endpoint0EntityId = createEntityId(row.endpoint0EntityId);
|
|
968
|
+
endpoint1EntityId = createEntityId(row.endpoint1EntityId);
|
|
969
|
+
} catch (error) {
|
|
970
|
+
issues.push(errorMessage(error));
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
const endpoint0 = entitiesById.get(endpoint0EntityId);
|
|
974
|
+
const endpoint1 = entitiesById.get(endpoint1EntityId);
|
|
975
|
+
if (endpoint0 == null || endpoint1 == null) {
|
|
976
|
+
const missing = [endpoint0 == null ? endpoint0EntityId : void 0, endpoint1 == null ? endpoint1EntityId : void 0].filter((value) => value != null).join(", ");
|
|
977
|
+
issues.push(`relation "${relationId}" references missing entity id(s): ${missing}`);
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
relations.push(relationIndex.link({
|
|
982
|
+
relationId,
|
|
983
|
+
kind: row.relationKind,
|
|
984
|
+
endpoints: [endpoint0, endpoint1]
|
|
985
|
+
}));
|
|
986
|
+
} catch (error) {
|
|
987
|
+
issues.push(errorMessage(error));
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (issues.length === 0) for (const entity of entitiesById.values()) for (const problem of validateEntity(entity.current())) issues.push(`invalid_entity_payload: ${problem}`);
|
|
991
|
+
if (issues.length === 0) for (const issue of checkComposedText(rows)) issues.push(issue.message);
|
|
992
|
+
if (issues.length === 0) for (const issue of validateRelationGraph([...entitiesById.values()], relationIndex)) issues.push(`${issue.code}: ${issue.message}`);
|
|
993
|
+
if (issues.length > 0) throw new CompileError(issues);
|
|
994
|
+
return {
|
|
995
|
+
entitiesById,
|
|
996
|
+
relationIndex,
|
|
997
|
+
relations
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
/** Check what a stored row states about itself; the entity is then read through its bases. */
|
|
1001
|
+
function checkEntityRow(row, issues) {
|
|
1002
|
+
let entityId;
|
|
1003
|
+
try {
|
|
1004
|
+
entityId = createEntityId(row.entityId);
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
issues.push(errorMessage(error));
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (!isKnownEntityKind(row.entityKind)) {
|
|
1010
|
+
issues.push(`entity "${entityId}" has unknown kind "${String(row.entityKind)}"`);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (typeof row.payload !== "object" || row.payload === null || Array.isArray(row.payload)) {
|
|
1014
|
+
issues.push(`entity "${entityId}" payload must be an object`);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const reserved = ["entityId", "entityKind"].filter((key) => Object.hasOwn(row.payload, key));
|
|
1018
|
+
if (reserved.length > 0) {
|
|
1019
|
+
issues.push(`entity "${entityId}" payload contains reserved field(s): ${reserved.join(", ")}`);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const unknown = unknownFields(row.entityKind, row.payload);
|
|
1023
|
+
if (unknown.length > 0) {
|
|
1024
|
+
issues.push(`entity "${entityId}" states field(s) its kind does not have: ${unknown.join(", ")}`);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
return entityId;
|
|
1028
|
+
}
|
|
1029
|
+
function errorMessage(error) {
|
|
1030
|
+
return error instanceof Error ? error.message : String(error);
|
|
1031
|
+
}
|
|
1032
|
+
//#endregion
|
|
1033
|
+
//#region src/legacy/projection/entity-projection-values.ts
|
|
1034
|
+
/** A graph cannot be represented faithfully by the compatibility reader. */
|
|
1035
|
+
var EntityTimelineProjectionError = class extends Error {
|
|
1036
|
+
constructor(message) {
|
|
1037
|
+
super(message);
|
|
1038
|
+
this.name = "EntityTimelineProjectionError";
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
function fail(message) {
|
|
1042
|
+
throw new EntityTimelineProjectionError(message);
|
|
1043
|
+
}
|
|
1044
|
+
function finite(value, label) {
|
|
1045
|
+
if (typeof value !== "number" || !Number.isFinite(value)) fail(`${label} must be finite`);
|
|
1046
|
+
return value;
|
|
1047
|
+
}
|
|
1048
|
+
function positive(value, label) {
|
|
1049
|
+
const result = finite(value, label);
|
|
1050
|
+
if (result <= 0) fail(`${label} must be positive`);
|
|
1051
|
+
return result;
|
|
1052
|
+
}
|
|
1053
|
+
function milliseconds(value, label) {
|
|
1054
|
+
const result = finite(value, label);
|
|
1055
|
+
if (!Number.isSafeInteger(result) || result < 0) fail(`${label} must be non-negative whole milliseconds`);
|
|
1056
|
+
return result;
|
|
1057
|
+
}
|
|
1058
|
+
function range(start, end, label) {
|
|
1059
|
+
if (start >= end) fail(`${label} must be non-empty`);
|
|
1060
|
+
return {
|
|
1061
|
+
start,
|
|
1062
|
+
end
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
//#endregion
|
|
1066
|
+
//#region src/legacy/projection/entity-timeline-layout.ts
|
|
1067
|
+
/** Validated graph lookup shared by the compatibility layout and projection. */
|
|
1068
|
+
var EntityProjectionGraph = class {
|
|
1069
|
+
rows;
|
|
1070
|
+
entities;
|
|
1071
|
+
timeline;
|
|
1072
|
+
constructor(rows) {
|
|
1073
|
+
this.rows = rows;
|
|
1074
|
+
const compiled = compile(rows);
|
|
1075
|
+
this.entities = new Map([...compiled.entitiesById.values()].map((ref) => [String(ref.entityId), entityToRow(ref.current())]));
|
|
1076
|
+
const roots = [...this.entities.values()].filter((row) => row.entityKind === "timeline");
|
|
1077
|
+
if (roots.length !== 1) fail("compatibility projection requires exactly one Timeline");
|
|
1078
|
+
this.timeline = roots[0];
|
|
1079
|
+
}
|
|
1080
|
+
/** The entity with this id; naming its kind narrows the row to that kind's fields. */
|
|
1081
|
+
entity(id, kind) {
|
|
1082
|
+
const row = this.entities.get(id);
|
|
1083
|
+
if (row === void 0 || kind !== void 0 && row.entityKind !== kind) fail(`Expected ${kind ?? "Entity"} ${id}`);
|
|
1084
|
+
return row;
|
|
1085
|
+
}
|
|
1086
|
+
adjacent(id, kind) {
|
|
1087
|
+
return [...new Set(this.rows.relations.filter((row) => row.relationKind === kind && (row.endpoint0EntityId === id || row.endpoint1EntityId === id)).map((row) => row.endpoint0EntityId === id ? row.endpoint1EntityId : row.endpoint0EntityId))].map((entityId) => this.entity(entityId));
|
|
1088
|
+
}
|
|
1089
|
+
/** What this entity contains, as opposed to what contains it. */
|
|
1090
|
+
holds(id) {
|
|
1091
|
+
return this.rows.relations.filter((row) => row.relationKind === "contain" && row.endpoint0EntityId === id).map((row) => this.entity(row.endpoint1EntityId));
|
|
1092
|
+
}
|
|
1093
|
+
heldBy(id) {
|
|
1094
|
+
return this.rows.relations.filter((row) => row.relationKind === "contain" && row.endpoint1EntityId === id).map((row) => this.entity(row.endpoint0EntityId));
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Ordered relations put the product at endpoint 0 and its source at endpoint 1.
|
|
1098
|
+
* One entity can sit on both sides of the same kind — a Voiceover Recording is
|
|
1099
|
+
* spoken from a PhoneticScript and is itself what a transcript was read from —
|
|
1100
|
+
* so provenance is never read through the undirected `adjacent`.
|
|
1101
|
+
*/
|
|
1102
|
+
madeFrom(id, kind) {
|
|
1103
|
+
return this.rows.relations.filter((row) => row.relationKind === kind && row.endpoint0EntityId === id).map((row) => this.entity(row.endpoint1EntityId));
|
|
1104
|
+
}
|
|
1105
|
+
madeInto(id, kind) {
|
|
1106
|
+
return this.rows.relations.filter((row) => row.relationKind === kind && row.endpoint1EntityId === id).map((row) => this.entity(row.endpoint0EntityId));
|
|
1107
|
+
}
|
|
1108
|
+
one(id, relation, kind, values = this.adjacent(id, relation)) {
|
|
1109
|
+
if (values.length !== 1 || kind !== void 0 && values[0]?.entityKind !== kind) fail(`entity ${id} requires exactly one ${relation}${kind === void 0 ? "" : ` to ${kind}`}`);
|
|
1110
|
+
return values[0];
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
const roleOrder$1 = {
|
|
1114
|
+
caption: 0,
|
|
1115
|
+
video_clip: 1,
|
|
1116
|
+
speech: 2,
|
|
1117
|
+
bgm: 3
|
|
1118
|
+
};
|
|
1119
|
+
/**
|
|
1120
|
+
* Resolve only entity-owned placement facts. This does not choose new anchors,
|
|
1121
|
+
* manufacture clips, or mutate a graph while reading. Write-side operations
|
|
1122
|
+
* must express any detach/reparent/overlap decision in their entity plan.
|
|
1123
|
+
*/
|
|
1124
|
+
function resolveEntityTimelineLayout(rows) {
|
|
1125
|
+
return resolveGraphLayout(new EntityProjectionGraph(rows));
|
|
1126
|
+
}
|
|
1127
|
+
function resolveGraphLayout(graph) {
|
|
1128
|
+
const facts = /* @__PURE__ */ new Map();
|
|
1129
|
+
const seenRoles = /* @__PURE__ */ new Set();
|
|
1130
|
+
const tracks = graph.holds(graph.timeline.entityId).map((track) => {
|
|
1131
|
+
if (track.entityKind !== "track" || !Object.hasOwn(roleOrder$1, track.payload.role)) fail(`unsupported compatibility Track role on ${track.entityId}`);
|
|
1132
|
+
const { role } = track.payload;
|
|
1133
|
+
if (seenRoles.has(role)) fail(`The compatibility editor supports only one ${role} Track`);
|
|
1134
|
+
seenRoles.add(role);
|
|
1135
|
+
const typedRole = role;
|
|
1136
|
+
const order = roleOrder$1[typedRole];
|
|
1137
|
+
const trackFacts = graph.holds(track.entityId).map((clip, clipIndex) => {
|
|
1138
|
+
if (clip.entityKind !== "clip" || graph.heldBy(clip.entityId).length !== 1) fail(`Clip ${clip.entityId} must belong to exactly one Track`);
|
|
1139
|
+
const marker = graph.one(clip.entityId, "marker", "sequence-marker");
|
|
1140
|
+
const held = graph.holds(clip.entityId);
|
|
1141
|
+
if (held.length !== 1) fail(`Clip ${clip.entityId} must contain exactly one content entity`);
|
|
1142
|
+
const content = held[0];
|
|
1143
|
+
const expected = typedRole === "video_clip" ? ["image", "video"] : typedRole === "speech" ? ["voiceover-recording"] : typedRole === "caption" ? ["caption"] : ["audio"];
|
|
1144
|
+
if (!expected.includes(content.entityKind)) fail(`${typedRole} Track accepts ${expected.join(" or ")}, got ${content.entityKind}`);
|
|
1145
|
+
const inMs = milliseconds(marker.payload.inMs, "inMs");
|
|
1146
|
+
const outMs = milliseconds(marker.payload.outMs, "outMs");
|
|
1147
|
+
const rate = marker.payload.rate === void 0 ? 1 : positive(marker.payload.rate, "rate");
|
|
1148
|
+
const duration = positive(Math.round((outMs - inMs) / rate), "duration");
|
|
1149
|
+
if (!Number.isSafeInteger(duration)) fail("Compatibility duration must be whole milliseconds");
|
|
1150
|
+
const fillTimeline = marker.payload.fillTimeline === true;
|
|
1151
|
+
if (fillTimeline && typedRole !== "bgm") fail("fillTimeline is only supported for timeline-filling background Audio");
|
|
1152
|
+
const anchors = graph.rows.relations.filter((row) => row.relationKind === "clip-anchor" && row.endpoint0EntityId === clip.entityId);
|
|
1153
|
+
if (anchors.length > 1) fail(`Clip ${clip.entityId} has multiple anchors`);
|
|
1154
|
+
const startMs = marker.payload.startMs === void 0 ? void 0 : milliseconds(marker.payload.startMs, "startMs");
|
|
1155
|
+
const anchorId = anchors[0]?.endpoint1EntityId;
|
|
1156
|
+
let mode;
|
|
1157
|
+
let absoluteStart;
|
|
1158
|
+
let anchorOffset;
|
|
1159
|
+
if (anchorId !== void 0) {
|
|
1160
|
+
if (startMs === void 0) fail(`Anchored Marker ${marker.entityId} requires startMs inside its host`);
|
|
1161
|
+
mode = "anchored";
|
|
1162
|
+
anchorOffset = startMs;
|
|
1163
|
+
} else if (startMs !== void 0) {
|
|
1164
|
+
mode = "absolute";
|
|
1165
|
+
absoluteStart = startMs;
|
|
1166
|
+
} else mode = "sequential";
|
|
1167
|
+
const fact = {
|
|
1168
|
+
clip,
|
|
1169
|
+
marker,
|
|
1170
|
+
content,
|
|
1171
|
+
trackId: track.entityId,
|
|
1172
|
+
role: typedRole,
|
|
1173
|
+
duration,
|
|
1174
|
+
mode,
|
|
1175
|
+
order: clipIndex,
|
|
1176
|
+
absoluteStart,
|
|
1177
|
+
anchorId,
|
|
1178
|
+
anchorOffset,
|
|
1179
|
+
fillTimeline
|
|
1180
|
+
};
|
|
1181
|
+
facts.set(clip.entityId, fact);
|
|
1182
|
+
return fact;
|
|
1183
|
+
});
|
|
1184
|
+
if (typedRole === "bgm" && trackFacts.length > 1) fail("The compatibility editor supports only one background Audio Clip");
|
|
1185
|
+
const sequential = trackFacts.filter((fact) => fact.mode === "sequential");
|
|
1186
|
+
sequential.forEach((fact, index) => {
|
|
1187
|
+
fact.predecessor = sequential[index - 1]?.clip.entityId;
|
|
1188
|
+
});
|
|
1189
|
+
return {
|
|
1190
|
+
entityId: String(track.entityId),
|
|
1191
|
+
role: typedRole,
|
|
1192
|
+
order,
|
|
1193
|
+
hidden: track.payload.hidden === true,
|
|
1194
|
+
trackFacts
|
|
1195
|
+
};
|
|
1196
|
+
});
|
|
1197
|
+
tracks.sort((left, right) => left.order - right.order || left.entityId.localeCompare(right.entityId));
|
|
1198
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
1199
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
1200
|
+
const resolve = (id) => {
|
|
1201
|
+
const cached = resolved.get(id);
|
|
1202
|
+
if (cached !== void 0) return cached;
|
|
1203
|
+
if (visiting.has(id)) fail(`Cyclic Clip placement dependency at ${id}`);
|
|
1204
|
+
visiting.add(id);
|
|
1205
|
+
const fact = facts.get(id);
|
|
1206
|
+
if (fact === void 0) fail(`Placement refers to unplaced Clip ${id}`);
|
|
1207
|
+
let start = fact.absoluteStart ?? 0;
|
|
1208
|
+
if (fact.mode === "anchored") {
|
|
1209
|
+
const host = resolve(fact.anchorId);
|
|
1210
|
+
if (fact.role === "speech" && !["video_clip", "bgm"].includes(facts.get(host.clipEntityId).role)) fail("An anchored voiceover must use a visual or BGM Clip as its host");
|
|
1211
|
+
start = host.start + fact.anchorOffset;
|
|
1212
|
+
} else if (fact.predecessor !== void 0) {
|
|
1213
|
+
const prior = resolve(fact.predecessor);
|
|
1214
|
+
start = prior.start + prior.duration;
|
|
1215
|
+
}
|
|
1216
|
+
milliseconds(start + fact.duration, "Clip end");
|
|
1217
|
+
const result = {
|
|
1218
|
+
clipEntityId: id,
|
|
1219
|
+
markerEntityId: fact.marker.entityId,
|
|
1220
|
+
contentEntityId: fact.content.entityId,
|
|
1221
|
+
trackEntityId: fact.trackId,
|
|
1222
|
+
start: milliseconds(start, "Clip start"),
|
|
1223
|
+
duration: fact.duration,
|
|
1224
|
+
mode: fact.mode,
|
|
1225
|
+
...fact.anchorId === void 0 ? {} : {
|
|
1226
|
+
anchorClipEntityId: fact.anchorId,
|
|
1227
|
+
anchorOffset: fact.anchorOffset
|
|
1228
|
+
}
|
|
1229
|
+
};
|
|
1230
|
+
visiting.delete(id);
|
|
1231
|
+
resolved.set(id, result);
|
|
1232
|
+
return result;
|
|
1233
|
+
};
|
|
1234
|
+
for (const id of facts.keys()) resolve(id);
|
|
1235
|
+
const durationMs = Math.max(0, ...[...resolved.values()].filter((clip) => facts.get(clip.clipEntityId).role !== "bgm").map((clip) => clip.start + clip.duration));
|
|
1236
|
+
for (const [id, fact] of facts) if (fact.fillTimeline) {
|
|
1237
|
+
const clip = resolved.get(id);
|
|
1238
|
+
resolved.set(id, {
|
|
1239
|
+
...clip,
|
|
1240
|
+
duration: durationMs
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
return {
|
|
1244
|
+
durationMs,
|
|
1245
|
+
clips: resolved,
|
|
1246
|
+
tracks: tracks.map(({ trackFacts, ...track }) => ({
|
|
1247
|
+
...track,
|
|
1248
|
+
clips: trackFacts.map((fact) => resolved.get(fact.clip.entityId)).sort((left, right) => left.start - right.start || left.clipEntityId.localeCompare(right.clipEntityId))
|
|
1249
|
+
}))
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
//#endregion
|
|
1253
|
+
//#region src/legacy/projection/entity-projection.ts
|
|
1254
|
+
/**
|
|
1255
|
+
* One-way compatibility view over the Entity graph. A legacy part is never
|
|
1256
|
+
* consulted as an editing fact. Peer IDs appear only in this derived read
|
|
1257
|
+
* contract, reconstructed from relations; they are not stored in payloads.
|
|
1258
|
+
*/
|
|
1259
|
+
function projectEntityTimeline(rows) {
|
|
1260
|
+
const graph = new EntityProjectionGraph(rows);
|
|
1261
|
+
const layout = resolveGraphLayout(graph);
|
|
1262
|
+
for (const track of layout.tracks) {
|
|
1263
|
+
if (track.role !== "video_clip") continue;
|
|
1264
|
+
for (let index = 1; index < track.clips.length; index += 1) {
|
|
1265
|
+
const previous = track.clips[index - 1];
|
|
1266
|
+
if (track.clips[index].start < previous.start + previous.duration) fail("Overlapping visual Clips require an explicit overlay adapter; the current editor supports one visual lane");
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
const parts = {};
|
|
1270
|
+
for (const track of layout.tracks) for (const placement of track.clips) {
|
|
1271
|
+
const clip = graph.entity(placement.clipEntityId, "clip");
|
|
1272
|
+
const marker = graph.entity(placement.markerEntityId, "sequence-marker");
|
|
1273
|
+
const volume = volumeSchema.safeParse(clip.payload.volume ?? 0);
|
|
1274
|
+
if (!volume.success) fail("Clip volume must be decibels between -60 and 20");
|
|
1275
|
+
if (track.role === "video_clip") {
|
|
1276
|
+
const content = graph.entity(placement.contentEntityId);
|
|
1277
|
+
const asset = mediaAsset(graph, content);
|
|
1278
|
+
const source = markerWindow$1(marker);
|
|
1279
|
+
const external = externalLocator(asset, "memota");
|
|
1280
|
+
const speed = videoSpeed(content, marker, placement);
|
|
1281
|
+
parts[clip.entityId] = { video_clip: {
|
|
1282
|
+
id: clip.entityId,
|
|
1283
|
+
kind: "video_clip",
|
|
1284
|
+
duration_ms: placement.duration,
|
|
1285
|
+
origin_media_id: external.key,
|
|
1286
|
+
play_in: content.entityKind === "image" && speed === void 0 ? 0 : source.start,
|
|
1287
|
+
play_out: content.entityKind === "image" && speed === void 0 ? placement.duration : source.end,
|
|
1288
|
+
volume: volume.data,
|
|
1289
|
+
...speed === void 0 ? {} : { speed_shift: speed }
|
|
1290
|
+
} };
|
|
1291
|
+
} else if (track.role === "speech") {
|
|
1292
|
+
const content = graph.entity(placement.contentEntityId, "voiceover-recording");
|
|
1293
|
+
const asset = mediaAsset(graph, content);
|
|
1294
|
+
requireUntrimmedAudio(content, marker, placement);
|
|
1295
|
+
const external = externalLocator(asset, "memota-speech");
|
|
1296
|
+
const renderEdges = graph.madeFrom(content.entityId, "derived-from");
|
|
1297
|
+
const timbreEdges = graph.adjacent(content.entityId, "voice-timbre");
|
|
1298
|
+
const spokenText = renderEdges.length > 0 || timbreEdges.length > 0 ? assembleScriptText(assembleEntityContent(rows, graph.one(content.entityId, "derived-from", "phonetic-script", renderEdges).entityId)) : void 0;
|
|
1299
|
+
if (timbreEdges.length > 1) fail("A voiceover resolves at most one Voice identity");
|
|
1300
|
+
const transcripts = graph.madeInto(content.entityId, "derived-from");
|
|
1301
|
+
const voice = timbreEdges.length === 0 ? void 0 : graph.one(content.entityId, "voice-timbre", "voice").payload.voice;
|
|
1302
|
+
const captionIds = layout.tracks.flatMap((candidate) => candidate.role === "caption" ? candidate.clips.filter((caption) => caption.anchorClipEntityId === clip.entityId).map((caption) => caption.clipEntityId) : []);
|
|
1303
|
+
parts[clip.entityId] = { speech: {
|
|
1304
|
+
id: clip.entityId,
|
|
1305
|
+
kind: "speech",
|
|
1306
|
+
duration_ms: placement.duration,
|
|
1307
|
+
audio_storage_key: storageKey(asset),
|
|
1308
|
+
origin_speech_id: external.key,
|
|
1309
|
+
audio_script: spokenText ?? (transcripts.length > 0 ? assembleScriptText(graph.one(content.entityId, "derived-from", "audio-script", transcripts)) : ""),
|
|
1310
|
+
volume: volume.data,
|
|
1311
|
+
voice: voice === void 0 ? void 0 : {
|
|
1312
|
+
id: voice.key,
|
|
1313
|
+
name: voice.name
|
|
1314
|
+
},
|
|
1315
|
+
caption_ids: captionIds
|
|
1316
|
+
} };
|
|
1317
|
+
} else if (track.role === "caption") {
|
|
1318
|
+
const caption = assembleEntityContent(rows, graph.entity(placement.contentEntityId).entityId, "caption");
|
|
1319
|
+
const segment = selectedSegment(caption);
|
|
1320
|
+
const text = segment.text;
|
|
1321
|
+
const cues = captionDisplayCues(caption, segment, marker, placement.duration);
|
|
1322
|
+
const style = captionStyle(caption.payload.style);
|
|
1323
|
+
const anchor = placement.anchorClipEntityId === void 0 ? void 0 : layout.clips.get(placement.anchorClipEntityId);
|
|
1324
|
+
const anchoredToSpeech = anchor !== void 0 && trackRoleOf(layout, anchor.trackEntityId) === "speech";
|
|
1325
|
+
parts[clip.entityId] = { caption: {
|
|
1326
|
+
id: clip.entityId,
|
|
1327
|
+
kind: "caption",
|
|
1328
|
+
text,
|
|
1329
|
+
...cues === void 0 ? {} : { display_cues: cues },
|
|
1330
|
+
speech_part_id: anchoredToSpeech ? placement.anchorClipEntityId : void 0,
|
|
1331
|
+
start_ms: anchoredToSpeech ? placement.anchorOffset : 0,
|
|
1332
|
+
duration_ms: placement.duration,
|
|
1333
|
+
...style === void 0 ? {} : { style }
|
|
1334
|
+
} };
|
|
1335
|
+
} else {
|
|
1336
|
+
const content = graph.entity(placement.contentEntityId, "audio");
|
|
1337
|
+
const asset = mediaAsset(graph, content);
|
|
1338
|
+
const external = externalLocator(asset, "memota");
|
|
1339
|
+
if (marker.payload.fillTimeline !== true) fail("Background Audio requires an explicit fillTimeline");
|
|
1340
|
+
if (marker.payload.rate !== void 0) fail("BGM rate is not supported by this reader");
|
|
1341
|
+
const source = markerWindow$1(marker);
|
|
1342
|
+
if (source.start !== 0 || content.payload.durationMs !== source.end) fail("Background Audio compatibility requires the complete source");
|
|
1343
|
+
parts[clip.entityId] = { bgm: {
|
|
1344
|
+
id: clip.entityId,
|
|
1345
|
+
kind: "bgm",
|
|
1346
|
+
duration_ms: placement.duration,
|
|
1347
|
+
audio_storage_key: storageKey(asset),
|
|
1348
|
+
origin_media_id: external.key,
|
|
1349
|
+
volume: volume.data
|
|
1350
|
+
} };
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
const tracks = layout.tracks.map((track) => ({
|
|
1354
|
+
id: track.entityId,
|
|
1355
|
+
parts_kind: track.role,
|
|
1356
|
+
is_hidden: track.hidden,
|
|
1357
|
+
items: track.clips.map((clip) => ({
|
|
1358
|
+
part_id: clip.clipEntityId,
|
|
1359
|
+
abs_time_position: clip.start
|
|
1360
|
+
}))
|
|
1361
|
+
}));
|
|
1362
|
+
const aggregations = /* @__PURE__ */ new Map();
|
|
1363
|
+
for (const track of layout.tracks) {
|
|
1364
|
+
if (track.role !== "speech") continue;
|
|
1365
|
+
for (const clip of track.clips) {
|
|
1366
|
+
if (clip.anchorClipEntityId === void 0) continue;
|
|
1367
|
+
const attachments = aggregations.get(clip.anchorClipEntityId) ?? [];
|
|
1368
|
+
attachments.push({
|
|
1369
|
+
part_id: clip.clipEntityId,
|
|
1370
|
+
relative_time_position: clip.anchorOffset
|
|
1371
|
+
});
|
|
1372
|
+
aggregations.set(clip.anchorClipEntityId, attachments);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return {
|
|
1376
|
+
timeline: {
|
|
1377
|
+
duration_ms: layout.durationMs,
|
|
1378
|
+
unit_time_ms: DEFAULT_UNIT_TIME_MS
|
|
1379
|
+
},
|
|
1380
|
+
main_track: tracks.find((track) => track.parts_kind === "video_clip"),
|
|
1381
|
+
above_main_tracks: tracks.filter((track) => track.parts_kind === "caption"),
|
|
1382
|
+
below_main_tracks: tracks.filter((track) => track.parts_kind === "speech" || track.parts_kind === "bgm"),
|
|
1383
|
+
part_aggregations: [...aggregations].map(([body_part_id, attachments]) => ({
|
|
1384
|
+
body_part_id,
|
|
1385
|
+
attachments
|
|
1386
|
+
})),
|
|
1387
|
+
part_library: parts
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
function trackRoleOf(layout, trackEntityId) {
|
|
1391
|
+
return layout.tracks.find((track) => track.entityId === trackEntityId)?.role;
|
|
1392
|
+
}
|
|
1393
|
+
/** The Asset a media entity was made from — its own, or its bases' — where the bytes are named alone. */
|
|
1394
|
+
function mediaAsset(graph, content) {
|
|
1395
|
+
const asset = assetOf(graph.rows, content.entityId);
|
|
1396
|
+
if (asset === void 0) fail(`entity ${content.entityId} requires a from-asset to an asset`);
|
|
1397
|
+
return graph.entity(asset.entityId, "asset");
|
|
1398
|
+
}
|
|
1399
|
+
function storageKey(asset) {
|
|
1400
|
+
const { storageKey } = asset.payload;
|
|
1401
|
+
if (storageKey === void 0 || storageKey.trim() === "") fail("Asset " + asset.entityId + " requires its factual storageKey");
|
|
1402
|
+
return storageKey;
|
|
1403
|
+
}
|
|
1404
|
+
function externalLocator(asset, system) {
|
|
1405
|
+
if (asset.payload.system !== system) fail("Asset " + asset.entityId + " requires an explicit " + system + " key");
|
|
1406
|
+
return { key: asset.payload.key };
|
|
1407
|
+
}
|
|
1408
|
+
function captionStyle(value) {
|
|
1409
|
+
if (value === void 0) return void 0;
|
|
1410
|
+
const { font, ...owned } = value;
|
|
1411
|
+
const fields = {
|
|
1412
|
+
fontSize: "font_size",
|
|
1413
|
+
fontColor: "font_color",
|
|
1414
|
+
fontWeight: "font_weight",
|
|
1415
|
+
entranceAnimation: "entrance_animation",
|
|
1416
|
+
entranceAnimationDurationMs: "entrance_animation_duration_ms",
|
|
1417
|
+
strokeColor: "stroke_color",
|
|
1418
|
+
strokeWidth: "stroke_width",
|
|
1419
|
+
positionX: "position_x",
|
|
1420
|
+
positionY: "position_y"
|
|
1421
|
+
};
|
|
1422
|
+
const style = {};
|
|
1423
|
+
for (const [key, field] of Object.entries(owned)) style[fields[key]] = field;
|
|
1424
|
+
return font === void 0 ? style : {
|
|
1425
|
+
...style,
|
|
1426
|
+
font_id: font.key
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
function requireUntrimmedAudio(content, marker, placement) {
|
|
1430
|
+
const source = markerWindow$1(marker);
|
|
1431
|
+
if (source.start !== 0 || content.payload.durationMs !== source.end || placement.duration !== source.end || marker.payload.rate !== void 0) fail("Voiceover compatibility requires the complete, unremapped take");
|
|
1432
|
+
}
|
|
1433
|
+
/** The Marker's window, in the {start,end} shape this compatibility reader speaks. */
|
|
1434
|
+
function markerWindow$1(marker) {
|
|
1435
|
+
return {
|
|
1436
|
+
start: marker.payload.inMs,
|
|
1437
|
+
end: marker.payload.outMs
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
function videoSpeed(content, marker, placement) {
|
|
1441
|
+
const { rate } = marker.payload;
|
|
1442
|
+
if (content.entityKind === "image" && rate === void 0) return void 0;
|
|
1443
|
+
const source = markerWindow$1(marker);
|
|
1444
|
+
if (rate !== void 0) {
|
|
1445
|
+
if (Math.round((source.end - source.start) / rate) !== placement.duration) fail("rate on " + marker.entityId + " must agree with Marker duration");
|
|
1446
|
+
return {
|
|
1447
|
+
category: "linear",
|
|
1448
|
+
mode: "constant",
|
|
1449
|
+
config: { linear: { speed: rate } }
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
const speed = (source.end - source.start) / placement.duration;
|
|
1453
|
+
return speed === 1 ? void 0 : {
|
|
1454
|
+
category: "linear",
|
|
1455
|
+
mode: "constant",
|
|
1456
|
+
config: { linear: { speed } }
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
/** Preserve selected text intervals without minting synthetic editable Clip identities. */
|
|
1460
|
+
function captionDisplayCues(caption, segment, marker, duration) {
|
|
1461
|
+
const ranges = caption.payload.segmentRanges;
|
|
1462
|
+
if (ranges === void 0) return void 0;
|
|
1463
|
+
const source = markerWindow$1(marker);
|
|
1464
|
+
const scale = duration / (source.end - source.start);
|
|
1465
|
+
return [segment].flatMap((segment) => {
|
|
1466
|
+
const timing = ranges.find((entry) => entry.segmentId === segment.segmentId);
|
|
1467
|
+
if (timing === void 0) fail(`Caption timing is missing for selected segment ${segment.segmentId}`);
|
|
1468
|
+
const intrinsic = range(timing.startMs, timing.endMs, "Caption segmentRange");
|
|
1469
|
+
const start = Math.max(source.start, intrinsic.start);
|
|
1470
|
+
const end = Math.min(source.end, intrinsic.end);
|
|
1471
|
+
if (end <= start) return [];
|
|
1472
|
+
const startMs = Math.round((start - source.start) * scale);
|
|
1473
|
+
const endMs = Math.round((end - source.start) * scale);
|
|
1474
|
+
return endMs > startMs ? [{
|
|
1475
|
+
segmentId: segment.segmentId,
|
|
1476
|
+
text: segment.text,
|
|
1477
|
+
startMs,
|
|
1478
|
+
durationMs: endMs - startMs
|
|
1479
|
+
}] : [];
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
//#endregion
|
|
1483
|
+
//#region src/entity-crdt/json-values.ts
|
|
1484
|
+
const nativeObjectConstructorSource = Function.prototype.toString.call(Object);
|
|
1485
|
+
/**
|
|
1486
|
+
* A plain object whose values are all JSON, however deep: finite numbers, no
|
|
1487
|
+
* class instances, no cycles. Objects built in a script's VM realm pass, since
|
|
1488
|
+
* their prototypes are that realm's `Object`, not this one's.
|
|
1489
|
+
*/
|
|
1490
|
+
function isJsonObject(value) {
|
|
1491
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && isJsonValue(value, /* @__PURE__ */ new Set());
|
|
1492
|
+
}
|
|
1493
|
+
function isJsonValue(value, ancestors) {
|
|
1494
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
1495
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
1496
|
+
if (typeof value !== "object") return false;
|
|
1497
|
+
if (!Array.isArray(value) && !isPlainObject(value)) return false;
|
|
1498
|
+
if (ancestors.has(value)) return false;
|
|
1499
|
+
ancestors.add(value);
|
|
1500
|
+
const valid = Array.isArray(value) ? value.every((item) => isJsonValue(item, ancestors)) : Object.values(value).every((item) => isJsonValue(item, ancestors));
|
|
1501
|
+
ancestors.delete(value);
|
|
1502
|
+
return valid;
|
|
1503
|
+
}
|
|
1504
|
+
/** Recognize an ordinary object from any VM realm without admitting class instances. */
|
|
1505
|
+
function isPlainObject(value) {
|
|
1506
|
+
try {
|
|
1507
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1508
|
+
if (prototype === null) return true;
|
|
1509
|
+
if (Object.getPrototypeOf(prototype) !== null) return false;
|
|
1510
|
+
const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor")?.value;
|
|
1511
|
+
return typeof constructor === "function" && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === nativeObjectConstructorSource;
|
|
1512
|
+
} catch {
|
|
1513
|
+
return false;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
//#endregion
|
|
1517
|
+
//#region src/entity-crdt/field-changes.ts
|
|
1518
|
+
/** Merge behaviour is declared with the field shapes in @mengine/medeo-dsl. */
|
|
1519
|
+
const merge = (key) => FIELD_MERGE[key];
|
|
1520
|
+
const atomicFields = { has: (key) => merge(key) === "atomic" };
|
|
1521
|
+
const textFields = { has: (key) => merge(key) === "text" };
|
|
1522
|
+
const orderedFields = { has: (key) => merge(key) === "list" };
|
|
1523
|
+
const unsafeKeys = new Set([
|
|
1524
|
+
"__proto__",
|
|
1525
|
+
"prototype",
|
|
1526
|
+
"constructor"
|
|
1527
|
+
]);
|
|
1528
|
+
function listElementId(value) {
|
|
1529
|
+
const id = typeof value === "string" ? value : isJsonObject(value) ? value.segmentId : void 0;
|
|
1530
|
+
if (typeof id !== "string" || !id.trim()) throw new Error("List elements require a nonempty segmentId or string identity");
|
|
1531
|
+
return id;
|
|
1532
|
+
}
|
|
1533
|
+
function assertFieldChanges(changes) {
|
|
1534
|
+
if (!Array.isArray(changes) || changes.length === 0) throw new Error("changes must be a nonempty array");
|
|
1535
|
+
for (const change of changes) {
|
|
1536
|
+
if (!isJsonObject(change) || ![
|
|
1537
|
+
"set",
|
|
1538
|
+
"unset",
|
|
1539
|
+
"text.splice",
|
|
1540
|
+
"list.insert",
|
|
1541
|
+
"list.move",
|
|
1542
|
+
"list.remove"
|
|
1543
|
+
].includes(change.op)) throw new Error("Unknown field operation");
|
|
1544
|
+
if (!Array.isArray(change.path) || !change.path.length || typeof change.path[0] !== "string") throw new Error("Field paths must start with a field name");
|
|
1545
|
+
for (const part of change.path) if (typeof part === "string") {
|
|
1546
|
+
if (!part || unsafeKeys.has(part)) throw new Error("Invalid field name");
|
|
1547
|
+
} else if (!isJsonObject(part) || Object.keys(part).length !== 1 || typeof part.elementId !== "string" || !part.elementId.trim()) throw new Error("List paths require { elementId }; numeric indices are not supported");
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
/** Resolve against the sequential causal view; the numeric path never crosses the API boundary. */
|
|
1551
|
+
function resolveFieldPath(payload, path) {
|
|
1552
|
+
let value = payload;
|
|
1553
|
+
const resolved = [];
|
|
1554
|
+
for (const [index, part] of path.entries()) if (typeof part === "string") {
|
|
1555
|
+
if (!isJsonObject(value)) throw new Error(`Field path does not reach a map: ${part}`);
|
|
1556
|
+
if (index < path.length - 1 && atomicFields.has(part)) throw new Error(`Atomic field ${part} must be changed as one value`);
|
|
1557
|
+
resolved.push(part);
|
|
1558
|
+
value = value[part];
|
|
1559
|
+
} else {
|
|
1560
|
+
if (!Array.isArray(value)) throw new Error("Element selector requires a list");
|
|
1561
|
+
const matches = value.flatMap((item, i) => listElementId(item) === part.elementId ? [i] : []);
|
|
1562
|
+
if (matches.length !== 1) throw new Error(`Missing or ambiguous list element ${part.elementId}`);
|
|
1563
|
+
resolved.push(matches[0]);
|
|
1564
|
+
value = value[matches[0]];
|
|
1565
|
+
}
|
|
1566
|
+
return resolved;
|
|
1567
|
+
}
|
|
1568
|
+
/** Detached plan evaluation. The Loro write replays the original operations, not this snapshot. */
|
|
1569
|
+
function applyFieldChanges(payload, changes) {
|
|
1570
|
+
assertFieldChanges(changes);
|
|
1571
|
+
const result = structuredClone(payload);
|
|
1572
|
+
for (const change of changes) {
|
|
1573
|
+
const path = resolveFieldPath(result, change.path);
|
|
1574
|
+
const key = path.at(-1);
|
|
1575
|
+
if (typeof key !== "string") throw new Error("Change fields inside a list element or use list operations");
|
|
1576
|
+
let parent = result;
|
|
1577
|
+
for (const part of path.slice(0, -1)) parent = parent[part];
|
|
1578
|
+
if (!isJsonObject(parent)) throw new Error("Field parent is not a map");
|
|
1579
|
+
const mutable = parent;
|
|
1580
|
+
const value = mutable[key];
|
|
1581
|
+
if ([
|
|
1582
|
+
"entityId",
|
|
1583
|
+
"entityKind",
|
|
1584
|
+
"entity_id",
|
|
1585
|
+
"entity_kind",
|
|
1586
|
+
"segmentId"
|
|
1587
|
+
].includes(key)) throw new Error(`Identity field ${key} cannot be edited`);
|
|
1588
|
+
switch (change.op) {
|
|
1589
|
+
case "set":
|
|
1590
|
+
if (textFields.has(key) || orderedFields.has(key) || !atomicFields.has(key) && (isJsonObject(value) || isJsonObject(change.value) || Array.isArray(value) || Array.isArray(change.value))) throw new Error(`Field ${key} requires a text, list, or leaf operation; snapshot replacement is not allowed`);
|
|
1591
|
+
if (change.value === void 0 || !isJsonObject({ value: change.value })) throw new Error("set requires a JSON value");
|
|
1592
|
+
if (value !== void 0 && value !== null && change.value !== null && typeof value !== typeof change.value) throw new Error(`Field ${key} cannot change type`);
|
|
1593
|
+
mutable[key] = structuredClone(change.value);
|
|
1594
|
+
break;
|
|
1595
|
+
case "unset":
|
|
1596
|
+
if (!Object.hasOwn(parent, key)) throw new Error(`Field ${key} is absent`);
|
|
1597
|
+
delete mutable[key];
|
|
1598
|
+
break;
|
|
1599
|
+
case "text.splice": {
|
|
1600
|
+
if (!textFields.has(key) || typeof value !== "string") throw new Error(`Field ${key} is not collaborative text`);
|
|
1601
|
+
const chars = Array.from(value);
|
|
1602
|
+
if (!Number.isSafeInteger(change.index) || !Number.isSafeInteger(change.deleteCount) || change.index < 0 || change.deleteCount < 0 || change.index + change.deleteCount > chars.length || typeof change.text !== "string") throw new Error("Invalid text splice range");
|
|
1603
|
+
chars.splice(change.index, change.deleteCount, ...Array.from(change.text));
|
|
1604
|
+
mutable[key] = chars.join("");
|
|
1605
|
+
break;
|
|
1606
|
+
}
|
|
1607
|
+
default: {
|
|
1608
|
+
if (!orderedFields.has(key) || !Array.isArray(value)) throw new Error(`Field ${key} is not a collaborative list`);
|
|
1609
|
+
const ids = value.map(listElementId);
|
|
1610
|
+
if (new Set(ids).size !== ids.length) throw new Error("Duplicate collaborative list identity");
|
|
1611
|
+
const requireIndex = (id) => {
|
|
1612
|
+
const index = ids.indexOf(id);
|
|
1613
|
+
if (index < 0) throw new Error(`Unknown list element ${id}`);
|
|
1614
|
+
return index;
|
|
1615
|
+
};
|
|
1616
|
+
if (change.op === "list.remove") value.splice(requireIndex(change.elementId), 1);
|
|
1617
|
+
else {
|
|
1618
|
+
if (change.beforeElementId !== null && typeof change.beforeElementId !== "string") throw new Error("beforeElementId must be an identity or null");
|
|
1619
|
+
const before = change.beforeElementId === null ? value.length : requireIndex(change.beforeElementId);
|
|
1620
|
+
if (change.op === "list.insert") {
|
|
1621
|
+
const id = listElementId(change.value);
|
|
1622
|
+
if (ids.includes(id)) throw new Error(`Duplicate list element ${id}`);
|
|
1623
|
+
value.splice(before, 0, structuredClone(change.value));
|
|
1624
|
+
} else {
|
|
1625
|
+
if (change.elementId === change.beforeElementId) throw new Error("Cannot move a list element before itself");
|
|
1626
|
+
const from = requireIndex(change.elementId);
|
|
1627
|
+
const [item] = value.splice(from, 1);
|
|
1628
|
+
value.splice(before > from ? before - 1 : before, 0, item);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
return result;
|
|
1635
|
+
}
|
|
1636
|
+
//#endregion
|
|
1637
|
+
//#region src/entity-crdt/json-containers.ts
|
|
1638
|
+
function equalJson(a, b) {
|
|
1639
|
+
return canonicalJson(a) === canonicalJson(b);
|
|
1640
|
+
}
|
|
1641
|
+
function canonicalJson(value) {
|
|
1642
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
1643
|
+
if (value !== null && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
1644
|
+
return JSON.stringify(value);
|
|
1645
|
+
}
|
|
1646
|
+
/** Write only the changed fields; untouched containers retain their operation identity. */
|
|
1647
|
+
function writeObject(map, before, after) {
|
|
1648
|
+
for (const key of Object.keys(before)) if (!Object.hasOwn(after, key)) map.delete(key);
|
|
1649
|
+
for (const [key, value] of Object.entries(after)) {
|
|
1650
|
+
if (equalJson(before[key], value)) continue;
|
|
1651
|
+
if (atomicFields.has(key)) map.set(key, value);
|
|
1652
|
+
else if (typeof value === "string" && textFields.has(key)) map.ensureMergeableText(key).update(value);
|
|
1653
|
+
else if (Array.isArray(value) && orderedFields.has(key)) writeList(map.ensureMergeableMovableList(key), value);
|
|
1654
|
+
else if (isJsonObject(value)) writeObject(map.ensureMergeableMap(key), isJsonObject(before[key]) ? before[key] : {}, value);
|
|
1655
|
+
else map.set(key, value);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
function readItem(list, index) {
|
|
1659
|
+
const value = list.get(index);
|
|
1660
|
+
return value instanceof LoroMap ? value.toJSON() : value;
|
|
1661
|
+
}
|
|
1662
|
+
function writeList(list, values) {
|
|
1663
|
+
const wanted = values.map(listElementId);
|
|
1664
|
+
if (new Set(wanted).size !== wanted.length) throw new Error("Duplicate collaborative list identity");
|
|
1665
|
+
for (let index = list.length - 1; index >= 0; index--) if (!wanted.includes(listElementId(readItem(list, index)))) list.delete(index, 1);
|
|
1666
|
+
for (let index = 0; index < values.length; index++) {
|
|
1667
|
+
const value = values[index];
|
|
1668
|
+
const key = wanted[index];
|
|
1669
|
+
let found = -1;
|
|
1670
|
+
for (let cursor = index; cursor < list.length; cursor++) if (listElementId(readItem(list, cursor)) === key) {
|
|
1671
|
+
found = cursor;
|
|
1672
|
+
break;
|
|
1673
|
+
}
|
|
1674
|
+
if (found === -1) if (isJsonObject(value)) writeObject(list.insertContainer(index, new LoroMap()), {}, value);
|
|
1675
|
+
else list.insert(index, value);
|
|
1676
|
+
else {
|
|
1677
|
+
if (found !== index) list.move(found, index);
|
|
1678
|
+
const current = list.get(index);
|
|
1679
|
+
if (isJsonObject(value)) {
|
|
1680
|
+
if (!(current instanceof LoroMap)) throw new Error("Collaborative list item changed type");
|
|
1681
|
+
writeObject(current, current.toJSON(), value);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
function requireText(map, path) {
|
|
1687
|
+
let value = map;
|
|
1688
|
+
for (const part of path) if (value instanceof LoroMap && typeof part === "string") value = value.get(part);
|
|
1689
|
+
else if (value instanceof LoroMovableList && typeof part === "number") value = value.get(part);
|
|
1690
|
+
else throw new Error("Invalid collaborative text path");
|
|
1691
|
+
if (!(value instanceof LoroText)) throw new Error("Field is not collaborative text");
|
|
1692
|
+
return value;
|
|
1693
|
+
}
|
|
1694
|
+
/** Apply the authored operation directly, preserving text positions and moved item identity. */
|
|
1695
|
+
function applyNativeFieldChange(map, path, change) {
|
|
1696
|
+
let parent = map;
|
|
1697
|
+
for (const part of path.slice(0, -1)) if (parent instanceof LoroMap && typeof part === "string") parent = parent.get(part);
|
|
1698
|
+
else if (parent instanceof LoroMovableList && typeof part === "number") parent = parent.get(part);
|
|
1699
|
+
else throw new Error("Invalid native field path");
|
|
1700
|
+
const key = path.at(-1);
|
|
1701
|
+
if (!(parent instanceof LoroMap) || typeof key !== "string") throw new Error("Invalid native field parent");
|
|
1702
|
+
if (change.op === "set") {
|
|
1703
|
+
parent.set(key, change.value);
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
if (change.op === "unset") {
|
|
1707
|
+
parent.delete(key);
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
const value = parent.get(key);
|
|
1711
|
+
if (change.op === "text.splice") {
|
|
1712
|
+
if (!(value instanceof LoroText)) throw new Error("Field is not collaborative text");
|
|
1713
|
+
const start = value.convertPos(change.index, "unicode", "utf16");
|
|
1714
|
+
const end = value.convertPos(change.index + change.deleteCount, "unicode", "utf16");
|
|
1715
|
+
value.splice(start, end - start, change.text);
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
if (!(value instanceof LoroMovableList)) throw new Error("Field is not a collaborative list");
|
|
1719
|
+
applyNativeListChange(value, change);
|
|
1720
|
+
}
|
|
1721
|
+
function applyNativeListChange(list, change) {
|
|
1722
|
+
const ids = Array.from({ length: list.length }, (_, i) => listElementId(readItem(list, i)));
|
|
1723
|
+
const index = (id) => {
|
|
1724
|
+
const found = ids.indexOf(id);
|
|
1725
|
+
if (found < 0) throw new Error(`Unknown list element ${id}`);
|
|
1726
|
+
return found;
|
|
1727
|
+
};
|
|
1728
|
+
if (change.op === "list.remove") {
|
|
1729
|
+
list.delete(index(change.elementId), 1);
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
const before = change.beforeElementId === null ? list.length : index(change.beforeElementId);
|
|
1733
|
+
if (change.op === "list.insert") if (isJsonObject(change.value)) writeObject(list.insertContainer(before, new LoroMap()), {}, change.value);
|
|
1734
|
+
else list.insert(before, change.value);
|
|
1735
|
+
else {
|
|
1736
|
+
const from = index(change.elementId);
|
|
1737
|
+
const to = before > from ? before - 1 : before;
|
|
1738
|
+
if (from !== to) list.move(from, to);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
//#endregion
|
|
1742
|
+
//#region src/entity-crdt/ordering.ts
|
|
1743
|
+
/**
|
|
1744
|
+
* Domain order is scoped by membership: the members of one container, in the
|
|
1745
|
+
* order their `contain` Relations sit in the rows.
|
|
1746
|
+
*
|
|
1747
|
+
* Nothing carries a copy of its own position, so this reads the order rather
|
|
1748
|
+
* than deriving it — the rows are already in it.
|
|
1749
|
+
*/
|
|
1750
|
+
function entityOrderGroups(rows) {
|
|
1751
|
+
const entities = new Map(rows.entities.map((row) => [String(row.entityId), row]));
|
|
1752
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1753
|
+
for (const relation of rows.relations) {
|
|
1754
|
+
if (!isSequencedRelationKind(relation.relationKind)) continue;
|
|
1755
|
+
const childKind = entities.get(String(relation.endpoint1EntityId))?.entityKind;
|
|
1756
|
+
if (childKind !== "track" && childKind !== "clip") continue;
|
|
1757
|
+
const parent = String(relation.endpoint0EntityId);
|
|
1758
|
+
groups.set(parent, [...groups.get(parent) ?? [], String(relation.endpoint1EntityId)]);
|
|
1759
|
+
}
|
|
1760
|
+
return groups;
|
|
1761
|
+
}
|
|
1762
|
+
function ordering(doc) {
|
|
1763
|
+
const map = doc.getMap("medeo").get("ordering");
|
|
1764
|
+
if (!(map instanceof LoroMap)) throw new Error("Missing native entity ordering");
|
|
1765
|
+
return map;
|
|
1766
|
+
}
|
|
1767
|
+
/** Returns a derived ordinal while the stable element retains its native move identity. */
|
|
1768
|
+
function entityOrdinal(doc, root) {
|
|
1769
|
+
let found;
|
|
1770
|
+
for (const key of ordering(doc).keys()) {
|
|
1771
|
+
const list = ordering(doc).get(key);
|
|
1772
|
+
if (!(list instanceof LoroMovableList)) throw new Error("Invalid entity order container");
|
|
1773
|
+
const index = list.toArray().indexOf(root);
|
|
1774
|
+
if (index < 0) continue;
|
|
1775
|
+
if (found !== void 0) throw new Error("Entity belongs to multiple ordered groups");
|
|
1776
|
+
found = index;
|
|
1777
|
+
}
|
|
1778
|
+
return found;
|
|
1779
|
+
}
|
|
1780
|
+
/** Move existing elements, insert new identities and delete removed members on the causal list. */
|
|
1781
|
+
function writeEntityOrder(doc, group, roots, token) {
|
|
1782
|
+
if (new Set(roots).size !== roots.length) throw new Error("Duplicate ordered entity identity");
|
|
1783
|
+
const list = ordering(doc).ensureMergeableMovableList(group);
|
|
1784
|
+
const before = list.toArray();
|
|
1785
|
+
for (let index = list.length - 1; index >= 0; index--) if (!roots.includes(String(list.get(index)))) list.delete(index, 1);
|
|
1786
|
+
roots.forEach((root, index) => {
|
|
1787
|
+
const current = list.toArray().indexOf(root);
|
|
1788
|
+
if (current < 0) list.insert(index, root);
|
|
1789
|
+
else if (current !== index) list.move(current, index);
|
|
1790
|
+
});
|
|
1791
|
+
if (token) {
|
|
1792
|
+
const editing = doc.getMap("medeo").get("editing");
|
|
1793
|
+
for (const root of new Set([...before, ...roots])) {
|
|
1794
|
+
if (before.indexOf(root) < 0 || before.indexOf(root) === roots.indexOf(root)) continue;
|
|
1795
|
+
editing.get(root).get("changes").set(token, true);
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
//#endregion
|
|
1800
|
+
//#region src/entity-crdt/apply.ts
|
|
1801
|
+
/**
|
|
1802
|
+
* Apply rows to a document as native Loro operations, returning the update.
|
|
1803
|
+
* On a live peer the edit joins its local undo history; an edit made on an
|
|
1804
|
+
* earlier baseline is applied to `LoroEntityDocument.fromSnapshot` of it.
|
|
1805
|
+
*/
|
|
1806
|
+
function apply(editor, rows) {
|
|
1807
|
+
const baseline = editor.snapshot();
|
|
1808
|
+
const before = baseline.rows;
|
|
1809
|
+
const oldEntities = new Map(before.entities.map((row) => [row.entityId, row]));
|
|
1810
|
+
const newEntities = new Map(rows.entities.map((row) => [row.entityId, row]));
|
|
1811
|
+
const oldRelations = new Map(before.relations.map((row) => [row.relationId, row]));
|
|
1812
|
+
const newRelations = new Map(rows.relations.map((row) => [row.relationId, row]));
|
|
1813
|
+
if (newEntities.size !== rows.entities.length || newRelations.size !== rows.relations.length) throw new Error("Duplicate domain identity");
|
|
1814
|
+
return {
|
|
1815
|
+
update: editor.transact((draft) => {
|
|
1816
|
+
for (const row of before.relations) {
|
|
1817
|
+
const next = newRelations.get(row.relationId);
|
|
1818
|
+
if (!next) draft.unlink(row.relationId);
|
|
1819
|
+
else if (next.relationKind !== row.relationKind || next.endpoint0EntityId !== row.endpoint0EntityId || next.endpoint1EntityId !== row.endpoint1EntityId) throw new Error("Relation identity cannot change kind or endpoint positions");
|
|
1820
|
+
}
|
|
1821
|
+
let attached = baseline.project.audioScriptEntityId !== void 0;
|
|
1822
|
+
for (const row of rows.entities) {
|
|
1823
|
+
const previous = oldEntities.get(row.entityId);
|
|
1824
|
+
if (!previous) {
|
|
1825
|
+
draft.create(row);
|
|
1826
|
+
if (!attached && row.entityKind === "audio-script") {
|
|
1827
|
+
draft.attach("audioScriptEntityId", String(row.entityId));
|
|
1828
|
+
attached = true;
|
|
1829
|
+
}
|
|
1830
|
+
} else {
|
|
1831
|
+
if (previous.entityKind !== row.entityKind) throw new Error("Entity kind cannot change");
|
|
1832
|
+
if (!equalJson(previous.payload, row.payload)) draft.replaceOwned(row.entityId, row.payload);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
for (const row of rows.relations) if (!oldRelations.has(row.relationId)) draft.link(row);
|
|
1836
|
+
const beforeOrder = entityOrderGroups(before), afterOrder = entityOrderGroups(rows);
|
|
1837
|
+
for (const group of new Set([...beforeOrder.keys(), ...afterOrder.keys()])) {
|
|
1838
|
+
const next = afterOrder.get(group) ?? [];
|
|
1839
|
+
if (!equalJson(beforeOrder.get(group) ?? [], next)) draft.order(group, next);
|
|
1840
|
+
}
|
|
1841
|
+
for (const row of before.entities) if (!newEntities.has(row.entityId)) draft.delete(row.entityId);
|
|
1842
|
+
}),
|
|
1843
|
+
state: editor.snapshot()
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
//#endregion
|
|
1847
|
+
//#region src/entity-crdt/audio-script-assets.ts
|
|
1848
|
+
/** A segment as a resource file writes it. */
|
|
1849
|
+
function segmentJson({ segmentId, text, language }) {
|
|
1850
|
+
return {
|
|
1851
|
+
segmentId,
|
|
1852
|
+
text,
|
|
1853
|
+
...language === void 0 ? {} : { language }
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
/** The bytes a resource is stored as: its JSON text, exactly as uploaded. */
|
|
1857
|
+
function assetBytes(content) {
|
|
1858
|
+
return new TextEncoder().encode(JSON.stringify(content));
|
|
1859
|
+
}
|
|
1860
|
+
/** The fingerprint of those bytes: `sha256:` and the hex of their SHA-256. */
|
|
1861
|
+
function assetDigest(content) {
|
|
1862
|
+
return `sha256:${bytesToHex(sha256(assetBytes(content)))}`;
|
|
1863
|
+
}
|
|
1864
|
+
/** A resource is a snapshot of text; the native Loro text remains the editing authority. */
|
|
1865
|
+
function audioScriptAssetContent(rows, entityId) {
|
|
1866
|
+
return { segments: segmentsOf(assembleEntityContent(rows, createEntityId(entityId), "audio-script")).map(segmentJson) };
|
|
1867
|
+
}
|
|
1868
|
+
/** What a script's snapshot holds, which is a different document for each kind of script. */
|
|
1869
|
+
function scriptAssetContent(rows, entityId) {
|
|
1870
|
+
const kind = rows.entities.find((row) => String(row.entityId) === entityId)?.entityKind;
|
|
1871
|
+
if (kind === "audio-script") return audioScriptAssetContent(rows, entityId);
|
|
1872
|
+
if (kind === "phonetic-script") return phoneticScriptAssetContent(rows, entityId);
|
|
1873
|
+
throw new Error(`Entity ${entityId} is a ${String(kind)}, which writes out no snapshot`);
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* Whether a script's snapshot still holds its content: the Asset's digest is
|
|
1877
|
+
* that of what the script would be saved as now.
|
|
1878
|
+
*
|
|
1879
|
+
* Only a `writable` Asset can be judged this way, because only for bytes this
|
|
1880
|
+
* document wrote is the digest one it can work out again. Anything else holds a
|
|
1881
|
+
* store's bytes and is left alone; a writable one with no digest cannot say
|
|
1882
|
+
* what it holds, so the content is written out again.
|
|
1883
|
+
*/
|
|
1884
|
+
function scriptSnapshotIsCurrent(rows, entityId) {
|
|
1885
|
+
const snapshot = scriptSnapshotAssetOf(rows, entityId)?.payload;
|
|
1886
|
+
if (snapshot === void 0) return false;
|
|
1887
|
+
if (snapshot.writable !== true) return true;
|
|
1888
|
+
return snapshot.digest !== void 0 && snapshot.digest === assetDigest(scriptAssetContent(rows, entityId));
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* The Asset a script was written out to. It is a snapshot the script owns, so
|
|
1892
|
+
* it is reached by `to-asset` — not `from-asset`, which would say the script
|
|
1893
|
+
* was produced from bytes it cannot change.
|
|
1894
|
+
*/
|
|
1895
|
+
function scriptSnapshotAssetOf(rows, entityId) {
|
|
1896
|
+
return boundAsset(rows, entityId, "to-asset");
|
|
1897
|
+
}
|
|
1898
|
+
/**
|
|
1899
|
+
* The Asset a script was made from — a transcript it does not own. A script
|
|
1900
|
+
* that has one never writes a snapshot of its own: the bytes it would describe
|
|
1901
|
+
* are already named, and rewriting them would contradict their source.
|
|
1902
|
+
*/
|
|
1903
|
+
function scriptSourceAssetOf(rows, entityId) {
|
|
1904
|
+
return boundAsset(rows, entityId, "from-asset");
|
|
1905
|
+
}
|
|
1906
|
+
function boundAsset(rows, entityId, relationKind) {
|
|
1907
|
+
const link = rows.relations.find((relation) => relation.relationKind === relationKind && (String(relation.endpoint0EntityId) === entityId || String(relation.endpoint1EntityId) === entityId));
|
|
1908
|
+
if (link === void 0) return void 0;
|
|
1909
|
+
const otherId = String(link.endpoint0EntityId) === entityId ? link.endpoint1EntityId : link.endpoint0EntityId;
|
|
1910
|
+
return rows.entities.find((row) => String(row.entityId) === String(otherId) && row.entityKind === "asset");
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* What a PhoneticScript hands to synthesis: the base AudioScript's text as the
|
|
1914
|
+
* variant pronounces it, plus the variant's own phoneme control.
|
|
1915
|
+
*
|
|
1916
|
+
* The variant's content is written by a model — deciding how words are said is
|
|
1917
|
+
* text work, not a mechanical copy — but the bytes a synthesizer reads have to
|
|
1918
|
+
* leave the entity graph to reach it, because synthesis does not handle
|
|
1919
|
+
* entities. Writing this resource down is that crossing, and it is why speech
|
|
1920
|
+
* can only come from a PhoneticScript.
|
|
1921
|
+
*/
|
|
1922
|
+
function phoneticScriptAssetContent(rows, entityId) {
|
|
1923
|
+
const phonetic = assembleEntityContent(rows, createEntityId(entityId), "phonetic-script");
|
|
1924
|
+
const { phonemeScript } = phonetic.payload;
|
|
1925
|
+
return {
|
|
1926
|
+
text: assembleScriptText(phonetic),
|
|
1927
|
+
segments: segmentsOf(phonetic).map(segmentJson),
|
|
1928
|
+
...phonemeScript === void 0 ? {} : { phonemeScript }
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
//#endregion
|
|
1932
|
+
//#region src/entity-crdt/entity-document.ts
|
|
1933
|
+
const LORO_ENTITY_SCHEMA = "medeo.entities.loro.v1";
|
|
1934
|
+
const MATERIALIZE_ORIGIN = "medeo.materialize";
|
|
1935
|
+
function hash(value) {
|
|
1936
|
+
return bytesToHex(sha256(new TextEncoder().encode(canonicalJson(value))));
|
|
1937
|
+
}
|
|
1938
|
+
function clone(value) {
|
|
1939
|
+
return structuredClone(value);
|
|
1940
|
+
}
|
|
1941
|
+
function root(doc) {
|
|
1942
|
+
return doc.getMap("medeo");
|
|
1943
|
+
}
|
|
1944
|
+
function requireMap(parent, key) {
|
|
1945
|
+
const value = parent.get(key);
|
|
1946
|
+
if (!(value instanceof LoroMap)) throw new Error(`Missing Loro map ${key}`);
|
|
1947
|
+
return value;
|
|
1948
|
+
}
|
|
1949
|
+
function section(doc, key) {
|
|
1950
|
+
return requireMap(root(doc), key);
|
|
1951
|
+
}
|
|
1952
|
+
function workspace(doc, id) {
|
|
1953
|
+
const value = section(doc, "editing").get(id);
|
|
1954
|
+
if (!(value instanceof LoroMap)) throw new Error(`Unknown entity editing identity ${id}`);
|
|
1955
|
+
return value;
|
|
1956
|
+
}
|
|
1957
|
+
function content(map) {
|
|
1958
|
+
return requireMap(map, "content").toJSON();
|
|
1959
|
+
}
|
|
1960
|
+
function entityContent(doc, key) {
|
|
1961
|
+
return content(workspace(doc, key));
|
|
1962
|
+
}
|
|
1963
|
+
function bases(map) {
|
|
1964
|
+
const links = requireMap(map, "bases");
|
|
1965
|
+
return links.keys().sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map((key) => String(links.get(key)));
|
|
1966
|
+
}
|
|
1967
|
+
/** The JSON tree the document keeps a row's fields as. */
|
|
1968
|
+
function tree(payload) {
|
|
1969
|
+
return payload;
|
|
1970
|
+
}
|
|
1971
|
+
/** A row read back from the document. Every state it holds compiled, so its fields are its kind's. */
|
|
1972
|
+
function entityRow(entityId, entityKind, payload) {
|
|
1973
|
+
return {
|
|
1974
|
+
entityId: createEntityId(entityId),
|
|
1975
|
+
entityKind,
|
|
1976
|
+
payload
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
function owned(payload) {
|
|
1980
|
+
const { bases: _bases, ...fields } = payload;
|
|
1981
|
+
return fields;
|
|
1982
|
+
}
|
|
1983
|
+
function baseIds(payload) {
|
|
1984
|
+
const ids = payload.bases ?? [];
|
|
1985
|
+
if (!Array.isArray(ids) || !ids.every((id) => typeof id === "string") || new Set(ids).size !== ids.length) throw new Error("bases must contain unique entity IDs");
|
|
1986
|
+
return ids;
|
|
1987
|
+
}
|
|
1988
|
+
function family(doc, id) {
|
|
1989
|
+
const value = section(doc, "origins").get(id);
|
|
1990
|
+
if (typeof value === "string") return value;
|
|
1991
|
+
for (const [key, value] of currentIds(doc)) if (value === id) return key;
|
|
1992
|
+
throw new Error(`Unknown entity ${id}`);
|
|
1993
|
+
}
|
|
1994
|
+
function currentIds(doc) {
|
|
1995
|
+
const result = /* @__PURE__ */ new Map();
|
|
1996
|
+
for (const key of section(doc, "editing").keys().sort((a, b) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
1997
|
+
const map = workspace(doc, key);
|
|
1998
|
+
if (map.get("deleted") === true) continue;
|
|
1999
|
+
const tokens = requireMap(map, "changes").keys().sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
2000
|
+
const initial = section(doc, "entities").get(key);
|
|
2001
|
+
const { id: _id, kind: _kind, ...initialPayload } = isJsonObject(initial) ? initial : {};
|
|
2002
|
+
const payload = entityContent(doc, key);
|
|
2003
|
+
const changed = tokens.length > 0 || initial !== void 0 && !equalJson(owned(initialPayload), payload);
|
|
2004
|
+
result.set(key, changed ? `entity_${hash([
|
|
2005
|
+
key,
|
|
2006
|
+
tokens,
|
|
2007
|
+
payload
|
|
2008
|
+
])}` : key);
|
|
2009
|
+
}
|
|
2010
|
+
return result;
|
|
2011
|
+
}
|
|
2012
|
+
function currentId(doc, id, ids = currentIds(doc)) {
|
|
2013
|
+
const resolved = ids.get(family(doc, id));
|
|
2014
|
+
if (resolved === void 0) throw new Error(`Referenced entity ${id} was deleted`);
|
|
2015
|
+
return resolved;
|
|
2016
|
+
}
|
|
2017
|
+
/** Plain domain objects assembled from native Loro state, never a second writable authority. */
|
|
2018
|
+
function readEntityDocument(doc, allowSelectionConflicts = false) {
|
|
2019
|
+
if (["dsl", "mixed"].includes(documentFormat(doc))) throw new UnsupportedDocumentFormatError();
|
|
2020
|
+
if (root(doc).get("schema") !== "medeo.entities.loro.v1") throw new Error("Document has no Loro entity state");
|
|
2021
|
+
const ids = currentIds(doc);
|
|
2022
|
+
const entities = [...ids].map(([key, entityId]) => {
|
|
2023
|
+
const map = workspace(doc, key);
|
|
2024
|
+
const current = bases(map).map((id) => currentId(doc, id, ids));
|
|
2025
|
+
return entityRow(entityId, map.get("kind"), {
|
|
2026
|
+
...entityContent(doc, key),
|
|
2027
|
+
...current.length ? { bases: current } : {}
|
|
2028
|
+
});
|
|
2029
|
+
});
|
|
2030
|
+
const relations = [];
|
|
2031
|
+
for (const key of section(doc, "relations").keys().sort((a, b) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
2032
|
+
const map = section(doc, "relations").get(key);
|
|
2033
|
+
if (!(map instanceof LoroMap) || map.get("deleted") === true) continue;
|
|
2034
|
+
const original = map.get("identity");
|
|
2035
|
+
if (original.endpoints.some((id) => !ids.has(family(doc, id)))) continue;
|
|
2036
|
+
const endpoints = original.endpoints.map((id) => currentId(doc, id, ids));
|
|
2037
|
+
const relationId = equalJson(endpoints, original.endpoints) ? key : `relation_${hash([key, endpoints])}`;
|
|
2038
|
+
relations.push({
|
|
2039
|
+
relationId: createRelationId(relationId),
|
|
2040
|
+
relationKind: original.kind,
|
|
2041
|
+
endpoint0EntityId: createEntityId(endpoints[0]),
|
|
2042
|
+
endpoint1EntityId: createEntityId(endpoints[1])
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
sortSequencedRelations(doc, relations);
|
|
2046
|
+
const project = {};
|
|
2047
|
+
for (const [key, value] of Object.entries(section(doc, "attachments").toJSON())) {
|
|
2048
|
+
if (typeof value !== "string") throw new Error(`Invalid project attachment ${key}`);
|
|
2049
|
+
project[key] = currentId(doc, value, ids);
|
|
2050
|
+
}
|
|
2051
|
+
const state = {
|
|
2052
|
+
rows: {
|
|
2053
|
+
entities,
|
|
2054
|
+
relations
|
|
2055
|
+
},
|
|
2056
|
+
project
|
|
2057
|
+
};
|
|
2058
|
+
if (!allowSelectionConflicts) for (const [key, id] of ids) {
|
|
2059
|
+
const fingerprint = selectionFingerprint(state.rows, id);
|
|
2060
|
+
if (fingerprint !== void 0 && workspace(doc, key).get("selectionBaseline") !== fingerprint) throw new Error(`Caption text selection conflict at ${id}: source text changed; revise its text ranges explicitly`);
|
|
2061
|
+
}
|
|
2062
|
+
return state;
|
|
2063
|
+
}
|
|
2064
|
+
/** Native editing containers survive domain version changes and merge independently. */
|
|
2065
|
+
var LoroEntityDocument = class LoroEntityDocument {
|
|
2066
|
+
doc;
|
|
2067
|
+
validate;
|
|
2068
|
+
undoManager;
|
|
2069
|
+
constructor(doc, validate) {
|
|
2070
|
+
this.doc = doc;
|
|
2071
|
+
this.validate = validate;
|
|
2072
|
+
if (["dsl", "mixed"].includes(documentFormat(doc))) throw new UnsupportedDocumentFormatError();
|
|
2073
|
+
this.undoManager = new UndoManager(doc, {
|
|
2074
|
+
mergeInterval: 0,
|
|
2075
|
+
excludeOriginPrefixes: [MATERIALIZE_ORIGIN]
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
/** Open the causal snapshot an edit is applied against. */
|
|
2079
|
+
static fromSnapshot(snapshot, validate) {
|
|
2080
|
+
const doc = new LoroDoc();
|
|
2081
|
+
doc.import(snapshot);
|
|
2082
|
+
readEntityDocument(doc);
|
|
2083
|
+
return new LoroEntityDocument(doc, validate);
|
|
2084
|
+
}
|
|
2085
|
+
static create(rows, project = {}, peerId) {
|
|
2086
|
+
const doc = new LoroDoc();
|
|
2087
|
+
if (peerId !== void 0) doc.setPeerId(peerId);
|
|
2088
|
+
root(doc).set("schema", LORO_ENTITY_SCHEMA);
|
|
2089
|
+
for (const name of [
|
|
2090
|
+
"editing",
|
|
2091
|
+
"entities",
|
|
2092
|
+
"origins",
|
|
2093
|
+
"relations",
|
|
2094
|
+
"project",
|
|
2095
|
+
"attachments",
|
|
2096
|
+
"ordering"
|
|
2097
|
+
]) root(doc).ensureMergeableMap(name);
|
|
2098
|
+
for (const row of rows.entities) apply$1(doc, {
|
|
2099
|
+
type: "create",
|
|
2100
|
+
row
|
|
2101
|
+
});
|
|
2102
|
+
for (const row of rows.relations) apply$1(doc, {
|
|
2103
|
+
type: "relation",
|
|
2104
|
+
row
|
|
2105
|
+
});
|
|
2106
|
+
for (const [slot, entityId] of Object.entries(project)) apply$1(doc, {
|
|
2107
|
+
type: "attach",
|
|
2108
|
+
slot,
|
|
2109
|
+
entityId
|
|
2110
|
+
});
|
|
2111
|
+
for (const [group, ids] of entityOrderGroups(rows)) writeEntityOrder(doc, group, ids);
|
|
2112
|
+
const initialState = readEntityDocument(doc, true);
|
|
2113
|
+
for (const row of initialState.rows.entities) {
|
|
2114
|
+
const fingerprint = selectionFingerprint(initialState.rows, row.entityId);
|
|
2115
|
+
if (fingerprint !== void 0) workspace(doc, row.entityId).set("selectionBaseline", fingerprint);
|
|
2116
|
+
}
|
|
2117
|
+
assertState(readEntityDocument(doc));
|
|
2118
|
+
materialize(doc);
|
|
2119
|
+
doc.commit({ origin: MATERIALIZE_ORIGIN });
|
|
2120
|
+
return new LoroEntityDocument(doc);
|
|
2121
|
+
}
|
|
2122
|
+
snapshot() {
|
|
2123
|
+
return readEntityDocument(this.doc);
|
|
2124
|
+
}
|
|
2125
|
+
relationsOf(entityId) {
|
|
2126
|
+
const id = currentIds(this.doc).get(family(this.doc, entityId));
|
|
2127
|
+
if (id === void 0) return [];
|
|
2128
|
+
return this.snapshot().rows.relations.filter((row) => row.endpoint0EntityId === id || row.endpoint1EntityId === id);
|
|
2129
|
+
}
|
|
2130
|
+
/** Resolve a queued edit target through its native identity without changing history. */
|
|
2131
|
+
resolveCurrentEntityId(id) {
|
|
2132
|
+
return currentId(this.doc, id);
|
|
2133
|
+
}
|
|
2134
|
+
getVersion(id) {
|
|
2135
|
+
const entity = section(this.doc, "entities").get(id);
|
|
2136
|
+
if (!isJsonObject(entity)) return void 0;
|
|
2137
|
+
const { id: entityId, kind, ...payload } = entity;
|
|
2138
|
+
if (typeof entityId !== "string") throw new Error("Invalid stored entity ID");
|
|
2139
|
+
return entityRow(entityId, kind, payload);
|
|
2140
|
+
}
|
|
2141
|
+
/** The callback and every domain validation execute on a causally complete fork first. */
|
|
2142
|
+
transact(edit) {
|
|
2143
|
+
const probe = this.doc.fork();
|
|
2144
|
+
const draft = new LoroEntityDraft(probe);
|
|
2145
|
+
edit(draft);
|
|
2146
|
+
draft.captureSelectionBaselines();
|
|
2147
|
+
const state = readEntityDocument(probe);
|
|
2148
|
+
assertState(state);
|
|
2149
|
+
this.validate?.(state);
|
|
2150
|
+
materialize(probe);
|
|
2151
|
+
const from = this.doc.oplogVersion();
|
|
2152
|
+
for (const command of draft.commands) apply$1(this.doc, command);
|
|
2153
|
+
this.doc.commit({ origin: "medeo.edit" });
|
|
2154
|
+
materialize(this.doc);
|
|
2155
|
+
this.doc.commit({ origin: MATERIALIZE_ORIGIN });
|
|
2156
|
+
return this.doc.export({
|
|
2157
|
+
mode: "update",
|
|
2158
|
+
from
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
/** Import preserves native operation identities; projection errors never discard remote operations. */
|
|
2162
|
+
import(update) {
|
|
2163
|
+
this.doc.import(update);
|
|
2164
|
+
const state = this.snapshot();
|
|
2165
|
+
assertState(state);
|
|
2166
|
+
this.validate?.(state);
|
|
2167
|
+
materialize(this.doc);
|
|
2168
|
+
this.doc.commit({ origin: MATERIALIZE_ORIGIN });
|
|
2169
|
+
}
|
|
2170
|
+
/**
|
|
2171
|
+
* Bind a saved snapshot without rewriting text operations or resolving
|
|
2172
|
+
* selection conflicts. The script names the bytes the same way everything
|
|
2173
|
+
* else does — an Asset entity plus a Relation — and the Asset's digest says
|
|
2174
|
+
* which content they hold, which is what tells a stale snapshot from a
|
|
2175
|
+
* current one. The Relation is `to-asset`: these bytes are the script's own
|
|
2176
|
+
* content written out, not a source it was made from, so the Asset is
|
|
2177
|
+
* `writable`.
|
|
2178
|
+
*/
|
|
2179
|
+
bindScriptAsset(entityId, snapshot) {
|
|
2180
|
+
const key = family(this.doc, entityId);
|
|
2181
|
+
const rows = readEntityDocument(this.doc, true).rows;
|
|
2182
|
+
const current = currentIds(this.doc).get(key);
|
|
2183
|
+
const script = rows.entities.find((row) => row.entityId === current);
|
|
2184
|
+
if (script?.entityKind !== "audio-script" && script?.entityKind !== "phonetic-script") throw new Error("Resource binding requires an active script");
|
|
2185
|
+
if (assetDigest(scriptAssetContent(rows, script.entityId)) !== snapshot.digest) throw new Error("Script changed while saving its resource");
|
|
2186
|
+
const assetId = snapshot.key;
|
|
2187
|
+
if (!assetId.trim()) throw new Error("Script resource writer returned an empty asset ID");
|
|
2188
|
+
const before = this.doc.oplogVersion();
|
|
2189
|
+
const existing = rows.entities.find((row) => row.entityKind === "asset" && row.payload.system === "memota" && row.payload.key === assetId);
|
|
2190
|
+
const assetEntityId = existing?.entityId ?? createEntityId(`asset_${assetId}`);
|
|
2191
|
+
if (existing !== void 0 && existing.payload.writable !== true) throw new Error(`Asset ${assetId} holds a store's own bytes`);
|
|
2192
|
+
if (existing?.payload.digest !== void 0 && existing.payload.digest !== snapshot.digest) throw new Error(`Asset ${assetId} already holds other bytes`);
|
|
2193
|
+
if (existing === void 0) this.transact((draft) => draft.create({
|
|
2194
|
+
entityId: assetEntityId,
|
|
2195
|
+
entityKind: "asset",
|
|
2196
|
+
payload: {
|
|
2197
|
+
system: "memota",
|
|
2198
|
+
key: assetId,
|
|
2199
|
+
digest: snapshot.digest,
|
|
2200
|
+
writable: true
|
|
2201
|
+
}
|
|
2202
|
+
}));
|
|
2203
|
+
else if (existing.payload.digest === void 0) this.transact((draft) => draft.declare(assetEntityId, { digest: snapshot.digest }));
|
|
2204
|
+
if (scriptSnapshotAssetOf(readEntityDocument(this.doc, true).rows, entityId)?.entityId !== assetEntityId) this.transact((draft) => {
|
|
2205
|
+
const current = readEntityDocument(this.doc, true).rows;
|
|
2206
|
+
for (const relation of current.relations) if (relation.relationKind === "to-asset" && (String(relation.endpoint0EntityId) === String(entityId) || String(relation.endpoint1EntityId) === String(entityId))) draft.unlink(String(relation.relationId));
|
|
2207
|
+
draft.link({
|
|
2208
|
+
relationId: createRelationId(`relation_to-asset_${entityId}_${assetId}`),
|
|
2209
|
+
relationKind: "to-asset",
|
|
2210
|
+
endpoint0EntityId: createEntityId(entityId),
|
|
2211
|
+
endpoint1EntityId: assetEntityId
|
|
2212
|
+
});
|
|
2213
|
+
});
|
|
2214
|
+
return this.doc.export({
|
|
2215
|
+
mode: "update",
|
|
2216
|
+
from: before
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
canUndo() {
|
|
2220
|
+
return this.undoManager.canUndo();
|
|
2221
|
+
}
|
|
2222
|
+
canRedo() {
|
|
2223
|
+
return this.undoManager.canRedo();
|
|
2224
|
+
}
|
|
2225
|
+
undo() {
|
|
2226
|
+
return this.historyEdit(() => this.undoManager.undo());
|
|
2227
|
+
}
|
|
2228
|
+
redo() {
|
|
2229
|
+
return this.historyEdit(() => this.undoManager.redo());
|
|
2230
|
+
}
|
|
2231
|
+
historyEdit(run) {
|
|
2232
|
+
const before = new Map(this.snapshot().rows.entities.map((row) => [family(this.doc, row.entityId), row]));
|
|
2233
|
+
if (!run()) return false;
|
|
2234
|
+
const after = this.snapshot();
|
|
2235
|
+
for (const row of after.rows.entities) {
|
|
2236
|
+
const id = family(this.doc, row.entityId);
|
|
2237
|
+
const previous = before.get(id);
|
|
2238
|
+
if (previous && !equalJson(owned(tree(previous.payload)), owned(tree(row.payload)))) workspace(this.doc, id).ensureMergeableMap("changes").set(globalThis.crypto.randomUUID(), true);
|
|
2239
|
+
}
|
|
2240
|
+
materialize(this.doc);
|
|
2241
|
+
this.doc.commit({ origin: MATERIALIZE_ORIGIN });
|
|
2242
|
+
return true;
|
|
2243
|
+
}
|
|
2244
|
+
};
|
|
2245
|
+
var LoroEntityDraft = class {
|
|
2246
|
+
doc;
|
|
2247
|
+
commands = [];
|
|
2248
|
+
selectedRoots = /* @__PURE__ */ new Set();
|
|
2249
|
+
relationAliases = /* @__PURE__ */ new Map();
|
|
2250
|
+
constructor(doc) {
|
|
2251
|
+
this.doc = doc;
|
|
2252
|
+
for (const row of this.snapshot().rows.relations) this.relationAliases.set(row.relationId, this.relationRoot(row.relationId));
|
|
2253
|
+
}
|
|
2254
|
+
snapshot() {
|
|
2255
|
+
return readEntityDocument(this.doc, true);
|
|
2256
|
+
}
|
|
2257
|
+
order(groupId, entityIds) {
|
|
2258
|
+
this.record({
|
|
2259
|
+
type: "order",
|
|
2260
|
+
group: family(this.doc, groupId),
|
|
2261
|
+
roots: entityIds.map((id) => family(this.doc, id)),
|
|
2262
|
+
token: globalThis.crypto.randomUUID()
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
/** Project the final scalar placement intents onto the existing native ordering containers. */
|
|
2266
|
+
reconcileOrder(rows) {
|
|
2267
|
+
const before = new Map([...entityOrderGroups(this.snapshot().rows)].map(([id, ids]) => [family(this.doc, id), ids.map((id) => family(this.doc, id))]));
|
|
2268
|
+
const after = new Map([...entityOrderGroups(rows)].map(([id, ids]) => [family(this.doc, id), ids.map((id) => family(this.doc, id))]));
|
|
2269
|
+
for (const group of new Set([...before.keys(), ...after.keys()])) {
|
|
2270
|
+
const next = after.get(group) ?? [];
|
|
2271
|
+
if (!equalJson(before.get(group) ?? [], next)) this.order(group, next);
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
create(row) {
|
|
2275
|
+
this.record({
|
|
2276
|
+
type: "create",
|
|
2277
|
+
row: clone(row)
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
2280
|
+
delete(entityId) {
|
|
2281
|
+
this.record({
|
|
2282
|
+
type: "delete",
|
|
2283
|
+
root: family(this.doc, entityId)
|
|
2284
|
+
});
|
|
2285
|
+
}
|
|
2286
|
+
attach(slot, entityId) {
|
|
2287
|
+
this.record({
|
|
2288
|
+
type: "attach",
|
|
2289
|
+
slot,
|
|
2290
|
+
entityId
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
link(row) {
|
|
2294
|
+
this.record({
|
|
2295
|
+
type: "relation",
|
|
2296
|
+
row: clone(row)
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
unlink(relationId) {
|
|
2300
|
+
this.record({
|
|
2301
|
+
type: "unlink",
|
|
2302
|
+
root: this.relationRoot(relationId)
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
relationRoot(relationId) {
|
|
2306
|
+
const alias = this.relationAliases.get(relationId);
|
|
2307
|
+
if (alias !== void 0) return alias;
|
|
2308
|
+
const ids = currentIds(this.doc);
|
|
2309
|
+
for (const key of section(this.doc, "relations").keys()) {
|
|
2310
|
+
const map = section(this.doc, "relations").get(key);
|
|
2311
|
+
if (!(map instanceof LoroMap) || map.get("deleted") === true) continue;
|
|
2312
|
+
const identity = map.get("identity");
|
|
2313
|
+
if (identity.endpoints.some((id) => !ids.has(family(this.doc, id)))) continue;
|
|
2314
|
+
const endpoints = identity.endpoints.map((id) => currentId(this.doc, id, ids));
|
|
2315
|
+
if ((equalJson(endpoints, identity.endpoints) ? key : `relation_${hash([key, endpoints])}`) === relationId || key === relationId) return key;
|
|
2316
|
+
}
|
|
2317
|
+
throw new Error(`Unknown relation ${relationId}`);
|
|
2318
|
+
}
|
|
2319
|
+
update(entityId, fields) {
|
|
2320
|
+
const state = this.snapshot();
|
|
2321
|
+
const id = createEntityId(currentId(this.doc, entityId));
|
|
2322
|
+
const updates = /* @__PURE__ */ new Map();
|
|
2323
|
+
for (const [field, value] of Object.entries(tree(fields))) {
|
|
2324
|
+
const owner = resolveEntityFieldOwner(state.rows, id, field) ?? id;
|
|
2325
|
+
const payload = updates.get(owner) ?? clone(tree(state.rows.entities.find((row) => row.entityId === owner).payload));
|
|
2326
|
+
updates.set(owner, {
|
|
2327
|
+
...payload,
|
|
2328
|
+
[field]: clone(value)
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
for (const [owner, payload] of updates) this.write(owner, payload);
|
|
2332
|
+
}
|
|
2333
|
+
declare(entityId, fields) {
|
|
2334
|
+
const row = this.snapshot().rows.entities.find((row) => row.entityId === currentId(this.doc, entityId));
|
|
2335
|
+
if (!row) throw new Error(`Unknown entity ${entityId}`);
|
|
2336
|
+
this.write(entityId, {
|
|
2337
|
+
...tree(row.payload),
|
|
2338
|
+
...tree(fields)
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
replaceOwned(entityId, payload) {
|
|
2342
|
+
this.write(entityId, tree(payload));
|
|
2343
|
+
}
|
|
2344
|
+
write(entityId, payload) {
|
|
2345
|
+
this.record({
|
|
2346
|
+
type: "write",
|
|
2347
|
+
root: family(this.doc, entityId),
|
|
2348
|
+
payload: clone(payload),
|
|
2349
|
+
token: globalThis.crypto.randomUUID()
|
|
2350
|
+
});
|
|
2351
|
+
}
|
|
2352
|
+
editText(entityId, path, start, deleteCount, text) {
|
|
2353
|
+
if (typeof path[0] !== "string") throw new Error("Text path must start with a field");
|
|
2354
|
+
const state = this.snapshot();
|
|
2355
|
+
const target = createEntityId(currentId(this.doc, entityId));
|
|
2356
|
+
const owner = resolveEntityFieldOwner(state.rows, target, path[0]) ?? target;
|
|
2357
|
+
this.record({
|
|
2358
|
+
type: "text",
|
|
2359
|
+
root: family(this.doc, owner),
|
|
2360
|
+
path: [...path],
|
|
2361
|
+
start,
|
|
2362
|
+
deleteCount,
|
|
2363
|
+
text,
|
|
2364
|
+
token: globalThis.crypto.randomUUID()
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
/** Generic assembled-field edit applied at the author's causal position. */
|
|
2368
|
+
change(entityId, changes) {
|
|
2369
|
+
assertFieldChanges(changes);
|
|
2370
|
+
for (const raw of changes) {
|
|
2371
|
+
const change = clone(raw);
|
|
2372
|
+
if (change.path[0] === "bases") {
|
|
2373
|
+
if (change.op === "list.insert" && typeof change.value === "string") change.value = currentId(this.doc, change.value);
|
|
2374
|
+
if (change.op === "list.remove" || change.op === "list.move") change.elementId = currentId(this.doc, change.elementId);
|
|
2375
|
+
if ((change.op === "list.insert" || change.op === "list.move") && change.beforeElementId !== null) change.beforeElementId = currentId(this.doc, change.beforeElementId);
|
|
2376
|
+
}
|
|
2377
|
+
const state = this.snapshot();
|
|
2378
|
+
const id = createEntityId(currentId(this.doc, entityId));
|
|
2379
|
+
const owner = resolveEntityFieldOwner(state.rows, id, change.path[0]) ?? id;
|
|
2380
|
+
const payload = tree(state.rows.entities.find((row) => row.entityId === owner).payload);
|
|
2381
|
+
if (equalJson(payload, applyFieldChanges(payload, [change]))) continue;
|
|
2382
|
+
this.record({
|
|
2383
|
+
type: "field",
|
|
2384
|
+
root: family(this.doc, owner),
|
|
2385
|
+
path: resolveFieldPath(payload, change.path),
|
|
2386
|
+
change: clone(change),
|
|
2387
|
+
token: globalThis.crypto.randomUUID()
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
captureSelectionBaselines() {
|
|
2392
|
+
const state = this.snapshot();
|
|
2393
|
+
for (const key of this.selectedRoots) {
|
|
2394
|
+
const id = currentIds(this.doc).get(key);
|
|
2395
|
+
if (id === void 0) continue;
|
|
2396
|
+
const fingerprint = selectionFingerprint(state.rows, id);
|
|
2397
|
+
if (fingerprint !== void 0) this.record({
|
|
2398
|
+
type: "selection-baseline",
|
|
2399
|
+
root: key,
|
|
2400
|
+
fingerprint
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
record(command) {
|
|
2405
|
+
if (command.type === "create") this.selectedRoots.add(command.row.entityId);
|
|
2406
|
+
if (command.type === "write" && !equalJson(content(workspace(this.doc, command.root)).selection, command.payload.selection)) this.selectedRoots.add(command.root);
|
|
2407
|
+
if (command.type === "field" && command.path[0] === "selection") this.selectedRoots.add(command.root);
|
|
2408
|
+
apply$1(this.doc, command);
|
|
2409
|
+
this.commands.push(command);
|
|
2410
|
+
}
|
|
2411
|
+
};
|
|
2412
|
+
function apply$1(doc, command) {
|
|
2413
|
+
switch (command.type) {
|
|
2414
|
+
case "selection-baseline":
|
|
2415
|
+
workspace(doc, command.root).set("selectionBaseline", command.fingerprint);
|
|
2416
|
+
break;
|
|
2417
|
+
case "order":
|
|
2418
|
+
writeEntityOrder(doc, command.group, command.roots, command.token);
|
|
2419
|
+
break;
|
|
2420
|
+
case "create": {
|
|
2421
|
+
const row = command.row;
|
|
2422
|
+
if (section(doc, "origins").get(row.entityId) !== void 0) throw new Error(`Duplicate entity ${row.entityId}`);
|
|
2423
|
+
const map = section(doc, "editing").ensureMergeableMap(row.entityId);
|
|
2424
|
+
map.set("kind", row.entityKind);
|
|
2425
|
+
map.ensureMergeableMap("changes");
|
|
2426
|
+
section(doc, "origins").set(row.entityId, row.entityId);
|
|
2427
|
+
writeObject(map.ensureMergeableMap("content"), {}, owned(tree(row.payload)));
|
|
2428
|
+
writeBases(doc, map, baseIds(tree(row.payload)));
|
|
2429
|
+
break;
|
|
2430
|
+
}
|
|
2431
|
+
case "write": {
|
|
2432
|
+
const map = workspace(doc, command.root);
|
|
2433
|
+
const before = content(map);
|
|
2434
|
+
const after = owned(command.payload);
|
|
2435
|
+
if (!equalJson(before, after)) {
|
|
2436
|
+
writeObject(map.ensureMergeableMap("content"), before, after);
|
|
2437
|
+
map.ensureMergeableMap("changes").set(command.token, true);
|
|
2438
|
+
}
|
|
2439
|
+
writeBases(doc, map, baseIds(command.payload));
|
|
2440
|
+
break;
|
|
2441
|
+
}
|
|
2442
|
+
case "text": {
|
|
2443
|
+
const map = workspace(doc, command.root);
|
|
2444
|
+
const text = requireText(map.ensureMergeableMap("content"), command.path);
|
|
2445
|
+
if (!Number.isSafeInteger(command.start) || !Number.isSafeInteger(command.deleteCount) || command.start < 0 || command.deleteCount < 0 || command.start + command.deleteCount > Array.from(text.toString()).length) throw new Error("Invalid text edit range");
|
|
2446
|
+
const start = text.convertPos(command.start, "unicode", "utf16");
|
|
2447
|
+
const end = text.convertPos(command.start + command.deleteCount, "unicode", "utf16");
|
|
2448
|
+
text.splice(start, end - start, command.text);
|
|
2449
|
+
map.ensureMergeableMap("changes").set(command.token, true);
|
|
2450
|
+
break;
|
|
2451
|
+
}
|
|
2452
|
+
case "field": {
|
|
2453
|
+
const map = workspace(doc, command.root);
|
|
2454
|
+
if (command.path[0] === "bases") {
|
|
2455
|
+
const before = { bases: bases(map).map((id) => currentId(doc, id)) };
|
|
2456
|
+
if (command.change.op === "list.move") throw new Error("Composition bases are unordered; list.move is not applicable");
|
|
2457
|
+
writeBases(doc, map, baseIds(applyFieldChanges(before, [command.change])));
|
|
2458
|
+
} else {
|
|
2459
|
+
applyNativeFieldChange(requireMap(map, "content"), command.path, command.change);
|
|
2460
|
+
map.ensureMergeableMap("changes").set(command.token, true);
|
|
2461
|
+
}
|
|
2462
|
+
break;
|
|
2463
|
+
}
|
|
2464
|
+
case "delete":
|
|
2465
|
+
workspace(doc, command.root).set("deleted", true);
|
|
2466
|
+
break;
|
|
2467
|
+
case "relation": {
|
|
2468
|
+
const row = command.row;
|
|
2469
|
+
const map = section(doc, "relations").ensureMergeableMap(row.relationId);
|
|
2470
|
+
const identity = {
|
|
2471
|
+
kind: row.relationKind,
|
|
2472
|
+
endpoints: [row.endpoint0EntityId, row.endpoint1EntityId]
|
|
2473
|
+
};
|
|
2474
|
+
const previous = map.get("identity");
|
|
2475
|
+
if (previous !== void 0 && !equalJson(previous, identity)) throw new Error("Relation identity cannot change kind or endpoint positions");
|
|
2476
|
+
if (previous === void 0) map.set("identity", identity);
|
|
2477
|
+
if (map.get("deleted") === true) throw new Error("Deleted relation IDs cannot be reused");
|
|
2478
|
+
break;
|
|
2479
|
+
}
|
|
2480
|
+
case "unlink": {
|
|
2481
|
+
const map = section(doc, "relations").get(command.root);
|
|
2482
|
+
if (!(map instanceof LoroMap)) throw new Error("Unknown relation");
|
|
2483
|
+
map.set("deleted", true);
|
|
2484
|
+
break;
|
|
2485
|
+
}
|
|
2486
|
+
case "attach":
|
|
2487
|
+
section(doc, "attachments").set(command.slot, command.entityId);
|
|
2488
|
+
break;
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
function writeBases(doc, map, ids) {
|
|
2492
|
+
const links = map.ensureMergeableMap("bases");
|
|
2493
|
+
const wanted = new Map(ids.map((id) => {
|
|
2494
|
+
const origin = section(doc, "origins").get(id);
|
|
2495
|
+
return [typeof origin === "string" ? origin : [...currentIds(doc)].find(([, current]) => current === id)?.[0] ?? id, id];
|
|
2496
|
+
}));
|
|
2497
|
+
if (wanted.size !== ids.length) throw new Error("Duplicate base entity family");
|
|
2498
|
+
for (const key of links.keys()) if (!wanted.has(key)) links.delete(key);
|
|
2499
|
+
for (const [key, id] of wanted) if (links.get(key) === void 0) links.set(key, id);
|
|
2500
|
+
}
|
|
2501
|
+
/** Numeric sub-spans must not silently quote different text after a collaborative edit. */
|
|
2502
|
+
function selectionFingerprint(rows, id) {
|
|
2503
|
+
const row = rows.entities.find((row) => row.entityId === id);
|
|
2504
|
+
if (row?.entityKind !== "caption") return void 0;
|
|
2505
|
+
const { selection } = row.payload;
|
|
2506
|
+
if (selection?.textRange === void 0) return void 0;
|
|
2507
|
+
const source = segmentsOf(assembleEntityContent(rows, row.entityId)).find((segment) => segment.segmentId === selection.segmentId);
|
|
2508
|
+
if (source === void 0) throw new Error("Caption text selection source is missing");
|
|
2509
|
+
return hash([selection, source.text]);
|
|
2510
|
+
}
|
|
2511
|
+
function assertState(state) {
|
|
2512
|
+
const composition = checkComposition(state.rows);
|
|
2513
|
+
if (composition.length > 0) throw new Error(composition.map((issue) => issue.message).join("; "));
|
|
2514
|
+
const ids = new Set(state.rows.entities.map((row) => String(row.entityId)));
|
|
2515
|
+
for (const row of state.rows.relations) if (!ids.has(row.endpoint0EntityId) || !ids.has(row.endpoint1EntityId)) throw new Error("Relation has a missing endpoint");
|
|
2516
|
+
}
|
|
2517
|
+
function materialize(doc, allowSelectionConflicts = false) {
|
|
2518
|
+
const state = readEntityDocument(doc, allowSelectionConflicts);
|
|
2519
|
+
const byId = section(doc, "entities");
|
|
2520
|
+
const ids = currentIds(doc);
|
|
2521
|
+
for (const [source, id] of ids) {
|
|
2522
|
+
const origins = section(doc, "origins");
|
|
2523
|
+
if (origins.get(id) !== source) origins.set(id, source);
|
|
2524
|
+
}
|
|
2525
|
+
for (const row of state.rows.entities) {
|
|
2526
|
+
const next = {
|
|
2527
|
+
id: row.entityId,
|
|
2528
|
+
kind: row.entityKind,
|
|
2529
|
+
...tree(row.payload)
|
|
2530
|
+
};
|
|
2531
|
+
const previous = byId.get(row.entityId);
|
|
2532
|
+
if (isJsonObject(previous) && !equalJson(owned(previous), owned(next))) throw new Error("Immutable entity version collision");
|
|
2533
|
+
if (!equalJson(previous, next)) byId.set(row.entityId, next);
|
|
2534
|
+
}
|
|
2535
|
+
const project = section(doc, "project");
|
|
2536
|
+
for (const [slot, id] of Object.entries(state.project)) if (project.get(slot) !== id) project.set(slot, id);
|
|
2537
|
+
}
|
|
2538
|
+
/**
|
|
2539
|
+
* Put each container's sequenced Relations in the order its native list states.
|
|
2540
|
+
*
|
|
2541
|
+
* Per container, never across: a Timeline's Tracks and a Track's Clips are two
|
|
2542
|
+
* separate orders, and sorting them together would interleave them.
|
|
2543
|
+
*/
|
|
2544
|
+
function sortSequencedRelations(doc, relations) {
|
|
2545
|
+
const containers = /* @__PURE__ */ new Map();
|
|
2546
|
+
for (const [slot, relation] of relations.entries()) {
|
|
2547
|
+
if (!isSequencedRelationKind(relation.relationKind)) continue;
|
|
2548
|
+
const container = String(relation.endpoint0EntityId);
|
|
2549
|
+
const ordinal = entityOrdinal(doc, family(doc, String(relation.endpoint1EntityId))) ?? slot;
|
|
2550
|
+
containers.set(container, [...containers.get(container) ?? [], {
|
|
2551
|
+
slot,
|
|
2552
|
+
relation,
|
|
2553
|
+
ordinal
|
|
2554
|
+
}]);
|
|
2555
|
+
}
|
|
2556
|
+
for (const members of containers.values()) {
|
|
2557
|
+
const slots = members.map((member) => member.slot);
|
|
2558
|
+
const ordered = [...members].sort((left, right) => left.ordinal - right.ordinal);
|
|
2559
|
+
slots.forEach((slot, at) => relations[slot] = ordered[at].relation);
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
//#endregion
|
|
2563
|
+
//#region src/entity-editor/document-audio-script.ts
|
|
2564
|
+
/** Read only the attached version and preserve every segment; never aggregate other scripts. */
|
|
2565
|
+
function readDocumentAudioScript(state) {
|
|
2566
|
+
const id = state.audioScriptEntityId;
|
|
2567
|
+
if (id === null) throw new EntityTimelineProjectionError("Document AudioScript is not initialized");
|
|
2568
|
+
if (typeof id !== "string" || id.trim() !== id || !id) throw new EntityTimelineProjectionError("Invalid document AudioScript identity");
|
|
2569
|
+
const entity = state.rows.entities.find((row) => row.entityId === id);
|
|
2570
|
+
if (!entity || entity.entityKind !== "audio-script") throw new EntityTimelineProjectionError(`Document AudioScript ${id} must name the project AudioScript`);
|
|
2571
|
+
return {
|
|
2572
|
+
entityId: id,
|
|
2573
|
+
segments: structuredClone(segmentsOf(entity))
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
//#endregion
|
|
2577
|
+
//#region src/entity-editor/editor-resources.ts
|
|
2578
|
+
const roleOrder = {
|
|
2579
|
+
caption: 0,
|
|
2580
|
+
video_clip: 1,
|
|
2581
|
+
speech: 2,
|
|
2582
|
+
bgm: 3
|
|
2583
|
+
};
|
|
2584
|
+
/**
|
|
2585
|
+
* MEngine editor policy, not a cardinality restriction on the general-purpose DSL.
|
|
2586
|
+
*
|
|
2587
|
+
* Resources live on Asset entities and are reached through `from-asset`: no
|
|
2588
|
+
* other entity carries a locator, one Asset claims each `(system, key)` pair,
|
|
2589
|
+
* and media reaches at most one Asset. Media whose bytes are not named through
|
|
2590
|
+
* that Relation is rejected rather than guessed at.
|
|
2591
|
+
*/
|
|
2592
|
+
function assertCanonicalEditorResources(rows) {
|
|
2593
|
+
if (rows.entities.filter((row) => row.entityKind === "timeline").length > 1) throw new EntityTimelineProjectionError("Editor requires exactly one Timeline");
|
|
2594
|
+
for (const role of Object.keys(roleOrder)) if (rows.entities.filter((row) => row.entityKind === "track" && roleOf(rows, row) === role).length > 1) throw new EntityTimelineProjectionError(`Editor has multiple ${role} Tracks`);
|
|
2595
|
+
const identityByAssetKey = /* @__PURE__ */ new Map();
|
|
2596
|
+
for (const entity of rows.entities) {
|
|
2597
|
+
if (entity.entityKind === "asset") {
|
|
2598
|
+
const locator = memotaLocator(entity.payload);
|
|
2599
|
+
if (locator === void 0) throw new EntityTimelineProjectionError(`Asset ${entity.entityId} must state where its bytes live`);
|
|
2600
|
+
const key = JSON.stringify([locator.system, locator.key]);
|
|
2601
|
+
const previous = identityByAssetKey.get(key);
|
|
2602
|
+
if (previous !== void 0 && previous !== entity.entityId) throw new EntityTimelineProjectionError(`Duplicate Asset for ${key}; reuse ${previous}`);
|
|
2603
|
+
identityByAssetKey.set(key, entity.entityId);
|
|
2604
|
+
continue;
|
|
2605
|
+
}
|
|
2606
|
+
if (memotaLocator(entity.payload) !== void 0) throw new EntityTimelineProjectionError(`Entity ${entity.entityId} must not carry a locator; link its Asset with from-asset`);
|
|
2607
|
+
}
|
|
2608
|
+
for (const entity of rows.entities) {
|
|
2609
|
+
if (!isMediaAssetVariantKind(entity.entityKind)) continue;
|
|
2610
|
+
const own = assetIdsOf(rows, entity.entityId).length;
|
|
2611
|
+
if (own > 1 || own === 0 && assetOf(rows, entity.entityId) === void 0) throw new EntityTimelineProjectionError(`Media Entity ${entity.entityId} requires exactly one from-asset Asset`);
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
/** A Track's role, read through its bases. */
|
|
2615
|
+
function roleOf(rows, track) {
|
|
2616
|
+
return assembleEntityContent(rows, track.entityId, "track").payload.role;
|
|
2617
|
+
}
|
|
2618
|
+
/** The Asset ids one entity reaches, in row order. */
|
|
2619
|
+
function assetIdsOf(rows, entityId) {
|
|
2620
|
+
const assets = new Set(rows.entities.filter((row) => row.entityKind === "asset").map((row) => String(row.entityId)));
|
|
2621
|
+
return rows.relations.filter((relation) => relation.relationKind === "from-asset").map((relation) => String(relation.endpoint0EntityId) === entityId ? String(relation.endpoint1EntityId) : String(relation.endpoint1EntityId) === entityId ? String(relation.endpoint0EntityId) : void 0).filter((id) => id !== void 0 && assets.has(id));
|
|
2622
|
+
}
|
|
2623
|
+
/**
|
|
2624
|
+
* Commit-transition policy shared by the editor, the HTTP commit boundary, the
|
|
2625
|
+
* host tool, and the entity stores. It compares the authoritative before and
|
|
2626
|
+
* after rows of one CAS commit and enforces the resource rules on the complete
|
|
2627
|
+
* after state, with no exemption for historical graphs:
|
|
2628
|
+
*
|
|
2629
|
+
* 1. every media Entity reaches exactly one Asset through `from-asset`;
|
|
2630
|
+
* 2. no entity other than an Asset carries a locator;
|
|
2631
|
+
* 3. an Asset's locator is immutable — pointing it at other bytes would
|
|
2632
|
+
* silently rewrite every entity already made from it.
|
|
2633
|
+
*
|
|
2634
|
+
* Deleting media is explicit and allowed.
|
|
2635
|
+
*/
|
|
2636
|
+
function assertMediaAssetWritePolicy(before, after) {
|
|
2637
|
+
const beforeEntities = new Map(before.entities.map((row) => [String(row.entityId), row]));
|
|
2638
|
+
assertCanonicalEditorResources(after);
|
|
2639
|
+
for (const row of after.entities) {
|
|
2640
|
+
if (row.entityKind !== "asset") continue;
|
|
2641
|
+
const prior = memotaLocator(beforeEntities.get(String(row.entityId))?.payload);
|
|
2642
|
+
if (prior === void 0) continue;
|
|
2643
|
+
const next = memotaLocator(row.payload);
|
|
2644
|
+
if (next !== void 0 && (next.system !== prior.system || next.key !== prior.key)) throw new EntityTimelineProjectionError(`Asset ${row.entityId} cannot be pointed at other bytes; create a new Asset instead`);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
/**
|
|
2648
|
+
* The locator a payload states, if any. The rows checked here may break the
|
|
2649
|
+
* contract — a media entity still carrying the locator it had before Assets —
|
|
2650
|
+
* so the payload is read field by field rather than trusted by its kind.
|
|
2651
|
+
*/
|
|
2652
|
+
function memotaLocator(payload) {
|
|
2653
|
+
const { system, key } = payload ?? {};
|
|
2654
|
+
if (system !== "memota" && system !== "memota-speech" || typeof key !== "string" || key.trim() === "") return void 0;
|
|
2655
|
+
return {
|
|
2656
|
+
system,
|
|
2657
|
+
key
|
|
2658
|
+
};
|
|
2659
|
+
}
|
|
2660
|
+
/**
|
|
2661
|
+
* Bootstrap an empty script when the foundation has no candidate. Without an
|
|
2662
|
+
* explicit project attachment, multiple candidates are ambiguous. Existing
|
|
2663
|
+
* content IDs are reused here; Loro editing advances their immutable versions.
|
|
2664
|
+
*/
|
|
2665
|
+
function ensureDocumentAudioScript(entities, selectedEntityId) {
|
|
2666
|
+
if (selectedEntityId != null) {
|
|
2667
|
+
if (!entities.some((row) => row.entityKind === "audio-script" && row.entityId === selectedEntityId)) throw new EntityTimelineProjectionError(`Selected AudioScript ${selectedEntityId} does not exist`);
|
|
2668
|
+
return selectedEntityId;
|
|
2669
|
+
}
|
|
2670
|
+
const scripts = entities.filter((row) => row.entityKind === "audio-script");
|
|
2671
|
+
if (scripts.length > 1) throw new EntityTimelineProjectionError("Select an AudioScript for the editor panel when multiple scripts exist");
|
|
2672
|
+
return scripts[0]?.entityId ?? null;
|
|
2673
|
+
}
|
|
2674
|
+
/** Add only missing editor structure; existing identities and owned settings are never replaced. */
|
|
2675
|
+
function ensureEditorFoundation(initial, idFactory = (prefix) => `${prefix}_${globalThis.crypto.randomUUID()}`, selectedAudioScriptEntityId) {
|
|
2676
|
+
const entities = structuredClone([...initial.entities]);
|
|
2677
|
+
const relations = structuredClone([...initial.relations]);
|
|
2678
|
+
const roots = entities.filter((row) => row.entityKind === "timeline");
|
|
2679
|
+
if (roots.length > 1) throw new EntityTimelineProjectionError("Editor requires exactly one Timeline");
|
|
2680
|
+
const mintEntity = () => {
|
|
2681
|
+
const id = createEntityId(idFactory("entity"));
|
|
2682
|
+
if (entities.some((row) => row.entityId === id)) throw new Error(`Duplicate entity id ${id}`);
|
|
2683
|
+
return id;
|
|
2684
|
+
};
|
|
2685
|
+
let timeline = roots[0];
|
|
2686
|
+
if (timeline === void 0) {
|
|
2687
|
+
timeline = {
|
|
2688
|
+
entityId: mintEntity(),
|
|
2689
|
+
entityKind: "timeline",
|
|
2690
|
+
payload: {}
|
|
2691
|
+
};
|
|
2692
|
+
entities.push(timeline);
|
|
2693
|
+
}
|
|
2694
|
+
const trackEntityIds = {};
|
|
2695
|
+
for (const role of Object.keys(roleOrder)) {
|
|
2696
|
+
const matches = entities.filter((row) => row.entityKind === "track" && roleOf({
|
|
2697
|
+
entities,
|
|
2698
|
+
relations
|
|
2699
|
+
}, row) === role);
|
|
2700
|
+
if (matches.length > 1) throw new EntityTimelineProjectionError(`Editor has multiple ${role} Tracks`);
|
|
2701
|
+
let track = matches[0];
|
|
2702
|
+
if (track === void 0) {
|
|
2703
|
+
track = {
|
|
2704
|
+
entityId: mintEntity(),
|
|
2705
|
+
entityKind: "track",
|
|
2706
|
+
payload: { role }
|
|
2707
|
+
};
|
|
2708
|
+
entities.push(track);
|
|
2709
|
+
}
|
|
2710
|
+
const bindings = relations.filter((row) => row.relationKind === "contain" && (row.endpoint0EntityId === track.entityId || row.endpoint1EntityId === track.entityId));
|
|
2711
|
+
if (bindings.some((row) => row.endpoint0EntityId !== timeline.entityId && row.endpoint1EntityId !== timeline.entityId)) throw new EntityTimelineProjectionError(`Track ${track.entityId} belongs to another Timeline`);
|
|
2712
|
+
if (bindings.length === 0) {
|
|
2713
|
+
const relationId = createRelationId(idFactory("relation"));
|
|
2714
|
+
if (relations.some((row) => row.relationId === relationId)) throw new Error(`Duplicate relation id ${relationId}`);
|
|
2715
|
+
relations.push({
|
|
2716
|
+
relationId,
|
|
2717
|
+
relationKind: "contain",
|
|
2718
|
+
endpoint0EntityId: timeline.entityId,
|
|
2719
|
+
endpoint1EntityId: track.entityId
|
|
2720
|
+
});
|
|
2721
|
+
}
|
|
2722
|
+
trackEntityIds[role] = track.entityId;
|
|
2723
|
+
}
|
|
2724
|
+
const audioScriptEntityId = ensureDocumentAudioScript(entities, selectedAudioScriptEntityId);
|
|
2725
|
+
return {
|
|
2726
|
+
rows: {
|
|
2727
|
+
entities,
|
|
2728
|
+
relations
|
|
2729
|
+
},
|
|
2730
|
+
timelineEntityId: timeline.entityId,
|
|
2731
|
+
trackEntityIds,
|
|
2732
|
+
audioScriptEntityId
|
|
2733
|
+
};
|
|
2734
|
+
}
|
|
2735
|
+
//#endregion
|
|
2736
|
+
//#region src/entity-editor/entity-graph-http-client.ts
|
|
2737
|
+
const MENGINE_API_PREFIX = "/api/mengine/v1";
|
|
2738
|
+
/** Browser-safe authenticated Loro client for a document's Entity graph. */
|
|
2739
|
+
var EntityGraphHttpClient = class {
|
|
2740
|
+
options;
|
|
2741
|
+
transport;
|
|
2742
|
+
endpoint;
|
|
2743
|
+
constructor(options) {
|
|
2744
|
+
this.options = options;
|
|
2745
|
+
this.transport = new MengineHttpClient(options);
|
|
2746
|
+
const origin = options.httpOrigin.replace(/\/$/, "");
|
|
2747
|
+
this.endpoint = `${origin}${MENGINE_API_PREFIX}/docs/${encodeURIComponent(options.docId)}/entity-state`;
|
|
2748
|
+
}
|
|
2749
|
+
async fetchState() {
|
|
2750
|
+
return this.request({ method: "GET" });
|
|
2751
|
+
}
|
|
2752
|
+
/**
|
|
2753
|
+
* Apply edits against the original causal snapshot and publish native Loro operations.
|
|
2754
|
+
*
|
|
2755
|
+
* Deletion intent is derived from baseline IDs omitted from `nextRows`, so a
|
|
2756
|
+
* transient create-then-remove never becomes a request to delete unknown
|
|
2757
|
+
* persisted data.
|
|
2758
|
+
*/
|
|
2759
|
+
async commit(baseState, nextRows, options = {}) {
|
|
2760
|
+
if (options.migrationBaseVv !== void 0) throw new Error("Legacy entity migration is not supported by the Loro contract");
|
|
2761
|
+
const next = cloneAndValidateRows(nextRows);
|
|
2762
|
+
assertMediaAssetWritePolicy(baseState.rows, next);
|
|
2763
|
+
const applied = apply(LoroEntityDocument.fromSnapshot(base64ToBytes(baseState.loroSnapshot)), next);
|
|
2764
|
+
return this.commitUpdate(bytesToBase64(applied.update));
|
|
2765
|
+
}
|
|
2766
|
+
/** Send the same prepared bytes on retry; never re-apply an unconfirmed edit. */
|
|
2767
|
+
async commitUpdate(update) {
|
|
2768
|
+
return this.request({
|
|
2769
|
+
method: "POST",
|
|
2770
|
+
body: JSON.stringify({ update })
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
async request(init) {
|
|
2774
|
+
const response = await this.transport.fetch(this.endpoint, {
|
|
2775
|
+
...init,
|
|
2776
|
+
headers: this.transport.headers()
|
|
2777
|
+
});
|
|
2778
|
+
const payload = await safeReadJson(response);
|
|
2779
|
+
if (!response.ok) throw new MengineHttpRequestError(response.status, payload);
|
|
2780
|
+
return parseWireState(payload, this.options.docId);
|
|
2781
|
+
}
|
|
2782
|
+
};
|
|
2783
|
+
function parseWireState(value, expectedDocId) {
|
|
2784
|
+
if (!isRecord(value) || typeof value.doc_id !== "string" || !isNonNegativeSafeInteger(value.revision)) throw new Error("Invalid entity-state response envelope");
|
|
2785
|
+
if (value.doc_id !== expectedDocId) throw new Error(`Entity-state response doc_id mismatch: expected "${expectedDocId}"`);
|
|
2786
|
+
const rows = parseWireRows(value.rows);
|
|
2787
|
+
compile(rows);
|
|
2788
|
+
const attached = value.audio_script_entity_id;
|
|
2789
|
+
if (attached !== null && typeof attached !== "string") throw new Error("Invalid document AudioScript identity");
|
|
2790
|
+
if (typeof value.loro_snapshot !== "string") throw new Error("Missing causal Loro snapshot");
|
|
2791
|
+
const state = {
|
|
2792
|
+
revision: value.revision,
|
|
2793
|
+
loroSnapshot: value.loro_snapshot,
|
|
2794
|
+
rows,
|
|
2795
|
+
audioScriptEntityId: attached
|
|
2796
|
+
};
|
|
2797
|
+
if (attached !== null) readDocumentAudioScript(state);
|
|
2798
|
+
return state;
|
|
2799
|
+
}
|
|
2800
|
+
function parseWireRows(value) {
|
|
2801
|
+
if (!isRecord(value) || !Array.isArray(value.entities) || !Array.isArray(value.relations)) throw new Error("Invalid entity-state response rows");
|
|
2802
|
+
return {
|
|
2803
|
+
entities: value.entities.map(parseWireEntity),
|
|
2804
|
+
relations: value.relations.map(parseWireRelation)
|
|
2805
|
+
};
|
|
2806
|
+
}
|
|
2807
|
+
/** Shape only: `compile` then judges each row as its kind states it. */
|
|
2808
|
+
function parseWireEntity(value) {
|
|
2809
|
+
if (!isRecord(value) || !isRecord(value.payload)) throw new Error("Invalid Entity row in entity-state response");
|
|
2810
|
+
return {
|
|
2811
|
+
entityId: createEntityId(requiredString(value.entity_id, "entity_id")),
|
|
2812
|
+
entityKind: requiredString(value.entity_kind, "entity_kind"),
|
|
2813
|
+
payload: structuredClone(value.payload)
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
function parseWireRelation(value) {
|
|
2817
|
+
if (!isRecord(value)) throw new Error("Invalid Relation row in entity-state response");
|
|
2818
|
+
return {
|
|
2819
|
+
relationId: createRelationId(requiredString(value.relation_id, "relation_id")),
|
|
2820
|
+
relationKind: requiredString(value.relation_kind, "relation_kind"),
|
|
2821
|
+
endpoint0EntityId: createEntityId(requiredString(value.endpoint_0_entity_id, "endpoint_0_entity_id")),
|
|
2822
|
+
endpoint1EntityId: createEntityId(requiredString(value.endpoint_1_entity_id, "endpoint_1_entity_id"))
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
2825
|
+
function cloneAndValidateRows(rows) {
|
|
2826
|
+
const clone = structuredClone(rows);
|
|
2827
|
+
compile(clone);
|
|
2828
|
+
return clone;
|
|
2829
|
+
}
|
|
2830
|
+
function requiredString(value, field) {
|
|
2831
|
+
if (typeof value !== "string" || value.length === 0 || value.trim() !== value) throw new Error(`Invalid ${field} in entity-state response`);
|
|
2832
|
+
return value;
|
|
2833
|
+
}
|
|
2834
|
+
function isRecord(value) {
|
|
2835
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2836
|
+
}
|
|
2837
|
+
function isNonNegativeSafeInteger(value) {
|
|
2838
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
2839
|
+
}
|
|
2840
|
+
async function safeReadJson(response) {
|
|
2841
|
+
const text = await response.text();
|
|
2842
|
+
if (text.length === 0) return null;
|
|
2843
|
+
try {
|
|
2844
|
+
return JSON.parse(text);
|
|
2845
|
+
} catch {
|
|
2846
|
+
return text;
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
//#endregion
|
|
2850
|
+
//#region src/entity-editor/caption-content.ts
|
|
2851
|
+
/** Author Caption content from semantic intent; callers never reconcile variant bases or assembled fields. */
|
|
2852
|
+
function prepareCaptionContent(rows, input) {
|
|
2853
|
+
const entityId = createEntityId(input.contentEntityId);
|
|
2854
|
+
const scriptId = createEntityId(input.audioScriptEntityId);
|
|
2855
|
+
if (!Number.isFinite(input.durationMs) || input.durationMs <= 0) throw new Error("Caption duration must be positive");
|
|
2856
|
+
const previous = rows.entities.find((row) => row.entityId === entityId);
|
|
2857
|
+
if (previous !== void 0 && previous.entityKind !== "caption") throw new Error("Caption content identity has a different kind");
|
|
2858
|
+
let nextBases = [scriptId];
|
|
2859
|
+
if (previous !== void 0) {
|
|
2860
|
+
const source = baseFieldOwner(rows, entityId, "segments");
|
|
2861
|
+
if (source === void 0) throw new Error("Caption composes no AudioScript");
|
|
2862
|
+
const previousBases = basesOf(previous);
|
|
2863
|
+
if (source !== scriptId && !previousBases.includes(source)) throw new Error("Replacing an indirectly composed AudioScript requires an explicit composition change");
|
|
2864
|
+
nextBases = previousBases.map((id) => id === source ? scriptId : id);
|
|
2865
|
+
}
|
|
2866
|
+
const candidate = {
|
|
2867
|
+
entityId,
|
|
2868
|
+
entityKind: "caption",
|
|
2869
|
+
payload: {
|
|
2870
|
+
...previous?.payload,
|
|
2871
|
+
bases: nextBases,
|
|
2872
|
+
durationMs: previous?.payload.durationMs ?? input.durationMs
|
|
2873
|
+
}
|
|
2874
|
+
};
|
|
2875
|
+
const graph = {
|
|
2876
|
+
...rows,
|
|
2877
|
+
entities: [...rows.entities.filter((row) => row.entityId !== entityId), candidate]
|
|
2878
|
+
};
|
|
2879
|
+
const assembled = assembleEntityContent(graph, entityId);
|
|
2880
|
+
const selected = {
|
|
2881
|
+
...candidate,
|
|
2882
|
+
payload: {
|
|
2883
|
+
...candidate.payload,
|
|
2884
|
+
selection: captionSelection(input.selection ?? selectCaptionText(assembled, input.text))
|
|
2885
|
+
}
|
|
2886
|
+
};
|
|
2887
|
+
if (selectedSegment(assembleEntityContent({
|
|
2888
|
+
...graph,
|
|
2889
|
+
entities: graph.entities.map((row) => row.entityId === entityId ? selected : row)
|
|
2890
|
+
}, entityId)).text !== input.text) throw new Error("Caption review text differs from its assembled variant content");
|
|
2891
|
+
return selected;
|
|
2892
|
+
}
|
|
2893
|
+
/** A selection as it is stored: the segment it names and, when narrowed, its code-point range, nothing else. */
|
|
2894
|
+
function captionSelection({ segmentId, textRange }) {
|
|
2895
|
+
return textRange === void 0 ? { segmentId } : {
|
|
2896
|
+
segmentId,
|
|
2897
|
+
textRange: {
|
|
2898
|
+
start: textRange.start,
|
|
2899
|
+
end: textRange.end
|
|
2900
|
+
}
|
|
2901
|
+
};
|
|
2902
|
+
}
|
|
2903
|
+
function selectCaptionText(caption, text) {
|
|
2904
|
+
if (!text.trim()) throw new Error("Caption text selection must not be blank");
|
|
2905
|
+
const matches = [];
|
|
2906
|
+
for (const segment of segmentsOf(caption)) {
|
|
2907
|
+
let offset = segment.text.indexOf(text);
|
|
2908
|
+
while (offset !== -1) {
|
|
2909
|
+
const start = Array.from(segment.text.slice(0, offset)).length;
|
|
2910
|
+
matches.push({
|
|
2911
|
+
segmentId: segment.segmentId,
|
|
2912
|
+
textRange: {
|
|
2913
|
+
start,
|
|
2914
|
+
end: start + Array.from(text).length
|
|
2915
|
+
}
|
|
2916
|
+
});
|
|
2917
|
+
offset = segment.text.indexOf(text, offset + 1);
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
if (matches.length !== 1) throw new Error("Caption text must select a unique AudioScript range; provide explicit selection");
|
|
2921
|
+
return matches[0];
|
|
2922
|
+
}
|
|
2923
|
+
//#endregion
|
|
2924
|
+
//#region src/entity-editor/media-asset-import.ts
|
|
2925
|
+
/**
|
|
2926
|
+
* Import at the editor boundary, not at asset generation time. Existing
|
|
2927
|
+
* provenance is retained.
|
|
2928
|
+
*
|
|
2929
|
+
* The bytes are named once, on an Asset entity, and the media entity reaches
|
|
2930
|
+
* them through a `from-asset` Relation. Re-importing the same `(system, key)`
|
|
2931
|
+
* reuses that Asset and the media already made from it, so one stored resource
|
|
2932
|
+
* is never copied into several rows.
|
|
2933
|
+
*/
|
|
2934
|
+
function importMediaAsset(initial, fact, idFactory = (prefix) => `${prefix}_${globalThis.crypto.randomUUID()}`) {
|
|
2935
|
+
if (!fact.assetId || fact.assetId.trim() !== fact.assetId) throw new Error("Expected an asset id");
|
|
2936
|
+
if (!isMediaAssetVariantKind(fact.kind)) throw new Error("Unsupported media kind");
|
|
2937
|
+
const durationMs = statedDuration(fact);
|
|
2938
|
+
if ((fact.kind === "audio" || fact.kind === "voiceover-recording") && fact.storageKey.trim() === "") throw new Error(`${fact.kind} ${fact.assetId} requires its physical storage key`);
|
|
2939
|
+
compile(initial);
|
|
2940
|
+
assertCanonicalEditorResources(initial);
|
|
2941
|
+
const entities = structuredClone([...initial.entities]);
|
|
2942
|
+
const relations = structuredClone([...initial.relations]);
|
|
2943
|
+
const system = fact.kind === "voiceover-recording" ? "memota-speech" : "memota";
|
|
2944
|
+
const entityKind = fact.kind;
|
|
2945
|
+
const timbre = fact.kind === "voiceover-recording" ? fact.voice : void 0;
|
|
2946
|
+
const asset = ensureAsset(entities, {
|
|
2947
|
+
system,
|
|
2948
|
+
key: fact.assetId,
|
|
2949
|
+
storageKey: fact.storageKey
|
|
2950
|
+
}, idFactory);
|
|
2951
|
+
const madeFromAsset = entities.filter((row) => row.entityId !== asset.entityId && assetIdOf(relations, row.entityId) === asset.entityId);
|
|
2952
|
+
if (madeFromAsset.length > 1) throw new Error(`Duplicate media identities for ${fact.assetId}; reuse ${madeFromAsset[0].entityId}`);
|
|
2953
|
+
const existing = madeFromAsset[0];
|
|
2954
|
+
if (existing !== void 0) {
|
|
2955
|
+
if (existing.entityKind !== entityKind || existing.payload.durationMs !== durationMs) throw new Error(`Media facts conflict with existing identity for ${fact.assetId}`);
|
|
2956
|
+
const voiceEntityId = bindVoiceTimbre(entities, relations, existing.entityId, timbre, idFactory);
|
|
2957
|
+
return {
|
|
2958
|
+
rows: {
|
|
2959
|
+
entities,
|
|
2960
|
+
relations
|
|
2961
|
+
},
|
|
2962
|
+
contentEntityId: existing.entityId,
|
|
2963
|
+
assetEntityId: asset.entityId,
|
|
2964
|
+
...voiceEntityId
|
|
2965
|
+
};
|
|
2966
|
+
}
|
|
2967
|
+
const entityId = createEntityId(idFactory("entity"));
|
|
2968
|
+
if (entities.some((row) => row.entityId === entityId)) throw new Error(`Duplicate entity id ${entityId}`);
|
|
2969
|
+
entities.push({
|
|
2970
|
+
entityId,
|
|
2971
|
+
entityKind,
|
|
2972
|
+
payload: { durationMs }
|
|
2973
|
+
});
|
|
2974
|
+
relations.push(assetLink(entityId, asset.entityId, idFactory));
|
|
2975
|
+
if (fact.kind === "voiceover-recording" && fact.phoneticScriptEntityId !== void 0) {
|
|
2976
|
+
const phonetic = entities.find((row) => String(row.entityId) === fact.phoneticScriptEntityId);
|
|
2977
|
+
if (phonetic?.entityKind !== "phonetic-script") throw new Error(`Voiceover ${fact.assetId} requires the PhoneticScript it was spoken from`);
|
|
2978
|
+
relations.push({
|
|
2979
|
+
relationId: createRelationId(idFactory("relation")),
|
|
2980
|
+
relationKind: "derived-from",
|
|
2981
|
+
endpoint0EntityId: entityId,
|
|
2982
|
+
endpoint1EntityId: phonetic.entityId
|
|
2983
|
+
});
|
|
2984
|
+
}
|
|
2985
|
+
const voiceEntityId = bindVoiceTimbre(entities, relations, entityId, timbre, idFactory);
|
|
2986
|
+
const rows = {
|
|
2987
|
+
entities,
|
|
2988
|
+
relations
|
|
2989
|
+
};
|
|
2990
|
+
compile(rows);
|
|
2991
|
+
assertCanonicalEditorResources(rows);
|
|
2992
|
+
return {
|
|
2993
|
+
rows,
|
|
2994
|
+
contentEntityId: entityId,
|
|
2995
|
+
assetEntityId: asset.entityId,
|
|
2996
|
+
...voiceEntityId
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
/**
|
|
3000
|
+
* One Asset per `(system, key)`: the bytes are named in exactly one row.
|
|
3001
|
+
*
|
|
3002
|
+
* Imported media is a store's own, which the Asset says by stating no
|
|
3003
|
+
* `writable` — unlike a snapshot the document wrote out of its own content.
|
|
3004
|
+
*/
|
|
3005
|
+
function ensureAsset(entities, locator, idFactory) {
|
|
3006
|
+
const found = entities.find((row) => row.entityKind === "asset" && row.payload.system === locator.system && row.payload.key === locator.key);
|
|
3007
|
+
if (found !== void 0) {
|
|
3008
|
+
if (locator.storageKey === void 0 || found.payload.storageKey === locator.storageKey) return found;
|
|
3009
|
+
if (found.payload.storageKey !== void 0) throw new Error(`Media facts conflict with existing storage key for ${locator.key}`);
|
|
3010
|
+
const filled = {
|
|
3011
|
+
...found,
|
|
3012
|
+
payload: {
|
|
3013
|
+
...found.payload,
|
|
3014
|
+
storageKey: locator.storageKey
|
|
3015
|
+
}
|
|
3016
|
+
};
|
|
3017
|
+
replaceEntity$1(entities, found.entityId, filled);
|
|
3018
|
+
return filled;
|
|
3019
|
+
}
|
|
3020
|
+
const asset = {
|
|
3021
|
+
entityId: createEntityId(idFactory("entity")),
|
|
3022
|
+
entityKind: "asset",
|
|
3023
|
+
payload: {
|
|
3024
|
+
system: locator.system,
|
|
3025
|
+
key: locator.key,
|
|
3026
|
+
...locator.storageKey === void 0 ? {} : { storageKey: locator.storageKey }
|
|
3027
|
+
}
|
|
3028
|
+
};
|
|
3029
|
+
entities.push(asset);
|
|
3030
|
+
return asset;
|
|
3031
|
+
}
|
|
3032
|
+
function assetLink(entityId, assetEntityId, idFactory) {
|
|
3033
|
+
return {
|
|
3034
|
+
relationId: createRelationId(idFactory("relation")),
|
|
3035
|
+
relationKind: "from-asset",
|
|
3036
|
+
endpoint0EntityId: createEntityId(entityId),
|
|
3037
|
+
endpoint1EntityId: createEntityId(assetEntityId)
|
|
3038
|
+
};
|
|
3039
|
+
}
|
|
3040
|
+
function assetIdOf(relations, entityId) {
|
|
3041
|
+
const link = relations.find((relation) => relation.relationKind === "from-asset" && (relation.endpoint0EntityId === entityId || relation.endpoint1EntityId === entityId));
|
|
3042
|
+
if (link === void 0) return void 0;
|
|
3043
|
+
return String(link.endpoint0EntityId === entityId ? link.endpoint1EntityId : link.endpoint0EntityId);
|
|
3044
|
+
}
|
|
3045
|
+
/**
|
|
3046
|
+
* Attach the timbre a synthesized voiceover was rendered with. One Voice
|
|
3047
|
+
* identity is shared by every take naming the same library entry, so an
|
|
3048
|
+
* existing row is reused rather than duplicated. Recorded media declares no
|
|
3049
|
+
* timbre and keeps no link.
|
|
3050
|
+
*/
|
|
3051
|
+
function bindVoiceTimbre(entities, relations, audioEntityId, descriptor, idFactory) {
|
|
3052
|
+
const linked = relations.filter((relation) => relation.relationKind === "voice-timbre" && (relation.endpoint0EntityId === audioEntityId || relation.endpoint1EntityId === audioEntityId));
|
|
3053
|
+
if (linked.length > 1) throw new Error(`Voiceover ${audioEntityId} has multiple Voice timbre relations`);
|
|
3054
|
+
if (descriptor === void 0) {
|
|
3055
|
+
if (linked.length > 0) throw new Error(`Recorded media ${audioEntityId} cannot keep a synthesized Voice timbre`);
|
|
3056
|
+
return {};
|
|
3057
|
+
}
|
|
3058
|
+
const voice = entities.find((row) => row.entityKind === "voice" && sameVoiceDescriptor(assembleEntityContent({
|
|
3059
|
+
entities,
|
|
3060
|
+
relations
|
|
3061
|
+
}, row.entityId, "voice").payload.voice, descriptor)) ?? appendVoiceIdentity(entities, descriptor, idFactory);
|
|
3062
|
+
const existing = linked[0];
|
|
3063
|
+
if (existing === void 0) relations.push({
|
|
3064
|
+
relationId: createRelationId(idFactory("relation")),
|
|
3065
|
+
relationKind: "voice-timbre",
|
|
3066
|
+
endpoint0EntityId: createEntityId(audioEntityId),
|
|
3067
|
+
endpoint1EntityId: createEntityId(voice.entityId)
|
|
3068
|
+
});
|
|
3069
|
+
else if (existing.endpoint0EntityId !== audioEntityId || existing.endpoint1EntityId !== voice.entityId) throw new Error(`Voiceover ${audioEntityId} already carries a different Voice timbre`);
|
|
3070
|
+
return { voiceEntityId: voice.entityId };
|
|
3071
|
+
}
|
|
3072
|
+
function appendVoiceIdentity(entities, descriptor, idFactory) {
|
|
3073
|
+
const voice = {
|
|
3074
|
+
entityId: createEntityId(idFactory("entity")),
|
|
3075
|
+
entityKind: "voice",
|
|
3076
|
+
payload: { voice: {
|
|
3077
|
+
system: descriptor.system,
|
|
3078
|
+
key: descriptor.key,
|
|
3079
|
+
...descriptor.name === void 0 ? {} : { name: descriptor.name }
|
|
3080
|
+
} }
|
|
3081
|
+
};
|
|
3082
|
+
entities.push(voice);
|
|
3083
|
+
return voice;
|
|
3084
|
+
}
|
|
3085
|
+
/** Two takes share a Voice identity only when they name the same library entry. */
|
|
3086
|
+
function sameVoiceDescriptor(stored, descriptor) {
|
|
3087
|
+
return stored.system === descriptor.system && stored.key === descriptor.key && stored.name === descriptor.name;
|
|
3088
|
+
}
|
|
3089
|
+
function replaceEntity$1(entities, entityId, replacement) {
|
|
3090
|
+
const index = entities.findIndex((row) => row.entityId === entityId);
|
|
3091
|
+
if (index < 0) throw new Error(`Entity id ${entityId} does not exist`);
|
|
3092
|
+
entities[index] = replacement;
|
|
3093
|
+
}
|
|
3094
|
+
/** The length a media fact states: whole milliseconds, or `null` for infinite. */
|
|
3095
|
+
function statedDuration(fact) {
|
|
3096
|
+
if (fact.durationMs === null) return null;
|
|
3097
|
+
if (!(Number.isFinite(fact.durationMs) && fact.durationMs > 0)) throw new Error(`${fact.kind} ${fact.assetId} requires its factual positive duration`);
|
|
3098
|
+
return fact.durationMs;
|
|
3099
|
+
}
|
|
3100
|
+
//#endregion
|
|
3101
|
+
//#region src/entity-editor/entity-timeline-editor.ts
|
|
3102
|
+
/**
|
|
3103
|
+
* Resolve the AudioScript a PhoneticScript composes: the base that supplies its
|
|
3104
|
+
* segments. The composition is the required text source, so a variant that
|
|
3105
|
+
* reads no segments has no content and fails here.
|
|
3106
|
+
*/
|
|
3107
|
+
function requireComposedAudioScript(rows, phoneticScriptEntityId) {
|
|
3108
|
+
segmentsOf(assembleEntityContent(rows, phoneticScriptEntityId));
|
|
3109
|
+
const owner = baseFieldOwner(rows, phoneticScriptEntityId, "segments");
|
|
3110
|
+
if (owner === void 0) throw new Error(`PhoneticScript "${phoneticScriptEntityId}" composes no AudioScript`);
|
|
3111
|
+
return owner;
|
|
3112
|
+
}
|
|
3113
|
+
/** Placing a medium whole runs it to its end, which an infinite medium does not have. */
|
|
3114
|
+
function wholeLength(media) {
|
|
3115
|
+
if (media.durationMs === null) throw new Error(`${media.kind} ${media.assetId} is infinite and cannot be placed whole`);
|
|
3116
|
+
return media.durationMs;
|
|
3117
|
+
}
|
|
3118
|
+
function captionBaseIds(ids) {
|
|
3119
|
+
if (!Array.isArray(ids) || ids.length === 0) throw new Error("Caption requires non-empty bases");
|
|
3120
|
+
return ids.map(createEntityId);
|
|
3121
|
+
}
|
|
3122
|
+
function assertCaptionScript(rows, captionId, scriptId) {
|
|
3123
|
+
if (baseFieldOwner(rows, captionId, "segments") !== scriptId) throw new Error("Voiceover Caption must compose the same AudioScript as the PhoneticScript");
|
|
3124
|
+
}
|
|
3125
|
+
const defaultIdFactory = (prefix) => `${prefix}_${globalThis.crypto.randomUUID()}`;
|
|
3126
|
+
/**
|
|
3127
|
+
* Graph-native timeline authoring over authoritative Medeo Entity and Relation rows.
|
|
3128
|
+
*
|
|
3129
|
+
* Each mutation is applied to a detached graph, validated as a complete DSL set,
|
|
3130
|
+
* and committed only after validation succeeds.
|
|
3131
|
+
*/
|
|
3132
|
+
var EntityTimelineEditor = class {
|
|
3133
|
+
idFactory;
|
|
3134
|
+
state;
|
|
3135
|
+
constructor(initialRows, idFactory = defaultIdFactory) {
|
|
3136
|
+
this.idFactory = idFactory;
|
|
3137
|
+
this.state = cloneRows(initialRows);
|
|
3138
|
+
}
|
|
3139
|
+
rows() {
|
|
3140
|
+
return cloneRows(this.state);
|
|
3141
|
+
}
|
|
3142
|
+
insertClip(input) {
|
|
3143
|
+
const requestedOrder = input.order;
|
|
3144
|
+
const order = typeof requestedOrder === "number" ? requestedOrder : nextSequentialOrder(this.state, createEntityId(input.trackEntityId));
|
|
3145
|
+
return this.insertPlacedClip({
|
|
3146
|
+
trackEntityId: input.trackEntityId,
|
|
3147
|
+
contentEntityId: input.contentEntityId,
|
|
3148
|
+
inMs: input.inMs,
|
|
3149
|
+
outMs: input.outMs,
|
|
3150
|
+
placement: input.startMs === void 0 ? {
|
|
3151
|
+
kind: "sequential",
|
|
3152
|
+
order
|
|
3153
|
+
} : {
|
|
3154
|
+
kind: "absolute",
|
|
3155
|
+
startMs: input.startMs
|
|
3156
|
+
},
|
|
3157
|
+
clipPayload: input.clipPayload
|
|
3158
|
+
});
|
|
3159
|
+
}
|
|
3160
|
+
insertPlacedClip(input) {
|
|
3161
|
+
return this.transact((draft) => this.insertPlacedClipIntoDraft(draft, input));
|
|
3162
|
+
}
|
|
3163
|
+
updateClipMarker(input) {
|
|
3164
|
+
this.transact((draft) => {
|
|
3165
|
+
const { marker } = requireClipTopology(draft, input.clipEntityId);
|
|
3166
|
+
const payload = structuredClone(marker.payload);
|
|
3167
|
+
if (input.inMs !== void 0) payload.inMs = finiteNonNegative(input.inMs, "inMs");
|
|
3168
|
+
if (input.outMs !== void 0) payload.outMs = finitePositive(input.outMs, "outMs");
|
|
3169
|
+
if (Object.hasOwn(input, "startMs")) if (input.startMs === void 0) {
|
|
3170
|
+
delete payload.startMs;
|
|
3171
|
+
if (!hasOutgoingClipAnchor(draft, input.clipEntityId)) {}
|
|
3172
|
+
} else payload.startMs = finiteNonNegative(input.startMs, "startMs");
|
|
3173
|
+
if (Object.hasOwn(input, "rate")) if (input.rate === void 0) delete payload.rate;
|
|
3174
|
+
else payload.rate = finitePositive(input.rate, "linear speed rate");
|
|
3175
|
+
replaceEntity(draft, marker.entityId, {
|
|
3176
|
+
...marker,
|
|
3177
|
+
payload
|
|
3178
|
+
});
|
|
3179
|
+
});
|
|
3180
|
+
}
|
|
3181
|
+
setClipPlacement(input) {
|
|
3182
|
+
this.transact((draft) => this.applyClipPlacement(draft, input.clipEntityId, input.placement));
|
|
3183
|
+
}
|
|
3184
|
+
moveSequentialClips(input) {
|
|
3185
|
+
if (input.clipEntityIds.length === 0) throw new Error("At least one Clip is required");
|
|
3186
|
+
this.transact((draft) => {
|
|
3187
|
+
const original = resolveEntityTimelineLayout(draft);
|
|
3188
|
+
this.normalizeMainSequential(draft);
|
|
3189
|
+
const before = resolveEntityTimelineLayout(draft);
|
|
3190
|
+
const targetIds = new Set(input.clipEntityIds);
|
|
3191
|
+
if (targetIds.size !== input.clipEntityIds.length) throw new Error("Clip ids must be unique");
|
|
3192
|
+
const placements = input.clipEntityIds.map((id) => before.clips.get(id));
|
|
3193
|
+
if (placements.some((placement) => placement === void 0 || placement.mode !== "sequential")) throw new Error("moveSequentialClips accepts only sequential Clips");
|
|
3194
|
+
const trackIds = new Set(placements.map((placement) => placement.trackEntityId));
|
|
3195
|
+
if (trackIds.size !== 1) throw new Error("Moved Clips must belong to one Track");
|
|
3196
|
+
const trackId = [...trackIds][0];
|
|
3197
|
+
const track = before.tracks.find((candidate) => candidate.entityId === trackId);
|
|
3198
|
+
if (track?.role !== "video_clip") throw new Error("Only the video_clip Track supports sequential block moves");
|
|
3199
|
+
const ordered = track.clips.filter((placement) => placement.mode === "sequential").map((placement) => placement.clipEntityId);
|
|
3200
|
+
if (input.anchor.position !== "trackStart" && targetIds.has(input.anchor.clipEntityId)) throw new Error("Move anchor cannot be one of the moved Clips");
|
|
3201
|
+
const moved = ordered.filter((id) => targetIds.has(id));
|
|
3202
|
+
if (moved.length !== targetIds.size) throw new Error("Every moved Clip must exist on the target Track");
|
|
3203
|
+
const remaining = ordered.filter((id) => !targetIds.has(id));
|
|
3204
|
+
let insertAt = 0;
|
|
3205
|
+
if (input.anchor.position !== "trackStart") {
|
|
3206
|
+
const anchorIndex = remaining.indexOf(input.anchor.clipEntityId);
|
|
3207
|
+
if (anchorIndex < 0) throw new Error("Move anchor Clip does not exist on the target Track");
|
|
3208
|
+
insertAt = anchorIndex + (input.anchor.position === "after" ? 1 : 0);
|
|
3209
|
+
}
|
|
3210
|
+
[
|
|
3211
|
+
...remaining.slice(0, insertAt),
|
|
3212
|
+
...moved,
|
|
3213
|
+
...remaining.slice(insertAt)
|
|
3214
|
+
].forEach((clipId, order) => setClipOrder(draft, clipId, order));
|
|
3215
|
+
if (input.onAnchored === "keepAbsolute") {
|
|
3216
|
+
const affected = outgoingAnchorChildren(draft, new Set(track.clips.map((clip) => clip.clipEntityId))).filter((childId) => clipRole(draft, childId) === "speech").map((childId) => {
|
|
3217
|
+
const placement = original.clips.get(childId);
|
|
3218
|
+
if (placement === void 0) throw new Error(`Anchored child Clip "${childId}" is not placed`);
|
|
3219
|
+
return {
|
|
3220
|
+
childId,
|
|
3221
|
+
start: placement.start
|
|
3222
|
+
};
|
|
3223
|
+
});
|
|
3224
|
+
const after = resolveEntityTimelineLayout(draft);
|
|
3225
|
+
for (const child of affected) this.reanchorAtAbsolute(draft, child.childId, child.start, after);
|
|
3226
|
+
}
|
|
3227
|
+
this.resolveOneVoiceoverOverlap(draft);
|
|
3228
|
+
});
|
|
3229
|
+
}
|
|
3230
|
+
moveClipsToStarts(input) {
|
|
3231
|
+
if (input.moves.length === 0) throw new Error("At least one Clip move is required");
|
|
3232
|
+
this.transact((draft) => {
|
|
3233
|
+
const original = resolveEntityTimelineLayout(draft);
|
|
3234
|
+
const originalVideo = original.tracks.find((track) => track.role === "video_clip");
|
|
3235
|
+
if (originalVideo === void 0) throw new Error("The timeline has no video_clip Track");
|
|
3236
|
+
const preservedVoiceovers = outgoingAnchorChildren(draft, new Set(originalVideo.clips.map((clip) => clip.clipEntityId))).filter((clipId) => clipRole(draft, clipId) === "speech").map((clipId) => ({
|
|
3237
|
+
clipId,
|
|
3238
|
+
start: original.clips.get(clipId).start
|
|
3239
|
+
}));
|
|
3240
|
+
this.normalizeMainSequential(draft);
|
|
3241
|
+
for (const move of input.moves) {
|
|
3242
|
+
finiteNonNegative(move.newStartMs, "newStartMs");
|
|
3243
|
+
requireClipOnRole(draft, move.clipEntityId, "video_clip");
|
|
3244
|
+
const track = resolveEntityTimelineLayout(draft).tracks.find((candidate) => candidate.role === "video_clip");
|
|
3245
|
+
const ordered = track.clips.map((clip) => clip.clipEntityId);
|
|
3246
|
+
const from = ordered.indexOf(move.clipEntityId);
|
|
3247
|
+
if (from < 0) throw new Error(`Clip "${move.clipEntityId}" is not on the video_clip Track`);
|
|
3248
|
+
const remaining = track.clips.filter((clip) => clip.clipEntityId !== move.clipEntityId);
|
|
3249
|
+
let cursor = 0;
|
|
3250
|
+
let target = remaining.length;
|
|
3251
|
+
for (const [index, clip] of remaining.entries()) {
|
|
3252
|
+
if (move.newStartMs <= cursor + clip.duration / 2) {
|
|
3253
|
+
target = index;
|
|
3254
|
+
break;
|
|
3255
|
+
}
|
|
3256
|
+
cursor += clip.duration;
|
|
3257
|
+
}
|
|
3258
|
+
const next = [...ordered];
|
|
3259
|
+
next.splice(from, 1);
|
|
3260
|
+
next.splice(target, 0, move.clipEntityId);
|
|
3261
|
+
next.forEach((clipId, order) => setClipOrder(draft, clipId, order));
|
|
3262
|
+
}
|
|
3263
|
+
const after = resolveEntityTimelineLayout(draft);
|
|
3264
|
+
for (const voiceover of preservedVoiceovers) this.reanchorAtAbsolute(draft, voiceover.clipId, voiceover.start, after);
|
|
3265
|
+
this.resolveOneVoiceoverOverlap(draft);
|
|
3266
|
+
});
|
|
3267
|
+
}
|
|
3268
|
+
moveClip(input) {
|
|
3269
|
+
this.transact((draft) => {
|
|
3270
|
+
const topology = requireClipTopology(draft, input.clipEntityId);
|
|
3271
|
+
const track = requireEntityKind(draft, input.trackEntityId, "track");
|
|
3272
|
+
requireTrackTimeline(draft, track.entityId);
|
|
3273
|
+
this.relinkRelation(draft, topology.trackRelation, {
|
|
3274
|
+
endpoint0EntityId: track.entityId,
|
|
3275
|
+
endpoint1EntityId: topology.clip.entityId
|
|
3276
|
+
});
|
|
3277
|
+
});
|
|
3278
|
+
}
|
|
3279
|
+
replaceClipContent(input) {
|
|
3280
|
+
this.transact((draft) => {
|
|
3281
|
+
const topology = requireClipTopology(draft, input.clipEntityId);
|
|
3282
|
+
const content = requireSequenceContent(draft, input.contentEntityId);
|
|
3283
|
+
const payload = {
|
|
3284
|
+
...structuredClone(topology.marker.payload),
|
|
3285
|
+
...requireWindow(input.inMs, input.outMs)
|
|
3286
|
+
};
|
|
3287
|
+
delete payload.startMs;
|
|
3288
|
+
delete payload.rate;
|
|
3289
|
+
if (input.startMs !== void 0) payload.startMs = finiteNonNegative(input.startMs, "startMs");
|
|
3290
|
+
if (input.rate !== void 0) payload.rate = finitePositive(input.rate, "linear speed rate");
|
|
3291
|
+
replaceEntity(draft, topology.marker.entityId, {
|
|
3292
|
+
...topology.marker,
|
|
3293
|
+
payload
|
|
3294
|
+
});
|
|
3295
|
+
this.relinkRelation(draft, topology.contentRelation, {
|
|
3296
|
+
endpoint0EntityId: topology.clip.entityId,
|
|
3297
|
+
endpoint1EntityId: content.entityId
|
|
3298
|
+
});
|
|
3299
|
+
});
|
|
3300
|
+
}
|
|
3301
|
+
insertMediaClip(input) {
|
|
3302
|
+
return this.transact((draft) => {
|
|
3303
|
+
if (input.placement.kind === "sequential") this.normalizeMainSequential(draft);
|
|
3304
|
+
const imported = importMediaAsset(draft, input.media, this.idFactory);
|
|
3305
|
+
replaceRows(draft, imported.rows);
|
|
3306
|
+
const track = this.ensureRoleTrack(draft, input.timelineEntityId, "video_clip");
|
|
3307
|
+
const window = requireWindow(input.inMs, input.outMs);
|
|
3308
|
+
const clipId = this.insertPlacedClipIntoDraft(draft, {
|
|
3309
|
+
trackEntityId: track.entityId,
|
|
3310
|
+
contentEntityId: imported.contentEntityId,
|
|
3311
|
+
...window,
|
|
3312
|
+
placement: input.placement,
|
|
3313
|
+
...input.clipEntityId === void 0 ? {} : { clipEntityId: input.clipEntityId },
|
|
3314
|
+
...input.volume === void 0 ? {} : { clipPayload: { volume: requireVolume(input.volume) } }
|
|
3315
|
+
});
|
|
3316
|
+
if (input.placement.kind === "sequential") this.resolveOneVoiceoverOverlap(draft);
|
|
3317
|
+
return clipId;
|
|
3318
|
+
});
|
|
3319
|
+
}
|
|
3320
|
+
insertMediaClips(input) {
|
|
3321
|
+
if (input.clips.length === 0) throw new Error("At least one media Clip is required");
|
|
3322
|
+
return this.transact((draft) => {
|
|
3323
|
+
this.normalizeMainSequential(draft);
|
|
3324
|
+
const track = this.ensureRoleTrack(draft, input.timelineEntityId, "video_clip");
|
|
3325
|
+
const ordered = resolveEntityTimelineLayout(draft).tracks.find((candidate) => candidate.entityId === track.entityId)?.clips ?? [];
|
|
3326
|
+
let insertAt = ordered.length;
|
|
3327
|
+
const insertion = input.insertion;
|
|
3328
|
+
if (insertion.kind === "before" || insertion.kind === "after") {
|
|
3329
|
+
const anchorIndex = ordered.findIndex((clip) => clip.clipEntityId === insertion.clipEntityId);
|
|
3330
|
+
if (anchorIndex >= 0) insertAt = anchorIndex + (insertion.kind === "after" ? 1 : 0);
|
|
3331
|
+
} else {
|
|
3332
|
+
const startMs = finiteNonNegative(insertion.startMs, "firstStart.startMs");
|
|
3333
|
+
let cursor = 0;
|
|
3334
|
+
for (const [index, clip] of ordered.entries()) {
|
|
3335
|
+
if (startMs <= cursor + clip.duration / 2) {
|
|
3336
|
+
insertAt = index;
|
|
3337
|
+
break;
|
|
3338
|
+
}
|
|
3339
|
+
cursor += clip.duration;
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
const created = [];
|
|
3343
|
+
for (const [offset, clip] of input.clips.entries()) {
|
|
3344
|
+
const imported = importMediaAsset(draft, clip.media, this.idFactory);
|
|
3345
|
+
replaceRows(draft, imported.rows);
|
|
3346
|
+
const window = requireWindow(clip.inMs, clip.outMs);
|
|
3347
|
+
created.push(this.insertPlacedClipIntoDraft(draft, {
|
|
3348
|
+
trackEntityId: track.entityId,
|
|
3349
|
+
contentEntityId: imported.contentEntityId,
|
|
3350
|
+
...window,
|
|
3351
|
+
placement: {
|
|
3352
|
+
kind: "sequential",
|
|
3353
|
+
order: ordered.length + offset
|
|
3354
|
+
},
|
|
3355
|
+
...clip.clipEntityId === void 0 ? {} : { clipEntityId: clip.clipEntityId },
|
|
3356
|
+
...clip.volume === void 0 ? {} : { clipPayload: { volume: requireVolume(clip.volume) } }
|
|
3357
|
+
}));
|
|
3358
|
+
}
|
|
3359
|
+
const desired = ordered.map((clip) => clip.clipEntityId);
|
|
3360
|
+
desired.splice(insertAt, 0, ...created);
|
|
3361
|
+
desired.forEach((clipId, order) => setClipOrder(draft, clipId, order));
|
|
3362
|
+
this.resolveOneVoiceoverOverlap(draft);
|
|
3363
|
+
return created;
|
|
3364
|
+
});
|
|
3365
|
+
}
|
|
3366
|
+
replaceMediaClip(input) {
|
|
3367
|
+
this.transact((draft) => {
|
|
3368
|
+
this.normalizeMainSequential(draft);
|
|
3369
|
+
const topology = requireClipOnRole(draft, input.clipEntityId, "video_clip");
|
|
3370
|
+
const imported = importMediaAsset(draft, input.media, this.idFactory);
|
|
3371
|
+
replaceRows(draft, imported.rows);
|
|
3372
|
+
const window = requireWindow(input.inMs, input.outMs);
|
|
3373
|
+
const markerPayloadValue = {
|
|
3374
|
+
...structuredClone(topology.marker.payload),
|
|
3375
|
+
...window
|
|
3376
|
+
};
|
|
3377
|
+
delete markerPayloadValue.rate;
|
|
3378
|
+
replaceEntity(draft, topology.marker.entityId, {
|
|
3379
|
+
...topology.marker,
|
|
3380
|
+
payload: markerPayloadValue
|
|
3381
|
+
});
|
|
3382
|
+
this.relinkRelation(draft, topology.contentRelation, {
|
|
3383
|
+
endpoint0EntityId: topology.clip.entityId,
|
|
3384
|
+
endpoint1EntityId: createEntityId(imported.contentEntityId)
|
|
3385
|
+
});
|
|
3386
|
+
this.resolveOneVoiceoverOverlap(draft);
|
|
3387
|
+
});
|
|
3388
|
+
}
|
|
3389
|
+
setClipVolume(input) {
|
|
3390
|
+
this.transact((draft) => {
|
|
3391
|
+
const topology = requireClipTopology(draft, input.clipEntityId);
|
|
3392
|
+
requireSupportedRole(draft, topology);
|
|
3393
|
+
replaceEntity(draft, topology.clip.entityId, {
|
|
3394
|
+
...topology.clip,
|
|
3395
|
+
payload: {
|
|
3396
|
+
...topology.clip.payload,
|
|
3397
|
+
volume: requireVolume(input.volume)
|
|
3398
|
+
}
|
|
3399
|
+
});
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
setClipSpeed(input) {
|
|
3403
|
+
this.transact((draft) => {
|
|
3404
|
+
const initialTopology = requireClipOnRole(draft, input.clipEntityId, "video_clip");
|
|
3405
|
+
const content = requireSequenceContent(draft, otherEndpoint(initialTopology.contentRelation, initialTopology.clip.entityId));
|
|
3406
|
+
if (content.entityKind !== "video" && content.entityKind !== "image") throw new Error("Clip speed is supported only for Video or Image content");
|
|
3407
|
+
const initialPayload = fieldsOf(draft, initialTopology.marker.entityId, "sequence-marker");
|
|
3408
|
+
const priorWindow = markerWindow(initialPayload);
|
|
3409
|
+
if (input.rate === null && initialPayload.rate === void 0) return;
|
|
3410
|
+
this.normalizeMainSequential(draft);
|
|
3411
|
+
const topology = requireClipTopology(draft, input.clipEntityId);
|
|
3412
|
+
const payload = { ...structuredClone(topology.marker.payload) };
|
|
3413
|
+
const window = content.entityKind === "image" && initialPayload.rate === void 0 && input.rate !== null ? {
|
|
3414
|
+
inMs: 0,
|
|
3415
|
+
outMs: markerDuration(initialPayload, priorWindow)
|
|
3416
|
+
} : priorWindow;
|
|
3417
|
+
Object.assign(payload, window);
|
|
3418
|
+
if (input.rate === null) delete payload.rate;
|
|
3419
|
+
else {
|
|
3420
|
+
const rate = finitePositive(input.rate, "linear speed rate");
|
|
3421
|
+
const duration = Math.round((window.outMs - window.inMs) / rate);
|
|
3422
|
+
if (duration <= 0 || !Number.isSafeInteger(duration)) throw new Error("Linear speed produces an invalid duration");
|
|
3423
|
+
payload.rate = rate;
|
|
3424
|
+
}
|
|
3425
|
+
replaceEntity(draft, topology.marker.entityId, {
|
|
3426
|
+
...topology.marker,
|
|
3427
|
+
payload
|
|
3428
|
+
});
|
|
3429
|
+
this.resolveOneVoiceoverOverlap(draft);
|
|
3430
|
+
});
|
|
3431
|
+
}
|
|
3432
|
+
trimClip(input) {
|
|
3433
|
+
this.transact((draft) => {
|
|
3434
|
+
this.normalizeMainSequential(draft);
|
|
3435
|
+
const topology = requireClipOnRole(draft, input.clipEntityId, "video_clip");
|
|
3436
|
+
const content = requireSequenceContent(draft, otherEndpoint(topology.contentRelation, topology.clip.entityId));
|
|
3437
|
+
if (content.entityKind !== "video" && content.entityKind !== "image") throw new Error("Clip trimming is supported only for Video or Image content");
|
|
3438
|
+
const window = requireWindow(input.inMs, input.outMs);
|
|
3439
|
+
const payload = {
|
|
3440
|
+
...structuredClone(topology.marker.payload),
|
|
3441
|
+
...window
|
|
3442
|
+
};
|
|
3443
|
+
const duration = markerDuration({
|
|
3444
|
+
...fieldsOf(draft, topology.marker.entityId, "sequence-marker"),
|
|
3445
|
+
...window
|
|
3446
|
+
});
|
|
3447
|
+
if (duration <= 0 || !Number.isSafeInteger(duration)) throw new Error("Trim produces an invalid duration");
|
|
3448
|
+
replaceEntity(draft, topology.marker.entityId, {
|
|
3449
|
+
...topology.marker,
|
|
3450
|
+
payload
|
|
3451
|
+
});
|
|
3452
|
+
this.resolveOneVoiceoverOverlap(draft);
|
|
3453
|
+
});
|
|
3454
|
+
}
|
|
3455
|
+
replaceSequentialClips(input) {
|
|
3456
|
+
if (input.oldClipEntityIds.length === 0 || input.newClips.length === 0) throw new Error("Replacement requires at least one old and one new Clip");
|
|
3457
|
+
return this.transact((draft) => {
|
|
3458
|
+
this.normalizeMainSequential(draft);
|
|
3459
|
+
const before = resolveEntityTimelineLayout(draft);
|
|
3460
|
+
const oldIds = new Set(input.oldClipEntityIds);
|
|
3461
|
+
if (oldIds.size !== input.oldClipEntityIds.length) throw new Error("Old Clip ids must be unique");
|
|
3462
|
+
const track = this.ensureRoleTrack(draft, input.timelineEntityId, "video_clip");
|
|
3463
|
+
const ordered = before.tracks.find((candidate) => candidate.entityId === track.entityId)?.clips.filter((clip) => clip.mode === "sequential").map((clip) => clip.clipEntityId);
|
|
3464
|
+
if (ordered === void 0) throw new Error("The video_clip Track is not present in the current layout");
|
|
3465
|
+
const indexes = input.oldClipEntityIds.map((id) => ordered.indexOf(id));
|
|
3466
|
+
if (indexes.some((index) => index < 0)) throw new Error("Every replaced Clip must be sequential on video_clip");
|
|
3467
|
+
const sortedIndexes = [...indexes].sort((left, right) => left - right);
|
|
3468
|
+
if (sortedIndexes.some((index, offset) => index !== sortedIndexes[0] + offset)) throw new Error("Replaced Clips must form one contiguous timeline run");
|
|
3469
|
+
if (input.oldClipEntityIds.some((id, index) => id !== ordered[sortedIndexes[0] + index])) throw new Error("Replaced Clip ids must be listed in timeline order");
|
|
3470
|
+
const insertOrder = sortedIndexes[0];
|
|
3471
|
+
const temporaryOrder = ordered.length;
|
|
3472
|
+
const created = [];
|
|
3473
|
+
for (const [offset, replacement] of input.newClips.entries()) {
|
|
3474
|
+
if (replacement.clipEntityId !== void 0 && oldIds.has(replacement.clipEntityId)) throw new Error("A replacement Clip id cannot reuse an Entity being removed");
|
|
3475
|
+
const imported = importMediaAsset(draft, replacement.media, this.idFactory);
|
|
3476
|
+
replaceRows(draft, imported.rows);
|
|
3477
|
+
const window = requireWindow(replacement.inMs, replacement.outMs);
|
|
3478
|
+
created.push(this.insertPlacedClipIntoDraft(draft, {
|
|
3479
|
+
trackEntityId: track.entityId,
|
|
3480
|
+
contentEntityId: imported.contentEntityId,
|
|
3481
|
+
...window,
|
|
3482
|
+
placement: {
|
|
3483
|
+
kind: "sequential",
|
|
3484
|
+
order: temporaryOrder + offset
|
|
3485
|
+
},
|
|
3486
|
+
...replacement.clipEntityId === void 0 ? {} : { clipEntityId: replacement.clipEntityId },
|
|
3487
|
+
...replacement.volume === void 0 ? {} : { clipPayload: { volume: requireVolume(replacement.volume) } }
|
|
3488
|
+
}));
|
|
3489
|
+
}
|
|
3490
|
+
for (const [index, oldId] of input.oldClipEntityIds.entries()) {
|
|
3491
|
+
const children = directAnchorChildren(draft, oldId);
|
|
3492
|
+
if (input.onAnchored === "remap" && created[index] !== void 0) for (const childId of children) this.replaceAnchorHost(draft, childId, created[index]);
|
|
3493
|
+
else {
|
|
3494
|
+
const scheduled = /* @__PURE__ */ new Set();
|
|
3495
|
+
for (const childId of children) {
|
|
3496
|
+
scheduled.add(childId);
|
|
3497
|
+
this.removeAnchoredDescendants(draft, childId, scheduled);
|
|
3498
|
+
this.removeClipGraph(draft, childId);
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
3502
|
+
for (const oldId of input.oldClipEntityIds) this.removeClipGraph(draft, oldId);
|
|
3503
|
+
[
|
|
3504
|
+
...ordered.slice(0, insertOrder),
|
|
3505
|
+
...created,
|
|
3506
|
+
...ordered.slice(insertOrder + input.oldClipEntityIds.length)
|
|
3507
|
+
].forEach((clipId, order) => setClipOrder(draft, clipId, order));
|
|
3508
|
+
this.resolveOneVoiceoverOverlap(draft);
|
|
3509
|
+
return created;
|
|
3510
|
+
});
|
|
3511
|
+
}
|
|
3512
|
+
deleteClip(input) {
|
|
3513
|
+
this.transact((draft) => {
|
|
3514
|
+
const topology = requireClipTopology(draft, input.clipEntityId);
|
|
3515
|
+
assertOnlyStructuralIncidents(draft, topology.clip.entityId, new Set(["contain", "marker"]));
|
|
3516
|
+
assertOnlyStructuralIncidents(draft, topology.marker.entityId, new Set(["marker"]));
|
|
3517
|
+
const markerIsExclusive = incidentRelations(draft, topology.marker.entityId).filter((relation) => relation.relationKind === "marker").length === 1;
|
|
3518
|
+
const removedRelationIds = new Set([
|
|
3519
|
+
topology.trackRelation.relationId,
|
|
3520
|
+
topology.clipMarkerRelation.relationId,
|
|
3521
|
+
topology.contentRelation.relationId
|
|
3522
|
+
]);
|
|
3523
|
+
if (markerIsExclusive) for (const relation of incidentRelations(draft, topology.marker.entityId)) removedRelationIds.add(relation.relationId);
|
|
3524
|
+
draft.relations = draft.relations.filter((relation) => !removedRelationIds.has(relation.relationId));
|
|
3525
|
+
draft.entities = draft.entities.filter((entity) => entity.entityId !== topology.clip.entityId && (!markerIsExclusive || entity.entityId !== topology.marker.entityId));
|
|
3526
|
+
});
|
|
3527
|
+
}
|
|
3528
|
+
deleteClipTree(input) {
|
|
3529
|
+
if (input.clipEntityIds.length === 0) throw new Error("At least one Clip is required");
|
|
3530
|
+
this.transact((draft) => {
|
|
3531
|
+
const targets = new Set(input.clipEntityIds);
|
|
3532
|
+
if (targets.size !== input.clipEntityIds.length) throw new Error("Clip ids must be unique");
|
|
3533
|
+
for (const target of targets) requireEntityKind(draft, target, "clip");
|
|
3534
|
+
const includesVisual = [...targets].some((target) => clipRole(draft, target) === "video_clip");
|
|
3535
|
+
const original = resolveEntityTimelineLayout(draft);
|
|
3536
|
+
if (includesVisual) this.normalizeMainSequential(draft);
|
|
3537
|
+
if (input.onAnchored === "detach") for (const childId of outgoingAnchorChildren(draft, targets)) {
|
|
3538
|
+
if (targets.has(childId)) continue;
|
|
3539
|
+
const placement = original.clips.get(childId);
|
|
3540
|
+
if (placement === void 0) throw new Error(`Anchored child Clip "${childId}" is not placed`);
|
|
3541
|
+
this.applyClipPlacement(draft, childId, {
|
|
3542
|
+
kind: "absolute",
|
|
3543
|
+
startMs: placement.start
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
else for (const target of targets) this.removeAnchoredDescendants(draft, target, targets);
|
|
3547
|
+
for (const target of targets) this.removeClipGraph(draft, target);
|
|
3548
|
+
if (includesVisual) this.resolveOneVoiceoverOverlap(draft);
|
|
3549
|
+
});
|
|
3550
|
+
}
|
|
3551
|
+
updateClip(input) {
|
|
3552
|
+
this.transact((draft) => {
|
|
3553
|
+
const clip = requireEntityKind(draft, input.clipEntityId, "clip");
|
|
3554
|
+
replaceEntity(draft, clip.entityId, {
|
|
3555
|
+
...clip,
|
|
3556
|
+
payload: structuredClone(input.payload)
|
|
3557
|
+
});
|
|
3558
|
+
});
|
|
3559
|
+
}
|
|
3560
|
+
upsertVoiceoverTake(input) {
|
|
3561
|
+
return this.transact((draft) => {
|
|
3562
|
+
this.normalizeMainSequential(draft);
|
|
3563
|
+
const placement = input.placement ?? {
|
|
3564
|
+
kind: "anchored",
|
|
3565
|
+
hostClipEntityId: input.hostClipEntityId,
|
|
3566
|
+
startMs: input.startMs
|
|
3567
|
+
};
|
|
3568
|
+
if (input.placement !== void 0 && (input.hostClipEntityId !== void 0 || input.startMs !== void 0)) throw new Error("Specify voiceover placement or hostClipEntityId/startMs, not both");
|
|
3569
|
+
requireVolume(input.volume);
|
|
3570
|
+
const imported = importMediaAsset(draft, input.media, this.idFactory);
|
|
3571
|
+
replaceRows(draft, imported.rows);
|
|
3572
|
+
const voiceover = requireEntityKind(draft, imported.contentEntityId, "voiceover-recording");
|
|
3573
|
+
const phonetic = this.requireComposedPhoneticScript(draft, input.phoneticScriptEntityId);
|
|
3574
|
+
this.ensurePhoneticRender(draft, voiceover.entityId, phonetic.entityId);
|
|
3575
|
+
const speechTrack = this.ensureRoleTrack(draft, input.timelineEntityId, "speech");
|
|
3576
|
+
const existing = draft.entities.find((row) => row.entityId === input.voiceoverClipEntityId);
|
|
3577
|
+
let voiceoverClipEntityId;
|
|
3578
|
+
if (existing === void 0) voiceoverClipEntityId = this.insertPlacedClipIntoDraft(draft, {
|
|
3579
|
+
clipEntityId: input.voiceoverClipEntityId,
|
|
3580
|
+
trackEntityId: speechTrack.entityId,
|
|
3581
|
+
contentEntityId: voiceover.entityId,
|
|
3582
|
+
inMs: 0,
|
|
3583
|
+
outMs: wholeLength(input.media),
|
|
3584
|
+
placement,
|
|
3585
|
+
clipPayload: { volume: input.volume }
|
|
3586
|
+
});
|
|
3587
|
+
else {
|
|
3588
|
+
const topology = requireClipOnRole(draft, input.voiceoverClipEntityId, "speech");
|
|
3589
|
+
voiceoverClipEntityId = topology.clip.entityId;
|
|
3590
|
+
replaceEntity(draft, topology.clip.entityId, {
|
|
3591
|
+
...topology.clip,
|
|
3592
|
+
payload: {
|
|
3593
|
+
...topology.clip.payload,
|
|
3594
|
+
volume: input.volume
|
|
3595
|
+
}
|
|
3596
|
+
});
|
|
3597
|
+
this.relinkRelation(draft, topology.contentRelation, {
|
|
3598
|
+
endpoint0EntityId: topology.clip.entityId,
|
|
3599
|
+
endpoint1EntityId: voiceover.entityId
|
|
3600
|
+
});
|
|
3601
|
+
this.applyClipPlacement(draft, topology.clip.entityId, placement);
|
|
3602
|
+
const placed = requireClipTopology(draft, topology.clip.entityId);
|
|
3603
|
+
replaceEntity(draft, placed.marker.entityId, {
|
|
3604
|
+
...placed.marker,
|
|
3605
|
+
payload: {
|
|
3606
|
+
...placed.marker.payload,
|
|
3607
|
+
inMs: 0,
|
|
3608
|
+
outMs: wholeLength(input.media)
|
|
3609
|
+
}
|
|
3610
|
+
});
|
|
3611
|
+
}
|
|
3612
|
+
this.reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, voiceover.entityId, phonetic.entityId, input.captions);
|
|
3613
|
+
this.resolveOneVoiceoverOverlap(draft);
|
|
3614
|
+
return {
|
|
3615
|
+
voiceoverClipEntityId,
|
|
3616
|
+
voiceoverAudioEntityId: voiceover.entityId,
|
|
3617
|
+
...imported.voiceEntityId === void 0 ? {} : { voiceEntityId: imported.voiceEntityId },
|
|
3618
|
+
phoneticScriptEntityId: phonetic.entityId,
|
|
3619
|
+
audioScriptEntityId: requireComposedAudioScript(draft, phonetic.entityId),
|
|
3620
|
+
captionClipEntityIds: input.captions.map((caption) => caption.captionClipEntityId)
|
|
3621
|
+
};
|
|
3622
|
+
});
|
|
3623
|
+
}
|
|
3624
|
+
moveVoiceover(input) {
|
|
3625
|
+
this.transact((draft) => {
|
|
3626
|
+
requireClipOnRole(draft, input.voiceoverClipEntityId, "speech");
|
|
3627
|
+
this.normalizeMainSequential(draft);
|
|
3628
|
+
const layout = resolveEntityTimelineLayout(draft);
|
|
3629
|
+
this.reanchorAtAbsolute(draft, input.voiceoverClipEntityId, finiteNonNegative(input.newStartMs, "newStartMs"), layout);
|
|
3630
|
+
this.resolveOneVoiceoverOverlap(draft);
|
|
3631
|
+
});
|
|
3632
|
+
}
|
|
3633
|
+
deleteVoiceover(input) {
|
|
3634
|
+
this.transact((draft) => {
|
|
3635
|
+
this.normalizeMainSequential(draft);
|
|
3636
|
+
const targets = new Set(input.voiceoverClipEntityIds);
|
|
3637
|
+
if (targets.size !== input.voiceoverClipEntityIds.length) throw new Error("Voiceover Clip ids must be unique");
|
|
3638
|
+
for (const clipId of targets) {
|
|
3639
|
+
requireClipOnRole(draft, clipId, "speech");
|
|
3640
|
+
this.removeAnchoredDescendants(draft, clipId, targets);
|
|
3641
|
+
}
|
|
3642
|
+
for (const clipId of targets) this.removeClipGraph(draft, clipId);
|
|
3643
|
+
this.resolveOneVoiceoverOverlap(draft);
|
|
3644
|
+
});
|
|
3645
|
+
}
|
|
3646
|
+
setBgm(input) {
|
|
3647
|
+
return this.transact((draft) => {
|
|
3648
|
+
const imported = importMediaAsset(draft, input.media, this.idFactory);
|
|
3649
|
+
replaceRows(draft, imported.rows);
|
|
3650
|
+
const track = this.ensureRoleTrack(draft, input.timelineEntityId, "bgm");
|
|
3651
|
+
const existing = clipsOnTrack(draft, track.entityId);
|
|
3652
|
+
for (const clip of existing) this.removeClipGraph(draft, clip.entityId);
|
|
3653
|
+
const clipId = this.insertPlacedClipIntoDraft(draft, {
|
|
3654
|
+
clipEntityId: input.bgmClipEntityId,
|
|
3655
|
+
trackEntityId: track.entityId,
|
|
3656
|
+
contentEntityId: imported.contentEntityId,
|
|
3657
|
+
inMs: 0,
|
|
3658
|
+
outMs: wholeLength(input.media),
|
|
3659
|
+
placement: {
|
|
3660
|
+
kind: "sequential",
|
|
3661
|
+
order: 0
|
|
3662
|
+
},
|
|
3663
|
+
clipPayload: { volume: requireVolume(input.volume) }
|
|
3664
|
+
});
|
|
3665
|
+
const topology = requireClipTopology(draft, clipId);
|
|
3666
|
+
replaceEntity(draft, topology.marker.entityId, {
|
|
3667
|
+
...topology.marker,
|
|
3668
|
+
payload: {
|
|
3669
|
+
...topology.marker.payload,
|
|
3670
|
+
fillTimeline: true
|
|
3671
|
+
}
|
|
3672
|
+
});
|
|
3673
|
+
return clipId;
|
|
3674
|
+
});
|
|
3675
|
+
}
|
|
3676
|
+
deleteBgm(input) {
|
|
3677
|
+
this.transact((draft) => {
|
|
3678
|
+
const track = findRoleTrack(draft, input.timelineEntityId, "bgm");
|
|
3679
|
+
if (track === void 0) return;
|
|
3680
|
+
for (const clip of clipsOnTrack(draft, track.entityId)) this.removeClipGraph(draft, clip.entityId);
|
|
3681
|
+
});
|
|
3682
|
+
}
|
|
3683
|
+
setCaptionVisibility(input) {
|
|
3684
|
+
this.transact((draft) => {
|
|
3685
|
+
const track = this.ensureRoleTrack(draft, input.timelineEntityId, "caption");
|
|
3686
|
+
replaceEntity(draft, track.entityId, {
|
|
3687
|
+
...track,
|
|
3688
|
+
payload: {
|
|
3689
|
+
...track.payload,
|
|
3690
|
+
hidden: input.hidden
|
|
3691
|
+
}
|
|
3692
|
+
});
|
|
3693
|
+
});
|
|
3694
|
+
}
|
|
3695
|
+
patchCaptionStyle(input) {
|
|
3696
|
+
this.transact((draft) => {
|
|
3697
|
+
const track = findRoleTrack(draft, input.timelineEntityId, "caption");
|
|
3698
|
+
if (track === void 0) return;
|
|
3699
|
+
const patch = structuredClone(input.style);
|
|
3700
|
+
const assembled = new EntityProjectionGraph(draft);
|
|
3701
|
+
for (const clip of clipsOnTrack(draft, track.entityId)) {
|
|
3702
|
+
const topology = requireClipTopology(draft, clip.entityId);
|
|
3703
|
+
const caption = requireEntityKind(draft, otherEndpoint(topology.contentRelation, topology.clip.entityId), "caption");
|
|
3704
|
+
const style = assembled.entity(caption.entityId, "caption").payload.style ?? {};
|
|
3705
|
+
for (const updated of updateEntityFields(draft, caption.entityId, { style: {
|
|
3706
|
+
...style,
|
|
3707
|
+
...patch
|
|
3708
|
+
} })) replaceEntity(draft, updated.entityId, updated);
|
|
3709
|
+
}
|
|
3710
|
+
});
|
|
3711
|
+
}
|
|
3712
|
+
/**
|
|
3713
|
+
* Place a Caption as a composed variant of an existing AudioScript: the
|
|
3714
|
+
* entity stores bases, selection, optional style, and inherent cue-bounded
|
|
3715
|
+
* Sequence. This helper does not copy base text. The Clip and its display Marker describe this use;
|
|
3716
|
+
* moving or stretching the returned Clip later changes the Clip/Marker,
|
|
3717
|
+
* never the Caption entity or the AudioScript.
|
|
3718
|
+
*/
|
|
3719
|
+
insertCaptionClip(input) {
|
|
3720
|
+
return this.transact((draft) => {
|
|
3721
|
+
const durationMs = finitePositive(input.durationMs, "Caption durationMs");
|
|
3722
|
+
const selection = captionSelection(input.selection);
|
|
3723
|
+
const track = this.ensureRoleTrack(draft, input.timelineEntityId, "caption");
|
|
3724
|
+
const content = {
|
|
3725
|
+
entityId: input.captionEntityId === void 0 ? this.mintEntityId(draft) : requireAvailableEntityId(draft, input.captionEntityId),
|
|
3726
|
+
entityKind: "caption",
|
|
3727
|
+
payload: {
|
|
3728
|
+
durationMs,
|
|
3729
|
+
bases: captionBaseIds(input.bases),
|
|
3730
|
+
selection,
|
|
3731
|
+
...input.style === void 0 ? {} : { style: structuredClone(input.style) }
|
|
3732
|
+
}
|
|
3733
|
+
};
|
|
3734
|
+
draft.entities.push(content);
|
|
3735
|
+
selectedSegment(assembleEntityContent(draft, content.entityId));
|
|
3736
|
+
return this.insertPlacedClipIntoDraft(draft, {
|
|
3737
|
+
trackEntityId: track.entityId,
|
|
3738
|
+
contentEntityId: content.entityId,
|
|
3739
|
+
inMs: 0,
|
|
3740
|
+
outMs: durationMs,
|
|
3741
|
+
placement: input.placement,
|
|
3742
|
+
...input.captionClipEntityId === void 0 ? {} : { clipEntityId: input.captionClipEntityId }
|
|
3743
|
+
});
|
|
3744
|
+
});
|
|
3745
|
+
}
|
|
3746
|
+
insertPlacedClipIntoDraft(draft, input) {
|
|
3747
|
+
const track = requireEntityKind(draft, input.trackEntityId, "track");
|
|
3748
|
+
requireTrackTimeline(draft, track.entityId);
|
|
3749
|
+
const content = requireSequenceContent(draft, input.contentEntityId);
|
|
3750
|
+
const clipId = input.clipEntityId === void 0 ? this.mintEntityId(draft) : requireAvailableEntityId(draft, input.clipEntityId);
|
|
3751
|
+
const markerId = this.mintEntityId(draft);
|
|
3752
|
+
const clipPayload = structuredClone(input.clipPayload ?? {});
|
|
3753
|
+
const markerPlacement = applyPlacementPayload(input.placement);
|
|
3754
|
+
const clip = {
|
|
3755
|
+
entityId: clipId,
|
|
3756
|
+
entityKind: "clip",
|
|
3757
|
+
payload: clipPayload
|
|
3758
|
+
};
|
|
3759
|
+
const marker = {
|
|
3760
|
+
entityId: markerId,
|
|
3761
|
+
entityKind: "sequence-marker",
|
|
3762
|
+
payload: {
|
|
3763
|
+
...requireWindow(input.inMs, input.outMs),
|
|
3764
|
+
...markerPlacement
|
|
3765
|
+
}
|
|
3766
|
+
};
|
|
3767
|
+
draft.entities.push(clip, marker);
|
|
3768
|
+
draft.relations.push(this.createRelation(draft, "contain", track.entityId, clipId), this.createRelation(draft, "marker", clipId, markerId), this.createRelation(draft, "contain", clipId, content.entityId));
|
|
3769
|
+
if (input.placement.kind === "anchored") {
|
|
3770
|
+
const host = requireEntityKind(draft, input.placement.hostClipEntityId, "clip");
|
|
3771
|
+
if (host.entityId === clipId) throw new Error("A Clip cannot anchor to itself");
|
|
3772
|
+
draft.relations.push(this.createRelation(draft, "clip-anchor", clipId, host.entityId));
|
|
3773
|
+
}
|
|
3774
|
+
return clipId;
|
|
3775
|
+
}
|
|
3776
|
+
ensureRoleTrack(draft, timelineEntityId, role) {
|
|
3777
|
+
const existing = findRoleTrack(draft, timelineEntityId, role);
|
|
3778
|
+
if (existing !== void 0) return existing;
|
|
3779
|
+
const timeline = requireEntityKind(draft, timelineEntityId, "timeline");
|
|
3780
|
+
const track = {
|
|
3781
|
+
entityId: this.mintEntityId(draft),
|
|
3782
|
+
entityKind: "track",
|
|
3783
|
+
payload: { role }
|
|
3784
|
+
};
|
|
3785
|
+
draft.entities.push(track);
|
|
3786
|
+
draft.relations.push(this.createRelation(draft, "contain", timeline.entityId, track.entityId));
|
|
3787
|
+
return track;
|
|
3788
|
+
}
|
|
3789
|
+
/** The existing pronunciation variant a voiceover consumes; composition is required. */
|
|
3790
|
+
requireComposedPhoneticScript(draft, phoneticScriptEntityId) {
|
|
3791
|
+
const phonetic = requireEntityKind(draft, phoneticScriptEntityId, "phonetic-script");
|
|
3792
|
+
requireComposedAudioScript(draft, phonetic.entityId);
|
|
3793
|
+
return phonetic;
|
|
3794
|
+
}
|
|
3795
|
+
/** Preserve the factual synthesis input, regardless of persisted endpoint positions. */
|
|
3796
|
+
ensurePhoneticRender(draft, voiceoverAudioEntityId, phoneticScriptEntityId) {
|
|
3797
|
+
const renders = draft.relations.filter((relation) => relation.relationKind === "derived-from" && (relation.endpoint0EntityId === voiceoverAudioEntityId || relation.endpoint1EntityId === voiceoverAudioEntityId));
|
|
3798
|
+
if (renders.length > 1) throw new Error(`Voiceover "${voiceoverAudioEntityId}" has multiple PhoneticScript render relations`);
|
|
3799
|
+
if (renders[0] !== void 0) {
|
|
3800
|
+
if (otherEndpoint(renders[0], voiceoverAudioEntityId) === phoneticScriptEntityId) return;
|
|
3801
|
+
throw new Error(`Voiceover "${voiceoverAudioEntityId}" already has a different PhoneticScript synthesis input`);
|
|
3802
|
+
}
|
|
3803
|
+
draft.relations.push(this.createRelation(draft, "derived-from", voiceoverAudioEntityId, phoneticScriptEntityId));
|
|
3804
|
+
}
|
|
3805
|
+
reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, voiceoverAudioEntityId, phoneticScriptEntityId, captions) {
|
|
3806
|
+
const audioScriptEntityId = requireComposedAudioScript(draft, phoneticScriptEntityId);
|
|
3807
|
+
const requested = new Set(captions.map((caption) => caption.captionClipEntityId));
|
|
3808
|
+
if (requested.size !== captions.length) throw new Error("Caption Clip ids must be unique within a voiceover take");
|
|
3809
|
+
const existing = directAnchorChildren(draft, voiceoverClipEntityId).filter((clipId) => clipRole(draft, clipId) === "caption");
|
|
3810
|
+
for (const clipId of existing) if (!requested.has(clipId)) this.removeCaptionClipGraph(draft, clipId);
|
|
3811
|
+
const captionTrack = captions.length === 0 ? void 0 : this.ensureRoleTrackForClip(draft, voiceoverClipEntityId, "caption");
|
|
3812
|
+
for (const caption of captions) {
|
|
3813
|
+
const startMs = finiteNonNegative(caption.startMs, "Caption startMs");
|
|
3814
|
+
const durationMs = finitePositive(caption.durationMs, "Caption durationMs");
|
|
3815
|
+
const selection = captionSelection(caption.selection);
|
|
3816
|
+
const bases = captionBaseIds(caption.bases);
|
|
3817
|
+
if (draft.entities.find((entity) => entity.entityId === caption.captionClipEntityId) === void 0) {
|
|
3818
|
+
const content = {
|
|
3819
|
+
entityId: this.mintEntityId(draft),
|
|
3820
|
+
entityKind: "caption",
|
|
3821
|
+
payload: {
|
|
3822
|
+
durationMs,
|
|
3823
|
+
bases,
|
|
3824
|
+
selection,
|
|
3825
|
+
...caption.style === void 0 ? {} : { style: structuredClone(caption.style) }
|
|
3826
|
+
}
|
|
3827
|
+
};
|
|
3828
|
+
draft.entities.push(content);
|
|
3829
|
+
assertCaptionScript(draft, content.entityId, audioScriptEntityId);
|
|
3830
|
+
draft.relations.push(this.createRelation(draft, "caption-alignment", content.entityId, voiceoverAudioEntityId));
|
|
3831
|
+
this.insertPlacedClipIntoDraft(draft, {
|
|
3832
|
+
clipEntityId: caption.captionClipEntityId,
|
|
3833
|
+
trackEntityId: captionTrack.entityId,
|
|
3834
|
+
contentEntityId: content.entityId,
|
|
3835
|
+
inMs: 0,
|
|
3836
|
+
outMs: durationMs,
|
|
3837
|
+
placement: {
|
|
3838
|
+
kind: "anchored",
|
|
3839
|
+
hostClipEntityId: voiceoverClipEntityId,
|
|
3840
|
+
startMs
|
|
3841
|
+
}
|
|
3842
|
+
});
|
|
3843
|
+
continue;
|
|
3844
|
+
}
|
|
3845
|
+
const topology = requireClipOnRole(draft, caption.captionClipEntityId, "caption");
|
|
3846
|
+
const content = requireEntityKind(draft, otherEndpoint(topology.contentRelation, topology.clip.entityId), "caption");
|
|
3847
|
+
const priorStyle = fieldsOf(draft, content.entityId, "caption").style ?? {};
|
|
3848
|
+
const style = caption.style === void 0 ? priorStyle : {
|
|
3849
|
+
...priorStyle,
|
|
3850
|
+
...structuredClone(caption.style)
|
|
3851
|
+
};
|
|
3852
|
+
replaceEntity(draft, content.entityId, {
|
|
3853
|
+
...content,
|
|
3854
|
+
payload: {
|
|
3855
|
+
...content.payload,
|
|
3856
|
+
bases,
|
|
3857
|
+
selection,
|
|
3858
|
+
...Object.keys(style).length === 0 ? {} : { style }
|
|
3859
|
+
}
|
|
3860
|
+
});
|
|
3861
|
+
assertCaptionScript(draft, content.entityId, audioScriptEntityId);
|
|
3862
|
+
this.applyClipPlacement(draft, topology.clip.entityId, {
|
|
3863
|
+
kind: "anchored",
|
|
3864
|
+
hostClipEntityId: voiceoverClipEntityId,
|
|
3865
|
+
startMs
|
|
3866
|
+
});
|
|
3867
|
+
const placed = requireClipTopology(draft, topology.clip.entityId);
|
|
3868
|
+
const window = markerWindow(fieldsOf(draft, placed.marker.entityId, "sequence-marker"));
|
|
3869
|
+
const rate = (window.outMs - window.inMs) / durationMs;
|
|
3870
|
+
replaceEntity(draft, placed.marker.entityId, {
|
|
3871
|
+
...placed.marker,
|
|
3872
|
+
payload: {
|
|
3873
|
+
...placed.marker.payload,
|
|
3874
|
+
...rate === 1 ? {} : { rate }
|
|
3875
|
+
}
|
|
3876
|
+
});
|
|
3877
|
+
this.replaceCaptionAlignment(draft, content.entityId, voiceoverAudioEntityId);
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
ensureRoleTrackForClip(draft, clipEntityId, role) {
|
|
3881
|
+
const topology = requireClipTopology(draft, clipEntityId);
|
|
3882
|
+
const track = requireEntityKind(draft, otherEndpoint(topology.trackRelation, topology.clip.entityId), "track");
|
|
3883
|
+
const timeline = requireEntityKind(draft, otherEndpoint(requireOneContain(draft, track.entityId, "contained"), track.entityId), "timeline");
|
|
3884
|
+
return this.ensureRoleTrack(draft, timeline.entityId, role);
|
|
3885
|
+
}
|
|
3886
|
+
/** A Caption follows one take; it is relinked only when that take changes. */
|
|
3887
|
+
replaceCaptionAlignment(draft, captionEntityId, voiceoverAudioEntityId) {
|
|
3888
|
+
const existing = incidentRelations(draft, captionEntityId).filter((relation) => relation.relationKind === "caption-alignment");
|
|
3889
|
+
if (existing.length === 1 && existing[0].endpoint1EntityId === voiceoverAudioEntityId) return;
|
|
3890
|
+
const removed = new Set(existing.map((relation) => relation.relationId));
|
|
3891
|
+
draft.relations = draft.relations.filter((relation) => !removed.has(relation.relationId));
|
|
3892
|
+
draft.relations.push(this.createRelation(draft, "caption-alignment", captionEntityId, voiceoverAudioEntityId));
|
|
3893
|
+
}
|
|
3894
|
+
/**
|
|
3895
|
+
* Remove one placed Caption use. The composed AudioScript outlives the
|
|
3896
|
+
* caption: it may anchor further captions and remains the text owner.
|
|
3897
|
+
*/
|
|
3898
|
+
removeCaptionClipGraph(draft, clipEntityId) {
|
|
3899
|
+
const topology = requireClipOnRole(draft, clipEntityId, "caption");
|
|
3900
|
+
const caption = requireEntityKind(draft, otherEndpoint(topology.contentRelation, topology.clip.entityId), "caption");
|
|
3901
|
+
const contentRelations = incidentRelations(draft, caption.entityId);
|
|
3902
|
+
const allowed = new Set(["caption-alignment", "contain"]);
|
|
3903
|
+
const unsupported = contentRelations.filter((relation) => !allowed.has(relation.relationKind));
|
|
3904
|
+
if (unsupported.length > 0) throw new Error(`Cannot delete Caption with unsupported Relation ${unsupported[0].relationId}`);
|
|
3905
|
+
this.removeClipGraph(draft, clipEntityId);
|
|
3906
|
+
if (containing(draft, caption.entityId, "contained").length > 0) return;
|
|
3907
|
+
const removed = new Set(incidentRelations(draft, caption.entityId).map((relation) => relation.relationId));
|
|
3908
|
+
draft.relations = draft.relations.filter((relation) => !removed.has(relation.relationId));
|
|
3909
|
+
draft.entities = draft.entities.filter((entity) => entity.entityId !== caption.entityId);
|
|
3910
|
+
}
|
|
3911
|
+
replaceAnchorHost(draft, childClipEntityId, hostClipEntityId) {
|
|
3912
|
+
const anchors = draft.relations.filter((relation) => relation.relationKind === "clip-anchor" && relation.endpoint0EntityId === childClipEntityId);
|
|
3913
|
+
if (anchors.length !== 1) throw new Error(`Clip "${childClipEntityId}" must have exactly one host`);
|
|
3914
|
+
this.relinkRelation(draft, anchors[0], {
|
|
3915
|
+
endpoint0EntityId: createEntityId(childClipEntityId),
|
|
3916
|
+
endpoint1EntityId: createEntityId(hostClipEntityId)
|
|
3917
|
+
});
|
|
3918
|
+
}
|
|
3919
|
+
/** Rebuild the legacy edit baseline from current visual order before a high-level cascade. */
|
|
3920
|
+
normalizeMainSequential(draft) {
|
|
3921
|
+
const videoTrack = resolveEntityTimelineLayout(draft).tracks.find((track) => track.role === "video_clip");
|
|
3922
|
+
if (videoTrack === void 0) return;
|
|
3923
|
+
videoTrack.clips.forEach((clip, order) => {
|
|
3924
|
+
this.applyClipPlacement(draft, clip.clipEntityId, {
|
|
3925
|
+
kind: "sequential",
|
|
3926
|
+
order
|
|
3927
|
+
});
|
|
3928
|
+
});
|
|
3929
|
+
}
|
|
3930
|
+
resolveOneVoiceoverOverlap(draft) {
|
|
3931
|
+
const layout = resolveEntityTimelineLayout(draft);
|
|
3932
|
+
const speechTrack = layout.tracks.find((track) => track.role === "speech");
|
|
3933
|
+
if (speechTrack === void 0) return;
|
|
3934
|
+
const ranges = speechTrack.clips.map((speech) => {
|
|
3935
|
+
const captions = directAnchorChildren(draft, speech.clipEntityId).filter((clipId) => clipRole(draft, clipId) === "caption").map((clipId) => layout.clips.get(clipId)).filter((value) => value !== void 0);
|
|
3936
|
+
return {
|
|
3937
|
+
speech,
|
|
3938
|
+
start: Math.min(speech.start, ...captions.map((caption) => caption.start)),
|
|
3939
|
+
end: Math.max(speech.start + speech.duration, ...captions.map((caption) => caption.start + caption.duration))
|
|
3940
|
+
};
|
|
3941
|
+
}).sort((left, right) => left.start - right.start || left.speech.clipEntityId.localeCompare(right.speech.clipEntityId));
|
|
3942
|
+
for (let index = 1; index < ranges.length; index += 1) {
|
|
3943
|
+
const prior = ranges[index - 1];
|
|
3944
|
+
const current = ranges[index];
|
|
3945
|
+
if (current.start >= prior.end) continue;
|
|
3946
|
+
const hostId = current.speech.anchorClipEntityId;
|
|
3947
|
+
if (hostId === void 0) continue;
|
|
3948
|
+
const videoTrack = layout.tracks.find((track) => track.role === "video_clip");
|
|
3949
|
+
const hostIndex = videoTrack?.clips.findIndex((clip) => clip.clipEntityId === hostId) ?? -1;
|
|
3950
|
+
if (videoTrack === void 0 || hostIndex < 0) continue;
|
|
3951
|
+
const overlapMs = Math.round(prior.end - current.start);
|
|
3952
|
+
for (const [visualIndex, clip] of videoTrack.clips.entries()) this.applyClipPlacement(draft, clip.clipEntityId, {
|
|
3953
|
+
kind: "absolute",
|
|
3954
|
+
startMs: clip.start + (visualIndex >= hostIndex ? overlapMs : 0)
|
|
3955
|
+
});
|
|
3956
|
+
return;
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
applyClipPlacement(draft, clipEntityId, placement) {
|
|
3960
|
+
const topology = requireClipTopology(draft, clipEntityId);
|
|
3961
|
+
const clipPayload = structuredClone(topology.clip.payload);
|
|
3962
|
+
const markerPayloadValue = { ...structuredClone(topology.marker.payload) };
|
|
3963
|
+
delete markerPayloadValue.startMs;
|
|
3964
|
+
const outgoingAnchors = draft.relations.filter((relation) => relation.relationKind === "clip-anchor" && relation.endpoint0EntityId === topology.clip.entityId);
|
|
3965
|
+
const removed = new Set(outgoingAnchors.map((relation) => relation.relationId));
|
|
3966
|
+
draft.relations = draft.relations.filter((relation) => !removed.has(relation.relationId));
|
|
3967
|
+
if (placement.kind === "sequential") setClipOrder(draft, topology.clip.entityId, placement.order);
|
|
3968
|
+
else if (placement.kind === "absolute") markerPayloadValue.startMs = finiteNonNegative(placement.startMs, "startMs");
|
|
3969
|
+
else {
|
|
3970
|
+
const host = requireEntityKind(draft, placement.hostClipEntityId, "clip");
|
|
3971
|
+
if (host.entityId === topology.clip.entityId) throw new Error("A Clip cannot anchor to itself");
|
|
3972
|
+
markerPayloadValue.startMs = finiteNonNegative(placement.startMs, "startMs");
|
|
3973
|
+
draft.relations.push(this.createRelation(draft, "clip-anchor", topology.clip.entityId, host.entityId));
|
|
3974
|
+
}
|
|
3975
|
+
replaceEntity(draft, topology.clip.entityId, {
|
|
3976
|
+
...topology.clip,
|
|
3977
|
+
payload: clipPayload
|
|
3978
|
+
});
|
|
3979
|
+
replaceEntity(draft, topology.marker.entityId, {
|
|
3980
|
+
...topology.marker,
|
|
3981
|
+
payload: markerPayloadValue
|
|
3982
|
+
});
|
|
3983
|
+
}
|
|
3984
|
+
reanchorAtAbsolute(draft, childClipEntityId, absoluteStart, layout) {
|
|
3985
|
+
const hosts = layout.tracks.find((track) => track.role === "video_clip")?.clips.filter((clip) => clip.mode === "sequential") ?? [];
|
|
3986
|
+
if (hosts.length === 0) {
|
|
3987
|
+
if (layout.clips.get(childClipEntityId) === void 0) throw new Error(`Clip "${childClipEntityId}" is not placed`);
|
|
3988
|
+
this.applyClipPlacement(draft, childClipEntityId, {
|
|
3989
|
+
kind: "absolute",
|
|
3990
|
+
startMs: absoluteStart
|
|
3991
|
+
});
|
|
3992
|
+
return;
|
|
3993
|
+
}
|
|
3994
|
+
const host = hosts.find((candidate) => candidate.start <= absoluteStart && absoluteStart < candidate.start + candidate.duration) ?? (absoluteStart < hosts[0].start ? hosts[0] : hosts.at(-1));
|
|
3995
|
+
this.applyClipPlacement(draft, childClipEntityId, {
|
|
3996
|
+
kind: "anchored",
|
|
3997
|
+
hostClipEntityId: host.clipEntityId,
|
|
3998
|
+
startMs: Math.max(0, Math.round(absoluteStart - host.start))
|
|
3999
|
+
});
|
|
4000
|
+
}
|
|
4001
|
+
removeAnchoredDescendants(draft, hostClipEntityId, scheduled) {
|
|
4002
|
+
const children = draft.relations.filter((relation) => relation.relationKind === "clip-anchor" && relation.endpoint1EntityId === hostClipEntityId).map((relation) => String(relation.endpoint0EntityId));
|
|
4003
|
+
for (const child of children) {
|
|
4004
|
+
if (scheduled.has(child)) continue;
|
|
4005
|
+
const descendants = new Set(scheduled);
|
|
4006
|
+
descendants.add(child);
|
|
4007
|
+
this.removeAnchoredDescendants(draft, child, descendants);
|
|
4008
|
+
if (clipRole(draft, child) === "caption") this.removeCaptionClipGraph(draft, child);
|
|
4009
|
+
else this.removeClipGraph(draft, child);
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
removeClipGraph(draft, clipEntityId) {
|
|
4013
|
+
const topology = requireClipTopology(draft, clipEntityId);
|
|
4014
|
+
assertOnlyStructuralIncidents(draft, topology.clip.entityId, new Set([
|
|
4015
|
+
"contain",
|
|
4016
|
+
"marker",
|
|
4017
|
+
"clip-anchor"
|
|
4018
|
+
]));
|
|
4019
|
+
assertOnlyStructuralIncidents(draft, topology.marker.entityId, new Set(["marker"]));
|
|
4020
|
+
const markerIsExclusive = incidentRelations(draft, topology.marker.entityId).filter((relation) => relation.relationKind === "marker").length === 1;
|
|
4021
|
+
const removed = new Set(incidentRelations(draft, topology.clip.entityId).map((relation) => relation.relationId));
|
|
4022
|
+
if (markerIsExclusive) for (const relation of incidentRelations(draft, topology.marker.entityId)) removed.add(relation.relationId);
|
|
4023
|
+
draft.relations = draft.relations.filter((relation) => !removed.has(relation.relationId));
|
|
4024
|
+
draft.entities = draft.entities.filter((entity) => entity.entityId !== topology.clip.entityId && (!markerIsExclusive || entity.entityId !== topology.marker.entityId));
|
|
4025
|
+
}
|
|
4026
|
+
transact(edit) {
|
|
4027
|
+
const draft = cloneRows(this.state);
|
|
4028
|
+
const result = edit(draft);
|
|
4029
|
+
validateFinalRows(draft);
|
|
4030
|
+
assertMediaAssetWritePolicy(this.state, draft);
|
|
4031
|
+
this.state = draft;
|
|
4032
|
+
return result;
|
|
4033
|
+
}
|
|
4034
|
+
mintEntityId(draft) {
|
|
4035
|
+
const entityId = createEntityId(this.idFactory("entity"));
|
|
4036
|
+
if (draft.entities.some((entity) => entity.entityId === entityId)) throw new Error(`Entity id "${entityId}" already exists`);
|
|
4037
|
+
return entityId;
|
|
4038
|
+
}
|
|
4039
|
+
createRelation(draft, relationKind, endpoint0EntityId, endpoint1EntityId) {
|
|
4040
|
+
return {
|
|
4041
|
+
relationId: this.mintRelationId(draft),
|
|
4042
|
+
relationKind,
|
|
4043
|
+
endpoint0EntityId,
|
|
4044
|
+
endpoint1EntityId
|
|
4045
|
+
};
|
|
4046
|
+
}
|
|
4047
|
+
relinkRelation(draft, relation, endpoints) {
|
|
4048
|
+
const relationId = this.mintRelationId(draft);
|
|
4049
|
+
const index = draft.relations.findIndex((candidate) => candidate.relationId === relation.relationId);
|
|
4050
|
+
if (index < 0) throw new Error(`Relation id "${relation.relationId}" does not exist`);
|
|
4051
|
+
draft.relations[index] = {
|
|
4052
|
+
...relation,
|
|
4053
|
+
...endpoints,
|
|
4054
|
+
relationId
|
|
4055
|
+
};
|
|
4056
|
+
}
|
|
4057
|
+
mintRelationId(draft) {
|
|
4058
|
+
const relationId = createRelationId(this.idFactory("relation"));
|
|
4059
|
+
if (draft.relations.some((relation) => relation.relationId === relationId)) throw new Error(`Relation id "${relationId}" already exists`);
|
|
4060
|
+
return relationId;
|
|
4061
|
+
}
|
|
4062
|
+
};
|
|
4063
|
+
function validateFinalRows(rows) {
|
|
4064
|
+
compile(rows);
|
|
4065
|
+
assertCanonicalEditorResources(rows);
|
|
4066
|
+
validateTimelineHierarchy(rows);
|
|
4067
|
+
}
|
|
4068
|
+
function validateTimelineHierarchy(rows) {
|
|
4069
|
+
for (const entity of rows.entities) {
|
|
4070
|
+
if (entity.entityKind === "track") requireTrackTimeline(rows, entity.entityId);
|
|
4071
|
+
if (entity.entityKind !== "clip") continue;
|
|
4072
|
+
const trackRelations = containing(rows, entity.entityId, "contained");
|
|
4073
|
+
if (trackRelations.length !== 1) throw new Error(`Clip Entity "${entity.entityId}" must belong to exactly one Track`);
|
|
4074
|
+
requireEntityKind(rows, otherEndpoint(trackRelations[0], entity.entityId), "track");
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
function requireTrackTimeline(rows, trackEntityId) {
|
|
4078
|
+
const timelineRelations = containing(rows, createEntityId(trackEntityId), "contained");
|
|
4079
|
+
if (timelineRelations.filter((relation) => {
|
|
4080
|
+
const otherId = otherEndpoint(relation, trackEntityId);
|
|
4081
|
+
return rows.entities.some((entity) => entity.entityId === otherId && entity.entityKind === "timeline");
|
|
4082
|
+
}).length !== 1 || timelineRelations.length !== 1) throw new Error(`Track Entity "${trackEntityId}" must belong to exactly one Timeline`);
|
|
4083
|
+
}
|
|
4084
|
+
function requireClipTopology(rows, clipEntityId) {
|
|
4085
|
+
const clip = requireEntityKind(rows, clipEntityId, "clip");
|
|
4086
|
+
const trackRelation = requireOneContain(rows, clip.entityId, "contained");
|
|
4087
|
+
requireEntityKind(rows, otherEndpoint(trackRelation, clip.entityId), "track");
|
|
4088
|
+
const clipMarkerRelation = requireOneRelation(rows, clip.entityId, "marker");
|
|
4089
|
+
const marker = requireEntityKind(rows, otherEndpoint(clipMarkerRelation, clip.entityId), "sequence-marker");
|
|
4090
|
+
const contentRelation = requireOneContain(rows, clip.entityId, "container");
|
|
4091
|
+
requireSequenceContent(rows, otherEndpoint(contentRelation, clip.entityId));
|
|
4092
|
+
return {
|
|
4093
|
+
clip,
|
|
4094
|
+
marker,
|
|
4095
|
+
trackRelation,
|
|
4096
|
+
clipMarkerRelation,
|
|
4097
|
+
contentRelation
|
|
4098
|
+
};
|
|
4099
|
+
}
|
|
4100
|
+
/**
|
|
4101
|
+
* One `contain` Relation with this entity on the stated side.
|
|
4102
|
+
*
|
|
4103
|
+
* A Clip is contained by its Track and contains its content; a Track is
|
|
4104
|
+
* contained by the Timeline and contains its Clips. Both are `contain`, so the
|
|
4105
|
+
* endpoint position is the only thing that tells them apart.
|
|
4106
|
+
*/
|
|
4107
|
+
function requireOneContain(rows, entityId, side) {
|
|
4108
|
+
const matches = containing(rows, entityId, side);
|
|
4109
|
+
if (matches.length !== 1) throw new Error(`Entity "${entityId}" must be the ${side} of exactly one contain Relation`);
|
|
4110
|
+
return matches[0];
|
|
4111
|
+
}
|
|
4112
|
+
/** `contain` is ordered, so a lookup says which side it wants. */
|
|
4113
|
+
function containing(rows, entityId, side) {
|
|
4114
|
+
return (side === "container" ? relationsFrom : relationsTo)(rows.relations, "contain", entityId);
|
|
4115
|
+
}
|
|
4116
|
+
function requireOneRelation(rows, entityId, relationKind) {
|
|
4117
|
+
const matches = incidentRelations(rows, entityId).filter((relation) => relation.relationKind === relationKind);
|
|
4118
|
+
if (matches.length !== 1) throw new Error(`Entity "${entityId}" must have exactly one ${relationKind} Relation`);
|
|
4119
|
+
return matches[0];
|
|
4120
|
+
}
|
|
4121
|
+
function requireEntityKind(rows, entityIdValue, entityKind) {
|
|
4122
|
+
const entityId = createEntityId(entityIdValue);
|
|
4123
|
+
const entity = rows.entities.find((candidate) => candidate.entityId === entityId);
|
|
4124
|
+
if (entity == null) throw new Error(`Entity id "${entityId}" does not exist`);
|
|
4125
|
+
if (entity.entityKind !== entityKind) throw new Error(`Entity "${entityId}" must have kind "${entityKind}", got "${entity.entityKind}"`);
|
|
4126
|
+
return entity;
|
|
4127
|
+
}
|
|
4128
|
+
function requireSequenceContent(rows, entityIdValue) {
|
|
4129
|
+
if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(entityIdValue)) throw new Error("Timeline content must reference an existing Sequence media Entity id; a URL is not allowed");
|
|
4130
|
+
const entityId = createEntityId(entityIdValue);
|
|
4131
|
+
const row = rows.entities.find((candidate) => candidate.entityId === entityId);
|
|
4132
|
+
if (row == null) throw new Error(`Timeline content must reference an existing Sequence media Entity; "${entityId}" was not found`);
|
|
4133
|
+
if (!hasSequence({
|
|
4134
|
+
...row.payload,
|
|
4135
|
+
entityId: row.entityId,
|
|
4136
|
+
entityKind: row.entityKind
|
|
4137
|
+
})) throw new Error(`Timeline content must reference a Sequence media Entity, but "${entityId}" has kind "${row.entityKind}"`);
|
|
4138
|
+
return row;
|
|
4139
|
+
}
|
|
4140
|
+
function assertOnlyStructuralIncidents(rows, entityId, allowedKinds) {
|
|
4141
|
+
const unsupported = incidentRelations(rows, entityId).filter((relation) => !allowedKinds.has(relation.relationKind));
|
|
4142
|
+
if (unsupported.length === 0) return;
|
|
4143
|
+
throw new Error(`Cannot delete Clip graph: Entity "${entityId}" has unsupported incident Relation id(s): ${unsupported.map((relation) => relation.relationId).sort().join(", ")}`);
|
|
4144
|
+
}
|
|
4145
|
+
function incidentRelations(rows, entityId) {
|
|
4146
|
+
return rows.relations.filter((relation) => relation.endpoint0EntityId === entityId || relation.endpoint1EntityId === entityId);
|
|
4147
|
+
}
|
|
4148
|
+
function hasOutgoingClipAnchor(rows, clipEntityId) {
|
|
4149
|
+
return rows.relations.some((relation) => relation.relationKind === "clip-anchor" && relation.endpoint0EntityId === clipEntityId);
|
|
4150
|
+
}
|
|
4151
|
+
function outgoingAnchorChildren(rows, hostIds) {
|
|
4152
|
+
return rows.relations.filter((relation) => relation.relationKind === "clip-anchor" && hostIds.has(relation.endpoint1EntityId)).map((relation) => String(relation.endpoint0EntityId));
|
|
4153
|
+
}
|
|
4154
|
+
/** What the placement puts on the Marker; a sequential one puts nothing there. */
|
|
4155
|
+
function applyPlacementPayload(placement) {
|
|
4156
|
+
if (placement.kind === "sequential") return {};
|
|
4157
|
+
return { startMs: finiteNonNegative(placement.startMs, "startMs") };
|
|
4158
|
+
}
|
|
4159
|
+
function requireAvailableEntityId(rows, value) {
|
|
4160
|
+
const entityId = createEntityId(value);
|
|
4161
|
+
if (rows.entities.some((entity) => entity.entityId === entityId)) throw new Error(`Entity id "${entityId}" already exists`);
|
|
4162
|
+
return entityId;
|
|
4163
|
+
}
|
|
4164
|
+
/**
|
|
4165
|
+
* Put a Clip at `order` among the Clips its Track contains.
|
|
4166
|
+
*
|
|
4167
|
+
* Where a Clip sits is the position of its membership Relation, so moving one
|
|
4168
|
+
* is moving that Relation — nothing on the Clip changes, and there are no
|
|
4169
|
+
* sibling positions left to keep in step.
|
|
4170
|
+
*/
|
|
4171
|
+
function setClipOrder(rows, clipEntityId, order) {
|
|
4172
|
+
const membership = relationsTo(rows.relations, "contain", clipEntityId)[0];
|
|
4173
|
+
if (membership === void 0) throw new Error(`Clip "${clipEntityId}" is contained by no Track`);
|
|
4174
|
+
const slots = [];
|
|
4175
|
+
const siblings = [];
|
|
4176
|
+
for (const [index, relation] of rows.relations.entries()) {
|
|
4177
|
+
if (relation.relationKind !== "contain" || relation.endpoint0EntityId !== membership.endpoint0EntityId) continue;
|
|
4178
|
+
slots.push(index);
|
|
4179
|
+
siblings.push(relation);
|
|
4180
|
+
}
|
|
4181
|
+
const from = siblings.indexOf(membership);
|
|
4182
|
+
const moved = siblings.filter((relation) => relation !== membership);
|
|
4183
|
+
moved.splice(Math.max(0, Math.min(order, moved.length)), 0, membership);
|
|
4184
|
+
if (from < 0) return;
|
|
4185
|
+
slots.forEach((slot, at) => rows.relations[slot] = moved[at]);
|
|
4186
|
+
}
|
|
4187
|
+
function findRoleTrack(rows, timelineEntityId, role) {
|
|
4188
|
+
const timeline = requireEntityKind(rows, timelineEntityId, "timeline");
|
|
4189
|
+
const tracks = containing(rows, timeline.entityId, "container").map((relation) => requireEntityKind(rows, otherEndpoint(relation, timeline.entityId), "track")).filter((track) => fieldsOf(rows, track.entityId, "track").role === role);
|
|
4190
|
+
if (tracks.length > 1) throw new Error(`Timeline "${timelineEntityId}" has multiple ${role} Tracks`);
|
|
4191
|
+
return tracks[0];
|
|
4192
|
+
}
|
|
4193
|
+
function clipsOnTrack(rows, trackEntityId) {
|
|
4194
|
+
return containing(rows, trackEntityId, "container").map((relation) => requireEntityKind(rows, otherEndpoint(relation, trackEntityId), "clip"));
|
|
4195
|
+
}
|
|
4196
|
+
function clipRole(rows, clipEntityId) {
|
|
4197
|
+
const topology = requireClipTopology(rows, clipEntityId);
|
|
4198
|
+
return fieldsOf(rows, otherEndpoint(topology.trackRelation, topology.clip.entityId), "track").role;
|
|
4199
|
+
}
|
|
4200
|
+
function directAnchorChildren(rows, hostClipEntityId) {
|
|
4201
|
+
return rows.relations.filter((relation) => relation.relationKind === "clip-anchor" && relation.endpoint1EntityId === hostClipEntityId).map((relation) => String(relation.endpoint0EntityId));
|
|
4202
|
+
}
|
|
4203
|
+
function requireSupportedRole(rows, topology) {
|
|
4204
|
+
const { role } = fieldsOf(rows, otherEndpoint(topology.trackRelation, topology.clip.entityId), "track");
|
|
4205
|
+
if (role !== "video_clip" && role !== "speech" && role !== "caption" && role !== "bgm") throw new Error(`Clip "${topology.clip.entityId}" is not on a supported timeline Track`);
|
|
4206
|
+
return role;
|
|
4207
|
+
}
|
|
4208
|
+
function requireClipOnRole(rows, clipEntityId, role) {
|
|
4209
|
+
const topology = requireClipTopology(rows, clipEntityId);
|
|
4210
|
+
if (fieldsOf(rows, otherEndpoint(topology.trackRelation, topology.clip.entityId), "track").role !== role) throw new Error(`Clip "${clipEntityId}" must belong to the ${role} Track`);
|
|
4211
|
+
return topology;
|
|
4212
|
+
}
|
|
4213
|
+
function finiteNumber(value, label) {
|
|
4214
|
+
if (!Number.isFinite(value)) throw new Error(`${label} must be finite`);
|
|
4215
|
+
return value;
|
|
4216
|
+
}
|
|
4217
|
+
function finitePositive(value, label) {
|
|
4218
|
+
const result = finiteNumber(value, label);
|
|
4219
|
+
if (result <= 0) throw new Error(`${label} must be positive`);
|
|
4220
|
+
return result;
|
|
4221
|
+
}
|
|
4222
|
+
function requireVolume(value) {
|
|
4223
|
+
const volume = finiteNumber(value, "Clip volume");
|
|
4224
|
+
if (volume < -60 || volume > 20) throw new Error("Clip volume must be between -60 and 20 dB");
|
|
4225
|
+
return volume;
|
|
4226
|
+
}
|
|
4227
|
+
function requireWindow(inMs, outMs, label = "Sequence Marker") {
|
|
4228
|
+
const start = finiteNonNegative(inMs, `${label} inMs`);
|
|
4229
|
+
const end = finitePositive(outMs, `${label} outMs`);
|
|
4230
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end <= start) throw new Error(`${label} must state a positive whole-millisecond window`);
|
|
4231
|
+
return {
|
|
4232
|
+
inMs: start,
|
|
4233
|
+
outMs: end
|
|
4234
|
+
};
|
|
4235
|
+
}
|
|
4236
|
+
/** An entity's fields read through its bases: what a read sees when the row may be a variant. */
|
|
4237
|
+
function fieldsOf(rows, entityId, kind) {
|
|
4238
|
+
return assembleEntityContent(rows, createEntityId(entityId), kind).payload;
|
|
4239
|
+
}
|
|
4240
|
+
function markerWindow(payload) {
|
|
4241
|
+
return requireWindow(payload.inMs, payload.outMs);
|
|
4242
|
+
}
|
|
4243
|
+
/**
|
|
4244
|
+
* How long the Clip runs. The window and the rate are the only stored facts;
|
|
4245
|
+
* the played length follows from them rather than being kept in step by hand.
|
|
4246
|
+
*/
|
|
4247
|
+
function markerDuration(payload, window = markerWindow(payload)) {
|
|
4248
|
+
return Math.round((window.outMs - window.inMs) / markerRate(payload));
|
|
4249
|
+
}
|
|
4250
|
+
function markerRate(payload) {
|
|
4251
|
+
return payload.rate === void 0 ? 1 : finitePositive(payload.rate, "linear speed rate");
|
|
4252
|
+
}
|
|
4253
|
+
function replaceRows(rows, replacement) {
|
|
4254
|
+
rows.entities = structuredClone([...replacement.entities]);
|
|
4255
|
+
rows.relations = structuredClone([...replacement.relations]);
|
|
4256
|
+
}
|
|
4257
|
+
function finiteNonNegative(value, label) {
|
|
4258
|
+
const result = finiteNumber(value, label);
|
|
4259
|
+
if (result < 0) throw new Error(`${label} must be non-negative`);
|
|
4260
|
+
return result;
|
|
4261
|
+
}
|
|
4262
|
+
/** How many Clips the Track already holds — the index a new one lands at. */
|
|
4263
|
+
function nextSequentialOrder(rows, trackEntityId) {
|
|
4264
|
+
return relationsFrom(rows.relations, "contain", trackEntityId).length;
|
|
4265
|
+
}
|
|
4266
|
+
function otherEndpoint(relation, entityId) {
|
|
4267
|
+
if (relation.endpoint0EntityId === entityId) return relation.endpoint1EntityId;
|
|
4268
|
+
if (relation.endpoint1EntityId === entityId) return relation.endpoint0EntityId;
|
|
4269
|
+
throw new Error(`Relation "${relation.relationId}" is not incident to Entity "${entityId}"`);
|
|
4270
|
+
}
|
|
4271
|
+
function replaceEntity(rows, entityId, replacement) {
|
|
4272
|
+
const index = rows.entities.findIndex((entity) => entity.entityId === entityId);
|
|
4273
|
+
if (index < 0) throw new Error(`Entity id "${entityId}" does not exist`);
|
|
4274
|
+
rows.entities[index] = replacement;
|
|
4275
|
+
}
|
|
4276
|
+
function cloneRows(rows) {
|
|
4277
|
+
return {
|
|
4278
|
+
entities: rows.entities.map((entity) => structuredClone(entity)),
|
|
4279
|
+
relations: rows.relations.map((relation) => structuredClone(relation))
|
|
4280
|
+
};
|
|
4281
|
+
}
|
|
4282
|
+
//#endregion
|
|
4283
|
+
//#region src/editor/id-gen.ts
|
|
4284
|
+
/**
|
|
4285
|
+
* Part-id generation, aligned with the online ecosystem.
|
|
4286
|
+
*
|
|
4287
|
+
* The authoritative online producers — agent-harness (`@harness/shared`
|
|
4288
|
+
* `genObjId`) and director.v2 (`common/obj_id.py` `gen_obj_id`) — both mint part
|
|
4289
|
+
* ids as `` `${prefix}_${ulid()}` ``, and real captured drafts use exactly that
|
|
4290
|
+
* shape (`clip_…` / `spe_…` / `cap_…` / `bgm_…`, each a 26-char ULID). The engine
|
|
4291
|
+
* previously emitted `vc_<base36 timestamp><6 random>`, a different prefix AND a
|
|
4292
|
+
* different encoding — the sole cross-repo id divergence. This module removes it
|
|
4293
|
+
* by emitting the same `<prefix>_<ULID>` bytes.
|
|
4294
|
+
*
|
|
4295
|
+
* The ULID is generated inline (Crockford Base32, 48-bit time + 80-bit random)
|
|
4296
|
+
* rather than pulling the `ulid` npm package: the randomness class matches the
|
|
4297
|
+
* old generator (both `Math.random`-based) and it keeps `@mengine/medeo-client`
|
|
4298
|
+
* dependency-free for a purely mechanical id string. Part ids only need to be
|
|
4299
|
+
* unique and lexicographically time-sortable, which this satisfies.
|
|
4300
|
+
*/
|
|
4301
|
+
/** Crockford Base32 alphabet (no I, L, O, U), per the ULID spec. */
|
|
4302
|
+
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
4303
|
+
const TIME_LEN = 10;
|
|
4304
|
+
const RANDOM_LEN = 16;
|
|
4305
|
+
function encodeTime(now) {
|
|
4306
|
+
let out = "";
|
|
4307
|
+
let ms = now;
|
|
4308
|
+
for (let i = TIME_LEN - 1; i >= 0; i--) {
|
|
4309
|
+
const mod = ms % 32;
|
|
4310
|
+
out = CROCKFORD[mod] + out;
|
|
4311
|
+
ms = (ms - mod) / 32;
|
|
4312
|
+
}
|
|
4313
|
+
return out;
|
|
4314
|
+
}
|
|
4315
|
+
function encodeRandom() {
|
|
4316
|
+
let out = "";
|
|
4317
|
+
for (let i = 0; i < RANDOM_LEN; i++) out += CROCKFORD[Math.floor(Math.random() * 32)];
|
|
4318
|
+
return out;
|
|
4319
|
+
}
|
|
4320
|
+
/** A 26-char Crockford Base32 ULID (10-char time + 16-char random). */
|
|
4321
|
+
function ulid() {
|
|
4322
|
+
return encodeTime(Date.now()) + encodeRandom();
|
|
4323
|
+
}
|
|
4324
|
+
function generatePartId(prefix) {
|
|
4325
|
+
return `${prefix}_${ulid()}`;
|
|
4326
|
+
}
|
|
4327
|
+
//#endregion
|
|
4328
|
+
//#region src/legacy/manual-sync-doc.ts
|
|
4329
|
+
/** Legacy entity tools only. The retired timeline document has no editing or reading adapter. */
|
|
4330
|
+
var ManualSyncDoc = class ManualSyncDoc extends ManualSyncTransport {
|
|
4331
|
+
constructor(client, doc, watermark) {
|
|
4332
|
+
super(client, doc, watermark);
|
|
4333
|
+
if (!Object.hasOwn(doc.getShallowValue(), "medeo") || doc.getMap("medeo").get("schema") !== "medeo.entities.loro.v1") throw new Error("Legacy tools require an Entity document");
|
|
4334
|
+
readEntityDocument(doc);
|
|
4335
|
+
}
|
|
4336
|
+
static open(options) {
|
|
4337
|
+
return openManualDocument(options, (doc, watermark) => new ManualSyncDoc(options.client, doc, watermark));
|
|
4338
|
+
}
|
|
4339
|
+
isEntityDocument() {
|
|
4340
|
+
this.assertActive();
|
|
4341
|
+
return true;
|
|
4342
|
+
}
|
|
4343
|
+
content() {
|
|
4344
|
+
this.assertActive();
|
|
4345
|
+
return projectEntityTimeline(readEntityDocument(this.doc).rows);
|
|
4346
|
+
}
|
|
4347
|
+
};
|
|
4348
|
+
//#endregion
|
|
4349
|
+
export { EntityGraphHttpClient, EntityProjectionGraph, EntityTimelineEditor, EntityTimelineProjectionError, LORO_ENTITY_SCHEMA, LoroEntityDocument, LoroEntityDraft, ManualSyncDoc, MedeoHttpDocStorage, MemoryDocStorage, MengineHttpClient, MengineHttpRequestError, MenginePushRejectedError, ScriptCompositionError, apply, applyFieldChanges, assembleEntityContent, assembleScriptText, assertCanonicalEditorResources, assertFieldChanges, assertMediaAssetWritePolicy, assetBytes, assetDigest, audioScriptAssetContent, base64ToBytes, bytesToBase64, decodeDocVersionMark, encodeDocVersionMark, ensureEditorFoundation, generatePartId, importMediaAsset, isJsonObject, phoneticScriptAssetContent, prepareCaptionContent, projectEntityTimeline, readDocumentAudioScript, readEntityDocument, readMengineEventStream, resolveEntityFieldOwner, resolveEntityTimelineLayout, resolveFieldPath, resolveGraphLayout, scriptAssetContent, scriptSnapshotAssetOf, scriptSnapshotIsCurrent, scriptSourceAssetOf, segmentsOf, selectedSegment };
|