@camstack/addon-pipeline 1.2.69 → 1.2.70

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 +32 -30
  4. package/dist/detection-pipeline/index.mjs +32 -30
  5. package/dist/{dist-DGLqaonv.mjs → dist-BPaa4_z6.mjs} +508 -8
  6. package/dist/{dist-tJF2BzWi.js → dist-cR-pvD_z.js} +508 -8
  7. package/dist/{event-loop-stall-monitor-C6_VDupx.mjs → event-loop-stall-monitor-B79REtL2.mjs} +1 -1
  8. package/dist/{event-loop-stall-monitor-D9d2e49f.js → event-loop-stall-monitor-STRdnQWm.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 +8 -5
  12. package/dist/pipeline-runner/index.mjs +8 -5
  13. package/dist/recorder/index.js +370 -32
  14. package/dist/recorder/index.mjs +370 -32
  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-DCyQ7RKH.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B-HGBNad.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-D1CqbPrT.mjs +26 -0
  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-DU7u9EJt.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-NjvjJWz-.mjs} +1 -1
  21. package/dist/stream-broker/{hostInit-DxqtIjAe.mjs → hostInit-B12DHOv7.mjs} +3 -3
  22. package/dist/stream-broker/index.js +501 -32
  23. package/dist/stream-broker/index.mjs +501 -32
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/{worker-protocol-D6tXz9Or.js → worker-protocol-C7V1qIIA.js} +1 -1
  26. package/dist/{worker-protocol-DuftUFEI.mjs → worker-protocol-CBO8nwQj.mjs} +1 -1
  27. package/package.json +1 -1
  28. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ircnzt0s.mjs +0 -26
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-tJF2BzWi.js");
1
+ const require_dist = require("./dist-cR-pvD_z.js");
2
2
  let node_perf_hooks = require("node:perf_hooks");
3
3
  //#region src/detection-pipeline/registry/model-catalogs.ts
4
4
  var HF_REPO = "camstack/camstack-models";
@@ -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-tJF2BzWi.js");
5
+ const require_dist = require("../dist-cR-pvD_z.js");
6
6
  const require_lazy_sharp = require("../lazy-sharp-CcYuXtsa.js");
7
7
  let node_fs = require("node:fs");
8
8
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { H as motionDetectionCapability, R as evaluateZoneRules, ft as hydrateSchema, st as BaseAddon, ut as DeviceType } from "../dist-DGLqaonv.mjs";
1
+ import { H as motionDetectionCapability, R as evaluateZoneRules, ft as hydrateSchema, st as BaseAddon, ut as DeviceType } from "../dist-BPaa4_z6.mjs";
2
2
  import { t as getSharp } from "../lazy-sharp-B-mb8uWx.mjs";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
@@ -3,9 +3,9 @@ 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-tJF2BzWi.js");
7
- const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-D9d2e49f.js");
8
- const require_worker_protocol = require("../worker-protocol-D6tXz9Or.js");
6
+ const require_dist = require("../dist-cR-pvD_z.js");
7
+ const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-STRdnQWm.js");
8
+ const require_worker_protocol = require("../worker-protocol-C7V1qIIA.js");
9
9
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
10
10
  const require_lazy_sharp = require("../lazy-sharp-CcYuXtsa.js");
11
11
  const require_remote_restream = require("../remote-restream-BYbAsgUf.js");
@@ -3190,6 +3190,7 @@ var PipelineRunner = class {
3190
3190
  motionQueue,
3191
3191
  detectionQueue,
3192
3192
  inferenceTimes: [],
3193
+ shedFrames: 0,
3193
3194
  processedCount: 0,
3194
3195
  startTime: Date.now(),
3195
3196
  phase: initialPhase,
@@ -3482,7 +3483,7 @@ var PipelineRunner = class {
3482
3483
  actualFps,
3483
3484
  queueDepth: state.motionQueue.size + state.detectionQueue.size,
3484
3485
  avgInferenceTimeMs: avgInference,
3485
- droppedFrames: state.motionQueue.droppedFrames + state.detectionQueue.droppedFrames,
3486
+ droppedFrames: state.motionQueue.droppedFrames + state.detectionQueue.droppedFrames + state.shedFrames,
3486
3487
  phase: state.phase
3487
3488
  };
3488
3489
  }
