@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
@@ -4,8 +4,8 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>CamStack Embed</title>
7
- <script type="module" crossorigin src="./assets/index-C-pL8ETk.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-DrJ0ee3f.css">
7
+ <script type="module" crossorigin src="./assets/index-CeZV1B-2.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-C5UpuPr8.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
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.28",
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
@@ -10,9 +10,10 @@ Architecture mirrors Scrypted's ML plugins (coreml / openvino / onnx):
10
10
  CoreML → ThreadPoolExecutor(1) — ANE is single-context; one
11
11
  Python thread is enough, and avoids GIL thrashing.
12
12
  OpenVINO → ThreadPoolExecutor(OPTIMAL_NUMBER_OF_INFER_REQUESTS),
13
- each thread driving its own InferRequest, with the model
14
- compiled under the THROUGHPUT hint so the streams run
15
- concurrently (a single shared request serialises cameras).
13
+ each worker checking out an InferRequest from a fixed
14
+ per-model pool (created once, post-compile), with the model
15
+ compiled on ONE explicit device under the LATENCY hint (no
16
+ AUTO — its CPU->GPU handover invalidated in-flight requests).
16
17
  ONNX → ThreadPoolExecutor(N) — N independent InferenceSessions
17
18
  where N = concurrency setting; each session pinned to
18
19
  its own worker thread.
@@ -48,7 +49,6 @@ import platform
48
49
  import shutil
49
50
  import struct
50
51
  import sys
51
- import threading
52
52
  import time
53
53
  from collections import deque
54
54
  from dataclasses import dataclass, field
@@ -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
@@ -166,62 +166,47 @@ class ModelSlot:
166
166
  _runtime: str = ""
167
167
  _runtime_lib: Any = None
168
168
  # Max OPTIMAL_NUMBER_OF_INFER_REQUESTS across loaded OpenVINO models — used
169
- # to size the predict pool so the THROUGHPUT streams are actually fed.
169
+ # to size the predict pool so the per-model InferRequest pool is actually fed.
170
170
  _ov_optimal_reqs: int = 0
171
171
  # Default OpenVINO predict-pool size when models load lazily (so we can't yet
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").
182
-
183
-
184
- 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.
186
-
187
- `available` is `core.available_devices`; `full_names` maps each device to
188
- 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:
191
-
192
- npu & gpu -> "AUTO:NPU,GPU,CPU"
193
- npu & !gpu -> "AUTO:NPU,CPU"
194
- NVIDIA dGPU(s) -> "AUTO:<dgpu[,dgpu...]>,CPU"
195
- gpu (intel only) -> "GPU" (explicit — the N100 case)
196
- else -> "AUTO"
175
+ # OpenVINO device decision. AUTO is NEVER used: the AUTO plugin runs inference
176
+ # on a CPU "helper" while the GPU JIT-compiles, then hands the model over to the
177
+ # GPU mid-flight. InferRequests created during that window bind to the
178
+ # CPU-helper's Result ports; after handover the served model's ports diverge and
179
+ # req.infer() raises "Cannot find tensor for port opset1::Result score_8" (proven
180
+ # live on the N100 iGPU). Compiling ONE explicit device removes the handover
181
+ # entirely. We resolve an ordered candidate list (NPU > GPU > CPU) and compile the
182
+ # first that succeeds, with CPU as the guaranteed floor.
183
+
184
+
185
+ def _resolve_ov_devices(available: list[str], full_names: dict[str, str]) -> list[str]:
186
+ """Ordered EXPLICIT device candidates for compile-time failover (NPU>GPU>CPU).
187
+
188
+ Never returns an AUTO/AUTO:.. mode (see the module comment above). `available`
189
+ is `core.available_devices`; `full_names` maps each device to its
190
+ FULL_DEVICE_NAME (missing entries = the probe failed, left unclassified). A
191
+ GPU is only accepted when its FULL_DEVICE_NAME probed successfully (this is
192
+ also how an Intel iGPU vs an NVIDIA dGPU is told apart downstream). Ordering:
193
+ NPU(s) -> GPU(s) -> CPU (always last). The compile loop tries each in order
194
+ and stops at the first that compiles; CPU is the guaranteed fallback.
197
195
  """
