@camstack/addon-pipeline 1.1.26 → 1.1.28

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.
Files changed (48) hide show
  1. package/dist/audio-analyzer/index.js +7 -6
  2. package/dist/audio-analyzer/index.mjs +7 -6
  3. package/dist/audio-codec-ffmpeg/index.js +1 -1
  4. package/dist/audio-codec-ffmpeg/index.mjs +1 -1
  5. package/dist/constants-CAqYVigN.js +6615 -0
  6. package/dist/constants-oYgW36Ty.mjs +3587 -0
  7. package/dist/decoder-ffmpeg/index.js +74 -281
  8. package/dist/decoder-ffmpeg/index.mjs +73 -280
  9. package/dist/decoder-nodeav/index.js +1218 -0
  10. package/dist/decoder-nodeav/index.mjs +1210 -0
  11. package/dist/detection-pipeline/index.js +157 -219
  12. package/dist/detection-pipeline/index.mjs +157 -219
  13. package/dist/{dist-DAIlCdAx.js → dist-Biq62zt4.js} +372 -35
  14. package/dist/{dist-CgEP_0OL.mjs → dist-C6_wgXqF.mjs} +373 -24
  15. package/dist/dist-D3Ytt-_Y.js +41111 -0
  16. package/dist/dist-DluJuxV5.mjs +37976 -0
  17. package/dist/{frame-handle-plane-Dq20KtKL.mjs → frame-handle-plane-BIoY6nRV.mjs} +13 -5
  18. package/dist/{frame-handle-plane-DtTRX_0n.js → frame-handle-plane-D6BzyEgy.js} +13 -5
  19. package/dist/frame-ring-sink-9J0wCdLF.js +444 -0
  20. package/dist/frame-ring-sink-Cs9vby6v.mjs +409 -0
  21. package/dist/motion-wasm/index.js +55 -3
  22. package/dist/motion-wasm/index.mjs +55 -3
  23. package/dist/node-topology-platform-BkR_k6WT.mjs +15 -0
  24. package/dist/node-topology-platform-CFZ7F4xW.js +20 -0
  25. package/dist/pipeline-runner/index.js +50 -16
  26. package/dist/pipeline-runner/index.mjs +50 -16
  27. package/dist/recorder/index.js +36 -40
  28. package/dist/recorder/index.mjs +36 -40
  29. package/dist/stream-broker/_stub.js +2 -2
  30. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-5tQlh9h4.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DoReAb4y.mjs} +3 -3
  31. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DhY3MZ2C.mjs +26 -0
  32. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BJK0-svt.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DtoYGpTp.mjs} +1 -1
  33. package/dist/stream-broker/{hostInit-DyLqyJaS.mjs → hostInit-C0SuwQhL.mjs} +3 -3
  34. package/dist/stream-broker/index.js +81 -40
  35. package/dist/stream-broker/index.mjs +81 -40
  36. package/dist/stream-broker/remoteEntry.js +1 -1
  37. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-BDLNwJ_F.js → MaskShapeCanvas-DI4BY7W2-Br2yi8ah.js} +1 -1
  38. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CoLjNiUN.js → MotionZonesSettings-NcxxQN8r-BSCcPdPf.js} +1 -1
  39. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DJE3OU-q.js → PrivacyMaskSettings-APgPLF7p-ztxvxSjS.js} +1 -1
  40. package/embed-dist/assets/index-C5UpuPr8.css +2 -0
  41. package/embed-dist/assets/{index-C-pL8ETk.js → index-CeZV1B-2.js} +10 -10
  42. package/embed-dist/index.html +2 -2
  43. package/package.json +31 -3
  44. package/python/inference_pool.py +108 -121
  45. package/python/test_inference_pool_device_selection.py +47 -43
  46. package/python/yamnet_audio.py +1 -1
  47. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CkOPfV8r.mjs +0 -26
  48. package/embed-dist/assets/index-DrJ0ee3f.css +0 -2
@@ -2,7 +2,8 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DAIlCdAx.js");
5
+ const require_dist = require("../dist-Biq62zt4.js");
6
+ const require_frame_ring_sink = require("../frame-ring-sink-9J0wCdLF.js");
6
7
  const require_frame_dropper = require("../frame-dropper-7RTo_YyG.js");
