@camstack/addon-pipeline 1.1.31 → 1.1.33

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 (69) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/audio-codec-ffmpeg/index.js +118 -3
  4. package/dist/audio-codec-ffmpeg/index.mjs +118 -3
  5. package/dist/decoder-ffmpeg/index.js +74 -9
  6. package/dist/decoder-ffmpeg/index.mjs +74 -9
  7. package/dist/decoder-nodeav/index.js +218 -16
  8. package/dist/decoder-nodeav/index.mjs +218 -16
  9. package/dist/detection-pipeline/index.js +34 -3
  10. package/dist/detection-pipeline/index.mjs +34 -3
  11. package/dist/{dist-Cwc0TUQr.js → dist-BrIE85BG.js} +38 -1
  12. package/dist/{dist-DjuGmyG9.mjs → dist-CfSORkv2.mjs} +38 -1
  13. package/dist/{ffmpeg-args-C5GPp8Cw.mjs → ffmpeg-args-CpeRWwL4.mjs} +7 -4
  14. package/dist/{ffmpeg-args-D6h1edXK.js → ffmpeg-args-Dl9mLcOC.js} +7 -4
  15. package/dist/{frame-handle-plane-Bkxz-TTD.mjs → frame-handle-plane-C05rq9Zs.mjs} +108 -17
  16. package/dist/{frame-handle-plane-DQNCTrpC.js → frame-handle-plane-zMju9vmk.js} +108 -17
  17. package/dist/{frame-ring-sink-8LLV-cvH.js → frame-ring-sink-B-DxHelU.js} +175 -22
  18. package/dist/{frame-ring-sink-ClEWjiRU.mjs → frame-ring-sink-B_WjDX0x.mjs} +164 -23
  19. package/dist/motion-wasm/index.js +1 -1
  20. package/dist/motion-wasm/index.mjs +1 -1
  21. package/dist/pipeline-runner/index.js +86 -16
  22. package/dist/pipeline-runner/index.mjs +85 -17
  23. package/dist/recorder/index.js +1 -1
  24. package/dist/recorder/index.mjs +1 -1
  25. package/dist/stream-broker/_stub.js +1 -1
  26. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D567z31g.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DPFAbcUo.mjs} +3 -3
  27. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DtoYGpTp.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BsrrdOBU.mjs} +1 -1
  28. package/dist/stream-broker/{hostInit-B_b3PIZB.mjs → hostInit-C8slrxw1.mjs} +3 -3
  29. package/dist/stream-broker/index.js +165 -27
  30. package/dist/stream-broker/index.mjs +165 -27
  31. package/dist/stream-broker/remoteEntry.js +1 -1
  32. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-x-DZOuQL.js → MaskShapeCanvas-DI4BY7W2-WUVfFzT-.js} +1 -1
  33. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DTeuDGCU.js → MotionZonesSettings-NcxxQN8r-CF7FXXLs.js} +1 -1
  34. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-CirtxO3e.js → PrivacyMaskSettings-APgPLF7p-qQCpVob3.js} +1 -1
  35. package/embed-dist/assets/{index-CpUy8OIE.js → index-BNhisVUZ.js} +4 -4
  36. package/embed-dist/index.html +1 -1
  37. package/package.json +1 -1
  38. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  39. package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
  40. package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
  41. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
  42. package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
  43. package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
  44. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  45. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  46. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  47. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  48. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  49. package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
  50. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  51. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  52. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  53. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  54. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  55. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  56. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  57. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  58. package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
  59. package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
  60. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  61. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  62. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  63. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  64. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  65. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
  66. package/dist/constants-CAqYVigN.js +0 -6615
  67. package/dist/constants-oYgW36Ty.mjs +0 -3587
  68. package/dist/dist-D3Ytt-_Y.js +0 -41111
  69. package/dist/dist-DluJuxV5.mjs +0 -37976
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-Cwc0TUQr.js");
1
+ const require_dist = require("./dist-BrIE85BG.js");
2
2
  let _camstack_shm_ring = require("@camstack/shm-ring");
3
3
  //#region src/shared/decoder-backend-keys.ts
4
4
  /** Built-in default when a node has no selection — the subprocess decoder. */
@@ -18,8 +18,75 @@ function normalizeDecoderNodeId(rawNodeId) {
18
18
  }
19
19
  //#endregion
20
20
  //#region src/shared/decoder-backend.ts
