@mengine/medeo-client 2.0.1-alpha.2 → 2.0.1-alpha.21
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 +31 -0
- package/dist/{document-BSAUud6c.js → document-BXQcpnOg.js} +576 -332
- package/dist/{index-B31KeDzA.d.ts → index-CzDNr1Tb.d.ts} +297 -184
- package/dist/index.d.ts +594 -320
- package/dist/index.js +2221 -370
- package/dist/{loro-relay-doc-ssdYpuef.js → loro-relay-doc-cJSY-uau.js} +7 -2
- package/dist/relay.js +1 -1
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +4 -19
- package/package.json +9 -9
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as validateVideoDocument, A as EntityTimelineProjectionError, B as variantBaseEntityIds, C as equalJson, Ct as partUnionToDraft, D as projectEntityTimeline, Dt as bytesToBase64, E as resolveFieldPath, Et as base64ToBytes, F as assemblePhoneticScriptContent, G as isMediaAssetVariantKind, H as createEntityId, I as assembleScriptText, J as derivePositionFromAbs, K as buildInitialVideoDocument, L as findComposedAudioScript, M as ScriptCompositionError, N as assembleCaptionContent, O as EntityProjectionGraph, P as assembleEntityContent, Q as assertValidVideoDocument, R as selectAudioScriptSegment, S as canonicalJson, St as speedOf, T as assertFieldChanges, Tt as videoDocumentMirrorSchema, U as createRelationId, V as isJsonObject, W as hasSequence, X as toVideoDocument, Y as fromVideoDocument, Z as VideoDocumentValidationError, _ as audioScriptAssetOf, _t as isEmptyVideoClip, a as createMirrorVideoDocument, at as hasResolvedPlacement, b as phoneticScriptAssetOf, bt as DEFAULT_UNIT_TIME_MS, c as DocumentMutationGuard, ct as cascadeAfterVideoClipChanges, d as LoroEntityDraft, dt as reassignSpeechesToVideoClipsByTime, et as partUnionSchema, f as readEntityDocument, ft as recalculateTimelineDuration, g as audioScriptAssetHash, gt as TIMELINE_SKELETON_DURATION_MS, h as audioScriptAssetFields, ht as syncAggregatedClipsTimePosition, i as MirrorVideoDocumentAdapter, it as findLaneTrack, j as decodeEntityRelationRows, k as resolveEntityTimelineLayout, l as LORO_ENTITY_SCHEMA, lt as arrangeMainTrackSeamlessly, m as audioScriptAssetContent, mt as resolveSpeechOverlapByShiftingVideos, n as createPlainMemoryAdapter, nt as LANE_KINDS_IN_STACK_ORDER, o as createMirrorVideoDocumentAdapter, ot as laneTrackId, p as entityOrderGroups, pt as resolveAllSpeechOverlaps, q as buildSpeechHostMap, r as generatePartId, rt as ensureLaneTrack, s as readVideoDocumentFromDraft, st as solveVideoDocument, t as PlainMemoryAdapter, tt as videoDocumentSchema, u as LoroEntityDocument, ut as fillMainTrackTimeGaps, v as phoneticScriptAssetContent, vt as partDurationMs, w as applyFieldChanges, wt as recordEntries, x as scriptSourceAssetOf, xt as effectiveVideoClipDurationMs, y as phoneticScriptAssetHash, yt as safeDurationMs, z as updateEntityFields } from "./document-BXQcpnOg.js";
|
|
2
2
|
import { addSpeechesInputSchema, addVideoClipsInputSchema, adjustBgmVolumeInputSchema, adjustSpeechVolumeInputSchema, adjustVideoClipDurationInputSchema, adjustVideoClipVolumeInputSchema, changeSpeechScriptInputSchema, changeSpeechVoiceInputSchema, deleteBgmInputSchema, deleteSpeechesInputSchema, deleteVideoClipsInputSchema, moveSpeechesInputSchema, moveVideoClipsByAnchorInputSchema, moveVideoClipsInputSchema, replaceVideoClipContentInputSchema, replaceVideoClipSequenceInputSchema, setBgmInputSchema, setCaptionStyleInputSchema, setCaptionVisibilityInputSchema, setVideoClipSpeedShiftInputSchema, t as schemas_exports } from "./schemas.js";
|
|
3
|
-
import { LoroDoc, VersionVector } from "loro-crdt";
|
|
3
|
+
import { LoroDoc, UndoManager, VersionVector } from "loro-crdt";
|
|
4
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
5
|
+
import { bytesToHex } from "@noble/hashes/utils.js";
|
|
4
6
|
import { ClientServerSynchronizer, DocManager } from "@mengine/sync";
|
|
5
7
|
import { DisposableSet, EventBus, Task } from "@mengine/utils";
|
|
6
8
|
import { BaseDocStorage, DummyConnection } from "@mengine/storage";
|
|
@@ -60,12 +62,6 @@ var MengineHttpClient = class {
|
|
|
60
62
|
async fetchSnapshot() {
|
|
61
63
|
return await this.requestJson("snapshot");
|
|
62
64
|
}
|
|
63
|
-
async bootstrapSnapshot(snapshot) {
|
|
64
|
-
return await this.requestJson("bootstrap", {
|
|
65
|
-
method: "POST",
|
|
66
|
-
body: JSON.stringify({ snapshot: bytesToBase64(snapshot) })
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
65
|
/**
|
|
70
66
|
* Loro VV-diff pull: send the caller's oplog `VersionVector.encode` as `from`
|
|
71
67
|
* (omit for a full pull) and receive exactly the updates it is missing plus
|
|
@@ -168,9 +164,6 @@ async function safeReadJson$1(response) {
|
|
|
168
164
|
//#endregion
|
|
169
165
|
//#region src/client/sse.ts
|
|
170
166
|
async function readMengineEventStream(options) {
|
|
171
|
-
const onUpdate = (event) => {
|
|
172
|
-
options.onUpdate(event);
|
|
173
|
-
};
|
|
174
167
|
const response = await options.client.fetch(options.client.eventsUrl(), {
|
|
175
168
|
headers: options.client.headers(),
|
|
176
169
|
signal: options.signal
|
|
@@ -189,15 +182,19 @@ async function readMengineEventStream(options) {
|
|
|
189
182
|
while (splitAt >= 0) {
|
|
190
183
|
const frame = buffer.slice(0, splitAt);
|
|
191
184
|
buffer = buffer.slice(buffer[splitAt] === "\r" ? splitAt + 4 : splitAt + 2);
|
|
192
|
-
handleSseFrame(frame,
|
|
185
|
+
handleSseFrame(frame, options);
|
|
193
186
|
splitAt = findSseFrameBoundary(buffer);
|
|
194
187
|
}
|
|
195
188
|
}
|
|
196
189
|
}
|
|
197
|
-
function handleSseFrame(frame,
|
|
190
|
+
function handleSseFrame(frame, options) {
|
|
198
191
|
const event = parseSseFrame(frame);
|
|
192
|
+
if (event.event === "resync") {
|
|
193
|
+
options.onResync?.();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
199
196
|
if (event.event !== "update" || event.data.length === 0) return;
|
|
200
|
-
onUpdate(JSON.parse(event.data.join("\n")));
|
|
197
|
+
options.onUpdate(JSON.parse(event.data.join("\n")));
|
|
201
198
|
}
|
|
202
199
|
function parseSseFrame(frame) {
|
|
203
200
|
let event = null;
|
|
@@ -226,7 +223,8 @@ function compileEntityRows(snapshot, rows, validate) {
|
|
|
226
223
|
}
|
|
227
224
|
/** Compile an edit on an existing peer, retaining its native local undo history. */
|
|
228
225
|
function applyEntityRows(editor, rows) {
|
|
229
|
-
const
|
|
226
|
+
const baseline = editor.snapshot();
|
|
227
|
+
const before = baseline.rows;
|
|
230
228
|
const oldEntities = new Map(before.entities.map((row) => [row.entityId, row]));
|
|
231
229
|
const newEntities = new Map(rows.entities.map((row) => [row.entityId, row]));
|
|
232
230
|
const oldRelations = new Map(before.relations.map((row) => [row.relationId, row]));
|
|
@@ -242,10 +240,16 @@ function applyEntityRows(editor, rows) {
|
|
|
242
240
|
if (!equalJson(row.metadata, next.metadata) || !equalJson(row.trace, next.trace)) draft.patchRelation(row.relationId, next.metadata, next.trace);
|
|
243
241
|
}
|
|
244
242
|
}
|
|
243
|
+
let attached = baseline.project.audioScriptEntityId !== void 0;
|
|
245
244
|
for (const row of rows.entities) {
|
|
246
245
|
const previous = oldEntities.get(row.entityId);
|
|
247
|
-
if (!previous)
|
|
248
|
-
|
|
246
|
+
if (!previous) {
|
|
247
|
+
draft.create(row);
|
|
248
|
+
if (!attached && row.entityKind === "audio-script") {
|
|
249
|
+
draft.attach("audioScriptEntityId", String(row.entityId));
|
|
250
|
+
attached = true;
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
249
253
|
if (previous.entityKind !== row.entityKind) throw new Error("Entity kind cannot change");
|
|
250
254
|
if (!equalJson(previous.payload, row.payload)) draft.replaceOwned(row.entityId, row.payload);
|
|
251
255
|
}
|
|
@@ -288,66 +292,59 @@ const roleOrder$1 = {
|
|
|
288
292
|
/**
|
|
289
293
|
* MEngine editor policy, not a cardinality restriction on the general-purpose DSL.
|
|
290
294
|
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
* are rejected, not migrated. Caption composes AudioScript text and may associate with a physical text Asset.
|
|
295
|
+
* Resources live on Asset entities and are reached through `from-asset`: no
|
|
296
|
+
* other entity carries a locator, one Asset claims each `(system, key)` pair,
|
|
297
|
+
* and media reaches at most one Asset. Media whose bytes are not named through
|
|
298
|
+
* that Relation is rejected rather than guessed at.
|
|
296
299
|
*/
|
|
297
300
|
function assertCanonicalEditorResources(rows) {
|
|
298
301
|
if (rows.entities.filter((row) => row.entityKind === "timeline").length > 1) throw new EntityTimelineProjectionError("Editor requires exactly one Timeline");
|
|
299
302
|
for (const role of Object.keys(roleOrder$1)) if (rows.entities.filter((row) => row.entityKind === "track" && row.payload.role === role).length > 1) throw new EntityTimelineProjectionError(`Editor has multiple ${role} Tracks`);
|
|
300
303
|
const identityByAssetKey = /* @__PURE__ */ new Map();
|
|
301
|
-
const kinds = new Map(rows.entities.map((row) => [String(row.entityId), row.entityKind]));
|
|
302
304
|
for (const entity of rows.entities) {
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
305
|
+
if (entity.entityKind === "asset") {
|
|
306
|
+
const locator = memotaLocator(entity.payload);
|
|
307
|
+
if (locator === void 0) throw new EntityTimelineProjectionError(`Asset ${entity.entityId} must state where its bytes live`);
|
|
308
|
+
const key = JSON.stringify([locator.system, locator.key]);
|
|
309
|
+
const previous = identityByAssetKey.get(key);
|
|
310
|
+
if (previous !== void 0 && previous !== entity.entityId) throw new EntityTimelineProjectionError(`Duplicate Asset for ${key}; reuse ${previous}`);
|
|
311
|
+
identityByAssetKey.set(key, entity.entityId);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (memotaLocator(entity.payload) !== void 0) throw new EntityTimelineProjectionError(`Entity ${entity.entityId} must not carry a locator; link its Asset with from-asset`);
|
|
311
315
|
}
|
|
312
|
-
for (const
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
if (mediaEndpoint !== void 0) throw new EntityTimelineProjectionError(`Cannot bind media Entity ${mediaEndpoint} through physical-asset Relation ${relation.relationId}; media owns its Asset identity directly`);
|
|
316
|
+
for (const entity of rows.entities) {
|
|
317
|
+
if (!isMediaAssetVariantKind(entity.entityKind)) continue;
|
|
318
|
+
if (assetIdsOf(rows, entity.entityId).length !== 1) throw new EntityTimelineProjectionError(`Media Entity ${entity.entityId} requires exactly one from-asset Asset`);
|
|
316
319
|
}
|
|
317
320
|
}
|
|
321
|
+
/** The Asset ids one entity reaches, in row order. */
|
|
322
|
+
function assetIdsOf(rows, entityId) {
|
|
323
|
+
const assets = new Set(rows.entities.filter((row) => row.entityKind === "asset").map((row) => String(row.entityId)));
|
|
324
|
+
return rows.relations.filter((relation) => relation.relationKind === "from-asset").map((relation) => String(relation.endpoint0EntityId) === entityId ? String(relation.endpoint1EntityId) : String(relation.endpoint1EntityId) === entityId ? String(relation.endpoint0EntityId) : void 0).filter((id) => id !== void 0 && assets.has(id));
|
|
325
|
+
}
|
|
318
326
|
/**
|
|
319
327
|
* Commit-transition policy shared by the editor, the HTTP commit boundary, the
|
|
320
328
|
* host tool, and the entity stores. It compares the authoritative before and
|
|
321
|
-
* after rows of one CAS commit and enforces the
|
|
322
|
-
*
|
|
329
|
+
* after rows of one CAS commit and enforces the resource rules on the complete
|
|
330
|
+
* after state, with no exemption for historical graphs:
|
|
323
331
|
*
|
|
324
|
-
* 1. every
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
* another asset id.
|
|
332
|
+
* 1. every media Entity reaches exactly one Asset through `from-asset`;
|
|
333
|
+
* 2. no entity other than an Asset carries a locator;
|
|
334
|
+
* 3. an Asset's locator is immutable — pointing it at other bytes would
|
|
335
|
+
* silently rewrite every entity already made from it.
|
|
329
336
|
*
|
|
330
|
-
*
|
|
331
|
-
* implicitly. Deleting media is explicit and allowed.
|
|
337
|
+
* Deleting media is explicit and allowed.
|
|
332
338
|
*/
|
|
333
339
|
function assertMediaAssetWritePolicy(before, after) {
|
|
334
340
|
const beforeEntities = new Map(before.entities.map((row) => [String(row.entityId), row]));
|
|
335
|
-
|
|
341
|
+
assertCanonicalEditorResources(after);
|
|
336
342
|
for (const row of after.entities) {
|
|
337
|
-
if (
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (
|
|
342
|
-
const mediaEndpoint = [relation.endpoint0EntityId, relation.endpoint1EntityId].find((endpoint) => isMediaAssetVariantKind(afterKinds.get(String(endpoint)) ?? ""));
|
|
343
|
-
if (mediaEndpoint !== void 0) throw new EntityTimelineProjectionError(`Cannot bind media Entity ${mediaEndpoint} through physical-asset Relation ${relation.relationId}; media owns its Asset identity directly`);
|
|
344
|
-
}
|
|
345
|
-
for (const row of after.entities) {
|
|
346
|
-
if (!isMediaAssetVariantKind(row.entityKind)) continue;
|
|
347
|
-
const priorExternal = memotaLocator(beforeEntities.get(String(row.entityId))?.payload.external);
|
|
348
|
-
if (priorExternal === void 0) continue;
|
|
349
|
-
const nextExternal = memotaLocator(row.payload.external);
|
|
350
|
-
if (nextExternal !== void 0 && (nextExternal.system !== priorExternal.system || nextExternal.key !== priorExternal.key)) throw new EntityTimelineProjectionError(`Media Entity ${row.entityId} cannot rewrite its Asset external identity; create a new Entity instead`);
|
|
343
|
+
if (row.entityKind !== "asset") continue;
|
|
344
|
+
const prior = memotaLocator(beforeEntities.get(String(row.entityId))?.payload);
|
|
345
|
+
if (prior === void 0) continue;
|
|
346
|
+
const next = memotaLocator(row.payload);
|
|
347
|
+
if (next !== void 0 && (next.system !== prior.system || next.key !== prior.key)) throw new EntityTimelineProjectionError(`Asset ${row.entityId} cannot be pointed at other bytes; create a new Asset instead`);
|
|
351
348
|
}
|
|
352
349
|
}
|
|
353
350
|
function memotaLocator(value) {
|
|
@@ -362,23 +359,14 @@ function memotaLocator(value) {
|
|
|
362
359
|
* explicit project attachment, multiple candidates are ambiguous. Existing
|
|
363
360
|
* content IDs are reused here; Loro editing advances their immutable versions.
|
|
364
361
|
*/
|
|
365
|
-
function ensureDocumentAudioScript(entities,
|
|
362
|
+
function ensureDocumentAudioScript(entities, selectedEntityId) {
|
|
366
363
|
if (selectedEntityId != null) {
|
|
367
364
|
if (!entities.some((row) => row.entityKind === "audio-script" && row.entityId === selectedEntityId)) throw new EntityTimelineProjectionError(`Selected AudioScript ${selectedEntityId} does not exist`);
|
|
368
365
|
return selectedEntityId;
|
|
369
366
|
}
|
|
370
367
|
const scripts = entities.filter((row) => row.entityKind === "audio-script");
|
|
371
368
|
if (scripts.length > 1) throw new EntityTimelineProjectionError("Select an AudioScript for the editor panel when multiple scripts exist");
|
|
372
|
-
|
|
373
|
-
if (existing !== void 0) return existing.entityId;
|
|
374
|
-
const id = createEntityId(idFactory("entity"));
|
|
375
|
-
if (entities.some((row) => row.entityId === id)) throw new Error(`Duplicate entity id ${id}`);
|
|
376
|
-
entities.push({
|
|
377
|
-
entityId: id,
|
|
378
|
-
entityKind: "audio-script",
|
|
379
|
-
payload: { segments: [] }
|
|
380
|
-
});
|
|
381
|
-
return id;
|
|
369
|
+
return scripts[0]?.entityId ?? null;
|
|
382
370
|
}
|
|
383
371
|
/** Add only missing editor structure; existing identities and owned settings are never replaced. */
|
|
384
372
|
function ensureEditorFoundation(initial, idFactory = (prefix) => `${prefix}_${globalThis.crypto.randomUUID()}`, timelinePayload = {}, selectedAudioScriptEntityId) {
|
|
@@ -432,7 +420,7 @@ function ensureEditorFoundation(initial, idFactory = (prefix) => `${prefix}_${gl
|
|
|
432
420
|
}
|
|
433
421
|
trackEntityIds[role] = track.entityId;
|
|
434
422
|
}
|
|
435
|
-
const audioScriptEntityId = ensureDocumentAudioScript(entities,
|
|
423
|
+
const audioScriptEntityId = ensureDocumentAudioScript(entities, selectedAudioScriptEntityId);
|
|
436
424
|
return {
|
|
437
425
|
rows: {
|
|
438
426
|
entities,
|
|
@@ -500,15 +488,16 @@ function parseWireState(value, expectedDocId) {
|
|
|
500
488
|
if (value.doc_id !== expectedDocId) throw new Error(`Entity-state response doc_id mismatch: expected "${expectedDocId}"`);
|
|
501
489
|
const rows = parseWireRows(value.rows);
|
|
502
490
|
decodeEntityRelationRows(rows, numericMarkerValidation$1);
|
|
503
|
-
|
|
491
|
+
const attached = value.audio_script_entity_id;
|
|
492
|
+
if (attached !== null && typeof attached !== "string") throw new Error("Invalid document AudioScript identity");
|
|
504
493
|
if (typeof value.loro_snapshot !== "string") throw new Error("Missing causal Loro snapshot");
|
|
505
494
|
const state = {
|
|
506
495
|
revision: value.revision,
|
|
507
496
|
loroSnapshot: value.loro_snapshot,
|
|
508
497
|
rows,
|
|
509
|
-
audioScriptEntityId:
|
|
498
|
+
audioScriptEntityId: attached
|
|
510
499
|
};
|
|
511
|
-
readDocumentAudioScript(state);
|
|
500
|
+
if (attached !== null) readDocumentAudioScript(state);
|
|
512
501
|
return state;
|
|
513
502
|
}
|
|
514
503
|
function parseWireRows(value) {
|
|
@@ -567,16 +556,38 @@ const numericCoordinates = { compareMarkerPoints: (_marker, _range, left, right)
|
|
|
567
556
|
if (typeof left !== "number" || typeof right !== "number" || !Number.isFinite(left) || !Number.isFinite(right)) throw new Error("Media editor requires numeric coordinates");
|
|
568
557
|
return left - right;
|
|
569
558
|
} };
|
|
559
|
+
/** Import only the fixed ASR artifact; Caption entities are authored separately. */
|
|
560
|
+
function importCaptionAsset(initial, fact, idFactory = (prefix) => `${prefix}_${globalThis.crypto.randomUUID()}`) {
|
|
561
|
+
if (!fact.assetId || fact.assetId.trim() !== fact.assetId) throw new Error("Expected a caption asset id");
|
|
562
|
+
if (fact.storageKey !== void 0 && (!fact.storageKey || fact.storageKey.trim() !== fact.storageKey)) throw new Error("Caption asset storage key must be non-empty and trimmed");
|
|
563
|
+
decodeEntityRelationRows(initial, numericCoordinates);
|
|
564
|
+
assertCanonicalEditorResources(initial);
|
|
565
|
+
const entities = structuredClone([...initial.entities]);
|
|
566
|
+
const relations = structuredClone([...initial.relations]);
|
|
567
|
+
const asset = ensureAsset(entities, relations, {
|
|
568
|
+
system: "memota",
|
|
569
|
+
key: fact.assetId,
|
|
570
|
+
...fact.storageKey === void 0 ? {} : { storageKey: fact.storageKey }
|
|
571
|
+
}, idFactory);
|
|
572
|
+
const rows = {
|
|
573
|
+
entities,
|
|
574
|
+
relations
|
|
575
|
+
};
|
|
576
|
+
decodeEntityRelationRows(rows, numericCoordinates);
|
|
577
|
+
assertCanonicalEditorResources(rows);
|
|
578
|
+
return {
|
|
579
|
+
rows,
|
|
580
|
+
assetEntityId: asset.entityId
|
|
581
|
+
};
|
|
582
|
+
}
|
|
570
583
|
/**
|
|
571
584
|
* Import at the editor boundary, not at asset generation time. Existing
|
|
572
585
|
* provenance is retained.
|
|
573
586
|
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
* the
|
|
577
|
-
*
|
|
578
|
-
* (`asset` row plus a `physical-asset` binding) are not reused and are
|
|
579
|
-
* rejected by the canonical resource policy instead of being migrated.
|
|
587
|
+
* The bytes are named once, on an Asset entity, and the media entity reaches
|
|
588
|
+
* them through a `from-asset` Relation. Re-importing the same `(system, key)`
|
|
589
|
+
* reuses that Asset and the media already made from it, so one stored resource
|
|
590
|
+
* is never copied into several rows.
|
|
580
591
|
*/
|
|
581
592
|
function importMediaAsset(initial, fact, idFactory = (prefix) => `${prefix}_${globalThis.crypto.randomUUID()}`) {
|
|
582
593
|
if (!fact.assetId || fact.assetId.trim() !== fact.assetId) throw new Error("Expected an asset id");
|
|
@@ -584,60 +595,60 @@ function importMediaAsset(initial, fact, idFactory = (prefix) => `${prefix}_${gl
|
|
|
584
595
|
"image",
|
|
585
596
|
"video",
|
|
586
597
|
"audio",
|
|
587
|
-
"
|
|
598
|
+
"voiceover"
|
|
588
599
|
].includes(fact.kind)) throw new Error("Unsupported media kind");
|
|
589
600
|
if (fact.kind !== "image" && (!Number.isFinite(fact.durationMs) || fact.durationMs <= 0)) throw new Error(`${fact.kind} ${fact.assetId} requires its factual positive duration`);
|
|
590
|
-
if ((fact.kind === "audio" || fact.kind === "
|
|
601
|
+
if ((fact.kind === "audio" || fact.kind === "voiceover") && fact.storageKey.trim() === "") throw new Error(`${fact.kind} ${fact.assetId} requires its physical storage key`);
|
|
591
602
|
decodeEntityRelationRows(initial, numericCoordinates);
|
|
592
603
|
assertCanonicalEditorResources(initial);
|
|
593
604
|
const entities = structuredClone([...initial.entities]);
|
|
594
605
|
const relations = structuredClone([...initial.relations]);
|
|
595
|
-
const system = fact.kind === "
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
606
|
+
const system = fact.kind === "voiceover" ? "memota-speech" : "memota";
|
|
607
|
+
const entityKind = fact.kind;
|
|
608
|
+
const timbre = fact.kind === "voiceover" ? fact.voice : void 0;
|
|
609
|
+
const durationMs = fact.kind === "image" ? null : fact.durationMs;
|
|
610
|
+
const asset = ensureAsset(entities, relations, {
|
|
611
|
+
system,
|
|
612
|
+
key: fact.assetId,
|
|
613
|
+
storageKey: fact.storageKey
|
|
614
|
+
}, idFactory);
|
|
615
|
+
const madeFromAsset = entities.filter((row) => row.entityId !== asset.entityId && assetIdOf(relations, row.entityId) === asset.entityId);
|
|
616
|
+
if (madeFromAsset.length > 1) throw new Error(`Duplicate media identities for ${fact.assetId}; reuse ${madeFromAsset[0].entityId}`);
|
|
617
|
+
const existing = madeFromAsset[0];
|
|
618
|
+
if (existing !== void 0) {
|
|
619
|
+
if (existing.entityKind !== entityKind || existing.payload.durationMs !== durationMs) throw new Error(`Media facts conflict with existing identity for ${fact.assetId}`);
|
|
620
|
+
const voiceEntityId = bindVoiceTimbre(entities, relations, existing.entityId, timbre, idFactory);
|
|
604
621
|
return {
|
|
605
622
|
rows: {
|
|
606
623
|
entities,
|
|
607
624
|
relations
|
|
608
625
|
},
|
|
609
|
-
contentEntityId:
|
|
626
|
+
contentEntityId: existing.entityId,
|
|
627
|
+
assetEntityId: asset.entityId,
|
|
628
|
+
...voiceEntityId
|
|
610
629
|
};
|
|
611
630
|
}
|
|
612
631
|
const entityId = createEntityId(idFactory("entity"));
|
|
613
632
|
if (entities.some((row) => row.entityId === entityId)) throw new Error(`Duplicate entity id ${entityId}`);
|
|
614
|
-
|
|
633
|
+
entities.push({
|
|
615
634
|
entityId,
|
|
616
|
-
entityKind
|
|
617
|
-
payload: {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
...fact.kind === "voice" && fact.voice !== void 0 ? { voice: {
|
|
634
|
-
system: fact.voice.system,
|
|
635
|
-
key: fact.voice.key,
|
|
636
|
-
...fact.voice.name === void 0 ? {} : { name: fact.voice.name }
|
|
637
|
-
} } : {}
|
|
638
|
-
}
|
|
639
|
-
};
|
|
640
|
-
entities.push(variant);
|
|
635
|
+
entityKind,
|
|
636
|
+
payload: { durationMs }
|
|
637
|
+
});
|
|
638
|
+
relations.push(assetLink(entityId, asset.entityId, idFactory));
|
|
639
|
+
if (fact.kind === "voiceover" && fact.phoneticScriptEntityId !== void 0) {
|
|
640
|
+
const phonetic = entities.find((row) => String(row.entityId) === fact.phoneticScriptEntityId);
|
|
641
|
+
if (phonetic?.entityKind !== "phonetic-script") throw new Error(`Voiceover ${fact.assetId} requires the PhoneticScript it was spoken from`);
|
|
642
|
+
relations.push({
|
|
643
|
+
relationId: createRelationId(idFactory("relation")),
|
|
644
|
+
relationKind: "phonetic-script-render",
|
|
645
|
+
endpoint0EntityId: entityId,
|
|
646
|
+
endpoint1EntityId: phonetic.entityId,
|
|
647
|
+
metadata: {},
|
|
648
|
+
trace: {}
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
const voiceEntityId = bindVoiceTimbre(entities, relations, entityId, timbre, idFactory);
|
|
641
652
|
const rows = {
|
|
642
653
|
entities,
|
|
643
654
|
relations
|
|
@@ -646,33 +657,113 @@ function importMediaAsset(initial, fact, idFactory = (prefix) => `${prefix}_${gl
|
|
|
646
657
|
assertCanonicalEditorResources(rows);
|
|
647
658
|
return {
|
|
648
659
|
rows,
|
|
649
|
-
contentEntityId: entityId
|
|
660
|
+
contentEntityId: entityId,
|
|
661
|
+
assetEntityId: asset.entityId,
|
|
662
|
+
...voiceEntityId
|
|
650
663
|
};
|
|
651
664
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
if (
|
|
656
|
-
if (
|
|
657
|
-
|
|
658
|
-
|
|
665
|
+
/** One Asset per `(system, key)`: the bytes are named in exactly one row. */
|
|
666
|
+
function ensureAsset(entities, relations, locator, idFactory) {
|
|
667
|
+
const found = entities.find((row) => row.entityKind === "asset" && row.payload.system === locator.system && row.payload.key === locator.key);
|
|
668
|
+
if (found !== void 0) {
|
|
669
|
+
if (locator.storageKey === void 0 || found.payload.storageKey === locator.storageKey) return found;
|
|
670
|
+
if (found.payload.storageKey !== void 0) throw new Error(`Media facts conflict with existing storage key for ${locator.key}`);
|
|
671
|
+
const replacement = {
|
|
672
|
+
entityId: createEntityId(idFactory("entity")),
|
|
673
|
+
entityKind: "asset",
|
|
659
674
|
payload: {
|
|
660
|
-
...
|
|
661
|
-
storageKey:
|
|
675
|
+
...found.payload,
|
|
676
|
+
storageKey: locator.storageKey
|
|
662
677
|
}
|
|
663
678
|
};
|
|
679
|
+
if (entities.some((row) => row.entityId === replacement.entityId)) throw new Error(`Duplicate entity id ${replacement.entityId}`);
|
|
680
|
+
const bindings = relations.filter((relation) => relation.relationKind === "from-asset" && (relation.endpoint0EntityId === found.entityId || relation.endpoint1EntityId === found.entityId)).sort((left, right) => String(left.relationId).localeCompare(String(right.relationId)));
|
|
681
|
+
const replaced = new Set(bindings.map((relation) => relation.relationId));
|
|
682
|
+
const nextBindings = bindings.map((relation) => ({
|
|
683
|
+
...relation,
|
|
684
|
+
relationId: createRelationId(idFactory("relation")),
|
|
685
|
+
endpoint0EntityId: relation.endpoint0EntityId === found.entityId ? replacement.entityId : relation.endpoint0EntityId,
|
|
686
|
+
endpoint1EntityId: relation.endpoint1EntityId === found.entityId ? replacement.entityId : relation.endpoint1EntityId
|
|
687
|
+
}));
|
|
688
|
+
relations.splice(0, relations.length, ...relations.filter((relation) => !replaced.has(relation.relationId)), ...nextBindings);
|
|
689
|
+
replaceEntity$1(entities, found.entityId, replacement);
|
|
690
|
+
return replacement;
|
|
664
691
|
}
|
|
665
|
-
|
|
692
|
+
const asset = {
|
|
693
|
+
entityId: createEntityId(idFactory("entity")),
|
|
694
|
+
entityKind: "asset",
|
|
695
|
+
payload: {
|
|
696
|
+
system: locator.system,
|
|
697
|
+
key: locator.key,
|
|
698
|
+
...locator.storageKey === void 0 ? {} : { storageKey: locator.storageKey }
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
entities.push(asset);
|
|
702
|
+
return asset;
|
|
703
|
+
}
|
|
704
|
+
function assetLink(entityId, assetEntityId, idFactory) {
|
|
705
|
+
return {
|
|
706
|
+
relationId: createRelationId(idFactory("relation")),
|
|
707
|
+
relationKind: "from-asset",
|
|
708
|
+
endpoint0EntityId: createEntityId(entityId),
|
|
709
|
+
endpoint1EntityId: createEntityId(assetEntityId),
|
|
710
|
+
metadata: {},
|
|
711
|
+
trace: {}
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
function assetIdOf(relations, entityId) {
|
|
715
|
+
const link = relations.find((relation) => relation.relationKind === "from-asset" && (relation.endpoint0EntityId === entityId || relation.endpoint1EntityId === entityId));
|
|
716
|
+
if (link === void 0) return void 0;
|
|
717
|
+
return String(link.endpoint0EntityId === entityId ? link.endpoint1EntityId : link.endpoint0EntityId);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Attach the timbre a synthesized voiceover was rendered with. One Voice
|
|
721
|
+
* identity is shared by every take naming the same library entry, so an
|
|
722
|
+
* existing row is reused rather than duplicated. Recorded media declares no
|
|
723
|
+
* timbre and keeps no link.
|
|
724
|
+
*/
|
|
725
|
+
function bindVoiceTimbre(entities, relations, audioEntityId, descriptor, idFactory) {
|
|
726
|
+
const linked = relations.filter((relation) => relation.relationKind === "voice-timbre" && (relation.endpoint0EntityId === audioEntityId || relation.endpoint1EntityId === audioEntityId));
|
|
727
|
+
if (linked.length > 1) throw new Error(`Voiceover ${audioEntityId} has multiple Voice timbre relations`);
|
|
728
|
+
if (descriptor === void 0) {
|
|
729
|
+
if (linked.length > 0) throw new Error(`Recorded media ${audioEntityId} cannot keep a synthesized Voice timbre`);
|
|
730
|
+
return {};
|
|
731
|
+
}
|
|
732
|
+
const voice = entities.find((row) => row.entityKind === "voice" && sameVoiceDescriptor(row.payload.voice, descriptor)) ?? appendVoiceIdentity(entities, descriptor, idFactory);
|
|
733
|
+
const existing = linked[0];
|
|
734
|
+
if (existing === void 0) relations.push({
|
|
735
|
+
relationId: createRelationId(idFactory("relation")),
|
|
736
|
+
relationKind: "voice-timbre",
|
|
737
|
+
endpoint0EntityId: createEntityId(audioEntityId),
|
|
738
|
+
endpoint1EntityId: createEntityId(voice.entityId),
|
|
739
|
+
metadata: {},
|
|
740
|
+
trace: {}
|
|
741
|
+
});
|
|
742
|
+
else if (existing.endpoint0EntityId !== audioEntityId || existing.endpoint1EntityId !== voice.entityId) throw new Error(`Voiceover ${audioEntityId} already carries a different Voice timbre`);
|
|
743
|
+
return { voiceEntityId: voice.entityId };
|
|
744
|
+
}
|
|
745
|
+
function appendVoiceIdentity(entities, descriptor, idFactory) {
|
|
746
|
+
const voice = {
|
|
747
|
+
entityId: createEntityId(idFactory("entity")),
|
|
748
|
+
entityKind: "voice",
|
|
749
|
+
payload: { voice: {
|
|
750
|
+
system: descriptor.system,
|
|
751
|
+
key: descriptor.key,
|
|
752
|
+
...descriptor.name === void 0 ? {} : { name: descriptor.name }
|
|
753
|
+
} }
|
|
754
|
+
};
|
|
755
|
+
entities.push(voice);
|
|
756
|
+
return voice;
|
|
757
|
+
}
|
|
758
|
+
/** Two takes share a Voice identity only when they name the same library entry. */
|
|
759
|
+
function sameVoiceDescriptor(stored, descriptor) {
|
|
760
|
+
return isJsonObject(stored) && stored.system === descriptor.system && stored.key === descriptor.key && stored.name === descriptor.name;
|
|
666
761
|
}
|
|
667
762
|
function replaceEntity$1(entities, entityId, replacement) {
|
|
668
763
|
const index = entities.findIndex((row) => row.entityId === entityId);
|
|
669
764
|
if (index < 0) throw new Error(`Entity id ${entityId} does not exist`);
|
|
670
765
|
entities[index] = replacement;
|
|
671
766
|
}
|
|
672
|
-
function sameVoice(left, right) {
|
|
673
|
-
if (right === void 0) return left === void 0;
|
|
674
|
-
return isJsonObject(left) && left.system === right.system && left.key === right.key && left.name === right.name && Object.keys(left).every((key) => key === "system" || key === "key" || key === "name");
|
|
675
|
-
}
|
|
676
767
|
//#endregion
|
|
677
768
|
//#region src/entity-editor/entity-timeline-editor.ts
|
|
678
769
|
/**
|
|
@@ -705,7 +796,7 @@ const defaultIdFactory = (prefix) => `${prefix}_${globalThis.crypto.randomUUID()
|
|
|
705
796
|
* Each mutation is applied to a detached graph, validated as a complete DSL set,
|
|
706
797
|
* and committed only after validation succeeds.
|
|
707
798
|
*/
|
|
708
|
-
var EntityTimelineEditor = class {
|
|
799
|
+
var EntityTimelineEditor = class EntityTimelineEditor {
|
|
709
800
|
idFactory;
|
|
710
801
|
state;
|
|
711
802
|
constructor(initialRows, idFactory = defaultIdFactory) {
|
|
@@ -1016,49 +1107,65 @@ var EntityTimelineEditor = class {
|
|
|
1016
1107
|
});
|
|
1017
1108
|
}
|
|
1018
1109
|
setClipSpeed(input) {
|
|
1110
|
+
this.transact((draft) => this.setClipSpeedIntoDraft(draft, input));
|
|
1111
|
+
}
|
|
1112
|
+
/** Apply one typed Director speed effect as a single graph transaction. */
|
|
1113
|
+
setVideoClipSpeeds(input) {
|
|
1114
|
+
if (input.clips.length === 0) throw new Error("At least one Video Clip speed is required");
|
|
1115
|
+
if (new Set(input.clips.map((clip) => clip.videoClipEntityId)).size !== input.clips.length) throw new Error("Video Clip speed targets must be unique");
|
|
1019
1116
|
this.transact((draft) => {
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
if (content.entityKind === "image" && initialPayload.timeRemapping === void 0 && input.timeRemapping !== null) sourceRange = {
|
|
1031
|
-
start: 0,
|
|
1032
|
-
end: markerDuration(initialPayload, priorSource)
|
|
1033
|
-
};
|
|
1034
|
-
if (input.timeRemapping === null) {
|
|
1035
|
-
delete payload.timeRemapping;
|
|
1036
|
-
payload.sourceRange = sourceRange;
|
|
1037
|
-
payload.duration = {
|
|
1038
|
-
mode: "fixed",
|
|
1039
|
-
value: sourceRange.end - sourceRange.start
|
|
1040
|
-
};
|
|
1041
|
-
} else {
|
|
1042
|
-
const rate = finitePositive(input.timeRemapping.rate, "linear speed rate");
|
|
1043
|
-
const duration = Math.round((sourceRange.end - sourceRange.start) / rate);
|
|
1044
|
-
if (duration <= 0 || !Number.isSafeInteger(duration)) throw new Error("Linear speed produces an invalid duration");
|
|
1045
|
-
payload.sourceRange = sourceRange;
|
|
1046
|
-
payload.duration = {
|
|
1047
|
-
mode: "fixed",
|
|
1048
|
-
value: duration
|
|
1049
|
-
};
|
|
1050
|
-
payload.timeRemapping = {
|
|
1051
|
-
kind: "linear",
|
|
1052
|
-
rate,
|
|
1053
|
-
...input.timeRemapping.mode === void 0 ? {} : { mode: input.timeRemapping.mode }
|
|
1054
|
-
};
|
|
1117
|
+
for (const clip of input.clips) {
|
|
1118
|
+
const rate = speedModeRate(clip.mode);
|
|
1119
|
+
this.setClipSpeedIntoDraft(draft, {
|
|
1120
|
+
clipEntityId: clip.videoClipEntityId,
|
|
1121
|
+
timeRemapping: rate === 1 ? null : {
|
|
1122
|
+
kind: "linear",
|
|
1123
|
+
rate,
|
|
1124
|
+
mode: "constant"
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1055
1127
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
setClipSpeedIntoDraft(draft, input) {
|
|
1131
|
+
const topology = requireCanonicalClipOnRole(draft, input.clipEntityId, "video_clip");
|
|
1132
|
+
const content = requireSequenceContent(draft, topology.markerContentRelation.endpoint1EntityId);
|
|
1133
|
+
if (content.entityKind !== "video" && content.entityKind !== "image") throw new Error("Clip speed is supported only for Video or Image content");
|
|
1134
|
+
const initialPayload = cloneJsonObject(topology.marker.payload, "Sequence Marker payload");
|
|
1135
|
+
const priorSource = requirePositiveRange(initialPayload.sourceRange, "sourceRange");
|
|
1136
|
+
if (input.timeRemapping === null && initialPayload.timeRemapping === void 0) return;
|
|
1137
|
+
const payload = { ...cloneJsonObject(topology.marker.payload, "Sequence Marker payload") };
|
|
1138
|
+
let sourceRange = priorSource;
|
|
1139
|
+
if (content.entityKind === "image" && initialPayload.timeRemapping === void 0 && input.timeRemapping !== null) sourceRange = {
|
|
1140
|
+
start: 0,
|
|
1141
|
+
end: markerDuration(initialPayload, priorSource)
|
|
1142
|
+
};
|
|
1143
|
+
if (input.timeRemapping === null) {
|
|
1144
|
+
delete payload.timeRemapping;
|
|
1145
|
+
payload.sourceRange = sourceRange;
|
|
1146
|
+
payload.duration = {
|
|
1147
|
+
mode: "fixed",
|
|
1148
|
+
value: sourceRange.end - sourceRange.start
|
|
1149
|
+
};
|
|
1150
|
+
} else {
|
|
1151
|
+
const rate = finitePositive(input.timeRemapping.rate, "linear speed rate");
|
|
1152
|
+
const duration = Math.round((sourceRange.end - sourceRange.start) / rate);
|
|
1153
|
+
if (duration <= 0 || !Number.isSafeInteger(duration)) throw new Error("Linear speed produces an invalid duration");
|
|
1154
|
+
payload.sourceRange = sourceRange;
|
|
1155
|
+
payload.duration = {
|
|
1156
|
+
mode: "fixed",
|
|
1157
|
+
value: duration
|
|
1158
|
+
};
|
|
1159
|
+
payload.timeRemapping = {
|
|
1160
|
+
kind: "linear",
|
|
1161
|
+
rate,
|
|
1162
|
+
...input.timeRemapping.mode === void 0 ? {} : { mode: input.timeRemapping.mode }
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
resizeAbsoluteTarget(payload, markerDuration(payload, sourceRange));
|
|
1166
|
+
replaceEntity(draft, topology.marker.entityId, {
|
|
1167
|
+
...topology.marker,
|
|
1168
|
+
payload
|
|
1062
1169
|
});
|
|
1063
1170
|
}
|
|
1064
1171
|
trimClip(input) {
|
|
@@ -1208,20 +1315,20 @@ var EntityTimelineEditor = class {
|
|
|
1208
1315
|
hostClipEntityId: input.hostClipEntityId,
|
|
1209
1316
|
anchorOffset: input.anchorOffset
|
|
1210
1317
|
};
|
|
1211
|
-
if (input.placement !== void 0 && (input.hostClipEntityId !== void 0 || input.anchorOffset !== void 0)) throw new Error("Specify
|
|
1318
|
+
if (input.placement !== void 0 && (input.hostClipEntityId !== void 0 || input.anchorOffset !== void 0)) throw new Error("Specify voiceover placement or hostClipEntityId/anchorOffset, not both");
|
|
1212
1319
|
requireVolume(input.volume);
|
|
1213
1320
|
const imported = importMediaAsset(draft, input.media, this.idFactory);
|
|
1214
1321
|
replaceRows(draft, imported.rows);
|
|
1215
|
-
const
|
|
1322
|
+
const voiceover = requireEntityKind(draft, imported.contentEntityId, "voiceover");
|
|
1216
1323
|
const phonetic = this.requireComposedPhoneticScript(draft, input.phoneticScriptEntityId);
|
|
1217
|
-
this.ensurePhoneticRender(draft,
|
|
1324
|
+
this.ensurePhoneticRender(draft, voiceover.entityId, phonetic.entityId);
|
|
1218
1325
|
const speechTrack = this.ensureRoleTrack(draft, input.timelineEntityId, "speech");
|
|
1219
1326
|
const existing = draft.entities.find((row) => row.entityId === input.voiceoverClipEntityId);
|
|
1220
1327
|
let voiceoverClipEntityId;
|
|
1221
1328
|
if (existing === void 0) voiceoverClipEntityId = this.insertPlacedClipIntoDraft(draft, {
|
|
1222
1329
|
clipEntityId: input.voiceoverClipEntityId,
|
|
1223
1330
|
trackEntityId: speechTrack.entityId,
|
|
1224
|
-
contentEntityId:
|
|
1331
|
+
contentEntityId: voiceover.entityId,
|
|
1225
1332
|
sourceRange: {
|
|
1226
1333
|
start: 0,
|
|
1227
1334
|
end: input.media.durationMs
|
|
@@ -1245,7 +1352,7 @@ var EntityTimelineEditor = class {
|
|
|
1245
1352
|
});
|
|
1246
1353
|
this.relinkRelation(draft, topology.markerContentRelation, {
|
|
1247
1354
|
endpoint0EntityId: topology.marker.entityId,
|
|
1248
|
-
endpoint1EntityId:
|
|
1355
|
+
endpoint1EntityId: voiceover.entityId
|
|
1249
1356
|
});
|
|
1250
1357
|
this.applyClipPlacement(draft, topology.clip.entityId, placement);
|
|
1251
1358
|
const placed = requireClipTopology(draft, topology.clip.entityId);
|
|
@@ -1264,11 +1371,12 @@ var EntityTimelineEditor = class {
|
|
|
1264
1371
|
}
|
|
1265
1372
|
});
|
|
1266
1373
|
}
|
|
1267
|
-
this.reconcileVoiceoverCaptions(draft, voiceoverClipEntityId,
|
|
1374
|
+
this.reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, voiceover.entityId, phonetic.entityId, input.captions);
|
|
1268
1375
|
this.resolveOneVoiceoverOverlap(draft);
|
|
1269
1376
|
return {
|
|
1270
1377
|
voiceoverClipEntityId,
|
|
1271
|
-
|
|
1378
|
+
voiceoverAudioEntityId: voiceover.entityId,
|
|
1379
|
+
...imported.voiceEntityId === void 0 ? {} : { voiceEntityId: imported.voiceEntityId },
|
|
1272
1380
|
phoneticScriptEntityId: phonetic.entityId,
|
|
1273
1381
|
audioScriptEntityId: requireComposedAudioScript(draft, phonetic.entityId),
|
|
1274
1382
|
captionClipEntityIds: input.captions.map((caption) => caption.captionClipEntityId)
|
|
@@ -1383,13 +1491,7 @@ var EntityTimelineEditor = class {
|
|
|
1383
1491
|
entityId: input.captionEntityId === void 0 ? this.mintEntityId(draft) : requireAvailableEntityId(draft, input.captionEntityId),
|
|
1384
1492
|
entityKind: "caption",
|
|
1385
1493
|
payload: {
|
|
1386
|
-
|
|
1387
|
-
kind: "bounded",
|
|
1388
|
-
start: 0,
|
|
1389
|
-
end: durationMs
|
|
1390
|
-
},
|
|
1391
|
-
sampling: "native",
|
|
1392
|
-
coordinateSpace: "milliseconds",
|
|
1494
|
+
durationMs,
|
|
1393
1495
|
baseEntityIds: captionBaseIds(input.baseEntityIds),
|
|
1394
1496
|
selection,
|
|
1395
1497
|
...input.style === void 0 ? {} : { style: cloneJsonObject(input.style, "Caption style") }
|
|
@@ -1413,12 +1515,481 @@ var EntityTimelineEditor = class {
|
|
|
1413
1515
|
});
|
|
1414
1516
|
});
|
|
1415
1517
|
}
|
|
1518
|
+
#applyCaptionAssets(envelope) {
|
|
1519
|
+
return this.transact((draft) => {
|
|
1520
|
+
const input = envelope.input;
|
|
1521
|
+
const effectId = requireTrimmed(input.effectId, "Caption effect id");
|
|
1522
|
+
const identityScope = stableEffectScope(captionAssetsCommand, effectId, envelope.baselineFingerprint);
|
|
1523
|
+
const segments = validateAsrSegments(input.segments);
|
|
1524
|
+
const source = requireEntityKind(draft, input.source.entityId, input.source.entityKind);
|
|
1525
|
+
const track = requireCanonicalCaptionTrack(draft, input.timelineEntityId, input.trackEntityId);
|
|
1526
|
+
const signature = envelope.signature;
|
|
1527
|
+
const replay = resolveCaptionAssetsReplay(draft, input, effectId, signature, segments, source.entityId, track.entityId);
|
|
1528
|
+
if (replay !== void 0) return replay;
|
|
1529
|
+
const imported = importCaptionAsset(draft, input.captionAsset, stableEffectIdFactory(identityScope, "caption-asset"));
|
|
1530
|
+
if (imported.assetEntityId !== envelope.decisions.captionAsset.resultAssetEntityId) throw new Error("Frozen Caption Asset decision no longer matches the causal baseline");
|
|
1531
|
+
replaceRows(draft, imported.rows);
|
|
1532
|
+
const scriptId = requireAvailableEntityId(draft, stableEffectId("entity", identityScope, "audio-script"));
|
|
1533
|
+
const scriptMarkerId = requireAvailableEntityId(draft, stableEffectId("entity", identityScope, "audio-script-marker"));
|
|
1534
|
+
const asrStartMs = Math.min(...segments.map((segment) => segment.startMs));
|
|
1535
|
+
const asrEndMs = Math.max(...segments.map((segment) => segment.endMs));
|
|
1536
|
+
draft.entities.push({
|
|
1537
|
+
entityId: scriptId,
|
|
1538
|
+
entityKind: "audio-script",
|
|
1539
|
+
payload: { segments: segments.map(({ segmentId, text, language }) => ({
|
|
1540
|
+
segmentId,
|
|
1541
|
+
text,
|
|
1542
|
+
...language === void 0 ? {} : { language }
|
|
1543
|
+
})) }
|
|
1544
|
+
});
|
|
1545
|
+
draft.entities.push({
|
|
1546
|
+
entityId: scriptMarkerId,
|
|
1547
|
+
entityKind: "sequence-marker",
|
|
1548
|
+
payload: {
|
|
1549
|
+
sourceRange: {
|
|
1550
|
+
start: asrStartMs,
|
|
1551
|
+
end: asrEndMs
|
|
1552
|
+
},
|
|
1553
|
+
duration: {
|
|
1554
|
+
mode: "fixed",
|
|
1555
|
+
value: asrEndMs - asrStartMs
|
|
1556
|
+
},
|
|
1557
|
+
segmentRanges: segments.map(({ segmentId, startMs, endMs }) => ({
|
|
1558
|
+
segmentId,
|
|
1559
|
+
startMs,
|
|
1560
|
+
endMs
|
|
1561
|
+
}))
|
|
1562
|
+
}
|
|
1563
|
+
});
|
|
1564
|
+
draft.relations.push(this.createRelation(draft, "audio-script-source", scriptId, source.entityId, {}, effectTrace(effectId, signature), stableEffectId("relation", identityScope, "audio-script-source")), this.createRelation(draft, "audio-script-marker", scriptId, scriptMarkerId, {}, effectTrace(effectId, signature), stableEffectId("relation", identityScope, "audio-script-marker")));
|
|
1565
|
+
const captionEntityIds = [];
|
|
1566
|
+
const captionClipEntityIds = [];
|
|
1567
|
+
for (const [index, segment] of segments.entries()) {
|
|
1568
|
+
const durationMs = segment.endMs - segment.startMs;
|
|
1569
|
+
const target = envelope.decisions.targetRanges[index];
|
|
1570
|
+
if (target?.segmentId !== segment.segmentId) throw new Error("Frozen Caption placement order is invalid");
|
|
1571
|
+
const targetRange = {
|
|
1572
|
+
start: target.start,
|
|
1573
|
+
end: target.end
|
|
1574
|
+
};
|
|
1575
|
+
const captionId = requireAvailableEntityId(draft, stableEffectId("entity", identityScope, `caption:${segment.segmentId}`));
|
|
1576
|
+
draft.entities.push({
|
|
1577
|
+
entityId: captionId,
|
|
1578
|
+
entityKind: "caption",
|
|
1579
|
+
payload: {
|
|
1580
|
+
durationMs,
|
|
1581
|
+
baseEntityIds: [scriptId],
|
|
1582
|
+
selection: { segmentId: segment.segmentId }
|
|
1583
|
+
}
|
|
1584
|
+
});
|
|
1585
|
+
const trace = effectTrace(effectId, signature, segment.segmentId);
|
|
1586
|
+
draft.relations.push(this.createRelation(draft, "from-asset", captionId, createEntityId(imported.assetEntityId), {}, trace, stableEffectId("relation", identityScope, `caption-asset:${segment.segmentId}`)));
|
|
1587
|
+
const clipId = this.insertPlacedClipIntoDraft(draft, {
|
|
1588
|
+
clipEntityId: stableEffectId("entity", identityScope, `caption-clip:${segment.segmentId}`),
|
|
1589
|
+
markerEntityId: stableEffectId("entity", identityScope, `caption-marker:${segment.segmentId}`),
|
|
1590
|
+
trackEntityId: track.entityId,
|
|
1591
|
+
contentEntityId: captionId,
|
|
1592
|
+
sourceRange: {
|
|
1593
|
+
start: 0,
|
|
1594
|
+
end: durationMs
|
|
1595
|
+
},
|
|
1596
|
+
duration: {
|
|
1597
|
+
mode: "fixed",
|
|
1598
|
+
value: targetRange.end - targetRange.start
|
|
1599
|
+
},
|
|
1600
|
+
placement: {
|
|
1601
|
+
kind: "absolute",
|
|
1602
|
+
targetRange
|
|
1603
|
+
},
|
|
1604
|
+
relationTrace: trace,
|
|
1605
|
+
relationIds: {
|
|
1606
|
+
trackClip: stableEffectId("relation", identityScope, `track-clip:${segment.segmentId}`),
|
|
1607
|
+
clipMarker: stableEffectId("relation", identityScope, `clip-marker:${segment.segmentId}`),
|
|
1608
|
+
markerContent: stableEffectId("relation", identityScope, `marker-content:${segment.segmentId}`)
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1611
|
+
captionEntityIds.push(captionId);
|
|
1612
|
+
captionClipEntityIds.push(clipId);
|
|
1613
|
+
}
|
|
1614
|
+
return {
|
|
1615
|
+
audioScriptEntityId: scriptId,
|
|
1616
|
+
audioScriptMarkerEntityId: scriptMarkerId,
|
|
1617
|
+
captionAssetEntityId: imported.assetEntityId,
|
|
1618
|
+
captionEntityIds,
|
|
1619
|
+
captionClipEntityIds
|
|
1620
|
+
};
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
static compileCaptionAssetsEffect(base, envelope) {
|
|
1624
|
+
assertCaptionAssetsEnvelope(envelope);
|
|
1625
|
+
const input = envelope.input;
|
|
1626
|
+
const effectId = requireTrimmed(input.effectId, "Caption effect id");
|
|
1627
|
+
const segments = validateAsrSegments(input.segments);
|
|
1628
|
+
if (effectId !== envelope.effectId || captionEffectSignature(input, segments) !== envelope.signature) throw new Error("Caption effect envelope signature does not match its canonical input");
|
|
1629
|
+
const current = cloneRows(base.snapshot().rows);
|
|
1630
|
+
const replay = resolveCaptionAssetsReplay(current, input, effectId, envelope.signature, segments, createEntityId(input.source.entityId), createEntityId(input.trackEntityId));
|
|
1631
|
+
if (replay !== void 0) return {
|
|
1632
|
+
result: replay,
|
|
1633
|
+
replayed: true,
|
|
1634
|
+
rows: current
|
|
1635
|
+
};
|
|
1636
|
+
const fingerprint = captionBaselineFingerprint(base);
|
|
1637
|
+
if (fingerprint !== envelope.baselineFingerprint) throw new Error("Caption effect baseline changed; independently prepared envelopes on divergent baselines are unsupported");
|
|
1638
|
+
if (canonicalJson(resolveCaptionEffectDecisions(current, input, stableEffectScope(captionAssetsCommand, effectId, fingerprint), segments)) !== canonicalJson(envelope.decisions)) throw new Error("Caption effect decisions no longer match the frozen causal baseline");
|
|
1639
|
+
const doc = base.doc.fork();
|
|
1640
|
+
doc.setPeerId(stableEffectPeerId(captionAssetsCommand, effectId, fingerprint));
|
|
1641
|
+
const staged = new LoroEntityDocument(doc);
|
|
1642
|
+
const editor = new EntityTimelineEditor(staged.snapshot().rows, () => {
|
|
1643
|
+
throw new Error("Caption effects must use effect-derived identities");
|
|
1644
|
+
});
|
|
1645
|
+
const result = editor.#applyCaptionAssets(envelope);
|
|
1646
|
+
const applied = applyEntityRows(staged, editor.rows());
|
|
1647
|
+
return {
|
|
1648
|
+
result,
|
|
1649
|
+
replayed: false,
|
|
1650
|
+
update: applied.update,
|
|
1651
|
+
rows: applied.state.rows
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
#applyGeneratedVisuals(envelope) {
|
|
1655
|
+
return this.transact((draft) => {
|
|
1656
|
+
const { input } = envelope;
|
|
1657
|
+
const identityScope = stableEffectScope(generatedVisualsCommand, envelope.effectId, envelope.baselineFingerprint);
|
|
1658
|
+
if (canonicalJson(resolveGeneratedVisualDecisions(draft, input, identityScope)) !== canonicalJson(envelope.decisions)) throw new Error("Frozen visual insertion decision no longer matches the causal baseline");
|
|
1659
|
+
ensureEffectRoleTrack(draft, input.timelineEntityId, "video_clip", envelope.decisions.trackEntityId, identityScope);
|
|
1660
|
+
const clipEntityIds = [];
|
|
1661
|
+
const mediaEntityIds = [];
|
|
1662
|
+
for (const [index, clip] of input.clips.entries()) {
|
|
1663
|
+
const imported = importMediaAsset(draft, clip.media, stableEffectIdFactory(identityScope, `visual:${index}:media`));
|
|
1664
|
+
replaceRows(draft, imported.rows);
|
|
1665
|
+
const media = requireSequenceContent(draft, imported.contentEntityId);
|
|
1666
|
+
if (media.entityKind !== "video" && media.entityKind !== "image") throw new Error("Generated visual content must be Video or Image");
|
|
1667
|
+
const durationMs = safeWholeMs(clip.durationMs, "Generated visual durationMs");
|
|
1668
|
+
if (durationMs === 0 || clip.media.kind === "video" && durationMs > clip.media.durationMs) throw new Error("Generated visual duration must fit its immutable media Sequence");
|
|
1669
|
+
const trace = generatedVisualTrace(envelope, index, clip.generationJobId);
|
|
1670
|
+
const clipId = this.insertPlacedClipIntoDraft(draft, {
|
|
1671
|
+
clipEntityId: stableEffectId("entity", identityScope, `visual:${index}:clip`),
|
|
1672
|
+
markerEntityId: stableEffectId("entity", identityScope, `visual:${index}:marker`),
|
|
1673
|
+
trackEntityId: envelope.decisions.trackEntityId,
|
|
1674
|
+
contentEntityId: media.entityId,
|
|
1675
|
+
sourceRange: {
|
|
1676
|
+
start: 0,
|
|
1677
|
+
end: durationMs
|
|
1678
|
+
},
|
|
1679
|
+
duration: {
|
|
1680
|
+
mode: "fixed",
|
|
1681
|
+
value: durationMs
|
|
1682
|
+
},
|
|
1683
|
+
placement: {
|
|
1684
|
+
kind: "sequential",
|
|
1685
|
+
order: envelope.decisions.insertionOrders[index]
|
|
1686
|
+
},
|
|
1687
|
+
...clip.volume === void 0 ? {} : { clipPayload: { volume: clip.volume } },
|
|
1688
|
+
relationTrace: trace,
|
|
1689
|
+
relationIds: {
|
|
1690
|
+
trackClip: stableEffectId("relation", identityScope, `visual:${index}:track-clip`),
|
|
1691
|
+
clipMarker: stableEffectId("relation", identityScope, `visual:${index}:clip-marker`),
|
|
1692
|
+
markerContent: stableEffectId("relation", identityScope, `visual:${index}:marker-content`)
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
for (const [sourceIndex, sourceId] of clip.sourceMediaEntityIds.entries()) {
|
|
1696
|
+
const source = requireSequenceContent(draft, sourceId);
|
|
1697
|
+
if (![
|
|
1698
|
+
"video",
|
|
1699
|
+
"image",
|
|
1700
|
+
"audio"
|
|
1701
|
+
].includes(source.entityKind)) throw new Error("Generated visual lineage requires Video, Image, or Audio inputs");
|
|
1702
|
+
draft.relations.push(this.createRelation(draft, "generated", media.entityId, source.entityId, {}, generatedVisualTrace(envelope, index, clip.generationJobId, sourceIndex), stableEffectId("relation", identityScope, `visual:${index}:generated:${sourceIndex}`)));
|
|
1703
|
+
}
|
|
1704
|
+
clipEntityIds.push(clipId);
|
|
1705
|
+
mediaEntityIds.push(media.entityId);
|
|
1706
|
+
}
|
|
1707
|
+
return {
|
|
1708
|
+
clipEntityIds,
|
|
1709
|
+
mediaEntityIds
|
|
1710
|
+
};
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
static compileGeneratedVisualEffect(base, envelope) {
|
|
1714
|
+
assertGeneratedVisualEnvelope(envelope);
|
|
1715
|
+
consumeEffectEnvelope(consumedGeneratedVisualEnvelopes, envelope, "Generated visual");
|
|
1716
|
+
const current = cloneRows(base.snapshot().rows);
|
|
1717
|
+
const replay = resolveGeneratedVisualReplay(current, envelope);
|
|
1718
|
+
if (replay !== void 0) return {
|
|
1719
|
+
result: replay,
|
|
1720
|
+
replayed: true,
|
|
1721
|
+
rows: current
|
|
1722
|
+
};
|
|
1723
|
+
const fingerprint = captionBaselineFingerprint(base);
|
|
1724
|
+
if (fingerprint !== envelope.baselineFingerprint) throw new Error("Generated visual effect baseline changed; prepare a new effect from the authority");
|
|
1725
|
+
if (canonicalJson(resolveGeneratedVisualDecisions(current, envelope.input, stableEffectScope(generatedVisualsCommand, envelope.effectId, envelope.baselineFingerprint))) !== canonicalJson(envelope.decisions)) throw new Error("Generated visual decisions no longer match the frozen causal baseline");
|
|
1726
|
+
const doc = base.doc.fork();
|
|
1727
|
+
doc.setPeerId(stableEffectPeerId(generatedVisualsCommand, envelope.effectId, fingerprint));
|
|
1728
|
+
const staged = new LoroEntityDocument(doc);
|
|
1729
|
+
const editor = new EntityTimelineEditor(staged.snapshot().rows, () => {
|
|
1730
|
+
throw new Error("Generated visual effects must use effect-derived identities");
|
|
1731
|
+
});
|
|
1732
|
+
const result = editor.#applyGeneratedVisuals(envelope);
|
|
1733
|
+
const applied = applyEntityRows(staged, editor.rows());
|
|
1734
|
+
return {
|
|
1735
|
+
result,
|
|
1736
|
+
replayed: false,
|
|
1737
|
+
update: applied.update,
|
|
1738
|
+
rows: applied.state.rows
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
#applySpeechAssets(envelope) {
|
|
1742
|
+
return this.transact((draft) => {
|
|
1743
|
+
const { input } = envelope;
|
|
1744
|
+
const value = input.value;
|
|
1745
|
+
const identityScope = stableEffectScope(speechAssetsCommand, envelope.effectId, envelope.baselineFingerprint);
|
|
1746
|
+
const speechTrack = ensureEffectRoleTrack(draft, value.timelineEntityId, "speech", envelope.decisions.speechTrackEntityId, identityScope);
|
|
1747
|
+
const captionTrack = value.items.some((item) => item.captions.length > 0) ? ensureEffectRoleTrack(draft, value.timelineEntityId, "caption", envelope.decisions.captionTrackEntityId, identityScope) : void 0;
|
|
1748
|
+
const speechClipEntityIds = [];
|
|
1749
|
+
const speechAudioEntityIds = [];
|
|
1750
|
+
const audioScriptEntityIds = [];
|
|
1751
|
+
const phoneticScriptEntityIds = [];
|
|
1752
|
+
const captionClipEntityIds = [];
|
|
1753
|
+
for (const [index, item] of value.items.entries()) {
|
|
1754
|
+
const trace = speechEffectTrace(envelope, index);
|
|
1755
|
+
const imported = importMediaAsset(draft, item.renderMode === "synthesized" ? (({ phoneticScriptEntityId: _phoneticScriptEntityId, ...fact }) => fact)(item.media) : item.media, stableEffectIdFactory(identityScope, `speech:${index}:media`));
|
|
1756
|
+
replaceRows(draft, imported.rows);
|
|
1757
|
+
const speech = item.renderMode === "recorded" ? requireEntityKind(draft, imported.contentEntityId, "audio") : requireEntityKind(draft, imported.contentEntityId, "voiceover");
|
|
1758
|
+
const scripts = materializeSpeechSource(draft, item, identityScope, index, trace, speech.entityId);
|
|
1759
|
+
const existingRender = incidentRelations(draft, speech.entityId).filter((relation) => relation.relationKind === "phonetic-script-render");
|
|
1760
|
+
if (item.renderMode === "synthesized") {
|
|
1761
|
+
if (imported.voiceEntityId === void 0 || item.media.voice === void 0) throw new Error("Synthesized speech requires an explicit Voice identity");
|
|
1762
|
+
if (item.media.phoneticScriptEntityId !== void 0 && item.media.phoneticScriptEntityId !== scripts.phonetic.entityId) throw new Error("Synthesized speech media conflicts with its PhoneticScript source");
|
|
1763
|
+
requireSynthesizedSpeechIdentity(draft, requireEntityKind(draft, speech.entityId, "voiceover"), item.media);
|
|
1764
|
+
if (existingRender.length > 1) throw new Error(`Speech Audio "${speech.entityId}" has ambiguous render lineage`);
|
|
1765
|
+
const renderId = stableEffectId("relation", identityScope, `speech:${index}:render`);
|
|
1766
|
+
const renderTrace = {
|
|
1767
|
+
...trace,
|
|
1768
|
+
role: "speech-render"
|
|
1769
|
+
};
|
|
1770
|
+
if (existingRender.length === 1) {
|
|
1771
|
+
if (existingRender[0].endpoint0EntityId !== speech.entityId || existingRender[0].endpoint1EntityId !== scripts.phonetic.entityId) throw new Error(`Speech Audio "${speech.entityId}" has conflicting render lineage`);
|
|
1772
|
+
replaceRelationWithTrace(draft, existingRender[0], speech.entityId, scripts.phonetic.entityId, renderTrace, renderId);
|
|
1773
|
+
} else draft.relations.push(this.createRelation(draft, "phonetic-script-render", speech.entityId, scripts.phonetic.entityId, {}, renderTrace, renderId));
|
|
1774
|
+
} else {
|
|
1775
|
+
const timbre = incidentRelations(draft, speech.entityId).filter((relation) => relation.relationKind === "voice-timbre");
|
|
1776
|
+
if (existingRender.length > 0 || timbre.length > 0) throw new Error("Recorded speech must not have Voice or render lineage");
|
|
1777
|
+
}
|
|
1778
|
+
const startMs = envelope.decisions.targetStartsMs[index];
|
|
1779
|
+
const recordedSpeechClipId = envelope.decisions.recordedSpeechClipEntityIds[index];
|
|
1780
|
+
let speechClipId;
|
|
1781
|
+
if (!("targetSpeechClipEntityId" in item) && recordedSpeechClipId === null) speechClipId = this.insertPlacedClipIntoDraft(draft, {
|
|
1782
|
+
clipEntityId: stableEffectId("entity", identityScope, `speech:${index}:clip`),
|
|
1783
|
+
markerEntityId: stableEffectId("entity", identityScope, `speech:${index}:marker`),
|
|
1784
|
+
trackEntityId: speechTrack.entityId,
|
|
1785
|
+
contentEntityId: speech.entityId,
|
|
1786
|
+
sourceRange: {
|
|
1787
|
+
start: 0,
|
|
1788
|
+
end: item.media.durationMs
|
|
1789
|
+
},
|
|
1790
|
+
duration: {
|
|
1791
|
+
mode: "fixed",
|
|
1792
|
+
value: item.media.durationMs
|
|
1793
|
+
},
|
|
1794
|
+
placement: {
|
|
1795
|
+
kind: "absolute",
|
|
1796
|
+
targetRange: {
|
|
1797
|
+
start: startMs,
|
|
1798
|
+
end: startMs + item.media.durationMs
|
|
1799
|
+
}
|
|
1800
|
+
},
|
|
1801
|
+
clipPayload: { volume: requireVolume(item.volume ?? 0) },
|
|
1802
|
+
relationTrace: {
|
|
1803
|
+
...trace,
|
|
1804
|
+
role: "speech-content"
|
|
1805
|
+
},
|
|
1806
|
+
relationIds: {
|
|
1807
|
+
trackClip: stableEffectId("relation", identityScope, `speech:${index}:track-clip`),
|
|
1808
|
+
clipMarker: stableEffectId("relation", identityScope, `speech:${index}:clip-marker`),
|
|
1809
|
+
markerContent: stableEffectId("relation", identityScope, `speech:${index}:marker-content`)
|
|
1810
|
+
}
|
|
1811
|
+
});
|
|
1812
|
+
else if (!("targetSpeechClipEntityId" in item)) {
|
|
1813
|
+
if (item.renderMode !== "recorded" || typeof recordedSpeechClipId !== "string") throw new Error("Only recorded speech may reuse a raw Audio occurrence");
|
|
1814
|
+
const target = requireCanonicalClipOnRole(draft, recordedSpeechClipId, "speech");
|
|
1815
|
+
if (target.markerContentRelation.endpoint1EntityId !== speech.entityId) throw new Error("Recorded speech occurrence no longer uses the frozen raw Audio");
|
|
1816
|
+
speechClipId = target.clip.entityId;
|
|
1817
|
+
const currentVolume = typeof target.clip.payload.volume === "number" ? target.clip.payload.volume : 0;
|
|
1818
|
+
replaceEntity(draft, target.clip.entityId, {
|
|
1819
|
+
...target.clip,
|
|
1820
|
+
payload: {
|
|
1821
|
+
...target.clip.payload,
|
|
1822
|
+
volume: requireVolume(item.volume ?? currentVolume)
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
replaceRelationWithTrace(draft, target.markerContentRelation, target.marker.entityId, speech.entityId, {
|
|
1826
|
+
...trace,
|
|
1827
|
+
role: "speech-content"
|
|
1828
|
+
}, stableEffectId("relation", identityScope, `speech:${index}:marker-content`));
|
|
1829
|
+
} else {
|
|
1830
|
+
const target = requireCanonicalClipOnRole(draft, item.targetSpeechClipEntityId, "speech");
|
|
1831
|
+
if (target.trackRelation.endpoint0EntityId !== speechTrack.entityId) throw new Error("Replacement Speech occurrence is not on the frozen Speech Track");
|
|
1832
|
+
speechClipId = target.clip.entityId;
|
|
1833
|
+
replaceEntity(draft, target.clip.entityId, {
|
|
1834
|
+
...target.clip,
|
|
1835
|
+
payload: {
|
|
1836
|
+
...target.clip.payload,
|
|
1837
|
+
volume: requireVolume(item.volume ?? 0)
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1840
|
+
replaceRelationWithTrace(draft, target.markerContentRelation, target.marker.entityId, speech.entityId, {
|
|
1841
|
+
...trace,
|
|
1842
|
+
role: "speech-content"
|
|
1843
|
+
}, stableEffectId("relation", identityScope, `speech:${index}:marker-content`));
|
|
1844
|
+
const markerPayload = { ...cloneJsonObject(target.marker.payload, "Speech Marker payload") };
|
|
1845
|
+
markerPayload.sourceRange = {
|
|
1846
|
+
start: 0,
|
|
1847
|
+
end: item.media.durationMs
|
|
1848
|
+
};
|
|
1849
|
+
markerPayload.duration = {
|
|
1850
|
+
mode: "fixed",
|
|
1851
|
+
value: item.media.durationMs
|
|
1852
|
+
};
|
|
1853
|
+
resizeAbsoluteTarget(markerPayload, item.media.durationMs);
|
|
1854
|
+
replaceEntity(draft, target.marker.entityId, {
|
|
1855
|
+
...target.marker,
|
|
1856
|
+
payload: markerPayload
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
const currentCaptionIds = "targetCaptionClipEntityIds" in item ? item.targetCaptionClipEntityIds : [];
|
|
1860
|
+
const itemCaptionIds = [];
|
|
1861
|
+
for (const [captionIndex, caption] of item.captions.entries()) {
|
|
1862
|
+
const durationMs = safeWholeMs(caption.endMs, "Speech Caption endMs") - safeWholeMs(caption.startMs, "Speech Caption startMs");
|
|
1863
|
+
if (durationMs <= 0) throw new Error("Speech Caption must have positive duration");
|
|
1864
|
+
const captionEntity = {
|
|
1865
|
+
entityId: requireAvailableEntityId(draft, stableEffectId("entity", identityScope, `speech:${index}:caption:${captionIndex}`)),
|
|
1866
|
+
entityKind: "caption",
|
|
1867
|
+
payload: {
|
|
1868
|
+
baseEntityIds: [scripts.audioScript.entityId],
|
|
1869
|
+
selection: captionSelection(caption.selection),
|
|
1870
|
+
durationMs
|
|
1871
|
+
}
|
|
1872
|
+
};
|
|
1873
|
+
draft.entities.push(captionEntity);
|
|
1874
|
+
assembleCaptionContent(draft, captionEntity.entityId);
|
|
1875
|
+
const captionTrace = {
|
|
1876
|
+
...trace,
|
|
1877
|
+
role: "caption-content",
|
|
1878
|
+
captionIndex
|
|
1879
|
+
};
|
|
1880
|
+
let captionClipId;
|
|
1881
|
+
const existingCaptionId = currentCaptionIds[captionIndex];
|
|
1882
|
+
if (!("targetSpeechClipEntityId" in item) || existingCaptionId === void 0) captionClipId = this.insertPlacedClipIntoDraft(draft, {
|
|
1883
|
+
clipEntityId: stableEffectId("entity", identityScope, `speech:${index}:caption:${captionIndex}:clip`),
|
|
1884
|
+
markerEntityId: stableEffectId("entity", identityScope, `speech:${index}:caption:${captionIndex}:marker`),
|
|
1885
|
+
trackEntityId: captionTrack.entityId,
|
|
1886
|
+
contentEntityId: captionEntity.entityId,
|
|
1887
|
+
sourceRange: {
|
|
1888
|
+
start: 0,
|
|
1889
|
+
end: durationMs
|
|
1890
|
+
},
|
|
1891
|
+
duration: {
|
|
1892
|
+
mode: "fixed",
|
|
1893
|
+
value: durationMs
|
|
1894
|
+
},
|
|
1895
|
+
placement: {
|
|
1896
|
+
kind: "absolute",
|
|
1897
|
+
targetRange: {
|
|
1898
|
+
start: startMs + caption.startMs,
|
|
1899
|
+
end: startMs + caption.endMs
|
|
1900
|
+
}
|
|
1901
|
+
},
|
|
1902
|
+
relationTrace: captionTrace,
|
|
1903
|
+
relationIds: {
|
|
1904
|
+
trackClip: stableEffectId("relation", identityScope, `speech:${index}:caption:${captionIndex}:track-clip`),
|
|
1905
|
+
clipMarker: stableEffectId("relation", identityScope, `speech:${index}:caption:${captionIndex}:clip-marker`),
|
|
1906
|
+
markerContent: stableEffectId("relation", identityScope, `speech:${index}:caption:${captionIndex}:marker-content`)
|
|
1907
|
+
}
|
|
1908
|
+
});
|
|
1909
|
+
else {
|
|
1910
|
+
const targetCaption = requireCanonicalClipOnRole(draft, existingCaptionId, "caption");
|
|
1911
|
+
if (targetCaption.trackRelation.endpoint0EntityId !== captionTrack.entityId) throw new Error("Replacement Caption occurrence is not on the frozen Caption Track");
|
|
1912
|
+
captionClipId = targetCaption.clip.entityId;
|
|
1913
|
+
replaceRelationWithTrace(draft, targetCaption.markerContentRelation, targetCaption.marker.entityId, captionEntity.entityId, captionTrace, stableEffectId("relation", identityScope, `speech:${index}:caption:${captionIndex}:marker-content`));
|
|
1914
|
+
replaceEntity(draft, targetCaption.marker.entityId, {
|
|
1915
|
+
...targetCaption.marker,
|
|
1916
|
+
payload: {
|
|
1917
|
+
...targetCaption.marker.payload,
|
|
1918
|
+
sourceRange: {
|
|
1919
|
+
start: 0,
|
|
1920
|
+
end: durationMs
|
|
1921
|
+
},
|
|
1922
|
+
duration: {
|
|
1923
|
+
mode: "fixed",
|
|
1924
|
+
value: durationMs
|
|
1925
|
+
},
|
|
1926
|
+
targetRange: {
|
|
1927
|
+
start: startMs + caption.startMs,
|
|
1928
|
+
end: startMs + caption.endMs
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
itemCaptionIds.push(captionClipId);
|
|
1934
|
+
}
|
|
1935
|
+
for (const retiredCaptionId of currentCaptionIds.slice(item.captions.length)) {
|
|
1936
|
+
const retired = requireCanonicalClipOnRole(draft, retiredCaptionId, "caption");
|
|
1937
|
+
if (retired.trackRelation.endpoint0EntityId !== envelope.decisions.captionTrackEntityId) throw new Error("Retired Caption occurrence is not on the frozen Caption Track");
|
|
1938
|
+
retireClipOccurrence(draft, retired);
|
|
1939
|
+
}
|
|
1940
|
+
speechClipEntityIds.push(speechClipId);
|
|
1941
|
+
speechAudioEntityIds.push(speech.entityId);
|
|
1942
|
+
audioScriptEntityIds.push(scripts.audioScript.entityId);
|
|
1943
|
+
phoneticScriptEntityIds.push(scripts.phonetic?.entityId ?? null);
|
|
1944
|
+
captionClipEntityIds.push(itemCaptionIds);
|
|
1945
|
+
}
|
|
1946
|
+
return {
|
|
1947
|
+
speechClipEntityIds,
|
|
1948
|
+
speechAudioEntityIds,
|
|
1949
|
+
audioScriptEntityIds,
|
|
1950
|
+
phoneticScriptEntityIds,
|
|
1951
|
+
captionClipEntityIds
|
|
1952
|
+
};
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
static compileSpeechAssetsEffect(base, envelope) {
|
|
1956
|
+
assertSpeechAssetsEnvelope(envelope);
|
|
1957
|
+
consumeEffectEnvelope(consumedSpeechEnvelopes, envelope, "Speech");
|
|
1958
|
+
const current = cloneRows(base.snapshot().rows);
|
|
1959
|
+
const replay = resolveSpeechAssetsReplay(current, envelope, {
|
|
1960
|
+
tombstonedRelations: base.tombstonedRelations(),
|
|
1961
|
+
getVersion: (id) => base.getVersion(id),
|
|
1962
|
+
resolveCurrentEntityId: (id) => base.resolveCurrentEntityId(id)
|
|
1963
|
+
});
|
|
1964
|
+
if (replay !== void 0) return {
|
|
1965
|
+
result: replay,
|
|
1966
|
+
replayed: true,
|
|
1967
|
+
rows: current
|
|
1968
|
+
};
|
|
1969
|
+
const fingerprint = captionBaselineFingerprint(base);
|
|
1970
|
+
if (fingerprint !== envelope.baselineFingerprint) throw new Error("Speech effect baseline changed; prepare a new effect from the authority");
|
|
1971
|
+
if (canonicalJson(resolveSpeechEffectDecisions(current, envelope.input, envelope.effectId, fingerprint)) !== canonicalJson(envelope.decisions)) throw new Error("Speech effect decisions no longer match the frozen causal baseline");
|
|
1972
|
+
const doc = base.doc.fork();
|
|
1973
|
+
doc.setPeerId(stableEffectPeerId(speechAssetsCommand, envelope.effectId, fingerprint));
|
|
1974
|
+
const staged = new LoroEntityDocument(doc);
|
|
1975
|
+
const editor = new EntityTimelineEditor(staged.snapshot().rows, stableEffectIdFactory(stableEffectScope(speechAssetsCommand, envelope.effectId, fingerprint), "speech-editor"));
|
|
1976
|
+
editor.#applySpeechAssets(envelope);
|
|
1977
|
+
const applied = applyEntityRows(staged, editor.rows());
|
|
1978
|
+
const result = resolveSpeechAssetsReplay(cloneRows(applied.state.rows), envelope);
|
|
1979
|
+
if (result === void 0) throw new Error("Committed Speech effect is missing its complete receipt");
|
|
1980
|
+
return {
|
|
1981
|
+
result,
|
|
1982
|
+
replayed: false,
|
|
1983
|
+
update: applied.update,
|
|
1984
|
+
rows: applied.state.rows
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1416
1987
|
insertPlacedClipIntoDraft(draft, input) {
|
|
1417
1988
|
const track = requireEntityKind(draft, input.trackEntityId, "track");
|
|
1418
1989
|
requireTrackTimeline(draft, track.entityId);
|
|
1419
1990
|
const content = requireSequenceContent(draft, input.contentEntityId);
|
|
1420
1991
|
const clipId = input.clipEntityId === void 0 ? this.mintEntityId(draft) : requireAvailableEntityId(draft, input.clipEntityId);
|
|
1421
|
-
const markerId = this.mintEntityId(draft);
|
|
1992
|
+
const markerId = input.markerEntityId === void 0 ? this.mintEntityId(draft) : requireAvailableEntityId(draft, input.markerEntityId);
|
|
1422
1993
|
const clipPayload = { ...cloneJsonObject(input.clipPayload ?? {}, "Clip payload") };
|
|
1423
1994
|
delete clipPayload.order;
|
|
1424
1995
|
const markerPlacement = applyPlacementPayload(clipPayload, input.placement);
|
|
@@ -1437,7 +2008,7 @@ var EntityTimelineEditor = class {
|
|
|
1437
2008
|
})
|
|
1438
2009
|
};
|
|
1439
2010
|
draft.entities.push(clip, marker);
|
|
1440
|
-
draft.relations.push(this.createRelation(draft, "track-clip", track.entityId, clipId), this.createRelation(draft, "clip-marker", clipId, markerId), this.createRelation(draft, "marker-content", markerId, content.entityId));
|
|
2011
|
+
draft.relations.push(this.createRelation(draft, "track-clip", track.entityId, clipId, {}, input.relationTrace, input.relationIds?.trackClip), this.createRelation(draft, "clip-marker", clipId, markerId, {}, input.relationTrace, input.relationIds?.clipMarker), this.createRelation(draft, "marker-content", markerId, content.entityId, {}, input.relationTrace, input.relationIds?.markerContent));
|
|
1441
2012
|
if (input.placement.kind === "anchored") {
|
|
1442
2013
|
const host = requireEntityKind(draft, input.placement.hostClipEntityId, "clip");
|
|
1443
2014
|
if (host.entityId === clipId) throw new Error("A Clip cannot anchor to itself");
|
|
@@ -1474,23 +2045,23 @@ var EntityTimelineEditor = class {
|
|
|
1474
2045
|
draft.relations.push(this.createRelation(draft, "timeline-track", timeline.entityId, track.entityId));
|
|
1475
2046
|
return track;
|
|
1476
2047
|
}
|
|
1477
|
-
/** The existing pronunciation variant a
|
|
2048
|
+
/** The existing pronunciation variant a voiceover consumes; composition is required. */
|
|
1478
2049
|
requireComposedPhoneticScript(draft, phoneticScriptEntityId) {
|
|
1479
2050
|
const phonetic = requireEntityKind(draft, phoneticScriptEntityId, "phonetic-script");
|
|
1480
2051
|
requireComposedAudioScript(draft, phonetic.entityId);
|
|
1481
2052
|
return phonetic;
|
|
1482
2053
|
}
|
|
1483
2054
|
/** Preserve the factual synthesis input, regardless of persisted endpoint positions. */
|
|
1484
|
-
ensurePhoneticRender(draft,
|
|
1485
|
-
const renders = draft.relations.filter((relation) => relation.relationKind === "phonetic-script-render" && (relation.endpoint0EntityId ===
|
|
1486
|
-
if (renders.length > 1) throw new Error(`
|
|
2055
|
+
ensurePhoneticRender(draft, voiceoverAudioEntityId, phoneticScriptEntityId) {
|
|
2056
|
+
const renders = draft.relations.filter((relation) => relation.relationKind === "phonetic-script-render" && (relation.endpoint0EntityId === voiceoverAudioEntityId || relation.endpoint1EntityId === voiceoverAudioEntityId));
|
|
2057
|
+
if (renders.length > 1) throw new Error(`Voiceover "${voiceoverAudioEntityId}" has multiple PhoneticScript render relations`);
|
|
1487
2058
|
if (renders[0] !== void 0) {
|
|
1488
|
-
if (otherEndpoint(renders[0],
|
|
1489
|
-
throw new Error(`
|
|
2059
|
+
if (otherEndpoint(renders[0], voiceoverAudioEntityId) === phoneticScriptEntityId) return;
|
|
2060
|
+
throw new Error(`Voiceover "${voiceoverAudioEntityId}" already has a different PhoneticScript synthesis input`);
|
|
1490
2061
|
}
|
|
1491
|
-
draft.relations.push(this.createRelation(draft, "phonetic-script-render",
|
|
2062
|
+
draft.relations.push(this.createRelation(draft, "phonetic-script-render", voiceoverAudioEntityId, phoneticScriptEntityId));
|
|
1492
2063
|
}
|
|
1493
|
-
reconcileVoiceoverCaptions(draft, voiceoverClipEntityId,
|
|
2064
|
+
reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, voiceoverAudioEntityId, phoneticScriptEntityId, captions) {
|
|
1494
2065
|
const audioScriptEntityId = requireComposedAudioScript(draft, phoneticScriptEntityId);
|
|
1495
2066
|
const requested = new Set(captions.map((caption) => caption.captionClipEntityId));
|
|
1496
2067
|
if (requested.size !== captions.length) throw new Error("Caption Clip ids must be unique within a voiceover take");
|
|
@@ -1507,13 +2078,7 @@ var EntityTimelineEditor = class {
|
|
|
1507
2078
|
entityId: this.mintEntityId(draft),
|
|
1508
2079
|
entityKind: "caption",
|
|
1509
2080
|
payload: {
|
|
1510
|
-
|
|
1511
|
-
kind: "bounded",
|
|
1512
|
-
start: 0,
|
|
1513
|
-
end: durationMs
|
|
1514
|
-
},
|
|
1515
|
-
sampling: "native",
|
|
1516
|
-
coordinateSpace: "milliseconds",
|
|
2081
|
+
durationMs,
|
|
1517
2082
|
baseEntityIds,
|
|
1518
2083
|
selection,
|
|
1519
2084
|
...caption.style === void 0 ? {} : { style: cloneJsonObject(caption.style, "Caption style") }
|
|
@@ -1521,7 +2086,7 @@ var EntityTimelineEditor = class {
|
|
|
1521
2086
|
};
|
|
1522
2087
|
draft.entities.push(content);
|
|
1523
2088
|
assertCaptionScript(draft, content.entityId, audioScriptEntityId);
|
|
1524
|
-
draft.relations.push(this.createRelation(draft, "caption-alignment", content.entityId,
|
|
2089
|
+
draft.relations.push(this.createRelation(draft, "caption-alignment", content.entityId, voiceoverAudioEntityId, { alignment: {
|
|
1525
2090
|
start: startMs,
|
|
1526
2091
|
end: startMs + durationMs
|
|
1527
2092
|
} }));
|
|
@@ -1578,7 +2143,7 @@ var EntityTimelineEditor = class {
|
|
|
1578
2143
|
}
|
|
1579
2144
|
}
|
|
1580
2145
|
});
|
|
1581
|
-
this.replaceCaptionAlignment(draft, content.entityId,
|
|
2146
|
+
this.replaceCaptionAlignment(draft, content.entityId, voiceoverAudioEntityId, startMs, durationMs);
|
|
1582
2147
|
}
|
|
1583
2148
|
}
|
|
1584
2149
|
ensureRoleTrackForClip(draft, clipEntityId, role) {
|
|
@@ -1587,10 +2152,10 @@ var EntityTimelineEditor = class {
|
|
|
1587
2152
|
const timeline = requireEntityKind(draft, otherEndpoint(requireOneRelation(draft, track.entityId, "timeline-track"), track.entityId), "timeline");
|
|
1588
2153
|
return this.ensureRoleTrack(draft, timeline.entityId, role);
|
|
1589
2154
|
}
|
|
1590
|
-
replaceCaptionAlignment(draft, captionEntityId,
|
|
2155
|
+
replaceCaptionAlignment(draft, captionEntityId, voiceoverAudioEntityId, startMs, durationMs) {
|
|
1591
2156
|
const removed = new Set(incidentRelations(draft, captionEntityId).filter((relation) => relation.relationKind === "caption-alignment").map((relation) => relation.relationId));
|
|
1592
2157
|
draft.relations = draft.relations.filter((relation) => !removed.has(relation.relationId));
|
|
1593
|
-
draft.relations.push(this.createRelation(draft, "caption-alignment", captionEntityId,
|
|
2158
|
+
draft.relations.push(this.createRelation(draft, "caption-alignment", captionEntityId, voiceoverAudioEntityId, { alignment: {
|
|
1594
2159
|
start: startMs,
|
|
1595
2160
|
end: startMs + durationMs
|
|
1596
2161
|
} }));
|
|
@@ -1722,59 +2287,1047 @@ var EntityTimelineEditor = class {
|
|
|
1722
2287
|
else this.removeClipGraph(draft, child);
|
|
1723
2288
|
}
|
|
1724
2289
|
}
|
|
1725
|
-
removeClipGraph(draft, clipEntityId) {
|
|
1726
|
-
const topology = requireClipTopology(draft, clipEntityId);
|
|
1727
|
-
assertOnlyStructuralIncidents(draft, topology.clip.entityId, new Set([
|
|
1728
|
-
"track-clip",
|
|
1729
|
-
"clip-marker",
|
|
1730
|
-
"clip-anchor"
|
|
1731
|
-
]));
|
|
1732
|
-
assertOnlyStructuralIncidents(draft, topology.marker.entityId, new Set(["clip-marker", "marker-content"]));
|
|
1733
|
-
const markerIsExclusive = incidentRelations(draft, topology.marker.entityId).filter((relation) => relation.relationKind === "clip-marker").length === 1;
|
|
1734
|
-
const removed = new Set(incidentRelations(draft, topology.clip.entityId).map((relation) => relation.relationId));
|
|
1735
|
-
if (markerIsExclusive) for (const relation of incidentRelations(draft, topology.marker.entityId)) removed.add(relation.relationId);
|
|
1736
|
-
draft.relations = draft.relations.filter((relation) => !removed.has(relation.relationId));
|
|
1737
|
-
draft.entities = draft.entities.filter((entity) => entity.entityId !== topology.clip.entityId && (!markerIsExclusive || entity.entityId !== topology.marker.entityId));
|
|
2290
|
+
removeClipGraph(draft, clipEntityId) {
|
|
2291
|
+
const topology = requireClipTopology(draft, clipEntityId);
|
|
2292
|
+
assertOnlyStructuralIncidents(draft, topology.clip.entityId, new Set([
|
|
2293
|
+
"track-clip",
|
|
2294
|
+
"clip-marker",
|
|
2295
|
+
"clip-anchor"
|
|
2296
|
+
]));
|
|
2297
|
+
assertOnlyStructuralIncidents(draft, topology.marker.entityId, new Set(["clip-marker", "marker-content"]));
|
|
2298
|
+
const markerIsExclusive = incidentRelations(draft, topology.marker.entityId).filter((relation) => relation.relationKind === "clip-marker").length === 1;
|
|
2299
|
+
const removed = new Set(incidentRelations(draft, topology.clip.entityId).map((relation) => relation.relationId));
|
|
2300
|
+
if (markerIsExclusive) for (const relation of incidentRelations(draft, topology.marker.entityId)) removed.add(relation.relationId);
|
|
2301
|
+
draft.relations = draft.relations.filter((relation) => !removed.has(relation.relationId));
|
|
2302
|
+
draft.entities = draft.entities.filter((entity) => entity.entityId !== topology.clip.entityId && (!markerIsExclusive || entity.entityId !== topology.marker.entityId));
|
|
2303
|
+
}
|
|
2304
|
+
transact(edit) {
|
|
2305
|
+
const draft = cloneRows(this.state);
|
|
2306
|
+
const result = edit(draft);
|
|
2307
|
+
validateFinalRows(draft);
|
|
2308
|
+
assertMediaAssetWritePolicy(this.state, draft);
|
|
2309
|
+
this.state = draft;
|
|
2310
|
+
return result;
|
|
2311
|
+
}
|
|
2312
|
+
mintEntityId(draft) {
|
|
2313
|
+
const entityId = createEntityId(this.idFactory("entity"));
|
|
2314
|
+
if (draft.entities.some((entity) => entity.entityId === entityId)) throw new Error(`Entity id "${entityId}" already exists`);
|
|
2315
|
+
return entityId;
|
|
2316
|
+
}
|
|
2317
|
+
createRelation(draft, relationKind, endpoint0EntityId, endpoint1EntityId, metadata = {}, trace = {}, requestedRelationId) {
|
|
2318
|
+
return {
|
|
2319
|
+
relationId: requestedRelationId === void 0 ? this.mintRelationId(draft) : requireAvailableRelationId(draft, requestedRelationId),
|
|
2320
|
+
relationKind,
|
|
2321
|
+
endpoint0EntityId,
|
|
2322
|
+
endpoint1EntityId,
|
|
2323
|
+
metadata,
|
|
2324
|
+
trace
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
relinkRelation(draft, relation, endpoints) {
|
|
2328
|
+
const relationId = this.mintRelationId(draft);
|
|
2329
|
+
const index = draft.relations.findIndex((candidate) => candidate.relationId === relation.relationId);
|
|
2330
|
+
if (index < 0) throw new Error(`Relation id "${relation.relationId}" does not exist`);
|
|
2331
|
+
draft.relations[index] = {
|
|
2332
|
+
...relation,
|
|
2333
|
+
...endpoints,
|
|
2334
|
+
relationId
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
mintRelationId(draft) {
|
|
2338
|
+
const relationId = createRelationId(this.idFactory("relation"));
|
|
2339
|
+
if (draft.relations.some((relation) => relation.relationId === relationId)) throw new Error(`Relation id "${relationId}" already exists`);
|
|
2340
|
+
return relationId;
|
|
2341
|
+
}
|
|
2342
|
+
};
|
|
2343
|
+
/** Freeze every state-dependent choice once in the sole FE tool-effect handler. */
|
|
2344
|
+
function prepareCaptionAssetsEffect(base, rawInput) {
|
|
2345
|
+
const effectId = requireTrimmed(rawInput.effectId, "Caption effect id");
|
|
2346
|
+
const segments = validateAsrSegments(rawInput.segments);
|
|
2347
|
+
const input = structuredClone({
|
|
2348
|
+
...rawInput,
|
|
2349
|
+
effectId,
|
|
2350
|
+
segments
|
|
2351
|
+
});
|
|
2352
|
+
const rows = cloneRows(base.snapshot().rows);
|
|
2353
|
+
const signature = captionEffectSignature(input, segments);
|
|
2354
|
+
const baselineFingerprint = captionBaselineFingerprint(base);
|
|
2355
|
+
return deepFreeze({
|
|
2356
|
+
version: 1,
|
|
2357
|
+
baselineFingerprint,
|
|
2358
|
+
effectId,
|
|
2359
|
+
signature,
|
|
2360
|
+
input,
|
|
2361
|
+
decisions: resolveCaptionEffectDecisions(rows, input, stableEffectScope(captionAssetsCommand, effectId, baselineFingerprint), segments)
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Compile only a frozen envelope on its exact causal baseline. Collaborators
|
|
2366
|
+
* receive the resulting Loro update, never the raw effect.
|
|
2367
|
+
*/
|
|
2368
|
+
function compileCaptionAssetsEffect(base, envelope) {
|
|
2369
|
+
return EntityTimelineEditor.compileCaptionAssetsEffect(base, envelope);
|
|
2370
|
+
}
|
|
2371
|
+
const captionAssetsCommand = "apply-caption-assets";
|
|
2372
|
+
const generatedVisualsCommand = "add-generated-visuals";
|
|
2373
|
+
const speechAssetsCommand = "apply-speech-assets";
|
|
2374
|
+
const consumedGeneratedVisualEnvelopes = /* @__PURE__ */ new Set();
|
|
2375
|
+
const consumedSpeechEnvelopes = /* @__PURE__ */ new Set();
|
|
2376
|
+
function consumeEffectEnvelope(consumed, envelope, label) {
|
|
2377
|
+
const key = `${envelope.baselineFingerprint}\u0000${envelope.effectId}\u0000${envelope.signature}`;
|
|
2378
|
+
if (consumed.has(key)) throw new Error(`${label} effect envelope is one-shot; retain and retry the compiled Loro update bytes`);
|
|
2379
|
+
consumed.add(key);
|
|
2380
|
+
}
|
|
2381
|
+
/** Freeze the insertion position and lineage on one causal baseline. */
|
|
2382
|
+
function prepareGeneratedVisualEffect(base, rawInput) {
|
|
2383
|
+
const effectId = requireTrimmed(rawInput.effectId, "Generated visual effect id");
|
|
2384
|
+
if (rawInput.clips.length === 0) throw new Error("Generated visual effect requires at least one clip");
|
|
2385
|
+
const clips = rawInput.clips.map((clip, index) => {
|
|
2386
|
+
const generationJobId = requireTrimmed(clip.generationJobId, `Generated visual ${index} generation job id`);
|
|
2387
|
+
const durationMs = safeWholeMs(clip.durationMs, `Generated visual ${index} durationMs`);
|
|
2388
|
+
if (durationMs === 0 || clip.media.kind === "video" && durationMs > clip.media.durationMs) throw new Error(`Generated visual ${index} duration must fit its immutable media Sequence`);
|
|
2389
|
+
if (clip.sourceMediaEntityIds.length === 0) throw new Error(`Generated visual ${index} requires explicit source lineage`);
|
|
2390
|
+
const sourceMediaEntityIds = clip.sourceMediaEntityIds.map((sourceId) => requireTrimmed(sourceId, `Generated visual ${index} source media id`));
|
|
2391
|
+
if (new Set(sourceMediaEntityIds).size !== sourceMediaEntityIds.length) throw new Error(`Generated visual ${index} source lineage must be unique`);
|
|
2392
|
+
return {
|
|
2393
|
+
...structuredClone(clip),
|
|
2394
|
+
durationMs,
|
|
2395
|
+
generationJobId,
|
|
2396
|
+
sourceMediaEntityIds
|
|
2397
|
+
};
|
|
2398
|
+
});
|
|
2399
|
+
const input = structuredClone({
|
|
2400
|
+
...rawInput,
|
|
2401
|
+
effectId,
|
|
2402
|
+
clips
|
|
2403
|
+
});
|
|
2404
|
+
const rows = cloneRows(base.snapshot().rows);
|
|
2405
|
+
for (const [index, clip] of input.clips.entries()) for (const sourceId of clip.sourceMediaEntityIds) {
|
|
2406
|
+
const source = requireSequenceContent(rows, sourceId);
|
|
2407
|
+
if (![
|
|
2408
|
+
"video",
|
|
2409
|
+
"image",
|
|
2410
|
+
"audio"
|
|
2411
|
+
].includes(source.entityKind)) throw new Error(`Generated visual ${index} source must be Video, Image, or Audio`);
|
|
2412
|
+
}
|
|
2413
|
+
const baselineFingerprint = captionBaselineFingerprint(base);
|
|
2414
|
+
return deepFreeze({
|
|
2415
|
+
version: 1,
|
|
2416
|
+
baselineFingerprint,
|
|
2417
|
+
effectId,
|
|
2418
|
+
signature: generatedVisualSignature(input),
|
|
2419
|
+
input,
|
|
2420
|
+
decisions: resolveGeneratedVisualDecisions(rows, input, stableEffectScope(generatedVisualsCommand, effectId, baselineFingerprint))
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
function prepareContinueVideoEffect(base, input) {
|
|
2424
|
+
const rows = cloneRows(base.snapshot().rows);
|
|
2425
|
+
requireEntityKind(rows, input.sourceMediaEntityId, "video");
|
|
2426
|
+
requireEntityKind(rows, input.tailFrameEntityId, "image");
|
|
2427
|
+
return prepareGeneratedVisualEffect(base, {
|
|
2428
|
+
effectId: input.effectId,
|
|
2429
|
+
timelineEntityId: input.timelineEntityId,
|
|
2430
|
+
afterClipEntityId: input.afterClipEntityId,
|
|
2431
|
+
clips: [{
|
|
2432
|
+
media: input.media,
|
|
2433
|
+
durationMs: input.media.durationMs,
|
|
2434
|
+
generationJobId: input.effectId,
|
|
2435
|
+
sourceMediaEntityIds: [input.sourceMediaEntityId, input.tailFrameEntityId]
|
|
2436
|
+
}]
|
|
2437
|
+
});
|
|
2438
|
+
}
|
|
2439
|
+
/** Compile once at the FE authority; retain the returned update bytes for retry and collaborators. */
|
|
2440
|
+
function compileGeneratedVisualEffect(base, envelope) {
|
|
2441
|
+
return EntityTimelineEditor.compileGeneratedVisualEffect(base, envelope);
|
|
2442
|
+
}
|
|
2443
|
+
function prepareSpeechAssetsEffect(base, rawInput) {
|
|
2444
|
+
for (const item of rawInput.value.items) {
|
|
2445
|
+
assertSpeechRenderContract(rawInput.mode, item);
|
|
2446
|
+
if (item.source.kind === "audio-script") if (item.source.audioScriptEntityId === void 0) audioScriptSegmentsFromAsr(item.source.segments, false);
|
|
2447
|
+
else validateResolvedAsrSegments(item.source.segments);
|
|
2448
|
+
else validateScriptSegments(item.source.segments);
|
|
2449
|
+
}
|
|
2450
|
+
const value = structuredClone(rawInput.value);
|
|
2451
|
+
const effectId = requireTrimmed(value.effectId, "Speech effect id");
|
|
2452
|
+
if (value.items.length === 0) throw new Error("Speech effect requires at least one item");
|
|
2453
|
+
if (new Set(value.items.map((item) => item.media.assetId)).size !== value.items.length) throw new Error("Speech effect media assets must be unique");
|
|
2454
|
+
const input = structuredClone({
|
|
2455
|
+
...rawInput,
|
|
2456
|
+
value: {
|
|
2457
|
+
...value,
|
|
2458
|
+
effectId
|
|
2459
|
+
}
|
|
2460
|
+
});
|
|
2461
|
+
const baselineFingerprint = captionBaselineFingerprint(base);
|
|
2462
|
+
return deepFreeze({
|
|
2463
|
+
version: 1,
|
|
2464
|
+
baselineFingerprint,
|
|
2465
|
+
effectId,
|
|
2466
|
+
signature: speechEffectSignature(input),
|
|
2467
|
+
input,
|
|
2468
|
+
decisions: resolveSpeechEffectDecisions(cloneRows(base.snapshot().rows), input, effectId, baselineFingerprint)
|
|
2469
|
+
});
|
|
2470
|
+
}
|
|
2471
|
+
function assertSpeechRenderContract(mode, item) {
|
|
2472
|
+
if (item.renderMode === "recorded") {
|
|
2473
|
+
if (mode !== "add") throw new Error("Recorded speech cannot replace a synthesized Voice");
|
|
2474
|
+
if (item.media.kind !== "audio") throw new Error("Recorded speech requires raw Audio media");
|
|
2475
|
+
if ("voice" in item.media && item.media.voice !== void 0) throw new Error("Recorded speech must not declare a Voice");
|
|
2476
|
+
if (item.source.kind !== "audio-script") throw new Error("Recorded speech requires an AudioScript source");
|
|
2477
|
+
return;
|
|
2478
|
+
}
|
|
2479
|
+
if (item.renderMode !== "synthesized") throw new Error("Speech renderMode must be recorded or synthesized");
|
|
2480
|
+
if (item.media.kind !== "voiceover") throw new Error("Synthesized speech requires voiceover media");
|
|
2481
|
+
if (item.media.voice === void 0) throw new Error("Synthesized speech requires an explicit Voice identity");
|
|
2482
|
+
}
|
|
2483
|
+
/** Compile once at the FE authority; retain the returned update bytes for retry and collaborators. */
|
|
2484
|
+
function compileSpeechAssetsEffect(base, envelope) {
|
|
2485
|
+
return EntityTimelineEditor.compileSpeechAssetsEffect(base, envelope);
|
|
2486
|
+
}
|
|
2487
|
+
function validateAsrSegments(segments) {
|
|
2488
|
+
if (segments.length === 0) throw new Error("Caption assets require at least one ASR segment");
|
|
2489
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2490
|
+
return segments.map((segment) => {
|
|
2491
|
+
const segmentId = requireTrimmed(segment.segmentId, "ASR segment id");
|
|
2492
|
+
if (ids.has(segmentId)) throw new Error(`Duplicate ASR segment id "${segmentId}"`);
|
|
2493
|
+
ids.add(segmentId);
|
|
2494
|
+
if (typeof segment.text !== "string" || segment.text.trim() === "") throw new Error(`ASR segment "${segmentId}" requires visible text`);
|
|
2495
|
+
const startMs = safeWholeMs(segment.startMs, `ASR segment "${segmentId}" startMs`);
|
|
2496
|
+
const endMs = safeWholeMs(segment.endMs, `ASR segment "${segmentId}" endMs`);
|
|
2497
|
+
if (endMs <= startMs) throw new Error(`ASR segment "${segmentId}" must have positive duration`);
|
|
2498
|
+
const language = segment.language === void 0 ? void 0 : requireTrimmed(segment.language, `ASR segment "${segmentId}" language`);
|
|
2499
|
+
return {
|
|
2500
|
+
segmentId,
|
|
2501
|
+
text: segment.text,
|
|
2502
|
+
startMs,
|
|
2503
|
+
endMs,
|
|
2504
|
+
...language === void 0 ? {} : { language }
|
|
2505
|
+
};
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
function requireCanonicalCaptionTrack(rows, timelineEntityId, trackEntityId) {
|
|
2509
|
+
const timeline = requireEntityKind(rows, timelineEntityId, "timeline");
|
|
2510
|
+
const track = requireEntityKind(rows, trackEntityId, "track");
|
|
2511
|
+
if (track.payload.role !== "caption") throw new Error(`Track "${track.entityId}" must have role "caption"`);
|
|
2512
|
+
const bindings = incidentRelations(rows, track.entityId).filter((relation) => relation.relationKind === "timeline-track");
|
|
2513
|
+
if (bindings.length !== 1) throw new Error(`Caption Track "${track.entityId}" must have one unambiguous Timeline`);
|
|
2514
|
+
const binding = bindings[0];
|
|
2515
|
+
if (binding.endpoint0EntityId !== timeline.entityId || binding.endpoint1EntityId !== track.entityId) throw new Error("timeline-track must use ordered endpoints [Timeline, Track]");
|
|
2516
|
+
return track;
|
|
2517
|
+
}
|
|
2518
|
+
function resolveSourceOccurrence(rows, input, sourceEntityId) {
|
|
2519
|
+
const clip = requireEntityKind(rows, input.sourceOccurrence.clipEntityId, "clip");
|
|
2520
|
+
const marker = requireEntityKind(rows, input.sourceOccurrence.markerEntityId, "sequence-marker");
|
|
2521
|
+
const clipMarkers = incidentRelations(rows, clip.entityId).filter((relation) => relation.relationKind === "clip-marker");
|
|
2522
|
+
if (clipMarkers.length !== 1 || clipMarkers[0].endpoint0EntityId !== clip.entityId || clipMarkers[0].endpoint1EntityId !== marker.entityId) throw new Error("Source occurrence requires one ordered clip-marker [Clip, SequenceMarker]");
|
|
2523
|
+
const contents = incidentRelations(rows, marker.entityId).filter((relation) => relation.relationKind === "marker-content");
|
|
2524
|
+
if (contents.length !== 1 || contents[0].endpoint0EntityId !== marker.entityId || contents[0].endpoint1EntityId !== sourceEntityId) throw new Error("Source occurrence requires one ordered marker-content [SequenceMarker, source Entity]");
|
|
2525
|
+
const trackBindings = incidentRelations(rows, clip.entityId).filter((relation) => relation.relationKind === "track-clip");
|
|
2526
|
+
if (trackBindings.length !== 1 || trackBindings[0].endpoint1EntityId !== clip.entityId) throw new Error("Source occurrence requires one ordered track-clip [Track, Clip]");
|
|
2527
|
+
const sourceTrack = requireEntityKind(rows, trackBindings[0].endpoint0EntityId, "track");
|
|
2528
|
+
const timelineBindings = incidentRelations(rows, sourceTrack.entityId).filter((relation) => relation.relationKind === "timeline-track");
|
|
2529
|
+
if (timelineBindings.length !== 1 || timelineBindings[0].endpoint0EntityId !== input.timelineEntityId || timelineBindings[0].endpoint1EntityId !== sourceTrack.entityId) throw new Error("Source occurrence must be placed on the selected Timeline through [Timeline, Track]");
|
|
2530
|
+
const placement = resolveEntityTimelineLayout(rows).clips.get(clip.entityId);
|
|
2531
|
+
if (placement === void 0 || placement.markerEntityId !== marker.entityId || placement.contentEntityId !== sourceEntityId) throw new Error("Source occurrence is not an unambiguous placed use of the selected source Entity");
|
|
2532
|
+
const sourceRange = requirePositiveRange(marker.payload.sourceRange, "Source occurrence sourceRange");
|
|
2533
|
+
return {
|
|
2534
|
+
sourceRange,
|
|
2535
|
+
timelineStart: placement.start,
|
|
2536
|
+
rate: (sourceRange.end - sourceRange.start) / placement.duration
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
function occurrenceTargetRange(occurrence, segment) {
|
|
2540
|
+
if (segment.startMs < occurrence.sourceRange.start || segment.endMs > occurrence.sourceRange.end) throw new Error(`ASR segment "${segment.segmentId}" is outside the selected source occurrence`);
|
|
2541
|
+
const start = Math.round(occurrence.timelineStart + (segment.startMs - occurrence.sourceRange.start) / occurrence.rate);
|
|
2542
|
+
const end = Math.round(occurrence.timelineStart + (segment.endMs - occurrence.sourceRange.start) / occurrence.rate);
|
|
2543
|
+
if (end <= start) throw new Error(`ASR segment "${segment.segmentId}" collapses under the occurrence time mapping`);
|
|
2544
|
+
return {
|
|
2545
|
+
start,
|
|
2546
|
+
end
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
2549
|
+
function resolveCaptionEffectDecisions(rows, input, identityScope, segments) {
|
|
2550
|
+
const source = requireEntityKind(rows, input.source.entityId, input.source.entityKind);
|
|
2551
|
+
requireCanonicalCaptionTrack(rows, input.timelineEntityId, input.trackEntityId);
|
|
2552
|
+
const occurrence = resolveSourceOccurrence(rows, input, source.entityId);
|
|
2553
|
+
return {
|
|
2554
|
+
sourceOccurrence: {
|
|
2555
|
+
sourceStartMs: occurrence.sourceRange.start,
|
|
2556
|
+
sourceEndMs: occurrence.sourceRange.end,
|
|
2557
|
+
timelineStartMs: occurrence.timelineStart,
|
|
2558
|
+
rate: occurrence.rate
|
|
2559
|
+
},
|
|
2560
|
+
targetRanges: segments.map((segment) => ({
|
|
2561
|
+
segmentId: segment.segmentId,
|
|
2562
|
+
...occurrenceTargetRange(occurrence, segment)
|
|
2563
|
+
})),
|
|
2564
|
+
captionAsset: resolveCaptionAssetDecision(rows, input, identityScope)
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2567
|
+
function resolveCaptionAssetDecision(rows, input, identityScope) {
|
|
2568
|
+
const assetId = requireTrimmed(input.captionAsset.assetId, "Caption asset id");
|
|
2569
|
+
const storageKey = input.captionAsset.storageKey === void 0 ? void 0 : requireTrimmed(input.captionAsset.storageKey, "Caption asset storage key");
|
|
2570
|
+
const matches = rows.entities.filter((row) => row.entityKind === "asset" && row.payload.system === "memota" && row.payload.key === assetId);
|
|
2571
|
+
if (matches.length > 1) throw new Error(`Caption Asset "${assetId}" is ambiguous`);
|
|
2572
|
+
const existing = matches[0];
|
|
2573
|
+
if (existing === void 0) return {
|
|
2574
|
+
mode: "create",
|
|
2575
|
+
resultAssetEntityId: stableEffectId("entity", identityScope, "caption-asset:entity:0"),
|
|
2576
|
+
reboundRelationIds: []
|
|
2577
|
+
};
|
|
2578
|
+
if (storageKey === void 0 || existing.payload.storageKey === storageKey) return {
|
|
2579
|
+
mode: "reuse",
|
|
2580
|
+
priorAssetEntityId: existing.entityId,
|
|
2581
|
+
resultAssetEntityId: existing.entityId,
|
|
2582
|
+
reboundRelationIds: []
|
|
2583
|
+
};
|
|
2584
|
+
if (existing.payload.storageKey !== void 0) throw new Error(`Caption Asset "${assetId}" conflicts with its existing storage key`);
|
|
2585
|
+
const bindings = rows.relations.filter((relation) => relation.relationKind === "from-asset" && (relation.endpoint0EntityId === existing.entityId || relation.endpoint1EntityId === existing.entityId)).map((relation) => String(relation.relationId)).sort();
|
|
2586
|
+
return {
|
|
2587
|
+
mode: "replace",
|
|
2588
|
+
priorAssetEntityId: existing.entityId,
|
|
2589
|
+
resultAssetEntityId: stableEffectId("entity", identityScope, "caption-asset:entity:0"),
|
|
2590
|
+
reboundRelationIds: bindings
|
|
2591
|
+
};
|
|
2592
|
+
}
|
|
2593
|
+
function captionBaselineFingerprint(base) {
|
|
2594
|
+
const causal = bytesToHex(base.doc.oplogVersion().encode());
|
|
2595
|
+
return bytesToHex(sha256(new TextEncoder().encode(canonicalJson({
|
|
2596
|
+
causal,
|
|
2597
|
+
state: base.snapshot()
|
|
2598
|
+
}))));
|
|
2599
|
+
}
|
|
2600
|
+
function resolveSpeechEffectDecisions(rows, input, effectId, baselineFingerprint) {
|
|
2601
|
+
const { value } = input;
|
|
2602
|
+
requireEntityKind(rows, value.timelineEntityId, "timeline");
|
|
2603
|
+
for (const item of value.items) if (item.source.kind === "phonetic-script") requirePersistedPhoneticSourceScript(rows, item.source);
|
|
2604
|
+
else if (item.source.audioScriptEntityId !== void 0) requireAssetlessAsrSourceScript(rows, item.source);
|
|
2605
|
+
const scope = stableEffectScope(speechAssetsCommand, effectId, baselineFingerprint);
|
|
2606
|
+
const speechTrackEntityId = resolveEffectTrackId(rows, value.timelineEntityId, "speech", scope);
|
|
2607
|
+
const captionTrackEntityId = value.items.some((item) => item.captions.length > 0 || "targetCaptionClipEntityIds" in item && item.targetCaptionClipEntityIds.length > 0) ? resolveEffectTrackId(rows, value.timelineEntityId, "caption", scope) : void 0;
|
|
2608
|
+
if (input.mode === "add") {
|
|
2609
|
+
const targetStartsMs = input.value.items.map((item) => safeWholeMs(item.placementStartMs, "Speech placement startMs"));
|
|
2610
|
+
return {
|
|
2611
|
+
speechTrackEntityId,
|
|
2612
|
+
...captionTrackEntityId === void 0 ? {} : { captionTrackEntityId },
|
|
2613
|
+
targetStartsMs,
|
|
2614
|
+
recordedSpeechClipEntityIds: input.value.items.map((item, index) => item.renderMode === "recorded" ? resolveRecordedSpeechOccurrence(rows, item, speechTrackEntityId, targetStartsMs[index]) : null)
|
|
2615
|
+
};
|
|
2616
|
+
}
|
|
2617
|
+
const targets = input.value.items.map((item) => item.targetSpeechClipEntityId);
|
|
2618
|
+
if (new Set(targets).size !== targets.length) throw new Error("Replacement Speech targets must be unique");
|
|
2619
|
+
const captionTargets = input.value.items.flatMap((item) => item.targetCaptionClipEntityIds);
|
|
2620
|
+
if (new Set(captionTargets).size !== captionTargets.length) throw new Error("Replacement Caption targets must be unique across the effect");
|
|
2621
|
+
const layout = resolveEntityTimelineLayout(rows);
|
|
2622
|
+
const starts = input.value.items.map((item) => {
|
|
2623
|
+
const target = requireCanonicalClipOnRole(rows, item.targetSpeechClipEntityId, "speech");
|
|
2624
|
+
if (target.trackRelation.endpoint0EntityId !== speechTrackEntityId || target.trackRelation.endpoint1EntityId !== target.clip.entityId) throw new Error("Replacement Speech target must use ordered Track/Clip endpoints");
|
|
2625
|
+
for (const captionId of item.targetCaptionClipEntityIds) {
|
|
2626
|
+
const caption = requireCanonicalClipOnRole(rows, captionId, "caption");
|
|
2627
|
+
if (captionTrackEntityId === void 0 || caption.trackRelation.endpoint0EntityId !== captionTrackEntityId || caption.trackRelation.endpoint1EntityId !== caption.clip.entityId) throw new Error("Replacement Caption target must use ordered Track/Clip endpoints");
|
|
2628
|
+
}
|
|
2629
|
+
const placed = layout.clips.get(target.clip.entityId);
|
|
2630
|
+
if (placed === void 0) throw new Error(`Replacement Speech Clip "${target.clip.entityId}" is not placed`);
|
|
2631
|
+
return placed.start;
|
|
2632
|
+
});
|
|
2633
|
+
return {
|
|
2634
|
+
speechTrackEntityId,
|
|
2635
|
+
...captionTrackEntityId === void 0 ? {} : { captionTrackEntityId },
|
|
2636
|
+
targetStartsMs: starts,
|
|
2637
|
+
recordedSpeechClipEntityIds: input.value.items.map(() => null)
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
function resolveRecordedSpeechOccurrence(rows, item, speechTrackEntityId, startMs) {
|
|
2641
|
+
if (item.source.sourceMediaEntityIds.length !== 1) throw new Error("Recorded speech requires exactly one raw Audio source");
|
|
2642
|
+
const audio = requireEntityKind(rows, item.source.sourceMediaEntityIds[0], "audio");
|
|
2643
|
+
requireRecordedAudioAsset(rows, audio, item.media);
|
|
2644
|
+
const candidates = [...resolveEntityTimelineLayout(rows).clips.values()].filter((placement) => placement.trackEntityId === speechTrackEntityId && placement.contentEntityId === audio.entityId);
|
|
2645
|
+
if (candidates.length > 1) throw new Error("Recorded speech Audio occurrence is ambiguous");
|
|
2646
|
+
const existing = candidates[0];
|
|
2647
|
+
if (existing === void 0) return null;
|
|
2648
|
+
if (existing.start !== startMs || existing.duration !== item.media.durationMs) throw new Error("Recorded speech Audio occurrence conflicts with the requested placement");
|
|
2649
|
+
const topology = requireCanonicalClipOnRole(rows, existing.clipEntityId, "speech");
|
|
2650
|
+
const sourceRange = requirePositiveRange(topology.marker.payload.sourceRange, "Recorded Speech sourceRange");
|
|
2651
|
+
if (sourceRange.start !== 0 || sourceRange.end !== item.media.durationMs || topology.marker.payload.timeRemapping !== void 0) throw new Error("Recorded speech requires the complete, unremapped raw Audio occurrence");
|
|
2652
|
+
return existing.clipEntityId;
|
|
2653
|
+
}
|
|
2654
|
+
function requireRecordedAudioAsset(rows, audio, media) {
|
|
2655
|
+
const bindings = incidentRelations(rows, audio.entityId).filter((relation) => relation.relationKind === "from-asset");
|
|
2656
|
+
if (bindings.length !== 1 || bindings[0].endpoint0EntityId !== audio.entityId) throw new Error("Recorded Audio requires one ordered from-asset binding");
|
|
2657
|
+
const asset = requireEntityKind(rows, bindings[0].endpoint1EntityId, "asset");
|
|
2658
|
+
if (asset.payload.system !== "memota" || asset.payload.key !== media.assetId || asset.payload.storageKey !== media.storageKey || audio.payload.durationMs !== media.durationMs) throw new Error("Recorded speech media must match its canonical memota Audio source");
|
|
2659
|
+
}
|
|
2660
|
+
function requireSynthesizedSpeechIdentity(rows, audio, media) {
|
|
2661
|
+
const bindings = incidentRelations(rows, audio.entityId).filter((relation) => relation.relationKind === "from-asset");
|
|
2662
|
+
if (bindings.length !== 1 || bindings[0].endpoint0EntityId !== audio.entityId) throw new Error("Synthesized speech requires one ordered from-asset binding");
|
|
2663
|
+
const asset = requireEntityKind(rows, bindings[0].endpoint1EntityId, "asset");
|
|
2664
|
+
if (asset.payload.system !== "memota-speech" || asset.payload.key !== media.assetId || asset.payload.storageKey !== media.storageKey || audio.payload.durationMs !== media.durationMs) throw new Error("Synthesized speech media must match its canonical memota-speech Voiceover");
|
|
2665
|
+
const timbre = incidentRelations(rows, audio.entityId).filter((relation) => relation.relationKind === "voice-timbre");
|
|
2666
|
+
if (timbre.length !== 1 || timbre[0].endpoint0EntityId !== audio.entityId) throw new Error("Synthesized speech requires one ordered Voice identity");
|
|
2667
|
+
if (canonicalJson(requireEntityKind(rows, timbre[0].endpoint1EntityId, "voice").payload.voice) !== canonicalJson(media.voice)) throw new Error("Synthesized speech Voice conflicts with its trusted media fact");
|
|
2668
|
+
}
|
|
2669
|
+
function resolveEffectTrackId(rows, timelineEntityId, role, identityScope) {
|
|
2670
|
+
if (rows.relations.filter((relation) => relation.relationKind === "timeline-track" && relation.endpoint1EntityId === timelineEntityId).length > 0) throw new Error("timeline-track must use ordered endpoints [Timeline, Track]");
|
|
2671
|
+
const candidates = rows.relations.filter((relation) => relation.relationKind === "timeline-track" && relation.endpoint0EntityId === timelineEntityId).map((relation) => requireEntityKind(rows, relation.endpoint1EntityId, "track")).filter((track) => track.payload.role === role);
|
|
2672
|
+
if (candidates.length > 1) throw new Error(`${role} Track is ambiguous`);
|
|
2673
|
+
return candidates[0]?.entityId ?? createEntityId(stableEffectId("entity", identityScope, `track:${role}`));
|
|
2674
|
+
}
|
|
2675
|
+
function ensureEffectRoleTrack(rows, timelineEntityId, role, expectedTrackId, identityScope) {
|
|
2676
|
+
const resolved = resolveEffectTrackId(rows, timelineEntityId, role, identityScope);
|
|
2677
|
+
if (resolved !== expectedTrackId) throw new Error(`Frozen ${role} Track decision changed`);
|
|
2678
|
+
if (rows.entities.find((entity) => entity.entityId === resolved) !== void 0) return requireEntityKind(rows, resolved, "track");
|
|
2679
|
+
const track = {
|
|
2680
|
+
entityId: createEntityId(expectedTrackId),
|
|
2681
|
+
entityKind: "track",
|
|
2682
|
+
payload: {
|
|
2683
|
+
role,
|
|
2684
|
+
order: roleOrder[role]
|
|
2685
|
+
}
|
|
2686
|
+
};
|
|
2687
|
+
rows.entities.push(track);
|
|
2688
|
+
rows.relations.push({
|
|
2689
|
+
relationId: createRelationId(stableEffectId("relation", identityScope, `timeline-track:${role}`)),
|
|
2690
|
+
relationKind: "timeline-track",
|
|
2691
|
+
endpoint0EntityId: createEntityId(timelineEntityId),
|
|
2692
|
+
endpoint1EntityId: track.entityId,
|
|
2693
|
+
metadata: {},
|
|
2694
|
+
trace: {}
|
|
2695
|
+
});
|
|
2696
|
+
return track;
|
|
2697
|
+
}
|
|
2698
|
+
function materializeSpeechSource(rows, item, identityScope, itemIndex, trace, speechAudioEntityId) {
|
|
2699
|
+
const source = item.source;
|
|
2700
|
+
const segments = source.kind === "audio-script" ? audioScriptSegmentsFromAsr(source.segments, source.audioScriptEntityId !== void 0) : validateScriptSegments(source.segments);
|
|
2701
|
+
const requestedScriptId = source.audioScriptEntityId === void 0 ? stableEffectId("entity", identityScope, `speech:${itemIndex}:audio-script`) : requireTrimmed(source.audioScriptEntityId, "AudioScript entity id");
|
|
2702
|
+
const existingScript = rows.entities.find((entity) => entity.entityId === requestedScriptId);
|
|
2703
|
+
let audioScript;
|
|
2704
|
+
if (existingScript === void 0) {
|
|
2705
|
+
if (source.kind === "phonetic-script") throw new Error(`Phonetic Speech source AudioScript "${requestedScriptId}" must already exist with an Asset`);
|
|
2706
|
+
audioScript = {
|
|
2707
|
+
entityId: requireAvailableEntityId(rows, requestedScriptId),
|
|
2708
|
+
entityKind: "audio-script",
|
|
2709
|
+
payload: { segments }
|
|
2710
|
+
};
|
|
2711
|
+
rows.entities.push(audioScript);
|
|
2712
|
+
} else {
|
|
2713
|
+
audioScript = requireEntityKind(rows, requestedScriptId, "audio-script");
|
|
2714
|
+
if (canonicalJson(audioScript.payload.segments) !== canonicalJson(segments)) throw new Error(`AudioScript "${requestedScriptId}" conflicts with the immutable Speech source`);
|
|
2715
|
+
if (source.kind === "phonetic-script") requireCanonicalAudioScriptAsset(rows, audioScript.entityId);
|
|
2716
|
+
else requireNoAudioScriptAsset(rows, audioScript.entityId, "ASR-composed");
|
|
2717
|
+
}
|
|
2718
|
+
if (source.kind === "audio-script") {
|
|
2719
|
+
if (item.renderMode === "recorded" && (source.sourceMediaEntityIds.length !== 1 || source.sourceMediaEntityIds[0] !== speechAudioEntityId)) throw new Error("Recorded speech Audio must be its sole AudioScript source");
|
|
2720
|
+
const timed = validateResolvedAsrSegments(source.segments);
|
|
2721
|
+
if (source.sourceMediaEntityIds.length === 0) throw new Error("Audio Speech source requires media lineage");
|
|
2722
|
+
if (item.renderMode === "recorded") {
|
|
2723
|
+
const selected = new Set(source.sourceMediaEntityIds);
|
|
2724
|
+
rows.relations = rows.relations.filter((relation) => relation.relationKind !== "audio-script-source" || !selected.has(relation.endpoint1EntityId));
|
|
2725
|
+
}
|
|
2726
|
+
for (const [sourceIndex, sourceId] of source.sourceMediaEntityIds.entries()) {
|
|
2727
|
+
const media = requireSequenceContent(rows, sourceId);
|
|
2728
|
+
if (media.entityKind !== "audio" && media.entityKind !== "video") throw new Error("audio-script-source requires Audio or Video input");
|
|
2729
|
+
const existing = rows.relations.filter((relation) => relation.relationKind === "audio-script-source" && (relation.endpoint0EntityId === audioScript.entityId || relation.endpoint1EntityId === audioScript.entityId));
|
|
2730
|
+
if (existing.some((relation) => relation.endpoint0EntityId !== audioScript.entityId)) throw new Error("audio-script-source must use ordered endpoints [AudioScript, source media]");
|
|
2731
|
+
if (!existing.some((relation) => relation.endpoint1EntityId === media.entityId)) rows.relations.push({
|
|
2732
|
+
relationId: createRelationId(stableEffectId("relation", identityScope, `speech:${itemIndex}:audio-source:${sourceIndex}`)),
|
|
2733
|
+
relationKind: "audio-script-source",
|
|
2734
|
+
endpoint0EntityId: audioScript.entityId,
|
|
2735
|
+
endpoint1EntityId: media.entityId,
|
|
2736
|
+
metadata: {},
|
|
2737
|
+
trace: {
|
|
2738
|
+
...trace,
|
|
2739
|
+
role: "audio-script-source",
|
|
2740
|
+
sourceIndex
|
|
2741
|
+
}
|
|
2742
|
+
});
|
|
2743
|
+
}
|
|
2744
|
+
const markerLinks = incidentRelations(rows, audioScript.entityId).filter((relation) => relation.relationKind === "audio-script-marker");
|
|
2745
|
+
if (markerLinks.some((relation) => relation.endpoint0EntityId !== audioScript.entityId)) throw new Error("audio-script-marker must use ordered endpoints [AudioScript, annotation SequenceMarker]");
|
|
2746
|
+
if (markerLinks.length > 1) throw new Error(`AudioScript "${audioScript.entityId}" has ambiguous timing annotations`);
|
|
2747
|
+
const segmentRanges = timed.map((segment) => ({
|
|
2748
|
+
segmentId: segment.segmentId,
|
|
2749
|
+
startMs: segment.startMs,
|
|
2750
|
+
endMs: segment.endMs
|
|
2751
|
+
}));
|
|
2752
|
+
if (markerLinks[0] !== void 0) {
|
|
2753
|
+
if (canonicalJson(requireEntityKind(rows, markerLinks[0].endpoint1EntityId, "sequence-marker").payload.segmentRanges) !== canonicalJson(segmentRanges)) throw new Error(`AudioScript "${audioScript.entityId}" timing conflicts with the immutable Speech source`);
|
|
2754
|
+
} else {
|
|
2755
|
+
const markerId = stableEffectId("entity", identityScope, `speech:${itemIndex}:audio-script-marker`);
|
|
2756
|
+
rows.entities.push({
|
|
2757
|
+
entityId: createEntityId(markerId),
|
|
2758
|
+
entityKind: "sequence-marker",
|
|
2759
|
+
payload: {
|
|
2760
|
+
sourceRange: {
|
|
2761
|
+
start: source.sourceStartMs,
|
|
2762
|
+
end: source.sourceStartMs + item.media.durationMs
|
|
2763
|
+
},
|
|
2764
|
+
duration: {
|
|
2765
|
+
mode: "fixed",
|
|
2766
|
+
value: item.media.durationMs
|
|
2767
|
+
},
|
|
2768
|
+
segmentRanges
|
|
2769
|
+
}
|
|
2770
|
+
});
|
|
2771
|
+
rows.relations.push({
|
|
2772
|
+
relationId: createRelationId(stableEffectId("relation", identityScope, `speech:${itemIndex}:script-marker`)),
|
|
2773
|
+
relationKind: "audio-script-marker",
|
|
2774
|
+
endpoint0EntityId: audioScript.entityId,
|
|
2775
|
+
endpoint1EntityId: createEntityId(markerId),
|
|
2776
|
+
metadata: {},
|
|
2777
|
+
trace: {
|
|
2778
|
+
...trace,
|
|
2779
|
+
role: "audio-script-marker"
|
|
2780
|
+
}
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
if (item.renderMode === "recorded") return { audioScript };
|
|
2785
|
+
const phoneticId = source.kind === "phonetic-script" ? requireTrimmed(source.phoneticScriptEntityId, "PhoneticScript entity id") : stableEffectId("entity", identityScope, `speech:${itemIndex}:phonetic-script`);
|
|
2786
|
+
const existingPhonetic = rows.entities.find((entity) => entity.entityId === phoneticId);
|
|
2787
|
+
const phoneticPayload = {
|
|
2788
|
+
baseEntityIds: [audioScript.entityId],
|
|
2789
|
+
...source.kind === "phonetic-script" && source.phonemeScript !== void 0 ? { phonemeScript: source.phonemeScript } : {}
|
|
2790
|
+
};
|
|
2791
|
+
let phonetic;
|
|
2792
|
+
if (existingPhonetic === void 0) {
|
|
2793
|
+
phonetic = {
|
|
2794
|
+
entityId: requireAvailableEntityId(rows, phoneticId),
|
|
2795
|
+
entityKind: "phonetic-script",
|
|
2796
|
+
payload: phoneticPayload
|
|
2797
|
+
};
|
|
2798
|
+
rows.entities.push(phonetic);
|
|
2799
|
+
} else {
|
|
2800
|
+
phonetic = requireEntityKind(rows, phoneticId, "phonetic-script");
|
|
2801
|
+
if (canonicalJson(phonetic.payload) !== canonicalJson(phoneticPayload)) throw new Error(`PhoneticScript "${phoneticId}" conflicts with the immutable Speech source`);
|
|
1738
2802
|
}
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
2803
|
+
assemblePhoneticScriptContent(rows, phonetic.entityId);
|
|
2804
|
+
return {
|
|
2805
|
+
audioScript,
|
|
2806
|
+
phonetic
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2809
|
+
function requirePersistedPhoneticSourceScript(rows, source) {
|
|
2810
|
+
const scriptId = requireTrimmed(source.audioScriptEntityId, "AudioScript entity id");
|
|
2811
|
+
const audioScript = requireEntityKind(rows, scriptId, "audio-script");
|
|
2812
|
+
const segments = validateScriptSegments(source.segments);
|
|
2813
|
+
if (canonicalJson(audioScript.payload.segments) !== canonicalJson(segments)) throw new Error(`AudioScript "${scriptId}" conflicts with the immutable Speech source`);
|
|
2814
|
+
requireCanonicalAudioScriptAsset(rows, audioScript.entityId);
|
|
2815
|
+
return audioScript;
|
|
2816
|
+
}
|
|
2817
|
+
function requireAssetlessAsrSourceScript(rows, source) {
|
|
2818
|
+
const scriptId = requireTrimmed(source.audioScriptEntityId, "AudioScript entity id");
|
|
2819
|
+
const audioScript = requireEntityKind(rows, scriptId, "audio-script");
|
|
2820
|
+
const segments = audioScriptSegmentsFromAsr(source.segments, true);
|
|
2821
|
+
if (canonicalJson(audioScript.payload.segments) !== canonicalJson(segments)) throw new Error(`AudioScript "${scriptId}" conflicts with the immutable ASR source`);
|
|
2822
|
+
requireNoAudioScriptAsset(rows, audioScript.entityId, "ASR-composed");
|
|
2823
|
+
return audioScript;
|
|
2824
|
+
}
|
|
2825
|
+
function requireNoAudioScriptAsset(rows, audioScriptEntityId, label) {
|
|
2826
|
+
if (incidentRelations(rows, audioScriptEntityId).filter((relation) => relation.relationKind === "to-asset").length > 0) throw new Error(`${label} AudioScript "${audioScriptEntityId}" must not have an authored Asset snapshot`);
|
|
2827
|
+
}
|
|
2828
|
+
function requireCanonicalAudioScriptAsset(rows, audioScriptEntityId) {
|
|
2829
|
+
const bindings = incidentRelations(rows, audioScriptEntityId).filter((relation) => relation.relationKind === "to-asset");
|
|
2830
|
+
if (bindings.length !== 1) throw new Error(`AudioScript "${audioScriptEntityId}" requires exactly one Asset binding`);
|
|
2831
|
+
const binding = bindings[0];
|
|
2832
|
+
if (binding.endpoint0EntityId !== audioScriptEntityId) throw new Error("to-asset must use ordered endpoints [AudioScript, Asset]");
|
|
2833
|
+
const asset = requireEntityKind(rows, binding.endpoint1EntityId, "asset");
|
|
2834
|
+
if (asset.payload.system !== "memota" || typeof asset.payload.key !== "string" || asset.payload.key.length === 0 || asset.payload.key.trim() !== asset.payload.key) throw new Error(`AudioScript Asset "${asset.entityId}" must state a canonical memota locator`);
|
|
2835
|
+
return asset;
|
|
2836
|
+
}
|
|
2837
|
+
function validateScriptSegments(segments) {
|
|
2838
|
+
if (segments.length === 0) throw new Error("Speech source requires at least one script segment");
|
|
2839
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2840
|
+
return Array.from({ length: segments.length }, (_, index) => {
|
|
2841
|
+
if (!Object.hasOwn(segments, index)) throw new Error("Speech source segments must not be sparse");
|
|
2842
|
+
const value = segments[index];
|
|
2843
|
+
const segment = strictJsonObject(value, `Speech source segment ${index}`);
|
|
2844
|
+
if (typeof segment.segmentId !== "string") throw new Error("Speech source segment id must be a string");
|
|
2845
|
+
const segmentId = requireTrimmed(segment.segmentId, "Speech source segment id");
|
|
2846
|
+
if (ids.has(segmentId)) throw new Error(`Duplicate Speech source segment id "${segmentId}"`);
|
|
2847
|
+
ids.add(segmentId);
|
|
2848
|
+
if (typeof segment.text !== "string" || segment.text.trim() === "") throw new Error("Speech source text is required");
|
|
2849
|
+
if (segment.language !== void 0 && typeof segment.language !== "string") throw new Error("Speech source language must be a string");
|
|
2850
|
+
return {
|
|
2851
|
+
...segment,
|
|
2852
|
+
segmentId,
|
|
2853
|
+
text: segment.text,
|
|
2854
|
+
...segment.language === void 0 ? {} : { language: requireTrimmed(segment.language, "Speech source language") }
|
|
2855
|
+
};
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
function validateResolvedAsrSegments(segments) {
|
|
2859
|
+
return validateScriptSegments(segments).map((segment) => {
|
|
2860
|
+
const segmentId = segment.segmentId;
|
|
2861
|
+
const startMs = safeWholeMs(segment.startMs, `ASR segment "${segmentId}" startMs`);
|
|
2862
|
+
const endMs = safeWholeMs(segment.endMs, `ASR segment "${segmentId}" endMs`);
|
|
2863
|
+
if (endMs <= startMs) throw new Error(`ASR segment "${segmentId}" must have positive duration`);
|
|
2864
|
+
return {
|
|
2865
|
+
...segment,
|
|
2866
|
+
segmentId,
|
|
2867
|
+
text: segment.text,
|
|
2868
|
+
startMs,
|
|
2869
|
+
endMs
|
|
2870
|
+
};
|
|
2871
|
+
});
|
|
2872
|
+
}
|
|
2873
|
+
function audioScriptSegmentsFromAsr(segments, preserveResolvedFields) {
|
|
2874
|
+
return validateResolvedAsrSegments(segments).map((timed) => {
|
|
2875
|
+
if (!preserveResolvedFields) {
|
|
2876
|
+
const allowed = new Set([
|
|
2877
|
+
"segmentId",
|
|
2878
|
+
"text",
|
|
2879
|
+
"language",
|
|
2880
|
+
"startMs",
|
|
2881
|
+
"endMs"
|
|
2882
|
+
]);
|
|
2883
|
+
const extras = Object.keys(timed).filter((key) => !allowed.has(key));
|
|
2884
|
+
if (extras.length > 0) throw new Error(`New ASR segment must not contain host-resolved fields: ${extras.join(", ")}`);
|
|
2885
|
+
return {
|
|
2886
|
+
segmentId: timed.segmentId,
|
|
2887
|
+
text: timed.text,
|
|
2888
|
+
...timed.language === void 0 ? {} : { language: timed.language }
|
|
2889
|
+
};
|
|
2890
|
+
}
|
|
2891
|
+
const segment = { ...timed };
|
|
2892
|
+
delete segment.startMs;
|
|
2893
|
+
delete segment.endMs;
|
|
2894
|
+
return segment;
|
|
2895
|
+
});
|
|
2896
|
+
}
|
|
2897
|
+
function strictJsonObject(value, label) {
|
|
2898
|
+
const clone = strictJsonValue(value, label, /* @__PURE__ */ new WeakSet());
|
|
2899
|
+
if (!isJsonObject(clone)) throw new Error(`${label} must be a plain JSON object`);
|
|
2900
|
+
return clone;
|
|
2901
|
+
}
|
|
2902
|
+
function strictJsonValue(value, label, seen) {
|
|
2903
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
2904
|
+
if (typeof value === "number") {
|
|
2905
|
+
if (!Number.isFinite(value)) throw new Error(`${label} must not contain non-finite JSON numbers`);
|
|
2906
|
+
return value;
|
|
2907
|
+
}
|
|
2908
|
+
if (Array.isArray(value)) {
|
|
2909
|
+
if (seen.has(value)) throw new Error(`${label} must not contain cyclic JSON values`);
|
|
2910
|
+
seen.add(value);
|
|
2911
|
+
const result = [];
|
|
2912
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
2913
|
+
if (!Object.hasOwn(value, index)) throw new Error(`${label} must not contain sparse JSON arrays`);
|
|
2914
|
+
result.push(strictJsonValue(value[index], `${label}[${index}]`, seen));
|
|
2915
|
+
}
|
|
2916
|
+
seen.delete(value);
|
|
1745
2917
|
return result;
|
|
1746
2918
|
}
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
2919
|
+
if (typeof value !== "object" || value === void 0) throw new Error(`${label} must contain only JSON values`);
|
|
2920
|
+
const prototype = Object.getPrototypeOf(value);
|
|
2921
|
+
if (prototype !== Object.prototype && prototype !== null) throw new Error(`${label} must not contain objects with custom prototypes`);
|
|
2922
|
+
if (seen.has(value)) throw new Error(`${label} must not contain cyclic JSON values`);
|
|
2923
|
+
seen.add(value);
|
|
2924
|
+
const result = {};
|
|
2925
|
+
for (const [key, child] of Object.entries(value)) {
|
|
2926
|
+
if (child === void 0) throw new Error(`${label}.${key} must not be undefined`);
|
|
2927
|
+
result[key] = strictJsonValue(child, `${label}.${key}`, seen);
|
|
2928
|
+
}
|
|
2929
|
+
seen.delete(value);
|
|
2930
|
+
return result;
|
|
2931
|
+
}
|
|
2932
|
+
function replaceRelationWithTrace(rows, previous, endpoint0EntityId, endpoint1EntityId, trace, relationId) {
|
|
2933
|
+
rows.relations = rows.relations.filter((relation) => relation.relationId !== previous.relationId);
|
|
2934
|
+
rows.relations.push({
|
|
2935
|
+
relationId: requireAvailableRelationId(rows, relationId),
|
|
2936
|
+
relationKind: previous.relationKind,
|
|
2937
|
+
endpoint0EntityId,
|
|
2938
|
+
endpoint1EntityId,
|
|
2939
|
+
metadata: previous.metadata,
|
|
2940
|
+
trace
|
|
2941
|
+
});
|
|
2942
|
+
}
|
|
2943
|
+
/** Retire one editor use while retaining its immutable content row and Loro history. */
|
|
2944
|
+
function retireClipOccurrence(rows, topology) {
|
|
2945
|
+
const removedRelations = new Set([
|
|
2946
|
+
topology.trackRelation.relationId,
|
|
2947
|
+
topology.clipMarkerRelation.relationId,
|
|
2948
|
+
topology.markerContentRelation.relationId
|
|
2949
|
+
]);
|
|
2950
|
+
rows.relations = rows.relations.filter((relation) => !removedRelations.has(relation.relationId));
|
|
2951
|
+
rows.entities = rows.entities.filter((entity) => entity.entityId !== topology.clip.entityId && entity.entityId !== topology.marker.entityId);
|
|
2952
|
+
}
|
|
2953
|
+
function speechEffectSignature(input) {
|
|
2954
|
+
return bytesToHex(sha256(new TextEncoder().encode(canonicalJson(input))));
|
|
2955
|
+
}
|
|
2956
|
+
function speechEffectTrace(envelope, itemIndex) {
|
|
2957
|
+
return {
|
|
2958
|
+
command: speechAssetsCommand,
|
|
2959
|
+
effectId: envelope.effectId,
|
|
2960
|
+
signature: envelope.signature,
|
|
2961
|
+
mode: envelope.input.mode,
|
|
2962
|
+
itemIndex
|
|
2963
|
+
};
|
|
2964
|
+
}
|
|
2965
|
+
function assertSpeechAssetsEnvelope(value) {
|
|
2966
|
+
if (value.version !== 1 || !Object.isFrozen(value) || !Object.isFrozen(value.input) || !Object.isFrozen(value.decisions) || speechEffectSignature(value.input) !== value.signature || value.effectId !== value.input.value.effectId) throw new Error("Speech effect requires a canonical frozen envelope");
|
|
2967
|
+
for (const item of value.input.value.items) assertSpeechRenderContract(value.input.mode, item);
|
|
2968
|
+
}
|
|
2969
|
+
function resolveSpeechAssetsReplay(rows, envelope, history) {
|
|
2970
|
+
const currentTagged = rows.relations.filter((relation) => relation.trace.command === speechAssetsCommand && relation.trace.effectId === envelope.effectId);
|
|
2971
|
+
const historicalTagged = (history?.tombstonedRelations ?? []).filter((relation) => relation.trace.command === speechAssetsCommand && relation.trace.effectId === envelope.effectId);
|
|
2972
|
+
const tagged = [...currentTagged, ...historicalTagged];
|
|
2973
|
+
const historicalIds = new Set(historicalTagged.map((relation) => relation.relationId));
|
|
2974
|
+
if (tagged.length === 0) return void 0;
|
|
2975
|
+
if (tagged.some((relation) => relation.trace.signature !== envelope.signature)) throw new Error(`Speech effect "${envelope.effectId}" was already applied with different input`);
|
|
2976
|
+
const result = {
|
|
2977
|
+
speechClipEntityIds: [],
|
|
2978
|
+
speechAudioEntityIds: [],
|
|
2979
|
+
audioScriptEntityIds: [],
|
|
2980
|
+
phoneticScriptEntityIds: [],
|
|
2981
|
+
captionClipEntityIds: []
|
|
2982
|
+
};
|
|
2983
|
+
for (const [index, item] of envelope.input.value.items.entries()) {
|
|
2984
|
+
const relations = tagged.filter((relation) => relation.trace.itemIndex === index);
|
|
2985
|
+
const sourceReceipts = relations.filter((relation) => relation.relationKind === "audio-script-source");
|
|
2986
|
+
const contentRelations = relations.filter((relation) => relation.relationKind === "marker-content");
|
|
2987
|
+
const speechContent = contentRelations.filter((relation) => relation.trace.role === "speech-content");
|
|
2988
|
+
const captionContent = contentRelations.filter((relation) => relation.trace.role === "caption-content");
|
|
2989
|
+
const receiptMarkers = relations.filter((relation) => relation.relationKind === "audio-script-marker");
|
|
2990
|
+
const renders = relations.filter((relation) => relation.relationKind === "phonetic-script-render");
|
|
2991
|
+
if (speechContent.length > 1) throw new Error(`Speech effect item ${index} has an ambiguous Speech occurrence receipt`);
|
|
2992
|
+
if (speechContent.length === 0) throw new Error(`Speech effect item ${index} has an incomplete Speech occurrence receipt`);
|
|
2993
|
+
if (contentRelations.length !== speechContent.length + captionContent.length) throw new Error(`Speech effect item ${index} has an unrecognized content receipt`);
|
|
2994
|
+
const captionReceipts = item.captions.map((_, captionIndex) => captionContent.filter((relation) => relation.trace.captionIndex === captionIndex));
|
|
2995
|
+
if (captionContent.some((relation) => !Number.isInteger(relation.trace.captionIndex) || relation.trace.captionIndex < 0 || relation.trace.captionIndex >= item.captions.length) || captionReceipts.some((matches) => matches.length > 1)) throw new Error(`Speech effect item ${index} has an ambiguous Caption receipt`);
|
|
2996
|
+
const sourceIndexes = sourceReceipts.map((relation) => relation.trace.sourceIndex);
|
|
2997
|
+
const expectedSourceCount = item.source.kind === "audio-script" ? item.source.sourceMediaEntityIds.length : void 0;
|
|
2998
|
+
if ((expectedSourceCount === void 0 ? sourceReceipts.length > 0 : sourceIndexes.some((sourceIndex) => !Number.isInteger(sourceIndex) || sourceIndex < 0 || sourceIndex >= expectedSourceCount)) || new Set(sourceIndexes).size !== sourceIndexes.length) throw new Error(`Speech effect item ${index} has an ambiguous AudioScript source receipt`);
|
|
2999
|
+
if (receiptMarkers.length > 1) throw new Error("Speech has an ambiguous AudioScript receipt");
|
|
3000
|
+
if (item.renderMode === "recorded") {
|
|
3001
|
+
if (sourceReceipts.length > 1) throw new Error("Recorded speech has an ambiguous AudioScript source receipt");
|
|
3002
|
+
if (sourceReceipts.length === 0) throw new Error("Recorded speech has an incomplete AudioScript source receipt");
|
|
3003
|
+
if (renders.length !== 0) throw new Error("Recorded speech must not have render lineage");
|
|
3004
|
+
} else if (renders.length > 1) throw new Error(`Speech effect item ${index} has an ambiguous render receipt`);
|
|
3005
|
+
else if (renders.length === 0) throw new Error(`Speech effect item ${index} has an incomplete render receipt`);
|
|
3006
|
+
const historicalRecorded = item.renderMode === "recorded" && (speechContent.some((relation) => historicalIds.has(relation.relationId)) || sourceReceipts.some((relation) => historicalIds.has(relation.relationId)));
|
|
3007
|
+
let audio;
|
|
3008
|
+
let speechClip;
|
|
3009
|
+
const speechReceipt = speechContent[0];
|
|
3010
|
+
const speechReceiptIsHistorical = historicalIds.has(speechReceipt.relationId);
|
|
3011
|
+
const speechKind = item.renderMode === "recorded" ? "audio" : "voiceover";
|
|
3012
|
+
if (speechReceiptIsHistorical) {
|
|
3013
|
+
requireHistoricalEntity(history?.getVersion(speechReceipt.endpoint1EntityId), speechReceipt.endpoint1EntityId, speechKind);
|
|
3014
|
+
requireHistoricalEntity(history?.getVersion(speechReceipt.endpoint0EntityId), speechReceipt.endpoint0EntityId, "sequence-marker");
|
|
3015
|
+
}
|
|
3016
|
+
const currentSpeechId = speechReceiptIsHistorical ? history.resolveCurrentEntityId(speechReceipt.endpoint1EntityId) : speechReceipt.endpoint1EntityId;
|
|
3017
|
+
const currentMarkerId = speechReceiptIsHistorical ? history.resolveCurrentEntityId(speechReceipt.endpoint0EntityId) : speechReceipt.endpoint0EntityId;
|
|
3018
|
+
audio = requireEntityKind(rows, currentSpeechId, speechKind);
|
|
3019
|
+
const speechMarker = requireEntityKind(rows, currentMarkerId, "sequence-marker");
|
|
3020
|
+
speechClip = requireSingleClipForMarker(rows, speechMarker.entityId, "speech");
|
|
3021
|
+
const currentSpeechContent = requireOneRelation(rows, speechMarker.entityId, "marker-content");
|
|
3022
|
+
if (currentSpeechContent.endpoint0EntityId !== speechMarker.entityId || currentSpeechContent.endpoint1EntityId !== audio.entityId) throw new Error("Speech occurrence receipt no longer resolves to its current content");
|
|
3023
|
+
let audioScriptId;
|
|
3024
|
+
let phoneticScriptEntityId;
|
|
3025
|
+
if (item.renderMode === "synthesized") {
|
|
3026
|
+
const voiceover = requireEntityKind(rows, audio.entityId, "voiceover");
|
|
3027
|
+
requireSynthesizedSpeechIdentity(rows, voiceover, item.media);
|
|
3028
|
+
if (renders[0].endpoint0EntityId !== audio.entityId) throw new Error(`Speech effect item ${index} has an incomplete render receipt`);
|
|
3029
|
+
const phonetic = requireEntityKind(rows, renders[0].endpoint1EntityId, "phonetic-script");
|
|
3030
|
+
const currentRenders = incidentRelations(rows, voiceover.entityId).filter((relation) => relation.relationKind === "phonetic-script-render");
|
|
3031
|
+
if (currentRenders.length !== 1 || currentRenders[0].endpoint0EntityId !== voiceover.entityId || currentRenders[0].endpoint1EntityId !== phonetic.entityId) throw new Error("Synthesized speech has ambiguous or conflicting current render lineage");
|
|
3032
|
+
audioScriptId = requireComposedAudioScript(rows, phonetic.entityId);
|
|
3033
|
+
phoneticScriptEntityId = phonetic.entityId;
|
|
3034
|
+
} else {
|
|
3035
|
+
audio = requireEntityKind(rows, audio.entityId, "audio");
|
|
3036
|
+
requireRecordedAudioAsset(rows, audio, item.media);
|
|
3037
|
+
if (sourceReceipts[0].endpoint1EntityId !== audio.entityId) throw new Error("Recorded speech has an incomplete AudioScript source receipt");
|
|
3038
|
+
if (item.source.audioScriptEntityId !== void 0 && sourceReceipts[0].endpoint0EntityId !== item.source.audioScriptEntityId) throw new Error("Recorded speech receipt does not match the requested AudioScript identity");
|
|
3039
|
+
const receiptScriptId = sourceReceipts[0].endpoint0EntityId;
|
|
3040
|
+
const sourceReceiptIsHistorical = historicalIds.has(sourceReceipts[0].relationId);
|
|
3041
|
+
const audioScript = sourceReceiptIsHistorical ? requireHistoricalEntity(history?.getVersion(receiptScriptId), receiptScriptId, "audio-script") : requireEntityKind(rows, receiptScriptId, "audio-script");
|
|
3042
|
+
const expected = audioScriptSegmentsFromAsr(item.source.segments, item.source.audioScriptEntityId !== void 0);
|
|
3043
|
+
if (canonicalJson(audioScript.payload.segments) !== canonicalJson(expected)) throw new Error("Recorded speech AudioScript conflicts with its immutable ASR source");
|
|
3044
|
+
const currentScriptId = sourceReceiptIsHistorical ? createEntityId(history.resolveCurrentEntityId(receiptScriptId)) : audioScript.entityId;
|
|
3045
|
+
requireNoAudioScriptAsset(rows, currentScriptId, "ASR-composed");
|
|
3046
|
+
if (receiptMarkers.length === 1) {
|
|
3047
|
+
const markerScriptId = historicalIds.has(receiptMarkers[0].relationId) ? audioScript.entityId : currentScriptId;
|
|
3048
|
+
if (receiptMarkers[0].endpoint0EntityId !== markerScriptId) throw new Error("Recorded speech marker receipt conflicts with its AudioScript source receipt");
|
|
3049
|
+
}
|
|
3050
|
+
const sources = incidentRelations(rows, audio.entityId).filter((relation) => relation.relationKind === "audio-script-source");
|
|
3051
|
+
if (sources.length > 1 || sources.length === 1 && (sources[0].endpoint1EntityId !== audio.entityId || requireEntityKind(rows, sources[0].endpoint0EntityId, "audio-script").entityKind !== "audio-script")) throw new Error("Recorded speech has ambiguous or reversed current AudioScript source lineage");
|
|
3052
|
+
if (!historicalRecorded && (sources.length !== 1 || sources[0].endpoint0EntityId !== currentScriptId)) throw new Error("Recorded speech has an incomplete AudioScript source receipt");
|
|
3053
|
+
if (incidentRelations(rows, audio.entityId).some((relation) => relation.relationKind === "voice-timbre" || relation.relationKind === "phonetic-script-render")) throw new Error("Recorded speech must not have Voice or render lineage");
|
|
3054
|
+
audioScriptId = currentScriptId;
|
|
3055
|
+
phoneticScriptEntityId = null;
|
|
3056
|
+
}
|
|
3057
|
+
const captionIds = [];
|
|
3058
|
+
for (let captionIndex = 0; captionIndex < item.captions.length; captionIndex += 1) {
|
|
3059
|
+
const matches = captionReceipts[captionIndex];
|
|
3060
|
+
if (matches.length !== 1) throw new Error(`Speech effect item ${index} Caption receipt is incomplete`);
|
|
3061
|
+
if (assembleCaptionContent(rows, requireEntityKind(rows, matches[0].endpoint1EntityId, "caption").entityId).audioScript.entityId !== audioScriptId) throw new Error("Speech Caption must retain the same AudioScript base");
|
|
3062
|
+
captionIds.push(requireSingleClipForMarker(rows, matches[0].endpoint0EntityId, "caption").entityId);
|
|
3063
|
+
}
|
|
3064
|
+
result.speechClipEntityIds.push(speechClip.entityId);
|
|
3065
|
+
result.speechAudioEntityIds.push(audio.entityId);
|
|
3066
|
+
result.audioScriptEntityIds.push(audioScriptId);
|
|
3067
|
+
result.phoneticScriptEntityIds.push(phoneticScriptEntityId);
|
|
3068
|
+
result.captionClipEntityIds.push(captionIds);
|
|
1751
3069
|
}
|
|
1752
|
-
|
|
3070
|
+
return result;
|
|
3071
|
+
}
|
|
3072
|
+
function requireSingleClipForMarker(rows, markerEntityId, role) {
|
|
3073
|
+
const links = incidentRelations(rows, markerEntityId).filter((relation) => relation.relationKind === "clip-marker");
|
|
3074
|
+
if (links.length !== 1 || links[0].endpoint1EntityId !== markerEntityId) throw new Error("clip-marker must use one ordered [Clip, SequenceMarker] relation");
|
|
3075
|
+
return requireClipOnRole(rows, links[0].endpoint0EntityId, role).clip;
|
|
3076
|
+
}
|
|
3077
|
+
function resolveGeneratedVisualDecisions(rows, input, identityScope) {
|
|
3078
|
+
const timeline = requireEntityKind(rows, input.timelineEntityId, "timeline");
|
|
3079
|
+
const anchorInput = visualInsertionAnchor(input);
|
|
3080
|
+
if (anchorInput.position === "track_start") {
|
|
3081
|
+
const trackEntityId = resolveEffectTrackId(rows, timeline.entityId, "video_clip", identityScope);
|
|
3082
|
+
const clips = resolveEntityTimelineLayout(rows).tracks.find((candidate) => candidate.entityId === trackEntityId)?.clips ?? [];
|
|
3083
|
+
if (clips.some((clip) => clip.mode !== "sequential")) throw new Error("Generated visual insertion requires a fully sequential Video Track");
|
|
1753
3084
|
return {
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
metadata,
|
|
1759
|
-
trace: {}
|
|
3085
|
+
trackEntityId,
|
|
3086
|
+
anchorOrder: null,
|
|
3087
|
+
anchorPosition: "track_start",
|
|
3088
|
+
insertionOrders: visualInsertionOrders(rows, clips.map((clip) => clip.clipEntityId), 0, input.clips.length)
|
|
1760
3089
|
};
|
|
1761
3090
|
}
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
3091
|
+
const anchor = requireCanonicalClipOnRole(rows, anchorInput.clipEntityId, "video_clip");
|
|
3092
|
+
const track = requireEntityKind(rows, anchor.trackRelation.endpoint0EntityId, "track");
|
|
3093
|
+
if (anchor.trackRelation.endpoint1EntityId !== anchor.clip.entityId) throw new Error("track-clip must use ordered endpoints [Track, Clip]");
|
|
3094
|
+
const timelineLinks = incidentRelations(rows, track.entityId).filter((relation) => relation.relationKind === "timeline-track");
|
|
3095
|
+
if (timelineLinks.length !== 1) throw new Error(`Video Track "${track.entityId}" must have one unambiguous Timeline`);
|
|
3096
|
+
const timelineLink = timelineLinks[0];
|
|
3097
|
+
if (timelineLink.endpoint0EntityId !== timeline.entityId || timelineLink.endpoint1EntityId !== track.entityId) throw new Error("timeline-track must use ordered endpoints [Timeline, Track]");
|
|
3098
|
+
const resolvedTrack = resolveEntityTimelineLayout(rows).tracks.find((candidate) => candidate.entityId === track.entityId);
|
|
3099
|
+
const anchorOrder = resolvedTrack?.clips.findIndex((clip) => clip.clipEntityId === anchor.clip.entityId) ?? -1;
|
|
3100
|
+
if (anchorOrder < 0) throw new Error(`Visual anchor Clip "${anchor.clip.entityId}" is not placed on its Track`);
|
|
3101
|
+
if (resolvedTrack.clips.some((clip) => clip.mode !== "sequential")) throw new Error("Generated visual insertion requires a fully sequential Video Track");
|
|
3102
|
+
const insertionIndex = anchorOrder + (anchorInput.position === "after" ? 1 : 0);
|
|
3103
|
+
return {
|
|
3104
|
+
trackEntityId: track.entityId,
|
|
3105
|
+
anchorOrder,
|
|
3106
|
+
anchorPosition: anchorInput.position,
|
|
3107
|
+
insertionOrders: visualInsertionOrders(rows, resolvedTrack.clips.map((clip) => clip.clipEntityId), insertionIndex, input.clips.length)
|
|
3108
|
+
};
|
|
3109
|
+
}
|
|
3110
|
+
function visualInsertionOrders(rows, orderedClipIds, insertionIndex, count) {
|
|
3111
|
+
const orders = orderedClipIds.map((clipId) => {
|
|
3112
|
+
const order = requireEntityKind(rows, clipId, "clip").payload.order;
|
|
3113
|
+
if (typeof order !== "number" || !Number.isFinite(order)) throw new Error(`Sequential Clip "${clipId}" requires a finite order`);
|
|
3114
|
+
return order;
|
|
3115
|
+
});
|
|
3116
|
+
if (orders.some((order, index) => index > 0 && order <= orders[index - 1])) throw new Error("Generated visual insertion requires unambiguous increasing Clip orders");
|
|
3117
|
+
const lower = orders[insertionIndex - 1];
|
|
3118
|
+
const upper = orders[insertionIndex];
|
|
3119
|
+
if (lower === void 0 && upper === void 0) return Array.from({ length: count }, (_, index) => index);
|
|
3120
|
+
if (lower === void 0) return Array.from({ length: count }, (_, index) => upper - count + index);
|
|
3121
|
+
if (upper === void 0) return Array.from({ length: count }, (_, index) => lower + index + 1);
|
|
3122
|
+
const gap = (upper - lower) / (count + 1);
|
|
3123
|
+
const result = Array.from({ length: count }, (_, index) => lower + gap * (index + 1));
|
|
3124
|
+
if (result.some((order, index) => !Number.isFinite(order) || order <= (index === 0 ? lower : result[index - 1]) || order >= upper)) throw new Error("Clip order precision is insufficient for generated visual insertion");
|
|
3125
|
+
return result;
|
|
3126
|
+
}
|
|
3127
|
+
function visualInsertionAnchor(input) {
|
|
3128
|
+
const before = input.beforeClipEntityId;
|
|
3129
|
+
const after = input.afterClipEntityId;
|
|
3130
|
+
if (before !== void 0 && after !== void 0) throw new Error("Generated visual effect cannot set both Clip anchors");
|
|
3131
|
+
if (before === void 0 && after === void 0) return { position: "track_start" };
|
|
3132
|
+
return before === void 0 ? {
|
|
3133
|
+
position: "after",
|
|
3134
|
+
clipEntityId: requireTrimmed(after, "After Clip anchor")
|
|
3135
|
+
} : {
|
|
3136
|
+
position: "before",
|
|
3137
|
+
clipEntityId: requireTrimmed(before, "Before Clip anchor")
|
|
3138
|
+
};
|
|
3139
|
+
}
|
|
3140
|
+
function generatedVisualSignature(input) {
|
|
3141
|
+
return bytesToHex(sha256(new TextEncoder().encode(canonicalJson(input))));
|
|
3142
|
+
}
|
|
3143
|
+
function assertGeneratedVisualEnvelope(value) {
|
|
3144
|
+
if (value.version !== 1 || !Object.isFrozen(value) || !Object.isFrozen(value.input) || !Object.isFrozen(value.decisions) || requireTrimmed(value.baselineFingerprint, "Generated visual baseline fingerprint") !== value.baselineFingerprint || requireTrimmed(value.effectId, "Generated visual effect id") !== value.effectId || generatedVisualSignature(value.input) !== value.signature) throw new Error("Generated visual effect requires a canonical frozen envelope");
|
|
3145
|
+
}
|
|
3146
|
+
function generatedVisualTrace(envelope, itemIndex, generationJobId, sourceIndex) {
|
|
3147
|
+
return {
|
|
3148
|
+
command: generatedVisualsCommand,
|
|
3149
|
+
effectId: envelope.effectId,
|
|
3150
|
+
signature: envelope.signature,
|
|
3151
|
+
itemIndex,
|
|
3152
|
+
generationJobId,
|
|
3153
|
+
...sourceIndex === void 0 ? {} : { sourceIndex }
|
|
3154
|
+
};
|
|
3155
|
+
}
|
|
3156
|
+
function resolveGeneratedVisualReplay(rows, envelope) {
|
|
3157
|
+
const tagged = rows.relations.filter((relation) => relation.trace.command === generatedVisualsCommand && relation.trace.effectId === envelope.effectId);
|
|
3158
|
+
if (tagged.length === 0) return void 0;
|
|
3159
|
+
if (tagged.some((relation) => relation.trace.signature !== envelope.signature)) throw new Error(`Generated visual effect "${envelope.effectId}" was already applied with different input`);
|
|
3160
|
+
const clipEntityIds = [];
|
|
3161
|
+
const mediaEntityIds = [];
|
|
3162
|
+
let expectedCount = 0;
|
|
3163
|
+
for (const [index, clipInput] of envelope.input.clips.entries()) {
|
|
3164
|
+
const item = tagged.filter((relation) => relation.trace.itemIndex === index);
|
|
3165
|
+
const trackLink = requireGeneratedVisualRelation(item, "track-clip", index);
|
|
3166
|
+
if (trackLink.endpoint0EntityId !== envelope.decisions.trackEntityId || requireEntityKind(rows, trackLink.endpoint1EntityId, "clip").entityId !== trackLink.endpoint1EntityId) throw new Error("track-clip must use ordered endpoints [Track, Clip]");
|
|
3167
|
+
const clipEntityId = trackLink.endpoint1EntityId;
|
|
3168
|
+
const markerLink = requireGeneratedVisualRelation(item, "clip-marker", index);
|
|
3169
|
+
if (markerLink.endpoint0EntityId !== clipEntityId) throw new Error("clip-marker must use ordered endpoints [Clip, SequenceMarker]");
|
|
3170
|
+
const marker = requireEntityKind(rows, markerLink.endpoint1EntityId, "sequence-marker");
|
|
3171
|
+
const contentLink = requireGeneratedVisualRelation(item, "marker-content", index);
|
|
3172
|
+
if (contentLink.endpoint0EntityId !== marker.entityId) throw new Error("marker-content must use ordered endpoints [SequenceMarker, media]");
|
|
3173
|
+
const media = requireSequenceContent(rows, contentLink.endpoint1EntityId);
|
|
3174
|
+
if (media.entityKind !== "video" && media.entityKind !== "image") throw new Error("Generated visual receipt must reference Video or Image output");
|
|
3175
|
+
const lineage = item.filter((relation) => relation.relationKind === "generated");
|
|
3176
|
+
if (lineage.length !== clipInput.sourceMediaEntityIds.length) throw new Error(`Generated visual ${index} has incomplete source lineage`);
|
|
3177
|
+
for (const [sourceIndex, sourceId] of clipInput.sourceMediaEntityIds.entries()) {
|
|
3178
|
+
const matches = lineage.filter((relation) => relation.trace.sourceIndex === sourceIndex);
|
|
3179
|
+
if (matches.length !== 1 || matches[0].endpoint0EntityId !== media.entityId || matches[0].endpoint1EntityId !== sourceId) throw new Error("generated must use ordered endpoints [output media, input media]");
|
|
3180
|
+
}
|
|
3181
|
+
expectedCount += 3 + clipInput.sourceMediaEntityIds.length;
|
|
3182
|
+
clipEntityIds.push(clipEntityId);
|
|
3183
|
+
mediaEntityIds.push(media.entityId);
|
|
1771
3184
|
}
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
3185
|
+
if (tagged.length !== expectedCount) throw new Error(`Generated visual effect "${envelope.effectId}" has an ambiguous relation receipt`);
|
|
3186
|
+
return {
|
|
3187
|
+
clipEntityIds,
|
|
3188
|
+
mediaEntityIds
|
|
3189
|
+
};
|
|
3190
|
+
}
|
|
3191
|
+
function requireGeneratedVisualRelation(relations, kind, itemIndex) {
|
|
3192
|
+
const matches = relations.filter((relation) => relation.relationKind === kind);
|
|
3193
|
+
if (matches.length !== 1) throw new Error(`Generated visual ${itemIndex} must have exactly one ${kind} Relation`);
|
|
3194
|
+
return matches[0];
|
|
3195
|
+
}
|
|
3196
|
+
function assertCaptionAssetsEnvelope(value) {
|
|
3197
|
+
if (value.version !== 1 || !Object.isFrozen(value) || !Object.isFrozen(value.input) || !Object.isFrozen(value.decisions) || requireTrimmed(value.baselineFingerprint, "Caption baseline fingerprint") !== value.baselineFingerprint || requireTrimmed(value.signature, "Caption effect signature") !== value.signature) throw new Error("Caption effect requires a canonical frozen envelope");
|
|
3198
|
+
}
|
|
3199
|
+
function deepFreeze(value) {
|
|
3200
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
3201
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
3202
|
+
Object.freeze(value);
|
|
1776
3203
|
}
|
|
1777
|
-
|
|
3204
|
+
return value;
|
|
3205
|
+
}
|
|
3206
|
+
function captionEffectSignature(input, segments) {
|
|
3207
|
+
const value = JSON.stringify({
|
|
3208
|
+
timelineEntityId: input.timelineEntityId,
|
|
3209
|
+
trackEntityId: input.trackEntityId,
|
|
3210
|
+
source: {
|
|
3211
|
+
entityId: input.source.entityId,
|
|
3212
|
+
entityKind: input.source.entityKind
|
|
3213
|
+
},
|
|
3214
|
+
sourceOccurrence: {
|
|
3215
|
+
clipEntityId: input.sourceOccurrence.clipEntityId,
|
|
3216
|
+
markerEntityId: input.sourceOccurrence.markerEntityId
|
|
3217
|
+
},
|
|
3218
|
+
captionAsset: {
|
|
3219
|
+
assetId: input.captionAsset.assetId,
|
|
3220
|
+
...input.captionAsset.storageKey === void 0 ? {} : { storageKey: input.captionAsset.storageKey }
|
|
3221
|
+
},
|
|
3222
|
+
segments
|
|
3223
|
+
});
|
|
3224
|
+
return bytesToHex(sha256(new TextEncoder().encode(value)));
|
|
3225
|
+
}
|
|
3226
|
+
function effectTrace(effectId, signature, segmentId) {
|
|
3227
|
+
return {
|
|
3228
|
+
command: captionAssetsCommand,
|
|
3229
|
+
effectId,
|
|
3230
|
+
signature,
|
|
3231
|
+
...segmentId === void 0 ? {} : { segmentId }
|
|
3232
|
+
};
|
|
3233
|
+
}
|
|
3234
|
+
function stableEffectId(kind, effectId, localKey) {
|
|
3235
|
+
return `${kind}_effect_${bytesToHex(sha256(new TextEncoder().encode(`${captionAssetsCommand}\u0000${effectId}\u0000${kind}\u0000${localKey}`))).slice(0, 40)}`;
|
|
3236
|
+
}
|
|
3237
|
+
function stableEffectScope(command, effectId, baselineFingerprint) {
|
|
3238
|
+
return `${command}\u0000${effectId}\u0000${baselineFingerprint}`;
|
|
3239
|
+
}
|
|
3240
|
+
function stableEffectIdFactory(effectId, namespace) {
|
|
3241
|
+
const counters = {
|
|
3242
|
+
entity: 0,
|
|
3243
|
+
relation: 0
|
|
3244
|
+
};
|
|
3245
|
+
return (kind) => stableEffectId(kind, effectId, `${namespace}:${kind}:${counters[kind]++}`);
|
|
3246
|
+
}
|
|
3247
|
+
function stableEffectPeerId(command, effectId, baselineFingerprint) {
|
|
3248
|
+
const digest = sha256(new TextEncoder().encode(`${command}\u0000${effectId}\u0000${baselineFingerprint}\u0000loro-peer`));
|
|
3249
|
+
return BigInt(`0x${bytesToHex(digest).slice(0, 15)}`).toString();
|
|
3250
|
+
}
|
|
3251
|
+
function isCaptionEffectRelation(relation, effectId) {
|
|
3252
|
+
return relation.trace.command === captionAssetsCommand && relation.trace.effectId === effectId;
|
|
3253
|
+
}
|
|
3254
|
+
function resolveCaptionAssetsReplay(rows, input, effectId, signature, segments, sourceEntityId, trackEntityId) {
|
|
3255
|
+
const tagged = rows.relations.filter((relation) => isCaptionEffectRelation(relation, effectId));
|
|
3256
|
+
if (tagged.length === 0) return void 0;
|
|
3257
|
+
if (tagged.some((relation) => relation.trace.signature !== signature)) throw new Error(`Caption effect "${effectId}" was already applied with different input`);
|
|
3258
|
+
const sourceRelations = tagged.filter((relation) => relation.relationKind === "audio-script-source");
|
|
3259
|
+
if (sourceRelations.length !== 1) throw new Error(`Caption effect "${effectId}" has an ambiguous source receipt`);
|
|
3260
|
+
const sourceRelation = sourceRelations[0];
|
|
3261
|
+
if (sourceRelation.endpoint1EntityId !== sourceEntityId) throw new Error("audio-script-source must use ordered endpoints [AudioScript, source media]");
|
|
3262
|
+
const script = requireEntityKind(rows, sourceRelation.endpoint0EntityId, "audio-script");
|
|
3263
|
+
if (!sameScriptSegments(script.payload.segments, segments)) throw new Error(`Caption effect "${effectId}" AudioScript no longer matches its immutable ASR result`);
|
|
3264
|
+
const scriptMarkerRelations = tagged.filter((relation) => relation.relationKind === "audio-script-marker");
|
|
3265
|
+
if (scriptMarkerRelations.length !== 1 || scriptMarkerRelations[0].endpoint0EntityId !== script.entityId) throw new Error("audio-script-marker must use ordered endpoints [AudioScript, annotation SequenceMarker]");
|
|
3266
|
+
const scriptMarker = requireEntityKind(rows, scriptMarkerRelations[0].endpoint1EntityId, "sequence-marker");
|
|
3267
|
+
if (!sameSegmentRanges(scriptMarker.payload.segmentRanges, segments)) throw new Error(`Caption effect "${effectId}" annotation Marker no longer matches its immutable ASR timing`);
|
|
3268
|
+
let assetEntityId;
|
|
3269
|
+
const captionEntityIds = [];
|
|
3270
|
+
const captionClipEntityIds = [];
|
|
3271
|
+
for (const segment of segments) {
|
|
3272
|
+
const relations = tagged.filter((relation) => relation.trace.segmentId === segment.segmentId);
|
|
3273
|
+
const assetLink = requireTaggedRelation(relations, "from-asset", segment.segmentId);
|
|
3274
|
+
const caption = requireEntityKind(rows, assetLink.endpoint0EntityId, "caption");
|
|
3275
|
+
const asset = requireEntityKind(rows, assetLink.endpoint1EntityId, "asset");
|
|
3276
|
+
if (assetEntityId !== void 0 && asset.entityId !== assetEntityId) throw new Error(`Caption effect "${effectId}" references multiple Caption Assets`);
|
|
3277
|
+
assetEntityId = asset.entityId;
|
|
3278
|
+
if (asset.payload.system !== "memota" || asset.payload.key !== input.captionAsset.assetId || input.captionAsset.storageKey !== void 0 && asset.payload.storageKey !== input.captionAsset.storageKey) throw new Error(`Caption effect "${effectId}" Caption Asset does not match its receipt`);
|
|
3279
|
+
const selection = caption.payload.selection;
|
|
3280
|
+
const bases = caption.payload.baseEntityIds;
|
|
3281
|
+
if (caption.payload.durationMs !== segment.endMs - segment.startMs || !isJsonObject(selection) || selection.segmentId !== segment.segmentId || !Array.isArray(bases) || bases.length !== 1 || bases[0] !== script.entityId) throw new Error(`Caption effect "${effectId}" Caption "${caption.entityId}" conflicts with its ASR segment`);
|
|
3282
|
+
const contentLink = requireTaggedRelation(relations, "marker-content", segment.segmentId);
|
|
3283
|
+
if (contentLink.endpoint1EntityId !== caption.entityId) throw new Error("marker-content must use ordered endpoints [SequenceMarker, Caption]");
|
|
3284
|
+
const marker = requireEntityKind(rows, contentLink.endpoint0EntityId, "sequence-marker");
|
|
3285
|
+
const markerLink = requireTaggedRelation(relations, "clip-marker", segment.segmentId);
|
|
3286
|
+
if (markerLink.endpoint1EntityId !== marker.entityId) throw new Error("clip-marker must use ordered endpoints [Clip, SequenceMarker]");
|
|
3287
|
+
const clip = requireEntityKind(rows, markerLink.endpoint0EntityId, "clip");
|
|
3288
|
+
const trackLink = requireTaggedRelation(relations, "track-clip", segment.segmentId);
|
|
3289
|
+
if (trackLink.endpoint0EntityId !== trackEntityId || trackLink.endpoint1EntityId !== clip.entityId) throw new Error("track-clip must use ordered endpoints [Track, Clip]");
|
|
3290
|
+
captionEntityIds.push(caption.entityId);
|
|
3291
|
+
captionClipEntityIds.push(clip.entityId);
|
|
3292
|
+
}
|
|
3293
|
+
if (assetEntityId === void 0 || tagged.length !== 2 + segments.length * 4) throw new Error(`Caption effect "${effectId}" has an incomplete or ambiguous relation receipt`);
|
|
3294
|
+
return {
|
|
3295
|
+
audioScriptEntityId: script.entityId,
|
|
3296
|
+
audioScriptMarkerEntityId: scriptMarker.entityId,
|
|
3297
|
+
captionAssetEntityId: assetEntityId,
|
|
3298
|
+
captionEntityIds,
|
|
3299
|
+
captionClipEntityIds
|
|
3300
|
+
};
|
|
3301
|
+
}
|
|
3302
|
+
function requireTaggedRelation(relations, kind, segmentId) {
|
|
3303
|
+
const matches = relations.filter((relation) => relation.relationKind === kind);
|
|
3304
|
+
if (matches.length !== 1) throw new Error(`ASR segment "${segmentId}" must have exactly one ${kind} Relation`);
|
|
3305
|
+
return matches[0];
|
|
3306
|
+
}
|
|
3307
|
+
function sameScriptSegments(value, segments) {
|
|
3308
|
+
if (!Array.isArray(value) || value.length !== segments.length) return false;
|
|
3309
|
+
return value.every((entry, index) => {
|
|
3310
|
+
if (!isJsonObject(entry)) return false;
|
|
3311
|
+
const expected = segments[index];
|
|
3312
|
+
return entry.segmentId === expected.segmentId && entry.text === expected.text && entry.language === expected.language && Object.keys(entry).every((key) => key === "segmentId" || key === "text" || key === "language");
|
|
3313
|
+
});
|
|
3314
|
+
}
|
|
3315
|
+
function sameSegmentRanges(value, segments) {
|
|
3316
|
+
if (!Array.isArray(value) || value.length !== segments.length) return false;
|
|
3317
|
+
return value.every((entry, index) => {
|
|
3318
|
+
if (!isJsonObject(entry)) return false;
|
|
3319
|
+
const expected = segments[index];
|
|
3320
|
+
return entry.segmentId === expected.segmentId && entry.startMs === expected.startMs && entry.endMs === expected.endMs && Object.keys(entry).every((key) => key === "segmentId" || key === "startMs" || key === "endMs");
|
|
3321
|
+
});
|
|
3322
|
+
}
|
|
3323
|
+
function requireTrimmed(value, label) {
|
|
3324
|
+
if (typeof value !== "string" || value.length === 0 || value.trim() !== value) throw new Error(`${label} must be non-empty and trimmed`);
|
|
3325
|
+
return value;
|
|
3326
|
+
}
|
|
3327
|
+
function safeWholeMs(value, label) {
|
|
3328
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${label} must be a non-negative whole millisecond`);
|
|
3329
|
+
return value;
|
|
3330
|
+
}
|
|
1778
3331
|
function validateFinalRows(rows) {
|
|
1779
3332
|
decodeEntityRelationRows(rows, numericMarkerValidation);
|
|
1780
3333
|
assertCanonicalEditorResources(rows);
|
|
@@ -1812,6 +3365,16 @@ function requireClipTopology(rows, clipEntityId) {
|
|
|
1812
3365
|
markerContentRelation
|
|
1813
3366
|
};
|
|
1814
3367
|
}
|
|
3368
|
+
function requireCanonicalClipOnRole(rows, clipEntityId, role) {
|
|
3369
|
+
const topology = requireClipTopology(rows, clipEntityId);
|
|
3370
|
+
if (topology.trackRelation.endpoint1EntityId !== topology.clip.entityId || topology.clipMarkerRelation.endpoint0EntityId !== topology.clip.entityId || topology.clipMarkerRelation.endpoint1EntityId !== topology.marker.entityId || topology.markerContentRelation.endpoint0EntityId !== topology.marker.entityId) throw new Error("Occurrence must use ordered Track→Clip→SequenceMarker→content endpoints");
|
|
3371
|
+
const track = requireEntityKind(rows, topology.trackRelation.endpoint0EntityId, "track");
|
|
3372
|
+
if (track.payload.role !== role) throw new Error(`Clip "${clipEntityId}" must belong to the ${role} Track`);
|
|
3373
|
+
const timelineLinks = incidentRelations(rows, track.entityId).filter((relation) => relation.relationKind === "timeline-track");
|
|
3374
|
+
if (timelineLinks.length !== 1 || timelineLinks[0].endpoint1EntityId !== track.entityId) throw new Error("timeline-track must use one ordered [Timeline, Track] relation");
|
|
3375
|
+
requireEntityKind(rows, timelineLinks[0].endpoint0EntityId, "timeline");
|
|
3376
|
+
return topology;
|
|
3377
|
+
}
|
|
1815
3378
|
function requireOneRelation(rows, entityId, relationKind) {
|
|
1816
3379
|
const matches = incidentRelations(rows, entityId).filter((relation) => relation.relationKind === relationKind);
|
|
1817
3380
|
if (matches.length !== 1) throw new Error(`Entity "${entityId}" must have exactly one ${relationKind} Relation`);
|
|
@@ -1824,6 +3387,11 @@ function requireEntityKind(rows, entityIdValue, entityKind) {
|
|
|
1824
3387
|
if (entity.entityKind !== entityKind) throw new Error(`Entity "${entityId}" must have kind "${entityKind}", got "${entity.entityKind}"`);
|
|
1825
3388
|
return entity;
|
|
1826
3389
|
}
|
|
3390
|
+
function requireHistoricalEntity(entity, entityId, entityKind) {
|
|
3391
|
+
if (entity === void 0) throw new Error(`Historical Entity id "${entityId}" does not exist`);
|
|
3392
|
+
if (entity.entityKind !== entityKind) throw new Error(`Historical Entity "${entityId}" must have kind "${entityKind}", got "${entity.entityKind}"`);
|
|
3393
|
+
return entity;
|
|
3394
|
+
}
|
|
1827
3395
|
function requireSequenceContent(rows, entityIdValue) {
|
|
1828
3396
|
if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(entityIdValue)) throw new Error("Timeline content must reference an existing Sequence media Entity id; a URL is not allowed");
|
|
1829
3397
|
const entityId = createEntityId(entityIdValue);
|
|
@@ -1863,6 +3431,11 @@ function requireAvailableEntityId(rows, value) {
|
|
|
1863
3431
|
if (rows.entities.some((entity) => entity.entityId === entityId)) throw new Error(`Entity id "${entityId}" already exists`);
|
|
1864
3432
|
return entityId;
|
|
1865
3433
|
}
|
|
3434
|
+
function requireAvailableRelationId(rows, value) {
|
|
3435
|
+
const relationId = createRelationId(value);
|
|
3436
|
+
if (rows.relations.some((relation) => relation.relationId === relationId)) throw new Error(`Relation id "${relationId}" already exists`);
|
|
3437
|
+
return relationId;
|
|
3438
|
+
}
|
|
1866
3439
|
function setClipOrder(rows, clipEntityId, order) {
|
|
1867
3440
|
const clip = requireEntityKind(rows, clipEntityId, "clip");
|
|
1868
3441
|
replaceEntity(rows, clip.entityId, {
|
|
@@ -1938,6 +3511,15 @@ function linearRate(value) {
|
|
|
1938
3511
|
if (!isJsonObject(value) || value.kind !== "linear") throw new Error("Only linear Clip time remapping is supported");
|
|
1939
3512
|
return finitePositive(value.rate, "linear speed rate");
|
|
1940
3513
|
}
|
|
3514
|
+
function speedModeRate(mode) {
|
|
3515
|
+
switch (mode) {
|
|
3516
|
+
case "0.5x": return .5;
|
|
3517
|
+
case "0.75x": return .75;
|
|
3518
|
+
case "1.0x": return 1;
|
|
3519
|
+
case "1.5x": return 1.5;
|
|
3520
|
+
case "2.0x": return 2;
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
1941
3523
|
function resizeAbsoluteTarget(payload, duration) {
|
|
1942
3524
|
if (payload.targetRange === void 0) return;
|
|
1943
3525
|
const target = requirePositiveRange(payload.targetRange, "targetRange");
|
|
@@ -1999,11 +3581,19 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
|
|
|
1999
3581
|
}, idFactory = (prefix) => `${prefix}_${globalThis.crypto.randomUUID()}`) {
|
|
2000
3582
|
assertSupportedLegacy(legacy);
|
|
2001
3583
|
if (initial.entities.some((row) => row.entityKind === "timeline")) fail("Timeline already exists; migration is not an editing operation");
|
|
2002
|
-
if (legacy
|
|
3584
|
+
if (hasResolvedPlacement(legacy)) fail("An entity projection cannot be migrated as legacy state");
|
|
2003
3585
|
const normalizedMainRanges = overlapNormalizedMainRanges(legacy);
|
|
2004
3586
|
let entities = structuredClone([...initial.entities]);
|
|
2005
3587
|
let relations = structuredClone([...initial.relations]);
|
|
2006
|
-
const documentAudioScriptEntityId = ensureDocumentAudioScript(entities
|
|
3588
|
+
const documentAudioScriptEntityId = ensureDocumentAudioScript(entities) ?? (() => {
|
|
3589
|
+
const id = createEntityId(idFactory("entity"));
|
|
3590
|
+
entities.push({
|
|
3591
|
+
entityId: id,
|
|
3592
|
+
entityKind: "audio-script",
|
|
3593
|
+
payload: { segments: [] }
|
|
3594
|
+
});
|
|
3595
|
+
return String(id);
|
|
3596
|
+
})();
|
|
2007
3597
|
const addEntity = (kind, payload, existingId) => {
|
|
2008
3598
|
const entityId = createEntityId(existingId ?? idFactory("entity"));
|
|
2009
3599
|
if (entities.some((row) => row.entityId === entityId)) fail(`Migration entity identity collision: ${entityId}`);
|
|
@@ -2053,7 +3643,7 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
|
|
|
2053
3643
|
relations
|
|
2054
3644
|
}, {
|
|
2055
3645
|
assetId: speech.origin_speech_id,
|
|
2056
|
-
kind: "
|
|
3646
|
+
kind: "voiceover",
|
|
2057
3647
|
durationMs: speech.media_duration_ms,
|
|
2058
3648
|
storageKey: speech.audio_storage_key,
|
|
2059
3649
|
voice: {
|
|
@@ -2141,8 +3731,8 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
|
|
|
2141
3731
|
} else if (part.caption !== void 0) {
|
|
2142
3732
|
const caption = part.caption;
|
|
2143
3733
|
duration = caption.initial_duration_ms;
|
|
2144
|
-
const
|
|
2145
|
-
const render = relations.find((relation) => relation.relationKind === "phonetic-script-render" && relation.endpoint0EntityId ===
|
|
3734
|
+
const voiceover = contentByPartId.get(caption.speech_part_id);
|
|
3735
|
+
const render = relations.find((relation) => relation.relationKind === "phonetic-script-render" && relation.endpoint0EntityId === voiceover);
|
|
2146
3736
|
const phonetic = entities.find((entity) => entity.entityId === render?.endpoint1EntityId);
|
|
2147
3737
|
const script = phonetic === void 0 ? void 0 : composedAudioScript(phonetic, entities);
|
|
2148
3738
|
if (script === void 0) fail("Legacy Caption has no composed AudioScript");
|
|
@@ -2158,18 +3748,12 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
|
|
|
2158
3748
|
} }
|
|
2159
3749
|
};
|
|
2160
3750
|
content = addEntity("caption", {
|
|
2161
|
-
|
|
2162
|
-
kind: "bounded",
|
|
2163
|
-
start: 0,
|
|
2164
|
-
end: duration
|
|
2165
|
-
},
|
|
2166
|
-
sampling: "native",
|
|
2167
|
-
coordinateSpace: "milliseconds",
|
|
3751
|
+
durationMs: duration,
|
|
2168
3752
|
baseEntityIds: [script.entityId],
|
|
2169
3753
|
selection,
|
|
2170
3754
|
...caption.style === void 0 ? {} : { style: toEntityCaptionStyle(caption.style) }
|
|
2171
3755
|
});
|
|
2172
|
-
link("caption-alignment", content,
|
|
3756
|
+
link("caption-alignment", content, voiceover, { alignment: {
|
|
2173
3757
|
start: caption.start_ms,
|
|
2174
3758
|
end: caption.start_ms + duration
|
|
2175
3759
|
} });
|
|
@@ -2225,27 +3809,24 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
|
|
|
2225
3809
|
entities,
|
|
2226
3810
|
relations
|
|
2227
3811
|
};
|
|
2228
|
-
assertEntityProjectionPreservesLegacy(legacy, projectEntityTimeline(rows
|
|
3812
|
+
assertEntityProjectionPreservesLegacy(legacy, projectEntityTimeline(rows), rows);
|
|
2229
3813
|
return rows;
|
|
2230
3814
|
}
|
|
2231
3815
|
/** Server gate: a cutover may change representation, never the existing render facts. */
|
|
2232
3816
|
function assertEntityProjectionPreservesLegacy(legacy, projection, rows) {
|
|
2233
3817
|
assertSupportedLegacy(legacy);
|
|
2234
|
-
if (projection
|
|
3818
|
+
if (!hasResolvedPlacement(projection)) fail("Expected an entity projection for migration");
|
|
2235
3819
|
const normalizedMainRanges = overlapNormalizedMainRanges(legacy);
|
|
2236
|
-
const compatibilityFacts = (document) => {
|
|
2237
|
-
const
|
|
3820
|
+
const compatibilityFacts = (document, placementResolved) => {
|
|
3821
|
+
const view = fromVideoDocument(document, { placementResolved });
|
|
2238
3822
|
for (const part of Object.values(view.part_library ?? {})) {
|
|
2239
3823
|
const speech = part.speech;
|
|
2240
3824
|
if (speech?.phoneme_script === null) delete speech.phoneme_script;
|
|
3825
|
+
if (speech !== void 0) delete speech.origin_asset_system;
|
|
2241
3826
|
}
|
|
2242
|
-
|
|
2243
|
-
return {
|
|
2244
|
-
meta,
|
|
2245
|
-
view
|
|
2246
|
-
};
|
|
3827
|
+
return { view };
|
|
2247
3828
|
};
|
|
2248
|
-
if (canonical(compatibilityFacts(legacy)) !== canonical(compatibilityFacts(projection))) fail("Entity migration must preserve all existing timeline facts; edit after cutover");
|
|
3829
|
+
if (canonical(compatibilityFacts(legacy, false)) !== canonical(compatibilityFacts(projection, true))) fail("Entity migration must preserve all existing timeline facts; edit after cutover");
|
|
2249
3830
|
const trackOrders = [];
|
|
2250
3831
|
for (const oldTrack of legacy.tracks ?? []) {
|
|
2251
3832
|
const candidates = rows.entities.filter((row) => row.entityKind === "track" && (oldTrack.id === void 0 ? row.payload.role === oldTrack.parts_kind : row.entityId === oldTrack.id));
|
|
@@ -2381,6 +3962,7 @@ function assertPartSupported(id, part, role) {
|
|
|
2381
3962
|
} else if (part.speech !== void 0) {
|
|
2382
3963
|
const speech = part.speech;
|
|
2383
3964
|
const phonemeScript = speech.phoneme_script;
|
|
3965
|
+
const originAssetSystem = speech.origin_asset_system;
|
|
2384
3966
|
if (speech.id !== id || speech.kind !== "speech" || !trimmed(speech.origin_speech_id) || !trimmed(speech.audio_storage_key) || typeof speech.audio_script !== "string") fail("Migration requires complete Speech identity and physical take facts");
|
|
2385
3967
|
requirePositiveMilliseconds(speech.media_duration_ms, "Speech duration");
|
|
2386
3968
|
requireFinite(speech.volume, "Speech volume");
|
|
@@ -2389,6 +3971,7 @@ function assertPartSupported(id, part, role) {
|
|
|
2389
3971
|
if (!Array.isArray(speech.caption_ids) || speech.caption_ids.some((captionId) => !trimmed(captionId))) fail("Migration requires explicit Speech caption identities");
|
|
2390
3972
|
if (new Set(speech.caption_ids).size !== speech.caption_ids.length) fail("Migration requires unique Speech caption identities");
|
|
2391
3973
|
if (phonemeScript !== void 0 && phonemeScript !== null && typeof phonemeScript !== "string") fail("Migration requires a string phoneme_script when present");
|
|
3974
|
+
if (originAssetSystem !== void 0 && originAssetSystem !== "memota-speech") fail("Legacy synthesized Speech requires a memota-speech origin asset");
|
|
2392
3975
|
assertKnownFields(speech.voice, ["id", "name"], "voice");
|
|
2393
3976
|
assertKnownFields(speech, [
|
|
2394
3977
|
"id",
|
|
@@ -2398,6 +3981,7 @@ function assertPartSupported(id, part, role) {
|
|
|
2398
3981
|
"volume",
|
|
2399
3982
|
"audio_storage_key",
|
|
2400
3983
|
"origin_speech_id",
|
|
3984
|
+
"origin_asset_system",
|
|
2401
3985
|
"voice",
|
|
2402
3986
|
"caption_ids",
|
|
2403
3987
|
"phoneme_script"
|
|
@@ -2512,21 +4096,21 @@ function requirePart(document, partId) {
|
|
|
2512
4096
|
if (part === void 0) fail(`Placed Clip ${partId} has no part payload`);
|
|
2513
4097
|
return part;
|
|
2514
4098
|
}
|
|
2515
|
-
function ensureSpeechPhoneticChain(
|
|
2516
|
-
const renders = relations.filter((relation) => relation.relationKind === "phonetic-script-render" && relation.endpoint0EntityId ===
|
|
2517
|
-
if (renders.length > 1) fail(`
|
|
4099
|
+
function ensureSpeechPhoneticChain(voiceoverAudioEntityId, audioScriptText, phonemeScript, entities, relations, addEntity, link, documentAudioScriptEntityId) {
|
|
4100
|
+
const renders = relations.filter((relation) => relation.relationKind === "phonetic-script-render" && relation.endpoint0EntityId === voiceoverAudioEntityId);
|
|
4101
|
+
if (renders.length > 1) fail(`Voiceover ${voiceoverAudioEntityId} has ambiguous PhoneticScript provenance`);
|
|
2518
4102
|
if (renders.length === 1) {
|
|
2519
4103
|
const phonetic = entities.find((entity) => entity.entityId === renders[0].endpoint1EntityId && entity.entityKind === "phonetic-script");
|
|
2520
|
-
if (phonetic === void 0) fail(`
|
|
4104
|
+
if (phonetic === void 0) fail(`Voiceover ${voiceoverAudioEntityId} has a dangling PhoneticScript render`);
|
|
2521
4105
|
const script = composedAudioScript(phonetic, entities);
|
|
2522
|
-
if (script === void 0 || scriptText(script) !== audioScriptText) fail(`
|
|
4106
|
+
if (script === void 0 || scriptText(script) !== audioScriptText) fail(`Voiceover ${voiceoverAudioEntityId} conflicts with the legacy Speech script`);
|
|
2523
4107
|
return phonetic.entityId;
|
|
2524
4108
|
}
|
|
2525
4109
|
const script = entities.find((entity) => entity.entityId === documentAudioScriptEntityId);
|
|
2526
4110
|
if (script === void 0 || script.entityKind !== "audio-script") fail("Migration must carry the fixed document AudioScript");
|
|
2527
4111
|
const segments = script.payload.segments;
|
|
2528
4112
|
if (Array.isArray(segments) && segments.length > 0) {
|
|
2529
|
-
if (scriptText(script) !== audioScriptText) fail(`
|
|
4113
|
+
if (scriptText(script) !== audioScriptText) fail(`Voiceover ${voiceoverAudioEntityId} conflicts with the project AudioScript text`);
|
|
2530
4114
|
} else {
|
|
2531
4115
|
const scriptIndex = entities.findIndex((row) => row.entityId === script.entityId);
|
|
2532
4116
|
entities[scriptIndex] = {
|
|
@@ -2541,7 +4125,7 @@ function ensureSpeechPhoneticChain(voiceEntityId, audioScriptText, phonemeScript
|
|
|
2541
4125
|
baseEntityIds: [script.entityId],
|
|
2542
4126
|
...phonemeScript === void 0 ? {} : { phonemeScript }
|
|
2543
4127
|
});
|
|
2544
|
-
link("phonetic-script-render",
|
|
4128
|
+
link("phonetic-script-render", voiceoverAudioEntityId, phonetic);
|
|
2545
4129
|
return phonetic;
|
|
2546
4130
|
}
|
|
2547
4131
|
function composedAudioScript(phonetic, entities) {
|
|
@@ -2659,13 +4243,7 @@ function prepareCaptionContent(rows, input) {
|
|
|
2659
4243
|
payload: {
|
|
2660
4244
|
...previous?.payload,
|
|
2661
4245
|
baseEntityIds,
|
|
2662
|
-
|
|
2663
|
-
kind: "bounded",
|
|
2664
|
-
start: 0,
|
|
2665
|
-
end: input.durationMs
|
|
2666
|
-
},
|
|
2667
|
-
sampling: previous?.payload.sampling ?? "native",
|
|
2668
|
-
coordinateSpace: previous?.payload.coordinateSpace ?? "milliseconds"
|
|
4246
|
+
durationMs: previous?.payload.durationMs ?? input.durationMs
|
|
2669
4247
|
}
|
|
2670
4248
|
};
|
|
2671
4249
|
const graph = {
|
|
@@ -3648,7 +5226,7 @@ function writeSpeechAssets(draft, assets) {
|
|
|
3648
5226
|
if (caption == null) continue;
|
|
3649
5227
|
const priorCaption = draft.part_library?.[captionId]?.caption;
|
|
3650
5228
|
setPart(draft, captionId, { caption: {
|
|
3651
|
-
...priorCaption,
|
|
5229
|
+
...priorCaption ?? { style: {} },
|
|
3652
5230
|
id: captionId,
|
|
3653
5231
|
kind: "caption",
|
|
3654
5232
|
initial_duration_ms: caption.duration_ms,
|
|
@@ -4057,6 +5635,10 @@ var ManualSyncDoc = class ManualSyncDoc {
|
|
|
4057
5635
|
client;
|
|
4058
5636
|
doc;
|
|
4059
5637
|
adapter;
|
|
5638
|
+
/** Whether this document's timeline is owned by the Entity graph. */
|
|
5639
|
+
isEntityDocument() {
|
|
5640
|
+
return this.adapter.isEntityDocument();
|
|
5641
|
+
}
|
|
4060
5642
|
editor;
|
|
4061
5643
|
/**
|
|
4062
5644
|
* The version the server is known to hold. Starts empty (nothing confirmed)
|
|
@@ -4075,17 +5657,24 @@ var ManualSyncDoc = class ManualSyncDoc {
|
|
|
4075
5657
|
* Fetches the snapshot up front rather than starting empty and converging: the
|
|
4076
5658
|
* agent's first act is to read the document, so there is no useful state before
|
|
4077
5659
|
* the snapshot lands. This also fails fast and loudly on a document that does
|
|
4078
|
-
* not exist
|
|
4079
|
-
* waiting forever for content that will never arrive.
|
|
5660
|
+
* not exist or whose first snapshot is incomplete or invalid.
|
|
4080
5661
|
*/
|
|
4081
5662
|
static async open(options) {
|
|
4082
|
-
const doc = new LoroDoc();
|
|
4083
|
-
if (options.peerId != null) doc.setPeerId(options.peerId);
|
|
4084
5663
|
const response = await options.client.fetchSnapshot();
|
|
4085
|
-
doc
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
5664
|
+
const doc = new LoroDoc();
|
|
5665
|
+
let manualSyncDoc;
|
|
5666
|
+
try {
|
|
5667
|
+
if (options.peerId != null) doc.setPeerId(options.peerId);
|
|
5668
|
+
if (doc.import(base64ToBytes(response.snapshot)).pending != null) throw new Error("Initial mengine snapshot has missing dependencies");
|
|
5669
|
+
manualSyncDoc = new ManualSyncDoc(options.client, doc);
|
|
5670
|
+
assertValidVideoDocument(manualSyncDoc.snapshot());
|
|
5671
|
+
manualSyncDoc.watermark = manualSyncDoc.serverVVFrom(response.version.server_vv) ?? doc.oplogVersion();
|
|
5672
|
+
return manualSyncDoc;
|
|
5673
|
+
} catch (error) {
|
|
5674
|
+
manualSyncDoc?.adapter.dispose();
|
|
5675
|
+
doc.free();
|
|
5676
|
+
throw error;
|
|
5677
|
+
}
|
|
4089
5678
|
}
|
|
4090
5679
|
/** Current document read model (authoritative shape). */
|
|
4091
5680
|
snapshot() {
|
|
@@ -4104,9 +5693,9 @@ var ManualSyncDoc = class ManualSyncDoc {
|
|
|
4104
5693
|
editorPeerId() {
|
|
4105
5694
|
return this.doc.peerIdStr;
|
|
4106
5695
|
}
|
|
4107
|
-
/** Current
|
|
4108
|
-
|
|
4109
|
-
return fromVideoDocument(this.adapter.snapshot());
|
|
5696
|
+
/** Current content projected into the compatible track and part read shape. */
|
|
5697
|
+
content() {
|
|
5698
|
+
return fromVideoDocument(this.adapter.snapshot(), { placementResolved: this.isEntityDocument() });
|
|
4110
5699
|
}
|
|
4111
5700
|
/**
|
|
4112
5701
|
* Replay a deterministic sandbox journal into this document.
|
|
@@ -4126,9 +5715,8 @@ var ManualSyncDoc = class ManualSyncDoc {
|
|
|
4126
5715
|
*
|
|
4127
5716
|
* This pair replaces the legacy integer-version comparison that
|
|
4128
5717
|
* `DraftVersionDetector` used to tell the LLM "the draft was modified
|
|
4129
|
-
* externally, reload before editing".
|
|
4130
|
-
*
|
|
4131
|
-
* vectors restores the same capability.
|
|
5718
|
+
* externally, reload before editing". Comparing Loro version vectors covers
|
|
5719
|
+
* all collaborative edits without a business revision field in the document.
|
|
4132
5720
|
*
|
|
4133
5721
|
* Same capability, not a stronger one: like the legacy detector, this only
|
|
4134
5722
|
* reports what changed between two moments the caller chose to sample.
|
|
@@ -4212,6 +5800,7 @@ var ManualSyncDoc = class ManualSyncDoc {
|
|
|
4212
5800
|
if (serverVV != null) this.watermark = serverVV;
|
|
4213
5801
|
return {
|
|
4214
5802
|
kind: response.kind,
|
|
5803
|
+
mengineUpdateSeq: response.version?.update_seq ?? response.update_seq ?? void 0,
|
|
4215
5804
|
updateSeq: response.update_seq ?? void 0,
|
|
4216
5805
|
collaborated: serverVV != null && !covers(localVersion, serverVV)
|
|
4217
5806
|
};
|
|
@@ -4250,22 +5839,24 @@ var ManualSyncDoc = class ManualSyncDoc {
|
|
|
4250
5839
|
* recovers whatever the stream missed while it was down. Recovery therefore
|
|
4251
5840
|
* lives in the synchronizer (keyed on the real doc version), not here.
|
|
4252
5841
|
*
|
|
4253
|
-
*
|
|
5842
|
+
* Update frames are forwarded via `onUpdate`, resync hints via `onResync`; this
|
|
4254
5843
|
* connection keeps no version cursor and does no catch-up of its own. Dedup is
|
|
4255
5844
|
* unnecessary because `LoroDoc.import` is idempotent by OpId/VV.
|
|
4256
5845
|
*/
|
|
4257
5846
|
var SseConnection = class {
|
|
4258
5847
|
client;
|
|
4259
5848
|
onUpdate;
|
|
5849
|
+
onResync;
|
|
4260
5850
|
reconnectDelayMs;
|
|
4261
5851
|
inner = void 0;
|
|
4262
5852
|
event = new EventBus();
|
|
4263
5853
|
streamTask = null;
|
|
4264
5854
|
_status = "idle";
|
|
4265
5855
|
_error;
|
|
4266
|
-
constructor(client, onUpdate, reconnectDelayMs) {
|
|
5856
|
+
constructor(client, onUpdate, onResync, reconnectDelayMs) {
|
|
4267
5857
|
this.client = client;
|
|
4268
5858
|
this.onUpdate = onUpdate;
|
|
5859
|
+
this.onResync = onResync;
|
|
4269
5860
|
this.reconnectDelayMs = reconnectDelayMs;
|
|
4270
5861
|
}
|
|
4271
5862
|
get status() {
|
|
@@ -4326,10 +5917,12 @@ var SseConnection = class {
|
|
|
4326
5917
|
client: this.client,
|
|
4327
5918
|
signal,
|
|
4328
5919
|
onOpen: () => this.setStatus("connected"),
|
|
5920
|
+
onResync: this.onResync,
|
|
4329
5921
|
onUpdate: (event) => {
|
|
4330
5922
|
for (const update of event.updates) this.onUpdate(update);
|
|
4331
5923
|
}
|
|
4332
5924
|
});
|
|
5925
|
+
if (!signal.aborted) this.setStatus("connecting", /* @__PURE__ */ new Error("SSE stream closed"));
|
|
4333
5926
|
} catch (error) {
|
|
4334
5927
|
if (signal.aborted) break;
|
|
4335
5928
|
this.setStatus("connecting", error instanceof Error ? error : new Error(String(error)));
|
|
@@ -4374,7 +5967,7 @@ var MedeoHttpDocStorage = class {
|
|
|
4374
5967
|
this.options = options;
|
|
4375
5968
|
this.client = options.client;
|
|
4376
5969
|
this.docId = options.docId;
|
|
4377
|
-
this.connection = new SseConnection(this.client, (update) => this.emitUpdate(update), options.sseReconnectDelayMs ?? 500);
|
|
5970
|
+
this.connection = new SseConnection(this.client, (update) => this.emitUpdate(update), () => this.events.emit("resync"), options.sseReconnectDelayMs ?? 500);
|
|
4378
5971
|
}
|
|
4379
5972
|
get isReadonly() {
|
|
4380
5973
|
return this.options.readonlyMode ?? false;
|
|
@@ -4382,6 +5975,7 @@ var MedeoHttpDocStorage = class {
|
|
|
4382
5975
|
async getDoc(docId) {
|
|
4383
5976
|
this.assertDocId(docId);
|
|
4384
5977
|
const snapshot = await this.client.fetchSnapshot();
|
|
5978
|
+
this.reportServerVersion(snapshot.version?.server_vv);
|
|
4385
5979
|
const now = /* @__PURE__ */ new Date();
|
|
4386
5980
|
return {
|
|
4387
5981
|
docId,
|
|
@@ -4393,6 +5987,7 @@ var MedeoHttpDocStorage = class {
|
|
|
4393
5987
|
async getDocDiff(docId, knownVersion) {
|
|
4394
5988
|
this.assertDocId(docId);
|
|
4395
5989
|
const response = await this.client.sync(knownVersion);
|
|
5990
|
+
this.reportServerVersion(response.server_vv);
|
|
4396
5991
|
return {
|
|
4397
5992
|
docId,
|
|
4398
5993
|
missing: base64ToBytes(response.update),
|
|
@@ -4423,7 +6018,7 @@ var MedeoHttpDocStorage = class {
|
|
|
4423
6018
|
if (update.data.byteLength === 0) return {};
|
|
4424
6019
|
try {
|
|
4425
6020
|
const response = await this.client.pushUpdate(update.data);
|
|
4426
|
-
const serverVV =
|
|
6021
|
+
const serverVV = this.reportServerVersion(response.version?.server_vv);
|
|
4427
6022
|
this.events.emit("pushOutcome", {
|
|
4428
6023
|
kind: response.kind,
|
|
4429
6024
|
updateSeq: response.update_seq ?? void 0,
|
|
@@ -4451,6 +6046,19 @@ var MedeoHttpDocStorage = class {
|
|
|
4451
6046
|
subscribePushOutcome(callback) {
|
|
4452
6047
|
return this.events.on("pushOutcome", callback);
|
|
4453
6048
|
}
|
|
6049
|
+
/** Observe live hints that require VV catch-up without replacing the SSE connection. */
|
|
6050
|
+
subscribeResync(callback) {
|
|
6051
|
+
return this.events.on("resync", callback);
|
|
6052
|
+
}
|
|
6053
|
+
/** Authoritative HTTP reads and accepted pushes, never inferred from SSE bytes. */
|
|
6054
|
+
subscribeServerVersion(callback) {
|
|
6055
|
+
return this.events.on("serverVersion", callback);
|
|
6056
|
+
}
|
|
6057
|
+
reportServerVersion(encoded) {
|
|
6058
|
+
const version = decodeServerVV(encoded);
|
|
6059
|
+
if (version != null) this.events.emit("serverVersion", version);
|
|
6060
|
+
return version;
|
|
6061
|
+
}
|
|
4454
6062
|
async deleteDoc(_docId) {
|
|
4455
6063
|
throw new Error("MedeoHttpDocStorage does not support deleteDoc");
|
|
4456
6064
|
}
|
|
@@ -4567,6 +6175,118 @@ var MemoryDocStorage = class extends BaseDocStorage {
|
|
|
4567
6175
|
}
|
|
4568
6176
|
};
|
|
4569
6177
|
//#endregion
|
|
6178
|
+
//#region src/session/document-undo-manager.ts
|
|
6179
|
+
/**
|
|
6180
|
+
* Tracks the shared LoroDoc independently of semantic editing. Undo/redo use
|
|
6181
|
+
* the same mutation guard as the adapter and publish settled availability.
|
|
6182
|
+
*/
|
|
6183
|
+
var DocumentUndoManager = class {
|
|
6184
|
+
doc;
|
|
6185
|
+
mutationGuard;
|
|
6186
|
+
onChange;
|
|
6187
|
+
manager;
|
|
6188
|
+
unsubscribe;
|
|
6189
|
+
disposed = false;
|
|
6190
|
+
notificationQueued = false;
|
|
6191
|
+
published = {
|
|
6192
|
+
canUndo: false,
|
|
6193
|
+
canRedo: false
|
|
6194
|
+
};
|
|
6195
|
+
activeSource = null;
|
|
6196
|
+
constructor(doc, mutationGuard, onChange) {
|
|
6197
|
+
this.doc = doc;
|
|
6198
|
+
this.mutationGuard = mutationGuard;
|
|
6199
|
+
this.onChange = onChange;
|
|
6200
|
+
const peer = doc.peerIdStr;
|
|
6201
|
+
this.manager = new UndoManager(doc, {
|
|
6202
|
+
mergeInterval: 0,
|
|
6203
|
+
maxUndoSteps: 100,
|
|
6204
|
+
excludeOriginPrefixes: ["mengine.bootstrap", "mengine.initialize"],
|
|
6205
|
+
onPush: (_isUndo, range) => {
|
|
6206
|
+
this.scheduleNotification();
|
|
6207
|
+
return {
|
|
6208
|
+
value: this.activeSource ?? {
|
|
6209
|
+
peer,
|
|
6210
|
+
counter: range.start
|
|
6211
|
+
},
|
|
6212
|
+
cursors: []
|
|
6213
|
+
};
|
|
6214
|
+
},
|
|
6215
|
+
onPop: () => this.scheduleNotification()
|
|
6216
|
+
});
|
|
6217
|
+
this.unsubscribe = doc.subscribe(() => this.scheduleNotification());
|
|
6218
|
+
}
|
|
6219
|
+
getState() {
|
|
6220
|
+
this.assertActive();
|
|
6221
|
+
return {
|
|
6222
|
+
canUndo: this.manager.canUndo(),
|
|
6223
|
+
canRedo: this.manager.canRedo()
|
|
6224
|
+
};
|
|
6225
|
+
}
|
|
6226
|
+
undo(options) {
|
|
6227
|
+
return this.applyUndoRedo("Undo", options);
|
|
6228
|
+
}
|
|
6229
|
+
redo(options) {
|
|
6230
|
+
return this.applyUndoRedo("Redo", options);
|
|
6231
|
+
}
|
|
6232
|
+
beginGroup() {
|
|
6233
|
+
this.assertActive();
|
|
6234
|
+
this.mutationGuard.run(() => this.manager.groupStart());
|
|
6235
|
+
}
|
|
6236
|
+
endGroup() {
|
|
6237
|
+
this.assertActive();
|
|
6238
|
+
this.mutationGuard.run(() => this.manager.groupEnd());
|
|
6239
|
+
this.scheduleNotification();
|
|
6240
|
+
}
|
|
6241
|
+
applyUndoRedo(kind, options) {
|
|
6242
|
+
this.assertActive();
|
|
6243
|
+
return this.mutationGuard.run(() => {
|
|
6244
|
+
if (kind === "Undo" ? !this.manager.canUndo() : !this.manager.canRedo()) return false;
|
|
6245
|
+
this.activeSource = kind === "Undo" ? this.manager.topUndoValue() : this.manager.topRedoValue();
|
|
6246
|
+
try {
|
|
6247
|
+
this.doc.setNextCommitOptions({ message: JSON.stringify({
|
|
6248
|
+
semantic_op: kind,
|
|
6249
|
+
payload: { source: this.activeSource },
|
|
6250
|
+
intent: options?.intent ?? null,
|
|
6251
|
+
actor: options?.actor ?? null
|
|
6252
|
+
}) });
|
|
6253
|
+
const before = this.doc.oplogFrontiers();
|
|
6254
|
+
return (kind === "Undo" ? this.manager.undo() : this.manager.redo()) && this.doc.cmpWithFrontiers(before) !== 0;
|
|
6255
|
+
} finally {
|
|
6256
|
+
this.doc.clearNextCommitOptions();
|
|
6257
|
+
this.activeSource = null;
|
|
6258
|
+
this.scheduleNotification();
|
|
6259
|
+
}
|
|
6260
|
+
});
|
|
6261
|
+
}
|
|
6262
|
+
dispose() {
|
|
6263
|
+
if (this.disposed) return;
|
|
6264
|
+
this.disposed = true;
|
|
6265
|
+
this.unsubscribe();
|
|
6266
|
+
this.manager.setOnPush(void 0);
|
|
6267
|
+
this.manager.setOnPop(void 0);
|
|
6268
|
+
this.manager.free();
|
|
6269
|
+
}
|
|
6270
|
+
scheduleNotification() {
|
|
6271
|
+
if (this.notificationQueued || this.disposed) return;
|
|
6272
|
+
this.notificationQueued = true;
|
|
6273
|
+
queueMicrotask(() => {
|
|
6274
|
+
this.notificationQueued = false;
|
|
6275
|
+
if (!this.disposed) this.publish();
|
|
6276
|
+
});
|
|
6277
|
+
}
|
|
6278
|
+
publish() {
|
|
6279
|
+
if (this.disposed) return;
|
|
6280
|
+
const state = this.getState();
|
|
6281
|
+
if (state.canUndo === this.published.canUndo && state.canRedo === this.published.canRedo) return;
|
|
6282
|
+
this.published = state;
|
|
6283
|
+
this.onChange();
|
|
6284
|
+
}
|
|
6285
|
+
assertActive() {
|
|
6286
|
+
if (this.disposed) throw new Error("mengine doc undo manager is disposed");
|
|
6287
|
+
}
|
|
6288
|
+
};
|
|
6289
|
+
//#endregion
|
|
4570
6290
|
//#region src/session/mengine-doc-session.ts
|
|
4571
6291
|
/**
|
|
4572
6292
|
* Raised by {@link MengineDocSession.waitForServerAck} when the local edits it
|
|
@@ -4602,13 +6322,9 @@ var MengineAckFailedError = class extends Error {
|
|
|
4602
6322
|
* A live editing session for one Medeo document — the single entry point clients
|
|
4603
6323
|
* (FE draft driver, Agent, embeds) use to open, edit, and observe a document.
|
|
4604
6324
|
*
|
|
4605
|
-
*
|
|
4606
|
-
*
|
|
4607
|
-
*
|
|
4608
|
-
* ├─ ClientServerSynchronizer ── DocManager ── LoroDoc
|
|
4609
|
-
* MedeoHttpDocStorage┘ │
|
|
4610
|
-
* (remote = Rust mengine-server) ▼
|
|
4611
|
-
* MirrorVideoDocumentAdapter + SemanticEditor
|
|
6325
|
+
* SemanticEditor writes through MirrorVideoDocumentAdapter directly, while
|
|
6326
|
+
* DocumentUndoManager observes the same LoroDoc independently. Both mutation
|
|
6327
|
+
* paths use the session's DocumentMutationGuard, closed before teardown.
|
|
4612
6328
|
*
|
|
4613
6329
|
* `DocManager` owns the `LoroDoc`: local edits committed on the adapter are
|
|
4614
6330
|
* picked up via `subscribeLocalUpdates`, saved to local storage, then pushed to
|
|
@@ -4629,11 +6345,20 @@ var MengineDocSession = class {
|
|
|
4629
6345
|
manager;
|
|
4630
6346
|
events = new EventBus();
|
|
4631
6347
|
disposables = new DisposableSet();
|
|
6348
|
+
mutationGuard = new DocumentMutationGuard();
|
|
4632
6349
|
adapterValue = null;
|
|
4633
6350
|
editorValue = null;
|
|
6351
|
+
undoManagerValue = null;
|
|
4634
6352
|
started = false;
|
|
6353
|
+
startTask = null;
|
|
6354
|
+
destroyed = false;
|
|
6355
|
+
lifetime = null;
|
|
6356
|
+
hasConnected = false;
|
|
6357
|
+
initialVersionValue = null;
|
|
6358
|
+
localVersionValue = null;
|
|
6359
|
+
pushErrorValue = null;
|
|
4635
6360
|
/**
|
|
4636
|
-
* Latest server oplog version seen on
|
|
6361
|
+
* Latest server oplog version seen on an authoritative HTTP response. Lets `waitForServerAck`
|
|
4637
6362
|
* return without waiting when the server is already known to cover the local
|
|
4638
6363
|
* doc (e.g. nothing was edited since the last confirmed push).
|
|
4639
6364
|
*/
|
|
@@ -4662,14 +6387,14 @@ var MengineDocSession = class {
|
|
|
4662
6387
|
return this.server.subscribePushOutcome(callback);
|
|
4663
6388
|
}
|
|
4664
6389
|
/**
|
|
4665
|
-
* Resolve once the server has durably accepted the local
|
|
6390
|
+
* Resolve once the server has durably accepted the local commits as of
|
|
4666
6391
|
* *now* — the capability an Agent tool needs to answer "did my edit land?"
|
|
4667
6392
|
* before reporting success.
|
|
4668
6393
|
*
|
|
4669
6394
|
* Call it right after the editor ops whose durability matters. It snapshots the
|
|
4670
|
-
* local
|
|
4671
|
-
*
|
|
4672
|
-
*
|
|
6395
|
+
* latest local commit's version (or the loaded baseline before any edit) and
|
|
6396
|
+
* resolves when an authoritative server version covers it. Remote-only
|
|
6397
|
+
* imports do not extend this target, matching the displayed confirmation state.
|
|
4673
6398
|
*
|
|
4674
6399
|
* Version coverage, not byte identity, is the acceptance test — for two reasons:
|
|
4675
6400
|
* the ops may reach the server inside a merged blob rather than as the exact
|
|
@@ -4684,31 +6409,39 @@ var MengineDocSession = class {
|
|
|
4684
6409
|
* @throws {MengineAckFailedError} on timeout, rejection, or transport failure.
|
|
4685
6410
|
*/
|
|
4686
6411
|
async waitForServerAck(options = {}) {
|
|
4687
|
-
if (this.adapterValue == null) throw new Error("mengine doc session is not started");
|
|
6412
|
+
if (this.adapterValue == null || this.lifetime == null) throw new Error("mengine doc session is not started");
|
|
4688
6413
|
const timeoutMs = options.timeoutMs ?? 15e3;
|
|
4689
|
-
const target = this.manager.connectDoc(this.docId).version();
|
|
6414
|
+
const target = this.confirmationTarget() ?? this.manager.connectDoc(this.docId).version();
|
|
6415
|
+
const signal = this.lifetime.signal;
|
|
4690
6416
|
if (this.serverVVValue != null && serverCovers(this.serverVVValue, target)) return;
|
|
4691
6417
|
await new Promise((resolve, reject) => {
|
|
4692
|
-
|
|
6418
|
+
const cleanup = [];
|
|
4693
6419
|
let done = false;
|
|
4694
6420
|
const settle = (error) => {
|
|
4695
6421
|
if (done) return;
|
|
4696
6422
|
done = true;
|
|
4697
6423
|
clearTimeout(timer);
|
|
4698
|
-
off();
|
|
6424
|
+
cleanup.forEach((off) => off());
|
|
4699
6425
|
if (error == null) resolve();
|
|
4700
6426
|
else reject(error);
|
|
4701
6427
|
};
|
|
4702
6428
|
const timer = setTimeout(() => {
|
|
4703
6429
|
settle(new MengineAckFailedError("timeout", void 0, void 0, `mengine did not acknowledge the update within ${timeoutMs}ms`));
|
|
4704
6430
|
}, timeoutMs);
|
|
4705
|
-
|
|
6431
|
+
const onAbort = () => settle(new MengineAckFailedError("failed", void 0, void 0, "mengine doc session destroyed"));
|
|
6432
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
6433
|
+
cleanup.push(() => signal.removeEventListener("abort", onAbort));
|
|
6434
|
+
cleanup.push(this.events.on("syncState", () => {
|
|
6435
|
+
if (this.serverVVValue != null && serverCovers(this.serverVVValue, target)) settle(void 0);
|
|
6436
|
+
}));
|
|
6437
|
+
cleanup.push(this.server.subscribePushOutcome((outcome) => {
|
|
4706
6438
|
if (outcome.kind === "rejected" || outcome.kind === "failed") {
|
|
4707
6439
|
settle(new MengineAckFailedError(outcome.kind, outcome.code, outcome.error, outcome.error?.message ?? `mengine push ${outcome.kind}`));
|
|
4708
6440
|
return;
|
|
4709
6441
|
}
|
|
4710
|
-
|
|
4711
|
-
|
|
6442
|
+
}));
|
|
6443
|
+
if (signal.aborted) onAbort();
|
|
6444
|
+
else this.synchronizer.requestSync(this.docId);
|
|
4712
6445
|
});
|
|
4713
6446
|
}
|
|
4714
6447
|
/**
|
|
@@ -4744,44 +6477,154 @@ var MengineDocSession = class {
|
|
|
4744
6477
|
if (this.adapterValue == null) throw new Error("mengine doc session is not started");
|
|
4745
6478
|
return this.adapterValue.snapshot();
|
|
4746
6479
|
}
|
|
6480
|
+
/** Current-session undo/redo availability. Loading and closed sessions report false. */
|
|
6481
|
+
getUndoState() {
|
|
6482
|
+
return this.undoManagerValue?.getState() ?? {
|
|
6483
|
+
canUndo: false,
|
|
6484
|
+
canRedo: false
|
|
6485
|
+
};
|
|
6486
|
+
}
|
|
6487
|
+
/**
|
|
6488
|
+
* Supplies current availability immediately, then coalesces stack changes in a
|
|
6489
|
+
* microtask. Session teardown publishes empty availability synchronously.
|
|
6490
|
+
*/
|
|
6491
|
+
subscribeUndoState(cb) {
|
|
6492
|
+
const notify = () => cb(this.getUndoState());
|
|
6493
|
+
const off = this.destroyed ? () => {} : this.events.on("undoState", notify);
|
|
6494
|
+
notify();
|
|
6495
|
+
return off;
|
|
6496
|
+
}
|
|
6497
|
+
/**
|
|
6498
|
+
* Undo a local edit immediately; server confirmation remains asynchronous.
|
|
6499
|
+
* On a shared LWW field this writes its prior value, potentially overwriting a
|
|
6500
|
+
* later remote edit to that same field. Returns false if no update is produced.
|
|
6501
|
+
*/
|
|
6502
|
+
undo(options) {
|
|
6503
|
+
if (this.undoManagerValue == null) throw new Error("mengine doc undo manager is not ready");
|
|
6504
|
+
return this.undoManagerValue.undo(options);
|
|
6505
|
+
}
|
|
6506
|
+
/**
|
|
6507
|
+
* Restore the state visible before undo without rerunning external side effects.
|
|
6508
|
+
* Returns false if no update is produced; durable confirmation is separate.
|
|
6509
|
+
*/
|
|
6510
|
+
redo(options) {
|
|
6511
|
+
if (this.undoManagerValue == null) throw new Error("mengine doc undo manager is not ready");
|
|
6512
|
+
return this.undoManagerValue.redo(options);
|
|
6513
|
+
}
|
|
6514
|
+
/**
|
|
6515
|
+
* Combine existing semantic operations (e.g. trim a clip and adjust its volume)
|
|
6516
|
+
* or repeated commits during one drag gesture into a single undo step. A gesture
|
|
6517
|
+
* that only previews locally and commits once on release needs no group.
|
|
6518
|
+
* Each commit is immediately visible locally and enters sync independently;
|
|
6519
|
+
* a later failure does not roll it back.
|
|
6520
|
+
* Always pair with endUndoGroup in a finally block. Conflicting remote imports
|
|
6521
|
+
* can end/split the group early, and unrelated local edits would join it.
|
|
6522
|
+
* Complete asynchronous asset preparation before opening the group.
|
|
6523
|
+
*/
|
|
6524
|
+
beginUndoGroup() {
|
|
6525
|
+
if (this.undoManagerValue == null) throw new Error("mengine doc undo manager is not ready");
|
|
6526
|
+
this.undoManagerValue.beginGroup();
|
|
6527
|
+
}
|
|
6528
|
+
/** End a gesture, including one already split by a conflicting remote import. */
|
|
6529
|
+
endUndoGroup() {
|
|
6530
|
+
if (this.undoManagerValue == null) throw new Error("mengine doc undo manager is not ready");
|
|
6531
|
+
this.undoManagerValue.endGroup();
|
|
6532
|
+
}
|
|
4747
6533
|
/**
|
|
4748
6534
|
* Start the sync stack and connect the document.
|
|
4749
6535
|
*
|
|
4750
|
-
* Returns
|
|
4751
|
-
*
|
|
4752
|
-
*
|
|
6536
|
+
* Returns after DocManager loads a local snapshot, or after the first remote
|
|
6537
|
+
* sync when no local snapshot exists. A valid empty document is ready too.
|
|
6538
|
+
* Cached documents can open before SSE connects; subsequent remote changes
|
|
6539
|
+
* surface through `subscribe`.
|
|
4753
6540
|
*/
|
|
4754
|
-
|
|
4755
|
-
if (this.
|
|
6541
|
+
start() {
|
|
6542
|
+
if (this.destroyed) return Promise.reject(/* @__PURE__ */ new Error("mengine doc session destroyed"));
|
|
6543
|
+
if (this.startTask != null) return this.startTask;
|
|
4756
6544
|
this.started = true;
|
|
4757
|
-
this.
|
|
4758
|
-
this.
|
|
6545
|
+
this.lifetime = new AbortController();
|
|
6546
|
+
this.startTask = this.open().catch((error) => {
|
|
6547
|
+
const wasDestroyed = this.destroyed;
|
|
6548
|
+
this.destroy();
|
|
6549
|
+
if (wasDestroyed) throw new Error("mengine doc session destroyed");
|
|
6550
|
+
throw error;
|
|
6551
|
+
});
|
|
6552
|
+
return this.startTask;
|
|
6553
|
+
}
|
|
6554
|
+
async open() {
|
|
6555
|
+
this.disposables.add(this.server.subscribeResync(() => this.synchronizer.requestSync(this.docId)));
|
|
6556
|
+
this.disposables.add(this.server.subscribeServerVersion((version) => {
|
|
6557
|
+
this.serverVVValue = version;
|
|
6558
|
+
if (this.getSyncState().confirmation === "confirmed") this.pushErrorValue = null;
|
|
6559
|
+
this.emitSyncState();
|
|
6560
|
+
}));
|
|
6561
|
+
this.disposables.add(this.server.subscribePushOutcome((outcome) => {
|
|
6562
|
+
this.pushErrorValue = outcome.kind === "failed" || outcome.kind === "rejected" ? outcome.error ?? /* @__PURE__ */ new Error(`mengine push ${outcome.kind}`) : null;
|
|
6563
|
+
this.emitSyncState();
|
|
6564
|
+
}));
|
|
6565
|
+
this.disposables.add(this.server.connection.onStatusChanged((status) => {
|
|
6566
|
+
if (status === "connected") this.hasConnected = true;
|
|
6567
|
+
this.emitSyncState();
|
|
6568
|
+
}));
|
|
4759
6569
|
this.disposables.add(() => this.local.connection.disconnect());
|
|
4760
6570
|
this.disposables.add(() => this.server.connection.disconnect());
|
|
6571
|
+
this.local.connection.connect();
|
|
6572
|
+
await this.local.connection.waitForConnected().abortOn(this.lifetime.signal);
|
|
6573
|
+
if (this.destroyed) throw new Error("mengine doc session destroyed");
|
|
6574
|
+
this.server.connection.connect();
|
|
4761
6575
|
this.synchronizer.start();
|
|
4762
6576
|
this.manager.start();
|
|
4763
6577
|
this.disposables.add(() => this.synchronizer.stop());
|
|
4764
6578
|
this.disposables.add(() => this.manager.stop());
|
|
4765
6579
|
const doc = this.manager.connectDoc(this.docId);
|
|
4766
|
-
if (this.options.peerId != null) doc.setPeerId(this.options.peerId);
|
|
4767
|
-
this.adapterValue = new MirrorVideoDocumentAdapter(doc);
|
|
4768
|
-
this.editorValue = new SemanticEditor(this.adapterValue);
|
|
4769
6580
|
this.disposables.add(() => this.manager.disconnectDoc(this.docId));
|
|
6581
|
+
if (this.options.peerId != null) doc.setPeerId(this.options.peerId);
|
|
6582
|
+
this.adapterValue = new MirrorVideoDocumentAdapter(doc, this.mutationGuard);
|
|
4770
6583
|
const unsubscribe = doc.subscribe((event) => {
|
|
4771
6584
|
if (this.adapterValue == null) return;
|
|
6585
|
+
if (event.by === "local") this.localVersionValue = doc.version();
|
|
6586
|
+
this.emitSyncState();
|
|
4772
6587
|
this.events.emit("update", {
|
|
4773
6588
|
source: event.by === "local" ? "local" : "remote",
|
|
4774
6589
|
snapshot: this.adapterValue.snapshot()
|
|
4775
6590
|
});
|
|
4776
6591
|
});
|
|
4777
6592
|
this.disposables.add(unsubscribe);
|
|
4778
|
-
this.disposables.add(this.manager.onDocStateChange(this.docId, (state) =>
|
|
4779
|
-
|
|
4780
|
-
|
|
6593
|
+
this.disposables.add(this.manager.onDocStateChange(this.docId, (state) => {
|
|
6594
|
+
this.events.emit("state", state);
|
|
6595
|
+
this.emitSyncState();
|
|
4781
6596
|
}));
|
|
4782
|
-
await this.
|
|
6597
|
+
await this.waitForInitialLoad();
|
|
6598
|
+
if (this.destroyed) throw new Error("mengine doc session destroyed");
|
|
6599
|
+
assertValidVideoDocument(this.snapshot());
|
|
6600
|
+
this.undoManagerValue = new DocumentUndoManager(doc, this.mutationGuard, () => this.events.emit("undoState"));
|
|
6601
|
+
this.editorValue = new SemanticEditor(this.adapterValue);
|
|
6602
|
+
this.initialVersionValue = doc.version();
|
|
6603
|
+
this.emitSyncState();
|
|
4783
6604
|
return this.snapshot();
|
|
4784
6605
|
}
|
|
6606
|
+
getSyncState() {
|
|
6607
|
+
const state = this.getState();
|
|
6608
|
+
const target = this.confirmationTarget();
|
|
6609
|
+
const confirmation = this.serverVVValue == null || target == null ? this.localVersionValue == null ? "unknown" : "pending" : serverCovers(this.serverVVValue, target) ? "confirmed" : "pending";
|
|
6610
|
+
const connection = !this.started ? "disconnected" : this.server.connection.status === "connected" ? "connected" : this.hasConnected || this.server.connection.error != null ? "disconnected" : "connecting";
|
|
6611
|
+
return {
|
|
6612
|
+
confirmation,
|
|
6613
|
+
phase: connection === "disconnected" ? "offline" : this.pushErrorValue != null || state.error != null ? "failed" : connection === "connecting" ? "connecting" : state.syncing ? "syncing" : "idle"
|
|
6614
|
+
};
|
|
6615
|
+
}
|
|
6616
|
+
confirmationTarget() {
|
|
6617
|
+
return this.localVersionValue ?? this.initialVersionValue;
|
|
6618
|
+
}
|
|
6619
|
+
/** Immediately supplies current state; unsubscribe when the host changes docs. */
|
|
6620
|
+
subscribeSyncState(cb) {
|
|
6621
|
+
const off = this.events.on("syncState", cb);
|
|
6622
|
+
cb(this.getSyncState());
|
|
6623
|
+
return off;
|
|
6624
|
+
}
|
|
6625
|
+
emitSyncState() {
|
|
6626
|
+
this.events.emit("syncState", this.getSyncState());
|
|
6627
|
+
}
|
|
4785
6628
|
subscribe(cb) {
|
|
4786
6629
|
return this.events.on("update", cb);
|
|
4787
6630
|
}
|
|
@@ -4792,40 +6635,48 @@ var MengineDocSession = class {
|
|
|
4792
6635
|
return this.manager.getDocState(this.docId);
|
|
4793
6636
|
}
|
|
4794
6637
|
destroy() {
|
|
6638
|
+
if (this.destroyed) return;
|
|
6639
|
+
this.destroyed = true;
|
|
6640
|
+
this.mutationGuard.close();
|
|
6641
|
+
this.started = false;
|
|
6642
|
+
this.lifetime?.abort();
|
|
6643
|
+
this.undoManagerValue?.dispose();
|
|
6644
|
+
this.undoManagerValue = null;
|
|
6645
|
+
this.events.emit("undoState");
|
|
4795
6646
|
this.disposables.dispose();
|
|
6647
|
+
this.emitSyncState();
|
|
4796
6648
|
this.events.clearAll();
|
|
6649
|
+
this.adapterValue?.dispose();
|
|
4797
6650
|
this.adapterValue = null;
|
|
4798
6651
|
this.editorValue = null;
|
|
4799
6652
|
this.started = false;
|
|
4800
6653
|
}
|
|
4801
|
-
/**
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
6654
|
+
/** Local absence needs a remote sync; empty business content does not. */
|
|
6655
|
+
async waitForInitialLoad() {
|
|
6656
|
+
const signal = this.lifetime.signal;
|
|
6657
|
+
await new Promise((resolve, reject) => {
|
|
6658
|
+
let off = () => {};
|
|
6659
|
+
let offConnection = () => {};
|
|
6660
|
+
const finish = (error) => {
|
|
6661
|
+
off();
|
|
6662
|
+
offConnection();
|
|
6663
|
+
signal.removeEventListener("abort", onAbort);
|
|
6664
|
+
if (error != null) reject(error);
|
|
6665
|
+
else resolve();
|
|
6666
|
+
};
|
|
6667
|
+
const onAbort = () => finish(/* @__PURE__ */ new Error("mengine doc session destroyed"));
|
|
6668
|
+
const check = (state) => {
|
|
6669
|
+
if (state.loaded && (state.loadedFromLocal || state.synced)) finish();
|
|
6670
|
+
else if (state.error != null) finish(new Error(state.error));
|
|
6671
|
+
else if (state.loaded && this.server.connection.error != null) finish(this.server.connection.error);
|
|
6672
|
+
};
|
|
6673
|
+
off = this.onStateChange(check);
|
|
6674
|
+
offConnection = this.server.connection.onStatusChanged(() => check(this.getState()));
|
|
6675
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
6676
|
+
if (signal.aborted) onAbort();
|
|
6677
|
+
else check(this.getState());
|
|
4819
6678
|
});
|
|
4820
6679
|
}
|
|
4821
|
-
hasContent() {
|
|
4822
|
-
if (this.adapterValue == null) return false;
|
|
4823
|
-
try {
|
|
4824
|
-
return this.adapterValue.hasContent();
|
|
4825
|
-
} catch {
|
|
4826
|
-
return false;
|
|
4827
|
-
}
|
|
4828
|
-
}
|
|
4829
6680
|
};
|
|
4830
6681
|
/**
|
|
4831
6682
|
* True when the *encoded* server version `serverVV` includes everything in
|
|
@@ -4849,4 +6700,4 @@ function serverCovers(serverVV, target) {
|
|
|
4849
6700
|
return covers(server, target);
|
|
4850
6701
|
}
|
|
4851
6702
|
//#endregion
|
|
4852
|
-
export { DEFAULT_UNIT_TIME_MS,
|
|
6703
|
+
export { DEFAULT_UNIT_TIME_MS, EntityGraphHttpClient, EntityTimelineEditor, EntityTimelineProjectionError, IMPLEMENTED_SEMANTIC_OP_KINDS, LANE_KINDS_IN_STACK_ORDER, LORO_ENTITY_SCHEMA, LoroEntityDocument, LoroEntityDraft, ManualSyncDoc, MedeoHttpDocStorage, MemoryDocStorage, MengineAckFailedError, MengineDocSession, MengineHttpClient, MengineHttpRequestError, MenginePushRejectedError, MirrorVideoDocumentAdapter, PlainMemoryAdapter, SchemaValidator, ScriptCompositionError, SemanticEditor, TIMELINE_SKELETON_DURATION_MS, ValidationError, VideoDocumentValidationError, applyEntityRows, applyFieldChanges, arrangeMainTrackSeamlessly, assembleCaptionContent, assembleEntityContent, assemblePhoneticScriptContent, assembleScriptText, assertCanonicalEditorResources, assertEntityProjectionPreservesLegacy, assertFieldChanges, assertMediaAssetWritePolicy, assertValidVideoDocument, audioScriptAssetContent, audioScriptAssetFields, audioScriptAssetHash, audioScriptAssetOf, base64ToBytes, buildInitialVideoDocument, buildSpeechHostMap, bytesToBase64, cascadeAfterVideoClipChanges, compileCaptionAssetsEffect, compileEntityRows, compileGeneratedVisualEffect, compileSpeechAssetsEffect, createEditSandbox, createMirrorVideoDocument, createMirrorVideoDocumentAdapter, createPlainMemoryAdapter, decodeDocVersionMark, derivePositionFromAbs, effectiveVideoClipDurationMs, encodeDocVersionMark, ensureEditorFoundation, ensureLaneTrack, fillMainTrackTimeGaps, findComposedAudioScript, findLaneTrack, fromVideoDocument, generatePartId, getAt, hostForAbsMs, importCaptionAsset, importMediaAsset, isEmptyVideoClip, isImplementedSemanticOpKind, isMap, laneTrackId, mainTrackRanges, migrateLegacyTimelineToEntities, partDurationMs, partUnionSchema, phoneticScriptAssetContent, phoneticScriptAssetHash, phoneticScriptAssetOf, prepareCaptionAssetsEffect, prepareCaptionContent, prepareContinueVideoEffect, prepareGeneratedVisualEffect, prepareSpeechAssetsEffect, projectEntityTimeline, readDocumentAudioScript, readEntityDocument, readMainTrackItems, readMengineEventStream, readPart, readPartDurationMs, readVideoDocumentFromDraft, reassignSpeechesToVideoClipsByTime, recalculateTimelineDuration, relativePositionForAbs, replayJournal, resolveAllSpeechOverlaps, resolveEntityTimelineLayout, resolveFieldPath, resolveSpeechOverlapByShiftingVideos, safeDurationMs, schemas_exports as schemas, scriptSourceAssetOf, selectAudioScriptSegment, snapshotToPlain, solveVideoDocument, speedOf, syncAggregatedClipsTimePosition, toVideoDocument, validateVideoDocument, videoDocumentMirrorSchema, videoDocumentSchema };
|