@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.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as UpdateEntityInput, S as UnlinkRelationInput, _ as LinkRelationInput, a as DeleteEntityInput, b as SandboxEntity, c as EntitySandboxCheckpoint, d as JsonObject, f as JsonPrimitive, g as LinkGeneratedRelationInput, h as KnownRelationKind, i as CreateEntityInput, l as EntityStoreSnapshot, m as KnownEntityKind, n as BusinessEntityFacade, o as EntityCommand, p as JsonValue, r as BusinessRelationFacade, s as EntityPlanState, t as AuthorableRelationKind, u as EntityUpdateInput, v as RelationUpdateInput, x as SandboxRelation, y as ResourceEntityKind } from "./entity-contract-DlmUSouB.mjs";
1
+ import { C as UpdateEntityInput, S as UnlinkRelationInput, _ as LinkRelationInput, a as DeleteEntityInput, b as SandboxEntity, c as EntitySandboxCheckpoint, d as JsonObject, f as JsonPrimitive, g as LinkGeneratedRelationInput, h as KnownRelationKind, i as CreateEntityInput, l as EntityStoreSnapshot, m as KnownEntityKind, n as BusinessEntityFacade, o as EntityCommand, p as JsonValue, r as BusinessRelationFacade, s as EntityPlanState, t as AuthorableRelationKind, u as EntityUpdateInput, v as RelationUpdateInput, x as SandboxRelation, y as ResourceEntityKind } from "./entity-contract-Cf3AiSe7.mjs";
2
2
  import { JournalEntry, ManualSyncDoc, MediaAssetFact, PartIdFactory, SemanticOpName, VideoDocument, VideoDraft } from "@mengine/medeo-client";
3
3
 
4
4
  //#region src/document/compact-projection.d.ts