198
- npu = False
199
- gpu = False
200
- dgpus: list[str] = []
196
+ npus: list[str] = []
197
+ gpus: list[str] = []
201
198
  for device in available:
202
199
  full_device_name = full_names.get(device)
203
- if full_device_name is None:
204
- continue
205
- if "NVIDIA" in full_device_name and "dGPU" in full_device_name:
206
- dgpus.append(device)
207
200
  if "NPU" in device:
208
- npu = True
209
- if "GPU" in device:
210
- gpu = True
211
-
212
- mode = "AUTO"
213
- if npu:
214
- if gpu:
215
- mode = "AUTO:NPU,GPU,CPU"
216
- else:
217
- mode = "AUTO:NPU,CPU"
218
- elif len(dgpus):
219
- 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.)
222
- elif gpu:
223
- mode = "GPU"
224
- return mode
201
+ npus.append(device)
202
+ elif "GPU" in device and full_device_name is not None:
203
+ gpus.append(device)
204
+ ordered: list[str] = [*npus, *gpus]
205
+ if "CPU" in available and "CPU" not in ordered:
206
+ ordered.append("CPU")
207
+ if not ordered:
208
+ ordered = ["CPU"]
209
+ return ordered
225
210
 
226
211
 
227
212
  # ONNX Runtime execution-provider names used by _resolve_onnx_providers.
@@ -239,8 +224,7 @@ def _resolve_onnx_providers(
239
224
  ) -> list:
240
225
  """Ordered ONNX Runtime providers — hardware first, CPU always last.
241
226
 
242
- Mirrors Scrypted's ONNX plugin ordering (scrypted-onnx-init.py:95-109)
243
- for the AUTO/default case:
227
+ Ordering for the AUTO/default case:
244
228
 
245
229
  darwin -> CoreMLExecutionProvider
246
230
  linux/win on x86_64/AMD64 -> ("CUDAExecutionProvider", {"device_id": N})
@@ -253,8 +237,8 @@ def _resolve_onnx_providers(
253
237
  (ort.get_available_providers()) are dropped — camstack ships the
254
238
  plain `onnxruntime` wheel on most nodes, and requesting an EP the
255
239
  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).
240
+ - platform match uses startswith so a substring test on "win" never
241
+ wrongly matches "darwin".
258
242
  """
259
243
  dev = (device or "").strip().lower()
260
244
  ordered: list = []
