@camstack/addon-pipeline 1.2.10 → 1.2.12

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 (28) 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-B89zyHyl.js} +1265 -1176
  6. package/dist/{dist-owmpaeIY.mjs → dist-DMiEnBkk.mjs} +1265 -1176
  7. package/dist/motion-wasm/index.js +1 -1
  8. package/dist/motion-wasm/index.mjs +1 -1
  9. package/dist/pipeline-runner/index.js +2 -2
  10. package/dist/pipeline-runner/index.mjs +2 -2
  11. package/dist/recorder/index.js +148 -42
  12. package/dist/recorder/index.mjs +148 -42
  13. package/dist/{step-definitions-C2bNlww3.js → step-definitions-DLgHGq8f.js} +1 -1
  14. package/dist/{step-definitions-CkvctwuS.mjs → step-definitions-NeFJ6mFi.mjs} +1 -1
  15. package/dist/stream-broker/_stub.js +1 -1
  16. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Co2DA61E.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CAvWbzaf.mjs} +2 -2
  17. 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-DbC3MMjB.mjs} +1 -1
  18. package/dist/stream-broker/{hostInit-COV9efWK.mjs → hostInit-Do62nL9c.mjs} +2 -2
  19. package/dist/stream-broker/index.js +1 -1
  20. package/dist/stream-broker/index.mjs +1 -1
  21. package/dist/stream-broker/remoteEntry.js +1 -1
  22. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CftzR7Pj.js → MaskShapeCanvas-DI4BY7W2-CNyCTsIC.js} +1 -1
  23. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DPgVIdEn.js → MotionZonesSettings-NcxxQN8r-DOdX9Yp1.js} +1 -1
  24. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-D0HqiXHW.js → PrivacyMaskSettings-APgPLF7p-9huXsgpT.js} +1 -1
  25. package/embed-dist/assets/index-BxOCtm8E.js +116 -0
  26. package/embed-dist/index.html +1 -1
  27. package/package.json +1 -1
  28. package/embed-dist/assets/index-DRtbV_en.js +0 -116
@@ -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-B89zyHyl.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-DMiEnBkk.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-B89zyHyl.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-DLgHGq8f.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-DMiEnBkk.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-NeFJ6mFi.mjs";
6
6
  import { fork } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import sharp from "sharp";
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("../chunk-emK7D4bc.js");
2
- const require_dist = require("../dist-DNjy_sr4.js");
2
+ const require_dist = require("../dist-B89zyHyl.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
5
  const require_fmp4_keyframes = require("../fmp4-keyframes-BzTPoPVn.js");
@@ -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,
@@ -3440,8 +3537,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3440
3537
  }
3441
3538
  });
