@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
- import { At as string, Dt as number, J as recordingExportCapability, Mt as EventCategory, N as deriveRecordingMode, Ot as object, St as array, Z as storageEvictableCapability, _ as OpsLogEntrySchema, b as RECORDING_EXPORT_MAX_READ_BYTES, c as DEFAULT_EVENTS_BAND_BUFFER_SEC, d as EVENT_PAD_MS, ft as hydrateSchema, gt as nodePin, kt as record, p as ExportRecordSchema, q as recordingCapability, st as BaseAddon, tt as errMsg, ut as DeviceType, x as RecordingConfigSchema, yt as selectAssignedProfileSlots } from "../dist-DGLqaonv.mjs";
1
+ import { At as string, Dt as number, J as recordingExportCapability, Mt as EventCategory, N as deriveRecordingMode, Ot as object, St as array, Z as storageEvictableCapability, _ as OpsLogEntrySchema, b as RECORDING_EXPORT_MAX_READ_BYTES, c as DEFAULT_EVENTS_BAND_BUFFER_SEC, d as EVENT_PAD_MS, ft as hydrateSchema, gt as nodePin, kt as record, p as ExportRecordSchema, q as recordingCapability, st as BaseAddon, tt as errMsg, ut as DeviceType, x as RecordingConfigSchema, yt as selectAssignedProfileSlots } from "../dist-BPaa4_z6.mjs";
2
2
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
3
3
  import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-CZ2xo67g.mjs";
4
4
  import { randomUUID } from "node:crypto";
@@ -681,6 +681,36 @@ var RecordingIndex = class {
681
681
  }
682
682
  return out;
683
683
  }
684
+ /**
685
+ * Every (device, profile, location) pile in ONE pass — bytes and file count,
686
+ * no sort and no cache rebuild.
687
+ *
688
+ * This is the input to the operator-armed rebalance (D116): it asks "where is
689
+ * each camera-profile's footage, and how much of it", which is exactly the
690
+ * unit the placement plan assigns and the relocate mover moves. Building it
691
+ * from `segmentsOnLocation` per location would sort the whole archive once
692
+ * per location instead.
693
+ */
694
+ piles() {
695
+ const out = /* @__PURE__ */ new Map();
696
+ for (const m of this.byDevice.values()) for (const s of m.values()) {
697
+ const key = `${s.deviceId}:${s.profile}:${s.locationId}`;
698
+ const prev = out.get(key);
699
+ if (prev) {
700
+ prev.bytes += s.bytes;
701
+ prev.files += 1;
702
+ continue;
703
+ }
704
+ out.set(key, {
705
+ deviceId: s.deviceId,
706
+ profile: s.profile,
707
+ locationId: s.locationId,
708
+ bytes: s.bytes,
709
+ files: 1
710
+ });
711
+ }
712
+ return [...out.values()];
713
+ }
684
714
  /** All segments on a storage location across every device, oldest-first. */
