@camstack/addon-pipeline 1.2.42 → 1.2.44

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.
Files changed (39) hide show
  1. package/dist/audio-analyzer/index.js +4 -4
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +164 -88
  4. package/dist/detection-pipeline/index.mjs +158 -82
  5. package/dist/{dist-BdDd3ql_.js → dist-BuFE5rOK.js} +1608 -36
  6. package/dist/{dist-COK_1Ot8.mjs → dist-DOu93i_g.mjs} +1525 -37
  7. package/dist/{event-loop-stall-monitor-CWOJtTNc.mjs → event-loop-stall-monitor-CShgQE6l.mjs} +40 -2
  8. package/dist/{event-loop-stall-monitor-DNFRgNef.js → event-loop-stall-monitor-L08yG6bB.js} +40 -2
  9. package/dist/{model-download-service-Cp9f4dk6-fsdDExML.js → model-download-service-D8B-4ktF-7-YxE9Wx.js} +2 -2
  10. package/dist/{model-download-service-Cp9f4dk6-CwUb5v3Y.mjs → model-download-service-D8B-4ktF-BB7oZL3y.mjs} +2 -2
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +496 -67
  14. package/dist/pipeline-runner/index.mjs +496 -67
  15. package/dist/recorder/index.js +194 -11
  16. package/dist/recorder/index.mjs +191 -8
  17. package/dist/session-decode/decode-worker-child.js +197 -24
  18. package/dist/session-decode/decode-worker-child.mjs +197 -24
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C8ovZN0g.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DiXoZnou.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BRc-m6W-.mjs +26 -0
  22. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DAjAxXHm.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B_hakWAb.mjs} +1 -1
  23. package/dist/stream-broker/{hostInit-mEtjQWcr.mjs → hostInit-khRXMnjl.mjs} +2 -2
  24. package/dist/stream-broker/index.js +17331 -14101
  25. package/dist/stream-broker/index.mjs +17330 -14100
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/worker-protocol-BOXlUhWO.mjs +244 -0
  28. package/dist/worker-protocol-VURr0nUh.js +279 -0
  29. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C0TIfr0G.js → MaskShapeCanvas-DI4BY7W2-DHZuYYjH.js} +1 -1
  30. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-x0SkfHwv.js → MotionZonesSettings-NcxxQN8r-jFDOzPCD.js} +1 -1
  31. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BoaAB4Ta.js → PrivacyMaskSettings-APgPLF7p-BPBXgIX8.js} +1 -1
  32. package/embed-dist/assets/{index-BgRdCCVy.js → index-4CtQAybX.js} +12 -12
  33. package/embed-dist/assets/index-DC63og2c.css +2 -0
  34. package/embed-dist/index.html +2 -2
  35. package/package.json +1 -1
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-SU_6F3Y7.mjs +0 -26
  37. package/dist/worker-protocol-D7RzZIla.mjs +0 -77
  38. package/dist/worker-protocol-DextwlTX.js +0 -94
  39. package/embed-dist/assets/index-CGdwTcwE.css +0 -2
@@ -1,12 +1,12 @@
1
1
  const require_chunk = require("../chunk-emK7D4bc.js");
2
- const require_dist = require("../dist-BdDd3ql_.js");
2
+ const require_dist = require("../dist-BuFE5rOK.js");
3
3
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
4
- const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-fsdDExML.js");
4
+ const require_model_download_service_D8B_4ktF = require("../model-download-service-D8B-4ktF-7-YxE9Wx.js");
5
+ let node_crypto = require("node:crypto");
5
6
  let node_child_process = require("node:child_process");
6
7
  let node_fs = require("node:fs");
7
8
  let node_path = require("node:path");
8
9
  node_path = require_chunk.__toESM(node_path);
9
- let node_crypto = require("node:crypto");
10
10
  let node_fs_promises = require("node:fs/promises");
11
11
  //#region src/recorder/addon/calendar-scan.ts