21
+ /**
22
+ * Decoder backend selection — decides which decoder addon registers the
23
+ * `decoder` cap on a node.
24
+ *
25
+ * Two decoder addons ship in this bundle: `decoder-ffmpeg` (subprocess
26
+ * decode; a decode crash is isolated to the child) and `decoder-nodeav`
27
+ * (in-process decode via FFmpeg native bindings; no subprocess lifecycle).
28
+ * Both are installed on every node, but only ONE may provide the `decoder`
29
+ * cap at a time.
30
+ *
31
+ * ## Why selection happens at REGISTRATION time (race-free by construction)
32
+ *
33
+ * The `decoder` cap is a singleton. Historically both decoder addons
34
+ * registered a provider and the active slot was resolved AFTER the fact —
35
+ * which exposed a boot-order race: whichever addon's runner initialised
36
+ * first briefly held the active slot, and any consumer that resolved the
37
+ * cap inside that window was dispatched to the wrong backend (the
38
+ * cap-declared `preferredProvider` override only corrects the slot when
39
+ * the preferred addon's registration eventually lands).
40
+ *
41
+ * This module removes the race by construction instead of arbitrating it:
42
+ * each decoder addon calls {@link resolveDecoderBackend} at the START of
43
+ * its `onInitialize` and registers its provider ONLY when it is the
44
+ * selected backend — the other addon returns no registrations at all. At
45
+ * most one `decoder` provider ever exists per node, so no resolution layer
46
+ * (kernel `CapabilityRegistry`, route resolver, UDS child preference) can
47
+ * pick a wrong one, regardless of boot order or timing.
48
+ *
49
+ * ## The setting
50
+ *
51
+ * The per-node selection lives in the `decoder-ffmpeg` OWNER addon's global
52
+ * settings — the standard `addon-settings` surface, visible in the admin UI
53
+ * as the owner's `backend` select field. The store is hub-central and the
54
+ * value is persisted node-scoped as `backend@<nodeId>`
55
+ * (`decoder-backend-keys.ts`); the owner's `getGlobalSettings` projects the
56
+ * requested node's value onto the bare `backend` field.
57
+ *
58
+ * Both decoder addons read it the SAME way, placement-agnostically: via
59
+ * `ctx.api.addonSettings.getGlobalSettings({ addonId: 'decoder-ffmpeg',
60
+ * nodeId })`. The `addon-settings` cap is hub-routed at the system level
61
+ * (`AddonCallGateway.classify` sends settings calls to the addon's base
62
+ * node — the hub instance answers for every node), so an agent addon
63
+ * transparently reads the hub-central store with no placement branching.
64
+ * NOT used: `ctx.settings.getSection` (node-local — empty on an agent, the
65
+ * original bug), the raw settings-store cap, or any custom side channel.
66
+ *
67
+ * Resolution: the node's scoped value → the built-in default
68
+ * {@link DEFAULT_DECODER_BACKEND} (`'ffmpeg'`). There is deliberately NO
69
+ * bare-key fallback (see `decoder-backend-keys.ts`), and any read failure
70
+ * resolves to the default so the safe backend still comes up.
71
+ *
72
+ * ## Switching backends
73
+ *
74
+ * Because registration is decided once at addon init, changing the setting
75
+ * takes effect on the next restart of the two decoder addons on the
76
+ * affected node (addon restart / redeploy / node reboot). This is the
77
+ * price of race-freedom: there is deliberately NO live re-arbitration
78
+ * path — a live hand-off would reintroduce a window with two providers.
79
+ */
21
80
  /** The addon whose global settings OWN the per-node `backend` field. */
22
81
  var DECODER_OWNER_ADDON_ID = "decoder-ffmpeg";
82
+ /** Fail-safe when the owner's hwaccel can't be read: defer to the local probe. */
83
+ var DEFAULT_DECODER_HWACCEL = "auto";
84
+ /** Narrow an arbitrary stored value to a known {@link HwAccelChoice}, else null. */
85
+ function parseDecoderHwAccel(raw) {
86
+ if (typeof raw !== "string") return null;
87
+ const match = require_dist.HWACCEL_OPTIONS.find((o) => o.value === raw);
88
+ return match ? match.value : null;
89
+ }
23
90
  function isHydratedField(entry) {
24
91
  return typeof entry === "object" && entry !== null && "key" in entry;
25
92
  }
@@ -38,38 +105,112 @@ function pickDecoderBackendFromSettings(view) {
38
105
  return DEFAULT_DECODER_BACKEND;
39
106
  }
