@camstack/addon-pipeline 1.1.41 → 1.1.43
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 +10 -7
- package/dist/detection-pipeline/index.mjs +10 -7
- package/dist/{dist-v6cKLmU3.mjs → dist-GFd8M6KO.mjs} +9 -1
- package/dist/{dist-BJTPkJFw.js → dist-NvwN60Fq.js} +9 -1
- package/dist/{frame-handle-plane-BP8YV4sF.js → frame-handle-plane-B3Fxww8H.js} +1 -1
- package/dist/{frame-handle-plane-DKAXTtfn.mjs → frame-handle-plane-E_AsR77T.mjs} +1 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +47 -9
- package/dist/pipeline-runner/index.mjs +47 -9
- package/dist/recorder/index.js +19 -12
- package/dist/recorder/index.mjs +19 -12
- package/dist/session-decode/decode-worker-child.js +341 -28
- package/dist/session-decode/decode-worker-child.mjs +341 -28
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C2l0kmD4.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CeielP5P.mjs} +3 -3
- package/dist/stream-broker/{hostInit-DHaCRMjM.mjs → hostInit-DmjqskOR.mjs} +3 -3
- package/dist/stream-broker/index.js +96 -63
- package/dist/stream-broker/index.mjs +96 -63
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-B6fza7ic.js → MaskShapeCanvas-DI4BY7W2-CEsPjwzT.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-Cy-4iTog.js → MotionZonesSettings-NcxxQN8r-D43fIRgB.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-CYX33lJb.js → PrivacyMaskSettings-APgPLF7p-C3wJxG8V.js} +1 -1
- package/embed-dist/assets/{index-BPkayx9u.js → index-CX-rILhw.js} +4 -4
- package/embed-dist/index.html +1 -1
- package/package.json +1 -1
|
@@ -23,6 +23,19 @@ var FrameSlot = class {
|
|
|
23
23
|
*/
|
|
24
24
|
reserved = null;
|
|
25
25
|
pendingPull = false;
|
|
26
|
+
/** Frames dropped by the `minIntervalMs` throttle (never entered the slot). */
|
|
27
|
+
throttledCount = 0;
|
|
28
|
+
/** Frames dropped by latest-wins (an unpulled slot frame superseded before delivery). */
|
|
29
|
+
supersededCount = 0;
|
|
30
|
+
/**
|
|
31
|
+
* Total frames this slot has DROPPED before delivery — the sum of the
|
|
32
|
+
* `minIntervalMs` throttle drops and the latest-wins (drop-older)
|
|
33
|
+
* supersessions. Read by the decode-worker child for its `framesSkipped`
|
|
34
|
+
* metric; purely additive, never affects slot behaviour.
|
|
35
|
+
*/
|
|
36
|
+
get droppedCount() {
|
|
37
|
+
return this.throttledCount + this.supersededCount;
|
|
38
|
+
}
|
|
26
39
|
/**
|
|
27
40
|
* Publish a newly decoded frame into the latest-wins slot, freeing
|
|
28
41
|
* whatever the slot held before (the drop-older half of "latest-wins").
|
|
@@ -32,6 +45,7 @@ var FrameSlot = class {
|
|
|
32
45
|
*/
|
|
33
46
|
publish(frame, timestamp, minIntervalMs) {
|
|
34
47
|
if (minIntervalMs > 0 && this.slot && timestamp - this.slot.timestamp < minIntervalMs) {
|
|
48
|
+
this.throttledCount++;
|
|
35
49
|
frame.free();
|
|
36
50
|
return null;
|
|
37
51
|
}
|
|
@@ -44,6 +58,7 @@ var FrameSlot = class {
|
|
|
44
58
|
width: frame.width,
|
|
45
59
|
height: frame.height
|
|
46
60
|
};
|
|
61
|
+
if (previous) this.supersededCount++;
|
|
47
62
|
previous?.frame.free();
|
|
48
63
|
if (this.pendingPull) {
|
|
49
64
|
this.pendingPull = false;
|
|
@@ -90,6 +105,27 @@ var FrameSlot = class {
|
|
|
90
105
|
//#region src/session-decode/decode-worker-child.ts
|
|
91
106
|
/** Re-dial backoff on a transient dial error/EOF (mirrors the decoder's PULL_REDIAL_MS). */
|
|
92
107
|
var REDIAL_MS = 3e3;
|
|
108
|
+
/** How often the worker emits its `framesDecoded/framesSkipped/deliveredFps` line. */
|
|
109
|
+
var METRICS_INTERVAL_MS = 1e4;
|
|
110
|
+
/**
|
|
111
|
+
* The libav GPU scale filter for a hwaccel backend, or `null` when none is
|
|
112
|
+
* known here — those backends fall back to the software crop+scale path.
|
|
113
|
+
* Mirrors `addon-decoder-ffmpeg/src/ffmpeg-args.ts` `gpuScaleFilterForBackend`.
|
|
114
|
+
*/
|
|
115
|
+
function gpuScaleFilterForBackend(backend) {
|
|
116
|
+
switch (backend) {
|
|
117
|
+
case "vaapi": return "scale_vaapi";
|
|
118
|
+
case "qsv": return "scale_qsv";
|
|
119
|
+
case "cuda":
|
|
120
|
+
case "nvdec": return "scale_cuda";
|
|
121
|
+
case "videotoolbox": return "scale_vt";
|
|
122
|
+
default: return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** libav `format` filter target name for the requested detection pixel format. */
|
|
126
|
+
function pixelFilterName(format) {
|
|
127
|
+
return format === "gray" ? "gray" : "rgb24";
|
|
128
|
+
}
|
|
93
129
|
var _nav = null;
|
|
94
130
|
var _consts = null;
|
|
95
131
|
async function getNodeAv() {
|
|
@@ -182,6 +218,45 @@ function resolveResizeTarget(cropWidth, cropHeight, resize) {
|
|
|
182
218
|
height: Math.max(1, Math.round(resize.height))
|
|
183
219
|
};
|
|
184
220
|
}
|
|
221
|
+
/** Whether `region` covers the entire `srcW x srcH` frame (i.e. no real crop). */
|
|
222
|
+
function isFullFrameRegion(region, srcW, srcH) {
|
|
223
|
+
return region.left === 0 && region.top === 0 && region.width === srcW && region.height === srcH;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Build the libav filtergraph description for the GPU crop+scale path.
|
|
227
|
+
*
|
|
228
|
+
* Full-frame — the ONLY shape the session-decode pump ever asks for (it passes
|
|
229
|
+
* `resize`+`format`, never `crop`, see `session-decode-pump.ts`): scale on the
|
|
230
|
+
* GPU, then `hwdownload` pulls only the small detection-sized surface into
|
|
231
|
+
* system memory + a cheap pixel-convert. This is byte-for-byte the chain the
|
|
232
|
+
* ffmpeg decoder already ships (`addon-decoder-ffmpeg/src/ffmpeg-args.ts`
|
|
233
|
+
* `buildVideoFilter`): `scale_<be>=w=W:h=H,hwdownload,format=nv12,format=<pixel>`.
|
|
234
|
+
*
|
|
235
|
+
* Sub-region crop — contract-supported but unused on the hot path: the generic
|
|
236
|
+
* `crop` filter offsets plane POINTERS, which corrupts opaque VAAPI/QSV
|
|
237
|
+
* surfaces, so there is no safe GPU crop for those. A real crop is therefore
|
|
238
|
+
* done on the CPU AFTER `hwdownload` (`hwdownload,format=nv12,crop=…,scale=…`).
|
|
239
|
+
* It is correct and never crashes; it just forgoes the GPU win for that rare
|
|
240
|
+
* call — exactly the safety/behaviour trade-off the task calls for.
|
|
241
|
+
*/
|
|
242
|
+
function buildGpuFilterDescription(scaleFilter, region, target, fullFrame, pixel) {
|
|
243
|
+
if (fullFrame) return `${scaleFilter}=w=${target.width}:h=${target.height},hwdownload,format=nv12,format=${pixel}`;
|
|
244
|
+
return `hwdownload,format=nv12,crop=${region.width}:${region.height}:${region.left}:${region.top},scale=${target.width}:${target.height},format=${pixel}`;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Copy a single-plane packed frame (rgb24 / gray8 out of the filtergraph) into
|
|
248
|
+
* a tightly-packed `Uint8Array` of `width*channels*height`, stripping libav's
|
|
249
|
+
* row padding (`linesize` ≥ `width*channels`). Keeps the `toBuffer` output
|
|
250
|
+
* contract byte-identical to the software `scaleRoiToBuffer`.
|
|
251
|
+
*/
|
|
252
|
+
function packSinglePlane(plane, linesize, width, height, channels) {
|
|
253
|
+
const rowBytes = width * channels;
|
|
254
|
+
const stride = linesize > 0 ? linesize : rowBytes;
|
|
255
|
+
const out = Buffer.allocUnsafe(rowBytes * height);
|
|
256
|
+
if (stride === rowBytes) plane.copy(out, 0, 0, rowBytes * height);
|
|
257
|
+
else for (let row = 0; row < height; row++) plane.copy(out, row * rowBytes, row * stride, row * stride + rowBytes);
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
185
260
|
/**
|
|
186
261
|
* The forked child's whole runtime: decode-loop lifecycle, the latest-wins
|
|
187
262
|
* frame slot, and the ROI scaler for `toBuffer`. One instance per process.
|
|
@@ -201,11 +276,57 @@ var DecodeWorkerChild = class {
|
|
|
201
276
|
*/
|
|
202
277
|
scaler = null;
|
|
203
278
|
scalerKey = "";
|
|
279
|
+
/** libav GPU scale filter for the resolved hwaccel backend, or null when GPU filtering isn't available. */
|
|
280
|
+
gpuScaleFilter = null;
|
|
281
|
+
/**
|
|
282
|
+
* GPU crop+scale path decision — picked ONCE per session.
|
|
283
|
+
* - `software`: the decoder downloads every frame to YUV420P system memory and
|
|
284
|
+
* `toBuffer` uses the CPU {@link scaler}. This is the original, leak-safe
|
|
285
|
+
* behaviour; the default until a HW device + `scale_<be>` filter is found.
|
|
286
|
+
* - `undecided`: a HW device + GPU scale filter are available; the decoder
|
|
287
|
+
* emits GPU surfaces and the FIRST HW frame is probed through the GPU
|
|
288
|
+
* filtergraph — success ⇒ `hardware`, any failure ⇒ `software` (re-dial).
|
|
289
|
+
* - `hardware`: the decoder emits GPU surfaces and `toBuffer` runs the GPU
|
|
290
|
+
* crop+scale, downloading only the tiny detection-sized result.
|
|
291
|
+
*/
|
|
292
|
+
hwFilterDecision = "software";
|
|
293
|
+
/** Skip the re-dial backoff when the re-dial is specifically to switch to software. */
|
|
294
|
+
redialAsSoftware = false;
|
|
295
|
+
/**
|
|
296
|
+
* Cached GPU crop+scale filtergraph + the geometry/format key it was built
|
|
297
|
+
* for — the HW analogue of {@link scaler}/{@link scalerKey}. Rebuilt (old
|
|
298
|
+
* CLOSED) only when the key changes. Its internal `scale_<be>` OUTPUT hw
|
|
299
|
+
* frames pool is the leak-prone VAAPI surface pool, so it is closed on EVERY
|
|
300
|
+
* re-dial ({@link closeInput}) and on teardown — this discipline is what stops
|
|
301
|
+
* the prior "keeping decoded frames on the GPU leaked the VAAPI surface pool
|
|
302
|
+
* on every re-dial" incident from recurring.
|
|
303
|
+
*/
|
|
304
|
+
hwFilter = null;
|
|
305
|
+
hwFilterKey = "";
|
|
306
|
+
/** Scrypted-style throughput counters (plain integers, no per-frame allocation). */
|
|
307
|
+
framesDecoded = 0;
|
|
308
|
+
/** Child-side skips only (HW-guard drops + probe/degrade frees); slot drops add {@link FrameSlot.droppedCount}. */
|
|
309
|
+
framesSkipped = 0;
|
|
310
|
+
/** Frames actually handed to the parent as a `frame` reply. */
|
|
311
|
+
framesDelivered = 0;
|
|
312
|
+
lastMetricsAt = 0;
|
|
313
|
+
lastDeliveredSnapshot = 0;
|
|
314
|
+
metricsTimer = null;
|
|
204
315
|
demuxer = null;
|
|
205
316
|
decoder = null;
|
|
206
317
|
abortController = null;
|
|
207
318
|
started = false;
|
|
208
319
|
stopped = false;
|
|
320
|
+
/**
|
|
321
|
+
* The numeric device id this worker decodes for — learned from the `start`
|
|
322
|
+
* message's {@link VideoFrameSource}. The child's stderr is INHERITED by the
|
|
323
|
+
* pipeline-runner (silent:false fork), so these lines land in the runner's
|
|
324
|
+
* raw stderr WITHOUT passing through a scoped logger; prefixing every line
|
|
325
|
+
* with `[dev:<id>]` (see {@link emitStderr}) is what lets the per-device log
|
|
326
|
+
* UI attribute the forked worker's output to its camera. `null` only for the
|
|
327
|
+
* brief pre-`start` window (e.g. a malformed first message).
|
|
328
|
+
*/
|
|
329
|
+
deviceId = null;
|
|
209
330
|
sessionFormat = "rgb";
|
|
210
331
|
minIntervalMs = 0;
|
|
211
332
|
/**
|
|
@@ -253,9 +374,14 @@ var DecodeWorkerChild = class {
|
|
|
253
374
|
clearTimeout(this.redialTimer);
|
|
254
375
|
this.redialTimer = null;
|
|
255
376
|
}
|
|
377
|
+
if (this.metricsTimer) {
|
|
378
|
+
clearInterval(this.metricsTimer);
|
|
379
|
+
this.metricsTimer = null;
|
|
380
|
+
}
|
|
381
|
+
if (this.started) this.logMetrics(true);
|
|
256
382
|
this.abortController?.abort();
|
|
257
|
-
this.closeInput();
|
|
258
383
|
this.frames.free();
|
|
384
|
+
this.closeInput();
|
|
259
385
|
this.scaler?.[Symbol.dispose]?.();
|
|
260
386
|
this.scaler = null;
|
|
261
387
|
this.scalerKey = "";
|
|
@@ -266,14 +392,29 @@ var DecodeWorkerChild = class {
|
|
|
266
392
|
this.send({ kind: "ended" });
|
|
267
393
|
}
|
|
268
394
|
}
|
|
395
|
+
/** Emit the compact throughput line (window `deliveredFps`); cheap, integer-only. */
|
|
396
|
+
logMetrics(final) {
|
|
397
|
+
const now = Date.now();
|
|
398
|
+
const windowMs = Math.max(1, now - this.lastMetricsAt);
|
|
399
|
+
const deliveredDelta = this.framesDelivered - this.lastDeliveredSnapshot;
|
|
400
|
+
const deliveredFps = Math.round(deliveredDelta / windowMs * 1e3 * 10) / 10;
|
|
401
|
+
const skipped = this.framesSkipped + this.frames.droppedCount;
|
|
402
|
+
this.emitStderr(`session-decode metrics {framesDecoded:${this.framesDecoded}, framesSkipped:${skipped}, deliveredFps:${deliveredFps}}${final ? " (final)" : ""}\n`);
|
|
403
|
+
this.lastMetricsAt = now;
|
|
404
|
+
this.lastDeliveredSnapshot = this.framesDelivered;
|
|
405
|
+
}
|
|
269
406
|
handleStart(source, opts) {
|
|
270
407
|
if (this.started) {
|
|
271
|
-
|
|
408
|
+
this.emitStderr("decode-worker-child: duplicate start ignored\n");
|
|
272
409
|
return;
|
|
273
410
|
}
|
|
274
411
|
this.started = true;
|
|
412
|
+
this.deviceId = source.deviceId;
|
|
275
413
|
this.sessionFormat = opts.format ?? "rgb";
|
|
276
414
|
this.minIntervalMs = opts.fps && opts.fps > 0 ? 1e3 / opts.fps : 0;
|
|
415
|
+
this.lastMetricsAt = Date.now();
|
|
416
|
+
this.metricsTimer = setInterval(() => this.logMetrics(false), METRICS_INTERVAL_MS);
|
|
417
|
+
this.metricsTimer.unref?.();
|
|
277
418
|
this.runDecodeLoop(source.restreamUrl).catch((err) => {
|
|
278
419
|
this.sendError(`decode-worker-child: decode loop crashed — ${errMessage(err)}`);
|
|
279
420
|
});
|
|
@@ -285,7 +426,7 @@ var DecodeWorkerChild = class {
|
|
|
285
426
|
}
|
|
286
427
|
const reply = this.frames.pull();
|
|
287
428
|
if (reply) {
|
|
288
|
-
this.
|
|
429
|
+
this.deliverFrame(reply);
|
|
289
430
|
return;
|
|
290
431
|
}
|
|
291
432
|
this.pendingPull = true;
|
|
@@ -298,7 +439,7 @@ var DecodeWorkerChild = class {
|
|
|
298
439
|
return;
|
|
299
440
|
}
|
|
300
441
|
try {
|
|
301
|
-
const bytes = this.scaleRoiToBuffer(frame, opts);
|
|
442
|
+
const bytes = frame.isHwFrame() ? this.hwScaleToBuffer(frame, opts) : this.scaleRoiToBuffer(frame, opts);
|
|
302
443
|
this.send({
|
|
303
444
|
kind: "buffer",
|
|
304
445
|
frameId,
|
|
@@ -325,26 +466,34 @@ var DecodeWorkerChild = class {
|
|
|
325
466
|
await this.dialAndDecode(nav, C, url);
|
|
326
467
|
} catch (err) {
|
|
327
468
|
if (this.stopped) break;
|
|
328
|
-
|
|
469
|
+
this.emitStderr(`decode-worker-child: dial ended — re-dial scheduled: ${errMessage(err)}\n`);
|
|
329
470
|
} finally {
|
|
330
471
|
this.closeInput();
|
|
331
472
|
}
|
|
332
473
|
if (this.stopped) break;
|
|
333
|
-
|
|
474
|
+
const skipBackoff = this.redialAsSoftware;
|
|
475
|
+
this.redialAsSoftware = false;
|
|
476
|
+
if (!skipBackoff) await this.sleep(REDIAL_MS);
|
|
334
477
|
}
|
|
335
478
|
}
|
|
336
479
|
/**
|
|
337
|
-
* One dial: open the demuxer, pick the video stream, build a HW/SW decoder
|
|
338
|
-
*
|
|
339
|
-
* scaler always reads plain software planes regardless of hwaccel), and
|
|
340
|
-
* pump `demuxer.packets → decoder.frames → consumeDecodedFrame` until the
|
|
480
|
+
* One dial: open the demuxer, pick the video stream, build a HW/SW decoder,
|
|
481
|
+
* and pump `demuxer.packets → decoder.frames → consumeDecodedFrame` until the
|
|
341
482
|
* stream ends, errors, or teardown aborts it.
|
|
342
483
|
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
484
|
+
* Two decoder shapes, selected by {@link hwFilterDecision} (picked once per
|
|
485
|
+
* session):
|
|
486
|
+
* - HW path (`undecided`/`hardware`): NO `rescale` — the decoder keeps frames
|
|
487
|
+
* as GPU surfaces so `toBuffer` can crop+scale them on the GPU and download
|
|
488
|
+
* only the small detection-sized result.
|
|
489
|
+
* - Software path (`software`): `rescale.pixelFormat: YUV420P` forces a
|
|
490
|
+
* full-res GPU→system download so the CPU ROI scaler reads plain planes.
|
|
491
|
+
* This is the ORIGINAL contract — keeping decoded frames on the GPU leaked
|
|
492
|
+
* the VAAPI surface pool on every re-dial (see `nodeav-decoder-session.ts`);
|
|
493
|
+
* the HW path avoids that leak by closing its filtergraph + freeing every
|
|
494
|
+
* surface on every re-dial/teardown (see {@link closeInput}/{@link teardown}).
|
|
495
|
+
*
|
|
496
|
+
* Same demuxer options + `exitOnError: false` bad-frame tolerance throughout.
|
|
348
497
|
*/
|
|
349
498
|
async dialAndDecode(nav, C, url) {
|
|
350
499
|
this.abortController = new AbortController();
|
|
@@ -359,9 +508,10 @@ var DecodeWorkerChild = class {
|
|
|
359
508
|
this.demuxer = demuxer;
|
|
360
509
|
const videoStream = demuxer.video();
|
|
361
510
|
if (!videoStream) throw new Error("decode-worker-child: pull input has no video stream");
|
|
511
|
+
const wantHwFrames = this.hwContext !== null && this.hwFilterDecision !== "software";
|
|
362
512
|
const decoder = await nav.Decoder.create(videoStream, {
|
|
363
513
|
...this.hwContext ? { hardware: this.hwContext } : {},
|
|
364
|
-
rescale: { pixelFormat: C.AV_PIX_FMT_YUV420P },
|
|
514
|
+
...wantHwFrames ? {} : { rescale: { pixelFormat: C.AV_PIX_FMT_YUV420P } },
|
|
365
515
|
exitOnError: false
|
|
366
516
|
});
|
|
367
517
|
if (this.stopped) {
|
|
@@ -373,24 +523,103 @@ var DecodeWorkerChild = class {
|
|
|
373
523
|
if (this.stopped) break;
|
|
374
524
|
if (!frame) continue;
|
|
375
525
|
this.consumeDecodedFrame(frame);
|
|
526
|
+
if (this.redialAsSoftware) break;
|
|
376
527
|
}
|
|
377
528
|
}
|
|
378
529
|
/**
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
530
|
+
* Route a decoded frame according to the session's {@link hwFilterDecision}.
|
|
531
|
+
* On the software path a frame is published straight to the latest-wins slot;
|
|
532
|
+
* on the HW path the FIRST GPU surface is probed through the GPU filtergraph
|
|
533
|
+
* to commit (or fall back) the path, then GPU surfaces are published for
|
|
534
|
+
* `toBuffer` to crop+scale on the GPU. Publishing itself (drop-older, the
|
|
535
|
+
* `minIntervalMs` throttle, resolving a pending `pull`) is `FrameSlot`'s job.
|
|
382
536
|
*/
|
|
383
537
|
consumeDecodedFrame(frame) {
|
|
384
|
-
|
|
538
|
+
this.framesDecoded++;
|
|
539
|
+
if (this.hwFilterDecision === "software") {
|
|
540
|
+
if (frame.isHwFrame()) {
|
|
541
|
+
this.framesSkipped++;
|
|
542
|
+
frame.free();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
this.publishFrame(frame);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (!frame.isHwFrame()) {
|
|
549
|
+
this.framesSkipped++;
|
|
385
550
|
frame.free();
|
|
551
|
+
this.degradeToSoftware();
|
|
386
552
|
return;
|
|
387
553
|
}
|
|
554
|
+
if (this.hwFilterDecision === "undecided") {
|
|
555
|
+
if (!this.probeHwFilter(frame)) {
|
|
556
|
+
this.framesSkipped++;
|
|
557
|
+
frame.free();
|
|
558
|
+
this.degradeToSoftware();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
this.hwFilterDecision = "hardware";
|
|
562
|
+
this.emitStderr(`decode-worker-child: GPU crop+scale path active (${this.gpuScaleFilter})\n`);
|
|
563
|
+
}
|
|
564
|
+
this.publishFrame(frame);
|
|
565
|
+
}
|
|
566
|
+
/** Publish to the latest-wins slot; deliver immediately if a `pull` is pending. */
|
|
567
|
+
publishFrame(frame) {
|
|
388
568
|
const reply = this.frames.publish(frame, Date.now(), this.minIntervalMs);
|
|
389
569
|
if (reply) {
|
|
390
570
|
this.pendingPull = false;
|
|
391
|
-
this.
|
|
571
|
+
this.deliverFrame(reply);
|
|
392
572
|
}
|
|
393
573
|
}
|
|
574
|
+
/** Send a `frame` reply to the parent and count it toward `deliveredFps`. */
|
|
575
|
+
deliverFrame(reply) {
|
|
576
|
+
this.framesDelivered++;
|
|
577
|
+
this.send(this.toFrameReply(reply));
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* One-shot GPU-filter capability probe on the FIRST decoded HW frame: build a
|
|
581
|
+
* no-op `scale_<be>=iw:ih,hwdownload,format=nv12` graph and push the frame
|
|
582
|
+
* through it. A frame out ⇒ the driver supports GPU crop+scale for this
|
|
583
|
+
* session; any throw / empty output ⇒ fall back to software. The probe graph
|
|
584
|
+
* and its output frames are freed here regardless of outcome, so no surface
|
|
585
|
+
* leaks out of the probe. The input `frame` keeps its own ref (buffersrc only
|
|
586
|
+
* ref'd it) and is published by the caller on success.
|
|
587
|
+
*/
|
|
588
|
+
probeHwFilter(frame) {
|
|
589
|
+
const nav = this.nav;
|
|
590
|
+
const scaleFilter = this.gpuScaleFilter;
|
|
591
|
+
if (!nav || !scaleFilter || !this.hwContext) return false;
|
|
592
|
+
let filter = null;
|
|
593
|
+
try {
|
|
594
|
+
filter = nav.FilterAPI.create(`${scaleFilter}=w=iw:h=ih,hwdownload,format=nv12`, { hardware: this.hwContext });
|
|
595
|
+
const outputs = filter.processAllSync(frame);
|
|
596
|
+
const ok = outputs.length > 0;
|
|
597
|
+
for (const out of outputs) out.free();
|
|
598
|
+
return ok;
|
|
599
|
+
} catch (err) {
|
|
600
|
+
this.emitStderr(`decode-worker-child: GPU filter probe failed — using software decode: ${errMessage(err)}\n`);
|
|
601
|
+
return false;
|
|
602
|
+
} finally {
|
|
603
|
+
filter?.close();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Abandon the GPU filter path for the rest of the session: close the
|
|
608
|
+
* filtergraph (freeing its GPU surface pool), mark the decision `software`,
|
|
609
|
+
* and abort the current HW dial so the decode loop re-dials with a YUV420P
|
|
610
|
+
* rescale. Called from the probe path AND from a runtime `toBuffer` GPU-filter
|
|
611
|
+
* failure, so a surprising driver/format error never leaves the session
|
|
612
|
+
* silently producing no usable frames. Idempotent.
|
|
613
|
+
*/
|
|
614
|
+
degradeToSoftware() {
|
|
615
|
+
if (this.hwFilterDecision === "software") return;
|
|
616
|
+
this.hwFilterDecision = "software";
|
|
617
|
+
this.redialAsSoftware = true;
|
|
618
|
+
this.hwFilter?.close();
|
|
619
|
+
this.hwFilter = null;
|
|
620
|
+
this.hwFilterKey = "";
|
|
621
|
+
this.abortController?.abort();
|
|
622
|
+
}
|
|
394
623
|
toFrameReply(reply) {
|
|
395
624
|
return {
|
|
396
625
|
kind: "frame",
|
|
@@ -473,10 +702,71 @@ var DecodeWorkerChild = class {
|
|
|
473
702
|
return scaler;
|
|
474
703
|
}
|
|
475
704
|
/**
|
|
705
|
+
* GPU crop+scale `toBuffer`: run the retained GPU surface through the cached
|
|
706
|
+
* GPU filtergraph (`scale_<be>→hwdownload→format` for the full-frame case),
|
|
707
|
+
* pulling ONLY the small detection-sized result into system memory, then pack
|
|
708
|
+
* it into the tightly-packed output buffer. On any GPU-filter failure,
|
|
709
|
+
* {@link degradeToSoftware} flips the whole session to the CPU path (via a
|
|
710
|
+
* re-dial) and this call rethrows so the pump drops just this one frame.
|
|
711
|
+
*
|
|
712
|
+
* Surface lifecycle: the input GPU surface is owned by {@link frames} (freed
|
|
713
|
+
* by the slot lifecycle) — `processAllSync` only ref's it. Every OUTPUT frame
|
|
714
|
+
* the filtergraph returns is freed here in the `finally`.
|
|
715
|
+
*/
|
|
716
|
+
hwScaleToBuffer(frame, opts) {
|
|
717
|
+
try {
|
|
718
|
+
const region = resolveCropRegion(frame.width, frame.height, opts?.crop);
|
|
719
|
+
const target = resolveResizeTarget(region.width, region.height, opts?.resize);
|
|
720
|
+
const format = opts?.format ?? this.sessionFormat;
|
|
721
|
+
const channels = format === "gray" ? 1 : 3;
|
|
722
|
+
const outputs = this.ensureHwFilter(region, target, frame.width, frame.height, format).processAllSync(frame);
|
|
723
|
+
const first = outputs[0];
|
|
724
|
+
if (!first) throw new Error("GPU filtergraph produced no frame");
|
|
725
|
+
try {
|
|
726
|
+
const plane = first.data?.[0];
|
|
727
|
+
if (!plane) throw new Error("GPU filtergraph frame missing packed plane data");
|
|
728
|
+
return packSinglePlane(plane, first.linesize[0] ?? 0, target.width, target.height, channels);
|
|
729
|
+
} finally {
|
|
730
|
+
for (const out of outputs) out.free();
|
|
731
|
+
}
|
|
732
|
+
} catch (err) {
|
|
733
|
+
this.degradeToSoftware();
|
|
734
|
+
throw err instanceof Error ? err : new Error(errMessage(err));
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Build-once / reuse the GPU crop+scale {@link NavFilterApi} for a given crop
|
|
739
|
+
* region + resize target + pixel format — the HW analogue of
|
|
740
|
+
* {@link ensureScaler}. Rebuilt (old CLOSED, freeing its GPU surface pool)
|
|
741
|
+
* only when the key changes; detection's ROI + resize target are stable, so
|
|
742
|
+
* this is a per-worker singleton in the steady state.
|
|
743
|
+
*/
|
|
744
|
+
ensureHwFilter(region, target, srcW, srcH, format) {
|
|
745
|
+
const nav = this.nav;
|
|
746
|
+
const scaleFilter = this.gpuScaleFilter;
|
|
747
|
+
if (!nav || !scaleFilter || !this.hwContext) throw new Error("decode-worker-child: GPU filter requested without a HW context");
|
|
748
|
+
const fullFrame = isFullFrameRegion(region, srcW, srcH);
|
|
749
|
+
const pixel = pixelFilterName(format);
|
|
750
|
+
const key = `${region.left},${region.top},${region.width},${region.height}->${target.width}x${target.height}:${pixel}`;
|
|
751
|
+
const cached = this.hwFilter;
|
|
752
|
+
if (cached && key === this.hwFilterKey) return cached;
|
|
753
|
+
this.hwFilter?.close();
|
|
754
|
+
this.hwFilter = null;
|
|
755
|
+
this.hwFilterKey = "";
|
|
756
|
+
const description = buildGpuFilterDescription(scaleFilter, region, target, fullFrame, pixel);
|
|
757
|
+
const filter = nav.FilterAPI.create(description, { hardware: this.hwContext });
|
|
758
|
+
this.hwFilter = filter;
|
|
759
|
+
this.hwFilterKey = key;
|
|
760
|
+
return filter;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
476
763
|
* Resolve the HW context ONCE (reused across every re-dial, freed in
|
|
477
764
|
* `teardown`). Tries the platform's ordered candidate list; the first
|
|
478
765
|
* `HardwareContext.create` that succeeds wins; every failure falls through
|
|
479
|
-
* to software decode (`hardware` omitted from `Decoder.create`).
|
|
766
|
+
* to software decode (`hardware` omitted from `Decoder.create`). When a GPU
|
|
767
|
+
* device is found, the session becomes a GPU crop+scale CANDIDATE
|
|
768
|
+
* (`hwFilterDecision = 'undecided'`) only if a `scale_<be>` filter exists for
|
|
769
|
+
* that backend — otherwise it stays on the software path.
|
|
480
770
|
*/
|
|
481
771
|
async ensureHwContext(nav, C) {
|
|
482
772
|
for (const backend of candidateBackends()) {
|
|
@@ -484,13 +774,15 @@ var DecodeWorkerChild = class {
|
|
|
484
774
|
if (deviceType === null) continue;
|
|
485
775
|
const hw = nav.HardwareContext.create(deviceType);
|
|
486
776
|
if (!hw) {
|
|
487
|
-
|
|
777
|
+
this.emitStderr(`decode-worker-child: hwaccel candidate '${backend}' failed — trying next\n`);
|
|
488
778
|
continue;
|
|
489
779
|
}
|
|
490
780
|
this.hwContext = hw;
|
|
781
|
+
this.gpuScaleFilter = gpuScaleFilterForBackend(backend);
|
|
782
|
+
this.hwFilterDecision = this.gpuScaleFilter !== null ? "undecided" : "software";
|
|
491
783
|
return;
|
|
492
784
|
}
|
|
493
|
-
|
|
785
|
+
this.emitStderr("decode-worker-child: no hwaccel backend available — using software decode\n");
|
|
494
786
|
}
|
|
495
787
|
/** Interruptible re-dial backoff — `teardown` clears the pending timer. */
|
|
496
788
|
sleep(ms) {
|
|
@@ -501,8 +793,18 @@ var DecodeWorkerChild = class {
|
|
|
501
793
|
}, ms);
|
|
502
794
|
});
|
|
503
795
|
}
|
|
504
|
-
/**
|
|
796
|
+
/**
|
|
797
|
+
* Dispose the CURRENT dial's demuxer + decoder (the HW *device* context is
|
|
798
|
+
* reused across dials). The GPU filtergraph is closed FIRST: it references the
|
|
799
|
+
* decoder's per-dial `hw_frames_ctx` AND owns the `scale_<be>` OUTPUT surface
|
|
800
|
+
* pool, so freeing it before the decoder is what stops re-dials from
|
|
801
|
+
* accumulating VAAPI surfaces (the exact prior leak). It is rebuilt lazily on
|
|
802
|
+
* the next dial's first `toBuffer`.
|
|
803
|
+
*/
|
|
505
804
|
closeInput() {
|
|
805
|
+
this.hwFilter?.close();
|
|
806
|
+
this.hwFilter = null;
|
|
807
|
+
this.hwFilterKey = "";
|
|
506
808
|
this.decoder?.[Symbol.dispose]?.();
|
|
507
809
|
this.decoder = null;
|
|
508
810
|
this.demuxer?.[Symbol.dispose]?.();
|
|
@@ -511,8 +813,19 @@ var DecodeWorkerChild = class {
|
|
|
511
813
|
send(reply) {
|
|
512
814
|
process.send?.(reply);
|
|
513
815
|
}
|
|
816
|
+
/**
|
|
817
|
+
* Write one diagnostic line to the (inherited) stderr, prefixed with the
|
|
818
|
+
* device tag so the per-device log UI can attribute the forked worker's
|
|
819
|
+
* output. Public so the module-level process handlers — which only hold the
|
|
820
|
+
* `worker` instance — emit device-tagged lines too. Callers pass the line
|
|
821
|
+
* INCLUDING its trailing newline (unchanged from the prior direct writes).
|
|
822
|
+
*/
|
|
823
|
+
emitStderr(line) {
|
|
824
|
+
const tag = this.deviceId === null ? "[dev:?]" : `[dev:${this.deviceId}]`;
|
|
825
|
+
process.stderr.write(`${tag} ${line}`);
|
|
826
|
+
}
|
|
514
827
|
sendError(message, frameId) {
|
|
515
|
-
|
|
828
|
+
this.emitStderr(`${message}\n`);
|
|
516
829
|
this.send(frameId === void 0 ? {
|
|
517
830
|
kind: "error",
|
|
518
831
|
message
|
|
@@ -532,12 +845,12 @@ process.on("disconnect", () => {
|
|
|
532
845
|
process.exit(0);
|
|
533
846
|
});
|
|
534
847
|
process.on("uncaughtException", (err) => {
|
|
535
|
-
|
|
848
|
+
worker.emitStderr(`decode-worker-child: uncaught exception — ${errMessage(err)}\n`);
|
|
536
849
|
worker.teardown();
|
|
537
850
|
process.exit(1);
|
|
538
851
|
});
|
|
539
852
|
process.on("unhandledRejection", (reason) => {
|
|
540
|
-
|
|
853
|
+
worker.emitStderr(`decode-worker-child: unhandled rejection — ${errMessage(reason)}\n`);
|
|
541
854
|
});
|
|
542
855
|
//#endregion
|
|
543
856
|
export { DecodeWorkerChild };
|
|
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
|
|
|
3
3
|
var e = {
|
|
4
4
|
"@camstack/sdk": {
|
|
5
5
|
name: "@camstack/sdk",
|
|
6
|
-
version: "1.1.
|
|
6
|
+
version: "1.1.19",
|
|
7
7
|
scope: ["default"],
|
|
8
8
|
loaded: !1,
|
|
9
9
|
from: "addon_stream_broker_widgets",
|
|
@@ -18,7 +18,7 @@ var e = {
|
|
|
18
18
|
},
|
|
19
19
|
"@camstack/types": {
|
|
20
20
|
name: "@camstack/types",
|
|
21
|
-
version: "1.1.
|
|
21
|
+
version: "1.1.32",
|
|
22
22
|
scope: ["default"],
|
|
23
23
|
loaded: !1,
|
|
24
24
|
from: "addon_stream_broker_widgets",
|
|
@@ -33,7 +33,7 @@ var e = {
|
|
|
33
33
|
},
|
|
34
34
|
"@camstack/ui-library": {
|
|
35
35
|
name: "@camstack/ui-library",
|
|
36
|
-
version: "1.1.
|
|
36
|
+
version: "1.1.27",
|
|
37
37
|
scope: ["default"],
|
|
38
38
|
loaded: !1,
|
|
39
39
|
from: "addon_stream_broker_widgets",
|
|
@@ -36,7 +36,7 @@ async function r() {
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"@camstack/types": {
|
|
39
|
-
version: "1.1.
|
|
39
|
+
version: "1.1.32",
|
|
40
40
|
scope: "default",
|
|
41
41
|
shareConfig: {
|
|
42
42
|
singleton: !0,
|
|
@@ -45,7 +45,7 @@ async function r() {
|
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"@camstack/sdk": {
|
|
48
|
-
version: "1.1.
|
|
48
|
+
version: "1.1.19",
|
|
49
49
|
scope: "default",
|
|
50
50
|
shareConfig: {
|
|
51
51
|
singleton: !0,
|
|
@@ -81,7 +81,7 @@ async function r() {
|
|
|
81
81
|
}
|
|
82
82
|
},
|
|
83
83
|
"@camstack/ui-library": {
|
|
84
|
-
version: "1.1.
|
|
84
|
+
version: "1.1.27",
|
|
85
85
|
scope: "default",
|
|
86
86
|
shareConfig: {
|
|
87
87
|
singleton: !0,
|