@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
@@ -1,6 +1,7 @@
1
- import { B as errMsg, V as BaseAddon, d as HWACCEL_OPTIONS, p as RingBuffer, s as DEFAULT_DECODER_HWACCEL_CONFIG, x as decoderCapability } from "../dist-CgEP_0OL.mjs";
1
+ import { B as errMsg, V as BaseAddon, d as HWACCEL_OPTIONS, j as nodePin, s as DEFAULT_DECODER_HWACCEL_CONFIG, x as decoderCapability } from "../dist-C6_wgXqF.mjs";
2
+ import { a as resolveDecoderBackend, i as NotifyingRingBuffer, n as RING_BUDGET_MB, o as DEFAULT_DECODER_BACKEND, t as DecoderFrameRingSink } from "../frame-ring-sink-Cs9vby6v.mjs";
2
3
  import { r as logBannerArgs, t as FrameDropper } from "../frame-dropper-CwkBTPGV.mjs";
3
- import { FrameRingReaderCache, FrameRingWriter, MIN_RING_SLOTS, computeSegmentSize, computeSlotByteLength, createSegment, deriveSlotCount } from "@camstack/shm-ring";
4
+ import { FrameRingReaderCache } from "@camstack/shm-ring";
4
5
  import { randomUUID } from "node:crypto";
5
6
  import { spawn } from "node:child_process";
6
7
  //#region src/decoder-ffmpeg/ffmpeg-args.ts
@@ -296,270 +297,6 @@ function rankDecodeHwAccels(preferred, supportedMethods) {
296
297
  return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
297
298
  }
298
299
  //#endregion
