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