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

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.js CHANGED
@@ -1,6 +1,8 @@
1
- import { $ as assertValidVideoDocument, A as decodeEntityRelationRows, B as updateEntityFields, C as applyFieldChanges, Ct as speedOf, D as EntityProjectionGraph, Dt as base64ToBytes, E as projectEntityTimeline, Et as videoDocumentMirrorSchema, F as assembleEntityContent, G as hasSequence, H as isJsonObject, I as assemblePhoneticScriptContent, J as buildSpeechHostMap, K as isMediaAssetVariantKind, L as assembleScriptText, M as relationsTo, N as ScriptCompositionError, O as resolveEntityTimelineLayout, Ot as bytesToBase64, P as assembleCaptionContent, Q as VideoDocumentValidationError, R as findComposedAudioScript, S as equalJson, St as effectiveVideoClipDurationMs, T as resolveFieldPath, Tt as recordEntries, U as createEntityId, V as variantBaseEntityIds, W as createRelationId, X as fromVideoDocument, Y as derivePositionFromAbs, Z as toVideoDocument, _ as audioScriptAssetOf, _t as TIMELINE_SKELETON_DURATION_MS, a as createMirrorVideoDocument, at as findLaneTrack, b as phoneticScriptAssetOf, bt as safeDurationMs, c as DocumentMutationGuard, ct as solveVideoDocument, d as LoroEntityDraft, dt as fillMainTrackTimeGaps, et as validateVideoDocument, f as readEntityDocument, ft as reassignSpeechesToVideoClipsByTime, g as audioScriptAssetHash, gt as syncAggregatedClipsTimePosition, h as audioScriptAssetFields, ht as resolveSpeechOverlapByShiftingVideos, i as MirrorVideoDocumentAdapter, it as ensureLaneTrack, j as relationsFrom, k as EntityTimelineProjectionError, l as LORO_ENTITY_SCHEMA, lt as cascadeAfterVideoClipChanges, m as audioScriptAssetContent, mt as resolveAllSpeechOverlaps, n as createPlainMemoryAdapter, nt as videoDocumentSchema, o as createMirrorVideoDocumentAdapter, ot as hasResolvedPlacement, p as entityOrderGroups, pt as recalculateTimelineDuration, q as buildInitialVideoDocument, r as generatePartId, rt as LANE_KINDS_IN_STACK_ORDER, s as readVideoDocumentFromDraft, st as laneTrackId, t as PlainMemoryAdapter, tt as partUnionSchema, u as LoroEntityDocument, ut as arrangeMainTrackSeamlessly, v as phoneticScriptAssetContent, vt as isEmptyVideoClip, w as assertFieldChanges, wt as partUnionToDraft, x as scriptSourceAssetOf, xt as DEFAULT_UNIT_TIME_MS, y as phoneticScriptAssetHash, yt as partDurationMs, z as selectAudioScriptSegment } from "./document-BGYPKtJ3.js";
1
+ import { $ as fromVideoDocument, A as resolveEntityTimelineLayout, At as base64ToBytes, B as assembleScriptText, C as canonicalJson, Ct as safeDurationMs, D as resolveFieldPath, Dt as partUnionToDraft, E as assertFieldChanges, Et as speedOf, F as relationsTo, G as isJsonObject, H as selectAudioScriptSegment, I as ScriptCompositionError, J as hasSequence, K as createEntityId, L as assembleCaptionContent, M as validateEntityRelationTransition, N as decodeEntityRelationRows, O as projectEntityTimeline, Ot as recordEntries, P as relationsFrom, Q as derivePositionFromAbs, R as assembleEntityContent, S as scriptSourceAssetOf, St as partDurationMs, T as applyFieldChanges, Tt as effectiveVideoClipDurationMs, U as updateEntityFields, V as findComposedAudioScript, W as variantBaseEntityIds, X as buildInitialVideoDocument, Y as isMediaAssetVariantKind, Z as buildSpeechHostMap, _ as audioScriptAssetHash, _t as resolveAllSpeechOverlaps, a as createMirrorVideoDocument, at as videoDocumentSchema, b as phoneticScriptAssetHash, bt as TIMELINE_SKELETON_DURATION_MS, c as DocumentMutationGuard, ct as findLaneTrack, d as LoroEntityDraft, dt as solveVideoDocument, et as toVideoDocument, f as readEntityDocument, ft as cascadeAfterVideoClipChanges, g as audioScriptAssetFields, gt as recalculateTimelineDuration, h as audioScriptAssetContent, ht as reassignSpeechesToVideoClipsByTime, i as MirrorVideoDocumentAdapter, it as partUnionSchema, j as EntityTimelineProjectionError, jt as bytesToBase64, k as EntityProjectionGraph, kt as videoDocumentMirrorSchema, l as LORO_ENTITY_SCHEMA, lt as hasResolvedPlacement, m as entityOrderGroups, mt as fillMainTrackTimeGaps, n as createPlainMemoryAdapter, nt as assertValidVideoDocument, o as createMirrorVideoDocumentAdapter, ot as LANE_KINDS_IN_STACK_ORDER, p as readEntityRelationRecords, pt as arrangeMainTrackSeamlessly, q as createRelationId, r as generatePartId, rt as validateVideoDocument, s as readVideoDocumentFromDraft, st as ensureLaneTrack, t as PlainMemoryAdapter, tt as VideoDocumentValidationError, u as LoroEntityDocument, ut as laneTrackId, v as audioScriptAssetOf, vt as resolveSpeechOverlapByShiftingVideos, w as equalJson, wt as DEFAULT_UNIT_TIME_MS, x as phoneticScriptAssetOf, xt as isEmptyVideoClip, y as phoneticScriptAssetContent, yt as syncAggregatedClipsTimePosition, z as assemblePhoneticScriptContent } from "./document-DcpvSvQK.js";
2
2
  import { addSpeechesInputSchema, addVideoClipsInputSchema, adjustBgmVolumeInputSchema, adjustSpeechVolumeInputSchema, adjustVideoClipDurationInputSchema, adjustVideoClipVolumeInputSchema, changeSpeechScriptInputSchema, changeSpeechVoiceInputSchema, deleteBgmInputSchema, deleteSpeechesInputSchema, deleteVideoClipsInputSchema, moveSpeechesInputSchema, moveVideoClipsByAnchorInputSchema, moveVideoClipsInputSchema, replaceVideoClipContentInputSchema, replaceVideoClipSequenceInputSchema, setBgmInputSchema, setCaptionStyleInputSchema, setCaptionVisibilityInputSchema, setVideoClipSpeedShiftInputSchema, t as schemas_exports } from "./schemas.js";
