@mengine/medeo-tool 1.4.1-alpha.4 → 2.0.1-alpha.10

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,15 +268,18 @@ 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 segments = selectAudioScriptSegments({
271
+ const selection = captionSelection(caption);
272
+ const composed = {
213
273
  ...script,
214
274
  payload: caption.payload
215
- }, captionSelections(caption));
275
+ };
276
+ const segment = selectAudioScriptSegment(composed, selection);
216
277
  return {
217
278
  caption,
218
279
  audioScript: script,
219
- segments,
220
- text: segments.map((segment) => segment.text).join("")
280
+ segments: [segment],
281
+ segmentIndex: scriptSegments(composed).findIndex((entry) => entry.segmentId === selection.segmentId),
282
+ text: segment.text
221
283
  };
222
284
  }
223
285
  /**
@@ -239,20 +301,18 @@ function assemblePhoneticScriptContent(rows, phoneticScriptEntityId) {
239
301
  text: segments.map((segment) => segment.text).join("")
240
302
  };
241
303
  }
242
- function captionSelections(caption) {
243
- const selections = caption.payload.selections;
244
- if (!Array.isArray(selections) || selections.length === 0) throw new ScriptCompositionError("empty_selection", caption.entityId, `Caption "${caption.entityId}" requires a non-empty segment selection into its AudioScript`);
245
- if (!selections.every(isSegmentSelection)) throw new ScriptCompositionError("invalid_selection", caption.entityId, "Caption selections require a segmentId and optional textRange");
246
- return selections;
304
+ function captionSelection(caption) {
305
+ if (Object.hasOwn(caption.payload, "selections") || !isSegmentSelection(caption.payload.selection)) throw new ScriptCompositionError("invalid_selection", caption.entityId, "Caption requires one selection object with segmentId and optional textRange; selections arrays are forbidden");
306
+ return caption.payload.selection;
247
307
  }
248
308
  function isSegmentSelection(value) {
249
309
  return typeof value === "object" && value != null && !Array.isArray(value) && typeof value.segmentId === "string" && value.segmentId.trim() !== "" && Object.keys(value).every((key) => key === "segmentId" || key === "textRange");
250
310
  }
251
311
  /** Select source text without creating another authoritative text field. */
252
- function selectAudioScriptSegments(script, selections) {
253
- if (!Array.isArray(selections) || selections.length === 0 || !selections.every(isSegmentSelection) || new Set(selections.map((s) => s.segmentId)).size !== selections.length) throw new ScriptCompositionError("invalid_selection", script.entityId, "Caption selections must be non-empty and name unique source segments");
312
+ function selectAudioScriptSegment(script, selection) {
313
+ if (!isSegmentSelection(selection)) throw new ScriptCompositionError("invalid_selection", script.entityId, "Caption requires one selection object with segmentId and optional textRange");
254
314
  const bySegmentId = new Map(scriptSegments(script).map((segment) => [segment.segmentId, segment]));
255
- const selected = selections.map((selection) => {
315
+ const selected = (() => {
256
316
  const segment = bySegmentId.get(selection.segmentId);
257
317
  if (segment === void 0) throw new ScriptCompositionError("unknown_segment", script.entityId, `Caption selection "${selection.segmentId}" does not name a segment of AudioScript "${script.entityId}"`);
258
318
  if (selection.textRange === void 0) return segment;
@@ -263,8 +323,8 @@ function selectAudioScriptSegments(script, selections) {
263
323
  ...segment,
264
324
  text: points.slice(range.start, range.end).join("")
265
325
  };
266
- });
267
- if (!selected.map((segment) => segment.text).join("").trim()) throw new ScriptCompositionError("empty_selection", script.entityId, "Caption selection must contain visible source text");
326
+ })();
327
+ if (!selected.text.trim()) throw new ScriptCompositionError("empty_selection", script.entityId, "Caption selection must contain visible source text");
268
328
  return selected;
269
329
  }
270
330
  function requireEntityKind(rows, entityIdValue, entityKind) {
@@ -286,17 +346,15 @@ function entityToRow(entity) {
286
346
  }
287
347
  //#endregion
288
348
  //#region ../medeo-dsl/src/invariants.ts
289
- /**
290
- * Media variants own their Asset identity directly: a `physical-asset`
291
- * Relation may not bind them. Caption may associate with a physical text Asset
292
- * independently of its AudioScript composition.
293
- */
294
- function validateMediaAssetIdentity(entity, index) {
295
- return [...index.relationsOf(entity)].filter((relation) => relation.kind === "physical-asset").map(() => ({
296
- 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",
297
355
  entityId: entity.entityId,
298
- message: `Media entity "${entity.entityId}" owns its Asset identity; remove its physical-asset Relation`
299
- }));
356
+ message: `Entity "${entity.entityId}" is made from more than one Asset`
357
+ }];
300
358
  }
