@mengine/medeo-client 2.1.1-dsl.1 → 2.1.1-dsl.10
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 +26 -11
- package/dist/{dsl-EV6kCdr6.js → dsl-COyzz7Fp.js} +1074 -531
- package/dist/dsl.d.ts +3 -3
- package/dist/dsl.js +3 -3
- package/dist/{index-Cu7SdKN5.d.ts → index-C5wDKYqC.d.ts} +183 -170
- package/dist/{index-yifEtZSP.d.ts → index-CHiPztZr.d.ts} +53 -56
- package/dist/{index-B7fC7q7P.d.ts → index-DAfOkcnZ.d.ts} +23 -4
- package/dist/index.d.ts +5 -5
- package/dist/index.js +65 -9
- 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-TXRv3wS6.js → video-draft-types-BnbfWrBx.js} +178 -111
- package/dist/{video-draft-types-c_shaRyq.d.ts → video-draft-types-GqXnsOzA.d.ts} +114 -54
- 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, at as atomicFields, b as schemaContainsIgnoreField, c as isInvalidAtomic, ct as mergeFields, d as schema, dt as validLanguage, et as assertId, f as getDefaultValue, ft as assertRelationIdentity, g as isLoroMapSchema, gt as resourceId, h as isLoroListSchema, ht as relationId, it as assertTimeAnchor, j as hardenCidDescriptors, k as defineCidProperty, l as phonemeCodec, lt as patchRow, m as isListLikeSchema, mt as defaultIdFactory, n as textStyles, nt as assertPhoneticScript, o as medeoDslLoroShape, ot as entityTables, p as isContainerSchema, pt as assertResourceIdentity, q as CID_KEY, rt as assertRelation, s as captionContentCodec, st as isRecord, t as DEFAULT_UNIT_TIME_MS, tt as assertPhoneme, ut as relationTables, v as isLoroTreeSchema, w as applySchemaToInferOptions, x as validateSchema, y as isRootSchemaType, z as isTreeID } from "./video-draft-types-BnbfWrBx.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);
|
|
226
|
+
}
|
|
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");
|
|
185
240
|
}
|
|
186
|
-
/** A caption selects a
|
|
187
|
-
function assertCaptionSelection(rows, selection,
|
|
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,24 @@ 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);
|
|
4412
|
+
break;
|
|
4413
|
+
case "move-segment":
|
|
4414
|
+
tx.moveSegment(m.audioScriptId, m.segmentId, m.index);
|
|
4415
|
+
break;
|
|
4416
|
+
case "delete-segment":
|
|
4417
|
+
tx.deleteSegment(m.audioScriptId, m.segmentId);
|
|
4418
|
+
break;
|
|
4274
4419
|
case "edit-text":
|
|
4275
|
-
tx.editText(m.audioScriptId, m.edits, m.expectedBodyId);
|
|
4420
|
+
tx.editText(m.audioScriptId, m.segmentId, m.edits, m.expectedBodyId);
|
|
4276
4421
|
break;
|
|
4277
4422
|
case "edit-caption-text":
|
|
4278
4423
|
tx.editCaptionText(m.captionId, m.edits, m.expectedBodyId);
|
|
@@ -4280,10 +4425,32 @@ function applyMutations(tx, mutations) {
|
|
|
4280
4425
|
case "set-caption-content":
|
|
4281
4426
|
tx.setCaptionContent(m);
|
|
4282
4427
|
break;
|
|
4428
|
+
case "set-phoneme":
|
|
4429
|
+
tx.setPhoneme(m);
|
|
4430
|
+
break;
|
|
4283
4431
|
default: changeTree(tx.draft["contain-relations"], m, tx.draft);
|
|
4284
4432
|
}
|
|
4285
4433
|
}
|
|
4286
4434
|
}
|
|
4435
|
+
/**
|
|
4436
|
+
* A derived-from records its source's digest (D177): the one the writer names, otherwise the
|
|
4437
|
+
* source's digest as this transaction leaves it. A source with no digest records none.
|
|
4438
|
+
*/
|
|
4439
|
+
function withSourceDigest(rows, table, value) {
|
|
4440
|
+
if (table !== "derived-from-relations") return value;
|
|
4441
|
+
const relation = value;
|
|
4442
|
+
if (relation.sourceDigest !== void 0) return value;
|
|
4443
|
+
const sourceDigest = digestOf(rows, relation.sourceId);
|
|
4444
|
+
return sourceDigest === void 0 ? value : {
|
|
4445
|
+
...relation,
|
|
4446
|
+
sourceDigest
|
|
4447
|
+
};
|
|
4448
|
+
}
|
|
4449
|
+
/** A source's digest is its file's: a PhoneticScript's or a medium's Media File `digest`. */
|
|
4450
|
+
function digestOf(rows, entityId) {
|
|
4451
|
+
const digest = (rows["phonetic-script-entities"][entityId] ?? rows["media-entities"][entityId])?.digest;
|
|
4452
|
+
return typeof digest === "string" ? digest : void 0;
|
|
4453
|
+
}
|
|
4287
4454
|
//#endregion
|
|
4288
4455
|
//#region src/dsl/document/native-text.ts
|
|
4289
4456
|
function applyTextEdits(text, edits) {
|
|
@@ -4295,11 +4462,7 @@ function applyTextEdits(text, edits) {
|
|
|
4295
4462
|
text.delete(edit.at, edit.length);
|
|
4296
4463
|
break;
|
|
4297
4464
|
case "mark":
|
|
4298
|
-
|
|
4299
|
-
start: edit.start,
|
|
4300
|
-
end: edit.end
|
|
4301
|
-
}, edit.key);
|
|
4302
|
-
else text.mark({
|
|
4465
|
+
text.mark({
|
|
4303
4466
|
start: edit.start,
|
|
4304
4467
|
end: edit.end
|
|
4305
4468
|
}, edit.key, edit.value);
|
|
@@ -4317,13 +4480,15 @@ function applyTextEdits(text, edits) {
|
|
|
4317
4480
|
}
|
|
4318
4481
|
//#endregion
|
|
4319
4482
|
//#region src/dsl/document/validation.ts
|
|
4320
|
-
/**
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4483
|
+
/**
|
|
4484
|
+
* A selection the draft wrote whole, by the DSL's rules: its segment must exist and what it
|
|
4485
|
+
* selects must not be empty. A run's Cursors must lie on that segment's live Text.
|
|
4486
|
+
*/
|
|
4487
|
+
function validateSelection(doc, state, table, id, value) {
|
|
4488
|
+
assertReferenceAdmitted(state, table, id, selectionField[table], value);
|
|
4489
|
+
const selection = value;
|
|
4490
|
+
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");
|
|
4491
|
+
(table === "caption-entities" ? captionContentCodec : phonemeCodec).encode(selection);
|
|
4327
4492
|
}
|
|
4328
4493
|
/**
|
|
4329
4494
|
* The DSL's admission of the whole draft, then what only this document can check: containment
|
|
@@ -4355,6 +4520,14 @@ function validateDraft(tracked) {
|
|
|
4355
4520
|
}
|
|
4356
4521
|
//#endregion
|
|
4357
4522
|
//#region src/dsl/document/transact.ts
|
|
4523
|
+
const codecs = {
|
|
4524
|
+
"caption-entities": captionContentCodec,
|
|
4525
|
+
"phonetic-script-entities": phonemeCodec
|
|
4526
|
+
};
|
|
4527
|
+
function entityRow(doc, table, id) {
|
|
4528
|
+
const row = doc.getMap(table).get(id);
|
|
4529
|
+
return isContainer(row) && row.kind() === "Map" ? row : void 0;
|
|
4530
|
+
}
|
|
4358
4531
|
/** Serialization happens before preparing any writes, so invalid/cyclic audit data cannot partly commit. */
|
|
4359
4532
|
function auditOptions(audit) {
|
|
4360
4533
|
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 +4546,8 @@ const nativeHookSupport = /* @__PURE__ */ new WeakMap();
|
|
|
4373
4546
|
/**
|
|
4374
4547
|
* Every write relies on the patched Mirror's `applyNative` (patches/loro-mirror@2.3.3.patch). An
|
|
4375
4548
|
* unpatched Mirror ignores the option and commits only the draft, silently dropping Text, ranges
|
|
4376
|
-
* and
|
|
4377
|
-
*
|
|
4549
|
+
* and selections. The patch runs the hook even for an empty draft without committing, so probe
|
|
4550
|
+
* each Mirror class once on a scratch document and refuse before the first real write.
|
|
4378
4551
|
*/
|
|
4379
4552
|
function assertNativeHook(mirror) {
|
|
4380
4553
|
const Impl = mirror.constructor;
|
|
@@ -4408,50 +4581,75 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4408
4581
|
mirror.setState((raw) => {
|
|
4409
4582
|
const tracked = new TransactionDraft(raw, before);
|
|
4410
4583
|
setReject((error) => tracked.fail(error));
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
if (
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4584
|
+
/** The current text of a Caption's own Text or a script segment, as this transaction leaves it so far. */
|
|
4585
|
+
const useText = (target) => {
|
|
4586
|
+
let text;
|
|
4587
|
+
if ("captionId" in target) {
|
|
4588
|
+
assertId(target.captionId);
|
|
4589
|
+
const entity = requireEntity(tracked.draft, "caption-entities", target.captionId);
|
|
4590
|
+
if (tracked.deleted.has(target.captionId)) throw new Error("Cannot use a deleted Text");
|
|
4591
|
+
if (tracked.removedCaptionTexts.has(target.captionId)) throw new Error("Cannot edit a removed Text");
|
|
4592
|
+
text = entity.text;
|
|
4593
|
+
} else {
|
|
4594
|
+
assertId(target.audioScriptId);
|
|
4595
|
+
if (tracked.deleted.has(target.audioScriptId)) throw new Error("Cannot use a deleted Text");
|
|
4596
|
+
text = requireSegment(tracked.draft, target.audioScriptId, target.segmentId).text;
|
|
4597
|
+
}
|
|
4598
|
+
if (typeof text !== "string") throw new Error("Missing Text");
|
|
4599
|
+
tracked.textPreviews.set(textKey(target), text);
|
|
4600
|
+
return text;
|
|
4420
4601
|
};
|
|
4421
|
-
const
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4602
|
+
const textBefore = (target) => {
|
|
4603
|
+
if ("captionId" in target) return before["caption-entities"][target.captionId]?.text;
|
|
4604
|
+
return before["audio-script-entities"][target.audioScriptId]?.segments?.find((segment) => segment.id === target.segmentId)?.text;
|
|
4605
|
+
};
|
|
4606
|
+
const flushSelections = () => {
|
|
4607
|
+
for (const [table, id] of tracked.dirtySelections()) {
|
|
4608
|
+
const row = raw[table][id];
|
|
4609
|
+
if (!row) continue;
|
|
4610
|
+
const field = selectionField[table];
|
|
4611
|
+
const value = row[field];
|
|
4612
|
+
const old = before[table][id]?.[field];
|
|
4613
|
+
if (isInvalidAtomic(value)) {
|
|
4614
|
+
if (isInvalidAtomic(old) && old.raw === value.raw) continue;
|
|
4615
|
+
throw new Error(`Invalid ${field}`);
|
|
4427
4616
|
}
|
|
4617
|
+
if (value !== void 0) validateSelection(doc, raw, table, id, value);
|
|
4428
4618
|
steps.push({
|
|
4429
|
-
type: "
|
|
4619
|
+
type: "selection",
|
|
4620
|
+
table,
|
|
4430
4621
|
id,
|
|
4431
4622
|
value: copyInput(value)
|
|
4432
4623
|
});
|
|
4433
4624
|
}
|
|
4434
|
-
tracked.
|
|
4625
|
+
tracked.clearDirtySelections();
|
|
4435
4626
|
};
|
|
4436
|
-
const editBody = (
|
|
4627
|
+
const editBody = (target, edits, expectedBodyId) => {
|
|
4437
4628
|
tracked.run(() => {
|
|
4438
|
-
|
|
4439
|
-
const
|
|
4440
|
-
|
|
4441
|
-
|
|
4629
|
+
flushSelections();
|
|
4630
|
+
const text = useText(target);
|
|
4631
|
+
const key = textKey(target);
|
|
4632
|
+
if (!bodies.has(key)) bodies.set(key, liveText(doc, target));
|
|
4633
|
+
const native = bodies.get(key);
|
|
4442
4634
|
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(
|
|
4635
|
+
if (textBefore(target) !== void 0 && !native) throw new Error("Missing live Text body");
|
|
4636
|
+
const prepared = prepareTextEdits(text, copyInput(edits));
|
|
4637
|
+
tracked.textPreviews.set(key, prepared.text);
|
|
4446
4638
|
steps.push({
|
|
4447
4639
|
type: "text",
|
|
4448
|
-
|
|
4449
|
-
caption,
|
|
4640
|
+
target,
|
|
4450
4641
|
expectedBodyId: native?.id,
|
|
4451
4642
|
edits: prepared.edits
|
|
4452
4643
|
});
|
|
4453
4644
|
});
|
|
4454
4645
|
};
|
|
4646
|
+
/** A script's segments as this transaction leaves them; never a Mirror-tracked write. */
|
|
4647
|
+
const segmentsOf = (audioScriptId) => {
|
|
4648
|
+
assertId(audioScriptId);
|
|
4649
|
+
requireEntity(tracked.draft, "audio-script-entities", audioScriptId);
|
|
4650
|
+
if (tracked.deleted.has(audioScriptId)) throw new Error("Cannot change a deleted AudioScript");
|
|
4651
|
+
return raw["audio-script-entities"][audioScriptId].segments;
|
|
4652
|
+
};
|
|
4455
4653
|
const tx = Object.freeze({
|
|
4456
4654
|
get draft() {
|
|
4457
4655
|
return tracked.run(() => tracked.draft);
|
|
@@ -4459,29 +4657,95 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4459
4657
|
applyMutations(mutations) {
|
|
4460
4658
|
tracked.run(() => applyMutations(tx, mutations));
|
|
4461
4659
|
},
|
|
4462
|
-
editText(
|
|
4463
|
-
editBody(
|
|
4660
|
+
editText(audioScriptId, segmentId, edits, expectedBodyId) {
|
|
4661
|
+
editBody({
|
|
4662
|
+
audioScriptId,
|
|
4663
|
+
segmentId
|
|
4664
|
+
}, edits, expectedBodyId);
|
|
4464
4665
|
},
|
|
4465
4666
|
editCaptionText(captionId, edits, expectedBodyId) {
|
|
4466
|
-
editBody(captionId, edits, expectedBodyId
|
|
4667
|
+
editBody({ captionId }, edits, expectedBodyId);
|
|
4668
|
+
},
|
|
4669
|
+
insertSegment(audioScriptId, segmentId, text, index) {
|
|
4670
|
+
tracked.run(() => {
|
|
4671
|
+
assertId(segmentId);
|
|
4672
|
+
if (typeof text !== "string") throw new Error("Segment text is a string");
|
|
4673
|
+
const segments = segmentsOf(audioScriptId);
|
|
4674
|
+
if (segments.some((segment) => segment.id === segmentId)) throw new Error(`Segment already exists: ${segmentId}`);
|
|
4675
|
+
const at = index ?? segments.length;
|
|
4676
|
+
if (!Number.isSafeInteger(at) || at < 0 || at > segments.length) throw new Error("Invalid segment index");
|
|
4677
|
+
segments.splice(at, 0, {
|
|
4678
|
+
id: segmentId,
|
|
4679
|
+
text
|
|
4680
|
+
});
|
|
4681
|
+
});
|
|
4682
|
+
},
|
|
4683
|
+
moveSegment(audioScriptId, segmentId, index) {
|
|
4684
|
+
tracked.run(() => {
|
|
4685
|
+
const segments = segmentsOf(audioScriptId);
|
|
4686
|
+
const from = segments.findIndex((segment) => segment.id === segmentId);
|
|
4687
|
+
if (from < 0) throw new Error(`Missing segment ${segmentId} of ${audioScriptId}`);
|
|
4688
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= segments.length) throw new Error("Invalid segment index");
|
|
4689
|
+
const [segment] = segments.splice(from, 1);
|
|
4690
|
+
segments.splice(index, 0, segment);
|
|
4691
|
+
});
|
|
4692
|
+
},
|
|
4693
|
+
deleteSegment(audioScriptId, segmentId) {
|
|
4694
|
+
tracked.run(() => {
|
|
4695
|
+
const segments = segmentsOf(audioScriptId);
|
|
4696
|
+
const at = segments.findIndex((segment) => segment.id === segmentId);
|
|
4697
|
+
if (at < 0) throw new Error(`Missing segment ${segmentId} of ${audioScriptId}`);
|
|
4698
|
+
if (tracked.textPreviews.has(textKey({
|
|
4699
|
+
audioScriptId,
|
|
4700
|
+
segmentId
|
|
4701
|
+
}))) throw new Error("Cannot delete a segment whose text this transaction edits");
|
|
4702
|
+
segments.splice(at, 1);
|
|
4703
|
+
});
|
|
4467
4704
|
},
|
|
4468
4705
|
setCaptionContent(selection) {
|
|
4469
4706
|
tracked.run(() => {
|
|
4470
|
-
|
|
4471
|
-
const { captionId, audioScriptId, start, end } = selection;
|
|
4707
|
+
flushSelections();
|
|
4708
|
+
const { captionId, audioScriptId, segmentId, start, end } = selection;
|
|
4472
4709
|
assertId(captionId);
|
|
4473
|
-
|
|
4474
|
-
|
|
4710
|
+
assertCaptionSelection(raw, selection, useText({
|
|
4711
|
+
audioScriptId,
|
|
4712
|
+
segmentId
|
|
4713
|
+
}));
|
|
4475
4714
|
steps.push({
|
|
4476
4715
|
type: "range",
|
|
4477
|
-
|
|
4478
|
-
|
|
4716
|
+
table: "caption-entities",
|
|
4717
|
+
id: captionId,
|
|
4718
|
+
range: {
|
|
4479
4719
|
audioScriptId,
|
|
4720
|
+
segmentId,
|
|
4480
4721
|
start,
|
|
4481
4722
|
end
|
|
4482
4723
|
}
|
|
4483
4724
|
});
|
|
4484
4725
|
});
|
|
4726
|
+
},
|
|
4727
|
+
setPhoneme(selection) {
|
|
4728
|
+
tracked.run(() => {
|
|
4729
|
+
flushSelections();
|
|
4730
|
+
const { phoneticScriptId, audioScriptId, segmentId, start, end, phonemeScript } = selection;
|
|
4731
|
+
assertId(phoneticScriptId);
|
|
4732
|
+
assertPhonemeSelection(raw, selection, useText({
|
|
4733
|
+
audioScriptId,
|
|
4734
|
+
segmentId
|
|
4735
|
+
}));
|
|
4736
|
+
steps.push({
|
|
4737
|
+
type: "range",
|
|
4738
|
+
table: "phonetic-script-entities",
|
|
4739
|
+
id: phoneticScriptId,
|
|
4740
|
+
range: {
|
|
4741
|
+
audioScriptId,
|
|
4742
|
+
segmentId,
|
|
4743
|
+
start,
|
|
4744
|
+
end
|
|
4745
|
+
},
|
|
4746
|
+
...phonemeScript === void 0 ? {} : { phonemeScript }
|
|
4747
|
+
});
|
|
4748
|
+
});
|
|
4485
4749
|
}
|
|
4486
4750
|
});
|
|
4487
4751
|
try {
|
|
@@ -4494,32 +4758,15 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4494
4758
|
}
|
|
4495
4759
|
tracked.assertValid();
|
|
4496
4760
|
if (doc.getPendingTxnLength()) throw new Error("Native writes are not allowed during transaction preparation");
|
|
4497
|
-
|
|
4761
|
+
flushSelections();
|
|
4498
4762
|
for (const id of tracked.entities.get("caption-entities")?.keys() ?? []) {
|
|
4499
4763
|
const caption = tracked.draft["caption-entities"][id];
|
|
4500
4764
|
if (caption) caption.style ??= {};
|
|
4501
4765
|
}
|
|
4502
4766
|
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);
|
|
4767
|
+
for (const step of steps) if (step.type === "text") if ("captionId" in step.target) requireEntity(raw, "caption-entities", step.target.captionId);
|
|
4768
|
+
else requireSegment(raw, step.target.audioScriptId, step.target.segmentId);
|
|
4769
|
+
assertSelectionsAfter(raw, before, tracked, steps);
|
|
4523
4770
|
for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) {
|
|
4524
4771
|
const row = raw["caption-entities"][id];
|
|
4525
4772
|
if (fields.has("text") && row?.text !== void 0 && before["caption-entities"][id]?.text === void 0) {
|
|
@@ -4527,8 +4774,16 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4527
4774
|
delete row.text;
|
|
4528
4775
|
}
|
|
4529
4776
|
}
|
|
4530
|
-
for (const
|
|
4531
|
-
|
|
4777
|
+
for (const table of Object.keys(selectionField)) {
|
|
4778
|
+
const field = selectionField[table];
|
|
4779
|
+
for (const [id, fields] of tracked.entities.get(table) ?? []) {
|
|
4780
|
+
const row = raw[table][id];
|
|
4781
|
+
if (!row || !fields.has(field)) continue;
|
|
4782
|
+
const old = before[table][id]?.[field];
|
|
4783
|
+
if (old === void 0) delete row[field];
|
|
4784
|
+
else row[field] = old;
|
|
4785
|
+
}
|
|
4786
|
+
}
|
|
4532
4787
|
});
|
|
4533
4788
|
} finally {
|
|
4534
4789
|
tracked.close();
|
|
@@ -4537,44 +4792,104 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4537
4792
|
}, {
|
|
4538
4793
|
...options,
|
|
4539
4794
|
applyNative: (doc) => {
|
|
4540
|
-
const
|
|
4541
|
-
|
|
4795
|
+
const textFor = (target) => {
|
|
4796
|
+
const key = textKey(target);
|
|
4797
|
+
let text = bodies.get(key);
|
|
4542
4798
|
if (!text) {
|
|
4543
|
-
text = liveText(doc,
|
|
4544
|
-
bodies.set(
|
|
4799
|
+
text = liveText(doc, target);
|
|
4800
|
+
bodies.set(key, text);
|
|
4545
4801
|
}
|
|
4546
|
-
if (!text || text.isDeleted()) throw new Error("Prepared Text
|
|
4802
|
+
if (!text || text.isDeleted()) throw new Error("Prepared Text is missing");
|
|
4547
4803
|
return text;
|
|
4548
4804
|
};
|
|
4549
4805
|
for (const [id, value] of newCaptionTexts) {
|
|
4550
|
-
const
|
|
4551
|
-
if (value.length)
|
|
4552
|
-
bodies.set(id,
|
|
4806
|
+
const text = doc.getMap("caption-entities").get(id).setContainer("text", new LoroText());
|
|
4807
|
+
if (value.length) text.insert(0, value);
|
|
4808
|
+
bodies.set(textKey({ captionId: id }), text);
|
|
4553
4809
|
}
|
|
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
|
-
|
|
4810
|
+
for (const step of steps) {
|
|
4811
|
+
if (step.type === "text") {
|
|
4812
|
+
const text = textFor(step.target);
|
|
4813
|
+
if (step.expectedBodyId && text.id !== step.expectedBodyId) throw new Error("Stale Text body");
|
|
4814
|
+
applyTextEdits(text, step.edits);
|
|
4815
|
+
continue;
|
|
4816
|
+
}
|
|
4817
|
+
const row = entityRow(doc, step.table, step.id);
|
|
4818
|
+
const field = selectionField[step.table];
|
|
4819
|
+
if (step.type === "range") {
|
|
4820
|
+
const { audioScriptId, segmentId, start, end } = step.range;
|
|
4821
|
+
const value = {
|
|
4822
|
+
audioScriptId,
|
|
4823
|
+
segmentId
|
|
4824
|
+
};
|
|
4825
|
+
if (start !== void 0 && end !== void 0) {
|
|
4826
|
+
const text = textFor({
|
|
4827
|
+
audioScriptId,
|
|
4828
|
+
segmentId
|
|
4829
|
+
});
|
|
4830
|
+
value.start = text.getCursor(start, 0);
|
|
4831
|
+
value.end = text.getCursor(end, 0);
|
|
4832
|
+
if (!value.start || !value.end) throw new Error("Unable to construct prepared range");
|
|
4833
|
+
}
|
|
4834
|
+
if (step.phonemeScript !== void 0) value.phonemeScript = step.phonemeScript;
|
|
4835
|
+
if (!row) throw new Error("Unable to construct prepared range");
|
|
4836
|
+
row.set(field, codecs[step.table].encode(value));
|
|
4837
|
+
} else if (step.value !== void 0) {
|
|
4838
|
+
if (!row) throw new Error("Prepared selection owner is missing");
|
|
4839
|
+
row.set(field, codecs[step.table].encode(step.value));
|
|
4840
|
+
} else if (row?.get(field) !== void 0) row.delete(field);
|
|
4574
4841
|
}
|
|
4575
4842
|
}
|
|
4576
4843
|
});
|
|
4577
4844
|
}
|
|
4845
|
+
/**
|
|
4846
|
+
* What only the whole write can say about selections, with the native steps counted in order:
|
|
4847
|
+
* each Caption keeps one source of words, each selection's segment still exists, and every
|
|
4848
|
+
* PhoneticScript the write touched is written for a segment and stores its text as a file.
|
|
4849
|
+
*/
|
|
4850
|
+
function assertSelectionsAfter(raw, before, tracked, steps) {
|
|
4851
|
+
const selected = new Set(Object.entries(before["caption-entities"]).filter(([, caption]) => typeof caption === "object" && caption.selection !== void 0).map(([id]) => id));
|
|
4852
|
+
const changedCaptions = /* @__PURE__ */ new Set();
|
|
4853
|
+
for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (fields.has("text")) changedCaptions.add(id);
|
|
4854
|
+
const phonemes = /* @__PURE__ */ new Map();
|
|
4855
|
+
for (const step of steps) {
|
|
4856
|
+
if (step.type === "text") {
|
|
4857
|
+
if ("captionId" in step.target) changedCaptions.add(step.target.captionId);
|
|
4858
|
+
continue;
|
|
4859
|
+
}
|
|
4860
|
+
const value = step.type === "range" ? {
|
|
4861
|
+
...step.range,
|
|
4862
|
+
...step.phonemeScript === void 0 ? {} : { phonemeScript: step.phonemeScript }
|
|
4863
|
+
} : step.value;
|
|
4864
|
+
if (step.table === "caption-entities") {
|
|
4865
|
+
changedCaptions.add(step.id);
|
|
4866
|
+
if (value) selected.add(step.id);
|
|
4867
|
+
else selected.delete(step.id);
|
|
4868
|
+
if (value) assertCaptionEndpoints(raw, {
|
|
4869
|
+
captionId: step.id,
|
|
4870
|
+
...value
|
|
4871
|
+
});
|
|
4872
|
+
} else {
|
|
4873
|
+
phonemes.set(step.id, value);
|
|
4874
|
+
if (value) assertPhonemeEndpoints(raw, {
|
|
4875
|
+
phoneticScriptId: step.id,
|
|
4876
|
+
...value
|
|
4877
|
+
});
|
|
4878
|
+
}
|
|
4879
|
+
}
|
|
4880
|
+
for (const id of changedCaptions) if (raw["caption-entities"][id]) assertCaptionSource(raw["caption-entities"][id], selected.has(id));
|
|
4881
|
+
const phonetics = new Set([...tracked.entities.get("phonetic-script-entities")?.keys() ?? [], ...phonemes.keys()]);
|
|
4882
|
+
for (const id of phonetics) {
|
|
4883
|
+
const row = raw["phonetic-script-entities"][id];
|
|
4884
|
+
if (!row) continue;
|
|
4885
|
+
const phoneme = phonemes.has(id) ? phonemes.get(id) : row.phoneme;
|
|
4886
|
+
const old = before["phonetic-script-entities"][id];
|
|
4887
|
+
assertPhoneticScript(old, {
|
|
4888
|
+
...row,
|
|
4889
|
+
phoneme: isRecord(phoneme) ? phoneme : void 0
|
|
4890
|
+
});
|
|
4891
|
+
}
|
|
4892
|
+
}
|
|
4578
4893
|
//#endregion
|
|
4579
4894
|
//#region src/dsl/document/medeo-dsl.ts
|
|
4580
4895
|
const contentRoots = new Set([
|
|
@@ -4665,17 +4980,23 @@ var MedeoDsl = class MedeoDsl {
|
|
|
4665
4980
|
applyMutations(mutations, audit) {
|
|
4666
4981
|
this.transact((tx) => tx.applyMutations(mutations), audit);
|
|
4667
4982
|
}
|
|
4668
|
-
editText(scriptId, expectedBodyId, edits, audit) {
|
|
4669
|
-
this.transact((tx) => tx.editText(scriptId, edits, expectedBodyId), audit);
|
|
4983
|
+
editText(scriptId, segmentId, expectedBodyId, edits, audit) {
|
|
4984
|
+
this.transact((tx) => tx.editText(scriptId, segmentId, edits, expectedBodyId), audit);
|
|
4670
4985
|
}
|
|
4671
4986
|
editCaptionText(captionId, expectedBodyId, edits, audit) {
|
|
4672
4987
|
this.transact((tx) => tx.editCaptionText(captionId, edits, expectedBodyId), audit);
|
|
4673
4988
|
}
|
|
4674
|
-
/**
|
|
4675
|
-
|
|
4989
|
+
/**
|
|
4990
|
+
* A run inside one segment as native Cursors. Local ranges exclude insertions at their start and
|
|
4991
|
+
* include insertions at their end. K01 remains deferred.
|
|
4992
|
+
*/
|
|
4993
|
+
textRange(scriptId, segmentId, start, end) {
|
|
4676
4994
|
this.assertActive();
|
|
4677
|
-
const text = liveText(this.doc,
|
|
4678
|
-
|
|
4995
|
+
const text = liveText(this.doc, {
|
|
4996
|
+
audioScriptId: scriptId,
|
|
4997
|
+
segmentId
|
|
4998
|
+
});
|
|
4999
|
+
if (!text) throw new Error("Missing AudioScript segment");
|
|
4679
5000
|
assertTextOffset(text.toString(), start);
|
|
4680
5001
|
assertTextOffset(text.toString(), end);
|
|
4681
5002
|
if (start >= end) throw new Error("Expected a nonempty range");
|
|
@@ -4683,6 +5004,7 @@ var MedeoDsl = class MedeoDsl {
|
|
|
4683
5004
|
if (!from || !to) throw new Error("Unable to construct text range");
|
|
4684
5005
|
return {
|
|
4685
5006
|
audioScriptId: scriptId,
|
|
5007
|
+
segmentId,
|
|
4686
5008
|
start: from,
|
|
4687
5009
|
end: to
|
|
4688
5010
|
};
|
|
@@ -4744,16 +5066,30 @@ function voiceoverPlan(s, input, result) {
|
|
|
4744
5066
|
id: result.mediaId,
|
|
4745
5067
|
value: input.media
|
|
4746
5068
|
}];
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
5069
|
+
let segmentIds;
|
|
5070
|
+
if (input.script.segments !== void 0) {
|
|
5071
|
+
if (result.segmentIds.length !== input.script.segments.length) throw new Error("Expected one id per segment");
|
|
5072
|
+
const segments = input.script.segments;
|
|
5073
|
+
segmentIds = result.segmentIds;
|
|
5074
|
+
mutations.push({
|
|
5075
|
+
type: "create-entity",
|
|
5076
|
+
table: "audio-script-entities",
|
|
5077
|
+
id: result.audioScriptId,
|
|
5078
|
+
value: {
|
|
5079
|
+
segments: segments.map((text, i) => ({
|
|
5080
|
+
id: segmentIds[i],
|
|
5081
|
+
text
|
|
5082
|
+
})),
|
|
5083
|
+
..."defaultLanguage" in input.script && input.script.defaultLanguage !== void 0 ? { defaultLanguage: input.script.defaultLanguage } : {}
|
|
5084
|
+
}
|
|
5085
|
+
});
|
|
5086
|
+
} else {
|
|
5087
|
+
const script = s["audio-script-entities"][result.audioScriptId];
|
|
5088
|
+
if (!script) throw new Error("Missing AudioScript");
|
|
5089
|
+
segmentIds = script.segments.map((segment) => segment.id);
|
|
5090
|
+
}
|
|
5091
|
+
const phoneticScriptId = input.script.phoneticScriptId;
|
|
5092
|
+
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
5093
|
validateWindow({
|
|
4758
5094
|
inMs: 0,
|
|
4759
5095
|
outMs: input.media.durationMs
|
|
@@ -4763,7 +5099,8 @@ function voiceoverPlan(s, input, result) {
|
|
|
4763
5099
|
table: "derived-from-relations",
|
|
4764
5100
|
value: {
|
|
4765
5101
|
entityId: result.mediaId,
|
|
4766
|
-
sourceId: result.audioScriptId
|
|
5102
|
+
sourceId: phoneticScriptId ?? result.audioScriptId,
|
|
5103
|
+
...input.sourceDigest === void 0 ? {} : { sourceDigest: input.sourceDigest }
|
|
4767
5104
|
}
|
|
4768
5105
|
});
|
|
4769
5106
|
if (input.voice) {
|
|
@@ -4793,6 +5130,7 @@ function voiceoverPlan(s, input, result) {
|
|
|
4793
5130
|
rate: void 0,
|
|
4794
5131
|
fillTimeline: void 0
|
|
4795
5132
|
},
|
|
5133
|
+
source: result.mediaId,
|
|
4796
5134
|
...input.volume === void 0 ? {} : { volume: input.volume }
|
|
4797
5135
|
}
|
|
4798
5136
|
} : {
|
|
@@ -4804,15 +5142,9 @@ function voiceoverPlan(s, input, result) {
|
|
|
4804
5142
|
inMs: 0,
|
|
4805
5143
|
outMs: input.media.durationMs
|
|
4806
5144
|
},
|
|
5145
|
+
source: result.mediaId,
|
|
4807
5146
|
...input.volume === void 0 ? {} : { volume: input.volume }
|
|
4808
5147
|
}
|
|
4809
|
-
}, {
|
|
4810
|
-
type: "set-relation",
|
|
4811
|
-
table: "clip-source-relations",
|
|
4812
|
-
value: {
|
|
4813
|
-
clipId: result.clipId,
|
|
4814
|
-
sourceId: result.mediaId
|
|
4815
|
-
}
|
|
4816
5148
|
});
|
|
4817
5149
|
if (!existing || trackOf(s, result.clipId) !== input.trackId) mutations.push({
|
|
4818
5150
|
type: existing ? "move-node" : "insert-node",
|
|
@@ -4822,13 +5154,14 @@ function voiceoverPlan(s, input, result) {
|
|
|
4822
5154
|
mutations.push(...placementMutations(s, result.clipId, input.placement));
|
|
4823
5155
|
uniqueIds(result.captionClipIds);
|
|
4824
5156
|
const retained = new Set(result.captionClipIds);
|
|
4825
|
-
const oldCaptions =
|
|
5157
|
+
const oldCaptions = anchoredTo(s, new Set([result.clipId])).filter((id) => requireTrack(s, trackOf(s, id)).role === "caption");
|
|
4826
5158
|
mutations.push(...deleteClips(s, oldCaptions.filter((id) => !retained.has(id)), "cascade"));
|
|
4827
|
-
const
|
|
5159
|
+
const placement = mutations.find((m) => m.type === "patch-entity" && m.table === "clip-entities" && m.id === result.clipId && "anchor" in m.patch);
|
|
5160
|
+
const anchor = placement?.type === "patch-entity" && placement.table === "clip-entities" ? placement.patch.anchor : void 0;
|
|
4828
5161
|
const trackClips = resolveDslLayout(s).tracks.find((track) => track.trackId === input.trackId).clips;
|
|
4829
5162
|
const slot = trackClips.findIndex((clip) => clip.clipId === result.clipId);
|
|
4830
5163
|
const predecessor = trackClips.slice(0, slot < 0 ? trackClips.length : slot).findLast((clip) => clip.mode === "sequential" && !clip.fillTimeline);
|
|
4831
|
-
const speechStart = anchor?.
|
|
5164
|
+
const speechStart = anchor?.fallback_abs_ms !== void 0 ? anchor.fallback_abs_ms : predecessor ? predecessor.startMs + predecessor.durationMs : 0;
|
|
4832
5165
|
input.captions.forEach((caption, i) => {
|
|
4833
5166
|
const clipId = result.captionClipIds[i], captionId = result.captionIds[i];
|
|
4834
5167
|
validateWindow({
|
|
@@ -4837,7 +5170,9 @@ function voiceoverPlan(s, input, result) {
|
|
|
4837
5170
|
}, input.media.durationMs);
|
|
4838
5171
|
const previous = s["clip-entities"][clipId];
|
|
4839
5172
|
if (previous && !oldCaptions.includes(clipId)) throw new Error("Cannot overwrite another use’s Caption Clip");
|
|
4840
|
-
const
|
|
5173
|
+
const segmentId = segmentIds[caption.segment];
|
|
5174
|
+
if (segmentId === void 0) throw new Error(`Missing segment ${caption.segment} of the voiceover's script`);
|
|
5175
|
+
const previousSource = previous?.source;
|
|
4841
5176
|
const style = caption.style ?? (previousSource ? s["caption-entities"][previousSource]?.style : void 0);
|
|
4842
5177
|
mutations.push({
|
|
4843
5178
|
type: "create-entity",
|
|
@@ -4848,48 +5183,38 @@ function voiceoverPlan(s, input, result) {
|
|
|
4848
5183
|
type: "set-caption-content",
|
|
4849
5184
|
captionId,
|
|
4850
5185
|
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
|
|
5186
|
+
segmentId,
|
|
5187
|
+
...caption.range === void 0 ? {} : {
|
|
5188
|
+
start: caption.range.start,
|
|
5189
|
+
end: caption.range.end
|
|
4861
5190
|
}
|
|
4862
|
-
}
|
|
5191
|
+
});
|
|
5192
|
+
const anchor = {
|
|
5193
|
+
to: result.clipId,
|
|
5194
|
+
offsetMs: caption.startMs,
|
|
5195
|
+
fallback_abs_ms: speechStart + caption.startMs
|
|
5196
|
+
};
|
|
5197
|
+
const seqMarker = {
|
|
5198
|
+
inMs: 0,
|
|
5199
|
+
outMs: caption.durationMs
|
|
5200
|
+
};
|
|
5201
|
+
mutations.push(previous ? {
|
|
4863
5202
|
type: "patch-entity",
|
|
4864
5203
|
table: "clip-entities",
|
|
4865
5204
|
id: clipId,
|
|
4866
|
-
patch: {
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
|
|
5205
|
+
patch: {
|
|
5206
|
+
seqMarker,
|
|
5207
|
+
source: captionId,
|
|
5208
|
+
anchor
|
|
5209
|
+
}
|
|
4870
5210
|
} : {
|
|
4871
5211
|
type: "create-entity",
|
|
4872
5212
|
table: "clip-entities",
|
|
4873
5213
|
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
5214
|
value: {
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
fallback_abs_ms: speechStart + caption.startMs
|
|
5215
|
+
seqMarker,
|
|
5216
|
+
source: captionId,
|
|
5217
|
+
anchor
|
|
4893
5218
|
}
|
|
4894
5219
|
});
|
|
4895
5220
|
if (!previous || trackOf(s, clipId) !== input.captionTrackId) mutations.push({
|
|
@@ -4906,9 +5231,11 @@ function voiceoverPlan(s, input, result) {
|
|
|
4906
5231
|
var DslEditor = class {
|
|
4907
5232
|
dsl;
|
|
4908
5233
|
idFactory;
|
|
4909
|
-
|
|
5234
|
+
uploadText;
|
|
5235
|
+
constructor(dsl, idFactory = defaultIdFactory, uploadText) {
|
|
4910
5236
|
this.dsl = dsl;
|
|
4911
5237
|
this.idFactory = idFactory;
|
|
5238
|
+
this.uploadText = uploadText;
|
|
4912
5239
|
}
|
|
4913
5240
|
createTrack(role, options) {
|
|
4914
5241
|
const id = this.idFactory("track");
|
|
@@ -4950,11 +5277,10 @@ var DslEditor = class {
|
|
|
4950
5277
|
/** index is measured in the target's children after removing the moved Clip. */
|
|
4951
5278
|
moveClip(clipId, trackId, index, options) {
|
|
4952
5279
|
const snapshot = this.dsl.getSnapshot();
|
|
4953
|
-
requireClip(snapshot, clipId);
|
|
5280
|
+
const clip = requireClip(snapshot, clipId);
|
|
4954
5281
|
const track = snapshot["track-entities"][trackId];
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
requireSource(snapshot, source.sourceId, track.role);
|
|
5282
|
+
if (!track) throw new Error("Missing target Track or Clip source");
|
|
5283
|
+
requireSource(snapshot, clip.source, track.role);
|
|
4958
5284
|
this.write("moveClip", {
|
|
4959
5285
|
clipId,
|
|
4960
5286
|
trackId,
|
|
@@ -4969,8 +5295,7 @@ var DslEditor = class {
|
|
|
4969
5295
|
trimClip(clipId, window, options) {
|
|
4970
5296
|
const snapshot = this.dsl.getSnapshot(), clip = requireClip(snapshot, clipId);
|
|
4971
5297
|
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;
|
|
5298
|
+
const source = snapshot["media-entities"][clip.source] ?? snapshot["caption-entities"][clip.source];
|
|
4974
5299
|
if (!source) throw new Error("Cannot trim a Clip with a missing source");
|
|
4975
5300
|
const patch = {};
|
|
4976
5301
|
if (window.inMs !== void 0) patch.inMs = window.inMs;
|
|
@@ -5013,30 +5338,76 @@ var DslEditor = class {
|
|
|
5013
5338
|
patch: { volume }
|
|
5014
5339
|
}], options);
|
|
5015
5340
|
}
|
|
5016
|
-
|
|
5341
|
+
/** A script of these segments, in order; each segment gets its own id. */
|
|
5342
|
+
createAudioScript(segments, defaultLanguage, options) {
|
|
5017
5343
|
const id = this.idFactory("audio-script");
|
|
5344
|
+
const value = {
|
|
5345
|
+
segments: segments.map((text) => ({
|
|
5346
|
+
id: this.idFactory("segment"),
|
|
5347
|
+
text
|
|
5348
|
+
})),
|
|
5349
|
+
...defaultLanguage === void 0 ? {} : { defaultLanguage }
|
|
5350
|
+
};
|
|
5018
5351
|
this.write("createAudioScript", {
|
|
5019
5352
|
id,
|
|
5020
|
-
|
|
5021
|
-
defaultLanguage
|
|
5353
|
+
...value
|
|
5022
5354
|
}, [{
|
|
5023
5355
|
type: "create-entity",
|
|
5024
5356
|
table: "audio-script-entities",
|
|
5025
5357
|
id,
|
|
5026
|
-
value
|
|
5027
|
-
body,
|
|
5028
|
-
...defaultLanguage === void 0 ? {} : { defaultLanguage }
|
|
5029
|
-
}
|
|
5358
|
+
value
|
|
5030
5359
|
}], options);
|
|
5031
5360
|
return id;
|
|
5032
5361
|
}
|
|
5033
|
-
editAudioScript(scriptId, expectedBodyId, edits, options) {
|
|
5034
|
-
this.dsl.editText(scriptId, expectedBodyId, edits, audit("editAudioScript", {
|
|
5362
|
+
editAudioScript(scriptId, segmentId, expectedBodyId, edits, options) {
|
|
5363
|
+
this.dsl.editText(scriptId, segmentId, expectedBodyId, edits, audit("editAudioScript", {
|
|
5035
5364
|
scriptId,
|
|
5365
|
+
segmentId,
|
|
5036
5366
|
expectedBodyId,
|
|
5037
5367
|
edits
|
|
5038
5368
|
}, options));
|
|
5039
5369
|
}
|
|
5370
|
+
/** Adds a segment at `index`, the end by default, and returns its id. */
|
|
5371
|
+
insertScriptSegment(scriptId, text, index, options) {
|
|
5372
|
+
const segmentId = this.idFactory("segment");
|
|
5373
|
+
this.write("insertScriptSegment", {
|
|
5374
|
+
scriptId,
|
|
5375
|
+
segmentId,
|
|
5376
|
+
text,
|
|
5377
|
+
index
|
|
5378
|
+
}, [{
|
|
5379
|
+
type: "insert-segment",
|
|
5380
|
+
audioScriptId: scriptId,
|
|
5381
|
+
segmentId,
|
|
5382
|
+
text,
|
|
5383
|
+
...index === void 0 ? {} : { index }
|
|
5384
|
+
}], options);
|
|
5385
|
+
return segmentId;
|
|
5386
|
+
}
|
|
5387
|
+
/** `index` is measured after removing the segment. */
|
|
5388
|
+
moveScriptSegment(scriptId, segmentId, index, options) {
|
|
5389
|
+
this.write("moveScriptSegment", {
|
|
5390
|
+
scriptId,
|
|
5391
|
+
segmentId,
|
|
5392
|
+
index
|
|
5393
|
+
}, [{
|
|
5394
|
+
type: "move-segment",
|
|
5395
|
+
audioScriptId: scriptId,
|
|
5396
|
+
segmentId,
|
|
5397
|
+
index
|
|
5398
|
+
}], options);
|
|
5399
|
+
}
|
|
5400
|
+
/** Captions and PhoneticScripts selecting the segment keep their selection, which then shows nothing (D72). */
|
|
5401
|
+
deleteScriptSegment(scriptId, segmentId, options) {
|
|
5402
|
+
this.write("deleteScriptSegment", {
|
|
5403
|
+
scriptId,
|
|
5404
|
+
segmentId
|
|
5405
|
+
}, [{
|
|
5406
|
+
type: "delete-segment",
|
|
5407
|
+
audioScriptId: scriptId,
|
|
5408
|
+
segmentId
|
|
5409
|
+
}], options);
|
|
5410
|
+
}
|
|
5040
5411
|
createCaption(input, options) {
|
|
5041
5412
|
const id = this.idFactory("caption");
|
|
5042
5413
|
this.write("createCaption", {
|
|
@@ -5053,18 +5424,16 @@ var DslEditor = class {
|
|
|
5053
5424
|
}, ...input.text !== void 0 ? [] : [{
|
|
5054
5425
|
type: "set-caption-content",
|
|
5055
5426
|
captionId: id,
|
|
5056
|
-
|
|
5057
|
-
start: input.start,
|
|
5058
|
-
end: input.end
|
|
5427
|
+
...segmentRange(input)
|
|
5059
5428
|
}]], options);
|
|
5060
5429
|
return id;
|
|
5061
5430
|
}
|
|
5062
|
-
|
|
5431
|
+
/** Show a segment of a script, or a run of it, in place of the Caption's own text. */
|
|
5432
|
+
setCaptionContent(captionId, selection, options) {
|
|
5433
|
+
const range = segmentRange(selection);
|
|
5063
5434
|
this.write("setCaptionContent", {
|
|
5064
5435
|
captionId,
|
|
5065
|
-
|
|
5066
|
-
start,
|
|
5067
|
-
end
|
|
5436
|
+
...range
|
|
5068
5437
|
}, [{
|
|
5069
5438
|
type: "patch-entity",
|
|
5070
5439
|
table: "caption-entities",
|
|
@@ -5073,9 +5442,7 @@ var DslEditor = class {
|
|
|
5073
5442
|
}, {
|
|
5074
5443
|
type: "set-caption-content",
|
|
5075
5444
|
captionId,
|
|
5076
|
-
|
|
5077
|
-
start,
|
|
5078
|
-
end
|
|
5445
|
+
...range
|
|
5079
5446
|
}], options);
|
|
5080
5447
|
}
|
|
5081
5448
|
setCaptionText(captionId, text, options) {
|
|
@@ -5083,9 +5450,10 @@ var DslEditor = class {
|
|
|
5083
5450
|
if (!caption) throw new Error("Missing Caption");
|
|
5084
5451
|
this.dsl.transact((tx) => {
|
|
5085
5452
|
tx.applyMutations([{
|
|
5086
|
-
type: "
|
|
5087
|
-
table: "caption-
|
|
5088
|
-
id:
|
|
5453
|
+
type: "patch-entity",
|
|
5454
|
+
table: "caption-entities",
|
|
5455
|
+
id: captionId,
|
|
5456
|
+
patch: { selection: void 0 }
|
|
5089
5457
|
}]);
|
|
5090
5458
|
if (caption.text === void 0) tx.draft["caption-entities"][captionId].text = text;
|
|
5091
5459
|
else if (caption.text !== text) tx.editCaptionText(captionId, [...caption.text.length ? [{
|
|
@@ -5199,7 +5567,8 @@ var DslEditor = class {
|
|
|
5199
5567
|
const result = {
|
|
5200
5568
|
clipId: input.clipId ?? this.idFactory("clip"),
|
|
5201
5569
|
mediaId: resourceId("media", input.media.system, input.media.key),
|
|
5202
|
-
audioScriptId:
|
|
5570
|
+
audioScriptId: this.voiceoverScriptId(input.script),
|
|
5571
|
+
segmentIds: input.script.segments?.map(() => this.idFactory("segment")) ?? [],
|
|
5203
5572
|
captionClipIds: input.captions.map((c) => c.clipId ?? this.idFactory("clip")),
|
|
5204
5573
|
captionIds: input.captions.map(() => this.idFactory("caption"))
|
|
5205
5574
|
};
|
|
@@ -5209,6 +5578,68 @@ var DslEditor = class {
|
|
|
5209
5578
|
}, voiceoverPlan(this.dsl.getSnapshot(), input, result), options);
|
|
5210
5579
|
return result;
|
|
5211
5580
|
}
|
|
5581
|
+
voiceoverScriptId(script) {
|
|
5582
|
+
if (script.audioScriptId !== void 0) return script.audioScriptId;
|
|
5583
|
+
if (script.phoneticScriptId === void 0) return this.idFactory("audio-script");
|
|
5584
|
+
const phonetic = this.dsl.getSnapshot()["phonetic-script-entities"][script.phoneticScriptId];
|
|
5585
|
+
if (!phonetic) throw new Error("Missing PhoneticScript");
|
|
5586
|
+
return phonetic.phoneme.audioScriptId;
|
|
5587
|
+
}
|
|
5588
|
+
/**
|
|
5589
|
+
* Write a PhoneticScript: create one for a segment of a script, or a run of it, or, given
|
|
5590
|
+
* `phoneticScriptId`, replace its selection and what to synthesize together (D181). A
|
|
5591
|
+
* `phonemeScript` is stored as a Memota text file first, and the file's facts are written with
|
|
5592
|
+
* it; an unchanged one keeps its file. Recordings already spoken from it keep their derived-from.
|
|
5593
|
+
*/
|
|
5594
|
+
async setPhoneticScript(input, options) {
|
|
5595
|
+
const id = input.phoneticScriptId ?? this.idFactory("phonetic-script");
|
|
5596
|
+
const range = segmentRange(input);
|
|
5597
|
+
const file = input.phonemeScript === void 0 ? {} : await this.fileFor(id, input.phonemeScript);
|
|
5598
|
+
const exists = Object.hasOwn(this.dsl.getSnapshot()["phonetic-script-entities"], id);
|
|
5599
|
+
const cleared = {
|
|
5600
|
+
system: void 0,
|
|
5601
|
+
key: void 0,
|
|
5602
|
+
storageKey: void 0,
|
|
5603
|
+
digest: void 0
|
|
5604
|
+
};
|
|
5605
|
+
const phonemeScript = input.phonemeScript === void 0 ? {} : { phonemeScript: input.phonemeScript };
|
|
5606
|
+
this.write("setPhoneticScript", {
|
|
5607
|
+
id,
|
|
5608
|
+
...range,
|
|
5609
|
+
...phonemeScript,
|
|
5610
|
+
...cleared,
|
|
5611
|
+
...file
|
|
5612
|
+
}, [exists ? {
|
|
5613
|
+
type: "patch-entity",
|
|
5614
|
+
table: "phonetic-script-entities",
|
|
5615
|
+
id,
|
|
5616
|
+
patch: {
|
|
5617
|
+
...cleared,
|
|
5618
|
+
...file
|
|
5619
|
+
}
|
|
5620
|
+
} : {
|
|
5621
|
+
type: "create-entity",
|
|
5622
|
+
table: "phonetic-script-entities",
|
|
5623
|
+
id,
|
|
5624
|
+
value: file
|
|
5625
|
+
}, {
|
|
5626
|
+
type: "set-phoneme",
|
|
5627
|
+
phoneticScriptId: id,
|
|
5628
|
+
...range,
|
|
5629
|
+
...phonemeScript
|
|
5630
|
+
}], options);
|
|
5631
|
+
return id;
|
|
5632
|
+
}
|
|
5633
|
+
/** The file holding `text`: the one the PhoneticScript keeps while its text is unchanged, else a new upload. */
|
|
5634
|
+
async fileFor(id, text) {
|
|
5635
|
+
const current = this.dsl.getSnapshot()["phonetic-script-entities"][id];
|
|
5636
|
+
if (current?.phoneme.phonemeScript === text) {
|
|
5637
|
+
const { phoneme: _, ...file } = current;
|
|
5638
|
+
return file;
|
|
5639
|
+
}
|
|
5640
|
+
if (!this.uploadText) throw new Error("This editor stores no files");
|
|
5641
|
+
return this.uploadText(text);
|
|
5642
|
+
}
|
|
5212
5643
|
setBgm(input, options) {
|
|
5213
5644
|
const s = this.dsl.getSnapshot();
|
|
5214
5645
|
if (requireTrack(s, input.trackId).role !== "bgm") throw new Error("Expected a BGM Track");
|
|
@@ -5227,37 +5658,28 @@ var DslEditor = class {
|
|
|
5227
5658
|
fillTimeline: true
|
|
5228
5659
|
};
|
|
5229
5660
|
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
|
-
}
|
|
5661
|
+
const mutations = [...source.mutations, old ? {
|
|
5662
|
+
type: "patch-entity",
|
|
5663
|
+
table: "clip-entities",
|
|
5664
|
+
id,
|
|
5665
|
+
patch: {
|
|
5666
|
+
seqMarker: {
|
|
5667
|
+
...seqMarker,
|
|
5668
|
+
rate: void 0
|
|
5669
|
+
},
|
|
5670
|
+
source: source.id,
|
|
5671
|
+
...volume
|
|
5259
5672
|
}
|
|
5260
|
-
|
|
5673
|
+
} : {
|
|
5674
|
+
type: "create-entity",
|
|
5675
|
+
table: "clip-entities",
|
|
5676
|
+
id,
|
|
5677
|
+
value: {
|
|
5678
|
+
seqMarker,
|
|
5679
|
+
source: source.id,
|
|
5680
|
+
...volume
|
|
5681
|
+
}
|
|
5682
|
+
}];
|
|
5261
5683
|
if (!old) mutations.push({
|
|
5262
5684
|
type: "insert-node",
|
|
5263
5685
|
entityId: id,
|
|
@@ -5273,6 +5695,15 @@ var DslEditor = class {
|
|
|
5273
5695
|
this.dsl.applyMutations(mutations, audit(action, payload, options));
|
|
5274
5696
|
}
|
|
5275
5697
|
};
|
|
5698
|
+
/** A selection as a write states it: a run only when both its ends are given. */
|
|
5699
|
+
function segmentRange(input) {
|
|
5700
|
+
return {
|
|
5701
|
+
audioScriptId: input.audioScriptId,
|
|
5702
|
+
segmentId: input.segmentId,
|
|
5703
|
+
...input.start === void 0 ? {} : { start: input.start },
|
|
5704
|
+
...input.end === void 0 ? {} : { end: input.end }
|
|
5705
|
+
};
|
|
5706
|
+
}
|
|
5276
5707
|
function audit(action, payload, options) {
|
|
5277
5708
|
return {
|
|
5278
5709
|
semantic_op: action,
|
|
@@ -5312,6 +5743,132 @@ function createInitialMedeoDsl(options = {}) {
|
|
|
5312
5743
|
}
|
|
5313
5744
|
}
|
|
5314
5745
|
//#endregion
|
|
5746
|
+
//#region src/dsl/projection/audio-scripts.ts
|
|
5747
|
+
/**
|
|
5748
|
+
* The scripts a recording says. It follows derived-from from the recording; a PhoneticScript is a
|
|
5749
|
+
* variant, so its words are the base its phoneme names, and derived-from is not followed past it.
|
|
5750
|
+
* When nothing is found that way, a script read off the recording itself is what it says.
|
|
5751
|
+
*/
|
|
5752
|
+
function spokenScripts(snapshot, recordingId) {
|
|
5753
|
+
const relations = Object.values(snapshot["derived-from-relations"]);
|
|
5754
|
+
const found = /* @__PURE__ */ new Map();
|
|
5755
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5756
|
+
const visit = (id, phoneticScriptId) => {
|
|
5757
|
+
if (seen.has(id)) return;
|
|
5758
|
+
seen.add(id);
|
|
5759
|
+
if (snapshot["audio-script-entities"][id]) {
|
|
5760
|
+
if (!found.has(id)) found.set(id, phoneticScriptId);
|
|
5761
|
+
return;
|
|
5762
|
+
}
|
|
5763
|
+
const phonetic = snapshot["phonetic-script-entities"][id];
|
|
5764
|
+
if (phonetic) {
|
|
5765
|
+
visit(phonetic.phoneme.audioScriptId, id);
|
|
5766
|
+
return;
|
|
5767
|
+
}
|
|
5768
|
+
for (const r of relations) if (r.entityId === id) visit(r.sourceId, phoneticScriptId);
|
|
5769
|
+
};
|
|
5770
|
+
visit(recordingId);
|
|
5771
|
+
if (!found.size) {
|
|
5772
|
+
for (const r of relations) if (r.sourceId === recordingId && snapshot["audio-script-entities"][r.entityId]) found.set(r.entityId, void 0);
|
|
5773
|
+
}
|
|
5774
|
+
return [...found].map(([audioScriptId, phoneticScriptId]) => phoneticScriptId === void 0 ? { audioScriptId } : {
|
|
5775
|
+
audioScriptId,
|
|
5776
|
+
phoneticScriptId
|
|
5777
|
+
});
|
|
5778
|
+
}
|
|
5779
|
+
/** Every AudioScript in the document with its PhoneticScripts, the Speech Clips saying it and the Caption Clips showing it. */
|
|
5780
|
+
function toAudioScriptViews(snapshot) {
|
|
5781
|
+
const views = /* @__PURE__ */ new Map();
|
|
5782
|
+
const segmentIndex = (scriptId, segmentId) => {
|
|
5783
|
+
const index = snapshot["audio-script-entities"][scriptId]?.segments.findIndex((s) => s.id === segmentId) ?? -1;
|
|
5784
|
+
return index < 0 ? void 0 : index;
|
|
5785
|
+
};
|
|
5786
|
+
for (const [id, script] of Object.entries(snapshot["audio-script-entities"])) views.set(id, {
|
|
5787
|
+
id,
|
|
5788
|
+
segments: script.segments.map((segment) => {
|
|
5789
|
+
const rich = snapshot.richText[segment.id];
|
|
5790
|
+
const language = rich ? markedLanguage(langsFromDelta(rich.delta)) : void 0;
|
|
5791
|
+
return language === void 0 ? {
|
|
5792
|
+
id: segment.id,
|
|
5793
|
+
text: segment.text
|
|
5794
|
+
} : {
|
|
5795
|
+
id: segment.id,
|
|
5796
|
+
text: segment.text,
|
|
5797
|
+
language
|
|
5798
|
+
};
|
|
5799
|
+
}),
|
|
5800
|
+
sourceMediaIds: [],
|
|
5801
|
+
phoneticScripts: [],
|
|
5802
|
+
speeches: [],
|
|
5803
|
+
captions: []
|
|
5804
|
+
});
|
|
5805
|
+
for (const r of Object.values(snapshot["derived-from-relations"])) if (snapshot["media-entities"][r.sourceId]) views.get(r.entityId)?.sourceMediaIds.push(r.sourceId);
|
|
5806
|
+
for (const [id, phonetic] of Object.entries(snapshot["phonetic-script-entities"])) {
|
|
5807
|
+
const { audioScriptId, segmentId, phonemeScript } = phonetic.phoneme;
|
|
5808
|
+
const { digest } = phonetic;
|
|
5809
|
+
const segment = segmentIndex(audioScriptId, segmentId);
|
|
5810
|
+
const text = snapshot.phoneticText[id]?.text;
|
|
5811
|
+
views.get(audioScriptId)?.phoneticScripts.push({
|
|
5812
|
+
id,
|
|
5813
|
+
...segment === void 0 ? {} : { segment },
|
|
5814
|
+
...text === void 0 ? {} : { text },
|
|
5815
|
+
...phonemeScript === void 0 ? {} : { phonemeScript },
|
|
5816
|
+
...digest === void 0 ? {} : { digest }
|
|
5817
|
+
});
|
|
5818
|
+
}
|
|
5819
|
+
const timbres = Object.values(snapshot["voice-timbre-relations"]);
|
|
5820
|
+
for (const track of resolveDslLayout(snapshot).tracks) for (const { clipId } of track.clips) {
|
|
5821
|
+
const source = snapshot["clip-entities"][clipId]?.source;
|
|
5822
|
+
if (!source) continue;
|
|
5823
|
+
if (snapshot["media-entities"][source]?.kind === "voiceover-recording") {
|
|
5824
|
+
const voices = timbres.filter((r) => r.recordingId === source).flatMap((r) => {
|
|
5825
|
+
const voice = snapshot["voice-entities"][r.voiceId];
|
|
5826
|
+
if (!voice) return [];
|
|
5827
|
+
return [voice.name === void 0 ? {
|
|
5828
|
+
voiceId: r.voiceId,
|
|
5829
|
+
key: voice.key
|
|
5830
|
+
} : {
|
|
5831
|
+
voiceId: r.voiceId,
|
|
5832
|
+
key: voice.key,
|
|
5833
|
+
name: voice.name
|
|
5834
|
+
}];
|
|
5835
|
+
});
|
|
5836
|
+
for (const spoken of spokenScripts(snapshot, source)) {
|
|
5837
|
+
const applied = appliedSpeech(snapshot, source, spoken);
|
|
5838
|
+
views.get(spoken.audioScriptId)?.speeches.push({
|
|
5839
|
+
clipId,
|
|
5840
|
+
recordingId: source,
|
|
5841
|
+
...spoken.phoneticScriptId === void 0 ? {} : { phoneticScriptId: spoken.phoneticScriptId },
|
|
5842
|
+
voices,
|
|
5843
|
+
...applied === void 0 ? {} : { applied }
|
|
5844
|
+
});
|
|
5845
|
+
}
|
|
5846
|
+
continue;
|
|
5847
|
+
}
|
|
5848
|
+
const caption = snapshot["caption-entities"][source];
|
|
5849
|
+
if (!caption?.selection || caption.text !== void 0 || !snapshot.captionText[source]) continue;
|
|
5850
|
+
const { audioScriptId, segmentId } = caption.selection;
|
|
5851
|
+
const segment = segmentIndex(audioScriptId, segmentId);
|
|
5852
|
+
if (segment !== void 0) views.get(audioScriptId)?.captions.push({
|
|
5853
|
+
clipId,
|
|
5854
|
+
captionId: source,
|
|
5855
|
+
segment
|
|
5856
|
+
});
|
|
5857
|
+
}
|
|
5858
|
+
return [...views.values()];
|
|
5859
|
+
}
|
|
5860
|
+
/**
|
|
5861
|
+
* Whether a recording still matches what it was synthesized from: the digest its derived-from
|
|
5862
|
+
* records against the source's file digest now. An AudioScript has no file, so a recording
|
|
5863
|
+
* derived from a script directly is not judged.
|
|
5864
|
+
*/
|
|
5865
|
+
function appliedSpeech(snapshot, recordingId, spoken) {
|
|
5866
|
+
const sourceId = spoken.phoneticScriptId ?? spoken.audioScriptId;
|
|
5867
|
+
const recorded = Object.values(snapshot["derived-from-relations"]).find((r) => r.entityId === recordingId && r.sourceId === sourceId)?.sourceDigest;
|
|
5868
|
+
const current = spoken.phoneticScriptId === void 0 ? void 0 : snapshot["phonetic-script-entities"][sourceId]?.digest;
|
|
5869
|
+
return recorded === void 0 || current === void 0 ? void 0 : recorded === current;
|
|
5870
|
+
}
|
|
5871
|
+
//#endregion
|
|
5315
5872
|
//#region src/dsl/projection/caption-style.ts
|
|
5316
5873
|
const fields = {
|
|
5317
5874
|
fontSize: "font_size",
|
|
@@ -5485,25 +6042,18 @@ function toVideoDraft(snapshot) {
|
|
|
5485
6042
|
function requireWholeAudio(media, clip, fail) {
|
|
5486
6043
|
if (media.kind === "image" || clip.inMs !== 0 || clip.outMs !== media.durationMs || clip.rate !== 1) fail("VideoDraft audio has no trim or speed fields");
|
|
5487
6044
|
}
|
|
6045
|
+
/**
|
|
6046
|
+
* What a recording says: what the selection of the PhoneticScript it was spoken from holds now,
|
|
6047
|
+
* or, for one derived from a script directly, the whole script, one segment per line.
|
|
6048
|
+
*/
|
|
5488
6049
|
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 ?? "" : "";
|
|
6050
|
+
const spoken = spokenScripts(snapshot, recordingId);
|
|
6051
|
+
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");
|
|
6052
|
+
const [first] = spoken;
|
|
6053
|
+
if (!first) return "";
|
|
6054
|
+
if (first.phoneticScriptId !== void 0) return snapshot.phoneticText[first.phoneticScriptId]?.text ?? "";
|
|
6055
|
+
const script = snapshot["audio-script-entities"][first.audioScriptId];
|
|
6056
|
+
return script ? script.segments.map((segment) => segment.text).join("\n") : "";
|
|
5507
6057
|
}
|
|
5508
6058
|
//#endregion
|
|
5509
6059
|
//#region src/dsl/projection/from-video-draft.ts
|
|
@@ -5645,16 +6195,16 @@ function prepare(input, options) {
|
|
|
5645
6195
|
let script = scriptByRecording.get(recordingId);
|
|
5646
6196
|
if (script && script.text !== speech.audio_script) fail(`part_library/${id}/audio_script`, "One recording cannot acquire conflicting source scripts during import");
|
|
5647
6197
|
if (!script) {
|
|
5648
|
-
script =
|
|
5649
|
-
id: claim(mint("audio-script")),
|
|
5650
|
-
text: speech.audio_script
|
|
5651
|
-
};
|
|
6198
|
+
script = importedScript(claim(mint("audio-script")), speech.audio_script, () => mint("segment"));
|
|
5652
6199
|
scriptByRecording.set(recordingId, script);
|
|
5653
6200
|
mutations.push({
|
|
5654
6201
|
type: "create-entity",
|
|
5655
6202
|
table: "audio-script-entities",
|
|
5656
6203
|
id: script.id,
|
|
5657
|
-
value: {
|
|
6204
|
+
value: { segments: script.segments.map(({ id, text }) => ({
|
|
6205
|
+
id,
|
|
6206
|
+
text
|
|
6207
|
+
})) }
|
|
5658
6208
|
}, {
|
|
5659
6209
|
type: "set-relation",
|
|
5660
6210
|
table: "derived-from-relations",
|
|
@@ -5702,12 +6252,18 @@ function prepare(input, options) {
|
|
|
5702
6252
|
const text = caption.text;
|
|
5703
6253
|
if (typeof text !== "string") fail(`part_library/${captionId}/text`, "Expected Caption text");
|
|
5704
6254
|
const start = text.length ? script.text.indexOf(text, cursor) : -1;
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
6255
|
+
const end = start + text.length;
|
|
6256
|
+
const segment = script.segments.find((s) => start >= s.start && end <= s.start + s.text.length);
|
|
6257
|
+
if (start >= 0 && segment) {
|
|
6258
|
+
cursor = end;
|
|
6259
|
+
selections.set(captionId, text === segment.text ? {
|
|
5708
6260
|
audioScriptId: script.id,
|
|
5709
|
-
|
|
5710
|
-
|
|
6261
|
+
segmentId: segment.id
|
|
6262
|
+
} : {
|
|
6263
|
+
audioScriptId: script.id,
|
|
6264
|
+
segmentId: segment.id,
|
|
6265
|
+
start: start - segment.start,
|
|
6266
|
+
end: end - segment.start
|
|
5711
6267
|
});
|
|
5712
6268
|
} else diagnostics.push({
|
|
5713
6269
|
code: "caption-text-unmatched",
|
|
@@ -5770,28 +6326,19 @@ function prepare(input, options) {
|
|
|
5770
6326
|
captionId: sourceId,
|
|
5771
6327
|
...selected
|
|
5772
6328
|
});
|
|
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
6329
|
}
|
|
5794
6330
|
if (outMs <= inMs || !sourceId) fail(`part_library/${id}`, "Expected a nonempty source window");
|
|
6331
|
+
let anchor;
|
|
6332
|
+
if (placement.role !== "video_clip") {
|
|
6333
|
+
const host = hosts.get(id);
|
|
6334
|
+
const speechId = part.caption?.speech_part_id;
|
|
6335
|
+
const offsetMs = host?.offset ?? (speechId ? milliseconds(part.caption?.start_ms ?? 0, `part_library/${id}/start_ms`) : placement.abs);
|
|
6336
|
+
anchor = {
|
|
6337
|
+
to: host?.to ?? speechId ?? timelineId,
|
|
6338
|
+
offsetMs,
|
|
6339
|
+
fallback_abs_ms: placement.abs
|
|
6340
|
+
};
|
|
6341
|
+
}
|
|
5795
6342
|
mutations.push({
|
|
5796
6343
|
type: "create-entity",
|
|
5797
6344
|
table: "clip-entities",
|
|
@@ -5803,36 +6350,15 @@ function prepare(input, options) {
|
|
|
5803
6350
|
...speed ? { rate: speed.rate } : {},
|
|
5804
6351
|
...part.bgm ? { fillTimeline: true } : {}
|
|
5805
6352
|
},
|
|
6353
|
+
source: sourceId,
|
|
6354
|
+
...anchor === void 0 ? {} : { anchor },
|
|
5806
6355
|
...volume === void 0 ? {} : { volume }
|
|
5807
6356
|
}
|
|
5808
|
-
}, {
|
|
5809
|
-
type: "set-relation",
|
|
5810
|
-
table: "clip-source-relations",
|
|
5811
|
-
value: {
|
|
5812
|
-
clipId: id,
|
|
5813
|
-
sourceId
|
|
5814
|
-
}
|
|
5815
6357
|
}, {
|
|
5816
6358
|
type: "insert-node",
|
|
5817
6359
|
entityId: id,
|
|
5818
6360
|
parentId: placement.trackId
|
|
5819
6361
|
});
|
|
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
6362
|
}
|
|
5837
6363
|
return {
|
|
5838
6364
|
timelineId,
|
|
@@ -5848,5 +6374,22 @@ function milliseconds(value, path, positive = false, signed = false) {
|
|
|
5848
6374
|
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
6375
|
return value;
|
|
5850
6376
|
}
|
|
6377
|
+
function importedScript(id, text, mintSegment) {
|
|
6378
|
+
const segments = [];
|
|
6379
|
+
let start = 0;
|
|
6380
|
+
if (text.length) for (const line of text.split("\n")) {
|
|
6381
|
+
segments.push({
|
|
6382
|
+
id: mintSegment(),
|
|
6383
|
+
text: line,
|
|
6384
|
+
start
|
|
6385
|
+
});
|
|
6386
|
+
start += line.length + 1;
|
|
6387
|
+
}
|
|
6388
|
+
return {
|
|
6389
|
+
id,
|
|
6390
|
+
text,
|
|
6391
|
+
segments
|
|
6392
|
+
};
|
|
6393
|
+
}
|
|
5851
6394
|
//#endregion
|
|
5852
|
-
export {
|
|
6395
|
+
export { DslEditor as a, DslProjectionError as c, createInitialMedeoDsl as i, toVideoDraft as n, MedeoDsl as o, toAudioScriptViews as r, resolveDslLayout as s, fromVideoDraft as t };
|