3442
3539
  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`);
3540
+ const exportsDir = node_path.default.join(this.ctx.dataDir, EXPORTS_PREFIX);
3541
+ const exportOutPath = (rec) => node_path.default.join(exportsDir, String(rec.deviceId), `${rec.id}.mp4`);
3445
3542
  const removeExportFile = async (p) => {
3446
3543
  await node_fs.promises.rm(p, { force: true });
3447
3544
  };
@@ -3450,9 +3547,18 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3450
3547
  logger: this.ctx.logger.child("export"),
3451
3548
  state: exportsState,
3452
3549
  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);
3550
+ await node_fs.promises.mkdir(node_path.default.dirname(exportOutPath(rec)), { recursive: true });
3551
+ return writeExportSourcePlaylist({
3552
+ deps: recordingDeps,
3553
+ exportsDir,
3554
+ exportId: rec.id,
3555
+ deviceId: rec.deviceId,
3556
+ profile: rec.profile,
3557
+ fromMs: rec.fromMs,
3558
+ toMs: rec.toMs
3559
+ });
3455
3560
  },
3561
+ cleanupPlaylist: removeExportFile,
3456
3562
  outPath: exportOutPath,
3457
3563
  statBytes: async (p) => (await node_fs.promises.stat(p)).size,
3458
3564
  removeFile: removeExportFile,
@@ -1,5 +1,5 @@
1
1
  import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../model-download-service-Cp9f4dk6-WO-foD7W.mjs";
2
- import { $ as selectAssignedProfileSlots, F as recordingExportCapability, G as DeviceType, H as BaseAddon, L as storageEvictableCapability, P as recordingCapability, V as errMsg, c as EVENT_PAD_MS, f as OpsLogEntrySchema, ft as EventCategory, j as nodePin, k as migrateConfigToBands, lt as record, nt as array, p as RecordingConfigSchema, q as hydrateSchema, u as ExportRecordSchema, ut as string } from "../dist-owmpaeIY.mjs";
2
+ import { $ as selectAssignedProfileSlots, F as recordingExportCapability, G as DeviceType, H as BaseAddon, L as storageEvictableCapability, P as recordingCapability, V as errMsg, c as EVENT_PAD_MS, f as OpsLogEntrySchema, ft as EventCategory, j as nodePin, k as migrateConfigToBands, lt as record, nt as array, p as RecordingConfigSchema, q as hydrateSchema, u as ExportRecordSchema, ut as string } from "../dist-DMiEnBkk.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
4
  import { t as extractKeyframes } from "../fmp4-keyframes-qOgJBjMO.mjs";
5
5
  import { spawn } from "node:child_process";
@@ -951,15 +951,6 @@ function noPlayback(deviceId) {
951
951
  function playbackDirFor(dataDir, deviceId) {
952
952
  return path.join(dataDir, "playback", String(deviceId));
953
953
  }
954
- /**
955
- * Absolute path of ONE profile's variant playlist inside the playback dir.
956
- * `buildPlaybackManifest` writes `<profile>.m3u8` next to the master whenever
957
- * that profile has overlapping footage — the export engine renders this variant
958
- * (a single-profile source) rather than the multi-variant master.
959
- */
960
- function playbackVariantPlaylistPath(dataDir, deviceId, profile) {
961
- return path.join(playbackDirFor(dataDir, deviceId), `${profile}.m3u8`);
962
- }
963
954
  /** Prefix window the CODECS probe reads — the fragment's moov (with its
964
955
  * avcC/hvcC) leads the file and is ~1-2KB, so 64KB is a generous bound. */
965
956
  var PROBE_PREFIX_BYTES = 65536;
@@ -997,6 +988,43 @@ async function probeVariantCodec(readPrefix, cache, deviceId, profile, firstPres
997
988
  }
998
989
  }
999
990
  /**
991
+ * Collect the segments of one (device, profile) overlapping `[fromMs, toMs)`
992
+ * that are actually present on disk, as playlist entries in `uriMode`'s shape.
993
+ *
994
+ * Absolute resolution is the SAME idiom as the scrub path's
995
+ * `resolveSegmentAbsPath`: `<root for the row's locationId>/<relative path>`.
996
+ * A row whose location is unknown, or whose file has been evicted since the
997
+ * index row was written, is skipped so the playlist never references a
998
+ * non-existent segment.
999
+ */
1000
+ async function collectRangeSegments(deps, deviceId, profile, fromMs, toMs, uriMode) {
1001
+ const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
1002
+ const rows = deps.index.segments(deviceId, profile).filter((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs).toSorted((a, b) => a.startMs - b.startMs);
1003
+ const segments = [];
1004
+ let firstPresentAbs = null;
1005
+ for (const s of rows) {
1006
+ const rel = segmentRelPath(deviceId, profile, s.startMs, s.durMs, s.bytes);
1007
+ const root = rootById.get(s.locationId);
1008
+ if (root === void 0) continue;
1009
+ const abs = path.join(root, rel);
1010
+ try {
1011
+ await promises.stat(abs);
1012
+ } catch {
1013
+ continue;
1014
+ }
1015
+ if (firstPresentAbs === null) firstPresentAbs = abs;
1016
+ segments.push({
1017
+ uri: uriMode === "absolute" ? abs : rel.split("/").slice(1).join("/"),
1018
+ startMs: s.startMs,
1019
+ durMs: s.durMs
1020
+ });
1021
+ }
1022
+ return {
1023
+ segments,
1024
+ firstPresentAbs
1025
+ };
1026
+ }
1027
+ /**
1000
1028
  * Build a master + per-profile variant HLS playlist on disk for the requested
1001
1029
  * window and return a RELATIVE playback URL through the hub's data-plane
1002
1030
  * reverse-proxy. Only segments actually present on disk are referenced (a
@@ -1009,32 +1037,11 @@ async function buildPlaybackManifest(deps, codecCache, deviceId, fromMs, toMs) {
1009
1037
  const base = deps.playbackBaseUrl();
1010
1038
  if (base === null) return noPlayback(deviceId);
1011
1039
  const readPrefix = deps.readFilePrefix ?? readPrefixFromDisk;
1012
- const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
1013
1040
  const playbackDir = playbackDirFor(deps.dataDir, deviceId);
1014
1041
  const profiles = [...new Set(deps.index.segments(deviceId).map((s) => s.profile))];
1015
1042
  const variants = [];
1016
1043
  for (const profile of profiles) {
1017
- const segs = deps.index.segments(deviceId, profile).filter((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs).toSorted((a, b) => a.startMs - b.startMs);
1018
- if (segs.length === 0) continue;
1019
- const variantSegments = [];
1020
- let firstPresentAbs = null;
1021
- for (const s of segs) {
1022
- const rel = segmentRelPath(deviceId, profile, s.startMs, s.durMs, s.bytes);
1023
- const root = rootById.get(s.locationId);
1024
- if (root === void 0) continue;
1025
- const abs = path.join(root, rel);
1026
- try {
1027
- await promises.stat(abs);
1028
- } catch {
1029
- continue;
1030
- }
1031
- if (firstPresentAbs === null) firstPresentAbs = abs;
1032
- variantSegments.push({
1033
- uri: rel.split("/").slice(1).join("/"),
1034
- startMs: s.startMs,
1035
- durMs: s.durMs
1036
- });
1037
- }
1044
+ const { segments: variantSegments, firstPresentAbs } = await collectRangeSegments(deps, deviceId, profile, fromMs, toMs, "playback-relative");
1038
1045
  if (variantSegments.length === 0) continue;
1039
1046
  const codecs = await probeVariantCodec(readPrefix, codecCache, deviceId, profile, firstPresentAbs);
1040
1047
  await promises.mkdir(playbackDir, { recursive: true });
@@ -1277,6 +1284,48 @@ function buildRecordingProvider(deps) {
1277
1284
  };
1278
1285
  }
1279
1286
  //#endregion
1287
+ //#region src/recorder/addon/export-source-playlist.ts
1288
+ /**
1289
+ * The ffmpeg INPUT playlist for one recording export.
1290
+ *
1291
+ * Exports must NOT reuse the playback variant playlist. That file lives under
1292
+ * `<dataDir>/playback` while its segments live under a recordings LOCATION root,
1293
+ * and the two trees are unified only by the HTTP data-plane's ordered multi-root
1294
+ * search. ffmpeg has no such fallback — it resolves every segment URI against
1295
+ * the playlist's own directory — so a playback playlist handed to `-i` ENOENTs
1296
+ * on every segment and dies with `AVERROR_INVALIDDATA` (exit 183).
1297
+ *
1298
+ * So each export gets its OWN playlist whose segment URIs are ABSOLUTE on-disk
1299
+ * paths, written at a PER-EXPORT path. Per-export is load-bearing twice over:
1300
+ * the playback playlist sits at a shared fixed path that `buildPlaybackManifest`
1301
+ * rewrites on every playback request, so an operator scrubbing the same camera
1302
+ * mid-render would otherwise swap ffmpeg's input out from under it.
1303
+ *
1304
+ * The file is transient — `ExportEngine` deletes it when the render finishes
1305
+ * (success, failure or cancel).
1306
+ */
1307
+ /**
1308
+ * Absolute path of ONE export's source playlist:
1309
+ * `<exportsDir>/<deviceId>/<exportId>.src.m3u8`. Unique per export id, so two
1310
+ * concurrent renders (and any concurrent playback request) never share a file.
1311
+ */
1312
+ function exportSourcePlaylistPath(exportsDir, deviceId, exportId) {
1313
+ return path.join(exportsDir, String(deviceId), `${exportId}.src.m3u8`);
1314
+ }
1315
+ /**
1316
+ * Write the export's source playlist and return its absolute path, or null when
1317
+ * no segment of the requested profile is present on disk for the range (the
1318
+ * engine then fails the job with 'no footage for range').
1319
+ */
1320
+ async function writeExportSourcePlaylist(input) {
1321
+ const { segments } = await collectRangeSegments(input.deps, input.deviceId, input.profile, input.fromMs, input.toMs, "absolute");
1322
+ if (segments.length === 0) return null;
1323
+ const playlistPath = exportSourcePlaylistPath(input.exportsDir, input.deviceId, input.exportId);
1324
+ await promises.mkdir(path.dirname(playlistPath), { recursive: true });
1325
+ await promises.writeFile(playlistPath, buildVariantPlaylist(segments), "utf8");
1326
+ return playlistPath;
1327
+ }
1328
+ //#endregion
1280
1329
  //#region src/recorder/addon/export-store.ts
1281
1330
  /**
1282
1331
  * Recording-export persistence for the recorder addon.
@@ -1476,16 +1525,22 @@ function buildExportArgs(input) {
1476
1525
  * Render engine for recording exports.
1477
1526
  *
1478
1527
  * A FIFO queue with ONE concurrent ffmpeg render (exports are heavy; serialising
1479
- * protects the recording node). Each job: resolve the range's local HLS master
1480
- * playlist → build argv (`buildExportArgs`) → spawn ffmpeg → parse `time=` from
1481
- * stderr for progress → on exit 0 stat the file and mark `ready`; on non-zero
1482
- * mark `failed` and remove the partial. `cancel` SIGKILLs an in-flight render.
1528
+ * protects the recording node). Each job: write the range's per-export source
1529
+ * playlist (`resolvePlaylist`) → build argv (`buildExportArgs`) → spawn ffmpeg →
1530
+ * parse `time=` from stderr for progress → on exit 0 stat the file and mark
1531
+ * `ready`; on non-zero mark `failed` and remove the partial; drop the source
1532
+ * playlist whatever the outcome. `cancel` SIGKILLs an in-flight render.
1483
1533
  * State transitions patch the DurableState store and emit telemetry events.
1484
1534
  *
1485
1535
  * Unlike `SegmentWriter` there is NO auto-restart — a render runs once; boot
1486
1536
  * re-queue of interrupted jobs is the provider's concern.
1487
1537
  */
1488
- var STDERR_TAIL_LINES$1 = 12;
1538
+ /** Stderr lines retained for the failure LOG. ffmpeg prints one line per failed
1539
+ * segment before the decisive "Error opening input" summary, so a short tail can
1540
+ * push the actual cause out of the window (it very nearly did — the line that
1541
+ * identified the ENOENT'd segment paths was the 12th). Bounded, because a long
1542
+ * render writes one progress line per second. */
1543
+ var STDERR_TAIL_LINES$1 = 40;
1489
1544
  var TIME_RE = /time=(\d+):(\d+):(\d+(?:\.\d+)?)/;
1490
1545
  /** Expected OUTPUT duration (seconds) for progress scaling. */
1491
1546
  function expectedOutSeconds(rec) {
@@ -1554,6 +1609,22 @@ var ExportEngine = class {
1554
1609
  await this.fail(id, "no footage for range");
1555
1610
  return;
1556
1611
  }
1612
+ try {
1613
+ await this.spawnRender(id, rec, playlist);
1614
+ } finally {
1615
+ if (this.deps.cleanupPlaylist) try {
1616
+ await this.deps.cleanupPlaylist(playlist);
1617
+ } catch (err) {
1618
+ this.deps.logger.warn("export: source playlist cleanup failed", { meta: {
1619
+ exportId: id,
1620
+ playlist,
1621
+ error: err instanceof Error ? err.message : String(err)
1622
+ } });
1623
+ }
1624
+ }
1625
+ }
1626
+ /** Spawn ffmpeg for one render and resolve when it has exited + finalized. */
1627
+ async spawnRender(id, rec, playlist) {
1557
1628
  const outPath = this.deps.outPath(rec);
1558
1629
  const args = buildExportArgs({
1559
1630
  inputPlaylist: playlist,
@@ -1561,6 +1632,17 @@ var ExportEngine = class {
1561
1632
  options: rec.options
1562
1633
  });
1563
1634
  const totalSec = expectedOutSeconds(rec);
1635
+ this.deps.logger.info("export render starting", { meta: {
1636
+ exportId: id,
1637
+ deviceId: rec.deviceId,
1638
+ profile: rec.profile,
1639
+ playlist,
1640
+ outPath
1641
+ } });
1642
+ this.deps.logger.debug("export ffmpeg argv", { meta: {
1643
+ exportId: id,
1644
+ args
1645
+ } });
1564
1646
  await new Promise((resolve) => {
1565
1647
  const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
1566
1648
  this.activeProc = proc;
@@ -1599,8 +1681,14 @@ var ExportEngine = class {
1599
1681
  return;
1600
1682
  }
1601
1683
  if (code !== 0) {
1684
+ this.deps.logger.error("export ffmpeg failed", { meta: {
1685
+ exportId: id,
1686
+ code,
1687
+ outPath,
1688
+ stderr: stderrTail.join("\n")
1689
+ } });
1602
1690
  await this.deps.removeFile(outPath);
1603
- await this.fail(id, `ffmpeg exited ${code}: ${stderrTail.join(" | ")}`);
1691
+ await this.fail(id, `ffmpeg exited ${code}: ${stderrTail.slice(-12).join(" | ")}`);
1604
1692
  return;
1605
1693
  }
1606
1694
  let bytes = 0;
@@ -1616,12 +1704,21 @@ var ExportEngine = class {
1616
1704
  fileBytes: bytes,
1617
1705
  error: null
1618
1706
  });
1707
+ this.deps.logger.info("export render ready", { meta: {
1708
+ exportId: id,
1709
+ outPath,
1710
+ bytes
1711
+ } });
1619
1712
  this.deps.emit(EventCategory.RecordingExportCompleted, {
1620
1713
  exportId: id,
1621
1714
  fileBytes: bytes
1622
1715
  });
1623
1716
  }
1624
1717
  async fail(id, error) {
1718
+ this.deps.logger.warn("export render failed", { meta: {
1719
+ exportId: id,
1720
+ error
1721
+ } });
1625
1722
  await patchExport(this.deps.state, id, {
1626
1723
  state: "failed",
1627
1724
  progressPct: null,
@@ -3438,8 +3535,8 @@ var RecorderV2Addon = class extends BaseAddon {
3438
3535
  }
3439
3536
  });
3440
3537
  const exportsState = this.exportsStore();
3441
- const exportCodecCache = /* @__PURE__ */ new Map();
3442
- const exportOutPath = (rec) => path.join(this.ctx.dataDir, EXPORTS_PREFIX, String(rec.deviceId), `${rec.id}.mp4`);
3538
+ const exportsDir = path.join(this.ctx.dataDir, EXPORTS_PREFIX);
3539
+ const exportOutPath = (rec) => path.join(exportsDir, String(rec.deviceId), `${rec.id}.mp4`);
3443
3540
  const removeExportFile = async (p) => {
3444
3541
  await promises.rm(p, { force: true });
3445
3542
  };
@@ -3448,9 +3545,18 @@ var RecorderV2Addon = class extends BaseAddon {
3448
3545
  logger: this.ctx.logger.child("export"),
3449
3546
  state: exportsState,
3450
3547
  resolvePlaylist: async (rec) => {
3451
- if ((await buildPlaybackManifest(recordingDeps, exportCodecCache, rec.deviceId, rec.fromMs, rec.toMs)).localMasterPath === null) return null;
3452
- return playbackVariantPlaylistPath(this.ctx.dataDir, rec.deviceId, rec.profile);
3548
+ await promises.mkdir(path.dirname(exportOutPath(rec)), { recursive: true });
3549
+ return writeExportSourcePlaylist({
3550
+ deps: recordingDeps,
3551
+ exportsDir,
3552
+ exportId: rec.id,
3553
+ deviceId: rec.deviceId,
3554
+ profile: rec.profile,
3555
+ fromMs: rec.fromMs,
3556
+ toMs: rec.toMs
3557
+ });
3453
3558
  },
3559
+ cleanupPlaylist: removeExportFile,
3454
3560
  outPath: exportOutPath,
3455
3561
  statBytes: async (p) => (await promises.stat(p)).size,
3456
3562
  removeFile: removeExportFile,
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-DNjy_sr4.js");
1
+ const require_dist = require("./dist-B89zyHyl.js");
2
2
  //#region src/detection-pipeline/registry/model-catalogs.ts
3
3
  var HF_REPO = "camstack/camstack-models";
4
4
  var HF_SCRYPTED = "scrypted/plugin-models";
@@ -1,4 +1,4 @@
1
- import { E as hfModelUrl, a as COCO_TO_MACRO, i as COCO_80_LABELS, r as AUDIO_MACRO_LABELS } from "./dist-owmpaeIY.mjs";
1
+ import { E as hfModelUrl, a as COCO_TO_MACRO, i as COCO_80_LABELS, r as AUDIO_MACRO_LABELS } from "./dist-DMiEnBkk.mjs";
2
2
  //#region src/detection-pipeline/registry/model-catalogs.ts
3
3
  var HF_REPO = "camstack/camstack-models";
4
4
  var HF_SCRYPTED = "scrypted/plugin-models";
@@ -2,7 +2,7 @@ import { a as e, c as t, d as n, i as r, l as i, n as a, o, r as s, s as c, t as
2
2
  import { a as d, i as f, n as p, o as m, r as h, t as g } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.js-C9j-2lBe.mjs";
3
3
  import { n as _, r as v, t as y } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-XO0-Pyu6.mjs";
4
4
  import { n as b, t as x } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-BO7TIbJV.mjs";
5
- import { t as S } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DHQsa56B.mjs";
5
+ import { t as S } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DbC3MMjB.mjs";
6
6
  //#region ../../node_modules/lucide-react/dist/esm/shared/src/utils.js
7
7
  var C = (e) => e.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(), w = (e) => e.replace(/^([A-Z])|[\s-_]+(\w)/g, (e, t, n) => n ? n.toUpperCase() : t.toLowerCase()), T = (e) => {
8
8
  let t = w(e);
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.11",
21
+ version: "1.2.13",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_stream_broker_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.2.9",
36
+ version: "1.2.10",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_stream_broker_widgets",