@mengine/medeo-tool 1.2.1-alpha.8 → 1.3.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,4 +1,4 @@
1
- import { SchemaValidator, SemanticEditor, createEditSandbox, effectiveVideoClipDurationMs, fromVideoDocument, solveVideoDocument, speedOf } from "@mengine/medeo-client";
1
+ import { SchemaValidator, SemanticEditor, assertCanonicalEditorResources, createEditSandbox, effectiveVideoClipDurationMs, ensureEditorFoundation, fromVideoDocument, importMediaAsset, readDocumentAudioScript, solveVideoDocument, speedOf } from "@mengine/medeo-client";
2
2
  //#region src/document/compact-projection.ts
3
3
  const DEFAULT_TEXT_PREVIEW_LENGTH = 24;
4
4
  /** Kind tag shown in the first column (`video_clip` → `clip`). */
@@ -149,7 +149,10 @@ function isSequenceFields(value) {
149
149
  return "coordinateSpace" in value && value.coordinateSpace !== void 0;
150
150
  }
151
151
  function isKnownSequenceKind(kind) {
152
- return kind === "video" || kind === "audio" || kind === "voice" || kind === "image" || kind === "caption" || kind === "axvideo";
152
+ return isMediaAssetVariantKind(kind) || kind === "caption" || kind === "axvideo";
153
+ }
154
+ function isMediaAssetVariantKind(kind) {
155
+ return kind === "video" || kind === "image" || kind === "audio" || kind === "voice";
153
156
  }