40
107
  /**
41
- * Resolve the decoder backend this node should run. Called by BOTH decoder
42
- * addons at the start of `onInitialize`; the addon whose backend is NOT
43
- * selected registers nothing.
108
+ * Resolve the OWNER addon's (`decoder-ffmpeg`) own per-node backend from its
109
+ * ALREADY-PROJECTED global store the store `BaseAddon.resolveGlobalStore`
110
+ * returns, where the `perNode` `backend` field carries THIS node's value on
111
+ * its bare key. The owner MUST use this instead of {@link resolveDecoderBackend}:
112
+ * routing `addonSettings.getGlobalSettings({addonId:'decoder-ffmpeg'})` back to
113
+ * itself during its OWN `onInitialize` DEADLOCKS (the addon is not "loaded" on
114
+ * the node until init completes, but init is blocked on the read) → the read
115
+ * throws `transport-failed (addon not loaded)` → it silently defaults to
116
+ * `ffmpeg` and REGISTERS even when the node selected `nodeav`, producing a
117
+ * two-provider decode storm. Reading its own store is local, race-free, and
118
+ * has no deadlock.
119
+ */
120
+ function resolveOwnDecoderBackend(projectedGlobalStore) {
121
+ return parseDecoderBackend(projectedGlobalStore["backend"]) ?? "ffmpeg";
122
+ }
123
+ /** Transient transport/settings-store fingerprints worth retrying on. */
124
+ function isTransientSettingsError(message) {
125
+ return /not routable/i.test(message) || /not loaded/i.test(message) || /transport-failed/i.test(message) || /not connected/i.test(message) || /provider not available/i.test(message) || /SqliteSettingsBackend not initialized/i.test(message);
126
+ }
127
+ /**
128
+ * Resolve the decoder backend a NON-OWNER addon (`decoder-nodeav`) should run,
129
+ * by reading the OWNER's (`decoder-ffmpeg`) hub-central per-node `backend`.
44
130
  *
45
- * Fails safe: no api surface, a read error, or a null payload (owner not
46
- * answering yet) resolves to {@link DEFAULT_DECODER_BACKEND} the default
47
- * backend still registers, and the non-default one stands down (never both).
131
+ * The read routes to the owner addon's child runner; during a simultaneous
132
+ * (re)start the owner may not be up yet → a transient `transport-failed (addon
133
+ * not loaded)`. Immediately defaulting to `ffmpeg` here makes `decoder-nodeav`
134
+ * stand down even though the node selected `nodeav` → NO decoder registers at
135
+ * all. So retry on the transient fingerprints with a bounded budget (mirrors
136
+ * `BaseAddon.readAddonStoreWithRetry`) until the owner answers; only a
137
+ * persistent failure falls back to {@link DEFAULT_DECODER_BACKEND}.
138
+ *
139
+ * The OWNER addon must NOT call this (it would self-route + deadlock) — it uses
140
+ * {@link resolveOwnDecoderBackend} against its own store instead.
48
141
  */
49
142
  async function resolveDecoderBackend(api, nodeId, logger) {
50
143
  if (!api) {
51
144
  logger.warn("decoder-backend: no api surface — using default backend", { meta: { default: DEFAULT_DECODER_BACKEND } });
52
145
  return DEFAULT_DECODER_BACKEND;
53
146
  }
147
+ const normalized = normalizeDecoderNodeId(nodeId);
148
+ const delaysMs = [
149
+ 150,
150
+ 350,
151
+ 600,
152
+ 900,
153
+ 1200
154
+ ];
155
+ let lastErr;
156
+ for (let attempt = 0; attempt <= delaysMs.length; attempt++) {
157
+ try {
158
+ const view = await api.addonSettings.getGlobalSettings.query({
159
+ addonId: DECODER_OWNER_ADDON_ID,
160
+ nodeId: normalized
161
+ });
162
+ if (view !== null) return pickDecoderBackendFromSettings(view);
163
+ lastErr = /* @__PURE__ */ new Error("owner settings unavailable (null)");
164
+ } catch (err) {
165
+ lastErr = err;
166
+ const msg = err instanceof Error ? err.message : String(err);
167
+ if (!isTransientSettingsError(msg)) {
168
+ logger.warn("decoder-backend: settings read failed — using default backend", { meta: {
169
+ default: DEFAULT_DECODER_BACKEND,
170
+ error: msg
171
+ } });
172
+ return DEFAULT_DECODER_BACKEND;
173
+ }
174
+ }
175
+ if (attempt === delaysMs.length) break;
176
+ await new Promise((resolve) => setTimeout(resolve, delaysMs[attempt]));
177
+ }
178
+ logger.warn("decoder-backend: owner settings unavailable after retries — using default backend", { meta: {
179
+ default: DEFAULT_DECODER_BACKEND,
180
+ owner: DECODER_OWNER_ADDON_ID,
181
+ error: lastErr instanceof Error ? lastErr.message : String(lastErr)
182
+ } });
183
+ return DEFAULT_DECODER_BACKEND;
184
+ }
185
+ /**
186
+ * Pure selection of the owner's `hwaccel` field from an already-read hydrated
187
+ * settings payload. Missing/invalid/null → {@link DEFAULT_DECODER_HWACCEL}.
188
+ */
189
+ function pickDecoderHwAccelFromSettings(view) {
190
+ if (view === null) return DEFAULT_DECODER_HWACCEL;
191
+ for (const section of view.sections) for (const entry of section.fields) {
192
+ if (!isHydratedField(entry) || entry.key !== "hwaccel") continue;
193
+ return parseDecoderHwAccel(entry.value) ?? "auto";
194
+ }
195
+ return DEFAULT_DECODER_HWACCEL;
196
+ }
197
+ /**
198
+ * Resolve the effective decoder hwaccel override for a node by reading the
199
+ * `decoder-ffmpeg` OWNER's `hwaccel@<node>` setting — the exact same
200
+ * hub-routed, placement-agnostic read as {@link resolveDecoderBackend}, so
201
+ * node-av and ffmpeg honour ONE per-node value (the one the UI edits). Fails
202
+ * safe to {@link DEFAULT_DECODER_HWACCEL} (`'auto'` → the session's local probe).
203
+ */
204
+ async function resolveDecoderHwAccel(api, nodeId, logger) {
205
+ if (!api) return DEFAULT_DECODER_HWACCEL;
54
206
  try {
55
- const view = await api.addonSettings.getGlobalSettings.query({
207
+ return pickDecoderHwAccelFromSettings(await api.addonSettings.getGlobalSettings.query({
56
208
  addonId: DECODER_OWNER_ADDON_ID,
57
209
  nodeId: normalizeDecoderNodeId(nodeId)
58
- });
59
- if (view === null) {
60
- logger.warn("decoder-backend: owner settings unavailable — using default backend", { meta: {
61
- default: DEFAULT_DECODER_BACKEND,
62
- owner: DECODER_OWNER_ADDON_ID
63
- } });
64
- return DEFAULT_DECODER_BACKEND;
65
- }
66
- return pickDecoderBackendFromSettings(view);
210
+ }));
67
211
  } catch (err) {
68
- logger.warn("decoder-backend: settings read failed — using default backend", { meta: {
69
- default: DEFAULT_DECODER_BACKEND,
70
- error: err instanceof Error ? err.message : String(err)
71
- } });
72
- return DEFAULT_DECODER_BACKEND;
212
+ logger.warn("decoder-hwaccel: owner settings read failed — deferring to local probe", { meta: { error: err instanceof Error ? err.message : String(err) } });
213
+ return DEFAULT_DECODER_HWACCEL;
73
214
  }
74
215
  }