@@ -3589,6 +3590,7 @@ var PipelineRunner = class {
3589
3590
  if (state.inferenceTimes.length > 100) state.inferenceTimes.shift();
3590
3591
  state.processedCount++;
3591
3592
  if (result) {
3593
+ if (result.shed === true) state.shedFrames++;
3592
3594
  await this.notifyCallbacks(deviceId, frame, result, streamType, handle);
3593
3595
  const emittedAt = Date.now();
3594
3596
  const capturedAt = frame.capturedAt;
@@ -6102,11 +6104,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
6102
6104
  let image;
6103
6105
  if (!this.executorCoLocated()) {
6104
6106
  if (frame.format === "rgb") try {
6105
- image = await (0, sharp.default)(frame.data, { raw: {
6107
+ const encoded = await (0, sharp.default)(frame.data, { raw: {
6106
6108
  width: frame.width,
6107
6109
  height: frame.height,
6108
6110
  channels: 3
6109
6111
  } }).jpeg({ quality: DETECTION_HOP_JPEG_QUALITY }).toBuffer();
6112
+ image = new Uint8Array(encoded);
6110
6113
  } catch (err) {
6111
6114
  log.debug("runInference: frame JPEG encode failed — falling back to raw pixels", { meta: { error: require_dist.errMsg(err) } });
6112
6115
  }
@@ -1,6 +1,6 @@
1
- import { At as string, Ct as boolean, Dt as number, Et as literal, K as pipelineRunnerCapability, M as deriveDetailCropRect, Mt as EventCategory, Ot as object, St as array, Tt as lazy, U as pickDetailCropConvention, W as pickNativeLeaseOverride, dt as createEvent, gt as nodePin, j as defineCustomActions, k as customAction, s as DEFAULT_DETAIL_CROP_CONVENTION, st as BaseAddon, tt as errMsg, u as DEVICE_BACKEND_TO_FORMAT, xt as _enum } from "../dist-DGLqaonv.mjs";
2
- import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-C6_VDupx.mjs";
3
- import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-DuftUFEI.mjs";
1
+ import { At as string, Ct as boolean, Dt as number, Et as literal, K as pipelineRunnerCapability, M as deriveDetailCropRect, Mt as EventCategory, Ot as object, St as array, Tt as lazy, U as pickDetailCropConvention, W as pickNativeLeaseOverride, dt as createEvent, gt as nodePin, j as defineCustomActions, k as customAction, s as DEFAULT_DETAIL_CROP_CONVENTION, st as BaseAddon, tt as errMsg, u as DEVICE_BACKEND_TO_FORMAT, xt as _enum } from "../dist-BPaa4_z6.mjs";
2
+ import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-B79REtL2.mjs";
3
+ import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-CBO8nwQj.mjs";
4
4
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
5
5
  import { t as getSharp } from "../lazy-sharp-B-mb8uWx.mjs";
6
6
  import { i as withDetectionIntent, n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-Ci7RXNGb.mjs";
@@ -3182,6 +3182,7 @@ var PipelineRunner = class {
3182
3182
  motionQueue,
3183
3183
  detectionQueue,
3184
3184
  inferenceTimes: [],
3185
+ shedFrames: 0,
3185
3186
  processedCount: 0,
3186
3187
  startTime: Date.now(),
3187
3188
  phase: initialPhase,
@@ -3474,7 +3475,7 @@ var PipelineRunner = class {
3474
3475
  actualFps,
3475
3476
  queueDepth: state.motionQueue.size + state.detectionQueue.size,
3476
3477
  avgInferenceTimeMs: avgInference,
3477
- droppedFrames: state.motionQueue.droppedFrames + state.detectionQueue.droppedFrames,
3478
+ droppedFrames: state.motionQueue.droppedFrames + state.detectionQueue.droppedFrames + state.shedFrames,
3478
3479
  phase: state.phase
3479
3480
  };
3480
3481
  }
@@ -3581,6 +3582,7 @@ var PipelineRunner = class {
3581
3582
  if (state.inferenceTimes.length > 100) state.inferenceTimes.shift();
3582
3583
  state.processedCount++;
3583
3584
  if (result) {
3585
+ if (result.shed === true) state.shedFrames++;
3584
3586
  await this.notifyCallbacks(deviceId, frame, result, streamType, handle);
3585
3587
  const emittedAt = Date.now();
3586
3588
  const capturedAt = frame.capturedAt;
@@ -6094,11 +6096,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6094
6096
  let image;
6095
6097
  if (!this.executorCoLocated()) {
6096
6098
  if (frame.format === "rgb") try {
6097
- image = await sharp(frame.data, { raw: {
6099
+ const encoded = await sharp(frame.data, { raw: {
6098
6100
  width: frame.width,
6099
6101
  height: frame.height,
6100
6102
  channels: 3
6101
6103
  } }).jpeg({ quality: DETECTION_HOP_JPEG_QUALITY }).toBuffer();
6104
+ image = new Uint8Array(encoded);
6102
6105
  } catch (err) {
6103
6106
  log.debug("runInference: frame JPEG encode failed — falling back to raw pixels", { meta: { error: errMsg(err) } });
6104
6107
  }
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("../chunk-emK7D4bc.js");
2
- const require_dist = require("../dist-tJF2BzWi.js");
2
+ const require_dist = require("../dist-cR-pvD_z.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");
@@ -683,6 +683,36 @@ var RecordingIndex = class {
683
683
  }
684
684
  return out;
685
685
  }
686
+ /**
687
+ * Every (device, profile, location) pile in ONE pass — bytes and file count,
688
+ * no sort and no cache rebuild.
689
+ *
690
+ * This is the input to the operator-armed rebalance (D116): it asks "where is
691
+ * each camera-profile's footage, and how much of it", which is exactly the
692
+ * unit the placement plan assigns and the relocate mover moves. Building it
693
+ * from `segmentsOnLocation` per location would sort the whole archive once
694
+ * per location instead.
695
+ */
696
+ piles() {
697
+ const out = /* @__PURE__ */ new Map();
698
+ for (const m of this.byDevice.values()) for (const s of m.values()) {
699
+ const key = `${s.deviceId}:${s.profile}:${s.locationId}`;
700
+ const prev = out.get(key);
701
+ if (prev) {
702
+ prev.bytes += s.bytes;
703
+ prev.files += 1;
704
+ continue;
705
+ }
706
+ out.set(key, {
707
+ deviceId: s.deviceId,
708
+ profile: s.profile,
709
+ locationId: s.locationId,
710
+ bytes: s.bytes,
711
+ files: 1
712
+ });
713
+ }
714
+ return [...out.values()];
715
+ }
686
716
  /** All segments on a storage location across every device, oldest-first. */
687
717
  segmentsOnLocation(locationId) {
688
718
  const out = [];
@@ -872,6 +902,19 @@ function resolveEvictionDomain(locations, locationId, realpath = node_fs.realpat
872
902
  *
873
903
  * Runs INSIDE the recorder process — container uid, same-uid files, no host
874
904
  * chown trap (see docs/history 2026-07-29).
905
+ *
906
+ * ── Phase 2 (D116, multi-location) ────────────────────────────────────────
907
+ * Two additions the OPERATOR-ARMED rebalance needs:
908
+ *
909
+ * - A move is scoped to one CAMERA (and optionally to specific profiles),
910
+ * not just to a whole disk. The rebalance's unit is a (camera, profile)
911
+ * pile, because that is the unit the placement plan assigns.
912
+ * - Concurrent starts QUEUE instead of being refused. The engine is still
913
+ * single-flight — two movers on one disk thrash both — but a rebalance
914
+ * enqueues one job per pile, and refusing the second turned a
915
+ * fifteen-camera rebalance into fifteen manual retries. Jobs run FIFO; a
916
+ * job cancelled while queued never runs, and a job that FAILS does not
917
+ * strand the queue behind it.
875
918
  */
876
919
  var DEFAULT_THROTTLE_MBPS = 40;
877
920
  /** Progress callback cadence (files) — cheap enough to fire often. */
@@ -915,22 +958,48 @@ async function moveFile(src, dst, srcBytes) {
915
958
  var RelocateEngine = class {
916
959
  deps;
917
960
  jobs = /* @__PURE__ */ new Map();
961
+ /** Job ids waiting for the mover, in enqueue order. */
962
+ queue = [];
963
+ /** Set while a job is executing — the single-flight latch. */
964
+ draining = false;
918
965
  constructor(deps) {
919
966
  this.deps = deps;
920
967
  }
921
968
  list() {
922
969
  return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
923
970
  }
971
+ /**
972
+ * Cancel a running OR queued job. A running job stops after its current
973
+ * file; a queued job is terminated where it stands and never runs — which is
974
+ * why this returns true for both, and why the cancelled job still gets its
975
+ * `onFinished` audit row.
976
+ */
924
977
  cancel(jobId) {
925
978
  const job = this.jobs.get(jobId);
926
- if (!job || job.state !== "running") return false;
979
+ if (!job) return false;
980
+ if (job.state === "running") {
981
+ job.cancelRequested = true;
982
+ return true;
983
+ }
984
+ if (job.state !== "queued") return false;
927
985
  job.cancelRequested = true;
986
+ job.state = "cancelled";
987
+ job.finishedAt = this.deps.now();
988
+ const at = this.queue.indexOf(jobId);
989
+ if (at >= 0) this.queue.splice(at, 1);
990
+ this.deps.onFinished?.(snapshot(job));
928
991
  return true;
929
992
  }
930
- /** Start a relocation. Throws on invalid locations or when a job is already
931
- * running (single-flight two movers on one disk thrash both). */
993
+ /**
994
+ * Enqueue a relocation and return its job id. Throws only on an input the
995
+ * mover could never satisfy (unknown/identical/read-only locations) —
996
+ * validated SYNCHRONOUSLY so a caller arming a rebalance learns about a bad
997
+ * target before any job runs.
998
+ *
999
+ * Concurrency: the returned job may be `queued` rather than `running`. The
1000
+ * engine still moves one job at a time.
1001
+ */
932
1002
  start(input) {
933
- for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a relocation is already running (${j.jobId})`);
934
1003
  const locs = this.deps.locations();
935
1004
  const from = locs.find((l) => l.id === input.fromLocationId);
936
1005
  const to = locs.find((l) => l.id === input.toLocationId);
@@ -940,10 +1009,13 @@ var RelocateEngine = class {
940
1009
  if (to.readOnly) throw new Error(`target location is read-only: ${to.id}`);
941
1010
  const job = {
942
1011
  jobId: this.deps.newId(),
943
- state: "running",
1012
+ state: "queued",
944
1013
  fromLocationId: from.id,
945
1014
  toLocationId: to.id,
946
- deviceId: null,
1015
+ deviceId: input.deviceId ?? null,
1016
+ profiles: input.profiles ? [...input.profiles] : [],
1017
+ footageClass: input.footageClass,
1018
+ throttleMbps: input.throttleMbps ?? DEFAULT_THROTTLE_MBPS,
947
1019
  entities: input.entities ? [...input.entities] : ["segments"],
948
1020
  filesMoved: 0,
949
1021
  bytesMoved: 0,
@@ -954,15 +1026,56 @@ var RelocateEngine = class {
954
1026
  cancelRequested: false
955
1027
  };
956
1028
  this.jobs.set(job.jobId, job);
957
- this.run(job, from, to, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS, input.footageClass);
1029
+ this.queue.push(job.jobId);
1030
+ this.drain();
958
1031
  return job.jobId;
959
1032
  }
960
- async run(job, from, to, throttleMbps, footageClass) {
1033
+ /**
1034
+ * Run queued jobs one at a time, in enqueue order. Re-entrant by design: the
1035
+ * `draining` latch is the single-flight guarantee, and every terminal state
1036
+ * (including `failed`) falls through to the next job — a queue stranded
1037
+ * behind one bad camera is the failure mode FIFO exists to avoid.
1038
+ */
1039
+ async drain() {
1040
+ if (this.draining) return;
1041
+ this.draining = true;
1042
+ try {
1043
+ for (;;) {
1044
+ const jobId = this.queue.shift();
1045
+ if (jobId === void 0) return;
1046
+ const job = this.jobs.get(jobId);
1047
+ if (!job || job.state !== "queued") continue;
1048
+ const locs = this.deps.locations();
1049
+ const from = locs.find((l) => l.id === job.fromLocationId);
1050
+ const to = locs.find((l) => l.id === job.toLocationId);
1051
+ if (!from || !to) {
1052
+ job.state = "failed";
1053
+ job.error = `location disappeared while queued: ${job.fromLocationId} → ${job.toLocationId}`;
1054
+ job.finishedAt = this.deps.now();
1055
+ this.deps.logger.warn("relocate job failed", {
1056
+ ...job.deviceId !== null ? { tags: { deviceId: job.deviceId } } : {},
1057
+ meta: {
1058
+ jobId: job.jobId,
1059
+ error: job.error
1060
+ }
1061
+ });
1062
+ this.deps.onFinished?.(snapshot(job));
1063
+ continue;
1064
+ }
1065
+ job.state = "running";
1066
+ job.startedAt = this.deps.now();
1067
+ await this.run(job, from, to);
1068
+ }
1069
+ } finally {
1070
+ this.draining = false;
1071
+ }
1072
+ }
1073
+ async run(job, from, to) {
961
1074
  const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
962
- const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
1075
+ const bytesPerMs = job.throttleMbps * 1024 * 1024 / 1e3;
963
1076
  try {
964
1077
  const samePhysicalRoot = await rootsPhysicallySame(from.root, to.root);
965
- 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);
1078
+ const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => job.deviceId === null || r.deviceId === job.deviceId).filter((r) => job.profiles.length === 0 || job.profiles.includes(r.profile)).filter((r) => profileBelongsToClass(r.profile, job.footageClass)).sort((a, b) => a.startMs - b.startMs);
966
1079
  job.filesTotal = segRows.length;
967
1080
  for (const row of segRows) {
968
1081
  if (job.cancelRequested) break;
@@ -985,10 +1098,13 @@ var RelocateEngine = class {
985
1098
  } catch (err) {
986
1099
  job.state = "failed";
987
1100
  job.error = err instanceof Error ? err.message : String(err);
988
- this.deps.logger.warn("relocate job failed", { meta: {
989
- jobId: job.jobId,
990
- error: job.error
991
- } });
1101
+ this.deps.logger.warn("relocate job failed", {
1102
+ ...job.deviceId !== null ? { tags: { deviceId: job.deviceId } } : {},
1103
+ meta: {
1104
+ jobId: job.jobId,
1105
+ error: job.error
1106
+ }
1107
+ });
992
1108
  } finally {
993
1109
  job.finishedAt = this.deps.now();
994
1110
  this.deps.onFinished?.(snapshot(job));
@@ -2470,20 +2586,6 @@ async function listExportsFor(state, deviceId) {
2470
2586
  }
2471
2587
  //#endregion
2472
2588
  //#region src/recorder/addon/export-engine.ts
2473
- /**
2474
- * Render engine for recording exports.
2475
- *
2476
- * A FIFO queue with ONE concurrent ffmpeg render (exports are heavy; serialising
2477
- * protects the recording node). Each job: write the range's per-export source
2478
- * playlist (`resolvePlaylist`) → build argv (`buildExportArgs`) → spawn ffmpeg →
2479
- * parse `time=` from stderr for progress → on exit 0 stat the file and mark
2480
- * `ready`; on non-zero mark `failed` and remove the partial; drop the source
2481
- * playlist whatever the outcome. `cancel` SIGKILLs an in-flight render.
2482
- * State transitions patch the DurableState store and emit telemetry events.
2483
- *
2484
- * Unlike `SegmentWriter` there is NO auto-restart — a render runs once; boot
2485
- * re-queue of interrupted jobs is the provider's concern.
2486
- */
2487
2589
  /** Stderr lines retained for the failure LOG. ffmpeg prints one line per failed
2488
2590
  * segment before the decisive "Error opening input" summary, so a short tail can
2489
2591
  * push the actual cause out of the window (it very nearly did — the line that
@@ -2491,6 +2593,26 @@ async function listExportsFor(state, deviceId) {
2491
2593
  * render writes one progress line per second. */
2492
2594
  var STDERR_TAIL_LINES$1 = 40;
2493
2595
  var TIME_RE = /time=(\d+):(\d+):(\d+(?:\.\d+)?)/;
2596
+ /**
2597
+ * The floor under which an MP4 cannot hold a single decodable frame.
2598
+ *
2599
+ * **Exit 0 is not the same question as "the file holds a video".** A timelapse
2600
+ * whose frame-select predicate matched nothing produced a **262-byte** MP4 —
2601
+ * `ftyp` + `moov` + an EMPTY `mdat`, `nb_streams=0` to ffprobe — and ffmpeg
2602
+ * exited 0 while doing it. The row went `ready`, `readExportBytes` served the
2603
+ * 262 bytes, the notification centre republished them on the artifact plane
2604
+ * and a phone was handed a video no player can open (2026-08-12; the operator
2605
+ * reported it as "il video è danneggiato", and the served artifact was
2606
+ * byte-identical to the file on disk, so the render was the only suspect).
2607
+ *
2608
+ * A frame-free MP4 tops out around 300 bytes (the box headers and nothing
2609
+ * else); the smallest REAL one-frame 640×360 h264 export measured here is
2610
+ * several kilobytes. 1 KiB sits between the two with room on both sides, and
2611
+ * it is deliberately a byte floor and not an ffprobe call: the engine must be
2612
+ * able to refuse an empty render without a second process, on every node,
2613
+ * whatever ffmpeg build is installed.
2614
+ */
2615
+ var EXPORT_MIN_PLAUSIBLE_BYTES = 1024;
2494
2616
  /** Expected OUTPUT duration (seconds) for progress scaling. */
2495
2617
  function expectedOutSeconds(rec) {
2496
2618
  const rangeSec = Math.max(.001, (rec.toMs - rec.fromMs) / 1e3);
@@ -2647,6 +2769,17 @@ var ExportEngine = class {
2647
2769
  await this.fail(id, err instanceof Error ? err.message : String(err));
2648
2770
  return;
2649
2771
  }
2772
+ if (bytes < 1024) {
2773
+ this.deps.logger.error("export produced no video frames", { meta: {
2774
+ exportId: id,
2775
+ outPath,
2776
+ bytes,
2777
+ minBytes: EXPORT_MIN_PLAUSIBLE_BYTES
2778
+ } });
2779
+ await this.deps.removeFile(outPath);
2780
+ await this.fail(id, `render produced no video frames (${bytes} bytes, < ${EXPORT_MIN_PLAUSIBLE_BYTES})`);
2781
+ return;
2782
+ }
2650
2783
  await patchExport(this.deps.state, id, {
2651
2784
  state: "ready",
2652
2785
  progressPct: 100,
@@ -3028,6 +3161,73 @@ function buildRecordingDeviceSchema() {
3028
3161
  }]
3029
3162
  }] };
3030
3163
  }
3164
+ /** The placement-store key for one (camera, profile). Mirrors `assignmentKey`
3165
+ * in `placement-store.ts`; kept local so this module stays dependency-free. */
3166
+ function sourceKey(source) {
3167
+ return `${source.deviceId}:${source.profile}`;
3168
+ }
3169
+ /** Biggest pile first (the overloaded disk is relieved fastest), ties broken
3170
+ * deterministically so the same input always yields the same plan. */
3171
+ function bySizeThenKey(a, b) {
3172
+ return b.bytes - a.bytes || a.deviceId - b.deviceId || a.profile.localeCompare(b.profile);
3173
+ }
3174
+ /**
3175
+ * Plan the moves that would make stored footage agree with the placement plan.
3176
+ * Pure: the same input always yields the same plan, and the input is untouched.
3177
+ */
3178
+ function planRebalance(input) {
3179
+ const minMoveBytes = input.minMoveBytes ?? 1e9;
3180
+ const remaining = new Map(input.headroom.map((h) => [h.locationId, h.headroomBytes]));
3181
+ const moves = [];
3182
+ const skipped = [];
3183
+ let bytesToMove = 0;
3184
+ for (const source of [...input.sources].sort(bySizeThenKey)) {
3185
+ const target = input.assignments.get(sourceKey(source));
3186
+ if (target === source.locationId) continue;
3187
+ const skip = (reason, toLocationId) => {
3188
+ skipped.push({
3189
+ deviceId: source.deviceId,
3190
+ profile: source.profile,
3191
+ fromLocationId: source.locationId,
3192
+ toLocationId,
3193
+ bytes: source.bytes,
3194
+ reason
3195
+ });
3196
+ };
3197
+ if (target === void 0) {
3198
+ skip("unassigned", null);
3199
+ continue;
3200
+ }
3201
+ const headroomBytes = remaining.get(target);
3202
+ if (headroomBytes === void 0) {
3203
+ skip("target-not-writable", target);
3204
+ continue;
3205
+ }
3206
+ if (source.bytes < minMoveBytes) {
3207
+ skip("below-threshold", target);
3208
+ continue;
3209
+ }
3210
+ if (source.bytes > headroomBytes) {
3211
+ skip("no-headroom", target);
3212
+ continue;
3213
+ }
3214
+ remaining.set(target, headroomBytes - source.bytes);
3215
+ bytesToMove += source.bytes;
3216
+ moves.push({
3217
+ deviceId: source.deviceId,
3218
+ profile: source.profile,
3219
+ fromLocationId: source.locationId,
3220
+ toLocationId: target,
3221
+ bytes: source.bytes,
3222
+ files: source.files
3223
+ });
3224
+ }
3225
+ return {
3226
+ moves,
3227
+ skipped,
3228
+ bytesToMove
3229
+ };
3230
+ }
3031
3231
  //#endregion
3032
3232
  //#region src/recorder/addon/recording-provider.ts
3033
3233
  /**
@@ -3305,6 +3505,75 @@ async function openRangeFromDisk(absPath, size) {
3305
3505
  close: () => handle.close()
3306
3506
  };
3307
3507
  }
3508
+ /** Bytes in one operator-facing gigabyte — the same decimal GB `maxUsedGb`
3509
+ * uses, so two knobs on the same page never mean two different things. */
3510
+ var BYTES_PER_GB = 1e9;
3511
+ /**
3512
+ * The operator-armed rebalance, both halves (D116, Phase 2).
3513
+ *
3514
+ * `arm: false` is the dry run the operator confirms; `arm: true` enqueues one
3515
+ * relocate job per planned move, FIFO behind the single-flight mover. The two
3516
+ * share this one body deliberately: what is confirmed is exactly what runs.
3517
+ *
3518
+ * Every refusal is LOGGED, per camera. A rebalance that quietly leaves half the
3519
+ * archive where it was is indistinguishable from one that had nothing to do,
3520
+ * and this repo has paid for that ambiguity twice.
3521
+ */
3522
+ async function runRebalance(deps, input, arm) {
3523
+ const relocate = deps.relocate;
3524
+ if (!deps.placementSnapshot) throw new Error("placement is not active on this node");
3525
+ if (arm && !relocate) throw new Error("relocation unavailable on this node");
3526
+ const snapshot = await deps.placementSnapshot();
3527
+ const plan = planRebalance({
3528
+ assignments: snapshot.assignments,
3529
+ sources: deps.index.piles(),
3530
+ headroom: snapshot.headroom,
3531
+ ...input.minMoveGb !== void 0 ? { minMoveBytes: input.minMoveGb * BYTES_PER_GB } : {}
3532
+ });
3533
+ for (const skip of plan.skipped) deps.logger.info("recorder rebalance: footage left where it is", {
3534
+ tags: { deviceId: skip.deviceId },
3535
+ meta: {
3536
+ profile: skip.profile,
3537
+ from: skip.fromLocationId,
3538
+ to: skip.toLocationId,
3539
+ bytes: skip.bytes,
3540
+ reason: skip.reason
3541
+ }
3542
+ });
3543
+ const result = {
3544
+ moves: [...plan.moves],
3545
+ skipped: [...plan.skipped],
3546
+ bytesToMove: plan.bytesToMove,
3547
+ jobIds: []
3548
+ };
3549
+ if (!arm || !relocate) return result;
3550
+ const jobIds = [];
3551
+ for (const move of plan.moves) {
3552
+ const jobId = relocate.start({
3553
+ fromLocationId: move.fromLocationId,
3554
+ toLocationId: move.toLocationId,
3555
+ deviceId: move.deviceId,
3556
+ profiles: [move.profile],
3557
+ ...input.throttleMbps !== void 0 ? { throttleMbps: input.throttleMbps } : {}
3558
+ });
3559
+ jobIds.push(jobId);
3560
+ deps.logger.info("recorder rebalance: relocate job enqueued", {
3561
+ tags: { deviceId: move.deviceId },
3562
+ meta: {
3563
+ jobId,
3564
+ profile: move.profile,
3565
+ from: move.fromLocationId,
3566
+ to: move.toLocationId,
3567
+ bytes: move.bytes,
3568
+ files: move.files
3569
+ }
3570
+ });
3571
+ }
3572
+ return {
3573
+ ...result,
3574
+ jobIds
3575
+ };
3576
+ }
3308
3577
  /**
3309
3578
  * Build the `IRecordingProvider`. The provider is a thin façade over the v2
3310
3579
  * core; all storage I/O flows through the injected deps.
@@ -3368,11 +3637,12 @@ function buildRecordingProvider(deps) {
3368
3637
  for (const loc of locations) for (const row of deps.index.segmentsOnLocation(loc.id)) seenDevices.add(row.deviceId);
3369
3638
  let totalUsedBytes = 0;
3370
3639
  for (const deviceId of seenDevices) {
3371
- const usedBytes = deps.index.accounting(deviceId).bytes;
3372
- totalUsedBytes += usedBytes;
3640
+ const accounting = deps.index.accounting(deviceId);
3641
+ totalUsedBytes += accounting.bytes;
3373
3642
  devices.push({
3374
3643
  deviceId,
3375
- usedBytes
3644
+ usedBytes: accounting.bytes,
3645
+ oldestMs: accounting.oldestMs
3376
3646
  });
3377
3647
  }
3378
3648
  const byPhysical = /* @__PURE__ */ new Map();
@@ -3559,6 +3829,24 @@ function buildRecordingProvider(deps) {
3559
3829
  },
3560
3830
  getStorageMigrationMoveStatus: async ({ jobId }) => deps.relocate?.list().find((job) => job.jobId === jobId) ?? null,
3561
3831
  cancelStorageMigrationMove: async ({ jobId }) => ({ cancelled: deps.relocate?.cancel(jobId) ?? false }),
3832
+ relocateFootage: async (input) => {
3833
+ if (!deps.relocate) throw new Error("relocation unavailable on this node");
3834
+ const jobId = deps.relocate.start(input);
3835
+ deps.logger.info("recorder: relocate job enqueued", {
3836
+ ...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
3837
+ meta: {
3838
+ jobId,
3839
+ from: input.fromLocationId,
3840
+ to: input.toLocationId,
3841
+ profiles: input.profiles ?? null
3842
+ }
3843
+ });
3844
+ return { jobId };
3845
+ },
3846
+ listRelocateJobs: async () => deps.relocate?.list() ?? [],
3847
+ cancelRelocateJob: async ({ jobId }) => ({ cancelled: deps.relocate?.cancel(jobId) ?? false }),
3848
+ planStorageRebalance: (input) => runRebalance(deps, input, false),
3849
+ startStorageRebalance: (input) => runRebalance(deps, input, true),
3562
3850
  getDeviceSettingsContribution: async ({ deviceId }) => {
3563
3851
  if (deps.isCameraDevice && !await deps.isCameraDevice(deviceId)) return null;
3564
3852
  return require_dist.hydrateSchema(buildRecordingDeviceSchema(), {});
@@ -4690,6 +4978,33 @@ var PlacementService = class {
4690
4978
  return plan;
4691
4979
  }
4692
4980
  /**
4981
+ * The write targets and what each can still absorb — the SAME pool and the
4982
+ * SAME headroom `recompute` plans over.
4983
+ *
4984
+ * Exposed for the operator-armed rebalance (D116): weighing existing footage
4985
+ * against a second, independently-derived idea of "what fits" is how the two
4986
+ * halves end up disagreeing, and a rebalance that plans onto a location the
4987
+ * planner would never write to is a rebalance that undoes itself on the next
4988
+ * tick. A location absent from this list is not a write target at all.
4989
+ */
4990
+ async candidates() {
4991
+ return this.candidatesFor(this.pool());
4992
+ }
4993
+ /**
4994
+ * The plan in force: `<deviceId>:<profile>` → locationId. Empty when the
4995
+ * store cannot be read — a fallible read decides nothing (D49), and an empty
4996
+ * plan makes the rebalance report every pile as `unassigned` rather than
4997
+ * inventing moves.
4998
+ */
4999
+ async currentAssignments() {
5000
+ try {
5001
+ return new Map(Object.entries((await readPlacementState(this.deps.state)).assignments));
5002
+ } catch (err) {
5003
+ this.deps.logger.warn("recorder placement: assignment read failed — reporting no plan", { meta: { error: require_dist.errMsg(err) } });
5004
+ return /* @__PURE__ */ new Map();
5005
+ }
5006
+ }
5007
+ /**
4693
5008
  * Turn each pool location into a planner candidate. Headroom is
4694
5009
  * `min(free − minFree floor, maxUsedGb − used)`, clamped at zero: the first
4695
5010
  * term is the per-location free-space guard the storage pressure manager also
@@ -6575,6 +6890,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
6575
6890
  resumeForStorageMigration: (leaseId) => this.resumeForStorageMigration(leaseId),
6576
6891
  refreshStorageLocationsForMigration: (leaseId) => this.refreshStorageLocationsForMigration(leaseId),
6577
6892
  assertStorageMigrationLease: (leaseId) => this.assertStorageMigrationLease(leaseId),
6893
+ placementSnapshot: () => this.placementSnapshot(),
6578
6894
  dataDir: this.ctx.dataDir,
6579
6895
  playbackBaseUrl: () => this.playbackBaseUrl,
6580
6896
  isCameraDevice: async (deviceId) => {
@@ -7014,6 +7330,28 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
7014
7330
  await this.placement.recompute(work);
7015
7331
  }
7016
7332
  /**
7333
+ * The plan in force plus each write target's headroom, for the operator-armed
7334
+ * rebalance (D116, Phase 2). Both come from the SAME `PlacementService` the
7335
+ * 30 s tick plans with: a rebalance computed against a second idea of "what
7336
+ * fits" would plan moves the next tick undoes.
7337
+ *
7338
+ * Throws when placement is not active (a non-recording node, or early boot) —
7339
+ * the rebalance is an operator action and a silent empty plan would read as
7340
+ * "nothing to do".
7341
+ */
7342
+ async placementSnapshot() {
7343
+ const placement = this.placement;
7344
+ if (!placement) throw new Error("placement is not active on this node");
7345
+ const [assignments, candidates] = await Promise.all([placement.currentAssignments(), placement.candidates()]);
7346
+ return {
7347
+ assignments,
7348
+ headroom: candidates.map((candidate) => ({
7349
+ locationId: candidate.locationId,
7350
+ headroomBytes: candidate.headroomBytes
7351
+ }))
7352
+ };
7353
+ }
7354
+ /**
7017
7355
  * Measured bytes/day for one (camera, profile) — the demand signal the
7018
7356
  * placement planner weights against each location's headroom. Derived from
7019
7357
  * the index's own accounting (segment paths encode bytes + start), so it