@@ -265,7 +249,7 @@ def _resolve_onnx_providers(
265
249
  elif dev == "cpu":
266
250
  pass # explicit CPU pin — no hardware EPs
267
251
  else:
268
- # AUTO/default — Scrypted's platform-driven ordering.
252
+ # AUTO/default — platform-driven ordering.
269
253
  if plat == "darwin":
270
254
  ordered.append(ONNX_COREML_EP)
271
255
  if plat.startswith(("linux", "win")) and machine in ("x86_64", "AMD64"):
@@ -281,8 +265,8 @@ def _resolve_onnx_providers(
281
265
  # ---------------------------------------------------------------------------
282
266
  # CoreML persistent compiled-model cache
283
267
  # ---------------------------------------------------------------------------
284
- # macOS bug (documented by Scrypted, coreml-init comment): the OS-level
285
- # compiled-model cache is NOT reused across process restarts — every
268
+ # Known macOS behavior: the OS-level compiled-model cache is NOT reused across
269
+ # process restarts — every
286
270
  # `ct.models.MLModel(.mlpackage)` load RECOMPILES the model, and the stale OS
287
271
  # cache is only cleared on reboot. camstack loads the .mlpackage on every pool
288
272
  # spawn, so each spawn paid the full recompile. Fix: after the first compile,
@@ -472,9 +456,9 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
472
456
  "ane": ct.ComputeUnit.CPU_AND_NE,
473
457
  "all": ct.ComputeUnit.ALL,
474
458
  }
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
459
+ # NOTE: a macOS behavior can cause the .mlpackage to be RECOMPILED on
460
+ # every load with the compiled cache not reused until reboot. camstack
461
+ # loads the .mlpackage on
478
462
  # every pool spawn, so it was exposed to the same trap.
479
463
  # _acquire_coreml_model routes through a persistent `.coreml-cache`
480
464
  # of the compiled `.mlmodelc` (CompiledMLModel on hit, plain MLModel
@@ -525,13 +509,13 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
525
509
 
526
510
  elif _runtime == "openvino":
527
511
  core = _runtime_lib
528
- 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.
534
- if ov_device in ("AUTO", "DEFAULT", ""):
512
+ ov_device_req = str(config.get("device", "AUTO")).upper()
513
+ # Device switch (see _resolve_ov_devices above): an AUTO/default request
514
+ # classifies the enumerated devices by FULL_DEVICE_NAME and yields an
515
+ # ORDERED EXPLICIT candidate list (NPU>GPU>CPU) for compile-time
516
+ # failover. An operator-pinned device (config "device" = gpu/cpu/npu) is
517
+ # honored unchanged — compiled directly, no failover.
518
+ if ov_device_req in ("AUTO", "DEFAULT", ""):
535
519
  available = list(core.available_devices)
536
520
  full_names: dict[str, str] = {}
537
521
  for dev in available:
@@ -539,41 +523,38 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
539
523
  full_names[dev] = str(core.get_property(dev, "FULL_DEVICE_NAME"))
540
524
  except Exception:
541
525
  pass
542
- ov_device = _resolve_ov_mode(available, full_names)
526
+ candidates = _resolve_ov_devices(available, full_names)
543
527
  sys.stderr.write(
544
- f"OpenVINO: AUTO resolved to mode {ov_device} "
528
+ f"OpenVINO: explicit device candidates {candidates} "
545
529
  f"(available: {available}, names: {full_names})\n"
546
530
  )
547
531
  sys.stderr.flush()
548
532
  else:
549
- sys.stderr.write(f"OpenVINO: operator-pinned device {ov_device}\n")
533
+ candidates = [ov_device_req] # operator pin — compiled directly
534
+ sys.stderr.write(f"OpenVINO: operator-pinned device {ov_device_req}\n")
550
535
  sys.stderr.flush()
551
- # THROUGHPUT hint lets OpenVINO spin up multiple internal execution
552
- # streams. Combined with one InferRequest per predict-pool thread
553
- # (below), concurrent frames from N cameras run in parallel the
554
- # old `compiled(inp)` path drove a single shared default request, so
555
- # every camera serialised through one stream regardless of how many
556
- # predict workers existed. Measured ~1.5–2.4x throughput on CPU/GPU/NPU.
557
- ov_config = {"PERFORMANCE_HINT": "THROUGHPUT"}
536
+ # LATENCY (not THROUGHPUT): one deterministic execution stream instead of
537
+ # N. THROUGHPUT spun up multiple internal streams whose lazy request
538
+ # creation collided with the AUTO handover AND multiplied memory (a factor
539
+ # in the N100 OOM-freeze). LATENCY keeps a single stream; camera-level
540
+ # concurrency comes from the fixed InferRequest pool built below.
541
+ ov_config = {"PERFORMANCE_HINT": "LATENCY"}
558
542
  # Persist compiled GPU/NPU kernels so the multi-second JIT compile is paid
559
543
  # once (first boot) instead of on every pool spawn. The cold compile storm
560
- # (~25s for the full model set on an N100 iGPU) is the window where the AUTO
561
- # CPU->GPU handover races (see the index-fetch note below) and frames pile up.
544
+ # (~25s for the full model set on an N100 iGPU) is where frames pile up;
545
+ # caching the kernels shrinks that window on subsequent boots.
562
546
  try:
563
547
  _cache_dir = os.path.join(os.path.dirname(os.path.abspath(path)), ".ov-cache")
564
548
  os.makedirs(_cache_dir, exist_ok=True)
565
549
  ov_config["CACHE_DIR"] = _cache_dir
566
550
  except OSError:
567
551
  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.
573
- candidates = [ov_device]
574
- if "GPU" in ov_device:
575
- candidates.append("AUTO")
552
+ # Compile-time failover over the explicit candidate list (NO AUTO): try
553
+ # each device in order (NPU>GPU>CPU) and stop at the first that compiles.
554
+ # CPU is the guaranteed floor. If every candidate fails, re-raise — the
555
+ # existing load/replace error handler reports the failure.
576
556
  compiled = None
557
+ ov_device = None
577
558
  last_exc: Optional[BaseException] = None
578
559
  for candidate in candidates:
579
560
  try:
@@ -582,16 +563,16 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
582
563
  break
583
564
  except Exception as exc:
584
565
  last_exc = exc
585
- sys.stderr.write(f"OpenVINO: compile with mode {candidate} failed: {exc}\n")
566
+ sys.stderr.write(f"OpenVINO: compile on {candidate} failed: {exc}\n")
586
567
  if candidate != candidates[-1]:
587
- sys.stderr.write(f"OpenVINO: {candidate} mode failed, reverting to AUTO.\n")
568
+ sys.stderr.write("OpenVINO: falling back to next device.\n")
588
569
  sys.stderr.flush()
589
570
  if compiled is None:
590
571
  if last_exc is not None:
591
572
  raise last_exc
592
573
  raise RuntimeError("OpenVINO: no device available to compile the model")
593
574
  # Name the resolved mode + what OpenVINO actually placed the model on
594
- # (mirrors Scrypted's EXECUTION_DEVICES print) so a silent CPU
575
+ # (the EXECUTION_DEVICES property) so a silent CPU
595
576
  # placement is visible in the pool stderr.
596
577
  try:
597
578
  _exec_devices = compiled.get_property("EXECUTION_DEVICES")
@@ -614,30 +595,36 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
614
595
  except Exception:
615
596
  pass
616
597
 
617
- # One InferRequest per predict thread (thread-local) InferRequests
618
- # are NOT safe to share across threads, and a per-thread request is
619
- # what lets the THROUGHPUT streams run concurrently.
620
- _ov_tls = threading.local()
598
+ # Create ALL InferRequests ONCE, now, post-compile. With AUTO gone there
599
+ # is no CPU->GPU handover, so no request can be born mid-handover and bind
600
+ # to stale ports — the score_8 race is structurally removed. Requests are
601
+ # handed out through a thread-safe queue: an InferRequest is never touched
602
+ # by two threads at once (checkout/return), so sharing a fixed pool is safe
603
+ # and avoids the old thread-local lazy creation. Size the pool to the
604
+ # device's optimal count (>= OV_DEFAULT_CONCURRENCY) so it matches the
605
+ # predict-pool worker count sized in _run().
606
+ import queue as _queue
607
+
608
+ try:
609
+ _n_req = int(compiled.get_property("OPTIMAL_NUMBER_OF_INFER_REQUESTS"))
610
+ except Exception:
611
+ _n_req = 0
612
+ _n_req = max(_n_req, OV_DEFAULT_CONCURRENCY)
613
+ _req_pool: "_queue.Queue" = _queue.Queue()
614
+ for _ in range(_n_req):
615
+ _req_pool.put(compiled.create_infer_request())
616
+ sys.stderr.write(f"OpenVINO: created {_n_req} InferRequest(s) up-front on {ov_device}\n")
617
+ sys.stderr.flush()
621
618
 
622
- def predict(inp_dict: dict, _c=compiled, _names=output_names, _tls=_ov_tls) -> dict:
623
- req = getattr(_tls, "req", None)
624
- if req is None:
625
- req = _tls.req = _c.create_infer_request()
619
+ def predict(inp_dict: dict, _names=output_names, _pool=_req_pool) -> dict:
626
620
  inp = list(inp_dict.values())[0]
621
+ req = _pool.get()
627
622
  try:
628
623
  result = req.infer(inp)
629
- except RuntimeError:
630
- # AUTO CPU->GPU handover race: a request created during the
631
- # CPU-helper phase (while the GPU JIT-compiles) cannot map the
632
- # served model's output ports and raises from INSIDE infer()
633
- # "Cannot find tensor for port ... score_8". (Reproduced live on
634
- # the N100 iGPU under concurrent THROUGHPUT threads; the raise is
635
- # in req.infer(), not the output fetch below.) Recreate the
636
- # thread-local request post-handover and retry once.
637
- req = _tls.req = _c.create_infer_request()
638
- result = req.infer(inp)
639
- # Fetch outputs by positional index (stable across the handover)
640
- # rather than ConstOutput port identity — belt-and-braces.
624
+ finally:
625
+ _pool.put(req)
626
+ # Output fetch by positional index stable and harmless now that the
627
+ # ports no longer diverge (single explicit device, no handover).
641
628
  return {name: result[i] for i, name in enumerate(_names)}
642
629
 
643
630
  slot.model = compiled
@@ -646,7 +633,7 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
646
633
  elif _runtime == "onnxruntime":
647
634
  ort = _runtime_lib
648
635
  ort_device = str(config.get("device", "") or "")
649
- # Scrypted-style ordered providers — hardware EP first, CPU LAST as
636
+ # Ordered providers — hardware EP first, CPU LAST as
650
637
  # fallback (see _resolve_onnx_providers): a "cuda"/"coreml" pin puts
651
638
  # that EP first, a "cpu" pin compiles CPU only, and the AUTO/default
652
639
  # case derives the hardware EP from the platform.
@@ -658,8 +645,8 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
658
645
  ort_device, sys.platform, platform.machine(), available_eps,
659
646
  )