301
359
  /** Validates one complete set of entities and its authoritative Relation rows. */
302
360
  function validateEntityRelationSet(entityRefs, index, options) {
@@ -319,7 +377,7 @@ function validateEntityRelationSet(entityRefs, index, options) {
319
377
  const current = entity.current();
320
378
  const entityIssues = validateEntity(entity, entityIds);
321
379
  issues.push(...entityIssues);
322
- if (isMediaAssetVariantKind(current.entityKind)) issues.push(...validateMediaAssetIdentity(entity, index));
380
+ issues.push(...validateAssetSource(entity, index));
323
381
  if (entityIssues.some((issue) => issue.code === "invalid_entity_payload")) continue;
324
382
  if (current.entityKind === "sequence-marker") {
325
383
  const marker = entity;
@@ -544,24 +602,25 @@ function validateMarkerSourceBounds(marker, index, compare) {
544
602
  const content = contentEdges[0]?.other(marker)?.deref()?.current();
545
603
  if (content == null || !hasSequence(content)) return [];
546
604
  const sourceRange = marker.current().sourceRange;
547
- const startVsExtent = compare(sourceRange.start, content.extent.start);
548
- const endVsExtent = content.extent.kind === "bounded" ? compare(sourceRange.end, content.extent.end) : void 0;
549
- 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 [];
550
608
  return [{
551
609
  code: "marker_source_out_of_bounds",
552
610
  entityId: marker.entityId,
553
- message: `Sequence Marker sourceRange must stay within content "${content.entityId}" extent`
611
+ message: `Sequence Marker sourceRange must stay within content "${content.entityId}" duration`
554
612
  }];
555
613
  }
556
614
  function validateSequenceComposition(entity) {
557
615
  const current = entity.current();
558
- const expected = expectedSequenceShape(current.entityKind);
559
- if (expected == null) return [];
560
- 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 [];
561
620
  return [{
562
621
  code: "invalid_sequence_composition",
563
622
  entityId: current.entityId,
564
- 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`
565
624
  }];
566
625
  }
567
626
  function validateEntity(entity, entityIds = /* @__PURE__ */ new Set()) {
@@ -592,6 +651,7 @@ function validateKnownEntityPayload(entity) {
592
651
  if (value.lifecycle !== void 0 && !isRecord(value.lifecycle)) problems.push("lifecycle must be an object when present");
593
652
  if (entity.entityKind === "audio-script" || entity.entityKind === "phonetic-script") {
594
653
  for (const key of [
654
+ "durationMs",
595
655
  "extent",
596
656
  "sampling",
597
657
  "coordinateSpace",
@@ -600,7 +660,7 @@ function validateKnownEntityPayload(entity) {
600
660
  "duration",
601
661
  "startMs",
602
662
  "endMs"
603
- ]) 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`);
604
664
  }
605
665
  switch (entity.entityKind) {
606
666
  case "track":
@@ -610,19 +670,25 @@ function validateKnownEntityPayload(entity) {
610
670
  break;
611
671
  case "video":
612
672
  case "audio":
613
- case "voice":
673
+ validateDurationPayload(value, "own", problems);
674
+ break;
614
675
  case "caption":
615
- validateSequencePayload(value, "bounded", "native", problems);
616
- if (entity.entityKind !== "caption") validateMediaAssetFields(value, problems);
617
- if (entity.entityKind === "voice") validateVoicePayload(value, problems);
618
- 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);
619
681
  break;
620
682
  case "image":
621
- validateSequencePayload(value, "unbounded", "constant", problems);
622
- validateMediaAssetFields(value, problems);
683
+ validateDurationPayload(value, "none", problems);
623
684
  break;
624
685
  case "axvideo":
625
- 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`);
626
692
  break;
627
693
  case "sequence-marker":
628
694
  validateMarkerPayload(value, problems);
@@ -647,17 +713,27 @@ function validateKnownEntityPayload(entity) {
647
713
  }
648
714
  return problems;
649
715
  }
650
- function validateSequencePayload(value, expectedExtent, expectedSampling, problems) {
651
- const extent = value.extent;
652
- if (!isRecord(extent)) problems.push("extent must be an object");
653
- else {
654
- if (extent.kind !== expectedExtent) problems.push(`extent.kind must be "${expectedExtent}"`);
655
- if (!Object.hasOwn(extent, "start") || extent.start === void 0) problems.push("extent.start is required");
656
- if (expectedExtent === "bounded" && (!Object.hasOwn(extent, "end") || extent.end === void 0)) problems.push("extent.end is required for bounded sequences");
657
- 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;
658
735
  }
659
- if (value.sampling !== expectedSampling) problems.push(`sampling must be "${expectedSampling}"`);
660
- 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");
661
737
  }
662
738
  function validateMarkerPayload(value, problems) {
663
739
  validateRange(value.sourceRange, "sourceRange", true, problems);
@@ -693,40 +769,38 @@ function validateSegmentRanges(value, problems) {
693
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`);
694
770
  }
695
771
  }
772
+ /** The Asset entity is the locator; it states where the bytes live and nothing else. */
696
773
  function validateAssetPayload(value, problems) {
697
- validateExternalLocator(value, problems);
698
- 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");
699
776
  if (value.inline !== void 0) problems.push("inline is not a physical location; text is domain content owned by its AudioScript");
700
- }
701
- /**
702
- * Media variants own their Asset locator directly: `external` is required and
703
- * textual domain content is owned by AudioScript; the physical-location
704
- * vocabularies cannot mix.
705
- */
706
- function validateMediaAssetFields(value, problems) {
707
- if (value.external === void 0) problems.push("external is required; media variants own their Asset identity");
708
- else validateExternalLocator(value, problems);
709
777
  validateStorageKey(value, problems);
710
- if (value.inline !== void 0) problems.push("media variants use external locators, not inline domain text");
711
- }
712
- function validateExternalLocator(value, problems) {
713
- const external = value.external;
714
- if (external === void 0) return;
715
- if (!isRecord(external)) {
716
- problems.push("external must be an object when present");
717
- return;
718
- }
719
- if (external.system !== "memota" && external.system !== "memota-speech") problems.push("external.system must be \"memota\" or \"memota-speech\"");
720
- if (typeof external.key !== "string" || external.key.trim() === "") problems.push("external.key must be a non-empty string");
721
778
  }
722
779
  function validateStorageKey(value, problems) {
723
780
  if (value.storageKey !== void 0 && (typeof value.storageKey !== "string" || value.storageKey.trim() === "")) problems.push("storageKey must be a non-empty string when present");
724
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
+ */
725
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`);
726
797
  const voice = value.voice;
727
- 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
+ }
728
802
  if (!isRecord(voice)) {
729
- problems.push("voice must be an object when present");
803
+ problems.push("voice must be an object");
730
804
  return;
731
805
  }
732
806
  if (voice.system !== "voice-library") problems.push("voice.system must be \"voice-library\"");
@@ -734,27 +808,16 @@ function validateVoicePayload(value, problems) {
734
808
  if (voice.name !== void 0 && typeof voice.name !== "string") problems.push("voice.name must be a string when present");
735
809
  }
736
810
  function validateCaptionPayload(value, problems) {
737
- validateExternalLocator(value, problems);
738
811
  if (value.segmentRanges !== void 0) validateSegmentRanges(value.segmentRanges, problems);
739
- const selections = value.selections;
740
- if (!Array.isArray(selections)) problems.push("selections must be a non-empty array of AudioScript segment selections");
812
+ if (Object.hasOwn(value, "selections")) problems.push("selections is forbidden; Caption requires one selection object");
813
+ const selection = value.selection;
814
+ if (!isRecord(selection)) problems.push("selection must be one AudioScript segment selection object, not an array");
741
815
  else {
742
- if (selections.length === 0) problems.push("selections must name at least one AudioScript segment");
743
- const seen = /* @__PURE__ */ new Set();
744
- for (const [index, selection] of selections.entries()) {
745
- if (!isRecord(selection)) {
746
- problems.push(`selections[${index}] must be an object`);
747
- continue;
748
- }
749
- if (typeof selection.segmentId !== "string" || selection.segmentId.trim() === "") problems.push(`selections[${index}].segmentId must be a string`);
750
- else if (seen.has(selection.segmentId)) problems.push(`selections[${index}].segmentId must be unique within the Caption`);
751
- else seen.add(selection.segmentId);
752
- if (Object.keys(selection).some((key) => key !== "segmentId" && key !== "textRange")) problems.push(`selections[${index}] may only contain segmentId and textRange`);
753
- if (selection.textRange !== void 0) {
754
- const range = selection.textRange;
755
- if (!isRecord(range) || !Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.end) || range.start < 0 || range.end <= range.start || Object.keys(range).some((key) => key !== "start" && key !== "end")) problems.push(`selections[${index}].textRange must be a non-empty half-open code-point range`);
756
- }
757
- }
816
+ if (typeof selection.segmentId !== "string" || !selection.segmentId.trim()) problems.push("selection.segmentId must be a non-empty string");
817
+ if (Object.keys(selection).some((key) => key !== "segmentId" && key !== "textRange")) problems.push("selection may only contain segmentId and textRange");
818
+ const range = selection.textRange;
819
+ if (range !== void 0 && (!isRecord(range) || !Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.end) || range.start < 0 || range.end <= range.start || Object.keys(range).some((key) => key !== "start" && key !== "end"))) problems.push("selection.textRange must be a non-empty half-open code-point range");
820
+ if (Array.isArray(value.segmentRanges) && (value.segmentRanges.length !== 1 || !isRecord(value.segmentRanges[0]) || value.segmentRanges[0].segmentId !== selection.segmentId)) problems.push("Caption segmentRanges must contain only the selected segment timing");
758
821
  }
759
822
  const style = value.style;
760
823
  if (style === void 0) return;
@@ -868,17 +931,15 @@ function isOwnedLocalEntityValuePath(entityKind, path) {
868
931
  if ([
869
932
  "video",
870
933
  "audio",
871
- "voice",
872
934
  "image",
873
935
  "caption",
874
936
  "axvideo"
875
- ].includes(entityKind) && /^(?:sampling|coordinateSpace|coordinateSpace\.unit|extent\.kind)$/.test(path)) return true;
937
+ ].includes(entityKind) && /^durationMs$/.test(path)) return true;
876
938
  if (entityKind === "sequence-marker" && /^(?:durationPolicy|duration\.mode|timeRemapping\.(?:kind|mode))$/.test(path)) return true;
877
939
  if ((entityKind === "sequence-marker" || entityKind === "caption") && /^segmentRanges\[\d+\]\.segmentId$/.test(path)) return true;
878
- if (entityKind === "asset" && /^(?:external\.(?:system|key)|storageKey)$/.test(path)) return true;
879
- if (isMediaAssetVariantKind(entityKind) && /^(?:external\.(?:system|key)|storageKey)$/.test(path)) return true;
940
+ if (entityKind === "asset" && /^(?:system|key|storageKey)$/.test(path)) return true;
880
941
  if (entityKind === "voice" && /^voice\.(?:system|key|name)$/.test(path)) return true;
881
- if (entityKind === "caption" && (path.startsWith("style.") || /^selections\[\d+\]\.segmentId$/.test(path))) return true;
942
+ if (entityKind === "caption" && (path.startsWith("style.") || path === "selection.segmentId")) return true;
882
943
  if ([
883
944
  "audio-script",
884
945
  "caption",
@@ -893,7 +954,7 @@ function isOwnedLocalIdPath(entityKind, path) {
893
954
  "caption",
894
955
  "phonetic-script"
895
956
  ].includes(entityKind) && /^segments\[\d+\]\.segmentId$/.test(path)) return true;
896
- if (entityKind === "caption" && /^selections\[\d+\]\.segmentId$/.test(path)) return true;
957
+ if (entityKind === "caption" && path === "selection.segmentId") return true;
897
958
  if ((entityKind === "sequence-marker" || entityKind === "caption") && /^segmentRanges\[\d+\]\.segmentId$/.test(path)) return true;
898
959
  if (entityKind === "asset" && /^(?:tracks\[\d+\]\.trackId|renditions\[\d+\]\.renditionId)$/.test(path)) return true;
899
960
  return false;
@@ -931,19 +992,16 @@ function collectRelatedEntities(entityRefs, index) {
931
992
  issues
932
993
  };
933
994
  }
934
- function expectedSequenceShape(kind) {
935
- if (kind === "video" || kind === "audio" || kind === "voice" || kind === "caption") return {
936
- extent: "bounded",
937
- sampling: "native"
938
- };
939
- if (kind === "image") return {
940
- extent: "unbounded",
941
- sampling: "constant"
942
- };
943
- if (kind === "axvideo") return {
944
- extent: "bounded",
945
- sampling: "derived"
946
- };
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;
947
1005
  }
948
1006
  function ofKind(relations, kind) {
949
1007
  return relations.filter((relation) => relation.kind === kind);
@@ -960,9 +1018,18 @@ const markerContentRelationSpec = Object.freeze({
960
1018
  validateEndpoints: (endpoints) => hasMarkerAndSequence(endpoints),
961
1019
  validateMetadata: isEmptyMetadata
962
1020
  });
963
- const physicalAssetRelationSpec = Object.freeze({
964
- kind: "physical-asset",
965
- 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),
966
1033
  validateMetadata: isAssetBinding
967
1034
  });
968
1035
  /** `generated(output, input)` means endpoint 0 was generated from endpoint 1. */
@@ -973,7 +1040,7 @@ const generatedRelationSpec = Object.freeze({
973
1040
  });
974
1041
  const captionAlignmentRelationSpec = Object.freeze({
975
1042
  kind: "caption-alignment",
976
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["caption"]), new Set(["audio", "voice"])),
1043
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["caption"]), new Set(["audio"])),
977
1044
  validateMetadata: isCaptionAlignmentMetadata
978
1045
  });
979
1046
  /** `clip-anchor(child, host)` means endpoint 0 follows endpoint 1. */
@@ -982,20 +1049,29 @@ const clipAnchorRelationSpec = Object.freeze({
982
1049
  validateEndpoints: (endpoints) => endpoints[0].current().entityKind === "clip" && endpoints[1].current().entityKind === "clip",
983
1050
  validateMetadata: isEmptyMetadata
984
1051
  });
985
- /** 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
+ */
986
1056
  const phoneticScriptRenderRelationSpec = Object.freeze({
987
1057
  kind: "phonetic-script-render",
988
- 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"])),
989
1069
  validateMetadata: isEmptyMetadata
990
1070
  });
991
1071
  /** AudioScript was transcribed from Audio, Video, or recorded Voice; kinds determine roles. */
992
1072
  const audioScriptSourceRelationSpec = Object.freeze({
993
1073
  kind: "audio-script-source",
994
- validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["audio-script"]), new Set([
995
- "audio",
996
- "video",
997
- "voice"
998
- ])),
1074
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["audio-script"]), new Set(["audio", "video"])),
999
1075
  validateMetadata: isEmptyMetadata
1000
1076
  });
1001
1077
  /**
@@ -1017,11 +1093,12 @@ const builtInRelationSpecs = Object.freeze([
1017
1093
  markerContentRelationSpec,
1018
1094
  axVideoMarkerRelationSpec,
1019
1095
  markerTimelineRelationSpec,
1020
- physicalAssetRelationSpec,
1096
+ fromAssetRelationSpec,
1021
1097
  generatedRelationSpec,
1022
1098
  captionAlignmentRelationSpec,
1023
1099
  clipAnchorRelationSpec,
1024
1100
  phoneticScriptRenderRelationSpec,
1101
+ voiceTimbreRelationSpec,
1025
1102
  audioScriptSourceRelationSpec,
1026
1103
  audioScriptMarkerRelationSpec
1027
1104
  ]);
@@ -1045,13 +1122,15 @@ function hasMarkerAndSequence(endpoints) {
1045
1122
  const second = endpoints[1].current();
1046
1123
  return first.entityKind === "sequence-marker" && hasSequence(second) || second.entityKind === "sequence-marker" && hasSequence(first);
1047
1124
  }
1048
- function hasAssetAndSequence(endpoints) {
1049
- const first = endpoints[0].current();
1050
- const second = endpoints[1].current();
1051
- 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");
1052
1131
  }
1053
1132
  function isGeneratedMedia(entity) {
1054
- return entity.entityKind === "video" || entity.entityKind === "image" || entity.entityKind === "audio" || entity.entityKind === "voice";
1133
+ return entity.entityKind === "video" || entity.entityKind === "image" || entity.entityKind === "audio";
1055
1134
  }
1056
1135
  function isEmptyMetadata(value) {
1057
1136
  return isJsonObject(value) && Object.keys(value).length === 0;
@@ -1387,7 +1466,7 @@ function errorMessage(error) {
1387
1466
  //#endregion
1388
1467
  //#region src/sandbox/business-facades.ts
1389
1468
  /** Resource IDs are entity data; physical storage paths remain host-owned. */
1390
- const infrastructureFields = new Set(["storageKey"]);
1469
+ const infrastructureFields = new Set(["storageKey", "assetContentHash"]);
1391
1470
  function businessEntity(entity) {
1392
1471
  return {
1393
1472
  ...entity,
@@ -1400,11 +1479,11 @@ function businessState(state) {
1400
1479
  revision: state.revision,
1401
1480
  audioScriptEntityId: state.audioScriptEntityId,
1402
1481
  entities: state.entities.filter((row) => !assets.has(row.entity_id)).map(businessEntity),
1403
- 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))
1404
1483
  };
1405
1484
  }
1406
1485
  /** Keep infrastructure rows available to assembly without exposing their management to scripts. */
1407
- function businessFacades(entities, relations) {
1486
+ function businessFacades(entities, relations, onDirectScriptWrite) {
1408
1487
  const isAsset = (id) => entities.get(id)?.entity_kind === "asset";
1409
1488
  const assertBusiness = (id) => {
1410
1489
  if (isAsset(id)) throw new Error("Asset entities are managed by host assembly");
@@ -1415,7 +1494,7 @@ function businessFacades(entities, relations) {
1415
1494
  for (const id of payload.baseEntityIds) if (typeof id === "string") assertBusiness(id);
1416
1495
  }
1417
1496
  };
1418
- 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);
1419
1498
  return {
1420
1499
  entities: {
1421
1500
  list: () => entities.list().filter((entity) => entity.entity_kind !== "asset").map(businessEntity),
@@ -1427,7 +1506,9 @@ function businessFacades(entities, relations) {
1427
1506
  if (input.entity_kind === "asset") throw new Error("Asset entities are managed by host assembly");
1428
1507
  assertBusinessPayload(input.payload);
1429
1508
  if (input.entity_id && entities.get(input.entity_id)) throw new Error(`Entity id ${input.entity_id} already exists`);
1430
- return entities.create(input);
1509
+ const id = entities.create(input);
1510
+ if (input.entity_kind === "audio-script") onDirectScriptWrite?.(id);
1511
+ return id;
1431
1512
  },
1432
1513
  update: (input) => {
1433
1514
  assertBusiness(input.entity_id);
@@ -1437,11 +1518,13 @@ function businessFacades(entities, relations) {
1437
1518
  if (change.path?.[0] === "baseEntityIds" && change.op === "list.insert" && typeof change.value === "string") assertBusiness(change.value);
1438
1519
  }
1439
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);
1440
1522
  },
1441
1523
  declareFields: (input) => {
1442
1524
  assertBusiness(input.entity_id);
1443
1525
  assertBusinessPayload(input.payload);
1444
1526
  entities.declareFields(input);
1527
+ if (entities.get(input.entity_id)?.entity_kind === "audio-script" && Object.hasOwn(input.payload, "segments")) onDirectScriptWrite?.(input.entity_id);
1445
1528
  },
1446
1529
  delete: (input) => {
1447
1530
  assertBusiness(input.entity_id);
@@ -1458,7 +1541,7 @@ function businessFacades(entities, relations) {
1458
1541
  list: () => relations.list().filter(visibleRelation),
1459
1542
  of: (id, kind) => relations.of(id, kind).filter(visibleRelation),
1460
1543
  link: (input) => {
1461
- 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");
1462
1545
  assertBusiness(input.endpoint_0_entity_id);
1463
1546
  assertBusiness(input.endpoint_1_entity_id);
1464
1547
  return relations.link(input);
@@ -1478,8 +1561,6 @@ function businessFacades(entities, relations) {
1478
1561
  }
1479
1562
  //#endregion
1480
1563
  //#region src/entity/entity-sandbox.ts
1481
- const ASSET_SOURCE_KEY = "external";
1482
- const MEMOTA_SYSTEM = "memota";
1483
1564
  /** Mutable entity/relation draft whose only durable product is an explicit command plan. */
1484
1565
  var EntitySandbox = class {
1485
1566
  original;
@@ -1520,6 +1601,14 @@ var EntitySandbox = class {
1520
1601
  captionAudioScriptId(entityId) {
1521
1602
  return findComposedAudioScript(toDslRows(this.state), createEntityId(entityId), "caption").entityId;
1522
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
+ }
1523
1612
  get audioScriptEntityId() {
1524
1613
  return this.state.audioScriptEntityId;
1525
1614
  }
@@ -1532,6 +1621,10 @@ var EntitySandbox = class {
1532
1621
  this.commands.push(...prefix);
1533
1622
  this.onTruncate?.(index);
1534
1623
  }
1624
+ /** The working rows, for callers that must ask the graph rather than one row. */
1625
+ rows() {
1626
+ return toDslRows(this.state);
1627
+ }
1535
1628
  buildPlan() {
1536
1629
  const rows = toDslRows(this.state);
1537
1630
  decodeEntityRelationRows(rows, numericMarkerComparators);
@@ -1596,12 +1689,15 @@ var EntitySandbox = class {
1596
1689
  },
1597
1690
  findByAssetId: (assetId) => {
1598
1691
  assertTrimmed(assetId, "assetId");
1599
- 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)));
1600
1695
  },
1601
1696
  readCaptionContent: (entityId) => {
1602
1697
  const assembled = assembleCaptionContent(toDslRows(this.state), createEntityId(entityId));
1603
1698
  return clone({
1604
1699
  audio_script_entity_id: assembled.audioScript.entityId,
1700
+ segment_index: assembled.segmentIndex,
1605
1701
  text: assembled.text,
1606
1702
  segments: assembled.segments.map((segment) => ({ ...segment }))
1607
1703
  });
@@ -1657,7 +1753,9 @@ var EntitySandbox = class {
1657
1753
  });
1658
1754
  },
1659
1755
  delete: (input) => this.deleteEntity(input),
1660
- 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
1661
1759
  };
1662
1760
  }
1663
1761
  buildRelationFacade() {
@@ -1686,15 +1784,10 @@ var EntitySandbox = class {
1686
1784
  if (matches.length > 1) throw new Error(`Ambiguous editor ${input.entity_kind}; resolve existing identities`);
1687
1785
  if (matches[0] !== void 0) return this.reuseEntity(matches[0], input, payload);
1688
1786
  }
1689
- const external = payload.external;
1690
- if (isMediaAssetVariantKind(input.entity_kind) && isJsonObject(external) && (external.system === "memota" || external.system === "memota-speech") && typeof external.key === "string") {
1691
- 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);
1692
- if (matches.length > 1) throw new Error(`Ambiguous Asset bindings for ${external.key}`);
1693
- const existing = matches[0];
1694
- if (existing !== void 0) {
1695
- 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}`);
1696
- return this.reuseEntity(existing, input, payload);
1697
- }
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);
1698
1791
  }
