@mengine/medeo-tool 1.4.1-alpha.5 → 2.0.1-alpha.11

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,35 +1,94 @@
1
1
  import { applyFieldChanges, assertCanonicalEditorResources, assertFieldChanges, ensureEditorFoundation, importMediaAsset, readDocumentAudioScript } from "@mengine/medeo-client";
2
2
  //#region ../medeo-dsl/src/entities.ts
3
+ /**
4
+ * What every known entity kind declares.
5
+ *
6
+ * `kinds` is a multi-declaration: every entry holds at the same time, and each
7
+ * is a property shared with other entity kinds — what the object *is* comes
8
+ * from `entityKind` and is never repeated here. The order carries no priority,
9
+ * endpoint role, or edit sequence — an operation asks whether the entity
10
+ * declares the kind it needs, never what its "main type" is. An entity kind
11
+ * that shares nothing declares nothing.
12
+ *
13
+ * `Caption` declares `Sequence` on purpose: it owns the display timing of the
14
+ * one AudioScript segment it shows, which is what admits it into a Clip.
15
+ * `Voice` declares no `Sequence` — it is a timbre identity, not playable
16
+ * content; a rendered voiceover is an `audio` entity.
17
+ *
18
+ * No declaration says anything about resources: whether an entity has one, and
19
+ * which, is the `from-asset` Relation's answer alone.
20
+ */
21
+ const ENTITY_KINDS = Object.freeze({
22
+ axvideo: Object.freeze([
23
+ "Container",
24
+ "Sequence",
25
+ "Visual",
26
+ "Audible"
27
+ ]),
28
+ timeline: Object.freeze(["Container"]),
29
+ track: Object.freeze(["Container"]),
30
+ clip: Object.freeze(["Container"]),
31
+ asset: Object.freeze([]),
32
+ video: Object.freeze([
33
+ "Sequence",
34
+ "Visual",
35
+ "Audible",
36
+ "FromAsset"
37
+ ]),
38
+ audio: Object.freeze([
39
+ "Sequence",
40
+ "Audible",
41
+ "FromAsset"
42
+ ]),
43
+ image: Object.freeze([
44
+ "Sequence",
45
+ "Visual",
46
+ "FromAsset"
47
+ ]),
48
+ voice: Object.freeze(["Container", "IPBound"]),
49
+ "sequence-marker": Object.freeze([]),
50
+ viewport: Object.freeze([]),
51
+ "audio-script": Object.freeze(["SegmentText", "FromAsset"]),
52
+ "phonetic-script": Object.freeze(["SegmentText"]),
53
+ caption: Object.freeze([
54
+ "SegmentText",
55
+ "Sequence",
56
+ "FromAsset"
57
+ ])
58
+ });
59
+ /** What a known kind declares; extension kinds declare nothing through this table. */
60
+ function kindsOf(entityKind) {
61
+ return isKnownEntityKind(entityKind) ? ENTITY_KINDS[entityKind] : [];
62
+ }
63
+ function declaresKind(entityKind, kind) {
64
+ return kindsOf(entityKind).includes(kind);
65
+ }
66
+ /**
67
+ * Sequence admission is a declaration, not a shape: a known kind is admitted
68
+ * because its `kinds` say so, and a malformed payload is a separate, louder
69
+ * failure than a silently non-Sequence entity. Extension kinds declare nothing
70
+ * through the table and stay structurally detected.
71
+ */
3
72
  function hasSequence(entity) {
4
- if (isKnownNonSequenceKind(entity.entityKind) || isReservedEntityKind(entity.entityKind)) return false;
73
+ if (isReservedEntityKind(entity.entityKind)) return false;
74
+ if (isKnownEntityKind(entity.entityKind)) return declaresKind(entity.entityKind, "Sequence");
5
75
  return isSequenceFields(entity);
6
76
  }
7
77
  function isSequenceFields(value) {
8
78
  if (!isRecord$1(value)) return false;
9
- const extent = value.extent;
10
- if (!isRecord$1(extent) || !("start" in extent) || extent.start === void 0) return false;
11
- if (extent.kind === "bounded" && (!("end" in extent) || extent.end === void 0)) return false;
12
- if (extent.kind === "unbounded" && "end" in extent) return false;
13
- if (extent.kind !== "bounded" && extent.kind !== "unbounded") return false;
14
- if (value.sampling !== "native" && value.sampling !== "constant" && value.sampling !== "derived") return false;
15
- return "coordinateSpace" in value && value.coordinateSpace !== void 0;
16
- }
17
- function isKnownSequenceKind(kind) {
18
- return isMediaAssetVariantKind(kind) || kind === "caption" || kind === "axvideo";
79
+ const duration = value.durationMs;
80
+ return duration === null || typeof duration === "number" && Number.isSafeInteger(duration) && duration > 0;
19
81
  }
20
82
  function isMediaAssetVariantKind(kind) {
21
- return kind === "video" || kind === "image" || kind === "audio" || kind === "voice";
83
+ return kind === "video" || kind === "image" || kind === "audio";
22
84
  }
23
85
  function isKnownEntityKind(kind) {
24
- return isKnownSequenceKind(kind) || isKnownNonSequenceKind(kind);
86
+ return Object.hasOwn(ENTITY_KINDS, kind);
25
87
  }