154
157
  function isKnownEntityKind(kind) {
155
158
  return isKnownSequenceKind(kind) || isKnownNonSequenceKind(kind);
@@ -177,15 +180,281 @@ function createId(value, label) {
177
180
  return value;
178
181
  }
179
182
  //#endregion
183
+ //#region ../medeo-dsl/src/json-values.ts
184
+ const nativeObjectConstructorSource = Function.prototype.toString.call(Object);
185
+ function isJsonObject(value) {
186
+ return typeof value === "object" && value !== null && !Array.isArray(value) && isJsonValue(value, /* @__PURE__ */ new Set());
187
+ }
188
+ function isJsonValue(value, ancestors) {
189
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
190
+ if (typeof value === "number") return Number.isFinite(value);
191
+ if (typeof value !== "object") return false;
192
+ if (!Array.isArray(value) && !isPlainObject(value)) return false;
193
+ if (ancestors.has(value)) return false;
194
+ ancestors.add(value);
195
+ const valid = Array.isArray(value) ? value.every((item) => isJsonValue(item, ancestors)) : Object.values(value).every((item) => isJsonValue(item, ancestors));
196
+ ancestors.delete(value);
197
+ return valid;
198
+ }
199
+ /** Recognize an ordinary object from any VM realm without admitting class instances. */
200
+ function isPlainObject(value) {
201
+ try {
202
+ const prototype = Object.getPrototypeOf(value);
203
+ if (prototype === null) return true;
204
+ if (Object.getPrototypeOf(prototype) !== null) return false;
205
+ const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor")?.value;
206
+ return typeof constructor === "function" && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === nativeObjectConstructorSource;
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
211
+ //#endregion
212
+ //#region ../medeo-dsl/src/composition.ts
213
+ /** Assembly fails explicitly when declared bases cannot provide valid, unambiguous content. */
214
+ var ScriptCompositionError = class extends Error {
215
+ code;
216
+ entityId;
217
+ constructor(code, entityId, message) {
218
+ super(message);
219
+ this.code = code;
220
+ this.entityId = entityId;
221
+ this.name = "ScriptCompositionError";
222
+ }
223
+ };
224
+ /** Direct base IDs are structural fields, never ordinary Relation endpoints. */
225
+ function variantBaseEntityIds(entity) {
226
+ if (!Object.hasOwn(entity.payload, "baseEntityIds")) return [];
227
+ const ids = entity.payload.baseEntityIds;
228
+ if (!Array.isArray(ids) || ids.length === 0 || !ids.every((id) => typeof id === "string" && id.trim() === id && id.length > 0) || new Set(ids).size !== ids.length) throw new ScriptCompositionError("invalid_bases", entity.entityId, `Entity "${entity.entityId}" requires non-empty, unique baseEntityIds`);
229
+ return ids.map(createEntityId);
230
+ }
231
+ /** Validate all base providers before applying explicit own fields; ordering cannot resolve ambiguity. */
232
+ function assembleEntityContent(rows, entityId) {
233
+ const byId = /* @__PURE__ */ new Map();
234
+ for (const row of rows.entities) {
235
+ if (byId.has(row.entityId)) throw new ScriptCompositionError("composition_ambiguous", row.entityId, `duplicate entity id "${row.entityId}"`);
236
+ byId.set(row.entityId, row);
237
+ }
238
+ const active = /* @__PURE__ */ new Set();
239
+ const cache = /* @__PURE__ */ new Map();
240
+ const visit = (id) => {
241
+ const cached = cache.get(id);
242
+ if (cached !== void 0) return cached;
243
+ if (active.has(id)) throw new ScriptCompositionError("composition_cycle", id, `Cyclic baseEntityIds at "${id}"`);
244
+ const row = byId.get(id);
245
+ if (row === void 0) throw new ScriptCompositionError("composition_dangling", id, `Missing base entity "${id}" in this document`);
246
+ if (!isJsonObject(row.payload) || Object.hasOwn(row.payload, "entityId") || Object.hasOwn(row.payload, "entityKind")) throw new ScriptCompositionError("invalid_bases", id, `Entity "${id}" requires JSON own fields without reserved identity fields`);
247
+ active.add(id);
248
+ const inherited = {};
249
+ const providers = /* @__PURE__ */ new Map();
250
+ for (const baseId of variantBaseEntityIds(row)) {
251
+ const base = visit(baseId);
252
+ for (const [field, value] of Object.entries(base.payload)) {
253
+ if (field === "baseEntityIds") continue;
254
+ const previous = providers.get(field);
255
+ if (previous !== void 0) throw new ScriptCompositionError("field_conflict", id, `Entity "${id}" field "${field}" conflicts between bases "${previous}" and "${baseId}"; own fields cannot resolve base ambiguity`);
256
+ providers.set(field, baseId);
257
+ Object.defineProperty(inherited, field, {
258
+ value,
259
+ enumerable: true,
260
+ configurable: true,
261
+ writable: true
262
+ });
263
+ }
264
+ }
265
+ const assembled = {
266
+ ...row,
267
+ payload: JSON.parse(JSON.stringify({
268
+ ...inherited,
269
+ ...row.payload
270
+ }))
271
+ };
272
+ active.delete(id);
273
+ cache.set(id, assembled);
274
+ return assembled;
275
+ };
276
+ return visit(entityId);
277
+ }
278
+ /** Resolve a field's declaring entity after validating the entire composition. */
279
+ function resolveEntityFieldOwner(rows, entityId, field) {
280
+ assembleEntityContent(rows, entityId);
281
+ const byId = new Map(rows.entities.map((row) => [row.entityId, row]));
282
+ const find = (id) => {
283
+ const row = byId.get(id);
284
+ if (Object.hasOwn(row.payload, field)) return id;
285
+ if (field === "baseEntityIds") return void 0;
286
+ for (const baseId of variantBaseEntityIds(row)) {
287
+ const owner = find(baseId);
288
+ if (owner !== void 0) return owner;
289
+ }
290
+ };
291
+ return find(entityId) ?? entityId;
292
+ }
293
+ /** Patch assembled fields without copying inherited fields into the variant's stored payload. */
294
+ function updateEntityFields(rows, entityId, fields) {
295
+ assembleEntityContent(rows, entityId);
296
+ if (!isJsonObject(fields) || Object.hasOwn(fields, "entityId") || Object.hasOwn(fields, "entityKind")) throw new ScriptCompositionError("invalid_bases", entityId, "Field updates require JSON without reserved identity fields");
297
+ const updates = /* @__PURE__ */ new Map();
298
+ for (const [field, value] of Object.entries(fields)) {
299
+ const ownerId = resolveEntityFieldOwner(rows, entityId, field);
300
+ const owner = updates.get(ownerId) ?? rows.entities.find((row) => row.entityId === ownerId);
301
+ updates.set(ownerId, {
302
+ ...owner,
303
+ payload: {
304
+ ...owner.payload,
305
+ [field]: value
306
+ }
307
+ });
308
+ }
309
+ return [...updates.values()].map((row) => JSON.parse(JSON.stringify(row)));
310
+ }
311
+ /** Find the actual AudioScript text owner through direct variant bases. */
312
+ function findComposedAudioScript(rows, variantEntityId, variantKind) {
313
+ const variant = requireEntityKind(rows, variantEntityId, variantKind);
314
+ assembleEntityContent(rows, variantEntityId);
315
+ const sources = /* @__PURE__ */ new Map();
316
+ const seen = /* @__PURE__ */ new Set();
317
+ const visit = (row) => {
318
+ for (const id of variantBaseEntityIds(row)) {
319
+ if (seen.has(id)) continue;
320
+ seen.add(id);
321
+ const base = rows.entities.find((candidate) => candidate.entityId === id);
322
+ if (base.entityKind === "audio-script") sources.set(id, assembleEntityContent(rows, id));
323
+ else visit(base);
324
+ }
325
+ };
326
+ visit(variant);
327
+ if (sources.size !== 1) throw new ScriptCompositionError(sources.size === 0 ? "composition_missing" : "composition_ambiguous", variantEntityId, `${variantKind} "${variantEntityId}" baseEntityIds must resolve one AudioScript text owner, got ${sources.size}`);
328
+ return [...sources.values()][0];
329
+ }
330
+ function scriptSegments(script) {
331
+ const segments = script.payload.segments;
332
+ if (!Array.isArray(segments)) throw new ScriptCompositionError("composition_dangling", script.entityId, `AudioScript "${script.entityId}" requires segments`);
333
+ if (!segments.every(isScriptSegment) || new Set(segments.map((s) => s.segmentId)).size !== segments.length) throw new ScriptCompositionError("invalid_script", script.entityId, "AudioScript requires valid segments with unique local segmentId and text");
334
+ return segments;
335
+ }
336
+ function isScriptSegment(value) {
337
+ if (typeof value !== "object" || value == null || Array.isArray(value)) return false;
338
+ const segment = value;
339
+ return typeof segment.segmentId === "string" && segment.segmentId.trim() !== "" && typeof segment.text === "string" && (segment.language === void 0 || typeof segment.language === "string");
340
+ }
341
+ /** Assemble selected text after base-field validation and explicit variant overrides. */
342
+ function assembleCaptionContent(rows, captionEntityId) {
343
+ requireEntityKind(rows, captionEntityId, "caption");
344
+ const caption = assembleEntityContent(rows, captionEntityId);
345
+ const script = findComposedAudioScript(rows, captionEntityId, "caption");
346
+ const segments = selectAudioScriptSegments({
347
+ ...script,
348
+ payload: caption.payload
349
+ }, captionSelections(caption));
350
+ return {
351
+ caption,
352
+ audioScript: script,
353
+ segments,
354
+ text: segments.map((segment) => segment.text).join("")
355
+ };
356
+ }
357
+ /**
358
+ * Assemble the complete Phonetic Script content for TTS: the composed
359
+ * AudioScript's base text plus the variant's own phoneme and prosody fields.
360
+ */
361
+ function assemblePhoneticScriptContent(rows, phoneticScriptEntityId) {
362
+ requireEntityKind(rows, phoneticScriptEntityId, "phonetic-script");
363
+ const phoneticScript = assembleEntityContent(rows, phoneticScriptEntityId);
364
+ const audioScript = findComposedAudioScript(rows, phoneticScriptEntityId, "phonetic-script");
365
+ const segments = scriptSegments({
366
+ ...audioScript,
367
+ payload: phoneticScript.payload
368
+ });
369
+ return {
370
+ phoneticScript,
371
+ audioScript,
372
+ segments,
373
+ text: segments.map((segment) => segment.text).join("")
374
+ };
375
+ }
376
+ function captionSelections(caption) {
377
+ const selections = caption.payload.selections;
378
+ 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`);
379
+ if (!selections.every(isSegmentSelection)) throw new ScriptCompositionError("invalid_selection", caption.entityId, "Caption selections require a segmentId and optional textRange");
380
+ return selections;
381
+ }
382
+ function isSegmentSelection(value) {
383
+ 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");
384
+ }
385
+ /** Select source text without creating another authoritative text field. */
386
+ function selectAudioScriptSegments(script, selections) {
387
+ 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");
388
+ const bySegmentId = new Map(scriptSegments(script).map((segment) => [segment.segmentId, segment]));
389
+ const selected = selections.map((selection) => {
390
+ const segment = bySegmentId.get(selection.segmentId);
391
+ if (segment === void 0) throw new ScriptCompositionError("unknown_segment", script.entityId, `Caption selection "${selection.segmentId}" does not name a segment of AudioScript "${script.entityId}"`);
392
+ if (selection.textRange === void 0) return segment;
393
+ const range = selection.textRange;
394
+ const points = Array.from(segment.text);
395
+ if (typeof range !== "object" || range === null || !Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.end) || range.start < 0 || range.end <= range.start || range.end > points.length || Object.keys(range).some((key) => key !== "start" && key !== "end")) throw new ScriptCompositionError("invalid_selection", script.entityId, "Caption textRange must be a non-empty half-open Unicode code-point range within its source segment");
396
+ return {
397
+ ...segment,
398
+ text: points.slice(range.start, range.end).join("")
399
+ };
400
+ });
401
+ if (!selected.map((segment) => segment.text).join("").trim()) throw new ScriptCompositionError("empty_selection", script.entityId, "Caption selection must contain visible source text");
402
+ return selected;
403
+ }
404
+ function requireEntityKind(rows, entityIdValue, entityKind) {
405
+ const entity = rows.entities.find((candidate) => candidate.entityId === entityIdValue);
406
+ if (entity === void 0) throw new ScriptCompositionError("composition_dangling", entityIdValue, `Entity "${entityIdValue}" does not exist`);
407
+ if (entity.entityKind !== entityKind) throw new ScriptCompositionError("composition_dangling", entityIdValue, `Entity "${entityIdValue}" must have kind "${entityKind}", got "${entity.entityKind}"`);
408
+ return entity;
409
+ }
410
+ //#endregion
411
+ //#region ../medeo-dsl/src/rows.ts
412
+ function entityToRow(entity) {
413
+ const { entityId, entityKind, ...payload } = entity;
414
+ if (!isJsonObject(payload)) throw new Error(`Entity "${entityId}" payload must contain only JSON values`);
415
+ return {
416
+ entityId,
417
+ entityKind,
418
+ payload
419
+ };
420
+ }
421
+ //#endregion
180
422
  //#region ../medeo-dsl/src/invariants.ts
423
+ /**
424
+ * Media variants own their Asset identity directly: a `physical-asset`
425
+ * Relation may not bind them. Caption text is composed domain content, so
426
+ * Captions do not bind Physical Assets either; the Relation vocabulary stays
427
+ * open for future physical pipeline semantics.
428
+ */
429
+ function validateMediaAssetIdentity(entity, index) {
430
+ return [...index.relationsOf(entity)].filter((relation) => relation.kind === "physical-asset").map(() => ({
431
+ code: "media_physical_asset_forbidden",
432
+ entityId: entity.entityId,
433
+ message: `Media entity "${entity.entityId}" owns its Asset identity; remove its physical-asset Relation`
434
+ }));
435
+ }
181
436
  /** Validates one complete set of entities and its authoritative Relation rows. */
182
437
  function validateEntityRelationSet(entityRefs, index, options) {
183
438
  const { entities, issues } = collectRelatedEntities(entityRefs, index);
184
439
  const entityIds = new Set(entities.map((entity) => entity.entityId));
440
+ const rows = {
441
+ entities: entities.map((entity) => entityToRow(entity.current())),
442
+ relations: []
443
+ };
444
+ for (const entity of entities) try {
445
+ assembleEntityContent(rows, entity.entityId);
446
+ } catch (error) {
447
+ issues.push({
448
+ code: "invalid_variant_composition",
449
+ entityId: entity.entityId,
450
+ message: error instanceof Error ? error.message : "Invalid variant composition"
451
+ });
452
+ }
185
453
  for (const entity of entities) {
186
454
  const current = entity.current();
187
455
  const entityIssues = validateEntity(entity, entityIds);
188
456
  issues.push(...entityIssues);
457
+ if (isMediaAssetVariantKind(current.entityKind)) issues.push(...validateMediaAssetIdentity(entity, index));
189
458
  if (entityIssues.some((issue) => issue.code === "invalid_entity_payload")) continue;
190
459
  if (current.entityKind === "sequence-marker") {
191
460
  const marker = entity;
@@ -201,7 +470,8 @@ function validateEntityRelationSet(entityRefs, index, options) {
201
470
  issues.push(...validateClipPlacement(entity, index));
202
471
  }
203
472
  if (current.entityKind === "axvideo") issues.push(...validateAXVideoAdmission(entity, index));
204
- if (current.entityKind === "caption") issues.push(...validateCaptionAsset(entity, index));
473
+ if (current.entityKind === "caption") issues.push(...validateCaptionComposition(entity, index, entities));
474
+ if (current.entityKind === "phonetic-script") issues.push(...validatePhoneticComposition(entity, index, entities));
205
475
  issues.push(...validateSequenceComposition(entity));
206
476
  }
207
477
  issues.push(...validateClipAnchorCycles(entities, index));
@@ -213,7 +483,39 @@ function validateMarkerUse(marker, index) {
213
483
  const axVideoEdges = ofKind(relations, "axvideo-marker");
214
484
  const contentEdges = ofKind(relations, "marker-content");
215
485
  const timelineEdges = ofKind(relations, "marker-timeline");
486
+ const scriptEdges = ofKind(relations, "audio-script-marker");
216
487
  const issues = [];
488
+ if (scriptEdges.length > 0) {
489
+ if (clipEdges.length + axVideoEdges.length + contentEdges.length + timelineEdges.length > 0) issues.push({
490
+ code: "marker_container_xor",
491
+ entityId: marker.entityId,
492
+ message: "An AudioScript annotation Marker must not carry Clip, AXVideo, content, or Timeline relations"
493
+ });
494
+ const ranges = marker.current().segmentRanges;
495
+ if (!ranges?.length) issues.push({
496
+ code: "invalid_entity_payload",
497
+ entityId: marker.entityId,
498
+ message: "An AudioScript annotation Marker requires segmentRanges"
499
+ });
500
+ for (const relation of scriptEdges) {
501
+ const script = relation.other(marker)?.deref()?.current();
502
+ if (script?.entityKind !== "audio-script") continue;
503
+ const segments = script.segments;
504
+ if (!Array.isArray(segments)) continue;
505
+ const segmentIds = new Set(segments.map((segment) => segment.segmentId));
506
+ if (ranges?.some((range) => !segmentIds.has(range.segmentId))) issues.push({
507
+ code: "invalid_entity_payload",
508
+ entityId: marker.entityId,
509
+ message: "Annotation segmentRanges must name segments of the related AudioScript"
510
+ });
511
+ }
512
+ return issues;
513
+ }
514
+ if (marker.current().segmentRanges !== void 0) issues.push({
515
+ code: "invalid_entity_payload",
516
+ entityId: marker.entityId,
517
+ message: "segmentRanges belongs to an AudioScript annotation Marker, not a display Marker"
518
+ });
217
519
  if (clipEdges.length + axVideoEdges.length !== 1) issues.push({
218
520
  code: "marker_container_xor",
219
521
  entityId: marker.entityId,
@@ -272,25 +574,35 @@ function validateAXVideoAdmission(axVideo, index) {
272
574
  message: "AXVideo must have exactly one live AXVideo-Marker Relation"
273
575
  }];
274
576
  }
275
- function validateCaptionAsset(caption, index) {
276
- const assets = ofKind([...index.relationsOf(caption)], "physical-asset").map((relation) => relation.other(caption)?.deref()?.current()).filter((entity) => entity?.entityKind === "asset");
277
- if (assets.length > 0) {
278
- const captionText = caption.current().text;
279
- if (!assets.some((asset) => {
280
- const inline = asset.inline;
281
- return isRecord(inline) && inline.mediaType === "text/plain" && inline.text !== captionText;
282
- })) return [];
283
- return [{
284
- code: "caption_inline_asset_mismatch",
285
- entityId: caption.entityId,
286
- message: "Caption text must match the text/plain inline Physical Asset"
287
- }];
577
+ /** Validate a Caption against the complete entity set, including its directly held bases. */
578
+ function validateCaptionComposition(caption, index, entities = []) {
579
+ return validateVariantComposition(caption, index, entities, "caption");
580
+ }
581
+ function validatePhoneticComposition(phonetic, index, entities = []) {
582
+ return validateVariantComposition(phonetic, index, entities, "phonetic-script");
583
+ }
584
+ function validateVariantComposition(variant, index, entities, kind) {
585
+ const issues = [];
586
+ if (kind === "caption" && ofKind([...index.relationsOf(variant)], "physical-asset").length > 0) issues.push({
587
+ code: "caption_physical_asset_forbidden",
588
+ entityId: variant.entityId,
589
+ message: "Caption text is composed from its AudioScript; Caption must not bind a Physical Asset"
590
+ });
591
+ const rows = {
592
+ entities: collectRelatedEntities([...entities, variant], index).entities.map((ref) => entityToRow(ref.current())),
593
+ relations: []
594
+ };
595
+ try {
596
+ if (kind === "caption") assembleCaptionContent(rows, variant.entityId);
597
+ else assemblePhoneticScriptContent(rows, variant.entityId);
598
+ } catch (error) {
599
+ issues.push({
600
+ code: kind === "caption" ? "caption_composition_required" : "phonetic_script_composition_required",
601
+ entityId: variant.entityId,
602
+ message: error instanceof Error ? error.message : "Invalid variant composition"
603
+ });
288
604
  }
289
- return [{
290
- code: "caption_asset_required",
291
- entityId: caption.entityId,
292
- message: "Caption must have a Physical Asset Relation"
293
- }];
605
+ return issues;
294
606
  }
295
607
  function validateClipPlacement(clip, index) {
296
608
  const marker = ofKind([...index.relationsOf(clip)], "clip-marker")[0]?.other(clip)?.deref();
@@ -316,10 +628,10 @@ function validateClipPlacement(clip, index) {
316
628
  const content = ofKind([...index.relationsOf(marker)], "marker-content")[0]?.other(marker)?.deref()?.current();
317
629
  const track = ofKind([...index.relationsOf(clip)], "track-clip")[0]?.other(clip)?.deref()?.current();
318
630
  const trackRole = track?.entityKind === "track" ? track.role : void 0;
319
- if (content?.entityKind !== "audio" || trackRole !== "bgm" || !hasOrder) issues.push({
631
+ if (content?.entityKind !== "audio" || trackRole !== "bgm" || !hasOrder && !hasTarget) issues.push({
320
632
  code: "timeline_duration_policy_invalid",
321
633
  entityId: marker.entityId,
322
- message: "Marker durationPolicy \"timeline\" is only valid for ordered Audio Clips on the bgm Track"
634
+ message: "Marker durationPolicy \"timeline\" is only valid for ordered or absolutely placed Audio Clips on the bgm Track"
323
635
  });
324
636
  }
325
637
  return issues;
@@ -418,6 +730,18 @@ function validateKnownEntityPayload(entity) {
418
730
  const value = entity;
419
731
  const problems = [];
420
732
  if (value.lifecycle !== void 0 && !isRecord(value.lifecycle)) problems.push("lifecycle must be an object when present");
733
+ if (entity.entityKind === "audio-script" || entity.entityKind === "phonetic-script") {
734
+ for (const key of [
735
+ "extent",
736
+ "sampling",
737
+ "coordinateSpace",
738
+ "sourceRange",
739
+ "targetRange",
740
+ "duration",
741
+ "startMs",
742
+ "endMs"
743
+ ]) if (Object.hasOwn(value, key)) problems.push(`${key} is not intrinsic script data; assign an external annotation Marker`);
744
+ }
421
745
  switch (entity.entityKind) {
422
746
  case "track":
423
747
  validateOptionalField(value, "hidden", "boolean", problems);
@@ -429,11 +753,13 @@ function validateKnownEntityPayload(entity) {
429
753
  case "voice":
430
754
  case "caption":
431
755
  validateSequencePayload(value, "bounded", "native", problems);
756
+ if (entity.entityKind !== "caption") validateMediaAssetFields(value, problems);
432
757
  if (entity.entityKind === "voice") validateVoicePayload(value, problems);
433
758
  if (entity.entityKind === "caption") validateCaptionPayload(value, problems);
434
759
  break;
435
760
  case "image":
436
761
  validateSequencePayload(value, "unbounded", "constant", problems);
762
+ validateMediaAssetFields(value, problems);
437
763
  break;
438
764
  case "axvideo":
439
765
  validateSequencePayload(value, "bounded", "derived", problems);
@@ -442,9 +768,11 @@ function validateKnownEntityPayload(entity) {
442
768
  validateMarkerPayload(value, problems);
443
769
  break;
444
770
  case "audio-script":
445
- case "phonetic-script":
446
771
  validateScriptPayload(value, problems);
447
772
  break;
773
+ case "phonetic-script":
774
+ validatePhoneticScriptPayload(value, problems);
775
+ break;
448
776
  case "timeline":
449
777
  case "viewport": break;
450
778
  case "clip":
@@ -481,22 +809,58 @@ function validateMarkerPayload(value, problems) {
481
809
  } else if (duration.mode !== "from-source") problems.push("duration.mode must be \"from-source\" or \"fixed\"");
482
810
  if (value.anchorOffset === void 0 && Object.hasOwn(value, "anchorOffset")) problems.push("anchorOffset cannot be undefined when present");
483
811
  if (value.durationPolicy !== void 0 && value.durationPolicy !== "timeline") problems.push("durationPolicy must be \"timeline\" when present");
812
+ if (value.segmentRanges !== void 0) validateSegmentRanges(value.segmentRanges, problems);
813
+ }
814
+ /** Annotation Marker time values are directly assigned facts; no cross-references are allowed. */
815
+ function validateSegmentRanges(value, problems) {
816
+ if (!Array.isArray(value)) {
817
+ problems.push("segmentRanges must be an array when present");
818
+ return;
819
+ }
820
+ const seen = /* @__PURE__ */ new Set();
821
+ for (const [index, entry] of value.entries()) {
822
+ if (!isRecord(entry)) {
823
+ problems.push(`segmentRanges[${index}] must be an object`);
824
+ continue;
825
+ }
826
+ if (typeof entry.segmentId !== "string") problems.push(`segmentRanges[${index}].segmentId must be a string`);
827
+ else if (seen.has(entry.segmentId)) problems.push(`segmentRanges[${index}].segmentId must be unique within the Marker`);
828
+ else seen.add(entry.segmentId);
829
+ for (const key of ["startMs", "endMs"]) {
830
+ const point = entry[key];
831
+ if (typeof point !== "number" || !Number.isFinite(point)) problems.push(`segmentRanges[${index}].${key} must be a finite number`);
832
+ }
833
+ 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`);
834
+ }
484
835
  }
485
836
  function validateAssetPayload(value, problems) {
837
+ validateExternalLocator(value, problems);
838
+ validateStorageKey(value, problems);
839
+ if (value.inline !== void 0) problems.push("inline is not a physical location; text is domain content owned by its AudioScript");
840
+ }
841
+ /**
842
+ * Media variants own their Asset locator directly: `external` is required and
843
+ * textual domain content is owned by AudioScript; the physical-location
844
+ * vocabularies cannot mix.
845
+ */
846
+ function validateMediaAssetFields(value, problems) {
847
+ if (value.external === void 0) problems.push("external is required; media variants own their Asset identity");
848
+ else validateExternalLocator(value, problems);
849
+ validateStorageKey(value, problems);
850
+ if (value.inline !== void 0) problems.push("media variants use external locators, not inline domain text");
851
+ }
852
+ function validateExternalLocator(value, problems) {
486
853
  const external = value.external;
487
- if (external !== void 0) if (!isRecord(external)) problems.push("external must be an object when present");
488
- else {
489
- if (external.system !== "memota" && external.system !== "memota-speech") problems.push("external.system must be \"memota\" or \"memota-speech\"");
490
- if (typeof external.key !== "string" || external.key.trim() === "") problems.push("external.key must be a non-empty string");
854
+ if (external === void 0) return;
855
+ if (!isRecord(external)) {
856
+ problems.push("external must be an object when present");
857
+ return;
491
858
  }
859
+ if (external.system !== "memota" && external.system !== "memota-speech") problems.push("external.system must be \"memota\" or \"memota-speech\"");
860
+ if (typeof external.key !== "string" || external.key.trim() === "") problems.push("external.key must be a non-empty string");
861
+ }
862
+ function validateStorageKey(value, problems) {
492
863
  if (value.storageKey !== void 0 && (typeof value.storageKey !== "string" || value.storageKey.trim() === "")) problems.push("storageKey must be a non-empty string when present");
493
- const inline = value.inline;
494
- if (inline !== void 0) if (!isRecord(inline)) problems.push("inline must be an object when present");
495
- else {
496
- if (inline.mediaType !== "text/plain") problems.push("inline.mediaType must be \"text/plain\"");
497
- if (typeof inline.text !== "string") problems.push("inline.text must be a string");
498
- }
499
- if (external !== void 0 && inline !== void 0) problems.push("Asset must not combine external and inline physical locations");
500
864
  }
501
865
  function validateVoicePayload(value, problems) {
502
866
  const voice = value.voice;
@@ -510,7 +874,26 @@ function validateVoicePayload(value, problems) {
510
874
  if (voice.name !== void 0 && typeof voice.name !== "string") problems.push("voice.name must be a string when present");
511
875
  }
512
876
  function validateCaptionPayload(value, problems) {
513
- if (value.text !== void 0 && typeof value.text !== "string") problems.push("text must be a string when present");
877
+ const selections = value.selections;
878
+ if (!Array.isArray(selections)) problems.push("selections must be a non-empty array of AudioScript segment selections");
879
+ else {
880
+ if (selections.length === 0) problems.push("selections must name at least one AudioScript segment");
881
+ const seen = /* @__PURE__ */ new Set();
882
+ for (const [index, selection] of selections.entries()) {
883
+ if (!isRecord(selection)) {
884
+ problems.push(`selections[${index}] must be an object`);
885
+ continue;
886
+ }
887
+ if (typeof selection.segmentId !== "string" || selection.segmentId.trim() === "") problems.push(`selections[${index}].segmentId must be a string`);
888
+ else if (seen.has(selection.segmentId)) problems.push(`selections[${index}].segmentId must be unique within the Caption`);
889
+ else seen.add(selection.segmentId);
890
+ if (Object.keys(selection).some((key) => key !== "segmentId" && key !== "textRange")) problems.push(`selections[${index}] may only contain segmentId and textRange`);
891
+ if (selection.textRange !== void 0) {
892
+ const range = selection.textRange;
893
+ 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`);
894
+ }
895
+ }
896
+ }
514
897
  const style = value.style;
515
898
  if (style === void 0) return;
516
899
  if (!isRecord(style)) {
@@ -545,6 +928,12 @@ function validateRange(value, path, required, problems) {
545
928
  if (!Object.hasOwn(value, "start") || value.start === void 0) problems.push(`${path}.start is required`);
546
929
  if (!Object.hasOwn(value, "end") || value.end === void 0) problems.push(`${path}.end is required`);
547
930
  }
931
+ /** Phonetic variants store pronunciation fields only; base text stays in the AudioScript. */
932
+ function validatePhoneticScriptPayload(value, problems) {
933
+ if (value.segments !== void 0) validateScriptPayload(value, problems);
934
+ if (value.phonemeScript !== void 0 && (typeof value.phonemeScript !== "string" || value.phonemeScript.trim() === "")) problems.push("phonemeScript must be a non-empty string when present");
935
+ if (value.prosody !== void 0 && !isRecord(value.prosody)) problems.push("prosody must be an object when present");
936
+ }
548
937
  function validateScriptPayload(value, problems) {
549
938
  if (!Array.isArray(value.segments)) {
550
939
  problems.push("segments must be an array");
@@ -555,6 +944,15 @@ function validateScriptPayload(value, problems) {
555
944
  problems.push(`segments[${index}] must be an object`);
556
945
  continue;
557
946
  }
947
+ for (const key of [
948
+ "startMs",
949
+ "endMs",
950
+ "start_ms",
951
+ "end_ms",
952
+ "sourceRange",
953
+ "targetRange",
954
+ "duration"
955
+ ]) if (Object.hasOwn(segment, key)) problems.push(`segments[${index}].${key} is timing; attach an AudioScript annotation Marker instead`);
558
956
  if (typeof segment.segmentId !== "string") problems.push(`segments[${index}].segmentId must be a string`);
559
957
  if (typeof segment.text !== "string") problems.push(`segments[${index}].text must be a string`);
560
958
  if (segment.language !== void 0 && typeof segment.language !== "string") problems.push(`segments[${index}].language must be a string when present`);
@@ -578,6 +976,7 @@ function visitPeerEntityIdPaths(value, entityKind, parentPath, ancestors, paths)
578
976
  if (Array.isArray(value)) for (const [index, item] of value.entries()) visitPeerEntityIdPaths(item, entityKind, `${parentPath}[${index}]`, ancestors, paths);
579
977
  else for (const [key, child] of Object.entries(value)) {
580
978
  const path = parentPath.length === 0 ? key : `${parentPath}.${key}`;
979
+ if (parentPath.length === 0 && key === "baseEntityIds") continue;
581
980
  if (!(parentPath.length === 0 && key === "entityId") && !isOwnedLocalIdPath(entityKind, path) && isEntityIdFieldName(key)) paths.push(path);
582
981
  visitPeerEntityIdPaths(child, entityKind, path, ancestors, paths);
583
982
  }
@@ -597,7 +996,7 @@ function visitPeerEntityValues(value, entityKind, ownEntityId, entityIds, path,
597
996
  ancestors.add(value);
598
997
  if (Array.isArray(value)) for (const [index, item] of value.entries()) visitPeerEntityValues(item, entityKind, ownEntityId, entityIds, `${path}[${index}]`, ancestors, paths);
599
998
  else for (const [key, child] of Object.entries(value)) {
600
- if (path.length === 0 && (key === "entityId" || key === "entityKind")) continue;
999
+ if (path.length === 0 && (key === "entityId" || key === "entityKind" || key === "baseEntityIds")) continue;
601
1000
  visitPeerEntityValues(child, entityKind, ownEntityId, entityIds, path.length === 0 ? key : `${path}.${key}`, ancestors, paths);
602
1001
  }
603
1002
  ancestors.delete(value);
@@ -613,15 +1012,27 @@ function isOwnedLocalEntityValuePath(entityKind, path) {
613
1012
  "axvideo"
614
1013
  ].includes(entityKind) && /^(?:sampling|coordinateSpace|coordinateSpace\.unit|extent\.kind)$/.test(path)) return true;
615
1014
  if (entityKind === "sequence-marker" && /^(?:durationPolicy|duration\.mode|timeRemapping\.(?:kind|mode))$/.test(path)) return true;
616
- if (entityKind === "asset" && /^(?:external\.(?:system|key)|storageKey|inline\.(?:mediaType|text))$/.test(path)) return true;
1015
+ if (entityKind === "sequence-marker" && /^segmentRanges\[\d+\]\.segmentId$/.test(path)) return true;
1016
+ if (entityKind === "asset" && /^(?:external\.(?:system|key)|storageKey)$/.test(path)) return true;
1017
+ if (isMediaAssetVariantKind(entityKind) && /^(?:external\.(?:system|key)|storageKey)$/.test(path)) return true;
617
1018
  if (entityKind === "voice" && /^voice\.(?:system|key|name)$/.test(path)) return true;
618
- if (entityKind === "caption" && (path === "text" || path.startsWith("style."))) return true;
619
- if ((entityKind === "audio-script" || entityKind === "phonetic-script") && /^segments\[\d+\]\.(?:segmentId|text|language)$/.test(path)) return true;
1019
+ if (entityKind === "caption" && (path.startsWith("style.") || /^selections\[\d+\]\.segmentId$/.test(path))) return true;
1020
+ if ([
1021
+ "audio-script",
1022
+ "caption",
1023
+ "phonetic-script"
1024
+ ].includes(entityKind) && /^segments\[\d+\]\.(?:segmentId|text|language)$/.test(path)) return true;
620
1025
  return false;
621
1026
  }
622
1027
  function isOwnedLocalIdPath(entityKind, path) {
623
1028
  if (path === "lifecycle.actorId") return true;
624
- if ((entityKind === "audio-script" || entityKind === "phonetic-script") && /^segments\[\d+\]\.segmentId$/.test(path)) return true;
1029
+ if ([
1030
+ "audio-script",
1031
+ "caption",
1032
+ "phonetic-script"
1033
+ ].includes(entityKind) && /^segments\[\d+\]\.segmentId$/.test(path)) return true;
1034
+ if (entityKind === "caption" && /^selections\[\d+\]\.segmentId$/.test(path)) return true;
1035
+ if (entityKind === "sequence-marker" && /^segmentRanges\[\d+\]\.segmentId$/.test(path)) return true;
625
1036
  if (entityKind === "asset" && /^(?:tracks\[\d+\]\.trackId|renditions\[\d+\]\.renditionId)$/.test(path)) return true;
626
1037
  return false;
627
1038
  }
@@ -676,35 +1087,6 @@ function ofKind(relations, kind) {
676
1087
  return relations.filter((relation) => relation.kind === kind);
677
1088
  }
678
1089
  //#endregion
679
- //#region ../medeo-dsl/src/json-values.ts
680
- const nativeObjectConstructorSource = Function.prototype.toString.call(Object);
681
- function isJsonObject(value) {
682
- return isJsonValue(value, /* @__PURE__ */ new Set()) && !Array.isArray(value) && value !== null;
683
- }
684
- function isJsonValue(value, ancestors) {
685
- if (value === null || typeof value === "string" || typeof value === "boolean") return true;
686
- if (typeof value === "number") return Number.isFinite(value);
687
- if (typeof value !== "object") return false;
688
- if (!Array.isArray(value) && !isPlainObject(value)) return false;
689
- if (ancestors.has(value)) return false;
690
- ancestors.add(value);
691
- const valid = Array.isArray(value) ? value.every((item) => isJsonValue(item, ancestors)) : Object.values(value).every((item) => isJsonValue(item, ancestors));
692
- ancestors.delete(value);
693
- return valid;
694
- }
695
- /** Recognize an ordinary object from any VM realm without admitting class instances. */
696
- function isPlainObject(value) {
697
- try {
698
- const prototype = Object.getPrototypeOf(value);
699
- if (prototype === null) return true;
700
- if (Object.getPrototypeOf(prototype) !== null) return false;
701
- const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor")?.value;
702
- return typeof constructor === "function" && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === nativeObjectConstructorSource;
703
- } catch {
704
- return false;
705
- }
706
- }
707
- //#endregion
708
1090
  //#region ../medeo-dsl/src/relation-specs.ts
709
1091
  const timelineTrackRelationSpec = emptySpec("timeline-track", "timeline", "track");
710
1092
  const trackClipRelationSpec = emptySpec("track-clip", "track", "clip");
@@ -727,8 +1109,6 @@ const generatedRelationSpec = Object.freeze({
727
1109
  validateEndpoints: (endpoints) => endpoints.every((endpoint) => isGeneratedMedia(endpoint.current())),
728
1110
  validateMetadata: isEmptyMetadata
729
1111
  });
730
- const phoneticScriptProvenanceRelationSpec = metadataSpec("phonetic-script-provenance", "phonetic-script", "audio-script", isSegmentAlignmentMetadata);
731
- const captionProvenanceRelationSpec = metadataSpec("caption-provenance", "caption", "audio-script", isSegmentAlignmentMetadata);
732
1112
  const captionAlignmentRelationSpec = Object.freeze({
733
1113
  kind: "caption-alignment",
734
1114
  validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["caption"]), new Set(["audio", "voice"])),
@@ -740,13 +1120,31 @@ const clipAnchorRelationSpec = Object.freeze({
740
1120
  validateEndpoints: (endpoints) => endpoints[0].current().entityKind === "clip" && endpoints[1].current().entityKind === "clip",
741
1121
  validateMetadata: isEmptyMetadata
742
1122
  });
743
- /** `audio-script-render(output, script)` means endpoint 0 was rendered from endpoint 1. */
744
- const audioScriptRenderRelationSpec = Object.freeze({
745
- kind: "audio-script-render",
746
- validateEndpoints: (endpoints) => {
747
- const outputKind = endpoints[0].current().entityKind;
748
- return (outputKind === "audio" || outputKind === "voice") && endpoints[1].current().entityKind === "audio-script";
749
- },
1123
+ /** Voice is generated from PhoneticScript; kinds determine roles regardless of endpoint positions. */
1124
+ const phoneticScriptRenderRelationSpec = Object.freeze({
1125
+ kind: "phonetic-script-render",
1126
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["voice"]), new Set(["phonetic-script"])),
1127
+ validateMetadata: isEmptyMetadata
1128
+ });
1129
+ /** AudioScript was transcribed from Audio, Video, or recorded Voice; kinds determine roles. */
1130
+ const audioScriptSourceRelationSpec = Object.freeze({
1131
+ kind: "audio-script-source",
1132
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["audio-script"]), new Set([
1133
+ "audio",
1134
+ "video",
1135
+ "voice"
1136
+ ])),
1137
+ validateMetadata: isEmptyMetadata
1138
+ });
1139
+ /**
1140
+ * `audio-script-marker(script, marker)` attaches an annotation Sequence Marker
1141
+ * whose `segmentRanges` hold directly assigned per-Segment time values. The
1142
+ * script keeps no Sequence and no Clip admission; annotation Markers cannot
1143
+ * enter Clip/AXVideo use chains (enforced by the marker-use invariant).
1144
+ */
1145
+ const audioScriptMarkerRelationSpec = Object.freeze({
1146
+ kind: "audio-script-marker",
1147
+ validateEndpoints: (endpoints) => hasKinds(endpoints, new Set(["audio-script"]), new Set(["sequence-marker"])),
750
1148
  validateMetadata: isEmptyMetadata
751
1149
  });
752
1150
  /** Built-in kinds are reserved; callers may add specs only under new names. */
@@ -759,11 +1157,11 @@ const builtInRelationSpecs = Object.freeze([
759
1157
  markerTimelineRelationSpec,
760
1158
  physicalAssetRelationSpec,
761
1159
  generatedRelationSpec,
762
- phoneticScriptProvenanceRelationSpec,
763
- captionProvenanceRelationSpec,
764
1160
  captionAlignmentRelationSpec,
765
1161
  clipAnchorRelationSpec,
766
- audioScriptRenderRelationSpec
1162
+ phoneticScriptRenderRelationSpec,
1163
+ audioScriptSourceRelationSpec,
1164
+ audioScriptMarkerRelationSpec
767
1165
  ]);
768
1166
  function emptySpec(kind, a, b) {
769
1167
  return metadataSpec(kind, a, b, isEmptyMetadata);
@@ -801,9 +1199,6 @@ function isAssetBinding(value) {
801
1199
  const orderingKey = /(order|ordinal|position|rank|index|z[_-]?index)/i;
802
1200
  return Object.keys(value).every((key) => !orderingKey.test(key));
803
1201
  }
804
- function isSegmentAlignmentMetadata(value) {
805
- return isJsonObject(value) && Object.hasOwn(value, "segmentAlignment");
806
- }
807
1202
  function isCaptionAlignmentMetadata(value) {
808
1203
  return isJsonObject(value) && Object.hasOwn(value, "alignment");
809
1204
  }
@@ -860,7 +1255,7 @@ var BiRelationIndex = class {
860
1255
  byRelationId = /* @__PURE__ */ new Map();
861
1256
  link(input) {
862
1257
  if (input.spec.kind === "generated") throw new Error("Author generated Relations with linkGenerated({ output, input })");
863
- if (input.spec.kind === "clip-anchor" || input.spec.kind === "audio-script-render") throw new Error(`Author ordered ${input.spec.kind} Relations with the dedicated role-named method`);
1258
+ if (input.spec.kind === "clip-anchor" || input.spec.kind === "phonetic-script-render" || input.spec.kind === "audio-script-source") throw new Error(`Author ordered ${input.spec.kind} Relations with the dedicated role-named method`);
864
1259
  return this.linkValidated(input);
865
1260
  }
866
1261
  /** Author `generated(output, input)` without exposing positional arguments. */
@@ -883,12 +1278,22 @@ var BiRelationIndex = class {
883
1278
  trace: input.trace
884
1279
  });
885
1280
  }
886
- /** Author `audio-script-render(output, script)` without exposing positional arguments. */
887
- linkAudioScriptRender(input) {
1281
+ /** Author `phonetic-script-render(output, phoneticScript)` without exposing positional arguments. */
1282
+ linkPhoneticScriptRender(input) {
888
1283
  return this.linkValidated({
889
1284
  relationId: input.relationId,
890
- spec: audioScriptRenderRelationSpec,
891
- endpoints: [input.output, input.script],
1285
+ spec: phoneticScriptRenderRelationSpec,
1286
+ endpoints: [input.output, input.phoneticScript],
1287
+ metadata: {},
1288
+ trace: input.trace
1289
+ });
1290
+ }
1291
+ /** Author `audio-script-source(script, source)` without exposing positional arguments. */
1292
+ linkAudioScriptSource(input) {
1293
+ return this.linkValidated({
1294
+ relationId: input.relationId,
1295
+ spec: audioScriptSourceRelationSpec,
1296
+ endpoints: [input.script, input.source],
892
1297
  metadata: {},
893
1298
  trace: input.trace
894
1299
  });
@@ -979,7 +1384,13 @@ function decodeEntityRelationRows(rows, options) {
979
1384
  const entitiesById = /* @__PURE__ */ new Map();
980
1385
  const extensionEntityKinds = new Set(options.entityKinds ?? []);
981
1386
  for (const row of rows.entities) {
982
- const entity = decodeEntityRow(row, extensionEntityKinds, issues);
1387
+ let entity = decodeEntityRow(row, extensionEntityKinds, issues);
1388
+ if (entity != null) try {
1389
+ entity = decodeEntityRow(assembleEntityContent(rows, row.entityId), extensionEntityKinds, issues);
1390
+ } catch (error) {
1391
+ issues.push(errorMessage(error));
1392
+ continue;
1393
+ }
983
1394
  if (entity == null) continue;
984
1395
  const ref = createEntityRef(entity);
985
1396
  if (entitiesById.has(ref.entityId)) {
@@ -1112,17 +1523,6 @@ function errorMessage(error) {
1112
1523
  return error instanceof Error ? error.message : String(error);
1113
1524
  }
1114
1525
  //#endregion
1115
- //#region ../medeo-dsl/src/rows.ts
1116
- function entityToRow(entity) {
1117
- const { entityId, entityKind, ...payload } = entity;
1118
- if (!isJsonObject(payload)) throw new Error(`Entity "${entityId}" payload must contain only JSON values`);
1119
- return {
1120
- entityId,
1121
- entityKind,
1122
- payload
1123
- };
1124
- }
1125
- //#endregion
1126
1526
  //#region src/entity/entity-sandbox.ts
1127
1527
  const ASSET_SOURCE_KEY = "external";
1128
1528
  const MEMOTA_SYSTEM = "memota";
@@ -1139,6 +1539,7 @@ var EntitySandbox = class {
1139
1539
  constructor(options) {
1140
1540
  this.original = cloneSnapshot(options.state ?? {
1141
1541
  revision: 0,
1542
+ audioScriptEntityId: null,
1142
1543
  entities: [],
1143
1544
  relations: []
1144
1545
  });
@@ -1155,6 +1556,15 @@ var EntitySandbox = class {
1155
1556
  getCommands() {
1156
1557
  return this.commands;
1157
1558
  }
1559
+ /** Host-owned fixed structure is journaled through the same CAS graph as model edits. */
1560
+ ensureFoundation(timelinePayload = {}) {
1561
+ const foundation = ensureEditorFoundation(toDslRows(this.state), this.idFactory, timelinePayload);
1562
+ this.appendResourceRows(foundation.rows);
1563
+ if (this.state.audioScriptEntityId === null) this.state.audioScriptEntityId = foundation.audioScriptEntityId;
1564
+ }
1565
+ get audioScriptEntityId() {
1566
+ return this.state.audioScriptEntityId;
1567
+ }
1158
1568
  rollbackTo(index) {
1159
1569
  if (!Number.isInteger(index) || index < 0 || index > this.commands.length) throw new Error(`rollbackTo: entity checkpoint index ${index} is past journal length ${this.commands.length}`);
1160
1570
  const prefix = this.commands.slice(0, index);
@@ -1165,7 +1575,15 @@ var EntitySandbox = class {
1165
1575
  this.onTruncate?.(index);
1166
1576
  }
1167
1577
  buildPlan() {
1168
- decodeEntityRelationRows(toDslRows(this.state), numericMarkerComparators);
1578
+ const rows = toDslRows(this.state);
1579
+ decodeEntityRelationRows(rows, numericMarkerComparators);
1580
+ if (this.state.revision !== 0 || rows.entities.length !== 0 || this.commands.length !== 0) {
1581
+ assertCanonicalEditorResources(rows);
1582
+ readDocumentAudioScript({
1583
+ rows,
1584
+ audioScriptEntityId: this.state.audioScriptEntityId
1585
+ });
1586
+ }
1169
1587
  const currentEntityIds = new Set(this.state.entities.map((entity) => entity.entity_id));
1170
1588
  const currentRelationIds = new Set(this.state.relations.map((relation) => relation.relation_id));
1171
1589
  return {
@@ -1198,21 +1616,67 @@ var EntitySandbox = class {
1198
1616
  }
1199
1617
  return lines.join("\n");
1200
1618
  }
1619
+ assembledEntity(entity) {
1620
+ const assembled = assembleEntityContent(toDslRows(this.state), createEntityId(entity.entity_id));
1621
+ return {
1622
+ ...clone(entity),
1623
+ payload: assembled.payload
1624
+ };
1625
+ }
1201
1626
  buildEntityFacade() {
1202
1627
  return {
1203
- list: () => clone(this.state.entities),
1628
+ list: () => this.state.entities.map((entity) => this.assembledEntity(entity)),
1204
1629
  get: (entityId) => {
1205
1630
  const entity = this.state.entities.find((candidate) => candidate.entity_id === entityId);
1206
- return entity == null ? null : clone(entity);
1631
+ return entity == null ? null : this.assembledEntity(entity);
1207
1632
  },
1208
1633
  findByAssetId: (assetId) => {
1209
1634
  assertTrimmed(assetId, "assetId");
1210
- return clone(this.state.entities.filter((entity) => entity.entity_kind === "asset" && isImportedMemotaAsset(entity.payload, assetId)));
1635
+ return clone(this.state.entities.map((entity) => this.assembledEntity(entity)).filter((entity) => isMediaAssetVariantKind(entity.entity_kind) && isImportedMemotaAsset(entity.payload, assetId)));
1636
+ },
1637
+ readCaptionContent: (entityId) => {
1638
+ const assembled = assembleCaptionContent(toDslRows(this.state), createEntityId(entityId));
1639
+ return clone({
1640
+ audio_script_entity_id: assembled.audioScript.entityId,
1641
+ text: assembled.text,
1642
+ segments: assembled.segments.map((segment) => ({ ...segment }))
1643
+ });
1644
+ },
1645
+ readPhoneticScriptContent: (entityId) => {
1646
+ const assembled = assemblePhoneticScriptContent(toDslRows(this.state), createEntityId(entityId));
1647
+ const own = assembled.phoneticScript.payload;
1648
+ return clone({
1649
+ audio_script_entity_id: assembled.audioScript.entityId,
1650
+ text: assembled.text,
1651
+ segments: assembled.segments.map((segment) => ({ ...segment })),
1652
+ ...typeof own.phonemeScript === "string" ? { phonemeScript: own.phonemeScript } : {},
1653
+ ...isJsonObject(own.prosody) ? { prosody: clone(own.prosody) } : {}
1654
+ });
1211
1655
  },
1212
1656
  create: (input) => this.createEntity(input),
1213
- update: (input) => this.updateEntity(input),
1657
+ update: (input) => {
1658
+ assertTrimmed(input.entity_id, "entity_id");
1659
+ if (!this.state.entities.some((entity) => entity.entity_id === input.entity_id)) throw new Error(`Entity id "${input.entity_id}" does not exist`);
1660
+ const updates = updateEntityFields(toDslRows(this.state), createEntityId(input.entity_id), input.payload);
1661
+ for (const row of updates) this.replaceOwnedPayload({
1662
+ entity_id: row.entityId,
1663
+ payload: row.payload
1664
+ });
1665
+ },
1666
+ declareFields: (input) => {
1667
+ const current = this.state.entities.find((entity) => entity.entity_id === input.entity_id);
1668
+ if (current === void 0) throw new Error(`Unknown entity "${input.entity_id}"`);
1669
+ assembleEntityContent(toDslRows(this.state), createEntityId(input.entity_id));
1670
+ this.replaceOwnedPayload({
1671
+ entity_id: input.entity_id,
1672
+ payload: {
1673
+ ...current.payload,
1674
+ ...input.payload
1675
+ }
1676
+ });
1677
+ },
1214
1678
  delete: (input) => this.deleteEntity(input),
1215
- importAsset: (input) => this.importAsset(input)
1679
+ ensureMedia: (fact) => this.ensureMedia(fact)
1216
1680
  };
1217
1681
  }
1218
1682
  buildRelationFacade() {
@@ -1226,7 +1690,8 @@ var EntitySandbox = class {
1226
1690
  link: (input) => this.link(input),
1227
1691
  linkGenerated: (input) => this.linkGenerated(input),
1228
1692
  linkClipAnchor: (input) => this.linkClipAnchor(input),
1229
- linkAudioScriptRender: (input) => this.linkAudioScriptRender(input),
1693
+ linkPhoneticScriptRender: (input) => this.linkPhoneticScriptRender(input),
1694
+ linkAudioScriptSource: (input) => this.linkAudioScriptSource(input),
1230
1695
  unlink: (input) => this.unlinkRelation(input)
1231
1696
  };
1232
1697
  }
@@ -1234,8 +1699,27 @@ var EntitySandbox = class {
1234
1699
  if (!isKnownEntityKind(input.entity_kind)) throw new Error(`Unknown or extension Entity kind "${String(input.entity_kind)}"`);
1235
1700
  const payload = clone(input.payload);
1236
1701
  if (!isJsonObject(payload)) throw new Error("Entity payload must contain only JSON values");
1702
+ if (input.entity_kind === "timeline" || input.entity_kind === "track") {
1703
+ const matches = this.state.entities.filter((entity) => entity.entity_kind === input.entity_kind && (input.entity_kind === "timeline" || entity.payload.role === payload.role));
1704
+ if (matches.length > 1) throw new Error(`Ambiguous editor ${input.entity_kind}; resolve existing identities`);
1705
+ if (matches[0] !== void 0) return this.reuseEntity(matches[0], input, payload);
1706
+ }
1707
+ const external = payload.external;
1708
+ if (isMediaAssetVariantKind(input.entity_kind) && isJsonObject(external) && (external.system === "memota" || external.system === "memota-speech") && typeof external.key === "string") {
1709
+ 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);
1710
+ if (matches.length > 1) throw new Error(`Ambiguous Asset bindings for ${external.key}`);
1711
+ const existing = matches[0];
1712
+ if (existing !== void 0) {
1713
+ 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}`);
1714
+ return this.reuseEntity(existing, input, payload);
1715
+ }
1716
+ }
1237
1717
  const entityId = input.entity_id ?? this.idFactory("entity");
1238
1718
  assertTrimmed(entityId, "entity_id");
1719
+ if (input.entity_kind === "audio-script") {
1720
+ const existing = this.state.entities.find((entity) => entity.entity_kind === "audio-script");
1721
+ if (existing !== void 0 && existing.entity_id !== entityId) throw new Error(`Editor requires exactly one AudioScript; edit ${existing.entity_id} instead of creating ${entityId}`);
1722
+ }
1239
1723
  const entity = {
1240
1724
  entity_id: entityId,
1241
1725
  entity_kind: input.entity_kind,
@@ -1252,7 +1736,21 @@ var EntitySandbox = class {
1252
1736
  });
1253
1737
  return entityId;
1254
1738
  }
1255
- updateEntity(input) {
1739
+ reuseEntity(existing, input, payload) {
1740
+ if (input.entity_id !== void 0 && input.entity_id !== existing.entity_id) throw new Error(`Entity already exists; reuse ${existing.entity_id}`);
1741
+ for (const [key, value] of Object.entries(payload)) if (existing.payload[key] !== void 0 && !sameJson(existing.payload[key], value)) throw new Error(`Resource facts conflict for ${existing.entity_id}: ${key}`);
1742
+ const merged = {
1743
+ ...existing.payload,
1744
+ ...payload
1745
+ };
1746
+ if (!sameJson(existing.payload, merged)) this.replaceOwnedPayload({
1747
+ entity_id: existing.entity_id,
1748
+ payload: merged
1749
+ });
1750
+ return existing.entity_id;
1751
+ }
1752
+ /** Host persistence adapter; never exposed as the DSL field update operation. */
1753
+ replaceOwnedPayload(input) {
1256
1754
  assertTrimmed(input.entity_id, "entity_id");
1257
1755
  const payload = clone(input.payload);
1258
1756
  if (!isJsonObject(payload)) throw new Error("Entity payload must contain only JSON values");
@@ -1269,21 +1767,42 @@ var EntitySandbox = class {
1269
1767
  entity_id: input.entity_id
1270
1768
  });
1271
1769
  }
1272
- importAsset(input) {
1273
- assertTrimmed(input.asset_id, "asset_id");
1274
- const payload = input.payload === void 0 ? {} : clone(input.payload);
1275
- if (!isJsonObject(payload)) throw new Error("Asset payload must contain only JSON values");
1276
- return this.createEntity({
1277
- ...input.entity_id !== void 0 ? { entity_id: input.entity_id } : {},
1278
- entity_kind: "asset",
1279
- payload: {
1280
- ...payload,
1281
- [ASSET_SOURCE_KEY]: {
1282
- system: MEMOTA_SYSTEM,
1283
- key: input.asset_id
1284
- }
1285
- }
1286
- });
1770
+ ensureMedia(fact) {
1771
+ const checkpoint = this.commandCount;
1772
+ try {
1773
+ const imported = importMediaAsset(toDslRows(this.state), fact, this.idFactory);
1774
+ this.appendResourceRows(imported.rows);
1775
+ return { contentEntityId: imported.contentEntityId };
1776
+ } catch (error) {
1777
+ this.rollbackTo(checkpoint);
1778
+ throw error;
1779
+ }
1780
+ }
1781
+ appendResourceRows(rows) {
1782
+ for (const row of rows.entities) {
1783
+ const existing = this.state.entities.find((entity) => entity.entity_id === row.entityId);
1784
+ if (existing === void 0) this.createEntity({
1785
+ entity_id: row.entityId,
1786
+ entity_kind: row.entityKind,
1787
+ payload: row.payload
1788
+ });
1789
+ else if (!sameJson(existing.payload, row.payload)) this.replaceOwnedPayload({
1790
+ entity_id: row.entityId,
1791
+ payload: clone(row.payload)
1792
+ });
1793
+ }
1794
+ for (const row of rows.relations) {
1795
+ if (this.state.relations.some((relation) => relation.relation_id === row.relationId)) continue;
1796
+ if (row.relationKind !== "timeline-track") throw new Error(`Unexpected resource Relation ${row.relationKind}`);
1797
+ this.link({
1798
+ relation_id: row.relationId,
1799
+ relation_kind: row.relationKind,
1800
+ endpoint_0_entity_id: row.endpoint0EntityId,
1801
+ endpoint_1_entity_id: row.endpoint1EntityId,
1802
+ metadata: clone(row.metadata),
1803
+ trace: clone(row.trace)
1804
+ });
1805
+ }
1287
1806
  }
1288
1807
  link(input) {
1289
1808
  if (input.relation_kind === "generated") throw new Error("Author generated Relations with relations.linkGenerated({ output_entity_id, input_entity_id })");
@@ -1346,19 +1865,40 @@ var EntitySandbox = class {
1346
1865
  });
1347
1866
  return relation.relation_id;
1348
1867
  }
1349
- linkAudioScriptRender(input) {
1868
+ linkPhoneticScriptRender(input) {
1350
1869
  const relation = this.relationFromInput({
1351
1870
  ...input.relation_id !== void 0 ? { relation_id: input.relation_id } : {},
1352
1871
  endpoint_0_entity_id: input.output_entity_id,
1353
- endpoint_1_entity_id: input.script_entity_id,
1872
+ endpoint_1_entity_id: input.phonetic_script_entity_id,
1354
1873
  metadata: {},
1355
1874
  ...input.trace !== void 0 ? { trace: input.trace } : {}
1356
- }, "audio-script-render");
1875
+ }, "phonetic-script-render");
1357
1876
  const [output, script] = this.refsFor(relation);
1358
- new BiRelationIndex().linkAudioScriptRender({
1877
+ new BiRelationIndex().linkPhoneticScriptRender({
1359
1878
  relationId: createRelationId(relation.relation_id),
1360
1879
  output,
1880
+ phoneticScript: script,
1881
+ trace: relation.trace
1882
+ });
1883
+ this.record({
1884
+ kind: "link-relation",
1885
+ relation
1886
+ });
1887
+ return relation.relation_id;
1888
+ }
1889
+ linkAudioScriptSource(input) {
1890
+ const relation = this.relationFromInput({
1891
+ ...input.relation_id !== void 0 ? { relation_id: input.relation_id } : {},
1892
+ endpoint_0_entity_id: input.script_entity_id,
1893
+ endpoint_1_entity_id: input.source_entity_id,
1894
+ metadata: {},
1895
+ ...input.trace !== void 0 ? { trace: input.trace } : {}
1896
+ }, "audio-script-source");
1897
+ const [script, source] = this.refsFor(relation);
1898
+ new BiRelationIndex().linkAudioScriptSource({
1899
+ relationId: createRelationId(relation.relation_id),
1361
1900
  script,
1901
+ source,
1362
1902
  trace: relation.trace
1363
1903
  });
1364
1904
  this.record({
@@ -1408,10 +1948,12 @@ var EntitySandbox = class {
1408
1948
  apply(command, enforceIdentity) {
1409
1949
  switch (command.kind) {
1410
1950
  case "create-entity": {
1951
+ if (command.entity.entity_kind === "audio-script" && this.state.entities.some((entity) => entity.entity_kind === "audio-script")) throw new Error("The project AudioScript already exists; edit its segments instead");
1411
1952
  if (enforceIdentity && this.state.entities.some((entity) => entity.entity_id === command.entity.entity_id)) throw new Error(`Entity id "${command.entity.entity_id}" already exists`);
1412
1953
  const original = this.original.entities.find((entity) => entity.entity_id === command.entity.entity_id);
1413
1954
  if (enforceIdentity && original != null && original.entity_kind !== command.entity.entity_kind) throw new Error(`Entity id "${command.entity.entity_id}" was originally kind "${original.entity_kind}" and cannot be recreated as "${command.entity.entity_kind}"`);
1414
1955
  this.state.entities.push(clone(command.entity));
1956
+ if (command.entity.entity_kind === "audio-script" && this.state.audioScriptEntityId === null) this.state.audioScriptEntityId = command.entity.entity_id;
1415
1957
  return;
1416
1958
  }
1417
1959
  case "update-entity": {
@@ -1426,6 +1968,7 @@ var EntitySandbox = class {
1426
1968
  return;
1427
1969
  }
1428
1970
  case "delete-entity": {
1971
+ if (this.state.entities.find((entity) => entity.entity_id === command.entity_id)?.entity_kind === "audio-script") throw new Error("The document AudioScript is a fixed project identity and cannot be deleted");
1429
1972
  const index = this.state.entities.findIndex((entity) => entity.entity_id === command.entity_id);
1430
1973
  if (index < 0) throw new Error(`Entity id "${command.entity_id}" does not exist`);
1431
1974
  const incidentRelationIds = this.state.relations.filter((relation) => relation.endpoint_0_entity_id === command.entity_id || relation.endpoint_1_entity_id === command.entity_id).map((relation) => relation.relation_id).sort();
@@ -1452,7 +1995,7 @@ var EntitySandbox = class {
1452
1995
  };
1453
1996
  function isImportedMemotaAsset(payload, assetId) {
1454
1997
  const external = payload[ASSET_SOURCE_KEY];
1455
- return external != null && !Array.isArray(external) && typeof external === "object" && external.system === MEMOTA_SYSTEM && external.key === assetId;
1998
+ return external != null && !Array.isArray(external) && typeof external === "object" && (external.system === MEMOTA_SYSTEM || external.system === "memota-speech") && external.key === assetId;
1456
1999
  }
1457
2000
  function toDslEntity(entity) {
1458
2001
  return {
@@ -1484,6 +2027,13 @@ function cloneSnapshot(state) {
1484
2027
  function clone(value) {
1485
2028
  return structuredClone(value);
1486
2029
  }
2030
+ function sameJson(left, right) {
2031
+ if (left === right) return true;
2032
+ if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((value, index) => sameJson(value, right[index]));
2033
+ if (!isJsonObject(left) || !isJsonObject(right)) return false;
2034
+ const keys = Object.keys(left);
2035
+ return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && sameJson(left[key], right[key]));
2036
+ }
1487
2037
  function assertTrimmed(value, label) {
1488
2038
  if (typeof value !== "string" || value.length === 0 || value.trim() !== value) throw new Error(`${label} must be a non-empty trimmed string`);
1489
2039
  }
@@ -1836,6 +2386,6 @@ function replayJournalSync(adapter, journal) {
1836
2386
  }
1837
2387
  }
1838
2388
  //#endregion
1839
- export { collectAffectedPartIds as a, createRelationId as i, EntitySandbox as n, renderPreview as o, createEntityId as r, renderCompactProjection as s, EditSandboxSession as t };
2389
+ export { isMediaAssetVariantKind as a, renderCompactProjection as c, createRelationId as i, EntitySandbox as n, collectAffectedPartIds as o, createEntityId as r, renderPreview as s, EditSandboxSession as t };
1840
2390
 
1841
- //# sourceMappingURL=script-session-CHyIUBkO.mjs.map
2391
+ //# sourceMappingURL=script-session-DLBhoA1R.mjs.map