7
8
  let _camstack_shm_ring = require("@camstack/shm-ring");
8
9
  let node_crypto = require("node:crypto");
@@ -300,270 +301,6 @@ function rankDecodeHwAccels(preferred, supportedMethods) {
300
301
  return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
301
302
  }
302
303
  //#endregion
303
- //#region src/decoder-ffmpeg/frame-ring-sink.ts
304
- /**
305
- * `DecoderFrameRingSink` — the decoder's shared-memory write side (Phase 5 / D9).
306
- *
307
- * When a decoder session is configured with `frameSink: 'shm'`, the decoder
308
- * **owns** the shared-memory ring segment for that stream: it creates the
309
- * segment on the first decoded frame (when the output geometry is known),
310
- * writes every subsequent decoded frame into the ring via a `FrameRingWriter`,
311
- * and closes + unlinks the segment when the session is destroyed.
312
- *
313
- * What leaves the decoder is no longer the pixel `Buffer` — it is a tiny,
314
- * serialisable `FrameHandle` (`FrameRingWriter.writeFrame`'s return value).
315
- * Same-host consumers (motion, detection, the WebRTC encoder) open the same
316
- * segment with a `FrameRingReader` and read the pixels zero-copy.
317
- *
318
- * ## Lazy segment creation
319
- *
320
- * The segment cannot be sized until the first frame: `slotByteLength` is
321
- * `width × height × bytesPerPixel`, and the output dimensions are only known
322
- * once the scaler has produced its first `dstFrame`. So `writeFrame` is a
323
- * no-op-until-armed: the first call sizes + creates the segment, every later
324
- * call writes into it.
325
- *
326
- * ## Resolution-change decision
327
- *
328
- * A live camera stream can change resolution mid-stream (the decoder's scaler
329
- * is rebuilt on a config toggle, or the source renegotiates). The slot is
330
- * sized for the **first** frame's geometry. A later frame that no longer fits
331
- * the slot triggers a **segment re-create**: the old segment is closed +
332
- * unlinked and a fresh, larger segment is created under a new generation-tagged
333
- * name. This is simpler and leak-free versus over-allocating slots for a
334
- * worst-case 4K frame on every stream; resolution changes on a live camera are
335
- * rare, and a brief gap while consumers re-open the segment is acceptable
336
- * (latest-wins — a missed frame is correct behaviour).
337
- */
338
- /**
339
- * Per-ring shared-memory budget (MB) — the slot count is derived per-resolution
340
- * from this budget via {@link deriveSlotCount}, so a 360p stream gets many
341
- * slots and a 4K stream a few, both inside the same memory footprint.
342
- *
343
- * Read ONCE at module load from `CAMSTACK_SHM_RING_BUDGET_MB`; a non-finite or
344
- * non-positive value falls back to the 16 MB default.
345
- *
346
- * The default is deliberately small (16 MB) so many concurrent per-camera rings
347
- * fit inside a bounded `/dev/shm`. The decoder output is capped at 640px wide, so
348
- * a slot is ≤ ~920 KB and 16 MB still yields ~17–70 latest-wins slots. A ring
349
- * segment larger than the container's `/dev/shm` tmpfs backing (Docker default is
350
- * only 64 MB) faults an **uncatchable SIGBUS** on write past `st_size` — the old
351
- * 128 MB default overflowed a 64 MB `/dev/shm` and crashed the decoder on 8MP
352
- * streams. Pair this with a `--shm-size` that scales with concurrent-decode load.
353
- */
354
- var RING_BUDGET_MB = (() => {
355
- const raw = Number(process.env["CAMSTACK_SHM_RING_BUDGET_MB"]);
356
- return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 16;
357
- })();
358
- /** {@link RING_BUDGET_MB} in bytes — the budget passed to `deriveSlotCount`. */
359
- var RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
360
- /** A unique, stable shared-memory segment name for a decoder stream.
361
- *
362
- * macOS POSIX shm names are capped at ~31 characters (`PSHMNAMLEN`). A
363
- * `camstack.frames.<deviceId>.<streamId>` scheme overflows that for realistic
364
- * ids, so the sink uses a short, collision-resistant scheme instead:
365
- * `csf.<base36 hash>.<gen>`. The hash folds the device id, the session tag
366
- * and a per-process random salt; the generation suffix makes a re-created
367
- * segment (resolution change) a distinct name so a stale consumer mapping is
368
- * never silently reused.
369
- */
370
- function makeSegmentName(seed, generation) {
371
- let hash = 5381;
372
- for (let i = 0; i < seed.length; i += 1) hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
373
- return `csf.${(hash >>> 0).toString(36)}.${generation}`;
374
- }
375
- /**
376
- * The decoder-side owner of one stream's shared-memory frame ring.
377
- *
378
- * Not constructed until a session actually uses the shm sink; the segment
379
- * itself is created lazily on the first `writeFrame`.
380
- */
381
- var DecoderFrameRingSink = class {
382
- seed;
383
- logger;
384
- nodeId;
385
- segment = null;
386
- writer = null;
387
- segmentName = null;
388
- slotByteLength = 0;
389
- generation = 0;
390
- destroyed = false;
391
- /** Frames committed into the ring across this sink's lifetime (all generations). */
392
- framesWritten = 0;
393
- constructor(options) {
394
- const salt = Math.random().toString(36).slice(2, 8);
395
- this.seed = `${options.seed}.${salt}`;
396
- this.logger = options.logger;
397
- this.nodeId = options.nodeId;
398
- }
399
- /** Whether a segment has been created (i.e. at least one frame written). */
400
- get isArmed() {
401
- return this.writer !== null;
402
- }
403
- /** The current segment name, or `null` before the first frame. */
404
- get currentSegmentName() {
405
- return this.segmentName;
406
- }
407
- /**
408
- * Write one decoded frame into the ring and return its `FrameHandle`.
409
- *
410
- * On the first call (or after a geometry change that overflows the current
411
- * slot) the segment is created / re-created sized for this frame. Returns
412
- * `null` only when the sink has been destroyed.
413
- *
414
- * This is the copy-in convenience form (it copies `pixels` into the slot).
415
- * The decoder's hot path uses the zero-copy {@link beginFrame} /
416
- * {@link commitFrame} scatter-write pair instead — the scaler produces its
417
- * packed output directly into the slot, eliminating the write-side memcpy.
418
- */
419
- writeFrame(pixels, meta) {
420
- if (this.destroyed) return null;
421
- if (this.writer === null || (0, _camstack_shm_ring.computeSlotByteLength)(meta.width, meta.height, meta.format) > this.slotByteLength) this.recreateSegment((0, _camstack_shm_ring.computeSlotByteLength)(meta.width, meta.height, meta.format));
422
- const writer = this.writer;
423
- if (writer === null) return null;
424
- const handle = writer.writeFrame(pixels, meta);
425
- this.framesWritten += 1;
426
- return handle;
427
- }
428
- /**
429
- * Reserve a ring slot for a frame of the given geometry — the **zero-copy**
430
- * scatter-write entry point (Phase 5 / D9 Task 7c).
431
- *
432
- * The segment is created / re-created here if this is the first frame or the
433
- * geometry overflows the current slot capacity, so the slot is correctly
434
- * sized before the caller fills it. The returned `buffer` is a writable view
435
- * **directly over the mapped segment** — the node-av scaler scatters its
436
- * packed output straight into it, with no intermediate copy. The caller MUST
437
- * call {@link commitFrame} with the returned `slot` once the slot is filled.
438
- *
439
- * Returns `null` when the sink is destroyed or the segment cannot be created.
440
- */
441
- beginFrame(width, height, format) {
442
- if (this.destroyed) return null;
443
- const requiredSlotBytes = (0, _camstack_shm_ring.computeSlotByteLength)(width, height, format);
444
- if (this.writer === null || requiredSlotBytes > this.slotByteLength) this.recreateSegment(requiredSlotBytes);
445
- const writer = this.writer;
446
- if (writer === null) return null;
447
- const { slot, buffer } = writer.beginFrame();
448
- return {
449
- slot,
450
- buffer
451
- };
452
- }
453
- /**
454
- * Publish the frame whose slot was reserved by {@link beginFrame} and filled
455
- * in place by the caller. `slot` MUST be the value from the matching
456
- * `beginFrame`. Returns the published `FrameHandle`, or `null` if the sink
457
- * was destroyed (or the segment lost) between begin and commit.
458
- */
459
- commitFrame(slot, meta) {
460
- if (this.destroyed) return null;
461
- const writer = this.writer;
462
- if (writer === null) return null;
463
- const handle = writer.commitFrame(slot, meta);
464
- this.framesWritten += 1;
465
- return handle;
466
- }
467
- /**
468
- * Current shm ring usage — `null` until the first frame arms the segment.
469
- * Surfaced through `decoder.getShmStats` so a downstream consumer can
470
- * observe ring pressure (slot depth, byte budget, frames written).
471
- */
472
- getShmStats() {
473
- if (this.writer === null) return null;
474
- return {
475
- slotCount: this.writer.slotCount,
476
- slotByteLength: this.slotByteLength,
477
- segmentBytes: (0, _camstack_shm_ring.computeSegmentSize)(this.writer.slotCount, this.slotByteLength),
478
- framesWritten: this.framesWritten
479
- };
480
- }
481
- /**
482
- * Abandon a slot reserved by {@link beginFrame} **without publishing it** —
483
- * the degenerate-path counterpart of {@link commitFrame}.
484
- *
485
- * A caller that reserved a slot but then could not produce valid pixels (no
486
- * decoded source planes, or the scaler threw) MUST call this instead of
487
- * `commitFrame`: it closes the open seqlock without advancing `writeIndex`,
488
- * so no reader ever sees the slot's uninitialised bytes as a real frame, and
489
- * no `FrameHandle` is handed downstream. `slot` MUST be the value from the
490
- * matching `beginFrame`. A no-op if the sink was destroyed (or the segment
491
- * lost) between begin and abort.
492
- */
493
- abortFrame(slot) {
494
- if (this.destroyed) return;
495
- const writer = this.writer;
496
- if (writer === null) return;
497
- writer.abortFrame(slot);
498
- }
499
- /** Close + unlink the segment. Idempotent. */
500
- destroy() {
501
- if (this.destroyed) return;
502
- this.destroyed = true;
503
- this.releaseSegment();
504
- }
505
- /**
506
- * Create a fresh segment sized for at least `slotByteLength` bytes per slot,
507
- * replacing any prior one. A re-create bumps the generation so the new
508
- * segment has a distinct name — a consumer holding the old mapping is never
509
- * silently handed a resized segment.
510
- */
511
- recreateSegment(slotByteLength) {
512
- this.releaseSegment();
513
- this.generation += 1;
514
- const name = makeSegmentName(this.seed, this.generation);
515
- const slotCount = (0, _camstack_shm_ring.deriveSlotCount)(RING_BUDGET_BYTES, slotByteLength);
516
- if (slotCount === _camstack_shm_ring.MIN_RING_SLOTS && _camstack_shm_ring.MIN_RING_SLOTS * slotByteLength > RING_BUDGET_BYTES) this.logger.warn("decoder shm ring: budget too small for resolution — using MIN slots", { meta: {
517
- slotByteLength,
518
- budgetMb: RING_BUDGET_MB
519
- } });
520
- const totalBytes = (0, _camstack_shm_ring.computeSegmentSize)(slotCount, slotByteLength);
521
- try {
522
- const segment = (0, _camstack_shm_ring.createSegment)(name, totalBytes);
523
- this.segment = segment;
524
- this.segmentName = name;
525
- this.slotByteLength = slotByteLength;
526
- this.writer = new _camstack_shm_ring.FrameRingWriter(segment.buffer, name, slotCount, slotByteLength, this.nodeId);
527
- this.logger.info("decoder shm ring: segment created", { meta: {
528
- segment: name,
529
- slotCount,
530
- slotByteLength,
531
- totalBytes,
532
- generation: this.generation
533
- } });
534
- } catch (err) {
535
- this.segment = null;
536
- this.writer = null;
537
- this.segmentName = null;
538
- this.slotByteLength = 0;
539
- this.logger.error("decoder shm ring: segment create failed", { meta: {
540
- segment: name,
541
- slotByteLength,
542
- error: err instanceof Error ? err.message : String(err)
543
- } });
544
- }
545
- }
546
- /** Unmap + unlink the current segment, if any. */
547
- releaseSegment() {
548
- const segment = this.segment;
549
- if (segment === null) return;
550
- this.segment = null;
551
- this.writer = null;
552
- const name = this.segmentName;
553
- this.segmentName = null;
554
- try {
555
- segment.close();
556
- segment.unlink();
557
- this.logger.info("decoder shm ring: segment released", { meta: { segment: name } });
558
- } catch (err) {
559
- this.logger.warn("decoder shm ring: segment release failed", { meta: {
560
- segment: name,
561
- error: err instanceof Error ? err.message : String(err)
562
- } });
563
- }
564
- }
565
- };
566
- //#endregion
567
304
  //#region src/decoder-ffmpeg/frame-stream-splitter.ts
