@camstack/addon-pipeline 1.2.136 → 1.2.138
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 +7 -7
- package/dist/audio-analyzer/index.mjs +4 -4
- package/dist/detection-pipeline/index.js +61 -23
- package/dist/detection-pipeline/index.mjs +53 -15
- package/dist/{dist-BzLiQ8Ue.js → dist-BuIP0499.js} +315 -25
- package/dist/{dist-C1dXLLQq.mjs → dist-CmEYRK7G.mjs} +304 -26
- package/dist/{event-loop-stall-monitor-DjV4Bow7.js → event-loop-stall-monitor-Bef2jXMX.js} +1 -1
- package/dist/{event-loop-stall-monitor-CU-xxX5c.mjs → event-loop-stall-monitor-JGTFb0Uv.mjs} +1 -1
- package/dist/{lazy-sharp-DxWmhTqk.js → lazy-sharp-DH2eVehm.js} +3 -3
- package/dist/motion-wasm/index.js +3 -3
- package/dist/motion-wasm/index.mjs +3 -3
- package/dist/node-ChdX9lv7.js +721 -0
- package/dist/node-J07GBWT3.mjs +690 -0
- package/dist/pipeline-runner/index.js +87 -18
- package/dist/pipeline-runner/index.mjs +87 -18
- package/dist/{process-memory-CZK3D3kM.js → process-memory-CJoZemF4.js} +2 -2
- package/dist/{process-memory-CaEwi5Cd.mjs → process-memory-DAKxUb9d.mjs} +2 -2
- package/dist/recorder/index.js +39 -7
- package/dist/recorder/index.mjs +36 -4
- package/dist/{segment-demux-js-BbpqygHL.js → segment-demux-js-BgXbXZnp.js} +1 -1
- package/dist/{segment-demux-js-njRO85gd.mjs → segment-demux-js-Ck9ly4bX.mjs} +1 -1
- package/dist/session-decode/decode-worker-child.js +115 -5
- package/dist/session-decode/decode-worker-child.mjs +115 -5
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CLtMmF1E.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cad8960O.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-xdmO8wYp.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-BrTM_tsb.mjs +26 -0
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-CP7Xhd8J.mjs → hostInit-Gd3OcDIa.mjs} +3 -3
- package/dist/stream-broker/index.js +245 -535
- package/dist/stream-broker/index.mjs +242 -530
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-DvTZBvh2.js → worker-protocol-Bpz62eyB.js} +2 -1
- package/dist/{worker-protocol-DxsVx0G7.mjs → worker-protocol-wMS29PG9.mjs} +2 -1
- package/package.json +17 -3
- package/dist/addon-utils-CKf8kbwh.js +0 -625
- package/dist/addon-utils-CgBCF-mE.mjs +0 -583
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqplWP1i.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-DKMXG57X.mjs +0 -26
- package/dist/{lazy-sharp-BtT5x-qP.mjs → lazy-sharp-DXsqwpph.mjs} +1 -1
|
@@ -2,27 +2,26 @@ 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-BuIP0499.js");
|
|
6
|
+
const require_node = require("../node-ChdX9lv7.js");
|
|
6
7
|
const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
|
|
7
8
|
const require_remote_restream = require("../remote-restream-CO36Sr30.js");
|
|
8
9
|
const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
|
|
9
|
-
const require_segment_demux_js = require("../segment-demux-js-
|
|
10
|
+
const require_segment_demux_js = require("../segment-demux-js-BgXbXZnp.js");
|
|
10
11
|
let node_crypto = require("node:crypto");
|
|
11
|
-
node_crypto = require_dist.__toESM(node_crypto
|
|
12
|
+
node_crypto = require_dist.__toESM(node_crypto);
|
|
13
|
+
let node_fs = require("node:fs");
|
|
14
|
+
node_fs = require_dist.__toESM(node_fs);
|
|
15
|
+
let node_path = require("node:path");
|
|
16
|
+
node_path = require_dist.__toESM(node_path);
|
|
12
17
|
let node_child_process = require("node:child_process");
|
|
18
|
+
let node_fs_promises = require("node:fs/promises");
|
|
13
19
|
let node_url = require("node:url");
|
|
14
20
|
let node_module = require("node:module");
|
|
15
|
-
let node_path = require("node:path");
|
|
16
|
-
require_dist.__toESM(node_path, 1);
|
|
17
|
-
node_path = require_dist.__toESM(node_path);
|
|
18
21
|
let node_os = require("node:os");
|
|
19
22
|
node_os = require_dist.__toESM(node_os);
|
|
20
|
-
let node_fs = require("node:fs");
|
|
21
|
-
require_dist.__toESM(node_fs, 1);
|
|
22
|
-
node_fs = require_dist.__toESM(node_fs);
|
|
23
23
|
let node_net = require("node:net");
|
|
24
24
|
node_net = require_dist.__toESM(node_net);
|
|
25
|
-
let node_fs_promises = require("node:fs/promises");
|
|
26
25
|
let node_dgram = require("node:dgram");
|
|
27
26
|
let net = require("net");
|
|
28
27
|
let events = require("events");
|
|
@@ -1266,6 +1265,9 @@ var FfmpegPushReader = class {
|
|
|
1266
1265
|
spawnFn;
|
|
1267
1266
|
callbacks;
|
|
1268
1267
|
logLabel;
|
|
1268
|
+
claimCost;
|
|
1269
|
+
/** The claim for the child currently running, if any. */
|
|
1270
|
+
costClaim = require_node.NO_COST_CLAIM;
|
|
1269
1271
|
child = null;
|
|
1270
1272
|
demux = null;
|
|
1271
1273
|
audioCodec;
|
|
@@ -1283,6 +1285,7 @@ var FfmpegPushReader = class {
|
|
|
1283
1285
|
this.spawnFn = options.spawnFn ?? node_child_process.spawn;
|
|
1284
1286
|
this.callbacks = callbacks;
|
|
1285
1287
|
this.logLabel = logLabel;
|
|
1288
|
+
this.claimCost = options.claimCost;
|
|
1286
1289
|
}
|
|
1287
1290
|
/** Spawn ffmpeg and pipe its MPEG-TS stdout into the demux. */
|
|
1288
1291
|
connect() {
|
|
@@ -1304,6 +1307,7 @@ var FfmpegPushReader = class {
|
|
|
1304
1307
|
return;
|
|
1305
1308
|
}
|
|
1306
1309
|
this.child = child;
|
|
1310
|
+
this.costClaim = this.claimCost?.(child.pid) ?? require_node.NO_COST_CLAIM;
|
|
1307
1311
|
child.stdin?.on("error", () => {});
|
|
1308
1312
|
child.stdout?.on("data", (chunk) => demux.push(chunk));
|
|
1309
1313
|
child.stderr?.on("data", (chunk) => this.appendStderr(chunk));
|
|
@@ -1397,6 +1401,8 @@ var FfmpegPushReader = class {
|
|
|
1397
1401
|
fireTeardown() {
|
|
1398
1402
|
if (this.toreDown) return;
|
|
1399
1403
|
this.toreDown = true;
|
|
1404
|
+
this.costClaim.release();
|
|
1405
|
+
this.costClaim = require_node.NO_COST_CLAIM;
|
|
1400
1406
|
this.callbacks.onTeardown?.();
|
|
1401
1407
|
}
|
|
1402
1408
|
/** SIGTERM, then SIGKILL if the child has not actually exited (gate on exit
|
|
@@ -2239,6 +2245,20 @@ function appendAudioMediaBlock(videoSdp, audioBlock) {
|
|
|
2239
2245
|
return `${videoSdp.replace(/[\r\n]+$/, "")}\r\n${audioBlock}\r\n`;
|
|
2240
2246
|
}
|
|
2241
2247
|
//#endregion
|
|
2248
|
+
//#region src/stream-broker/rtsp/join-burst-admission.ts
|
|
2249
|
+
/**
|
|
2250
|
+
* Decide whether this session may take this burst.
|
|
2251
|
+
*
|
|
2252
|
+
* Pure and total: no clock, no logger, no restreamer state. The restreamer maps
|
|
2253
|
+
* the result onto its counters and its `continue`.
|
|
2254
|
+
*/
|
|
2255
|
+
function admitJoinBurst(input) {
|
|
2256
|
+
if (input.muted && !input.detectionIntent) return !input.fromRing || input.spanMs > input.liveEdgeMaxSpanMs ? "live-edge" : "none";
|
|
2257
|
+
if (!input.detectionIntent && input.spanMs > input.bound.maxSpanMs) return "span";
|
|
2258
|
+
if (input.packets > input.bound.maxPackets) return "packets";
|
|
2259
|
+
return "none";
|
|
2260
|
+
}
|
|
2261
|
+
//#endregion
|
|
2242
2262
|
//#region src/stream-broker/rtsp/rtsp-types.ts
|
|
2243
2263
|
/** RTSP method constants. */
|
|
2244
2264
|
var RTSP_METHODS = [
|
|
@@ -2865,8 +2885,6 @@ var RtspRestreamer = class {
|
|
|
2865
2885
|
if (ready.length === 0) return;
|
|
2866
2886
|
const preRollPlans = /* @__PURE__ */ new Map();
|
|
2867
2887
|
const bound = this.currentPrimeBound();
|
|
2868
|
-
const overSpan = spanMs > bound.maxSpanMs;
|
|
2869
|
-
const overPackets = primePackets.length > bound.maxPackets;
|
|
2870
2888
|
/**
|
|
2871
2889
|
* Resolve (and memoize) the plan for one intent.
|
|
2872
2890
|
*
|
|
@@ -2932,14 +2950,18 @@ var RtspRestreamer = class {
|
|
|
2932
2950
|
continue;
|
|
2933
2951
|
}
|
|
2934
2952
|
}
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2953
|
+
const withhold = admitJoinBurst({
|
|
2954
|
+
muted: session.isMuted(),
|
|
2955
|
+
detectionIntent,
|
|
2956
|
+
fromRing,
|
|
2957
|
+
spanMs,
|
|
2958
|
+
packets: primePackets.length,
|
|
2959
|
+
bound,
|
|
2960
|
+
liveEdgeMaxSpanMs: LIVE_EDGE_MAX_SPAN_MS
|
|
2961
|
+
});
|
|
2962
|
+
if (withhold !== "none") {
|
|
2963
|
+
if (withhold === "live-edge") this.dropsWithheldMutedLiveEdge++;
|
|
2964
|
+
else if (withhold === "span") this.dropsWithheldSpan++;
|
|
2943
2965
|
else this.dropsWithheldPackets++;
|
|
2944
2966
|
withheld++;
|
|
2945
2967
|
continue;
|
|
@@ -4125,6 +4147,90 @@ function buildAudioCodecCapApi(api) {
|
|
|
4125
4147
|
flushEncode: (input) => router.flushEncode.mutate(input)
|
|
4126
4148
|
};
|
|
4127
4149
|
}
|
|
4150
|
+
//#endregion
|
|
4151
|
+
//#region src/stream-broker/stream-broker/catalog-pull-backoff.ts
|
|
4152
|
+
/**
|
|
4153
|
+
* CATALOG-PULL BACKOFF — how often the broker asks a camera whose
|
|
4154
|
+
* `stream-catalog` pull keeps failing.
|
|
4155
|
+
*
|
|
4156
|
+
* ## What this exists to stop
|
|
4157
|
+
*
|
|
4158
|
+
* 2026-08-25, device 3629 ("Videocamera porta retro", Reolink). Its Baichuan
|
|
4159
|
+
* session expired under a socket that stayed open, so every
|
|
4160
|
+
* `stream-catalog.getCatalog` answered `buildVideoStreamOptions failed …
|
|
4161
|
+
* responseCode 400`. The reconcile poll asked again 30 s later. And again. For
|
|
4162
|
+
* 35 minutes, ~70 identical failures, each one a login attempt, a 10 s timeout
|
|
4163
|
+
* budget and a `debug` line nobody was reading.
|
|
4164
|
+
*
|
|
4165
|
+
* The fleet was never at risk from that camera — `reconcileAllCatalogs` fans
|
|
4166
|
+
* out with `Promise.allSettled` and `reconcileDeviceCatalogNow` catches its own
|
|
4167
|
+
* pull, so exactly one device was omitted (that isolation is frozen by
|
|
4168
|
+
* `scripts/check-catalog-per-device-isolation.ts`). What was wrong is that the
|
|
4169
|
+
* omission was invisible and the retry was a flat 30 s hammer that could not
|
|
4170
|
+
* have recovered anyway.
|
|
4171
|
+
*
|
|
4172
|
+
* ## The shape
|
|
4173
|
+
*
|
|
4174
|
+
* The ladder is {@link BOOT_RECOVERY_BACKOFF_MS} — the repo's ONE retry
|
|
4175
|
+
* schedule for work that can fail for reasons that pass: two cheap attempts
|
|
4176
|
+
* (15 s, 60 s) for a blip, then the long tail (5 m → 90 m) for a camera that
|
|
4177
|
+
* is genuinely gone.
|
|
4178
|
+
*
|
|
4179
|
+
* ## The one place this DIVERGES from boot recovery, and why
|
|
4180
|
+
*
|
|
4181
|
+
* The boot ladder ENDS: it runs out and manual re-arm is the only path. This
|
|
4182
|
+
* one CLAMPS at the last rung and keeps asking every 90 minutes forever.
|
|
4183
|
+
*
|
|
4184
|
+
* Boot recovery can end because a human is present — a node that just booted
|
|
4185
|
+
* wrong is a node someone is looking at. A camera whose session expired at
|
|
4186
|
+
* 03:00 has nobody. The reconcile poll is the ONLY thing that will ever ask it
|
|
4187
|
+
* again: no event fires when a camera silently starts working, and a device
|
|
4188
|
+
* that never re-enters the catalog is a device with no broker, no recording
|
|
4189
|
+
* and no detection, permanently. Ending the chain would convert a transient
|
|
4190
|
+
* session expiry into a permanent outage — precisely the failure it is meant
|
|
4191
|
+
* to bound. A 90-minute floor is not a spin: it is ~16 pulls a day for one
|
|
4192
|
+
* broken camera, against 2 880 before this.
|
|
4193
|
+
*/
|
|
4194
|
+
/**
|
|
4195
|
+
* The rung the ladder settles on. Read off the shared schedule rather than
|
|
4196
|
+
* written down twice; the literal only keeps the expression total for a
|
|
4197
|
+
* hypothetically empty ladder, and is not the clamp.
|
|
4198
|
+
*/
|
|
4199
|
+
var LAST_RUNG_MS = require_dist.BOOT_RECOVERY_BACKOFF_MS[require_dist.BOOT_RECOVERY_BACKOFF_MS.length - 1] ?? 90 * 6e4;
|
|
4200
|
+
/**
|
|
4201
|
+
* Delay before the next poll-driven attempt, given how many pulls in a row
|
|
4202
|
+
* have now failed (1 = the first failure).
|
|
4203
|
+
*
|
|
4204
|
+
* Clamped at the last rung rather than exhausted — see the module doc.
|
|
4205
|
+
*/
|
|
4206
|
+
function catalogPullRetryDelayMs(consecutiveFailures) {
|
|
4207
|
+
return require_dist.BOOT_RECOVERY_BACKOFF_MS[Math.max(consecutiveFailures, 1) - 1] ?? LAST_RUNG_MS;
|
|
4208
|
+
}
|
|
4209
|
+
/**
|
|
4210
|
+
* Advance the failure memory after one more failed pull.
|
|
4211
|
+
*
|
|
4212
|
+
* @param previous state before this failure, or `undefined` for the first one
|
|
4213
|
+
* @param nowMs wall clock at the moment of the failure
|
|
4214
|
+
*/
|
|
4215
|
+
function nextCatalogPullFailureState(previous, nowMs) {
|
|
4216
|
+
const consecutiveFailures = (previous?.consecutiveFailures ?? 0) + 1;
|
|
4217
|
+
return {
|
|
4218
|
+
consecutiveFailures,
|
|
4219
|
+
nextAttemptAtMs: nowMs + catalogPullRetryDelayMs(consecutiveFailures)
|
|
4220
|
+
};
|
|
4221
|
+
}
|
|
4222
|
+
/**
|
|
4223
|
+
* May a POLL-driven pull run for this device right now?
|
|
4224
|
+
*
|
|
4225
|
+
* Only the poll is gated. An event-driven reconcile (device registered,
|
|
4226
|
+
* stream params changed) is EVIDENCE that something moved and always
|
|
4227
|
+
* attempts — a backoff that ignores new information is just a slower way of
|
|
4228
|
+
* being wrong.
|
|
4229
|
+
*/
|
|
4230
|
+
function mayAttemptPolledCatalogPull(state, nowMs) {
|
|
4231
|
+
if (state === void 0) return true;
|
|
4232
|
+
return nowMs >= state.nextAttemptAtMs;
|
|
4233
|
+
}
|
|
4128
4234
|
/**
|
|
4129
4235
|
* Minimum gap between two re-dials of the same device. A re-dial costs a
|
|
4130
4236
|
* ~2 s video interruption on every source of the camera; at the 30 s
|
|
@@ -4564,506 +4670,6 @@ function pushDimension(into, constraint, requested, known) {
|
|
|
4564
4670
|
reason: `source ${known} > requested ${requested}`
|
|
4565
4671
|
});
|
|
4566
4672
|
}
|
|
4567
|
-
/**
|
|
4568
|
-
* Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
|
|
4569
|
-
* {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
|
|
4570
|
-
*
|
|
4571
|
-
* ## Why a plane and not a callback
|
|
4572
|
-
*
|
|
4573
|
-
* The operator's requirement for HKSV was explicit: the live fMP4 source built
|
|
4574
|
-
* for it must be **dual-use**, so a HomeKit-triggered recording also lands in
|
|
4575
|
-
* CamStack as an additional videoclip source alongside the recorder and the NC
|
|
4576
|
-
* clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
|
|
4577
|
-
* (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
|
|
4578
|
-
* a second ffmpeg child of the same camera. So this is the same shape the
|
|
4579
|
-
* broker's other multi-consumer surfaces already have
|
|
4580
|
-
* (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
|
|
4581
|
-
* one producer.
|
|
4582
|
-
*
|
|
4583
|
-
* **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
|
|
4584
|
-
* clip source; both are named here so the seam is not re-invented, and neither
|
|
4585
|
-
* is built.
|
|
4586
|
-
*
|
|
4587
|
-
* ## The init segment is RETAINED
|
|
4588
|
-
*
|
|
4589
|
-
* A subscriber that attaches mid-stream — the clip consumer joining an already
|
|
4590
|
-
* running HKSV session, which is the whole dual-use case — receives the
|
|
4591
|
-
* retained `ftyp`+`moov` as its first packet and then live fragments. Without
|
|
4592
|
-
* retention its fragments are undecodable and the failure looks like a codec
|
|
4593
|
-
* problem.
|
|
4594
|
-
*
|
|
4595
|
-
* ## A slow subscriber is CLOSED, never silently gapped
|
|
4596
|
-
*
|
|
4597
|
-
* `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
|
|
4598
|
-
* click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
|
|
4599
|
-
* `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
|
|
4600
|
-
* shows a clip that fails to play with nothing anywhere saying why. So a
|
|
4601
|
-
* subscription whose queue overflows is ENDED with a reason, loudly, and the
|
|
4602
|
-
* other subscriptions are untouched.
|
|
4603
|
-
*
|
|
4604
|
-
* ## The PREBUFFER (phase 3)
|
|
4605
|
-
*
|
|
4606
|
-
* HKSV asks for context BEFORE the trigger — `CameraRecordingOptions.prebufferLength`
|
|
4607
|
-
* is a HAP-mandated minimum of 4000 ms — and a subscriber that attaches at the
|
|
4608
|
-
* motion edge has none. So the plane optionally retains the last few fragments
|
|
4609
|
-
* and replays them to a subscriber that asks for them.
|
|
4610
|
-
*
|
|
4611
|
-
* Three things this ring gets right, each of which is a measured fact rather
|
|
4612
|
-
* than a preference (see [D84](../../../../docs/decisions/adr-0084.md)):
|
|
4613
|
-
*
|
|
4614
|
-
* - **It is bounded by TIME *and* BYTES.** On the live fleet a 720p copy
|
|
4615
|
-
* fragment is ~255 KB and a 4K one is ~6.35 MB — a 25× spread over the same
|
|
4616
|
-
* window. A time-only bound is a per-camera RAM figure nobody can predict.
|
|
4617
|
-
* - **The window is measured on ARRIVAL, not parsed from `tfdt`.** The
|
|
4618
|
-
* splitter deliberately never computes a fragment's duration (a second
|
|
4619
|
-
* opinion about a fact the muxer owns), and a prebuffer cares about how long
|
|
4620
|
-
* ago the bytes turned up, which is exactly what arrival time answers.
|
|
4621
|
-
* - **A replay is not backlog.** A subscriber taking N retained fragments gets
|
|
4622
|
-
* its queue capacity raised by N for them, because closing a subscriber as a
|
|
4623
|
-
* slow consumer for the prebuffer it explicitly asked for would be the
|
|
4624
|
-
* stupidest possible failure — and, with `DEFAULT_QUEUE_CAPACITY` of 4 and a
|
|
4625
|
-
* ring of 4, the guaranteed one.
|
|
4626
|
-
*
|
|
4627
|
-
* ## `isLast`
|
|
4628
|
-
*
|
|
4629
|
-
* hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
|
|
4630
|
-
* `isLast` — a generator that finishes without it produces the twelve-second
|
|
4631
|
-
* timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
|
|
4632
|
-
* plane therefore computes it at DELIVERY time: a packet is last when the plane
|
|
4633
|
-
* has ended and nothing remains queued behind it. A subscription that ends
|
|
4634
|
-
* having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
|
|
4635
|
-
* the future delegate must not open an HDS stream it cannot feed.
|
|
4636
|
-
*/
|
|
4637
|
-
var DEFAULT_QUEUE_CAPACITY = 4;
|
|
4638
|
-
var Fmp4FragmentPlane = class {
|
|
4639
|
-
logger;
|
|
4640
|
-
prebuffer;
|
|
4641
|
-
now;
|
|
4642
|
-
subscriptions = /* @__PURE__ */ new Map();
|
|
4643
|
-
/** The last init unit seen, handed to every later subscriber. */
|
|
4644
|
-
retainedInit = null;
|
|
4645
|
-
ended = false;
|
|
4646
|
-
/** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
|
|
4647
|
-
ring = [];
|
|
4648
|
-
ringBytes = 0;
|
|
4649
|
-
constructor(logger, prebuffer, now = Date.now) {
|
|
4650
|
-
this.logger = logger;
|
|
4651
|
-
this.prebuffer = prebuffer;
|
|
4652
|
-
this.now = now;
|
|
4653
|
-
}
|
|
4654
|
-
get subscriberCount() {
|
|
4655
|
-
return this.subscriptions.size;
|
|
4656
|
-
}
|
|
4657
|
-
/** True once {@link end} has been called — no further units are accepted. */
|
|
4658
|
-
get isEnded() {
|
|
4659
|
-
return this.ended;
|
|
4660
|
-
}
|
|
4661
|
-
/** What the prebuffer ring holds right now. All zeroes when disabled. */
|
|
4662
|
-
prebufferStats() {
|
|
4663
|
-
const oldest = this.ring[0];
|
|
4664
|
-
return {
|
|
4665
|
-
fragments: this.ring.length,
|
|
4666
|
-
bytes: this.ringBytes,
|
|
4667
|
-
spanMs: oldest === void 0 ? 0 : this.now() - oldest.arrivedAt
|
|
4668
|
-
};
|
|
4669
|
-
}
|
|
4670
|
-
subscribe(input) {
|
|
4671
|
-
const replay = input.withPrebuffer === true ? this.trimmedRing() : [];
|
|
4672
|
-
const requested = Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY);
|
|
4673
|
-
const sub = {
|
|
4674
|
-
id: `fmp4-${(0, node_crypto.randomUUID)()}`,
|
|
4675
|
-
tag: input.tag,
|
|
4676
|
-
subscribedAt: this.now(),
|
|
4677
|
-
capacity: requested + replay.length,
|
|
4678
|
-
queue: [],
|
|
4679
|
-
delivered: 0,
|
|
4680
|
-
closedReason: null,
|
|
4681
|
-
wake: null,
|
|
4682
|
-
iterating: false
|
|
4683
|
-
};
|
|
4684
|
-
this.subscriptions.set(sub.id, sub);
|
|
4685
|
-
if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
|
|
4686
|
-
for (const retained of replay) this.enqueue(sub, retained.unit);
|
|
4687
|
-
if (this.ended) this.closeSubscription(sub, "ended");
|
|
4688
|
-
this.logger?.info("fmp4 plane: subscribed", { meta: {
|
|
4689
|
-
subscriptionId: sub.id,
|
|
4690
|
-
tag: sub.tag,
|
|
4691
|
-
hasRetainedInit: this.retainedInit !== null,
|
|
4692
|
-
prebufferFragments: replay.length,
|
|
4693
|
-
prebufferBytes: replay.reduce((n, r) => n + r.unit.data.length, 0)
|
|
4694
|
-
} });
|
|
4695
|
-
return this.facade(sub);
|
|
4696
|
-
}
|
|
4697
|
-
/**
|
|
4698
|
-
* Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
|
|
4699
|
-
* emits exactly one per child, and a second means the child was respawned, in
|
|
4700
|
-
* which case the old one describes a stream that no longer exists.
|
|
4701
|
-
*/
|
|
4702
|
-
publish(unit) {
|
|
4703
|
-
if (this.ended) return;
|
|
4704
|
-
if (unit.kind === "init") {
|
|
4705
|
-
this.retainedInit = unit;
|
|
4706
|
-
this.ring.length = 0;
|
|
4707
|
-
this.ringBytes = 0;
|
|
4708
|
-
} else this.retain(unit);
|
|
4709
|
-
for (const sub of this.subscriptions.values()) {
|
|
4710
|
-
if (sub.closedReason !== null) continue;
|
|
4711
|
-
this.enqueue(sub, unit);
|
|
4712
|
-
}
|
|
4713
|
-
}
|
|
4714
|
-
/**
|
|
4715
|
-
* The producer stopped. Every subscriber drains what it holds; its final
|
|
4716
|
-
* packet carries `isLast`, and its generator then completes.
|
|
4717
|
-
*/
|
|
4718
|
-
end(reason = "producer ended") {
|
|
4719
|
-
if (this.ended) return;
|
|
4720
|
-
this.ended = true;
|
|
4721
|
-
this.logger?.info("fmp4 plane: ended", { meta: {
|
|
4722
|
-
reason,
|
|
4723
|
-
subscribers: this.subscriptions.size
|
|
4724
|
-
} });
|
|
4725
|
-
for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
|
|
4726
|
-
}
|
|
4727
|
-
listSubscribers() {
|
|
4728
|
-
return [...this.subscriptions.values()].map((s) => ({
|
|
4729
|
-
tag: s.tag,
|
|
4730
|
-
subscribedAt: s.subscribedAt,
|
|
4731
|
-
delivered: s.delivered,
|
|
4732
|
-
closedReason: s.closedReason
|
|
4733
|
-
}));
|
|
4734
|
-
}
|
|
4735
|
-
/** End and forget everything. Idempotent. */
|
|
4736
|
-
dispose() {
|
|
4737
|
-
this.end("disposed");
|
|
4738
|
-
this.subscriptions.clear();
|
|
4739
|
-
this.retainedInit = null;
|
|
4740
|
-
this.ring.length = 0;
|
|
4741
|
-
this.ringBytes = 0;
|
|
4742
|
-
}
|
|
4743
|
-
/**
|
|
4744
|
-
* Add one fragment to the ring and evict from the front until BOTH bounds
|
|
4745
|
-
* hold. Eviction is oldest-first, which is the one place in this file where
|
|
4746
|
-
* dropping is correct: the ring is context, not stream — nobody is mid-decode
|
|
4747
|
-
* on it, and a subscriber only ever receives a contiguous tail of it.
|
|
4748
|
-
*/
|
|
4749
|
-
retain(unit) {
|
|
4750
|
-
const prebuffer = this.prebuffer;
|
|
4751
|
-
if (prebuffer === void 0) return;
|
|
4752
|
-
const arrivedAt = this.now();
|
|
4753
|
-
this.ring.push({
|
|
4754
|
-
unit,
|
|
4755
|
-
arrivedAt
|
|
4756
|
-
});
|
|
4757
|
-
this.ringBytes += unit.data.length;
|
|
4758
|
-
const cutoff = arrivedAt - prebuffer.windowMs;
|
|
4759
|
-
while (this.ring.length > 0) {
|
|
4760
|
-
const oldest = this.ring[0];
|
|
4761
|
-
if (oldest === void 0) break;
|
|
4762
|
-
const tooOld = oldest.arrivedAt < cutoff;
|
|
4763
|
-
const tooBig = this.ringBytes > prebuffer.maxBytes;
|
|
4764
|
-
if (!tooOld && !tooBig || this.ring.length === 1) break;
|
|
4765
|
-
this.ring.shift();
|
|
4766
|
-
this.ringBytes -= oldest.unit.data.length;
|
|
4767
|
-
}
|
|
4768
|
-
}
|
|
4769
|
-
/**
|
|
4770
|
-
* The ring as a subscriber should receive it — window applied AT SUBSCRIBE
|
|
4771
|
-
* time, not only at publish time. A camera that went quiet keeps its last
|
|
4772
|
-
* fragment in the ring indefinitely (see the never-evict-the-newest rule),
|
|
4773
|
-
* and replaying a 40-second-old fragment as "prebuffer" would put stale video
|
|
4774
|
-
* at the head of a clip iOS presents as the moment of the event.
|
|
4775
|
-
*/
|
|
4776
|
-
trimmedRing() {
|
|
4777
|
-
const prebuffer = this.prebuffer;
|
|
4778
|
-
if (prebuffer === void 0) return [];
|
|
4779
|
-
const cutoff = this.now() - prebuffer.windowMs;
|
|
4780
|
-
return this.ring.filter((r) => r.arrivedAt >= cutoff);
|
|
4781
|
-
}
|
|
4782
|
-
enqueue(sub, unit) {
|
|
4783
|
-
if (sub.queue.length >= sub.capacity) {
|
|
4784
|
-
this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
|
|
4785
|
-
subscriptionId: sub.id,
|
|
4786
|
-
tag: sub.tag,
|
|
4787
|
-
capacity: sub.capacity,
|
|
4788
|
-
delivered: sub.delivered
|
|
4789
|
-
} });
|
|
4790
|
-
this.closeSubscription(sub, "slow-consumer");
|
|
4791
|
-
return;
|
|
4792
|
-
}
|
|
4793
|
-
sub.queue.push({
|
|
4794
|
-
kind: unit.kind,
|
|
4795
|
-
data: unit.data,
|
|
4796
|
-
sequence: unit.sequence,
|
|
4797
|
-
isLast: false
|
|
4798
|
-
});
|
|
4799
|
-
this.wake(sub);
|
|
4800
|
-
}
|
|
4801
|
-
closeSubscription(sub, reason) {
|
|
4802
|
-
if (sub.closedReason !== null) return;
|
|
4803
|
-
sub.closedReason = reason;
|
|
4804
|
-
if (reason === "slow-consumer") sub.queue.length = 0;
|
|
4805
|
-
this.wake(sub);
|
|
4806
|
-
}
|
|
4807
|
-
wake(sub) {
|
|
4808
|
-
const resume = sub.wake;
|
|
4809
|
-
sub.wake = null;
|
|
4810
|
-
resume?.();
|
|
4811
|
-
}
|
|
4812
|
-
facade(sub) {
|
|
4813
|
-
const plane = this;
|
|
4814
|
-
return {
|
|
4815
|
-
id: sub.id,
|
|
4816
|
-
tag: sub.tag,
|
|
4817
|
-
get delivered() {
|
|
4818
|
-
return sub.delivered;
|
|
4819
|
-
},
|
|
4820
|
-
get closedReason() {
|
|
4821
|
-
return sub.closedReason;
|
|
4822
|
-
},
|
|
4823
|
-
packets: () => plane.iterate(sub),
|
|
4824
|
-
release: () => {
|
|
4825
|
-
plane.closeSubscription(sub, "released");
|
|
4826
|
-
plane.subscriptions.delete(sub.id);
|
|
4827
|
-
}
|
|
4828
|
-
};
|
|
4829
|
-
}
|
|
4830
|
-
async *iterate(sub) {
|
|
4831
|
-
if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
|
|
4832
|
-
sub.iterating = true;
|
|
4833
|
-
for (;;) {
|
|
4834
|
-
const next = sub.queue.shift();
|
|
4835
|
-
if (next === void 0) {
|
|
4836
|
-
if (sub.closedReason !== null) return;
|
|
4837
|
-
await new Promise((resolve) => {
|
|
4838
|
-
sub.wake = resolve;
|
|
4839
|
-
});
|
|
4840
|
-
continue;
|
|
4841
|
-
}
|
|
4842
|
-
const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
|
|
4843
|
-
sub.delivered += 1;
|
|
4844
|
-
yield {
|
|
4845
|
-
...next,
|
|
4846
|
-
isLast
|
|
4847
|
-
};
|
|
4848
|
-
if (isLast) return;
|
|
4849
|
-
}
|
|
4850
|
-
}
|
|
4851
|
-
};
|
|
4852
|
-
var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
|
|
4853
|
-
/** Heartbeat cadence — ~2 minutes of 4 s fragments. */
|
|
4854
|
-
var FRAGMENT_LOG_EVERY = 30;
|
|
4855
|
-
var Fmp4FragmentChild = class {
|
|
4856
|
-
deps;
|
|
4857
|
-
args;
|
|
4858
|
-
child = null;
|
|
4859
|
-
splitter = new require_dist.Fmp4BoxSplitter();
|
|
4860
|
-
stopped = false;
|
|
4861
|
-
unitsOut = 0;
|
|
4862
|
-
activeHwAccel = null;
|
|
4863
|
-
constructor(deps, args) {
|
|
4864
|
-
this.deps = deps;
|
|
4865
|
-
this.args = args;
|
|
4866
|
-
}
|
|
4867
|
-
/** Spawn, and resolve once the INIT segment has been cut out of stdout. */
|
|
4868
|
-
async start() {
|
|
4869
|
-
const requested = this.args.invocation.decodeHwAccel;
|
|
4870
|
-
this.activeHwAccel = requested;
|
|
4871
|
-
try {
|
|
4872
|
-
await this.spawnAttempt(requested);
|
|
4873
|
-
return;
|
|
4874
|
-
} catch (err) {
|
|
4875
|
-
if (this.stopped) throw err;
|
|
4876
|
-
if (requested === null || require_dist.isSoftwareDecode(requested)) throw err;
|
|
4877
|
-
this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
|
|
4878
|
-
tags: { deviceId: this.args.deviceId },
|
|
4879
|
-
meta: {
|
|
4880
|
-
sourceId: this.args.sourceId,
|
|
4881
|
-
decodeHwAccel: requested,
|
|
4882
|
-
error: require_dist.errMsg(err)
|
|
4883
|
-
}
|
|
4884
|
-
});
|
|
4885
|
-
this.killChild();
|
|
4886
|
-
this.splitter = new require_dist.Fmp4BoxSplitter();
|
|
4887
|
-
this.activeHwAccel = null;
|
|
4888
|
-
await this.spawnAttempt(null);
|
|
4889
|
-
}
|
|
4890
|
-
}
|
|
4891
|
-
/** The backend the child ACTUALLY ran with — `null` for software. */
|
|
4892
|
-
activeDecodeHwAccel() {
|
|
4893
|
-
const value = this.activeHwAccel;
|
|
4894
|
-
return value === null || value === "none" || value === "copy" ? null : value;
|
|
4895
|
-
}
|
|
4896
|
-
/** Kill ffmpeg and end the plane. Idempotent. */
|
|
4897
|
-
async stop() {
|
|
4898
|
-
if (this.stopped) return;
|
|
4899
|
-
this.stopped = true;
|
|
4900
|
-
this.killChild();
|
|
4901
|
-
this.args.plane.end("the fragment child stopped");
|
|
4902
|
-
}
|
|
4903
|
-
spawnAttempt(decodeHwAccel) {
|
|
4904
|
-
const args = require_dist.buildFfmpegArgs({
|
|
4905
|
-
...this.args.invocation,
|
|
4906
|
-
decodeHwAccel,
|
|
4907
|
-
sink: {
|
|
4908
|
-
kind: "stdout",
|
|
4909
|
-
container: "mp4",
|
|
4910
|
-
fragmentMs: this.args.fragmentMs
|
|
4911
|
-
}
|
|
4912
|
-
});
|
|
4913
|
-
this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
|
|
4914
|
-
tags: { deviceId: this.args.deviceId },
|
|
4915
|
-
meta: {
|
|
4916
|
-
sourceId: this.args.sourceId,
|
|
4917
|
-
fragmentMs: this.args.fragmentMs,
|
|
4918
|
-
decodeHwAccel: decodeHwAccel ?? "software",
|
|
4919
|
-
argv: args.join(" ")
|
|
4920
|
-
}
|
|
4921
|
-
});
|
|
4922
|
-
return new Promise((resolve, reject) => {
|
|
4923
|
-
const child = this.deps.spawnFn(this.deps.ffmpegBinaryPath, args, { stdio: [
|
|
4924
|
-
"ignore",
|
|
4925
|
-
"pipe",
|
|
4926
|
-
"pipe"
|
|
4927
|
-
] });
|
|
4928
|
-
this.child = child;
|
|
4929
|
-
let settled = false;
|
|
4930
|
-
/**
|
|
4931
|
-
* This attempt FAILED. Set before the kill, because SIGTERM makes the
|
|
4932
|
-
* child exit and that exit must not be reported as a death: the retry —
|
|
4933
|
-
* or the caller's rejection — already owns what happens next. Without it
|
|
4934
|
-
* the timeout path ends the plane the software retry is about to fill,
|
|
4935
|
-
* and the consumer sees a stream that stopped for no reason. A "which
|
|
4936
|
-
* spawn is current" counter does NOT cover this: the retry has not been
|
|
4937
|
-
* spawned when the kill's exit arrives.
|
|
4938
|
-
*/
|
|
4939
|
-
let failed = false;
|
|
4940
|
-
/**
|
|
4941
|
-
* This attempt is still the live producer: it has not failed (a failure
|
|
4942
|
-
* hands ownership to the retry, or to the caller's rejection) and nothing
|
|
4943
|
-
* has stopped the child. Those two cover every way an attempt stops being
|
|
4944
|
-
* current — `start` only respawns after a rejection.
|
|
4945
|
-
*/
|
|
4946
|
-
const isCurrent = () => !this.stopped && !failed;
|
|
4947
|
-
const timeoutMs = this.deps.firstUnitTimeoutMs ?? DEFAULT_FIRST_UNIT_TIMEOUT_MS;
|
|
4948
|
-
const settle = (fail) => {
|
|
4949
|
-
if (settled) return;
|
|
4950
|
-
settled = true;
|
|
4951
|
-
clearTimeout(timer);
|
|
4952
|
-
if (fail) {
|
|
4953
|
-
failed = true;
|
|
4954
|
-
reject(fail);
|
|
4955
|
-
} else resolve();
|
|
4956
|
-
};
|
|
4957
|
-
const timer = setTimeout(() => {
|
|
4958
|
-
settle(/* @__PURE__ */ new Error(`fmp4 fragment child: no fragment within ${timeoutMs}ms`));
|
|
4959
|
-
this.killChild();
|
|
4960
|
-
}, timeoutMs);
|
|
4961
|
-
timer.unref?.();
|
|
4962
|
-
child.stdout?.on("data", (chunk) => {
|
|
4963
|
-
for (const unit of this.splitter.push(chunk)) {
|
|
4964
|
-
this.unitsOut += 1;
|
|
4965
|
-
this.args.plane.publish(unit);
|
|
4966
|
-
if (unit.kind === "init") {
|
|
4967
|
-
this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
|
|
4968
|
-
tags: { deviceId: this.args.deviceId },
|
|
4969
|
-
meta: {
|
|
4970
|
-
sourceId: this.args.sourceId,
|
|
4971
|
-
bytes: unit.data.length
|
|
4972
|
-
}
|
|
4973
|
-
});
|
|
4974
|
-
settle();
|
|
4975
|
-
} else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
|
|
4976
|
-
tags: { deviceId: this.args.deviceId },
|
|
4977
|
-
meta: {
|
|
4978
|
-
sourceId: this.args.sourceId,
|
|
4979
|
-
unitsOut: this.unitsOut,
|
|
4980
|
-
bytes: unit.data.length,
|
|
4981
|
-
subscribers: this.args.plane.subscriberCount
|
|
4982
|
-
}
|
|
4983
|
-
});
|
|
4984
|
-
}
|
|
4985
|
-
const fault = this.splitter.fault;
|
|
4986
|
-
if (fault !== null) this.onFault(fault, settled, isCurrent(), settle);
|
|
4987
|
-
});
|
|
4988
|
-
child.stderr?.setEncoding("utf8");
|
|
4989
|
-
child.stderr?.on("data", (line) => {
|
|
4990
|
-
this.deps.logger.debug("fmp4 fragment child ffmpeg", {
|
|
4991
|
-
tags: { deviceId: this.args.deviceId },
|
|
4992
|
-
meta: {
|
|
4993
|
-
sourceId: this.args.sourceId,
|
|
4994
|
-
line: line.trim()
|
|
4995
|
-
}
|
|
4996
|
-
});
|
|
4997
|
-
});
|
|
4998
|
-
child.once("error", (err) => {
|
|
4999
|
-
if (!settled) {
|
|
5000
|
-
settle(err);
|
|
5001
|
-
return;
|
|
5002
|
-
}
|
|
5003
|
-
if (!isCurrent()) return;
|
|
5004
|
-
this.args.plane.end("the fragment child errored");
|
|
5005
|
-
this.deps.onChildExit?.(err);
|
|
5006
|
-
});
|
|
5007
|
-
child.once("exit", (code, signal) => {
|
|
5008
|
-
if (!settled) {
|
|
5009
|
-
settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
|
|
5010
|
-
return;
|
|
5011
|
-
}
|
|
5012
|
-
if (!isCurrent()) return;
|
|
5013
|
-
const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
|
|
5014
|
-
this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
|
|
5015
|
-
tags: { deviceId: this.args.deviceId },
|
|
5016
|
-
meta: {
|
|
5017
|
-
sourceId: this.args.sourceId,
|
|
5018
|
-
code,
|
|
5019
|
-
signal,
|
|
5020
|
-
unitsOut: this.unitsOut
|
|
5021
|
-
}
|
|
5022
|
-
});
|
|
5023
|
-
this.args.plane.end("the fragment child exited");
|
|
5024
|
-
this.deps.onChildExit?.(error);
|
|
5025
|
-
});
|
|
5026
|
-
});
|
|
5027
|
-
}
|
|
5028
|
-
/**
|
|
5029
|
-
* The byte stream stopped being splittable. Not recoverable — the splitter
|
|
5030
|
-
* cannot resynchronise mid-box — so the child is a corpse and every consumer
|
|
5031
|
-
* has to be told, loudly, with the reason.
|
|
5032
|
-
*/
|
|
5033
|
-
onFault(reason, wasLive, current, settle) {
|
|
5034
|
-
const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
|
|
5035
|
-
this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
|
|
5036
|
-
tags: { deviceId: this.args.deviceId },
|
|
5037
|
-
meta: {
|
|
5038
|
-
sourceId: this.args.sourceId,
|
|
5039
|
-
unitsOut: this.unitsOut,
|
|
5040
|
-
interstitial: this.splitter.discardedInterstitialTypes,
|
|
5041
|
-
reason
|
|
5042
|
-
}
|
|
5043
|
-
});
|
|
5044
|
-
this.killChild();
|
|
5045
|
-
settle(error);
|
|
5046
|
-
if (wasLive && current) {
|
|
5047
|
-
this.args.plane.end("the fragment child produced unsplittable output");
|
|
5048
|
-
this.deps.onChildExit?.(error);
|
|
5049
|
-
}
|
|
5050
|
-
}
|
|
5051
|
-
killChild() {
|
|
5052
|
-
const child = this.child;
|
|
5053
|
-
this.child = null;
|
|
5054
|
-
if (child && !child.killed) try {
|
|
5055
|
-
child.kill("SIGTERM");
|
|
5056
|
-
} catch (err) {
|
|
5057
|
-
this.deps.logger.warn("fmp4 fragment child: kill error", {
|
|
5058
|
-
tags: { deviceId: this.args.deviceId },
|
|
5059
|
-
meta: {
|
|
5060
|
-
sourceId: this.args.sourceId,
|
|
5061
|
-
error: require_dist.errMsg(err)
|
|
5062
|
-
}
|
|
5063
|
-
});
|
|
5064
|
-
}
|
|
5065
|
-
}
|
|
5066
|
-
};
|
|
5067
4673
|
//#endregion
|
|
5068
4674
|
//#region src/stream-broker/stream-broker/rtsp-host.ts
|
|
5069
4675
|
/**
|
|
@@ -5268,6 +4874,7 @@ var TranscodeEgress = class {
|
|
|
5268
4874
|
] });
|
|
5269
4875
|
this.child = child;
|
|
5270
4876
|
this.startedAtMs = Date.now();
|
|
4877
|
+
const costClaim = this.deps.claimCost?.(child.pid) ?? require_node.NO_COST_CLAIM;
|
|
5271
4878
|
let settled = false;
|
|
5272
4879
|
const timeoutMs = this.deps.firstDataTimeoutMs ?? DEFAULT_FIRST_DATA_TIMEOUT_MS;
|
|
5273
4880
|
const timer = setTimeout(() => {
|
|
@@ -5304,12 +4911,14 @@ var TranscodeEgress = class {
|
|
|
5304
4911
|
} });
|
|
5305
4912
|
});
|
|
5306
4913
|
child.once("error", (err) => {
|
|
4914
|
+
costClaim.release();
|
|
5307
4915
|
if (settled) return;
|
|
5308
4916
|
settled = true;
|
|
5309
4917
|
clearTimeout(timer);
|
|
5310
4918
|
reject(err);
|
|
5311
4919
|
});
|
|
5312
4920
|
child.once("exit", (code, signal) => {
|
|
4921
|
+
costClaim.release();
|
|
5313
4922
|
if (settled) {
|
|
5314
4923
|
if (this.stopped) return;
|
|
5315
4924
|
const error = /* @__PURE__ */ new Error(`transcode egress: ffmpeg exited while live (code=${code} signal=${signal})`);
|
|
@@ -5639,7 +5248,7 @@ var EgressTranscodeManager = class {
|
|
|
5639
5248
|
});
|
|
5640
5249
|
const targetCodec = request.encode.video.codec === "h265" ? "H265" : "H264";
|
|
5641
5250
|
const brokerId = `egress:${deviceId}:${(0, node_crypto.randomUUID)().slice(0, 8)}`;
|
|
5642
|
-
const plane = fragments ? new Fmp4FragmentPlane(this.opts.logger.child("egress-fmp4")) : null;
|
|
5251
|
+
const plane = fragments ? new require_node.Fmp4FragmentPlane(this.opts.logger.child("egress-fmp4")) : null;
|
|
5643
5252
|
const channel = pushSink === null && plane === null ? this.opts.acquireChannel(brokerId) : null;
|
|
5644
5253
|
const consumerUrl = channel !== null ? channel.consumerUrl : plane !== null ? fragmentsOnlyUrl(key) : pushOnlyUrl(publishedCamStreamId);
|
|
5645
5254
|
const child = this.createChild({
|
|
@@ -5870,7 +5479,13 @@ var EgressTranscodeManager = class {
|
|
|
5870
5479
|
ffmpegBinaryPath: this.opts.ffmpegConfig().binaryPath,
|
|
5871
5480
|
spawnFn: this.opts.spawnFn ?? node_child_process.spawn,
|
|
5872
5481
|
acquireChannel: () => channel,
|
|
5873
|
-
...spec.onDied ? { onChildExit: spec.onDied } : {}
|
|
5482
|
+
...spec.onDied ? { onChildExit: spec.onDied } : {},
|
|
5483
|
+
claimCost: (pid) => this.opts.costRegistry?.claim({
|
|
5484
|
+
role: "transcode",
|
|
5485
|
+
deviceId: spec.deviceId,
|
|
5486
|
+
unit: spec.brokerId,
|
|
5487
|
+
pid
|
|
5488
|
+
}) ?? require_node.NO_COST_CLAIM
|
|
5874
5489
|
}, {
|
|
5875
5490
|
brokerId: spec.brokerId,
|
|
5876
5491
|
targetCodec: spec.targetCodec,
|
|
@@ -5894,7 +5509,7 @@ var EgressTranscodeManager = class {
|
|
|
5894
5509
|
* the logic. See the primitive's `ffmpeg/fmp4-fragment-child.ts`.
|
|
5895
5510
|
*/
|
|
5896
5511
|
createFragmentChild(spec, fragments) {
|
|
5897
|
-
const child = new Fmp4FragmentChild({
|
|
5512
|
+
const child = new require_node.Fmp4FragmentChild({
|
|
5898
5513
|
logger: this.opts.logger.child("egress-fmp4"),
|
|
5899
5514
|
ffmpegBinaryPath: this.opts.ffmpegConfig().binaryPath,
|
|
5900
5515
|
spawnFn: this.opts.spawnFn ?? node_child_process.spawn,
|
|
@@ -11376,6 +10991,14 @@ var StreamBroker = class StreamBroker {
|
|
|
11376
10991
|
* and a line without the tag cannot be grouped to answer it.
|
|
11377
10992
|
*/
|
|
11378
10993
|
numericDeviceId;
|
|
10994
|
+
/**
|
|
10995
|
+
* Where this broker records the ffmpeg ingest it spawns, so the stream-broker
|
|
10996
|
+
* addon reports its OWN per-camera cost through `load-contribution` instead
|
|
10997
|
+
* of leaving an observer to join a pid against a camera. Null until the
|
|
10998
|
+
* manager injects it; a broker without one still streams, and its ffmpeg is
|
|
10999
|
+
* simply unclaimed in the node's process snapshot.
|
|
11000
|
+
*/
|
|
11001
|
+
costRegistry = null;
|
|
11379
11002
|
_status = "idle";
|
|
11380
11003
|
source;
|
|
11381
11004
|
/**
|
|
@@ -11909,6 +11532,30 @@ var StreamBroker = class StreamBroker {
|
|
|
11909
11532
|
this.ffmpegBinaryPath = binaryPath;
|
|
11910
11533
|
}
|
|
11911
11534
|
/**
|
|
11535
|
+
* Inject the addon's child-cost registry. Injected rather than constructed
|
|
11536
|
+
* here because the registry is per-ADDON-PROCESS (it is what the addon's
|
|
11537
|
+
* `load-contribution` provider reads) while a broker is per-camera.
|
|
11538
|
+
*/
|
|
11539
|
+
setCostRegistry(registry) {
|
|
11540
|
+
this.costRegistry = registry;
|
|
11541
|
+
}
|
|
11542
|
+
/**
|
|
11543
|
+
* Claim one ffmpeg ingest generation for this camera, or nothing when the
|
|
11544
|
+
* camera cannot be named. Never a placeholder device id: an unnamed
|
|
11545
|
+
* per-camera claim is indistinguishable from a shared cost and would quietly
|
|
11546
|
+
* turn one camera's ffmpeg into everybody's.
|
|
11547
|
+
*/
|
|
11548
|
+
claimIngestCost(pid) {
|
|
11549
|
+
const deviceId = this.numericDeviceId;
|
|
11550
|
+
if (this.costRegistry === null || deviceId === void 0) return require_node.NO_COST_CLAIM;
|
|
11551
|
+
return this.costRegistry.claim({
|
|
11552
|
+
role: "streaming",
|
|
11553
|
+
deviceId,
|
|
11554
|
+
unit: this.deviceId,
|
|
11555
|
+
pid
|
|
11556
|
+
});
|
|
11557
|
+
}
|
|
11558
|
+
/**
|
|
11912
11559
|
* Inject a manager-owned closure that derives the current
|
|
11913
11560
|
* `StreamSource` from the live cam-stream registry. Every dial
|
|
11914
11561
|
* attempt (initial start, scheduled reconnect, demand-driven resume)
|
|
@@ -13524,7 +13171,8 @@ var StreamBroker = class StreamBroker {
|
|
|
13524
13171
|
const reader = new FfmpegSourceReader({
|
|
13525
13172
|
ffmpegPath: this.ffmpegBinaryPath,
|
|
13526
13173
|
args,
|
|
13527
|
-
...this.logger ? { logger: this.logger.child("ffmpeg-source") } : {}
|
|
13174
|
+
...this.logger ? { logger: this.logger.child("ffmpeg-source") } : {},
|
|
13175
|
+
claimCost: (pid) => this.claimIngestCost(pid)
|
|
13528
13176
|
}, {
|
|
13529
13177
|
onVideoCodec: (codec) => {
|
|
13530
13178
|
this.detectedCodec = codec;
|
|
@@ -15097,6 +14745,13 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
15097
14745
|
deviceOverridePersister = null;
|
|
15098
14746
|
profileMapPersister = null;
|
|
15099
14747
|
deviceCatalogOperations = /* @__PURE__ */ new Map();
|
|
14748
|
+
/**
|
|
14749
|
+
* Devices whose `stream-catalog` pull is currently failing, and when the
|
|
14750
|
+
* POLL may ask again. Absent = healthy; an entry is deleted the moment a
|
|
14751
|
+
* pull succeeds. See `catalog-pull-backoff.ts` for the ladder and for the
|
|
14752
|
+
* outage that put it here.
|
|
14753
|
+
*/
|
|
14754
|
+
catalogPullFailures = /* @__PURE__ */ new Map();
|
|
15100
14755
|
eventBus = null;
|
|
15101
14756
|
/**
|
|
15102
14757
|
* Per-broker stream health flag set by the watchdog. `undefined`
|
|
@@ -15302,6 +14957,8 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
15302
14957
|
* that reclaims a refcount whose holder died without releasing.
|
|
15303
14958
|
*/
|
|
15304
14959
|
egressTranscodeManager = null;
|
|
14960
|
+
/** See {@link StreamBrokerManager.setCostRegistry}. */
|
|
14961
|
+
costRegistry = null;
|
|
15305
14962
|
/**
|
|
15306
14963
|
* Test/injection seam for the egress primitive's child. Production leaves it
|
|
15307
14964
|
* null and `EgressTranscodeManager` builds a real `TranscodeEgress` /
|
|
@@ -15344,6 +15001,15 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
15344
15001
|
this.rtspProvider = new RtspRestreamProviderImpl(this.rtspServer, logger.child("rtsp-restream"));
|
|
15345
15002
|
}
|
|
15346
15003
|
/**
|
|
15004
|
+
* The addon's child-cost registry, threaded down to every broker and to the
|
|
15005
|
+
* egress transcode manager so each spawn site claims its OWN child. Injected
|
|
15006
|
+
* (not constructed) because it is per-addon-process and is what the addon's
|
|
15007
|
+
* `load-contribution` provider reads.
|
|
15008
|
+
*/
|
|
15009
|
+
setCostRegistry(registry) {
|
|
15010
|
+
this.costRegistry = registry;
|
|
15011
|
+
}
|
|
15012
|
+
/**
|
|
15347
15013
|
* Lazily create the transcode manager wired with closures rather than
|
|
15348
15014
|
* direct refs so it only sees the surface it needs.
|
|
15349
15015
|
*/
|
|
@@ -15411,6 +15077,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
15411
15077
|
return entry ? { url: entry.url } : null;
|
|
15412
15078
|
},
|
|
15413
15079
|
ffmpegConfig: () => this.ffmpegConfig,
|
|
15080
|
+
...this.costRegistry === null ? {} : { costRegistry: this.costRegistry },
|
|
15414
15081
|
resolveDecodeHwAccel: (deviceId, override) => this.resolveEgressHwAccel(deviceId, override),
|
|
15415
15082
|
acquireChannel: (brokerId) => {
|
|
15416
15083
|
const restreamer = new RtspRestreamer(brokerId);
|
|
@@ -16636,26 +16303,54 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16636
16303
|
/**
|
|
16637
16304
|
* Pull one camera's `stream-catalog` and reconcile its cam-streams: upsert
|
|
16638
16305
|
* every descriptor, retract any cam-stream no longer in the catalog. A pull
|
|
16639
|
-
* that throws (provider mid-restart, no `stream-catalog` cap
|
|
16640
|
-
*
|
|
16641
|
-
*
|
|
16642
|
-
*
|
|
16306
|
+
* that throws (provider mid-restart, no `stream-catalog` cap, a camera whose
|
|
16307
|
+
* session died) omits THIS DEVICE ONLY — it is logged at `warn` with the
|
|
16308
|
+
* device's id and re-asked on the `catalog-pull-backoff.ts` ladder. An EMPTY
|
|
16309
|
+
* catalog is also skipped rather than tearing down (a provider probe can
|
|
16310
|
+
* transiently return none); genuine removal flows through the
|
|
16311
|
+
* device-unregistered path.
|
|
16312
|
+
*
|
|
16313
|
+
* `trigger` decides whether the backoff applies — see
|
|
16314
|
+
* {@link CatalogReconcileTrigger}. It defaults to `'event'` (always attempt)
|
|
16315
|
+
* so only the caller that actually repeats forever pays the throttle.
|
|
16643
16316
|
*/
|
|
16644
|
-
async reconcileDeviceCatalog(deviceId) {
|
|
16645
|
-
await this.runDeviceCatalogOperation(deviceId, () => this.reconcileDeviceCatalogNow(deviceId));
|
|
16317
|
+
async reconcileDeviceCatalog(deviceId, trigger = "event") {
|
|
16318
|
+
await this.runDeviceCatalogOperation(deviceId, () => this.reconcileDeviceCatalogNow(deviceId, trigger));
|
|
16646
16319
|
}
|
|
16647
|
-
async reconcileDeviceCatalogNow(deviceId) {
|
|
16320
|
+
async reconcileDeviceCatalogNow(deviceId, trigger) {
|
|
16648
16321
|
if (!this.api) return;
|
|
16322
|
+
const failure = this.catalogPullFailures.get(deviceId);
|
|
16323
|
+
if (trigger === "poll" && !mayAttemptPolledCatalogPull(failure, Date.now())) {
|
|
16324
|
+
this.logger.debug("reconcileDeviceCatalog: skipped — device is on catalog-pull backoff", {
|
|
16325
|
+
tags: { deviceId },
|
|
16326
|
+
meta: {
|
|
16327
|
+
consecutiveFailures: failure?.consecutiveFailures ?? 0,
|
|
16328
|
+
nextAttemptInMs: Math.max(0, (failure?.nextAttemptAtMs ?? 0) - Date.now())
|
|
16329
|
+
}
|
|
16330
|
+
});
|
|
16331
|
+
return;
|
|
16332
|
+
}
|
|
16649
16333
|
let descriptors;
|
|
16650
16334
|
try {
|
|
16651
16335
|
descriptors = await this.withCatalogTimeout(this.api.streamCatalog.getCatalog.query({ deviceId }), deviceId) ?? [];
|
|
16652
16336
|
} catch (err) {
|
|
16653
|
-
this.
|
|
16337
|
+
const next = nextCatalogPullFailureState(this.catalogPullFailures.get(deviceId), Date.now());
|
|
16338
|
+
this.catalogPullFailures.set(deviceId, next);
|
|
16339
|
+
this.logger.warn("stream-catalog pull failed — device omitted from this catalog reconcile", {
|
|
16654
16340
|
tags: { deviceId },
|
|
16655
|
-
meta: {
|
|
16341
|
+
meta: {
|
|
16342
|
+
trigger,
|
|
16343
|
+
consecutiveFailures: next.consecutiveFailures,
|
|
16344
|
+
retryInMs: catalogPullRetryDelayMs(next.consecutiveFailures),
|
|
16345
|
+
error: require_dist.errMsg(err)
|
|
16346
|
+
}
|
|
16656
16347
|
});
|
|
16657
16348
|
return;
|
|
16658
16349
|
}
|
|
16350
|
+
if (this.catalogPullFailures.delete(deviceId)) this.logger.info("stream-catalog pull recovered — device is back in the catalog", {
|
|
16351
|
+
tags: { deviceId },
|
|
16352
|
+
meta: { descriptors: descriptors.length }
|
|
16353
|
+
});
|
|
16659
16354
|
if (descriptors.length === 0) return;
|
|
16660
16355
|
const keep = new Set(descriptors.map((d) => d.camStreamId));
|
|
16661
16356
|
await this.publishCatalog(deviceId, descriptors);
|
|
@@ -16690,10 +16385,11 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16690
16385
|
await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
|
|
16691
16386
|
} else this.disabledDevices.delete(d.id);
|
|
16692
16387
|
const live = cameras.filter((d) => !d.disabled);
|
|
16693
|
-
await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id)));
|
|
16388
|
+
await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id, "poll")));
|
|
16694
16389
|
await Promise.allSettled(live.map((d) => this.reconcileDeviceAudioPlane(d.id)));
|
|
16695
16390
|
const liveIds = new Set(live.map((d) => d.id));
|
|
16696
16391
|
for (const deviceId of [...this.audioPlaneState.keys()]) if (!liveIds.has(deviceId)) this.audioPlaneState.delete(deviceId);
|
|
16392
|
+
for (const deviceId of [...this.catalogPullFailures.keys()]) if (!liveIds.has(deviceId)) this.catalogPullFailures.delete(deviceId);
|
|
16697
16393
|
}
|
|
16698
16394
|
/**
|
|
16699
16395
|
* ONE device's audio-plane reconcile pass. See `audio-plane-reconcile.ts`
|
|
@@ -16835,6 +16531,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16835
16531
|
await this.runDeviceCatalogOperation(deviceId, () => this.retractDeviceNow(deviceId, opts));
|
|
16836
16532
|
}
|
|
16837
16533
|
async retractDeviceNow(deviceId, opts) {
|
|
16534
|
+
this.catalogPullFailures.delete(deviceId);
|
|
16838
16535
|
const existing = this.cameraStreams.get(deviceId);
|
|
16839
16536
|
if (existing) for (const camStreamId of Array.from(existing.keys())) await this.retractCameraStream({
|
|
16840
16537
|
deviceId,
|
|
@@ -19061,6 +18758,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
19061
18758
|
const broker = new StreamBroker(brokerId, this.buildDecoderCapApi(deviceId), brokerLogger, buildAudioCodecCapApi(this.api), this.localNodeId);
|
|
19062
18759
|
broker.setRestreamUrlResolver(() => this.rtspProvider.getEntry(brokerId)?.mutedUrl ?? null);
|
|
19063
18760
|
broker.setFfmpegBinaryPath(this.ffmpegConfig.binaryPath);
|
|
18761
|
+
if (this.costRegistry !== null) broker.setCostRegistry(this.costRegistry);
|
|
19064
18762
|
this.applyPreBufferConfig(broker, deviceId, camStreamId);
|
|
19065
18763
|
this.applyAudioMuteConfig(broker, deviceId);
|
|
19066
18764
|
this.applyJoinBurstBound(broker, deviceId, camStreamId);
|
|
@@ -29507,6 +29205,12 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
29507
29205
|
* diagnostic timer never keeps a runner from exiting. */
|
|
29508
29206
|
logChannels = null;
|
|
29509
29207
|
/**
|
|
29208
|
+
* The ffmpeg children this addon started, claimed at their spawn sites and
|
|
29209
|
+
* released at their exits. One registry per PROCESS: it dies with the
|
|
29210
|
+
* process, which is correct — those children were this process's children.
|
|
29211
|
+
*/
|
|
29212
|
+
costRegistry = new require_node.ChildCostRegistry();
|
|
29213
|
+
/**
|
|
29510
29214
|
* Snapshot-equality cache for the broker-metrics emit. Each
|
|
29511
29215
|
* broker emits a stats snapshot every BROKER_METRICS_SNAPSHOT_-
|
|
29512
29216
|
* INTERVAL_MS; for an idle broker (status=streaming, fps=0,
|
|
@@ -29592,11 +29296,16 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
29592
29296
|
capability: require_dist.logChannelsCapability,
|
|
29593
29297
|
provider: this.logChannels
|
|
29594
29298
|
};
|
|
29299
|
+
const loadContributionRegistration = {
|
|
29300
|
+
capability: require_dist.loadContributionCapability,
|
|
29301
|
+
provider: { list: () => this.costRegistry.contributions() }
|
|
29302
|
+
};
|
|
29595
29303
|
this.loopStallMonitor = new EventLoopStallMonitor({ onStall: (stallMs) => {
|
|
29596
29304
|
this.ctx?.logger?.warn?.("event-loop stall — every session in this process froze", { meta: { stallMs } });
|
|
29597
29305
|
} });
|
|
29598
29306
|
this.loopStallMonitor.start();
|
|
29599
29307
|
this.brokerManager = new StreamBrokerManager(void 0, this.ctx.logger);
|
|
29308
|
+
this.brokerManager.setCostRegistry(this.costRegistry);
|
|
29600
29309
|
this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
|
|
29601
29310
|
this.brokerManager.setDefaultJoinBurstMaxSpanMs(this.config.joinBurstMaxSpanMs);
|
|
29602
29311
|
this.brokerManager.setDefaultPreRollMs(this.config.preRollMs);
|
|
@@ -29949,7 +29658,8 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
29949
29658
|
capability: require_dist.addonWidgetsSourceCapability,
|
|
29950
29659
|
provider: widgetsProvider
|
|
29951
29660
|
},
|
|
29952
|
-
logChannelsRegistration
|
|
29661
|
+
logChannelsRegistration,
|
|
29662
|
+
loadContributionRegistration
|
|
29953
29663
|
];
|
|
29954
29664
|
}
|
|
29955
29665
|
async onShutdown() {
|