12
12
  /**
@@ -955,6 +955,34 @@ function parseMfra(tail, fileSize, opts = {}) {
955
955
  trackId
956
956
  };
957
957
  }
958
+ /**
959
+ * The byte range of the fragment covering `offsetTicks`, and the index of the
960
+ * sync sample chosen.
961
+ *
962
+ * `end` is the START of the next fragment (exclusive), or the start of `mfra`
963
+ * for the last one — never the end of the file, which would drag the index
964
+ * bytes along with every final-GOP fetch.
965
+ */
966
+ function fragmentRangeFor(index, offsetTicks) {
967
+ const { samples, mfraOffset } = index;
968
+ let lo = 0;
969
+ let hi = samples.length - 1;
970
+ let chosen = 0;
971
+ while (lo <= hi) {
972
+ const mid = lo + hi >> 1;
973
+ const s = samples[mid];
974
+ if (s === void 0) break;
975
+ if (s.timeTicks <= offsetTicks) {
976
+ chosen = mid;
977
+ lo = mid + 1;
978
+ } else hi = mid - 1;
979
+ }
980
+ return {
981
+ start: samples[chosen]?.moofOffset ?? 0,
982
+ end: samples[chosen + 1]?.moofOffset ?? mfraOffset,
983
+ sampleIndex: chosen
984
+ };
985
+ }
958
986
  //#endregion
959
987
  //#region src/recorder/mfra-capture.ts
960
988
  /**
@@ -1760,6 +1788,105 @@ function subscribeEventCapture(deps) {
1760
1788
  });
1761
1789
  }
1762
1790
  //#endregion
1791
+ //#region src/recorder/gop-range-read.ts
1792
+ /**
1793
+ * gop-range-read — serve ONE GOP of a recorded segment by byte range, resolved
1794
+ * through the segment's own `mfra` (D31's letter, applied to the D42 feeder
1795
+ * path).
1796
+ *
1797
+ * The recorded feeder scrubs at GOP granularity: a drag target needs the
1798
+ * fragment covering ONE instant, yet `readSegmentBytes` hands it the whole
1799
+ * file — 100–600 KB on `low` to serve a 41–173 KB GOP, and proportionally
1800
+ * worse on bigger profiles. This module reads exactly three ranges instead:
1801
+ * the `mfra` TAIL (the time→moof index `mfro` makes reachable from the end),
1802
+ * the `moov` HEAD (codec params — the decoder init), and the single
1803
+ * `moof`+`mdat` covering the requested instant. The returned bytes are
1804
+ * standalone-demuxable (`default_base_moof` makes fragment offsets
1805
+ * self-relative), so the feeder's demux consumes them exactly like a whole
1806
+ * segment. Net I/O is strictly LOWER than the whole-file read it replaces.
1807
+ *
1808
+ * PURE over an injected {@link RangeReadable}: no fs here, so the specs run
1809
+ * against a real captured segment with every read recorded — "never the whole
1810
+ * file" is asserted in bytes.
1811
+ */
1812
+ /** Ticks → ms on the video timescale, rounded like the demux anchors samples. */
1813
+ function ticksToMs(ticks, timescale) {
1814
+ return Math.round(ticks / timescale * 1e3);
1815
+ }
1816
+ /**
1817
+ * Read the single GOP covering `epochMs` from `file`, by byte range.
1818
+ *
1819
+ * Returns `null` when the bytes do not carry a usable `mfra`/`moov` — the
1820
+ * caller's answer to null is the whole-segment read, which is slow but
1821
+ * correct; guessing offsets would be fast and wrong. A parse failure is a
1822
+ * property of the FILE, so the caller may cache the null per segment.
1823
+ */
1824
+ async function readGopRange(file, args) {
1825
+ let bytesRead = 0;
1826
+ const read = async (offset, length) => {
1827
+ const chunk = await file.read(offset, length);
1828
+ bytesRead += chunk.length;
1829
+ return chunk;
1830
+ };
1831
+ const parsed = await parseTail(file, read);
1832
+ if (parsed === null) return null;
1833
+ const { tail, index } = parsed;
1834
+ const firstMoof = index.samples[0]?.moofOffset ?? 0;
1835
+ if (firstMoof <= 0 || firstMoof >= file.size) return null;
1836
+ const head = await read(0, firstMoof);
1837
+ const init = parseInitInfo(head);
1838
+ if (init === null) return null;
1839
+ const videoIndex = index.trackId === init.videoTrackId ? index : reparseTail(tail, file.size, init.videoTrackId);
1840
+ if (videoIndex === null) return null;
1841
+ const { start, end, sampleIndex } = fragmentRangeFor(videoIndex, Math.max(0, args.epochMs - args.segmentStartMs) / 1e3 * init.videoTimescale);
1842
+ if (start <= 0 || end <= start || end > file.size) return null;
1843
+ const fragment = await read(start, end - start);
1844
+ const startTicks = videoIndex.samples[sampleIndex]?.timeTicks ?? 0;
1845
+ const nextTicks = videoIndex.samples[sampleIndex + 1]?.timeTicks;
1846
+ const gopStartOffsetMs = ticksToMs(startTicks, init.videoTimescale);
1847
+ const gopDurMs = nextTicks !== void 0 ? ticksToMs(nextTicks - startTicks, init.videoTimescale) : Math.max(1, args.segmentDurMs - gopStartOffsetMs);
1848
+ const data = new Uint8Array(head.length + fragment.length);
1849
+ data.set(head, 0);
1850
+ data.set(fragment, head.length);
1851
+ return {
1852
+ data,
1853
+ gopStartMs: args.segmentStartMs + gopStartOffsetMs,
1854
+ gopDurMs,
1855
+ bytesRead
1856
+ };
1857
+ }
1858
+ /**
1859
+ * Fetch + parse the `mfra` tail, honouring `MfraParseError.needBytes` with ONE
1860
+ * widened retry. Null on anything else — bytes that are not a segment.
1861
+ */
1862
+ async function parseTail(file, read) {
1863
+ let tailBytes = Math.min(DEFAULT_TAIL_BYTES, file.size);
1864
+ for (let attempt = 0; attempt < 2; attempt++) {
1865
+ const tail = await read(file.size - tailBytes, tailBytes);
1866
+ try {
1867
+ return {
1868
+ tail,
1869
+ index: parseMfra(tail, file.size)
1870
+ };
1871
+ } catch (err) {
1872
+ if (err instanceof MfraParseError && err.needBytes && err.needBytes > tailBytes) {
1873
+ tailBytes = Math.min(err.needBytes, file.size);
1874
+ continue;
1875
+ }
1876
+ return null;
1877
+ }
1878
+ }
1879
+ return null;
1880
+ }
1881
+ /** Re-parse an already-fetched tail for a specific track. Null when absent. */
1882
+ function reparseTail(tail, fileSize, trackId) {
1883
+ try {
1884
+ return parseMfra(tail, fileSize, { trackId });
1885
+ } catch {
1886
+ return null;
1887
+ }
1888
+ }
1889
+ //#endregion
1763
1890
  //#region src/recorder/codec-probe.ts
