@camstack/addon-pipeline 1.2.63 → 1.2.65

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 (84) 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-DTvDedJV.mjs → dist-B9_C-QmC.mjs} +323 -38
  6. package/dist/{dist-BNXiD8pH.js → dist-DKrqeIfS.js} +323 -38
  7. package/dist/{event-loop-stall-monitor-RLis0vRd.mjs → event-loop-stall-monitor-BucTbe9v.mjs} +1 -1
  8. package/dist/{event-loop-stall-monitor-DmscRH8r.js → event-loop-stall-monitor-CnJ5JGA2.js} +1 -1
  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 +3 -3
  12. package/dist/pipeline-runner/index.mjs +3 -3
  13. package/dist/recorder/index.js +211 -41
  14. package/dist/recorder/index.mjs +211 -41
  15. package/dist/session-decode/decode-worker-child.js +1 -1
  16. package/dist/session-decode/decode-worker-child.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-CFoXGKXX.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BCyIjQkw.mjs} +3 -3
  19. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Cp3o2Ve6.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C2tu4c14.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-Bw7zJGAQ.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CJAUKSrT.mjs} +1 -1
  21. package/dist/stream-broker/{hostInit-DqexQ1ED.mjs → hostInit-DqLdTCfo.mjs} +3 -3
  22. package/dist/stream-broker/index.js +119 -22
  23. package/dist/stream-broker/index.mjs +119 -22
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/{worker-protocol-D8ZsVii1.js → worker-protocol-CDkbdSuM.js} +1 -1
  26. package/dist/{worker-protocol-C3pPkCGe.mjs → worker-protocol-CQHY2NeB.mjs} +1 -1
  27. package/package.json +1 -1
  28. package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
  29. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  30. package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
  31. package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
  32. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
  33. package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
  34. package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
  35. package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
  36. package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
  37. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  38. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  39. package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  41. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  42. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  43. package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
  44. package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
  45. package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
  46. package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
  47. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  48. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  49. package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
  50. package/python/postprocessors/__pycache__/rfdetr.cpython-312.pyc +0 -0
  51. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  52. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  53. package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
  54. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  55. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  56. package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
  57. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  58. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  59. package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
  60. package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
  61. package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
  62. package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
  63. package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
  64. package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
  65. package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
  66. package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
  67. package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
  68. package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
  69. package/python/postprocessors/__pycache__/test_rfdetr.cpython-312-pytest-9.1.1.pyc +0 -0
  70. package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
  71. package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
  72. package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
  73. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
  74. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
  75. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  76. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  77. package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
  78. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  79. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  80. package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
  81. package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
  82. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  83. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
  84. package/python/postprocessors/__pycache__/yolo_seg.cpython-314.pyc +0 -0
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("../chunk-emK7D4bc.js");
2
- const require_dist = require("../dist-BNXiD8pH.js");
2
+ const require_dist = require("../dist-DKrqeIfS.js");
3
3
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
4
4
  const require_addon_utils = require("../addon-utils-CQs-AjTJ.js");
5
5
  let node_crypto = require("node:crypto");
@@ -2773,12 +2773,28 @@ function buildRecordingProvider(deps) {
2773
2773
  if (!deps.gifRender) throw new Error("gif rendering unavailable on this node");
2774
2774
  return deps.gifRender(input);
2775
2775
  },