3
- import { LoroDoc, UndoManager, VersionVector } from "loro-crdt";
3
+ import { LoroDoc, LoroMap, UndoManager, VersionVector } from "loro-crdt";
4
+ import { sha256 } from "@noble/hashes/sha2.js";
5
+ import { bytesToHex } from "@noble/hashes/utils.js";
4
6
  import { ClientServerSynchronizer, DocManager } from "@mengine/sync";
5
7
  import { DisposableSet, EventBus, Task } from "@mengine/utils";
6
8
  import { BaseDocStorage, DummyConnection } from "@mengine/storage";
@@ -214,20 +216,36 @@ function findSseFrameBoundary(buffer) {
214
216
  //#endregion
215
217
  //#region src/entity-crdt/compile-rows.ts
216
218
  /** Lower a detached ordinary-object edit against its actual causal Loro baseline. */
217
- function compileEntityRows(snapshot, rows, validate) {
219
+ function compileEntityRows(snapshot, rows, validate, options = {}) {
218
220
  const doc = new LoroDoc();
219
221
  doc.import(snapshot);
220
- return applyEntityRows(new LoroEntityDocument(doc, validate), rows);
222
+ return applyEntityRows(new LoroEntityDocument(doc, validate), rows, options);
223
+ }
224
+ /** Publish final rows as imported causal operations, outside the caller's local undo history. */
225
+ function publishEntityRows(editor, rows, options = {}) {
226
+ const from = editor.doc.oplogVersion();
227
+ const compiled = compileEntityRows(editor.doc.export({ mode: "snapshot" }), rows, void 0, options);
228
+ editor.import(compiled.update);
229
+ return {
230
+ update: editor.doc.export({
231
+ mode: "update",
232
+ from
233
+ }),
234
+ state: editor.snapshot()
235
+ };
221
236
  }
222
237
  /** Compile an edit on an existing peer, retaining its native local undo history. */
223
- function applyEntityRows(editor, rows) {
238
+ function applyEntityRows(editor, rows, options = {}) {
224
239
  const baseline = editor.snapshot();
225
240
  const before = baseline.rows;
226
241
  const oldEntities = new Map(before.entities.map((row) => [row.entityId, row]));
227
242
  const newEntities = new Map(rows.entities.map((row) => [row.entityId, row]));
228
243
  const oldRelations = new Map(before.relations.map((row) => [row.relationId, row]));
229
244
  const newRelations = new Map(rows.relations.map((row) => [row.relationId, row]));
245
+ const oldClipContent = new Map(before.relations.filter((row) => row.relationKind === "contain" && oldEntities.get(row.endpoint0EntityId)?.entityKind === "clip").map((row) => [row.endpoint0EntityId, row.relationId]));
246
+ const newClipContent = new Map(rows.relations.filter((row) => row.relationKind === "contain" && newEntities.get(row.endpoint0EntityId)?.entityKind === "clip").map((row) => [row.endpoint0EntityId, row.relationId]));
230
247
  if (newEntities.size !== rows.entities.length || newRelations.size !== rows.relations.length) throw new Error("Duplicate domain identity");
248
+ validateEntityRelationTransition(before, rows);
231
249
  return {
232
250
  update: editor.transact((draft) => {
233
251
  for (const row of before.relations) {
@@ -235,7 +253,7 @@ function applyEntityRows(editor, rows) {
235
253
  if (!next) draft.unlink(row.relationId);
236
254
  else {
237
255
  if (next.relationKind !== row.relationKind || next.endpoint0EntityId !== row.endpoint0EntityId || next.endpoint1EntityId !== row.endpoint1EntityId) throw new Error("Relation identity cannot change kind or endpoint positions");
238
- if (!equalJson(row.metadata, next.metadata) || !equalJson(row.trace, next.trace)) draft.patchRelation(row.relationId, next.metadata, next.trace);
256
+ if (!equalJson(row.trace, next.trace)) draft.patchRelationTrace(row.relationId, next.trace);
239
257
  }
240
258
  }
241
259
  let attached = baseline.project.audioScriptEntityId !== void 0;
@@ -253,12 +271,15 @@ function applyEntityRows(editor, rows) {
253
271
  }
254
272
  }
255
273
  for (const row of rows.relations) if (!oldRelations.has(row.relationId)) draft.link(row);
274
+ for (const [clipId, relationId] of newClipContent) if (oldClipContent.get(clipId) !== relationId) draft.selectClipContent(relationId);
256
275
  const beforeOrder = entityOrderGroups(before), afterOrder = entityOrderGroups(rows);
257
276
  for (const group of new Set([...beforeOrder.keys(), ...afterOrder.keys()])) {
258
277
  const next = afterOrder.get(group) ?? [];
259
278
  if (!equalJson(beforeOrder.get(group) ?? [], next)) draft.order(group, next);
260
279
  }
261
280
  for (const row of before.entities) if (!newEntities.has(row.entityId)) draft.delete(row.entityId);
281
+ for (const [slot, entityId] of Object.entries(options.projectAttachments ?? {})) draft.attach(slot, entityId);
282
+ for (const [clipEntityId, phoneticScriptEntityId] of Object.entries(options.phoneticScriptSelections ?? {})) draft.selectPhoneticScript(clipEntityId, phoneticScriptEntityId);
262
283
  }),
263
284
  state: editor.snapshot()
264
285
  };
@@ -432,6 +453,16 @@ function ensureEditorFoundation(initial, idFactory = (prefix) => `${prefix}_${gl
432
453
  //#endregion
433
454
  //#region src/entity-editor/entity-graph-http-client.ts
434
455
  const MENGINE_API_PREFIX = "/api/mengine/v1";
456
+ var EntityGraphRevisionConflictError = class extends MengineHttpRequestError {
457
+ expectedRevision;
458
+ currentRevision;
459
+ constructor(expectedRevision, currentRevision, payload) {
460
+ super(409, payload);
461
+ this.expectedRevision = expectedRevision;
462
+ this.currentRevision = currentRevision;
463
+ this.name = "EntityGraphRevisionConflictError";
464
+ }
465
+ };
435
466
  /** Browser-safe authenticated Loro client for a document's Entity graph. */
436
467
  var EntityGraphHttpClient = class {
437
468
  options;
@@ -448,6 +479,8 @@ var EntityGraphHttpClient = class {
448
479
  }
449
480
  /**
450
481
  * Compile edits against the original causal snapshot and publish native Loro operations.
482
+ * The base revision is sent as an optimistic precondition, so a stale adoption
483
+ * fails loudly instead of letting Loro choose between concurrent selections.
451
484
  *
452
485
  * Deletion intent is derived from baseline IDs omitted from `nextRows`, so a
453
486
  * transient create-then-remove never becomes a request to delete unknown
@@ -458,13 +491,17 @@ var EntityGraphHttpClient = class {
458
491
  const next = cloneAndValidateRows(nextRows);
459
492
  assertMediaAssetWritePolicy(baseState.rows, next);
460
493
  const compiled = compileEntityRows(base64ToBytes(baseState.loroSnapshot), next);
461
- return this.commitUpdate(bytesToBase64(compiled.update));
494
+ return this.commitUpdate(bytesToBase64(compiled.update), baseState.revision);
462
495
  }
463
- /** Send the same prepared bytes on retry; never recompile an unconfirmed edit. */
464
- async commitUpdate(update) {
496
+ /** Send prepared bytes directly; omit `expectedRevision` for immutable fact publication. */
497
+ async commitUpdate(update, expectedRevision) {
498
+ if (expectedRevision !== void 0 && !isNonNegativeSafeInteger(expectedRevision)) throw new Error("expectedRevision must be a nonnegative safe integer");
465
499
  return this.request({
466
500
  method: "POST",
467
- body: JSON.stringify({ update })
501
+ body: JSON.stringify({
502
+ update,
503
+ ...expectedRevision === void 0 ? {} : { expected_revision: expectedRevision }
504
+ })
468
505
  });
469
506
  }
470
507
  async request(init) {
@@ -473,10 +510,21 @@ var EntityGraphHttpClient = class {
473
510
  headers: this.transport.headers()
474
511
  });
475
512
  const payload = await safeReadJson(response);
476
- if (!response.ok) throw new MengineHttpRequestError(response.status, payload);
513
+ if (!response.ok) throw parseRevisionConflict(response.status, payload) ?? new MengineHttpRequestError(response.status, payload);
477
514
  return parseWireState(payload, this.options.docId);
478
515
  }
479
516
  };
517
+ function parseRevisionConflict(status, value) {
518
+ if (status !== 409 || !isRecord(value) || !isRecord(value.error) || value.error.code !== "revision_conflict") return;
519
+ const error = value.error;
520
+ if (typeof error.message !== "string" || !isNonNegativeSafeInteger(error.expected_revision) || !isNonNegativeSafeInteger(error.current_revision)) return;
521
+ return new EntityGraphRevisionConflictError(error.expected_revision, error.current_revision, { error: {
522
+ code: "revision_conflict",
523
+ message: error.message,
524
+ expected_revision: error.expected_revision,
525
+ current_revision: error.current_revision
526
+ } });
527
+ }
480
528
  function parseWireState(value, expectedDocId) {
481
529
  if (!isRecord(value) || typeof value.doc_id !== "string" || !isNonNegativeSafeInteger(value.revision)) throw new Error("Invalid entity-state response envelope");
482
530
  if (value.doc_id !== expectedDocId) throw new Error(`Entity-state response doc_id mismatch: expected "${expectedDocId}"`);
@@ -1228,7 +1276,7 @@ var EntityTimelineEditor = class {
1228
1276
  }
1229
1277
  });
1230
1278
  }
1231
- this.reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, voiceover.entityId, phonetic.entityId, input.captions);
1279
+ this.reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, phonetic.entityId, input.captions);
1232
1280
  this.resolveOneVoiceoverOverlap(draft);
1233
1281
  return {
1234
1282
  voiceoverClipEntityId,
@@ -1415,7 +1463,7 @@ var EntityTimelineEditor = class {
1415
1463
  requireComposedAudioScript(draft, phonetic.entityId);
1416
1464
  return phonetic;
1417
1465
  }
1418
- /** Preserve the factual synthesis input, regardless of persisted endpoint positions. */
1466
+ /** Preserve the factual synthesis input as `[rendered Audio, PhoneticScript]`. */
1419
1467
  ensurePhoneticRender(draft, voiceoverAudioEntityId, phoneticScriptEntityId) {
1420
1468
  const renders = draft.relations.filter((relation) => relation.relationKind === "derived-from" && (relation.endpoint0EntityId === voiceoverAudioEntityId || relation.endpoint1EntityId === voiceoverAudioEntityId));
1421
1469
  if (renders.length > 1) throw new Error(`Voiceover "${voiceoverAudioEntityId}" has multiple PhoneticScript render relations`);
@@ -1425,7 +1473,7 @@ var EntityTimelineEditor = class {
1425
1473
  }
1426
1474
  draft.relations.push(this.createRelation(draft, "derived-from", voiceoverAudioEntityId, phoneticScriptEntityId));
1427
1475
  }
1428
- reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, voiceoverAudioEntityId, phoneticScriptEntityId, captions) {
1476
+ reconcileVoiceoverCaptions(draft, voiceoverClipEntityId, phoneticScriptEntityId, captions) {
1429
1477
  const audioScriptEntityId = requireComposedAudioScript(draft, phoneticScriptEntityId);
1430
1478
  const requested = new Set(captions.map((caption) => caption.captionClipEntityId));
1431
1479
  if (requested.size !== captions.length) throw new Error("Caption Clip ids must be unique within a voiceover take");
@@ -1450,10 +1498,6 @@ var EntityTimelineEditor = class {
1450
1498
  };
1451
1499
  draft.entities.push(content);
1452
1500
  assertCaptionScript(draft, content.entityId, audioScriptEntityId);
1453
- draft.relations.push(this.createRelation(draft, "caption-alignment", content.entityId, voiceoverAudioEntityId, { alignment: {
1454
- start: startMs,
1455
- end: startMs + durationMs
1456
- } }));
1457
1501
  this.insertPlacedClipIntoDraft(draft, {
1458
1502
  clipEntityId: caption.captionClipEntityId,
1459
1503
  trackEntityId: captionTrack.entityId,
@@ -1500,7 +1544,6 @@ var EntityTimelineEditor = class {
1500
1544
  ...rate === 1 ? {} : { rate }
1501
1545
  }
1502
1546
  });
1503
- this.replaceCaptionAlignment(draft, content.entityId, voiceoverAudioEntityId, startMs, durationMs);
1504
1547
  }
1505
1548
  }
1506
1549
  ensureRoleTrackForClip(draft, clipEntityId, role) {
@@ -1509,14 +1552,6 @@ var EntityTimelineEditor = class {
1509
1552
  const timeline = requireEntityKind(draft, otherEndpoint(requireOneContain(draft, track.entityId, "contained"), track.entityId), "timeline");
1510
1553
  return this.ensureRoleTrack(draft, timeline.entityId, role);
1511
1554
  }
1512
- replaceCaptionAlignment(draft, captionEntityId, voiceoverAudioEntityId, startMs, durationMs) {
1513
- const removed = new Set(incidentRelations(draft, captionEntityId).filter((relation) => relation.relationKind === "caption-alignment").map((relation) => relation.relationId));
1514
- draft.relations = draft.relations.filter((relation) => !removed.has(relation.relationId));
1515
- draft.relations.push(this.createRelation(draft, "caption-alignment", captionEntityId, voiceoverAudioEntityId, { alignment: {
1516
- start: startMs,
1517
- end: startMs + durationMs
1518
- } }));
1519
- }
1520
1555
  /**
1521
1556
  * Remove one placed Caption use. The composed AudioScript outlives the
1522
1557
  * caption: it may anchor further captions and remains the text owner.
@@ -1912,6 +1947,224 @@ function cloneRows(rows) {
1912
1947
  };
1913
1948
  }
1914
1949
  //#endregion
1950
+ //#region src/entity-editor/revise-audio-script.ts
1951
+ /** Revise text as an independent authored document, never as a version-family mutation. */
1952
+ function reviseAudioScript(rows, input) {
1953
+ if (typeof input.editId !== "string" || !input.editId.trim()) throw new Error("editId is required");
1954
+ if (!Array.isArray(input.edits) || !input.edits.length) throw new Error("At least one segment edit is required");
1955
+ const edits = /* @__PURE__ */ new Map();
1956
+ for (const edit of input.edits) {
1957
+ if (!edit.segmentId?.trim() || typeof edit.text !== "string" || !edit.text.trim()) throw new Error("Segment text must be nonempty");
1958
+ if (edits.has(edit.segmentId)) throw new Error("Duplicate segment edit");
1959
+ edits.set(edit.segmentId, edit.text);
1960
+ }
1961
+ const signature = hash({
1962
+ ...input,
1963
+ captionUsages: input.captionUsages ?? []
1964
+ });
1965
+ const prior = rows.relations.filter((r) => r.relationKind === "derived-from" && r.trace.command === "revise-audio-script" && r.trace.editId === input.editId);
1966
+ if (prior.length && (prior.length !== 1 || prior[0].trace.signature !== signature)) throw new Error("AudioScript edit identity conflict");
1967
+ const source = rows.entities.find((row) => row.entityId === input.sourceAudioScriptEntityId);
1968
+ if (source?.entityKind !== "audio-script") throw new Error("AudioScript source is missing");
1969
+ assembleScriptText({
1970
+ ...source,
1971
+ entityKind: "audio-script"
1972
+ });
1973
+ const payload = structuredClone(source.payload);
1974
+ if (!Array.isArray(payload.segments)) throw new Error("AudioScript must own its segments");
1975
+ const segmentIds = new Set(payload.segments.filter(isJsonObject).map((segment) => segment.segmentId));
1976
+ for (const segmentId of edits.keys()) if (!segmentIds.has(segmentId)) throw new Error(`Unknown segment ${segmentId}`);
1977
+ const changedSegments = /* @__PURE__ */ new Set();
1978
+ payload.segments = payload.segments.map((segment) => {
1979
+ if (segment === null || typeof segment !== "object" || Array.isArray(segment)) throw new Error("Invalid segment");
1980
+ const text = edits.get(String(segment.segmentId)) ?? segment.text;
1981
+ if (text !== segment.text) changedSegments.add(String(segment.segmentId));
1982
+ return {
1983
+ ...segment,
1984
+ text
1985
+ };
1986
+ });
1987
+ if (!changedSegments.size) {
1988
+ if (prior.length) throw new Error("AudioScript edit identity conflict");
1989
+ return {
1990
+ rows,
1991
+ audioScriptEntityId: source.entityId,
1992
+ captionUsages: [],
1993
+ changed: false
1994
+ };
1995
+ }
1996
+ delete payload.assetContentHash;
1997
+ const id = hash(input.editId);
1998
+ const audioScriptEntityId = createEntityId(`entity_script_revision_${id}`);
1999
+ const audioScript = {
2000
+ entityId: audioScriptEntityId,
2001
+ entityKind: "audio-script",
2002
+ payload
2003
+ };
2004
+ const provenance = {
2005
+ relationId: createRelationId(`relation_script_revision_${id}`),
2006
+ relationKind: "derived-from",
2007
+ endpoint0EntityId: audioScriptEntityId,
2008
+ endpoint1EntityId: source.entityId,
2009
+ metadata: {},
2010
+ trace: {
2011
+ command: "revise-audio-script",
2012
+ editId: input.editId,
2013
+ signature
2014
+ }
2015
+ };
2016
+ const selectedMarkers = /* @__PURE__ */ new Set();
2017
+ const planCaption = (usage, index, targetAudioScriptEntityId) => {
2018
+ if (selectedMarkers.has(usage.markerEntityId)) throw new Error("Duplicate caption usage");
2019
+ selectedMarkers.add(usage.markerEntityId);
2020
+ const caption = rows.entities.find((row) => row.entityId === usage.captionEntityId);
2021
+ if (caption?.entityKind !== "caption") throw new Error("Caption is missing");
2022
+ const assembled = assembleCaptionContent(rows, caption.entityId);
2023
+ if (assembled.audioScript.entityId !== source.entityId) throw new Error("Caption does not use the source AudioScript");
2024
+ const selection = assembled.caption.payload.selection;
2025
+ if (isJsonObject(selection) && selection.textRange !== void 0 && typeof selection.segmentId === "string" && changedSegments.has(selection.segmentId) && usage.selection === void 0) throw new Error("Changed partial Caption requires an explicit selection");
2026
+ const bases = caption.payload.baseEntityIds;
2027
+ if (!Array.isArray(bases) || !bases.includes(source.entityId)) throw new Error("Caption requires a direct AudioScript base");
2028
+ const clipMarkers = rows.relations.filter((r) => r.relationKind === "marker" && r.endpoint0EntityId === usage.clipEntityId);
2029
+ const clipContents = rows.relations.filter((r) => r.relationKind === "contain" && r.endpoint0EntityId === usage.clipEntityId);
2030
+ const markerOwners = rows.relations.filter((r) => r.relationKind === "marker" && r.endpoint1EntityId === usage.markerEntityId);
2031
+ if (clipMarkers.length !== 1 || clipMarkers[0].endpoint1EntityId !== usage.markerEntityId || markerOwners.length !== 1 || clipContents.length !== 1) throw new Error("Caption usage conflict");
2032
+ const newCaptionId = createEntityId(`entity_caption_revision_${id}_${index}`);
2033
+ return {
2034
+ clone: {
2035
+ ...caption,
2036
+ entityId: newCaptionId,
2037
+ payload: {
2038
+ ...structuredClone(caption.payload),
2039
+ baseEntityIds: bases.map((base) => base === source.entityId ? targetAudioScriptEntityId : base),
2040
+ ...usage.selection === void 0 ? {} : { selection: structuredClone(usage.selection) }
2041
+ }
2042
+ },
2043
+ clipContent: clipContents[0],
2044
+ newCaptionId,
2045
+ relationId: createRelationId(`relation_caption_revision_${id}_${index}`),
2046
+ trace: {
2047
+ command: "revise-audio-script",
2048
+ editId: input.editId,
2049
+ usageIndex: index
2050
+ }
2051
+ };
2052
+ };
2053
+ if (prior.length) {
2054
+ const currentAudioScriptEntityId = prior[0].endpoint0EntityId;
2055
+ if (!equalJson(prior[0], {
2056
+ ...provenance,
2057
+ relationId: prior[0].relationId,
2058
+ endpoint0EntityId: currentAudioScriptEntityId
2059
+ })) throw new Error("AudioScript edit identity conflict");
2060
+ const preservedScripts = rows.entities.filter((row) => row.entityId === currentAudioScriptEntityId);
2061
+ const preservedScript = preservedScripts[0]?.entityKind === "audio-script" ? {
2062
+ ...preservedScripts[0],
2063
+ payload: Object.fromEntries(Object.entries(preservedScripts[0].payload).filter(([key]) => key !== "assetContentHash"))
2064
+ } : preservedScripts[0];
2065
+ if (preservedScripts.length !== 1 || !equalJson(preservedScript, {
2066
+ ...audioScript,
2067
+ entityId: currentAudioScriptEntityId
2068
+ })) throw new Error("AudioScript edit identity conflict");
2069
+ let relations = [...rows.relations];
2070
+ let changed = false;
2071
+ const captionUsages = [];
2072
+ for (const [index, usage] of (input.captionUsages ?? []).entries()) {
2073
+ const plan = planCaption(usage, index, currentAudioScriptEntityId);
2074
+ const preservedCaptions = rows.entities.filter((row) => row.entityId === plan.newCaptionId);
2075
+ if (preservedCaptions.length !== 1 || !equalJson(preservedCaptions[0], plan.clone)) throw new Error("Caption usage conflict after revision");
2076
+ const plannedEdge = {
2077
+ relationId: plan.relationId,
2078
+ relationKind: "contain",
2079
+ endpoint0EntityId: plan.clipContent.endpoint0EntityId,
2080
+ endpoint1EntityId: plan.newCaptionId,
2081
+ metadata: structuredClone(plan.clipContent.metadata),
2082
+ trace: plan.trace
2083
+ };
2084
+ if (plan.clipContent.relationId === plan.relationId) {
2085
+ if (!equalJson(plan.clipContent, plannedEdge)) throw new Error("Caption usage conflict after revision");
2086
+ } else {
2087
+ if (plan.clipContent.endpoint1EntityId !== usage.captionEntityId) throw new Error("Caption usage conflict after revision");
2088
+ relations = relations.filter((row) => row.relationId !== plan.clipContent.relationId);
2089
+ relations.push(plannedEdge);
2090
+ changed = true;
2091
+ }
2092
+ captionUsages.push({
2093
+ clipEntityId: usage.clipEntityId,
2094
+ markerEntityId: usage.markerEntityId,
2095
+ previousCaptionEntityId: usage.captionEntityId,
2096
+ captionEntityId: plan.newCaptionId
2097
+ });
2098
+ }
2099
+ if (!changed) return {
2100
+ rows,
2101
+ audioScriptEntityId: currentAudioScriptEntityId,
2102
+ captionUsages,
2103
+ changed: false
2104
+ };
2105
+ const revised = {
2106
+ entities: rows.entities,
2107
+ relations
2108
+ };
2109
+ decodeAndValidate(rows, revised);
2110
+ return {
2111
+ rows: revised,
2112
+ audioScriptEntityId: currentAudioScriptEntityId,
2113
+ captionUsages,
2114
+ changed: true
2115
+ };
2116
+ }
2117
+ const result = {
2118
+ entities: [...rows.entities, audioScript],
2119
+ relations: [...rows.relations, provenance]
2120
+ };
2121
+ const entities = [...result.entities];
2122
+ let relations = [...result.relations];
2123
+ const captionUsages = [];
2124
+ for (const [index, usage] of (input.captionUsages ?? []).entries()) {
2125
+ const plan = planCaption(usage, index, audioScriptEntityId);
2126
+ if (plan.clipContent.endpoint1EntityId !== usage.captionEntityId) throw new Error("Caption usage conflict");
2127
+ entities.push(plan.clone);
2128
+ assembleCaptionContent({
2129
+ entities,
2130
+ relations
2131
+ }, plan.newCaptionId);
2132
+ relations = relations.filter((r) => r.relationId !== plan.clipContent.relationId);
2133
+ relations.push({
2134
+ relationId: plan.relationId,
2135
+ relationKind: "contain",
2136
+ endpoint0EntityId: plan.clipContent.endpoint0EntityId,
2137
+ endpoint1EntityId: plan.newCaptionId,
2138
+ metadata: structuredClone(plan.clipContent.metadata),
2139
+ trace: plan.trace
2140
+ });
2141
+ captionUsages.push({
2142
+ clipEntityId: usage.clipEntityId,
2143
+ markerEntityId: usage.markerEntityId,
2144
+ previousCaptionEntityId: usage.captionEntityId,
2145
+ captionEntityId: plan.newCaptionId
2146
+ });
2147
+ }
2148
+ const revised = {
2149
+ entities,
2150
+ relations
2151
+ };
2152
+ decodeAndValidate(rows, revised);
2153
+ return {
2154
+ rows: revised,
2155
+ audioScriptEntityId,
2156
+ captionUsages,
2157
+ changed: true
2158
+ };
2159
+ }
2160
+ function decodeAndValidate(previous, revised) {
2161
+ decodeEntityRelationRows(revised, {});
2162
+ validateEntityRelationTransition(previous, revised);
2163
+ }
2164
+ function hash(value) {
2165
+ return bytesToHex(sha256(new TextEncoder().encode(canonicalJson(value))));
2166
+ }
2167
+ //#endregion
1915
2168
  //#region src/entity-editor/legacy-entity-migration.ts
1916
2169
  /** One-time import only. Ordinary editor operations must never reverse-diff a legacy document. */
1917
2170
  function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
@@ -2076,10 +2329,6 @@ function migrateLegacyTimelineToEntities(legacy, assetFacts, initial = {
2076
2329
  selection,
2077
2330
  ...caption.style === void 0 ? {} : { style: toEntityCaptionStyle(caption.style) }
2078
2331
  });
2079
- link("caption-alignment", content, voiceover, { alignment: {
2080
- start: caption.start_ms,
2081
- end: caption.start_ms + duration
2082
- } });
2083
2332
  markerPayload = {
2084
2333
  inMs: 0,
2085
2334
  outMs: duration
@@ -2587,6 +2836,147 @@ function selectCaptionText(script, text) {
2587
2836
  return matches[0];
2588
2837
  }
2589
2838
  //#endregion
2839
+ //#region src/entity-crdt/legacy-schema-migration.ts
2840
+ const LEGACY_KINDS = new Set([
2841
+ "timeline-track",
2842
+ "track-clip",
2843
+ "clip-marker",
2844
+ "marker-content",
2845
+ "audio-script-marker",
2846
+ "audio-script-source",
2847
+ "generated"
2848
+ ]);
2849
+ function whole(value, label) {
2850
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Legacy SequenceMarker ${label} is invalid`);
2851
+ return value;
2852
+ }
2853
+ function legacyMarkerPayload(payload, annotation) {
2854
+ if (payload.inMs !== void 0 || payload.outMs !== void 0) return void 0;
2855
+ const keys = canonicalJson(Object.keys(payload).sort());
2856
+ if (!(annotation ? [[
2857
+ "duration",
2858
+ "segmentRanges",
2859
+ "sourceRange"
2860
+ ]] : [
2861
+ ["duration", "sourceRange"],
2862
+ [
2863
+ "anchorOffset",
2864
+ "duration",
2865
+ "sourceRange"
2866
+ ],
2867
+ [
2868
+ "duration",
2869
+ "sourceRange",
2870
+ "targetRange"
2871
+ ],
2872
+ [
2873
+ "duration",
2874
+ "sourceRange",
2875
+ "timeRemapping"
2876
+ ]
2877
+ ]).some((shape) => keys === canonicalJson(shape))) throw new Error("Legacy SequenceMarker has unsupported fields");
2878
+ if (!isJsonObject(payload.sourceRange) || !isJsonObject(payload.duration)) throw new Error("Legacy SequenceMarker ranges are invalid");
2879
+ const inMs = whole(payload.sourceRange.start, "sourceRange.start");
2880
+ const outMs = whole(payload.sourceRange.end, "sourceRange.end");
2881
+ const duration = whole(payload.duration.value, "duration.value");
2882
+ if (payload.duration.mode !== "fixed" || duration === 0 || outMs <= inMs) throw new Error("Legacy SequenceMarker duration is invalid");
2883
+ const rate = (outMs - inMs) / duration;
2884
+ if (!Number.isFinite(rate) || rate <= 0) throw new Error("Legacy SequenceMarker rate is invalid");
2885
+ if (annotation) {
2886
+ if (rate !== 1 || !Array.isArray(payload.segmentRanges)) throw new Error("Legacy AudioScript annotation Marker is invalid");
2887
+ return {
2888
+ inMs,
2889
+ outMs,
2890
+ segmentRanges: payload.segmentRanges
2891
+ };
2892
+ }
2893
+ let startMs = 0;
2894
+ if (payload.anchorOffset !== void 0) startMs = whole(payload.anchorOffset, "anchorOffset");
2895
+ if (payload.targetRange !== void 0) {
2896
+ if (!isJsonObject(payload.targetRange)) throw new Error("Legacy SequenceMarker targetRange is invalid");
2897
+ startMs = whole(payload.targetRange.start, "targetRange.start");
2898
+ const targetEnd = whole(payload.targetRange.end, "targetRange.end");
2899
+ if (targetEnd <= startMs || targetEnd - startMs !== duration) throw new Error("Legacy SequenceMarker targetRange disagrees with duration");
2900
+ }
2901
+ if (payload.timeRemapping !== void 0) {
2902
+ const remapping = payload.timeRemapping;
2903
+ if (!isJsonObject(remapping) || canonicalJson(Object.keys(remapping).sort()) !== canonicalJson([
2904
+ "kind",
2905
+ "mode",
2906
+ "rate"
2907
+ ]) || remapping.kind !== "linear" || remapping.mode !== "constant" || typeof remapping.rate !== "number" || !Number.isFinite(remapping.rate) || remapping.rate <= 0) throw new Error("Legacy SequenceMarker timeRemapping is invalid");
2908
+ if (remapping.rate !== rate) throw new Error("Legacy SequenceMarker timeRemapping disagrees with duration");
2909
+ }
2910
+ return {
2911
+ inMs,
2912
+ outMs,
2913
+ startMs,
2914
+ ...rate === 1 ? {} : { rate }
2915
+ };
2916
+ }
2917
+ function relationId(root, kind) {
2918
+ return createRelationId(`relation_${bytesToHex(sha256(new TextEncoder().encode(canonicalJson([root, kind]))))}`);
2919
+ }
2920
+ function entityRoot(doc, id) {
2921
+ const origins = doc.getMap("medeo").get("origins");
2922
+ if (!(origins instanceof LoroMap)) throw new Error("Missing Loro entity origins");
2923
+ const origin = origins.get(id);
2924
+ return typeof origin === "string" ? origin : id;
2925
+ }
2926
+ function mappedRow(doc, row, records) {
2927
+ const endpoint0 = entityRoot(doc, row.endpoint0EntityId);
2928
+ const endpoint1 = entityRoot(doc, row.endpoint1EntityId);
2929
+ let kind;
2930
+ let endpoints;
2931
+ if (row.relationKind === "timeline-track" || row.relationKind === "track-clip") {
2932
+ kind = "contain";
2933
+ endpoints = [endpoint0, endpoint1];
2934
+ } else if (row.relationKind === "clip-marker" || row.relationKind === "audio-script-marker") {
2935
+ kind = "marker";
2936
+ endpoints = [endpoint0, endpoint1];
2937
+ } else if (row.relationKind === "audio-script-source" || row.relationKind === "generated") {
2938
+ kind = "derived-from";
2939
+ endpoints = [endpoint0, endpoint1];
2940
+ } else {
2941
+ const owners = records.filter((candidate) => !candidate.deleted && candidate.relationKind === "clip-marker" && entityRoot(doc, candidate.endpoint1EntityId) === endpoint0);
2942
+ if (owners.length !== 1) throw new Error(`Legacy marker-content ${row.relationId} requires exactly one clip-marker owner`);
2943
+ endpoints = [entityRoot(doc, owners[0].endpoint0EntityId), endpoint1];
2944
+ kind = "contain";
2945
+ }
2946
+ return {
2947
+ relationId: relationId(row.relationId, kind),
2948
+ relationKind: kind,
2949
+ endpoint0EntityId: createEntityId(endpoints[0]),
2950
+ endpoint1EntityId: createEntityId(endpoints[1]),
2951
+ metadata: row.metadata,
2952
+ trace: row.trace
2953
+ };
2954
+ }
2955
+ /** Replace only the four positionally equivalent alpha23 relation kinds. */
2956
+ function migrateAlpha23EntityDocument(doc) {
2957
+ if (doc.getMap("medeo").get("schema") !== "medeo.entities.loro.v1") return void 0;
2958
+ const records = readEntityRelationRecords(doc);
2959
+ const legacy = records.filter((row) => !row.deleted && LEGACY_KINDS.has(row.relationKind));
2960
+ if (legacy.length === 0) return void 0;
2961
+ const replacements = legacy.map((row) => mappedRow(doc, row, records));
2962
+ const current = readEntityDocument(doc, true).rows;
2963
+ const markerPayloads = /* @__PURE__ */ new Map();
2964
+ for (const relation of current.relations) {
2965
+ if (relation.relationKind !== "clip-marker" && relation.relationKind !== "audio-script-marker") continue;
2966
+ const marker = current.entities.find((row) => row.entityId === relation.endpoint1EntityId);
2967
+ if (marker?.entityKind !== "sequence-marker") throw new Error(`Legacy ${relation.relationKind} ${relation.relationId} has no SequenceMarker`);
2968
+ const payload = legacyMarkerPayload(marker.payload, relation.relationKind === "audio-script-marker");
2969
+ if (payload !== void 0) markerPayloads.set(marker.entityId, payload);
2970
+ }
2971
+ const occupied = new Set(records.map((row) => String(row.relationId)));
2972
+ for (const row of replacements) if (occupied.has(row.relationId)) throw new Error(`Legacy relation migration ID collision: ${row.relationId}`);
2973
+ return new LoroEntityDocument(doc).transact((draft) => {
2974
+ for (const [markerId, payload] of markerPayloads) draft.replaceOwned(markerId, payload);
2975
+ for (const row of legacy) draft.unlink(row.relationId);
2976
+ for (const row of replacements) draft.link(row);
2977
+ });
2978
+ }
2979
+ //#endregion
2590
2980
  //#region src/editor/snapshot-utils.ts
2591
2981
  function isMap(value) {
2592
2982
  return value instanceof Map;
@@ -4998,4 +5388,4 @@ function serverCovers(serverVV, target) {
4998
5388
  return covers(server, target);
4999
5389
  }
5000
5390
  //#endregion
5001
- export { DEFAULT_UNIT_TIME_MS, EntityGraphHttpClient, EntityTimelineEditor, EntityTimelineProjectionError, IMPLEMENTED_SEMANTIC_OP_KINDS, LANE_KINDS_IN_STACK_ORDER, LORO_ENTITY_SCHEMA, LoroEntityDocument, LoroEntityDraft, ManualSyncDoc, MedeoHttpDocStorage, MemoryDocStorage, MengineAckFailedError, MengineDocSession, MengineHttpClient, MengineHttpRequestError, MenginePushRejectedError, MirrorVideoDocumentAdapter, PlainMemoryAdapter, SchemaValidator, ScriptCompositionError, SemanticEditor, TIMELINE_SKELETON_DURATION_MS, ValidationError, VideoDocumentValidationError, applyEntityRows, applyFieldChanges, arrangeMainTrackSeamlessly, assembleCaptionContent, assembleEntityContent, assemblePhoneticScriptContent, assembleScriptText, assertCanonicalEditorResources, assertEntityProjectionPreservesLegacy, assertFieldChanges, assertMediaAssetWritePolicy, assertValidVideoDocument, audioScriptAssetContent, audioScriptAssetFields, audioScriptAssetHash, audioScriptAssetOf, base64ToBytes, buildInitialVideoDocument, buildSpeechHostMap, bytesToBase64, cascadeAfterVideoClipChanges, compileEntityRows, createEditSandbox, createMirrorVideoDocument, createMirrorVideoDocumentAdapter, createPlainMemoryAdapter, decodeDocVersionMark, derivePositionFromAbs, effectiveVideoClipDurationMs, encodeDocVersionMark, ensureEditorFoundation, ensureLaneTrack, fillMainTrackTimeGaps, findComposedAudioScript, findLaneTrack, fromVideoDocument, generatePartId, getAt, hostForAbsMs, importMediaAsset, isEmptyVideoClip, isImplementedSemanticOpKind, isMap, laneTrackId, mainTrackRanges, migrateLegacyTimelineToEntities, partDurationMs, partUnionSchema, phoneticScriptAssetContent, phoneticScriptAssetHash, phoneticScriptAssetOf, prepareCaptionContent, projectEntityTimeline, readDocumentAudioScript, readEntityDocument, readMainTrackItems, readMengineEventStream, readPart, readPartDurationMs, readVideoDocumentFromDraft, reassignSpeechesToVideoClipsByTime, recalculateTimelineDuration, relativePositionForAbs, replayJournal, resolveAllSpeechOverlaps, resolveEntityTimelineLayout, resolveFieldPath, resolveSpeechOverlapByShiftingVideos, safeDurationMs, schemas_exports as schemas, scriptSourceAssetOf, selectAudioScriptSegment, snapshotToPlain, solveVideoDocument, speedOf, syncAggregatedClipsTimePosition, toVideoDocument, validateVideoDocument, videoDocumentMirrorSchema, videoDocumentSchema };
5391
+ export { DEFAULT_UNIT_TIME_MS, EntityGraphHttpClient, EntityGraphRevisionConflictError, EntityTimelineEditor, EntityTimelineProjectionError, IMPLEMENTED_SEMANTIC_OP_KINDS, LANE_KINDS_IN_STACK_ORDER, LORO_ENTITY_SCHEMA, LoroEntityDocument, LoroEntityDraft, ManualSyncDoc, MedeoHttpDocStorage, MemoryDocStorage, MengineAckFailedError, MengineDocSession, MengineHttpClient, MengineHttpRequestError, MenginePushRejectedError, MirrorVideoDocumentAdapter, PlainMemoryAdapter, SchemaValidator, ScriptCompositionError, SemanticEditor, TIMELINE_SKELETON_DURATION_MS, ValidationError, VideoDocumentValidationError, applyEntityRows, applyFieldChanges, arrangeMainTrackSeamlessly, assembleCaptionContent, assembleEntityContent, assemblePhoneticScriptContent, assembleScriptText, assertCanonicalEditorResources, assertEntityProjectionPreservesLegacy, assertFieldChanges, assertMediaAssetWritePolicy, assertValidVideoDocument, audioScriptAssetContent, audioScriptAssetFields, audioScriptAssetHash, audioScriptAssetOf, base64ToBytes, buildInitialVideoDocument, buildSpeechHostMap, bytesToBase64, cascadeAfterVideoClipChanges, compileEntityRows, createEditSandbox, createMirrorVideoDocument, createMirrorVideoDocumentAdapter, createPlainMemoryAdapter, decodeDocVersionMark, derivePositionFromAbs, effectiveVideoClipDurationMs, encodeDocVersionMark, ensureEditorFoundation, ensureLaneTrack, fillMainTrackTimeGaps, findComposedAudioScript, findLaneTrack, fromVideoDocument, generatePartId, getAt, hostForAbsMs, importMediaAsset, isEmptyVideoClip, isImplementedSemanticOpKind, isMap, laneTrackId, mainTrackRanges, migrateAlpha23EntityDocument, migrateLegacyTimelineToEntities, partDurationMs, partUnionSchema, phoneticScriptAssetContent, phoneticScriptAssetHash, phoneticScriptAssetOf, prepareCaptionContent, projectEntityTimeline, publishEntityRows, readDocumentAudioScript, readEntityDocument, readEntityRelationRecords, readMainTrackItems, readMengineEventStream, readPart, readPartDurationMs, readVideoDocumentFromDraft, reassignSpeechesToVideoClipsByTime, recalculateTimelineDuration, relativePositionForAbs, replayJournal, resolveAllSpeechOverlaps, resolveEntityTimelineLayout, resolveFieldPath, resolveSpeechOverlapByShiftingVideos, reviseAudioScript, safeDurationMs, schemas_exports as schemas, scriptSourceAssetOf, selectAudioScriptSegment, snapshotToPlain, solveVideoDocument, speedOf, syncAggregatedClipsTimePosition, toVideoDocument, validateVideoDocument, videoDocumentMirrorSchema, videoDocumentSchema };
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { c as MirrorVideoDocumentAdapter, kt as VideoDraft } from "./index-CcIoMxs1.js";
1
+ import { c as MirrorVideoDocumentAdapter, kt as VideoDraft } from "./index-BYv9FPSS.js";
2
2
 
3
3
  //#region src/testing/in-memory-mengine-server.d.ts
4
4
  /**
package/dist/testing.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Dt as base64ToBytes, Ot as bytesToBase64, o as createMirrorVideoDocumentAdapter } from "./document-BGYPKtJ3.js";
1
+ import { At as base64ToBytes, jt as bytesToBase64, o as createMirrorVideoDocumentAdapter } from "./document-DcpvSvQK.js";
2
2
  import { t as classifyUpdate } from "./loro-relay-doc-cJSY-uau.js";
3
3
  import { LoroDoc, VersionVector, encodeFrontiers } from "loro-crdt";
4
4
  //#region src/testing/in-memory-mengine-server.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mengine/medeo-client",
3
- "version": "2.0.1-alpha.27",
3
+ "version": "2.0.1-alpha.28",
4
4
  "license": "UNLICENSED",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,9 +28,9 @@
28
28
  "immer": "^10.2.0",
29
29
  "loro-mirror": "^2.3.3",
30
30
  "zod": "^4.4.3",
31
- "@mengine/utils": "2.0.1-alpha.27",
32
- "@mengine/sync": "2.0.1-alpha.27",
33
- "@mengine/storage": "2.0.1-alpha.27"
31
+ "@mengine/sync": "2.0.1-alpha.28",
32
+ "@mengine/utils": "2.0.1-alpha.28",
33
+ "@mengine/storage": "2.0.1-alpha.28"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^25.9.1",