660
647
  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.
648
+ # Report the EP that ACTUALLY initialized (CPU stripped from the log)
649
+ # so a silent CPU fallback is visible.
663
650
  _active_eps = [p for p in session.get_providers() if p != ONNX_CPU_EP]
664
651
  _requested = [p[0] if isinstance(p, tuple) else p for p in providers]
665
652
  sys.stderr.write(
@@ -1271,18 +1258,18 @@ async def _run() -> None:
1271
1258
  sys.stderr.flush()
1272
1259
  models.append(slot)
1273
1260
 
1274
- # OpenVINO: size the predict pool so the THROUGHPUT execution streams are
1275
- # actually fed by concurrent infer-requests. The addon passes
1276
- # concurrency=1 (it predates per-thread infer requests) and models load
1261
+ # OpenVINO: size the predict pool so the fixed InferRequest pool (built per
1262
+ # model in _load_model) is actually fed by concurrent predict workers. The
1263
+ # addon passes concurrency=1 (it predates the request pool) and models load
1277
1264
  # lazily AFTER this point, so we can't read OPTIMAL_NUMBER_OF_INFER_REQUESTS
1278
1265
  # here — default to OV_DEFAULT_CONCURRENCY (≈ the measured optimal of 4-5
1279
1266
  # on Intel CPU/iGPU/NPU). If startup-loaded models report a higher optimal,
1280
- # use that. One InferRequest is created per predict thread on first use.
1267
+ # use that. The per-model InferRequest pool is sized to the same target.
1281
1268
  if runtime == "openvino":
1282
1269
  target = max(_ov_optimal_reqs, OV_DEFAULT_CONCURRENCY)
1283
1270
  if target > concurrency:
1284
1271
  sys.stderr.write(
1285
- f"OpenVINO THROUGHPUT: predict concurrency {concurrency} -> {target}\n"
1272
+ f"OpenVINO LATENCY: predict concurrency {concurrency} -> {target}\n"
1286
1273
  )
1287
1274
  sys.stderr.flush()
1288
1275
  concurrency = target