75
216
  //#endregion
@@ -442,3 +583,15 @@ Object.defineProperty(exports, "resolveDecoderBackend", {
442
583
  return resolveDecoderBackend;
443
584
  }
444
585
  });
586
+ Object.defineProperty(exports, "resolveDecoderHwAccel", {
587
+ enumerable: true,
588
+ get: function() {
589
+ return resolveDecoderHwAccel;
590
+ }
591
+ });
592
+ Object.defineProperty(exports, "resolveOwnDecoderBackend", {
593
+ enumerable: true,
594
+ get: function() {
595
+ return resolveOwnDecoderBackend;
596
+ }
597
+ });
@@ -1,4 +1,4 @@
1
- import { p as RingBuffer } from "./dist-DjuGmyG9.mjs";
1
+ import { d as HWACCEL_OPTIONS, p as RingBuffer } from "./dist-CfSORkv2.mjs";
2
2
  import { FrameRingWriter, MIN_RING_SLOTS, computeSegmentSize, computeSlotByteLength, createSegment, deriveSlotCount } from "@camstack/shm-ring";
3
3
  //#region src/shared/decoder-backend-keys.ts
4
4
  /** Built-in default when a node has no selection — the subprocess decoder. */
@@ -18,8 +18,75 @@ function normalizeDecoderNodeId(rawNodeId) {
18
18
  }
19
19
  //#endregion
20
20
  //#region src/shared/decoder-backend.ts
21
+ /**
22
+ * Decoder backend selection — decides which decoder addon registers the
23
+ * `decoder` cap on a node.
24
+ *
25
+ * Two decoder addons ship in this bundle: `decoder-ffmpeg` (subprocess
26
+ * decode; a decode crash is isolated to the child) and `decoder-nodeav`
27
+ * (in-process decode via FFmpeg native bindings; no subprocess lifecycle).
28
+ * Both are installed on every node, but only ONE may provide the `decoder`
29
+ * cap at a time.
30
+ *
31
+ * ## Why selection happens at REGISTRATION time (race-free by construction)
32
+ *
33
+ * The `decoder` cap is a singleton. Historically both decoder addons
34
+ * registered a provider and the active slot was resolved AFTER the fact —
35
+ * which exposed a boot-order race: whichever addon's runner initialised
36
+ * first briefly held the active slot, and any consumer that resolved the
37
+ * cap inside that window was dispatched to the wrong backend (the
38
+ * cap-declared `preferredProvider` override only corrects the slot when
39
+ * the preferred addon's registration eventually lands).
40
+ *
41
+ * This module removes the race by construction instead of arbitrating it:
42
+ * each decoder addon calls {@link resolveDecoderBackend} at the START of
43
+ * its `onInitialize` and registers its provider ONLY when it is the
44
+ * selected backend — the other addon returns no registrations at all. At
45
+ * most one `decoder` provider ever exists per node, so no resolution layer
46
+ * (kernel `CapabilityRegistry`, route resolver, UDS child preference) can
47
+ * pick a wrong one, regardless of boot order or timing.
48
+ *
49
+ * ## The setting
50
+ *
51
+ * The per-node selection lives in the `decoder-ffmpeg` OWNER addon's global
52
+ * settings — the standard `addon-settings` surface, visible in the admin UI
53
+ * as the owner's `backend` select field. The store is hub-central and the
54
+ * value is persisted node-scoped as `backend@<nodeId>`
55
+ * (`decoder-backend-keys.ts`); the owner's `getGlobalSettings` projects the
56
+ * requested node's value onto the bare `backend` field.
57
+ *
58
+ * Both decoder addons read it the SAME way, placement-agnostically: via
59
+ * `ctx.api.addonSettings.getGlobalSettings({ addonId: 'decoder-ffmpeg',
60
+ * nodeId })`. The `addon-settings` cap is hub-routed at the system level
61
+ * (`AddonCallGateway.classify` sends settings calls to the addon's base
62
+ * node — the hub instance answers for every node), so an agent addon
63
+ * transparently reads the hub-central store with no placement branching.
64
+ * NOT used: `ctx.settings.getSection` (node-local — empty on an agent, the
65
+ * original bug), the raw settings-store cap, or any custom side channel.
66
+ *
67
+ * Resolution: the node's scoped value → the built-in default
68
+ * {@link DEFAULT_DECODER_BACKEND} (`'ffmpeg'`). There is deliberately NO
69
+ * bare-key fallback (see `decoder-backend-keys.ts`), and any read failure
70
+ * resolves to the default so the safe backend still comes up.
71
+ *
72
+ * ## Switching backends
73
+ *
74
+ * Because registration is decided once at addon init, changing the setting
75
+ * takes effect on the next restart of the two decoder addons on the
76
+ * affected node (addon restart / redeploy / node reboot). This is the
77
+ * price of race-freedom: there is deliberately NO live re-arbitration
78
+ * path — a live hand-off would reintroduce a window with two providers.
79
+ */
21
80
  /** The addon whose global settings OWN the per-node `backend` field. */
