@camstack/addon-pipeline 1.2.43 → 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.
- package/dist/audio-analyzer/index.js +1 -1
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +157 -81
- package/dist/detection-pipeline/index.mjs +157 -81
- package/dist/{dist-UHbKZiSa.js → dist-BuFE5rOK.js} +724 -39
- package/dist/{dist-B-VVBzrL.mjs → dist-DOu93i_g.mjs} +659 -40
- package/dist/{event-loop-stall-monitor-DfKdC7G4.mjs → event-loop-stall-monitor-CShgQE6l.mjs} +40 -2
- package/dist/{event-loop-stall-monitor-C7_L6vHO.js → event-loop-stall-monitor-L08yG6bB.js} +40 -2
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +342 -69
- package/dist/pipeline-runner/index.mjs +342 -69
- package/dist/recorder/index.js +189 -6
- package/dist/recorder/index.mjs +189 -6
- package/dist/session-decode/decode-worker-child.js +197 -24
- package/dist/session-decode/decode-worker-child.mjs +197 -24
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D7iQbuKY.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DiXoZnou.mjs} +2 -2
- 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
- package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-54IklEep.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
- package/dist/stream-broker/{hostInit-CKYS9yZO.mjs → hostInit-khRXMnjl.mjs} +2 -2
- package/dist/stream-broker/index.js +3952 -1103
- package/dist/stream-broker/index.mjs +3953 -1104
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/worker-protocol-BOXlUhWO.mjs +244 -0
- package/dist/worker-protocol-VURr0nUh.js +279 -0
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-DxZ75vB8.js → MaskShapeCanvas-DI4BY7W2-DHZuYYjH.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BBY2Ztz5.js → MotionZonesSettings-NcxxQN8r-jFDOzPCD.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BC-3QmHa.js → PrivacyMaskSettings-APgPLF7p-BPBXgIX8.js} +1 -1
- package/embed-dist/assets/{index-on1wyOnd.js → index-4CtQAybX.js} +12 -12
- package/embed-dist/assets/index-DC63og2c.css +2 -0
- package/embed-dist/index.html +2 -2
- package/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CCC9fqHH.mjs +0 -26
- package/dist/worker-protocol-D7RzZIla.mjs +0 -77
- package/dist/worker-protocol-DextwlTX.js +0 -94
- package/embed-dist/assets/index-CGdwTcwE.css +0 -2
package/dist/recorder/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const require_chunk = require("../chunk-emK7D4bc.js");
|
|
2
|
-
const require_dist = require("../dist-
|
|
2
|
+
const require_dist = require("../dist-BuFE5rOK.js");
|
|
3
3
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
4
4
|
const require_model_download_service_D8B_4ktF = require("../model-download-service-D8B-4ktF-7-YxE9Wx.js");
|
|
5
5
|
let node_crypto = require("node:crypto");
|
|
@@ -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
|
|
2476
|
-
|
|
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 {
|
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
3
|
import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../model-download-service-D8B-4ktF-BB7oZL3y.mjs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -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
|
|
2474
|
-
|
|
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 {
|