@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.
- package/dist/audio-analyzer/index.js +1 -1
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +32 -30
- package/dist/detection-pipeline/index.mjs +32 -30
- package/dist/{dist-DGLqaonv.mjs → dist-BPaa4_z6.mjs} +508 -8
- package/dist/{dist-tJF2BzWi.js → dist-cR-pvD_z.js} +508 -8
- package/dist/{event-loop-stall-monitor-C6_VDupx.mjs → event-loop-stall-monitor-B79REtL2.mjs} +1 -1
- package/dist/{event-loop-stall-monitor-D9d2e49f.js → event-loop-stall-monitor-STRdnQWm.js} +1 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +8 -5
- package/dist/pipeline-runner/index.mjs +8 -5
- package/dist/recorder/index.js +370 -32
- package/dist/recorder/index.mjs +370 -32
- package/dist/session-decode/decode-worker-child.js +1 -1
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- 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
- 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
- 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
- package/dist/stream-broker/{hostInit-DxqtIjAe.mjs → hostInit-B12DHOv7.mjs} +3 -3
- package/dist/stream-broker/index.js +501 -32
- package/dist/stream-broker/index.mjs +501 -32
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-D6tXz9Or.js → worker-protocol-C7V1qIIA.js} +1 -1
- package/dist/{worker-protocol-DuftUFEI.mjs → worker-protocol-CBO8nwQj.mjs} +1 -1
- package/package.json +1 -1
- 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
package/dist/recorder/index.mjs
CHANGED
|
@@ -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-
|
|
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
|
|
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
|
-
/**
|
|
929
|
-
*
|
|
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: "
|
|
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.
|
|
1027
|
+
this.queue.push(job.jobId);
|
|
1028
|
+
this.drain();
|
|
956
1029
|
return job.jobId;
|
|
957
1030
|
}
|
|
958
|
-
|
|
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", {
|
|
987
|
-
|
|
988
|
-
|
|
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
|
|
3370
|
-
totalUsedBytes +=
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
36
|
+
version: "1.2.43",
|
|
37
37
|
scope: ["default"],
|
|
38
38
|
loaded: !1,
|
|
39
39
|
from: "addon_stream_broker_widgets",
|