22
81
  var DECODER_OWNER_ADDON_ID = "decoder-ffmpeg";
82
+ /** Fail-safe when the owner's hwaccel can't be read: defer to the local probe. */
83
+ var DEFAULT_DECODER_HWACCEL = "auto";
84
+ /** Narrow an arbitrary stored value to a known {@link HwAccelChoice}, else null. */
85
+ function parseDecoderHwAccel(raw) {
86
+ if (typeof raw !== "string") return null;
87
+ const match = HWACCEL_OPTIONS.find((o) => o.value === raw);
88
+ return match ? match.value : null;
89
+ }
23
90
  function isHydratedField(entry) {
24
91
  return typeof entry === "object" && entry !== null && "key" in entry;
25
92
  }
@@ -38,38 +105,112 @@ function pickDecoderBackendFromSettings(view) {
38
105
  return DEFAULT_DECODER_BACKEND;
39
106
  }
40
107
  /**
41
- * Resolve the decoder backend this node should run. Called by BOTH decoder
42
- * addons at the start of `onInitialize`; the addon whose backend is NOT
43
- * selected registers nothing.
108
+ * Resolve the OWNER addon's (`decoder-ffmpeg`) own per-node backend from its
109
+ * ALREADY-PROJECTED global store the store `BaseAddon.resolveGlobalStore`
110
+ * returns, where the `perNode` `backend` field carries THIS node's value on
111
+ * its bare key. The owner MUST use this instead of {@link resolveDecoderBackend}:
112
+ * routing `addonSettings.getGlobalSettings({addonId:'decoder-ffmpeg'})` back to
113
+ * itself during its OWN `onInitialize` DEADLOCKS (the addon is not "loaded" on
114
+ * the node until init completes, but init is blocked on the read) → the read
115
+ * throws `transport-failed (addon not loaded)` → it silently defaults to
116
+ * `ffmpeg` and REGISTERS even when the node selected `nodeav`, producing a
117
+ * two-provider decode storm. Reading its own store is local, race-free, and
118
+ * has no deadlock.
119
+ */
120
+ function resolveOwnDecoderBackend(projectedGlobalStore) {
121
+ return parseDecoderBackend(projectedGlobalStore["backend"]) ?? "ffmpeg";
122
+ }
123
+ /** Transient transport/settings-store fingerprints worth retrying on. */
124
+ function isTransientSettingsError(message) {
125
+ return /not routable/i.test(message) || /not loaded/i.test(message) || /transport-failed/i.test(message) || /not connected/i.test(message) || /provider not available/i.test(message) || /SqliteSettingsBackend not initialized/i.test(message);
126
+ }
127
+ /**
128
+ * Resolve the decoder backend a NON-OWNER addon (`decoder-nodeav`) should run,
129
+ * by reading the OWNER's (`decoder-ffmpeg`) hub-central per-node `backend`.
130
+ *
131
+ * The read routes to the owner addon's child runner; during a simultaneous
132
+ * (re)start the owner may not be up yet → a transient `transport-failed (addon
133
+ * not loaded)`. Immediately defaulting to `ffmpeg` here makes `decoder-nodeav`
134
+ * stand down even though the node selected `nodeav` → NO decoder registers at
135
+ * all. So retry on the transient fingerprints with a bounded budget (mirrors
136
+ * `BaseAddon.readAddonStoreWithRetry`) until the owner answers; only a
137
+ * persistent failure falls back to {@link DEFAULT_DECODER_BACKEND}.
44
138
  *
45
- * Fails safe: no api surface, a read error, or a null payload (owner not
46
- * answering yet) resolves to {@link DEFAULT_DECODER_BACKEND} the default
47
- * backend still registers, and the non-default one stands down (never both).
139
+ * The OWNER addon must NOT call this (it would self-route + deadlock) it uses
140
+ * {@link resolveOwnDecoderBackend} against its own store instead.
48
141
  */
