@mengine/medeo-client 2.0.1-alpha.27 → 2.0.1-alpha.29

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.
@@ -1,9 +1,9 @@
1
1
  import { u as volumeSchema } from "./shared-iWnU2osE.js";
2
2
  import { Mirror, schema, validateSchema } from "loro-mirror";
3
3
  import { z } from "zod";
4
- import { LoroDoc, LoroMap, LoroMovableList, LoroText, UndoManager } from "loro-crdt";
5
4
  import { sha256 } from "@noble/hashes/sha2.js";
6
- import { bytesToHex } from "@noble/hashes/utils.js";
5
+ import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
6
+ import { LoroDoc, LoroMap, LoroMovableList, LoroText, UndoManager } from "loro-crdt";
7
7
  import { produce } from "immer";
8
8
  //#region src/client/base64.ts
9
9
  function bytesToBase64(bytes) {
@@ -1508,6 +1508,7 @@ const ENTITY_KINDS = Object.freeze({
1508
1508
  ]),
1509
1509
  voice: Object.freeze(["Container", "IPBound"]),
1510
1510
  "sequence-marker": Object.freeze([]),
1511
+ script: Object.freeze(["SegmentText"]),
1511
1512
  "audio-script": Object.freeze(["SegmentText", "MediaFile"]),
1512
1513
  "phonetic-script": Object.freeze(["SegmentText", "MediaFile"]),
1513
1514
  caption: Object.freeze([
@@ -1809,6 +1810,32 @@ function entityToRow(entity) {
1809
1810
  };
1810
1811
  }
1811
1812
  //#endregion
1813
+ //#region ../medeo-dsl/src/script-content.ts
1814
+ /** Canonical text used to identify one immutable professional Script version. */
1815
+ function normalizeScriptText(text) {
1816
+ return text.normalize("NFC").replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n").map((line) => line.trimEnd()).join("\n");
1817
+ }
1818
+ /** Preserve segment identity, boundaries and order while removing textual encoding noise. */
1819
+ function normalizeScriptSegments(segments) {
1820
+ return segments.map(({ segmentId, text, language }) => ({
1821
+ segmentId,
1822
+ text: normalizeScriptText(text),
1823
+ ...language === void 0 ? {} : { language: language.normalize("NFC").trim() }
1824
+ }));
1825
+ }
1826
+ /** Content identity: ordered canonical text with array boundaries, never local ids or language metadata. */
1827
+ function scriptContentHash(segments) {
1828
+ return `sha256:${bytesToHex(sha256(utf8ToBytes(JSON.stringify(normalizeScriptSegments(segments).map((segment) => segment.text)))))}`;
1829
+ }
1830
+ //#endregion
1831
+ //#region ../medeo-dsl/src/script-revision.ts
1832
+ const HASH = /^sha256:[0-9a-f]{64}$/;
1833
+ function readScriptRevisionTrace(value) {
1834
+ if (!isJsonObject(value)) return null;
1835
+ if (Object.keys(value).length !== 4 || value.command !== "revise-script" || typeof value.editId !== "string" || value.editId.trim() !== value.editId || value.editId === "" || typeof value.sourceContentHash !== "string" || !HASH.test(value.sourceContentHash) || typeof value.resultContentHash !== "string" || !HASH.test(value.resultContentHash)) return null;
1836
+ return value;
1837
+ }
1838
+ //#endregion
1812
1839
  //#region ../medeo-dsl/src/invariants.ts
1813
1840
  /** An entity is made from at most one Asset; two would leave its bytes ambiguous. */
1814
1841
  /** Validates one complete set of entities and its authoritative Relation rows. */
@@ -1848,6 +1875,38 @@ function validateEntityRelationSet(entityRefs, index) {
1848
1875
  issues.push(...validateSequenceComposition(entity));
1849
1876
  }
1850
1877
  issues.push(...validateClipAnchorCycles(entities, index));
1878
+ issues.push(...validateScriptRevisionRelations(entities, index));
1879
+ return issues;
1880
+ }
1881
+ function validateScriptRevisionRelations(entities, index) {
1882
+ const byId = new Map(entities.map((entity) => [entity.entityId, entity.current()]));
1883
+ const seenRelations = /* @__PURE__ */ new Set();
1884
+ const editIds = /* @__PURE__ */ new Set();
1885
+ const parentCounts = /* @__PURE__ */ new Map();
1886
+ const issues = [];
1887
+ for (const entity of entities) for (const relation of index.relationsOf(entity)) {
1888
+ const row = relation.toRow();
1889
+ if (row == null || seenRelations.has(row.relationId)) continue;
1890
+ seenRelations.add(row.relationId);
1891
+ const result = byId.get(row.endpoint0EntityId);
1892
+ const source = byId.get(row.endpoint1EntityId);
1893
+ if (row.relationKind !== "derived-from" || result?.entityKind !== "script" || source?.entityKind !== "script") continue;
1894
+ const trace = readScriptRevisionTrace(row.trace);
1895
+ const invalid = trace == null || trace.resultContentHash !== result.contentHash || trace.sourceContentHash !== source.contentHash || trace.resultContentHash === trace.sourceContentHash || trace != null && editIds.has(trace.editId);
1896
+ if (trace != null) editIds.add(trace.editId);
1897
+ const parentCount = (parentCounts.get(row.endpoint0EntityId) ?? 0) + 1;
1898
+ parentCounts.set(row.endpoint0EntityId, parentCount);
1899
+ if (invalid) issues.push({
1900
+ code: "invalid_script_revision",
1901
+ entityId: row.endpoint0EntityId,
1902
+ message: "Script revision requires one canonical revise-script trace matching distinct result and source text"
1903
+ });
1904
+ if (parentCount > 1) issues.push({
1905
+ code: "invalid_script_revision",
1906
+ entityId: row.endpoint0EntityId,
1907
+ message: "A Script revision may name exactly one source Script"
1908
+ });
1909
+ }
1851
1910
  return issues;
1852
1911
  }
1853
1912
  function validateMarkerUse(marker, index) {
@@ -2068,7 +2127,7 @@ function validateKnownEntityPayload(entity) {
2068
2127
  const value = entity;
2069
2128
  const problems = [];
2070
2129
  if (value.lifecycle !== void 0 && !isRecord(value.lifecycle)) problems.push("lifecycle must be an object when present");
2071
- if (entity.entityKind === "audio-script" || entity.entityKind === "phonetic-script") {
2130
+ if (entity.entityKind === "script" || entity.entityKind === "audio-script" || entity.entityKind === "phonetic-script") {
2072
2131
  for (const key of [
2073
2132
  "durationMs",
2074
2133
  "extent",
@@ -2109,6 +2168,9 @@ function validateKnownEntityPayload(entity) {
2109
2168
  case "audio-script":
2110
2169
  validateScriptPayload(value, problems);
2111
2170
  break;
2171
+ case "script":
2172
+ validateProfessionalScriptPayload(value, problems);
2173
+ break;
2112
2174
  case "phonetic-script":
2113
2175
  validatePhoneticScriptPayload(value, problems);
2114
2176
  break;
@@ -2284,6 +2346,24 @@ function validateScriptPayload(value, problems) {
2284
2346
  if (segment.language !== void 0 && typeof segment.language !== "string") problems.push(`segments[${index}].language must be a string when present`);
2285
2347
  }
2286
2348
  }
2349
+ function validateProfessionalScriptPayload(value, problems) {
2350
+ validateScriptPayload(value, problems);
2351
+ if (!Array.isArray(value.segments) || !value.segments.every(isCompleteScriptSegment)) return;
2352
+ const segments = value.segments;
2353
+ if (segments.length === 0) problems.push("segments must contain at least one screenplay segment");
2354
+ const ids = segments.map((segment) => segment.segmentId);
2355
+ if (ids.some((id) => id.trim() === "")) problems.push("segmentId must be a non-empty trimmed string");
2356
+ if (ids.some((id) => id.trim() !== id)) problems.push("segmentId must be a non-empty trimmed string");
2357
+ if (new Set(ids).size !== ids.length) problems.push("segmentId must be unique within the Script");
2358
+ const normalized = normalizeScriptSegments(segments);
2359
+ if (normalized.some((segment) => segment.text === "")) problems.push("segment text must be non-empty after normalization");
2360
+ if (segments.some((segment, index) => segment.text !== normalized[index].text)) problems.push("segment text must use canonical NFC, LF line endings, and trimmed line endings");
2361
+ if (segments.some((segment, index) => segment.language !== normalized[index].language)) problems.push("segment language must be canonical NFC and trimmed when present");
2362
+ if (typeof value.contentHash !== "string" || value.contentHash !== scriptContentHash(segments)) problems.push("contentHash must equal the SHA-256 of the canonical ordered segment texts");
2363
+ }
2364
+ function isCompleteScriptSegment(value) {
2365
+ return isRecord(value) && typeof value.segmentId === "string" && typeof value.text === "string" && (value.language === void 0 || typeof value.language === "string");
2366
+ }
2287
2367
  function validateOptionalField(value, key, expectedType, problems) {
2288
2368
  if (value[key] !== void 0 && typeof value[key] !== expectedType) problems.push(`${key} must be a ${expectedType} when present`);
2289
2369
  }
@@ -2342,6 +2422,7 @@ function isOwnedLocalEntityValuePath(entityKind, path) {
2342
2422
  if (entityKind === "voice" && /^voice\.(?:system|key|name)$/.test(path)) return true;
2343
2423
  if (entityKind === "caption" && (path.startsWith("style.") || path === "selection.segmentId")) return true;
2344
2424
  if ([
2425
+ "script",
2345
2426
  "audio-script",
2346
2427
  "caption",
2347
2428
  "phonetic-script"
@@ -2351,6 +2432,7 @@ function isOwnedLocalEntityValuePath(entityKind, path) {
2351
2432
  function isOwnedLocalIdPath(entityKind, path) {
2352
2433
  if (path === "lifecycle.actorId") return true;
2353
2434
  if ([
2435
+ "script",
2354
2436
  "audio-script",
2355
2437
  "caption",
2356
2438
  "phonetic-script"
@@ -2495,7 +2577,7 @@ const toAssetRelationSpec = Object.freeze({
2495
2577
  validateEndpoints: (endpoints) => hasExactlyOneAsset(endpoints),
2496
2578
  validateMetadata: isAssetBinding
2497
2579
  });
2498
- /** Media a generated medium can be made from, and a transcript can be read off. */
2580
+ /** Media lineage, speech inputs, transcription sources, and immutable script revisions. */
2499
2581
  const MEDIA_VARIANT_KINDS = [
2500
2582
  "video",
2501
2583
  "image",
@@ -2517,7 +2599,7 @@ const derivedFromRelationSpec = Object.freeze({
2517
2599
  kind: "derived-from",
2518
2600
  ordered: true,
2519
2601
  sequenced: false,
2520
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(MEDIA_VARIANT_KINDS), new Set(MEDIA_VARIANT_KINDS)) || hasKinds(endpoints, new Set(["voiceover-recording"]), new Set(["phonetic-script"])) || hasKinds(endpoints, new Set(["audio-script"]), new Set(MEDIA_AUDIBLE_KINDS)),
2602
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(MEDIA_VARIANT_KINDS), new Set(MEDIA_VARIANT_KINDS)) || hasKinds(endpoints, new Set(["voiceover-recording"]), new Set(["phonetic-script"])) || hasKinds(endpoints, new Set(["video"]), new Set(["script"])) || hasKinds(endpoints, new Set(["script"]), new Set(["script"])) || hasKinds(endpoints, new Set(["audio-script"]), new Set(MEDIA_AUDIBLE_KINDS)) || hasKinds(endpoints, new Set(["audio-script"]), new Set(["audio-script"])),
2521
2603
  validateMetadata: isEmptyMetadata
2522
2604
  });
2523
2605
  const captionAlignmentRelationSpec = Object.freeze({
@@ -2652,6 +2734,7 @@ var BiRelationIndex = class {
2652
2734
  }
2653
2735
  /** Author `derived-from(product, source)` without exposing positional arguments. */
2654
2736
  linkDerivedFrom(input) {
2737
+ if (input.product.current().entityKind === "script" && input.source.current().entityKind === "script") throw new Error("Script revision provenance is host-authored by reviseScript");
2655
2738
  return this.linkValidated({
2656
2739
  relationId: input.relationId,
2657
2740
  spec: derivedFromRelationSpec,
@@ -2887,6 +2970,63 @@ function errorMessage(error) {
2887
2970
  return error instanceof Error ? error.message : String(error);
2888
2971
  }
2889
2972
  //#endregion
2973
+ //#region ../medeo-dsl/src/relation-transition.ts
2974
+ var InvalidEntityRelationTransitionError = class extends Error {
2975
+ issues;
2976
+ constructor(issues) {
2977
+ super(`Invalid Medeo relation transition:\n- ${issues.join("\n- ")}`);
2978
+ this.issues = issues;
2979
+ this.name = "InvalidEntityRelationTransitionError";
2980
+ }
2981
+ };
2982
+ /** Enforce relation fact identity while admitting a complete candidate view. */
2983
+ function validateEntityRelationTransition(before, candidate) {
2984
+ const issues = [];
2985
+ const previous = new Map(before.relations.map((row) => [row.relationId, row]));
2986
+ const beforeEntities = new Map(before.entities.map((row) => [row.entityId, row]));
2987
+ const candidateEntities = new Map(candidate.entities.map((row) => [row.entityId, row]));
2988
+ const revisionEditIds = new Set(before.relations.filter((relation) => isScriptRevision(relation, beforeEntities)).map((relation) => readScriptRevisionTrace(relation.trace)?.editId).filter((editId) => editId !== void 0));
2989
+ const revisionParents = /* @__PURE__ */ new Map();
2990
+ for (const entity of before.entities) if (entity.entityKind === "script" && !candidateEntities.has(entity.entityId)) issues.push(`Script entity "${entity.entityId}" is immutable history and cannot be deleted or replaced`);
2991
+ for (const relation of candidate.relations) {
2992
+ if (isScriptRevision(relation, candidateEntities)) {
2993
+ const count = (revisionParents.get(relation.endpoint0EntityId) ?? 0) + 1;
2994
+ revisionParents.set(relation.endpoint0EntityId, count);
2995
+ if (count > 1) issues.push(`Script "${relation.endpoint0EntityId}" may name only one revision source`);
2996
+ }
2997
+ const original = previous.get(relation.relationId);
2998
+ if (original == null) {
2999
+ if (relation.relationKind === "caption-alignment") issues.push(`new caption-alignment relation "${relation.relationId}" is forbidden`);
3000
+ if (isScriptRevision(relation, candidateEntities)) {
3001
+ const trace = readScriptRevisionTrace(relation.trace);
3002
+ const result = candidateEntities.get(relation.endpoint0EntityId);
3003
+ const source = candidateEntities.get(relation.endpoint1EntityId);
3004
+ if (trace == null || beforeEntities.has(relation.endpoint0EntityId) || !beforeEntities.has(relation.endpoint1EntityId) || trace.resultContentHash !== result.payload.contentHash || trace.sourceContentHash !== source.payload.contentHash || trace.resultContentHash === trace.sourceContentHash || revisionEditIds.has(trace.editId)) issues.push(`Script revision relation "${relation.relationId}" must be one canonical new-result to existing-source edit`);
3005
+ else revisionEditIds.add(trace.editId);
3006
+ }
3007
+ continue;
3008
+ }
3009
+ if (original.relationKind !== relation.relationKind || original.endpoint0EntityId !== relation.endpoint0EntityId || original.endpoint1EntityId !== relation.endpoint1EntityId) {
3010
+ issues.push(`relation "${relation.relationId}" changed its kind or ordered endpoints`);
3011
+ continue;
3012
+ }
3013
+ if (!sameJson(original.metadata, relation.metadata)) issues.push(`relation "${relation.relationId}" changed semantic metadata without a new identity`);
3014
+ if (relation.relationKind === "caption-alignment" && !sameJson(original.trace, relation.trace)) issues.push(`legacy caption-alignment relation "${relation.relationId}" may only be carried unchanged or deleted`);
3015
+ if (isScriptRevision(relation, candidateEntities) && !sameJson(original.trace, relation.trace)) issues.push(`Script revision relation "${relation.relationId}" trace is immutable`);
3016
+ }
3017
+ if (issues.length > 0) throw new InvalidEntityRelationTransitionError(issues);
3018
+ }
3019
+ function isScriptRevision(relation, entities) {
3020
+ return relation.relationKind === "derived-from" && entities.get(relation.endpoint0EntityId)?.entityKind === "script" && entities.get(relation.endpoint1EntityId)?.entityKind === "script";
3021
+ }
3022
+ function sameJson(left, right) {
3023
+ if (left === right) return true;
3024
+ if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((value, index) => sameJson(value, right[index]));
3025
+ if (!isJsonObject(left) || !isJsonObject(right)) return false;
3026
+ const leftKeys = Object.keys(left);
3027
+ return leftKeys.length === Object.keys(right).length && leftKeys.every((key) => Object.hasOwn(right, key) && sameJson(left[key], right[key]));
3028
+ }
3029
+ //#endregion
2890
3030
  //#region src/document/entity-projection-values.ts
2891
3031
  /** A graph cannot be represented faithfully by the compatibility reader. */
2892
3032
  var EntityTimelineProjectionError = class extends Error {
@@ -3003,7 +3143,7 @@ function resolveGraphLayout(graph) {
3003
3143
  const held = graph.holds(clip.entityId);
3004
3144
  if (held.length !== 1) fail(`Clip ${clip.entityId} must contain exactly one content entity`);
3005
3145
  const content = held[0];
3006
- const expected = typedRole === "video_clip" ? ["image", "video"] : typedRole === "speech" ? ["voiceover-recording"] : typedRole === "caption" ? ["caption"] : ["audio"];
3146
+ const expected = typedRole === "video_clip" ? ["image", "video"] : typedRole === "speech" ? ["voiceover-recording", "audio"] : typedRole === "caption" ? ["caption"] : ["audio"];
3007
3147
  if (!expected.includes(content.entityKind)) fail(`${typedRole} Track accepts ${expected.join(" or ")}, got ${content.entityKind}`);
3008
3148
  const inMs = milliseconds(marker.payload.inMs, "inMs");
3009
3149
  const outMs = milliseconds(marker.payload.outMs, "outMs");
@@ -3153,12 +3293,12 @@ function projectEntityTimeline(rows) {
3153
3293
  } else if (track.role === "speech") {
3154
3294
  const asset = mediaAssetFacts(graph, content);
3155
3295
  requireUntrimmedAudio(content, marker, placement);
3156
- const external = externalLocator(asset, "memota-speech");
3157
- const renderEdges = graph.madeFrom(content.entityId, "derived-from");
3296
+ const synthesized = content.entityKind === "voiceover-recording";
3297
+ const external = externalLocator(asset, synthesized ? "memota-speech" : "memota");
3158
3298
  const timbreEdges = graph.adjacent(content.entityId, "voice-timbre");
3159
- const assembled = renderEdges.length > 0 || timbreEdges.length > 0 ? assemblePhoneticScriptContent(rows, graph.one(content.entityId, "derived-from", "phonetic-script", renderEdges).entityId) : void 0;
3299
+ const assembled = synthesized ? assemblePhoneticScriptContent(rows, synthesizedPhoneticScript(graph, content).entityId) : void 0;
3300
+ const transcript = synthesized ? void 0 : transcribedAudioScript(graph, content);
3160
3301
  if (timbreEdges.length > 1) fail("A voiceover resolves at most one Voice identity");
3161
- const transcripts = graph.madeInto(content.entityId, "derived-from");
3162
3302
  const voice = timbreEdges.length === 0 ? void 0 : graph.one(content.entityId, "voice-timbre", "voice").payload.voice;
3163
3303
  if (voice !== void 0 && (!object(voice) || voice.system !== "voice-library" || typeof voice.key !== "string")) fail("Voice must use an explicit voice-library locator");
3164
3304
  const captionIds = layout.tracks.flatMap((candidate) => candidate.role === "caption" ? candidate.clips.filter((caption) => caption.anchorClipEntityId === clip.entityId).map((caption) => caption.clipEntityId) : []);
@@ -3168,7 +3308,8 @@ function projectEntityTimeline(rows) {
3168
3308
  media_duration_ms: placement.duration,
3169
3309
  audio_storage_key: storageKey(asset),
3170
3310
  origin_speech_id: external.key,
3171
- audio_script: assembled?.text ?? (transcripts.length > 0 ? assembleScriptText(graph.one(content.entityId, "derived-from", "audio-script", transcripts)) : ""),
3311
+ ...synthesized ? {} : { origin_asset_system: "memota" },
3312
+ audio_script: assembled?.text ?? (transcript === void 0 ? "" : assembleScriptText(transcript)),
3172
3313
  volume: volume.data,
3173
3314
  voice: object(voice) ? {
3174
3315
  id: typeof voice.key === "string" ? voice.key : void 0,
@@ -3287,7 +3428,17 @@ function captionStyle(value) {
3287
3428
  }
3288
3429
  function requireUntrimmedAudio(content, marker, placement) {
3289
3430
  const source = markerWindow(marker);
3290
- if (source.start !== 0 || content.payload.durationMs !== source.end || placement.duration !== source.end || marker.payload.rate !== void 0) fail("Voiceover compatibility requires the complete, unremapped take");
3431
+ if (source.start !== 0 || content.payload.durationMs !== source.end || placement.duration !== source.end || marker.payload.rate !== void 0) fail("Speech compatibility requires the complete, unremapped take");
3432
+ }
3433
+ function synthesizedPhoneticScript(graph, content) {
3434
+ const scripts = graph.rows.relations.filter((row) => row.relationKind === "derived-from" && row.endpoint0EntityId === content.entityId).map((row) => graph.entity(row.endpoint1EntityId)).filter((row) => row.entityKind === "phonetic-script");
3435
+ if (scripts.length !== 1) fail("Synthesized Speech requires exactly one ordered PhoneticScript input");
3436
+ return scripts[0];
3437
+ }
3438
+ function transcribedAudioScript(graph, content) {
3439
+ const scripts = graph.rows.relations.filter((row) => row.relationKind === "derived-from" && row.endpoint1EntityId === content.entityId).map((row) => graph.entity(row.endpoint0EntityId)).filter((row) => row.entityKind === "audio-script");
3440
+ if (scripts.length > 1) fail("Recorded Speech has ambiguous AudioScript transcripts");
3441
+ return scripts[0];
3291
3442
  }
3292
3443
  /** The Marker's window, in the {start,end} shape this compatibility reader speaks. */
3293
3444
  function markerWindow(marker) {
@@ -3696,6 +3847,19 @@ function writeEntityOrder(doc, group, roots, token) {
3696
3847
  //#region src/entity-crdt/entity-document.ts
3697
3848
  const LORO_ENTITY_SCHEMA = "medeo.entities.loro.v1";
3698
3849
  const MATERIALIZE_ORIGIN = "medeo.materialize";
3850
+ /** Read persisted relation roots before current entity versions are projected. */
3851
+ function readEntityRelationRecords(doc) {
3852
+ if (root(doc).get("schema") !== "medeo.entities.loro.v1") throw new Error("Document has no Loro entity state");
3853
+ return relationRecords(doc).map(({ rootId, identity, map, deleted }) => ({
3854
+ relationId: createRelationId(rootId),
3855
+ relationKind: identity.kind,
3856
+ endpoint0EntityId: createEntityId(identity.endpoints[0]),
3857
+ endpoint1EntityId: createEntityId(identity.endpoints[1]),
3858
+ metadata: requireMap(map, "metadata").toJSON(),
3859
+ trace: requireMap(map, "trace").toJSON(),
3860
+ deleted
3861
+ }));
3862
+ }
3699
3863
  function hash(value) {
3700
3864
  return bytesToHex(sha256(new TextEncoder().encode(canonicalJson(value))));
3701
3865
  }
@@ -3782,35 +3946,31 @@ function readEntityDocument(doc, allowSelectionConflicts = false) {
3782
3946
  }
3783
3947
  };
3784
3948
  });
3785
- const relations = [];
3786
- for (const key of section(doc, "relations").keys().sort((a, b) => a < b ? -1 : a > b ? 1 : 0)) {
3787
- const map = section(doc, "relations").get(key);
3788
- if (!(map instanceof LoroMap) || map.get("deleted") === true) continue;
3789
- const original = map.get("identity");
3790
- if (original.endpoints.some((id) => !ids.has(family(doc, id)))) continue;
3791
- const endpoints = original.endpoints.map((id) => currentId(doc, id, ids));
3792
- const relationId = equalJson(endpoints, original.endpoints) ? key : `relation_${hash([key, endpoints])}`;
3793
- relations.push({
3794
- relationId: createRelationId(relationId),
3795
- relationKind: original.kind,
3949
+ const relations = activeRelationRecords(doc, ids).map(({ rootId, identity, map }) => {
3950
+ const endpoints = identity.endpoints.map((id) => currentId(doc, id, ids));
3951
+ return {
3952
+ relationId: createRelationId(equalJson(endpoints, identity.endpoints) ? rootId : `relation_${hash([rootId, endpoints])}`),
3953
+ relationKind: identity.kind,
3796
3954
  endpoint0EntityId: createEntityId(endpoints[0]),
3797
3955
  endpoint1EntityId: createEntityId(endpoints[1]),
3798
3956
  metadata: requireMap(map, "metadata").toJSON(),
3799
3957
  trace: requireMap(map, "trace").toJSON()
3800
- });
3801
- }
3958
+ };
3959
+ });
3802
3960
  sortSequencedRelations(doc, relations);
3803
3961
  const project = {};
3804
3962
  for (const [key, value] of Object.entries(section(doc, "attachments").toJSON())) {
3805
3963
  if (typeof value !== "string") throw new Error(`Invalid project attachment ${key}`);
3806
3964
  project[key] = currentId(doc, value, ids);
3807
3965
  }
3966
+ const phoneticScriptSelections = readPhoneticScriptSelections(doc, ids);
3808
3967
  const state = {
3809
3968
  rows: {
3810
3969
  entities,
3811
3970
  relations
3812
3971
  },
3813
- project
3972
+ project,
3973
+ ...phoneticScriptSelections === void 0 ? {} : { phoneticScriptSelections }
3814
3974
  };
3815
3975
  if (!allowSelectionConflicts) for (const [key, id] of ids) {
3816
3976
  const fingerprint = selectionFingerprint(state.rows, id);
@@ -3818,6 +3978,54 @@ function readEntityDocument(doc, allowSelectionConflicts = false) {
3818
3978
  }
3819
3979
  return state;
3820
3980
  }
3981
+ function readPhoneticScriptSelections(doc, ids) {
3982
+ const native = root(doc).get("phoneticScriptSelections");
3983
+ if (native === void 0) return void 0;
3984
+ if (!(native instanceof LoroMap)) throw new Error("Invalid phonetic-script selector");
3985
+ const selections = {};
3986
+ for (const clipRoot of native.keys().sort((a, b) => a < b ? -1 : a > b ? 1 : 0)) {
3987
+ const phoneticScriptRoot = native.get(clipRoot);
3988
+ if (typeof phoneticScriptRoot !== "string") throw new Error("Invalid phonetic-script selection");
3989
+ const clipId = ids.get(clipRoot);
3990
+ const phoneticScriptId = ids.get(phoneticScriptRoot);
3991
+ if (clipId === void 0 || phoneticScriptId === void 0) continue;
3992
+ if (workspace(doc, clipRoot).get("kind") !== "clip") throw new Error("Phonetic-script selection target must be a Clip");
3993
+ if (workspace(doc, phoneticScriptRoot).get("kind") !== "phonetic-script") throw new Error("Phonetic-script selection value must be a PhoneticScript");
3994
+ selections[clipRoot] = phoneticScriptId;
3995
+ }
3996
+ return Object.keys(selections).length === 0 ? void 0 : selections;
3997
+ }
3998
+ function relationRecords(doc) {
3999
+ const records = [];
4000
+ for (const rootId of section(doc, "relations").keys().sort((a, b) => a < b ? -1 : a > b ? 1 : 0)) {
4001
+ const map = section(doc, "relations").get(rootId);
4002
+ if (!(map instanceof LoroMap)) throw new Error(`Invalid relation record ${rootId}`);
4003
+ const value = map.get("identity");
4004
+ if (!isJsonObject(value) || typeof value.kind !== "string" || !Array.isArray(value.endpoints) || value.endpoints.length !== 2 || !value.endpoints.every((endpoint) => typeof endpoint === "string")) throw new Error(`Invalid relation identity ${rootId}`);
4005
+ records.push({
4006
+ rootId,
4007
+ identity: {
4008
+ kind: value.kind,
4009
+ endpoints: [value.endpoints[0], value.endpoints[1]]
4010
+ },
4011
+ map,
4012
+ deleted: map.get("deleted") === true
4013
+ });
4014
+ }
4015
+ return records;
4016
+ }
4017
+ function activeRelationRecords(doc, ids) {
4018
+ const selector = root(doc).get("clipContentSelections");
4019
+ if (selector !== void 0 && !(selector instanceof LoroMap)) throw new Error("Invalid clip-content selector");
4020
+ return relationRecords(doc).filter(({ rootId, identity, deleted }) => {
4021
+ if (deleted) return false;
4022
+ if (!identity.endpoints.every((id) => ids.has(family(doc, id)))) return false;
4023
+ if (identity.kind !== "contain" || selector === void 0 || workspace(doc, family(doc, identity.endpoints[0])).get("kind") !== "clip") return true;
4024
+ const selected = selector.get(family(doc, identity.endpoints[0]));
4025
+ if (selected !== void 0 && typeof selected !== "string") throw new Error("Invalid clip-content selection");
4026
+ return selected === void 0 || selected === rootId;
4027
+ });
4028
+ }
3821
4029
  /** Native editing containers survive domain version changes and merge independently. */
3822
4030
  var LoroEntityDocument = class LoroEntityDocument {
3823
4031
  doc;
@@ -3849,7 +4057,9 @@ var LoroEntityDocument = class LoroEntityDocument {
3849
4057
  "relations",
3850
4058
  "project",
3851
4059
  "attachments",
3852
- "ordering"
4060
+ "ordering",
4061
+ "clipContentSelections",
4062
+ "phoneticScriptSelections"
3853
4063
  ]) root(doc).ensureMergeableMap(name);
3854
4064
  for (const row of rows.entities) apply(doc, {
3855
4065
  type: "create",
@@ -3900,11 +4110,13 @@ var LoroEntityDocument = class LoroEntityDocument {
3900
4110
  }
3901
4111
  /** The callback and every domain validation execute on a causally complete fork first. */
3902
4112
  transact(edit) {
4113
+ const before = readEntityDocument(this.doc, true);
3903
4114
  const probe = this.doc.fork();
3904
4115
  const draft = new LoroEntityDraft(probe);
3905
4116
  edit(draft);
3906
4117
  draft.captureSelectionBaselines();
3907
4118
  const state = readEntityDocument(probe);
4119
+ validateEntityRelationTransition(before.rows, state.rows);
3908
4120
  assertState(state);
3909
4121
  this.validate?.(state);
3910
4122
  materialize(probe);
@@ -3918,8 +4130,15 @@ var LoroEntityDocument = class LoroEntityDocument {
3918
4130
  from
3919
4131
  });
3920
4132
  }
3921
- /** Import preserves native operation identities; projection errors never discard remote operations. */
4133
+ /** Validate remote operations on a fork before importing their native identities. */
3922
4134
  import(update) {
4135
+ const before = readEntityDocument(this.doc, true);
4136
+ const probe = this.doc.fork();
4137
+ probe.import(update);
4138
+ const candidate = readEntityDocument(probe, true);
4139
+ validateEntityRelationTransition(before.rows, candidate.rows);
4140
+ assertState(candidate);
4141
+ this.validate?.(candidate);
3923
4142
  this.doc.import(update);
3924
4143
  const state = this.snapshot();
3925
4144
  assertState(state);
@@ -4035,6 +4254,7 @@ var LoroEntityDraft = class {
4035
4254
  });
4036
4255
  }
4037
4256
  delete(entityId) {
4257
+ if (workspace(this.doc, family(this.doc, entityId)).get("kind") === "script") throw new Error("Script entities are immutable history and cannot be deleted");
4038
4258
  this.record({
4039
4259
  type: "delete",
4040
4260
  root: family(this.doc, entityId)
@@ -4053,11 +4273,14 @@ var LoroEntityDraft = class {
4053
4273
  row: clone(row)
4054
4274
  });
4055
4275
  }
4056
- patchRelation(relationId, metadata, trace) {
4276
+ patchRelationTrace(relationId, trace) {
4277
+ const relation = this.snapshot().rows.relations.find((row) => row.relationId === relationId);
4278
+ if (relation == null) throw new Error(`Unknown relation ${relationId}`);
4279
+ if (relation.relationKind === "caption-alignment") throw new Error("caption-alignment Relations are read-only legacy facts");
4057
4280
  this.record({
4058
4281
  type: "relation-fields",
4059
4282
  root: this.relationRoot(relationId),
4060
- metadata: clone(metadata),
4283
+ metadata: clone(relation.metadata),
4061
4284
  trace: clone(trace)
4062
4285
  });
4063
4286
  }
@@ -4067,6 +4290,35 @@ var LoroEntityDraft = class {
4067
4290
  root: this.relationRoot(relationId)
4068
4291
  });
4069
4292
  }
4293
+ /** Select the one content membership projected for this stable Clip identity. */
4294
+ selectClipContent(relationId) {
4295
+ const relationRoot = this.relationRoot(relationId);
4296
+ const map = section(this.doc, "relations").get(relationRoot);
4297
+ if (!(map instanceof LoroMap)) throw new Error(`Unknown relation ${relationId}`);
4298
+ const identity = map.get("identity");
4299
+ if (!isJsonObject(identity) || identity.kind !== "contain" || !Array.isArray(identity.endpoints) || typeof identity.endpoints[0] !== "string") throw new Error("Clip-content selection requires a contain relation");
4300
+ const clipRoot = family(this.doc, identity.endpoints[0]);
4301
+ if (workspace(this.doc, clipRoot).get("kind") !== "clip") throw new Error("Clip-content selection requires a Clip container");
4302
+ this.record({
4303
+ type: "select-clip-content",
4304
+ clipRoot,
4305
+ relationRoot
4306
+ });
4307
+ }
4308
+ /** Select the pending immutable pronunciation variant for one stable Speech Clip. */
4309
+ selectPhoneticScript(clipEntityId, phoneticScriptEntityId) {
4310
+ const clipRoot = family(this.doc, clipEntityId);
4311
+ const phoneticScriptRoot = family(this.doc, phoneticScriptEntityId);
4312
+ const clip = workspace(this.doc, clipRoot);
4313
+ const phoneticScript = workspace(this.doc, phoneticScriptRoot);
4314
+ if (clip.get("deleted") === true || clip.get("kind") !== "clip") throw new Error("Phonetic-script selection target must be an active Clip");
4315
+ if (phoneticScript.get("deleted") === true || phoneticScript.get("kind") !== "phonetic-script") throw new Error("Phonetic-script selection value must be an active PhoneticScript");
4316
+ this.record({
4317
+ type: "select-phonetic-script",
4318
+ clipRoot,
4319
+ phoneticScriptRoot
4320
+ });
4321
+ }
4070
4322
  relationRoot(relationId) {
4071
4323
  const alias = this.relationAliases.get(relationId);
4072
4324
  if (alias !== void 0) return alias;
@@ -4104,6 +4356,7 @@ var LoroEntityDraft = class {
4104
4356
  });
4105
4357
  }
4106
4358
  replaceOwned(entityId, payload) {
4359
+ this.assertGenericMutationAllowed(entityId);
4107
4360
  this.record({
4108
4361
  type: "write",
4109
4362
  root: family(this.doc, entityId),
@@ -4114,6 +4367,7 @@ var LoroEntityDraft = class {
4114
4367
  editText(entityId, path, start, deleteCount, text) {
4115
4368
  if (typeof path[0] !== "string") throw new Error("Text path must start with a field");
4116
4369
  const owner = resolveEntityFieldOwner(this.snapshot().rows, createEntityId(currentId(this.doc, entityId)), path[0]);
4370
+ this.assertGenericMutationAllowed(owner);
4117
4371
  this.record({
4118
4372
  type: "text",
4119
4373
  root: family(this.doc, owner),
@@ -4139,6 +4393,7 @@ var LoroEntityDraft = class {
4139
4393
  const owner = resolveEntityFieldOwner(state.rows, id, change.path[0]);
4140
4394
  const row = state.rows.entities.find((row) => row.entityId === owner);
4141
4395
  if (equalJson(row.payload, applyFieldChanges(row.payload, [change]))) continue;
4396
+ this.assertGenericMutationAllowed(owner);
4142
4397
  this.record({
4143
4398
  type: "field",
4144
4399
  root: family(this.doc, owner),
@@ -4148,13 +4403,17 @@ var LoroEntityDraft = class {
4148
4403
  });
4149
4404
  }
4150
4405
  }
4406
+ assertGenericMutationAllowed(entityId) {
4407
+ if (workspace(this.doc, family(this.doc, entityId)).get("kind") === "script") throw new Error("Script content is immutable; create a new Script revision");
4408
+ }
4151
4409
  changeRelation(relationId, changes) {
4152
4410
  assertFieldChanges(changes);
4153
4411
  const key = this.relationRoot(relationId);
4154
4412
  const map = section(this.doc, "relations").get(key);
4155
4413
  if (!(map instanceof LoroMap) || map.get("deleted") === true) throw new Error("Unknown relation");
4414
+ if (map.get("identity").kind === "caption-alignment") throw new Error("caption-alignment Relations are read-only legacy facts");
4156
4415
  for (const change of changes) {
4157
- if (!["metadata", "trace"].includes(change.path[0]) || change.path.length < 2) throw new Error("Relation changes may only edit metadata or trace fields");
4416
+ if (change.path[0] !== "trace" || change.path.length < 2) throw new Error("Relation changes may only edit trace fields");
4158
4417
  const payload = {
4159
4418
  metadata: requireMap(map, "metadata").toJSON(),
4160
4419
  trace: requireMap(map, "trace").toJSON()
@@ -4273,6 +4532,12 @@ function apply(doc, command) {
4273
4532
  map.set("deleted", true);
4274
4533
  break;
4275
4534
  }
4535
+ case "select-clip-content":
4536
+ root(doc).ensureMergeableMap("clipContentSelections").set(command.clipRoot, command.relationRoot);
4537
+ break;
4538
+ case "select-phonetic-script":
4539
+ root(doc).ensureMergeableMap("phoneticScriptSelections").set(command.clipRoot, command.phoneticScriptRoot);
4540
+ break;
4276
4541
  case "relation-fields": {
4277
4542
  const map = section(doc, "relations").get(command.root);
4278
4543
  if (!(map instanceof LoroMap)) throw new Error("Unknown relation");
@@ -4694,4 +4959,4 @@ function createPlainMemoryAdapter(document, options) {
4694
4959
  return new PlainMemoryAdapter(document, options);
4695
4960
  }
4696
4961
  //#endregion
4697
- export { assertValidVideoDocument as $, decodeEntityRelationRows as A, updateEntityFields as B, applyFieldChanges as C, speedOf as Ct, EntityProjectionGraph as D, base64ToBytes as Dt, projectEntityTimeline as E, videoDocumentMirrorSchema as Et, assembleEntityContent as F, hasSequence as G, isJsonObject as H, assemblePhoneticScriptContent as I, buildSpeechHostMap as J, isMediaAssetVariantKind as K, assembleScriptText as L, relationsTo as M, ScriptCompositionError as N, resolveEntityTimelineLayout as O, bytesToBase64 as Ot, assembleCaptionContent as P, VideoDocumentValidationError as Q, findComposedAudioScript as R, equalJson as S, effectiveVideoClipDurationMs as St, resolveFieldPath as T, recordEntries as Tt, createEntityId as U, variantBaseEntityIds as V, createRelationId as W, fromVideoDocument as X, derivePositionFromAbs as Y, toVideoDocument as Z, audioScriptAssetOf as _, TIMELINE_SKELETON_DURATION_MS as _t, createMirrorVideoDocument as a, findLaneTrack as at, phoneticScriptAssetOf as b, safeDurationMs as bt, DocumentMutationGuard as c, solveVideoDocument as ct, LoroEntityDraft as d, fillMainTrackTimeGaps as dt, validateVideoDocument as et, readEntityDocument as f, reassignSpeechesToVideoClipsByTime as ft, audioScriptAssetHash as g, syncAggregatedClipsTimePosition as gt, audioScriptAssetFields as h, resolveSpeechOverlapByShiftingVideos as ht, MirrorVideoDocumentAdapter as i, ensureLaneTrack as it, relationsFrom as j, EntityTimelineProjectionError as k, LORO_ENTITY_SCHEMA as l, cascadeAfterVideoClipChanges as lt, audioScriptAssetContent as m, resolveAllSpeechOverlaps as mt, createPlainMemoryAdapter as n, videoDocumentSchema as nt, createMirrorVideoDocumentAdapter as o, hasResolvedPlacement as ot, entityOrderGroups as p, recalculateTimelineDuration as pt, buildInitialVideoDocument as q, generatePartId as r, LANE_KINDS_IN_STACK_ORDER as rt, readVideoDocumentFromDraft as s, laneTrackId as st, PlainMemoryAdapter as t, partUnionSchema as tt, LoroEntityDocument as u, arrangeMainTrackSeamlessly as ut, phoneticScriptAssetContent as v, isEmptyVideoClip as vt, assertFieldChanges as w, partUnionToDraft as wt, scriptSourceAssetOf as x, DEFAULT_UNIT_TIME_MS as xt, phoneticScriptAssetHash as y, partDurationMs as yt, selectAudioScriptSegment as z };
4962
+ export { fromVideoDocument as $, resolveEntityTimelineLayout as A, base64ToBytes as At, assembleScriptText as B, canonicalJson as C, safeDurationMs as Ct, resolveFieldPath as D, partUnionToDraft as Dt, assertFieldChanges as E, speedOf as Et, relationsTo as F, isJsonObject as G, selectAudioScriptSegment as H, ScriptCompositionError as I, hasSequence as J, createEntityId as K, assembleCaptionContent as L, validateEntityRelationTransition as M, decodeEntityRelationRows as N, projectEntityTimeline as O, recordEntries as Ot, relationsFrom as P, derivePositionFromAbs as Q, assembleEntityContent as R, scriptSourceAssetOf as S, partDurationMs as St, applyFieldChanges as T, effectiveVideoClipDurationMs as Tt, updateEntityFields as U, findComposedAudioScript as V, variantBaseEntityIds as W, buildInitialVideoDocument as X, isMediaAssetVariantKind as Y, buildSpeechHostMap as Z, audioScriptAssetHash as _, resolveAllSpeechOverlaps as _t, createMirrorVideoDocument as a, videoDocumentSchema as at, phoneticScriptAssetHash as b, TIMELINE_SKELETON_DURATION_MS as bt, DocumentMutationGuard as c, findLaneTrack as ct, LoroEntityDraft as d, solveVideoDocument as dt, toVideoDocument as et, readEntityDocument as f, cascadeAfterVideoClipChanges as ft, audioScriptAssetFields as g, recalculateTimelineDuration as gt, audioScriptAssetContent as h, reassignSpeechesToVideoClipsByTime as ht, MirrorVideoDocumentAdapter as i, partUnionSchema as it, EntityTimelineProjectionError as j, bytesToBase64 as jt, EntityProjectionGraph as k, videoDocumentMirrorSchema as kt, LORO_ENTITY_SCHEMA as l, hasResolvedPlacement as lt, entityOrderGroups as m, fillMainTrackTimeGaps as mt, createPlainMemoryAdapter as n, assertValidVideoDocument as nt, createMirrorVideoDocumentAdapter as o, LANE_KINDS_IN_STACK_ORDER as ot, readEntityRelationRecords as p, arrangeMainTrackSeamlessly as pt, createRelationId as q, generatePartId as r, validateVideoDocument as rt, readVideoDocumentFromDraft as s, ensureLaneTrack as st, PlainMemoryAdapter as t, VideoDocumentValidationError as tt, LoroEntityDocument as u, laneTrackId as ut, audioScriptAssetOf as v, resolveSpeechOverlapByShiftingVideos as vt, equalJson as w, DEFAULT_UNIT_TIME_MS as wt, phoneticScriptAssetOf as x, isEmptyVideoClip as xt, phoneticScriptAssetContent as y, syncAggregatedClipsTimePosition as yt, assemblePhoneticScriptContent as z };