1764
1891
  /** Iterate the boxes fully contained in `bytes[start, end)`. Stops at the
1765
1892
  * first malformed or truncated box (the probe only needs the front boxes). */
@@ -2360,6 +2487,34 @@ async function evictVictims(deps, deviceId, victims) {
2360
2487
  };
2361
2488
  }
2362
2489
  /**
2490
+ * Resolve one indexed segment to its row + absolute path. Throws a clear error
2491
+ * when the segment or its location is unknown — shared by the whole-segment
2492
+ * and GOP byte-range reads so the two paths cannot drift on resolution.
2493
+ */
2494
+ function resolveSegmentFile(deps, deviceId, profile, startMs) {
2495
+ const seg = deps.index.segments(deviceId, profile).find((s) => s.startMs === startMs);
2496
+ if (!seg) throw new Error(`recording: no segment at start ${startMs} for ${deviceId}/${profile}`);
2497
+ const loc = deps.locations().find((l) => l.id === seg.locationId);
2498
+ if (!loc) throw new Error(`recording: unknown location ${seg.locationId}`);
2499
+ return {
2500
+ seg,
2501
+ abs: node_path.default.join(loc.root, seg.path)
2502
+ };
2503
+ }
2504
+ /** Default ranged opener: ONE `fs.open` per GOP answer serving all its reads. */
2505
+ async function openRangeFromDisk(absPath, size) {
2506
+ const handle = await node_fs.promises.open(absPath, "r");
2507
+ return {
2508
+ size,
2509
+ read: async (offset, length) => {
2510
+ const buf = Buffer.alloc(length);
2511
+ const { bytesRead } = await handle.read(buf, 0, length, offset);
2512
+ return Uint8Array.from(buf.subarray(0, bytesRead));
2513
+ },
2514
+ close: () => handle.close()
2515
+ };
2516
+ }
2517
+ /**
2363
2518
  * Build the `IRecordingProvider`. The provider is a thin façade over the v2
2364
2519
  * core; all storage I/O flows through the injected deps.
2365
2520
  */
