@camstack/addon-pipeline 1.2.73 → 1.2.75
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/{addon-utils-CQs-AjTJ.js → addon-utils-a_36HH_N.js} +4 -4
- package/dist/audio-analyzer/index.js +5 -6
- package/dist/audio-analyzer/index.mjs +3 -3
- package/dist/detection-pipeline/index.js +9 -10
- package/dist/detection-pipeline/index.mjs +5 -5
- package/dist/{dist-BAbvf81B.js → dist-6ZJCV0jY.js} +1901 -28
- package/dist/{dist-Dpr92hOt.mjs → dist-T_8jMw7d.mjs} +1866 -28
- package/dist/{event-loop-stall-monitor-BhKoybot.js → event-loop-stall-monitor-Cjof8vA1.js} +1 -1
- package/dist/{event-loop-stall-monitor-B7Wd2jo-.mjs → event-loop-stall-monitor-XeGmL4xr.mjs} +1 -1
- package/dist/lazy-sharp-6oymT_yf.mjs +341 -0
- package/dist/lazy-sharp-DvwPafeP.js +360 -0
- package/dist/motion-wasm/index.js +3 -3
- package/dist/motion-wasm/index.mjs +3 -3
- package/dist/pipeline-runner/index.js +109 -21
- package/dist/pipeline-runner/index.mjs +107 -18
- package/dist/recorder/index.js +138 -32
- package/dist/recorder/index.mjs +137 -30
- package/dist/session-decode/decode-worker-child.js +311 -126
- package/dist/session-decode/decode-worker-child.mjs +311 -123
- package/dist/stream-broker/_stub.js +94 -94
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cvh5La_s.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-H2OTmgR6.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-RYTOS6B-.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-DfsLsn2e.mjs +26 -0
- package/dist/stream-broker/{hostInit-Ci7epN77.mjs → hostInit-4XDN_Nt0.mjs} +2 -2
- package/dist/stream-broker/index.js +12 -13
- package/dist/stream-broker/index.mjs +3 -3
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-BFq9AIDa.js → worker-protocol-B0cbTXyw.js} +1 -1
- package/dist/{worker-protocol-C1FtUWvH.mjs → worker-protocol-SqED7Y9i.mjs} +1 -1
- package/package.json +1 -1
- package/dist/chunk-emK7D4bc.js +0 -35
- package/dist/lazy-sharp-B-mb8uWx.mjs +0 -20
- package/dist/lazy-sharp-CcYuXtsa.js +0 -25
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C_plRUtD.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CExO0piw.mjs +0 -26
- package/dist/{addon-utils-CZ2xo67g.mjs → addon-utils-A2S9D7pu.mjs} +2 -2
|
@@ -1,8 +1,8 @@
|
|
|
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-T_8jMw7d.mjs";
|
|
2
|
+
import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-XeGmL4xr.mjs";
|
|
3
|
+
import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-SqED7Y9i.mjs";
|
|
4
4
|
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
5
|
-
import { t as getSharp } from "../lazy-sharp-
|
|
5
|
+
import { t as getSharp } from "../lazy-sharp-6oymT_yf.mjs";
|
|
6
6
|
import { i as withDetectionIntent, n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-Ci7RXNGb.mjs";
|
|
7
7
|
import sharp from "sharp";
|
|
8
8
|
import { fork } from "node:child_process";
|
|
@@ -3828,6 +3828,18 @@ function isUsable(bbox) {
|
|
|
3828
3828
|
}
|
|
3829
3829
|
//#endregion
|
|
3830
3830
|
//#region src/pipeline-runner/index.ts
|
|
3831
|
+
/**
|
|
3832
|
+
* Bound on the frames whose cutter is bound but whose result has not come back.
|
|
3833
|
+
* A few per camera in flight; the ceiling only catches results that never
|
|
3834
|
+
* arrive (shed, inference timeout, detach mid-flight).
|
|
3835
|
+
*/
|
|
3836
|
+
var MAX_BOUND_FRAME_CUTTERS = 256;
|
|
3837
|
+
/** How often a camera reports its undispatched cuts. */
|
|
3838
|
+
var TILE_CUT_DROP_LOG_INTERVAL_MS = 6e4;
|
|
3839
|
+
/** The per-frame binding key — the same identity the retention handle carries. */
|
|
3840
|
+
function frameCutterKey(handle) {
|
|
3841
|
+
return `${handle.shmId}#${handle.slot}#${handle.seq}`;
|
|
3842
|
+
}
|
|
3831
3843
|
var DEFAULT_CONFIG = {
|
|
3832
3844
|
maxQueueDepth: 30,
|
|
3833
3845
|
maxConcurrentInferences: 16,
|
|
@@ -4143,6 +4155,20 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
|
|
|
4143
4155
|
*/
|
|
4144
4156
|
nativeCropRegistry = new NativeCropRegistry();
|
|
4145
4157
|
/**
|
|
4158
|
+
* Per-frame cutter bindings — handle key → the requester that can cut this
|
|
4159
|
+
* frame's subject tiles and end its hold.
|
|
4160
|
+
*
|
|
4161
|
+
* Deliberately NOT `nativeCropRegistry`. That one is a node-wide LRU capped
|
|
4162
|
+
* at 256 and shared by every camera to serve LATE cross-process crops; on the
|
|
4163
|
+
* live hub it sits pinned at its cap, so an entry's lifetime depends on how
|
|
4164
|
+
* busy the OTHER cameras are. Ending a frame's hold is a per-frame lifecycle
|
|
4165
|
+
* event and cannot be allowed to depend on that.
|
|
4166
|
+
*/
|
|
4167
|
+
frameCutters = /* @__PURE__ */ new Map();
|
|
4168
|
+
/** Per-camera tally of cuts that could not be dispatched, and why. */
|
|
4169
|
+
tileCutDrops = /* @__PURE__ */ new Map();
|
|
4170
|
+
tileCutDropLoggedAt = /* @__PURE__ */ new Map();
|
|
4171
|
+
/**
|
|
4146
4172
|
* Node-level RAM store of retained rgb detection frames, keyed by the
|
|
4147
4173
|
* `FrameHandle` that rides the inference-result event (shm-ring removal
|
|
4148
4174
|
* 2026-07-16 — replaces the `csr.` shm retention ring). Backs the
|
|
@@ -5150,23 +5176,80 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
|
|
|
5150
5176
|
* by an encode.
|
|
5151
5177
|
*/
|
|
5152
5178
|
cutSubjectTiles(deviceId, result, handle) {
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5179
|
+
const outcome = this.dispatchSubjectTiles(result, handle);
|
|
5180
|
+
if (outcome === "sent") return;
|
|
5181
|
+
const tally = this.tileCutDrops.get(deviceId) ?? {
|
|
5182
|
+
handleless: 0,
|
|
5183
|
+
unbound: 0,
|
|
5184
|
+
threw: 0
|
|
5185
|
+
};
|
|
5186
|
+
tally[outcome] += 1;
|
|
5187
|
+
this.tileCutDrops.set(deviceId, tally);
|
|
5188
|
+
const now = Date.now();
|
|
5189
|
+
if (now - (this.tileCutDropLoggedAt.get(deviceId) ?? 0) < TILE_CUT_DROP_LOG_INTERVAL_MS) return;
|
|
5190
|
+
this.tileCutDropLoggedAt.set(deviceId, now);
|
|
5191
|
+
this.ctx.logger.warn("subject tile cut NOT dispatched — the frame hold will not be released", {
|
|
5192
|
+
tags: { deviceId },
|
|
5193
|
+
meta: {
|
|
5194
|
+
...tally,
|
|
5195
|
+
lastReason: outcome,
|
|
5196
|
+
boundFrames: this.frameCutters.size
|
|
5197
|
+
}
|
|
5198
|
+
});
|
|
5199
|
+
}
|
|
5200
|
+
/**
|
|
5201
|
+
* Send the cut for one finished frame. Returns what happened, so the caller
|
|
5202
|
+
* can account for it.
|
|
5203
|
+
*
|
|
5204
|
+
* The cutter is looked up in {@link frameCutters} — the addon's OWN per-frame
|
|
5205
|
+
* binding — and NOT in `nativeCropRegistry`. That distinction is the bug this
|
|
5206
|
+
* method exists to not have again: the registry is a node-wide LRU capped at
|
|
5207
|
+
* 256 entries, shared by every camera, and it exists to serve LATE
|
|
5208
|
+
* cross-process crops. Measured on the live hub during the incident it sits
|
|
5209
|
+
* PINNED at `registrySize=256 registryCap=256`, so an entry's lifetime is a
|
|
5210
|
+
* function of how many other cameras are busy. Ending a frame's hold is a
|
|
5211
|
+
* per-frame lifecycle event and must never be subject to another camera's
|
|
5212
|
+
* eviction pressure.
|
|
5213
|
+
*/
|
|
5214
|
+
dispatchSubjectTiles(result, handle) {
|
|
5215
|
+
if (!handle) return "handleless";
|
|
5216
|
+
const key = frameCutterKey(handle);
|
|
5217
|
+
const cutter = this.frameCutters.get(key);
|
|
5218
|
+
this.frameCutters.delete(key);
|
|
5219
|
+
if (!cutter) return "unbound";
|
|
5156
5220
|
const boxes = planTileCuts(result.detections ?? [], {
|
|
5157
5221
|
width: result.width,
|
|
5158
5222
|
height: result.height
|
|
5159
5223
|
});
|
|
5160
5224
|
try {
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5225
|
+
cutter.cutTiles(boxes);
|
|
5226
|
+
return "sent";
|
|
5227
|
+
} catch {
|
|
5228
|
+
return "threw";
|
|
5229
|
+
}
|
|
5230
|
+
}
|
|
5231
|
+
/**
|
|
5232
|
+
* Bind a frame's cutter at DELIVERY, so its result can end its hold.
|
|
5233
|
+
*
|
|
5234
|
+
* Bounded by {@link MAX_BOUND_FRAME_CUTTERS}: a frame whose result never
|
|
5235
|
+
* comes back (a shed, a timed-out inference, a camera detaching mid-flight)
|
|
5236
|
+
* would otherwise leave its binding forever. The oldest is dropped and its
|
|
5237
|
+
* hold released directly — the worker's own frame bound would have reclaimed
|
|
5238
|
+
* it anyway, but releasing here is what keeps `holdOverflow` meaning "the
|
|
5239
|
+
* runner stopped answering" rather than "the runner forgot".
|
|
5240
|
+
*/
|
|
5241
|
+
bindFrameCutter(handle, cutter) {
|
|
5242
|
+
const key = frameCutterKey(handle);
|
|
5243
|
+
this.frameCutters.delete(key);
|
|
5244
|
+
this.frameCutters.set(key, cutter);
|
|
5245
|
+
while (this.frameCutters.size > MAX_BOUND_FRAME_CUTTERS) {
|
|
5246
|
+
const oldest = this.frameCutters.keys().next().value;
|
|
5247
|
+
if (oldest === void 0) break;
|
|
5248
|
+
const stale = this.frameCutters.get(oldest);
|
|
5249
|
+
this.frameCutters.delete(oldest);
|
|
5250
|
+
try {
|
|
5251
|
+
stale?.release();
|
|
5252
|
+
} catch {}
|
|
5170
5253
|
}
|
|
5171
5254
|
}
|
|
5172
5255
|
/**
|
|
@@ -5965,7 +6048,10 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
|
|
|
5965
6048
|
reservation = null;
|
|
5966
6049
|
if (!current) return;
|
|
5967
6050
|
const handle = current.detection ? retainedFrames?.write(config.deviceId, frame) ?? void 0 : void 0;
|
|
5968
|
-
if (handle && nativeCrop)
|
|
6051
|
+
if (handle && nativeCrop) {
|
|
6052
|
+
this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
|
|
6053
|
+
this.bindFrameCutter(handle, nativeCrop);
|
|
6054
|
+
}
|
|
5969
6055
|
if (this.enqueueSharedDetectionFrame(config.deviceId, frame, handle, current)) nativeCrop?.markInferred();
|
|
5970
6056
|
}
|
|
5971
6057
|
});
|
|
@@ -6489,7 +6575,10 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
|
|
|
6489
6575
|
onDecodedFrame: (frame, nativeCrop) => {
|
|
6490
6576
|
if (settled) return;
|
|
6491
6577
|
const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
|
|
6492
|
-
if (handle && nativeCrop)
|
|
6578
|
+
if (handle && nativeCrop) {
|
|
6579
|
+
this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
|
|
6580
|
+
this.bindFrameCutter(handle, nativeCrop);
|
|
6581
|
+
}
|
|
6493
6582
|
if (runner.enqueueOccupancyFrame(deviceId, frame, handle)) nativeCrop?.markInferred();
|
|
6494
6583
|
count++;
|
|
6495
6584
|
if (count >= frames) {
|
package/dist/recorder/index.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
const
|
|
2
|
-
const require_dist = require("../dist-BAbvf81B.js");
|
|
1
|
+
const require_dist = require("../dist-6ZJCV0jY.js");
|
|
3
2
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
4
|
-
const require_addon_utils = require("../addon-utils-
|
|
3
|
+
const require_addon_utils = require("../addon-utils-a_36HH_N.js");
|
|
5
4
|
let node_crypto = require("node:crypto");
|
|
6
5
|
let node_child_process = require("node:child_process");
|
|
7
|
-
let node_fs = require("node:fs");
|
|
8
6
|
let node_path = require("node:path");
|
|
9
|
-
node_path =
|
|
7
|
+
node_path = require_dist.__toESM(node_path);
|
|
8
|
+
let node_fs = require("node:fs");
|
|
10
9
|
let node_fs_promises = require("node:fs/promises");
|
|
11
10
|
//#region src/recorder/event-capture.ts
|
|
12
11
|
function markerFromMotion(data, pad) {
|
|
@@ -2251,7 +2250,7 @@ var DEFAULT_DEVICE_CONFIG = {
|
|
|
2251
2250
|
enabled: false,
|
|
2252
2251
|
bands: []
|
|
2253
2252
|
};
|
|
2254
|
-
function serialize(map) {
|
|
2253
|
+
function serialize$1(map) {
|
|
2255
2254
|
const out = {};
|
|
2256
2255
|
for (const [id, config] of map) out[String(id)] = config;
|
|
2257
2256
|
return out;
|
|
@@ -2306,7 +2305,7 @@ async function saveDeviceConfig(state, deviceId, config) {
|
|
|
2306
2305
|
};
|
|
2307
2306
|
const map = await readDeviceConfigs(state);
|
|
2308
2307
|
map.set(deviceId, validated);
|
|
2309
|
-
await state.set(serialize(map));
|
|
2308
|
+
await state.set(serialize$1(map));
|
|
2310
2309
|
return validated;
|
|
2311
2310
|
}
|
|
2312
2311
|
/**
|
|
@@ -2644,6 +2643,33 @@ function buildExportArgs(input) {
|
|
|
2644
2643
|
var RECORDING_EXPORTS_KEY = "recordingExports";
|
|
2645
2644
|
/** Persisted shape: export id → full ExportRecord. */
|
|
2646
2645
|
var RecordingExportsBlobSchema = require_dist.record(require_dist.string(), require_dist.ExportRecordSchema);
|
|
2646
|
+
/**
|
|
2647
|
+
* One in-flight mutation chain per durable handle.
|
|
2648
|
+
*
|
|
2649
|
+
* EVERY mutation here is a read-modify-write of the SAME blob, and both halves
|
|
2650
|
+
* are async — so two overlapping cycles read the same snapshot and the second
|
|
2651
|
+
* `set` discards whatever the first one added. Last writer wins, silently.
|
|
2652
|
+
*
|
|
2653
|
+
* That is not a hypothetical. The engine patches `progressPct` roughly once a
|
|
2654
|
+
* second for an in-flight render, unawaited; on 2026-08-14 a `createExport` for
|
|
2655
|
+
* camera 615 landed between one such patch's read and its write, the row was
|
|
2656
|
+
* erased, and five seconds later the poll answered `unknown export
|
|
2657
|
+
* "547ea182-…"`. The camera's whole night was lost to it — the scheduler read a
|
|
2658
|
+
* vanished row as a failed render and re-queued, forever.
|
|
2659
|
+
*
|
|
2660
|
+
* Serialising per handle is the smallest fix that makes the loss impossible:
|
|
2661
|
+
* the cycles queue instead of interleaving, and the queue is per state object
|
|
2662
|
+
* so two recorders in one process never contend. A `WeakMap` keeps a disposed
|
|
2663
|
+
* handle collectable. Reads stay OUTSIDE the chain — a blob replacement is
|
|
2664
|
+
* atomic, so a reader always sees one consistent generation.
|
|
2665
|
+
*/
|
|
2666
|
+
var mutationChains = /* @__PURE__ */ new WeakMap();
|
|
2667
|
+
/** Run `work` after every mutation already queued for `state`, never before. */
|
|
2668
|
+
async function serialize(state, work) {
|
|
2669
|
+
const next = (mutationChains.get(state) ?? Promise.resolve()).then(work, work);
|
|
2670
|
+
mutationChains.set(state, next.catch(() => void 0));
|
|
2671
|
+
return next;
|
|
2672
|
+
}
|
|
2647
2673
|
/** Read every persisted export row, keyed by export id. */
|
|
2648
2674
|
async function readExports(state) {
|
|
2649
2675
|
const blob = await state.get();
|
|
@@ -2652,26 +2678,30 @@ async function readExports(state) {
|
|
|
2652
2678
|
/** Insert or replace one export row. Validates at the boundary. Returns it. */
|
|
2653
2679
|
async function upsertExport(state, record) {
|
|
2654
2680
|
const validated = require_dist.ExportRecordSchema.parse(record);
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2681
|
+
return serialize(state, async () => {
|
|
2682
|
+
const map = await readExports(state);
|
|
2683
|
+
map.set(validated.id, validated);
|
|
2684
|
+
await state.set(Object.fromEntries(map));
|
|
2685
|
+
return validated;
|
|
2686
|
+
});
|
|
2659
2687
|
}
|
|
2660
2688
|
/**
|
|
2661
2689
|
* Shallow-merge a patch into one export row and persist. Returns the merged
|
|
2662
2690
|
* record, or null when the id is unknown.
|
|
2663
2691
|
*/
|
|
2664
2692
|
async function patchExport(state, id, patch) {
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2693
|
+
return serialize(state, async () => {
|
|
2694
|
+
const map = await readExports(state);
|
|
2695
|
+
const existing = map.get(id);
|
|
2696
|
+
if (!existing) return null;
|
|
2697
|
+
const merged = require_dist.ExportRecordSchema.parse({
|
|
2698
|
+
...existing,
|
|
2699
|
+
...patch
|
|
2700
|
+
});
|
|
2701
|
+
map.set(id, merged);
|
|
2702
|
+
await state.set(Object.fromEntries(map));
|
|
2703
|
+
return merged;
|
|
2671
2704
|
});
|
|
2672
|
-
map.set(id, merged);
|
|
2673
|
-
await state.set(Object.fromEntries(map));
|
|
2674
|
-
return merged;
|
|
2675
2705
|
}
|
|
2676
2706
|
/** Read one export row by id, or null. */
|
|
2677
2707
|
async function getExportRecord(state, id) {
|
|
@@ -3002,6 +3032,80 @@ async function markDownloaded(id, now, deps, scheduleDelete, graceMs) {
|
|
|
3002
3032
|
}, graceMs);
|
|
3003
3033
|
}
|
|
3004
3034
|
//#endregion
|
|
3035
|
+
//#region src/recorder/addon/export-reconcile.ts
|
|
3036
|
+
/**
|
|
3037
|
+
* States that can only mean "a render was owed and the process went away".
|
|
3038
|
+
*
|
|
3039
|
+
* There is no live render at reconcile time by construction: the engine is
|
|
3040
|
+
* built fresh with an empty queue, and this runs before anything is enqueued.
|
|
3041
|
+
*/
|
|
3042
|
+
function isInterrupted(record) {
|
|
3043
|
+
return record.state === "queued" || record.state === "rendering";
|
|
3044
|
+
}
|
|
3045
|
+
/**
|
|
3046
|
+
* Hand every interrupted row back to the engine, once, at boot.
|
|
3047
|
+
*
|
|
3048
|
+
* A row past its `expiresAt` is FAILED rather than re-rendered: hours of
|
|
3049
|
+
* footage re-encoded for a file the janitor is about to drop costs a recording
|
|
3050
|
+
* node an ffmpeg for nothing. Failing it is also what makes the outcome
|
|
3051
|
+
* visible — a row that silently stays `queued` forever is the defect this
|
|
3052
|
+
* module exists to remove, and swapping it for a quieter one would not be a
|
|
3053
|
+
* fix.
|
|
3054
|
+
*/
|
|
3055
|
+
async function reconcileInterruptedExports(deps) {
|
|
3056
|
+
const now = deps.now();
|
|
3057
|
+
const interrupted = (await listExportsFor(deps.state)).filter(isInterrupted);
|
|
3058
|
+
if (interrupted.length === 0) return {
|
|
3059
|
+
requeued: 0,
|
|
3060
|
+
expired: 0
|
|
3061
|
+
};
|
|
3062
|
+
let requeued = 0;
|
|
3063
|
+
let expired = 0;
|
|
3064
|
+
for (const row of interrupted) {
|
|
3065
|
+
const tags = { deviceId: row.deviceId };
|
|
3066
|
+
const meta = {
|
|
3067
|
+
exportId: row.id,
|
|
3068
|
+
wasState: row.state,
|
|
3069
|
+
fromMs: row.fromMs,
|
|
3070
|
+
toMs: row.toMs,
|
|
3071
|
+
createdAt: row.createdAt
|
|
3072
|
+
};
|
|
3073
|
+
if (row.expiresAt <= now) {
|
|
3074
|
+
expired += 1;
|
|
3075
|
+
await patchExport(deps.state, row.id, {
|
|
3076
|
+
state: "failed",
|
|
3077
|
+
progressPct: null,
|
|
3078
|
+
error: "interrupted by a restart and expired before it could be rendered"
|
|
3079
|
+
});
|
|
3080
|
+
deps.logger.warn("export abandoned: interrupted by a restart, and already expired", {
|
|
3081
|
+
tags,
|
|
3082
|
+
meta
|
|
3083
|
+
});
|
|
3084
|
+
continue;
|
|
3085
|
+
}
|
|
3086
|
+
requeued += 1;
|
|
3087
|
+
await patchExport(deps.state, row.id, {
|
|
3088
|
+
state: "queued",
|
|
3089
|
+
progressPct: null,
|
|
3090
|
+
error: null
|
|
3091
|
+
});
|
|
3092
|
+
deps.logger.warn("export re-queued: it was interrupted by a restart", {
|
|
3093
|
+
tags,
|
|
3094
|
+
meta
|
|
3095
|
+
});
|
|
3096
|
+
deps.engine.enqueue(row.id);
|
|
3097
|
+
}
|
|
3098
|
+
deps.logger.info("recording exports reconciled after restart", { meta: {
|
|
3099
|
+
interrupted: interrupted.length,
|
|
3100
|
+
requeued,
|
|
3101
|
+
expired
|
|
3102
|
+
} });
|
|
3103
|
+
return {
|
|
3104
|
+
requeued,
|
|
3105
|
+
expired
|
|
3106
|
+
};
|
|
3107
|
+
}
|
|
3108
|
+
//#endregion
|
|
3005
3109
|
//#region src/recorder/playlist.ts
|
|
3006
3110
|
/** A VOD variant playlist with an absolute wall-clock anchor per segment. */
|
|
3007
3111
|
function buildVariantPlaylist(segments) {
|
|
@@ -7479,23 +7583,25 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
7479
7583
|
this.retentionSweeper?.start();
|
|
7480
7584
|
}
|
|
7481
7585
|
/**
|
|
7482
|
-
* Resume interrupted exports and arm the expiry sweep.
|
|
7483
|
-
*
|
|
7484
|
-
*
|
|
7485
|
-
*
|
|
7586
|
+
* Resume interrupted exports and arm the expiry sweep.
|
|
7587
|
+
*
|
|
7588
|
+
* The resume itself lives in `export-reconcile.ts` — pure, tested, and loud
|
|
7589
|
+
* about every row it moves. It used to be an untested loop inlined here that
|
|
7590
|
+
* said nothing at all, so "why is this export still queued an hour later" had
|
|
7591
|
+
* no answer in the log, and a row whose life had already run out was
|
|
7592
|
+
* re-rendered anyway. Both halves are best-effort: a failure never blocks
|
|
7593
|
+
* boot.
|
|
7486
7594
|
*/
|
|
7487
7595
|
async bootstrapExports() {
|
|
7488
7596
|
const engine = this.exportEngine;
|
|
7489
7597
|
if (!engine) return;
|
|
7490
|
-
const state = this.exportsStore();
|
|
7491
7598
|
try {
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7498
|
-
}
|
|
7599
|
+
await reconcileInterruptedExports({
|
|
7600
|
+
state: this.exportsStore(),
|
|
7601
|
+
engine,
|
|
7602
|
+
now: () => Date.now(),
|
|
7603
|
+
logger: this.ctx.logger.child("export")
|
|
7604
|
+
});
|
|
7499
7605
|
} catch (err) {
|
|
7500
7606
|
this.ctx.logger.warn("recorder: export boot re-queue failed", { meta: { error: require_dist.errMsg(err) } });
|
|
7501
7607
|
}
|
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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-T_8jMw7d.mjs";
|
|
2
2
|
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
3
|
-
import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-
|
|
3
|
+
import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-A2S9D7pu.mjs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { createReadStream, promises, realpathSync } from "node:fs";
|
|
7
6
|
import path from "node:path";
|
|
7
|
+
import { createReadStream, promises, realpathSync } from "node:fs";
|
|
8
8
|
import { open } from "node:fs/promises";
|
|
9
9
|
//#region src/recorder/event-capture.ts
|
|
10
10
|
function markerFromMotion(data, pad) {
|
|
@@ -2249,7 +2249,7 @@ var DEFAULT_DEVICE_CONFIG = {
|
|
|
2249
2249
|
enabled: false,
|
|
2250
2250
|
bands: []
|
|
2251
2251
|
};
|
|
2252
|
-
function serialize(map) {
|
|
2252
|
+
function serialize$1(map) {
|
|
2253
2253
|
const out = {};
|
|
2254
2254
|
for (const [id, config] of map) out[String(id)] = config;
|
|
2255
2255
|
return out;
|
|
@@ -2304,7 +2304,7 @@ async function saveDeviceConfig(state, deviceId, config) {
|
|
|
2304
2304
|
};
|
|
2305
2305
|
const map = await readDeviceConfigs(state);
|
|
2306
2306
|
map.set(deviceId, validated);
|
|
2307
|
-
await state.set(serialize(map));
|
|
2307
|
+
await state.set(serialize$1(map));
|
|
2308
2308
|
return validated;
|
|
2309
2309
|
}
|
|
2310
2310
|
/**
|
|
@@ -2642,6 +2642,33 @@ function buildExportArgs(input) {
|
|
|
2642
2642
|
var RECORDING_EXPORTS_KEY = "recordingExports";
|
|
2643
2643
|
/** Persisted shape: export id → full ExportRecord. */
|
|
2644
2644
|
var RecordingExportsBlobSchema = record(string(), ExportRecordSchema);
|
|
2645
|
+
/**
|
|
2646
|
+
* One in-flight mutation chain per durable handle.
|
|
2647
|
+
*
|
|
2648
|
+
* EVERY mutation here is a read-modify-write of the SAME blob, and both halves
|
|
2649
|
+
* are async — so two overlapping cycles read the same snapshot and the second
|
|
2650
|
+
* `set` discards whatever the first one added. Last writer wins, silently.
|
|
2651
|
+
*
|
|
2652
|
+
* That is not a hypothetical. The engine patches `progressPct` roughly once a
|
|
2653
|
+
* second for an in-flight render, unawaited; on 2026-08-14 a `createExport` for
|
|
2654
|
+
* camera 615 landed between one such patch's read and its write, the row was
|
|
2655
|
+
* erased, and five seconds later the poll answered `unknown export
|
|
2656
|
+
* "547ea182-…"`. The camera's whole night was lost to it — the scheduler read a
|
|
2657
|
+
* vanished row as a failed render and re-queued, forever.
|
|
2658
|
+
*
|
|
2659
|
+
* Serialising per handle is the smallest fix that makes the loss impossible:
|
|
2660
|
+
* the cycles queue instead of interleaving, and the queue is per state object
|
|
2661
|
+
* so two recorders in one process never contend. A `WeakMap` keeps a disposed
|
|
2662
|
+
* handle collectable. Reads stay OUTSIDE the chain — a blob replacement is
|
|
2663
|
+
* atomic, so a reader always sees one consistent generation.
|
|
2664
|
+
*/
|
|
2665
|
+
var mutationChains = /* @__PURE__ */ new WeakMap();
|
|
2666
|
+
/** Run `work` after every mutation already queued for `state`, never before. */
|
|
2667
|
+
async function serialize(state, work) {
|
|
2668
|
+
const next = (mutationChains.get(state) ?? Promise.resolve()).then(work, work);
|
|
2669
|
+
mutationChains.set(state, next.catch(() => void 0));
|
|
2670
|
+
return next;
|
|
2671
|
+
}
|
|
2645
2672
|
/** Read every persisted export row, keyed by export id. */
|
|
2646
2673
|
async function readExports(state) {
|
|
2647
2674
|
const blob = await state.get();
|
|
@@ -2650,26 +2677,30 @@ async function readExports(state) {
|
|
|
2650
2677
|
/** Insert or replace one export row. Validates at the boundary. Returns it. */
|
|
2651
2678
|
async function upsertExport(state, record) {
|
|
2652
2679
|
const validated = ExportRecordSchema.parse(record);
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2680
|
+
return serialize(state, async () => {
|
|
2681
|
+
const map = await readExports(state);
|
|
2682
|
+
map.set(validated.id, validated);
|
|
2683
|
+
await state.set(Object.fromEntries(map));
|
|
2684
|
+
return validated;
|
|
2685
|
+
});
|
|
2657
2686
|
}
|
|
2658
2687
|
/**
|
|
2659
2688
|
* Shallow-merge a patch into one export row and persist. Returns the merged
|
|
2660
2689
|
* record, or null when the id is unknown.
|
|
2661
2690
|
*/
|
|
2662
2691
|
async function patchExport(state, id, patch) {
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2692
|
+
return serialize(state, async () => {
|
|
2693
|
+
const map = await readExports(state);
|
|
2694
|
+
const existing = map.get(id);
|
|
2695
|
+
if (!existing) return null;
|
|
2696
|
+
const merged = ExportRecordSchema.parse({
|
|
2697
|
+
...existing,
|
|
2698
|
+
...patch
|
|
2699
|
+
});
|
|
2700
|
+
map.set(id, merged);
|
|
2701
|
+
await state.set(Object.fromEntries(map));
|
|
2702
|
+
return merged;
|
|
2669
2703
|
});
|
|
2670
|
-
map.set(id, merged);
|
|
2671
|
-
await state.set(Object.fromEntries(map));
|
|
2672
|
-
return merged;
|
|
2673
2704
|
}
|
|
2674
2705
|
/** Read one export row by id, or null. */
|
|
2675
2706
|
async function getExportRecord(state, id) {
|
|
@@ -3000,6 +3031,80 @@ async function markDownloaded(id, now, deps, scheduleDelete, graceMs) {
|
|
|
3000
3031
|
}, graceMs);
|
|
3001
3032
|
}
|
|
3002
3033
|
//#endregion
|
|
3034
|
+
//#region src/recorder/addon/export-reconcile.ts
|
|
3035
|
+
/**
|
|
3036
|
+
* States that can only mean "a render was owed and the process went away".
|
|
3037
|
+
*
|
|
3038
|
+
* There is no live render at reconcile time by construction: the engine is
|
|
3039
|
+
* built fresh with an empty queue, and this runs before anything is enqueued.
|
|
3040
|
+
*/
|
|
3041
|
+
function isInterrupted(record) {
|
|
3042
|
+
return record.state === "queued" || record.state === "rendering";
|
|
3043
|
+
}
|
|
3044
|
+
/**
|
|
3045
|
+
* Hand every interrupted row back to the engine, once, at boot.
|
|
3046
|
+
*
|
|
3047
|
+
* A row past its `expiresAt` is FAILED rather than re-rendered: hours of
|
|
3048
|
+
* footage re-encoded for a file the janitor is about to drop costs a recording
|
|
3049
|
+
* node an ffmpeg for nothing. Failing it is also what makes the outcome
|
|
3050
|
+
* visible — a row that silently stays `queued` forever is the defect this
|
|
3051
|
+
* module exists to remove, and swapping it for a quieter one would not be a
|
|
3052
|
+
* fix.
|
|
3053
|
+
*/
|
|
3054
|
+
async function reconcileInterruptedExports(deps) {
|
|
3055
|
+
const now = deps.now();
|
|
3056
|
+
const interrupted = (await listExportsFor(deps.state)).filter(isInterrupted);
|
|
3057
|
+
if (interrupted.length === 0) return {
|
|
3058
|
+
requeued: 0,
|
|
3059
|
+
expired: 0
|
|
3060
|
+
};
|
|
3061
|
+
let requeued = 0;
|
|
3062
|
+
let expired = 0;
|
|
3063
|
+
for (const row of interrupted) {
|
|
3064
|
+
const tags = { deviceId: row.deviceId };
|
|
3065
|
+
const meta = {
|
|
3066
|
+
exportId: row.id,
|
|
3067
|
+
wasState: row.state,
|
|
3068
|
+
fromMs: row.fromMs,
|
|
3069
|
+
toMs: row.toMs,
|
|
3070
|
+
createdAt: row.createdAt
|
|
3071
|
+
};
|
|
3072
|
+
if (row.expiresAt <= now) {
|
|
3073
|
+
expired += 1;
|
|
3074
|
+
await patchExport(deps.state, row.id, {
|
|
3075
|
+
state: "failed",
|
|
3076
|
+
progressPct: null,
|
|
3077
|
+
error: "interrupted by a restart and expired before it could be rendered"
|
|
3078
|
+
});
|
|
3079
|
+
deps.logger.warn("export abandoned: interrupted by a restart, and already expired", {
|
|
3080
|
+
tags,
|
|
3081
|
+
meta
|
|
3082
|
+
});
|
|
3083
|
+
continue;
|
|
3084
|
+
}
|
|
3085
|
+
requeued += 1;
|
|
3086
|
+
await patchExport(deps.state, row.id, {
|
|
3087
|
+
state: "queued",
|
|
3088
|
+
progressPct: null,
|
|
3089
|
+
error: null
|
|
3090
|
+
});
|
|
3091
|
+
deps.logger.warn("export re-queued: it was interrupted by a restart", {
|
|
3092
|
+
tags,
|
|
3093
|
+
meta
|
|
3094
|
+
});
|
|
3095
|
+
deps.engine.enqueue(row.id);
|
|
3096
|
+
}
|
|
3097
|
+
deps.logger.info("recording exports reconciled after restart", { meta: {
|
|
3098
|
+
interrupted: interrupted.length,
|
|
3099
|
+
requeued,
|
|
3100
|
+
expired
|
|
3101
|
+
} });
|
|
3102
|
+
return {
|
|
3103
|
+
requeued,
|
|
3104
|
+
expired
|
|
3105
|
+
};
|
|
3106
|
+
}
|
|
3107
|
+
//#endregion
|
|
3003
3108
|
//#region src/recorder/playlist.ts
|
|
3004
3109
|
/** A VOD variant playlist with an absolute wall-clock anchor per segment. */
|
|
3005
3110
|
function buildVariantPlaylist(segments) {
|
|
@@ -7477,23 +7582,25 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
7477
7582
|
this.retentionSweeper?.start();
|
|
7478
7583
|
}
|
|
7479
7584
|
/**
|
|
7480
|
-
* Resume interrupted exports and arm the expiry sweep.
|
|
7481
|
-
*
|
|
7482
|
-
*
|
|
7483
|
-
*
|
|
7585
|
+
* Resume interrupted exports and arm the expiry sweep.
|
|
7586
|
+
*
|
|
7587
|
+
* The resume itself lives in `export-reconcile.ts` — pure, tested, and loud
|
|
7588
|
+
* about every row it moves. It used to be an untested loop inlined here that
|
|
7589
|
+
* said nothing at all, so "why is this export still queued an hour later" had
|
|
7590
|
+
* no answer in the log, and a row whose life had already run out was
|
|
7591
|
+
* re-rendered anyway. Both halves are best-effort: a failure never blocks
|
|
7592
|
+
* boot.
|
|
7484
7593
|
*/
|
|
7485
7594
|
async bootstrapExports() {
|
|
7486
7595
|
const engine = this.exportEngine;
|
|
7487
7596
|
if (!engine) return;
|
|
7488
|
-
const state = this.exportsStore();
|
|
7489
7597
|
try {
|
|
7490
|
-
|
|
7491
|
-
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
}
|
|
7598
|
+
await reconcileInterruptedExports({
|
|
7599
|
+
state: this.exportsStore(),
|
|
7600
|
+
engine,
|
|
7601
|
+
now: () => Date.now(),
|
|
7602
|
+
logger: this.ctx.logger.child("export")
|
|
7603
|
+
});
|
|
7497
7604
|
} catch (err) {
|
|
7498
7605
|
this.ctx.logger.warn("recorder: export boot re-queue failed", { meta: { error: errMsg(err) } });
|
|
7499
7606
|
}
|