49
142
  async function resolveDecoderBackend(api, nodeId, logger) {
50
143
  if (!api) {
51
144
  logger.warn("decoder-backend: no api surface — using default backend", { meta: { default: DEFAULT_DECODER_BACKEND } });
52
145
  return DEFAULT_DECODER_BACKEND;
53
146
  }
147
+ const normalized = normalizeDecoderNodeId(nodeId);
148
+ const delaysMs = [
149
+ 150,
150
+ 350,
151
+ 600,
152
+ 900,
153
+ 1200
154
+ ];
155
+ let lastErr;
156
+ for (let attempt = 0; attempt <= delaysMs.length; attempt++) {
157
+ try {
158
+ const view = await api.addonSettings.getGlobalSettings.query({
159
+ addonId: DECODER_OWNER_ADDON_ID,
160
+ nodeId: normalized
161
+ });
162
+ if (view !== null) return pickDecoderBackendFromSettings(view);
163
+ lastErr = /* @__PURE__ */ new Error("owner settings unavailable (null)");
164
+ } catch (err) {
165
+ lastErr = err;
166
+ const msg = err instanceof Error ? err.message : String(err);
167
+ if (!isTransientSettingsError(msg)) {
168
+ logger.warn("decoder-backend: settings read failed — using default backend", { meta: {
169
+ default: DEFAULT_DECODER_BACKEND,
170
+ error: msg
171
+ } });
172
+ return DEFAULT_DECODER_BACKEND;
173
+ }
174
+ }
175
+ if (attempt === delaysMs.length) break;
176
+ await new Promise((resolve) => setTimeout(resolve, delaysMs[attempt]));
177
+ }
178
+ logger.warn("decoder-backend: owner settings unavailable after retries — using default backend", { meta: {
179
+ default: DEFAULT_DECODER_BACKEND,
180
+ owner: DECODER_OWNER_ADDON_ID,
181
+ error: lastErr instanceof Error ? lastErr.message : String(lastErr)
182
+ } });
183
+ return DEFAULT_DECODER_BACKEND;
184
+ }
185
+ /**
186
+ * Pure selection of the owner's `hwaccel` field from an already-read hydrated
187
+ * settings payload. Missing/invalid/null → {@link DEFAULT_DECODER_HWACCEL}.
188
+ */
189
+ function pickDecoderHwAccelFromSettings(view) {
190
+ if (view === null) return DEFAULT_DECODER_HWACCEL;
191
+ for (const section of view.sections) for (const entry of section.fields) {
192
+ if (!isHydratedField(entry) || entry.key !== "hwaccel") continue;
193
+ return parseDecoderHwAccel(entry.value) ?? "auto";
194
+ }
195
+ return DEFAULT_DECODER_HWACCEL;
196
+ }
197
+ /**
198
+ * Resolve the effective decoder hwaccel override for a node by reading the
199
+ * `decoder-ffmpeg` OWNER's `hwaccel@<node>` setting — the exact same
200
+ * hub-routed, placement-agnostic read as {@link resolveDecoderBackend}, so
201
+ * node-av and ffmpeg honour ONE per-node value (the one the UI edits). Fails
202
+ * safe to {@link DEFAULT_DECODER_HWACCEL} (`'auto'` → the session's local probe).
203
+ */
204
+ async function resolveDecoderHwAccel(api, nodeId, logger) {
205
+ if (!api) return DEFAULT_DECODER_HWACCEL;
54
206
  try {
55
- const view = await api.addonSettings.getGlobalSettings.query({
207
+ return pickDecoderHwAccelFromSettings(await api.addonSettings.getGlobalSettings.query({
56
208
  addonId: DECODER_OWNER_ADDON_ID,
57
209
  nodeId: normalizeDecoderNodeId(nodeId)
58
- });
59
- if (view === null) {
60
- logger.warn("decoder-backend: owner settings unavailable — using default backend", { meta: {
61
- default: DEFAULT_DECODER_BACKEND,
62
- owner: DECODER_OWNER_ADDON_ID
63
- } });
64
- return DEFAULT_DECODER_BACKEND;
65
- }
66
- return pickDecoderBackendFromSettings(view);
210
+ }));
67
211
  } catch (err) {
68
- logger.warn("decoder-backend: settings read failed — using default backend", { meta: {
69
- default: DEFAULT_DECODER_BACKEND,
70
- error: err instanceof Error ? err.message : String(err)
71
- } });
72
- return DEFAULT_DECODER_BACKEND;
212
+ logger.warn("decoder-hwaccel: owner settings read failed — deferring to local probe", { meta: { error: err instanceof Error ? err.message : String(err) } });
213
+ return DEFAULT_DECODER_HWACCEL;
73
214
  }
74
215
  }
75
216
  //#endregion
@@ -406,4 +547,4 @@ var DecoderFrameRingSink = class {
406
547
  }
407
548
  };
408
549
  //#endregion
409
- export { resolveDecoderBackend as a, NotifyingRingBuffer as i, RING_BUDGET_MB as n, DEFAULT_DECODER_BACKEND as o, makeSegmentName as r, DecoderFrameRingSink as t };
550
+ export { resolveDecoderBackend as a, DEFAULT_DECODER_BACKEND as c, NotifyingRingBuffer as i, RING_BUDGET_MB as n, resolveDecoderHwAccel as o, makeSegmentName as r, resolveOwnDecoderBackend as s, DecoderFrameRingSink as t };
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Cwc0TUQr.js");
5
+ const require_dist = require("../dist-BrIE85BG.js");
6
6
  let _camstack_shm_ring = require("@camstack/shm-ring");