@@ -11,6 +11,14 @@ import { JournalEntry, ManualSyncDoc, MediaAssetFact, PartIdFactory, SemanticOpN
11
11
  * `fromVideoDocument`.
12
12
  */
13
13
  interface CompactProjectionOptions {
14
+ /**
15
+ * Document identity for the header. Stated by the caller: the document
16
+ * content no longer carries its own id or revision (business metadata left
17
+ * the CRDT), and the model needs to know which draft this summary describes.
18
+ * Omitted values keep the header's fixed shape with the historical blanks.
19
+ */
20
+ docId?: string;
21
+ revision?: number;
14
22
  /** Only render these parts (header still reports the full timeline total). Default = all. */
15
23
  onlyPartIds?: ReadonlySet<string>;
16
24
  /** Caption text preview truncation length. Default 24. */
@@ -30,7 +38,10 @@ declare function collectAffectedPartIds(journal: readonly JournalEntry[]): Set<s
30
38
  * Render a ChangePlan preview: header + rows for journal-affected parts only.
31
39
  * Empty journal → empty `onlyPartIds` (header alone), matching the T2 contract.
32
40
  */
33
- declare function renderPreview(document: VideoDocument, journal: readonly JournalEntry[]): string;
41
+ declare function renderPreview(document: VideoDocument, journal: readonly JournalEntry[], identity?: {
42
+ docId?: string;
43
+ revision?: number;
44
+ }): string;
34
45
  //#endregion
35
46
  //#region src/entity/entity-asset.d.ts
36
47
  /** Immutable resource content resolved by the host for a document Entity. */
@@ -39,7 +50,17 @@ interface EntityAssetContent {
39
50
  content: JsonValue;
40
51
  }
41
52
  /** Host-only I/O. The sandbox supplies an Entity, never an arbitrary Asset query. */
42
- type EntityAssetLoader = (docId: string, entity: SandboxEntity) => Promise<EntityAssetContent>;
53
+ /**
54
+ * Resolve one entity's stored resource.
55
+ *
56
+ * The Asset travels with the entity because the locator lives on the Asset
57
+ * alone: an entity made from stored bytes names them through a `from-asset`
58
+ * Relation, never on its own row, so a loader given only the entity would have
59
+ * nothing to look up.
60
+ */
61
+ type EntityAssetLoader = (docId: string, entity: SandboxEntity, asset: SandboxEntity) => Promise<EntityAssetContent>;
62
+ /** Program-only initial resource creation; never exposed as a sandbox API. */
63
+ type EntityAssetWriter = (docId: string, entity: SandboxEntity, content: JsonValue) => Promise<EntityAssetContent>;
43
64
  //#endregion
44
65
  //#region src/entity/entity-sandbox.d.ts
45
66
  type DomainIdFactory = (prefix: 'entity' | 'relation') => string;
@@ -61,6 +82,12 @@ interface ChangePlan {
61
82
  logs: string[];
62
83
  }
63
84
  interface EditSandboxSessionOptions {
85
+ /**
86
+ * Document this session edits. Stated by the host: the document content no
87
+ * longer carries its own identity (business metadata left the CRDT), and a
88
+ * plan must name the document it will be committed against.
89
+ */
90
+ docId?: string;
64
91
  idFactory?: PartIdFactory;
65
92
  onEntry?: (entry: JournalEntry) => void;
66
93
  onLog?: (line: string) => void;
@@ -87,7 +114,10 @@ interface ConsoleShim {
87
114
  * injects loaders that break worker boot.
88
115
  */
89
116
  interface RunEditScriptOptions {
90
- loadEntityAsset?: (entity: SandboxEntity) => Promise<EntityAssetContent>;
117
+ /** Document the plan will be committed against; stated by the host. */
118
+ docId: string;
119
+ loadEntityAsset?: (entity: SandboxEntity, asset: SandboxEntity) => Promise<EntityAssetContent>;
120
+ writeEntityAsset?: (entity: SandboxEntity, content: EntityAssetContent['content']) => Promise<EntityAssetContent>;
91
121
  document: VideoDocument;
92
122
  baseVersion: string;
93
123
  script: string;
@@ -376,13 +406,6 @@ interface CreateMedeoToolOptions {
376
406
  userId?: ContextualValue<string>;
377
407
  /** Stable agent peer id. Supply a host-scoped value so audit provenance is durable. */
378
408
  peerId?: ContextualValue<string>;
379
- /**
380
- * Load the authoritative legacy draft used to create a missing Mengine
381
- * document. The tool owns the get-or-create flow: it first probes Mengine,
382
- * converts this draft into a VideoDocument only on a 404, bootstraps the
383
- * snapshot, and tolerates a concurrent creator winning the race.
384
- */
385
- loadInitialDraft?: (docId: string) => Promise<VideoDraft>;
386
409
  /**
387
410
  * Resolve factual generation lineage by external asset id after a confirmed
388
411
  * entity commit. Return every known generation record involving the given
@@ -395,6 +418,8 @@ interface CreateMedeoToolOptions {
395
418
  /** Assemble optional Caption artifacts by immutable entity ID; never exposed to scripts. */
396
419
  loadCaptionAssets?: CaptionAssetsLoader;
397
420
  loadEntityAsset?: EntityAssetLoader;
421
+ /** Program-side persistence for immutable AudioScript resources. */
422
+ writeEntityAsset?: EntityAssetWriter;
398
423
  fetchImpl?: typeof fetch;
399
424
  /** @deprecated ManualSyncDoc has no SSE or reconnect loop. */
400
425
  sseReconnectDelayMs?: number;
@@ -567,5 +592,5 @@ declare function materializeResources(options: EntityHttpClientOptions & {
567
592
  loadGenerationFacts?: GenerationFactsLoader;
568
593
  }, resources: readonly GeneratedResource[]): Promise<readonly string[]>;
569
594
  //#endregion
570
- export { type AssetGenerationFact, type AuthorableRelationKind, type BusinessEntityFacade, type BusinessRelationFacade, type CaptionAssetAssemblyOutcome, type CaptionAssetFact, type CaptionAssetsLoader, type ChangePlan, type CommitPlan, type CommitPlanOptions, type CommitPlanResult, type CompactProjectionOptions, type ConsoleShim, type CreateEntityInput, type CreateMedeoToolOptions, type DeleteEntityInput, type EditSandboxSessionOptions, type EditScriptResult, type EntityAssetContent, type EntityAssetLoader, type EntityCommand, type EntityCommitResult, type EntityPlanState, type EntitySandboxCheckpoint, type EntityStoreSnapshot, type EntityUpdateInput, type GeneratedResource, type GenerationFactsLoader, type GenerationSyncOutcome, type JsonObject, type JsonPrimitive, type JsonValue, type KnownEntityKind, type KnownRelationKind, type LinkGeneratedRelationInput, type LinkRelationInput, MEDEO_TOOL_DESCRIPTION, MEDEO_TOOL_NAME, MEDEO_TOOL_PARAMETERS, type MedeoCommitResult, type MedeoInitialDraft, type MedeoModelContext, type MedeoModelContextInput, type MedeoTool, type MedeoToolInput, type MedeoToolOp, type MedeoToolResult, type RelationUpdateInput, type ResourceEntityKind, type RunEditScriptOptions, type SandboxEntity, type SandboxRelation, type UnlinkRelationInput, type UpdateEntityInput, collectAffectedPartIds, commitPlan, createMedeoTool, materializeResources, renderCompactProjection, renderPreview, runEditScript };
595
+ export { type AssetGenerationFact, type AuthorableRelationKind, type BusinessEntityFacade, type BusinessRelationFacade, type CaptionAssetAssemblyOutcome, type CaptionAssetFact, type CaptionAssetsLoader, type ChangePlan, type CommitPlan, type CommitPlanOptions, type CommitPlanResult, type CompactProjectionOptions, type ConsoleShim, type CreateEntityInput, type CreateMedeoToolOptions, type DeleteEntityInput, type EditSandboxSessionOptions, type EditScriptResult, type EntityAssetContent, type EntityAssetLoader, type EntityAssetWriter, type EntityCommand, type EntityCommitResult, type EntityPlanState, type EntitySandboxCheckpoint, type EntityStoreSnapshot, type EntityUpdateInput, type GeneratedResource, type GenerationFactsLoader, type GenerationSyncOutcome, type JsonObject, type JsonPrimitive, type JsonValue, type KnownEntityKind, type KnownRelationKind, type LinkGeneratedRelationInput, type LinkRelationInput, MEDEO_TOOL_DESCRIPTION, MEDEO_TOOL_NAME, MEDEO_TOOL_PARAMETERS, type MedeoCommitResult, type MedeoInitialDraft, type MedeoModelContext, type MedeoModelContextInput, type MedeoTool, type MedeoToolInput, type MedeoToolOp, type MedeoToolResult, type RelationUpdateInput, type ResourceEntityKind, type RunEditScriptOptions, type SandboxEntity, type SandboxRelation, type UnlinkRelationInput, type UpdateEntityInput, collectAffectedPartIds, commitPlan, createMedeoTool, materializeResources, renderCompactProjection, renderPreview, runEditScript };
571
596
  //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { c as isMediaAssetVariantKind, i as businessState, o as createEntityId, s as createRelationId, t as EntitySandbox } from "./entity-sandbox-TaUVT3on.mjs";
2
- import { LoroEntityDocument, ManualSyncDoc, MengineHttpClient, MengineHttpRequestError, ValidationError, base64ToBytes, bytesToBase64, compileEntityRows, createMirrorVideoDocument, createPlainMemoryAdapter, decodeDocVersionMark, effectiveVideoClipDurationMs, encodeDocVersionMark, ensureEditorFoundation, replayJournal, solveVideoDocument, speedOf, toVideoDocument } from "@mengine/medeo-client";
1
+ import { a as createEntityId, i as businessState, o as createRelationId, s as isMediaAssetVariantKind, t as EntitySandbox } from "./entity-sandbox-BTR2cRl1.mjs";
2
+ import { ManualSyncDoc, MengineHttpClient, ValidationError, base64ToBytes, bytesToBase64, compileEntityRows, createPlainMemoryAdapter, decodeDocVersionMark, effectiveVideoClipDurationMs, encodeDocVersionMark, replayJournal, solveVideoDocument, speedOf } from "@mengine/medeo-client";
3
3
  import { Worker } from "node:worker_threads";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
5
  //#region src/document/compact-projection.ts
@@ -79,7 +79,7 @@ function renderCompactProjection(document, options) {
79
79
  rows.push(`${tag} ${partId} ${lane} [${abs},${abs + dur}) ${attrs}`);
80
80
  }
81
81
  }
82
- return [`# draft=${document.meta.draft_id ?? ""} v=${document.meta.version ?? 0} duration=${solved.durationMs} parts=${totalParts} shown=${rows.length}`, ...rows].join("\n");
82
+ return [`# draft=${options?.docId ?? ""} v=${options?.revision ?? 0} duration=${solved.durationMs} parts=${totalParts} shown=${rows.length}`, ...rows].join("\n");
83
83
  }
84
84
  //#endregion
85
85
  //#region src/sandbox/preview.ts
@@ -132,8 +132,12 @@ function collectFromValue(value, ids) {
132
132
  * Render a ChangePlan preview: header + rows for journal-affected parts only.
133
133
  * Empty journal → empty `onlyPartIds` (header alone), matching the T2 contract.
134
134
  */
135
- function renderPreview(document, journal) {
136
- return renderCompactProjection(document, { onlyPartIds: journal.length === 0 ? /* @__PURE__ */ new Set() : collectAffectedPartIds(journal) });
135
+ function renderPreview(document, journal, identity) {
136
+ const onlyPartIds = journal.length === 0 ? /* @__PURE__ */ new Set() : collectAffectedPartIds(journal);
137
+ return renderCompactProjection(document, {
138
+ ...identity,
139
+ onlyPartIds
140
+ });
137
141
  }
138
142
  //#endregion
139
143
  //#region src/sandbox/node-host.ts
@@ -165,6 +169,7 @@ function runEditScript(options) {
165
169
  let timer;
166
170
  const worker = new Worker(workerEntryUrl, {
167
171
  workerData: {
172
+ docId: options.docId,
168
173
  document: options.document,
169
174
  script: options.script,
170
175
  inputs: options.inputs,
@@ -214,11 +219,14 @@ function runEditScript(options) {
214
219
  armTimeout();
215
220
  return;
216
221
  }
217
- if (message.t === "entity-asset") {
222
+ if (message.t === "entity-asset" || message.t === "write-entity-asset") {
218
223
  (async () => {
219
224
  try {
220
- if (!options.loadEntityAsset) throw new Error("Entity Asset loader is unavailable");
221
- const result = await options.loadEntityAsset(message.entity);
225
+ const result = message.t === "write-entity-asset" ? await (options.writeEntityAsset ?? (() => {
226
+ throw new Error("Entity Asset writer is unavailable");
227
+ }))(message.entity, message.content) : await (options.loadEntityAsset ?? (() => {
228
+ throw new Error("Entity Asset loader is unavailable");
229
+ }))(message.entity, message.asset);
222
230
  if (!settled) worker.postMessage({
223
231
  t: "entity-asset-result",
224
232
  requestId: message.requestId,
@@ -272,7 +280,7 @@ function runEditScript(options) {
272
280
  ok: true,
273
281
  plan: {
274
282
  plan_kind: message.planKind,
275
- doc_id: options.document.meta.draft_id ?? "",
283
+ doc_id: options.docId,
276
284
  base_version: options.baseVersion,
277
285
  ops: ops.slice(),
278
286
  ...message.loroUpdate ? { loro_update: message.loroUpdate } : {},
@@ -338,7 +346,7 @@ function runEditScript(options) {
338
346
  async function assembleCaptionAssets(input) {
339
347
  try {
340
348
  const state = await input.client.fetchState();
341
- const candidates = state.entities.filter((entity) => entity.entity_kind === "caption" && !state.relations.some((relation) => relation.relation_kind === "physical-asset" && (relation.endpoint_0_entity_id === entity.entity_id || relation.endpoint_1_entity_id === entity.entity_id)));
349
+ const candidates = state.entities.filter((entity) => entity.entity_kind === "caption" && !state.relations.some((relation) => relation.relation_kind === "from-asset" && (relation.endpoint_0_entity_id === entity.entity_id || relation.endpoint_1_entity_id === entity.entity_id)));
342
350
  if (!candidates.length) return { status: "current" };
343
351
  const ids = new Set(candidates.map((entity) => entity.entity_id));
344
352
  const facts = await input.loadAssets(input.docId, [...ids]);
@@ -353,11 +361,8 @@ async function assembleCaptionAssets(input) {
353
361
  continue;
354
362
  }
355
363
  bound.set(fact.captionEntityId, fact.assetId);
356
- const matches = entities.filter((entity) => {
357
- const external = entity.payload.external;
358
- return external !== null && typeof external === "object" && !Array.isArray(external) && external.system === "memota" && external.key === fact.assetId;
359
- });
360
- if (matches.length > 1 || matches[0] && matches[0].entity_kind !== "asset") throw new Error(`Conflicting resource identity for Caption Asset ${fact.assetId}`);
364
+ const matches = entities.filter((entity) => entity.entity_kind === "asset" && entity.payload.system === "memota" && entity.payload.key === fact.assetId);
365
+ if (matches.length > 1) throw new Error(`Conflicting resource identity for Caption Asset ${fact.assetId}`);
361
366
  let asset = matches[0];
362
367
  if (asset && asset.payload.storageKey !== fact.storageKey) throw new Error(`Conflicting storage key for Caption Asset ${fact.assetId}`);
363
368
  if (!asset) {
@@ -365,10 +370,8 @@ async function assembleCaptionAssets(input) {
365
370
  entity_id: stableId$1("asset", fact.assetId),
366
371
  entity_kind: "asset",
367
372
  payload: {
368
- external: {
369
- system: "memota",
370
- key: fact.assetId
371
- },
373
+ system: "memota",
374
+ key: fact.assetId,
372
375
  storageKey: fact.storageKey
373
376
  }
374
377
  };
@@ -377,7 +380,7 @@ async function assembleCaptionAssets(input) {
377
380
  }
378
381
  relations.push({
379
382
  relation_id: stableId$1("relation", fact.captionEntityId, asset.entity_id),
380
- relation_kind: "physical-asset",
383
+ relation_kind: "from-asset",
381
384
  endpoint_0_entity_id: fact.captionEntityId,
382
385
  endpoint_1_entity_id: asset.entity_id,
383
386
  metadata: {},
@@ -429,11 +432,12 @@ const KNOWN_RELATION_KINDS = [
429
432
  "marker-content",
430
433
  "axvideo-marker",
431
434
  "marker-timeline",
432
- "physical-asset",
435
+ "from-asset",
433
436
  "generated",
434
437
  "caption-alignment",
435
438
  "clip-anchor",
436
439
  "phonetic-script-render",
440
+ "voice-timbre",
437
441
  "audio-script-source",
438
442
  "audio-script-marker"
439
443
  ];
@@ -685,33 +689,39 @@ async function syncGeneratedRelations(input) {
685
689
  };
686
690
  }
687
691
  }
688
- function assetKeyOf(entity) {
689
- if (!isMediaAssetVariantKind(entity.entity_kind)) return void 0;
690
- const external = entity.payload?.external;
691
- if (external == null || typeof external !== "object" || Array.isArray(external)) return void 0;
692
- const { system, key } = external;
693
- if (typeof system !== "string" || !ASSET_SYSTEMS.has(system)) return void 0;
694
- if (typeof key !== "string" || key.length === 0 || key.trim() !== key) return void 0;
695
- return key;
692
+ /** The locator an entity reaches through `from-asset`; only Assets state one. */
693
+ function assetLocatorOf(state, entityId) {
694
+ const assetIds = state.relations.filter((relation) => relation.relation_kind === "from-asset").map((relation) => relation.endpoint_0_entity_id === entityId ? relation.endpoint_1_entity_id : relation.endpoint_1_entity_id === entityId ? relation.endpoint_0_entity_id : void 0).filter((id) => id !== void 0);
695
+ for (const assetId of assetIds) {
696
+ const asset = state.entities.find((row) => row.entity_id === assetId && row.entity_kind === "asset");
697
+ const system = asset?.payload.system;
698
+ const key = asset?.payload.key;
699
+ if (typeof system !== "string" || !ASSET_SYSTEMS.has(system)) continue;
700
+ if (typeof key !== "string" || key.length === 0 || key.trim() !== key) continue;
701
+ return {
702
+ system,
703
+ key
704
+ };
705
+ }
696
706
  }
697
- /** Media variants own their Asset locator; generation lookup never follows Relations. */
707
+ /**
708
+ * Media reaches its Asset through `from-asset`, so lineage lookup follows that
709
+ * Relation — the locator is never copied onto the medium's own row.
710
+ */
698
711
  function resolveMediaByAssetKey(state, factKeys) {
699
712
  const systemByKey = /* @__PURE__ */ new Map();
700
- for (const entity of state.entities) {
701
- const key = assetKeyOf(entity);
702
- if (key === void 0 || !factKeys?.has(key)) continue;
703
- const system = entity.payload.external.system;
704
- if (systemByKey.has(key) && systemByKey.get(key) !== system) throw new Error(`Ambiguous generation asset id ${key} across media and speech namespaces`);
705
- systemByKey.set(key, system);
706
- }
707
713
  const resolved = /* @__PURE__ */ new Map();
708
714
  for (const entity of state.entities) {
709
715
  if (!isMediaAssetVariantKind(entity.entity_kind)) continue;
710
- const key = assetKeyOf(entity);
711
- if (key === void 0) continue;
712
- const matches = resolved.get(key) ?? [];
716
+ const locator = assetLocatorOf(state, entity.entity_id);
717
+ if (locator === void 0) continue;
718
+ if (factKeys?.has(locator.key)) {
719
+ if (systemByKey.has(locator.key) && systemByKey.get(locator.key) !== locator.system) throw new Error(`Ambiguous generation asset id ${locator.key} across media and speech namespaces`);
720
+ systemByKey.set(locator.key, locator.system);
721
+ }
722
+ const matches = resolved.get(locator.key) ?? [];
713
723
  matches.push(entity.entity_id);
714
- resolved.set(key, matches);
724
+ resolved.set(locator.key, matches);
715
725
  }
716
726
  return resolved;
717
727
  }
@@ -738,25 +748,6 @@ function isRecord$1(value) {
738
748
  /** @generated by gen:sandbox-dts. DO NOT EDIT. */
739
749
  const ENTITY_EDIT_SANDBOX_API_DTS = [
740
750
  "/** @generated by gen:sandbox-dts. Entity-native editor contract; DO NOT EDIT. */",
741
- "export interface BoundedDerivedSequencePayload extends JsonObject {",
742
- " extent: {",
743
- " kind: 'bounded';",
744
- " start: number;",
745
- " end: number;",
746
- " };",
747
- " sampling: 'derived';",
748
- " coordinateSpace: JsonValue;",
749
- "}",
750
- "export interface BoundedNativeSequencePayload extends JsonObject {",
751
- " /** Factual coordinates from recalled media metadata; never invent an end/duration. */",
752
- " extent: {",
753
- " kind: 'bounded';",
754
- " start: number;",
755
- " end: number;",
756
- " };",
757
- " sampling: 'native';",
758
- " coordinateSpace: JsonValue;",
759
- "}",
760
751
  "/** Business editing surface. Reads are assembled snapshots; writes preserve native operation intent. */",
761
752
  "export interface BusinessEntityFacade {",
762
753
  " list(): SandboxEntity[];",
@@ -805,17 +796,20 @@ const ENTITY_EDIT_SANDBOX_API_DTS = [
805
796
  " content: JsonValue;",
806
797
  "}",
807
798
  "export interface EntityPayloadByKind {",
808
- " axvideo: BoundedDerivedSequencePayload;",
799
+ " /** Its Timeline decides how long it runs, so it stores no length. */",
800
+ " axvideo: JsonObject;",
809
801
  " timeline: JsonObject;",
810
802
  " track: JsonObject & {",
811
803
  " hidden?: boolean;",
812
804
  " role?: string;",
813
805
  " };",
814
806
  " clip: JsonObject;",
815
- " video: BoundedNativeSequencePayload & MediaAssetPayload;",
816
- " audio: BoundedNativeSequencePayload & MediaAssetPayload;",
817
- " voice: BoundedNativeSequencePayload & MediaAssetPayload;",
818
- " image: UnboundedConstantSequencePayload & MediaAssetPayload;",
807
+ " video: OwnDurationPayload;",
808
+ " /** Every playable sound, including video original audio and synthesized voiceovers. */",
809
+ " audio: OwnDurationPayload;",
810
+ " /** A timbre identity, never playable content; the rendered take is an `audio` entity. */",
811
+ " voice: VoiceIdentityPayload;",
812
+ " image: NoDurationPayload;",
819
813
  " 'sequence-marker': JsonObject & {",
820
814
  " sourceRange: {",
821
815
  " start: number;",
@@ -852,7 +846,7 @@ const ENTITY_EDIT_SANDBOX_API_DTS = [
852
846
  " phonemeScript?: string;",
853
847
  " prosody?: JsonObject;",
854
848
  " };",
855
- " caption: BoundedNativeSequencePayload & {",
849
+ " caption: OwnDurationPayload & {",
856
850
  " baseEntityIds: string[];",
857
851
  " selection: CaptionTextSelection;",
858
852
  " style?: JsonObject;",
@@ -942,6 +936,7 @@ const ENTITY_EDIT_SANDBOX_API_DTS = [
942
936
  " | 'caption-alignment'",
943
937
  " | 'clip-anchor'",
944
938
  " | 'phonetic-script-render'",
939
+ " | 'voice-timbre'",
945
940
  " | 'audio-script-source'",
946
941
  " | 'audio-script-marker';",
947
942
  "interface LinkRelationBase {",
@@ -954,12 +949,18 @@ const ENTITY_EDIT_SANDBOX_API_DTS = [
954
949
  " relation_kind: KnownRelationKind;",
955
950
  " metadata?: JsonObject;",
956
951
  "}",
957
- "export type MediaAssetPayload = JsonObject & {",
958
- " external: {",
959
- " system: 'memota' | 'memota-speech';",
960
- " key: string;",
961
- " };",
962
- "};",
952
+ "/** A still frame runs for as long as its use asks, so it states no length. */",
953
+ "export interface NoDurationPayload extends JsonObject {",
954
+ " durationMs: null;",
955
+ "}",
956
+ "/**",
957
+ " * Content states how long it runs and nothing else: where it is taken from and",
958
+ " * where it lands both belong to its Sequence Marker.",
959
+ " */",
960
+ "export interface OwnDurationPayload extends JsonObject {",
961
+ " /** Factual whole milliseconds from recalled media metadata; never invented. */",
962
+ " durationMs: number;",
963
+ "}",
963
964
  "export interface RelationUpdateInput {",
964
965
  " relation_id: string;",
965
966
  " changes: FieldChange[];",
@@ -982,22 +983,22 @@ const ENTITY_EDIT_SANDBOX_API_DTS = [
982
983
  " text: string;",
983
984
  " language?: string;",
984
985
  "};",
985
- "/** Stored own fields; a variant may obtain required content fields from its declared bases. */",
986
+ "/**",
987
+ " * Stored own fields; a variant may obtain required content fields from its",
988
+ " * declared bases.",
989
+ " *",
990
+ " * Caption and Audio may also be declared resource-only: the ASR transcript and",
991
+ " * the synthesized voiceover are attached by the host, which then fills in the",
992
+ " * remaining factual fields.",
993
+ " */",
986
994
  "export type StoredEntityPayload<K extends KnownEntityKind> =",
987
995
  " | EntityPayloadByKind[K]",
988
- " | (K extends 'caption' ? JsonObject & Pick<MediaAssetPayload, 'external'> : never)",
996
+ " // Resource-only: the host fills the factual fields after reading the Asset.",
997
+ " | (K extends 'caption' | 'audio' ? Record<string, never> : never)",
989
998
  " | (JsonObject &",
990
999
  " Partial<EntityPayloadByKind[K]> & {",
991
1000
  " baseEntityIds: string[];",
992
1001
  " });",
993
- "export interface UnboundedConstantSequencePayload extends JsonObject {",
994
- " extent: {",
995
- " kind: 'unbounded';",
996
- " start: number;",
997
- " };",
998
- " sampling: 'constant';",
999
- " coordinateSpace: JsonValue;",
1000
- "}",
1001
1002
  "export interface UnlinkRelationInput {",
1002
1003
  " relation_id: string;",
1003
1004
  "}",
@@ -1006,9 +1007,17 @@ const ENTITY_EDIT_SANDBOX_API_DTS = [
1006
1007
  " entity_id: string;",
1007
1008
  " payload: JsonObject;",
1008
1009
  "}",
1010
+ "/** The voice-library timbre a synthesized Audio was rendered with. */",
1011
+ "export type VoiceIdentityPayload = JsonObject & {",
1012
+ " voice: {",
1013
+ " system: 'voice-library';",
1014
+ " key: string;",
1015
+ " name?: string;",
1016
+ " };",
1017
+ "};",
1009
1018
  "export declare const entities: BusinessEntityFacade;",
1010
1019
  "export declare const relations: BusinessRelationFacade;",
1011
- "/** Resolve this Entity's attached resource through the host; await before using an uninitialized Caption. */",
1020
+ "/** Resolve this Entity's attached resource through the host; await before using an uninitialized Caption or Voice. */",
1012
1021
  "export declare function rgetAssetFromEntity(entityId: string): Promise<EntityAssetContent>;",
1013
1022
  "export declare function checkpoint(): EntitySandboxCheckpoint;",
1014
1023
  "export declare function rollbackTo(cp: EntitySandboxCheckpoint): void;",
@@ -1038,19 +1047,21 @@ The host supplies the current document. Do not invent or request a document id.
1038
1047
 
1039
1048
  Entity reads return assembled snapshots. Mutating returned objects has no persistence effect. Create declares own fields. Update requires a nonempty changes array and atomically applies its ordered operations to assembled fields, routing each to its declaring owner. DeclareFields explicitly establishes own fields or an override; it is not the normal way to edit inherited content. Deletion requires explicitly handling incident relations, variant references and protected project attachments; it never deletes external resources or cascades business entities.
1040
1049
 
1041
- FieldChange paths start at payload for an entity, and at metadata or trace for a relation. Use strings for fields and {elementId:'...'} for list members, never numeric indices. Segment identity is segmentId; reference identity is the referenced id. set writes scalars or schema-declared atomic values, including complete sourceRange, targetRange, extent, duration, timeRemapping, selection, textRange and external. It cannot replace collaborative text, lists or maps. Edit text with text.splice (Unicode code-point index and deleteCount), and segments/segmentRanges with list.insert, list.move or list.remove. beforeElementId:null means append; moving before itself, duplicate identity or an absent anchor is an error. Unset removes optional fields only. Map fields are edited through leaf paths; initialize a new own map via declareFields. baseEntityIds is unordered composition membership: insert/remove are supported, moving bases cannot resolve field conflicts. Independent fields and list-item text edits retain native Loro identities.
1050
+ FieldChange paths start at payload for an entity, and at metadata or trace for a relation. Use strings for fields and {elementId:'...'} for list members, never numeric indices. Segment identity is segmentId; reference identity is the referenced id. set writes scalars or schema-declared atomic values, including complete sourceRange, targetRange, duration, timeRemapping, selection, textRange and external. It cannot replace collaborative text, lists or maps. Edit text with text.splice (Unicode code-point index and deleteCount), and segments/segmentRanges with list.insert, list.move or list.remove. beforeElementId:null means append; moving before itself, duplicate identity or an absent anchor is an error. Unset removes optional fields only. Map fields are edited through leaf paths; initialize a new own map via declareFields. baseEntityIds is unordered composition membership: insert/remove are supported, moving bases cannot resolve field conflicts. Independent fields and list-item text edits retain native Loro identities.
1042
1051
 
1043
1052
  Entities own data. Relations associate two existing entities; endpoint_0 and endpoint_1 retain their declared positions. Each relation kind defines direction and meaning; neither relation listing order nor endpoint storage order implies a global business order. Relations.update changes metadata/trace fields only; kind/endpoints require explicit unlink plus link with a new identity. Generated relations use generated(output,input). Ordinary associations do not implement composition.
1044
1053
 
1045
1054
  Variants directly hold baseEntityIds. Assembly validates every base before applying explicit own overrides: two bases supplying the same field are an error even if values match or the variant declares that field. Missing/cyclic bases fail. Consumers operate assembled fields without copying inherited content or resolving owners themselves. An owned-field edit creates a new immutable entity version. Base changes automatically advance variant base references and project attachments without versioning an unchanged variant. A variant-owned change versions the variant and retains unchanged bases. Do not manually clone entities for versioning. Use the current plan's ids throughout that execution; refresh after commit.
1046
1055
 
1047
- Generation tools return resource references. An asset id may be passed in an entity's external:{system:'memota',key:assetId}; it is never an entity id, baseEntityId or relation endpoint. Physical resources are host infrastructure. The sandbox has no direct resource lookup/CRUD by asset id. rgetAssetFromEntity takes only an existing business entity id and returns {assetId,content} for its attached resource. For output_caption, create a Caption with payload:{external:{system:'memota',key:assetId}}, then await rgetAssetFromEntity(captionId). The host initializes the complete AudioScript and one Caption per resource segment in the same causal plan when selection is omitted. The requested Caption represents the first segment; additional Caption entities share that same script and external reference. After awaiting, discover ALL resource Captions with entities.list filtered by entity_kind and payload.external.key, inspect each singular selection/extent, and place each in its own Clip/SequenceMarker. An explicit selection initializes only that selected Caption. Never place the first Caption over the whole transcript; Without explicit bases, the host creates an independent AudioScript for that resource. With bases, it resolves their unique AudioScript text owner and preserves other nonconflicting bases. It never routes text through the panel selection. Read the editable fields with entities.get afterwards. Repeated reads preserve edited text and return the original immutable resource content. Unawaited initialization is completed before validation; failure prevents publication. Resource-free Caption composition from AudioScript is also valid.
1056
+ Generation tools return resource references. An asset id names an Asset entity ({system:'memota',key:assetId}); every entity made from it links to that Asset with from-asset(entity,asset) and copies no locator of its own. An asset id is never an entity id, baseEntityId or relation endpoint. Physical resources are host infrastructure. The sandbox has no direct resource lookup/CRUD by asset id. rgetAssetFromEntity takes only an existing business entity id and returns {assetId,content} for its attached resource. For output_caption, create a Caption, link it with from-asset to the Asset for that assetId, then await rgetAssetFromEntity(captionId). The host initializes the complete AudioScript and one Caption per resource segment in the same causal plan when selection is omitted. The requested Caption represents the first segment; additional Caption entities share that same script and external reference. After awaiting, discover ALL resource Captions with entities.list filtered by entity_kind and their from-asset Asset, inspect each singular selection/durationMs, and place each in its own Clip/SequenceMarker. Discover them by following from-asset from the Asset. An explicit selection initializes only that selected Caption. Never place the first Caption over the whole transcript; Without explicit bases, the host creates an independent AudioScript for that resource. With bases, it resolves their unique AudioScript text owner and preserves other nonconflicting bases. It never routes text through the panel selection. Read the editable fields with entities.get afterwards. Repeated reads preserve edited text and return the original immutable resource content. Unawaited initialization is completed before validation; failure prevents publication. Resource-free Caption composition from AudioScript is also valid.
1048
1057
 
1049
- Project initialization creates one current Timeline, the four standard Tracks and one attached AudioScript with segments:[]. A project may contain multiple independent complete AudioScripts (for example a generated script and an uploaded video transcript). The project AudioScript attachment selects only the panel view; it is not a singleton or membership constraint. Locate scripts with entities.list and choose the appropriate source. AudioScript owns independent multi-segment text; each can have multiple Caption variants. Caption and PhoneticScript compose it through baseEntityIds. Caption owns exactly one selection object ({segmentId,textRange?}), never an array or the legacy selections field. It selects one script segment or a contiguous substring, and owns only that selection's intrinsic timing and style; segmentId is a stable script-local identity, not an entity reference or transient array index; selection textRange is a half-open code-point range and can split display text without rewriting the script. PhoneticScript owns pronunciation/prosody; Voice is generated from it and related by phonetic-script-render(voice,phoneticScript). Speech and caption occupy separate Clips. Subtitles normally accompany sound; use source media or generate Voice from PhoneticScript when the request requires narration. Do not fabricate a sound resource.
1058
+ Project initialization creates one current Timeline, the four standard Tracks and one attached AudioScript with segments:[]. A project may contain multiple independent complete AudioScripts (for example a generated script and an uploaded video transcript). The project AudioScript attachment selects only the panel view; it is not a singleton or membership constraint. Locate scripts with entities.list and choose the appropriate source. AudioScript owns independent multi-segment text; each can have multiple Caption variants. Caption and PhoneticScript compose it through baseEntityIds. Caption locates its text as AudioScript plus index: it owns exactly one selection object ({segmentId,textRange?}), never an array or the legacy selections field. segmentId is that segment's stable identity rather than its ordinal, so a concurrent insert or re-segmentation cannot slide a Caption onto other text. It selects one script segment or a contiguous substring, and owns only that selection's intrinsic timing and style; selection textRange is a half-open code-point range and can split display text without rewriting the script. PhoneticScript owns pronunciation/prosody; the voiceover is an Audio rendered from it and related by phonetic-script-render(audio,phoneticScript). Voice is only a timbre identity ({voice:{system:'voice-library',key}}) and is never playable; link the rendered Audio to it with voice-timbre(audio,voice). Speech and caption occupy separate Clips. Caption carries Sequence: it owns its display timing and can be Clip content directly. Subtitles normally accompany sound; use source media or render a voiceover Audio from PhoneticScript when the request requires narration. Do not fabricate a sound resource.
1050
1059
 
1051
- AudioScript has no intrinsic time and cannot be Clip content. ASR input Audio/Video/Voice associates via audio-script-source(script,source). Its external annotation SequenceMarker associates via audio-script-marker(script,marker) and receives assigned segmentRanges, not references to another marker. Caption intrinsic timing and its Clip's display SequenceMarker are distinct. Move/stretch display by editing only the placement marker.
1060
+ For Speech resources, create an Audio and link it with from-asset to the Asset {system:'memota-speech',key:speechId}, then await rgetAssetFromEntity(audioId) before linking its placement. The host supplies the factual durationMs and assembles physical storage facts internally; the voice-library identity stays on its own Voice entity. Read the visible fields through entities.get; never invent them or use a Speech ID as a Memota media ID. A TTS voiceover still requires its real PhoneticScript relation; resource loading does not invent script content.
1052
1061
 
1053
- To place content, create a Clip and a SequenceMarker, then link track-clip(track,clip), clip-marker(clip,marker) and marker-content(marker,content). Existing track membership uses timeline-track(timeline,track). The editor has one track of each role: video_clip for Image/Video, speech for Voice, caption for Caption, bgm for Audio. Inspect factual media extents; never invent duration. Each placement is exactly one of Clip.order (sequential), Marker.targetRange (absolute) or clip-anchor(childClip,hostClip) plus Marker.anchorOffset. Marker.sourceRange selects source, duration describes playback/display. Whole milliseconds are required by this editor projection. Clip.volume is decibels (-60..20, 0 original). Image is unbounded/constant. Linear timeRemapping is {kind:'linear',rate:2,mode:'constant'}, with duration matching the rounded source span/rate. BGM can use durationPolicy:'timeline'. Nonlinear speed and multiple overlay tracks are unsupported. Caption visibility is the caption Track's hidden field. Caption style is its style map. Handle structural links explicitly when moving/deleting; there is no implicit cascade.
1062
+ Direct AudioScript creation saves its segments as a resource before publication. Its assembled segments remain readable and editable; committed text changes preserve native Loro operations and save a new immutable resource, automatically advancing the external reference. AudioScripts initialized through Caption resources keep inline segments without an additional script resource. Do not manage these resources yourself. AudioScript has no intrinsic time and cannot be Clip content. ASR input Audio/Video associates via audio-script-source(script,source). Its external annotation SequenceMarker associates via audio-script-marker(script,marker) and receives assigned segmentRanges, not references to another marker. Caption intrinsic timing and its Clip's display SequenceMarker are distinct. Move/stretch display by editing only the placement marker.
1063
+
1064
+ To place content, create a Clip and a SequenceMarker, then link track-clip(track,clip), clip-marker(clip,marker) and marker-content(marker,content). Existing track membership uses timeline-track(timeline,track). The editor has one track of each role: video_clip for Image/Video, speech for the voiceover Audio, caption for Caption, bgm for Audio. Content states only its own durationMs in whole milliseconds, never a position: where it is taken from is Marker.sourceRange and where it lands is Marker.targetRange. A still Image states durationMs:null and each use decides how long it runs; an AXVideo stores none because its Timeline decides it. Inspect factual media durations; never invent one. Each placement is exactly one of Clip.order (sequential), Marker.targetRange (absolute) or clip-anchor(childClip,hostClip) plus Marker.anchorOffset. Marker.sourceRange selects source, duration describes playback/display. Whole milliseconds are required by this editor projection. Clip.volume is decibels (-60..20, 0 original). Linear timeRemapping is {kind:'linear',rate:2,mode:'constant'}, with duration matching the rounded source span/rate. BGM can use durationPolicy:'timeline'. Nonlinear speed and multiple overlay tracks are unsupported. Caption visibility is the caption Track's hidden field. Caption style is its style map. Handle structural links explicitly when moving/deleting; there is no implicit cascade.
1054
1065
 
1055
1066
  checkpoint returns an opaque token valid only in this execution. rollbackTo restores that point and invalidates later checkpoints. Returned read snapshots and inputs are not writable document authorities. Use only the declarations below.
1056
1067
  `.trim();
@@ -1213,7 +1224,7 @@ async function commitPlan(doc, plan, options) {
1213
1224
  * integrity throws are not wrapped.
1214
1225
  */
1215
1226
  async function commitPlanPreflight(doc, plan) {
1216
- const scratch = createPlainMemoryAdapter(doc.snapshot());
1227
+ const scratch = createPlainMemoryAdapter(doc.snapshot(), { readOnly: doc.isEntityDocument() });
1217
1228
  for (let index = 0; index < plan.ops.length; index++) {
1218
1229
  const entry = plan.ops[index];
1219
1230
  if (entry == null) continue;
@@ -1405,13 +1416,13 @@ function createMedeoTool(options) {
1405
1416
  const existing = documents.get(docId);
1406
1417
  if (existing != null) return await existing;
1407
1418
  const created = (async () => {
1408
- return await getOrCreateDocument(new MengineHttpClient({
1419
+ return await openDocument(new MengineHttpClient({
1409
1420
  docId,
1410
1421
  httpOrigin: requiredContext(options.httpOrigin, docId, "httpOrigin"),
1411
1422
  ...options.authToken !== void 0 ? { authToken: () => optionalContext(options.authToken, docId) } : {},
1412
1423
  ...options.userId !== void 0 ? { userId: () => optionalContext(options.userId, docId) } : {},
1413
1424
  ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {}
1414
- }), docId, optionalContext(options.peerId, docId));
1425
+ }), optionalContext(options.peerId, docId));
1415
1426
  })();
1416
1427
  documents.set(docId, created);
1417
1428
  try {
@@ -1451,36 +1462,14 @@ function createMedeoTool(options) {
1451
1462
  if (documentTails.get(docId) === tail) documentTails.delete(docId);
1452
1463
  }
1453
1464
  }
1454
- async function getOrCreateDocument(client, docId, peerId) {
1455
- try {
1456
- return await ManualSyncDoc.open({
1457
- client,
1458
- ...peerId !== void 0 ? { peerId } : {}
1459
- });
1460
- } catch (error) {
1461
- if (!(error instanceof MengineHttpRequestError) || error.status !== 404) throw error;
1462
- if (options.loadInitialDraft === void 0) throw error;
1463
- }
1464
- const document = toVideoDocument(await options.loadInitialDraft(docId));
1465
- if (Object.keys(document.part_library ?? {}).length > 0) throw new Error("Legacy content cannot bootstrap a Loro entity project");
1466
- const seed = createMirrorVideoDocument(document, {
1467
- ...peerId !== void 0 ? { peerId } : {},
1468
- origin: "mengine.medeo_tool.bootstrap"
1469
- });
1470
- const foundation = ensureEditorFoundation({
1471
- entities: [],
1472
- relations: []
1473
- });
1474
- const entities = LoroEntityDocument.create(foundation.rows, {
1475
- timelineEntityId: foundation.timelineEntityId,
1476
- audioScriptEntityId: foundation.audioScriptEntityId
1477
- });
1478
- seed.import(entities.doc.export({ mode: "snapshot" }));
1479
- try {
1480
- await client.bootstrapSnapshot(seed.export({ mode: "snapshot" }));
1481
- } catch (error) {
1482
- if (!(error instanceof MengineHttpRequestError) || error.status !== 400) throw error;
1483
- }
1465
+ /**
1466
+ * Open an existing Mengine document. Creation belongs to Director, which
1467
+ * initializes every project's document (`POST .../initialize`) before any
1468
+ * agent edit; Mengine's `bootstrap` is the internal Legacy-migration route
1469
+ * and is deliberately absent from the client SDK. A 404 here is a real
1470
+ * missing document, not something this tool may paper over.
1471
+ */
1472
+ async function openDocument(client, peerId) {
1484
1473
  return await ManualSyncDoc.open({
1485
1474
  client,
1486
1475
  ...peerId !== void 0 ? { peerId } : {}
@@ -1652,10 +1641,12 @@ function createMedeoTool(options) {
1652
1641
  const document = doc.snapshot();
1653
1642
  const baseVersion = encodeDocVersionMark(doc.versionMark());
1654
1643
  const result = await runEditScript({
1644
+ docId: input.doc_id,
1655
1645
  document,
1656
1646
  baseVersion,
1657
1647
  entityState,
1658
- loadEntityAsset: options.loadEntityAsset ? (entity) => options.loadEntityAsset(input.doc_id, entity) : void 0,
1648
+ loadEntityAsset: options.loadEntityAsset ? (entity, asset) => options.loadEntityAsset(input.doc_id, entity, asset) : void 0,
1649
+ writeEntityAsset: options.writeEntityAsset ? (entity, content) => options.writeEntityAsset(input.doc_id, entity, content) : void 0,
1659
1650
  script: input.script,
1660
1651
  ...input.inputs !== void 0 ? { inputs: input.inputs } : {},
1661
1652
  timeoutMs: input.timeout_ms ?? options.sandbox?.timeoutMs ?? 3e4,
@@ -1791,20 +1782,24 @@ async function materializeResources(options, resources) {
1791
1782
  const client = new EntityHttpClient(options);
1792
1783
  const state = await client.fetchState();
1793
1784
  let resourceKey = "";
1785
+ let mintOrdinal = 0;
1794
1786
  const sandbox = new EntitySandbox({
1795
1787
  state,
1796
- idFactory: (prefix) => stableId(prefix, options.docId, resourceKey)
1788
+ idFactory: (prefix) => stableId(prefix, options.docId, resourceKey, String(mintOrdinal++))
1797
1789
  });
1798
1790
  const ids = [];
1799
1791
  for (const resource of resources) {
1800
1792
  resourceKey = `${resource.kind}:${resource.assetId}`;
1793
+ mintOrdinal = 0;
1801
1794
  if (resource.kind !== "caption") {
1802
1795
  ids.push(sandbox.entities.ensureMedia(resource).contentEntityId);
1803
1796
  continue;
1804
1797
  }
1805
- const existingCaptions = sandbox.entities.list().filter((row) => row.entity_kind === "caption" && row.payload.external?.key === resource.assetId);
1798
+ const rows = sandbox.entities.list();
1799
+ const assetRow = rows.find((row) => row.entity_kind === "asset" && row.payload.key === resource.assetId);
1800
+ const existingCaptions = assetRow === void 0 ? [] : rows.filter((row) => row.entity_kind === "caption" && sandbox.entities.assetIdOf(row.entity_id) === assetRow.entity_id);
1806
1801
  if (existingCaptions.length) {
1807
- if (existingCaptions.some((caption) => caption.payload.storageKey !== resource.storageKey)) throw new Error("Conflicting Caption resource identity");
1802
+ if (assetRow?.payload.storageKey !== resource.storageKey) throw new Error("Conflicting Caption resource identity");
1808
1803
  ids.push(...existingCaptions.map((caption) => caption.entity_id));
1809
1804
  continue;
1810
1805
  }
@@ -1825,6 +1820,11 @@ async function materializeResources(options, resources) {
1825
1820
  entity_kind: "audio-script",
1826
1821
  payload: { segments }
1827
1822
  });
1823
+ const captionAssetId = sandbox.entities.ensureAsset({
1824
+ system: "memota",
1825
+ key: resource.assetId,
1826
+ storageKey: resource.storageKey
1827
+ });
1828
1828
  const ranges = resource.segments.map((segment, index) => ({
1829
1829
  segmentId: segments[index].segmentId,
1830
1830
  startMs: segment.startMs,
@@ -1837,22 +1837,16 @@ async function materializeResources(options, resources) {
1837
1837
  entity_kind: "caption",
1838
1838
  payload: {
1839
1839
  baseEntityIds: [scriptId],
1840
- external: {
1841
- system: "memota",
1842
- key: resource.assetId
1843
- },
1844
- storageKey: resource.storageKey,
1845
1840
  selection: { segmentId: range.segmentId },
1846
1841
  segmentRanges: [range],
1847
- extent: {
1848
- kind: "bounded",
1849
- start: range.startMs,
1850
- end: range.endMs
1851
- },
1852
- sampling: "native",
1853
- coordinateSpace: "milliseconds"
1842
+ durationMs: range.endMs - range.startMs
1854
1843
  }
1855
1844
  });
1845
+ sandbox.relations.link({
1846
+ relation_kind: "from-asset",
1847
+ endpoint_0_entity_id: captionId,
1848
+ endpoint_1_entity_id: captionAssetId
1849
+ });
1856
1850
  ids.push(captionId);
1857
1851
  }
1858
1852
  const markerId = sandbox.entities.create({