299
- //#region src/decoder-ffmpeg/frame-ring-sink.ts
300
- /**
301
- * `DecoderFrameRingSink` — the decoder's shared-memory write side (Phase 5 / D9).
302
- *
303
- * When a decoder session is configured with `frameSink: 'shm'`, the decoder
304
- * **owns** the shared-memory ring segment for that stream: it creates the
305
- * segment on the first decoded frame (when the output geometry is known),
306
- * writes every subsequent decoded frame into the ring via a `FrameRingWriter`,
307
- * and closes + unlinks the segment when the session is destroyed.
308
- *
309
- * What leaves the decoder is no longer the pixel `Buffer` — it is a tiny,
310
- * serialisable `FrameHandle` (`FrameRingWriter.writeFrame`'s return value).
311
- * Same-host consumers (motion, detection, the WebRTC encoder) open the same
312
- * segment with a `FrameRingReader` and read the pixels zero-copy.
313
- *
314
- * ## Lazy segment creation
315
- *
316
- * The segment cannot be sized until the first frame: `slotByteLength` is
317
- * `width × height × bytesPerPixel`, and the output dimensions are only known
318
- * once the scaler has produced its first `dstFrame`. So `writeFrame` is a
319
- * no-op-until-armed: the first call sizes + creates the segment, every later
320
- * call writes into it.
321
- *
322
- * ## Resolution-change decision
323
- *
324
- * A live camera stream can change resolution mid-stream (the decoder's scaler
325
- * is rebuilt on a config toggle, or the source renegotiates). The slot is
326
- * sized for the **first** frame's geometry. A later frame that no longer fits
327
- * the slot triggers a **segment re-create**: the old segment is closed +
328
- * unlinked and a fresh, larger segment is created under a new generation-tagged
329
- * name. This is simpler and leak-free versus over-allocating slots for a
330
- * worst-case 4K frame on every stream; resolution changes on a live camera are
331
- * rare, and a brief gap while consumers re-open the segment is acceptable
332
- * (latest-wins — a missed frame is correct behaviour).
333
- */
334
- /**
335
- * Per-ring shared-memory budget (MB) — the slot count is derived per-resolution
336
- * from this budget via {@link deriveSlotCount}, so a 360p stream gets many
337
- * slots and a 4K stream a few, both inside the same memory footprint.
338
- *
339
- * Read ONCE at module load from `CAMSTACK_SHM_RING_BUDGET_MB`; a non-finite or
340
- * non-positive value falls back to the 16 MB default.
341
- *
342
- * The default is deliberately small (16 MB) so many concurrent per-camera rings
343
- * fit inside a bounded `/dev/shm`. The decoder output is capped at 640px wide, so
344
- * a slot is ≤ ~920 KB and 16 MB still yields ~17–70 latest-wins slots. A ring
345
- * segment larger than the container's `/dev/shm` tmpfs backing (Docker default is
346
- * only 64 MB) faults an **uncatchable SIGBUS** on write past `st_size` — the old
347
- * 128 MB default overflowed a 64 MB `/dev/shm` and crashed the decoder on 8MP
348
- * streams. Pair this with a `--shm-size` that scales with concurrent-decode load.
349
- */
350
- var RING_BUDGET_MB = (() => {
351
- const raw = Number(process.env["CAMSTACK_SHM_RING_BUDGET_MB"]);
352
- return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 16;
353
- })();
354
- /** {@link RING_BUDGET_MB} in bytes — the budget passed to `deriveSlotCount`. */
355
- var RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
356
- /** A unique, stable shared-memory segment name for a decoder stream.
357
- *
358
- * macOS POSIX shm names are capped at ~31 characters (`PSHMNAMLEN`). A
359
- * `camstack.frames.<deviceId>.<streamId>` scheme overflows that for realistic
360
- * ids, so the sink uses a short, collision-resistant scheme instead:
361
- * `csf.<base36 hash>.<gen>`. The hash folds the device id, the session tag
362
- * and a per-process random salt; the generation suffix makes a re-created
363
- * segment (resolution change) a distinct name so a stale consumer mapping is
364
- * never silently reused.
365
- */
366
- function makeSegmentName(seed, generation) {
367
- let hash = 5381;
368
- for (let i = 0; i < seed.length; i += 1) hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
369
- return `csf.${(hash >>> 0).toString(36)}.${generation}`;
370
- }
371
- /**
372
- * The decoder-side owner of one stream's shared-memory frame ring.
373
- *
374
- * Not constructed until a session actually uses the shm sink; the segment
375
- * itself is created lazily on the first `writeFrame`.
376
- */
377
- var DecoderFrameRingSink = class {
378
- seed;
379
- logger;
380
- nodeId;
381
- segment = null;
382
- writer = null;
383
- segmentName = null;
384
- slotByteLength = 0;
385
- generation = 0;
386
- destroyed = false;
387
- /** Frames committed into the ring across this sink's lifetime (all generations). */
388
- framesWritten = 0;
389
- constructor(options) {
390
- const salt = Math.random().toString(36).slice(2, 8);
391
- this.seed = `${options.seed}.${salt}`;
392
- this.logger = options.logger;
393
- this.nodeId = options.nodeId;
394
- }
395
- /** Whether a segment has been created (i.e. at least one frame written). */
396
- get isArmed() {
397
- return this.writer !== null;
398
- }
399
- /** The current segment name, or `null` before the first frame. */
400
- get currentSegmentName() {
401
- return this.segmentName;
402
- }
403
- /**
404
- * Write one decoded frame into the ring and return its `FrameHandle`.
405
- *
406
- * On the first call (or after a geometry change that overflows the current
407
- * slot) the segment is created / re-created sized for this frame. Returns
408
- * `null` only when the sink has been destroyed.
409
- *
410
- * This is the copy-in convenience form (it copies `pixels` into the slot).
411
- * The decoder's hot path uses the zero-copy {@link beginFrame} /
412
- * {@link commitFrame} scatter-write pair instead — the scaler produces its
413
- * packed output directly into the slot, eliminating the write-side memcpy.
414
- */
415
- writeFrame(pixels, meta) {
416
- if (this.destroyed) return null;
417
- if (this.writer === null || computeSlotByteLength(meta.width, meta.height, meta.format) > this.slotByteLength) this.recreateSegment(computeSlotByteLength(meta.width, meta.height, meta.format));
418
- const writer = this.writer;
419
- if (writer === null) return null;
420
- const handle = writer.writeFrame(pixels, meta);
421
- this.framesWritten += 1;
422
- return handle;
423
- }
424
- /**
425
- * Reserve a ring slot for a frame of the given geometry — the **zero-copy**
426
- * scatter-write entry point (Phase 5 / D9 Task 7c).
427
- *
428
- * The segment is created / re-created here if this is the first frame or the
429
- * geometry overflows the current slot capacity, so the slot is correctly
430
- * sized before the caller fills it. The returned `buffer` is a writable view
431
- * **directly over the mapped segment** — the node-av scaler scatters its
432
- * packed output straight into it, with no intermediate copy. The caller MUST
433
- * call {@link commitFrame} with the returned `slot` once the slot is filled.
434
- *
435
- * Returns `null` when the sink is destroyed or the segment cannot be created.
436
- */
437
- beginFrame(width, height, format) {
438
- if (this.destroyed) return null;
439
- const requiredSlotBytes = computeSlotByteLength(width, height, format);
440
- if (this.writer === null || requiredSlotBytes > this.slotByteLength) this.recreateSegment(requiredSlotBytes);
441
- const writer = this.writer;
442
- if (writer === null) return null;
443
- const { slot, buffer } = writer.beginFrame();
444
- return {
445
- slot,
446
- buffer
447
- };
448
- }
449
- /**
450
- * Publish the frame whose slot was reserved by {@link beginFrame} and filled
451
- * in place by the caller. `slot` MUST be the value from the matching
452
- * `beginFrame`. Returns the published `FrameHandle`, or `null` if the sink
453
- * was destroyed (or the segment lost) between begin and commit.
454
- */
455
- commitFrame(slot, meta) {
456
- if (this.destroyed) return null;
457
- const writer = this.writer;
458
- if (writer === null) return null;
459
- const handle = writer.commitFrame(slot, meta);
460
- this.framesWritten += 1;
461
- return handle;
462
- }
463
- /**
464
- * Current shm ring usage — `null` until the first frame arms the segment.
465
- * Surfaced through `decoder.getShmStats` so a downstream consumer can
466
- * observe ring pressure (slot depth, byte budget, frames written).
467
- */
468
- getShmStats() {
469
- if (this.writer === null) return null;
470
- return {
471
- slotCount: this.writer.slotCount,
472
- slotByteLength: this.slotByteLength,
473
- segmentBytes: computeSegmentSize(this.writer.slotCount, this.slotByteLength),
474
- framesWritten: this.framesWritten
475
- };
476
- }
477
- /**
478
- * Abandon a slot reserved by {@link beginFrame} **without publishing it** —
479
- * the degenerate-path counterpart of {@link commitFrame}.
480
- *
481
- * A caller that reserved a slot but then could not produce valid pixels (no
482
- * decoded source planes, or the scaler threw) MUST call this instead of
483
- * `commitFrame`: it closes the open seqlock without advancing `writeIndex`,
484
- * so no reader ever sees the slot's uninitialised bytes as a real frame, and
485
- * no `FrameHandle` is handed downstream. `slot` MUST be the value from the
486
- * matching `beginFrame`. A no-op if the sink was destroyed (or the segment
487
- * lost) between begin and abort.
488
- */
489
- abortFrame(slot) {
490
- if (this.destroyed) return;
491
- const writer = this.writer;
492
- if (writer === null) return;
493
- writer.abortFrame(slot);
494
- }
495
- /** Close + unlink the segment. Idempotent. */
496
- destroy() {
497
- if (this.destroyed) return;
498
- this.destroyed = true;
499
- this.releaseSegment();
500
- }
501
- /**
502
- * Create a fresh segment sized for at least `slotByteLength` bytes per slot,
503
- * replacing any prior one. A re-create bumps the generation so the new
504
- * segment has a distinct name — a consumer holding the old mapping is never
505
- * silently handed a resized segment.
506
- */
507
- recreateSegment(slotByteLength) {
508
- this.releaseSegment();
509
- this.generation += 1;
510
- const name = makeSegmentName(this.seed, this.generation);
511
- const slotCount = deriveSlotCount(RING_BUDGET_BYTES, slotByteLength);
512
- if (slotCount === MIN_RING_SLOTS && MIN_RING_SLOTS * slotByteLength > RING_BUDGET_BYTES) this.logger.warn("decoder shm ring: budget too small for resolution — using MIN slots", { meta: {
513
- slotByteLength,
514
- budgetMb: RING_BUDGET_MB
515
- } });
516
- const totalBytes = computeSegmentSize(slotCount, slotByteLength);
517
- try {
518
- const segment = createSegment(name, totalBytes);
519
- this.segment = segment;
520
- this.segmentName = name;
521
- this.slotByteLength = slotByteLength;
522
- this.writer = new FrameRingWriter(segment.buffer, name, slotCount, slotByteLength, this.nodeId);
523
- this.logger.info("decoder shm ring: segment created", { meta: {
524
- segment: name,
525
- slotCount,
526
- slotByteLength,
527
- totalBytes,
528
- generation: this.generation
529
- } });
530
- } catch (err) {
531
- this.segment = null;
532
- this.writer = null;
533
- this.segmentName = null;
534
- this.slotByteLength = 0;
535
- this.logger.error("decoder shm ring: segment create failed", { meta: {
536
- segment: name,
537
- slotByteLength,
538
- error: err instanceof Error ? err.message : String(err)
539
- } });
540
- }
541
- }
542
- /** Unmap + unlink the current segment, if any. */
543
- releaseSegment() {
544
- const segment = this.segment;
545
- if (segment === null) return;
546
- this.segment = null;
547
- this.writer = null;
548
- const name = this.segmentName;
549
- this.segmentName = null;
550
- try {
551
- segment.close();
552
- segment.unlink();
553
- this.logger.info("decoder shm ring: segment released", { meta: { segment: name } });
554
- } catch (err) {
555
- this.logger.warn("decoder shm ring: segment release failed", { meta: {
556
- segment: name,
557
- error: err instanceof Error ? err.message : String(err)
558
- } });
559
- }
560
- }
561
- };
562
- //#endregion
563
300
  //#region src/decoder-ffmpeg/frame-stream-splitter.ts