685
715
  segmentsOnLocation(locationId) {
686
716
  const out = [];
@@ -870,6 +900,19 @@ function resolveEvictionDomain(locations, locationId, realpath = realpathSync) {
870
900
  *
871
901
  * Runs INSIDE the recorder process — container uid, same-uid files, no host
872
902
  * chown trap (see docs/history 2026-07-29).
903
+ *
904
+ * ── Phase 2 (D116, multi-location) ────────────────────────────────────────
905
+ * Two additions the OPERATOR-ARMED rebalance needs:
906
+ *
907
+ * - A move is scoped to one CAMERA (and optionally to specific profiles),
908
+ * not just to a whole disk. The rebalance's unit is a (camera, profile)
909
+ * pile, because that is the unit the placement plan assigns.
910
+ * - Concurrent starts QUEUE instead of being refused. The engine is still
911
+ * single-flight — two movers on one disk thrash both — but a rebalance
912
+ * enqueues one job per pile, and refusing the second turned a
913
+ * fifteen-camera rebalance into fifteen manual retries. Jobs run FIFO; a
914
+ * job cancelled while queued never runs, and a job that FAILS does not
915
+ * strand the queue behind it.
873
916
  */
874
917
  var DEFAULT_THROTTLE_MBPS = 40;
875
918
  /** Progress callback cadence (files) — cheap enough to fire often. */
@@ -913,22 +956,48 @@ async function moveFile(src, dst, srcBytes) {
913
956
  var RelocateEngine = class {
914
957
  deps;
915
958
  jobs = /* @__PURE__ */ new Map();
959
+ /** Job ids waiting for the mover, in enqueue order. */
960
+ queue = [];
961
+ /** Set while a job is executing — the single-flight latch. */
962
+ draining = false;
916
963
  constructor(deps) {
917
964
  this.deps = deps;
918
965
  }
919
966
  list() {
920
967
  return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
921
968
  }
969
+ /**
970
+ * Cancel a running OR queued job. A running job stops after its current
971
+ * file; a queued job is terminated where it stands and never runs — which is
972
+ * why this returns true for both, and why the cancelled job still gets its
973
+ * `onFinished` audit row.
974
+ */
922
975
  cancel(jobId) {
923
976
  const job = this.jobs.get(jobId);
924
- if (!job || job.state !== "running") return false;
977
+ if (!job) return false;
978
+ if (job.state === "running") {
979
+ job.cancelRequested = true;
980
+ return true;
981
+ }
982
+ if (job.state !== "queued") return false;
925
983
  job.cancelRequested = true;
984
+ job.state = "cancelled";
985
+ job.finishedAt = this.deps.now();
986
+ const at = this.queue.indexOf(jobId);
987
+ if (at >= 0) this.queue.splice(at, 1);
988
+ this.deps.onFinished?.(snapshot(job));
926
989
  return true;
927
990
  }
928
- /** Start a relocation. Throws on invalid locations or when a job is already
929
- * running (single-flight two movers on one disk thrash both). */
991
+ /**
992
+ * Enqueue a relocation and return its job id. Throws only on an input the
993
+ * mover could never satisfy (unknown/identical/read-only locations) —
994
+ * validated SYNCHRONOUSLY so a caller arming a rebalance learns about a bad
995
+ * target before any job runs.
996
+ *
997
+ * Concurrency: the returned job may be `queued` rather than `running`. The
998
+ * engine still moves one job at a time.
999
+ */
930
1000
  start(input) {
931
- for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a relocation is already running (${j.jobId})`);
932
1001
  const locs = this.deps.locations();
933
1002
  const from = locs.find((l) => l.id === input.fromLocationId);
934
1003
  const to = locs.find((l) => l.id === input.toLocationId);
@@ -938,10 +1007,13 @@ var RelocateEngine = class {
938
1007
  if (to.readOnly) throw new Error(`target location is read-only: ${to.id}`);
939
1008
  const job = {
940
1009
  jobId: this.deps.newId(),
941
- state: "running",
1010
+ state: "queued",
942
1011
  fromLocationId: from.id,
943
1012
  toLocationId: to.id,
944
- deviceId: null,
1013
+ deviceId: input.deviceId ?? null,
1014
+ profiles: input.profiles ? [...input.profiles] : [],
1015
+ footageClass: input.footageClass,
1016
+ throttleMbps: input.throttleMbps ?? DEFAULT_THROTTLE_MBPS,
945
1017
  entities: input.entities ? [...input.entities] : ["segments"],
946
1018
  filesMoved: 0,
947
1019
  bytesMoved: 0,
@@ -952,15 +1024,56 @@ var RelocateEngine = class {
952
1024
  cancelRequested: false
953
1025
  };
954
1026
  this.jobs.set(job.jobId, job);
955
- this.run(job, from, to, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS, input.footageClass);
1027
+ this.queue.push(job.jobId);
1028
+ this.drain();
956
1029
  return job.jobId;
957
1030
  }
958
- async run(job, from, to, throttleMbps, footageClass) {
1031
+ /**
1032
+ * Run queued jobs one at a time, in enqueue order. Re-entrant by design: the
1033
+ * `draining` latch is the single-flight guarantee, and every terminal state
1034
+ * (including `failed`) falls through to the next job — a queue stranded
1035
+ * behind one bad camera is the failure mode FIFO exists to avoid.
1036
+ */
1037
+ async drain() {
1038
+ if (this.draining) return;
1039
+ this.draining = true;
1040
+ try {
1041
+ for (;;) {
1042
+ const jobId = this.queue.shift();
1043
+ if (jobId === void 0) return;
1044
+ const job = this.jobs.get(jobId);
1045
+ if (!job || job.state !== "queued") continue;
1046
+ const locs = this.deps.locations();
1047
+ const from = locs.find((l) => l.id === job.fromLocationId);
1048
+ const to = locs.find((l) => l.id === job.toLocationId);
1049
+ if (!from || !to) {
1050
+ job.state = "failed";
1051
+ job.error = `location disappeared while queued: ${job.fromLocationId} → ${job.toLocationId}`;
1052
+ job.finishedAt = this.deps.now();
1053
+ this.deps.logger.warn("relocate job failed", {
1054
+ ...job.deviceId !== null ? { tags: { deviceId: job.deviceId } } : {},
1055
+ meta: {
1056
+ jobId: job.jobId,
1057
+ error: job.error
1058
+ }
1059
+ });
1060
+ this.deps.onFinished?.(snapshot(job));
1061
+ continue;
1062
+ }
1063
+ job.state = "running";
1064
+ job.startedAt = this.deps.now();
1065
+ await this.run(job, from, to);
1066
+ }
1067
+ } finally {
1068
+ this.draining = false;
1069
+ }
1070
+ }
1071
+ async run(job, from, to) {
959
1072
  const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
960
- const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
1073
+ const bytesPerMs = job.throttleMbps * 1024 * 1024 / 1e3;
961
1074
  try {
962
1075
  const samePhysicalRoot = await rootsPhysicallySame(from.root, to.root);
963
- 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);
1076
+ 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);
964
1077
  job.filesTotal = segRows.length;
965
1078
  for (const row of segRows) {
966
1079
  if (job.cancelRequested) break;
@@ -983,10 +1096,13 @@ var RelocateEngine = class {
983
1096
  } catch (err) {
984
1097
  job.state = "failed";
985
1098
  job.error = err instanceof Error ? err.message : String(err);
986
- this.deps.logger.warn("relocate job failed", { meta: {
987
- jobId: job.jobId,
988
- error: job.error
989
- } });
1099
+ this.deps.logger.warn("relocate job failed", {
1100
+ ...job.deviceId !== null ? { tags: { deviceId: job.deviceId } } : {},
1101
+ meta: {
1102
+ jobId: job.jobId,
1103
+ error: job.error
1104
+ }
1105
+ });
990
1106
  } finally {
991
1107
  job.finishedAt = this.deps.now();
992
1108
  this.deps.onFinished?.(snapshot(job));
@@ -2468,20 +2584,6 @@ async function listExportsFor(state, deviceId) {
2468
2584
  }
2469
2585
  //#endregion
2470
2586
  //#region src/recorder/addon/export-engine.ts
2471
- /**
2472
- * Render engine for recording exports.
2473
- *
2474
- * A FIFO queue with ONE concurrent ffmpeg render (exports are heavy; serialising
2475
- * protects the recording node). Each job: write the range's per-export source
2476
- * playlist (`resolvePlaylist`) → build argv (`buildExportArgs`) → spawn ffmpeg →
2477
- * parse `time=` from stderr for progress → on exit 0 stat the file and mark
2478
- * `ready`; on non-zero mark `failed` and remove the partial; drop the source
2479
- * playlist whatever the outcome. `cancel` SIGKILLs an in-flight render.
2480
- * State transitions patch the DurableState store and emit telemetry events.
2481
- *
2482
- * Unlike `SegmentWriter` there is NO auto-restart — a render runs once; boot
2483
- * re-queue of interrupted jobs is the provider's concern.
2484
- */
2485
2587
  /** Stderr lines retained for the failure LOG. ffmpeg prints one line per failed
2486
2588
  * segment before the decisive "Error opening input" summary, so a short tail can
2487
2589
  * push the actual cause out of the window (it very nearly did — the line that
@@ -2489,6 +2591,26 @@ async function listExportsFor(state, deviceId) {
2489
2591
  * render writes one progress line per second. */
2490
2592
  var STDERR_TAIL_LINES$1 = 40;
2491
2593
  var TIME_RE = /time=(\d+):(\d+):(\d+(?:\.\d+)?)/;
2594
+ /**
2595
+ * The floor under which an MP4 cannot hold a single decodable frame.
2596
+ *
2597
+ * **Exit 0 is not the same question as "the file holds a video".** A timelapse
2598
+ * whose frame-select predicate matched nothing produced a **262-byte** MP4 —
2599
+ * `ftyp` + `moov` + an EMPTY `mdat`, `nb_streams=0` to ffprobe — and ffmpeg
2600
+ * exited 0 while doing it. The row went `ready`, `readExportBytes` served the
2601
+ * 262 bytes, the notification centre republished them on the artifact plane
2602
+ * and a phone was handed a video no player can open (2026-08-12; the operator
2603
+ * reported it as "il video è danneggiato", and the served artifact was
2604
+ * byte-identical to the file on disk, so the render was the only suspect).
2605
+ *
2606
+ * A frame-free MP4 tops out around 300 bytes (the box headers and nothing
2607
+ * else); the smallest REAL one-frame 640×360 h264 export measured here is
2608
+ * several kilobytes. 1 KiB sits between the two with room on both sides, and
2609
+ * it is deliberately a byte floor and not an ffprobe call: the engine must be
2610
+ * able to refuse an empty render without a second process, on every node,
2611
+ * whatever ffmpeg build is installed.
2612
+ */
2613
+ var EXPORT_MIN_PLAUSIBLE_BYTES = 1024;
2492
2614
  /** Expected OUTPUT duration (seconds) for progress scaling. */
2493
2615
  function expectedOutSeconds(rec) {
2494
2616
  const rangeSec = Math.max(.001, (rec.toMs - rec.fromMs) / 1e3);
@@ -2645,6 +2767,17 @@ var ExportEngine = class {
2645
2767
  await this.fail(id, err instanceof Error ? err.message : String(err));
2646
2768
  return;
2647
2769
  }
2770
+ if (bytes < 1024) {
2771
+ this.deps.logger.error("export produced no video frames", { meta: {
2772
+ exportId: id,
2773
+ outPath,
2774
+ bytes,
2775
+ minBytes: EXPORT_MIN_PLAUSIBLE_BYTES
2776
+ } });
2777
+ await this.deps.removeFile(outPath);
2778
+ await this.fail(id, `render produced no video frames (${bytes} bytes, < ${EXPORT_MIN_PLAUSIBLE_BYTES})`);
2779
+ return;
2780
+ }
2648
2781
  await patchExport(this.deps.state, id, {
2649
2782
  state: "ready",
2650
2783
  progressPct: 100,
@@ -3026,6 +3159,73 @@ function buildRecordingDeviceSchema() {
3026
3159
  }]
3027
3160
  }] };
3028
3161
  }
3162
+ /** The placement-store key for one (camera, profile). Mirrors `assignmentKey`
3163
+ * in `placement-store.ts`; kept local so this module stays dependency-free. */
3164
+ function sourceKey(source) {
3165
+ return `${source.deviceId}:${source.profile}`;
3166
+ }
3167
+ /** Biggest pile first (the overloaded disk is relieved fastest), ties broken
3168
+ * deterministically so the same input always yields the same plan. */
3169
+ function bySizeThenKey(a, b) {
3170
+ return b.bytes - a.bytes || a.deviceId - b.deviceId || a.profile.localeCompare(b.profile);
3171
+ }
3172
+ /**
3173
+ * Plan the moves that would make stored footage agree with the placement plan.
3174
+ * Pure: the same input always yields the same plan, and the input is untouched.
3175
+ */
3176
+ function planRebalance(input) {
3177
+ const minMoveBytes = input.minMoveBytes ?? 1e9;
3178
+ const remaining = new Map(input.headroom.map((h) => [h.locationId, h.headroomBytes]));
3179
+ const moves = [];
3180
+ const skipped = [];
3181
+ let bytesToMove = 0;
3182
+ for (const source of [...input.sources].sort(bySizeThenKey)) {
3183
+ const target = input.assignments.get(sourceKey(source));
3184
+ if (target === source.locationId) continue;
3185
+ const skip = (reason, toLocationId) => {
3186
+ skipped.push({
3187
+ deviceId: source.deviceId,
3188
+ profile: source.profile,
3189
+ fromLocationId: source.locationId,
3190
+ toLocationId,
3191
+ bytes: source.bytes,
3192
+ reason
3193
+ });
3194
+ };
3195
+ if (target === void 0) {
3196
+ skip("unassigned", null);
3197
+ continue;
3198
+ }
3199
+ const headroomBytes = remaining.get(target);
3200
+ if (headroomBytes === void 0) {
3201
+ skip("target-not-writable", target);
3202
+ continue;
3203
+ }
3204
+ if (source.bytes < minMoveBytes) {
3205
+ skip("below-threshold", target);
3206
+ continue;
3207
+ }
3208
+ if (source.bytes > headroomBytes) {
3209
+ skip("no-headroom", target);
3210
+ continue;
3211
+ }
3212
+ remaining.set(target, headroomBytes - source.bytes);
3213
+ bytesToMove += source.bytes;
3214
+ moves.push({
3215
+ deviceId: source.deviceId,
3216
+ profile: source.profile,
3217
+ fromLocationId: source.locationId,
3218
+ toLocationId: target,
3219
+ bytes: source.bytes,
3220
+ files: source.files
3221
+ });
3222
+ }
3223
+ return {
3224
+ moves,
3225
+ skipped,
3226
+ bytesToMove
3227
+ };
3228
+ }
3029
3229
  //#endregion
3030
3230
  //#region src/recorder/addon/recording-provider.ts
3031
3231
  /**
@@ -3303,6 +3503,75 @@ async function openRangeFromDisk(absPath, size) {
3303
3503
  close: () => handle.close()
3304
3504
  };
3305
3505
  }
3506
+ /** Bytes in one operator-facing gigabyte — the same decimal GB `maxUsedGb`
3507
+ * uses, so two knobs on the same page never mean two different things. */
3508
+ var BYTES_PER_GB = 1e9;
3509
+ /**
3510
+ * The operator-armed rebalance, both halves (D116, Phase 2).
3511
+ *
3512
+ * `arm: false` is the dry run the operator confirms; `arm: true` enqueues one
3513
+ * relocate job per planned move, FIFO behind the single-flight mover. The two
3514
+ * share this one body deliberately: what is confirmed is exactly what runs.
3515
+ *
3516
+ * Every refusal is LOGGED, per camera. A rebalance that quietly leaves half the
3517
+ * archive where it was is indistinguishable from one that had nothing to do,
3518
+ * and this repo has paid for that ambiguity twice.
3519
+ */
3520
+ async function runRebalance(deps, input, arm) {
3521
+ const relocate = deps.relocate;
3522
+ if (!deps.placementSnapshot) throw new Error("placement is not active on this node");
3523
+ if (arm && !relocate) throw new Error("relocation unavailable on this node");
3524
+ const snapshot = await deps.placementSnapshot();
3525
+ const plan = planRebalance({
3526
+ assignments: snapshot.assignments,
3527
+ sources: deps.index.piles(),
3528
+ headroom: snapshot.headroom,
3529
+ ...input.minMoveGb !== void 0 ? { minMoveBytes: input.minMoveGb * BYTES_PER_GB } : {}
3530
+ });
3531
+ for (const skip of plan.skipped) deps.logger.info("recorder rebalance: footage left where it is", {
3532
+ tags: { deviceId: skip.deviceId },
3533
+ meta: {
3534
+ profile: skip.profile,
3535
+ from: skip.fromLocationId,
3536
+ to: skip.toLocationId,
3537
+ bytes: skip.bytes,
3538
+ reason: skip.reason
3539
+ }
3540
+ });
3541
+ const result = {
3542
+ moves: [...plan.moves],
3543
+ skipped: [...plan.skipped],
3544
+ bytesToMove: plan.bytesToMove,
3545
+ jobIds: []
3546
+ };
3547
+ if (!arm || !relocate) return result;
3548
+ const jobIds = [];
3549
+ for (const move of plan.moves) {
3550
+ const jobId = relocate.start({
3551
+ fromLocationId: move.fromLocationId,
3552
+ toLocationId: move.toLocationId,
3553
+ deviceId: move.deviceId,
3554
+ profiles: [move.profile],
3555
+ ...input.throttleMbps !== void 0 ? { throttleMbps: input.throttleMbps } : {}
3556
+ });
3557
+ jobIds.push(jobId);
3558
+ deps.logger.info("recorder rebalance: relocate job enqueued", {
3559
+ tags: { deviceId: move.deviceId },
3560
+ meta: {
3561
+ jobId,
3562
+ profile: move.profile,
3563
+ from: move.fromLocationId,
3564
+ to: move.toLocationId,
3565
+ bytes: move.bytes,
3566
+ files: move.files
3567
+ }
3568
+ });
3569
+ }
3570
+ return {
3571
+ ...result,
3572
+ jobIds
3573
+ };
3574
+ }
3306
3575
  /**
3307
3576
  * Build the `IRecordingProvider`. The provider is a thin façade over the v2
3308
3577
  * core; all storage I/O flows through the injected deps.
@@ -3366,11 +3635,12 @@ function buildRecordingProvider(deps) {
3366
3635
  for (const loc of locations) for (const row of deps.index.segmentsOnLocation(loc.id)) seenDevices.add(row.deviceId);
3367
3636
  let totalUsedBytes = 0;
3368
3637
  for (const deviceId of seenDevices) {
3369
- const usedBytes = deps.index.accounting(deviceId).bytes;
3370
- totalUsedBytes += usedBytes;
3638
+ const accounting = deps.index.accounting(deviceId);
3639
+ totalUsedBytes += accounting.bytes;
3371
3640
  devices.push({
3372
3641
  deviceId,
3373
- usedBytes
3642
+ usedBytes: accounting.bytes,
3643
+ oldestMs: accounting.oldestMs
3374
3644
  });
3375
3645
  }
3376
3646
  const byPhysical = /* @__PURE__ */ new Map();
@@ -3557,6 +3827,24 @@ function buildRecordingProvider(deps) {
3557
3827
  },
3558
3828
  getStorageMigrationMoveStatus: async ({ jobId }) => deps.relocate?.list().find((job) => job.jobId === jobId) ?? null,
3559
3829
  cancelStorageMigrationMove: async ({ jobId }) => ({ cancelled: deps.relocate?.cancel(jobId) ?? false }),
3830
+ relocateFootage: async (input) => {
3831
+ if (!deps.relocate) throw new Error("relocation unavailable on this node");
3832
+ const jobId = deps.relocate.start(input);
3833
+ deps.logger.info("recorder: relocate job enqueued", {
3834
+ ...input.deviceId !== void 0 ? { tags: { deviceId: input.deviceId } } : {},
3835
+ meta: {
3836
+ jobId,
3837
+ from: input.fromLocationId,
3838
+ to: input.toLocationId,
3839
+ profiles: input.profiles ?? null
3840
+ }
3841
+ });
3842
+ return { jobId };
3843
+ },
3844
+ listRelocateJobs: async () => deps.relocate?.list() ?? [],
3845
+ cancelRelocateJob: async ({ jobId }) => ({ cancelled: deps.relocate?.cancel(jobId) ?? false }),
3846
+ planStorageRebalance: (input) => runRebalance(deps, input, false),
3847
+ startStorageRebalance: (input) => runRebalance(deps, input, true),
3560
3848
  getDeviceSettingsContribution: async ({ deviceId }) => {
3561
3849
  if (deps.isCameraDevice && !await deps.isCameraDevice(deviceId)) return null;
3562
3850
  return hydrateSchema(buildRecordingDeviceSchema(), {});
@@ -4688,6 +4976,33 @@ var PlacementService = class {
4688
4976
  return plan;
4689
4977
  }
4690
4978
  /**
4979
+ * The write targets and what each can still absorb — the SAME pool and the
4980
+ * SAME headroom `recompute` plans over.
4981
+ *
4982
+ * Exposed for the operator-armed rebalance (D116): weighing existing footage
4983
+ * against a second, independently-derived idea of "what fits" is how the two
4984
+ * halves end up disagreeing, and a rebalance that plans onto a location the
4985
+ * planner would never write to is a rebalance that undoes itself on the next
4986
+ * tick. A location absent from this list is not a write target at all.
4987
+ */
4988
+ async candidates() {
4989
+ return this.candidatesFor(this.pool());
4990
+ }
4991
+ /**
4992
+ * The plan in force: `<deviceId>:<profile>` → locationId. Empty when the
4993
+ * store cannot be read — a fallible read decides nothing (D49), and an empty
4994
+ * plan makes the rebalance report every pile as `unassigned` rather than
4995
+ * inventing moves.
4996
+ */
4997
+ async currentAssignments() {
4998
+ try {
4999
+ return new Map(Object.entries((await readPlacementState(this.deps.state)).assignments));
5000
+ } catch (err) {
5001
+ this.deps.logger.warn("recorder placement: assignment read failed — reporting no plan", { meta: { error: errMsg(err) } });
5002
+ return /* @__PURE__ */ new Map();
5003
+ }
5004
+ }
5005
+ /**
4691
5006
  * Turn each pool location into a planner candidate. Headroom is
4692
5007
  * `min(free − minFree floor, maxUsedGb − used)`, clamped at zero: the first
4693
5008
  * term is the per-location free-space guard the storage pressure manager also
@@ -6573,6 +6888,7 @@ var RecorderV2Addon = class extends BaseAddon {
6573
6888
  resumeForStorageMigration: (leaseId) => this.resumeForStorageMigration(leaseId),
6574
6889
  refreshStorageLocationsForMigration: (leaseId) => this.refreshStorageLocationsForMigration(leaseId),
6575
6890
  assertStorageMigrationLease: (leaseId) => this.assertStorageMigrationLease(leaseId),
6891
+ placementSnapshot: () => this.placementSnapshot(),
6576
6892
  dataDir: this.ctx.dataDir,
6577
6893
  playbackBaseUrl: () => this.playbackBaseUrl,
6578
6894
  isCameraDevice: async (deviceId) => {
@@ -7012,6 +7328,28 @@ var RecorderV2Addon = class extends BaseAddon {
7012
7328
  await this.placement.recompute(work);
7013
7329
  }
7014
7330
  /**
7331
+ * The plan in force plus each write target's headroom, for the operator-armed
7332
+ * rebalance (D116, Phase 2). Both come from the SAME `PlacementService` the
7333
+ * 30 s tick plans with: a rebalance computed against a second idea of "what
7334
+ * fits" would plan moves the next tick undoes.
7335
+ *
7336
+ * Throws when placement is not active (a non-recording node, or early boot) —
7337
+ * the rebalance is an operator action and a silent empty plan would read as
7338
+ * "nothing to do".
7339
+ */
7340
+ async placementSnapshot() {
7341
+ const placement = this.placement;
7342
+ if (!placement) throw new Error("placement is not active on this node");
7343
+ const [assignments, candidates] = await Promise.all([placement.currentAssignments(), placement.candidates()]);
7344
+ return {
7345
+ assignments,
7346
+ headroom: candidates.map((candidate) => ({
7347
+ locationId: candidate.locationId,
7348
+ headroomBytes: candidate.headroomBytes
7349
+ }))
7350
+ };
7351
+ }
7352
+ /**
7015
7353
  * Measured bytes/day for one (camera, profile) — the demand signal the
7016
7354
  * placement planner weights against each location's headroom. Derived from
7017
7355
  * the index's own accounting (segment paths encode bytes + start), so it
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../chunk-emK7D4bc.js");
3
- const require_worker_protocol = require("../worker-protocol-D6tXz9Or.js");
3
+ const require_worker_protocol = require("../worker-protocol-C7V1qIIA.js");
4
4
  let node_url = require("node:url");
5
5
  let node_fs = require("node:fs");
6
6
  node_fs = require_chunk.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { i as formatNativeLeaseKnobs, n as isWorkerRequest, o as resolveNativeLeaseKnobs, r as logLevelForLine } from "../worker-protocol-DuftUFEI.mjs";
1
+ import { i as formatNativeLeaseKnobs, n as isWorkerRequest, o as resolveNativeLeaseKnobs, r as logLevelForLine } from "../worker-protocol-CBO8nwQj.mjs";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import * as fs from "node:fs";
4
4
  import * as path$1 from "node:path";
@@ -1,8 +1,8 @@
1
- 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 l, u } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DU7u9EJt.mjs";
1
+ 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 l, u } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-NjvjJWz-.mjs";
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-ircnzt0s.mjs";
5
+ import { t as S } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D1CqbPrT.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);
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
3
3
  var e = {
4
4
  "@camstack/sdk": {
5
5
  name: "@camstack/sdk",
6
- version: "1.2.14",
6
+ version: "1.2.15",
7
7
  scope: ["default"],
8
8
  loaded: !1,
9
9
  from: "addon_stream_broker_widgets",
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.63",
21
+ version: "1.2.64",
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.42",
36
+ version: "1.2.43",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_stream_broker_widgets",