26
88
  function isReservedEntityKind(kind) {
27
89
  const normalized = kind.toLowerCase().replaceAll("-", "").replaceAll("_", "");
28
90
  return normalized === "speech" || normalized === "videodocument";
29
91
  }
30
- function isKnownNonSequenceKind(kind) {
31
- return kind === "timeline" || kind === "track" || kind === "clip" || kind === "asset" || kind === "sequence-marker" || kind === "viewport" || kind === "audio-script" || kind === "phonetic-script";
32
- }
33
92
  function isRecord$1(value) {
34
93
  return typeof value === "object" && value != null && !Array.isArray(value);
35
94
  }
@@ -209,14 +268,17 @@ function assembleCaptionContent(rows, captionEntityId) {
209
268
  requireEntityKind(rows, captionEntityId, "caption");
210
269
  const caption = assembleEntityContent(rows, captionEntityId);
211
270
  const script = findComposedAudioScript(rows, captionEntityId, "caption");
212
- const segment = selectAudioScriptSegment({
271
+ const selection = captionSelection(caption);
272
+ const composed = {
213
273
  ...script,
214
274
  payload: caption.payload
215
- }, captionSelection(caption));
275
+ };
276
+ const segment = selectAudioScriptSegment(composed, selection);
216
277
  return {
217
278
  caption,
218
279
  audioScript: script,
219
280
  segments: [segment],
281
+ segmentIndex: scriptSegments(composed).findIndex((entry) => entry.segmentId === selection.segmentId),
220
282
  text: segment.text
221
283
  };
222
284
  }
@@ -284,17 +346,15 @@ function entityToRow(entity) {
284
346
  }
285
347
  //#endregion
286
348
  //#region ../medeo-dsl/src/invariants.ts
