@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
|
@@ -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-
|
|
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-
|
|
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-
|
|
7
|
-
const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-
|
|
8
|
-
const require_worker_protocol = require("../worker-protocol-
|
|
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
|
-
|
|
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-
|
|
2
|
-
import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-
|
|
3
|
-
import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-
|
|
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
|
-
|
|
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
|
}
|
package/dist/recorder/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const require_chunk = require("../chunk-emK7D4bc.js");
|
|
2
|
-
const require_dist = require("../dist-
|
|
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
|
|
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
|
-
/**
|
|
931
|
-
*
|
|
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: "
|
|
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.
|
|
1029
|
+
this.queue.push(job.jobId);
|
|
1030
|
+
this.drain();
|
|
958
1031
|
return job.jobId;
|
|
959
1032
|
}
|
|
960
|
-
|
|
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", {
|
|
989
|
-
|
|
990
|
-
|
|
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
|
|
3372
|
-
totalUsedBytes +=
|
|
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
|