2776
- relocateFootage: async (input) => {
2776
+ pauseForStorageMigration: async ({ leaseId }) => {
2777
+ if (!deps.pauseForStorageMigration) throw new Error("storage migration pause unavailable");
2778
+ await deps.pauseForStorageMigration(leaseId);
2779
+ return { paused: true };
2780
+ },
2781
+ resumeForStorageMigration: async ({ leaseId }) => {
2782
+ if (!deps.resumeForStorageMigration) throw new Error("storage migration resume unavailable");
2783
+ await deps.resumeForStorageMigration(leaseId);
2784
+ return { resumed: true };
2785
+ },
2786
+ refreshStorageLocationsForMigration: async ({ leaseId }) => {
2787
+ if (!deps.refreshStorageLocationsForMigration) throw new Error("storage migration location refresh unavailable");
2788
+ await deps.refreshStorageLocationsForMigration(leaseId);
2789
+ return { refreshed: true };
2790
+ },
2791
+ startStorageMigrationMove: async (input) => {
2777
2792
  if (!deps.relocate) throw new Error("relocation unavailable on this node");
2793
+ deps.assertStorageMigrationLease?.(input.leaseId);
2778
2794
  return { jobId: deps.relocate.start(input) };
2779
2795
  },
2780
- getRelocateStatus: async () => deps.relocate?.list() ?? [],
2781
- cancelRelocate: async ({ jobId }) => ({ cancelled: deps.relocate?.cancel(jobId) ?? false }),
2796
+ getStorageMigrationMoveStatus: async ({ jobId }) => deps.relocate?.list().find((job) => job.jobId === jobId) ?? null,
2797
+ cancelStorageMigrationMove: async ({ jobId }) => ({ cancelled: deps.relocate?.cancel(jobId) ?? false }),
2782
2798
  getDeviceSettingsContribution: async ({ deviceId }) => {
2783
2799
  if (deps.isCameraDevice && !await deps.isCameraDevice(deviceId)) return null;
2784
2800
  return require_dist.hydrateSchema(buildRecordingDeviceSchema(), {});
@@ -3390,7 +3406,31 @@ function resolvePlacement(locations, profile) {
3390
3406
  const writable = locations.filter((l) => !l.readOnly);
3391
3407
  if (writable.length === 0) throw new Error("recorder controller: every recordings location is read-only — nowhere to write");
3392
3408
  const preferType = profile === "low" ? "recordingsLow" : "recordings";
3393
- return writable.find((l) => l.type === preferType) ?? writable[0];
3409
+ const preferred = writable.filter((l) => l.type === preferType);
3410
+ return preferred.find((l) => l.isDefault) ?? preferred[0] ?? writable.find((l) => l.isDefault) ?? writable[0];
3411
+ }
3412
+ /**
3413
+ * Attribute a discovered segment on an aliased physical root by its profile,
3414
+ * not by whichever logical location happened to be hydrated last. Stock
3415
+ * `recordings` and `recordingsLow` deliberately share a root: high/mid belong
3416
+ * to `recordings`, while low belongs to `recordingsLow`.
3417
+ */
3418
+ function locationForHydratedProfile(aliases, profile) {
3419
+ const preferredType = profile === "low" ? "recordingsLow" : "recordings";
3420
+ const preferred = aliases.filter((location) => location.type === preferredType).toSorted((a, b) => a.id.localeCompare(b.id))[0];
3421
+ const fallback = aliases.toSorted((a, b) => a.id.localeCompare(b.id))[0];
3422
+ if (preferred) return preferred;
3423
+ if (fallback) return fallback;
3424
+ throw new Error("recorder: no location aliases to attribute hydrated profile");
3425
+ }
3426
+ function aliasesByRoot(locations) {
3427
+ const grouped = /* @__PURE__ */ new Map();
3428
+ for (const location of locations) {
3429
+ const aliases = grouped.get(location.root);
3430
+ if (aliases) aliases.push(location);
3431
+ else grouped.set(location.root, [location]);
3432
+ }
3433
+ return grouped;
3394
3434
  }
3395
3435
  /**
3396
3436
  * Resolve every recordings storage location (`recordings` + `recordingsLow`)
@@ -3407,7 +3447,8 @@ async function resolveRecordingsLocations(api, logger) {
3407
3447
  location: l.id,
3408
3448
  relativePath: ""
3409
3449
  }),
3410
- readOnly: l.config["readOnly"] === true
3450
+ readOnly: l.config["readOnly"] === true,
3451
+ isDefault: l.isDefault
3411
3452
  })));
3412
3453
  } catch (err) {
3413
3454
  logger.warn("recorder: listLocations failed — no recordings locations resolved", { meta: { error: require_dist.errMsg(err) } });
@@ -3423,8 +3464,8 @@ async function resolveRecordingsLocations(api, logger) {
3423
3464
  * (best-effort), so one bad volume never blocks the rest of the hydrate.
3424
3465
  */
3425
3466
  async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger) {
3426
- for (const loc of locations) {
3427
- const deviceDir = node_path.default.join(loc.root, String(deviceId));
3467
+ for (const [root, aliases] of aliasesByRoot(locations)) {
3468
+ const deviceDir = node_path.default.join(root, String(deviceId));
3428
3469
  let entries;
3429
3470
  try {
3430
3471
  entries = await node_fs.promises.readdir(deviceDir, { recursive: true });
@@ -3432,14 +3473,21 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
3432
3473
  if (err.code !== "ENOENT") logger.warn("recorder: hydrateDevice walk failed for location", {
3433
3474
  tags: { deviceId },
3434
3475
  meta: {
3435
- locationId: loc.id,
3476
+ locationIds: aliases.map((location) => location.id),
3436
3477
  error: require_dist.errMsg(err)
3437
3478
  }
3438
3479
  });
3439
3480
  continue;
3440
3481
  }
3441
- const relPaths = entries.filter((e) => e.endsWith(".m4s")).map((e) => `${deviceId}/${e.split(node_path.default.sep).join("/")}`);
3442
- index.hydrateDevice(deviceId, loc.id, relPaths);
3482
+ const pathsByLocation = /* @__PURE__ */ new Map();
3483
+ for (const entry of entries.filter((entry) => entry.endsWith(".m4s"))) {
3484
+ const relative = `${deviceId}/${entry.split(node_path.default.sep).join("/")}`;
3485
+ const location = locationForHydratedProfile(aliases, relative.split("/")[1] ?? "");
3486
+ const paths = pathsByLocation.get(location.id);
3487
+ if (paths) paths.push(relative);
3488
+ else pathsByLocation.set(location.id, [relative]);
3489
+ }
3490
+ for (const [locationId, relPaths] of pathsByLocation) index.hydrateDevice(deviceId, locationId, relPaths);
3443
3491
  }
3444
3492
  }
3445
3493
  /** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
@@ -3468,8 +3516,8 @@ var HOUR_MS = 36e5;
3468
3516
  */
3469
3517
  async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger) {
3470
3518
  if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
3471
- for (const loc of locations) {
3472
- const deviceDir = node_path.default.join(loc.root, String(deviceId));
3519
+ for (const [root, aliases] of aliasesByRoot(locations)) {
3520
+ const deviceDir = node_path.default.join(root, String(deviceId));
3473
3521
  let profiles;
3474
3522
  try {
3475
3523
  profiles = (await node_fs.promises.readdir(deviceDir)).filter((e) => e === "high" || e === "mid" || e === "low");
@@ -3477,7 +3525,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
3477
3525
  if (err.code !== "ENOENT") logger.warn("recorder: hydrateWindow profile listing failed", {
3478
3526
  tags: { deviceId },
3479
3527
  meta: {
3480
- locationId: loc.id,
3528
+ locationIds: aliases.map((location) => location.id),
3481
3529
  error: require_dist.errMsg(err)
3482
3530
  }
3483
3531
  });
@@ -3488,11 +3536,11 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
3488
3536
  const rel = hourDirRelPath(deviceId, profile, hour);
3489
3537
  let files;
3490
3538
  try {
3491
- files = await node_fs.promises.readdir(node_path.default.join(loc.root, rel));
3539
+ files = await node_fs.promises.readdir(node_path.default.join(root, rel));
3492
3540
  } catch {
3493
3541
  continue;
3494
3542
  }
3495
- index.hydrateHour(deviceId, loc.id, hour, files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`));
3543
+ index.hydrateHour(deviceId, locationForHydratedProfile(aliases, profile).id, hour, files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`));
3496
3544
  }
3497
3545
  index.markHydrated(deviceId, fromMs, toMs);
3498
3546
  }
@@ -3775,7 +3823,7 @@ async function handleSegmentEntry(outDir, entry, durMs, onFinalized, logger) {
3775
3823
  logger.debug("segment flat file not on disk yet — will retry", { meta: { path: flatAbsPath } });
3776
3824
  return "pending";
3777
3825
  }
3778
- onFinalized(startMs, durMs, flatAbsPath);
3826
+ await onFinalized(startMs, durMs, flatAbsPath);
3779
3827
  return "retained";
3780
3828
  }
3781
3829
  /**
@@ -3788,11 +3836,11 @@ async function handleSegmentEntry(outDir, entry, durMs, onFinalized, logger) {
3788
3836
  * inflated by the initial PTS offset). Once `#EXT-X-ENDLIST` appears the tail
3789
3837
  * is flushed too — by then it is never the unreliable first segment.
3790
3838
  */
3791
- function planFinalizations(body, processed) {
3839
+ function planFinalizations(body, processed, forceFinal = false) {
3792
3840
  const entries = parseLivePlaylist(body);
3793
3841
  const endlist = body.includes("#EXT-X-ENDLIST");
3794
3842
  const hw = entries.length < processed ? 0 : processed;
3795
- const finalizable = endlist ? entries.length : Math.max(0, entries.length - 1);
3843
+ const finalizable = endlist || forceFinal ? entries.length : Math.max(0, entries.length - 1);
3796
3844
  const toHandle = [];
3797
3845
  for (let i = hw; i < finalizable; i++) {
3798
3846
  const entry = entries[i];
@@ -3820,9 +3868,10 @@ function startSegmentWatcher(deps) {
3820
3868
  let processed = 0;
3821
3869
  let stopped = false;
3822
3870
  let ticking = false;
3871
+ let currentTick = null;
3823
3872
  let pendingIndex = -1;
3824
3873
  let pendingTicks = 0;
3825
- const tick = async () => {
3874
+ const tick = async (forceFinal = false) => {
3826
3875
  if (stopped || ticking) return;
3827
3876
  ticking = true;
3828
3877
  try {
@@ -3832,7 +3881,7 @@ function startSegmentWatcher(deps) {
3832
3881
  } catch {
3833
3882
  return;
3834
3883
  }
3835
- const plan = planFinalizations(body, processed);
3884
+ const plan = planFinalizations(body, processed, forceFinal);
3836
3885
  let index = plan.from;
3837
3886
  for (const { entry, durMs } of plan.toHandle) {
3838
3887
  if (await handleSegmentEntry(deps.outDir, entry, durMs, deps.onFinalized, deps.logger) === "pending") {
@@ -3862,14 +3911,31 @@ function startSegmentWatcher(deps) {
3862
3911
  ticking = false;
3863
3912
  }
3864
3913
  };
3914
+ const startTick = (forceFinal = false) => {
3915
+ if (currentTick !== null) return currentTick;
3916
+ const running = tick(forceFinal);
3917
+ currentTick = running;
3918
+ running.then(() => {
3919
+ if (currentTick === running) currentTick = null;
3920
+ }, () => {
3921
+ if (currentTick === running) currentTick = null;
3922
+ });
3923
+ return running;
3924
+ };
3865
3925
  const timer = setInterval(() => {
3866
- tick();
3926
+ startTick();
3867
3927
  }, deps.intervalMs);
3868
3928
  timer.unref?.();
3869
- return { stop() {
3870
- stopped = true;
3871
- clearInterval(timer);
3872
- } };
3929
+ return {
3930
+ stop() {
3931
+ stopped = true;
3932
+ clearInterval(timer);
3933
+ },
3934
+ flush: async () => {
3935
+ await currentTick;
3936
+ await startTick(true);
3937
+ }
3938
+ };
3873
3939
  }
3874
3940
  //#endregion
3875
3941
  //#region src/recorder/addon/ffmpeg-args.ts
@@ -3937,6 +4003,8 @@ var SegmentWriter = class {
3937
4003
  restarts = 0;
3938
4004
  restartTimer = null;
3939
4005
  startedAt = 0;
4006
+ exitWait = null;
4007
+ resolveExit = null;
3940
4008
  constructor(cfg, deps) {
3941
4009
  this.cfg = cfg;
3942
4010
  this.deps = deps;
@@ -3946,6 +4014,9 @@ var SegmentWriter = class {
3946
4014
  const args = buildPassthroughArgs(this.cfg);
3947
4015
  const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
3948
4016
  this.proc = proc;
4017
+ this.exitWait = new Promise((resolve) => {
4018
+ this.resolveExit = resolve;
4019
+ });
3949
4020
  this.startedAt = Date.now();
3950
4021
  const stderrTail = [];
3951
4022
  proc.stderr?.on("data", (chunk) => {
@@ -3958,6 +4029,8 @@ var SegmentWriter = class {
3958
4029
  }
3959
4030
  });
3960
4031
  proc.on("exit", (code) => {
4032
+ this.resolveExit?.();
4033
+ this.resolveExit = null;
3961
4034
  this.proc = null;
3962
4035
  if (this.stopped) return;
3963
4036
  const ranMs = Date.now() - this.startedAt;
@@ -3987,12 +4060,22 @@ var SegmentWriter = class {
3987
4060
  });
3988
4061
  }
3989
4062
  stop() {
4063
+ this.stopAndWait();
4064
+ }
4065
+ /**
4066
+ * Stop ffmpeg and wait for its exit when the process exposes real child
4067
+ * liveness fields. Test fakes often omit them, in which case stop remains
4068
+ * deliberately synchronous. The recorder keeps its watcher alive until this
4069
+ * promise settles, so a final ENDLIST segment is indexed before migration.
4070
+ */
4071
+ async stopAndWait() {
3990
4072
  this.stopped = true;
3991
4073
  if (this.restartTimer) {
3992
4074
  clearTimeout(this.restartTimer);
3993
4075
  this.restartTimer = null;
3994
4076
  }
3995
4077
  const proc = this.proc;
4078
+ const exitWait = this.exitWait;
3996
4079
  this.proc = null;
3997
4080
  if (!proc) return;
3998
4081
  try {
@@ -4003,6 +4086,8 @@ var SegmentWriter = class {
4003
4086
  proc.kill("SIGKILL");
4004
4087
  } catch {}
4005
4088
  }, KILL_GRACE_MS).unref?.();
4089
+ if (proc.exitCode === void 0 && proc.signalCode === void 0) return;
4090
+ await exitWait;
4006
4091
  }
4007
4092
  };
4008
4093
  //#endregion
@@ -4119,6 +4204,9 @@ var RecordingController = class {
4119
4204
  * a `finally`. Mirrors the decoder-ffmpeg session-creation coalescing guard.
4120
4205
  */
4121
4206
  attaching = /* @__PURE__ */ new Set();
4207
+ /** Every attach admitted before a maintenance pause. The pause waits these
4208
+ * promises before it snapshots/drains active writers. */
4209
+ attachingTasks = /* @__PURE__ */ new Map();
4122
4210
  /** Latest qualifying trigger timestamps per device — drives `events`-band gating. */
4123
4211
  triggers = /* @__PURE__ */ new Map();
4124
4212
  /**
@@ -4138,6 +4226,9 @@ var RecordingController = class {
4138
4226
  restore = null;
4139
4227
  tickTimer = null;
4140
4228
  stopped = false;
4229
+ /** A reversible maintenance lease. Unlike `stopped`, it never changes
4230
+ * persisted recording intent and `resume()` restarts normal convergence. */
4231
+ paused = false;
4141
4232
  constructor(deps) {
4142
4233
  this.deps = deps;
4143
4234
  }
@@ -4202,7 +4293,7 @@ var RecordingController = class {
4202
4293
  * has already been written by the time this runs.
4203
4294
  */
4204
4295
  async onConfigChanged(deviceId) {
4205
- if (this.stopped) return;
4296
+ if (this.stopped || this.paused) return;
4206
4297
  await this.evaluateDevice(deviceId);
4207
4298
  }
4208
4299
  /**
@@ -4308,8 +4399,9 @@ var RecordingController = class {
4308
4399
  * call throws → `ReadinessRestore.attempt` defers → retried on next ready).
4309
4400
  */
4310
4401
  async evaluateDevice(deviceId) {
4311
- if (this.stopped) return;
4402
+ if (this.stopped || this.paused) return;
4312
4403
  const config = await this.deps.loadConfig(deviceId);
4404
+ if (this.stopped || this.paused) return;
4313
4405
  if (!shouldRecordAt(config, this.triggersFor(deviceId), new Date(this.now()))) {
4314
4406
  this.reportUnrecordable(deviceId, config);
4315
4407
  await this.detachDevice(deviceId);
@@ -4320,9 +4412,12 @@ var RecordingController = class {
4320
4412
  if (this.active.has(deviceId)) return;
4321
4413
  if (this.attaching.has(deviceId)) return;
4322
4414
  this.attaching.add(deviceId);
4415
+ const attaching = this.performAttach(deviceId, config);
4416
+ this.attachingTasks.set(deviceId, attaching);
4323
4417
  try {
4324
- await this.performAttach(deviceId, config);
4418
+ await attaching;
4325
4419
  } finally {
4420
+ this.attachingTasks.delete(deviceId);
4326
4421
  this.attaching.delete(deviceId);
4327
4422
  }
4328
4423
  }
@@ -4380,6 +4475,10 @@ var RecordingController = class {
4380
4475
  for (const r of recordings) await this.teardownProfile(deviceId, r);
4381
4476
  throw err;
4382
4477
  }
4478
+ if (this.paused) {
4479
+ await Promise.all(recordings.map((recording) => this.teardownProfile(deviceId, recording)));
4480
+ return;
4481
+ }
4383
4482
  this.active.set(deviceId, recordings);
4384
4483
  this.deps.logger.info("recorder pinned broker source(s) for recording", {
4385
4484
  tags: { deviceId },
@@ -4442,9 +4541,9 @@ var RecordingController = class {
4442
4541
  watcher: startSegmentWatcher({
4443
4542
  outDir,
4444
4543
  intervalMs: this.deps.watchIntervalMs > 0 ? this.deps.watchIntervalMs : DEFAULT_WATCH_INTERVAL_MS,
4445
- onFinalized: (startMs, durMs, flatAbsPath) => {
4544
+ onFinalized: async (startMs, durMs, flatAbsPath) => {
4446
4545
  this.lastSegmentAt.set(idleKey(deviceId, profile), this.now());
4447
- this.handleFinalizedSegment(deviceId, profile, placement, startMs, durMs, flatAbsPath);
4546
+ await this.handleFinalizedSegment(deviceId, profile, placement, startMs, durMs, flatAbsPath);
4448
4547
  },
4449
4548
  logger: deviceLog
4450
4549
  }),
@@ -4499,10 +4598,13 @@ var RecordingController = class {
4499
4598
  /** Stop one profile's writer + watcher and release its broker lease. */
4500
4599
  async teardownProfile(deviceId, r) {
4501
4600
  try {
4502
- r.watcher.stop();
4601
+ await r.writer.stopAndWait();
4503
4602
  } catch {}
4504
4603
  try {
4505
- r.writer.stop();
4604
+ await r.watcher.flush?.();
4605
+ } catch {}
4606
+ try {
4607
+ r.watcher.stop();
4506
4608
  } catch {}
4507
4609
  try {
4508
4610
  await withDeadline(this.deps.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey: r.pipelineKey }, require_dist.nodePin(this.deps.ownerNodeId)), RELEASE_RPC_TIMEOUT_MS, "releaseStreamWithCodec");
@@ -4529,6 +4631,25 @@ var RecordingController = class {
4529
4631
  meta: { pipelineKeys: recordings.map((r) => r.pipelineKey) }
4530
4632
  });
4531
4633
  }
4634
+ /** Acquire a reversible maintenance pause without mutating camera settings. */
4635
+ async pause() {
4636
+ if (this.stopped || this.paused) return;
4637
+ this.paused = true;
4638
+ while (this.attachingTasks.size > 0) await Promise.allSettled([...this.attachingTasks.values()]);
4639
+ await Promise.all(Array.from(this.active.keys()).map((deviceId) => this.detachDevice(deviceId)));
4640
+ }
4641
+ /** Release the maintenance pause and converge persisted recording intent. */
4642
+ async resume() {
4643
+ if (this.stopped || !this.paused) return;
4644
+ this.paused = false;
4645
+ let ids = [];
4646
+ try {
4647
+ ids = await this.deps.enabledDeviceIds();
4648
+ } catch (err) {
4649
+ this.deps.logger.warn("recorder: resume could not enumerate configured devices", { meta: { error: require_dist.errMsg(err) } });
4650
+ }
4651
+ for (const deviceId of ids) await this.evaluateDevice(deviceId);
4652
+ }
4532
4653
  /** Tear EVERYTHING down: timer, restore subscription, every writer/watcher/lease. */
4533
4654
  async stop() {
4534
4655
  this.stopped = true;
@@ -4751,14 +4872,13 @@ var RelocateEngine = class {
4751
4872
  if (!from) throw new Error(`unknown source location: ${input.fromLocationId}`);
4752
4873
  if (!to) throw new Error(`unknown target location: ${input.toLocationId}`);
4753
4874
  if (from.id === to.id) throw new Error("source and target locations are identical");
4754
- if (from.root === to.root) throw new Error("source and target locations share one root — nothing to move");
4755
4875
  if (to.readOnly) throw new Error(`target location is read-only: ${to.id}`);
4756
4876
  const job = {
4757
4877
  jobId: this.deps.newId(),
4758
4878
  state: "running",
4759
4879
  fromLocationId: from.id,
4760
4880
  toLocationId: to.id,
4761
- deviceId: input.deviceId ?? null,
4881
+ deviceId: null,
4762
4882
  entities: input.entities ? [...input.entities] : ["segments"],
4763
4883
  filesMoved: 0,
4764
4884
  bytesMoved: 0,
@@ -4769,23 +4889,33 @@ var RelocateEngine = class {
4769
4889
  cancelRequested: false
4770
4890
  };
4771
4891
  this.jobs.set(job.jobId, job);
4772
- this.run(job, from, to, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS);
4892
+ this.run(job, from, to, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS, input.footageClass);
4773
4893
  return job.jobId;
4774
4894
  }
4775
- async run(job, from, to, throttleMbps) {
4895
+ async run(job, from, to, throttleMbps, footageClass) {
4776
4896
  const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
4777
4897
  const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
4778
4898
  try {
4779
- const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => job.deviceId === null || r.deviceId === job.deviceId).sort((a, b) => a.startMs - b.startMs);
4899
+ const samePhysicalRoot = await rootsPhysicallySame(from.root, to.root);
4900
+ const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => profileBelongsToClass(r.profile, footageClass)).sort((a, b) => a.startMs - b.startMs);
4780
4901
  job.filesTotal = segRows.length;
4781
4902
  for (const row of segRows) {
4782
4903
  if (job.cancelRequested) break;
4783
- await this.moveSegment(row, from, to);
4904
+ await this.moveSegment(row, from, to, samePhysicalRoot);
4784
4905
  job.filesMoved++;
4785
4906
  job.bytesMoved += row.bytes;
4786
4907
  if (job.filesMoved % PROGRESS_EVERY_FILES === 0) this.deps.onProgress?.(snapshot(job));
4787
4908
  await sleep(row.bytes / bytesPerMs);
4788
4909
  }
4910
+ async function rootsPhysicallySame(from, to) {
4911
+ if (physicalRootKey(from) === physicalRootKey(to)) return true;
4912
+ try {
4913
+ const [a, b] = await Promise.all([node_fs.promises.stat(from), node_fs.promises.stat(to)]);
4914
+ return a.dev === b.dev && a.ino === b.ino;
4915
+ } catch {
4916
+ return false;
4917
+ }
4918
+ }
4789
4919
  job.state = job.cancelRequested ? "cancelled" : "done";
4790
4920
  } catch (err) {
4791
4921
  job.state = "failed";
@@ -4801,8 +4931,8 @@ var RelocateEngine = class {
4801
4931
  }
4802
4932
  /** Move one segment file and re-point its index row to the target location
4803
4933
  * (same location-relative path — the layout is identical on every root). */
4804
- async moveSegment(row, from, to) {
4805
- await moveFile(node_path.default.join(from.root, row.path), node_path.default.join(to.root, row.path), row.bytes);
4934
+ async moveSegment(row, from, to, samePhysicalRoot) {
4935
+ if (!samePhysicalRoot) await moveFile(node_path.default.join(from.root, row.path), node_path.default.join(to.root, row.path), row.bytes);
4806
4936
  this.deps.index.removeSegments([row.path]);
4807
4937
  this.deps.index.addSegment({
4808
4938
  ...row,
@@ -4810,6 +4940,12 @@ var RelocateEngine = class {
4810
4940
  });
4811
4941
  }
4812
4942
  };
4943
+ /** Logical migration classes are profile-derived, never location-derived:
4944
+ * high+mid are `recordings`; low is `recordingsLow`. */
4945
+ var profileBelongsToClass = (profile, footageClass) => {
4946
+ if (footageClass === void 0) return true;
4947
+ return footageClass === "recordingsLow" ? profile === "low" : profile === "high" || profile === "mid";
4948
+ };
4813
4949
  //#endregion
4814
4950
  //#region src/recorder/addon/footage-render.ts
4815
4951
  /**
@@ -5284,6 +5420,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
5284
5420
  /** Periodic footage-retention sweep (owns its timer + single-flight guard).
5285
5421
  * Constructed + started in `onHubReachable`, stopped on shutdown. */
5286
5422
  retentionSweeper = null;
5423
+ /** Reversible migration pause lease; never reflected in RecordingConfig. */
5424
+ storageMigrationLeaseId = null;
5287
5425
  constructor() {
5288
5426
  super({ ...DEFAULT_CONFIG });
5289
5427
  }
@@ -5441,6 +5579,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
5441
5579
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
5442
5580
  hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
5443
5581
  onConfigChanged: (deviceId) => this.controller?.onConfigChanged(deviceId) ?? Promise.resolve(),
5582
+ pauseForStorageMigration: (leaseId) => this.pauseForStorageMigration(leaseId),
5583
+ resumeForStorageMigration: (leaseId) => this.resumeForStorageMigration(leaseId),
5584
+ refreshStorageLocationsForMigration: (leaseId) => this.refreshStorageLocationsForMigration(leaseId),
5585
+ assertStorageMigrationLease: (leaseId) => this.assertStorageMigrationLease(leaseId),
5444
5586
  dataDir: this.ctx.dataDir,
5445
5587
  playbackBaseUrl: () => this.playbackBaseUrl,
5446
5588
  isCameraDevice: async (deviceId) => {
@@ -5829,6 +5971,34 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
5829
5971
  this.resolvedLocations = await resolveRecordingsLocations(this.ctx.api, this.ctx.logger);
5830
5972
  return this.resolvedLocations;
5831
5973
  }
5974
+ async pauseForStorageMigration(leaseId) {
5975
+ if (this.storageMigrationLeaseId !== null && this.storageMigrationLeaseId !== leaseId) throw new Error("recorder is already paused by another storage migration");
5976
+ if (!this.controller) throw new Error("recorder controller is unavailable for storage migration");
5977
+ this.storageMigrationLeaseId = leaseId;
5978
+ try {
5979
+ await this.controller.pause();
5980
+ } catch (err) {
5981
+ this.storageMigrationLeaseId = null;
5982
+ throw err;
5983
+ }
5984
+ }
5985
+ async resumeForStorageMigration(leaseId) {
5986
+ if (this.storageMigrationLeaseId === null) return;
5987
+ if (this.storageMigrationLeaseId !== leaseId) throw new Error("recorder storage migration lease does not match");
5988
+ try {
5989
+ await this.controller?.resume();
5990
+ } finally {
5991
+ this.storageMigrationLeaseId = null;
5992
+ }
5993
+ }
5994
+ async refreshStorageLocationsForMigration(leaseId) {
5995
+ if (this.storageMigrationLeaseId !== leaseId) throw new Error("recorder storage migration lease does not match");
5996
+ await this.refreshLocations();
5997
+ this.calendar.invalidateAll();
5998
+ }
5999
+ assertStorageMigrationLease(leaseId) {
6000
+ if (this.storageMigrationLeaseId !== leaseId) throw new Error("recorder storage migration lease does not match");
6001
+ }
5832
6002
  /**
5833
6003
  * Resolve the cluster's camera-source owner. Graceful degrade: the
5834
6004
  * `pipeline-orchestrator` cap may not be mounted yet (pre-image-release),