1699
1792
  const entityId = input.entity_id ?? this.idFactory("entity");
1700
1793
  assertTrimmed(entityId, "entity_id");
@@ -1765,6 +1858,18 @@ var EntitySandbox = class {
1765
1858
  entity_id: input.entity_id
1766
1859
  });
1767
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
+ }
1768
1873
  ensureMedia(fact) {
1769
1874
  const checkpoint = this.commandCount;
1770
1875
  try {
@@ -1791,7 +1896,7 @@ var EntitySandbox = class {
1791
1896
  }
1792
1897
  for (const row of rows.relations) {
1793
1898
  if (this.state.relations.some((relation) => relation.relation_id === row.relationId)) continue;
1794
- 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}`);
1795
1900
  this.link({
1796
1901
  relation_id: row.relationId,
1797
1902
  relation_kind: row.relationKind,
@@ -2048,8 +2153,7 @@ var EntitySandbox = class {
2048
2153
  }
2049
2154
  };
2050
2155
  function isImportedMemotaAsset(payload, assetId) {
2051
- const external = payload[ASSET_SOURCE_KEY];
2052
- 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;
2053
2157
  }
2054
2158
  function toDslEntity(entity) {
2055
2159
  return {
@@ -2098,4 +2202,4 @@ const numericMarkerComparators = { compareMarkerPoints: (_marker, _range, left,
2098
2202
  //#endregion
2099
2203
  export { createEntityId as a, businessState as i, toDslRows as n, createRelationId as o, businessFacades as r, isMediaAssetVariantKind as s, EntitySandbox as t };
2100
2204
 
2101
- //# sourceMappingURL=entity-sandbox-OArq9NSH.mjs.map
2205
+ //# sourceMappingURL=entity-sandbox-BTR2cRl1.mjs.map