@mengine/medeo-client 2.1.1-dsl.1 → 2.1.1-dsl.3
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 +17 -3
- package/dist/{dsl-EV6kCdr6.js → dsl-Dm3Sq1fa.js} +307 -354
- package/dist/dsl.d.ts +3 -3
- package/dist/dsl.js +2 -2
- package/dist/{index-B7fC7q7P.d.ts → index-CwG4lYKp.d.ts} +23 -4
- package/dist/{index-Cu7SdKN5.d.ts → index-DP9FG0z_.d.ts} +41 -137
- package/dist/{index-yifEtZSP.d.ts → index-W1eHhVus.d.ts} +22 -53
- package/dist/index.d.ts +5 -5
- package/dist/index.js +54 -4
- package/dist/legacy.d.ts +2 -2
- package/dist/legacy.js +1 -1
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +2 -2
- package/dist/{video-draft-types-c_shaRyq.d.ts → video-draft-types-BfkIuW5f.d.ts} +40 -44
- package/dist/{video-draft-types-TXRv3wS6.js → video-draft-types-DvPeam87.js} +105 -98
- package/package.json +6 -6
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as assertId, A as hardenCidDescriptors, B as safeStringify, C as applySchemaToInferOptions, D as deepEqual, E as decodeNestedJsonValues, F as isObject, G as valuesEqual, H as stripUndefined, I as isObjectLike, J as getChildSchema, K as CID_KEY, L as isStateAndSchemaOfType, M as inferContainerTypeFromValue, N as insertChildToMap, O as defineCidProperty, P as isArrayLike, Q as assertEntityPatch, R as isTreeID, S as applyEncode, T as containerIdToContainerType, U as tryInferContainerType, V as schemaToContainerType, W as tryUpdateToContainer, X as assertCaptionContent, Y as getMapFieldSchema, Z as assertEntity, _ as isLoroTreeSchema, at as isRecord, b as validateSchema, c as isInvalidAtomic, ct as relationTables, d as getDefaultValue, dt as assertResourceIdentity, et as assertPhoneme, f as isContainerSchema, ft as defaultIdFactory, g as isLoroMovableListSchema, h as isLoroMapSchema, it as entityTables, j as hasTransform, k as getRootContainerByType, lt as validLanguage, m as isLoroListSchema, mt as resourceId, n as textStyles, nt as assertTimeAnchor, o as medeoDslLoroShape, ot as mergeFields, p as isListLikeSchema, pt as relationId, q as getChildContainerSchema, rt as atomicFields, s as captionContentCodec, st as patchRow, t as DEFAULT_UNIT_TIME_MS, tt as assertRelation, u as schema, ut as assertRelationIdentity, v as isRootSchemaType, w as cidsEqual, x as applyDecode, y as schemaContainsIgnoreField, z as isValueOfContainerType } from "./video-draft-types-DvPeam87.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);
|
|
@@ -122,60 +117,73 @@ function requireEntity(rows, table, id) {
|
|
|
122
117
|
return row;
|
|
123
118
|
}
|
|
124
119
|
/**
|
|
125
|
-
* Whether one relation row may stand among these rows: its fields, its derived ID, and what
|
|
126
|
-
*
|
|
127
|
-
*
|
|
120
|
+
* Whether one relation row may stand among these rows: its fields, its derived ID, and what its
|
|
121
|
+
* kind requires of each end. A row being linked need not be in `rows` yet. Relations an entity
|
|
122
|
+
* holds at most one of are fields of that entity; see {@link assertReferenceAdmitted}.
|
|
128
123
|
*/
|
|
129
124
|
function assertRelationAdmitted(rows, table, id, value) {
|
|
130
125
|
assertId(id);
|
|
131
126
|
assertRelation(table, value);
|
|
132
127
|
assertRelationIdentity(table, id, value);
|
|
133
128
|
const v = value;
|
|
134
|
-
const require = (endpoint, id) => requireEntity(rows, endpoint, id);
|
|
135
129
|
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
130
|
case "voice-timbre-relations":
|
|
166
|
-
if (
|
|
167
|
-
|
|
131
|
+
if (requireEntity(rows, "media-entities", v.recordingId).kind !== "voiceover-recording") throw new Error("Timbre requires a voiceover recording");
|
|
132
|
+
requireEntity(rows, "voice-entities", v.voiceId);
|
|
168
133
|
break;
|
|
169
134
|
case "derived-from-relations":
|
|
170
135
|
for (const end of [v.entityId, v.sourceId]) if (!entityTables.some((t) => Object.hasOwn(rows[t], end))) throw new Error("Missing provenance endpoint");
|
|
171
136
|
break;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Entity fields that hold a single-valued relation, by the entity that owns it. */
|
|
140
|
+
const referenceFields = {
|
|
141
|
+
"clip-entities": ["source", "anchor"],
|
|
142
|
+
"phonetic-script-entities": ["phoneme"],
|
|
143
|
+
"caption-entities": ["selection"]
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Whether a single-valued relation, stored as a field of the entity that owns it, may hold this
|
|
147
|
+
* value among these rows. The owner need not be stored yet; an absent value unlinks it.
|
|
148
|
+
* A caption selection's range is checked against the live body by whoever holds it.
|
|
149
|
+
*/
|
|
150
|
+
function assertReferenceAdmitted(rows, table, id, field, value) {
|
|
151
|
+
assertId(id);
|
|
152
|
+
const key = `${table}.${field}`;
|
|
153
|
+
if (value === void 0) {
|
|
154
|
+
if (key === "clip-entities.source" || key === "phonetic-script-entities.phoneme") throw new Error(`Missing required relation: ${key}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
switch (key) {
|
|
158
|
+
case "clip-entities.source":
|
|
159
|
+
assertId(value);
|
|
160
|
+
if (!Object.hasOwn(rows["media-entities"], value) && !Object.hasOwn(rows["caption-entities"], value)) throw new Error("Invalid Clip source");
|
|
161
|
+
break;
|
|
162
|
+
case "clip-entities.anchor": {
|
|
163
|
+
assertTimeAnchor(value);
|
|
164
|
+
const anchor = value;
|
|
165
|
+
if (Object.hasOwn(rows["timeline-entities"], anchor.to)) {
|
|
166
|
+
if (anchor.offsetMs < 0) throw new Error("Absolute offset must be nonnegative");
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
requireEntity(rows, "clip-entities", anchor.to);
|
|
170
|
+
const visited = new Set([id]);
|
|
171
|
+
for (let next = anchor.to; typeof next === "string";) {
|
|
172
|
+
if (visited.has(next)) throw new Error("Time anchor cycle");
|
|
173
|
+
visited.add(next);
|
|
174
|
+
const clip = rows["clip-entities"][next];
|
|
175
|
+
next = isRecord(clip) && isRecord(clip.anchor) ? clip.anchor.to : void 0;
|
|
176
|
+
}
|
|
177
177
|
break;
|
|
178
178
|
}
|
|
179
|
+
case "caption-entities.selection":
|
|
180
|
+
assertCaptionContent(value);
|
|
181
|
+
requireEntity(rows, "audio-script-entities", value.audioScriptId);
|
|
182
|
+
break;
|
|
183
|
+
case "phonetic-script-entities.phoneme":
|
|
184
|
+
assertPhoneme(value);
|
|
185
|
+
requireEntity(rows, "audio-script-entities", value.audioScriptId);
|
|
186
|
+
break;
|
|
179
187
|
}
|
|
180
188
|
}
|
|
181
189
|
/** Both ends of a caption's selection exist: the caption and the script it selects from. */
|
|
@@ -261,8 +269,9 @@ function replacementPatch(before, after) {
|
|
|
261
269
|
/**
|
|
262
270
|
* Admission of one whole change, asked once before it is published. Only what the change touched
|
|
263
271
|
* is checked, so an unrelated edit still lands in a merged document that holds invalid rows.
|
|
264
|
-
*
|
|
265
|
-
*
|
|
272
|
+
* Single-valued relations are fields of their owner and are checked when the owner's field is
|
|
273
|
+
* touched. Containment and caption ranges are checked by whoever holds the tree and the script
|
|
274
|
+
* bodies, with `assertContainTree` and `assertCaptionSelection`.
|
|
266
275
|
*/
|
|
267
276
|
function assertChangeAdmitted(change) {
|
|
268
277
|
const { before, after } = change;
|
|
@@ -278,16 +287,15 @@ function assertChangeAdmitted(change) {
|
|
|
278
287
|
if (!isRecord(value)) throw new Error("Expected an entity object");
|
|
279
288
|
assertEntityPatch(table, Object.fromEntries([...fields].map((field) => [field, replacementPatch(old[field], value[field])])), old);
|
|
280
289
|
}
|
|
281
|
-
const
|
|
290
|
+
const row = value;
|
|
291
|
+
for (const field of referenceFields[table] ?? []) if (!old || fields.has(field)) assertReferenceAdmitted(after, table, id, field, row[field]);
|
|
292
|
+
const marker = row.seqMarker;
|
|
282
293
|
if (table === "clip-entities" && (!old || fields.has("seqMarker")) && marker?.outMs <= marker?.inMs) throw new Error("Clip window must be nonempty");
|
|
283
294
|
}
|
|
284
295
|
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
|
-
}
|
|
296
|
+
for (const [table, ids] of change.relations) for (const id of ids) {
|
|
297
|
+
const value = after[table][id];
|
|
298
|
+
if (value !== void 0) assertRelationAdmitted(after, table, id, value);
|
|
291
299
|
}
|
|
292
300
|
}
|
|
293
301
|
//#endregion
|
|
@@ -331,9 +339,9 @@ function resolveDslLayout(snapshot) {
|
|
|
331
339
|
const clips = [];
|
|
332
340
|
for (const child of node.children) {
|
|
333
341
|
const id = child.entityId, clip = snapshot["clip-entities"][id];
|
|
334
|
-
const sourceId =
|
|
335
|
-
const media =
|
|
336
|
-
const caption =
|
|
342
|
+
const sourceId = clip.source;
|
|
343
|
+
const media = snapshot["media-entities"][sourceId];
|
|
344
|
+
const caption = snapshot["caption-entities"][sourceId];
|
|
337
345
|
const marker = clip.seqMarker;
|
|
338
346
|
let inMs = marker.inMs, outMs = marker.outMs;
|
|
339
347
|
const present = media !== void 0 || caption !== void 0;
|
|
@@ -347,7 +355,7 @@ function resolveDslLayout(snapshot) {
|
|
|
347
355
|
const rate = marker.rate ?? 1;
|
|
348
356
|
const duration = present ? Math.max(0, Math.round((outMs - inMs) / rate)) : 0;
|
|
349
357
|
if (!Number.isSafeInteger(duration)) projectionError("time-overflow", `clip-entities/${id}`, "Duration exceeds safe integer milliseconds");
|
|
350
|
-
const anchor =
|
|
358
|
+
const anchor = clip.anchor;
|
|
351
359
|
const placement = {
|
|
352
360
|
clipId: id,
|
|
353
361
|
trackId: node.entityId,
|
|
@@ -384,7 +392,7 @@ function resolveDslLayout(snapshot) {
|
|
|
384
392
|
}
|
|
385
393
|
seen.set(current, path.length);
|
|
386
394
|
path.push(current);
|
|
387
|
-
current = snapshot["
|
|
395
|
+
current = snapshot["clip-entities"][current]?.anchor?.to;
|
|
388
396
|
}
|
|
389
397
|
for (const member of path) done.add(member);
|
|
390
398
|
}
|
|
@@ -393,7 +401,7 @@ function resolveDslLayout(snapshot) {
|
|
|
393
401
|
const position = (id) => {
|
|
394
402
|
const known = positions.get(id);
|
|
395
403
|
if (known) return known;
|
|
396
|
-
const clip = result.clips[id], anchor = snapshot["
|
|
404
|
+
const clip = result.clips[id], anchor = snapshot["clip-entities"][id]?.anchor;
|
|
397
405
|
let value;
|
|
398
406
|
if (anchor && clip.mode === "anchored" && !cycle.has(id) && result.clips[anchor.to]) {
|
|
399
407
|
const parent = position(anchor.to);
|
|
@@ -500,14 +508,6 @@ function resolveDslLayout(snapshot) {
|
|
|
500
508
|
const a = track.clips[i];
|
|
501
509
|
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
510
|
}
|
|
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
511
|
return result;
|
|
512
512
|
}
|
|
513
513
|
function overlap(a, b) {
|
|
@@ -563,9 +563,10 @@ function timelineId(s) {
|
|
|
563
563
|
/** Placement facts are authored only by explicit actions; projection never writes them back. */
|
|
564
564
|
function placementMutations(s, clipId, placement) {
|
|
565
565
|
if (placement.kind === "sequential") return [{
|
|
566
|
-
type: "
|
|
567
|
-
table: "
|
|
568
|
-
id:
|
|
566
|
+
type: "patch-entity",
|
|
567
|
+
table: "clip-entities",
|
|
568
|
+
id: clipId,
|
|
569
|
+
patch: { anchor: void 0 }
|
|
569
570
|
}];
|
|
570
571
|
const offset = placement.kind === "absolute" ? placement.startMs : placement.offsetMs;
|
|
571
572
|
if (!Number.isSafeInteger(offset) || placement.kind === "absolute" && offset < 0) throw new Error("Invalid placement time");
|
|
@@ -578,27 +579,31 @@ function placementMutations(s, clipId, placement) {
|
|
|
578
579
|
while (next && s["clip-entities"][next]) {
|
|
579
580
|
if (visited.has(next)) throw new Error("Cyclic time anchor");
|
|
580
581
|
visited.add(next);
|
|
581
|
-
next = s["
|
|
582
|
+
next = s["clip-entities"][next]?.anchor?.to;
|
|
582
583
|
}
|
|
583
584
|
fallback = Math.max(0, resolveDslLayout(s).clips[target].startMs + offset);
|
|
584
585
|
}
|
|
585
586
|
if (!Number.isSafeInteger(fallback)) throw new Error("Invalid fallback time");
|
|
586
587
|
return [{
|
|
587
|
-
type: "
|
|
588
|
-
table: "
|
|
589
|
-
|
|
590
|
-
|
|
588
|
+
type: "patch-entity",
|
|
589
|
+
table: "clip-entities",
|
|
590
|
+
id: clipId,
|
|
591
|
+
patch: { anchor: {
|
|
591
592
|
to: target,
|
|
592
593
|
offsetMs: offset,
|
|
593
594
|
fallback_abs_ms: fallback
|
|
594
|
-
}
|
|
595
|
+
} }
|
|
595
596
|
}];
|
|
596
597
|
}
|
|
598
|
+
/** Clips whose time anchor points at a Clip in `ids`, with that anchor. */
|
|
599
|
+
function anchoredTo(s, ids) {
|
|
600
|
+
return Object.entries(s["clip-entities"]).flatMap(([id, clip]) => clip.anchor && ids.has(clip.anchor.to) ? [id] : []);
|
|
601
|
+
}
|
|
597
602
|
function detachChildren(s, ids) {
|
|
598
603
|
const layout = resolveDslLayout(s);
|
|
599
|
-
return
|
|
604
|
+
return anchoredTo(s, ids).flatMap((id) => !ids.has(id) && layout.clips[id] ? placementMutations(s, id, {
|
|
600
605
|
kind: "absolute",
|
|
601
|
-
startMs: layout.clips[
|
|
606
|
+
startMs: layout.clips[id].startMs
|
|
602
607
|
}) : []);
|
|
603
608
|
}
|
|
604
609
|
function deleteClips(s, ids, policy) {
|
|
@@ -608,9 +613,9 @@ function deleteClips(s, ids, policy) {
|
|
|
608
613
|
const removed = new Set(ids);
|
|
609
614
|
if (policy === "cascade") {
|
|
610
615
|
const queue = [...ids];
|
|
611
|
-
for (let i = 0; i < queue.length; i++) for (const
|
|
612
|
-
removed.add(
|
|
613
|
-
queue.push(
|
|
616
|
+
for (let i = 0; i < queue.length; i++) for (const id of anchoredTo(s, new Set([queue[i]]))) if (!removed.has(id)) {
|
|
617
|
+
removed.add(id);
|
|
618
|
+
queue.push(id);
|
|
614
619
|
}
|
|
615
620
|
}
|
|
616
621
|
const mutations = policy === "detach" ? detachChildren(s, removed) : [];
|
|
@@ -620,14 +625,6 @@ function deleteClips(s, ids, policy) {
|
|
|
620
625
|
entityId: id
|
|
621
626
|
});
|
|
622
627
|
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
628
|
type: "delete-entity",
|
|
632
629
|
table: "clip-entities",
|
|
633
630
|
id
|
|
@@ -640,11 +637,10 @@ function deleteClips(s, ids, policy) {
|
|
|
640
637
|
/** Batch edits validate against preceding prepared facts without touching the live document. */
|
|
641
638
|
function updateClipsPlan(base, updates) {
|
|
642
639
|
uniqueIds(updates.map((update) => update.clipId));
|
|
643
|
-
const clips = { ...base["clip-entities"] }
|
|
640
|
+
const clips = { ...base["clip-entities"] };
|
|
644
641
|
const preview = {
|
|
645
642
|
...base,
|
|
646
|
-
"clip-entities": clips
|
|
647
|
-
"time-anchor-relations": anchors
|
|
643
|
+
"clip-entities": clips
|
|
648
644
|
};
|
|
649
645
|
const mutations = [];
|
|
650
646
|
for (const update of updates) {
|
|
@@ -660,9 +656,7 @@ function updateClipsPlan(base, updates) {
|
|
|
660
656
|
if (clip.seqMarker.fillTimeline && (windowChanged || Object.hasOwn(marker, "rate"))) throw new Error("BGM trim and speed are not enabled");
|
|
661
657
|
const next = mergeFields(clip, patch);
|
|
662
658
|
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);
|
|
659
|
+
const source = requireSource(preview, clip.source, requireTrack(preview, trackOf(preview, update.clipId)).role);
|
|
666
660
|
validateWindow(next.seqMarker, "durationMs" in source ? source.durationMs : void 0);
|
|
667
661
|
}
|
|
668
662
|
if (Object.keys(patch).length) mutations.push({
|
|
@@ -676,8 +670,7 @@ function updateClipsPlan(base, updates) {
|
|
|
676
670
|
trackOf(preview, update.clipId);
|
|
677
671
|
const changes = placementMutations(preview, update.clipId, update.placement);
|
|
678
672
|
mutations.push(...changes);
|
|
679
|
-
for (const change of changes) if (change.type === "
|
|
680
|
-
else if (change.type === "delete-relation") delete anchors[change.id];
|
|
673
|
+
for (const change of changes) if (change.type === "patch-entity" && change.table === "clip-entities") clips[update.clipId] = mergeFields(clips[update.clipId], change.patch);
|
|
681
674
|
}
|
|
682
675
|
}
|
|
683
676
|
return mutations;
|
|
@@ -724,7 +717,10 @@ function addClipsPlan(s, input, ids) {
|
|
|
724
717
|
marker.fillTimeline = true;
|
|
725
718
|
}
|
|
726
719
|
validateWindow(marker, duration);
|
|
727
|
-
const clip = {
|
|
720
|
+
const clip = {
|
|
721
|
+
seqMarker: marker,
|
|
722
|
+
source: source.id
|
|
723
|
+
};
|
|
728
724
|
if (item.volume !== void 0) clip.volume = item.volume;
|
|
729
725
|
const id = ids[offset];
|
|
730
726
|
const result = [
|
|
@@ -735,14 +731,6 @@ function addClipsPlan(s, input, ids) {
|
|
|
735
731
|
id,
|
|
736
732
|
value: clip
|
|
737
733
|
},
|
|
738
|
-
{
|
|
739
|
-
type: "set-relation",
|
|
740
|
-
table: "clip-source-relations",
|
|
741
|
-
value: {
|
|
742
|
-
clipId: id,
|
|
743
|
-
sourceId: source.id
|
|
744
|
-
}
|
|
745
|
-
},
|
|
746
734
|
{
|
|
747
735
|
type: "insert-node",
|
|
748
736
|
entityId: id,
|
|
@@ -762,10 +750,9 @@ function moveClipsPlan(s, input) {
|
|
|
762
750
|
if (!Number.isSafeInteger(input.index) || input.index < 0 || input.index > target.length) throw new Error("Invalid insertion index");
|
|
763
751
|
for (const id of input.clipIds) {
|
|
764
752
|
trackOf(s, id);
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
requireSource(s, source.sourceId, requireTrack(s, input.trackId).role);
|
|
753
|
+
const clip = requireClip(s, id);
|
|
754
|
+
if (clip.anchor) throw new Error("Sequential reorder requires sequential Clips");
|
|
755
|
+
requireSource(s, clip.source, requireTrack(s, input.trackId).role);
|
|
769
756
|
}
|
|
770
757
|
const mutations = input.onAnchored === "keepAbsolute" ? detachChildren(s, moved) : [];
|
|
771
758
|
const order = [...target];
|
|
@@ -783,7 +770,7 @@ function replaceSequencePlan(s, input, ids) {
|
|
|
783
770
|
uniqueIds(input.clipIds);
|
|
784
771
|
if (!input.clipIds.length || !input.clips.length) throw new Error("Replacement requires nonempty runs");
|
|
785
772
|
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["
|
|
773
|
+
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
774
|
if (input.clips.some((c) => c.placement && c.placement.kind !== "sequential")) throw new Error("Replacement Clips must be sequential");
|
|
788
775
|
const mutations = addClipsPlan(s, {
|
|
789
776
|
trackId,
|
|
@@ -791,7 +778,7 @@ function replaceSequencePlan(s, input, ids) {
|
|
|
791
778
|
index
|
|
792
779
|
}, ids);
|
|
793
780
|
const removed = new Set(input.clipIds);
|
|
794
|
-
const
|
|
781
|
+
const clips = { ...s["clip-entities"] };
|
|
795
782
|
if (input.onAnchored === "remap") {
|
|
796
783
|
const begin = resolveDslLayout(s).clips[input.clipIds[0]].startMs;
|
|
797
784
|
const starts = [];
|
|
@@ -803,26 +790,30 @@ function replaceSequencePlan(s, input, ids) {
|
|
|
803
790
|
const marker = creation.value.seqMarker;
|
|
804
791
|
end += Math.round((marker.outMs - marker.inMs) / (marker.rate ?? 1));
|
|
805
792
|
}
|
|
806
|
-
for (const
|
|
807
|
-
const
|
|
808
|
-
|
|
793
|
+
for (const child of anchoredTo(s, removed)) {
|
|
794
|
+
const anchor = s["clip-entities"][child].anchor;
|
|
795
|
+
const slot = input.clipIds.indexOf(anchor.to);
|
|
796
|
+
if (removed.has(child) || slot >= ids.length) continue;
|
|
809
797
|
const value = {
|
|
810
|
-
from: r.from,
|
|
811
798
|
to: ids[slot],
|
|
812
|
-
offsetMs:
|
|
813
|
-
fallback_abs_ms: Math.max(0, starts[slot] +
|
|
799
|
+
offsetMs: anchor.offsetMs,
|
|
800
|
+
fallback_abs_ms: Math.max(0, starts[slot] + anchor.offsetMs)
|
|
814
801
|
};
|
|
815
802
|
mutations.push({
|
|
816
|
-
type: "
|
|
817
|
-
table: "
|
|
818
|
-
|
|
803
|
+
type: "patch-entity",
|
|
804
|
+
table: "clip-entities",
|
|
805
|
+
id: child,
|
|
806
|
+
patch: { anchor: value }
|
|
819
807
|
});
|
|
820
|
-
|
|
808
|
+
clips[child] = {
|
|
809
|
+
...clips[child],
|
|
810
|
+
anchor: value
|
|
811
|
+
};
|
|
821
812
|
}
|
|
822
813
|
}
|
|
823
814
|
mutations.push(...deleteClips({
|
|
824
815
|
...s,
|
|
825
|
-
"
|
|
816
|
+
"clip-entities": clips
|
|
826
817
|
}, input.clipIds, "cascade"));
|
|
827
818
|
return mutations;
|
|
828
819
|
}
|
|
@@ -830,26 +821,18 @@ function replaceSourcePlan(s, clipId, input) {
|
|
|
830
821
|
if (requireClip(s, clipId).seqMarker.fillTimeline) throw new Error("Use setBgm to replace a fill Clip");
|
|
831
822
|
const source = sourcePlan(s, input, trackOf(s, clipId));
|
|
832
823
|
validateWindow(input, "durationMs" in source.source ? source.source.durationMs : void 0);
|
|
833
|
-
return [
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
patch: { seqMarker: {
|
|
824
|
+
return [...source.mutations, {
|
|
825
|
+
type: "patch-entity",
|
|
826
|
+
table: "clip-entities",
|
|
827
|
+
id: clipId,
|
|
828
|
+
patch: {
|
|
829
|
+
seqMarker: {
|
|
840
830
|
inMs: input.inMs,
|
|
841
831
|
outMs: input.outMs
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
{
|
|
845
|
-
type: "set-relation",
|
|
846
|
-
table: "clip-source-relations",
|
|
847
|
-
value: {
|
|
848
|
-
clipId,
|
|
849
|
-
sourceId: source.id
|
|
850
|
-
}
|
|
832
|
+
},
|
|
833
|
+
source: source.id
|
|
851
834
|
}
|
|
852
|
-
];
|
|
835
|
+
}];
|
|
853
836
|
}
|
|
854
837
|
const produce = new Immer({
|
|
855
838
|
autoFreeze: false,
|
|
@@ -3895,6 +3878,21 @@ function resolveContent(doc, content) {
|
|
|
3895
3878
|
bodyId: text.id
|
|
3896
3879
|
};
|
|
3897
3880
|
}
|
|
3881
|
+
/** Optional relation fields read apart from their row: an unreadable one is dropped, the row is kept. */
|
|
3882
|
+
const optionalReferences = {
|
|
3883
|
+
"clip-entities": ["anchor"],
|
|
3884
|
+
"caption-entities": ["selection"]
|
|
3885
|
+
};
|
|
3886
|
+
/** The entity a single-valued relation field points at, by the field's path. */
|
|
3887
|
+
function referenceTargets(table, row) {
|
|
3888
|
+
const nested = (field, key) => isRecord(row[field]) ? row[field][key] : void 0;
|
|
3889
|
+
switch (table) {
|
|
3890
|
+
case "clip-entities": return [["source", row.source], ["anchor/to", nested("anchor", "to")]];
|
|
3891
|
+
case "caption-entities": return [["selection/audioScriptId", nested("selection", "audioScriptId")]];
|
|
3892
|
+
case "phonetic-script-entities": return [["phoneme/audioScriptId", nested("phoneme", "audioScriptId")]];
|
|
3893
|
+
default: return [];
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3898
3896
|
function readMedeoDsl(doc, state) {
|
|
3899
3897
|
const diagnostics = [];
|
|
3900
3898
|
const diagnostic = (code, path, error) => diagnostics.push({
|
|
@@ -3907,10 +3905,17 @@ function readMedeoDsl(doc, state) {
|
|
|
3907
3905
|
const richText = {};
|
|
3908
3906
|
for (const table of entityTables) for (const [id, raw] of Object.entries(state[table] ?? {})) try {
|
|
3909
3907
|
assertId(id);
|
|
3910
|
-
|
|
3911
|
-
const
|
|
3908
|
+
let v = raw;
|
|
3909
|
+
for (const field of optionalReferences[table] ?? []) {
|
|
3910
|
+
const value = v[field];
|
|
3911
|
+
if (!isInvalidAtomic(value)) continue;
|
|
3912
|
+
diagnostic("invalid-relation", `${table}/${id}/${field}`, value.reason);
|
|
3913
|
+
const { [field]: _dropped, ...rest } = v;
|
|
3914
|
+
v = rest;
|
|
3915
|
+
}
|
|
3916
|
+
assertEntity(table, v);
|
|
3912
3917
|
assertResourceIdentity(table, id, v);
|
|
3913
|
-
entities[table][id] =
|
|
3918
|
+
entities[table][id] = v;
|
|
3914
3919
|
if (table === "audio-script-entities" || table === "caption-entities" && v.text !== void 0) {
|
|
3915
3920
|
const text = liveText(doc, id, table === "caption-entities");
|
|
3916
3921
|
if (!text) throw new Error("Missing live LoroText body");
|
|
@@ -3928,23 +3933,17 @@ function readMedeoDsl(doc, state) {
|
|
|
3928
3933
|
diagnostic("invalid-entity", `${table}/${id}`, error);
|
|
3929
3934
|
}
|
|
3930
3935
|
const entityIds = new Set(entityTables.flatMap((t) => Object.keys(entities[t])));
|
|
3936
|
+
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
3937
|
for (const table of relationTables) for (const [id, value] of Object.entries(state[table] ?? {})) try {
|
|
3932
3938
|
if (isInvalidAtomic(value)) throw new Error(value.reason);
|
|
3933
3939
|
assertRelation(table, value);
|
|
3934
3940
|
assertRelationIdentity(table, id, value);
|
|
3935
3941
|
relations[table][id] = value;
|
|
3936
3942
|
for (const key of [
|
|
3937
|
-
"clipId",
|
|
3938
|
-
"sourceId",
|
|
3939
|
-
"from",
|
|
3940
|
-
"to",
|
|
3941
|
-
"captionId",
|
|
3942
|
-
"audioScriptId",
|
|
3943
|
-
"mediaId",
|
|
3944
3943
|
"recordingId",
|
|
3945
3944
|
"voiceId",
|
|
3946
3945
|
"entityId",
|
|
3947
|
-
"
|
|
3946
|
+
"sourceId"
|
|
3948
3947
|
]) {
|
|
3949
3948
|
const endpoint = value[key];
|
|
3950
3949
|
if (typeof endpoint === "string" && !entityIds.has(endpoint)) diagnostic("dangling-reference", `${table}/${id}/${key}`, `Missing entity ${endpoint}`);
|
|
@@ -3972,22 +3971,25 @@ function readMedeoDsl(doc, state) {
|
|
|
3972
3971
|
}];
|
|
3973
3972
|
});
|
|
3974
3973
|
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
3974
|
for (const [id, caption] of Object.entries(entities["caption-entities"])) {
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3975
|
+
if (caption.text !== void 0) {
|
|
3976
|
+
if (Object.hasOwn(state["caption-entities"]?.[id] ?? {}, "selection")) diagnostic("multiple-caption-sources", `caption-entities/${id}`, "Inline text takes precedence over the content reference");
|
|
3977
|
+
if (richText[id]) captionText[id] = {
|
|
3978
|
+
text: caption.text,
|
|
3979
|
+
start: 0,
|
|
3980
|
+
end: caption.text.length,
|
|
3981
|
+
bodyId: richText[id].bodyId
|
|
3982
|
+
};
|
|
3983
|
+
continue;
|
|
3984
|
+
}
|
|
3985
|
+
if (!caption.selection) continue;
|
|
3986
|
+
try {
|
|
3987
|
+
const resolved = resolveContent(doc, caption.selection);
|
|
3988
|
+
if (resolved) captionText[id] = resolved;
|
|
3989
|
+
else diagnostic("invalid-caption-content", `caption-entities/${id}/selection`, "Cursor does not resolve in the current body");
|
|
3990
|
+
} catch (error) {
|
|
3991
|
+
diagnostic("invalid-caption-content", `caption-entities/${id}/selection`, error);
|
|
3992
|
+
}
|
|
3991
3993
|
}
|
|
3992
3994
|
return freezeSnapshot({
|
|
3993
3995
|
...entities,
|
|
@@ -4040,6 +4042,7 @@ var TransactionDraft = class {
|
|
|
4040
4042
|
relations = /* @__PURE__ */ new Map();
|
|
4041
4043
|
deleted = /* @__PURE__ */ new Set();
|
|
4042
4044
|
textPreviews = /* @__PURE__ */ new Map();
|
|
4045
|
+
/** Captions whose selection the draft wrote; they are replayed natively in order with text edits. */
|
|
4043
4046
|
dirtyCaptions = /* @__PURE__ */ new Set();
|
|
4044
4047
|
removedCaptionTexts = /* @__PURE__ */ new Set();
|
|
4045
4048
|
treeChanged = false;
|
|
@@ -4137,6 +4140,7 @@ var TransactionDraft = class {
|
|
|
4137
4140
|
if (path.length === 1 && this.textPreviews.has(rowId)) throw new Error("Cannot delete or replace a body used in this transaction");
|
|
4138
4141
|
}
|
|
4139
4142
|
if (table === "caption-entities") {
|
|
4143
|
+
if (path.length === 1 && isRecord(next) && Object.hasOwn(next, "selection") || path.length === 2 && key === "selection" || path[2] === "selection") this.dirtyCaptions.add(rowId);
|
|
4140
4144
|
const changesText = path.length === 1 || path.length === 2 && key === "text";
|
|
4141
4145
|
const nextText = path.length === 1 ? isRecord(next) ? next.text : void 0 : next;
|
|
4142
4146
|
if (changesText) {
|
|
@@ -4145,11 +4149,7 @@ var TransactionDraft = class {
|
|
|
4145
4149
|
if (initial?.text !== void 0 && nextText === void 0) this.removedCaptionTexts.add(rowId);
|
|
4146
4150
|
}
|
|
4147
4151
|
}
|
|
4148
|
-
if (initial && [
|
|
4149
|
-
"media-entities",
|
|
4150
|
-
"asset-entities",
|
|
4151
|
-
"voice-entities"
|
|
4152
|
-
].includes(table)) {
|
|
4152
|
+
if (initial && ["media-entities", "voice-entities"].includes(table)) {
|
|
4153
4153
|
for (const field of [
|
|
4154
4154
|
"system",
|
|
4155
4155
|
"key",
|
|
@@ -4169,7 +4169,6 @@ var TransactionDraft = class {
|
|
|
4169
4169
|
let ids = this.relations.get(table);
|
|
4170
4170
|
if (!ids) this.relations.set(table, ids = /* @__PURE__ */ new Set());
|
|
4171
4171
|
ids.add(rowId);
|
|
4172
|
-
if (table === "caption-content-relations") this.dirtyCaptions.add(rowId);
|
|
4173
4172
|
}
|
|
4174
4173
|
}
|
|
4175
4174
|
};
|
|
@@ -4224,6 +4223,7 @@ function changeTree(nodes, mutation, rows) {
|
|
|
4224
4223
|
}
|
|
4225
4224
|
//#endregion
|
|
4226
4225
|
//#region src/dsl/document/mutations.ts
|
|
4226
|
+
const resourceTables = ["media-entities", "voice-entities"];
|
|
4227
4227
|
/** Mutations are a convenience vocabulary over the same guarded draft and native command queue. */
|
|
4228
4228
|
function applyMutations(tx, mutations) {
|
|
4229
4229
|
const rows = tx.draft;
|
|
@@ -4233,11 +4233,7 @@ function applyMutations(tx, mutations) {
|
|
|
4233
4233
|
case "create-entity": {
|
|
4234
4234
|
assertEntity(m.table, m.value);
|
|
4235
4235
|
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}`);
|
|
4236
|
+
if (old && !resourceTables.includes(m.table)) throw new Error(`Entity already exists: ${m.id}`);
|
|
4241
4237
|
if (old) {
|
|
4242
4238
|
assertEntityPatch(m.table, m.value, old);
|
|
4243
4239
|
applyPatch(old, m.value);
|
|
@@ -4247,12 +4243,15 @@ function applyMutations(tx, mutations) {
|
|
|
4247
4243
|
case "patch-entity": {
|
|
4248
4244
|
const current = rows[m.table][m.id];
|
|
4249
4245
|
if (!current) throw new Error(`Missing entity: ${m.id}`);
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4246
|
+
const patch = m.patch;
|
|
4247
|
+
assertEntityPatch(m.table, patch, current);
|
|
4248
|
+
const atomic = atomicFields[m.table] ?? [];
|
|
4249
|
+
const next = patchRow(m.table, current, patch);
|
|
4250
|
+
const ordinary = {};
|
|
4251
|
+
for (const key of Object.keys(patch)) if (!atomic.includes(key)) ordinary[key] = patch[key];
|
|
4252
|
+
else if (next[key] === void 0) delete current[key];
|
|
4253
|
+
else current[key] = next[key];
|
|
4254
|
+
applyPatch(current, ordinary);
|
|
4256
4255
|
break;
|
|
4257
4256
|
}
|
|
4258
4257
|
case "delete-entity":
|
|
@@ -4261,11 +4260,8 @@ function applyMutations(tx, mutations) {
|
|
|
4261
4260
|
case "set-relation": {
|
|
4262
4261
|
assertRelation(m.table, m.value);
|
|
4263
4262
|
const id = relationId(m.table, m.value);
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
rows[m.table][id] ??= {};
|
|
4267
|
-
applyPatch(rows[m.table][id], m.value);
|
|
4268
|
-
}
|
|
4263
|
+
rows[m.table][id] ??= {};
|
|
4264
|
+
applyPatch(rows[m.table][id], m.value);
|
|
4269
4265
|
break;
|
|
4270
4266
|
}
|
|
4271
4267
|
case "delete-relation":
|
|
@@ -4295,11 +4291,7 @@ function applyTextEdits(text, edits) {
|
|
|
4295
4291
|
text.delete(edit.at, edit.length);
|
|
4296
4292
|
break;
|
|
4297
4293
|
case "mark":
|
|
4298
|
-
|
|
4299
|
-
start: edit.start,
|
|
4300
|
-
end: edit.end
|
|
4301
|
-
}, edit.key);
|
|
4302
|
-
else text.mark({
|
|
4294
|
+
text.mark({
|
|
4303
4295
|
start: edit.start,
|
|
4304
4296
|
end: edit.end
|
|
4305
4297
|
}, edit.key, edit.value);
|
|
@@ -4317,10 +4309,9 @@ function applyTextEdits(text, edits) {
|
|
|
4317
4309
|
}
|
|
4318
4310
|
//#endregion
|
|
4319
4311
|
//#region src/dsl/document/validation.ts
|
|
4320
|
-
/**
|
|
4321
|
-
function
|
|
4322
|
-
|
|
4323
|
-
if (table !== "caption-content-relations") return;
|
|
4312
|
+
/** A caption selection by the DSL's rules, which must also select a nonempty run of the live body. */
|
|
4313
|
+
function validateSelection(doc, state, captionId, value) {
|
|
4314
|
+
assertReferenceAdmitted(state, "caption-entities", captionId, "selection", value);
|
|
4324
4315
|
const resolved = resolveContent(doc, value);
|
|
4325
4316
|
if (!resolved || resolved.start === resolved.end) throw new Error("Expected a nonempty range in the current body");
|
|
4326
4317
|
captionContentCodec.encode(value);
|
|
@@ -4355,6 +4346,10 @@ function validateDraft(tracked) {
|
|
|
4355
4346
|
}
|
|
4356
4347
|
//#endregion
|
|
4357
4348
|
//#region src/dsl/document/transact.ts
|
|
4349
|
+
function captionRow(doc, captionId) {
|
|
4350
|
+
const row = doc.getMap("caption-entities").get(captionId);
|
|
4351
|
+
return isContainer(row) && row.kind() === "Map" ? row : void 0;
|
|
4352
|
+
}
|
|
4358
4353
|
/** Serialization happens before preparing any writes, so invalid/cyclic audit data cannot partly commit. */
|
|
4359
4354
|
function auditOptions(audit) {
|
|
4360
4355
|
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,7 +4368,7 @@ const nativeHookSupport = /* @__PURE__ */ new WeakMap();
|
|
|
4373
4368
|
/**
|
|
4374
4369
|
* Every write relies on the patched Mirror's `applyNative` (patches/loro-mirror@2.3.3.patch). An
|
|
4375
4370
|
* unpatched Mirror ignores the option and commits only the draft, silently dropping Text, ranges
|
|
4376
|
-
* and Caption
|
|
4371
|
+
* and Caption selections. The patch runs the hook even for an empty draft without committing, so
|
|
4377
4372
|
* probe each Mirror class once on a scratch document and refuse before the first real write.
|
|
4378
4373
|
*/
|
|
4379
4374
|
function assertNativeHook(mirror) {
|
|
@@ -4420,9 +4415,16 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4420
4415
|
};
|
|
4421
4416
|
const flushCaptions = () => {
|
|
4422
4417
|
for (const id of tracked.dirtyCaptions) {
|
|
4423
|
-
const
|
|
4418
|
+
const row = raw["caption-entities"][id];
|
|
4419
|
+
if (!row) continue;
|
|
4420
|
+
const value = row.selection;
|
|
4421
|
+
const old = before["caption-entities"][id]?.selection;
|
|
4422
|
+
if (isInvalidAtomic(value)) {
|
|
4423
|
+
if (isInvalidAtomic(old) && old.raw === value.raw) continue;
|
|
4424
|
+
throw new Error("Invalid Caption selection");
|
|
4425
|
+
}
|
|
4424
4426
|
if (value !== void 0) {
|
|
4425
|
-
|
|
4427
|
+
validateSelection(doc, raw, id, value);
|
|
4426
4428
|
useBody(value.audioScriptId);
|
|
4427
4429
|
}
|
|
4428
4430
|
steps.push({
|
|
@@ -4501,7 +4503,7 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4501
4503
|
}
|
|
4502
4504
|
validateDraft(tracked);
|
|
4503
4505
|
for (const step of steps) if (step.type === "text") requireEntity(raw, step.caption ? "caption-entities" : "audio-script-entities", step.scriptId);
|
|
4504
|
-
const referenceIds = new Set(Object.
|
|
4506
|
+
const referenceIds = new Set(Object.entries(before["caption-entities"]).filter(([, caption]) => typeof caption === "object" && caption.selection !== void 0).map(([id]) => id));
|
|
4505
4507
|
const changedCaptions = /* @__PURE__ */ new Set();
|
|
4506
4508
|
for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) if (fields.has("text")) changedCaptions.add(id);
|
|
4507
4509
|
for (const step of steps) {
|
|
@@ -4510,16 +4512,18 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4510
4512
|
changedCaptions.add(step.selection.captionId);
|
|
4511
4513
|
}
|
|
4512
4514
|
if (step.type === "caption") {
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
else referenceIds.delete(id);
|
|
4515
|
+
changedCaptions.add(step.id);
|
|
4516
|
+
if (step.value) referenceIds.add(step.id);
|
|
4517
|
+
else referenceIds.delete(step.id);
|
|
4517
4518
|
}
|
|
4518
4519
|
if (step.type === "text" && step.caption) changedCaptions.add(step.scriptId);
|
|
4519
4520
|
}
|
|
4520
|
-
for (const id of changedCaptions) assertCaptionSource(raw["caption-entities"][id], referenceIds.has(id));
|
|
4521
|
+
for (const id of changedCaptions) if (raw["caption-entities"][id]) assertCaptionSource(raw["caption-entities"][id], referenceIds.has(id));
|
|
4521
4522
|
for (const step of steps) if (step.type === "range") assertCaptionEndpoints(raw, step.selection);
|
|
4522
|
-
else if (step.type === "caption" && step.value) assertCaptionEndpoints(raw,
|
|
4523
|
+
else if (step.type === "caption" && step.value) assertCaptionEndpoints(raw, {
|
|
4524
|
+
captionId: step.id,
|
|
4525
|
+
audioScriptId: step.value.audioScriptId
|
|
4526
|
+
});
|
|
4523
4527
|
for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) {
|
|
4524
4528
|
const row = raw["caption-entities"][id];
|
|
4525
4529
|
if (fields.has("text") && row?.text !== void 0 && before["caption-entities"][id]?.text === void 0) {
|
|
@@ -4527,8 +4531,13 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4527
4531
|
delete row.text;
|
|
4528
4532
|
}
|
|
4529
4533
|
}
|
|
4530
|
-
for (const id of tracked.
|
|
4531
|
-
|
|
4534
|
+
for (const [id, fields] of tracked.entities.get("caption-entities") ?? []) {
|
|
4535
|
+
const row = raw["caption-entities"][id];
|
|
4536
|
+
if (!row || !fields.has("selection")) continue;
|
|
4537
|
+
const old = before["caption-entities"][id];
|
|
4538
|
+
if (old?.selection === void 0) delete row.selection;
|
|
4539
|
+
else row.selection = old.selection;
|
|
4540
|
+
}
|
|
4532
4541
|
});
|
|
4533
4542
|
} finally {
|
|
4534
4543
|
tracked.close();
|
|
@@ -4559,18 +4568,19 @@ function runTransaction(doc, mirror, edit, options, setReject) {
|
|
|
4559
4568
|
const { captionId, audioScriptId, start, end } = step.selection;
|
|
4560
4569
|
const text = bodyFor(audioScriptId);
|
|
4561
4570
|
const from = text?.getCursor(start, 0), to = text?.getCursor(end, 0);
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4571
|
+
const row = captionRow(doc, captionId);
|
|
4572
|
+
if (!from || !to || !row) throw new Error("Unable to construct prepared range");
|
|
4573
|
+
row.set("selection", captionContentCodec.encode({
|
|
4565
4574
|
audioScriptId,
|
|
4566
4575
|
start: from,
|
|
4567
4576
|
end: to
|
|
4568
4577
|
}));
|
|
4569
4578
|
} else {
|
|
4570
|
-
const
|
|
4571
|
-
if (step.value
|
|
4572
|
-
if (
|
|
4573
|
-
|
|
4579
|
+
const row = captionRow(doc, step.id);
|
|
4580
|
+
if (step.value !== void 0) {
|
|
4581
|
+
if (!row) throw new Error("Prepared Caption is missing");
|
|
4582
|
+
row.set("selection", captionContentCodec.encode(step.value));
|
|
4583
|
+
} else if (row?.get("selection") !== void 0) row.delete("selection");
|
|
4574
4584
|
}
|
|
4575
4585
|
}
|
|
4576
4586
|
});
|
|
@@ -4793,6 +4803,7 @@ function voiceoverPlan(s, input, result) {
|
|
|
4793
4803
|
rate: void 0,
|
|
4794
4804
|
fillTimeline: void 0
|
|
4795
4805
|
},
|
|
4806
|
+
source: result.mediaId,
|
|
4796
4807
|
...input.volume === void 0 ? {} : { volume: input.volume }
|
|
4797
4808
|
}
|
|
4798
4809
|
} : {
|
|
@@ -4804,15 +4815,9 @@ function voiceoverPlan(s, input, result) {
|
|
|
4804
4815
|
inMs: 0,
|
|
4805
4816
|
outMs: input.media.durationMs
|
|
4806
4817
|
},
|
|
4818
|
+
source: result.mediaId,
|
|
4807
4819
|
...input.volume === void 0 ? {} : { volume: input.volume }
|
|
4808
4820
|
}
|
|
4809
|
-
}, {
|
|
4810
|
-
type: "set-relation",
|
|
4811
|
-
table: "clip-source-relations",
|
|
4812
|
-
value: {
|
|
4813
|
-
clipId: result.clipId,
|
|
4814
|
-
sourceId: result.mediaId
|
|
4815
|
-
}
|
|
4816
4821
|
});
|
|
4817
4822
|
if (!existing || trackOf(s, result.clipId) !== input.trackId) mutations.push({
|
|
4818
4823
|
type: existing ? "move-node" : "insert-node",
|
|
@@ -4822,13 +4827,14 @@ function voiceoverPlan(s, input, result) {
|
|
|
4822
4827
|
mutations.push(...placementMutations(s, result.clipId, input.placement));
|
|
4823
4828
|
uniqueIds(result.captionClipIds);
|
|
4824
4829
|
const retained = new Set(result.captionClipIds);
|
|
4825
|
-
const oldCaptions =
|
|
4830
|
+
const oldCaptions = anchoredTo(s, new Set([result.clipId])).filter((id) => requireTrack(s, trackOf(s, id)).role === "caption");
|
|
4826
4831
|
mutations.push(...deleteClips(s, oldCaptions.filter((id) => !retained.has(id)), "cascade"));
|
|
4827
|
-
const
|
|
4832
|
+
const placement = mutations.find((m) => m.type === "patch-entity" && m.table === "clip-entities" && m.id === result.clipId && "anchor" in m.patch);
|
|
4833
|
+
const anchor = placement?.type === "patch-entity" && placement.table === "clip-entities" ? placement.patch.anchor : void 0;
|
|
4828
4834
|
const trackClips = resolveDslLayout(s).tracks.find((track) => track.trackId === input.trackId).clips;
|
|
4829
4835
|
const slot = trackClips.findIndex((clip) => clip.clipId === result.clipId);
|
|
4830
4836
|
const predecessor = trackClips.slice(0, slot < 0 ? trackClips.length : slot).findLast((clip) => clip.mode === "sequential" && !clip.fillTimeline);
|
|
4831
|
-
const speechStart = anchor?.
|
|
4837
|
+
const speechStart = anchor?.fallback_abs_ms !== void 0 ? anchor.fallback_abs_ms : predecessor ? predecessor.startMs + predecessor.durationMs : 0;
|
|
4832
4838
|
input.captions.forEach((caption, i) => {
|
|
4833
4839
|
const clipId = result.captionClipIds[i], captionId = result.captionIds[i];
|
|
4834
4840
|
validateWindow({
|
|
@@ -4837,7 +4843,7 @@ function voiceoverPlan(s, input, result) {
|
|
|
4837
4843
|
}, input.media.durationMs);
|
|
4838
4844
|
const previous = s["clip-entities"][clipId];
|
|
4839
4845
|
if (previous && !oldCaptions.includes(clipId)) throw new Error("Cannot overwrite another use’s Caption Clip");
|
|
4840
|
-
const previousSource =
|
|
4846
|
+
const previousSource = previous?.source;
|
|
4841
4847
|
const style = caption.style ?? (previousSource ? s["caption-entities"][previousSource]?.style : void 0);
|
|
4842
4848
|
mutations.push({
|
|
4843
4849
|
type: "create-entity",
|
|
@@ -4850,46 +4856,33 @@ function voiceoverPlan(s, input, result) {
|
|
|
4850
4856
|
audioScriptId: result.audioScriptId,
|
|
4851
4857
|
start: caption.range.start,
|
|
4852
4858
|
end: caption.range.end
|
|
4853
|
-
}
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
}
|
|
4859
|
+
});
|
|
4860
|
+
const anchor = {
|
|
4861
|
+
to: result.clipId,
|
|
4862
|
+
offsetMs: caption.startMs,
|
|
4863
|
+
fallback_abs_ms: speechStart + caption.startMs
|
|
4864
|
+
};
|
|
4865
|
+
const seqMarker = {
|
|
4866
|
+
inMs: 0,
|
|
4867
|
+
outMs: caption.durationMs
|
|
4868
|
+
};
|
|
4869
|
+
mutations.push(previous ? {
|
|
4863
4870
|
type: "patch-entity",
|
|
4864
4871
|
table: "clip-entities",
|
|
4865
4872
|
id: clipId,
|
|
4866
|
-
patch: {
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
|
|
4873
|
+
patch: {
|
|
4874
|
+
seqMarker,
|
|
4875
|
+
source: captionId,
|
|
4876
|
+
anchor
|
|
4877
|
+
}
|
|
4870
4878
|
} : {
|
|
4871
4879
|
type: "create-entity",
|
|
4872
4880
|
table: "clip-entities",
|
|
4873
4881
|
id: clipId,
|
|
4874
|
-
value: { seqMarker: {
|
|
4875
|
-
inMs: 0,
|
|
4876
|
-
outMs: caption.durationMs
|
|
4877
|
-
} }
|
|
4878
|
-
}, {
|
|
4879
|
-
type: "set-relation",
|
|
4880
|
-
table: "clip-source-relations",
|
|
4881
4882
|
value: {
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
}, {
|
|
4886
|
-
type: "set-relation",
|
|
4887
|
-
table: "time-anchor-relations",
|
|
4888
|
-
value: {
|
|
4889
|
-
from: clipId,
|
|
4890
|
-
to: result.clipId,
|
|
4891
|
-
offsetMs: caption.startMs,
|
|
4892
|
-
fallback_abs_ms: speechStart + caption.startMs
|
|
4883
|
+
seqMarker,
|
|
4884
|
+
source: captionId,
|
|
4885
|
+
anchor
|
|
4893
4886
|
}
|
|
4894
4887
|
});
|
|
4895
4888
|
if (!previous || trackOf(s, clipId) !== input.captionTrackId) mutations.push({
|
|
@@ -4950,11 +4943,10 @@ var DslEditor = class {
|
|
|
4950
4943
|
/** index is measured in the target's children after removing the moved Clip. */
|
|
4951
4944
|
moveClip(clipId, trackId, index, options) {
|
|
4952
4945
|
const snapshot = this.dsl.getSnapshot();
|
|
4953
|
-
requireClip(snapshot, clipId);
|
|
4946
|
+
const clip = requireClip(snapshot, clipId);
|
|
4954
4947
|
const track = snapshot["track-entities"][trackId];
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
requireSource(snapshot, source.sourceId, track.role);
|
|
4948
|
+
if (!track) throw new Error("Missing target Track or Clip source");
|
|
4949
|
+
requireSource(snapshot, clip.source, track.role);
|
|
4958
4950
|
this.write("moveClip", {
|
|
4959
4951
|
clipId,
|
|
4960
4952
|
trackId,
|
|
@@ -4969,8 +4961,7 @@ var DslEditor = class {
|
|
|
4969
4961
|
trimClip(clipId, window, options) {
|
|
4970
4962
|
const snapshot = this.dsl.getSnapshot(), clip = requireClip(snapshot, clipId);
|
|
4971
4963
|
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;
|
|
4964
|
+
const source = snapshot["media-entities"][clip.source] ?? snapshot["caption-entities"][clip.source];
|
|
4974
4965
|
if (!source) throw new Error("Cannot trim a Clip with a missing source");
|
|
4975
4966
|
const patch = {};
|
|
4976
4967
|
if (window.inMs !== void 0) patch.inMs = window.inMs;
|
|
@@ -5083,9 +5074,10 @@ var DslEditor = class {
|
|
|
5083
5074
|
if (!caption) throw new Error("Missing Caption");
|
|
5084
5075
|
this.dsl.transact((tx) => {
|
|
5085
5076
|
tx.applyMutations([{
|
|
5086
|
-
type: "
|
|
5087
|
-
table: "caption-
|
|
5088
|
-
id:
|
|
5077
|
+
type: "patch-entity",
|
|
5078
|
+
table: "caption-entities",
|
|
5079
|
+
id: captionId,
|
|
5080
|
+
patch: { selection: void 0 }
|
|
5089
5081
|
}]);
|
|
5090
5082
|
if (caption.text === void 0) tx.draft["caption-entities"][captionId].text = text;
|
|
5091
5083
|
else if (caption.text !== text) tx.editCaptionText(captionId, [...caption.text.length ? [{
|
|
@@ -5227,37 +5219,28 @@ var DslEditor = class {
|
|
|
5227
5219
|
fillTimeline: true
|
|
5228
5220
|
};
|
|
5229
5221
|
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
|
-
}
|
|
5222
|
+
const mutations = [...source.mutations, old ? {
|
|
5223
|
+
type: "patch-entity",
|
|
5224
|
+
table: "clip-entities",
|
|
5225
|
+
id,
|
|
5226
|
+
patch: {
|
|
5227
|
+
seqMarker: {
|
|
5228
|
+
...seqMarker,
|
|
5229
|
+
rate: void 0
|
|
5230
|
+
},
|
|
5231
|
+
source: source.id,
|
|
5232
|
+
...volume
|
|
5259
5233
|
}
|
|
5260
|
-
|
|
5234
|
+
} : {
|
|
5235
|
+
type: "create-entity",
|
|
5236
|
+
table: "clip-entities",
|
|
5237
|
+
id,
|
|
5238
|
+
value: {
|
|
5239
|
+
seqMarker,
|
|
5240
|
+
source: source.id,
|
|
5241
|
+
...volume
|
|
5242
|
+
}
|
|
5243
|
+
}];
|
|
5261
5244
|
if (!old) mutations.push({
|
|
5262
5245
|
type: "insert-node",
|
|
5263
5246
|
entityId: id,
|
|
@@ -5770,28 +5753,19 @@ function prepare(input, options) {
|
|
|
5770
5753
|
captionId: sourceId,
|
|
5771
5754
|
...selected
|
|
5772
5755
|
});
|
|
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
5756
|
}
|
|
5794
5757
|
if (outMs <= inMs || !sourceId) fail(`part_library/${id}`, "Expected a nonempty source window");
|
|
5758
|
+
let anchor;
|
|
5759
|
+
if (placement.role !== "video_clip") {
|
|
5760
|
+
const host = hosts.get(id);
|
|
5761
|
+
const speechId = part.caption?.speech_part_id;
|
|
5762
|
+
const offsetMs = host?.offset ?? (speechId ? milliseconds(part.caption?.start_ms ?? 0, `part_library/${id}/start_ms`) : placement.abs);
|
|
5763
|
+
anchor = {
|
|
5764
|
+
to: host?.to ?? speechId ?? timelineId,
|
|
5765
|
+
offsetMs,
|
|
5766
|
+
fallback_abs_ms: placement.abs
|
|
5767
|
+
};
|
|
5768
|
+
}
|
|
5795
5769
|
mutations.push({
|
|
5796
5770
|
type: "create-entity",
|
|
5797
5771
|
table: "clip-entities",
|
|
@@ -5803,36 +5777,15 @@ function prepare(input, options) {
|
|
|
5803
5777
|
...speed ? { rate: speed.rate } : {},
|
|
5804
5778
|
...part.bgm ? { fillTimeline: true } : {}
|
|
5805
5779
|
},
|
|
5780
|
+
source: sourceId,
|
|
5781
|
+
...anchor === void 0 ? {} : { anchor },
|
|
5806
5782
|
...volume === void 0 ? {} : { volume }
|
|
5807
5783
|
}
|
|
5808
|
-
}, {
|
|
5809
|
-
type: "set-relation",
|
|
5810
|
-
table: "clip-source-relations",
|
|
5811
|
-
value: {
|
|
5812
|
-
clipId: id,
|
|
5813
|
-
sourceId
|
|
5814
|
-
}
|
|
5815
5784
|
}, {
|
|
5816
5785
|
type: "insert-node",
|
|
5817
5786
|
entityId: id,
|
|
5818
5787
|
parentId: placement.trackId
|
|
5819
5788
|
});
|
|
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
5789
|
}
|
|
5837
5790
|
return {
|
|
5838
5791
|
timelineId,
|