@@ -2472,13 +2627,41 @@ function buildRecordingProvider(deps) {
2472
2627
  };
2473
2628
  },
2474
2629
  readSegmentBytes: async ({ deviceId, profile, startMs }) => {
2475
- const seg = deps.index.segments(deviceId, profile).find((s) => s.startMs === startMs);
2476
- if (!seg) throw new Error(`recording: no segment at start ${startMs} for ${deviceId}/${profile}`);
2477
- const loc = deps.locations().find((l) => l.id === seg.locationId);
2478
- if (!loc) throw new Error(`recording: unknown location ${seg.locationId}`);
2479
- const data = await readFile(node_path.default.join(loc.root, seg.path));
2630
+ const { abs } = resolveSegmentFile(deps, deviceId, profile, startMs);
2631
+ const data = await readFile(abs);
2480
2632
  return { data: Uint8Array.from(data) };
2481
2633
  },
2634
+ readGopBytes: async ({ deviceId, profile, startMs, epochMs }) => {
2635
+ const { abs, seg } = resolveSegmentFile(deps, deviceId, profile, startMs);
2636
+ const file = await (deps.openRangeReadable ?? openRangeFromDisk)(abs, seg.bytes);
2637
+ try {
2638
+ const gop = await readGopRange(file, {
2639
+ segmentStartMs: seg.startMs,
2640
+ segmentDurMs: seg.durMs,
2641
+ epochMs
2642
+ });
2643
+ if (gop !== null) return {
2644
+ data: gop.data,
2645
+ gopStartMs: gop.gopStartMs,
2646
+ gopDurMs: gop.gopDurMs
2647
+ };
2648
+ } finally {
2649
+ await file.close();
2650
+ }
2651
+ deps.logger.warn("recording: readGopBytes fell back to the WHOLE segment (mfra/moov unparseable)", {
2652
+ tags: { deviceId },
2653
+ meta: {
2654
+ profile,
2655
+ startMs
2656
+ }
2657
+ });
2658
+ const data = await readFile(abs);
2659
+ return {
2660
+ data: Uint8Array.from(data),
2661
+ gopStartMs: seg.startMs,
2662
+ gopDurMs: seg.durMs
2663
+ };
2664
+ },
2482
2665
  setDeviceConfig: async ({ deviceId, config }) => {
2483
2666
  const saved = await saveDeviceConfig(deps.configStore, deviceId, config);
2484
2667
  if (deps.onConfigChanged) try {
@@ -5235,7 +5418,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
5235
5418
  source: this.calendar,
5236
5419
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
5237
5420
  onLog: (msg, meta) => this.ctx.logger.debug(msg, { meta })
5238
- }, require_model_download_service_Cp9f4dk6.createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() }));
5421
+ }, require_model_download_service_D8B_4ktF.createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() }));
5239
5422
  const served = await this.ctx.dataPlane?.serve({
5240
5423
  prefix: PLAYBACK_PREFIX,
5241
5424
  access: "authenticated",
@@ -5709,11 +5892,11 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
5709
5892
  return;
5710
5893
  }
5711
5894
  const baseHeaders = {
5712
- "content-type": require_model_download_service_Cp9f4dk6.contentTypeFor(absPath),
5895
+ "content-type": require_model_download_service_D8B_4ktF.contentTypeFor(absPath),
5713
5896
  "accept-ranges": "bytes",
5714
5897
  "cache-control": "no-cache"
5715
5898
  };
5716
- const range = require_model_download_service_Cp9f4dk6.parseRangeHeader(req.headers.range, size);
5899
+ const range = require_model_download_service_D8B_4ktF.parseRangeHeader(req.headers.range, size);
5717
5900
  const servesFinalByte = range ? range.end === size - 1 : true;
5718
5901
  const recordDownloadOnFinish = () => {
5719
5902
  if (req.method !== "GET" || !servesFinalByte) return;
@@ -1,10 +1,10 @@
1
- import { $ as nodePin, I as recordingCapability, L as recordingExportCapability, T as deriveRecordingMode, U as errMsg, W as BaseAddon, Y as hydrateSchema, at as array, d as ExportRecordSchema, ft as record, ht as EventCategory, l as EVENT_PAD_MS, m as RecordingConfigSchema, nt as selectAssignedProfileSlots, p as OpsLogEntrySchema, pt as string, q as DeviceType, z as storageEvictableCapability } from "../dist-COK_1Ot8.mjs";
1
+ import { $ as storageEvictableCapability, Dt as string, Et as record, N as deriveRecordingMode, X as recordingExportCapability, Y as recordingCapability, _ as OpsLogEntrySchema, b as RecordingConfigSchema, d as EVENT_PAD_MS, gt as selectAssignedProfileSlots, it as BaseAddon, kt as EventCategory, lt as hydrateSchema, p as ExportRecordSchema, pt as nodePin, rt as errMsg, st as DeviceType, yt as array } from "../dist-DOu93i_g.mjs";
2
2
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
3
- import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../model-download-service-Cp9f4dk6-CwUb5v3Y.mjs";
3
+ import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../model-download-service-D8B-4ktF-BB7oZL3y.mjs";
4
+ import { randomUUID } from "node:crypto";
4
5
  import { spawn } from "node:child_process";
5
6
  import { createReadStream, promises, realpathSync } from "node:fs";
6
7
  import path from "node:path";
7
- import { randomUUID } from "node:crypto";
8
8
  import { open } from "node:fs/promises";
9
9
  //#region src/recorder/addon/calendar-scan.ts
10
10
  /**
@@ -953,6 +953,34 @@ function parseMfra(tail, fileSize, opts = {}) {
953
953
  trackId
954
954
  };
955
955
  }
956
+ /**
957
+ * The byte range of the fragment covering `offsetTicks`, and the index of the
958
+ * sync sample chosen.
959
+ *
960
+ * `end` is the START of the next fragment (exclusive), or the start of `mfra`
961
+ * for the last one — never the end of the file, which would drag the index
962
+ * bytes along with every final-GOP fetch.
963
+ */
964
+ function fragmentRangeFor(index, offsetTicks) {
965
+ const { samples, mfraOffset } = index;
966
+ let lo = 0;
967
+ let hi = samples.length - 1;
968
+ let chosen = 0;
969
+ while (lo <= hi) {
970
+ const mid = lo + hi >> 1;
971
+ const s = samples[mid];
972
+ if (s === void 0) break;
973
+ if (s.timeTicks <= offsetTicks) {
974
+ chosen = mid;
975
+ lo = mid + 1;
976
+ } else hi = mid - 1;
977
+ }
978
+ return {
979
+ start: samples[chosen]?.moofOffset ?? 0,
980
+ end: samples[chosen + 1]?.moofOffset ?? mfraOffset,
981
+ sampleIndex: chosen
982
+ };
983
+ }
956
984
  //#endregion
957
985
  //#region src/recorder/mfra-capture.ts
958
986
  /**
@@ -1758,6 +1786,105 @@ function subscribeEventCapture(deps) {
1758
1786
  });
1759
1787
  }
1760
1788
  //#endregion
1789
+ //#region src/recorder/gop-range-read.ts
1790
+ /**
1791
+ * gop-range-read — serve ONE GOP of a recorded segment by byte range, resolved
1792
+ * through the segment's own `mfra` (D31's letter, applied to the D42 feeder
1793
+ * path).
1794
+ *
1795
+ * The recorded feeder scrubs at GOP granularity: a drag target needs the
1796
+ * fragment covering ONE instant, yet `readSegmentBytes` hands it the whole
1797
+ * file — 100–600 KB on `low` to serve a 41–173 KB GOP, and proportionally
1798
+ * worse on bigger profiles. This module reads exactly three ranges instead:
1799
+ * the `mfra` TAIL (the time→moof index `mfro` makes reachable from the end),
1800
+ * the `moov` HEAD (codec params — the decoder init), and the single
1801
+ * `moof`+`mdat` covering the requested instant. The returned bytes are
1802
+ * standalone-demuxable (`default_base_moof` makes fragment offsets
1803
+ * self-relative), so the feeder's demux consumes them exactly like a whole
1804
+ * segment. Net I/O is strictly LOWER than the whole-file read it replaces.
1805
+ *
1806
+ * PURE over an injected {@link RangeReadable}: no fs here, so the specs run
1807
+ * against a real captured segment with every read recorded — "never the whole
1808
+ * file" is asserted in bytes.
1809
+ */
1810
+ /** Ticks → ms on the video timescale, rounded like the demux anchors samples. */
1811
+ function ticksToMs(ticks, timescale) {
1812
+ return Math.round(ticks / timescale * 1e3);
1813
+ }
1814
+ /**
1815
+ * Read the single GOP covering `epochMs` from `file`, by byte range.
1816
+ *
1817
+ * Returns `null` when the bytes do not carry a usable `mfra`/`moov` — the
1818
+ * caller's answer to null is the whole-segment read, which is slow but
1819
+ * correct; guessing offsets would be fast and wrong. A parse failure is a
1820
+ * property of the FILE, so the caller may cache the null per segment.
1821
+ */
1822
+ async function readGopRange(file, args) {
1823
+ let bytesRead = 0;
1824
+ const read = async (offset, length) => {
1825
+ const chunk = await file.read(offset, length);
1826
+ bytesRead += chunk.length;
1827
+ return chunk;
1828
+ };
1829
+ const parsed = await parseTail(file, read);
1830
+ if (parsed === null) return null;
1831
+ const { tail, index } = parsed;
1832
+ const firstMoof = index.samples[0]?.moofOffset ?? 0;
1833
+ if (firstMoof <= 0 || firstMoof >= file.size) return null;
1834
+ const head = await read(0, firstMoof);
1835
+ const init = parseInitInfo(head);
1836
+ if (init === null) return null;
1837
+ const videoIndex = index.trackId === init.videoTrackId ? index : reparseTail(tail, file.size, init.videoTrackId);
1838
+ if (videoIndex === null) return null;
1839
+ const { start, end, sampleIndex } = fragmentRangeFor(videoIndex, Math.max(0, args.epochMs - args.segmentStartMs) / 1e3 * init.videoTimescale);
1840
+ if (start <= 0 || end <= start || end > file.size) return null;
1841
+ const fragment = await read(start, end - start);
1842
+ const startTicks = videoIndex.samples[sampleIndex]?.timeTicks ?? 0;
1843
+ const nextTicks = videoIndex.samples[sampleIndex + 1]?.timeTicks;
1844
+ const gopStartOffsetMs = ticksToMs(startTicks, init.videoTimescale);
1845
+ const gopDurMs = nextTicks !== void 0 ? ticksToMs(nextTicks - startTicks, init.videoTimescale) : Math.max(1, args.segmentDurMs - gopStartOffsetMs);
1846
+ const data = new Uint8Array(head.length + fragment.length);
1847
+ data.set(head, 0);
1848
+ data.set(fragment, head.length);
1849
+ return {
1850
+ data,
1851
+ gopStartMs: args.segmentStartMs + gopStartOffsetMs,
1852
+ gopDurMs,
1853
+ bytesRead
1854
+ };
1855
+ }
1856
+ /**
1857
+ * Fetch + parse the `mfra` tail, honouring `MfraParseError.needBytes` with ONE
1858
+ * widened retry. Null on anything else — bytes that are not a segment.
1859
+ */
1860
+ async function parseTail(file, read) {
1861
+ let tailBytes = Math.min(DEFAULT_TAIL_BYTES, file.size);
1862
+ for (let attempt = 0; attempt < 2; attempt++) {
1863
+ const tail = await read(file.size - tailBytes, tailBytes);
1864
+ try {
1865
+ return {
1866
+ tail,
1867
+ index: parseMfra(tail, file.size)
1868
+ };
1869
+ } catch (err) {
1870
+ if (err instanceof MfraParseError && err.needBytes && err.needBytes > tailBytes) {
1871
+ tailBytes = Math.min(err.needBytes, file.size);
1872
+ continue;
1873
+ }
1874
+ return null;
1875
+ }
1876
+ }
1877
+ return null;
1878
+ }
1879
+ /** Re-parse an already-fetched tail for a specific track. Null when absent. */
1880
+ function reparseTail(tail, fileSize, trackId) {
1881
+ try {
1882
+ return parseMfra(tail, fileSize, { trackId });
1883
+ } catch {
1884
+ return null;
1885
+ }
1886
+ }
1887
+ //#endregion
1761
1888
  //#region src/recorder/codec-probe.ts
1762
1889
  /** Iterate the boxes fully contained in `bytes[start, end)`. Stops at the
1763
1890
  * first malformed or truncated box (the probe only needs the front boxes). */
@@ -2358,6 +2485,34 @@ async function evictVictims(deps, deviceId, victims) {
2358
2485
  };
2359
2486
  }
2360
2487
  /**
2488
+ * Resolve one indexed segment to its row + absolute path. Throws a clear error
2489
+ * when the segment or its location is unknown — shared by the whole-segment
2490
+ * and GOP byte-range reads so the two paths cannot drift on resolution.
2491
+ */
2492
+ function resolveSegmentFile(deps, deviceId, profile, startMs) {
2493
+ const seg = deps.index.segments(deviceId, profile).find((s) => s.startMs === startMs);
2494
+ if (!seg) throw new Error(`recording: no segment at start ${startMs} for ${deviceId}/${profile}`);
2495
+ const loc = deps.locations().find((l) => l.id === seg.locationId);
2496
+ if (!loc) throw new Error(`recording: unknown location ${seg.locationId}`);
2497
+ return {
2498
+ seg,
2499
+ abs: path.join(loc.root, seg.path)
2500
+ };
2501
+ }
2502
+ /** Default ranged opener: ONE `fs.open` per GOP answer serving all its reads. */
2503
+ async function openRangeFromDisk(absPath, size) {
2504
+ const handle = await promises.open(absPath, "r");
2505
+ return {
2506
+ size,
2507
+ read: async (offset, length) => {
2508
+ const buf = Buffer.alloc(length);
2509
+ const { bytesRead } = await handle.read(buf, 0, length, offset);
2510
+ return Uint8Array.from(buf.subarray(0, bytesRead));
2511
+ },
2512
+ close: () => handle.close()
2513
+ };
2514
+ }
2515
+ /**
2361
2516
  * Build the `IRecordingProvider`. The provider is a thin façade over the v2
2362
2517
  * core; all storage I/O flows through the injected deps.
2363
2518
  */
@@ -2470,13 +2625,41 @@ function buildRecordingProvider(deps) {
2470
2625
  };
2471
2626
  },
