@camstack/addon-pipeline 1.2.11 → 1.2.13

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 (31) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +2 -2
  4. package/dist/detection-pipeline/index.mjs +2 -2
  5. package/dist/{dist-DNjy_sr4.js → dist-BUd2xSjP.js} +2243 -2075
  6. package/dist/{dist-owmpaeIY.mjs → dist-DuI0Ypuu.mjs} +2243 -2075
  7. package/dist/{fmp4-keyframes-BzTPoPVn.js → fmp4-keyframes-5s8T9Mng.js} +42 -7
  8. package/dist/{fmp4-keyframes-qOgJBjMO.mjs → fmp4-keyframes-CV0X1mGy.mjs} +37 -12
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +2 -2
  12. package/dist/pipeline-runner/index.mjs +2 -2
  13. package/dist/recorder/index.js +183 -49
  14. package/dist/recorder/index.mjs +183 -49
  15. package/dist/{step-definitions-C2bNlww3.js → step-definitions-Ck4_6GKK.js} +1 -1
  16. package/dist/{step-definitions-CkvctwuS.mjs → step-definitions-DqNWVeYB.mjs} +1 -1
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CkiyBstP.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-5EJfCCjk.mjs} +2 -2
  19. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DHQsa56B.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BDeEvhV5.mjs} +1 -1
  20. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js--zEZtV2N.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BGtpZkgz.mjs} +1 -1
  21. package/dist/stream-broker/{hostInit-BC07ueda.mjs → hostInit-QaJMh7Ec.mjs} +2 -2
  22. package/dist/stream-broker/index.js +1186 -337
  23. package/dist/stream-broker/index.mjs +1187 -338
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CftzR7Pj.js → MaskShapeCanvas-DI4BY7W2-Di-nvxaR.js} +1 -1
  26. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DPgVIdEn.js → MotionZonesSettings-NcxxQN8r-C6Nj76Lm.js} +1 -1
  27. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-D0HqiXHW.js → PrivacyMaskSettings-APgPLF7p-DwVoJunF.js} +1 -1
  28. package/embed-dist/assets/index-CtDVz0vI.js +116 -0
  29. package/embed-dist/index.html +1 -1
  30. package/package.json +1 -1
  31. package/embed-dist/assets/index-DRtbV_en.js +0 -116
@@ -6631,12 +6631,16 @@ function readTrack(file, segment) {
6631
6631
  });
6632
6632
  }
6633
6633
  /**
6634
- * Demux a recorded LOW `.m4s` segment and return its sync samples plus the
6635
- * WebCodecs decoder config, or null if the segment carries no video / no
6636
- * decodable keyframe. `segmentStartMs` anchors keyframe timestamps to absolute
6637
- * epoch ms; the first sample's cts is the zero reference.
6634
+ * Demux a recorded `.m4s` fragment and return ALL its samples (sync + delta) in
6635
+ * decode order plus the WebCodecs decoder config, or null if the segment
6636
+ * carries no video / no decodable sample. `segmentStartMs` anchors each sample's
6637
+ * `ts` to absolute epoch ms; the first sample's cts is the zero reference.
6638
+ *
6639
+ * No decode, no ffmpeg — byte copies only. This is the general form used by the
6640
+ * recorded-playback demux; {@link extractKeyframes} filters its output to sync
6641
+ * samples for the scrub-pack service.
6638
6642
  */
