@mengine/medeo-client 1.2.1-alpha.1 → 1.2.1-alpha.3

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,6 +1,7 @@
1
1
  import { Mirror, schema } from "loro-mirror";
2
2
  import { z } from "zod";
3
3
  import { LoroDoc } from "loro-crdt";
4
+ import { produce } from "immer";
4
5
  //#region src/client/base64.ts
5
6
  function bytesToBase64(bytes) {
6
7
  let binary = "";
@@ -87,8 +88,7 @@ function jsonTransform() {
87
88
  const trackItem = schema.LoroMap({
88
89
  part_id: schema.String(),
89
90
  time_position: schema.String().transform(jsonTransform()),
90
- fallback_abs_ms: schema.Number({ required: false }),
91
- duration_override_ms: schema.Number({ required: false })
91
+ fallback_abs_ms: schema.Number({ required: false })
92
92
  });
93
93
  const track = schema.LoroMap({
94
94
  id: schema.String(),
@@ -322,6 +322,71 @@ function syncAggregatedClipsTimePosition(doc) {
322
322
  }
323
323
  }
324
324
  /**
325
+ * Resolve authoritative `time_position` edges without inventing parents from
326
+ * geometric overlap. Roots stay absolute/sequential; every anchored descendant
327
+ * follows its declared parent, regardless of whether that parent is video, BGM,
328
+ * or speech.
329
+ */
330
+ function resolveAnchoredClipsTimePosition(doc) {
331
+ const items = [
332
+ ...doc.main_track,
333
+ ...doc.bgm_track,
334
+ ...doc.speech_track,
335
+ ...doc.caption_track
336
+ ];
337
+ const byPart = indexByPart(items);
338
+ const resolved = /* @__PURE__ */ new Set();
339
+ const visiting = /* @__PURE__ */ new Set();
340
+ const resolve = (item) => {
341
+ if (resolved.has(item.part_id)) return item.abs_time_position;
342
+ if (visiting.has(item.part_id)) throw new Error(`anchored position cycle at ${item.part_id}`);
343
+ visiting.add(item.part_id);
344
+ const position = item.time_position;
345
+ if (position.mode === "absolute") item.abs_time_position = Math.round(position.offsetMs);
346
+ else if (position.mode === "anchored") {
347
+ const parent = byPart.get(position.anchorPartId);
348
+ if (parent != null) item.abs_time_position = resolve(parent) + Math.round(position.offsetMs);
349
+ }
350
+ visiting.delete(item.part_id);
351
+ resolved.add(item.part_id);
352
+ return item.abs_time_position;
353
+ };
354
+ for (const item of items) resolve(item);
355
+ }
356
+ /** Preserve the historical orphan fallback without re-parenting valid roots. */
357
+ function recoverOrphanedSpeechAnchorsByTime(doc) {
358
+ const placedPartIds = new Set([
359
+ ...doc.main_track,
360
+ ...doc.bgm_track,
361
+ ...doc.speech_track,
362
+ ...doc.caption_track
363
+ ].map((item) => item.part_id));
364
+ const ranges = doc.main_track.map((item) => ({
365
+ part_id: item.part_id,
366
+ start: item.abs_time_position,
367
+ end: item.abs_time_position + partDurationMs(doc, item.part_id)
368
+ }));
369
+ if (ranges.length === 0) return;
370
+ for (const speech of doc.speech_track) {
371
+ const position = speech.time_position;
372
+ if (position.mode !== "anchored" || placedPartIds.has(position.anchorPartId)) continue;
373
+ const range = ranges.find((candidate) => candidate.start <= speech.abs_time_position && speech.abs_time_position < candidate.end) ?? (speech.abs_time_position < ranges[0].start ? ranges[0] : ranges[ranges.length - 1]);
374
+ const relative = Math.max(0, Math.round(speech.abs_time_position - range.start));
375
+ let aggregation = doc.aggregations.find((candidate) => candidate.body_part_id === range.part_id);
376
+ if (aggregation == null) {
377
+ aggregation = {
378
+ body_part_id: range.part_id,
379
+ attachments: []
380
+ };
381
+ doc.aggregations.push(aggregation);
382
+ }
383
+ aggregation.attachments.push({
384
+ part_id: speech.part_id,
385
+ relative_time_position: relative
386
+ });
387
+ }
388
+ }
389
+ /**
325
390
  * Reassign each speech to the video clip whose time range contains its start,
326
391
  * rebuilding `aggregations`. A speech before the first clip or after the last
327
392
  * falls back to the first / last clip respectively (FE: see §4 note — FE falls
@@ -524,14 +589,14 @@ function hostVideoOf(doc, speechPartId) {
524
589
  //#endregion
525
590
  //#region src/timeline-core/entrypoints.ts
526
591
  /**
527
- * The full solve pipeline: arrange → sync → reassign → resolve-overlap →
528
- * fill-gaps → recalc. The single cascade the read-side projection runs; ops
592
+ * The full solve pipeline: arrange → resolve declared parent edges →
593
+ * resolve-overlap → fill-gaps → recalc. The single cascade the read-side projection runs; ops
529
594
  * never call it (they write only facts — RFC 02 §7/§10).
530
595
  */
531
596
  function cascadeAfterVideoClipChanges(doc, makeEmptyPart) {
532
597
  arrangeMainTrackSeamlessly(doc);
533
- syncAggregatedClipsTimePosition(doc);
534
- reassignSpeechesToVideoClipsByTime(doc);
598
+ resolveAnchoredClipsTimePosition(doc);
599
+ recoverOrphanedSpeechAnchorsByTime(doc);
535
600
  resolveAllSpeechOverlaps(doc);
536
601
  fillMainTrackTimeGaps(doc, makeEmptyPart);
537
602
  recalculateTimelineDuration(doc);
@@ -605,7 +670,7 @@ function videoDocumentToTimelineDoc(document) {
605
670
  caption_track: captionItems,
606
671
  bgm_track: bgmItems,
607
672
  part_library: partLibrary,
608
- aggregations: seedAggregations(mainItems, speechItems),
673
+ aggregations: seedAggregations([...mainItems, ...bgmItems], speechItems),
609
674
  timeline: {
610
675
  duration_ms: 0,
611
676
  unit_time_ms: document.timeline?.unit_time_ms ?? 0
@@ -629,19 +694,18 @@ function seedAbs(position, fallbackAbsMs) {
629
694
  }
630
695
  }
631
696
  /**
632
- * Build the cascade `aggregations` parent-map from anchored items: an anchored
633
- * item's `anchorPartId` is the host's `part_id` (= `body_part_id`), `offsetMs`
634
- * is `relative_time_position`. Only main-track (video) hosts form aggregations;
635
- * caption→speech relations are recomputed by the cascade from `caption.start_ms`.
697
+ * Build the read-view `aggregations` parent-map from anchored speech items.
698
+ * Video and BGM roots are both valid hosts; absolute speech roots have no entry.
699
+ * Caption→speech ownership remains in `speech_part_id` / `caption_ids`.
636
700
  */
637
- function seedAggregations(mainItems, speechItems) {
638
- const mainPartIds = new Set(mainItems.map((item) => item.part_id));
701
+ function seedAggregations(hostItems, speechItems) {
702
+ const hostPartIds = new Set(hostItems.map((item) => item.part_id));
639
703
  const byHost = /* @__PURE__ */ new Map();
640
704
  const order = [];
641
705
  for (const speech of speechItems) {
642
706
  if (speech.time_position.mode !== "anchored") continue;
643
707
  const host = speech.time_position.anchorPartId;
644
- if (!mainPartIds.has(host)) continue;
708
+ if (!hostPartIds.has(host)) continue;
645
709
  let aggregation = byHost.get(host);
646
710
  if (aggregation == null) {
647
711
  aggregation = {
@@ -774,8 +838,7 @@ const trackItemTimePositionSchema = z.discriminatedUnion("mode", [
774
838
  const trackItemSchema = z.object({
775
839
  part_id: z.string(),
776
840
  time_position: trackItemTimePositionSchema,
777
- fallback_abs_ms: finiteNumber.optional(),
778
- duration_override_ms: z.number().finite().positive().optional()
841
+ fallback_abs_ms: finiteNumber.optional()
779
842
  }).passthrough();
780
843
  const trackSchema = z.object({
781
844
  id: z.string().optional(),
@@ -1328,7 +1391,7 @@ function fromVideoDocument(document) {
1328
1391
  above_main_tracks: aboveTracks.map((t) => draftTrack(t, view.absByPartId)),
1329
1392
  below_main_tracks: belowTracks.map((t) => draftTrack(t, view.absByPartId)),
1330
1393
  part_aggregations: view.aggregations,
1331
- part_library: toReadViewPartLibrary(view.partLibrary, view.durationMs, view.derivedFillerPartIds, captionDurationOverrides(document)),
1394
+ part_library: toReadViewPartLibrary(view.partLibrary, view.durationMs, view.derivedFillerPartIds),
1332
1395
  version: document.meta.version
1333
1396
  };
1334
1397
  }
@@ -1349,7 +1412,7 @@ function fromVideoDocument(document) {
1349
1412
  * and they are not part of the document (see `fromVideoDocument`). An empty clip a
1350
1413
  * writer placed deliberately is NOT in that set and is projected normally.
1351
1414
  */
1352
- function toReadViewPartLibrary(partLibrary, durationMs, derivedFillerPartIds, captionDurationByPartId) {
1415
+ function toReadViewPartLibrary(partLibrary, durationMs, derivedFillerPartIds) {
1353
1416
  const out = {};
1354
1417
  for (const [partId, part] of Object.entries(partLibrary)) {
1355
1418
  if (derivedFillerPartIds.has(partId)) continue;
@@ -1369,7 +1432,7 @@ function toReadViewPartLibrary(partLibrary, durationMs, derivedFillerPartIds, ca
1369
1432
  const caption = clone(part.caption);
1370
1433
  out[partId] = { caption: {
1371
1434
  ...caption,
1372
- duration_ms: captionDurationByPartId.get(partId) ?? caption.initial_duration_ms
1435
+ duration_ms: caption.initial_duration_ms
1373
1436
  } };
1374
1437
  } else if (part.bgm != null) out[partId] = { bgm: {
1375
1438
  ...clone(part.bgm),
@@ -1378,14 +1441,6 @@ function toReadViewPartLibrary(partLibrary, durationMs, derivedFillerPartIds, ca
1378
1441
  }
1379
1442
  return out;
1380
1443
  }
1381
- function captionDurationOverrides(document) {
1382
- const result = /* @__PURE__ */ new Map();
1383
- for (const track of document.tracks ?? []) {
1384
- if (track.parts_kind !== "caption") continue;
1385
- for (const item of track.items ?? []) if (item.duration_override_ms != null) result.set(item.part_id, item.duration_override_ms);
1386
- }
1387
- return result;
1388
- }
1389
1444
  /** Deep-clone a part payload, dropping the derived read-view `duration_ms`. */
1390
1445
  function omitDurationMs(part) {
1391
1446
  const { duration_ms: _drop, ...rest } = clone(part);
@@ -1548,7 +1603,6 @@ function rowToTrack(row) {
1548
1603
  time_position: item.time_position
1549
1604
  };
1550
1605
  if (item.fallback_abs_ms != null) result.fallback_abs_ms = item.fallback_abs_ms;
1551
- if (item.duration_override_ms != null) result.duration_override_ms = item.duration_override_ms;
1552
1606
  return result;
1553
1607
  })
1554
1608
  };
@@ -1623,7 +1677,7 @@ function createMirrorVideoDocument(document, options = {}) {
1623
1677
  doc,
1624
1678
  schema: videoDocumentMirrorSchema
1625
1679
  }).setState((draft) => {
1626
- seedDraft(draft, document);
1680
+ writeVideoDocumentToDraft(draft, document);
1627
1681
  }, { origin: options.origin ?? "mengine.bootstrap" });
1628
1682
  return doc;
1629
1683
  }
@@ -1631,8 +1685,8 @@ function createMirrorVideoDocument(document, options = {}) {
1631
1685
  function createMirrorVideoDocumentAdapter(document, options) {
1632
1686
  return new MirrorVideoDocumentAdapter(createMirrorVideoDocument(document, options));
1633
1687
  }
1634
- /** Write a whole `VideoDocument` into the mirror draft (used to seed a fresh doc). */
1635
- function seedDraft(draft, document) {
1688
+ /** Write a whole `VideoDocument` into a draft (seed a fresh doc / plain-memory state). */
1689
+ function writeVideoDocumentToDraft(draft, document) {
1636
1690
  draft.meta = {
1637
1691
  schema_version: document.meta.schema_version,
1638
1692
  draft_id: document.meta.draft_id ?? void 0,
@@ -1657,10 +1711,124 @@ function toTrackRow(track) {
1657
1711
  items: (track.items ?? []).map((item) => ({
1658
1712
  part_id: item.part_id,
1659
1713
  time_position: item.time_position,
1660
- fallback_abs_ms: item.fallback_abs_ms,
1661
- duration_override_ms: item.duration_override_ms
1714
+ fallback_abs_ms: item.fallback_abs_ms
1662
1715
  }))
1663
1716
  };
1664
1717
  }
1665
1718
  //#endregion
1666
- export { partDurationMs as A, reassignSpeechesToVideoClipsByTime as C, syncAggregatedClipsTimePosition as D, resolveSpeechOverlapByShiftingVideos as E, partUnionToDraft as F, recordEntries as I, videoDocumentMirrorSchema as L, DEFAULT_UNIT_TIME_MS as M, VIDEO_DOCUMENT_SCHEMA_VERSION as N, TIMELINE_SKELETON_DURATION_MS as O, effectiveVideoClipDurationMs as P, base64ToBytes as R, fillMainTrackTimeGaps as S, resolveAllSpeechOverlaps as T, findLaneTrack as _, buildInitialVideoDocument as a, cascadeAfterVideoClipChanges as b, fromVideoDocument as c, assertValidVideoDocument as d, validateVideoDocument as f, ensureLaneTrack as g, LANE_KINDS_IN_STACK_ORDER as h, readVideoDocumentFromDraft as i, safeDurationMs as j, isEmptyVideoClip as k, toVideoDocument as l, videoDocumentSchema as m, createMirrorVideoDocument as n, buildSpeechHostMap as o, partUnionSchema as p, createMirrorVideoDocumentAdapter as r, derivePositionFromAbs as s, MirrorVideoDocumentAdapter as t, VideoDocumentValidationError as u, laneTrackId as v, recalculateTimelineDuration as w, arrangeMainTrackSeamlessly as x, solveVideoDocument as y, bytesToBase64 as z };
1719
+ //#region src/editor/id-gen.ts
1720
+ /**
1721
+ * Part-id generation, aligned with the online ecosystem.
1722
+ *
1723
+ * The authoritative online producers — agent-harness (`@harness/shared`
1724
+ * `genObjId`) and director.v2 (`common/obj_id.py` `gen_obj_id`) — both mint part
1725
+ * ids as `` `${prefix}_${ulid()}` ``, and real captured drafts use exactly that
1726
+ * shape (`clip_…` / `spe_…` / `cap_…` / `bgm_…`, each a 26-char ULID). The engine
1727
+ * previously emitted `vc_<base36 timestamp><6 random>`, a different prefix AND a
1728
+ * different encoding — the sole cross-repo id divergence. This module removes it
1729
+ * by emitting the same `<prefix>_<ULID>` bytes.
1730
+ *
1731
+ * The ULID is generated inline (Crockford Base32, 48-bit time + 80-bit random)
1732
+ * rather than pulling the `ulid` npm package: the randomness class matches the
1733
+ * old generator (both `Math.random`-based) and it keeps `@mengine/medeo-client`
1734
+ * dependency-free for a purely mechanical id string. Part ids only need to be
1735
+ * unique and lexicographically time-sortable, which this satisfies.
1736
+ */
1737
+ /** Crockford Base32 alphabet (no I, L, O, U), per the ULID spec. */
1738
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1739
+ const TIME_LEN = 10;
1740
+ const RANDOM_LEN = 16;
1741
+ function encodeTime(now) {
1742
+ let out = "";
1743
+ let ms = now;
1744
+ for (let i = TIME_LEN - 1; i >= 0; i--) {
1745
+ const mod = ms % 32;
1746
+ out = CROCKFORD[mod] + out;
1747
+ ms = (ms - mod) / 32;
1748
+ }
1749
+ return out;
1750
+ }
1751
+ function encodeRandom() {
1752
+ let out = "";
1753
+ for (let i = 0; i < RANDOM_LEN; i++) out += CROCKFORD[Math.floor(Math.random() * 32)];
1754
+ return out;
1755
+ }
1756
+ /** A 26-char Crockford Base32 ULID (10-char time + 16-char random). */
1757
+ function ulid() {
1758
+ return encodeTime(Date.now()) + encodeRandom();
1759
+ }
1760
+ function generatePartId(prefix) {
1761
+ return `${prefix}_${ulid()}`;
1762
+ }
1763
+ //#endregion
1764
+ //#region src/document/plain-memory-adapter.ts
1765
+ /**
1766
+ * Pure in-memory `SemanticDocumentAdapter` — no Loro/WASM. Holds a
1767
+ * `VideoDocumentDraft` object and applies each `transact` via immer `produce`,
1768
+ * journaling every audit that actually mutated state (plus any ids minted
1769
+ * during that transact).
1770
+ *
1771
+ * Known benign difference vs `MirrorVideoDocumentAdapter`: the editor's
1772
+ * `setPart` assigns a fresh part object on every call, so a same-value rewrite
1773
+ * produces a new immer state and IS journaled here, while the mirror's deep
1774
+ * diff emits no commit. Terminal `snapshot()` stays equal; replay is idempotent.
1775
+ */
1776
+ var PlainMemoryAdapter = class {
1777
+ state;
1778
+ _journal = [];
1779
+ baseIdFactory;
1780
+ /** Non-null only while a `transact` edit callback is running. */
1781
+ pendingIds = null;
1782
+ /**
1783
+ * Recording wrapper around the underlying factory. Callers (sandbox editor)
1784
+ * use this so every minted id is appended to the current transact's list.
1785
+ */
1786
+ idFactory;
1787
+ constructor(document, options) {
1788
+ assertValidVideoDocument(document);
1789
+ const draft = {};
1790
+ writeVideoDocumentToDraft(draft, document);
1791
+ this.state = draft;
1792
+ this.baseIdFactory = options?.idFactory ?? generatePartId;
1793
+ this.idFactory = (prefix) => {
1794
+ const id = this.baseIdFactory(prefix);
1795
+ if (this.pendingIds != null) this.pendingIds.push(id);
1796
+ return id;
1797
+ };
1798
+ }
1799
+ get journal() {
1800
+ return this._journal;
1801
+ }
1802
+ hasContent() {
1803
+ return (this.state.meta?.schema_version ?? "") !== "";
1804
+ }
1805
+ snapshot() {
1806
+ return readVideoDocumentFromDraft(this.state);
1807
+ }
1808
+ /**
1809
+ * Apply one op via immer. A throw in `edit` discards the draft (state and
1810
+ * journal unchanged). When `produce` returns the same reference, there was
1811
+ * no structural change — skip journal, matching mirror "no change, no commit".
1812
+ */
1813
+ transact(edit, audit) {
1814
+ this.pendingIds = [];
1815
+ try {
1816
+ const next = produce(this.state, edit);
1817
+ if (next !== this.state) {
1818
+ this.state = next;
1819
+ this._journal.push({
1820
+ ...audit,
1821
+ generated_ids: this.pendingIds
1822
+ });
1823
+ }
1824
+ } finally {
1825
+ this.pendingIds = null;
1826
+ }
1827
+ }
1828
+ };
1829
+ /** Build a `PlainMemoryAdapter` seeded with `document`. */
1830
+ function createPlainMemoryAdapter(document, options) {
1831
+ return new PlainMemoryAdapter(document, options);
1832
+ }
1833
+ //#endregion
1834
+ export { resolveAllSpeechOverlaps as A, effectiveVideoClipDurationMs as B, solveVideoDocument as C, reassignSpeechesToVideoClipsByTime as D, fillMainTrackTimeGaps as E, isEmptyVideoClip as F, base64ToBytes as G, partUnionToDraft as H, partDurationMs as I, bytesToBase64 as K, safeDurationMs as L, resolveSpeechOverlapByShiftingVideos as M, syncAggregatedClipsTimePosition as N, recalculateTimelineDuration as O, TIMELINE_SKELETON_DURATION_MS as P, DEFAULT_UNIT_TIME_MS as R, laneTrackId as S, arrangeMainTrackSeamlessly as T, recordEntries as U, speedOf as V, videoDocumentMirrorSchema as W, partUnionSchema as _, createMirrorVideoDocument as a, ensureLaneTrack as b, readVideoDocumentFromDraft as c, derivePositionFromAbs as d, fromVideoDocument as f, validateVideoDocument as g, assertValidVideoDocument as h, MirrorVideoDocumentAdapter as i, resolveAnchoredClipsTimePosition as j, recoverOrphanedSpeechAnchorsByTime as k, buildInitialVideoDocument as l, VideoDocumentValidationError as m, createPlainMemoryAdapter as n, createMirrorVideoDocumentAdapter as o, toVideoDocument as p, generatePartId as r, writeVideoDocumentToDraft as s, PlainMemoryAdapter as t, buildSpeechHostMap as u, videoDocumentSchema as v, cascadeAfterVideoClipChanges as w, findLaneTrack as x, LANE_KINDS_IN_STACK_ORDER as y, VIDEO_DOCUMENT_SCHEMA_VERSION as z };