7
7
  let node_fs = require("node:fs");
8
8
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { A as motionDetectionCapability, T as evaluateZoneRules, V as BaseAddon, W as DeviceType, q as hydrateSchema } from "../dist-DjuGmyG9.mjs";
1
+ import { A as motionDetectionCapability, T as evaluateZoneRules, V as BaseAddon, W as DeviceType, q as hydrateSchema } from "../dist-CfSORkv2.mjs";
2
2
  import { FrameRingReaderCache } from "@camstack/shm-ring";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
@@ -2,8 +2,8 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Cwc0TUQr.js");
6
- const require_frame_handle_plane = require("../frame-handle-plane-DQNCTrpC.js");
5
+ const require_dist = require("../dist-BrIE85BG.js");
6
+ const require_frame_handle_plane = require("../frame-handle-plane-zMju9vmk.js");
7
7
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
8
8
  let _camstack_shm_ring = require("@camstack/shm-ring");
9
9
  //#region src/pipeline-runner/bench-actions.ts
@@ -1409,6 +1409,55 @@ function toFrameInput(frame) {
1409
1409
  };
1410
1410
  }
1411
1411
  /**
1412
+ * Convert a packed RGB24 `DecodedFrame` into a packed GRAY8 one using the
1413
+ * BT.601 luma weighting (Y = 0.299R + 0.587G + 0.114B, integer-approximated as
1414
+ * 77/150/29 over 256).
1415
+ *
1416
+ * This is what lets **sensor-triggered (onboard) motion share the detection
1417
+ * decode**: the gray the motion analyzer needs is just the luma of the
1418
+ * already-decoded detection `rgb` frame, so no second decoder is spun up. Far
1419
+ * cheaper than a full second decode (a per-pixel weighted sum vs. an entire
1420
+ * H.264/H.265 decode). Persistent (analyzer-only) motion keeps its own gray
1421
+ * decode on the sub stream and never hits this path.
1422
+ */
1423
+ function rgbFrameToGrayFrame(frame) {
1424
+ const rgb = frame.data;
1425
+ const pixels = frame.width * frame.height;
1426
+ const gray = Buffer.allocUnsafe(pixels);
1427
+ for (let i = 0, j = 0; i < pixels; i++, j += 3) gray[i] = rgb[j] * 77 + rgb[j + 1] * 150 + rgb[j + 2] * 29 >> 8;
1428
+ return {
1429
+ data: gray,
1430
+ width: frame.width,
1431
+ height: frame.height,
1432
+ format: "gray",
1433
+ timestamp: frame.timestamp,
1434
+ ...frame.capturedAt !== void 0 ? { capturedAt: frame.capturedAt } : {}
1435
+ };
1436
+ }
1437
+ /**
1438
+ * Decide where the motion analyzer reads frames from.
1439
+ *
1440
+ * - `sharedWithDetection` (onboard/sensor-triggered) AND detection actually
1441
+ * runs (`detectionMode !== 'disabled'`) → ride the detection decode:
1442
+ * `rgb` on the MAIN (`detectionStreamId`) stream, converted to gray. No
1443
+ * second decoder — the `FrameHandlePlane` coalesces it onto detection's session.
1444
+ * - Otherwise (persistent analyzer, or detection disabled) → a dedicated
1445
+ * `gray` decode on the SUB (`motionStreamId`) stream, exactly as before.
1446
+ *
1447
+ * Pure so the decision is unit-testable without the addon/broker.
1448
+ */
1449
+ function resolveMotionFrameSource(config, sharedWithDetection) {
1450
+ return sharedWithDetection && config.detectionMode !== "disabled" ? {
1451
+ streamId: config.detectionStreamId,
1452
+ format: "rgb",
1453
+ shared: true
1454
+ } : {
1455
+ streamId: config.motionStreamId,
1456
+ format: "gray",
1457
+ shared: false
1458
+ };
1459
+ }
1460
+ /**
1412
1461
  * Interval for periodic metric snapshot emission. ~1 Hz strikes the
1413
1462
  * balance between UI freshness (overlay phase / fps numbers feel live)
1414
1463
  * and bus traffic (one snapshot per attached camera + one per node
@@ -1911,6 +1960,12 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
1911
1960
  }
1912
1961
  });
1913
1962
  if (this.attached.has(config.deviceId)) this.detachInternal(config.deviceId);
1963
+ const attachment = {
1964
+ config,
1965
+ motionUnsubscribe: null,
1966
+ detectionUnsubscribe: null
1967
+ };
1968
+ this.attached.set(config.deviceId, attachment);
1914
1969
  runner.registerCamera(config.deviceId, {
1915
1970
  detectionMode: config.detectionMode,
1916
1971
  fps: config.detectionFps,
@@ -1918,12 +1973,6 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
1918
1973
  occupancyRecheckSec: config.occupancyRecheckSec,
1919
1974
  occupancyRecheckFrames: config.occupancyRecheckFrames
1920
1975
  });
1921
- const attachment = {
1922
- config,
1923
- motionUnsubscribe: null,
1924
- detectionUnsubscribe: null
1925
- };
1926
- this.attached.set(config.deviceId, attachment);
1927
1976
  if (config.motionSources.includes("analyzer")) attachment.motionUnsubscribe = await this.subscribeMotionFrames(config);
1928
1977
  const stepsCount = config.steps?.length ?? 0;
1929
1978
  const dispatch = stepsCount > 0 ? `runPipeline(${stepsCount}step${stepsCount === 1 ? "" : "s"})` : config.steps !== void 0 ? "skip(0steps)" : "runFrame(legacy)";
@@ -2013,10 +2062,10 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2013
2062
  if (!shouldStartOnboardAnalyzer(config)) return;
2014
2063
  const log = this.ctx.logger.withTags({ deviceId });
2015
2064
  if (detected && !this.onboardAnalyzerSubs.has(deviceId)) {
2016
- const unsub = await this.subscribeMotionFrames(config);
2065
+ const unsub = await this.subscribeMotionFrames(config, { sharedWithDetection: true });
2017
2066
  if (unsub) {
2018
2067
  this.onboardAnalyzerSubs.set(deviceId, unsub);
2019
- log.debug("onboard-analyzer: opened motion-frame subscription");
2068
+ log.debug("onboard-analyzer: opened motion-frame subscription (shared detection decode)");
2020
2069
  }
2021
2070
  }
2022
2071
  const existing = this.onboardAnalyzerTeardownTimers.get(deviceId);
@@ -2140,7 +2189,21 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2140
2189
  this.remotePlanes.set(config.deviceId, plane);
2141
2190
  return plane;
2142
2191
  }
2143
- async subscribeMotionFrames(config) {
2192
+ /**
2193
+ * Subscribe the motion analyzer to decoded frames.
2194
+ *
2195
+ * Two modes:
2196
+ * - **Persistent (default)** — a dedicated `gray` decode of the camera's
2197
+ * `motionStreamId` (the sub stream). Cheap, always-on; used when the SW
2198
+ * analyzer is the motion source (`motionSources` includes `'analyzer'`).
2199
+ * - **Shared with detection** (`opts.sharedWithDetection`) — subscribes
2200
+ * `rgb` on `detectionStreamId` (the MAIN stream) so it rides the SAME
2201
+ * decoder session detection already uses (the `FrameHandlePlane` coalesces
2202
+ * same-format subscribers on a stream into ONE decode), then derives the
2203
+ * analyzer's `gray` from that rgb frame's luma. No second decoder. Used for
2204
+ * onboard/sensor-triggered motion, where a decode is already active.
2205
+ */
2206
+ async subscribeMotionFrames(config, opts) {
2144
2207
  const ctx = this.ctx;
2145
2208
  const runner = this.runner;
2146
2209
  if (!ctx || !runner) return null;
@@ -2150,18 +2213,23 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2150
2213
  log.warn("subscribeMotionFrames: this.ctx.api not available");
2151
2214
  return null;
2152
2215
  }