6639
- async function extractKeyframes(segment, segmentStartMs) {
6643
+ async function extractSamples(segment, segmentStartMs) {
6640
6644
  const file = import_mp4box_all.createFile();
6641
6645
  const ready = await readTrack(file, segment);
6642
6646
  if (!ready) return null;
@@ -6647,10 +6651,10 @@ async function extractKeyframes(segment, segmentStartMs) {
6647
6651
  file.onSamples = (_id, _user, samples) => {
6648
6652
  for (const s of samples) {
6649
6653
  if (firstCts === null) firstCts = s.cts;
6650
- if (!s.is_sync) continue;
6651
6654
  const ts = segmentStartMs + Math.round((s.cts - firstCts) / s.timescale * 1e3);
6652
6655
  collected.push({
6653
6656
  ts,
6657
+ isSync: s.is_sync,
6654
6658
  data: new Uint8Array(s.data)
6655
6659
  });
6656
6660
  }
@@ -6664,7 +6668,26 @@ async function extractKeyframes(segment, segmentStartMs) {
6664
6668
  return collected.length > 0 ? {
6665
6669
  codec,
6666
6670
  description,
6667
- keyframes: collected
6671
+ samples: collected
6672
+ } : null;
6673
+ }
6674
+ /**
6675
+ * Demux a recorded LOW `.m4s` segment and return its sync samples plus the
6676
+ * WebCodecs decoder config, or null if the segment carries no video / no
6677
+ * decodable keyframe. `segmentStartMs` anchors keyframe timestamps to absolute
6678
+ * epoch ms; the first sample's cts is the zero reference.
6679
+ */
6680
+ async function extractKeyframes(segment, segmentStartMs) {
6681
+ const extracted = await extractSamples(segment, segmentStartMs);
6682
+ if (!extracted) return null;
6683
+ const keyframes = extracted.samples.filter((s) => s.isSync).map((s) => ({
6684
+ ts: s.ts,
6685
+ data: s.data
6686
+ }));
6687
+ return keyframes.length > 0 ? {
6688
+ codec: extracted.codec,
6689
+ description: extracted.description,
6690
+ keyframes
6668
6691
  } : null;
6669
6692
  }
6670
6693
  //#endregion
@@ -6674,3 +6697,15 @@ Object.defineProperty(exports, "extractKeyframes", {
6674
6697
  return extractKeyframes;
6675
6698
  }
6676
6699
  });
6700
+ Object.defineProperty(exports, "extractSamples", {
6701
+ enumerable: true,
6702
+ get: function() {
6703
+ return extractSamples;
6704
+ }
6705
+ });
6706
+ Object.defineProperty(exports, "require_mp4box_all", {
6707
+ enumerable: true,
6708
+ get: function() {
6709
+ return require_mp4box_all;
6710
+ }
6711
+ });
@@ -1,7 +1,6 @@
1
1
  import { c as __commonJSMin, u as __toESM } from "./model-download-service-Cp9f4dk6-WO-foD7W.mjs";
2
- //#endregion
3
- //#region src/recorder/scrub/fmp4-keyframes.ts
4
- var import_mp4box_all = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports) => {
2
+ //#region ../../node_modules/mp4box/dist/mp4box.all.js
3
+ var require_mp4box_all = /* @__PURE__ */ __commonJSMin(((exports) => {
5
4
  var Log = (function() {
6
5
  var start = /* @__PURE__ */ new Date();
7
6
  var LOG_LEVEL_ERROR = 4;
@@ -6566,7 +6565,10 @@ var import_mp4box_all = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin((
6566
6565
  return file;
6567
6566
  };
6568
6567
  if (typeof exports !== "undefined") exports.createFile = MP4Box.createFile;
6569
- })))());
6568
+ }));
6569
+ //#endregion
6570
+ //#region src/recorder/scrub/fmp4-keyframes.ts
6571
+ var import_mp4box_all = /* @__PURE__ */ __toESM(require_mp4box_all());
6570
6572
  /**
6571
6573
  * Normalize an RFC 6381 codec string for WebCodecs HEVC. Apple/WebKit
6572
6574
  * WebCodecs only accepts the `hvc1.*` fourcc — a `hev1.*` string (what mp4box
@@ -6629,12 +6631,16 @@ function readTrack(file, segment) {
6629
6631
  });
6630
6632
  }
6631
6633
  /**
6632
- * Demux a recorded LOW `.m4s` segment and return its sync samples plus the
6633
- * WebCodecs decoder config, or null if the segment carries no video / no
6634
- * decodable keyframe. `segmentStartMs` anchors keyframe timestamps to absolute
6635
- * epoch ms; the first sample's cts is the zero reference.
6634
+ * Demux a recorded `.m4s` fragment and return ALL its samples (sync + delta) in
6635
+ * decode order plus the WebCodecs decoder config, or null if the segment
6636
+ * carries no video / no decodable sample. `segmentStartMs` anchors each sample's
6637
+ * `ts` to absolute epoch ms; the first sample's cts is the zero reference.
6638
+ *
6639
+ * No decode, no ffmpeg — byte copies only. This is the general form used by the
6640
+ * recorded-playback demux; {@link extractKeyframes} filters its output to sync
6641
+ * samples for the scrub-pack service.
6636
6642
  */
6637
- async function extractKeyframes(segment, segmentStartMs) {
6643
+ async function extractSamples(segment, segmentStartMs) {
6638
6644
  const file = import_mp4box_all.createFile();
6639
6645
  const ready = await readTrack(file, segment);
6640
6646
  if (!ready) return null;
@@ -6645,10 +6651,10 @@ async function extractKeyframes(segment, segmentStartMs) {
6645
6651
  file.onSamples = (_id, _user, samples) => {
6646
6652
  for (const s of samples) {
6647
6653
  if (firstCts === null) firstCts = s.cts;
6648
- if (!s.is_sync) continue;
6649
6654
  const ts = segmentStartMs + Math.round((s.cts - firstCts) / s.timescale * 1e3);
6650
6655
  collected.push({
6651
6656
  ts,
6657
+ isSync: s.is_sync,
6652
6658
  data: new Uint8Array(s.data)
6653
6659
  });
6654
6660
  }
@@ -6662,8 +6668,27 @@ async function extractKeyframes(segment, segmentStartMs) {
6662
6668
  return collected.length > 0 ? {
6663
6669
  codec,
6664
6670
  description,
6665
- keyframes: collected
6671
+ samples: collected
6672
+ } : null;
6673
+ }
6674
+ /**
6675
+ * Demux a recorded LOW `.m4s` segment and return its sync samples plus the
6676
+ * WebCodecs decoder config, or null if the segment carries no video / no
6677
+ * decodable keyframe. `segmentStartMs` anchors keyframe timestamps to absolute
6678
+ * epoch ms; the first sample's cts is the zero reference.
6679
+ */
6680
+ async function extractKeyframes(segment, segmentStartMs) {
6681
+ const extracted = await extractSamples(segment, segmentStartMs);
6682
+ if (!extracted) return null;
6683
+ const keyframes = extracted.samples.filter((s) => s.isSync).map((s) => ({
6684
+ ts: s.ts,
6685
+ data: s.data
6686
+ }));
6687
+ return keyframes.length > 0 ? {
6688
+ codec: extracted.codec,
6689
+ description: extracted.description,
6690
+ keyframes
6666
6691
  } : null;
6667
6692
  }
6668
6693
  //#endregion
6669
- export { extractKeyframes as t };
6694
+ export { extractSamples as n, require_mp4box_all as r, extractKeyframes as t };
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DNjy_sr4.js");
5
+ const require_dist = require("../dist-BUd2xSjP.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  //#region src/motion-wasm/wasm-motion-detector.ts
@@ -1,4 +1,4 @@
1
- import { A as motionDetectionCapability, G as DeviceType, H as BaseAddon, T as evaluateZoneRules, q as hydrateSchema } from "../dist-owmpaeIY.mjs";
1
+ import { A as motionDetectionCapability, G as DeviceType, H as BaseAddon, T as evaluateZoneRules, q as hydrateSchema } from "../dist-DuI0Ypuu.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  //#region src/motion-wasm/wasm-motion-detector.ts
@@ -3,11 +3,11 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-DNjy_sr4.js");
6
+ const require_dist = require("../dist-BUd2xSjP.js");
7
7
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
8
8
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
9
9
  const require_worker_protocol = require("../worker-protocol-DextwlTX.js");
10
- const require_step_definitions = require("../step-definitions-C2bNlww3.js");
10
+ const require_step_definitions = require("../step-definitions-Ck4_6GKK.js");
11
11
  let node_child_process = require("node:child_process");
12
12
  let node_url = require("node:url");
13
13
  let sharp = require("sharp");
@@ -1,8 +1,8 @@
1
- import { H as BaseAddon, K as createEvent, N as pipelineRunnerCapability, S as defineCustomActions, V as errMsg, at as lazy, b as customAction, ct as object, ft as EventCategory, j as nodePin, nt as array, rt as boolean, s as DEVICE_BACKEND_TO_FORMAT, st as number, tt as _enum, ut as string } from "../dist-owmpaeIY.mjs";
1
+ import { H as BaseAddon, K as createEvent, N as pipelineRunnerCapability, S as defineCustomActions, V as errMsg, at as lazy, b as customAction, ct as object, ft as EventCategory, j as nodePin, nt as array, rt as boolean, s as DEVICE_BACKEND_TO_FORMAT, st as number, tt as _enum, ut as string } from "../dist-DuI0Ypuu.mjs";
2
2
  import { n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-BeHi78PZ.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
4
  import { t as isWorkerReply } from "../worker-protocol-D7RzZIla.mjs";
5
- import { a as getStepDefinition } from "../step-definitions-CkvctwuS.mjs";
5
+ import { a as getStepDefinition } from "../step-definitions-DqNWVeYB.mjs";
6
6
  import { fork } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import sharp from "sharp";
@@ -1,8 +1,8 @@
1
1
  const require_chunk = require("../chunk-emK7D4bc.js");
2
- const require_dist = require("../dist-DNjy_sr4.js");
2
+ const require_dist = require("../dist-BUd2xSjP.js");
3
3
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
4
4
  const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-fsdDExML.js");
5
- const require_fmp4_keyframes = require("../fmp4-keyframes-BzTPoPVn.js");
5
+ const require_fmp4_keyframes = require("../fmp4-keyframes-5s8T9Mng.js");
6
6
  let node_child_process = require("node:child_process");
7
7
  let node_fs = require("node:fs");
8
8
  let node_path = require("node:path");
@@ -953,15 +953,6 @@ function noPlayback(deviceId) {
953
953
  function playbackDirFor(dataDir, deviceId) {
954
954
  return node_path.default.join(dataDir, "playback", String(deviceId));
955
955
  }
956
- /**
957
- * Absolute path of ONE profile's variant playlist inside the playback dir.
958
- * `buildPlaybackManifest` writes `<profile>.m3u8` next to the master whenever
959
- * that profile has overlapping footage — the export engine renders this variant
960
- * (a single-profile source) rather than the multi-variant master.
961
- */
962
- function playbackVariantPlaylistPath(dataDir, deviceId, profile) {
963
- return node_path.default.join(playbackDirFor(dataDir, deviceId), `${profile}.m3u8`);
964
- }
965
956
  /** Prefix window the CODECS probe reads — the fragment's moov (with its
966
957
  * avcC/hvcC) leads the file and is ~1-2KB, so 64KB is a generous bound. */
967
958
  var PROBE_PREFIX_BYTES = 65536;
@@ -999,6 +990,43 @@ async function probeVariantCodec(readPrefix, cache, deviceId, profile, firstPres
999
990
  }
1000
991
  }
1001
992
  /**
993
+ * Collect the segments of one (device, profile) overlapping `[fromMs, toMs)`
994
+ * that are actually present on disk, as playlist entries in `uriMode`'s shape.
995
+ *
996
+ * Absolute resolution is the SAME idiom as the scrub path's
997
+ * `resolveSegmentAbsPath`: `<root for the row's locationId>/<relative path>`.
998
+ * A row whose location is unknown, or whose file has been evicted since the
999
+ * index row was written, is skipped so the playlist never references a
1000
+ * non-existent segment.
1001
+ */
1002
+ async function collectRangeSegments(deps, deviceId, profile, fromMs, toMs, uriMode) {
1003
+ const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
1004
+ const rows = deps.index.segments(deviceId, profile).filter((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs).toSorted((a, b) => a.startMs - b.startMs);
1005
+ const segments = [];
1006
+ let firstPresentAbs = null;
1007
+ for (const s of rows) {
1008
+ const rel = segmentRelPath(deviceId, profile, s.startMs, s.durMs, s.bytes);
1009
+ const root = rootById.get(s.locationId);
1010
+ if (root === void 0) continue;
1011
+ const abs = node_path.default.join(root, rel);
1012
+ try {
1013
+ await node_fs.promises.stat(abs);
1014
+ } catch {
1015
+ continue;
1016
+ }
1017
+ if (firstPresentAbs === null) firstPresentAbs = abs;
1018
+ segments.push({
1019
+ uri: uriMode === "absolute" ? abs : rel.split("/").slice(1).join("/"),
1020
+ startMs: s.startMs,
1021
+ durMs: s.durMs
1022
+ });
1023
+ }
1024
+ return {
1025
+ segments,
1026
+ firstPresentAbs
1027
+ };
1028
+ }
1029
+ /**
1002
1030
  * Build a master + per-profile variant HLS playlist on disk for the requested
1003
1031
  * window and return a RELATIVE playback URL through the hub's data-plane
1004
1032
  * reverse-proxy. Only segments actually present on disk are referenced (a
@@ -1011,32 +1039,11 @@ async function buildPlaybackManifest(deps, codecCache, deviceId, fromMs, toMs) {
1011
1039
  const base = deps.playbackBaseUrl();
1012
1040
  if (base === null) return noPlayback(deviceId);
1013
1041
  const readPrefix = deps.readFilePrefix ?? readPrefixFromDisk;
1014
- const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
1015
1042
  const playbackDir = playbackDirFor(deps.dataDir, deviceId);
1016
1043
  const profiles = [...new Set(deps.index.segments(deviceId).map((s) => s.profile))];
1017
1044
  const variants = [];
1018
1045
  for (const profile of profiles) {
1019
- const segs = deps.index.segments(deviceId, profile).filter((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs).toSorted((a, b) => a.startMs - b.startMs);
1020
- if (segs.length === 0) continue;
1021
- const variantSegments = [];
1022
- let firstPresentAbs = null;
1023
- for (const s of segs) {
1024
- const rel = segmentRelPath(deviceId, profile, s.startMs, s.durMs, s.bytes);
1025
- const root = rootById.get(s.locationId);
1026
- if (root === void 0) continue;
1027
- const abs = node_path.default.join(root, rel);
1028
- try {
1029
- await node_fs.promises.stat(abs);
1030
- } catch {
1031
- continue;
1032
- }
1033
- if (firstPresentAbs === null) firstPresentAbs = abs;
1034
- variantSegments.push({
1035
- uri: rel.split("/").slice(1).join("/"),
1036
- startMs: s.startMs,
1037
- durMs: s.durMs
1038
- });
1039
- }
1046
+ const { segments: variantSegments, firstPresentAbs } = await collectRangeSegments(deps, deviceId, profile, fromMs, toMs, "playback-relative");
1040
1047
  if (variantSegments.length === 0) continue;
1041
1048
  const codecs = await probeVariantCodec(readPrefix, codecCache, deviceId, profile, firstPresentAbs);
1042
1049
  await node_fs.promises.mkdir(playbackDir, { recursive: true });
@@ -1279,6 +1286,48 @@ function buildRecordingProvider(deps) {
1279
1286
  };
1280
1287
  }
1281
1288
  //#endregion
1289
+ //#region src/recorder/addon/export-source-playlist.ts
1290
+ /**
1291
+ * The ffmpeg INPUT playlist for one recording export.
1292
+ *
1293
+ * Exports must NOT reuse the playback variant playlist. That file lives under
1294
+ * `<dataDir>/playback` while its segments live under a recordings LOCATION root,
1295
+ * and the two trees are unified only by the HTTP data-plane's ordered multi-root
1296
+ * search. ffmpeg has no such fallback — it resolves every segment URI against
1297
+ * the playlist's own directory — so a playback playlist handed to `-i` ENOENTs
1298
+ * on every segment and dies with `AVERROR_INVALIDDATA` (exit 183).
1299
+ *
1300
+ * So each export gets its OWN playlist whose segment URIs are ABSOLUTE on-disk
1301
+ * paths, written at a PER-EXPORT path. Per-export is load-bearing twice over:
1302
+ * the playback playlist sits at a shared fixed path that `buildPlaybackManifest`
1303
+ * rewrites on every playback request, so an operator scrubbing the same camera
1304
+ * mid-render would otherwise swap ffmpeg's input out from under it.
1305
+ *
1306
+ * The file is transient — `ExportEngine` deletes it when the render finishes
1307
+ * (success, failure or cancel).
1308
+ */
1309
+ /**
1310
+ * Absolute path of ONE export's source playlist:
1311
+ * `<exportsDir>/<deviceId>/<exportId>.src.m3u8`. Unique per export id, so two
1312
+ * concurrent renders (and any concurrent playback request) never share a file.
1313
+ */
1314
+ function exportSourcePlaylistPath(exportsDir, deviceId, exportId) {
1315
+ return node_path.default.join(exportsDir, String(deviceId), `${exportId}.src.m3u8`);
1316
+ }
1317
+ /**
1318
+ * Write the export's source playlist and return its absolute path, or null when
1319
+ * no segment of the requested profile is present on disk for the range (the
1320
+ * engine then fails the job with 'no footage for range').
1321
+ */
1322
+ async function writeExportSourcePlaylist(input) {
1323
+ const { segments } = await collectRangeSegments(input.deps, input.deviceId, input.profile, input.fromMs, input.toMs, "absolute");
1324
+ if (segments.length === 0) return null;
1325
+ const playlistPath = exportSourcePlaylistPath(input.exportsDir, input.deviceId, input.exportId);
1326
+ await node_fs.promises.mkdir(node_path.default.dirname(playlistPath), { recursive: true });
1327
+ await node_fs.promises.writeFile(playlistPath, buildVariantPlaylist(segments), "utf8");
1328
+ return playlistPath;
1329
+ }
1330
+ //#endregion
1282
1331
  //#region src/recorder/addon/export-store.ts
1283
1332
  /**
1284
1333
  * Recording-export persistence for the recorder addon.
@@ -1478,16 +1527,22 @@ function buildExportArgs(input) {
1478
1527
  * Render engine for recording exports.
1479
1528
  *
1480
1529
  * A FIFO queue with ONE concurrent ffmpeg render (exports are heavy; serialising
1481
- * protects the recording node). Each job: resolve the range's local HLS master
1482
- * playlist → build argv (`buildExportArgs`) → spawn ffmpeg → parse `time=` from
1483
- * stderr for progress → on exit 0 stat the file and mark `ready`; on non-zero
1484
- * mark `failed` and remove the partial. `cancel` SIGKILLs an in-flight render.
1530
+ * protects the recording node). Each job: write the range's per-export source
1531
+ * playlist (`resolvePlaylist`) → build argv (`buildExportArgs`) → spawn ffmpeg →
1532
+ * parse `time=` from stderr for progress → on exit 0 stat the file and mark
1533
+ * `ready`; on non-zero mark `failed` and remove the partial; drop the source
1534
+ * playlist whatever the outcome. `cancel` SIGKILLs an in-flight render.
1485
1535
  * State transitions patch the DurableState store and emit telemetry events.
1486
1536
  *
1487
1537
  * Unlike `SegmentWriter` there is NO auto-restart — a render runs once; boot
1488
1538
  * re-queue of interrupted jobs is the provider's concern.
1489
1539
  */
1490
- var STDERR_TAIL_LINES$1 = 12;
1540
+ /** Stderr lines retained for the failure LOG. ffmpeg prints one line per failed
1541
+ * segment before the decisive "Error opening input" summary, so a short tail can
1542
+ * push the actual cause out of the window (it very nearly did — the line that
1543
+ * identified the ENOENT'd segment paths was the 12th). Bounded, because a long
1544
+ * render writes one progress line per second. */
1545
+ var STDERR_TAIL_LINES$1 = 40;
1491
1546
  var TIME_RE = /time=(\d+):(\d+):(\d+(?:\.\d+)?)/;
1492
1547
  /** Expected OUTPUT duration (seconds) for progress scaling. */
1493
1548
  function expectedOutSeconds(rec) {
@@ -1556,6 +1611,22 @@ var ExportEngine = class {
1556
1611
  await this.fail(id, "no footage for range");
1557
1612
  return;
1558
1613
  }
1614
+ try {
1615
+ await this.spawnRender(id, rec, playlist);
1616
+ } finally {
1617
+ if (this.deps.cleanupPlaylist) try {
1618
+ await this.deps.cleanupPlaylist(playlist);
1619
+ } catch (err) {
1620
+ this.deps.logger.warn("export: source playlist cleanup failed", { meta: {
1621
+ exportId: id,
1622
+ playlist,
1623
+ error: err instanceof Error ? err.message : String(err)
1624
+ } });
1625
+ }
1626
+ }
1627
+ }
1628
+ /** Spawn ffmpeg for one render and resolve when it has exited + finalized. */
1629
+ async spawnRender(id, rec, playlist) {
1559
1630
  const outPath = this.deps.outPath(rec);
1560
1631
  const args = buildExportArgs({
1561
1632
  inputPlaylist: playlist,
@@ -1563,6 +1634,17 @@ var ExportEngine = class {
1563
1634
  options: rec.options
1564
1635
  });
1565
1636
  const totalSec = expectedOutSeconds(rec);
1637
+ this.deps.logger.info("export render starting", { meta: {
1638
+ exportId: id,
1639
+ deviceId: rec.deviceId,
1640
+ profile: rec.profile,
1641
+ playlist,
1642
+ outPath
1643
+ } });
1644
+ this.deps.logger.debug("export ffmpeg argv", { meta: {
1645
+ exportId: id,
1646
+ args
1647
+ } });
1566
1648
  await new Promise((resolve) => {
1567
1649
  const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
1568
1650
  this.activeProc = proc;
@@ -1601,8 +1683,14 @@ var ExportEngine = class {
1601
1683
  return;
1602
1684
  }
1603
1685
  if (code !== 0) {
1686
+ this.deps.logger.error("export ffmpeg failed", { meta: {
1687
+ exportId: id,
1688
+ code,
1689
+ outPath,
1690
+ stderr: stderrTail.join("\n")
1691
+ } });
1604
1692
  await this.deps.removeFile(outPath);
1605
- await this.fail(id, `ffmpeg exited ${code}: ${stderrTail.join(" | ")}`);
1693
+ await this.fail(id, `ffmpeg exited ${code}: ${stderrTail.slice(-12).join(" | ")}`);
1606
1694
  return;
1607
1695
  }
1608
1696
  let bytes = 0;
@@ -1618,12 +1706,21 @@ var ExportEngine = class {
1618
1706
  fileBytes: bytes,
1619
1707
  error: null
1620
1708
  });
1709
+ this.deps.logger.info("export render ready", { meta: {
1710
+ exportId: id,
1711
+ outPath,
1712
+ bytes
1713
+ } });
1621
1714
  this.deps.emit(require_dist.EventCategory.RecordingExportCompleted, {
1622
1715
  exportId: id,
1623
1716
  fileBytes: bytes
1624
1717
  });
1625
1718
  }
1626
1719
  async fail(id, error) {
1720
+ this.deps.logger.warn("export render failed", { meta: {
1721
+ exportId: id,
1722
+ error
1723
+ } });
1627
1724
  await patchExport(this.deps.state, id, {
1628
1725
  state: "failed",
1629
1726
  progressPct: null,
@@ -2213,7 +2310,7 @@ function startSegmentWatcher(deps) {
2213
2310
  }
2214
2311
  //#endregion
2215
2312
  //#region src/recorder/addon/ffmpeg-args.ts
2216
- /** ffmpeg argv: pull RTSP (TCP), copy video / transcode audio to AAC, write
2313
+ /** ffmpeg argv: pull RTSP (TCP), copy video / transcode audio to Opus, write
2217
2314
  * fragmented-mp4 segments named by epoch seconds FLAT under `outDir`, plus a
2218
2315
  * rolling `live.m3u8` playlist. The recorder segment-watcher tails the
2219
2316
  * playlist and hands each finalized flat file to `SegmentStore.onFinalized`,
@@ -2232,7 +2329,9 @@ function buildPassthroughArgs(a) {
2232
2329
  "copy",
2233
2330
  ...a.videoCodec === "H265" ? ["-tag:v", "hvc1"] : [],
2234
2331
  "-c:a",
2235
- "aac",
2332
+ "libopus",
2333
+ "-b:a",
2334
+ "48k",
2236
2335
  "-f",
2237
2336
  "segment",
2238
2337
  "-segment_time",
@@ -2920,8 +3019,12 @@ var ScrubPackService = class {
2920
3019
  this.inflight.set(key, run);
2921
3020
  return run;
2922
3021
  }
3022
+ resolveCacheDir() {
3023
+ const c = this.deps.cacheDir;
3024
+ return typeof c === "function" ? c() : c;
3025
+ }
2923
3026
  cachePath(deviceId, windowStartMs) {
2924
- return node_path.default.join(this.deps.cacheDir, String(deviceId), utcDay(windowStartMs), `${windowStartMs}.pack`);
3027
+ return node_path.default.join(this.resolveCacheDir(), String(deviceId), utcDay(windowStartMs), `${windowStartMs}.pack`);
2925
3028
  }
2926
3029
  async getOrBuildInner(deviceId, windowStartMs) {
2927
3030
  const now = (this.deps.now ?? Date.now)();
@@ -2998,14 +3101,15 @@ var ScrubPackService = class {
2998
3101
  /** Remove per-device day directories older than the retention horizon. */
2999
3102
  async prune(retentionDays) {
3000
3103
  const cutoff = (this.deps.now ?? Date.now)() - retentionDays * 864e5;
3104
+ const cacheDir = this.resolveCacheDir();
3001
3105
  let deviceDirs = [];
3002
3106
  try {
3003
- deviceDirs = await node_fs.promises.readdir(this.deps.cacheDir);
3107
+ deviceDirs = await node_fs.promises.readdir(cacheDir);
3004
3108
  } catch {
3005
3109
  return;
3006
3110
  }
3007
3111
  for (const dev of deviceDirs) {
3008
- const devPath = node_path.default.join(this.deps.cacheDir, dev);
3112
+ const devPath = node_path.default.join(cacheDir, dev);
3009
3113
  let days = [];
3010
3114
  try {
3011
3115
  days = await node_fs.promises.readdir(devPath);
@@ -3440,8 +3544,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3440
3544
  }
3441
3545
  });
3442
3546
  const exportsState = this.exportsStore();
3443
- const exportCodecCache = /* @__PURE__ */ new Map();
3444
- const exportOutPath = (rec) => node_path.default.join(this.ctx.dataDir, EXPORTS_PREFIX, String(rec.deviceId), `${rec.id}.mp4`);
3547
+ const exportsDir = node_path.default.join(this.ctx.dataDir, EXPORTS_PREFIX);
3548
+ const exportOutPath = (rec) => node_path.default.join(exportsDir, String(rec.deviceId), `${rec.id}.mp4`);
3445
3549
  const removeExportFile = async (p) => {
3446
3550
  await node_fs.promises.rm(p, { force: true });
3447
3551
  };
@@ -3450,9 +3554,18 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3450
3554
  logger: this.ctx.logger.child("export"),
3451
3555
  state: exportsState,
3452
3556
  resolvePlaylist: async (rec) => {
3453
- if ((await buildPlaybackManifest(recordingDeps, exportCodecCache, rec.deviceId, rec.fromMs, rec.toMs)).localMasterPath === null) return null;
3454
- return playbackVariantPlaylistPath(this.ctx.dataDir, rec.deviceId, rec.profile);
3557
+ await node_fs.promises.mkdir(node_path.default.dirname(exportOutPath(rec)), { recursive: true });
3558
+ return writeExportSourcePlaylist({
3559
+ deps: recordingDeps,
3560
+ exportsDir,
3561
+ exportId: rec.id,
3562
+ deviceId: rec.deviceId,
3563
+ profile: rec.profile,
3564
+ fromMs: rec.fromMs,
3565
+ toMs: rec.toMs
3566
+ });
3455
3567
  },
3568
+ cleanupPlaylist: removeExportFile,
3456
3569
  outPath: exportOutPath,
3457
3570
  statBytes: async (p) => (await node_fs.promises.stat(p)).size,
3458
3571
  removeFile: removeExportFile,
@@ -3491,7 +3604,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3491
3604
  const abs = this.resolveSegmentAbsPath(row);
3492
3605
  return new Uint8Array(await node_fs.promises.readFile(abs));
3493
3606
  },
3494
- cacheDir: node_path.default.join(this.ctx.dataDir, "scrub-packs"),
3607
+ cacheDir: () => this.resolveScrubCacheRoot(),
3495
3608
  logger: this.ctx.logger.child("ScrubPacks")
3496
3609
  });
3497
3610
  this.scrubPackService = scrubService;
@@ -3625,6 +3738,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3625
3738
  this.ctx.logger.warn("recorder: no recordings storage locations resolved — index not hydrated");
3626
3739
  return;
3627
3740
  }
3741
+ node_fs.promises.rm(node_path.default.join(this.ctx.dataDir, "scrub-packs"), {
3742
+ recursive: true,
3743
+ force: true
3744
+ }).catch(() => {});
3628
3745
  try {
3629
3746
  await this.controller?.start();
3630
3747
  } catch (err) {
@@ -3914,6 +4031,23 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3914
4031
  return node_path.default.join(loc.root, row.path);
3915
4032
  }
3916
4033
  /**
4034
+ * Cache root for the scrub packs — a hidden `.scrub-packs` dir UNDER a
4035
+ * recordings storage root (the `recordingsLow` location the packs derive from,
4036
+ * else the first resolved location). Packs are a regenerable keyframe cache of
4037
+ * the LOW segments; keeping them beside the recordings (a top-level mount, NOT
4038
+ * backed up) — instead of in `addons-data` — keeps the backup perimeter pure
4039
+ * operator state so it can be tarred whole with no exclusion list. See ADR-0027.
4040
+ *
4041
+ * Resolved lazily: `resolvedLocations` is only populated in `onHubReachable`,
4042
+ * after the ScrubPackService is constructed in `onInitialize`. getOrBuild/prune
4043
+ * run only at request time / on a delayed timer, by when locations are ready.
4044
+ */
4045
+ resolveScrubCacheRoot() {
4046
+ const loc = this.resolvedLocations.find((l) => l.type === "recordingsLow") ?? this.resolvedLocations[0];
4047
+ if (loc === void 0) return node_path.default.join(this.ctx.dataDir, "scrub-packs");
4048
+ return node_path.default.join(loc.root, ".scrub-packs");
4049
+ }
4050
+ /**
3917
4051
  * Scrub-pack request handler: `GET /<deviceId>/<windowStartSec>` →
3918
4052
  * `application/octet-stream` pack | 204 (no footage) | 400 (bad path / window
3919
4053
  * not aligned to `SCRUB_WINDOW_MS`) | 500. Immutable (fully past) windows carry