287
- /**
288
- * Media variants own their Asset identity directly: a `physical-asset`
289
- * Relation may not bind them. Caption may associate with a physical text Asset
290
- * independently of its AudioScript composition.
291
- */
292
- function validateMediaAssetIdentity(entity, index) {
293
- return [...index.relationsOf(entity)].filter((relation) => relation.kind === "physical-asset").map(() => ({
294
- code: "media_physical_asset_forbidden",
349
+ /** An entity is made from at most one Asset; two would leave its bytes ambiguous. */
350
+ function validateAssetSource(entity, index) {
351
+ if (entity.current().entityKind === "asset") return [];
352
+ if ([...index.relationsOf(entity)].filter((relation) => relation.kind === "from-asset").length <= 1) return [];
353
+ return [{
354
+ code: "asset_source_cardinality",
295
355
  entityId: entity.entityId,
296
- message: `Media entity "${entity.entityId}" owns its Asset identity; remove its physical-asset Relation`
297
- }));
356
+ message: `Entity "${entity.entityId}" is made from more than one Asset`
357
+ }];
298
358
  }
299
359
  /** Validates one complete set of entities and its authoritative Relation rows. */
300
360
  function validateEntityRelationSet(entityRefs, index, options) {
@@ -317,7 +377,7 @@ function validateEntityRelationSet(entityRefs, index, options) {
317
377
  const current = entity.current();
318
378
  const entityIssues = validateEntity(entity, entityIds);
319
379
  issues.push(...entityIssues);
320
- if (isMediaAssetVariantKind(current.entityKind)) issues.push(...validateMediaAssetIdentity(entity, index));
380
+ issues.push(...validateAssetSource(entity, index));
321
381
  if (entityIssues.some((issue) => issue.code === "invalid_entity_payload")) continue;
322
382
  if (current.entityKind === "sequence-marker") {
323
383
  const marker = entity;
@@ -542,24 +602,25 @@ function validateMarkerSourceBounds(marker, index, compare) {
542
602
  const content = contentEdges[0]?.other(marker)?.deref()?.current();
543
603
  if (content == null || !hasSequence(content)) return [];
544
604
  const sourceRange = marker.current().sourceRange;
545
- const startVsExtent = compare(sourceRange.start, content.extent.start);
546
- const endVsExtent = content.extent.kind === "bounded" ? compare(sourceRange.end, content.extent.end) : void 0;
547
- if (!Number.isNaN(startVsExtent) && startVsExtent >= 0 && (endVsExtent == null || !Number.isNaN(endVsExtent) && endVsExtent <= 0)) return [];
605
+ const startVsOrigin = compare(sourceRange.start, 0);
606
+ const endVsDuration = content.durationMs == null ? void 0 : compare(sourceRange.end, content.durationMs);
607
+ if (!Number.isNaN(startVsOrigin) && startVsOrigin >= 0 && (endVsDuration == null || !Number.isNaN(endVsDuration) && endVsDuration <= 0)) return [];
548
608
  return [{
549
609
  code: "marker_source_out_of_bounds",
550
610
  entityId: marker.entityId,
551
- message: `Sequence Marker sourceRange must stay within content "${content.entityId}" extent`
611
+ message: `Sequence Marker sourceRange must stay within content "${content.entityId}" duration`
552
612
  }];
553
613
  }
554
614
  function validateSequenceComposition(entity) {
555
615
  const current = entity.current();
556
- const expected = expectedSequenceShape(current.entityKind);
557
- if (expected == null) return [];
558
- if (hasSequence(current) && current.extent.kind === expected.extent && current.sampling === expected.sampling) return [];
616
+ const expected = expectedDurationShape(current.entityKind);
617
+ if (expected === void 0) return [];
618
+ const duration = current.durationMs;
619
+ if ((duration === null ? "none" : isPositiveInteger(duration) ? "own" : "invalid") === expected) return [];
559
620
  return [{
560
621
  code: "invalid_sequence_composition",
561
622
  entityId: current.entityId,
562
- message: `${current.entityKind} must compose ${expected.extent} / ${expected.sampling} Sequence semantics`
623
+ message: expected === "own" ? `${current.entityKind} must state its own durationMs in whole milliseconds` : `${current.entityKind} has no intrinsic length; durationMs must be null`
563
624
  }];
564
625
  }
565
626
  function validateEntity(entity, entityIds = /* @__PURE__ */ new Set()) {
@@ -590,6 +651,7 @@ function validateKnownEntityPayload(entity) {
590
651
  if (value.lifecycle !== void 0 && !isRecord(value.lifecycle)) problems.push("lifecycle must be an object when present");
591
652
  if (entity.entityKind === "audio-script" || entity.entityKind === "phonetic-script") {
592
653
  for (const key of [
654
+ "durationMs",
593
655
  "extent",
594
656
  "sampling",
595
657
  "coordinateSpace",
@@ -598,7 +660,7 @@ function validateKnownEntityPayload(entity) {
598
660
  "duration",
599
661
  "startMs",
600
662
  "endMs"
601
- ]) if (Object.hasOwn(value, key)) problems.push(`${key} is not intrinsic script data; assign an external annotation Marker`);
663
+ ]) if (Object.hasOwn(value, key)) problems.push(`${key} is not intrinsic script data; assign an annotation Marker instead`);
602
664
  }
603
665
  switch (entity.entityKind) {
604
666
  case "track":
@@ -608,19 +670,25 @@ function validateKnownEntityPayload(entity) {
608
670
  break;
609
671
  case "video":
610
672
  case "audio":
611
- case "voice":
673
+ validateDurationPayload(value, "own", problems);
674
+ break;
612
675
  case "caption":
613
- validateSequencePayload(value, "bounded", "native", problems);
614
- if (entity.entityKind !== "caption") validateMediaAssetFields(value, problems);
615
- if (entity.entityKind === "voice") validateVoicePayload(value, problems);
616
- if (entity.entityKind === "caption") validateCaptionPayload(value, problems);
676
+ validateDurationPayload(value, "own", problems);
677
+ validateCaptionPayload(value, problems);
678
+ break;
679
+ case "voice":
680
+ validateVoicePayload(value, problems);
617
681
  break;
618
682
  case "image":
619
- validateSequencePayload(value, "unbounded", "constant", problems);
620
- validateMediaAssetFields(value, problems);
683
+ validateDurationPayload(value, "none", problems);
621
684
  break;
622
685
  case "axvideo":
623
- validateSequencePayload(value, "bounded", "derived", problems);
686
+ for (const retired of [
687
+ "extent",
688
+ "sampling",
689
+ "coordinateSpace",
690
+ "durationMs"
691
+ ]) if (Object.hasOwn(value, retired)) problems.push(`${retired} is derived from the Timeline, not stored`);
624
692
  break;
625
693
  case "sequence-marker":
626
694
  validateMarkerPayload(value, problems);
@@ -645,17 +713,27 @@ function validateKnownEntityPayload(entity) {
645
713
  }
646
714
  return problems;
647
715
  }
648
- function validateSequencePayload(value, expectedExtent, expectedSampling, problems) {
649
- const extent = value.extent;
650
- if (!isRecord(extent)) problems.push("extent must be an object");
651
- else {
652
- if (extent.kind !== expectedExtent) problems.push(`extent.kind must be "${expectedExtent}"`);
653
- if (!Object.hasOwn(extent, "start") || extent.start === void 0) problems.push("extent.start is required");
654
- if (expectedExtent === "bounded" && (!Object.hasOwn(extent, "end") || extent.end === void 0)) problems.push("extent.end is required for bounded sequences");
655
- if (expectedExtent === "unbounded" && Object.hasOwn(extent, "end")) problems.push("extent.end is forbidden for unbounded sequences");
716
+ /**
717
+ * Content states its own length and nothing else. The retired `extent` carried
718
+ * a start as well, which had no meaning of its own and drifted into holding a
719
+ * position; rejecting it keeps that mistake unwritable.
720
+ */
721
+ function validateDurationPayload(value, expected, problems) {
722
+ for (const retired of [
723
+ "extent",
724
+ "sampling",
725
+ "coordinateSpace"
726
+ ]) if (Object.hasOwn(value, retired)) problems.push(`${retired} is retired; state durationMs instead`);
727
+ if (!Object.hasOwn(value, "durationMs")) {
728
+ problems.push("durationMs is required");
729
+ return;
730
+ }
731
+ const duration = value.durationMs;
732
+ if (expected === "none") {
733
+ if (duration !== null) problems.push("durationMs must be null; this content has no intrinsic length");
734
+ return;
656
735
  }
657
- if (value.sampling !== expectedSampling) problems.push(`sampling must be "${expectedSampling}"`);
658
- if (!Object.hasOwn(value, "coordinateSpace") || value.coordinateSpace === void 0) problems.push("coordinateSpace is required");
736
+ if (!isPositiveInteger(duration)) problems.push("durationMs must be positive whole milliseconds");
659
737
  }
660
738
  function validateMarkerPayload(value, problems) {
661
739
  validateRange(value.sourceRange, "sourceRange", true, problems);
@@ -691,40 +769,38 @@ function validateSegmentRanges(value, problems) {
691
769
  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`);
692
770
  }
693
771
  }
772
+ /** The Asset entity is the locator; it states where the bytes live and nothing else. */
694
773
  function validateAssetPayload(value, problems) {
695
- validateExternalLocator(value, problems);
696
- validateStorageKey(value, problems);
774
+ if (value.system !== "memota" && value.system !== "memota-speech") problems.push("system must be \"memota\" or \"memota-speech\"");
775
+ if (typeof value.key !== "string" || value.key.trim() === "") problems.push("key must be a non-empty string");
697
776
  if (value.inline !== void 0) problems.push("inline is not a physical location; text is domain content owned by its AudioScript");
698
- }
699
- /**
700
- * Media variants own their Asset locator directly: `external` is required and
701
- * textual domain content is owned by AudioScript; the physical-location
702
- * vocabularies cannot mix.
703
- */
704
- function validateMediaAssetFields(value, problems) {
705
- if (value.external === void 0) problems.push("external is required; media variants own their Asset identity");
706
- else validateExternalLocator(value, problems);
707
777
  validateStorageKey(value, problems);
708
- if (value.inline !== void 0) problems.push("media variants use external locators, not inline domain text");
709
- }
710
- function validateExternalLocator(value, problems) {
711
- const external = value.external;
712
- if (external === void 0) return;
713
- if (!isRecord(external)) {
714
- problems.push("external must be an object when present");
715
- return;
716
- }
717
- if (external.system !== "memota" && external.system !== "memota-speech") problems.push("external.system must be \"memota\" or \"memota-speech\"");
718
- if (typeof external.key !== "string" || external.key.trim() === "") problems.push("external.key must be a non-empty string");
719
778
  }
720
779
  function validateStorageKey(value, problems) {
721
780
  if (value.storageKey !== void 0 && (typeof value.storageKey !== "string" || value.storageKey.trim() === "")) problems.push("storageKey must be a non-empty string when present");
722
781
  }
782
+ /**
783
+ * Voice is a timbre identity, not playable content: it owns the voice-library
784
+ * descriptor and nothing that would make it look like media or a Sequence. The
785
+ * rendered voiceover is an `audio` entity bound by a `voice-timbre` Relation.
786
+ */
723
787
  function validateVoicePayload(value, problems) {
788
+ for (const key of [
789
+ "durationMs",
790
+ "extent",
791
+ "sampling",
792
+ "coordinateSpace",
793
+ "system",
794
+ "key",
795
+ "storageKey"
796
+ ]) if (Object.hasOwn(value, key)) problems.push(`${key} belongs to the rendered Audio, not to the Voice identity`);
724
797
  const voice = value.voice;
725
- if (voice === void 0) return;
798
+ if (voice === void 0) {
799
+ problems.push("voice is required; a Voice entity is its voice-library identity");
800
+ return;
801
+ }
726
802
  if (!isRecord(voice)) {
727
- problems.push("voice must be an object when present");
803
+ problems.push("voice must be an object");
728
804
  return;
729
805
  }
730
806
  if (voice.system !== "voice-library") problems.push("voice.system must be \"voice-library\"");
@@ -732,7 +808,6 @@ function validateVoicePayload(value, problems) {
732
808
  if (voice.name !== void 0 && typeof voice.name !== "string") problems.push("voice.name must be a string when present");
733
809
  }
734
810
  function validateCaptionPayload(value, problems) {
735
- validateExternalLocator(value, problems);
736
811
  if (value.segmentRanges !== void 0) validateSegmentRanges(value.segmentRanges, problems);
737
812
  if (Object.hasOwn(value, "selections")) problems.push("selections is forbidden; Caption requires one selection object");
738
813
  const selection = value.selection;
@@ -856,15 +931,13 @@ function isOwnedLocalEntityValuePath(entityKind, path) {
856
931
  if ([
857
932
  "video",
858
933
  "audio",
859
- "voice",
860
934
  "image",
861
935
  "caption",
862
936
  "axvideo"
863
- ].includes(entityKind) && /^(?:sampling|coordinateSpace|coordinateSpace\.unit|extent\.kind)$/.test(path)) return true;
937
+ ].includes(entityKind) && /^durationMs$/.test(path)) return true;
864
938
  if (entityKind === "sequence-marker" && /^(?:durationPolicy|duration\.mode|timeRemapping\.(?:kind|mode))$/.test(path)) return true;
865
939
  if ((entityKind === "sequence-marker" || entityKind === "caption") && /^segmentRanges\[\d+\]\.segmentId$/.test(path)) return true;
866
- if (entityKind === "asset" && /^(?:external\.(?:system|key)|storageKey)$/.test(path)) return true;
867
- if (isMediaAssetVariantKind(entityKind) && /^(?:external\.(?:system|key)|storageKey)$/.test(path)) return true;
940
+ if (entityKind === "asset" && /^(?:system|key|storageKey)$/.test(path)) return true;
868
941
  if (entityKind === "voice" && /^voice\.(?:system|key|name)$/.test(path)) return true;
869
942
  if (entityKind === "caption" && (path.startsWith("style.") || path === "selection.segmentId")) return true;
870
943
  if ([
@@ -919,19 +992,16 @@ function collectRelatedEntities(entityRefs, index) {
919
992
  issues
920
993
  };
921
994
  }
922
- function expectedSequenceShape(kind) {
923
- if (kind === "video" || kind === "audio" || kind === "voice" || kind === "caption") return {
924
- extent: "bounded",
925
- sampling: "native"
926
- };
927
- if (kind === "image") return {
928
- extent: "unbounded",
929
- sampling: "constant"
930
- };
931
- if (kind === "axvideo") return {
932
- extent: "bounded",
933
- sampling: "derived"
934
- };
995
+ /**
996
+ * What a kind must state about its own length. An AXVideo is absent here on
997
+ * purpose: its Timeline decides its length, so it stores none.
998
+ */
999
+ function expectedDurationShape(kind) {
1000
+ if (kind === "video" || kind === "audio" || kind === "caption") return "own";
1001
+ if (kind === "image") return "none";
1002
+ }
1003
+ function isPositiveInteger(value) {
1004
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
935
1005
  }
936
1006
  function ofKind(relations, kind) {
937
1007
  return relations.filter((relation) => relation.kind === kind);
@@ -948,9 +1018,18 @@ const markerContentRelationSpec = Object.freeze({
948
1018
  validateEndpoints: (endpoints) => hasMarkerAndSequence(endpoints),
949
1019
  validateMetadata: isEmptyMetadata
950
1020
  });
951
- const physicalAssetRelationSpec = Object.freeze({
952
- kind: "physical-asset",
953
- validateEndpoints: (endpoints) => hasAssetAndSequence(endpoints),
1021
+ /**
1022
+ * `from-asset(entity, asset)` — the entity was made from that stored resource.
1023
+ *
1024
+ * Eligibility is declared: the non-Asset endpoint must carry the `FromAsset`
1025
+ * kind, which says an entity of that kind can be made from stored bytes and
1026
+ * asks nothing of its payload. The bytes stay named in exactly one place, so
1027
+ * no entity copies the locator into its own row and one Asset can back several
1028
+ * entities.
1029
+ */
1030
+ const fromAssetRelationSpec = Object.freeze({
1031
+ kind: "from-asset",
1032
+ validateEndpoints: (endpoints) => hasExactlyOneAsset(endpoints),
954
1033
  validateMetadata: isAssetBinding
955
1034
  });
956
1035
  /** `generated(output, input)` means endpoint 0 was generated from endpoint 1. */
@@ -961,7 +1040,7 @@ const generatedRelationSpec = Object.freeze({
961
1040
  });
962
1041
  const captionAlignmentRelationSpec = Object.freeze({
963
1042
  kind: "caption-alignment",
964
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["caption"]), new Set(["audio", "voice"])),
1043
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["caption"]), new Set(["audio"])),
965
1044
  validateMetadata: isCaptionAlignmentMetadata
966
1045
  });
967
1046
  /** `clip-anchor(child, host)` means endpoint 0 follows endpoint 1. */
@@ -970,20 +1049,29 @@ const clipAnchorRelationSpec = Object.freeze({
970
1049
  validateEndpoints: (endpoints) => endpoints[0].current().entityKind === "clip" && endpoints[1].current().entityKind === "clip",
971
1050
  validateMetadata: isEmptyMetadata
972
1051
  });
973
- /** Voice is generated from PhoneticScript; kinds determine roles regardless of endpoint positions. */
1052
+ /**
1053
+ * The rendered voiceover Audio and the PhoneticScript it was synthesized from;
1054
+ * kinds determine roles regardless of endpoint positions.
1055
+ */
974
1056
  const phoneticScriptRenderRelationSpec = Object.freeze({
975
1057
  kind: "phonetic-script-render",
976
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["voice"]), new Set(["phonetic-script"])),
1058
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["audio"]), new Set(["phonetic-script"])),
1059
+ validateMetadata: isEmptyMetadata
1060
+ });
1061
+ /**
1062
+ * The timbre identity a rendered voiceover Audio was synthesized with. Voice
1063
+ * stays an identity: it never carries the audio itself, so the link between the
1064
+ * two is a Relation rather than one shared entity.
1065
+ */
1066
+ const voiceTimbreRelationSpec = Object.freeze({
1067
+ kind: "voice-timbre",
1068
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["audio"]), new Set(["voice"])),
977
1069
  validateMetadata: isEmptyMetadata
978
1070
  });
979
1071
  /** AudioScript was transcribed from Audio, Video, or recorded Voice; kinds determine roles. */
980
1072
  const audioScriptSourceRelationSpec = Object.freeze({
981
1073
  kind: "audio-script-source",
982
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["audio-script"]), new Set([
983
- "audio",
984
- "video",
985
- "voice"
986
- ])),
1074
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["audio-script"]), new Set(["audio", "video"])),
987
1075
  validateMetadata: isEmptyMetadata
988
1076
  });
989
1077
  /**
@@ -1005,11 +1093,12 @@ const builtInRelationSpecs = Object.freeze([
1005
1093
  markerContentRelationSpec,
1006
1094
  axVideoMarkerRelationSpec,
1007
1095
  markerTimelineRelationSpec,
1008
- physicalAssetRelationSpec,
1096
+ fromAssetRelationSpec,
1009
1097
  generatedRelationSpec,
1010
1098
  captionAlignmentRelationSpec,
1011
1099
  clipAnchorRelationSpec,
1012
1100
  phoneticScriptRenderRelationSpec,
1101
+ voiceTimbreRelationSpec,
1013
1102
  audioScriptSourceRelationSpec,
1014
1103
  audioScriptMarkerRelationSpec
1015
1104
  ]);
@@ -1033,13 +1122,15 @@ function hasMarkerAndSequence(endpoints) {
1033
1122
  const second = endpoints[1].current();
1034
1123
  return first.entityKind === "sequence-marker" && hasSequence(second) || second.entityKind === "sequence-marker" && hasSequence(first);
1035
1124
  }
1036
- function hasAssetAndSequence(endpoints) {
1037
- const first = endpoints[0].current();
1038
- const second = endpoints[1].current();
1039
- return first.entityKind === "asset" && hasSequence(second) || second.entityKind === "asset" && hasSequence(first);
1125
+ /** Exactly one end is the Asset, so the other end is unambiguously the maker. */
1126
+ function hasExactlyOneAsset(endpoints) {
1127
+ const kinds = endpoints.map((endpoint) => endpoint.current().entityKind);
1128
+ const assets = kinds.map((kind) => kind === "asset");
1129
+ if (assets[0] === assets[1]) return false;
1130
+ return declaresKind(assets[0] ? kinds[1] : kinds[0], "FromAsset");
1040
1131
  }
1041
1132
  function isGeneratedMedia(entity) {
1042
- return entity.entityKind === "video" || entity.entityKind === "image" || entity.entityKind === "audio" || entity.entityKind === "voice";
1133
+ return entity.entityKind === "video" || entity.entityKind === "image" || entity.entityKind === "audio";
1043
1134
  }
1044
1135
  function isEmptyMetadata(value) {
1045
1136
  return isJsonObject(value) && Object.keys(value).length === 0;
@@ -1375,7 +1466,7 @@ function errorMessage(error) {
1375
1466
  //#endregion
1376
1467
  //#region src/sandbox/business-facades.ts
1377
1468
  /** Resource IDs are entity data; physical storage paths remain host-owned. */
1378
- const infrastructureFields = new Set(["storageKey"]);
1469
+ const infrastructureFields = new Set(["storageKey", "assetContentHash"]);
1379
1470
  function businessEntity(entity) {
1380
1471
  return {
1381
1472
  ...entity,
@@ -1388,11 +1479,11 @@ function businessState(state) {
1388
1479
  revision: state.revision,
1389
1480
  audioScriptEntityId: state.audioScriptEntityId,
1390
1481
  entities: state.entities.filter((row) => !assets.has(row.entity_id)).map(businessEntity),
1391
- relations: state.relations.filter((row) => row.relation_kind !== "physical-asset" && !assets.has(row.endpoint_0_entity_id) && !assets.has(row.endpoint_1_entity_id))
1482
+ relations: state.relations.filter((row) => row.relation_kind !== "from-asset" && !assets.has(row.endpoint_0_entity_id) && !assets.has(row.endpoint_1_entity_id))
1392
1483
  };
1393
1484
  }
1394
1485
  /** Keep infrastructure rows available to assembly without exposing their management to scripts. */
1395
- function businessFacades(entities, relations) {
1486
+ function businessFacades(entities, relations, onDirectScriptWrite) {
1396
1487
  const isAsset = (id) => entities.get(id)?.entity_kind === "asset";
1397
1488
  const assertBusiness = (id) => {
1398
1489
  if (isAsset(id)) throw new Error("Asset entities are managed by host assembly");
@@ -1403,7 +1494,7 @@ function businessFacades(entities, relations) {
1403
1494
  for (const id of payload.baseEntityIds) if (typeof id === "string") assertBusiness(id);
1404
1495
  }
1405
1496
  };
1406
- const visibleRelation = (relation) => relation.relation_kind !== "physical-asset" && !isAsset(relation.endpoint_0_entity_id) && !isAsset(relation.endpoint_1_entity_id);
1497
+ const visibleRelation = (relation) => relation.relation_kind !== "from-asset" && !isAsset(relation.endpoint_0_entity_id) && !isAsset(relation.endpoint_1_entity_id);
1407
1498
  return {
1408
1499
  entities: {
1409
1500
  list: () => entities.list().filter((entity) => entity.entity_kind !== "asset").map(businessEntity),
@@ -1415,7 +1506,9 @@ function businessFacades(entities, relations) {
1415
1506
  if (input.entity_kind === "asset") throw new Error("Asset entities are managed by host assembly");
1416
1507
  assertBusinessPayload(input.payload);
1417
1508
  if (input.entity_id && entities.get(input.entity_id)) throw new Error(`Entity id ${input.entity_id} already exists`);
1418
- return entities.create(input);
1509
+ const id = entities.create(input);
1510
+ if (input.entity_kind === "audio-script") onDirectScriptWrite?.(id);
1511
+ return id;
1419
1512
  },
1420
1513
  update: (input) => {
1421
1514
  assertBusiness(input.entity_id);
@@ -1425,11 +1518,13 @@ function businessFacades(entities, relations) {
1425
1518
  if (change.path?.[0] === "baseEntityIds" && change.op === "list.insert" && typeof change.value === "string") assertBusiness(change.value);
1426
1519
  }
1427
1520
  entities.changeFields(input);
1521
+ if (entities.get(input.entity_id)?.entity_kind === "audio-script" && input.changes.some((change) => change.path[0] === "segments")) onDirectScriptWrite?.(input.entity_id);
1428
1522
  },
1429
1523
  declareFields: (input) => {
1430
1524
  assertBusiness(input.entity_id);
1431
1525
  assertBusinessPayload(input.payload);
1432
1526
  entities.declareFields(input);
1527
+ if (entities.get(input.entity_id)?.entity_kind === "audio-script" && Object.hasOwn(input.payload, "segments")) onDirectScriptWrite?.(input.entity_id);
1433
1528
  },
1434
1529
  delete: (input) => {
1435
1530
  assertBusiness(input.entity_id);
@@ -1446,7 +1541,7 @@ function businessFacades(entities, relations) {
1446
1541
  list: () => relations.list().filter(visibleRelation),
1447
1542
  of: (id, kind) => relations.of(id, kind).filter(visibleRelation),
1448
1543
  link: (input) => {
1449
- if (input.relation_kind === "physical-asset") throw new Error("Asset bindings are managed by host assembly");
1544
+ if (input.relation_kind === "from-asset") throw new Error("Asset bindings are managed by host assembly");
1450
1545
  assertBusiness(input.endpoint_0_entity_id);
1451
1546
  assertBusiness(input.endpoint_1_entity_id);
1452
1547
  return relations.link(input);
@@ -1466,8 +1561,6 @@ function businessFacades(entities, relations) {
1466
1561
  }
1467
1562
  //#endregion
1468
1563
  //#region src/entity/entity-sandbox.ts
1469
- const ASSET_SOURCE_KEY = "external";
1470
- const MEMOTA_SYSTEM = "memota";
1471
1564
  /** Mutable entity/relation draft whose only durable product is an explicit command plan. */
1472
1565
  var EntitySandbox = class {
1473
1566
  original;
@@ -1508,6 +1601,14 @@ var EntitySandbox = class {
1508
1601
  captionAudioScriptId(entityId) {
1509
1602
  return findComposedAudioScript(toDslRows(this.state), createEntityId(entityId), "caption").entityId;
1510
1603
  }
1604
+ /** The Asset an entity was made from; the bytes are named there alone. */
1605
+ assetOf(entityId) {
1606
+ const link = this.state.relations.find((relation) => relation.relation_kind === "from-asset" && (relation.endpoint_0_entity_id === entityId || relation.endpoint_1_entity_id === entityId));
1607
+ if (!link) return null;
1608
+ const otherId = link.endpoint_0_entity_id === entityId ? link.endpoint_1_entity_id : link.endpoint_0_entity_id;
1609
+ const other = this.entities.get(otherId);
1610
+ return other?.entity_kind === "asset" ? other : null;
1611
+ }
1511
1612
  get audioScriptEntityId() {
1512
1613
  return this.state.audioScriptEntityId;
1513
1614
  }
@@ -1520,6 +1621,10 @@ var EntitySandbox = class {
1520
1621
  this.commands.push(...prefix);
1521
1622
  this.onTruncate?.(index);
1522
1623
  }
1624
+ /** The working rows, for callers that must ask the graph rather than one row. */
1625
+ rows() {
1626
+ return toDslRows(this.state);
1627
+ }
1523
1628
  buildPlan() {
1524
1629
  const rows = toDslRows(this.state);
1525
1630
  decodeEntityRelationRows(rows, numericMarkerComparators);
@@ -1584,12 +1689,15 @@ var EntitySandbox = class {
1584
1689
  },
1585
1690
  findByAssetId: (assetId) => {
1586
1691
  assertTrimmed(assetId, "assetId");
1587
- return clone(this.state.entities.map((entity) => this.assembledEntity(entity)).filter((entity) => isMediaAssetVariantKind(entity.entity_kind) && isImportedMemotaAsset(entity.payload, assetId)));
1692
+ const assetIds = new Set(this.state.entities.filter((entity) => entity.entity_kind === "asset" && isImportedMemotaAsset(entity.payload, assetId)).map((entity) => entity.entity_id));
1693
+ const madeIds = new Set(this.state.relations.filter((relation) => relation.relation_kind === "from-asset").flatMap((relation) => assetIds.has(relation.endpoint_1_entity_id) ? [relation.endpoint_0_entity_id] : assetIds.has(relation.endpoint_0_entity_id) ? [relation.endpoint_1_entity_id] : []));
1694
+ return clone(this.state.entities.filter((entity) => madeIds.has(entity.entity_id) && isMediaAssetVariantKind(entity.entity_kind)).map((entity) => this.assembledEntity(entity)));
1588
1695
  },
1589
1696
  readCaptionContent: (entityId) => {
1590
1697
  const assembled = assembleCaptionContent(toDslRows(this.state), createEntityId(entityId));
1591
1698
  return clone({
1592
1699
  audio_script_entity_id: assembled.audioScript.entityId,
1700
+ segment_index: assembled.segmentIndex,
1593
1701
  text: assembled.text,
1594
1702
  segments: assembled.segments.map((segment) => ({ ...segment }))
1595
1703
  });
@@ -1645,7 +1753,9 @@ var EntitySandbox = class {
1645
1753
  });
1646
1754
  },
1647
1755
  delete: (input) => this.deleteEntity(input),
1648
- ensureMedia: (fact) => this.ensureMedia(fact)
1756
+ ensureMedia: (fact) => this.ensureMedia(fact),
1757
+ ensureAsset: (locator) => this.ensureAsset(locator),
1758
+ assetIdOf: (entityId) => this.assetOf(entityId)?.entity_id ?? null
1649
1759
  };
1650
1760
  }
1651
1761
  buildRelationFacade() {
@@ -1674,15 +1784,10 @@ var EntitySandbox = class {
1674
1784
  if (matches.length > 1) throw new Error(`Ambiguous editor ${input.entity_kind}; resolve existing identities`);
1675
1785
  if (matches[0] !== void 0) return this.reuseEntity(matches[0], input, payload);
1676
1786
  }
1677
- const external = payload.external;
1678
- if (isMediaAssetVariantKind(input.entity_kind) && isJsonObject(external) && (external.system === "memota" || external.system === "memota-speech") && typeof external.key === "string") {
1679
- const matches = this.state.entities.filter((entity) => isMediaAssetVariantKind(entity.entity_kind) && isJsonObject(entity.payload.external) && entity.payload.external.system === external.system && entity.payload.external.key === external.key);
1680
- if (matches.length > 1) throw new Error(`Ambiguous Asset bindings for ${external.key}`);
1681
- const existing = matches[0];
1682
- if (existing !== void 0) {
1683
- if (existing.entity_kind !== input.entity_kind) throw new Error(`Asset kind conflicts for ${external.key}; cannot change ${existing.entity_kind} to ${input.entity_kind}`);
1684
- return this.reuseEntity(existing, input, payload);
1685
- }
1787
+ if (input.entity_kind === "asset" && typeof payload.key === "string") {
1788
+ const matches = this.state.entities.filter((entity) => entity.entity_kind === "asset" && entity.payload.system === payload.system && entity.payload.key === payload.key);
1789
+ if (matches.length > 1) throw new Error(`Ambiguous Assets for ${payload.key}`);
1790
+ if (matches[0] !== void 0) return this.reuseEntity(matches[0], input, payload);
1686
1791
  }
1687
1792
  const entityId = input.entity_id ?? this.idFactory("entity");
1688
1793
  assertTrimmed(entityId, "entity_id");
@@ -1753,6 +1858,18 @@ var EntitySandbox = class {
1753
1858
  entity_id: input.entity_id
1754
1859
  });
1755
1860
  }
1861
+ /** Get or create the one Asset entity naming these bytes. */
1862
+ ensureAsset(locator) {
1863
+ const found = this.state.entities.find((row) => row.entity_kind === "asset" && row.payload.system === locator.system && row.payload.key === locator.key);
1864
+ if (found) return found.entity_id;
1865
+ return this.createEntity({
1866
+ entity_kind: "asset",
1867
+ payload: {
1868
+ ...locator,
1869
+ ...locator.storageKey === void 0 ? {} : { storageKey: locator.storageKey }
1870
+ }
1871
+ });
1872
+ }
1756
1873
  ensureMedia(fact) {
1757
1874
  const checkpoint = this.commandCount;
1758
1875
  try {
@@ -1779,7 +1896,7 @@ var EntitySandbox = class {
1779
1896
  }
1780
1897
  for (const row of rows.relations) {
1781
1898
  if (this.state.relations.some((relation) => relation.relation_id === row.relationId)) continue;
1782
- if (row.relationKind !== "timeline-track") throw new Error(`Unexpected resource Relation ${row.relationKind}`);
1899
+ if (row.relationKind !== "timeline-track" && row.relationKind !== "voice-timbre" && row.relationKind !== "from-asset") throw new Error(`Unexpected resource Relation ${row.relationKind}`);
1783
1900
  this.link({
1784
1901
  relation_id: row.relationId,
1785
1902
  relation_kind: row.relationKind,
@@ -2036,8 +2153,7 @@ var EntitySandbox = class {
2036
2153
  }
2037
2154
  };
2038
2155
  function isImportedMemotaAsset(payload, assetId) {
2039
- const external = payload[ASSET_SOURCE_KEY];
2040
- return external != null && !Array.isArray(external) && typeof external === "object" && (external.system === MEMOTA_SYSTEM || external.system === "memota-speech") && external.key === assetId;
2156
+ return (payload.system === "memota" || payload.system === "memota-speech") && payload.key === assetId;
2041
2157
  }
2042
2158
  function toDslEntity(entity) {
2043
2159
  return {
@@ -2084,6 +2200,6 @@ const numericMarkerComparators = { compareMarkerPoints: (_marker, _range, left,
2084
2200
  return left - right;
2085
2201
  } };
2086
2202
  //#endregion
2087
- export { isJsonObject as a, isMediaAssetVariantKind as c, businessState as i, toDslRows as n, createEntityId as o, businessFacades as r, createRelationId as s, EntitySandbox as t };
2203
+ export { createEntityId as a, businessState as i, toDslRows as n, createRelationId as o, businessFacades as r, isMediaAssetVariantKind as s, EntitySandbox as t };
2088
2204
 
2089
- //# sourceMappingURL=entity-sandbox-TaUVT3on.mjs.map
2205
+ //# sourceMappingURL=entity-sandbox-BTR2cRl1.mjs.map