2472
2627
  readSegmentBytes: async ({ deviceId, profile, startMs }) => {
2473
- const seg = deps.index.segments(deviceId, profile).find((s) => s.startMs === startMs);
2474
- if (!seg) throw new Error(`recording: no segment at start ${startMs} for ${deviceId}/${profile}`);
2475
- const loc = deps.locations().find((l) => l.id === seg.locationId);
2476
- if (!loc) throw new Error(`recording: unknown location ${seg.locationId}`);
2477
- const data = await readFile(path.join(loc.root, seg.path));
2628
+ const { abs } = resolveSegmentFile(deps, deviceId, profile, startMs);
2629
+ const data = await readFile(abs);
2478
2630
  return { data: Uint8Array.from(data) };
2479
2631
  },
2632
+ readGopBytes: async ({ deviceId, profile, startMs, epochMs }) => {
2633
+ const { abs, seg } = resolveSegmentFile(deps, deviceId, profile, startMs);
2634
+ const file = await (deps.openRangeReadable ?? openRangeFromDisk)(abs, seg.bytes);
2635
+ try {
2636
+ const gop = await readGopRange(file, {
2637
+ segmentStartMs: seg.startMs,
2638
+ segmentDurMs: seg.durMs,
2639
+ epochMs
2640
+ });
2641
+ if (gop !== null) return {
2642
+ data: gop.data,
2643
+ gopStartMs: gop.gopStartMs,
2644
+ gopDurMs: gop.gopDurMs
2645
+ };
2646
+ } finally {
2647
+ await file.close();
2648
+ }
2649
+ deps.logger.warn("recording: readGopBytes fell back to the WHOLE segment (mfra/moov unparseable)", {
2650
+ tags: { deviceId },
2651
+ meta: {
2652
+ profile,
2653
+ startMs
2654
+ }
2655
+ });
2656
+ const data = await readFile(abs);
2657
+ return {
2658
+ data: Uint8Array.from(data),
2659
+ gopStartMs: seg.startMs,
2660
+ gopDurMs: seg.durMs
2661
+ };
2662
+ },
2480
2663
  setDeviceConfig: async ({ deviceId, config }) => {
2481
2664
  const saved = await saveDeviceConfig(deps.configStore, deviceId, config);
2482
2665
  if (deps.onConfigChanged) try {