@camstack/addon-pipeline 1.1.26 → 1.1.27

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.
@@ -17799,30 +17799,35 @@ var FfmpegDecoderSession = class {
17799
17799
  this.outputBuffer = Buffer.alloc(0);
17800
17800
  this.cachedWidth = 0;
17801
17801
  this.cachedHeight = 0;
17802
- const args = buildFfmpegArgs(this.config);
17803
- this.process = (0, node_child_process.spawn)("ffmpeg", args);
17804
- this.process.stdin?.on("error", () => {});
17805
- this.process.stdout?.on("data", (chunk) => {
17806
- this.handleOutputData(chunk);
17802
+ const child = (0, node_child_process.spawn)("ffmpeg", buildFfmpegArgs(this.config));
17803
+ this.process = child;
17804
+ child.stdin?.on("error", () => {});
17805
+ child.stdout?.on("data", (chunk) => {
17806
+ if (child === this.process) this.handleOutputData(chunk);
17807
17807
  });
17808
- this.process.stderr?.on("data", (data) => {
17808
+ child.stderr?.on("data", (data) => {
17809
+ if (child !== this.process) return;
17809
17810
  const line = data.toString().trim();
17810
17811
  if (line) this.logger.debug("ffmpeg stderr", { meta: { line } });
17811
17812
  });
17812
- this.process.on("error", (err) => {
17813
+ child.on("error", (err) => {
17814
+ if (child !== this.process) return;
17813
17815
  this.logger.error("FFmpeg decoder spawn error", { meta: { error: err.message } });
17814
17816
  });
17815
- this.process.on("close", (_code, _signal) => {
17816
- if (!this.destroyed) this.process = null;
17817
+ child.on("close", (_code, _signal) => {
17818
+ if (child === this.process && !this.destroyed) this.process = null;
17817
17819
  });
17818
17820
  }
17819
17821
  killFfmpeg() {
17820
- if (this.process) {
17821
- try {
17822
- this.process.kill("SIGKILL");
17823
- } catch {}
17824
- this.process = null;
17825
- }
17822
+ const child = this.process;
17823
+ if (!child) return;
17824
+ this.process = null;
17825
+ child.stdout?.removeAllListeners("data");
17826
+ child.stderr?.removeAllListeners("data");
17827
+ child.removeAllListeners("close");
17828
+ try {
17829
+ child.kill("SIGKILL");
17830
+ } catch {}
17826
17831
  }
17827
17832
  handleOutputData(chunk) {
17828
17833
  this.outputBuffer = Buffer.concat([this.outputBuffer, chunk]);
@@ -17795,30 +17795,35 @@ var FfmpegDecoderSession = class {
17795
17795
  this.outputBuffer = Buffer.alloc(0);
17796
17796
  this.cachedWidth = 0;
17797
17797
  this.cachedHeight = 0;
17798
- const args = buildFfmpegArgs(this.config);
17799
- this.process = spawn("ffmpeg", args);
17800
- this.process.stdin?.on("error", () => {});
17801
- this.process.stdout?.on("data", (chunk) => {
17802
- this.handleOutputData(chunk);
17798
+ const child = spawn("ffmpeg", buildFfmpegArgs(this.config));
17799
+ this.process = child;
17800
+ child.stdin?.on("error", () => {});
17801
+ child.stdout?.on("data", (chunk) => {
17802
+ if (child === this.process) this.handleOutputData(chunk);
17803
17803
  });
17804
- this.process.stderr?.on("data", (data) => {
17804
+ child.stderr?.on("data", (data) => {
17805
+ if (child !== this.process) return;
17805
17806
  const line = data.toString().trim();
17806
17807
  if (line) this.logger.debug("ffmpeg stderr", { meta: { line } });
17807
17808
  });
17808
- this.process.on("error", (err) => {
17809
+ child.on("error", (err) => {
17810
+ if (child !== this.process) return;
17809
17811
  this.logger.error("FFmpeg decoder spawn error", { meta: { error: err.message } });
17810
17812
  });
17811
- this.process.on("close", (_code, _signal) => {
17812
- if (!this.destroyed) this.process = null;
17813
+ child.on("close", (_code, _signal) => {
17814
+ if (child === this.process && !this.destroyed) this.process = null;
17813
17815
  });
17814
17816
  }
17815
17817
  killFfmpeg() {
17816
- if (this.process) {
17817
- try {
17818
- this.process.kill("SIGKILL");
17819
- } catch {}
17820
- this.process = null;
17821
- }
17818
+ const child = this.process;
17819
+ if (!child) return;
17820
+ this.process = null;
17821
+ child.stdout?.removeAllListeners("data");
17822
+ child.stderr?.removeAllListeners("data");
17823
+ child.removeAllListeners("close");
17824
+ try {
17825
+ child.kill("SIGKILL");
17826
+ } catch {}
17822
17827
  }
17823
17828
  handleOutputData(chunk) {
17824
17829
  this.outputBuffer = Buffer.concat([this.outputBuffer, chunk]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.1.26",
3
+ "version": "1.1.27",
4
4
  "description": "CamStack Pipeline bundle — runner, detection, motion, decoders, audio + stream broker. Multi-entry npm package shipping 7 addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",
@@ -36,6 +36,10 @@
36
36
  "import": "./dist/decoder-ffmpeg/index.mjs",
37
37
  "require": "./dist/decoder-ffmpeg/index.js"
38
38
  },
39
+ "./decoder-nodeav": {
40
+ "import": "./dist/decoder-nodeav/index.mjs",
41
+ "require": "./dist/decoder-nodeav/index.js"
42
+ },
39
43
  "./audio-codec-ffmpeg": {
40
44
  "import": "./dist/audio-codec-ffmpeg/index.mjs",
41
45
  "require": "./dist/audio-codec-ffmpeg/index.js"
@@ -149,7 +153,7 @@
149
153
  "category": "pipeline",
150
154
  "name": "Decoder (ffmpeg)",
151
155
  "version": "0.1.0",
152
- "description": "Out-of-process video decoder spawning an ffmpeg subprocess — VA-API safe on Intel (a decode crash is isolated to the child, never taking down the runner). The sole decoder; ffmpeg is provisioned per node via ctx.deps.ensureFfmpeg().",
156
+ "description": "Out-of-process video decoder spawning an ffmpeg subprocess — a decode crash is isolated to the child, never taking down the runner. The DEFAULT decoder backend: it registers the decoder cap only when the node's `decoder` settings section selects `ffmpeg` (the default). ffmpeg is provisioned per node via ctx.deps.ensureFfmpeg().",
153
157
  "entry": "./dist/decoder-ffmpeg/index.js",
154
158
  "execution": {
155
159
  "placement": "any-node",
@@ -157,7 +161,30 @@
157
161
  },
158
162
  "capabilities": [
159
163
  {
160
- "name": "decoder"
164
+ "name": "decoder",
165
+ "optional": true
166
+ }
167
+ ],
168
+ "passive": true,
169
+ "protected": true,
170
+ "icon": "assets/icon.svg",
171
+ "color": "#0ea5e9"
172
+ },
173
+ {
174
+ "id": "decoder-nodeav",
175
+ "category": "pipeline",
176
+ "name": "Decoder (node-av)",
177
+ "version": "0.1.0",
178
+ "description": "In-process video decoder using FFmpeg native bindings (node-av) — no subprocess lifecycle (nothing to spawn, supervise, or leak). Registers the decoder cap only when the node's `decoder` settings section selects `nodeav`; decoder-ffmpeg is the default backend.",
179
+ "entry": "./dist/decoder-nodeav/index.js",
180
+ "execution": {
181
+ "placement": "any-node",
182
+ "heapProfile": "heavy"
183
+ },
184
+ "capabilities": [
185
+ {
186
+ "name": "decoder",
187
+ "optional": true
161
188
  }
162
189
  ],
163
190
  "passive": true,
@@ -299,6 +326,7 @@
299
326
  "@camstack/system": "*",
300
327
  "lucide-react": "^0.511.0",
301
328
  "mp4box": "0.5.4",
329
+ "node-av": "^6.0.0",
302
330
  "sharp": "^0.35.2",
303
331
  "zod": "^4.3.6"
304
332
  },
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """Async inference pool — request_id multiplexed, per-runtime concurrency.
3
3
 
4
- Architecture mirrors Scrypted's ML plugins (coreml / openvino / onnx):
4
+ Per-runtime ML backends (coreml / openvino / onnx):
5
5
  - asyncio main loop reads requests from stdin; inference is dispatched
6
6
  onto a runtime-specific executor so the reader never blocks.
7
7
  - Each request carries a 32-bit id; responses are tagged with the same
@@ -76,7 +76,7 @@ MSG_INFER_RAW = 0x02
76
76
  # Each item is dispatched concurrently via asyncio.gather so the
77
77
  # predict pool's existing parallelism applies; the saving over N
78
78
  # separate calls is one IPC round-trip per item collapsed to one,
79
- # matching Scrypted's batch=4 semantics for fair benchmarking.
79
+ # with batch=4 semantics for fair benchmarking.
80
80
  MSG_INFER_BATCH = 0x03
81
81
  MSG_CACHE_FRAME = 0x04
82
82
  MSG_INFER_CACHED = 0x05
@@ -172,22 +172,21 @@ _ov_optimal_reqs: int = 0
172
172
  # query the device's optimal request count). ≈ measured optimal on Intel
173
173
  # CPU/iGPU/NPU (4-5). Threads idle-block on infer, so over-provisioning is cheap.
174
174
  OV_DEFAULT_CONCURRENCY: int = 4
175
- # OpenVINO device-mode decision EXACT port of Scrypted's OpenVINO plugin
176
- # device switch (scrypted-ov-init.py:109-194). Scrypted's rationale, kept
177
- # verbatim: "AUTO mode can cause conflicts or hide errors with NPU and GPU
178
- # so try to be explicit and fall back accordingly." The intel-iGPU-only case
179
- # (the N100) compiles explicit "GPU" the exact host where the AUTO plugin's
180
- # mid-flight CPU-helper -> GPU handover invalidated in-flight InferRequest
181
- # output ports ("Cannot find tensor for port opset1::Result score_8").
175
+ # OpenVINO device-mode decision. AUTO mode can hide errors / cause conflicts
176
+ # with NPU and GPU, so we resolve an explicit device and fall back accordingly.
177
+ # The intel-iGPU-only case (the N100) compiles explicit "GPU" the exact host
178
+ # where the AUTO plugin's mid-flight CPU-helper -> GPU handover invalidated
179
+ # in-flight InferRequest output ports ("Cannot find tensor for port
180
+ # opset1::Result score_8").
182
181
 
183
182
 
184
183
  def _resolve_ov_mode(available: list[str], full_names: dict[str, str]) -> str:
185
- """Scrypted's exact mode string for an AUTO/default device request.
184
+ """Resolve the device-mode string for an AUTO/default device request.
186
185
 
187
186
  `available` is `core.available_devices`; `full_names` maps each device to
188
187
  its FULL_DEVICE_NAME property (entries whose property query failed are
189
- absent — mirrors Scrypted's per-device `except: pass`, so such a device
190
- is not classified). The branch matrix, byte-faithful to Scrypted:
188
+ absent — a device whose FULL_DEVICE_NAME probe failed is not classified).
189
+ The branch matrix:
191
190
 
192
191
  npu & gpu -> "AUTO:NPU,GPU,CPU"
193
192
  npu & !gpu -> "AUTO:NPU,CPU"
@@ -217,8 +216,8 @@ def _resolve_ov_mode(available: list[str], full_names: dict[str, str]) -> str:
217
216
  mode = "AUTO:NPU,CPU"
218
217
  elif len(dgpus):
219
218
  mode = f"AUTO:{','.join(dgpus)},CPU"
220
- # forcing GPU can cause crashes on older GPU. (Scrypted's comment — the
221
- # GPU-mode compile failure falls back to AUTO in the compile loop below.)
219
+ # forcing GPU can crash on older GPUs — the GPU-mode compile failure falls
220
+ # back to AUTO in the compile loop below.
222
221
  elif gpu:
223
222
  mode = "GPU"
224
223
  return mode
@@ -239,8 +238,7 @@ def _resolve_onnx_providers(
239
238
  ) -> list:
240
239
  """Ordered ONNX Runtime providers — hardware first, CPU always last.
241
240
 
242
- Mirrors Scrypted's ONNX plugin ordering (scrypted-onnx-init.py:95-109)
243
- for the AUTO/default case:
241
+ Ordering for the AUTO/default case:
244
242
 
245
243
  darwin -> CoreMLExecutionProvider
246
244
  linux/win on x86_64/AMD64 -> ("CUDAExecutionProvider", {"device_id": N})
@@ -253,8 +251,8 @@ def _resolve_onnx_providers(
253
251
  (ort.get_available_providers()) are dropped — camstack ships the
254
252
  plain `onnxruntime` wheel on most nodes, and requesting an EP the
255
253
  build lacks raises at session creation instead of falling back.
256
- - platform match uses startswith so "darwin" never matches "win"
257
- (Scrypted's `"win" in sys.platform` would).
254
+ - platform match uses startswith so a substring test on "win" never
255
+ wrongly matches "darwin".
258
256
  """
259
257
  dev = (device or "").strip().lower()
260
258
  ordered: list = []
@@ -265,7 +263,7 @@ def _resolve_onnx_providers(
265
263
  elif dev == "cpu":
266
264
  pass # explicit CPU pin — no hardware EPs
267
265
  else:
268
- # AUTO/default — Scrypted's platform-driven ordering.
266
+ # AUTO/default — platform-driven ordering.
269
267
  if plat == "darwin":
270
268
  ordered.append(ONNX_COREML_EP)
271
269
  if plat.startswith(("linux", "win")) and machine in ("x86_64", "AMD64"):
@@ -281,8 +279,8 @@ def _resolve_onnx_providers(
281
279
  # ---------------------------------------------------------------------------
282
280
  # CoreML persistent compiled-model cache
283
281
  # ---------------------------------------------------------------------------
284
- # macOS bug (documented by Scrypted, coreml-init comment): the OS-level
285
- # compiled-model cache is NOT reused across process restarts — every
282
+ # Known macOS behavior: the OS-level compiled-model cache is NOT reused across
283
+ # process restarts — every
286
284
  # `ct.models.MLModel(.mlpackage)` load RECOMPILES the model, and the stale OS
287
285
  # cache is only cleared on reboot. camstack loads the .mlpackage on every pool
288
286
  # spawn, so each spawn paid the full recompile. Fix: after the first compile,
@@ -472,9 +470,9 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
472
470
  "ane": ct.ComputeUnit.CPU_AND_NE,
473
471
  "all": ct.ComputeUnit.ALL,
474
472
  }
475
- # NOTE (Scrypted caveat, coreml-init comment): a macOS bug can cause
476
- # the .mlpackage to be RECOMPILED on every load with the compiled
477
- # cache not reused until reboot. camstack loads the .mlpackage on
473
+ # NOTE: a macOS behavior can cause the .mlpackage to be RECOMPILED on
474
+ # every load with the compiled cache not reused until reboot. camstack
475
+ # loads the .mlpackage on
478
476
  # every pool spawn, so it was exposed to the same trap.
479
477
  # _acquire_coreml_model routes through a persistent `.coreml-cache`
480
478
  # of the compiled `.mlmodelc` (CompiledMLModel on hit, plain MLModel
@@ -526,11 +524,10 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
526
524
  elif _runtime == "openvino":
527
525
  core = _runtime_lib
528
526
  ov_device = str(config.get("device", "AUTO")).upper()
529
- # Scrypted's exact device switch (see _resolve_ov_mode above): an
530
- # AUTO/default request classifies the enumerated devices by their
531
- # FULL_DEVICE_NAME and builds the same mode string Scrypted's
532
- # OpenVINO plugin does. An operator-pinned device (config "device" =
533
- # gpu/cpu/npu) is honored unchanged — compiled directly.
527
+ # Device switch (see _resolve_ov_mode above): an AUTO/default request
528
+ # classifies the enumerated devices by their FULL_DEVICE_NAME and
529
+ # builds an explicit mode string. An operator-pinned device (config
530
+ # "device" = gpu/cpu/npu) is honored unchanged compiled directly.
534
531
  if ov_device in ("AUTO", "DEFAULT", ""):
535
532
  available = list(core.available_devices)
536
533
  full_names: dict[str, str] = {}
@@ -565,11 +562,10 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
565
562
  ov_config["CACHE_DIR"] = _cache_dir
566
563
  except OSError:
567
564
  pass
568
- # Compile the resolved mode. Failure fallback is Scrypted's, exactly:
569
- # any mode containing "GPU" reverts to plain "AUTO" and recompiles
570
- # once; if THAT also fails (or the mode had no GPU), re-raise —
571
- # camstack has no reset-and-restart like Scrypted, so the existing
572
- # load/replace error handler reports the failure.
565
+ # Compile the resolved mode. Failure fallback: any mode containing
566
+ # "GPU" reverts to plain "AUTO" and recompiles once; if THAT also
567
+ # fails (or the mode had no GPU), re-raise — the existing load/replace
568
+ # error handler reports the failure.
573
569
  candidates = [ov_device]
574
570
  if "GPU" in ov_device:
575
571
  candidates.append("AUTO")
@@ -591,7 +587,7 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
591
587
  raise last_exc
592
588
  raise RuntimeError("OpenVINO: no device available to compile the model")
593
589
  # Name the resolved mode + what OpenVINO actually placed the model on
594
- # (mirrors Scrypted's EXECUTION_DEVICES print) so a silent CPU
590
+ # (the EXECUTION_DEVICES property) so a silent CPU
595
591
  # placement is visible in the pool stderr.
596
592
  try:
597
593
  _exec_devices = compiled.get_property("EXECUTION_DEVICES")
@@ -646,7 +642,7 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
646
642
  elif _runtime == "onnxruntime":
647
643
  ort = _runtime_lib
648
644
  ort_device = str(config.get("device", "") or "")
649
- # Scrypted-style ordered providers — hardware EP first, CPU LAST as
645
+ # Ordered providers — hardware EP first, CPU LAST as
650
646
  # fallback (see _resolve_onnx_providers): a "cuda"/"coreml" pin puts
651
647
  # that EP first, a "cpu" pin compiles CPU only, and the AUTO/default
652
648
  # case derives the hardware EP from the platform.
@@ -658,8 +654,8 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
658
654
  ort_device, sys.platform, platform.machine(), available_eps,
659
655
  )
660
656
  session = ort.InferenceSession(path, providers=providers)
661
- # Report the EP that ACTUALLY initialized Scrypted strips CPU and
662
- # logs the remainder — so a silent CPU fallback is visible.
657
+ # Report the EP that ACTUALLY initialized (CPU stripped from the log)
658
+ # so a silent CPU fallback is visible.
663
659
  _active_eps = [p for p in session.get_providers() if p != ONNX_CPU_EP]
664
660
  _requested = [p[0] if isinstance(p, tuple) else p for p in providers]
665
661
  sys.stderr.write(
@@ -1,10 +1,9 @@
1
1
  """Unit tests for the runtime device/provider selection in inference_pool.
2
2
 
3
3
  Covers:
4
- - _resolve_ov_mode — Scrypted's exact OpenVINO device-mode branch matrix
5
- (scrypted-ov-init.py:109-194).
6
- - _resolve_onnx_providers — Scrypted's ONNX execution-provider ordering
7
- (scrypted-onnx-init.py:95-109) with camstack's pin + availability filter.
4
+ - _resolve_ov_mode — the OpenVINO device-mode branch matrix.
5
+ - _resolve_onnx_providers — the ONNX execution-provider ordering
6
+ with camstack's pin + availability filter.
8
7
 
9
8
  Pure-python: stubs numpy / PIL / postprocessors in sys.modules so the module
10
9
  imports without the ML runtime installed (inference_pool uses
@@ -47,10 +46,10 @@ from inference_pool import (
47
46
 
48
47
 
49
48
  class ResolveOvModeTest(unittest.TestCase):
50
- """Branch matrix of Scrypted's OpenVINO device switch."""
49
+ """Branch matrix of the OpenVINO device switch."""
51
50
 
52
51
  def test_n100_intel_igpu_and_cpu_is_explicit_gpu(self) -> None:
53
- # Intel iGPU + CPU, no NPU, no dGPU — Scrypted compiles explicit GPU.
52
+ # Intel iGPU + CPU, no NPU, no dGPU — compiles explicit GPU.
54
53
  self.assertEqual(
55
54
  _resolve_ov_mode(
56
55
  ["CPU", "GPU"],
@@ -127,7 +126,7 @@ class ResolveOvModeTest(unittest.TestCase):
127
126
  )
128
127
 
129
128
  def test_npu_wins_over_nvidia_dgpu(self) -> None:
130
- # Scrypted checks npu FIRST — dGPUs only matter when no NPU exists.
129
+ # npu is checked FIRST — dGPUs only matter when no NPU exists.
131
130
  self.assertEqual(
132
131
  _resolve_ov_mode(
133
132
  ["CPU", "GPU.0", "NPU"],
@@ -150,7 +149,7 @@ class ResolveOvModeTest(unittest.TestCase):
150
149
  self.assertEqual(_resolve_ov_mode([], {}), "AUTO")
151
150
 
152
151
  def test_device_without_full_name_is_not_classified(self) -> None:
153
- # Mirrors Scrypted's per-device `except: pass` — a device whose
152
+ # A device whose
154
153
  # FULL_DEVICE_NAME query failed is skipped entirely.
155
154
  self.assertEqual(
156
155
  _resolve_ov_mode(["CPU", "GPU"], {"CPU": "Intel(R) N100"}),
@@ -159,7 +158,7 @@ class ResolveOvModeTest(unittest.TestCase):
159
158
 
160
159
 
161
160
  class ResolveOnnxProvidersTest(unittest.TestCase):
162
- """Scrypted's ONNX EP ordering + camstack's pin/availability filter."""
161
+ """ONNX EP ordering + camstack's pin/availability filter."""
163
162
 
164
163
  def test_darwin_auto_prefers_coreml_cpu_last(self) -> None:
165
164
  self.assertEqual(
@@ -203,7 +202,7 @@ class ResolveOnnxProvidersTest(unittest.TestCase):
203
202
 
204
203
  def test_darwin_never_matches_win_substring(self) -> None:
205
204
  # "win" in "darwin" is True — the helper must NOT add CUDA on an
206
- # Intel Mac (Scrypted's `"win" in sys.platform` latent quirk).
205
+ # Intel Mac (a substring "win" in platform check would misfire).
207
206
  self.assertEqual(
208
207
  _resolve_onnx_providers(
209
208
  "", "darwin", "x86_64",
@@ -54,7 +54,7 @@ def main() -> None:
54
54
  # YAMNet is a tiny model — ONNX Runtime otherwise defaults intra_op threads to the CPU core
55
55
  # count, so every short inference spins up N threads whose coordination overhead dwarfs the
56
56
  # actual compute (observed: ~1000% CPU across a handful of always-on audio streams). Cap to a
57
- # single op thread (Frigate runs it the same way); overridable via --threads for large hosts.
57
+ # single op thread; overridable via --threads for large hosts.
58
58
  ap.add_argument("--threads", type=int, default=1)
59
59
  args = ap.parse_args()
60
60