@mengine/medeo-client 2.1.1-dsl.2 → 2.1.1-dsl.20
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 +13 -12
- package/dist/{dsl-EV6kCdr6.js → dsl-wdel0lX7.js} +1125 -531
- package/dist/dsl.d.ts +3 -3
- package/dist/dsl.js +3 -3
- package/dist/{index-D-6uebu4.d.ts → index-Bt3RhxV4.d.ts} +59 -56
- package/dist/{index-Cu7SdKN5.d.ts → index-CTai9I2R.d.ts} +208 -171
- package/dist/{index-D-3rdfUP.d.ts → index-CwZ0zt-E.d.ts} +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +13 -7
- package/dist/legacy.d.ts +2 -2
- package/dist/legacy.js +1 -1
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +4 -7
- package/dist/{video-draft-types-c_shaRyq.d.ts → video-draft-types-BOdawmig.d.ts} +126 -55
- package/dist/{video-draft-types-TXRv3wS6.js → video-draft-types-DKo53mZ8.js} +212 -113
- package/package.json +4 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as assertEntityPatch, A as getRootContainerByType, B as isValueOfContainerType, C as applyEncode, D as decodeNestedJsonValues, E as containerIdToContainerType, F as isArrayLike, G as tryUpdateToContainer, H as schemaToContainerType, I as isObject, J as getChildContainerSchema, K as valuesEqual, L as isObjectLike, M as hasTransform, N as inferContainerTypeFromValue, O as deepEqual, P as insertChildToMap, Q as assertEntity, R as isStateAndSchemaOfType, S as applyDecode, T as cidsEqual, U as stripUndefined, V as safeStringify, W as tryInferContainerType, X as getMapFieldSchema, Y as getChildSchema, Z as assertCaptionContent, _ as isLoroMovableListSchema, _t as resourceId, at as assertTimeAnchor, b as schemaContainsIgnoreField, c as isInvalidAtomic, ct as isRecord, d as schema, dt as relationTables, et as assertId, f as getDefaultValue, ft as validLanguage, g as isLoroMapSchema, gt as relationId, h as isLoroListSchema, ht as defaultIdFactory, it as assertScriptSegment, j as hardenCidDescriptors, k as defineCidProperty, l as phonemeCodec, lt as mergeFields, m as isListLikeSchema, mt as assertResourceIdentity, n as textStyles, nt as assertPhoneticScript, o as medeoDslLoroShape, ot as atomicFields, p as isContainerSchema, pt as assertRelationIdentity, q as CID_KEY, rt as assertRelation, s as captionContentCodec, st as entityTables, t as DEFAULT_UNIT_TIME_MS, tt as assertPhoneme, ut as patchRow, v as isLoroTreeSchema, w as applySchemaToInferOptions, x as validateSchema, y as isRootSchemaType, z as isTreeID } from "./video-draft-types-DKo53mZ8.js";
|
|
2
2
|
import { Cursor, LoroDoc, LoroList, LoroMap, LoroMovableList, LoroText, LoroTree, isContainer, isContainerId } from "loro-crdt";
|
|
3
3
|
import { Immer, setAutoFreeze } from "immer";
|
|
4
4
|
//#region ../medeo-dsl/src/text.ts
|
|
@@ -12,20 +12,15 @@ function range(text, start, end) {
|
|
|
12
12
|
assertTextOffset(text, end);
|
|
13
13
|
if (start >= end) throw new Error("Expected a nonempty text range");
|
|
14
14
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
if (
|
|
19
|
-
if (key === "pauseMs" && (selected !== " " || typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) throw new Error("pauseMs requires a nonnegative integer on one ordinary space");
|
|
15
|
+
/** A script marks only what language a run is written in; how to say it belongs to its phonetic script. */
|
|
16
|
+
function assertMark(key, value) {
|
|
17
|
+
if (key !== "language") throw new Error(`Unsupported mark: ${key}`);
|
|
18
|
+
if (value !== null && !validLanguage(value)) throw new Error("Invalid language");
|
|
20
19
|
}
|
|
21
|
-
function attributes(attrs
|
|
20
|
+
function attributes(attrs) {
|
|
22
21
|
if (!attrs) return;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
assertMark(key, value, selected);
|
|
26
|
-
result[key] = key === "pauseMs" && value === 0 ? null : value;
|
|
27
|
-
}
|
|
28
|
-
return result;
|
|
22
|
+
for (const [key, value] of Object.entries(attrs)) assertMark(key, value);
|
|
23
|
+
return { ...attrs };
|
|
29
24
|
}
|
|
30
25
|
function previewDelta(text, delta) {
|
|
31
26
|
let offset = 0;
|
|
@@ -44,7 +39,7 @@ function previewDelta(text, delta) {
|
|
|
44
39
|
].includes(key))) throw new Error("Invalid text Delta operation");
|
|
45
40
|
if ("insert" in op) {
|
|
46
41
|
if (typeof op.insert !== "string") throw new Error("Only text insertions are supported");
|
|
47
|
-
const attrs = attributes(op.attributes
|
|
42
|
+
const attrs = attributes(op.attributes);
|
|
48
43
|
text = text.slice(0, offset) + op.insert + text.slice(offset);
|
|
49
44
|
normalized.push({
|
|
50
45
|
insert: op.insert,
|
|
@@ -56,7 +51,7 @@ function previewDelta(text, delta) {
|
|
|
56
51
|
if (!Number.isSafeInteger(length) || length < 0) throw new Error("Invalid Delta length");
|
|
57
52
|
assertTextOffset(text, offset + length);
|
|
58
53
|
if ("retain" in op) {
|
|
59
|
-
const attrs = attributes(op.attributes
|
|
54
|
+
const attrs = attributes(op.attributes);
|
|
60
55
|
normalized.push({
|
|
61
56
|
retain: length,
|
|
62
57
|
...attrs ? { attributes: attrs } : {}
|
|
@@ -91,11 +86,11 @@ function prepareTextEdits(text, edits) {
|
|
|
91
86
|
break;
|
|
92
87
|
case "mark":
|
|
93
88
|
range(text, edit.start, edit.end);
|
|
94
|
-
assertMark(edit.key, edit.value
|
|
89
|
+
assertMark(edit.key, edit.value);
|
|
95
90
|
break;
|
|
96
91
|
case "unmark":
|
|
97
92
|
range(text, edit.start, edit.end);
|
|
98
|
-
assertMark(edit.key, null
|
|
93
|
+
assertMark(edit.key, null);
|
|
99
94
|
break;
|
|
100
95
|
case "delta": {
|
|
101
96
|
const result = previewDelta(text, edit.delta);
|
|
@@ -109,7 +104,28 @@ function prepareTextEdits(text, edits) {
|
|
|
109
104
|
}
|
|
110
105
|
return edit;
|
|
111
106
|
}),
|
|
112
|
-
|
|
107
|
+
text
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** The language a whole segment carries: one shared mark on every unit, else none. */
|
|
111
|
+
function markedLanguage(text) {
|
|
112
|
+
if (!text.text.length) return void 0;
|
|
113
|
+
const first = text.langs[0];
|
|
114
|
+
for (let i = 1; i < text.text.length; i++) if (text.langs[i] !== first) return void 0;
|
|
115
|
+
return first;
|
|
116
|
+
}
|
|
117
|
+
/** Read language marks off a rich-text delta. */
|
|
118
|
+
function langsFromDelta(delta) {
|
|
119
|
+
let text = "";
|
|
120
|
+
const langs = [];
|
|
121
|
+
for (const run of delta) {
|
|
122
|
+
text += run.insert;
|
|
123
|
+
const language = typeof run.attributes?.language === "string" ? run.attributes.language : void 0;
|
|
124
|
+
for (let i = 0; i < run.insert.length; i++) langs.push(language);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
text,
|
|
128
|
+
langs
|
|
113
129
|
};
|
|
114
130
|
}
|
|
115
131
|
//#endregion
|
|
@@ -122,73 +138,116 @@ function requireEntity(rows, table, id) {
|
|
|
122
138
|
return row;
|
|
123
139
|
}
|
|
124
140
|
/**
|
|
125
|
-
* Whether one relation row may stand among these rows: its fields, its derived ID, and what
|
|
126
|
-
*
|
|
127
|
-
*
|
|
141
|
+
* Whether one relation row may stand among these rows: its fields, its derived ID, and what its
|
|
142
|
+
* kind requires of each end. A row being linked need not be in `rows` yet. Relations an entity
|
|
143
|
+
* holds at most one of are fields of that entity; see {@link assertReferenceAdmitted}.
|
|
128
144
|
*/
|
|
129
145
|
function assertRelationAdmitted(rows, table, id, value) {
|
|
130
146
|
assertId(id);
|
|
131
147
|
assertRelation(table, value);
|
|
132
148
|
assertRelationIdentity(table, id, value);
|
|
133
149
|
const v = value;
|
|
134
|
-
const require = (endpoint, id) => requireEntity(rows, endpoint, id);
|
|
135
150
|
switch (table) {
|
|
136
|
-
case "clip-source-relations":
|
|
137
|
-
require("clip-entities", v.clipId);
|
|
138
|
-
if (!rows["media-entities"][v.sourceId] && !rows["caption-entities"][v.sourceId]) throw new Error("Invalid Clip source");
|
|
139
|
-
break;
|
|
140
|
-
case "time-anchor-relations": {
|
|
141
|
-
require("clip-entities", v.from);
|
|
142
|
-
if (rows["timeline-entities"][v.to]) {
|
|
143
|
-
if (v.offsetMs < 0) throw new Error("Absolute offset must be nonnegative");
|
|
144
|
-
} else require("clip-entities", v.to);
|
|
145
|
-
const visited = /* @__PURE__ */ new Set();
|
|
146
|
-
let from = v.from;
|
|
147
|
-
while (true) {
|
|
148
|
-
if (visited.has(from)) throw new Error("Time anchor cycle");
|
|
149
|
-
visited.add(from);
|
|
150
|
-
const anchor = from === v.from ? v : rows["time-anchor-relations"][`ta:${from}`];
|
|
151
|
-
if (!isRecord(anchor) || typeof anchor.to !== "string") break;
|
|
152
|
-
from = anchor.to;
|
|
153
|
-
}
|
|
154
|
-
break;
|
|
155
|
-
}
|
|
156
|
-
case "caption-content-relations":
|
|
157
|
-
assertCaptionEndpoints(rows, v);
|
|
158
|
-
break;
|
|
159
|
-
case "caption-alignment-relations": {
|
|
160
|
-
require("caption-entities", v.captionId);
|
|
161
|
-
const media = require("media-entities", v.mediaId);
|
|
162
|
-
if (media.kind === "image" || v.startMs >= v.endMs || v.endMs > media.durationMs) throw new Error("Invalid source alignment");
|
|
163
|
-
break;
|
|
164
|
-
}
|
|
165
151
|
case "voice-timbre-relations":
|
|
166
|
-
if (
|
|
167
|
-
|
|
152
|
+
if (requireEntity(rows, "media-entities", v.recordingId).kind !== "voiceover-recording") throw new Error("Timbre requires a voiceover recording");
|
|
153
|
+
requireEntity(rows, "voice-entities", v.voiceId);
|
|
168
154
|
break;
|
|
169
155
|
case "derived-from-relations":
|
|
170
156
|
for (const end of [v.entityId, v.sourceId]) if (!entityTables.some((t) => Object.hasOwn(rows[t], end))) throw new Error("Missing provenance endpoint");
|
|
171
157
|
break;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Entity fields that hold a single-valued relation, by the entity that owns it. */
|
|
161
|
+
const referenceFields = {
|
|
162
|
+
"clip-entities": ["source", "anchor"],
|
|
163
|
+
"phonetic-script-entities": ["phoneme"],
|
|
164
|
+
"caption-entities": ["selection"]
|
|
165
|
+
};
|
|
166
|
+
/** The segment a selection names: refused when its script or the segment is missing. */
|
|
167
|
+
function requireSegment(rows, audioScriptId, segmentId) {
|
|
168
|
+
const script = requireEntity(rows, "audio-script-entities", audioScriptId);
|
|
169
|
+
const segment = Array.isArray(script.segments) ? script.segments.find((s) => isRecord(s) && s.id === segmentId) : void 0;
|
|
170
|
+
if (!segment) throw new Error(`Missing segment ${String(segmentId)} of ${String(audioScriptId)}`);
|
|
171
|
+
return segment;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Whether a single-valued relation, stored as a field of the entity that owns it, may hold this
|
|
175
|
+
* value among these rows. The owner need not be stored yet; an absent value unlinks it.
|
|
176
|
+
* A selection's run is checked against the segment's live text by whoever holds it.
|
|
177
|
+
*/
|
|
178
|
+
function assertReferenceAdmitted(rows, table, id, field, value) {
|
|
179
|
+
assertId(id);
|
|
180
|
+
const key = `${table}.${field}`;
|
|
181
|
+
if (value === void 0) {
|
|
182
|
+
if (key === "clip-entities.source") throw new Error(`Missing required relation: ${key}`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
switch (key) {
|
|
186
|
+
case "clip-entities.source":
|
|
187
|
+
assertId(value);
|
|
188
|
+
if (!Object.hasOwn(rows["media-entities"], value) && !Object.hasOwn(rows["caption-entities"], value)) throw new Error("Invalid Clip source");
|
|
189
|
+
break;
|
|
190
|
+
case "clip-entities.anchor": {
|
|
191
|
+
assertTimeAnchor(value);
|
|
192
|
+
const anchor = value;
|
|
193
|
+
if (Object.hasOwn(rows["timeline-entities"], anchor.to)) {
|
|
194
|
+
if (anchor.offsetMs < 0) throw new Error("Absolute offset must be nonnegative");
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
requireEntity(rows, "clip-entities", anchor.to);
|
|
198
|
+
const visited = new Set([id]);
|
|
199
|
+
for (let next = anchor.to; typeof next === "string";) {
|
|
200
|
+
if (visited.has(next)) throw new Error("Time anchor cycle");
|
|
201
|
+
visited.add(next);
|
|
202
|
+
const clip = rows["clip-entities"][next];
|
|
203
|
+
next = isRecord(clip) && isRecord(clip.anchor) ? clip.anchor.to : void 0;
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case "caption-entities.selection":
|
|
208
|
+
case "phonetic-script-entities.phoneme": {
|
|
209
|
+
if (key === "caption-entities.selection") assertCaptionContent(value);
|
|
210
|
+
else assertPhoneme(value);
|
|
211
|
+
const { audioScriptId, segmentId } = value;
|
|
212
|
+
requireSegment(rows, audioScriptId, segmentId);
|
|
177
213
|
break;
|
|
178
214
|
}
|
|
179
215
|
}
|
|
180
216
|
}
|
|
181
|
-
/** Both ends of a caption's selection exist: the caption and the
|
|
217
|
+
/** Both ends of a caption's selection exist: the caption and the segment it selects. */
|
|
182
218
|
function assertCaptionEndpoints(rows, selection) {
|
|
183
219
|
requireEntity(rows, "caption-entities", selection.captionId);
|
|
184
|
-
|
|
220
|
+
requireSegment(rows, selection.audioScriptId, selection.segmentId);
|
|
221
|
+
}
|
|
222
|
+
/** Both ends of a phoneme exist: the PhoneticScript and the segment it is written for. */
|
|
223
|
+
function assertPhonemeEndpoints(rows, selection) {
|
|
224
|
+
requireEntity(rows, "phonetic-script-entities", selection.phoneticScriptId);
|
|
225
|
+
requireSegment(rows, selection.audioScriptId, selection.segmentId);
|
|
185
226
|
}
|
|
186
|
-
/**
|
|
187
|
-
|
|
227
|
+
/**
|
|
228
|
+
* A selection of a segment's current text: the whole segment, or a run of it in UTF-16 offsets
|
|
229
|
+
* with both ends stated. What it selects is never empty.
|
|
230
|
+
*/
|
|
231
|
+
function assertSegmentRange(range, text) {
|
|
232
|
+
if (range.start === void 0 !== (range.end === void 0)) throw new Error("A run inside a segment states both its start and its end");
|
|
233
|
+
if (range.start === void 0 || range.end === void 0) {
|
|
234
|
+
if (!text.length) throw new Error("An empty segment cannot be selected");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
assertTextOffset(text, range.start);
|
|
238
|
+
assertTextOffset(text, range.end);
|
|
239
|
+
if (range.start >= range.end) throw new Error("Expected a nonempty range in the current segment");
|
|
240
|
+
}
|
|
241
|
+
/** A caption selects a segment of its script, or a nonempty run of it. */
|
|
242
|
+
function assertCaptionSelection(rows, selection, text) {
|
|
188
243
|
assertCaptionEndpoints(rows, selection);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
244
|
+
assertSegmentRange(selection, text);
|
|
245
|
+
}
|
|
246
|
+
/** A PhoneticScript is written for a segment of its script, or a nonempty run of it, as a Caption selects one. */
|
|
247
|
+
function assertPhonemeSelection(rows, selection, text) {
|
|
248
|
+
assertPhonemeEndpoints(rows, selection);
|
|
249
|
+
assertSegmentRange(selection, text);
|
|
250
|
+
if (selection.phonemeScript !== void 0 && typeof selection.phonemeScript !== "string") throw new Error("phonemeScript is a string");
|
|
192
251
|
}
|
|
193
252
|
/** A caption's text comes from a selection of a script or from its own text, never both. */
|
|
194
253
|
function assertCaptionSource(caption, selected) {
|
|
@@ -261,8 +320,9 @@ function replacementPatch(before, after) {
|
|
|
261
320
|
/**
|
|
262
321
|
* Admission of one whole change, asked once before it is published. Only what the change touched
|
|
263
322
|
* is checked, so an unrelated edit still lands in a merged document that holds invalid rows.
|
|
264
|
-
*
|
|
265
|
-
*
|
|
323
|
+
* Single-valued relations are fields of their owner and are checked when the owner's field is
|
|
324
|
+
* touched. Containment and caption ranges are checked by whoever holds the tree and the script
|
|
325
|
+
* bodies, with `assertContainTree` and `assertCaptionSelection`.
|
|
266
326
|
*/
|
|
267
327
|
function assertChangeAdmitted(change) {
|
|
268
328
|
const { before, after } = change;
|
|
@@ -278,18 +338,28 @@ function assertChangeAdmitted(change) {
|
|
|
278
338
|
if (!isRecord(value)) throw new Error("Expected an entity object");
|
|
279
339
|
assertEntityPatch(table, Object.fromEntries([...fields].map((field) => [field, replacementPatch(old[field], value[field])])), old);
|
|
280
340
|
}
|
|
281
|
-
const
|
|
341
|
+
const row = value;
|
|
342
|
+
for (const field of referenceFields[table] ?? []) if (!old || fields.has(field)) assertReferenceAdmitted(after, table, id, field, row[field]);
|
|
343
|
+
const marker = row.seqMarker;
|
|
282
344
|
if (table === "clip-entities" && (!old || fields.has("seqMarker")) && marker?.outMs <= marker?.inMs) throw new Error("Clip window must be nonempty");
|
|
345
|
+
if (table === "clip-entities" && (!old || fields.has("seqMarker") || fields.has("source"))) assertFillsWithWholeSource(after, marker, row.source);
|
|
283
346
|
}
|
|
284
347
|
if (change.entities.has("timeline-entities") && Object.keys(after["timeline-entities"]).length > 1) throw new Error("Document already has a Timeline");
|
|
285
|
-
for (const [table, ids] of change.relations) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const value = after[table][id];
|
|
289
|
-
if (value !== void 0) assertRelationAdmitted(after, table, id, value);
|
|
290
|
-
}
|
|
348
|
+
for (const [table, ids] of change.relations) for (const id of ids) {
|
|
349
|
+
const value = after[table][id];
|
|
350
|
+
if (value !== void 0) assertRelationAdmitted(after, table, id, value);
|
|
291
351
|
}
|
|
292
352
|
}
|
|
353
|
+
/**
|
|
354
|
+
* A Clip that fills the Timeline plays its whole source at 1×: the player loops a short source and
|
|
355
|
+
* cuts a long one at the Timeline's end (RFC 02 §3.3), so a window or speed of its own has no meaning.
|
|
356
|
+
*/
|
|
357
|
+
function assertFillsWithWholeSource(rows, marker, source) {
|
|
358
|
+
if (marker?.fillTimeline !== true) return;
|
|
359
|
+
const media = typeof source === "string" ? rows["media-entities"][source] : void 0;
|
|
360
|
+
const durationMs = typeof media?.durationMs === "number" ? media.durationMs : void 0;
|
|
361
|
+
if (marker.inMs !== 0 || marker.rate !== void 0 && marker.rate !== 1 || durationMs !== void 0 && marker.outMs !== durationMs) throw new Error(`A Clip that fills the Timeline plays its whole source at 1×: inMs 0, outMs ${durationMs ?? "its length"}, no rate; the Timeline's end cuts it`);
|
|
362
|
+
}
|
|
293
363
|
//#endregion
|
|
294
364
|
//#region src/dsl/projection/types.ts
|
|
295
365
|
/** Unsupported legacy representations fail explicitly; recoverable CRDT state is returned as diagnostics. */
|
|
@@ -331,9 +401,9 @@ function resolveDslLayout(snapshot) {
|
|
|
331
401
|
const clips = [];
|
|
332
402
|
for (const child of node.children) {
|
|
333
403
|
const id = child.entityId, clip = snapshot["clip-entities"][id];
|
|
334
|
-
const sourceId =
|
|
335
|
-
const media =
|
|
336
|
-
const caption =
|
|
404
|
+
const sourceId = clip.source;
|
|
405
|
+
const media = snapshot["media-entities"][sourceId];
|
|
406
|
+
const caption = snapshot["caption-entities"][sourceId];
|
|
337
407
|
const marker = clip.seqMarker;
|
|
338
408
|
let inMs = marker.inMs, outMs = marker.outMs;
|
|
339
409
|
const present = media !== void 0 || caption !== void 0;
|
|
@@ -347,7 +417,7 @@ function resolveDslLayout(snapshot) {
|
|
|
347
417
|
const rate = marker.rate ?? 1;
|
|
348
418
|
const duration = present ? Math.max(0, Math.round((outMs - inMs) / rate)) : 0;
|
|
349
419
|
if (!Number.isSafeInteger(duration)) projectionError("time-overflow", `clip-entities/${id}`, "Duration exceeds safe integer milliseconds");
|
|
350
|
-
const anchor =
|
|
420
|
+
const anchor = clip.anchor;
|
|
351
421
|
const placement = {
|
|
352
422
|
clipId: id,
|
|
353
423
|
trackId: node.entityId,
|
|
@@ -384,7 +454,7 @@ function resolveDslLayout(snapshot) {
|
|
|
384
454
|
}
|
|
385
455
|
seen.set(current, path.length);
|
|
386
456
|
path.push(current);
|
|
387
|
-
current = snapshot["
|
|
457
|
+
current = snapshot["clip-entities"][current]?.anchor?.to;
|
|
388
458
|
}
|
|
389
459
|
for (const member of path) done.add(member);
|
|
390
460
|
}
|
|
@@ -393,7 +463,7 @@ function resolveDslLayout(snapshot) {
|
|
|
393
463
|
const position = (id) => {
|
|
394
464
|
const known = positions.get(id);
|
|
395
465
|
if (known) return known;
|
|
396
|
-
const clip = result.clips[id], anchor = snapshot["
|
|
466
|
+
const clip = result.clips[id], anchor = snapshot["clip-entities"][id]?.anchor;
|
|
397
467
|
let value;
|
|
398
468
|
if (anchor && clip.mode === "anchored" && !cycle.has(id) && result.clips[anchor.to]) {
|
|
399
469
|
const parent = position(anchor.to);
|
|
@@ -500,14 +570,6 @@ function resolveDslLayout(snapshot) {
|
|
|
500
570
|
const a = track.clips[i];
|
|
501
571
|
for (const b of track.clips.slice(i + 1)) if (overlap(a, b)) diagnose("unresolved-overlap", b.clipId, `Overlaps ${a.clipId}; fixed positions, anchors and complete windows are retained`);
|
|
502
572
|
}
|
|
503
|
-
for (const [id, relation] of Object.entries(snapshot["caption-alignment-relations"])) {
|
|
504
|
-
const media = snapshot["media-entities"][relation.mediaId];
|
|
505
|
-
if (!media || media.kind === "image" || relation.endMs <= relation.startMs || relation.endMs > media.durationMs) result.diagnostics.push({
|
|
506
|
-
code: "invalid-caption-alignment",
|
|
507
|
-
path: `caption-alignment-relations/${id}`,
|
|
508
|
-
message: "No valid source alignment; the placed display window is retained"
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
573
|
return result;
|
|
512
574
|
}
|
|
513
575
|
function overlap(a, b) {
|
|
@@ -563,9 +625,10 @@ function timelineId(s) {
|
|
|
563
625
|
/** Placement facts are authored only by explicit actions; projection never writes them back. */
|
|
564
626
|
function placementMutations(s, clipId, placement) {
|
|
565
627
|
if (placement.kind === "sequential") return [{
|
|
566
|
-
type: "
|
|
567
|
-
table: "
|
|
568
|
-
id:
|
|
628
|
+
type: "patch-entity",
|
|
629
|
+
table: "clip-entities",
|
|
630
|
+
id: clipId,
|
|
631
|
+
patch: { anchor: void 0 }
|
|
569
632
|
}];
|
|
570
633
|
const offset = placement.kind === "absolute" ? placement.startMs : placement.offsetMs;
|
|
571
634
|
if (!Number.isSafeInteger(offset) || placement.kind === "absolute" && offset < 0) throw new Error("Invalid placement time");
|
|
@@ -578,27 +641,31 @@ function placementMutations(s, clipId, placement) {
|
|
|
578
641
|
while (next && s["clip-entities"][next]) {
|
|
579
642
|
if (visited.has(next)) throw new Error("Cyclic time anchor");
|
|
580
643
|
visited.add(next);
|
|
581
|
-
next = s["
|
|
644
|
+
next = s["clip-entities"][next]?.anchor?.to;
|
|
582
645
|
}
|
|
583
646
|
fallback = Math.max(0, resolveDslLayout(s).clips[target].startMs + offset);
|
|
584
647
|
}
|
|
585
648
|
if (!Number.isSafeInteger(fallback)) throw new Error("Invalid fallback time");
|
|
586
649
|
return [{
|
|
587
|
-
type: "
|
|
588
|
-
table: "
|
|
589
|
-
|
|
590
|
-
|
|
650
|
+
type: "patch-entity",
|
|
651
|
+
table: "clip-entities",
|
|
652
|
+
id: clipId,
|
|
653
|
+
patch: { anchor: {
|
|
591
654
|
to: target,
|
|
592
655
|
offsetMs: offset,
|
|
593
656
|
fallback_abs_ms: fallback
|
|
594
|
-
}
|
|
657
|
+
} }
|
|
595
658
|
}];
|
|
596
659
|
}
|
|
660
|
+
/** Clips whose time anchor points at a Clip in `ids`, with that anchor. */
|
|
661
|
+
function anchoredTo(s, ids) {
|
|
662
|
+
return Object.entries(s["clip-entities"]).flatMap(([id, clip]) => clip.anchor && ids.has(clip.anchor.to) ? [id] : []);
|
|
663
|
+
}
|
|
597
664
|
function detachChildren(s, ids) {
|
|
598
665
|
const layout = resolveDslLayout(s);
|
|
599
|
-
return
|
|
666
|
+
return anchoredTo(s, ids).flatMap((id) => !ids.has(id) && layout.clips[id] ? placementMutations(s, id, {
|
|
600
667
|
kind: "absolute",
|
|
601
|
-
startMs: layout.clips[
|
|
668
|
+
startMs: layout.clips[id].startMs
|
|
602
669
|
}) : []);
|
|
603
670
|
}
|
|
604
671
|
function deleteClips(s, ids, policy) {
|
|
@@ -608,9 +675,9 @@ function deleteClips(s, ids, policy) {
|
|
|
608
675
|
const removed = new Set(ids);
|
|
609
676
|
if (policy === "cascade") {
|
|
610
677
|
const queue = [...ids];
|
|
611
|
-
for (let i = 0; i < queue.length; i++) for (const
|
|
612
|
-
removed.add(
|
|
613
|
-
queue.push(
|
|
678
|
+
for (let i = 0; i < queue.length; i++) for (const id of anchoredTo(s, new Set([queue[i]]))) if (!removed.has(id)) {
|
|
679
|
+
removed.add(id);
|
|
680
|
+
queue.push(id);
|
|
614
681
|
}
|
|
615
682
|
}
|
|
616
683
|
const mutations = policy === "detach" ? detachChildren(s, removed) : [];
|
|
@@ -620,14 +687,6 @@ function deleteClips(s, ids, policy) {
|
|
|
620
687
|
entityId: id
|
|
621
688
|
});
|
|
622
689
|
mutations.push({
|
|
623
|
-
type: "delete-relation",
|
|
624
|
-
table: "time-anchor-relations",
|
|
625
|
-
id: `ta:${id}`
|
|
626
|
-
}, {
|
|
627
|
-
type: "delete-relation",
|
|
628
|
-
table: "clip-source-relations",
|
|
629
|
-
id: `cs:${id}`
|
|
630
|
-
}, {
|
|
631
690
|
type: "delete-entity",
|
|
632
691
|
table: "clip-entities",
|
|
633
692
|
id
|
|
@@ -640,11 +699,10 @@ function deleteClips(s, ids, policy) {
|
|
|
640
699
|
/** Batch edits validate against preceding prepared facts without touching the live document. */
|
|
641
700
|
function updateClipsPlan(base, updates) {
|
|
642
701
|
uniqueIds(updates.map((update) => update.clipId));
|
|
643
|
-
const clips = { ...base["clip-entities"] }
|
|
702
|
+
const clips = { ...base["clip-entities"] };
|
|
644
703
|
const preview = {
|
|
645
704
|
...base,
|
|
646
|
-
"clip-entities": clips
|
|
647
|
-
"time-anchor-relations": anchors
|
|
705
|
+
"clip-entities": clips
|
|
648
706
|
};
|
|
649
707
|
const mutations = [];
|
|
650
708
|
for (const update of updates) {
|
|
@@ -660,9 +718,7 @@ function updateClipsPlan(base, updates) {
|
|
|
660
718
|
if (clip.seqMarker.fillTimeline && (windowChanged || Object.hasOwn(marker, "rate"))) throw new Error("BGM trim and speed are not enabled");
|
|
661
719
|
const next = mergeFields(clip, patch);
|
|
662
720
|
if (windowChanged) {
|
|
663
|
-
const
|
|
664
|
-
if (!sourceId) throw new Error("Missing Clip source");
|
|
665
|
-
const source = requireSource(preview, sourceId, requireTrack(preview, trackOf(preview, update.clipId)).role);
|
|
721
|
+
const source = requireSource(preview, clip.source, requireTrack(preview, trackOf(preview, update.clipId)).role);
|
|
666
722
|
validateWindow(next.seqMarker, "durationMs" in source ? source.durationMs : void 0);
|
|
667
723
|
}
|
|
668
724
|
if (Object.keys(patch).length) mutations.push({
|
|
@@ -676,8 +732,7 @@ function updateClipsPlan(base, updates) {
|
|
|
676
732
|
trackOf(preview, update.clipId);
|
|
677
733
|
const changes = placementMutations(preview, update.clipId, update.placement);
|
|
678
734
|
mutations.push(...changes);
|
|
679
|
-
for (const change of changes) if (change.type === "
|
|
680
|
-
else if (change.type === "delete-relation") delete anchors[change.id];
|
|
735
|
+
for (const change of changes) if (change.type === "patch-entity" && change.table === "clip-entities") clips[update.clipId] = mergeFields(clips[update.clipId], change.patch);
|
|
681
736
|
}
|
|
682
737
|
}
|
|
683
738
|
return mutations;
|
|
@@ -724,7 +779,10 @@ function addClipsPlan(s, input, ids) {
|
|
|
724
779
|
marker.fillTimeline = true;
|
|
725
780
|
}
|
|
726
781
|
validateWindow(marker, duration);
|
|
727
|
-
const clip = {
|
|
782
|
+
const clip = {
|
|
783
|
+
seqMarker: marker,
|
|
784
|
+
source: source.id
|
|
785
|
+
};
|
|
728
786
|
if (item.volume !== void 0) clip.volume = item.volume;
|
|
729
787
|
const id = ids[offset];
|
|
730
788
|
const result = [
|
|
@@ -735,14 +793,6 @@ function addClipsPlan(s, input, ids) {
|
|
|
735
793
|
id,
|
|
736
794
|
value: clip
|
|
737
795
|
},
|
|
738
|
-
{
|
|
739
|
-
type: "set-relation",
|
|
740
|
-
table: "clip-source-relations",
|
|
741
|
-
value: {
|
|
742
|
-
clipId: id,
|
|
743
|
-
sourceId: source.id
|
|
744
|
-
}
|
|
745
|
-
},
|
|
746
796
|
{
|
|
747
797
|
type: "insert-node",
|
|
748
798
|
entityId: id,
|
|
@@ -762,10 +812,9 @@ function moveClipsPlan(s, input) {
|
|
|
762
812
|
if (!Number.isSafeInteger(input.index) || input.index < 0 || input.index > target.length) throw new Error("Invalid insertion index");
|
|
763
813
|
for (const id of input.clipIds) {
|
|
764
814
|
trackOf(s, id);
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
requireSource(s, source.sourceId, requireTrack(s, input.trackId).role);
|
|
815
|
+
const clip = requireClip(s, id);
|
|
816
|
+
if (clip.anchor) throw new Error("Sequential reorder requires sequential Clips");
|
|
817
|
+
requireSource(s, clip.source, requireTrack(s, input.trackId).role);
|
|
769
818
|
}
|
|
770
819
|
const mutations = input.onAnchored === "keepAbsolute" ? detachChildren(s, moved) : [];
|
|
771
820
|
const order = [...target];
|
|
@@ -783,7 +832,7 @@ function replaceSequencePlan(s, input, ids) {
|
|
|
783
832
|
uniqueIds(input.clipIds);
|
|
784
833
|
if (!input.clipIds.length || !input.clips.length) throw new Error("Replacement requires nonempty runs");
|
|
785
834
|
const trackId = trackOf(s, input.clipIds[0]), order = children(s, trackId), index = order.indexOf(input.clipIds[0]);
|
|
786
|
-
if (input.clipIds.some((id, i) => order[index + i] !== id || s["
|
|
835
|
+
if (input.clipIds.some((id, i) => order[index + i] !== id || s["clip-entities"][id]?.anchor)) throw new Error("Expected an ordered contiguous sequential run");
|
|
787
836
|
if (input.clips.some((c) => c.placement && c.placement.kind !== "sequential")) throw new Error("Replacement Clips must be sequential");
|
|
788
837
|
const mutations = addClipsPlan(s, {
|
|
789
838
|
trackId,
|
|
@@ -791,7 +840,7 @@ function replaceSequencePlan(s, input, ids) {
|
|
|
791
840
|
index
|
|
792
841
|
}, ids);
|
|
793
842
|
const removed = new Set(input.clipIds);
|
|
794
|
-
const
|
|
843
|
+
const clips = { ...s["clip-entities"] };
|
|
795
844
|
if (input.onAnchored === "remap") {
|
|
796
845
|
const begin = resolveDslLayout(s).clips[input.clipIds[0]].startMs;
|
|
797
846
|
const starts = [];
|
|
@@ -803,26 +852,30 @@ function replaceSequencePlan(s, input, ids) {
|
|
|
803
852
|
const marker = creation.value.seqMarker;
|
|
804
853
|
end += Math.round((marker.outMs - marker.inMs) / (marker.rate ?? 1));
|
|
805
854
|
}
|
|
806
|
-
for (const
|
|
807
|
-
const
|
|
808
|
-
|
|
855
|
+
for (const child of anchoredTo(s, removed)) {
|
|
856
|
+
const anchor = s["clip-entities"][child].anchor;
|
|
857
|
+
const slot = input.clipIds.indexOf(anchor.to);
|
|
858
|
+
if (removed.has(child) || slot >= ids.length) continue;
|
|
809
859
|
const value = {
|
|
810
|
-
from: r.from,
|
|
811
860
|
to: ids[slot],
|
|
812
|
-
offsetMs:
|
|
813
|
-
fallback_abs_ms: Math.max(0, starts[slot] +
|
|
861
|
+
offsetMs: anchor.offsetMs,
|
|
862
|
+
fallback_abs_ms: Math.max(0, starts[slot] + anchor.offsetMs)
|
|
814
863
|
};
|
|
815
864
|
mutations.push({
|
|
816
|
-
type: "
|
|
817
|
-
table: "
|
|
818
|
-
|
|
865
|
+
type: "patch-entity",
|
|
866
|
+
table: "clip-entities",
|
|
867
|
+
id: child,
|
|
868
|
+
patch: { anchor: value }
|
|
819
869
|
});
|
|
820
|
-
|
|
870
|
+
clips[child] = {
|
|
871
|
+
...clips[child],
|
|
872
|
+
anchor: value
|
|
873
|
+
};
|
|
821
874
|
}
|
|
822
875
|
}
|
|
823
876
|
mutations.push(...deleteClips({
|
|
824
877
|
...s,
|
|
825
|
-
"
|
|
878
|
+
"clip-entities": clips
|
|
826
879
|
}, input.clipIds, "cascade"));
|
|
827
880
|
return mutations;
|
|
828
881
|
}
|
|
@@ -830,26 +883,18 @@ function replaceSourcePlan(s, clipId, input) {
|
|
|
830
883
|
if (requireClip(s, clipId).seqMarker.fillTimeline) throw new Error("Use setBgm to replace a fill Clip");
|
|
831
884
|
const source = sourcePlan(s, input, trackOf(s, clipId));
|
|
832
885
|
validateWindow(input, "durationMs" in source.source ? source.source.durationMs : void 0);
|
|
833
|
-
return [
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
patch: { seqMarker: {
|
|
886
|
+
return [...source.mutations, {
|
|
887
|
+
type: "patch-entity",
|
|
888
|
+
table: "clip-entities",
|
|
889
|
+
id: clipId,
|
|
890
|
+
patch: {
|
|
891
|
+
seqMarker: {
|
|
840
892
|
inMs: input.inMs,
|
|
841
893
|
outMs: input.outMs
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
{
|
|
845
|
-
type: "set-relation",
|
|
846
|
-
table: "clip-source-relations",
|
|
847
|
-
value: {
|
|
848
|
-
clipId,
|
|
849
|
-
sourceId: source.id
|
|
850
|
-
}
|
|
894
|
+
},
|
|
895
|
+
source: source.id
|
|
851
896
|
}
|
|
852
|
-
];
|
|
897
|
+
}];
|
|
853
898
|
}
|
|
854
899
|
const produce = new Immer({
|
|
855
900
|
autoFreeze: false,
|
|
@@ -3868,11 +3913,21 @@ function rootMap(doc, name) {
|
|
|
3868
3913
|
const value = doc.getContainerById(id);
|
|
3869
3914
|
return value?.kind() === "Map" ? value : void 0;
|
|
3870
3915
|
}
|
|
3871
|
-
function
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
return isContainer(
|
|
3916
|
+
function asMap(value) {
|
|
3917
|
+
return isContainer(value) && value.kind() === "Map" ? value : void 0;
|
|
3918
|
+
}
|
|
3919
|
+
function asText(value) {
|
|
3920
|
+
return isContainer(value) && value.kind() === "Text" && !value.isDeleted() ? value : void 0;
|
|
3921
|
+
}
|
|
3922
|
+
/** The live Text a target names: a Caption's own, or a script segment's. */
|
|
3923
|
+
function liveText(doc, target) {
|
|
3924
|
+
if ("captionId" in target) return asText(asMap(rootMap(doc, "caption-entities")?.get(target.captionId))?.get("text"));
|
|
3925
|
+
const segments = asMap(rootMap(doc, "audio-script-entities")?.get(target.audioScriptId))?.get("segments");
|
|
3926
|
+
if (!isContainer(segments) || segments.kind() !== "MovableList") return;
|
|
3927
|
+
for (const item of segments.toArray()) {
|
|
3928
|
+
const segment = asMap(item);
|
|
3929
|
+
if (segment?.get("id") === target.segmentId) return asText(segment.get("text"));
|
|
3930
|
+
}
|
|
3876
3931
|
}
|
|
3877
3932
|
function readContainNodes(nodes) {
|
|
3878
3933
|
const read = (nodes) => nodes.map((node) => ({
|
|
@@ -3882,19 +3937,56 @@ function readContainNodes(nodes) {
|
|
|
3882
3937
|
}));
|
|
3883
3938
|
return read(nodes);
|
|
3884
3939
|
}
|
|
3940
|
+
/** What a selection holds now: its whole segment, or the run between its Cursors on that segment's Text. */
|
|
3885
3941
|
function resolveContent(doc, content) {
|
|
3886
|
-
const text = liveText(doc,
|
|
3887
|
-
|
|
3942
|
+
const text = liveText(doc, {
|
|
3943
|
+
audioScriptId: content.audioScriptId,
|
|
3944
|
+
segmentId: content.segmentId
|
|
3945
|
+
});
|
|
3946
|
+
if (!text) return;
|
|
3947
|
+
const whole = text.toString();
|
|
3948
|
+
if (content.start === void 0 || content.end === void 0) return {
|
|
3949
|
+
text: whole,
|
|
3950
|
+
start: 0,
|
|
3951
|
+
end: whole.length,
|
|
3952
|
+
bodyId: text.id
|
|
3953
|
+
};
|
|
3954
|
+
if (content.start.containerId() !== text.id || content.end.containerId() !== text.id) return;
|
|
3888
3955
|
const start = doc.getCursorPos(content.start)?.offset;
|
|
3889
3956
|
const end = doc.getCursorPos(content.end)?.offset;
|
|
3890
3957
|
if (start === void 0 || end === void 0 || start > end) return;
|
|
3891
3958
|
return {
|
|
3892
|
-
text:
|
|
3959
|
+
text: whole.slice(start, end),
|
|
3893
3960
|
start,
|
|
3894
3961
|
end,
|
|
3895
3962
|
bodyId: text.id
|
|
3896
3963
|
};
|
|
3897
3964
|
}
|
|
3965
|
+
function richTextOf(text) {
|
|
3966
|
+
return {
|
|
3967
|
+
bodyId: text.id,
|
|
3968
|
+
plainText: text.toString(),
|
|
3969
|
+
delta: text.toDelta().flatMap((run) => typeof run.insert === "string" ? [{
|
|
3970
|
+
insert: run.insert,
|
|
3971
|
+
...run.attributes ? { attributes: run.attributes } : {}
|
|
3972
|
+
}] : [])
|
|
3973
|
+
};
|
|
3974
|
+
}
|
|
3975
|
+
/** Optional relation fields read apart from their row: an unreadable one is dropped, the row is kept. */
|
|
3976
|
+
const optionalReferences = {
|
|
3977
|
+
"clip-entities": ["anchor"],
|
|
3978
|
+
"caption-entities": ["selection"]
|
|
3979
|
+
};
|
|
3980
|
+
/** The entity a single-valued relation field points at, by the field's path. */
|
|
3981
|
+
function referenceTargets(table, row) {
|
|
3982
|
+
const nested = (field, key) => isRecord(row[field]) ? row[field][key] : void 0;
|
|
3983
|
+
switch (table) {
|
|
3984
|
+
case "clip-entities": return [["source", row.source], ["anchor/to", nested("anchor", "to")]];
|
|
3985
|
+
case "caption-entities": return [["selection/audioScriptId", nested("selection", "audioScriptId")]];
|
|
3986
|
+
case "phonetic-script-entities": return [["phoneme/audioScriptId", nested("phoneme", "audioScriptId")]];
|
|
3987
|
+
default: return [];
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3898
3990
|
function readMedeoDsl(doc, state) {
|
|
3899
3991
|
const diagnostics = [];
|
|
3900
3992
|
const diagnostic = (code, path, error) => diagnostics.push({
|
|
@@ -3907,44 +3999,42 @@ function readMedeoDsl(doc, state) {
|
|
|
3907
3999
|
const richText = {};
|
|
3908
4000
|
for (const table of entityTables) for (const [id, raw] of Object.entries(state[table] ?? {})) try {
|
|
3909
4001
|
assertId(id);
|
|
3910
|
-
|
|
3911
|
-
const
|
|
4002
|
+
let v = raw;
|
|
4003
|
+
for (const field of optionalReferences[table] ?? []) {
|
|
4004
|
+
const value = v[field];
|
|
4005
|
+
if (!isInvalidAtomic(value)) continue;
|
|
4006
|
+
diagnostic("invalid-relation", `${table}/${id}/${field}`, value.reason);
|
|
4007
|
+
const { [field]: _dropped, ...rest } = v;
|
|
4008
|
+
v = rest;
|
|
4009
|
+
}
|
|
4010
|
+
assertEntity(table, v);
|
|
3912
4011
|
assertResourceIdentity(table, id, v);
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
}] : [])
|
|
3924
|
-
};
|
|
3925
|
-
}
|
|
4012
|
+
if (table === "phonetic-script-entities") assertPhoneticScript(void 0, v);
|
|
4013
|
+
const texts = [];
|
|
4014
|
+
if (table === "audio-script-entities") for (const segment of v.segments) texts.push([segment.id, liveText(doc, {
|
|
4015
|
+
audioScriptId: id,
|
|
4016
|
+
segmentId: segment.id
|
|
4017
|
+
})]);
|
|
4018
|
+
if (table === "caption-entities" && v.text !== void 0) texts.push([id, liveText(doc, { captionId: id })]);
|
|
4019
|
+
for (const [, text] of texts) if (!text) throw new Error("Missing live LoroText");
|
|
4020
|
+
entities[table][id] = v;
|
|
4021
|
+
for (const [key, text] of texts) richText[key] = richTextOf(text);
|
|
3926
4022
|
} catch (error) {
|
|
3927
4023
|
delete entities[table][id];
|
|
3928
4024
|
diagnostic("invalid-entity", `${table}/${id}`, error);
|
|
3929
4025
|
}
|
|
3930
4026
|
const entityIds = new Set(entityTables.flatMap((t) => Object.keys(entities[t])));
|
|
4027
|
+
for (const table of entityTables) for (const [id, row] of Object.entries(entities[table])) for (const [path, target] of referenceTargets(table, row)) if (typeof target === "string" && !entityIds.has(target)) diagnostic("dangling-reference", `${table}/${id}/${path}`, `Missing entity ${target}`);
|
|
3931
4028
|
for (const table of relationTables) for (const [id, value] of Object.entries(state[table] ?? {})) try {
|
|
3932
4029
|
if (isInvalidAtomic(value)) throw new Error(value.reason);
|
|
3933
4030
|
assertRelation(table, value);
|
|
3934
4031
|
assertRelationIdentity(table, id, value);
|
|
3935
4032
|
relations[table][id] = value;
|
|
3936
4033
|
for (const key of [
|
|
3937
|
-
"clipId",
|
|
3938
|
-
"sourceId",
|
|
3939
|
-
"from",
|
|
3940
|
-
"to",
|
|
3941
|
-
"captionId",
|
|
3942
|
-
"audioScriptId",
|
|
3943
|
-
"mediaId",
|
|
3944
4034
|
"recordingId",
|
|
3945
4035
|
"voiceId",
|
|
3946
4036
|
"entityId",
|
|
3947
|
-
"
|
|
4037
|
+
"sourceId"
|
|
3948
4038
|
]) {
|
|
3949
4039
|
const endpoint = value[key];
|
|
3950
4040
|
if (typeof endpoint === "string" && !entityIds.has(endpoint)) diagnostic("dangling-reference", `${table}/${id}/${key}`, `Missing entity ${endpoint}`);
|
|
@@ -3972,22 +4062,33 @@ function readMedeoDsl(doc, state) {
|
|
|
3972
4062
|
}];
|
|
3973
4063
|
});
|
|
3974
4064
|
const captionText = {};
|
|
3975
|
-
for (const [id, content] of Object.entries(relations["caption-content-relations"])) try {
|
|
3976
|
-
const resolved = resolveContent(doc, content);
|
|
3977
|
-
if (resolved) captionText[content.captionId] = resolved;
|
|
3978
|
-
else diagnostic("invalid-caption-content", `caption-content-relations/${id}`, "Cursor does not resolve in the current body");
|
|
3979
|
-
} catch (error) {
|
|
3980
|
-
diagnostic("invalid-caption-content", `caption-content-relations/${id}`, error);
|
|
3981
|
-
}
|
|
3982
4065
|
for (const [id, caption] of Object.entries(entities["caption-entities"])) {
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
4066
|
+
if (caption.text !== void 0) {
|
|
4067
|
+
if (Object.hasOwn(state["caption-entities"]?.[id] ?? {}, "selection")) diagnostic("multiple-caption-sources", `caption-entities/${id}`, "Inline text takes precedence over the content reference");
|
|
4068
|
+
if (richText[id]) captionText[id] = {
|
|
4069
|
+
text: caption.text,
|
|
4070
|
+
start: 0,
|
|
4071
|
+
end: caption.text.length,
|
|
4072
|
+
bodyId: richText[id].bodyId
|
|
4073
|
+
};
|
|
4074
|
+
continue;
|
|
4075
|
+
}
|
|
4076
|
+
if (!caption.selection) continue;
|
|
4077
|
+
try {
|
|
4078
|
+
const resolved = resolveContent(doc, caption.selection);
|
|
4079
|
+
if (resolved) captionText[id] = resolved;
|
|
4080
|
+
else diagnostic("invalid-caption-content", `caption-entities/${id}/selection`, "Selection does not resolve in its current segment");
|
|
4081
|
+
} catch (error) {
|
|
4082
|
+
diagnostic("invalid-caption-content", `caption-entities/${id}/selection`, error);
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
const phoneticText = {};
|
|
4086
|
+
for (const [id, phonetic] of Object.entries(entities["phonetic-script-entities"])) try {
|
|
4087
|
+
const resolved = resolveContent(doc, phonetic.phoneme);
|
|
4088
|
+
if (resolved) phoneticText[id] = resolved;
|
|
4089
|
+
else diagnostic("invalid-phoneme", `phonetic-script-entities/${id}/phoneme`, "Selection does not resolve in its current segment");
|
|
4090
|
+
} catch (error) {
|
|
4091
|
+
diagnostic("invalid-phoneme", `phonetic-script-entities/${id}/phoneme`, error);
|
|
3991
4092
|
}
|
|
3992
4093
|
return freezeSnapshot({
|
|
3993
4094
|
...entities,
|
|
@@ -3995,6 +4096,7 @@ function readMedeoDsl(doc, state) {
|
|
|
3995
4096
|
"contain-relations": validTree(treeNodes),
|
|
3996
4097
|
richText,
|
|
3997
4098
|
captionText,
|
|
4099
|
+
phoneticText,
|
|
3998
4100
|
diagnostics
|
|
3999
4101
|
});
|
|
4000
4102
|
}
|
|
@@ -4007,6 +4109,19 @@ function freezeSnapshot(value) {
|
|
|
4007
4109
|
}
|
|
4008
4110
|
//#endregion
|
|
4009
4111
|
//#region src/dsl/document/draft.ts
|
|
4112
|
+
function textKey(target) {
|
|
4113
|
+
return "captionId" in target ? JSON.stringify(["caption", target.captionId]) : JSON.stringify([
|
|
4114
|
+
"segment",
|
|
4115
|
+
target.audioScriptId,
|
|
4116
|
+
target.segmentId
|
|
4117
|
+
]);
|
|
4118
|
+
}
|
|
4119
|
+
/** Rows holding a segment selection, by the field that holds it (D181). */
|
|
4120
|
+
const selectionField = {
|
|
4121
|
+
"caption-entities": "selection",
|
|
4122
|
+
"phonetic-script-entities": "phoneme"
|
|
4123
|
+
};
|
|
4124
|
+
const isSelectionTable = (table) => Object.hasOwn(selectionField, table);
|
|
4010
4125
|
const entities = new Set(entityTables);
|
|
4011
4126
|
const relations = new Set(relationTables);
|
|
4012
4127
|
const reserved = new Set([
|
|
@@ -4039,8 +4154,10 @@ var TransactionDraft = class {
|
|
|
4039
4154
|
entities = /* @__PURE__ */ new Map();
|
|
4040
4155
|
relations = /* @__PURE__ */ new Map();
|
|
4041
4156
|
deleted = /* @__PURE__ */ new Set();
|
|
4157
|
+
/** Texts this write edits, by {@link textKey}, as its edits so far leave them. */
|
|
4042
4158
|
textPreviews = /* @__PURE__ */ new Map();
|
|
4043
|
-
|
|
4159
|
+
/** Selections the draft wrote, by table then row; they are replayed natively in order with text edits. */
|
|
4160
|
+
dirty = /* @__PURE__ */ new Map();
|
|
4044
4161
|
removedCaptionTexts = /* @__PURE__ */ new Set();
|
|
4045
4162
|
treeChanged = false;
|
|
4046
4163
|
active = true;
|
|
@@ -4071,13 +4188,30 @@ var TransactionDraft = class {
|
|
|
4071
4188
|
close() {
|
|
4072
4189
|
this.active = false;
|
|
4073
4190
|
}
|
|
4191
|
+
dirtySelections() {
|
|
4192
|
+
return [...this.dirty].flatMap(([table, ids]) => [...ids].map((id) => [table, id]));
|
|
4193
|
+
}
|
|
4194
|
+
clearDirtySelections() {
|
|
4195
|
+
this.dirty.clear();
|
|
4196
|
+
}
|
|
4197
|
+
/** Whether this write edits any segment of a script. */
|
|
4198
|
+
usesScript(scriptId) {
|
|
4199
|
+
const prefix = JSON.stringify(["segment", scriptId]).slice(0, -1) + ",";
|
|
4200
|
+
return [...this.textPreviews.keys()].some((key) => key.startsWith(prefix));
|
|
4201
|
+
}
|
|
4074
4202
|
wrap(value, path) {
|
|
4075
4203
|
if (!value || typeof value !== "object" || value instanceof Cursor) return value;
|
|
4076
4204
|
const cached = this.wrappers.get(value);
|
|
4077
4205
|
if (cached) return cached;
|
|
4078
4206
|
const proxy = new Proxy(value, {
|
|
4079
4207
|
get: (target, key) => this.run(() => {
|
|
4080
|
-
if (
|
|
4208
|
+
if (key === "text") {
|
|
4209
|
+
const preview = path[0] === "caption-entities" && path.length === 2 ? this.textPreviews.get(textKey({ captionId: path[1] })) : path[0] === "audio-script-entities" && path[2] === "segments" && path.length === 4 ? this.textPreviews.get(textKey({
|
|
4210
|
+
audioScriptId: path[1],
|
|
4211
|
+
segmentId: String(target.id)
|
|
4212
|
+
})) : void 0;
|
|
4213
|
+
if (preview !== void 0) return preview;
|
|
4214
|
+
}
|
|
4081
4215
|
if (!path.length && key === "contain-relations" && target[key] === void 0) target[key] = [];
|
|
4082
4216
|
return this.wrap(Reflect.get(target, key), [...path, String(key)]);
|
|
4083
4217
|
}),
|
|
@@ -4132,24 +4266,27 @@ var TransactionDraft = class {
|
|
|
4132
4266
|
if (path.length === 1 && (remove || next === void 0)) this.deleted.add(lifetime);
|
|
4133
4267
|
else if (this.deleted.has(lifetime)) throw new Error("Recreate an entity in a separate transaction");
|
|
4134
4268
|
if (table === "audio-script-entities") {
|
|
4135
|
-
if (path.length === 2 && key === "
|
|
4136
|
-
if (path.length === 1 &&
|
|
4137
|
-
|
|
4269
|
+
if ((path.length === 2 && key === "segments" || path[2] === "segments" || path.length === 1 && initial && isRecord(next) && !sameSegments(next.segments, initial.segments)) && (initial || this.usesScript(rowId))) throw new Error("Change segments with segment mutations and edit their text through the Text adapter");
|
|
4270
|
+
if (path.length === 1 && this.usesScript(rowId)) throw new Error("Cannot delete or replace a Text used in this transaction");
|
|
4271
|
+
}
|
|
4272
|
+
if (isSelectionTable(table)) {
|
|
4273
|
+
const field = selectionField[table];
|
|
4274
|
+
if (path.length === 1 && isRecord(next) && Object.hasOwn(next, field) || path.length === 2 && key === field || path[2] === field) {
|
|
4275
|
+
let ids = this.dirty.get(table);
|
|
4276
|
+
if (!ids) this.dirty.set(table, ids = /* @__PURE__ */ new Set());
|
|
4277
|
+
ids.add(rowId);
|
|
4278
|
+
}
|
|
4138
4279
|
}
|
|
4139
4280
|
if (table === "caption-entities") {
|
|
4140
4281
|
const changesText = path.length === 1 || path.length === 2 && key === "text";
|
|
4141
4282
|
const nextText = path.length === 1 ? isRecord(next) ? next.text : void 0 : next;
|
|
4142
4283
|
if (changesText) {
|
|
4143
|
-
if (this.textPreviews.has(rowId)) throw new Error("Cannot delete or replace a
|
|
4284
|
+
if (this.textPreviews.has(textKey({ captionId: rowId }))) throw new Error("Cannot delete or replace a Text used in this transaction");
|
|
4144
4285
|
if (nextText !== void 0 && (this.removedCaptionTexts.has(rowId) || initial?.text !== void 0 && nextText !== initial.text)) throw new Error("Edit Caption text through the Text adapter");
|
|
4145
4286
|
if (initial?.text !== void 0 && nextText === void 0) this.removedCaptionTexts.add(rowId);
|
|
4146
4287
|
}
|
|
4147
4288
|
}
|
|
4148
|
-
if (initial && [
|
|
4149
|
-
"media-entities",
|
|
4150
|
-
"asset-entities",
|
|
4151
|
-
"voice-entities"
|
|
4152
|
-
].includes(table)) {
|
|
4289
|
+
if (initial && ["media-entities", "voice-entities"].includes(table)) {
|
|
4153
4290
|
for (const field of [
|
|
4154
4291
|
"system",
|
|
4155
4292
|
"key",
|
|
@@ -4161,7 +4298,7 @@ var TransactionDraft = class {
|
|
|
4161
4298
|
let fields = rows.get(rowId);
|
|
4162
4299
|
if (!fields) rows.set(rowId, fields = /* @__PURE__ */ new Set());
|
|
4163
4300
|
if (path.length === 1) for (const field of [...Object.keys(initial ?? {}), ...Object.keys(isRecord(next) ? next : {})]) {
|
|
4164
|
-
if ((field === "
|
|
4301
|
+
if (initial && isRecord(next) && (field === "segments" && table === "audio-script-entities" && sameSegments(next[field], initial[field]) || field === "text" && table === "caption-entities" && next[field] === initial[field])) continue;
|
|
4165
4302
|
fields.add(field);
|
|
4166
4303
|
}
|
|
4167
4304
|
else fields.add(path[2] ?? key);
|
|
@@ -4169,10 +4306,12 @@ var TransactionDraft = class {
|
|
|
4169
4306
|
let ids = this.relations.get(table);
|
|
4170
4307
|
if (!ids) this.relations.set(table, ids = /* @__PURE__ */ new Set());
|
|
4171
4308
|
ids.add(rowId);
|
|
4172
|
-
if (table === "caption-content-relations") this.dirtyCaptions.add(rowId);
|
|
4173
4309
|
}
|
|
4174
4310
|
}
|
|
4175
4311
|
};
|
|
4312
|
+
function sameSegments(a, b) {
|
|
4313
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
4314
|
+
}
|
|
4176
4315
|
//#endregion
|
|
4177
4316
|
//#region src/dsl/document/draft-operations.ts
|
|
4178
4317
|
function nodeIndex(nodes) {
|
|
@@ -4224,6 +4363,7 @@ function changeTree(nodes, mutation, rows) {
|
|
|
4224
4363
|
}
|
|
4225
4364
|
//#endregion
|
|
4226
4365
|
//#region src/dsl/document/mutations.ts
|
|
4366
|
+
const resourceTables = ["media-entities", "voice-entities"];
|
|
4227
4367
|
/** Mutations are a convenience vocabulary over the same guarded draft and native command queue. */
|
|
4228
4368
|
function applyMutations(tx, mutations) {
|
|
4229
4369
|
const rows = tx.draft;
|
|
@@ -4233,11 +4373,7 @@ function applyMutations(tx, mutations) {
|
|
|
4233
4373
|
case "create-entity": {
|
|
4234
4374
|
assertEntity(m.table, m.value);
|
|
4235
4375
|
const old = rows[m.table][m.id];
|
|
4236
|
-
if (old && !
|
|
4237
|
-
"media-entities",
|
|
4238
|
-
"voice-entities",
|
|
4239
|
-
"asset-entities"
|
|
4240
|
-
].includes(m.table)) throw new Error(`Entity already exists: ${m.id}`);
|
|
4376
|
+
if (old && !resourceTables.includes(m.table)) throw new Error(`Entity already exists: ${m.id}`);
|
|
4241
4377
|
if (old) {
|
|
4242
4378
|
assertEntityPatch(m.table, m.value, old);
|
|
4243
4379
|
applyPatch(old, m.value);
|
|
@@ -4247,12 +4383,15 @@ function applyMutations(tx, mutations) {
|
|
|
4247
4383
|
case "patch-entity": {
|
|
4248
4384
|
const current = rows[m.table][m.id];
|
|
4249
4385
|
if (!current) throw new Error(`Missing entity: ${m.id}`);
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4386
|
+
const patch = m.patch;
|
|
4387
|
+
assertEntityPatch(m.table, patch, current);
|
|
4388
|
+
const atomic = atomicFields[m.table] ?? [];
|
|
4389
|
+
const next = patchRow(m.table, current, patch);
|
|
4390
|
+
const ordinary = {};
|
|
4391
|
+
for (const key of Object.keys(patch)) if (!atomic.includes(key)) ordinary[key] = patch[key];
|
|
4392
|
+
else if (next[key] === void 0) delete current[key];
|
|
4393
|
+
else current[key] = next[key];
|
|
4394
|
+
applyPatch(current, ordinary);
|
|
4256
4395
|
break;
|
|
4257
4396
|
}
|
|
4258
4397
|
case "delete-entity":
|
|
@@ -4261,18 +4400,27 @@ function applyMutations(tx, mutations) {
|
|
|
4261
4400
|
case "set-relation": {
|
|
4262
4401
|
assertRelation(m.table, m.value);
|
|
4263
4402
|
const id = relationId(m.table, m.value);
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
rows[m.table][id] ??= {};
|
|
4267
|
-
applyPatch(rows[m.table][id], m.value);
|
|
4268
|
-
}
|
|
4403
|
+
rows[m.table][id] ??= {};
|
|
4404
|
+
applyPatch(rows[m.table][id], withSourceDigest(rows, m.table, m.value));
|
|
4269
4405
|
break;
|
|
4270
4406
|
}
|
|
4271
4407
|
case "delete-relation":
|
|
4272
4408
|
delete rows[m.table][m.id];
|
|
4273
4409
|
break;
|
|
4410
|
+
case "insert-segment":
|
|
4411
|
+
tx.insertSegment(m.audioScriptId, m.segmentId, m.text, m.index, m.startMs === void 0 && m.endMs === void 0 ? void 0 : {
|
|
4412
|
+
startMs: m.startMs,
|
|
4413
|
+
endMs: m.endMs
|
|
4414
|
+
});
|
|
4415
|
+
break;
|
|
4416
|
+
case "move-segment":
|
|
4417
|
+
tx.moveSegment(m.audioScriptId, m.segmentId, m.index);
|
|
4418
|
+
break;
|
|
4419
|
+
case "delete-segment":
|
|
4420
|
+
tx.deleteSegment(m.audioScriptId, m.segmentId);
|
|
4421
|
+
break;
|
|
4274
4422
|
case "edit-text":
|
|
4275
|
-
tx.editText(m.audioScriptId, m.edits, m.expectedBodyId);
|
|
4423
|
+
tx.editText(m.audioScriptId, m.segmentId, m.edits, m.expectedBodyId);
|
|
4276
4424
|
break;
|
|
4277
4425
|
case "edit-caption-text":
|
|
4278
4426
|
tx.editCaptionText(m.captionId, m.edits, m.expectedBodyId);
|
|
@@ -4280,10 +4428,32 @@ function applyMutations(tx, mutations) {
|
|
|
4280
4428
|
case "set-caption-content":
|
|
4281
4429
|
tx.setCaptionContent(m);
|
|
4282
4430
|
break;
|
|
4431
|
+
case "set-phoneme":
|
|
4432
|
+
tx.setPhoneme(m);
|
|
4433
|
+
break;
|
|
4283
4434
|
default: changeTree(tx.draft["contain-relations"], m, tx.draft);
|
|
4284
4435
|
}
|
|
4285
4436
|
}
|
|
4286
4437
|
}
|
|
4438
|
+
/**
|
|
4439
|
+
* A derived-from records its source's digest (D177): the one the writer names, otherwise the
|
|
4440
|
+
* source's digest as this transaction leaves it. A source with no digest records none.
|
|
4441
|
+
*/
|
|
4442
|
+
function withSourceDigest(rows, table, value) {
|
|
4443
|
+
if (table !== "derived-from-relations") return value;
|
|
4444
|
+
const relation = value;
|
|
4445
|
+
if (relation.sourceDigest !== void 0) return value;
|
|
4446
|
+
const sourceDigest = digestOf(rows, relation.sourceId);
|
|
4447
|
+
return sourceDigest === void 0 ? value : {
|
|
4448
|
+
...relation,
|
|
4449
|
+
sourceDigest
|
|
4450
|
+
};
|
|
4451
|
+
}
|
|
4452
|
+
/** A source's digest is its file's: a PhoneticScript's or a medium's Media File `digest`. */
|
|
4453
|
+
function digestOf(rows, entityId) {
|
|
4454
|
+
const digest = (rows["phonetic-script-entities"][entityId] ?? rows["media-entities"][entityId])?.digest;
|
|
4455
|
+
return typeof digest === "string" ? digest : void 0;
|
|
4456
|
+
}
|
|
4287
4457
|
//#endregion
|
|
4288
4458
|
//#region src/dsl/document/native-text.ts
|
|
4289
4459
|
function applyTextEdits(text, edits) {
|
|
@@ -4295,11 +4465,7 @@ function applyTextEdits(text, edits) {
|
|
|
4295
4465
|
text.delete(edit.at, edit.length);
|
|
4296
4466
|
break;
|
|
4297
4467
|
case "mark":
|
|
4298
|
-
|
|
4299
|
-
start: edit.start,
|
|
4300
|
-
end: edit.end
|
|
4301
|
-
}, edit.key);
|
|
4302
|
-
else text.mark({
|
|
4468
|
+
text.mark({
|
|
4303
4469
|
start: edit.start,
|
|
4304
4470
|
end: edit.end
|
|
4305
4471
|
}, edit.key, edit.value);
|
|
@@ -4317,13 +4483,15 @@ function applyTextEdits(text, edits) {
|
|
|
4317
4483
|
}
|
|
4318
4484
|
//#endregion
|
|
4319
4485
|
//#region src/dsl/document/validation.ts
|
|
4320
|
-
/**
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4486
|
+
/**
|
|
4487
|
+
* A selection the draft wrote whole, by the DSL's rules: its segment must exist and what it
|
|
4488
|
+
* selects must not be empty. A run's Cursors must lie on that segment's live Text.
|
|
4489
|
+
*/
|
|
4490
|
+
function validateSelection(doc, state, table, id, value) {
|
|
4491
|
+
assertReferenceAdmitted(state, table, id, selectionField[table], value);
|
|
4492
|
+
const selection = value;
|
|
4493
|
+
if (!(selection.start === void 0 ? requireSegment(state, selection.audioScriptId, selection.segmentId).text : resolveContent(doc, selection)?.text)) throw new Error("Expected a nonempty selection of the current segment");
|
|
4494
|
+
(table === "caption-entities" ? captionContentCodec : phonemeCodec).encode(selection);
|
|
4327
4495
|
}
|
|
4328
4496
|
/**
|
|
4329
4497
|
* The DSL's admission of the whole draft, then what only this document can check: containment
|
|
@@ -4355,6 +4523,14 @@ function validateDraft(tracked) {
|
|
|
4355
4523
|
}
|
|
4356
4524
|
//#endregion
|
|
4357
4525
|
//#region src/dsl/document/transact.ts
|
|
4526
|
+
const codecs = {
|
|
4527
|
+
"caption-entities": captionContentCodec,
|
|
4528
|
+
"phonetic-script-entities": phonemeCodec
|
|
4529
|
+
};
|
|
4530
|
+
function entityRow(doc, table, id) {
|
|
4531
|
+
const row = doc.getMap(table).get(id);
|
|
4532
|
+
return isContainer(row) && row.kind() === "Map" ? row : void 0;
|
|
4533
|
+
}
|
|
4358
4534
|
/** Serialization happens before preparing any writes, so invalid/cyclic audit data cannot partly commit. */
|
|
4359
4535
|
function auditOptions(audit) {
|
|
4360
4536
|
if (!audit || typeof audit.semantic_op !== "string" || !audit.semantic_op.length || !Object.hasOwn(audit, "payload")) throw new Error("A structured DSL audit is required");
|
|
@@ -4373,8 +4549,8 @@ const nativeHookSupport = /* @__PURE__ */ new WeakMap();
|
|
|
4373
4549
|
/**
|
|
4374
4550
|
* Every write relies on the patched Mirror's `applyNative` (patches/loro-mirror@2.3.3.patch). An
|
|
4375
4551
|
* unpatched Mirror ignores the option and commits only the draft, silently dropping Text, ranges
|
|
4376
|
-
* and
|
|
4377
|
-
*
|
|
4552
|
+
* and selections. The patch runs the hook even for an empty draft without committing, so probe
|
|
4553
|
+
* each Mirror class once on a scratch document and refuse before the first real write.
|
|
4378
4554
|
*/
|
|
4379
4555
|
function assertNativeHook(mirror) {
|
|
4380
4556
|
const Impl = mirror.constructor;
|
|
@@ -4408,50 +4584,75 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4408
4584
|
mirror.setState((raw) => {
|
|
4409
4585
|
const tracked = new TransactionDraft(raw, before);
|
|
4410
4586
|
setReject((error) => tracked.fail(error));
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
if (
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4587
|
+
/** The current text of a Caption's own Text or a script segment, as this transaction leaves it so far. */
|
|
4588
|
+
const useText = (target) => {
|
|
4589
|
+
let text;
|
|
4590
|
+
if ("captionId" in target) {
|
|
4591
|
+
assertId(target.captionId);
|
|
4592
|
+
const entity = requireEntity(tracked.draft, "caption-entities", target.captionId);
|
|
4593
|
+
if (tracked.deleted.has(target.captionId)) throw new Error("Cannot use a deleted Text");
|
|
4594
|
+
if (tracked.removedCaptionTexts.has(target.captionId)) throw new Error("Cannot edit a removed Text");
|
|
4595
|
+
text = entity.text;
|
|
4596
|
+
} else {
|
|
4597
|
+
assertId(target.audioScriptId);
|
|
4598
|
+
if (tracked.deleted.has(target.audioScriptId)) throw new Error("Cannot use a deleted Text");
|
|
4599
|
+
text = requireSegment(tracked.draft, target.audioScriptId, target.segmentId).text;
|
|
4600
|
+
}
|
|
4601
|
+
if (typeof text !== "string") throw new Error("Missing Text");
|
|
4602
|
+
tracked.textPreviews.set(textKey(target), text);
|
|
4603
|
+
return text;
|
|
4420
4604
|
};
|
|
4421
|
-
const
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4605
|
+
const textBefore = (target) => {
|
|
4606
|
+
if ("captionId" in target) return before["caption-entities"][target.captionId]?.text;
|
|
4607
|
+
return before["audio-script-entities"][target.audioScriptId]?.segments?.find((segment) => segment.id === target.segmentId)?.text;
|
|
4608
|
+
};
|
|
4609
|
+
const flushSelections = () => {
|
|
4610
|
+
for (const [table, id] of tracked.dirtySelections()) {
|
|
4611
|
+
const row = raw[table][id];
|
|
4612
|
+
if (!row) continue;
|
|
4613
|
+
const field = selectionField[table];
|
|
4614
|
+
const value = row[field];
|
|
4615
|
+
const old = before[table][id]?.[field];
|
|
4616
|
+
if (isInvalidAtomic(value)) {
|
|
4617
|
+
if (isInvalidAtomic(old) && old.raw === value.raw) continue;
|
|
4618
|
+
throw new Error(`Invalid ${field}`);
|
|
4427
4619
|
}
|
|
4620
|
+
if (value !== void 0) validateSelection(doc, raw, table, id, value);
|
|
4428
4621
|
steps.push({
|
|
4429
|
-
type: "
|
|
4622
|
+
type: "selection",
|
|
4623
|
+
table,
|
|
4430
4624
|
id,
|
|
4431
4625
|
value: copyInput(value)
|
|
4432
4626
|
});
|
|
4433
4627
|
}
|
|
4434
|
-
tracked.
|
|
4628
|
+
tracked.clearDirtySelections();
|
|
4435
4629
|
};
|
|
4436
|
-
const editBody = (
|
|
4630
|
+
const editBody = (target, edits, expectedBodyId) => {
|
|
4437
4631
|
tracked.run(() => {
|
|
4438
|
-
|
|
4439
|
-
const
|
|
4440
|
-
|
|
4441
|
-
|
|
4632
|
+
flushSelections();
|
|
4633
|
+
const text = useText(target);
|
|
4634
|
+
const key = textKey(target);
|
|
4635
|
+
if (!bodies.has(key)) bodies.set(key, liveText(doc, target));
|
|
4636
|
+
const native = bodies.get(key);
|
|
4442
4637
|
if (expectedBodyId !== void 0 && native?.id !== expectedBodyId) throw new Error("Stale or missing Text body");
|
|
4443
|
-
if ((
|
|
4444
|
-
const prepared = prepareTextEdits(
|
|
4445
|
-
tracked.textPreviews.set(
|
|
4638
|
+
if (textBefore(target) !== void 0 && !native) throw new Error("Missing live Text body");
|
|
4639
|
+
const prepared = prepareTextEdits(text, copyInput(edits));
|
|
4640
|
+
tracked.textPreviews.set(key, prepared.text);
|
|
4446
4641
|
steps.push({
|
|
4447
4642
|
type: "text",
|
|
4448
|
-
|
|
4449
|
-
caption,
|
|
4643
|
+
target,
|
|
4450
4644
|
expectedBodyId: native?.id,
|
|
4451
4645
|
edits: prepared.edits
|
|
4452
4646
|
});
|
|
4453
4647
|
});
|
|
4454
4648
|
};
|
|
4649
|
+
/** A script's segments as this transaction leaves them; never a Mirror-tracked write. */
|
|
4650
|
+
const segmentsOf = (audioScriptId) => {
|
|
4651
|
+
assertId(audioScriptId);
|
|
4652
|
+
requireEntity(tracked.draft, "audio-script-entities", audioScriptId);
|
|
4653
|
+
if (tracked.deleted.has(audioScriptId)) throw new Error("Cannot change a deleted AudioScript");
|
|
4654
|
+
return raw["audio-script-entities"][audioScriptId].segments;
|
|
4655
|
+
};
|
|
4455
4656
|
const tx = Object.freeze({
|
|
4456
4657
|
get draft() {
|
|
4457
4658
|
return tracked.run(() => tracked.draft);
|
|
@@ -4459,29 +4660,98 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4459
4660
|
applyMutations(mutations) {
|
|
4460
4661
|
tracked.run(() => applyMutations(tx, mutations));
|
|
4461
4662
|
},
|
|
4462
|
-
editText(
|
|
4463
|
-
editBody(
|
|
4663
|
+
editText(audioScriptId, segmentId, edits, expectedBodyId) {
|
|
4664
|
+
editBody({
|
|
4665
|
+
audioScriptId,
|
|
4666
|
+
segmentId
|
|
4667
|
+
}, edits, expectedBodyId);
|
|
4464
4668
|
},
|
|
4465
4669
|
editCaptionText(captionId, edits, expectedBodyId) {
|
|
4466
|
-
editBody(captionId, edits, expectedBodyId
|
|
4670
|
+
editBody({ captionId }, edits, expectedBodyId);
|
|
4671
|
+
},
|
|
4672
|
+
insertSegment(audioScriptId, segmentId, text, index, time) {
|
|
4673
|
+
tracked.run(() => {
|
|
4674
|
+
assertId(segmentId);
|
|
4675
|
+
if (typeof text !== "string") throw new Error("Segment text is a string");
|
|
4676
|
+
const segment = {
|
|
4677
|
+
id: segmentId,
|
|
4678
|
+
text,
|
|
4679
|
+
...time
|
|
4680
|
+
};
|
|
4681
|
+
assertScriptSegment(segment);
|
|
4682
|
+
const segments = segmentsOf(audioScriptId);
|
|
4683
|
+
if (segments.some((segment) => segment.id === segmentId)) throw new Error(`Segment already exists: ${segmentId}`);
|
|
4684
|
+
const at = index ?? segments.length;
|
|
4685
|
+
if (!Number.isSafeInteger(at) || at < 0 || at > segments.length) throw new Error("Invalid segment index");
|
|
4686
|
+
segments.splice(at, 0, segment);
|
|
4687
|
+
});
|
|
4688
|
+
},
|
|
4689
|
+
moveSegment(audioScriptId, segmentId, index) {
|
|
4690
|
+
tracked.run(() => {
|
|
4691
|
+
const segments = segmentsOf(audioScriptId);
|
|
4692
|
+
const from = segments.findIndex((segment) => segment.id === segmentId);
|
|
4693
|
+
if (from < 0) throw new Error(`Missing segment ${segmentId} of ${audioScriptId}`);
|
|
4694
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= segments.length) throw new Error("Invalid segment index");
|
|
4695
|
+
const [segment] = segments.splice(from, 1);
|
|
4696
|
+
segments.splice(index, 0, segment);
|
|
4697
|
+
});
|
|
4698
|
+
},
|
|
4699
|
+
deleteSegment(audioScriptId, segmentId) {
|
|
4700
|
+
tracked.run(() => {
|
|
4701
|
+
const segments = segmentsOf(audioScriptId);
|
|
4702
|
+
const at = segments.findIndex((segment) => segment.id === segmentId);
|
|
4703
|
+
if (at < 0) throw new Error(`Missing segment ${segmentId} of ${audioScriptId}`);
|
|
4704
|
+
if (tracked.textPreviews.has(textKey({
|
|
4705
|
+
audioScriptId,
|
|
4706
|
+
segmentId
|
|
4707
|
+
}))) throw new Error("Cannot delete a segment whose text this transaction edits");
|
|
4708
|
+
segments.splice(at, 1);
|
|
4709
|
+
});
|
|
4467
4710
|
},
|
|
4468
4711
|
setCaptionContent(selection) {
|
|
4469
4712
|
tracked.run(() => {
|
|
4470
|
-
|
|
4471
|
-
const { captionId, audioScriptId, start, end } = selection;
|
|
4713
|
+
flushSelections();
|
|
4714
|
+
const { captionId, audioScriptId, segmentId, start, end } = selection;
|
|
4472
4715
|
assertId(captionId);
|
|
4473
|
-
|
|
4474
|
-
|
|
4716
|
+
assertCaptionSelection(raw, selection, useText({
|
|
4717
|
+
audioScriptId,
|
|
4718
|
+
segmentId
|
|
4719
|
+
}));
|
|
4475
4720
|
steps.push({
|
|
4476
4721
|
type: "range",
|
|
4477
|
-
|
|
4478
|
-
|
|
4722
|
+
table: "caption-entities",
|
|
4723
|
+
id: captionId,
|
|
4724
|
+
range: {
|
|
4479
4725
|
audioScriptId,
|
|
4726
|
+
segmentId,
|
|
4480
4727
|
start,
|
|
4481
4728
|
end
|
|
4482
4729
|
}
|
|
4483
4730
|
});
|
|
4484
4731
|
});
|
|
4732
|
+
},
|
|
4733
|
+
setPhoneme(selection) {
|
|
4734
|
+
tracked.run(() => {
|
|
4735
|
+
flushSelections();
|
|
4736
|
+
const { phoneticScriptId, audioScriptId, segmentId, start, end, phonemeScript } = selection;
|
|
4737
|
+
assertId(phoneticScriptId);
|
|
4738
|
+
assertPhonemeSelection(raw, selection, useText({
|
|
4739
|
+
audioScriptId,
|
|
4740
|
+
segmentId
|
|
4741
|
+
}));
|
|
4742
|
+
steps.push({
|
|
4743
|
+
type: "range",
|
|
4744
|
+
table: "phonetic-script-entities",
|
|
4745
|
+
id: phoneticScriptId,
|
|
4746
|
+
range: {
|
|
4747
|
+
audioScriptId,
|
|
4748
|
+
segmentId,
|
|
4749
|
+
start,
|
|
4750
|
+
end
|
|
4751
|
+
},
|
|
4752
|
+
...phonemeScript === void 0 ? {} : { phonemeScript }
|
|
4753
|
+
});
|
|
4754
|
+
});
|
|
4485
4755
|
}
|
|
4486
4756
|
});
|
|
4487
4757
|
try {
|
|
@@ -4494,32 +4764,15 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4494
4764
|
}
|
|
4495
4765
|
tracked.assertValid();
|
|
4496
4766
|
if (doc.getPendingTxnLength()) throw new Error("Native writes are not allowed during transaction preparation");
|
|
4497
|
-
|
|
4767
|
+
flushSelections();
|
|
4498
4768
|
for (const id of tracked.entities.get("caption-entities")?.keys() ?? []) {
|
|
4499
4769
|
const caption = tracked.draft["caption-entities"][id];
|
|
4500
4770
|
if (caption) caption.style ??= {};
|
|
4501
4771
|
}
|
|
4502
4772
|
validateDraft(tracked);
|
|
4503
|
-
for (const step of steps) if (step.type === "text")
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (fields.has("text")) changedCaptions.add(id);
|
|
4507
|
-
for (const step of steps) {
|
|
4508
|
-
if (step.type === "range") {
|
|
4509
|
-
referenceIds.add(step.selection.captionId);
|
|
4510
|
-
changedCaptions.add(step.selection.captionId);
|
|
4511
|
-
}
|
|
4512
|
-
if (step.type === "caption") {
|
|
4513
|
-
const id = step.id.slice(3);
|
|
4514
|
-
changedCaptions.add(id);
|
|
4515
|
-
if (step.value) referenceIds.add(id);
|
|
4516
|
-
else referenceIds.delete(id);
|
|
4517
|
-
}
|
|
4518
|
-
if (step.type === "text" && step.caption) changedCaptions.add(step.scriptId);
|
|
4519
|
-
}
|
|
4520
|
-
for (const id of changedCaptions) assertCaptionSource(raw["caption-entities"][id], referenceIds.has(id));
|
|
4521
|
-
for (const step of steps) if (step.type === "range") assertCaptionEndpoints(raw, step.selection);
|
|
4522
|
-
else if (step.type === "caption" && step.value) assertCaptionEndpoints(raw, step.value);
|
|
4773
|
+
for (const step of steps) if (step.type === "text") if ("captionId" in step.target) requireEntity(raw, "caption-entities", step.target.captionId);
|
|
4774
|
+
else requireSegment(raw, step.target.audioScriptId, step.target.segmentId);
|
|
4775
|
+
assertSelectionsAfter(raw, before, tracked, steps);
|
|
4523
4776
|
for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) {
|
|
4524
4777
|
const row = raw["caption-entities"][id];
|
|
4525
4778
|
if (fields.has("text") && row?.text !== void 0 && before["caption-entities"][id]?.text === void 0) {
|
|
@@ -4527,8 +4780,16 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4527
4780
|
delete row.text;
|
|
4528
4781
|
}
|
|
4529
4782
|
}
|
|
4530
|
-
for (const
|
|
4531
|
-
|
|
4783
|
+
for (const table of Object.keys(selectionField)) {
|
|
4784
|
+
const field = selectionField[table];
|
|
4785
|
+
for (const [id, fields] of tracked.entities.get(table) ?? []) {
|
|
4786
|
+
const row = raw[table][id];
|
|
4787
|
+
if (!row || !fields.has(field)) continue;
|
|
4788
|
+
const old = before[table][id]?.[field];
|
|
4789
|
+
if (old === void 0) delete row[field];
|
|
4790
|
+
else row[field] = old;
|
|
4791
|
+
}
|
|
4792
|
+
}
|
|
4532
4793
|
});
|
|
4533
4794
|
} finally {
|
|
4534
4795
|
tracked.close();
|
|
@@ -4537,44 +4798,104 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4537
4798
|
}, {
|
|
4538
4799
|
...options,
|
|
4539
4800
|
applyNative: (doc) => {
|
|
4540
|
-
const
|
|
4541
|
-
|
|
4801
|
+
const textFor = (target) => {
|
|
4802
|
+
const key = textKey(target);
|
|
4803
|
+
let text = bodies.get(key);
|
|
4542
4804
|
if (!text) {
|
|
4543
|
-
text = liveText(doc,
|
|
4544
|
-
bodies.set(
|
|
4805
|
+
text = liveText(doc, target);
|
|
4806
|
+
bodies.set(key, text);
|
|
4545
4807
|
}
|
|
4546
|
-
if (!text || text.isDeleted()) throw new Error("Prepared Text
|
|
4808
|
+
if (!text || text.isDeleted()) throw new Error("Prepared Text is missing");
|
|
4547
4809
|
return text;
|
|
4548
4810
|
};
|
|
4549
4811
|
for (const [id, value] of newCaptionTexts) {
|
|
4550
|
-
const
|
|
4551
|
-
if (value.length)
|
|
4552
|
-
bodies.set(id,
|
|
4812
|
+
const text = doc.getMap("caption-entities").get(id).setContainer("text", new LoroText());
|
|
4813
|
+
if (value.length) text.insert(0, value);
|
|
4814
|
+
bodies.set(textKey({ captionId: id }), text);
|
|
4553
4815
|
}
|
|
4554
|
-
for (const step of steps)
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
const
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4816
|
+
for (const step of steps) {
|
|
4817
|
+
if (step.type === "text") {
|
|
4818
|
+
const text = textFor(step.target);
|
|
4819
|
+
if (step.expectedBodyId && text.id !== step.expectedBodyId) throw new Error("Stale Text body");
|
|
4820
|
+
applyTextEdits(text, step.edits);
|
|
4821
|
+
continue;
|
|
4822
|
+
}
|
|
4823
|
+
const row = entityRow(doc, step.table, step.id);
|
|
4824
|
+
const field = selectionField[step.table];
|
|
4825
|
+
if (step.type === "range") {
|
|
4826
|
+
const { audioScriptId, segmentId, start, end } = step.range;
|
|
4827
|
+
const value = {
|
|
4828
|
+
audioScriptId,
|
|
4829
|
+
segmentId
|
|
4830
|
+
};
|
|
4831
|
+
if (start !== void 0 && end !== void 0) {
|
|
4832
|
+
const text = textFor({
|
|
4833
|
+
audioScriptId,
|
|
4834
|
+
segmentId
|
|
4835
|
+
});
|
|
4836
|
+
value.start = text.getCursor(start, 0);
|
|
4837
|
+
value.end = text.getCursor(end, 0);
|
|
4838
|
+
if (!value.start || !value.end) throw new Error("Unable to construct prepared range");
|
|
4839
|
+
}
|
|
4840
|
+
if (step.phonemeScript !== void 0) value.phonemeScript = step.phonemeScript;
|
|
4841
|
+
if (!row) throw new Error("Unable to construct prepared range");
|
|
4842
|
+
row.set(field, codecs[step.table].encode(value));
|
|
4843
|
+
} else if (step.value !== void 0) {
|
|
4844
|
+
if (!row) throw new Error("Prepared selection owner is missing");
|
|
4845
|
+
row.set(field, codecs[step.table].encode(step.value));
|
|
4846
|
+
} else if (row?.get(field) !== void 0) row.delete(field);
|
|
4574
4847
|
}
|
|
4575
4848
|
}
|
|
4576
4849
|
});
|
|
4577
4850
|
}
|
|
4851
|
+
/**
|
|
4852
|
+
* What only the whole write can say about selections, with the native steps counted in order:
|
|
4853
|
+
* each Caption keeps one source of words, each selection's segment still exists, and every
|
|
4854
|
+
* PhoneticScript the write touched is written for a segment and stores its text as a file.
|
|
4855
|
+
*/
|
|
4856
|
+
function assertSelectionsAfter(raw, before, tracked, steps) {
|
|
4857
|
+
const selected = new Set(Object.entries(before["caption-entities"]).filter(([, caption]) => typeof caption === "object" && caption.selection !== void 0).map(([id]) => id));
|
|
4858
|
+
const changedCaptions = /* @__PURE__ */ new Set();
|
|
4859
|
+
for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (fields.has("text")) changedCaptions.add(id);
|
|
4860
|
+
const phonemes = /* @__PURE__ */ new Map();
|
|
4861
|
+
for (const step of steps) {
|
|
4862
|
+
if (step.type === "text") {
|
|
4863
|
+
if ("captionId" in step.target) changedCaptions.add(step.target.captionId);
|
|
4864
|
+
continue;
|
|
4865
|
+
}
|
|
4866
|
+
const value = step.type === "range" ? {
|
|
4867
|
+
...step.range,
|
|
4868
|
+
...step.phonemeScript === void 0 ? {} : { phonemeScript: step.phonemeScript }
|
|
4869
|
+
} : step.value;
|
|
4870
|
+
if (step.table === "caption-entities") {
|
|
4871
|
+
changedCaptions.add(step.id);
|
|
4872
|
+
if (value) selected.add(step.id);
|
|
4873
|
+
else selected.delete(step.id);
|
|
4874
|
+
if (value) assertCaptionEndpoints(raw, {
|
|
4875
|
+
captionId: step.id,
|
|
4876
|
+
...value
|
|
4877
|
+
});
|
|
4878
|
+
} else {
|
|
4879
|
+
phonemes.set(step.id, value);
|
|
4880
|
+
if (value) assertPhonemeEndpoints(raw, {
|
|
4881
|
+
phoneticScriptId: step.id,
|
|
4882
|
+
...value
|
|
4883
|
+
});
|
|
4884
|
+
}
|
|
4885
|
+
}
|
|
4886
|
+
for (const id of changedCaptions) if (raw["caption-entities"][id]) assertCaptionSource(raw["caption-entities"][id], selected.has(id));
|
|
4887
|
+
const phonetics = new Set([...tracked.entities.get("phonetic-script-entities")?.keys() ?? [], ...phonemes.keys()]);
|
|
4888
|
+
for (const id of phonetics) {
|
|
4889
|
+
const row = raw["phonetic-script-entities"][id];
|
|
4890
|
+
if (!row) continue;
|
|
4891
|
+
const phoneme = phonemes.has(id) ? phonemes.get(id) : row.phoneme;
|
|
4892
|
+
const old = before["phonetic-script-entities"][id];
|
|
4893
|
+
assertPhoneticScript(old, {
|
|
4894
|
+
...row,
|
|
4895
|
+
phoneme: isRecord(phoneme) ? phoneme : void 0
|
|
4896
|
+
});
|
|
4897
|
+
}
|
|
4898
|
+
}
|
|
4578
4899
|
//#endregion
|
|
4579
4900
|
//#region src/dsl/document/medeo-dsl.ts
|
|
4580
4901
|
const contentRoots = new Set([
|
|
@@ -4665,17 +4986,23 @@ var MedeoDsl = class MedeoDsl {
|
|
|
4665
4986
|
applyMutations(mutations, audit) {
|
|
4666
4987
|
this.transact((tx) => tx.applyMutations(mutations), audit);
|
|
4667
4988
|
}
|
|
4668
|
-
editText(scriptId, expectedBodyId, edits, audit) {
|
|
4669
|
-
this.transact((tx) => tx.editText(scriptId, edits, expectedBodyId), audit);
|
|
4989
|
+
editText(scriptId, segmentId, expectedBodyId, edits, audit) {
|
|
4990
|
+
this.transact((tx) => tx.editText(scriptId, segmentId, edits, expectedBodyId), audit);
|
|
4670
4991
|
}
|
|
4671
4992
|
editCaptionText(captionId, expectedBodyId, edits, audit) {
|
|
4672
4993
|
this.transact((tx) => tx.editCaptionText(captionId, edits, expectedBodyId), audit);
|
|
4673
4994
|
}
|
|
4674
|
-
/**
|
|
4675
|
-
|
|
4995
|
+
/**
|
|
4996
|
+
* A run inside one segment as native Cursors. Local ranges exclude insertions at their start and
|
|
4997
|
+
* include insertions at their end. K01 remains deferred.
|
|
4998
|
+
*/
|
|
4999
|
+
textRange(scriptId, segmentId, start, end) {
|
|
4676
5000
|
this.assertActive();
|
|
4677
|
-
const text = liveText(this.doc,
|
|
4678
|
-
|
|
5001
|
+
const text = liveText(this.doc, {
|
|
5002
|
+
audioScriptId: scriptId,
|
|
5003
|
+
segmentId
|
|
5004
|
+
});
|
|
5005
|
+
if (!text) throw new Error("Missing AudioScript segment");
|
|
4679
5006
|
assertTextOffset(text.toString(), start);
|
|
4680
5007
|
assertTextOffset(text.toString(), end);
|
|
4681
5008
|
if (start >= end) throw new Error("Expected a nonempty range");
|
|
@@ -4683,6 +5010,7 @@ var MedeoDsl = class MedeoDsl {
|
|
|
4683
5010
|
if (!from || !to) throw new Error("Unable to construct text range");
|
|
4684
5011
|
return {
|
|
4685
5012
|
audioScriptId: scriptId,
|
|
5013
|
+
segmentId,
|
|
4686
5014
|
start: from,
|
|
4687
5015
|
end: to
|
|
4688
5016
|
};
|
|
@@ -4732,6 +5060,150 @@ var MedeoDsl = class MedeoDsl {
|
|
|
4732
5060
|
}
|
|
4733
5061
|
};
|
|
4734
5062
|
//#endregion
|
|
5063
|
+
//#region src/dsl/projection/audio-scripts.ts
|
|
5064
|
+
/**
|
|
5065
|
+
* The scripts a recording says. It follows derived-from from the recording; a PhoneticScript is a
|
|
5066
|
+
* variant, so its words are the base its phoneme names, and derived-from is not followed past it.
|
|
5067
|
+
* When nothing is found that way, a script read off the recording itself is what it says.
|
|
5068
|
+
*/
|
|
5069
|
+
function spokenScripts(snapshot, recordingId) {
|
|
5070
|
+
const relations = Object.values(snapshot["derived-from-relations"]);
|
|
5071
|
+
const found = /* @__PURE__ */ new Map();
|
|
5072
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5073
|
+
const visit = (id, phoneticScriptId) => {
|
|
5074
|
+
if (seen.has(id)) return;
|
|
5075
|
+
seen.add(id);
|
|
5076
|
+
if (snapshot["audio-script-entities"][id]) {
|
|
5077
|
+
if (!found.has(id)) found.set(id, phoneticScriptId);
|
|
5078
|
+
return;
|
|
5079
|
+
}
|
|
5080
|
+
const phonetic = snapshot["phonetic-script-entities"][id];
|
|
5081
|
+
if (phonetic) {
|
|
5082
|
+
visit(phonetic.phoneme.audioScriptId, id);
|
|
5083
|
+
return;
|
|
5084
|
+
}
|
|
5085
|
+
for (const r of relations) if (r.entityId === id) visit(r.sourceId, phoneticScriptId);
|
|
5086
|
+
};
|
|
5087
|
+
visit(recordingId);
|
|
5088
|
+
if (!found.size) {
|
|
5089
|
+
for (const r of relations) if (r.sourceId === recordingId && snapshot["audio-script-entities"][r.entityId]) found.set(r.entityId, void 0);
|
|
5090
|
+
}
|
|
5091
|
+
return [...found].map(([audioScriptId, phoneticScriptId]) => phoneticScriptId === void 0 ? { audioScriptId } : {
|
|
5092
|
+
audioScriptId,
|
|
5093
|
+
phoneticScriptId
|
|
5094
|
+
});
|
|
5095
|
+
}
|
|
5096
|
+
/** Every AudioScript in the document with its PhoneticScripts, the Speech Clips saying it and the Caption Clips showing it. */
|
|
5097
|
+
function toAudioScriptViews(snapshot) {
|
|
5098
|
+
const views = /* @__PURE__ */ new Map();
|
|
5099
|
+
const segmentIndex = (scriptId, segmentId) => {
|
|
5100
|
+
const index = snapshot["audio-script-entities"][scriptId]?.segments.findIndex((s) => s.id === segmentId) ?? -1;
|
|
5101
|
+
return index < 0 ? void 0 : index;
|
|
5102
|
+
};
|
|
5103
|
+
for (const [id, script] of Object.entries(snapshot["audio-script-entities"])) views.set(id, {
|
|
5104
|
+
id,
|
|
5105
|
+
segments: script.segments.map((segment) => {
|
|
5106
|
+
const rich = snapshot.richText[segment.id];
|
|
5107
|
+
const language = rich ? markedLanguage(langsFromDelta(rich.delta)) : void 0;
|
|
5108
|
+
return {
|
|
5109
|
+
id: segment.id,
|
|
5110
|
+
text: segment.text,
|
|
5111
|
+
...language === void 0 ? {} : { language },
|
|
5112
|
+
...segment.startMs === void 0 ? {} : {
|
|
5113
|
+
startMs: segment.startMs,
|
|
5114
|
+
endMs: segment.endMs
|
|
5115
|
+
}
|
|
5116
|
+
};
|
|
5117
|
+
}),
|
|
5118
|
+
sourceMediaIds: [],
|
|
5119
|
+
phoneticScripts: [],
|
|
5120
|
+
speeches: [],
|
|
5121
|
+
captions: []
|
|
5122
|
+
});
|
|
5123
|
+
for (const r of Object.values(snapshot["derived-from-relations"])) if (snapshot["media-entities"][r.sourceId]) views.get(r.entityId)?.sourceMediaIds.push(r.sourceId);
|
|
5124
|
+
for (const [id, phonetic] of Object.entries(snapshot["phonetic-script-entities"])) {
|
|
5125
|
+
const { audioScriptId, segmentId, phonemeScript } = phonetic.phoneme;
|
|
5126
|
+
const { digest } = phonetic;
|
|
5127
|
+
const segment = segmentIndex(audioScriptId, segmentId);
|
|
5128
|
+
const text = snapshot.phoneticText[id]?.text;
|
|
5129
|
+
views.get(audioScriptId)?.phoneticScripts.push({
|
|
5130
|
+
id,
|
|
5131
|
+
...segment === void 0 ? {} : { segment },
|
|
5132
|
+
...text === void 0 ? {} : { text },
|
|
5133
|
+
...phonemeScript === void 0 ? {} : { phonemeScript },
|
|
5134
|
+
...digest === void 0 ? {} : { digest }
|
|
5135
|
+
});
|
|
5136
|
+
}
|
|
5137
|
+
const layout = resolveDslLayout(snapshot);
|
|
5138
|
+
for (const view of views.values()) placeTranscript(snapshot, layout, view);
|
|
5139
|
+
const timbres = Object.values(snapshot["voice-timbre-relations"]);
|
|
5140
|
+
for (const track of layout.tracks) for (const { clipId } of track.clips) {
|
|
5141
|
+
const source = snapshot["clip-entities"][clipId]?.source;
|
|
5142
|
+
if (!source) continue;
|
|
5143
|
+
if (snapshot["media-entities"][source]?.kind === "voiceover-recording") {
|
|
5144
|
+
const voices = timbres.filter((r) => r.recordingId === source).flatMap((r) => {
|
|
5145
|
+
const voice = snapshot["voice-entities"][r.voiceId];
|
|
5146
|
+
if (!voice) return [];
|
|
5147
|
+
return [voice.name === void 0 ? {
|
|
5148
|
+
voiceId: r.voiceId,
|
|
5149
|
+
key: voice.key
|
|
5150
|
+
} : {
|
|
5151
|
+
voiceId: r.voiceId,
|
|
5152
|
+
key: voice.key,
|
|
5153
|
+
name: voice.name
|
|
5154
|
+
}];
|
|
5155
|
+
});
|
|
5156
|
+
for (const spoken of spokenScripts(snapshot, source)) {
|
|
5157
|
+
const applied = appliedSpeech(snapshot, source, spoken);
|
|
5158
|
+
views.get(spoken.audioScriptId)?.speeches.push({
|
|
5159
|
+
clipId,
|
|
5160
|
+
recordingId: source,
|
|
5161
|
+
...spoken.phoneticScriptId === void 0 ? {} : { phoneticScriptId: spoken.phoneticScriptId },
|
|
5162
|
+
voices,
|
|
5163
|
+
...applied === void 0 ? {} : { applied }
|
|
5164
|
+
});
|
|
5165
|
+
}
|
|
5166
|
+
continue;
|
|
5167
|
+
}
|
|
5168
|
+
const caption = snapshot["caption-entities"][source];
|
|
5169
|
+
if (!caption?.selection || caption.text !== void 0 || !snapshot.captionText[source]) continue;
|
|
5170
|
+
const { audioScriptId, segmentId } = caption.selection;
|
|
5171
|
+
const segment = segmentIndex(audioScriptId, segmentId);
|
|
5172
|
+
if (segment !== void 0) views.get(audioScriptId)?.captions.push({
|
|
5173
|
+
clipId,
|
|
5174
|
+
captionId: source,
|
|
5175
|
+
segment
|
|
5176
|
+
});
|
|
5177
|
+
}
|
|
5178
|
+
return [...views.values()];
|
|
5179
|
+
}
|
|
5180
|
+
/**
|
|
5181
|
+
* A transcript's segments on the Timeline: a segment's time is in the medium the script was read
|
|
5182
|
+
* off, so it lands where the earliest Clip playing that medium holds that moment in its window.
|
|
5183
|
+
*/
|
|
5184
|
+
function placeTranscript(snapshot, layout, view) {
|
|
5185
|
+
if (!view.sourceMediaIds.length) return;
|
|
5186
|
+
const plays = layout.tracks.flatMap((track) => track.clips).filter((clip) => view.sourceMediaIds.includes(snapshot["clip-entities"][clip.clipId]?.source ?? "")).sort((a, b) => a.startMs - b.startMs);
|
|
5187
|
+
for (const segment of view.segments) {
|
|
5188
|
+
const at = segment.startMs;
|
|
5189
|
+
if (at === void 0) continue;
|
|
5190
|
+
const clip = plays.find((c) => c.inMs <= at && at < c.outMs);
|
|
5191
|
+
if (clip) segment.timelineMs = Math.round(clip.startMs + (at - clip.inMs) / clip.rate);
|
|
5192
|
+
}
|
|
5193
|
+
}
|
|
5194
|
+
/**
|
|
5195
|
+
* Whether a recording still matches what it was synthesized from: the digest its derived-from
|
|
5196
|
+
* records against the source's file digest now. A recording derived from a script directly is
|
|
5197
|
+
* not judged: a written script has no file, and a transcript's file is what it was read from,
|
|
5198
|
+
* not what a take was synthesized from (D190).
|
|
5199
|
+
*/
|
|
5200
|
+
function appliedSpeech(snapshot, recordingId, spoken) {
|
|
5201
|
+
const sourceId = spoken.phoneticScriptId ?? spoken.audioScriptId;
|
|
5202
|
+
const recorded = Object.values(snapshot["derived-from-relations"]).find((r) => r.entityId === recordingId && r.sourceId === sourceId)?.sourceDigest;
|
|
5203
|
+
const current = spoken.phoneticScriptId === void 0 ? void 0 : snapshot["phonetic-script-entities"][sourceId]?.digest;
|
|
5204
|
+
return recorded === void 0 || current === void 0 ? void 0 : recorded === current;
|
|
5205
|
+
}
|
|
5206
|
+
//#endregion
|
|
4735
5207
|
//#region src/dsl/voiceover-actions.ts
|
|
4736
5208
|
function voiceoverPlan(s, input, result) {
|
|
4737
5209
|
if (requireTrack(s, input.trackId).role !== "speech" || input.media.kind !== "voiceover-recording") throw new Error("Voiceover requires a speech Track and a recording");
|
|
@@ -4744,16 +5216,30 @@ function voiceoverPlan(s, input, result) {
|
|
|
4744
5216
|
id: result.mediaId,
|
|
4745
5217
|
value: input.media
|
|
4746
5218
|
}];
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
5219
|
+
let segmentIds;
|
|
5220
|
+
if (input.script.segments !== void 0) {
|
|
5221
|
+
if (result.segmentIds.length !== input.script.segments.length) throw new Error("Expected one id per segment");
|
|
5222
|
+
const segments = input.script.segments;
|
|
5223
|
+
segmentIds = result.segmentIds;
|
|
5224
|
+
mutations.push({
|
|
5225
|
+
type: "create-entity",
|
|
5226
|
+
table: "audio-script-entities",
|
|
5227
|
+
id: result.audioScriptId,
|
|
5228
|
+
value: {
|
|
5229
|
+
segments: segments.map((text, i) => ({
|
|
5230
|
+
id: segmentIds[i],
|
|
5231
|
+
text
|
|
5232
|
+
})),
|
|
5233
|
+
..."defaultLanguage" in input.script && input.script.defaultLanguage !== void 0 ? { defaultLanguage: input.script.defaultLanguage } : {}
|
|
5234
|
+
}
|
|
5235
|
+
});
|
|
5236
|
+
} else {
|
|
5237
|
+
const script = s["audio-script-entities"][result.audioScriptId];
|
|
5238
|
+
if (!script) throw new Error("Missing AudioScript");
|
|
5239
|
+
segmentIds = script.segments.map((segment) => segment.id);
|
|
5240
|
+
}
|
|
5241
|
+
const phoneticScriptId = input.script.phoneticScriptId;
|
|
5242
|
+
if (phoneticScriptId !== void 0 && s["phonetic-script-entities"][phoneticScriptId]?.phoneme.audioScriptId !== result.audioScriptId) throw new Error("The PhoneticScript must name this AudioScript as its base");
|
|
4757
5243
|
validateWindow({
|
|
4758
5244
|
inMs: 0,
|
|
4759
5245
|
outMs: input.media.durationMs
|
|
@@ -4763,7 +5249,8 @@ function voiceoverPlan(s, input, result) {
|
|
|
4763
5249
|
table: "derived-from-relations",
|
|
4764
5250
|
value: {
|
|
4765
5251
|
entityId: result.mediaId,
|
|
4766
|
-
sourceId: result.audioScriptId
|
|
5252
|
+
sourceId: phoneticScriptId ?? result.audioScriptId,
|
|
5253
|
+
...input.sourceDigest === void 0 ? {} : { sourceDigest: input.sourceDigest }
|
|
4767
5254
|
}
|
|
4768
5255
|
});
|
|
4769
5256
|
if (input.voice) {
|
|
@@ -4793,6 +5280,7 @@ function voiceoverPlan(s, input, result) {
|
|
|
4793
5280
|
rate: void 0,
|
|
4794
5281
|
fillTimeline: void 0
|
|
4795
5282
|
},
|
|
5283
|
+
source: result.mediaId,
|
|
4796
5284
|
...input.volume === void 0 ? {} : { volume: input.volume }
|
|
4797
5285
|
}
|
|
4798
5286
|
} : {
|
|
@@ -4804,15 +5292,9 @@ function voiceoverPlan(s, input, result) {
|
|
|
4804
5292
|
inMs: 0,
|
|
4805
5293
|
outMs: input.media.durationMs
|
|
4806
5294
|
},
|
|
5295
|
+
source: result.mediaId,
|
|
4807
5296
|
...input.volume === void 0 ? {} : { volume: input.volume }
|
|
4808
5297
|
}
|
|
4809
|
-
}, {
|
|
4810
|
-
type: "set-relation",
|
|
4811
|
-
table: "clip-source-relations",
|
|
4812
|
-
value: {
|
|
4813
|
-
clipId: result.clipId,
|
|
4814
|
-
sourceId: result.mediaId
|
|
4815
|
-
}
|
|
4816
5298
|
});
|
|
4817
5299
|
if (!existing || trackOf(s, result.clipId) !== input.trackId) mutations.push({
|
|
4818
5300
|
type: existing ? "move-node" : "insert-node",
|
|
@@ -4822,13 +5304,14 @@ function voiceoverPlan(s, input, result) {
|
|
|
4822
5304
|
mutations.push(...placementMutations(s, result.clipId, input.placement));
|
|
4823
5305
|
uniqueIds(result.captionClipIds);
|
|
4824
5306
|
const retained = new Set(result.captionClipIds);
|
|
4825
|
-
const oldCaptions =
|
|
5307
|
+
const oldCaptions = existing ? captionsShowing(s, existing.source) : [];
|
|
4826
5308
|
mutations.push(...deleteClips(s, oldCaptions.filter((id) => !retained.has(id)), "cascade"));
|
|
4827
|
-
const
|
|
5309
|
+
const placement = mutations.find((m) => m.type === "patch-entity" && m.table === "clip-entities" && m.id === result.clipId && "anchor" in m.patch);
|
|
5310
|
+
const anchor = placement?.type === "patch-entity" && placement.table === "clip-entities" ? placement.patch.anchor : void 0;
|
|
4828
5311
|
const trackClips = resolveDslLayout(s).tracks.find((track) => track.trackId === input.trackId).clips;
|
|
4829
5312
|
const slot = trackClips.findIndex((clip) => clip.clipId === result.clipId);
|
|
4830
5313
|
const predecessor = trackClips.slice(0, slot < 0 ? trackClips.length : slot).findLast((clip) => clip.mode === "sequential" && !clip.fillTimeline);
|
|
4831
|
-
const speechStart = anchor?.
|
|
5314
|
+
const speechStart = anchor?.fallback_abs_ms !== void 0 ? anchor.fallback_abs_ms : predecessor ? predecessor.startMs + predecessor.durationMs : 0;
|
|
4832
5315
|
input.captions.forEach((caption, i) => {
|
|
4833
5316
|
const clipId = result.captionClipIds[i], captionId = result.captionIds[i];
|
|
4834
5317
|
validateWindow({
|
|
@@ -4837,7 +5320,9 @@ function voiceoverPlan(s, input, result) {
|
|
|
4837
5320
|
}, input.media.durationMs);
|
|
4838
5321
|
const previous = s["clip-entities"][clipId];
|
|
4839
5322
|
if (previous && !oldCaptions.includes(clipId)) throw new Error("Cannot overwrite another use’s Caption Clip");
|
|
4840
|
-
const
|
|
5323
|
+
const segmentId = segmentIds[caption.segment];
|
|
5324
|
+
if (segmentId === void 0) throw new Error(`Missing segment ${caption.segment} of the voiceover's script`);
|
|
5325
|
+
const previousSource = previous?.source;
|
|
4841
5326
|
const style = caption.style ?? (previousSource ? s["caption-entities"][previousSource]?.style : void 0);
|
|
4842
5327
|
mutations.push({
|
|
4843
5328
|
type: "create-entity",
|
|
@@ -4848,48 +5333,38 @@ function voiceoverPlan(s, input, result) {
|
|
|
4848
5333
|
type: "set-caption-content",
|
|
4849
5334
|
captionId,
|
|
4850
5335
|
audioScriptId: result.audioScriptId,
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
table: "caption-alignment-relations",
|
|
4856
|
-
value: {
|
|
4857
|
-
captionId,
|
|
4858
|
-
mediaId: result.mediaId,
|
|
4859
|
-
startMs: caption.startMs,
|
|
4860
|
-
endMs: caption.startMs + caption.durationMs
|
|
5336
|
+
segmentId,
|
|
5337
|
+
...caption.range === void 0 ? {} : {
|
|
5338
|
+
start: caption.range.start,
|
|
5339
|
+
end: caption.range.end
|
|
4861
5340
|
}
|
|
4862
|
-
}
|
|
5341
|
+
});
|
|
5342
|
+
const anchor = {
|
|
5343
|
+
to: result.clipId,
|
|
5344
|
+
offsetMs: caption.startMs,
|
|
5345
|
+
fallback_abs_ms: speechStart + caption.startMs
|
|
5346
|
+
};
|
|
5347
|
+
const seqMarker = {
|
|
5348
|
+
inMs: 0,
|
|
5349
|
+
outMs: caption.durationMs
|
|
5350
|
+
};
|
|
5351
|
+
mutations.push(previous ? {
|
|
4863
5352
|
type: "patch-entity",
|
|
4864
5353
|
table: "clip-entities",
|
|
4865
5354
|
id: clipId,
|
|
4866
|
-
patch: {
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
|
|
5355
|
+
patch: {
|
|
5356
|
+
seqMarker,
|
|
5357
|
+
source: captionId,
|
|
5358
|
+
anchor
|
|
5359
|
+
}
|
|
4870
5360
|
} : {
|
|
4871
5361
|
type: "create-entity",
|
|
4872
5362
|
table: "clip-entities",
|
|
4873
5363
|
id: clipId,
|
|
4874
|
-
value: { seqMarker: {
|
|
4875
|
-
inMs: 0,
|
|
4876
|
-
outMs: caption.durationMs
|
|
4877
|
-
} }
|
|
4878
|
-
}, {
|
|
4879
|
-
type: "set-relation",
|
|
4880
|
-
table: "clip-source-relations",
|
|
4881
|
-
value: {
|
|
4882
|
-
clipId,
|
|
4883
|
-
sourceId: captionId
|
|
4884
|
-
}
|
|
4885
|
-
}, {
|
|
4886
|
-
type: "set-relation",
|
|
4887
|
-
table: "time-anchor-relations",
|
|
4888
5364
|
value: {
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
fallback_abs_ms: speechStart + caption.startMs
|
|
5365
|
+
seqMarker,
|
|
5366
|
+
source: captionId,
|
|
5367
|
+
anchor
|
|
4893
5368
|
}
|
|
4894
5369
|
});
|
|
4895
5370
|
if (!previous || trackOf(s, clipId) !== input.captionTrackId) mutations.push({
|
|
@@ -4900,15 +5375,44 @@ function voiceoverPlan(s, input, result) {
|
|
|
4900
5375
|
});
|
|
4901
5376
|
return mutations;
|
|
4902
5377
|
}
|
|
5378
|
+
/**
|
|
5379
|
+
* Caption Clips showing what a recording says (D183): their Caption selects the run of the segment
|
|
5380
|
+
* the PhoneticScript it was spoken from is written for, or any segment of the script it was read
|
|
5381
|
+
* from directly.
|
|
5382
|
+
*/
|
|
5383
|
+
function captionsShowing(s, recordingId) {
|
|
5384
|
+
const said = spokenScripts(s, recordingId).map(({ audioScriptId, phoneticScriptId }) => {
|
|
5385
|
+
if (phoneticScriptId === void 0) return { audioScriptId };
|
|
5386
|
+
const { segmentId } = s["phonetic-script-entities"][phoneticScriptId].phoneme;
|
|
5387
|
+
return {
|
|
5388
|
+
audioScriptId,
|
|
5389
|
+
segmentId,
|
|
5390
|
+
run: s.phoneticText[phoneticScriptId]
|
|
5391
|
+
};
|
|
5392
|
+
});
|
|
5393
|
+
return Object.entries(s["clip-entities"]).flatMap(([id, clip]) => {
|
|
5394
|
+
const selection = s["caption-entities"][clip.source]?.selection;
|
|
5395
|
+
const shown = s.captionText[clip.source];
|
|
5396
|
+
if (!selection) return [];
|
|
5397
|
+
return said.some((words) => {
|
|
5398
|
+
if (selection.audioScriptId !== words.audioScriptId) return false;
|
|
5399
|
+
if (!("segmentId" in words)) return true;
|
|
5400
|
+
if (selection.segmentId !== words.segmentId || !shown || !words.run) return false;
|
|
5401
|
+
return shown.start < words.run.end && words.run.start < shown.end;
|
|
5402
|
+
}) && requireTrack(s, trackOf(s, id)).role === "caption" ? [id] : [];
|
|
5403
|
+
});
|
|
5404
|
+
}
|
|
4903
5405
|
//#endregion
|
|
4904
5406
|
//#region src/dsl/dsl-editor.ts
|
|
4905
5407
|
/** Business edits write DSL facts in one audited transaction; layout is derived on read. */
|
|
4906
5408
|
var DslEditor = class {
|
|
4907
5409
|
dsl;
|
|
4908
5410
|
idFactory;
|
|
4909
|
-
|
|
5411
|
+
uploadText;
|
|
5412
|
+
constructor(dsl, idFactory = defaultIdFactory, uploadText) {
|
|
4910
5413
|
this.dsl = dsl;
|
|
4911
5414
|
this.idFactory = idFactory;
|
|
5415
|
+
this.uploadText = uploadText;
|
|
4912
5416
|
}
|
|
4913
5417
|
createTrack(role, options) {
|
|
4914
5418
|
const id = this.idFactory("track");
|
|
@@ -4950,11 +5454,10 @@ var DslEditor = class {
|
|
|
4950
5454
|
/** index is measured in the target's children after removing the moved Clip. */
|
|
4951
5455
|
moveClip(clipId, trackId, index, options) {
|
|
4952
5456
|
const snapshot = this.dsl.getSnapshot();
|
|
4953
|
-
requireClip(snapshot, clipId);
|
|
5457
|
+
const clip = requireClip(snapshot, clipId);
|
|
4954
5458
|
const track = snapshot["track-entities"][trackId];
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
requireSource(snapshot, source.sourceId, track.role);
|
|
5459
|
+
if (!track) throw new Error("Missing target Track or Clip source");
|
|
5460
|
+
requireSource(snapshot, clip.source, track.role);
|
|
4958
5461
|
this.write("moveClip", {
|
|
4959
5462
|
clipId,
|
|
4960
5463
|
trackId,
|
|
@@ -4969,8 +5472,7 @@ var DslEditor = class {
|
|
|
4969
5472
|
trimClip(clipId, window, options) {
|
|
4970
5473
|
const snapshot = this.dsl.getSnapshot(), clip = requireClip(snapshot, clipId);
|
|
4971
5474
|
if (clip.seqMarker.fillTimeline) throw new Error("BGM trim is not enabled");
|
|
4972
|
-
const
|
|
4973
|
-
const source = sourceId ? snapshot["media-entities"][sourceId] ?? snapshot["caption-entities"][sourceId] : void 0;
|
|
5475
|
+
const source = snapshot["media-entities"][clip.source] ?? snapshot["caption-entities"][clip.source];
|
|
4974
5476
|
if (!source) throw new Error("Cannot trim a Clip with a missing source");
|
|
4975
5477
|
const patch = {};
|
|
4976
5478
|
if (window.inMs !== void 0) patch.inMs = window.inMs;
|
|
@@ -5013,30 +5515,76 @@ var DslEditor = class {
|
|
|
5013
5515
|
patch: { volume }
|
|
5014
5516
|
}], options);
|
|
5015
5517
|
}
|
|
5016
|
-
|
|
5518
|
+
/** A script of these segments, in order; each segment gets its own id. */
|
|
5519
|
+
createAudioScript(segments, defaultLanguage, options) {
|
|
5017
5520
|
const id = this.idFactory("audio-script");
|
|
5521
|
+
const value = {
|
|
5522
|
+
segments: segments.map((text) => ({
|
|
5523
|
+
id: this.idFactory("segment"),
|
|
5524
|
+
text
|
|
5525
|
+
})),
|
|
5526
|
+
...defaultLanguage === void 0 ? {} : { defaultLanguage }
|
|
5527
|
+
};
|
|
5018
5528
|
this.write("createAudioScript", {
|
|
5019
5529
|
id,
|
|
5020
|
-
|
|
5021
|
-
defaultLanguage
|
|
5530
|
+
...value
|
|
5022
5531
|
}, [{
|
|
5023
5532
|
type: "create-entity",
|
|
5024
5533
|
table: "audio-script-entities",
|
|
5025
5534
|
id,
|
|
5026
|
-
value
|
|
5027
|
-
body,
|
|
5028
|
-
...defaultLanguage === void 0 ? {} : { defaultLanguage }
|
|
5029
|
-
}
|
|
5535
|
+
value
|
|
5030
5536
|
}], options);
|
|
5031
5537
|
return id;
|
|
5032
5538
|
}
|
|
5033
|
-
editAudioScript(scriptId, expectedBodyId, edits, options) {
|
|
5034
|
-
this.dsl.editText(scriptId, expectedBodyId, edits, audit("editAudioScript", {
|
|
5539
|
+
editAudioScript(scriptId, segmentId, expectedBodyId, edits, options) {
|
|
5540
|
+
this.dsl.editText(scriptId, segmentId, expectedBodyId, edits, audit("editAudioScript", {
|
|
5035
5541
|
scriptId,
|
|
5542
|
+
segmentId,
|
|
5036
5543
|
expectedBodyId,
|
|
5037
5544
|
edits
|
|
5038
5545
|
}, options));
|
|
5039
5546
|
}
|
|
5547
|
+
/** Adds a segment at `index`, the end by default, and returns its id. */
|
|
5548
|
+
insertScriptSegment(scriptId, text, index, options) {
|
|
5549
|
+
const segmentId = this.idFactory("segment");
|
|
5550
|
+
this.write("insertScriptSegment", {
|
|
5551
|
+
scriptId,
|
|
5552
|
+
segmentId,
|
|
5553
|
+
text,
|
|
5554
|
+
index
|
|
5555
|
+
}, [{
|
|
5556
|
+
type: "insert-segment",
|
|
5557
|
+
audioScriptId: scriptId,
|
|
5558
|
+
segmentId,
|
|
5559
|
+
text,
|
|
5560
|
+
...index === void 0 ? {} : { index }
|
|
5561
|
+
}], options);
|
|
5562
|
+
return segmentId;
|
|
5563
|
+
}
|
|
5564
|
+
/** `index` is measured after removing the segment. */
|
|
5565
|
+
moveScriptSegment(scriptId, segmentId, index, options) {
|
|
5566
|
+
this.write("moveScriptSegment", {
|
|
5567
|
+
scriptId,
|
|
5568
|
+
segmentId,
|
|
5569
|
+
index
|
|
5570
|
+
}, [{
|
|
5571
|
+
type: "move-segment",
|
|
5572
|
+
audioScriptId: scriptId,
|
|
5573
|
+
segmentId,
|
|
5574
|
+
index
|
|
5575
|
+
}], options);
|
|
5576
|
+
}
|
|
5577
|
+
/** Captions and PhoneticScripts selecting the segment keep their selection, which then shows nothing (D72). */
|
|
5578
|
+
deleteScriptSegment(scriptId, segmentId, options) {
|
|
5579
|
+
this.write("deleteScriptSegment", {
|
|
5580
|
+
scriptId,
|
|
5581
|
+
segmentId
|
|
5582
|
+
}, [{
|
|
5583
|
+
type: "delete-segment",
|
|
5584
|
+
audioScriptId: scriptId,
|
|
5585
|
+
segmentId
|
|
5586
|
+
}], options);
|
|
5587
|
+
}
|
|
5040
5588
|
createCaption(input, options) {
|
|
5041
5589
|
const id = this.idFactory("caption");
|
|
5042
5590
|
this.write("createCaption", {
|
|
@@ -5053,18 +5601,16 @@ var DslEditor = class {
|
|
|
5053
5601
|
}, ...input.text !== void 0 ? [] : [{
|
|
5054
5602
|
type: "set-caption-content",
|
|
5055
5603
|
captionId: id,
|
|
5056
|
-
|
|
5057
|
-
start: input.start,
|
|
5058
|
-
end: input.end
|
|
5604
|
+
...segmentRange(input)
|
|
5059
5605
|
}]], options);
|
|
5060
5606
|
return id;
|
|
5061
5607
|
}
|
|
5062
|
-
|
|
5608
|
+
/** Show a segment of a script, or a run of it, in place of the Caption's own text. */
|
|
5609
|
+
setCaptionContent(captionId, selection, options) {
|
|
5610
|
+
const range = segmentRange(selection);
|
|
5063
5611
|
this.write("setCaptionContent", {
|
|
5064
5612
|
captionId,
|
|
5065
|
-
|
|
5066
|
-
start,
|
|
5067
|
-
end
|
|
5613
|
+
...range
|
|
5068
5614
|
}, [{
|
|
5069
5615
|
type: "patch-entity",
|
|
5070
5616
|
table: "caption-entities",
|
|
@@ -5073,9 +5619,7 @@ var DslEditor = class {
|
|
|
5073
5619
|
}, {
|
|
5074
5620
|
type: "set-caption-content",
|
|
5075
5621
|
captionId,
|
|
5076
|
-
|
|
5077
|
-
start,
|
|
5078
|
-
end
|
|
5622
|
+
...range
|
|
5079
5623
|
}], options);
|
|
5080
5624
|
}
|
|
5081
5625
|
setCaptionText(captionId, text, options) {
|
|
@@ -5083,9 +5627,10 @@ var DslEditor = class {
|
|
|
5083
5627
|
if (!caption) throw new Error("Missing Caption");
|
|
5084
5628
|
this.dsl.transact((tx) => {
|
|
5085
5629
|
tx.applyMutations([{
|
|
5086
|
-
type: "
|
|
5087
|
-
table: "caption-
|
|
5088
|
-
id:
|
|
5630
|
+
type: "patch-entity",
|
|
5631
|
+
table: "caption-entities",
|
|
5632
|
+
id: captionId,
|
|
5633
|
+
patch: { selection: void 0 }
|
|
5089
5634
|
}]);
|
|
5090
5635
|
if (caption.text === void 0) tx.draft["caption-entities"][captionId].text = text;
|
|
5091
5636
|
else if (caption.text !== text) tx.editCaptionText(captionId, [...caption.text.length ? [{
|
|
@@ -5199,7 +5744,8 @@ var DslEditor = class {
|
|
|
5199
5744
|
const result = {
|
|
5200
5745
|
clipId: input.clipId ?? this.idFactory("clip"),
|
|
5201
5746
|
mediaId: resourceId("media", input.media.system, input.media.key),
|
|
5202
|
-
audioScriptId:
|
|
5747
|
+
audioScriptId: this.voiceoverScriptId(input.script),
|
|
5748
|
+
segmentIds: input.script.segments?.map(() => this.idFactory("segment")) ?? [],
|
|
5203
5749
|
captionClipIds: input.captions.map((c) => c.clipId ?? this.idFactory("clip")),
|
|
5204
5750
|
captionIds: input.captions.map(() => this.idFactory("caption"))
|
|
5205
5751
|
};
|
|
@@ -5209,6 +5755,68 @@ var DslEditor = class {
|
|
|
5209
5755
|
}, voiceoverPlan(this.dsl.getSnapshot(), input, result), options);
|
|
5210
5756
|
return result;
|
|
5211
5757
|
}
|
|
5758
|
+
voiceoverScriptId(script) {
|
|
5759
|
+
if (script.audioScriptId !== void 0) return script.audioScriptId;
|
|
5760
|
+
if (script.phoneticScriptId === void 0) return this.idFactory("audio-script");
|
|
5761
|
+
const phonetic = this.dsl.getSnapshot()["phonetic-script-entities"][script.phoneticScriptId];
|
|
5762
|
+
if (!phonetic) throw new Error("Missing PhoneticScript");
|
|
5763
|
+
return phonetic.phoneme.audioScriptId;
|
|
5764
|
+
}
|
|
5765
|
+
/**
|
|
5766
|
+
* Write a PhoneticScript: create one for a segment of a script, or a run of it, or, given
|
|
5767
|
+
* `phoneticScriptId`, replace its selection and what to synthesize together (D181). A
|
|
5768
|
+
* `phonemeScript` is stored as a Memota text file first, and the file's facts are written with
|
|
5769
|
+
* it; an unchanged one keeps its file. Recordings already spoken from it keep their derived-from.
|
|
5770
|
+
*/
|
|
5771
|
+
async setPhoneticScript(input, options) {
|
|
5772
|
+
const id = input.phoneticScriptId ?? this.idFactory("phonetic-script");
|
|
5773
|
+
const range = segmentRange(input);
|
|
5774
|
+
const file = input.phonemeScript === void 0 ? {} : await this.fileFor(id, input.phonemeScript);
|
|
5775
|
+
const exists = Object.hasOwn(this.dsl.getSnapshot()["phonetic-script-entities"], id);
|
|
5776
|
+
const cleared = {
|
|
5777
|
+
system: void 0,
|
|
5778
|
+
key: void 0,
|
|
5779
|
+
storageKey: void 0,
|
|
5780
|
+
digest: void 0
|
|
5781
|
+
};
|
|
5782
|
+
const phonemeScript = input.phonemeScript === void 0 ? {} : { phonemeScript: input.phonemeScript };
|
|
5783
|
+
this.write("setPhoneticScript", {
|
|
5784
|
+
id,
|
|
5785
|
+
...range,
|
|
5786
|
+
...phonemeScript,
|
|
5787
|
+
...cleared,
|
|
5788
|
+
...file
|
|
5789
|
+
}, [exists ? {
|
|
5790
|
+
type: "patch-entity",
|
|
5791
|
+
table: "phonetic-script-entities",
|
|
5792
|
+
id,
|
|
5793
|
+
patch: {
|
|
5794
|
+
...cleared,
|
|
5795
|
+
...file
|
|
5796
|
+
}
|
|
5797
|
+
} : {
|
|
5798
|
+
type: "create-entity",
|
|
5799
|
+
table: "phonetic-script-entities",
|
|
5800
|
+
id,
|
|
5801
|
+
value: file
|
|
5802
|
+
}, {
|
|
5803
|
+
type: "set-phoneme",
|
|
5804
|
+
phoneticScriptId: id,
|
|
5805
|
+
...range,
|
|
5806
|
+
...phonemeScript
|
|
5807
|
+
}], options);
|
|
5808
|
+
return id;
|
|
5809
|
+
}
|
|
5810
|
+
/** The file holding `text`: the one the PhoneticScript keeps while its text is unchanged, else a new upload. */
|
|
5811
|
+
async fileFor(id, text) {
|
|
5812
|
+
const current = this.dsl.getSnapshot()["phonetic-script-entities"][id];
|
|
5813
|
+
if (current?.phoneme.phonemeScript === text) {
|
|
5814
|
+
const { phoneme: _, ...file } = current;
|
|
5815
|
+
return file;
|
|
5816
|
+
}
|
|
5817
|
+
if (!this.uploadText) throw new Error("This editor stores no files");
|
|
5818
|
+
return this.uploadText(text);
|
|
5819
|
+
}
|
|
5212
5820
|
setBgm(input, options) {
|
|
5213
5821
|
const s = this.dsl.getSnapshot();
|
|
5214
5822
|
if (requireTrack(s, input.trackId).role !== "bgm") throw new Error("Expected a BGM Track");
|
|
@@ -5227,37 +5835,28 @@ var DslEditor = class {
|
|
|
5227
5835
|
fillTimeline: true
|
|
5228
5836
|
};
|
|
5229
5837
|
const volume = input.volume === void 0 ? {} : { volume: input.volume };
|
|
5230
|
-
const mutations = [
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
...volume
|
|
5242
|
-
}
|
|
5243
|
-
} : {
|
|
5244
|
-
type: "create-entity",
|
|
5245
|
-
table: "clip-entities",
|
|
5246
|
-
id,
|
|
5247
|
-
value: {
|
|
5248
|
-
seqMarker,
|
|
5249
|
-
...volume
|
|
5250
|
-
}
|
|
5251
|
-
},
|
|
5252
|
-
{
|
|
5253
|
-
type: "set-relation",
|
|
5254
|
-
table: "clip-source-relations",
|
|
5255
|
-
value: {
|
|
5256
|
-
clipId: id,
|
|
5257
|
-
sourceId: source.id
|
|
5258
|
-
}
|
|
5838
|
+
const mutations = [...source.mutations, old ? {
|
|
5839
|
+
type: "patch-entity",
|
|
5840
|
+
table: "clip-entities",
|
|
5841
|
+
id,
|
|
5842
|
+
patch: {
|
|
5843
|
+
seqMarker: {
|
|
5844
|
+
...seqMarker,
|
|
5845
|
+
rate: void 0
|
|
5846
|
+
},
|
|
5847
|
+
source: source.id,
|
|
5848
|
+
...volume
|
|
5259
5849
|
}
|
|
5260
|
-
|
|
5850
|
+
} : {
|
|
5851
|
+
type: "create-entity",
|
|
5852
|
+
table: "clip-entities",
|
|
5853
|
+
id,
|
|
5854
|
+
value: {
|
|
5855
|
+
seqMarker,
|
|
5856
|
+
source: source.id,
|
|
5857
|
+
...volume
|
|
5858
|
+
}
|
|
5859
|
+
}];
|
|
5261
5860
|
if (!old) mutations.push({
|
|
5262
5861
|
type: "insert-node",
|
|
5263
5862
|
entityId: id,
|
|
@@ -5273,6 +5872,15 @@ var DslEditor = class {
|
|
|
5273
5872
|
this.dsl.applyMutations(mutations, audit(action, payload, options));
|
|
5274
5873
|
}
|
|
5275
5874
|
};
|
|
5875
|
+
/** A selection as a write states it: a run only when both its ends are given. */
|
|
5876
|
+
function segmentRange(input) {
|
|
5877
|
+
return {
|
|
5878
|
+
audioScriptId: input.audioScriptId,
|
|
5879
|
+
segmentId: input.segmentId,
|
|
5880
|
+
...input.start === void 0 ? {} : { start: input.start },
|
|
5881
|
+
...input.end === void 0 ? {} : { end: input.end }
|
|
5882
|
+
};
|
|
5883
|
+
}
|
|
5276
5884
|
function audit(action, payload, options) {
|
|
5277
5885
|
return {
|
|
5278
5886
|
semantic_op: action,
|
|
@@ -5485,25 +6093,18 @@ function toVideoDraft(snapshot) {
|
|
|
5485
6093
|
function requireWholeAudio(media, clip, fail) {
|
|
5486
6094
|
if (media.kind === "image" || clip.inMs !== 0 || clip.outMs !== media.durationMs || clip.rate !== 1) fail("VideoDraft audio has no trim or speed fields");
|
|
5487
6095
|
}
|
|
6096
|
+
/**
|
|
6097
|
+
* What a recording says: what the selection of the PhoneticScript it was spoken from holds now,
|
|
6098
|
+
* or, for one derived from a script directly, the whole script, one segment per line.
|
|
6099
|
+
*/
|
|
5488
6100
|
function spokenText(snapshot, recordingId) {
|
|
5489
|
-
const
|
|
5490
|
-
|
|
5491
|
-
const
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
return;
|
|
5497
|
-
}
|
|
5498
|
-
for (const r of relations) if (r.entityId === id) visit(r.sourceId);
|
|
5499
|
-
};
|
|
5500
|
-
visit(recordingId);
|
|
5501
|
-
if (!scripts.size) {
|
|
5502
|
-
for (const r of relations) if (r.sourceId === recordingId && snapshot["audio-script-entities"][r.entityId]) scripts.add(r.entityId);
|
|
5503
|
-
}
|
|
5504
|
-
if (scripts.size > 1) projectionError("ambiguous-script", `media-entities/${recordingId}`, "Multiple scripts cannot be silently concatenated into audio_script");
|
|
5505
|
-
const id = [...scripts][0];
|
|
5506
|
-
return id ? snapshot.richText[id]?.plainText ?? "" : "";
|
|
6101
|
+
const spoken = spokenScripts(snapshot, recordingId);
|
|
6102
|
+
if (new Set(spoken.map((s) => s.phoneticScriptId ?? s.audioScriptId)).size > 1) projectionError("ambiguous-script", `media-entities/${recordingId}`, "Multiple scripts cannot be silently concatenated into audio_script");
|
|
6103
|
+
const [first] = spoken;
|
|
6104
|
+
if (!first) return "";
|
|
6105
|
+
if (first.phoneticScriptId !== void 0) return snapshot.phoneticText[first.phoneticScriptId]?.text ?? "";
|
|
6106
|
+
const script = snapshot["audio-script-entities"][first.audioScriptId];
|
|
6107
|
+
return script ? script.segments.map((segment) => segment.text).join("\n") : "";
|
|
5507
6108
|
}
|
|
5508
6109
|
//#endregion
|
|
5509
6110
|
//#region src/dsl/projection/from-video-draft.ts
|
|
@@ -5645,16 +6246,16 @@ function prepare(input, options) {
|
|
|
5645
6246
|
let script = scriptByRecording.get(recordingId);
|
|
5646
6247
|
if (script && script.text !== speech.audio_script) fail(`part_library/${id}/audio_script`, "One recording cannot acquire conflicting source scripts during import");
|
|
5647
6248
|
if (!script) {
|
|
5648
|
-
script =
|
|
5649
|
-
id: claim(mint("audio-script")),
|
|
5650
|
-
text: speech.audio_script
|
|
5651
|
-
};
|
|
6249
|
+
script = importedScript(claim(mint("audio-script")), speech.audio_script, () => mint("segment"));
|
|
5652
6250
|
scriptByRecording.set(recordingId, script);
|
|
5653
6251
|
mutations.push({
|
|
5654
6252
|
type: "create-entity",
|
|
5655
6253
|
table: "audio-script-entities",
|
|
5656
6254
|
id: script.id,
|
|
5657
|
-
value: {
|
|
6255
|
+
value: { segments: script.segments.map(({ id, text }) => ({
|
|
6256
|
+
id,
|
|
6257
|
+
text
|
|
6258
|
+
})) }
|
|
5658
6259
|
}, {
|
|
5659
6260
|
type: "set-relation",
|
|
5660
6261
|
table: "derived-from-relations",
|
|
@@ -5702,12 +6303,18 @@ function prepare(input, options) {
|
|
|
5702
6303
|
const text = caption.text;
|
|
5703
6304
|
if (typeof text !== "string") fail(`part_library/${captionId}/text`, "Expected Caption text");
|
|
5704
6305
|
const start = text.length ? script.text.indexOf(text, cursor) : -1;
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
6306
|
+
const end = start + text.length;
|
|
6307
|
+
const segment = script.segments.find((s) => start >= s.start && end <= s.start + s.text.length);
|
|
6308
|
+
if (start >= 0 && segment) {
|
|
6309
|
+
cursor = end;
|
|
6310
|
+
selections.set(captionId, text === segment.text ? {
|
|
6311
|
+
audioScriptId: script.id,
|
|
6312
|
+
segmentId: segment.id
|
|
6313
|
+
} : {
|
|
5708
6314
|
audioScriptId: script.id,
|
|
5709
|
-
|
|
5710
|
-
|
|
6315
|
+
segmentId: segment.id,
|
|
6316
|
+
start: start - segment.start,
|
|
6317
|
+
end: end - segment.start
|
|
5711
6318
|
});
|
|
5712
6319
|
} else diagnostics.push({
|
|
5713
6320
|
code: "caption-text-unmatched",
|
|
@@ -5770,28 +6377,19 @@ function prepare(input, options) {
|
|
|
5770
6377
|
captionId: sourceId,
|
|
5771
6378
|
...selected
|
|
5772
6379
|
});
|
|
5773
|
-
const speechId = caption.speech_part_id, mediaId = speechId ? mediaByPart.get(speechId) : void 0;
|
|
5774
|
-
if (mediaId && caption.start_ms !== void 0) {
|
|
5775
|
-
const startMs = milliseconds(caption.start_ms, `part_library/${id}/start_ms`), endMs = startMs + (caption.initial_duration_ms ?? outMs);
|
|
5776
|
-
const media = imported.get(mediaId);
|
|
5777
|
-
if ("durationMs" in media && endMs > startMs && endMs <= media.durationMs) mutations.push({
|
|
5778
|
-
type: "set-relation",
|
|
5779
|
-
table: "caption-alignment-relations",
|
|
5780
|
-
value: {
|
|
5781
|
-
captionId: sourceId,
|
|
5782
|
-
mediaId,
|
|
5783
|
-
startMs,
|
|
5784
|
-
endMs
|
|
5785
|
-
}
|
|
5786
|
-
});
|
|
5787
|
-
else diagnostics.push({
|
|
5788
|
-
code: "invalid-caption-alignment",
|
|
5789
|
-
path: `part_library/${id}`,
|
|
5790
|
-
message: "Source interval is invalid; display window is retained without alignment"
|
|
5791
|
-
});
|
|
5792
|
-
}
|
|
5793
6380
|
}
|
|
5794
6381
|
if (outMs <= inMs || !sourceId) fail(`part_library/${id}`, "Expected a nonempty source window");
|
|
6382
|
+
let anchor;
|
|
6383
|
+
if (placement.role !== "video_clip") {
|
|
6384
|
+
const host = hosts.get(id);
|
|
6385
|
+
const speechId = part.caption?.speech_part_id;
|
|
6386
|
+
const offsetMs = host?.offset ?? (speechId ? milliseconds(part.caption?.start_ms ?? 0, `part_library/${id}/start_ms`) : placement.abs);
|
|
6387
|
+
anchor = {
|
|
6388
|
+
to: host?.to ?? speechId ?? timelineId,
|
|
6389
|
+
offsetMs,
|
|
6390
|
+
fallback_abs_ms: placement.abs
|
|
6391
|
+
};
|
|
6392
|
+
}
|
|
5795
6393
|
mutations.push({
|
|
5796
6394
|
type: "create-entity",
|
|
5797
6395
|
table: "clip-entities",
|
|
@@ -5803,36 +6401,15 @@ function prepare(input, options) {
|
|
|
5803
6401
|
...speed ? { rate: speed.rate } : {},
|
|
5804
6402
|
...part.bgm ? { fillTimeline: true } : {}
|
|
5805
6403
|
},
|
|
6404
|
+
source: sourceId,
|
|
6405
|
+
...anchor === void 0 ? {} : { anchor },
|
|
5806
6406
|
...volume === void 0 ? {} : { volume }
|
|
5807
6407
|
}
|
|
5808
|
-
}, {
|
|
5809
|
-
type: "set-relation",
|
|
5810
|
-
table: "clip-source-relations",
|
|
5811
|
-
value: {
|
|
5812
|
-
clipId: id,
|
|
5813
|
-
sourceId
|
|
5814
|
-
}
|
|
5815
6408
|
}, {
|
|
5816
6409
|
type: "insert-node",
|
|
5817
6410
|
entityId: id,
|
|
5818
6411
|
parentId: placement.trackId
|
|
5819
6412
|
});
|
|
5820
|
-
if (placement.role !== "video_clip") {
|
|
5821
|
-
const host = hosts.get(id);
|
|
5822
|
-
const speechId = part.caption?.speech_part_id;
|
|
5823
|
-
const to = host?.to ?? speechId ?? timelineId;
|
|
5824
|
-
const offsetMs = host?.offset ?? (speechId ? milliseconds(part.caption?.start_ms ?? 0, `part_library/${id}/start_ms`) : placement.abs);
|
|
5825
|
-
mutations.push({
|
|
5826
|
-
type: "set-relation",
|
|
5827
|
-
table: "time-anchor-relations",
|
|
5828
|
-
value: {
|
|
5829
|
-
from: id,
|
|
5830
|
-
to,
|
|
5831
|
-
offsetMs,
|
|
5832
|
-
fallback_abs_ms: placement.abs
|
|
5833
|
-
}
|
|
5834
|
-
});
|
|
5835
|
-
}
|
|
5836
6413
|
}
|
|
5837
6414
|
return {
|
|
5838
6415
|
timelineId,
|
|
@@ -5848,5 +6425,22 @@ function milliseconds(value, path, positive = false, signed = false) {
|
|
|
5848
6425
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || !signed && value < 0 || positive && value <= 0) projectionError("invalid-time", path, "Expected safe integer milliseconds in the valid range");
|
|
5849
6426
|
return value;
|
|
5850
6427
|
}
|
|
6428
|
+
function importedScript(id, text, mintSegment) {
|
|
6429
|
+
const segments = [];
|
|
6430
|
+
let start = 0;
|
|
6431
|
+
if (text.length) for (const line of text.split("\n")) {
|
|
6432
|
+
segments.push({
|
|
6433
|
+
id: mintSegment(),
|
|
6434
|
+
text: line,
|
|
6435
|
+
start
|
|
6436
|
+
});
|
|
6437
|
+
start += line.length + 1;
|
|
6438
|
+
}
|
|
6439
|
+
return {
|
|
6440
|
+
id,
|
|
6441
|
+
text,
|
|
6442
|
+
segments
|
|
6443
|
+
};
|
|
6444
|
+
}
|
|
5851
6445
|
//#endregion
|
|
5852
|
-
export {
|
|
6446
|
+
export { toAudioScriptViews as a, DslProjectionError as c, DslEditor as i, toVideoDraft as n, MedeoDsl as o, createInitialMedeoDsl as r, resolveDslLayout as s, fromVideoDraft as t };
|