568
305
  var FrameStreamSplitter = class {
569
306
  frameSize;
@@ -699,6 +436,14 @@ var FfmpegDecoderSession = class {
699
436
  process = null;
700
437
  spawnedAtMs = 0;
701
438
  /**
439
+ * Whether a spawn has ever been attempted. Drives the LAZY first spawn
440
+ * (first `pushPacket` / `openStream`) and preserves the recovery semantics
441
+ * afterwards: a push-mode child that later dies is NOT respawned by the
442
+ * packet feed (the consumer / frame-plane self-heal recreates the session),
443
+ * and `updateConfig` only restarts a session that has already spawned.
444
+ */
445
+ spawnAttempted = false;
446
+ /**
702
447
  * PULL-MODE input URL (P2b). `null` = push mode (packets via stdin — the
703
448
  * default). Set by {@link openStream}; every (re)spawn since reads this URL
704
449
  * directly instead of `pipe:0`. A session is push-mode or pull-mode for its
@@ -754,7 +499,6 @@ var FfmpegDecoderSession = class {
754
499
  this.channels = channelsForPixel(this.pixel);
755
500
  this.frameFormat = this.pixel === "gray" ? "gray" : "rgb";
756
501
  this.frameDropper = new require_frame_dropper.FrameDropper(config.maxFps);
757
- this.spawnFfmpeg();
758
502
  }
759
503
  /** Exposed so the owning addon can surface ring stats via `getShmStats`. */
760
504
  get frameRingSinkOrNull() {
@@ -787,7 +531,7 @@ var FfmpegDecoderSession = class {
787
531
  if (typeof this.config.deviceId === "number") seedParts.push(String(this.config.deviceId));
788
532
  if (typeof this.config.tag === "string" && this.config.tag.length > 0) seedParts.push(this.config.tag);
789
533
  const seed = seedParts.length > 0 ? seedParts.join(":") : "anon";
790
- this.frameRingSink = new DecoderFrameRingSink({
534
+ this.frameRingSink = new require_frame_ring_sink.DecoderFrameRingSink({
791
535
  seed,
792
536
  logger: this.logger,
793
537
  nodeId: this.nodeId
@@ -797,6 +541,7 @@ var FfmpegDecoderSession = class {
797
541
  }
798
542
  spawnFfmpeg() {
799
543
  if (this.destroyed) return;
544
+ this.spawnAttempted = true;
800
545
  this.clearPullRedial();
801
546
  this.killFfmpeg();
802
547
  this.abortOpenSlot();
@@ -827,12 +572,19 @@ var FfmpegDecoderSession = class {
827
572
  this.process = child;
828
573
  this.spawnedAtMs = Date.now();
829
574
  child.stdin?.on("error", () => {});
830
- child.stdout?.on("data", (chunk) => this.handleStdout(chunk));
831
- child.stderr?.on("data", (data) => this.handleStderr(data));
575
+ child.stdout?.on("data", (chunk) => {
576
+ if (child === this.process) this.handleStdout(chunk);
577
+ });
578
+ child.stderr?.on("data", (data) => {
579
+ if (child === this.process) this.handleStderr(data);
580
+ });
832
581
  child.on("error", (err) => {
582
+ if (child !== this.process) return;
833
583
  this.logger.error("ffmpeg decoder: process error", { meta: { error: err.message } });
834
584
  });
835
- child.on("exit", (code, signal) => this.handleExit(code, signal));
585
+ child.on("exit", (code, signal) => {
586
+ if (child === this.process) this.handleExit(code, signal);
587
+ });
836
588
  this.logger.info("ffmpeg decoder: spawned", { meta: {
837
589
  hwaccel: this.hwaccel,
838
590
  gpuScale: this.activeGpuScaleFilter ?? "cpu",
@@ -845,6 +597,9 @@ var FfmpegDecoderSession = class {
845
597
  const child = this.process;
846
598
  if (!child) return;
847
599
  this.process = null;
600
+ child.stdout?.removeAllListeners("data");
601
+ child.stderr?.removeAllListeners("data");
602
+ child.removeAllListeners("exit");
848
603
  try {
849
604
  child.stdin?.end();
850
605
  } catch {}
@@ -1028,6 +783,7 @@ var FfmpegDecoderSession = class {
1028
783
  pushPacket(packet) {
1029
784
  if (this.destroyed) return;
1030
785
  if (this.pullUrl !== null) return;
786
+ if (this.process === null && !this.spawnAttempted) this.spawnFfmpeg();
1031
787
  const stdin = this.process?.stdin;
1032
788
  if (!stdin) return;
1033
789
  this.inputPackets++;
@@ -1055,7 +811,7 @@ var FfmpegDecoderSession = class {
1055
811
  ...update
1056
812
  };
1057
813
  if (update.maxFps !== void 0) this.frameDropper.setMaxFps(update.maxFps);
1058
- if (needsRestart) this.spawnFfmpeg();
814
+ if (needsRestart && this.spawnAttempted) this.spawnFfmpeg();
1059
815
  }
1060
816
  async destroy() {
1061
817
  if (this.destroyed) return;
@@ -1140,6 +896,18 @@ function probeGpuScaleFilters(ffmpegPath, logger) {
1140
896
  //#endregion
1141
897
  //#region src/decoder-ffmpeg/addon/index.ts
1142
898
  var FRAME_BUFFER_CAPACITY = 32;
899
+ var DEFAULT_DECODER_FFMPEG_ADDON_CONFIG = {
900
+ ...require_dist.DEFAULT_DECODER_HWACCEL_CONFIG,
901
+ backend: require_frame_ring_sink.DEFAULT_DECODER_BACKEND
902
+ };
903
+ /** The two selectable decoder backends, in default-first order. */
904
+ var DECODER_BACKEND_OPTIONS = [{
905
+ value: "ffmpeg",
906
+ label: "ffmpeg (subprocess — crash-isolated, default)"
907
+ }, {
908
+ value: "nodeav",
909
+ label: "node-av (in-process FFmpeg bindings)"
910
+ }];
1143
911
  var DecoderFfmpegAddon = class extends require_dist.BaseAddon {
1144
912
  sessions = /* @__PURE__ */ new Map();
1145
913
  frameBuffers = /* @__PURE__ */ new Map();
@@ -1170,10 +938,24 @@ var DecoderFfmpegAddon = class extends require_dist.BaseAddon {
1170
938
  */
1171
939
  unsupportedGpuScaleFilters = /* @__PURE__ */ new Set();
1172
940
  constructor() {
1173
- super(require_dist.DEFAULT_DECODER_HWACCEL_CONFIG);
941
+ super(DEFAULT_DECODER_FFMPEG_ADDON_CONFIG);
1174
942
  }
1175
943
  globalSettingsSchema() {
1176
944
  return this.schema({ sections: [{
945
+ id: "backend",
946
+ title: "Decoder backend",
947
+ tab: "decoder",
948
+ description: "Which decoder addon provides the decoder capability on this node — per-node selection, stored centrally on the hub. Selection is decided once at addon init (registration-time, race-free), so a change applies at the next restart of the decoder addons on the node.",
949
+ fields: [this.field({
950
+ type: "select",
951
+ key: "backend",
952
+ label: "Backend",
953
+ options: [...DECODER_BACKEND_OPTIONS],
954
+ default: require_frame_ring_sink.DEFAULT_DECODER_BACKEND,
955
+ immediate: true,
956
+ perNode: true
957
+ })]
958
+ }, {
1177
959
  id: "hwaccel",
1178
960
  title: "Hardware acceleration",
1179
961
  tab: "decoder",
@@ -1192,6 +974,7 @@ var DecoderFfmpegAddon = class extends require_dist.BaseAddon {
1192
974
  description: "Auto-detected best decoder backend on this host. Click the refresh icon to re-run the probe.",
1193
975
  readonlyField: true,
1194
976
  default: "",
977
+ perNode: true,
1195
978
  actions: [{
1196
979
  action: "reprobe-hwaccel",
1197
980
  icon: "refresh-cw",
@@ -1201,7 +984,12 @@ var DecoderFfmpegAddon = class extends require_dist.BaseAddon {
1201
984
  }] });
1202
985
  }
1203
986
  async onInitialize() {
1204
- this.ctx.logger.info("ffmpeg decoder addon initialized");
987
+ const backend = await require_frame_ring_sink.resolveDecoderBackend(this.ctx.api, this.resolveLocalNodeId(), this.ctx.logger);
988
+ if (backend !== "ffmpeg") {
989
+ this.ctx.logger.info("ffmpeg decoder: this node selects a different decoder backend — standing down (no decoder provider registered)", { meta: { selectedBackend: backend } });
990
+ return [];
991
+ }
992
+ this.ctx.logger.info("ffmpeg decoder addon initialized", { meta: { selectedBackend: backend } });
1205
993
  this.frameReaders = new _camstack_shm_ring.FrameRingReaderCache(this.ctx.logger);
1206
994
  this.ffmpegPath = await this.resolveFfmpegBinaryPath();
1207
995
  this.probedGpuScaleFilters = await probeGpuScaleFilters(this.ffmpegPath, this.ctx.logger);
@@ -1264,8 +1052,9 @@ var DecoderFfmpegAddon = class extends require_dist.BaseAddon {
1264
1052
  if (pref === "none") return [];
1265
1053
  if (pref !== "auto") return [pref];
1266
1054
  try {
1267
- const res = await this.ctx.api.platformProbe.resolveHwAccel.query({ prefer: null });
1268
- const supported = await this.ctx.api.platformProbe.getHardwareDecodeAccels.query();
1055
+ const pin = require_dist.nodePin(this.resolveLocalNodeId());
1056
+ const res = await this.ctx.api.platformProbe.resolveHwAccel.query({ prefer: null }, pin);
1057
+ const supported = await this.ctx.api.platformProbe.getHardwareDecodeAccels.query(void 0, pin);
1269
1058
  return rankDecodeHwAccels(res.preferred, supported.methods);
1270
1059
  } catch (err) {
1271
1060
  this.ctx.logger.warn("ffmpeg: decode-hwaccel probe failed — software decode", { meta: { error: err instanceof Error ? err.message : String(err) } });
@@ -1276,13 +1065,13 @@ var DecoderFfmpegAddon = class extends require_dist.BaseAddon {
1276
1065
  const resolver = this.ctx.kernel.hwaccel;
1277
1066
  if (!resolver) {
1278
1067
  this.ctx.logger.warn("reprobeHwaccel: no kernel hwaccel resolver — returning none");
1279
- await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: "none" });
1068
+ await this.updateGlobalSettings({ probedBestHwaccel: "none" });
1280
1069
  return { backend: "none" };
1281
1070
  }
1282
1071
  try {
1283
1072
  const res = await resolver.resolve();
1284
1073
  const backend = res.preferred[0] ?? "none";
1285
- await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: backend });
1074
+ await this.updateGlobalSettings({ probedBestHwaccel: backend });
1286
1075
  this.ctx.logger.info("reprobeHwaccel: wrote probedBestHwaccel", { meta: {
1287
1076
  backend,
1288
1077
  rationale: res.rationale,
@@ -1291,7 +1080,7 @@ var DecoderFfmpegAddon = class extends require_dist.BaseAddon {
1291
1080
  return { backend };
1292
1081
  } catch (err) {
1293
1082
  this.ctx.logger.warn("reprobeHwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
1294
- await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: "none" });
1083
+ await this.updateGlobalSettings({ probedBestHwaccel: "none" });
1295
1084
  return { backend: "none" };
1296
1085
  }
1297
1086
  }
@@ -1358,7 +1147,7 @@ var DecoderFfmpegAddon = class extends require_dist.BaseAddon {
1358
1147
  };
1359
1148
  }
1360
1149
  wireCallbackSink(sessionId, session) {
1361
- const ringBuffer = new require_dist.RingBuffer(FRAME_BUFFER_CAPACITY);
1150
+ const ringBuffer = new require_frame_ring_sink.NotifyingRingBuffer(FRAME_BUFFER_CAPACITY);
1362
1151
  this.frameBuffers.set(sessionId, ringBuffer);
1363
1152
  return session.onFrame((frame) => {
1364
1153
  const { format } = frame;
@@ -1376,7 +1165,7 @@ var DecoderFfmpegAddon = class extends require_dist.BaseAddon {
1376
1165
  });
1377
1166
  }
1378
1167
  wireShmSink(sessionId, session) {
1379
- const handleRing = new require_dist.RingBuffer(FRAME_BUFFER_CAPACITY);
1168
+ const handleRing = new require_frame_ring_sink.NotifyingRingBuffer(FRAME_BUFFER_CAPACITY);
1380
1169
  this.handleBuffers.set(sessionId, handleRing);
1381
1170
  return session.onFrameHandle((frame) => {
1382
1171
  handleRing.push(frame.handle);
@@ -1388,6 +1177,8 @@ var DecoderFfmpegAddon = class extends require_dist.BaseAddon {
1388
1177
  if (!session) throw new Error(`decoder-ffmpeg: unknown sessionId ${sessionId}`);
1389
1178
  const unsub = this.unsubscribers.get(sessionId);
1390
1179
  if (unsub) unsub();
1180
+ this.frameBuffers.get(sessionId)?.cancel();
1181
+ this.handleBuffers.get(sessionId)?.cancel();
1391
1182
  await session.destroy();
1392
1183
  this.sessions.delete(sessionId);
1393
1184
  this.frameBuffers.delete(sessionId);
@@ -1425,12 +1216,14 @@ var DecoderFfmpegAddon = class extends require_dist.BaseAddon {
1425
1216
  if (!this.sessions.has(input.sessionId)) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
1426
1217
  const ringBuffer = this.frameBuffers.get(input.sessionId);
1427
1218
  if (!ringBuffer) return [];
1219
+ if (ringBuffer.size === 0 && input.waitMs) await ringBuffer.waitForItem(input.waitMs);
1428
1220
  return ringBuffer.drain(input.maxCount);
1429
1221
  }
1430
1222
  async pullHandles(input) {
1431
1223
  if (!this.sessions.has(input.sessionId)) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
1432
1224
  const handleRing = this.handleBuffers.get(input.sessionId);
1433
1225
  if (!handleRing) return [];
1226
+ if (handleRing.size === 0 && input.waitMs) await handleRing.waitForItem(input.waitMs);
1434
1227
  return handleRing.drain(input.maxCount);
1435
1228
  }
1436
1229
  async updateConfig(input) {
@@ -1466,7 +1259,7 @@ var DecoderFfmpegAddon = class extends require_dist.BaseAddon {
1466
1259
  return {
1467
1260
  sessionId: input.sessionId,
1468
1261
  ...stats,
1469
- budgetMb: RING_BUDGET_MB,
1262
+ budgetMb: require_frame_ring_sink.RING_BUDGET_MB,
1470
1263
  getFrameHits: this.getFrameHits,
1471
1264
  getFrameMisses: this.getFrameMisses
1472
1265
  };