@mengine/medeo-client 2.0.1-alpha.2 → 2.0.1-alpha.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/dist/{document-BSAUud6c.js → document-yPiwEmkJ.js} +521 -328
- package/dist/{index-B31KeDzA.d.ts → index-C0uUWqkz.d.ts} +278 -183
- package/dist/index.d.ts +196 -168
- package/dist/index.js +590 -282
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { u as volumeSchema } from "./shared-iWnU2osE.js";
|
|
2
|
-
import { Mirror, schema } from "loro-mirror";
|
|
2
|
+
import { Mirror, schema, validateSchema } from "loro-mirror";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { LoroDoc, LoroMap, LoroMovableList, LoroText, UndoManager } from "loro-crdt";
|
|
5
5
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
@@ -7,9 +7,11 @@ import { bytesToHex } from "@noble/hashes/utils.js";
|
|
|
7
7
|
import { produce } from "immer";
|
|
8
8
|
//#region src/client/base64.ts
|
|
9
9
|
function bytesToBase64(bytes) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const native = bytes.toBase64;
|
|
11
|
+
if (typeof native === "function") return native.call(bytes);
|
|
12
|
+
const chunks = [];
|
|
13
|
+
for (let offset = 0; offset < bytes.length; offset += 8192) chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 8192)));
|
|
14
|
+
return btoa(chunks.join(""));
|
|
13
15
|
}
|
|
14
16
|
function base64ToBytes(base64) {
|
|
15
17
|
const binary = atob(base64);
|
|
@@ -47,10 +49,6 @@ function base64ToBytes(base64) {
|
|
|
47
49
|
* value, last-writer-wins). The derived `fallback_abs_ms` snapshot sits beside
|
|
48
50
|
* it as its own number field — a separate LWW unit so a projection refresh
|
|
49
51
|
* never clobbers a `time_position` edit.
|
|
50
|
-
* - meta lives in a `meta` LoroMap: the mirror root only accepts container
|
|
51
|
-
* schemas, so meta scalars can't sit at the root as bare values. The domain
|
|
52
|
-
* `VideoDocument` keeps the same `meta` wrapper, so the shapes are isomorphic
|
|
53
|
-
* and the read/seed mapping is a near-identity.
|
|
54
52
|
* - `part_library` values are nested `LoroMap`s: the value is a `LoroMap` with
|
|
55
53
|
* four mutually-exclusive optional part sub-maps (`video_clip` / `speech` /
|
|
56
54
|
* `caption` / `bgm`), each a `LoroMap` whose fields are stored as real Loro
|
|
@@ -64,16 +62,10 @@ function base64ToBytes(base64) {
|
|
|
64
62
|
* as opaque JSON-blob sub-keys via `transform`. The four sub-keys are optional,
|
|
65
63
|
* so the "exactly one part kind" union invariant is not enforced by the schema
|
|
66
64
|
* type; it is rebuilt by `draftToPartUnion` on read and gated by zod on write.
|
|
67
|
-
* - a caption's `style` is
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* other nested maps are keyed by a unique id (`part_library`) or created
|
|
72
|
-
* atomically with their parent (`partValue`'s part sub-map), so they never hit
|
|
73
|
-
* that concurrent-first-create fork and stay on plain `setContainer`. See the
|
|
74
|
-
* note on `captionPart` below.
|
|
75
|
-
* - `video_creation_settings` is an opaque JSON blob carried in a string via
|
|
76
|
-
* `transform`: a whole value, last-writer-wins (no field-level concurrent edits).
|
|
65
|
+
* - a caption's `style` is a mergeable child map for independent style fields.
|
|
66
|
+
* Creation paths materialize it with the caption, even when empty, so undoing
|
|
67
|
+
* a later style edit never removes the parent reference. Other nested maps
|
|
68
|
+
* keep their regular container identities from creation.
|
|
77
69
|
*/
|
|
78
70
|
/**
|
|
79
71
|
* JSON-blob transform for an opaque, last-writer-wins value carried in a Loro
|
|
@@ -154,16 +146,6 @@ const partValue = schema.LoroMap({
|
|
|
154
146
|
bgm: bgmPart
|
|
155
147
|
});
|
|
156
148
|
const videoDocumentMirrorSchema = schema({
|
|
157
|
-
meta: schema.LoroMap({
|
|
158
|
-
schema_version: schema.String({ required: false }),
|
|
159
|
-
draft_id: schema.String({ required: false }),
|
|
160
|
-
project_id: schema.String({ required: false }),
|
|
161
|
-
owner_id: schema.String({ required: false }),
|
|
162
|
-
thumbnail_storage_key: schema.String({ required: false }),
|
|
163
|
-
chat_session_id: schema.String({ required: false }),
|
|
164
|
-
video_creation_settings: schema.String({ required: false }).transform(jsonTransform()),
|
|
165
|
-
version: schema.Number({ required: false })
|
|
166
|
-
}),
|
|
167
149
|
timeline: schema.LoroMap({ unit_time_ms: schema.Number({ required: false }) }),
|
|
168
150
|
tracks: schema.LoroMovableList(track, (t) => t.id),
|
|
169
151
|
part_library: schema.LoroMapRecord(partValue)
|
|
@@ -213,9 +195,6 @@ function recordEntries(record) {
|
|
|
213
195
|
}
|
|
214
196
|
//#endregion
|
|
215
197
|
//#region src/document/types.ts
|
|
216
|
-
const VIDEO_DOCUMENT_SCHEMA_VERSION = "video-document/v0";
|
|
217
|
-
/** A server-derived compatibility view, never an editor write authority. */
|
|
218
|
-
const ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION = "video-document/entity-projection-v1";
|
|
219
198
|
/** The linear speed multiplier of a `speed_shift`, defaulting to 1 (original). */
|
|
220
199
|
function speedOf(speedShift) {
|
|
221
200
|
const speed = speedShift?.config?.linear?.speed;
|
|
@@ -549,12 +528,12 @@ function cascadeAfterVideoClipChanges(doc, makeEmptyPart) {
|
|
|
549
528
|
* read-view: absolute time per item, `part_aggregations`, and total duration.
|
|
550
529
|
* This is the read side of the single-directional flow — never written back.
|
|
551
530
|
*/
|
|
552
|
-
function solveVideoDocument(document) {
|
|
531
|
+
function solveVideoDocument(document, options = {}) {
|
|
553
532
|
const doc = videoDocumentToTimelineDoc(document);
|
|
554
533
|
let counter = 0;
|
|
555
534
|
const derivedFillerPartIds = /* @__PURE__ */ new Set();
|
|
556
|
-
if (
|
|
557
|
-
|
|
535
|
+
if (options.placementResolved === true) {
|
|
536
|
+
assertResolvedPlacement(document);
|
|
558
537
|
recalculateTimelineDuration(doc);
|
|
559
538
|
} else cascadeAfterVideoClipChanges(doc, () => {
|
|
560
539
|
const partId = `empty_${counter++}`;
|
|
@@ -759,6 +738,19 @@ function ensureLaneTrack(draft, kind) {
|
|
|
759
738
|
function findLaneTrack(draft, kind) {
|
|
760
739
|
return (draft.tracks ?? []).find((t) => t?.parts_kind === kind);
|
|
761
740
|
}
|
|
741
|
+
/**
|
|
742
|
+
* Whether every item already states a position that can be read without running
|
|
743
|
+
* the cascade: nothing `sequential`, and every `anchored` item carrying the
|
|
744
|
+
* absolute landing its writer stamped. This is a verification predicate, not a
|
|
745
|
+
* provenance test — see `SolveVideoDocumentOptions.placementResolved`.
|
|
746
|
+
*/
|
|
747
|
+
function hasResolvedPlacement(document) {
|
|
748
|
+
return (document.tracks ?? []).every((track) => (track.items ?? []).every((item) => item.time_position.mode !== "sequential" && (item.time_position.mode !== "anchored" || Number.isSafeInteger(item.fallback_abs_ms))));
|
|
749
|
+
}
|
|
750
|
+
/** A producer that claims resolved placement must actually have resolved it. */
|
|
751
|
+
function assertResolvedPlacement(document) {
|
|
752
|
+
if (!hasResolvedPlacement(document)) throw new Error("An entity timeline projection must contain resolved placement facts");
|
|
753
|
+
}
|
|
762
754
|
//#endregion
|
|
763
755
|
//#region src/document/zod-schema.ts
|
|
764
756
|
const partKindSchema = z.enum([
|
|
@@ -859,18 +851,7 @@ const partUnionSchema = z.union([
|
|
|
859
851
|
"bgm"
|
|
860
852
|
].filter((k) => p[k] != null).length === 1;
|
|
861
853
|
}, { message: "A part_library value must have exactly one of video_clip / speech / caption / bgm" });
|
|
862
|
-
const videoDocumentMetaSchema = z.object({
|
|
863
|
-
schema_version: z.enum([VIDEO_DOCUMENT_SCHEMA_VERSION, ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION]),
|
|
864
|
-
draft_id: z.string().optional(),
|
|
865
|
-
project_id: z.string().optional(),
|
|
866
|
-
owner_id: z.string().optional(),
|
|
867
|
-
thumbnail_storage_key: z.string().optional(),
|
|
868
|
-
chat_session_id: z.string().optional(),
|
|
869
|
-
video_creation_settings: z.unknown().optional(),
|
|
870
|
-
version: finiteNumber.optional()
|
|
871
|
-
}).passthrough();
|
|
872
854
|
const videoDocumentSchema = z.object({
|
|
873
|
-
meta: videoDocumentMetaSchema,
|
|
874
855
|
timeline: z.object({ unit_time_ms: finiteNumber.optional() }).passthrough().optional(),
|
|
875
856
|
tracks: z.array(trackSchema).optional(),
|
|
876
857
|
part_library: z.record(z.string(), partUnionSchema).optional()
|
|
@@ -1140,13 +1121,13 @@ function zodPath(path) {
|
|
|
1140
1121
|
//#endregion
|
|
1141
1122
|
//#region src/document/projection.ts
|
|
1142
1123
|
/**
|
|
1143
|
-
* Projection between
|
|
1144
|
-
*
|
|
1124
|
+
* Projection between authoritative `VideoDocument` and compatible content
|
|
1125
|
+
* layouts (RFC 02 §5/§7). Both directions live here:
|
|
1145
1126
|
*
|
|
1146
1127
|
* - `toVideoDocument` ingests a `VideoDraft`, deriving each item's `position`
|
|
1147
1128
|
* from the legacy absolute layout + aggregations; derived values (abs time,
|
|
1148
1129
|
* `part_aggregations`, total duration) are dropped.
|
|
1149
|
-
* - `fromVideoDocument` solves a `VideoDocument`
|
|
1130
|
+
* - `fromVideoDocument` solves a `VideoDocument` into `VideoDraftContent` via the
|
|
1150
1131
|
* timeline-core cascade, re-deriving exactly those values.
|
|
1151
1132
|
*
|
|
1152
1133
|
* Business validation lives in `validation.ts`; `fromVideoDocument` asserts a
|
|
@@ -1177,16 +1158,6 @@ function toVideoDocument(draft) {
|
|
|
1177
1158
|
...(draft.below_main_tracks ?? []).map((t) => toTrack(t, false))
|
|
1178
1159
|
];
|
|
1179
1160
|
return {
|
|
1180
|
-
meta: {
|
|
1181
|
-
schema_version: VIDEO_DOCUMENT_SCHEMA_VERSION,
|
|
1182
|
-
draft_id: draft.id,
|
|
1183
|
-
project_id: draft.project_id,
|
|
1184
|
-
owner_id: draft.owner_id,
|
|
1185
|
-
thumbnail_storage_key: draft.thumbnail_storage_key,
|
|
1186
|
-
chat_session_id: draft.chat_session_id,
|
|
1187
|
-
video_creation_settings: clone$1(draft.video_creation_settings),
|
|
1188
|
-
version: draft.version
|
|
1189
|
-
},
|
|
1190
1161
|
timeline: draft.timeline == null ? void 0 : { unit_time_ms: draft.timeline.unit_time_ms },
|
|
1191
1162
|
tracks,
|
|
1192
1163
|
part_library: toAuthoritativePartLibrary(draft.part_library)
|
|
@@ -1296,8 +1267,8 @@ function deriveItem(item, isMain, speechHost, partLibrary) {
|
|
|
1296
1267
|
};
|
|
1297
1268
|
}
|
|
1298
1269
|
/**
|
|
1299
|
-
* Project
|
|
1300
|
-
*
|
|
1270
|
+
* Project `VideoDocument` into `VideoDraftContent`, solving each item's
|
|
1271
|
+
* absolute position, the `part_aggregations`, and
|
|
1301
1272
|
* the total duration via the timeline-core cascade.
|
|
1302
1273
|
*
|
|
1303
1274
|
* The read-view is a compatibility contract, and the IDL declares
|
|
@@ -1311,33 +1282,26 @@ function deriveItem(item, isMain, speechHost, partLibrary) {
|
|
|
1311
1282
|
* These two are defaultable because the projection knows their values on its own:
|
|
1312
1283
|
* absent `unit_time_ms` means "no display granularity was ever stated" and absent
|
|
1313
1284
|
* `is_hidden` means "this track was never hidden". `version` is NOT defaultable
|
|
1314
|
-
* here —
|
|
1315
|
-
*
|
|
1285
|
+
* here — it belongs to Director assembly using the server envelope update_seq.
|
|
1286
|
+
* Neither business metadata nor a revision counter belongs in content projection.
|
|
1316
1287
|
*/
|
|
1317
|
-
function fromVideoDocument(document) {
|
|
1288
|
+
function fromVideoDocument(document, options = {}) {
|
|
1318
1289
|
assertValidVideoDocument(document);
|
|
1319
|
-
const view = solveVideoDocument(document);
|
|
1290
|
+
const view = solveVideoDocument(document, options);
|
|
1320
1291
|
const tracks = document.tracks ?? [];
|
|
1321
1292
|
const mainTrack = tracks.find((t) => t.parts_kind === "video_clip");
|
|
1322
1293
|
const aboveTracks = tracks.filter((t) => t.parts_kind === "caption");
|
|
1323
1294
|
const belowTracks = tracks.filter((t) => t.parts_kind === "speech" || t.parts_kind === "bgm");
|
|
1324
1295
|
return {
|
|
1325
|
-
id: document.meta.draft_id,
|
|
1326
|
-
project_id: document.meta.project_id,
|
|
1327
|
-
owner_id: document.meta.owner_id,
|
|
1328
|
-
thumbnail_storage_key: document.meta.thumbnail_storage_key,
|
|
1329
1296
|
timeline: {
|
|
1330
1297
|
duration_ms: view.durationMs,
|
|
1331
1298
|
unit_time_ms: document.timeline?.unit_time_ms ?? 33.333
|
|
1332
1299
|
},
|
|
1333
|
-
video_creation_settings: clone$1(document.meta.video_creation_settings),
|
|
1334
|
-
chat_session_id: document.meta.chat_session_id,
|
|
1335
1300
|
main_track: draftTrack(mainTrack, view.absByPartId),
|
|
1336
1301
|
above_main_tracks: aboveTracks.map((t) => draftTrack(t, view.absByPartId)),
|
|
1337
1302
|
below_main_tracks: belowTracks.map((t) => draftTrack(t, view.absByPartId)),
|
|
1338
1303
|
part_aggregations: view.aggregations,
|
|
1339
|
-
part_library: toReadViewPartLibrary(view.partLibrary, view.durationMs, view.derivedFillerPartIds)
|
|
1340
|
-
version: document.meta.version
|
|
1304
|
+
part_library: toReadViewPartLibrary(view.partLibrary, view.durationMs, view.derivedFillerPartIds)
|
|
1341
1305
|
};
|
|
1342
1306
|
}
|
|
1343
1307
|
/**
|
|
@@ -1444,7 +1408,7 @@ function clone$1(value) {
|
|
|
1444
1408
|
//#region src/document/initial-document.ts
|
|
1445
1409
|
/**
|
|
1446
1410
|
* Build the `VideoDocument` a newly created project starts from: the caller's
|
|
1447
|
-
*
|
|
1411
|
+
* no parts and one empty track per lane. Business facts remain in Director.
|
|
1448
1412
|
*
|
|
1449
1413
|
* The empty lane tracks are the reason this function exists rather than callers
|
|
1450
1414
|
* assembling a document inline. Lane tracks are otherwise minted lazily by the
|
|
@@ -1465,18 +1429,8 @@ function clone$1(value) {
|
|
|
1465
1429
|
* supplies, and total duration is derived on read (RFC 02 §6), so a new document
|
|
1466
1430
|
* has no timeline fact to state.
|
|
1467
1431
|
*/
|
|
1468
|
-
function buildInitialVideoDocument(
|
|
1432
|
+
function buildInitialVideoDocument() {
|
|
1469
1433
|
return {
|
|
1470
|
-
meta: {
|
|
1471
|
-
schema_version: VIDEO_DOCUMENT_SCHEMA_VERSION,
|
|
1472
|
-
draft_id: facts.draftId,
|
|
1473
|
-
project_id: facts.projectId,
|
|
1474
|
-
owner_id: facts.ownerId,
|
|
1475
|
-
thumbnail_storage_key: void 0,
|
|
1476
|
-
chat_session_id: facts.chatSessionId,
|
|
1477
|
-
video_creation_settings: facts.videoCreationSettings,
|
|
1478
|
-
version: void 0
|
|
1479
|
-
},
|
|
1480
1434
|
timeline: void 0,
|
|
1481
1435
|
tracks: emptyLaneTracks(),
|
|
1482
1436
|
part_library: {}
|
|
@@ -1493,36 +1447,101 @@ function emptyLaneTracks() {
|
|
|
1493
1447
|
}
|
|
1494
1448
|
//#endregion
|
|
1495
1449
|
//#region ../medeo-dsl/src/entities.ts
|
|
1450
|
+
/**
|
|
1451
|
+
* What every known entity kind declares.
|
|
1452
|
+
*
|
|
1453
|
+
* `kinds` is a multi-declaration: every entry holds at the same time, and each
|
|
1454
|
+
* is a property shared with other entity kinds — what the object *is* comes
|
|
1455
|
+
* from `entityKind` and is never repeated here. The order carries no priority,
|
|
1456
|
+
* endpoint role, or edit sequence — an operation asks whether the entity
|
|
1457
|
+
* declares the kind it needs, never what its "main type" is. An entity kind
|
|
1458
|
+
* that shares nothing declares nothing.
|
|
1459
|
+
*
|
|
1460
|
+
* `Caption` declares `Sequence` on purpose: it owns the display timing of the
|
|
1461
|
+
* one AudioScript segment it shows, which is what admits it into a Clip.
|
|
1462
|
+
* `Voice` declares no `Sequence` — it is a timbre identity, not playable
|
|
1463
|
+
* content; rendered speech is a `voiceover` entity.
|
|
1464
|
+
*
|
|
1465
|
+
* `MediaFile` says only that an entity of that kind may be bound to stored bytes,
|
|
1466
|
+
* and asks nothing of its payload. Which bytes, and which way the content
|
|
1467
|
+
* flowed, is the `from-asset` / `to-asset` Relation's answer alone.
|
|
1468
|
+
*/
|
|
1469
|
+
const ENTITY_KINDS = Object.freeze({
|
|
1470
|
+
axvideo: Object.freeze([
|
|
1471
|
+
"Container",
|
|
1472
|
+
"Sequence",
|
|
1473
|
+
"Visual",
|
|
1474
|
+
"Audible"
|
|
1475
|
+
]),
|
|
1476
|
+
timeline: Object.freeze(["Container"]),
|
|
1477
|
+
track: Object.freeze(["Container"]),
|
|
1478
|
+
clip: Object.freeze(["Container"]),
|
|
1479
|
+
asset: Object.freeze([]),
|
|
1480
|
+
video: Object.freeze([
|
|
1481
|
+
"Sequence",
|
|
1482
|
+
"Visual",
|
|
1483
|
+
"Audible",
|
|
1484
|
+
"MediaFile"
|
|
1485
|
+
]),
|
|
1486
|
+
audio: Object.freeze([
|
|
1487
|
+
"Sequence",
|
|
1488
|
+
"Audible",
|
|
1489
|
+
"MediaFile"
|
|
1490
|
+
]),
|
|
1491
|
+
voiceover: Object.freeze([
|
|
1492
|
+
"Sequence",
|
|
1493
|
+
"Audible",
|
|
1494
|
+
"MediaFile"
|
|
1495
|
+
]),
|
|
1496
|
+
image: Object.freeze([
|
|
1497
|
+
"Sequence",
|
|
1498
|
+
"Visual",
|
|
1499
|
+
"MediaFile"
|
|
1500
|
+
]),
|
|
1501
|
+
voice: Object.freeze(["Container", "IPBound"]),
|
|
1502
|
+
"sequence-marker": Object.freeze([]),
|
|
1503
|
+
viewport: Object.freeze([]),
|
|
1504
|
+
"audio-script": Object.freeze(["SegmentText", "MediaFile"]),
|
|
1505
|
+
"phonetic-script": Object.freeze(["SegmentText", "MediaFile"]),
|
|
1506
|
+
caption: Object.freeze([
|
|
1507
|
+
"SegmentText",
|
|
1508
|
+
"Sequence",
|
|
1509
|
+
"MediaFile"
|
|
1510
|
+
])
|
|
1511
|
+
});
|
|
1512
|
+
/** What a known kind declares; extension kinds declare nothing through this table. */
|
|
1513
|
+
function kindsOf(entityKind) {
|
|
1514
|
+
return isKnownEntityKind(entityKind) ? ENTITY_KINDS[entityKind] : [];
|
|
1515
|
+
}
|
|
1516
|
+
function declaresKind(entityKind, kind) {
|
|
1517
|
+
return kindsOf(entityKind).includes(kind);
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Sequence admission is a declaration, not a shape: a known kind is admitted
|
|
1521
|
+
* because its `kinds` say so, and a malformed payload is a separate, louder
|
|
1522
|
+
* failure than a silently non-Sequence entity. Extension kinds declare nothing
|
|
1523
|
+
* through the table and stay structurally detected.
|
|
1524
|
+
*/
|
|
1496
1525
|
function hasSequence(entity) {
|
|
1497
|
-
if (
|
|
1526
|
+
if (isReservedEntityKind(entity.entityKind)) return false;
|
|
1527
|
+
if (isKnownEntityKind(entity.entityKind)) return declaresKind(entity.entityKind, "Sequence");
|
|
1498
1528
|
return isSequenceFields(entity);
|
|
1499
1529
|
}
|
|
1500
1530
|
function isSequenceFields(value) {
|
|
1501
1531
|
if (!isRecord$1(value)) return false;
|
|
1502
|
-
const
|
|
1503
|
-
|
|
1504
|
-
if (extent.kind === "bounded" && (!("end" in extent) || extent.end === void 0)) return false;
|
|
1505
|
-
if (extent.kind === "unbounded" && "end" in extent) return false;
|
|
1506
|
-
if (extent.kind !== "bounded" && extent.kind !== "unbounded") return false;
|
|
1507
|
-
if (value.sampling !== "native" && value.sampling !== "constant" && value.sampling !== "derived") return false;
|
|
1508
|
-
return "coordinateSpace" in value && value.coordinateSpace !== void 0;
|
|
1509
|
-
}
|
|
1510
|
-
function isKnownSequenceKind(kind) {
|
|
1511
|
-
return isMediaAssetVariantKind(kind) || kind === "caption" || kind === "axvideo";
|
|
1532
|
+
const duration = value.durationMs;
|
|
1533
|
+
return duration === null || typeof duration === "number" && Number.isSafeInteger(duration) && duration > 0;
|
|
1512
1534
|
}
|
|
1513
1535
|
function isMediaAssetVariantKind(kind) {
|
|
1514
|
-
return kind === "video" || kind === "image" || kind === "audio" || kind === "
|
|
1536
|
+
return kind === "video" || kind === "image" || kind === "audio" || kind === "voiceover";
|
|
1515
1537
|
}
|
|
1516
1538
|
function isKnownEntityKind(kind) {
|
|
1517
|
-
return
|
|
1539
|
+
return Object.hasOwn(ENTITY_KINDS, kind);
|
|
1518
1540
|
}
|
|
1519
1541
|
function isReservedEntityKind(kind) {
|
|
1520
1542
|
const normalized = kind.toLowerCase().replaceAll("-", "").replaceAll("_", "");
|
|
1521
1543
|
return normalized === "speech" || normalized === "videodocument";
|
|
1522
1544
|
}
|
|
1523
|
-
function isKnownNonSequenceKind(kind) {
|
|
1524
|
-
return kind === "timeline" || kind === "track" || kind === "clip" || kind === "asset" || kind === "sequence-marker" || kind === "viewport" || kind === "audio-script" || kind === "phonetic-script";
|
|
1525
|
-
}
|
|
1526
1545
|
function isRecord$1(value) {
|
|
1527
1546
|
return typeof value === "object" && value != null && !Array.isArray(value);
|
|
1528
1547
|
}
|
|
@@ -1706,14 +1725,17 @@ function assembleCaptionContent(rows, captionEntityId) {
|
|
|
1706
1725
|
requireEntityKind(rows, captionEntityId, "caption");
|
|
1707
1726
|
const caption = assembleEntityContent(rows, captionEntityId);
|
|
1708
1727
|
const script = findComposedAudioScript(rows, captionEntityId, "caption");
|
|
1709
|
-
const
|
|
1728
|
+
const selection = captionSelection(caption);
|
|
1729
|
+
const composed = {
|
|
1710
1730
|
...script,
|
|
1711
1731
|
payload: caption.payload
|
|
1712
|
-
}
|
|
1732
|
+
};
|
|
1733
|
+
const segment = selectAudioScriptSegment(composed, selection);
|
|
1713
1734
|
return {
|
|
1714
1735
|
caption,
|
|
1715
1736
|
audioScript: script,
|
|
1716
1737
|
segments: [segment],
|
|
1738
|
+
segmentIndex: scriptSegments(composed).findIndex((entry) => entry.segmentId === selection.segmentId),
|
|
1717
1739
|
text: segment.text
|
|
1718
1740
|
};
|
|
1719
1741
|
}
|
|
@@ -1781,18 +1803,7 @@ function entityToRow(entity) {
|
|
|
1781
1803
|
}
|
|
1782
1804
|
//#endregion
|
|
1783
1805
|
//#region ../medeo-dsl/src/invariants.ts
|
|
1784
|
-
/**
|
|
1785
|
-
* Media variants own their Asset identity directly: a `physical-asset`
|
|
1786
|
-
* Relation may not bind them. Caption may associate with a physical text Asset
|
|
1787
|
-
* independently of its AudioScript composition.
|
|
1788
|
-
*/
|
|
1789
|
-
function validateMediaAssetIdentity(entity, index) {
|
|
1790
|
-
return [...index.relationsOf(entity)].filter((relation) => relation.kind === "physical-asset").map(() => ({
|
|
1791
|
-
code: "media_physical_asset_forbidden",
|
|
1792
|
-
entityId: entity.entityId,
|
|
1793
|
-
message: `Media entity "${entity.entityId}" owns its Asset identity; remove its physical-asset Relation`
|
|
1794
|
-
}));
|
|
1795
|
-
}
|
|
1806
|
+
/** An entity is made from at most one Asset; two would leave its bytes ambiguous. */
|
|
1796
1807
|
/** Validates one complete set of entities and its authoritative Relation rows. */
|
|
1797
1808
|
function validateEntityRelationSet(entityRefs, index, options) {
|
|
1798
1809
|
const { entities, issues } = collectRelatedEntities(entityRefs, index);
|
|
@@ -1814,7 +1825,6 @@ function validateEntityRelationSet(entityRefs, index, options) {
|
|
|
1814
1825
|
const current = entity.current();
|
|
1815
1826
|
const entityIssues = validateEntity(entity, entityIds);
|
|
1816
1827
|
issues.push(...entityIssues);
|
|
1817
|
-
if (isMediaAssetVariantKind(current.entityKind)) issues.push(...validateMediaAssetIdentity(entity, index));
|
|
1818
1828
|
if (entityIssues.some((issue) => issue.code === "invalid_entity_payload")) continue;
|
|
1819
1829
|
if (current.entityKind === "sequence-marker") {
|
|
1820
1830
|
const marker = entity;
|
|
@@ -2039,24 +2049,25 @@ function validateMarkerSourceBounds(marker, index, compare) {
|
|
|
2039
2049
|
const content = contentEdges[0]?.other(marker)?.deref()?.current();
|
|
2040
2050
|
if (content == null || !hasSequence(content)) return [];
|
|
2041
2051
|
const sourceRange = marker.current().sourceRange;
|
|
2042
|
-
const
|
|
2043
|
-
const
|
|
2044
|
-
if (!Number.isNaN(
|
|
2052
|
+
const startVsOrigin = compare(sourceRange.start, 0);
|
|
2053
|
+
const endVsDuration = content.durationMs == null ? void 0 : compare(sourceRange.end, content.durationMs);
|
|
2054
|
+
if (!Number.isNaN(startVsOrigin) && startVsOrigin >= 0 && (endVsDuration == null || !Number.isNaN(endVsDuration) && endVsDuration <= 0)) return [];
|
|
2045
2055
|
return [{
|
|
2046
2056
|
code: "marker_source_out_of_bounds",
|
|
2047
2057
|
entityId: marker.entityId,
|
|
2048
|
-
message: `Sequence Marker sourceRange must stay within content "${content.entityId}"
|
|
2058
|
+
message: `Sequence Marker sourceRange must stay within content "${content.entityId}" duration`
|
|
2049
2059
|
}];
|
|
2050
2060
|
}
|
|
2051
2061
|
function validateSequenceComposition(entity) {
|
|
2052
2062
|
const current = entity.current();
|
|
2053
|
-
const expected =
|
|
2054
|
-
if (expected
|
|
2055
|
-
|
|
2063
|
+
const expected = expectedDurationShape(current.entityKind);
|
|
2064
|
+
if (expected === void 0) return [];
|
|
2065
|
+
const duration = current.durationMs;
|
|
2066
|
+
if ((duration === null ? "none" : isPositiveInteger(duration) ? "own" : "invalid") === expected) return [];
|
|
2056
2067
|
return [{
|
|
2057
2068
|
code: "invalid_sequence_composition",
|
|
2058
2069
|
entityId: current.entityId,
|
|
2059
|
-
message: `${current.entityKind} must
|
|
2070
|
+
message: expected === "own" ? `${current.entityKind} must state its own durationMs in whole milliseconds` : `${current.entityKind} has no intrinsic length; durationMs must be null`
|
|
2060
2071
|
}];
|
|
2061
2072
|
}
|
|
2062
2073
|
function validateEntity(entity, entityIds = /* @__PURE__ */ new Set()) {
|
|
@@ -2087,6 +2098,7 @@ function validateKnownEntityPayload(entity) {
|
|
|
2087
2098
|
if (value.lifecycle !== void 0 && !isRecord(value.lifecycle)) problems.push("lifecycle must be an object when present");
|
|
2088
2099
|
if (entity.entityKind === "audio-script" || entity.entityKind === "phonetic-script") {
|
|
2089
2100
|
for (const key of [
|
|
2101
|
+
"durationMs",
|
|
2090
2102
|
"extent",
|
|
2091
2103
|
"sampling",
|
|
2092
2104
|
"coordinateSpace",
|
|
@@ -2095,7 +2107,7 @@ function validateKnownEntityPayload(entity) {
|
|
|
2095
2107
|
"duration",
|
|
2096
2108
|
"startMs",
|
|
2097
2109
|
"endMs"
|
|
2098
|
-
]) if (Object.hasOwn(value, key)) problems.push(`${key} is not intrinsic script data; assign an
|
|
2110
|
+
]) if (Object.hasOwn(value, key)) problems.push(`${key} is not intrinsic script data; assign an annotation Marker instead`);
|
|
2099
2111
|
}
|
|
2100
2112
|
switch (entity.entityKind) {
|
|
2101
2113
|
case "track":
|
|
@@ -2105,19 +2117,25 @@ function validateKnownEntityPayload(entity) {
|
|
|
2105
2117
|
break;
|
|
2106
2118
|
case "video":
|
|
2107
2119
|
case "audio":
|
|
2108
|
-
|
|
2120
|
+
validateDurationPayload(value, "own", problems);
|
|
2121
|
+
break;
|
|
2109
2122
|
case "caption":
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2123
|
+
validateDurationPayload(value, "own", problems);
|
|
2124
|
+
validateCaptionPayload(value, problems);
|
|
2125
|
+
break;
|
|
2126
|
+
case "voice":
|
|
2127
|
+
validateVoicePayload(value, problems);
|
|
2114
2128
|
break;
|
|
2115
2129
|
case "image":
|
|
2116
|
-
|
|
2117
|
-
validateMediaAssetFields(value, problems);
|
|
2130
|
+
validateDurationPayload(value, "none", problems);
|
|
2118
2131
|
break;
|
|
2119
2132
|
case "axvideo":
|
|
2120
|
-
|
|
2133
|
+
for (const retired of [
|
|
2134
|
+
"extent",
|
|
2135
|
+
"sampling",
|
|
2136
|
+
"coordinateSpace",
|
|
2137
|
+
"durationMs"
|
|
2138
|
+
]) if (Object.hasOwn(value, retired)) problems.push(`${retired} is derived from the Timeline, not stored`);
|
|
2121
2139
|
break;
|
|
2122
2140
|
case "sequence-marker":
|
|
2123
2141
|
validateMarkerPayload(value, problems);
|
|
@@ -2142,17 +2160,27 @@ function validateKnownEntityPayload(entity) {
|
|
|
2142
2160
|
}
|
|
2143
2161
|
return problems;
|
|
2144
2162
|
}
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2163
|
+
/**
|
|
2164
|
+
* Content states its own length and nothing else. The retired `extent` carried
|
|
2165
|
+
* a start as well, which had no meaning of its own and drifted into holding a
|
|
2166
|
+
* position; rejecting it keeps that mistake unwritable.
|
|
2167
|
+
*/
|
|
2168
|
+
function validateDurationPayload(value, expected, problems) {
|
|
2169
|
+
for (const retired of [
|
|
2170
|
+
"extent",
|
|
2171
|
+
"sampling",
|
|
2172
|
+
"coordinateSpace"
|
|
2173
|
+
]) if (Object.hasOwn(value, retired)) problems.push(`${retired} is retired; state durationMs instead`);
|
|
2174
|
+
if (!Object.hasOwn(value, "durationMs")) {
|
|
2175
|
+
problems.push("durationMs is required");
|
|
2176
|
+
return;
|
|
2153
2177
|
}
|
|
2154
|
-
|
|
2155
|
-
if (
|
|
2178
|
+
const duration = value.durationMs;
|
|
2179
|
+
if (expected === "none") {
|
|
2180
|
+
if (duration !== null) problems.push("durationMs must be null; this content has no intrinsic length");
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
if (!isPositiveInteger(duration)) problems.push("durationMs must be positive whole milliseconds");
|
|
2156
2184
|
}
|
|
2157
2185
|
function validateMarkerPayload(value, problems) {
|
|
2158
2186
|
validateRange(value.sourceRange, "sourceRange", true, problems);
|
|
@@ -2188,40 +2216,38 @@ function validateSegmentRanges(value, problems) {
|
|
|
2188
2216
|
if (typeof entry.startMs === "number" && Number.isFinite(entry.startMs) && typeof entry.endMs === "number" && Number.isFinite(entry.endMs) && entry.startMs > entry.endMs) problems.push(`segmentRanges[${index}].startMs must not exceed segmentRanges[${index}].endMs`);
|
|
2189
2217
|
}
|
|
2190
2218
|
}
|
|
2219
|
+
/** The Asset entity is the locator; it states where the bytes live and nothing else. */
|
|
2191
2220
|
function validateAssetPayload(value, problems) {
|
|
2192
|
-
|
|
2193
|
-
|
|
2221
|
+
if (value.system !== "memota" && value.system !== "memota-speech") problems.push("system must be \"memota\" or \"memota-speech\"");
|
|
2222
|
+
if (typeof value.key !== "string" || value.key.trim() === "") problems.push("key must be a non-empty string");
|
|
2194
2223
|
if (value.inline !== void 0) problems.push("inline is not a physical location; text is domain content owned by its AudioScript");
|
|
2195
|
-
}
|
|
2196
|
-
/**
|
|
2197
|
-
* Media variants own their Asset locator directly: `external` is required and
|
|
2198
|
-
* textual domain content is owned by AudioScript; the physical-location
|
|
2199
|
-
* vocabularies cannot mix.
|
|
2200
|
-
*/
|
|
2201
|
-
function validateMediaAssetFields(value, problems) {
|
|
2202
|
-
if (value.external === void 0) problems.push("external is required; media variants own their Asset identity");
|
|
2203
|
-
else validateExternalLocator(value, problems);
|
|
2204
2224
|
validateStorageKey(value, problems);
|
|
2205
|
-
if (value.inline !== void 0) problems.push("media variants use external locators, not inline domain text");
|
|
2206
|
-
}
|
|
2207
|
-
function validateExternalLocator(value, problems) {
|
|
2208
|
-
const external = value.external;
|
|
2209
|
-
if (external === void 0) return;
|
|
2210
|
-
if (!isRecord(external)) {
|
|
2211
|
-
problems.push("external must be an object when present");
|
|
2212
|
-
return;
|
|
2213
|
-
}
|
|
2214
|
-
if (external.system !== "memota" && external.system !== "memota-speech") problems.push("external.system must be \"memota\" or \"memota-speech\"");
|
|
2215
|
-
if (typeof external.key !== "string" || external.key.trim() === "") problems.push("external.key must be a non-empty string");
|
|
2216
2225
|
}
|
|
2217
2226
|
function validateStorageKey(value, problems) {
|
|
2218
2227
|
if (value.storageKey !== void 0 && (typeof value.storageKey !== "string" || value.storageKey.trim() === "")) problems.push("storageKey must be a non-empty string when present");
|
|
2219
2228
|
}
|
|
2229
|
+
/**
|
|
2230
|
+
* Voice is a timbre identity, not playable content: it owns the voice-library
|
|
2231
|
+
* descriptor and nothing that would make it look like media or a Sequence. The
|
|
2232
|
+
* rendered voiceover is an `audio` entity bound by a `voice-timbre` Relation.
|
|
2233
|
+
*/
|
|
2220
2234
|
function validateVoicePayload(value, problems) {
|
|
2235
|
+
for (const key of [
|
|
2236
|
+
"durationMs",
|
|
2237
|
+
"extent",
|
|
2238
|
+
"sampling",
|
|
2239
|
+
"coordinateSpace",
|
|
2240
|
+
"system",
|
|
2241
|
+
"key",
|
|
2242
|
+
"storageKey"
|
|
2243
|
+
]) if (Object.hasOwn(value, key)) problems.push(`${key} belongs to the rendered Audio, not to the Voice identity`);
|
|
2221
2244
|
const voice = value.voice;
|
|
2222
|
-
if (voice === void 0)
|
|
2245
|
+
if (voice === void 0) {
|
|
2246
|
+
problems.push("voice is required; a Voice entity is its voice-library identity");
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2223
2249
|
if (!isRecord(voice)) {
|
|
2224
|
-
problems.push("voice must be an object
|
|
2250
|
+
problems.push("voice must be an object");
|
|
2225
2251
|
return;
|
|
2226
2252
|
}
|
|
2227
2253
|
if (voice.system !== "voice-library") problems.push("voice.system must be \"voice-library\"");
|
|
@@ -2229,7 +2255,6 @@ function validateVoicePayload(value, problems) {
|
|
|
2229
2255
|
if (voice.name !== void 0 && typeof voice.name !== "string") problems.push("voice.name must be a string when present");
|
|
2230
2256
|
}
|
|
2231
2257
|
function validateCaptionPayload(value, problems) {
|
|
2232
|
-
validateExternalLocator(value, problems);
|
|
2233
2258
|
if (value.segmentRanges !== void 0) validateSegmentRanges(value.segmentRanges, problems);
|
|
2234
2259
|
if (Object.hasOwn(value, "selections")) problems.push("selections is forbidden; Caption requires one selection object");
|
|
2235
2260
|
const selection = value.selection;
|
|
@@ -2353,15 +2378,13 @@ function isOwnedLocalEntityValuePath(entityKind, path) {
|
|
|
2353
2378
|
if ([
|
|
2354
2379
|
"video",
|
|
2355
2380
|
"audio",
|
|
2356
|
-
"voice",
|
|
2357
2381
|
"image",
|
|
2358
2382
|
"caption",
|
|
2359
2383
|
"axvideo"
|
|
2360
|
-
].includes(entityKind) && /^
|
|
2384
|
+
].includes(entityKind) && /^durationMs$/.test(path)) return true;
|
|
2361
2385
|
if (entityKind === "sequence-marker" && /^(?:durationPolicy|duration\.mode|timeRemapping\.(?:kind|mode))$/.test(path)) return true;
|
|
2362
2386
|
if ((entityKind === "sequence-marker" || entityKind === "caption") && /^segmentRanges\[\d+\]\.segmentId$/.test(path)) return true;
|
|
2363
|
-
if (entityKind === "asset" && /^(?:
|
|
2364
|
-
if (isMediaAssetVariantKind(entityKind) && /^(?:external\.(?:system|key)|storageKey)$/.test(path)) return true;
|
|
2387
|
+
if (entityKind === "asset" && /^(?:system|key|storageKey)$/.test(path)) return true;
|
|
2365
2388
|
if (entityKind === "voice" && /^voice\.(?:system|key|name)$/.test(path)) return true;
|
|
2366
2389
|
if (entityKind === "caption" && (path.startsWith("style.") || path === "selection.segmentId")) return true;
|
|
2367
2390
|
if ([
|
|
@@ -2416,19 +2439,16 @@ function collectRelatedEntities(entityRefs, index) {
|
|
|
2416
2439
|
issues
|
|
2417
2440
|
};
|
|
2418
2441
|
}
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
if (kind === "
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
extent: "bounded",
|
|
2430
|
-
sampling: "derived"
|
|
2431
|
-
};
|
|
2442
|
+
/**
|
|
2443
|
+
* What a kind must state about its own length. An AXVideo is absent here on
|
|
2444
|
+
* purpose: its Timeline decides its length, so it stores none.
|
|
2445
|
+
*/
|
|
2446
|
+
function expectedDurationShape(kind) {
|
|
2447
|
+
if (kind === "video" || kind === "audio" || kind === "caption") return "own";
|
|
2448
|
+
if (kind === "image") return "none";
|
|
2449
|
+
}
|
|
2450
|
+
function isPositiveInteger(value) {
|
|
2451
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
2432
2452
|
}
|
|
2433
2453
|
function ofKind(relations, kind) {
|
|
2434
2454
|
return relations.filter((relation) => relation.kind === kind);
|
|
@@ -2445,9 +2465,38 @@ const markerContentRelationSpec = Object.freeze({
|
|
|
2445
2465
|
validateEndpoints: (endpoints) => hasMarkerAndSequence(endpoints),
|
|
2446
2466
|
validateMetadata: isEmptyMetadata
|
|
2447
2467
|
});
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2468
|
+
/**
|
|
2469
|
+
* `from-asset(entity, asset)` — the entity was made from that stored resource.
|
|
2470
|
+
*
|
|
2471
|
+
* Eligibility is declared: the non-Asset endpoint must carry the `MediaFile` kind,
|
|
2472
|
+
* which says an entity of that kind can be bound to stored bytes and asks
|
|
2473
|
+
* nothing of its payload. The bytes stay named in exactly one place, so no
|
|
2474
|
+
* entity copies the locator into its own row and one Asset can back several
|
|
2475
|
+
* entities.
|
|
2476
|
+
*/
|
|
2477
|
+
const fromAssetRelationSpec = Object.freeze({
|
|
2478
|
+
kind: "from-asset",
|
|
2479
|
+
validateEndpoints: (endpoints) => hasExactlyOneAsset(endpoints),
|
|
2480
|
+
validateMetadata: isAssetBinding
|
|
2481
|
+
});
|
|
2482
|
+
/**
|
|
2483
|
+
* `to-asset(entity, asset)` — the entity's content was written out to that
|
|
2484
|
+
* stored resource.
|
|
2485
|
+
*
|
|
2486
|
+
* The mirror of `from-asset`, and deliberately a separate relation kind rather
|
|
2487
|
+
* than a second declared kind: eligibility is the same either way, but which
|
|
2488
|
+
* way the content flowed is what says whether an entity may still be edited.
|
|
2489
|
+
* An entity reached by `from-asset` was produced from bytes it does not own,
|
|
2490
|
+
* so changing it would contradict them. An entity with a `to-asset` owns its
|
|
2491
|
+
* content and the resource is a snapshot of it, which is what goes stale and
|
|
2492
|
+
* gets written again. A transcript that is later edited carries both at once,
|
|
2493
|
+
* and only the distinct relation kinds keep them apart.
|
|
2494
|
+
*
|
|
2495
|
+
* Writing a snapshot is host assembly; nothing a script authors creates one.
|
|
2496
|
+
*/
|
|
2497
|
+
const toAssetRelationSpec = Object.freeze({
|
|
2498
|
+
kind: "to-asset",
|
|
2499
|
+
validateEndpoints: (endpoints) => hasExactlyOneAsset(endpoints),
|
|
2451
2500
|
validateMetadata: isAssetBinding
|
|
2452
2501
|
});
|
|
2453
2502
|
/** `generated(output, input)` means endpoint 0 was generated from endpoint 1. */
|
|
@@ -2458,7 +2507,7 @@ const generatedRelationSpec = Object.freeze({
|
|
|
2458
2507
|
});
|
|
2459
2508
|
const captionAlignmentRelationSpec = Object.freeze({
|
|
2460
2509
|
kind: "caption-alignment",
|
|
2461
|
-
validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["caption"]), new Set(["
|
|
2510
|
+
validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["caption"]), new Set(["voiceover"])),
|
|
2462
2511
|
validateMetadata: isCaptionAlignmentMetadata
|
|
2463
2512
|
});
|
|
2464
2513
|
/** `clip-anchor(child, host)` means endpoint 0 follows endpoint 1. */
|
|
@@ -2467,10 +2516,27 @@ const clipAnchorRelationSpec = Object.freeze({
|
|
|
2467
2516
|
validateEndpoints: (endpoints) => endpoints[0].current().entityKind === "clip" && endpoints[1].current().entityKind === "clip",
|
|
2468
2517
|
validateMetadata: isEmptyMetadata
|
|
2469
2518
|
});
|
|
2470
|
-
/**
|
|
2519
|
+
/**
|
|
2520
|
+
* The Voiceover and the PhoneticScript it was spoken from; kinds determine
|
|
2521
|
+
* roles regardless of endpoint positions.
|
|
2522
|
+
*
|
|
2523
|
+
* Every Voiceover must carry one: which words were said, and how, is the only
|
|
2524
|
+
* thing that makes speech reproducible, and a Voiceover that cannot answer it
|
|
2525
|
+
* is refused when its plan is built.
|
|
2526
|
+
*/
|
|
2471
2527
|
const phoneticScriptRenderRelationSpec = Object.freeze({
|
|
2472
2528
|
kind: "phonetic-script-render",
|
|
2473
|
-
validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["
|
|
2529
|
+
validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["voiceover"]), new Set(["phonetic-script"])),
|
|
2530
|
+
validateMetadata: isEmptyMetadata
|
|
2531
|
+
});
|
|
2532
|
+
/**
|
|
2533
|
+
* The timbre identity a Voiceover was synthesized with. Voice stays an
|
|
2534
|
+
* identity: it never carries the audio itself, so the link between the two is a
|
|
2535
|
+
* Relation rather than one shared entity, and several takes can name one voice.
|
|
2536
|
+
*/
|
|
2537
|
+
const voiceTimbreRelationSpec = Object.freeze({
|
|
2538
|
+
kind: "voice-timbre",
|
|
2539
|
+
validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["voiceover"]), new Set(["voice"])),
|
|
2474
2540
|
validateMetadata: isEmptyMetadata
|
|
2475
2541
|
});
|
|
2476
2542
|
/** AudioScript was transcribed from Audio, Video, or recorded Voice; kinds determine roles. */
|
|
@@ -2478,8 +2544,8 @@ const audioScriptSourceRelationSpec = Object.freeze({
|
|
|
2478
2544
|
kind: "audio-script-source",
|
|
2479
2545
|
validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["audio-script"]), new Set([
|
|
2480
2546
|
"audio",
|
|
2481
|
-
"
|
|
2482
|
-
"
|
|
2547
|
+
"voiceover",
|
|
2548
|
+
"video"
|
|
2483
2549
|
])),
|
|
2484
2550
|
validateMetadata: isEmptyMetadata
|
|
2485
2551
|
});
|
|
@@ -2502,11 +2568,13 @@ const builtInRelationSpecs = Object.freeze([
|
|
|
2502
2568
|
markerContentRelationSpec,
|
|
2503
2569
|
axVideoMarkerRelationSpec,
|
|
2504
2570
|
markerTimelineRelationSpec,
|
|
2505
|
-
|
|
2571
|
+
fromAssetRelationSpec,
|
|
2572
|
+
toAssetRelationSpec,
|
|
2506
2573
|
generatedRelationSpec,
|
|
2507
2574
|
captionAlignmentRelationSpec,
|
|
2508
2575
|
clipAnchorRelationSpec,
|
|
2509
2576
|
phoneticScriptRenderRelationSpec,
|
|
2577
|
+
voiceTimbreRelationSpec,
|
|
2510
2578
|
audioScriptSourceRelationSpec,
|
|
2511
2579
|
audioScriptMarkerRelationSpec
|
|
2512
2580
|
]);
|
|
@@ -2530,13 +2598,15 @@ function hasMarkerAndSequence(endpoints) {
|
|
|
2530
2598
|
const second = endpoints[1].current();
|
|
2531
2599
|
return first.entityKind === "sequence-marker" && hasSequence(second) || second.entityKind === "sequence-marker" && hasSequence(first);
|
|
2532
2600
|
}
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
const
|
|
2536
|
-
|
|
2601
|
+
/** Exactly one end is the Asset, so the other end is unambiguously the bound entity. */
|
|
2602
|
+
function hasExactlyOneAsset(endpoints) {
|
|
2603
|
+
const kinds = endpoints.map((endpoint) => endpoint.current().entityKind);
|
|
2604
|
+
const assets = kinds.map((kind) => kind === "asset");
|
|
2605
|
+
if (assets[0] === assets[1]) return false;
|
|
2606
|
+
return declaresKind(assets[0] ? kinds[1] : kinds[0], "MediaFile");
|
|
2537
2607
|
}
|
|
2538
2608
|
function isGeneratedMedia(entity) {
|
|
2539
|
-
return entity.entityKind === "video" || entity.entityKind === "image" || entity.entityKind === "audio" || entity.entityKind === "
|
|
2609
|
+
return entity.entityKind === "video" || entity.entityKind === "image" || entity.entityKind === "audio" || entity.entityKind === "voiceover";
|
|
2540
2610
|
}
|
|
2541
2611
|
function isEmptyMetadata(value) {
|
|
2542
2612
|
return isJsonObject(value) && Object.keys(value).length === 0;
|
|
@@ -2725,7 +2795,6 @@ var InvalidEntityRelationRowsError = class extends Error {
|
|
|
2725
2795
|
this.name = "InvalidEntityRelationRowsError";
|
|
2726
2796
|
}
|
|
2727
2797
|
};
|
|
2728
|
-
/** Decodes database rows into flat entities and validates the complete relation set. */
|
|
2729
2798
|
function decodeEntityRelationRows(rows, options) {
|
|
2730
2799
|
const issues = [];
|
|
2731
2800
|
const entitiesById = /* @__PURE__ */ new Map();
|
|
@@ -2966,9 +3035,7 @@ function resolveGraphLayout(graph) {
|
|
|
2966
3035
|
if (clip.entityKind !== "clip" || graph.adjacent(clip.entityId, "track-clip").length !== 1) fail(`Clip ${clip.entityId} must belong to exactly one Track`);
|
|
2967
3036
|
const marker = graph.one(clip.entityId, "clip-marker", "sequence-marker");
|
|
2968
3037
|
const content = graph.one(marker.entityId, "marker-content");
|
|
2969
|
-
if (!(typedRole === "video_clip" ? ["image", "video"] : typedRole === "speech" ? ["
|
|
2970
|
-
const space = content.payload.coordinateSpace;
|
|
2971
|
-
if (space !== "ms" && space !== "milliseconds" && (!object(space) || space.unit !== "ms" && space.unit !== "milliseconds")) fail(`media ${content.entityId} must state millisecond coordinates for this reader`);
|
|
3038
|
+
if (!(typedRole === "video_clip" ? ["image", "video"] : typedRole === "speech" ? ["voiceover"] : typedRole === "caption" ? ["caption"] : ["audio"]).includes(content.entityKind)) fail(`unsupported compatibility content kind ${content.entityKind} on ${typedRole} Track`);
|
|
2972
3039
|
const source = range(marker.payload.sourceRange, "sourceRange");
|
|
2973
3040
|
const durationFact = marker.payload.duration;
|
|
2974
3041
|
if (!object(durationFact)) fail(`Marker ${marker.entityId} requires duration`);
|
|
@@ -3045,7 +3112,7 @@ function resolveGraphLayout(graph) {
|
|
|
3045
3112
|
let start = fact.absoluteStart ?? 0;
|
|
3046
3113
|
if (fact.mode === "anchored") {
|
|
3047
3114
|
const host = resolve(fact.anchorId);
|
|
3048
|
-
if (fact.role === "speech" && !["video_clip", "bgm"].includes(facts.get(host.clipEntityId).role)) fail("An anchored
|
|
3115
|
+
if (fact.role === "speech" && !["video_clip", "bgm"].includes(facts.get(host.clipEntityId).role)) fail("An anchored voiceover must use a visual or BGM Clip as its host");
|
|
3049
3116
|
start = host.start + fact.anchorOffset;
|
|
3050
3117
|
} else if (fact.predecessor !== void 0) {
|
|
3051
3118
|
const prior = resolve(fact.predecessor);
|
|
@@ -3094,7 +3161,7 @@ function resolveGraphLayout(graph) {
|
|
|
3094
3161
|
* consulted as an editing fact. Peer IDs appear only in this derived read
|
|
3095
3162
|
* contract, reconstructed from relations; they are not stored in payloads.
|
|
3096
3163
|
*/
|
|
3097
|
-
function projectEntityTimeline(rows
|
|
3164
|
+
function projectEntityTimeline(rows) {
|
|
3098
3165
|
const graph = new EntityProjectionGraph(rows);
|
|
3099
3166
|
const layout = resolveGraphLayout(graph);
|
|
3100
3167
|
for (const track of layout.tracks) {
|
|
@@ -3129,8 +3196,11 @@ function projectEntityTimeline(rows, meta) {
|
|
|
3129
3196
|
const asset = mediaAssetFacts(graph, content);
|
|
3130
3197
|
requireUntrimmedAudio(content, marker, placement);
|
|
3131
3198
|
const external = externalLocator(asset, "memota-speech");
|
|
3132
|
-
const
|
|
3133
|
-
const
|
|
3199
|
+
const renderEdges = graph.adjacent(content.entityId, "phonetic-script-render");
|
|
3200
|
+
const timbreEdges = graph.adjacent(content.entityId, "voice-timbre");
|
|
3201
|
+
const assembled = renderEdges.length > 0 || timbreEdges.length > 0 ? assemblePhoneticScriptContent(rows, graph.one(content.entityId, "phonetic-script-render", "phonetic-script").entityId) : void 0;
|
|
3202
|
+
if (timbreEdges.length > 1) fail("A voiceover resolves at most one Voice identity");
|
|
3203
|
+
const voice = timbreEdges.length === 0 ? void 0 : graph.one(content.entityId, "voice-timbre", "voice").payload.voice;
|
|
3134
3204
|
if (voice !== void 0 && (!object(voice) || voice.system !== "voice-library" || typeof voice.key !== "string")) fail("Voice must use an explicit voice-library locator");
|
|
3135
3205
|
const captionIds = layout.tracks.flatMap((candidate) => candidate.role === "caption" ? candidate.clips.filter((caption) => caption.anchorClipEntityId === clip.entityId).map((caption) => caption.clipEntityId) : []);
|
|
3136
3206
|
parts[clip.entityId] = { speech: {
|
|
@@ -3171,8 +3241,7 @@ function projectEntityTimeline(rows, meta) {
|
|
|
3171
3241
|
if (marker.payload.durationPolicy !== "timeline") fail("Background Audio requires an explicit timeline-fill durationPolicy");
|
|
3172
3242
|
if (marker.payload.timeRemapping !== void 0) fail("BGM timeRemapping is not supported by this reader");
|
|
3173
3243
|
const source = range(marker.payload.sourceRange, "sourceRange");
|
|
3174
|
-
|
|
3175
|
-
if (!object(extent) || extent.start !== source.start || extent.end !== source.end) fail("Background Audio compatibility requires the complete source");
|
|
3244
|
+
if (source.start !== 0 || content.payload.durationMs !== source.end) fail("Background Audio compatibility requires the complete source");
|
|
3176
3245
|
parts[clip.entityId] = { bgm: {
|
|
3177
3246
|
id: clip.entityId,
|
|
3178
3247
|
kind: "bgm",
|
|
@@ -3183,10 +3252,6 @@ function projectEntityTimeline(rows, meta) {
|
|
|
3183
3252
|
}
|
|
3184
3253
|
}
|
|
3185
3254
|
const document = {
|
|
3186
|
-
meta: {
|
|
3187
|
-
...structuredClone(meta),
|
|
3188
|
-
schema_version: ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION
|
|
3189
|
-
},
|
|
3190
3255
|
timeline: graph.timeline.payload.unit_time_ms === void 0 ? void 0 : { unit_time_ms: positive(graph.timeline.payload.unit_time_ms, "unit_time_ms") },
|
|
3191
3256
|
tracks: layout.tracks.map((track) => ({
|
|
3192
3257
|
id: track.entityId,
|
|
@@ -3216,29 +3281,23 @@ function projectEntityTimeline(rows, meta) {
|
|
|
3216
3281
|
function trackRoleOf(layout, trackEntityId) {
|
|
3217
3282
|
return layout.tracks.find((track) => track.entityId === trackEntityId)?.role;
|
|
3218
3283
|
}
|
|
3219
|
-
/**
|
|
3220
|
-
* Media facts are read from the variant itself: a media variant owns its
|
|
3221
|
-
* Asset identity and carries `external`/`storageKey` directly. Dual-entity
|
|
3222
|
-
* media graphs are rejected instead of being resolved through a legacy
|
|
3223
|
-
* `physical-asset` binding.
|
|
3224
|
-
*/
|
|
3284
|
+
/** The Asset a media entity was made from; the bytes are named there alone. */
|
|
3225
3285
|
function mediaAssetFacts(graph, content) {
|
|
3226
|
-
|
|
3227
|
-
if (!object(content.payload.external)) fail("Media " + content.entityId + " must carry its own Asset external identity");
|
|
3286
|
+
const asset = graph.one(content.entityId, "from-asset", "asset");
|
|
3228
3287
|
return {
|
|
3229
|
-
entityId: String(
|
|
3230
|
-
payload:
|
|
3288
|
+
entityId: String(asset.entityId),
|
|
3289
|
+
payload: asset.payload
|
|
3231
3290
|
};
|
|
3232
3291
|
}
|
|
3233
|
-
function externalLocator(asset, system) {
|
|
3234
|
-
const value = asset.payload.external;
|
|
3235
|
-
if (!object(value) || value.system !== system || typeof value.key !== "string" || value.key.trim() === "") fail("Asset " + asset.entityId + " requires an explicit " + system + " external key");
|
|
3236
|
-
return { key: value.key };
|
|
3237
|
-
}
|
|
3238
3292
|
function storageKey(asset) {
|
|
3239
3293
|
if (typeof asset.payload.storageKey !== "string" || asset.payload.storageKey.trim() === "") fail("Asset " + asset.entityId + " requires its factual storageKey");
|
|
3240
3294
|
return asset.payload.storageKey;
|
|
3241
3295
|
}
|
|
3296
|
+
function externalLocator(asset, system) {
|
|
3297
|
+
const { system: found, key } = asset.payload;
|
|
3298
|
+
if (found !== system || typeof key !== "string" || key.trim() === "") fail("Asset " + asset.entityId + " requires an explicit " + system + " key");
|
|
3299
|
+
return { key };
|
|
3300
|
+
}
|
|
3242
3301
|
function captionStyle(value) {
|
|
3243
3302
|
if (value === void 0) return void 0;
|
|
3244
3303
|
if (!object(value)) fail("Caption style must be an object");
|
|
@@ -3270,8 +3329,7 @@ function captionStyle(value) {
|
|
|
3270
3329
|
}
|
|
3271
3330
|
function requireUntrimmedAudio(content, marker, placement) {
|
|
3272
3331
|
const source = range(marker.payload.sourceRange, "sourceRange");
|
|
3273
|
-
|
|
3274
|
-
if (!object(extent) || extent.start !== source.start || extent.end !== source.end || placement.duration !== source.end - source.start || marker.payload.timeRemapping !== void 0) fail("Voice compatibility requires the complete, unremapped take");
|
|
3332
|
+
if (source.start !== 0 || content.payload.durationMs !== source.end || placement.duration !== source.end || marker.payload.timeRemapping !== void 0) fail("Voiceover compatibility requires the complete, unremapped take");
|
|
3275
3333
|
}
|
|
3276
3334
|
function videoSpeed(content, marker, placement) {
|
|
3277
3335
|
const remapping = marker.payload.timeRemapping;
|
|
@@ -3329,12 +3387,11 @@ const atomicFields = new Set([
|
|
|
3329
3387
|
"sourceRange",
|
|
3330
3388
|
"targetRange",
|
|
3331
3389
|
"duration",
|
|
3332
|
-
"
|
|
3390
|
+
"durationMs",
|
|
3333
3391
|
"timeRemapping",
|
|
3334
3392
|
"textRange",
|
|
3335
3393
|
"selection",
|
|
3336
|
-
"external"
|
|
3337
|
-
"coordinateSpace"
|
|
3394
|
+
"external"
|
|
3338
3395
|
]);
|
|
3339
3396
|
const textFields = new Set(["text", "phonemeScript"]);
|
|
3340
3397
|
const orderedFields = new Set([
|
|
@@ -3561,6 +3618,73 @@ function applyNativeListChange(list, change) {
|
|
|
3561
3618
|
}
|
|
3562
3619
|
}
|
|
3563
3620
|
//#endregion
|
|
3621
|
+
//#region src/entity-crdt/audio-script-assets.ts
|
|
3622
|
+
/** A resource is a snapshot of text; the native Loro text remains the editing authority. */
|
|
3623
|
+
function audioScriptAssetContent(payload) {
|
|
3624
|
+
assembleScriptText({
|
|
3625
|
+
entityId: createEntityId("script-validation"),
|
|
3626
|
+
entityKind: "audio-script",
|
|
3627
|
+
payload
|
|
3628
|
+
});
|
|
3629
|
+
return { segments: structuredClone(payload.segments) };
|
|
3630
|
+
}
|
|
3631
|
+
function audioScriptAssetHash(payload) {
|
|
3632
|
+
return bytesToHex(sha256(new TextEncoder().encode(canonicalJson(audioScriptAssetContent(payload)))));
|
|
3633
|
+
}
|
|
3634
|
+
/**
|
|
3635
|
+
* The Asset a script was written out to. It is a snapshot the script owns, so
|
|
3636
|
+
* it is reached by `to-asset` — not `from-asset`, which would say the script
|
|
3637
|
+
* was produced from bytes it cannot change.
|
|
3638
|
+
*/
|
|
3639
|
+
function audioScriptAssetOf(rows, entityId) {
|
|
3640
|
+
return boundAsset(rows, entityId, "to-asset");
|
|
3641
|
+
}
|
|
3642
|
+
/**
|
|
3643
|
+
* The Asset a script was made from — a transcript it does not own. A script
|
|
3644
|
+
* that has one never writes a snapshot of its own: the bytes it would describe
|
|
3645
|
+
* are already named, and rewriting them would contradict their source.
|
|
3646
|
+
*/
|
|
3647
|
+
function scriptSourceAssetOf(rows, entityId) {
|
|
3648
|
+
return boundAsset(rows, entityId, "from-asset");
|
|
3649
|
+
}
|
|
3650
|
+
function boundAsset(rows, entityId, relationKind) {
|
|
3651
|
+
const link = rows.relations.find((relation) => relation.relationKind === relationKind && (String(relation.endpoint0EntityId) === entityId || String(relation.endpoint1EntityId) === entityId));
|
|
3652
|
+
if (link === void 0) return void 0;
|
|
3653
|
+
const otherId = String(link.endpoint0EntityId) === entityId ? link.endpoint1EntityId : link.endpoint0EntityId;
|
|
3654
|
+
return rows.entities.find((row) => String(row.entityId) === String(otherId) && row.entityKind === "asset");
|
|
3655
|
+
}
|
|
3656
|
+
/** Internal fingerprint prevents a resource from silently describing an older text revision. */
|
|
3657
|
+
function audioScriptAssetFields(payload) {
|
|
3658
|
+
return { assetContentHash: audioScriptAssetHash(payload) };
|
|
3659
|
+
}
|
|
3660
|
+
/**
|
|
3661
|
+
* What a PhoneticScript hands to synthesis: the base AudioScript's text as the
|
|
3662
|
+
* variant pronounces it, plus the variant's own phoneme and prosody control.
|
|
3663
|
+
*
|
|
3664
|
+
* The variant's content is written by a model — deciding how words are said is
|
|
3665
|
+
* text work, not a mechanical copy — but the bytes a synthesizer reads have to
|
|
3666
|
+
* leave the entity graph to reach it, because synthesis does not handle
|
|
3667
|
+
* entities. Writing this resource down is that crossing, and it is why speech
|
|
3668
|
+
* can only come from a PhoneticScript.
|
|
3669
|
+
*/
|
|
3670
|
+
function phoneticScriptAssetContent(rows, entityId) {
|
|
3671
|
+
const assembled = assemblePhoneticScriptContent(rows, createEntityId(entityId));
|
|
3672
|
+
const own = assembled.phoneticScript.payload;
|
|
3673
|
+
return {
|
|
3674
|
+
text: assembled.text,
|
|
3675
|
+
segments: structuredClone(assembled.segments),
|
|
3676
|
+
...typeof own.phonemeScript === "string" ? { phonemeScript: own.phonemeScript } : {},
|
|
3677
|
+
...isJsonObject(own.prosody) ? { prosody: structuredClone(own.prosody) } : {}
|
|
3678
|
+
};
|
|
3679
|
+
}
|
|
3680
|
+
function phoneticScriptAssetHash(rows, entityId) {
|
|
3681
|
+
return bytesToHex(sha256(new TextEncoder().encode(canonicalJson(phoneticScriptAssetContent(rows, entityId)))));
|
|
3682
|
+
}
|
|
3683
|
+
/** The Asset a PhoneticScript was written out to, read from the graph. */
|
|
3684
|
+
function phoneticScriptAssetOf(rows, entityId) {
|
|
3685
|
+
return audioScriptAssetOf(rows, entityId);
|
|
3686
|
+
}
|
|
3687
|
+
//#endregion
|
|
3564
3688
|
//#region src/entity-crdt/ordering.ts
|
|
3565
3689
|
/** Domain order is scoped by membership; relation storage has no global order. */
|
|
3566
3690
|
function entityOrderGroups(rows) {
|
|
@@ -3816,6 +3940,10 @@ var LoroEntityDocument = class LoroEntityDocument {
|
|
|
3816
3940
|
if (id === void 0) return [];
|
|
3817
3941
|
return this.snapshot().rows.relations.filter((row) => row.endpoint0EntityId === id || row.endpoint1EntityId === id);
|
|
3818
3942
|
}
|
|
3943
|
+
/** Resolve a queued edit target through its native identity without changing history. */
|
|
3944
|
+
resolveCurrentEntityId(id) {
|
|
3945
|
+
return currentId(this.doc, id);
|
|
3946
|
+
}
|
|
3819
3947
|
getVersion(id) {
|
|
3820
3948
|
const entity = section(this.doc, "entities").get(id);
|
|
3821
3949
|
if (!isJsonObject(entity)) return void 0;
|
|
@@ -3856,6 +3984,52 @@ var LoroEntityDocument = class LoroEntityDocument {
|
|
|
3856
3984
|
materialize(this.doc);
|
|
3857
3985
|
this.doc.commit({ origin: MATERIALIZE_ORIGIN });
|
|
3858
3986
|
}
|
|
3987
|
+
/**
|
|
3988
|
+
* Bind a saved snapshot without rewriting text operations or resolving
|
|
3989
|
+
* selection conflicts. The script names the bytes the same way everything
|
|
3990
|
+
* else does — an Asset entity plus a Relation — and keeps only the
|
|
3991
|
+
* fingerprint that tells a stale snapshot from a current one. The Relation is
|
|
3992
|
+
* `to-asset`: these bytes are the script's own content written out, not a
|
|
3993
|
+
* source it was made from.
|
|
3994
|
+
*/
|
|
3995
|
+
bindAudioScriptAsset(entityId, assetId, expectedContentHash) {
|
|
3996
|
+
const key = family(this.doc, entityId);
|
|
3997
|
+
const map = workspace(this.doc, key);
|
|
3998
|
+
if (map.get("kind") !== "audio-script" || map.get("deleted") === true) throw new Error("Resource binding requires an active AudioScript");
|
|
3999
|
+
const payload = entityContent(this.doc, key);
|
|
4000
|
+
if (audioScriptAssetHash(payload) !== expectedContentHash) throw new Error("AudioScript changed while saving its resource");
|
|
4001
|
+
if (!assetId.trim()) throw new Error("AudioScript resource writer returned an empty asset ID");
|
|
4002
|
+
const before = this.doc.oplogVersion();
|
|
4003
|
+
const existing = readEntityDocument(this.doc, true).rows.entities.find((row) => row.entityKind === "asset" && row.payload.system === "memota" && row.payload.key === assetId);
|
|
4004
|
+
const assetEntityId = existing?.entityId ?? createEntityId(`asset_${assetId}`);
|
|
4005
|
+
if (existing === void 0) this.transact((draft) => draft.create({
|
|
4006
|
+
entityId: assetEntityId,
|
|
4007
|
+
entityKind: "asset",
|
|
4008
|
+
payload: {
|
|
4009
|
+
system: "memota",
|
|
4010
|
+
key: assetId
|
|
4011
|
+
}
|
|
4012
|
+
}));
|
|
4013
|
+
this.transact((draft) => {
|
|
4014
|
+
const current = readEntityDocument(this.doc, true).rows;
|
|
4015
|
+
if (audioScriptAssetOf(current, entityId)?.entityId !== assetEntityId) {
|
|
4016
|
+
for (const relation of current.relations) if (relation.relationKind === "to-asset" && (String(relation.endpoint0EntityId) === String(entityId) || String(relation.endpoint1EntityId) === String(entityId))) draft.unlink(String(relation.relationId));
|
|
4017
|
+
draft.link({
|
|
4018
|
+
relationId: createRelationId(`relation_to-asset_${entityId}_${assetId}`),
|
|
4019
|
+
relationKind: "to-asset",
|
|
4020
|
+
endpoint0EntityId: createEntityId(entityId),
|
|
4021
|
+
endpoint1EntityId: assetEntityId,
|
|
4022
|
+
metadata: {},
|
|
4023
|
+
trace: {}
|
|
4024
|
+
});
|
|
4025
|
+
}
|
|
4026
|
+
draft.declare(entityId, audioScriptAssetFields(payload));
|
|
4027
|
+
});
|
|
4028
|
+
return this.doc.export({
|
|
4029
|
+
mode: "update",
|
|
4030
|
+
from: before
|
|
4031
|
+
});
|
|
4032
|
+
}
|
|
3859
4033
|
canUndo() {
|
|
3860
4034
|
return this.undoManager.canUndo();
|
|
3861
4035
|
}
|
|
@@ -3869,12 +4043,12 @@ var LoroEntityDocument = class LoroEntityDocument {
|
|
|
3869
4043
|
return this.historyEdit(() => this.undoManager.redo());
|
|
3870
4044
|
}
|
|
3871
4045
|
historyEdit(run) {
|
|
3872
|
-
const before = this.snapshot();
|
|
4046
|
+
const before = new Map(this.snapshot().rows.entities.map((row) => [family(this.doc, row.entityId), row]));
|
|
3873
4047
|
if (!run()) return false;
|
|
3874
4048
|
const after = this.snapshot();
|
|
3875
4049
|
for (const row of after.rows.entities) {
|
|
3876
4050
|
const id = family(this.doc, row.entityId);
|
|
3877
|
-
const previous = before.
|
|
4051
|
+
const previous = before.get(id);
|
|
3878
4052
|
if (previous && !equalJson(owned(previous.payload), owned(row.payload))) workspace(this.doc, id).ensureMergeableMap("changes").set(globalThis.crypto.randomUUID(), true);
|
|
3879
4053
|
}
|
|
3880
4054
|
materialize(this.doc);
|
|
@@ -4197,8 +4371,8 @@ function assertState(state) {
|
|
|
4197
4371
|
const ids = new Set(state.rows.entities.map((row) => String(row.entityId)));
|
|
4198
4372
|
for (const row of state.rows.relations) if (!ids.has(row.endpoint0EntityId) || !ids.has(row.endpoint1EntityId)) throw new Error("Relation has a missing endpoint");
|
|
4199
4373
|
}
|
|
4200
|
-
function materialize(doc) {
|
|
4201
|
-
const state = readEntityDocument(doc);
|
|
4374
|
+
function materialize(doc, allowSelectionConflicts = false) {
|
|
4375
|
+
const state = readEntityDocument(doc, allowSelectionConflicts);
|
|
4202
4376
|
const byId = section(doc, "entities");
|
|
4203
4377
|
const ids = currentIds(doc);
|
|
4204
4378
|
for (const [source, id] of ids) {
|
|
@@ -4219,13 +4393,37 @@ function materialize(doc) {
|
|
|
4219
4393
|
for (const [slot, id] of Object.entries(state.project)) if (project.get(slot) !== id) project.set(slot, id);
|
|
4220
4394
|
}
|
|
4221
4395
|
//#endregion
|
|
4396
|
+
//#region src/document/document-mutation-guard.ts
|
|
4397
|
+
/**
|
|
4398
|
+
* @internal
|
|
4399
|
+
* Shared by synchronous mutation entry points for one document. The session
|
|
4400
|
+
* closes it before teardown callbacks can attempt another write.
|
|
4401
|
+
*/
|
|
4402
|
+
var DocumentMutationGuard = class {
|
|
4403
|
+
running = false;
|
|
4404
|
+
closed = false;
|
|
4405
|
+
run(mutation) {
|
|
4406
|
+
if (this.closed) throw new Error("mengine document is disposed");
|
|
4407
|
+
if (this.running) throw new Error("mengine document mutation is already running");
|
|
4408
|
+
this.running = true;
|
|
4409
|
+
try {
|
|
4410
|
+
return mutation();
|
|
4411
|
+
} finally {
|
|
4412
|
+
this.running = false;
|
|
4413
|
+
}
|
|
4414
|
+
}
|
|
4415
|
+
close() {
|
|
4416
|
+
this.closed = true;
|
|
4417
|
+
}
|
|
4418
|
+
};
|
|
4419
|
+
//#endregion
|
|
4222
4420
|
//#region src/document/mirror-read.ts
|
|
4223
4421
|
/**
|
|
4224
4422
|
* Project the mirror state (`VideoDocumentDraft`) into the authoritative
|
|
4225
4423
|
* `VideoDocument`. The storage shape is isomorphic to the domain shape (RFC 03
|
|
4226
|
-
* §4, reference/17 §4:
|
|
4424
|
+
* §4, reference/17 §4: timeline + a single `tracks` list + part_library), so this
|
|
4227
4425
|
* is a near-identity — it reads `time_position` / `fallback_abs_ms` JSON blobs
|
|
4228
|
-
* back into structured values and
|
|
4426
|
+
* back into structured values and selects content fields. Legacy meta is ignored.
|
|
4229
4427
|
*
|
|
4230
4428
|
* It maps only authoritative facts (RFC 02 §6): `part_id` + `time_position`. The
|
|
4231
4429
|
* projection-derived `VideoDraft` read-view (absolute time, `part_aggregations`,
|
|
@@ -4242,20 +4440,8 @@ function readVideoDocumentFromDraft(draft) {
|
|
|
4242
4440
|
if (part != null) partLibrary[partId] = part;
|
|
4243
4441
|
}
|
|
4244
4442
|
const timeline = draft.timeline;
|
|
4245
|
-
const hasTimeline = timeline?.unit_time_ms != null;
|
|
4246
|
-
const meta = draft.meta;
|
|
4247
4443
|
return {
|
|
4248
|
-
|
|
4249
|
-
schema_version: meta?.schema_version === "video-document/entity-projection-v1" ? ENTITY_TIMELINE_PROJECTION_SCHEMA_VERSION : VIDEO_DOCUMENT_SCHEMA_VERSION,
|
|
4250
|
-
draft_id: emptyToUndefined(meta?.draft_id),
|
|
4251
|
-
project_id: emptyToUndefined(meta?.project_id),
|
|
4252
|
-
owner_id: emptyToUndefined(meta?.owner_id),
|
|
4253
|
-
thumbnail_storage_key: emptyToUndefined(meta?.thumbnail_storage_key),
|
|
4254
|
-
chat_session_id: emptyToUndefined(meta?.chat_session_id),
|
|
4255
|
-
video_creation_settings: meta?.video_creation_settings ?? void 0,
|
|
4256
|
-
version: meta?.version ?? void 0
|
|
4257
|
-
},
|
|
4258
|
-
timeline: hasTimeline ? { unit_time_ms: timeline.unit_time_ms } : void 0,
|
|
4444
|
+
timeline: timeline?.unit_time_ms != null ? { unit_time_ms: timeline.unit_time_ms } : void 0,
|
|
4259
4445
|
tracks: rowsToTracks(draft.tracks),
|
|
4260
4446
|
part_library: partLibrary
|
|
4261
4447
|
};
|
|
@@ -4279,9 +4465,6 @@ function rowToTrack(row) {
|
|
|
4279
4465
|
})
|
|
4280
4466
|
};
|
|
4281
4467
|
}
|
|
4282
|
-
function emptyToUndefined(value) {
|
|
4283
|
-
return value == null || value === "" ? void 0 : value;
|
|
4284
|
-
}
|
|
4285
4468
|
//#endregion
|
|
4286
4469
|
//#region src/document/mirror-adapter.ts
|
|
4287
4470
|
/**
|
|
@@ -4294,33 +4477,50 @@ function emptyToUndefined(value) {
|
|
|
4294
4477
|
* - `transact(edit, audit)` runs the whole op in one `mirror.setState` callback:
|
|
4295
4478
|
* one diff, one `doc.commit` carrying the audit message. The callback edits an
|
|
4296
4479
|
* immer draft, so a throw inside it discards the draft and never touches Loro
|
|
4297
|
-
* (natural rollback)
|
|
4480
|
+
* (natural rollback) without a document checkout or compensating commit.
|
|
4298
4481
|
* - mirror's `idSelector` (track items keyed by `part_id`) diffs reorders to
|
|
4299
4482
|
* real Loro `move` ops on every lane, so per-item CRDT identity survives on
|
|
4300
4483
|
* main and secondary tracks alike.
|
|
4301
4484
|
*/
|
|
4302
4485
|
var MirrorVideoDocumentAdapter = class {
|
|
4303
4486
|
doc;
|
|
4487
|
+
mutationGuard;
|
|
4304
4488
|
mirror;
|
|
4305
|
-
|
|
4489
|
+
disposed = false;
|
|
4490
|
+
constructor(doc, mutationGuard = new DocumentMutationGuard()) {
|
|
4306
4491
|
this.doc = doc;
|
|
4492
|
+
this.mutationGuard = mutationGuard;
|
|
4307
4493
|
this.mirror = new Mirror({
|
|
4308
4494
|
doc,
|
|
4309
|
-
schema: videoDocumentMirrorSchema
|
|
4495
|
+
schema: videoDocumentMirrorSchema,
|
|
4496
|
+
validateUpdates: false
|
|
4310
4497
|
});
|
|
4311
4498
|
}
|
|
4312
4499
|
snapshot() {
|
|
4313
|
-
|
|
4314
|
-
if (this.
|
|
4315
|
-
return
|
|
4500
|
+
this.assertActive();
|
|
4501
|
+
if (this.isEntityDocument()) return projectEntityTimeline(readEntityDocument(this.doc).rows);
|
|
4502
|
+
return readVideoDocumentFromDraft(this.mirror.getState());
|
|
4316
4503
|
}
|
|
4317
4504
|
/**
|
|
4318
|
-
*
|
|
4319
|
-
*
|
|
4320
|
-
*
|
|
4505
|
+
* Native Entity authority: placement is already solved by the Entity graph.
|
|
4506
|
+
*
|
|
4507
|
+
* The root check goes through `getShallowValue` first because `getMap` creates
|
|
4508
|
+
* the container on access — probing a legacy document for `medeo` would
|
|
4509
|
+
* otherwise mutate it, which a read must never do.
|
|
4321
4510
|
*/
|
|
4322
|
-
|
|
4323
|
-
|
|
4511
|
+
isEntityDocument() {
|
|
4512
|
+
const roots = this.doc.getShallowValue();
|
|
4513
|
+
if (!Object.hasOwn(roots, "medeo")) return false;
|
|
4514
|
+
return this.doc.getMap("medeo").get("schema") === LORO_ENTITY_SCHEMA;
|
|
4515
|
+
}
|
|
4516
|
+
/**
|
|
4517
|
+
* Release mirror subscriptions; do not reuse this adapter afterwards.
|
|
4518
|
+
* The caller still owns the borrowed LoroDoc.
|
|
4519
|
+
*/
|
|
4520
|
+
dispose() {
|
|
4521
|
+
if (this.disposed) return;
|
|
4522
|
+
this.disposed = true;
|
|
4523
|
+
this.mirror.dispose();
|
|
4324
4524
|
}
|
|
4325
4525
|
/**
|
|
4326
4526
|
* Apply one op as a single transaction. `edit` mutates the immer draft; mirror
|
|
@@ -4329,80 +4529,71 @@ var MirrorVideoDocumentAdapter = class {
|
|
|
4329
4529
|
* skips the commit — matching the prior "empty op leaves no audit" behavior.
|
|
4330
4530
|
*/
|
|
4331
4531
|
transact(edit, audit) {
|
|
4332
|
-
|
|
4333
|
-
this.
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4532
|
+
this.assertActive();
|
|
4533
|
+
if (this.isEntityDocument()) throw new Error("Entity documents are read-only through this adapter; edit the Medeo entities instead");
|
|
4534
|
+
this.mutationGuard.run(() => {
|
|
4535
|
+
this.mirror.setState((draft) => {
|
|
4536
|
+
edit(draft);
|
|
4537
|
+
const result = validateSchema(videoDocumentMirrorSchema, {
|
|
4538
|
+
timeline: draft.timeline,
|
|
4539
|
+
tracks: draft.tracks,
|
|
4540
|
+
part_library: draft.part_library
|
|
4541
|
+
});
|
|
4542
|
+
if (!result.valid) throw new Error(`Invalid content update: ${result.errors?.join("; ")}`);
|
|
4543
|
+
}, {
|
|
4544
|
+
origin: "mengine.semantic_editor",
|
|
4545
|
+
message: JSON.stringify({
|
|
4546
|
+
semantic_op: audit.kind,
|
|
4547
|
+
payload: audit.payload,
|
|
4548
|
+
intent: audit.intent ?? null,
|
|
4549
|
+
actor: audit.actor ?? null
|
|
4550
|
+
})
|
|
4551
|
+
});
|
|
4343
4552
|
});
|
|
4344
4553
|
}
|
|
4554
|
+
assertActive() {
|
|
4555
|
+
if (this.disposed) throw new Error("mengine document adapter is disposed");
|
|
4556
|
+
}
|
|
4345
4557
|
};
|
|
4346
4558
|
/** Build a fresh Loro doc seeded with `document` through the mirror. */
|
|
4347
4559
|
function createMirrorVideoDocument(document, options = {}) {
|
|
4348
4560
|
assertValidVideoDocument(document);
|
|
4349
4561
|
const doc = new LoroDoc();
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4562
|
+
try {
|
|
4563
|
+
if (options.peerId != null) doc.setPeerId(options.peerId);
|
|
4564
|
+
const mirror = new Mirror({
|
|
4565
|
+
doc,
|
|
4566
|
+
schema: videoDocumentMirrorSchema
|
|
4567
|
+
});
|
|
4568
|
+
try {
|
|
4569
|
+
mirror.setState((draft) => {
|
|
4570
|
+
seedDraft(draft, document);
|
|
4571
|
+
}, { origin: options.origin ?? "mengine.bootstrap" });
|
|
4572
|
+
return doc;
|
|
4573
|
+
} finally {
|
|
4574
|
+
mirror.dispose();
|
|
4575
|
+
}
|
|
4576
|
+
} catch (error) {
|
|
4577
|
+
doc.free();
|
|
4578
|
+
throw error;
|
|
4579
|
+
}
|
|
4358
4580
|
}
|
|
4359
4581
|
/** Build a `MirrorVideoDocumentAdapter` over a fresh doc seeded with `document`. */
|
|
4360
4582
|
function createMirrorVideoDocumentAdapter(document, options) {
|
|
4361
4583
|
return new MirrorVideoDocumentAdapter(createMirrorVideoDocument(document, options));
|
|
4362
4584
|
}
|
|
4363
|
-
/**
|
|
4364
|
-
function
|
|
4365
|
-
if (projection.meta.schema_version !== "video-document/entity-projection-v1") throw new Error("Expected an entity timeline projection");
|
|
4366
|
-
if (!Number.isSafeInteger(entityRevision) || entityRevision < 1) throw new Error("Expected a positive entity revision");
|
|
4367
|
-
assertValidVideoDocument(projection);
|
|
4368
|
-
const from = doc.oplogVersion();
|
|
4369
|
-
new Mirror({
|
|
4370
|
-
doc,
|
|
4371
|
-
schema: videoDocumentMirrorSchema
|
|
4372
|
-
}).setState((draft) => writeVideoDocumentToDraft(draft, {
|
|
4373
|
-
...projection,
|
|
4374
|
-
meta: {
|
|
4375
|
-
...projection.meta,
|
|
4376
|
-
version: entityRevision
|
|
4377
|
-
}
|
|
4378
|
-
}), {
|
|
4379
|
-
origin: "mengine.entity_projection",
|
|
4380
|
-
message: JSON.stringify({
|
|
4381
|
-
semantic_op: "EntityTimelineProjection",
|
|
4382
|
-
entity_revision: entityRevision
|
|
4383
|
-
})
|
|
4384
|
-
});
|
|
4385
|
-
return doc.export({
|
|
4386
|
-
mode: "update",
|
|
4387
|
-
from
|
|
4388
|
-
});
|
|
4389
|
-
}
|
|
4390
|
-
/** Write a whole `VideoDocument` into a draft (seed a fresh doc / plain-memory state). */
|
|
4391
|
-
function writeVideoDocumentToDraft(draft, document) {
|
|
4392
|
-
draft.meta = {
|
|
4393
|
-
schema_version: document.meta.schema_version,
|
|
4394
|
-
draft_id: document.meta.draft_id ?? void 0,
|
|
4395
|
-
project_id: document.meta.project_id ?? void 0,
|
|
4396
|
-
owner_id: document.meta.owner_id ?? void 0,
|
|
4397
|
-
thumbnail_storage_key: document.meta.thumbnail_storage_key ?? void 0,
|
|
4398
|
-
chat_session_id: document.meta.chat_session_id ?? void 0,
|
|
4399
|
-
video_creation_settings: document.meta.video_creation_settings ?? void 0,
|
|
4400
|
-
version: document.meta.version ?? void 0
|
|
4401
|
-
};
|
|
4585
|
+
/** Write a whole `VideoDocument` into the mirror draft (seed a fresh doc / plain-memory state). */
|
|
4586
|
+
function seedDraft(draft, document) {
|
|
4402
4587
|
draft.timeline = { unit_time_ms: document.timeline?.unit_time_ms ?? void 0 };
|
|
4403
4588
|
draft.tracks = (document.tracks ?? []).map(toTrackRow);
|
|
4404
4589
|
draft.part_library = {};
|
|
4405
|
-
for (const [partId, part] of Object.entries(document.part_library ?? {}))
|
|
4590
|
+
for (const [partId, part] of Object.entries(document.part_library ?? {})) {
|
|
4591
|
+
const createdPart = part.caption != null ? { caption: {
|
|
4592
|
+
...part.caption,
|
|
4593
|
+
style: part.caption.style ?? {}
|
|
4594
|
+
} } : part;
|
|
4595
|
+
draft.part_library[partId] = partUnionToDraft(createdPart);
|
|
4596
|
+
}
|
|
4406
4597
|
}
|
|
4407
4598
|
/** Map a domain `Track` to a draft track row (no lane/lane_order — see schema). */
|
|
4408
4599
|
function toTrackRow(track) {
|
|
@@ -4486,10 +4677,12 @@ var PlainMemoryAdapter = class {
|
|
|
4486
4677
|
* use this so every minted id is appended to the current transact's list.
|
|
4487
4678
|
*/
|
|
4488
4679
|
idFactory;
|
|
4680
|
+
readOnly;
|
|
4489
4681
|
constructor(document, options) {
|
|
4490
4682
|
assertValidVideoDocument(document);
|
|
4683
|
+
this.readOnly = options?.readOnly === true;
|
|
4491
4684
|
const draft = {};
|
|
4492
|
-
|
|
4685
|
+
seedDraft(draft, document);
|
|
4493
4686
|
this.state = draft;
|
|
4494
4687
|
this.baseIdFactory = options?.idFactory ?? generatePartId;
|
|
4495
4688
|
this.idFactory = (prefix) => {
|
|
@@ -4502,7 +4695,7 @@ var PlainMemoryAdapter = class {
|
|
|
4502
4695
|
return this._journal;
|
|
4503
4696
|
}
|
|
4504
4697
|
hasContent() {
|
|
4505
|
-
return
|
|
4698
|
+
return this.state.tracks !== void 0 || this.state.timeline !== void 0;
|
|
4506
4699
|
}
|
|
4507
4700
|
snapshot() {
|
|
4508
4701
|
return readVideoDocumentFromDraft(this.state);
|
|
@@ -4513,7 +4706,7 @@ var PlainMemoryAdapter = class {
|
|
|
4513
4706
|
* no structural change — skip journal, matching mirror "no change, no commit".
|
|
4514
4707
|
*/
|
|
4515
4708
|
transact(edit, audit) {
|
|
4516
|
-
if (this.
|
|
4709
|
+
if (this.readOnly) throw new Error("Entity documents are read-only here; edit the Medeo entities instead");
|
|
4517
4710
|
this.pendingIds = [];
|
|
4518
4711
|
try {
|
|
4519
4712
|
const next = produce(this.state, edit);
|
|
@@ -4534,4 +4727,4 @@ function createPlainMemoryAdapter(document, options) {
|
|
|
4534
4727
|
return new PlainMemoryAdapter(document, options);
|
|
4535
4728
|
}
|
|
4536
4729
|
//#endregion
|
|
4537
|
-
export {
|
|
4730
|
+
export { partUnionSchema as $, decodeEntityRelationRows as A, isJsonObject as B, applyFieldChanges as C, recordEntries as Ct, EntityProjectionGraph as D, projectEntityTimeline as E, bytesToBase64 as Et, assembleScriptText as F, buildInitialVideoDocument as G, createRelationId as H, findComposedAudioScript as I, fromVideoDocument as J, buildSpeechHostMap as K, selectAudioScriptSegment as L, assembleCaptionContent as M, assembleEntityContent as N, resolveEntityTimelineLayout as O, assemblePhoneticScriptContent as P, validateVideoDocument as Q, updateEntityFields as R, equalJson as S, partUnionToDraft as St, resolveFieldPath as T, base64ToBytes as Tt, hasSequence as U, createEntityId as V, isMediaAssetVariantKind as W, VideoDocumentValidationError as X, toVideoDocument as Y, assertValidVideoDocument as Z, audioScriptAssetOf as _, partDurationMs as _t, createMirrorVideoDocument as a, laneTrackId as at, phoneticScriptAssetOf as b, effectiveVideoClipDurationMs as bt, DocumentMutationGuard as c, arrangeMainTrackSeamlessly as ct, LoroEntityDraft as d, recalculateTimelineDuration as dt, videoDocumentSchema as et, readEntityDocument as f, resolveAllSpeechOverlaps as ft, audioScriptAssetHash as g, isEmptyVideoClip as gt, audioScriptAssetFields as h, TIMELINE_SKELETON_DURATION_MS as ht, MirrorVideoDocumentAdapter as i, hasResolvedPlacement as it, ScriptCompositionError as j, EntityTimelineProjectionError as k, LORO_ENTITY_SCHEMA as l, fillMainTrackTimeGaps as lt, audioScriptAssetContent as m, syncAggregatedClipsTimePosition as mt, createPlainMemoryAdapter as n, ensureLaneTrack as nt, createMirrorVideoDocumentAdapter as o, solveVideoDocument as ot, entityOrderGroups as p, resolveSpeechOverlapByShiftingVideos as pt, derivePositionFromAbs as q, generatePartId as r, findLaneTrack as rt, readVideoDocumentFromDraft as s, cascadeAfterVideoClipChanges as st, PlainMemoryAdapter as t, LANE_KINDS_IN_STACK_ORDER as tt, LoroEntityDocument as u, reassignSpeechesToVideoClipsByTime as ut, phoneticScriptAssetContent as v, safeDurationMs as vt, assertFieldChanges as w, videoDocumentMirrorSchema as wt, scriptSourceAssetOf as x, speedOf as xt, phoneticScriptAssetHash as y, DEFAULT_UNIT_TIME_MS as yt, variantBaseEntityIds as z };
|