2216
+ const src = resolveMotionFrameSource(config, opts?.sharedWithDetection === true);
2217
+ const shared = src.shared;
2218
+ const streamId = src.streamId;
2153
2219
  const remote = this.remoteSourceFor(config);
2154
2220
  return startFrameHandlePoller({
2155
2221
  api,
2156
- brokerId: require_dist.makeSourceBrokerId(config.deviceId, config.motionStreamId),
2157
- format: "gray",
2222
+ brokerId: require_dist.makeSourceBrokerId(config.deviceId, streamId),
2223
+ format: src.format,
2158
2224
  maxFps: config.motionFps,
2159
- tag: "motion",
2225
+ tag: shared ? "motion-shared" : "motion",
2160
2226
  logger: log,
2161
2227
  ownNodeId: this.nodeId,
2162
2228
  getRemoteFrame: (handle) => this.getRemoteFrame(handle),
2163
- ...remote ? { source: remote.makeSource(config.motionStreamId) } : {},
2164
- onFrame: (frame, handle) => {
2229
+ ...remote ? { source: remote.makeSource(streamId) } : {},
2230
+ onFrame: shared ? (frame) => {
2231
+ runner.enqueueMotionFrame(config.deviceId, rgbFrameToGrayFrame(frame), void 0);
2232
+ } : (frame, handle) => {
2165
2233
  runner.enqueueMotionFrame(config.deviceId, frame, handle);
2166
2234
  }
2167
2235
  });
@@ -2588,4 +2656,6 @@ exports.PipelineTimingSampler = PipelineTimingSampler;
2588
2656
  exports.Semaphore = Semaphore;
2589
2657
  exports.customActions = pipelineRunnerBenchActions;
2590
2658
  exports.default = PipelineRunnerAddon;
2659
+ exports.resolveMotionFrameSource = resolveMotionFrameSource;
2660
+ exports.rgbFrameToGrayFrame = rgbFrameToGrayFrame;
2591
2661
  exports.shouldStartOnboardAnalyzer = shouldStartOnboardAnalyzer;