564
301
  var FrameStreamSplitter = class {
565
302
  frameSize;
@@ -695,6 +432,14 @@ var FfmpegDecoderSession = class {
695
432
  process = null;
696
433
  spawnedAtMs = 0;
697
434
  /**
435
+ * Whether a spawn has ever been attempted. Drives the LAZY first spawn
436
+ * (first `pushPacket` / `openStream`) and preserves the recovery semantics
437
+ * afterwards: a push-mode child that later dies is NOT respawned by the
438
+ * packet feed (the consumer / frame-plane self-heal recreates the session),
439
+ * and `updateConfig` only restarts a session that has already spawned.
440
+ */
441
+ spawnAttempted = false;
442
+ /**
698
443
  * PULL-MODE input URL (P2b). `null` = push mode (packets via stdin — the
699
444
  * default). Set by {@link openStream}; every (re)spawn since reads this URL
700
445
  * directly instead of `pipe:0`. A session is push-mode or pull-mode for its
@@ -750,7 +495,6 @@ var FfmpegDecoderSession = class {
750
495
  this.channels = channelsForPixel(this.pixel);
751
496
  this.frameFormat = this.pixel === "gray" ? "gray" : "rgb";
752
497
  this.frameDropper = new FrameDropper(config.maxFps);
753
- this.spawnFfmpeg();
754
498
  }
755
499
  /** Exposed so the owning addon can surface ring stats via `getShmStats`. */
756
500
  get frameRingSinkOrNull() {
@@ -793,6 +537,7 @@ var FfmpegDecoderSession = class {
793
537
  }
794
538
  spawnFfmpeg() {
795
539
  if (this.destroyed) return;
540
+ this.spawnAttempted = true;
796
541
  this.clearPullRedial();
797
542
  this.killFfmpeg();
798
543
  this.abortOpenSlot();
@@ -823,12 +568,19 @@ var FfmpegDecoderSession = class {
823
568
  this.process = child;
824
569
  this.spawnedAtMs = Date.now();
825
570
  child.stdin?.on("error", () => {});
826
- child.stdout?.on("data", (chunk) => this.handleStdout(chunk));
827
- child.stderr?.on("data", (data) => this.handleStderr(data));
571
+ child.stdout?.on("data", (chunk) => {
572
+ if (child === this.process) this.handleStdout(chunk);
573
+ });
574
+ child.stderr?.on("data", (data) => {
575
+ if (child === this.process) this.handleStderr(data);
576
+ });
828
577
  child.on("error", (err) => {
578
+ if (child !== this.process) return;
829
579
  this.logger.error("ffmpeg decoder: process error", { meta: { error: err.message } });
830
580
  });
831
- child.on("exit", (code, signal) => this.handleExit(code, signal));
581
+ child.on("exit", (code, signal) => {
582
+ if (child === this.process) this.handleExit(code, signal);
583
+ });
832
584
  this.logger.info("ffmpeg decoder: spawned", { meta: {
833
585
  hwaccel: this.hwaccel,
834
586
  gpuScale: this.activeGpuScaleFilter ?? "cpu",
@@ -841,6 +593,9 @@ var FfmpegDecoderSession = class {
841
593
  const child = this.process;
842
594
  if (!child) return;
843
595
  this.process = null;
596
+ child.stdout?.removeAllListeners("data");
597
+ child.stderr?.removeAllListeners("data");
598
+ child.removeAllListeners("exit");
844
599
  try {
845
600
  child.stdin?.end();
846
601
  } catch {}
@@ -1024,6 +779,7 @@ var FfmpegDecoderSession = class {
1024
779
  pushPacket(packet) {
1025
780
  if (this.destroyed) return;
1026
781
  if (this.pullUrl !== null) return;
782
+ if (this.process === null && !this.spawnAttempted) this.spawnFfmpeg();
1027
783
  const stdin = this.process?.stdin;
1028
784
  if (!stdin) return;
1029
785
  this.inputPackets++;
@@ -1051,7 +807,7 @@ var FfmpegDecoderSession = class {
1051
807
  ...update
1052
808
  };
1053
809
  if (update.maxFps !== void 0) this.frameDropper.setMaxFps(update.maxFps);
1054
- if (needsRestart) this.spawnFfmpeg();
810
+ if (needsRestart && this.spawnAttempted) this.spawnFfmpeg();
1055
811
  }
1056
812
  async destroy() {
1057
813
  if (this.destroyed) return;
@@ -1136,6 +892,18 @@ function probeGpuScaleFilters(ffmpegPath, logger) {
1136
892
  //#endregion
1137
893
  //#region src/decoder-ffmpeg/addon/index.ts
1138
894
  var FRAME_BUFFER_CAPACITY = 32;
895
+ var DEFAULT_DECODER_FFMPEG_ADDON_CONFIG = {
896
+ ...DEFAULT_DECODER_HWACCEL_CONFIG,
897
+ backend: DEFAULT_DECODER_BACKEND
898
+ };
899
+ /** The two selectable decoder backends, in default-first order. */
900
+ var DECODER_BACKEND_OPTIONS = [{
901
+ value: "ffmpeg",
902
+ label: "ffmpeg (subprocess — crash-isolated, default)"
903
+ }, {
904
+ value: "nodeav",
905
+ label: "node-av (in-process FFmpeg bindings)"
906
+ }];
1139
907
  var DecoderFfmpegAddon = class extends BaseAddon {
1140
908
  sessions = /* @__PURE__ */ new Map();
1141
909
  frameBuffers = /* @__PURE__ */ new Map();
@@ -1166,10 +934,24 @@ var DecoderFfmpegAddon = class extends BaseAddon {
1166
934
  */
1167
935
  unsupportedGpuScaleFilters = /* @__PURE__ */ new Set();
1168
936
  constructor() {
1169
- super(DEFAULT_DECODER_HWACCEL_CONFIG);
937
+ super(DEFAULT_DECODER_FFMPEG_ADDON_CONFIG);
1170
938
  }
1171
939
  globalSettingsSchema() {
1172
940
  return this.schema({ sections: [{
941
+ id: "backend",
942
+ title: "Decoder backend",
943
+ tab: "decoder",
944
+ 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.",
945
+ fields: [this.field({
946
+ type: "select",
947
+ key: "backend",
948
+ label: "Backend",
949
+ options: [...DECODER_BACKEND_OPTIONS],
950
+ default: DEFAULT_DECODER_BACKEND,
951
+ immediate: true,
952
+ perNode: true
953
+ })]
954
+ }, {
1173
955
  id: "hwaccel",
1174
956
  title: "Hardware acceleration",
1175
957
  tab: "decoder",
@@ -1188,6 +970,7 @@ var DecoderFfmpegAddon = class extends BaseAddon {
1188
970
  description: "Auto-detected best decoder backend on this host. Click the refresh icon to re-run the probe.",
1189
971
  readonlyField: true,
1190
972
  default: "",
973
+ perNode: true,
1191
974
  actions: [{
1192
975
  action: "reprobe-hwaccel",
1193
976
  icon: "refresh-cw",
@@ -1197,7 +980,12 @@ var DecoderFfmpegAddon = class extends BaseAddon {
1197
980
  }] });
1198
981
  }
1199
982
  async onInitialize() {
1200
- this.ctx.logger.info("ffmpeg decoder addon initialized");
983
+ const backend = await resolveDecoderBackend(this.ctx.api, this.resolveLocalNodeId(), this.ctx.logger);
984
+ if (backend !== "ffmpeg") {
985
+ this.ctx.logger.info("ffmpeg decoder: this node selects a different decoder backend — standing down (no decoder provider registered)", { meta: { selectedBackend: backend } });
986
+ return [];
987
+ }
988
+ this.ctx.logger.info("ffmpeg decoder addon initialized", { meta: { selectedBackend: backend } });
1201
989
  this.frameReaders = new FrameRingReaderCache(this.ctx.logger);
1202
990
  this.ffmpegPath = await this.resolveFfmpegBinaryPath();
1203
991
  this.probedGpuScaleFilters = await probeGpuScaleFilters(this.ffmpegPath, this.ctx.logger);
@@ -1260,8 +1048,9 @@ var DecoderFfmpegAddon = class extends BaseAddon {
1260
1048
  if (pref === "none") return [];
1261
1049
  if (pref !== "auto") return [pref];
1262
1050
  try {
1263
- const res = await this.ctx.api.platformProbe.resolveHwAccel.query({ prefer: null });
1264
- const supported = await this.ctx.api.platformProbe.getHardwareDecodeAccels.query();
1051
+ const pin = nodePin(this.resolveLocalNodeId());
1052
+ const res = await this.ctx.api.platformProbe.resolveHwAccel.query({ prefer: null }, pin);
1053
+ const supported = await this.ctx.api.platformProbe.getHardwareDecodeAccels.query(void 0, pin);
1265
1054
  return rankDecodeHwAccels(res.preferred, supported.methods);
1266
1055
  } catch (err) {
1267
1056
  this.ctx.logger.warn("ffmpeg: decode-hwaccel probe failed — software decode", { meta: { error: err instanceof Error ? err.message : String(err) } });
@@ -1272,13 +1061,13 @@ var DecoderFfmpegAddon = class extends BaseAddon {
1272
1061
  const resolver = this.ctx.kernel.hwaccel;
1273
1062
  if (!resolver) {
1274
1063
  this.ctx.logger.warn("reprobeHwaccel: no kernel hwaccel resolver — returning none");
1275
- await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: "none" });
1064
+ await this.updateGlobalSettings({ probedBestHwaccel: "none" });
1276
1065
  return { backend: "none" };
1277
1066
  }
1278
1067
  try {
1279
1068
  const res = await resolver.resolve();
1280
1069
  const backend = res.preferred[0] ?? "none";
1281
- await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: backend });
1070
+ await this.updateGlobalSettings({ probedBestHwaccel: backend });
1282
1071
  this.ctx.logger.info("reprobeHwaccel: wrote probedBestHwaccel", { meta: {
1283
1072
  backend,
1284
1073
  rationale: res.rationale,
@@ -1287,7 +1076,7 @@ var DecoderFfmpegAddon = class extends BaseAddon {
1287
1076
  return { backend };
1288
1077
  } catch (err) {
1289
1078
  this.ctx.logger.warn("reprobeHwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
1290
- await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: "none" });
1079
+ await this.updateGlobalSettings({ probedBestHwaccel: "none" });
1291
1080
  return { backend: "none" };
1292
1081
  }
1293
1082
  }
@@ -1354,7 +1143,7 @@ var DecoderFfmpegAddon = class extends BaseAddon {
1354
1143
  };
1355
1144
  }
1356
1145
  wireCallbackSink(sessionId, session) {
1357
- const ringBuffer = new RingBuffer(FRAME_BUFFER_CAPACITY);
1146
+ const ringBuffer = new NotifyingRingBuffer(FRAME_BUFFER_CAPACITY);
1358
1147
  this.frameBuffers.set(sessionId, ringBuffer);
1359
1148
  return session.onFrame((frame) => {
1360
1149
  const { format } = frame;
@@ -1372,7 +1161,7 @@ var DecoderFfmpegAddon = class extends BaseAddon {
1372
1161
  });
1373
1162
  }
1374
1163
  wireShmSink(sessionId, session) {
1375
- const handleRing = new RingBuffer(FRAME_BUFFER_CAPACITY);
1164
+ const handleRing = new NotifyingRingBuffer(FRAME_BUFFER_CAPACITY);
1376
1165
  this.handleBuffers.set(sessionId, handleRing);
1377
1166
  return session.onFrameHandle((frame) => {
1378
1167
  handleRing.push(frame.handle);
@@ -1384,6 +1173,8 @@ var DecoderFfmpegAddon = class extends BaseAddon {
1384
1173
  if (!session) throw new Error(`decoder-ffmpeg: unknown sessionId ${sessionId}`);
1385
1174
  const unsub = this.unsubscribers.get(sessionId);
1386
1175
  if (unsub) unsub();
1176
+ this.frameBuffers.get(sessionId)?.cancel();
1177
+ this.handleBuffers.get(sessionId)?.cancel();
1387
1178
  await session.destroy();
1388
1179
  this.sessions.delete(sessionId);
1389
1180
  this.frameBuffers.delete(sessionId);
@@ -1421,12 +1212,14 @@ var DecoderFfmpegAddon = class extends BaseAddon {
1421
1212
  if (!this.sessions.has(input.sessionId)) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
1422
1213
  const ringBuffer = this.frameBuffers.get(input.sessionId);
1423
1214
  if (!ringBuffer) return [];
1215
+ if (ringBuffer.size === 0 && input.waitMs) await ringBuffer.waitForItem(input.waitMs);
1424
1216
  return ringBuffer.drain(input.maxCount);
1425
1217
  }
1426
1218
  async pullHandles(input) {
1427
1219
  if (!this.sessions.has(input.sessionId)) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
1428
1220
  const handleRing = this.handleBuffers.get(input.sessionId);
1429
1221
  if (!handleRing) return [];
1222
+ if (handleRing.size === 0 && input.waitMs) await handleRing.waitForItem(input.waitMs);
1430
1223
  return handleRing.drain(input.maxCount);
1431
1224
  }
1432
1225
  async updateConfig(input) {