@camstack/addon-pipeline 0.1.20 → 0.2.0

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 (101) hide show
  1. package/dist/audio-analyzer/index.js +736 -719
  2. package/dist/audio-analyzer/index.mjs +726 -679
  3. package/dist/audio-codec-nodeav/index.js +304 -461
  4. package/dist/audio-codec-nodeav/index.mjs +300 -462
  5. package/dist/chunk-BdkLduGY.mjs +5 -0
  6. package/dist/chunk-D6vf50IK.js +28 -0
  7. package/dist/codec-runtime-BOk-13PN.js +202 -0
  8. package/dist/codec-runtime-BsqlEjPi.mjs +197 -0
  9. package/dist/constants-B_b0a-6h.mjs +3119 -0
  10. package/dist/{index-CMcx_k6Y.js → constants-D65v6yp6.js} +3107 -2935
  11. package/dist/decoder-nodeav/index.js +1374 -1444
  12. package/dist/decoder-nodeav/index.mjs +1369 -1425
  13. package/dist/detection-pipeline/index.js +6462 -5613
  14. package/dist/detection-pipeline/index.mjs +6451 -5574
  15. package/dist/dist-7ewQjTle.js +22454 -0
  16. package/dist/dist-C5jnNl0n.mjs +22089 -0
  17. package/dist/motion-wasm/index.js +469 -467
  18. package/dist/motion-wasm/index.mjs +464 -446
  19. package/dist/pipeline-runner/index.js +2029 -1827
  20. package/dist/pipeline-runner/index.mjs +2025 -1811
  21. package/dist/recorder/index.js +2045 -2157
  22. package/dist/recorder/index.mjs +2042 -2156
  23. package/dist/stream-broker/_stub.js +1806 -1352
  24. package/dist/stream-broker/_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D4-DHanK.mjs +156 -0
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Tf-HACFd.mjs +26 -0
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-C9WX5HNw.mjs +26 -0
  27. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-BO7TIbJV.mjs +26 -0
  28. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.js-C9j-2lBe.mjs +26 -0
  29. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-XO0-Pyu6.mjs +26 -0
  30. package/dist/stream-broker/dist-CYZr2fwk.mjs +2726 -0
  31. package/dist/stream-broker/hostInit-Di6vceAU.mjs +129 -0
  32. package/dist/stream-broker/index.js +17778 -15470
  33. package/dist/stream-broker/index.mjs +17769 -15465
  34. package/dist/stream-broker/remoteEntry.js +134 -2973
  35. package/dist/stream-broker/remoteEntry.ssr.js +33 -0
  36. package/dist/stream-broker/virtualExposes-dYNvIwoR.mjs +27 -0
  37. package/dist/stream-broker/virtual_mf-exposes-ssr___mfe_internal__addon_stream_broker_widgets__remoteEntry_js-Cmqfp4i_.mjs +10 -0
  38. package/embed-dist/assets/index-B8VlSD0-.js +150 -0
  39. package/embed-dist/assets/index-ZhDdp1Nd.css +2 -0
  40. package/embed-dist/index.html +13 -0
  41. package/package.json +25 -7
  42. package/wasm/assembly/index.ts +41 -16
  43. package/dist/audio-analyzer/index.js.map +0 -1
  44. package/dist/audio-analyzer/index.mjs.map +0 -1
  45. package/dist/audio-codec-nodeav/index.js.map +0 -1
  46. package/dist/audio-codec-nodeav/index.mjs.map +0 -1
  47. package/dist/decoder-nodeav/index.js.map +0 -1
  48. package/dist/decoder-nodeav/index.mjs.map +0 -1
  49. package/dist/detection-pipeline/index.js.map +0 -1
  50. package/dist/detection-pipeline/index.mjs.map +0 -1
  51. package/dist/index-5aYef068.mjs +0 -17514
  52. package/dist/index-5aYef068.mjs.map +0 -1
  53. package/dist/index-B36NMAdu.js +0 -17513
  54. package/dist/index-B36NMAdu.js.map +0 -1
  55. package/dist/index-CMcx_k6Y.js.map +0 -1
  56. package/dist/index-CYb7cFrv.mjs +0 -5790
  57. package/dist/index-CYb7cFrv.mjs.map +0 -1
  58. package/dist/motion-wasm/index.js.map +0 -1
  59. package/dist/motion-wasm/index.mjs.map +0 -1
  60. package/dist/pipeline-runner/index.js.map +0 -1
  61. package/dist/pipeline-runner/index.mjs.map +0 -1
  62. package/dist/recorder/index.js.map +0 -1
  63. package/dist/recorder/index.mjs.map +0 -1
  64. package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/FfmpegParamsField.d.ts +0 -41
  65. package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/GeometryBuilder.d.ts +0 -54
  66. package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/StreamBrokerPanel.d.ts +0 -21
  67. package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/format-ua.d.ts +0 -13
  68. package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/index.d.ts +0 -15
  69. package/dist/stream-broker/@mf-types/widgets.d.ts +0 -2
  70. package/dist/stream-broker/@mf-types.d.ts +0 -3
  71. package/dist/stream-broker/@mf-types.zip +0 -0
  72. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-lantnv8e.mjs +0 -12
  73. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-DJ3UNg7O.mjs +0 -30
  74. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-CYXy_bhS.mjs +0 -21
  75. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.mjs-U1EUeEPs.mjs +0 -104
  76. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_trpc_mf_1_client__loadShare__.mjs-DeouEaSs.mjs +0 -85
  77. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.mjs-DHUwjbb9.mjs +0 -62
  78. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs-CaDEYBIU.mjs +0 -89
  79. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-D6EROtlA.mjs +0 -29
  80. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-x6pP3Ghk.mjs +0 -36
  81. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs-DYEKzzY-.mjs +0 -45
  82. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-CcnN6sbA.mjs +0 -6
  83. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs-DICOtMTl.mjs +0 -34
  84. package/dist/stream-broker/_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CL9DR49k.mjs +0 -156
  85. package/dist/stream-broker/client-BvTmMOQu.mjs +0 -9836
  86. package/dist/stream-broker/getErrorShape-BPSzUA7W-TlK8ipWe.mjs +0 -211
  87. package/dist/stream-broker/hostInit-ChmiMPS0.mjs +0 -168
  88. package/dist/stream-broker/index-BxsFuFmE.mjs +0 -2603
  89. package/dist/stream-broker/index-C-248uOU.mjs +0 -725
  90. package/dist/stream-broker/index-C05B6jqp.mjs +0 -185
  91. package/dist/stream-broker/index-CWkKuNLr.mjs +0 -232
  92. package/dist/stream-broker/index-DOJoSShD.mjs +0 -67784
  93. package/dist/stream-broker/index-DtOI1aTU.mjs +0 -18504
  94. package/dist/stream-broker/index-oMq6ilgR.mjs +0 -1641
  95. package/dist/stream-broker/index-vIWZQBIL.mjs +0 -435
  96. package/dist/stream-broker/index-xncRG7-x.mjs +0 -2713
  97. package/dist/stream-broker/index.js.map +0 -1
  98. package/dist/stream-broker/index.mjs.map +0 -1
  99. package/dist/stream-broker/jsx-runtime-BRT_HL0A.mjs +0 -55
  100. package/dist/stream-broker/schemas-B7L0qZtq.mjs +0 -3599
  101. package/dist/stream-broker/virtualExposes-pCd777Rp.mjs +0 -42
@@ -1,1441 +1,1385 @@
1
+ import { O as decoderCapability, _ as RingBuffer, h as HWACCEL_OPTIONS, i as BaseAddon, j as errMsg, l as DEFAULT_DECODER_HWACCEL_CONFIG } from "../dist-C5jnNl0n.mjs";
2
+ import { FrameRingReaderCache, FrameRingWriter, MIN_RING_SLOTS, computeSegmentSize, computeSlotByteLength, createSegment, deriveSlotCount } from "@camstack/shm-ring";
1
3
  import { randomUUID } from "node:crypto";
2
- import { e as errMsg, B as BaseAddon, w as DEFAULT_DECODER_HWACCEL_CONFIG, H as HWACCEL_OPTIONS, x as decoderCapability, R as RingBuffer } from "../index-5aYef068.mjs";
3
- import { computeSlotByteLength, computeSegmentSize, deriveSlotCount, MIN_RING_SLOTS, createSegment, FrameRingWriter, FrameRingReaderCache } from "@camstack/shm-ring";
4
- const RING_BUDGET_MB = (() => {
5
- const raw = Number(process.env["CAMSTACK_SHM_RING_BUDGET_MB"]);
6
- return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 128;
4
+ //#region src/decoder-nodeav/frame-ring-sink.ts
5
+ /**
6
+ * `DecoderFrameRingSink` the decoder's shared-memory write side (Phase 5 / D9).
7
+ *
8
+ * When a decoder session is configured with `frameSink: 'shm'`, the decoder
9
+ * **owns** the shared-memory ring segment for that stream: it creates the
10
+ * segment on the first decoded frame (when the output geometry is known),
11
+ * writes every subsequent decoded frame into the ring via a `FrameRingWriter`,
12
+ * and closes + unlinks the segment when the session is destroyed.
13
+ *
14
+ * What leaves the decoder is no longer the pixel `Buffer` — it is a tiny,
15
+ * serialisable `FrameHandle` (`FrameRingWriter.writeFrame`'s return value).
16
+ * Same-host consumers (motion, detection, the WebRTC encoder) open the same
17
+ * segment with a `FrameRingReader` and read the pixels zero-copy.
18
+ *
19
+ * ## Lazy segment creation
20
+ *
21
+ * The segment cannot be sized until the first frame: `slotByteLength` is
22
+ * `width × height × bytesPerPixel`, and the output dimensions are only known
23
+ * once the scaler has produced its first `dstFrame`. So `writeFrame` is a
24
+ * no-op-until-armed: the first call sizes + creates the segment, every later
25
+ * call writes into it.
26
+ *
27
+ * ## Resolution-change decision
28
+ *
29
+ * A live camera stream can change resolution mid-stream (the decoder's scaler
30
+ * is rebuilt on a config toggle, or the source renegotiates). The slot is
31
+ * sized for the **first** frame's geometry. A later frame that no longer fits
32
+ * the slot triggers a **segment re-create**: the old segment is closed +
33
+ * unlinked and a fresh, larger segment is created under a new generation-tagged
34
+ * name. This is simpler and leak-free versus over-allocating slots for a
35
+ * worst-case 4K frame on every stream; resolution changes on a live camera are
36
+ * rare, and a brief gap while consumers re-open the segment is acceptable
37
+ * (latest-wins — a missed frame is correct behaviour).
38
+ */
39
+ /**
40
+ * Per-ring shared-memory budget (MB) — the slot count is derived per-resolution
41
+ * from this budget via {@link deriveSlotCount}, so a 360p stream gets many
42
+ * slots and a 4K stream a few, both inside the same memory footprint.
43
+ *
44
+ * Read ONCE at module load from `CAMSTACK_SHM_RING_BUDGET_MB`; a non-finite or
45
+ * non-positive value falls back to the 128 MB default.
46
+ */
47
+ var RING_BUDGET_MB = (() => {
48
+ const raw = Number(process.env["CAMSTACK_SHM_RING_BUDGET_MB"]);
49
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 128;
7
50
  })();
8
- const RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
51
+ /** {@link RING_BUDGET_MB} in bytes the budget passed to `deriveSlotCount`. */
52
+ var RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
53
+ /** A unique, stable shared-memory segment name for a decoder stream.
54
+ *
55
+ * macOS POSIX shm names are capped at ~31 characters (`PSHMNAMLEN`). A
56
+ * `camstack.frames.<deviceId>.<streamId>` scheme overflows that for realistic
57
+ * ids, so the sink uses a short, collision-resistant scheme instead:
58
+ * `csf.<base36 hash>.<gen>`. The hash folds the device id, the session tag
59
+ * and a per-process random salt; the generation suffix makes a re-created
60
+ * segment (resolution change) a distinct name so a stale consumer mapping is
61
+ * never silently reused.
62
+ */
9
63
  function makeSegmentName(seed, generation) {
10
- let hash = 5381;
11
- for (let i = 0; i < seed.length; i += 1) {
12
- hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
13
- }
14
- const tag = (hash >>> 0).toString(36);
15
- return `csf.${tag}.${generation}`;
16
- }
17
- class DecoderFrameRingSink {
18
- seed;
19
- logger;
20
- nodeId;
21
- segment = null;
22
- writer = null;
23
- segmentName = null;
24
- slotByteLength = 0;
25
- generation = 0;
26
- destroyed = false;
27
- /** Frames committed into the ring across this sink's lifetime (all generations). */
28
- framesWritten = 0;
29
- constructor(options) {
30
- const salt = Math.random().toString(36).slice(2, 8);
31
- this.seed = `${options.seed}.${salt}`;
32
- this.logger = options.logger;
33
- this.nodeId = options.nodeId;
34
- }
35
- /** Whether a segment has been created (i.e. at least one frame written). */
36
- get isArmed() {
37
- return this.writer !== null;
38
- }
39
- /** The current segment name, or `null` before the first frame. */
40
- get currentSegmentName() {
41
- return this.segmentName;
42
- }
43
- /**
44
- * Write one decoded frame into the ring and return its `FrameHandle`.
45
- *
46
- * On the first call (or after a geometry change that overflows the current
47
- * slot) the segment is created / re-created sized for this frame. Returns
48
- * `null` only when the sink has been destroyed.
49
- *
50
- * This is the copy-in convenience form (it copies `pixels` into the slot).
51
- * The decoder's hot path uses the zero-copy {@link beginFrame} /
52
- * {@link commitFrame} scatter-write pair instead — the scaler produces its
53
- * packed output directly into the slot, eliminating the write-side memcpy.
54
- */
55
- writeFrame(pixels, meta) {
56
- if (this.destroyed) return null;
57
- if (this.writer === null || computeSlotByteLength(meta.width, meta.height, meta.format) > this.slotByteLength) {
58
- this.recreateSegment(
59
- computeSlotByteLength(meta.width, meta.height, meta.format)
60
- );
61
- }
62
- const writer = this.writer;
63
- if (writer === null) {
64
- return null;
65
- }
66
- const handle = writer.writeFrame(pixels, meta);
67
- this.framesWritten += 1;
68
- return handle;
69
- }
70
- /**
71
- * Reserve a ring slot for a frame of the given geometry — the **zero-copy**
72
- * scatter-write entry point (Phase 5 / D9 Task 7c).
73
- *
74
- * The segment is created / re-created here if this is the first frame or the
75
- * geometry overflows the current slot capacity, so the slot is correctly
76
- * sized before the caller fills it. The returned `buffer` is a writable view
77
- * **directly over the mapped segment** — the node-av scaler scatters its
78
- * packed output straight into it, with no intermediate copy. The caller MUST
79
- * call {@link commitFrame} with the returned `slot` once the slot is filled.
80
- *
81
- * Returns `null` when the sink is destroyed or the segment cannot be created.
82
- */
83
- beginFrame(width, height, format) {
84
- if (this.destroyed) return null;
85
- const requiredSlotBytes = computeSlotByteLength(width, height, format);
86
- if (this.writer === null || requiredSlotBytes > this.slotByteLength) {
87
- this.recreateSegment(requiredSlotBytes);
88
- }
89
- const writer = this.writer;
90
- if (writer === null) {
91
- return null;
92
- }
93
- const { slot, buffer } = writer.beginFrame();
94
- return { slot, buffer };
95
- }
96
- /**
97
- * Publish the frame whose slot was reserved by {@link beginFrame} and filled
98
- * in place by the caller. `slot` MUST be the value from the matching
99
- * `beginFrame`. Returns the published `FrameHandle`, or `null` if the sink
100
- * was destroyed (or the segment lost) between begin and commit.
101
- */
102
- commitFrame(slot, meta) {
103
- if (this.destroyed) return null;
104
- const writer = this.writer;
105
- if (writer === null) return null;
106
- const handle = writer.commitFrame(slot, meta);
107
- this.framesWritten += 1;
108
- return handle;
109
- }
110
- /**
111
- * Current shm ring usage — `null` until the first frame arms the segment.
112
- * Surfaced through `decoder.getShmStats` so a downstream consumer can
113
- * observe ring pressure (slot depth, byte budget, frames written).
114
- */
115
- getShmStats() {
116
- if (this.writer === null) return null;
117
- return {
118
- slotCount: this.writer.slotCount,
119
- slotByteLength: this.slotByteLength,
120
- segmentBytes: computeSegmentSize(this.writer.slotCount, this.slotByteLength),
121
- framesWritten: this.framesWritten
122
- };
123
- }
124
- /**
125
- * Abandon a slot reserved by {@link beginFrame} **without publishing it** —
126
- * the degenerate-path counterpart of {@link commitFrame}.
127
- *
128
- * A caller that reserved a slot but then could not produce valid pixels (no
129
- * decoded source planes, or the scaler threw) MUST call this instead of
130
- * `commitFrame`: it closes the open seqlock without advancing `writeIndex`,
131
- * so no reader ever sees the slot's uninitialised bytes as a real frame, and
132
- * no `FrameHandle` is handed downstream. `slot` MUST be the value from the
133
- * matching `beginFrame`. A no-op if the sink was destroyed (or the segment
134
- * lost) between begin and abort.
135
- */
136
- abortFrame(slot) {
137
- if (this.destroyed) return;
138
- const writer = this.writer;
139
- if (writer === null) return;
140
- writer.abortFrame(slot);
141
- }
142
- /** Close + unlink the segment. Idempotent. */
143
- destroy() {
144
- if (this.destroyed) return;
145
- this.destroyed = true;
146
- this.releaseSegment();
147
- }
148
- /**
149
- * Create a fresh segment sized for at least `slotByteLength` bytes per slot,
150
- * replacing any prior one. A re-create bumps the generation so the new
151
- * segment has a distinct name — a consumer holding the old mapping is never
152
- * silently handed a resized segment.
153
- */
154
- recreateSegment(slotByteLength) {
155
- this.releaseSegment();
156
- this.generation += 1;
157
- const name = makeSegmentName(this.seed, this.generation);
158
- const slotCount = deriveSlotCount(RING_BUDGET_BYTES, slotByteLength);
159
- if (slotCount === MIN_RING_SLOTS && MIN_RING_SLOTS * slotByteLength > RING_BUDGET_BYTES) {
160
- this.logger.warn(
161
- "decoder shm ring: budget too small for resolution — using MIN slots",
162
- { meta: { slotByteLength, budgetMb: RING_BUDGET_MB } }
163
- );
164
- }
165
- const totalBytes = computeSegmentSize(slotCount, slotByteLength);
166
- try {
167
- const segment = createSegment(name, totalBytes);
168
- this.segment = segment;
169
- this.segmentName = name;
170
- this.slotByteLength = slotByteLength;
171
- this.writer = new FrameRingWriter(
172
- segment.buffer,
173
- name,
174
- slotCount,
175
- slotByteLength,
176
- this.nodeId
177
- );
178
- this.logger.info("decoder shm ring: segment created", {
179
- meta: {
180
- segment: name,
181
- slotCount,
182
- slotByteLength,
183
- totalBytes,
184
- generation: this.generation
185
- }
186
- });
187
- } catch (err) {
188
- this.segment = null;
189
- this.writer = null;
190
- this.segmentName = null;
191
- this.slotByteLength = 0;
192
- this.logger.error("decoder shm ring: segment create failed", {
193
- meta: {
194
- segment: name,
195
- slotByteLength,
196
- error: err instanceof Error ? err.message : String(err)
197
- }
198
- });
199
- }
200
- }
201
- /** Unmap + unlink the current segment, if any. */
202
- releaseSegment() {
203
- const segment = this.segment;
204
- if (segment === null) return;
205
- this.segment = null;
206
- this.writer = null;
207
- const name = this.segmentName;
208
- this.segmentName = null;
209
- try {
210
- segment.close();
211
- segment.unlink();
212
- this.logger.info("decoder shm ring: segment released", {
213
- meta: { segment: name }
214
- });
215
- } catch (err) {
216
- this.logger.warn("decoder shm ring: segment release failed", {
217
- meta: {
218
- segment: name,
219
- error: err instanceof Error ? err.message : String(err)
220
- }
221
- });
222
- }
223
- }
64
+ let hash = 5381;
65
+ for (let i = 0; i < seed.length; i += 1) hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
66
+ return `csf.${(hash >>> 0).toString(36)}.${generation}`;
224
67
  }
68
+ /**
69
+ * The decoder-side owner of one stream's shared-memory frame ring.
70
+ *
71
+ * Not constructed until a session actually uses the shm sink; the segment
72
+ * itself is created lazily on the first `writeFrame`.
73
+ */
74
+ var DecoderFrameRingSink = class {
75
+ seed;
76
+ logger;
77
+ nodeId;
78
+ segment = null;
79
+ writer = null;
80
+ segmentName = null;
81
+ slotByteLength = 0;
82
+ generation = 0;
83
+ destroyed = false;
84
+ /** Frames committed into the ring across this sink's lifetime (all generations). */
85
+ framesWritten = 0;
86
+ constructor(options) {
87
+ const salt = Math.random().toString(36).slice(2, 8);
88
+ this.seed = `${options.seed}.${salt}`;
89
+ this.logger = options.logger;
90
+ this.nodeId = options.nodeId;
91
+ }
92
+ /** Whether a segment has been created (i.e. at least one frame written). */
93
+ get isArmed() {
94
+ return this.writer !== null;
95
+ }
96
+ /** The current segment name, or `null` before the first frame. */
97
+ get currentSegmentName() {
98
+ return this.segmentName;
99
+ }
100
+ /**
101
+ * Write one decoded frame into the ring and return its `FrameHandle`.
102
+ *
103
+ * On the first call (or after a geometry change that overflows the current
104
+ * slot) the segment is created / re-created sized for this frame. Returns
105
+ * `null` only when the sink has been destroyed.
106
+ *
107
+ * This is the copy-in convenience form (it copies `pixels` into the slot).
108
+ * The decoder's hot path uses the zero-copy {@link beginFrame} /
109
+ * {@link commitFrame} scatter-write pair instead — the scaler produces its
110
+ * packed output directly into the slot, eliminating the write-side memcpy.
111
+ */
112
+ writeFrame(pixels, meta) {
113
+ if (this.destroyed) return null;
114
+ if (this.writer === null || computeSlotByteLength(meta.width, meta.height, meta.format) > this.slotByteLength) this.recreateSegment(computeSlotByteLength(meta.width, meta.height, meta.format));
115
+ const writer = this.writer;
116
+ if (writer === null) return null;
117
+ const handle = writer.writeFrame(pixels, meta);
118
+ this.framesWritten += 1;
119
+ return handle;
120
+ }
121
+ /**
122
+ * Reserve a ring slot for a frame of the given geometry — the **zero-copy**
123
+ * scatter-write entry point (Phase 5 / D9 Task 7c).
124
+ *
125
+ * The segment is created / re-created here if this is the first frame or the
126
+ * geometry overflows the current slot capacity, so the slot is correctly
127
+ * sized before the caller fills it. The returned `buffer` is a writable view
128
+ * **directly over the mapped segment** — the node-av scaler scatters its
129
+ * packed output straight into it, with no intermediate copy. The caller MUST
130
+ * call {@link commitFrame} with the returned `slot` once the slot is filled.
131
+ *
132
+ * Returns `null` when the sink is destroyed or the segment cannot be created.
133
+ */
134
+ beginFrame(width, height, format) {
135
+ if (this.destroyed) return null;
136
+ const requiredSlotBytes = computeSlotByteLength(width, height, format);
137
+ if (this.writer === null || requiredSlotBytes > this.slotByteLength) this.recreateSegment(requiredSlotBytes);
138
+ const writer = this.writer;
139
+ if (writer === null) return null;
140
+ const { slot, buffer } = writer.beginFrame();
141
+ return {
142
+ slot,
143
+ buffer
144
+ };
145
+ }
146
+ /**
147
+ * Publish the frame whose slot was reserved by {@link beginFrame} and filled
148
+ * in place by the caller. `slot` MUST be the value from the matching
149
+ * `beginFrame`. Returns the published `FrameHandle`, or `null` if the sink
150
+ * was destroyed (or the segment lost) between begin and commit.
151
+ */
152
+ commitFrame(slot, meta) {
153
+ if (this.destroyed) return null;
154
+ const writer = this.writer;
155
+ if (writer === null) return null;
156
+ const handle = writer.commitFrame(slot, meta);
157
+ this.framesWritten += 1;
158
+ return handle;
159
+ }
160
+ /**
161
+ * Current shm ring usage — `null` until the first frame arms the segment.
162
+ * Surfaced through `decoder.getShmStats` so a downstream consumer can
163
+ * observe ring pressure (slot depth, byte budget, frames written).
164
+ */
165
+ getShmStats() {
166
+ if (this.writer === null) return null;
167
+ return {
168
+ slotCount: this.writer.slotCount,
169
+ slotByteLength: this.slotByteLength,
170
+ segmentBytes: computeSegmentSize(this.writer.slotCount, this.slotByteLength),
171
+ framesWritten: this.framesWritten
172
+ };
173
+ }
174
+ /**
175
+ * Abandon a slot reserved by {@link beginFrame} **without publishing it** —
176
+ * the degenerate-path counterpart of {@link commitFrame}.
177
+ *
178
+ * A caller that reserved a slot but then could not produce valid pixels (no
179
+ * decoded source planes, or the scaler threw) MUST call this instead of
180
+ * `commitFrame`: it closes the open seqlock without advancing `writeIndex`,
181
+ * so no reader ever sees the slot's uninitialised bytes as a real frame, and
182
+ * no `FrameHandle` is handed downstream. `slot` MUST be the value from the
183
+ * matching `beginFrame`. A no-op if the sink was destroyed (or the segment
184
+ * lost) between begin and abort.
185
+ */
186
+ abortFrame(slot) {
187
+ if (this.destroyed) return;
188
+ const writer = this.writer;
189
+ if (writer === null) return;
190
+ writer.abortFrame(slot);
191
+ }
192
+ /** Close + unlink the segment. Idempotent. */
193
+ destroy() {
194
+ if (this.destroyed) return;
195
+ this.destroyed = true;
196
+ this.releaseSegment();
197
+ }
198
+ /**
199
+ * Create a fresh segment sized for at least `slotByteLength` bytes per slot,
200
+ * replacing any prior one. A re-create bumps the generation so the new
201
+ * segment has a distinct name — a consumer holding the old mapping is never
202
+ * silently handed a resized segment.
203
+ */
204
+ recreateSegment(slotByteLength) {
205
+ this.releaseSegment();
206
+ this.generation += 1;
207
+ const name = makeSegmentName(this.seed, this.generation);
208
+ const slotCount = deriveSlotCount(RING_BUDGET_BYTES, slotByteLength);
209
+ 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: {
210
+ slotByteLength,
211
+ budgetMb: RING_BUDGET_MB
212
+ } });
213
+ const totalBytes = computeSegmentSize(slotCount, slotByteLength);
214
+ try {
215
+ const segment = createSegment(name, totalBytes);
216
+ this.segment = segment;
217
+ this.segmentName = name;
218
+ this.slotByteLength = slotByteLength;
219
+ this.writer = new FrameRingWriter(segment.buffer, name, slotCount, slotByteLength, this.nodeId);
220
+ this.logger.info("decoder shm ring: segment created", { meta: {
221
+ segment: name,
222
+ slotCount,
223
+ slotByteLength,
224
+ totalBytes,
225
+ generation: this.generation
226
+ } });
227
+ } catch (err) {
228
+ this.segment = null;
229
+ this.writer = null;
230
+ this.segmentName = null;
231
+ this.slotByteLength = 0;
232
+ this.logger.error("decoder shm ring: segment create failed", { meta: {
233
+ segment: name,
234
+ slotByteLength,
235
+ error: err instanceof Error ? err.message : String(err)
236
+ } });
237
+ }
238
+ }
239
+ /** Unmap + unlink the current segment, if any. */
240
+ releaseSegment() {
241
+ const segment = this.segment;
242
+ if (segment === null) return;
243
+ this.segment = null;
244
+ this.writer = null;
245
+ const name = this.segmentName;
246
+ this.segmentName = null;
247
+ try {
248
+ segment.close();
249
+ segment.unlink();
250
+ this.logger.info("decoder shm ring: segment released", { meta: { segment: name } });
251
+ } catch (err) {
252
+ this.logger.warn("decoder shm ring: segment release failed", { meta: {
253
+ segment: name,
254
+ error: err instanceof Error ? err.message : String(err)
255
+ } });
256
+ }
257
+ }
258
+ };
259
+ //#endregion
260
+ //#region src/decoder-nodeav/nodeav-decoder-session.ts
261
+ /** Map our canonical backend name to the node-av `AV_HWDEVICE_TYPE_*` constant. */
225
262
  function backendToHwDeviceConst(backend, consts) {
226
- switch (backend) {
227
- case "videotoolbox":
228
- return consts.AV_HWDEVICE_TYPE_VIDEOTOOLBOX;
229
- case "cuda":
230
- return consts.AV_HWDEVICE_TYPE_CUDA;
231
- case "nvdec":
232
- return consts.AV_HWDEVICE_TYPE_CUDA;
233
- // node-av exposes only CUDA; nvdec aliases to it
234
- case "vaapi":
235
- return consts.AV_HWDEVICE_TYPE_VAAPI;
236
- case "qsv":
237
- return consts.AV_HWDEVICE_TYPE_QSV;
238
- case "d3d11va":
239
- return consts.AV_HWDEVICE_TYPE_D3D11VA;
240
- case "dxva2":
241
- return consts.AV_HWDEVICE_TYPE_DXVA2;
242
- case "amf":
243
- return consts.AV_HWDEVICE_TYPE_AMF;
244
- case "vdpau":
245
- return consts.AV_HWDEVICE_TYPE_VDPAU;
246
- case "drm":
247
- return consts.AV_HWDEVICE_TYPE_DRM;
248
- default:
249
- return null;
250
- }
263
+ switch (backend) {
264
+ case "videotoolbox": return consts.AV_HWDEVICE_TYPE_VIDEOTOOLBOX;
265
+ case "cuda": return consts.AV_HWDEVICE_TYPE_CUDA;
266
+ case "nvdec": return consts.AV_HWDEVICE_TYPE_CUDA;
267
+ case "vaapi": return consts.AV_HWDEVICE_TYPE_VAAPI;
268
+ case "qsv": return consts.AV_HWDEVICE_TYPE_QSV;
269
+ case "d3d11va": return consts.AV_HWDEVICE_TYPE_D3D11VA;
270
+ case "dxva2": return consts.AV_HWDEVICE_TYPE_DXVA2;
271
+ case "amf": return consts.AV_HWDEVICE_TYPE_AMF;
272
+ case "vdpau": return consts.AV_HWDEVICE_TYPE_VDPAU;
273
+ case "drm": return consts.AV_HWDEVICE_TYPE_DRM;
274
+ default: return null;
275
+ }
251
276
  }
277
+ /** Pick the hw pixel format for a given backend — used in get_format callback. */
252
278
  function backendToHwPixFmt(backend, consts) {
253
- switch (backend) {
254
- case "videotoolbox":
255
- return consts.AV_PIX_FMT_VIDEOTOOLBOX;
256
- case "cuda":
257
- return consts.AV_PIX_FMT_CUDA;
258
- case "nvdec":
259
- return consts.AV_PIX_FMT_CUDA;
260
- case "vaapi":
261
- return consts.AV_PIX_FMT_VAAPI;
262
- case "qsv":
263
- return consts.AV_PIX_FMT_QSV;
264
- case "d3d11va":
265
- return consts.AV_PIX_FMT_D3D11;
266
- case "dxva2":
267
- return consts.AV_PIX_FMT_DXVA2_VLD;
268
- case "amf":
269
- return consts.AV_PIX_FMT_D3D11;
270
- case "vdpau":
271
- return consts.AV_PIX_FMT_VDPAU;
272
- case "drm":
273
- return consts.AV_PIX_FMT_DRM_PRIME;
274
- default:
275
- return null;
276
- }
279
+ switch (backend) {
280
+ case "videotoolbox": return consts.AV_PIX_FMT_VIDEOTOOLBOX;
281
+ case "cuda": return consts.AV_PIX_FMT_CUDA;
282
+ case "nvdec": return consts.AV_PIX_FMT_CUDA;
283
+ case "vaapi": return consts.AV_PIX_FMT_VAAPI;
284
+ case "qsv": return consts.AV_PIX_FMT_QSV;
285
+ case "d3d11va": return consts.AV_PIX_FMT_D3D11;
286
+ case "dxva2": return consts.AV_PIX_FMT_DXVA2_VLD;
287
+ case "amf": return consts.AV_PIX_FMT_D3D11;
288
+ case "vdpau": return consts.AV_PIX_FMT_VDPAU;
289
+ case "drm": return consts.AV_PIX_FMT_DRM_PRIME;
290
+ default: return null;
291
+ }
277
292
  }
278
- let _nav = null;
279
- let _consts = null;
280
- let _sharp = null;
293
+ var _nav = null;
294
+ var _consts = null;
295
+ var _sharp = null;
281
296
  async function getNodeAv() {
282
- if (!_nav) _nav = await import("node-av");
283
- return _nav;
297
+ if (!_nav) _nav = await import("node-av");
298
+ return _nav;
284
299
  }
285
300
  async function getConstants() {
286
- if (!_consts) _consts = await import("../index-CYb7cFrv.mjs");
287
- return _consts;
301
+ if (!_consts) _consts = await import("../constants-B_b0a-6h.mjs");
302
+ return _consts;
288
303
  }
289
304
  async function getSharp() {
290
- if (!_sharp) {
291
- const mod = await import("sharp");
292
- _sharp = mod.default;
293
- }
294
- return _sharp;
305
+ if (!_sharp) _sharp = (await import("sharp")).default;
306
+ return _sharp;
295
307
  }
296
- const noopLogger = {
297
- debug() {
298
- },
299
- info() {
300
- },
301
- warn() {
302
- },
303
- error() {
304
- },
305
- child() {
306
- return noopLogger;
307
- },
308
- withTags() {
309
- return noopLogger;
310
- }
308
+ var noopLogger = {
309
+ debug() {},
310
+ info() {},
311
+ warn() {},
312
+ error() {},
313
+ child() {
314
+ return noopLogger;
315
+ },
316
+ withTags() {
317
+ return noopLogger;
318
+ }
311
319
  };
312
- class NodeAvDecoderSession {
313
- config;
314
- logger;
315
- frameCallbacks = /* @__PURE__ */ new Set();
316
- handleCallbacks = /* @__PURE__ */ new Set();
317
- destroyed = false;
318
- /**
319
- * Frame delivery mode (see {@link DecoderFrameSink}). `'shm'` lazily
320
- * constructs `frameRingSink` and routes every decoded frame through it.
321
- */
322
- frameSink;
323
- /**
324
- * The shared-memory ring writer for this stream. Created lazily on the
325
- * first decoded frame in `'shm'` mode (the segment cannot be sized until
326
- * the output geometry is known) and torn down in `destroy`.
327
- */
328
- frameRingSink = null;
329
- // Low-level node-av objects (initialized on first keyframe)
330
- parser = null;
331
- codecCtx = null;
332
- scaler = null;
333
- avPacket = null;
334
- avFrame = null;
335
- dstFrame = null;
336
- // Cached constants (loaded during init, used in hot path)
337
- // Hard-coded numeric defaults are replaced in initDecoder() with the real
338
- // branded constants from `node-av/constants`. The `as` cast at the literal
339
- // is a one-time branding bridge — once initDecoder runs we hold the exact
340
- // AVPixelFormat/SwsFlags/AVError values.
341
- EAGAIN = -11;
342
- PIX_FMT_GRAY8 = 8;
343
- PIX_FMT_RGB24 = 2;
344
- SWS_FAST_BILINEAR = 1;
345
- /**
346
- * Decoder output mode. Drives both the scaler's destination pixel
347
- * format and whether sharp runs the JPEG encode at the end:
348
- *
349
- * - `'jpeg'` scaler→RGB24 sharp encode emit JPEG bytes
350
- * - `'rgb'` — scaler→RGB24 emit raw RGB24 (no sharp)
351
- * - `'gray'` — scaler→GRAY8 → emit raw GRAY8 (no sharp)
352
- *
353
- * The broker holds the policy decision on which mode to request based
354
- * on its active subscribers; on-the-fly conversion (e.g. RGB→JPEG for
355
- * a WebRTC consumer that joined while detection holds the decoder in
356
- * RGB mode) happens broker-side via the per-frame conversion cache.
357
- */
358
- outputMode;
359
- sharpFn = null;
360
- /**
361
- * Backpressure for the sharp JPEG encode pipeline. The broker
362
- * currently creates sessions with `maxFps: 0` (unlimited) and relies
363
- * on per-subscriber throttling, so without a bound the
364
- * fire-and-forget `sharp(...).toBuffer()` chain would accumulate
365
- * unboundedly whenever sharp falls behind the decoder. Cap at
366
- * `MAX_JPEG_INFLIGHT` pending encodes per session any frame that
367
- * arrives while the cap is saturated is dropped and counted.
368
- */
369
- static MAX_JPEG_INFLIGHT = 2;
370
- jpegEncodeInFlight = 0;
371
- /**
372
- * Map a `DecoderSessionConfig.outputFormat` value to one of the three
373
- * native scaler/encoder modes the session understands. The cap-level
374
- * format vocabulary is broader (it accepts `bgr`, `yuv420`) than what
375
- * libav's scaler is wired for here — anything else degrades to RGB
376
- * (the canonical raw mode) and the broker is expected to convert
377
- * downstream if a subscriber needs a different shape.
378
- */
379
- static resolveOutputMode(format) {
380
- if (format === "jpeg" || format === void 0) return "jpeg";
381
- if (format === "gray") return "gray";
382
- return "rgb";
383
- }
384
- initialized = false;
385
- initializing = false;
386
- scalerInitializing = false;
387
- /**
388
- * Monotonic counter incremented by `updateConfig` whenever the
389
- * scaler + dstFrame get invalidated (e.g. output format toggle).
390
- * `initScaler` captures the current value at entry and aborts — or
391
- * disposes the locally-built scaler — if the epoch moved while
392
- * its async init was in flight. Without this, a toggle racing an
393
- * in-flight init could leave two scalers allocated natively while
394
- * `this.scaler` only holds a reference to one libav leak.
395
- */
396
- scalerEpoch = 0;
397
- /**
398
- * One-shot guard for the "first frame" diagnostic log + raw frame
399
- * dump. Setting this synchronously inside `emitDecodedFrame`
400
- * prevents re-entry — without it we were using `outputFrames === 0`
401
- * which stays true until the async sharp encode callback runs, so
402
- * several decoded frames could trigger the dump before the first
403
- * JPEG landed.
404
- */
405
- firstFrameLogged = false;
406
- // Output dimensions
407
- outWidth = 0;
408
- outHeight = 0;
409
- // FPS limiter
410
- lastEmitTime = 0;
411
- minIntervalMs;
412
- // Stats
413
- inputPackets = 0;
414
- outputFrames = 0;
415
- droppedFrames = 0;
416
- totalDecodeTimeMs = 0;
417
- startTime = Date.now();
418
- hwaccelPref;
419
- hwaccelResolver;
420
- /** Cluster node id stamped into every `FrameHandle` the `'shm'` sink emits. */
421
- nodeId;
422
- /** The backend that actually initialised successfully `'none'` = software fallback. */
423
- activeHwAccel = "none";
424
- hwDevice = null;
425
- swTransferFrame = null;
426
- constructor(config, logger = noopLogger, options) {
427
- this.config = { ...config };
428
- const sessionTags = {};
429
- if (typeof config.deviceId === "number") sessionTags["deviceId"] = config.deviceId;
430
- if (typeof config.tag === "string" && config.tag.length > 0) sessionTags["tag"] = config.tag;
431
- this.logger = Object.keys(sessionTags).length > 0 ? logger.withTags(sessionTags) : logger;
432
- this.minIntervalMs = config.maxFps > 0 ? 1e3 / config.maxFps : 0;
433
- this.outputMode = NodeAvDecoderSession.resolveOutputMode(config.outputFormat);
434
- this.hwaccelPref = options?.hwaccel ?? "auto";
435
- this.hwaccelResolver = options?.hwaccelResolver ?? null;
436
- this.frameSink = options?.frameSink ?? "callback";
437
- this.nodeId = options?.nodeId ?? "local";
438
- }
439
- /**
440
- * The shared-memory ring sink for this stream, or `null` before the first
441
- * `'shm'`-mode frame lazily creates it. Exposed so the owning addon can
442
- * surface ring stats via `decoder.getShmStats`.
443
- */
444
- get frameRingSinkOrNull() {
445
- return this.frameRingSink;
446
- }
447
- /**
448
- * Lazily build the shared-memory ring sink for this stream. The segment
449
- * itself is still created lazily by the sink on its first `writeFrame`,
450
- * once the decoded geometry is known.
451
- */
452
- ensureFrameRingSink() {
453
- if (this.frameRingSink === null) {
454
- const seedParts = [];
455
- if (typeof this.config.deviceId === "number") {
456
- seedParts.push(String(this.config.deviceId));
457
- }
458
- if (typeof this.config.tag === "string" && this.config.tag.length > 0) {
459
- seedParts.push(this.config.tag);
460
- }
461
- const seed = seedParts.length > 0 ? seedParts.join(":") : "anon";
462
- this.frameRingSink = new DecoderFrameRingSink({
463
- seed,
464
- logger: this.logger,
465
- nodeId: this.nodeId
466
- });
467
- }
468
- return this.frameRingSink;
469
- }
470
- /**
471
- * Resolve the backend preference list and try each one against
472
- * node-av's HW context APIs. The first backend whose
473
- * `HardwareDeviceContext.create()` succeeds gets attached to
474
- * `codecCtx.hwDeviceCtx` + its hw pixel format registered via
475
- * `setHardwarePixelFormat`. On any failure, falls through to the
476
- * next backend; if all fail, returns with `activeHwAccel='none'`
477
- * and the decoder runs in software on the same context.
478
- */
479
- async tryAttachHwAccel(nav, C) {
480
- if (!this.codecCtx) return;
481
- if (this.hwaccelPref === "none") {
482
- this.activeHwAccel = "none";
483
- return;
484
- }
485
- const explicit = this.hwaccelPref === "auto" ? null : this.hwaccelPref;
486
- const resolution = this.hwaccelResolver ? await this.hwaccelResolver.resolve(explicit) : explicit ? { preferred: [explicit], rationale: "explicit (no resolver)" } : { preferred: [], rationale: "auto + no resolver → software" };
487
- if (resolution.preferred.length === 0) {
488
- this.activeHwAccel = "none";
489
- return;
490
- }
491
- for (const backend of resolution.preferred) {
492
- const deviceType = backendToHwDeviceConst(backend, C);
493
- const hwPixFmt = backendToHwPixFmt(backend, C);
494
- if (!deviceType || !hwPixFmt) continue;
495
- const device = new nav.HardwareDeviceContext();
496
- const rc = device.create(deviceType);
497
- if (rc < 0) {
498
- this.logger.warn("node-av: hwaccel device create failed — trying next", {
499
- meta: { backend, rc }
500
- });
501
- device.free();
502
- continue;
503
- }
504
- try {
505
- this.codecCtx.hwDeviceCtx = device;
506
- this.codecCtx.setHardwarePixelFormat(hwPixFmt);
507
- } catch (err) {
508
- this.logger.warn("node-av: hwaccel context attach failed — trying next", {
509
- meta: { backend, error: errMsg(err) }
510
- });
511
- device.free();
512
- continue;
513
- }
514
- this.hwDevice = device;
515
- this.activeHwAccel = backend;
516
- return;
517
- }
518
- this.logger.warn("node-av: no hwaccel backend initialised — using software", {
519
- meta: { attempted: resolution.preferred.join(","), rationale: resolution.rationale }
520
- });
521
- this.activeHwAccel = "none";
522
- }
523
- /**
524
- * Download a HW frame (format == hw pix fmt) into a SW frame so the
525
- * rest of the pipeline (scaler, JPEG encoder, grayscale passthrough)
526
- * handles it identically to the pure-software path. Uses the sync
527
- * variant so the synchronous receive loop below doesn't need to be
528
- * async-ified. Returns `null` on transfer failure, meaning the
529
- * caller should drop the frame.
530
- */
531
- transferHwFrame(hwFrame) {
532
- if (this.activeHwAccel === "none" || !this.swTransferFrame) return hwFrame;
533
- const rc = hwFrame.hwframeTransferDataSync(this.swTransferFrame, 0);
534
- if (rc < 0) {
535
- this.logger.warn("node-av: hwframeTransferData failed", { meta: { rc } });
536
- return null;
537
- }
538
- return this.swTransferFrame;
539
- }
540
- /**
541
- * Initialize the decoder pipeline on the first keyframe.
542
- * After this returns, all hot-path methods are fully synchronous (except JPEG encode).
543
- */
544
- async initDecoder() {
545
- if (this.initialized || this.initializing || this.destroyed) return;
546
- this.initializing = true;
547
- try {
548
- const nav = await getNodeAv();
549
- const C = await getConstants();
550
- this.EAGAIN = C.AVERROR_EAGAIN;
551
- this.PIX_FMT_GRAY8 = C.AV_PIX_FMT_GRAY8;
552
- this.PIX_FMT_RGB24 = C.AV_PIX_FMT_RGB24;
553
- this.SWS_FAST_BILINEAR = C.SWS_FAST_BILINEAR;
554
- if (this.outputMode === "jpeg") {
555
- this.sharpFn = await getSharp();
556
- }
557
- nav.Log.setLevel(C.AV_LOG_FATAL);
558
- const isHevc = this.config.codec === "h265" || this.config.codec === "hevc";
559
- const codecId = isHevc ? C.AV_CODEC_ID_HEVC : C.AV_CODEC_ID_H264;
560
- this.parser = new nav.CodecParser();
561
- this.parser.init(codecId);
562
- const codec = nav.Codec.findDecoder(codecId);
563
- if (!codec) {
564
- this.logger.error("node-av: no decoder found", { meta: { codec: this.config.codec } });
565
- return;
566
- }
567
- this.codecCtx = new nav.CodecContext();
568
- this.codecCtx.allocContext3(codec);
569
- if (this.config.width && this.config.height) {
570
- this.codecCtx.width = this.config.width;
571
- this.codecCtx.height = this.config.height;
572
- }
573
- this.codecCtx.threadCount = 1;
574
- await this.tryAttachHwAccel(nav, C);
575
- const ret = await this.codecCtx.open2(codec);
576
- if (ret < 0) {
577
- if (this.activeHwAccel !== "none") {
578
- this.logger.warn("node-av: open2 failed with hwaccel — retrying in software", {
579
- meta: { ret, hwAccel: this.activeHwAccel }
580
- });
581
- this.hwDevice?.free();
582
- this.hwDevice = null;
583
- this.activeHwAccel = "none";
584
- this.codecCtx = new nav.CodecContext();
585
- this.codecCtx.allocContext3(codec);
586
- if (this.config.width && this.config.height) {
587
- this.codecCtx.width = this.config.width;
588
- this.codecCtx.height = this.config.height;
589
- }
590
- this.codecCtx.threadCount = 1;
591
- const retry = await this.codecCtx.open2(codec);
592
- if (retry < 0) {
593
- this.logger.error("node-av: failed to open decoder (sw fallback)", { meta: { ret: retry } });
594
- return;
595
- }
596
- } else {
597
- this.logger.error("node-av: failed to open decoder", { meta: { ret } });
598
- return;
599
- }
600
- }
601
- if (this.activeHwAccel !== "none") {
602
- this.swTransferFrame = new nav.Frame();
603
- this.swTransferFrame.alloc();
604
- }
605
- this.avPacket = new nav.Packet();
606
- this.avPacket.alloc();
607
- this.avFrame = new nav.Frame();
608
- this.avFrame.alloc();
609
- this.initialized = true;
610
- this.logger.info("node-av push decoder initialized", {
611
- meta: {
612
- codec: this.config.codec,
613
- output: this.outputMode,
614
- // Reports the backend that actually succeeded at
615
- // `open2(codec)` with `hwDeviceCtx` attached, or `'none'` if
616
- // we fell back to software (explicit `hwaccel: 'none'`
617
- // override, empty resolver output, or every attempted
618
- // backend failed init).
619
- hwAccel: this.activeHwAccel
620
- }
621
- });
622
- } catch (err) {
623
- this.logger.error("node-av init error", { meta: { error: errMsg(err) } });
624
- } finally {
625
- this.initializing = false;
626
- }
627
- }
628
- /**
629
- * Initialize the scaler after the first frame tells us the actual
630
- * dimensions. Output pixel format: RGB24 for JPEG encoding, GRAY8
631
- * for raw motion.
632
- *
633
- * Builds `scaler` + `dstFrame` on local variables and publishes
634
- * them onto `this` in a single atomic step at the end. Captures
635
- * `scalerEpoch` at entry; if `updateConfig` invalidated the scaler
636
- * while this init was in flight (epoch mismatch), the locally
637
- * built pair is disposed and discarded so the later init wins.
638
- * Without the local-first approach, partial state (scaler set,
639
- * dstFrame still null) could be observed by a concurrent
640
- * `emitDecodedFrame` call.
641
- */
642
- async initScaler(srcW, srcH, srcFmt) {
643
- if (this.scalerInitializing) return;
644
- this.scalerInitializing = true;
645
- const myEpoch = this.scalerEpoch;
646
- let localScaler = null;
647
- let localDstFrame = null;
648
- try {
649
- const nav = await getNodeAv();
650
- if (this.destroyed || myEpoch !== this.scalerEpoch) return;
651
- const scale = this.config.scale > 1 ? this.config.scale : 1;
652
- const maxW = Math.floor(640 / scale);
653
- const outWidth = Math.min(srcW, maxW);
654
- const outHeight = Math.round(outWidth * srcH / srcW);
655
- const dstFmt = this.outputMode === "gray" ? this.PIX_FMT_GRAY8 : this.PIX_FMT_RGB24;
656
- const fmtName = this.outputMode === "gray" ? "gray8" : "rgb24";
657
- localScaler = new nav.SoftwareScaleContext();
658
- localScaler.getContext(
659
- srcW,
660
- srcH,
661
- srcFmt,
662
- outWidth,
663
- outHeight,
664
- dstFmt,
665
- this.SWS_FAST_BILINEAR
666
- );
667
- const ret = localScaler.initContext();
668
- if (ret < 0) {
669
- this.logger.error("node-av: sws_init_context failed", { meta: { ret } });
670
- return;
671
- }
672
- localDstFrame = new nav.Frame();
673
- localDstFrame.alloc();
674
- localDstFrame.width = outWidth;
675
- localDstFrame.height = outHeight;
676
- localDstFrame.format = dstFmt;
677
- const allocRet = localDstFrame.allocBuffer();
678
- if (allocRet < 0) {
679
- this.logger.error("node-av: dst frame allocBuffer failed", { meta: { ret: allocRet } });
680
- return;
681
- }
682
- if (this.destroyed || myEpoch !== this.scalerEpoch) return;
683
- this.scaler?.[Symbol.dispose]?.();
684
- this.dstFrame?.[Symbol.dispose]?.();
685
- this.scaler = localScaler;
686
- this.dstFrame = localDstFrame;
687
- this.outWidth = outWidth;
688
- this.outHeight = outHeight;
689
- localScaler = null;
690
- localDstFrame = null;
691
- this.logger.info("node-av scaler initialized", {
692
- meta: { srcWidth: srcW, srcHeight: srcH, outWidth, outHeight, format: fmtName }
693
- });
694
- } catch (err) {
695
- this.logger.error("Scaler init failed", { meta: { error: errMsg(err) } });
696
- } finally {
697
- localScaler?.[Symbol.dispose]?.();
698
- localDstFrame?.[Symbol.dispose]?.();
699
- this.scalerInitializing = false;
700
- }
701
- }
702
- pushPacket(packet) {
703
- if (this.destroyed) return;
704
- this.inputPackets++;
705
- if (!this.initialized && !this.initializing && packet.keyframe) {
706
- this.initDecoder().then(() => {
707
- if (this.initialized) this.decodeRawData(packet.data, packet.pts ?? Date.now());
708
- }).catch((err) => {
709
- this.logger.error("node-av init failed", { meta: { error: errMsg(err) } });
710
- });
711
- return;
712
- }
713
- if (!this.initialized) return;
714
- this.decodeRawData(packet.data, packet.pts ?? Date.now());
715
- }
716
- decodeRawData(data, pts) {
717
- if (!this.parser || !this.codecCtx || !this.avPacket || !this.avFrame) return;
718
- const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
719
- const bigPts = BigInt(pts);
720
- let offset = 0;
721
- while (offset < buf.length) {
722
- const remaining = buf.subarray(offset);
723
- const consumed = this.parser.parse2(
724
- this.codecCtx,
725
- this.avPacket,
726
- remaining,
727
- bigPts,
728
- bigPts,
729
- offset
730
- );
731
- if (consumed < 0) {
732
- this.logger.warn("node-av parser error", { meta: { ret: consumed } });
733
- break;
734
- }
735
- offset += consumed;
736
- if (this.avPacket.size > 0) {
737
- this.decodePacket();
738
- this.avPacket.unref();
739
- }
740
- }
741
- }
742
- decodePacket() {
743
- if (!this.codecCtx || !this.avFrame) return;
744
- const sendRet = this.codecCtx.sendPacketSync(this.avPacket);
745
- if (sendRet < 0 && sendRet !== this.EAGAIN) {
746
- this.logger.warn("node-av sendPacket error", { meta: { ret: sendRet } });
747
- return;
748
- }
749
- while (true) {
750
- const recvRet = this.codecCtx.receiveFrameSync(this.avFrame);
751
- if (recvRet < 0) break;
752
- const frame = this.transferHwFrame(this.avFrame);
753
- if (!frame) continue;
754
- this.emitDecodedFrame(frame);
755
- }
756
- }
757
- emitDecodedFrame(frame) {
758
- const now = performance.now();
759
- if (this.minIntervalMs > 0 && now - this.lastEmitTime < this.minIntervalMs) {
760
- this.droppedFrames++;
761
- return;
762
- }
763
- if (!this.scaler && !this.scalerInitializing) {
764
- this.initScaler(frame.width, frame.height, frame.format);
765
- return;
766
- }
767
- if (!this.dstFrame || !this.scaler) return;
768
- const decodeStart = performance.now();
769
- if (this.frameSink === "shm" && this.outputMode !== "jpeg") {
770
- this.scaleIntoRingSlot(frame, decodeStart);
771
- return;
772
- }
773
- try {
774
- this.dstFrame.makeWritable();
775
- this.scaler.scaleFrameSync(this.dstFrame, frame);
776
- } catch (err) {
777
- this.logger.warn("node-av scale error", { meta: { error: errMsg(err) } });
778
- return;
779
- }
780
- if (!this.firstFrameLogged) {
781
- this.firstFrameLogged = true;
782
- const channels = this.outputMode === "gray" ? 1 : 3;
783
- const ls = this.dstFrame.linesize;
784
- const buf = this.dstFrame.toBuffer();
785
- this.logger.info("dstFrame after scale", {
786
- meta: {
787
- phase: "frame-debug",
788
- width: this.dstFrame.width,
789
- height: this.dstFrame.height,
790
- linesize: [ls[0], ls[1], ls[2]],
791
- expectedStride: this.dstFrame.width * channels,
792
- bufLen: buf.length,
793
- expectedPacked: this.dstFrame.width * channels * this.dstFrame.height,
794
- srcFormat: frame.format ?? "?"
795
- }
796
- });
797
- if (this.outputMode !== "gray") {
798
- import("node:fs").then((fsModule) => {
799
- import("node:path").then((pathModule) => {
800
- const dumpPath = pathModule.join(process.cwd(), "camstack-data", "debug-frame-rgb24.raw");
801
- fsModule.writeFileSync(dumpPath, buf);
802
- this.logger.info("Dumped first RGB24 frame", { meta: { phase: "frame-debug", path: dumpPath, bytes: buf.length } });
803
- }).catch(() => {
804
- });
805
- }).catch(() => {
806
- });
807
- }
808
- }
809
- const rawBuf = this.extractPackedBuffer(this.dstFrame);
810
- if (this.outputMode === "jpeg") {
811
- this.encodeAndEmitJpeg(rawBuf, decodeStart);
812
- } else if (this.outputMode === "rgb") {
813
- this.emitRawFrame(rawBuf, "rgb", decodeStart);
814
- } else {
815
- this.emitRawFrame(rawBuf, "gray", decodeStart);
816
- }
817
- }
818
- /**
819
- * Scale a decoded frame **directly into a shared-memory ring slot** — the
820
- * zero write-side copy path (Phase 5 / D9 Task 7c).
821
- *
822
- * `beginFrame` reserves the slot (its seqlock open / odd — readers skip it);
823
- * `SoftwareScaleContext.scaleSync` (the low-level `sws_scale` mapping) writes
824
- * the packed pixels straight into the slot buffer with `linesize = width ×
825
- * bpp` no linesize padding, so there is nothing to strip and nothing to
826
- * memcpy; `commitFrame` writes the metadata, closes the seqlock and publishes
827
- * the slot. The decoded source planes feed `scaleSync` as `srcSlice` +
828
- * `srcStride` (`frame.data` / `frame.linesize`).
829
- *
830
- * Used only for raw output formats (rgb/bgr/gray). The jpeg path keeps the
831
- * `dstFrame` + sharp encode route because sharp must consume an RGB buffer
832
- * before the final (variable-length) jpeg bytes exist.
833
- */
834
- scaleIntoRingSlot(frame, decodeStart) {
835
- if (!this.scaler) return;
836
- const format = this.outputMode === "gray" ? "gray" : "rgb";
837
- const channels = this.outputMode === "gray" ? 1 : 3;
838
- const sink = this.ensureFrameRingSink();
839
- const reserved = sink.beginFrame(this.outWidth, this.outHeight, format);
840
- if (reserved === null) {
841
- this.droppedFrames++;
842
- return;
843
- }
844
- const srcPlanes = frame.data;
845
- if (!srcPlanes || srcPlanes.length === 0) {
846
- sink.abortFrame(reserved.slot);
847
- this.droppedFrames++;
848
- return;
849
- }
850
- const dstStride = this.outWidth * channels;
851
- try {
852
- const srcStrides = Array.from(frame.linesize).slice(0, srcPlanes.length);
853
- this.scaler.scaleSync(
854
- Array.from(srcPlanes),
855
- srcStrides,
856
- 0,
857
- frame.height,
858
- [reserved.buffer],
859
- [dstStride]
860
- );
861
- } catch (err) {
862
- this.logger.warn("node-av scale-into-slot error", {
863
- meta: { error: errMsg(err) }
864
- });
865
- sink.abortFrame(reserved.slot);
866
- this.droppedFrames++;
867
- return;
868
- }
869
- const decodeMs = performance.now() - decodeStart;
870
- this.totalDecodeTimeMs += decodeMs;
871
- this.outputFrames++;
872
- this.lastEmitTime = performance.now();
873
- if (!this.firstFrameLogged) {
874
- this.firstFrameLogged = true;
875
- this.logger.info("node-av: scaled directly into shm ring slot", {
876
- meta: {
877
- phase: "frame-debug",
878
- width: this.outWidth,
879
- height: this.outHeight,
880
- dstStride,
881
- format
882
- }
883
- });
884
- }
885
- if (this.outputFrames === 1 || this.outputFrames % 500 === 0) {
886
- this.logger.info("node-av frame emitted", {
887
- meta: {
888
- frameNumber: this.outputFrames,
889
- width: this.outWidth,
890
- height: this.outHeight,
891
- format,
892
- decodeMs,
893
- sink: "shm",
894
- subs: this.handleCallbacks.size
895
- }
896
- });
897
- }
898
- const handle = sink.commitFrame(reserved.slot, {
899
- width: this.outWidth,
900
- height: this.outHeight,
901
- format,
902
- // pts uses performance.now() (monotonic, process-relative) for
903
- // ring-ordering; DecodedFrameHandle.timestamp carries the wall clock.
904
- pts: performance.now(),
905
- byteLength: dstStride * this.outHeight
906
- });
907
- if (handle === null) {
908
- this.droppedFrames++;
909
- return;
910
- }
911
- const delivered = { handle, timestamp: Date.now() };
912
- for (const cb of this.handleCallbacks) {
913
- cb(delivered);
914
- }
915
- }
916
- /**
917
- * Extract packed pixel buffer from a decoded frame.
918
- * FFmpeg's av_frame_get_buffer() may pad each row to alignment (32/64 bytes).
919
- * Sharp and WASM consumers expect tightly-packed rows (stride = width * channels).
920
- * If linesize matches expected stride, return the buffer directly (zero-copy).
921
- */
922
- extractPackedBuffer(frame) {
923
- const channels = this.outputMode === "gray" ? 1 : 3;
924
- const expectedStride = frame.width * channels;
925
- const actualStride = frame.linesize[0] ?? expectedStride;
926
- const src = frame.data?.[0];
927
- if (!src) {
928
- return frame.toBuffer();
929
- }
930
- if (actualStride === expectedStride) {
931
- return Buffer.from(src.buffer, src.byteOffset, expectedStride * frame.height);
932
- }
933
- const dst = Buffer.allocUnsafe(expectedStride * frame.height);
934
- for (let y = 0; y < frame.height; y++) {
935
- src.copy(dst, y * expectedStride, y * actualStride, y * actualStride + expectedStride);
936
- }
937
- return dst;
938
- }
939
- /**
940
- * Encode RGB24 raw buffer as JPEG and emit.
941
- *
942
- * Drops the frame (and counts it) when `MAX_JPEG_INFLIGHT` encodes
943
- * are already pending — prevents unbounded growth of the
944
- * fire-and-forget promise chain when sharp cannot keep up with the
945
- * decode rate.
946
- */
947
- encodeAndEmitJpeg(rgb, decodeStart) {
948
- if (!this.sharpFn) return;
949
- if (this.jpegEncodeInFlight >= NodeAvDecoderSession.MAX_JPEG_INFLIGHT) {
950
- this.droppedFrames++;
951
- return;
952
- }
953
- this.jpegEncodeInFlight++;
954
- this.sharpFn(rgb, {
955
- raw: { width: this.outWidth, height: this.outHeight, channels: 3 }
956
- }).jpeg({ quality: 80, mozjpeg: false }).toBuffer().then((jpegBuf) => {
957
- if (this.destroyed) return;
958
- this.emitRawFrame(jpegBuf, "jpeg", decodeStart);
959
- }).catch((err) => {
960
- this.logger.warn("sharp jpeg encode error", { meta: { error: errMsg(err) } });
961
- }).finally(() => {
962
- this.jpegEncodeInFlight--;
963
- });
964
- }
965
- emitRawFrame(data, format, decodeStart) {
966
- const decodeMs = performance.now() - decodeStart;
967
- this.totalDecodeTimeMs += decodeMs;
968
- this.outputFrames++;
969
- this.lastEmitTime = performance.now();
970
- if (this.outputFrames === 1 || this.outputFrames % 500 === 0) {
971
- this.logger.info("node-av frame emitted", {
972
- meta: {
973
- frameNumber: this.outputFrames,
974
- width: this.outWidth,
975
- height: this.outHeight,
976
- format,
977
- decodeMs,
978
- sink: this.frameSink,
979
- subs: this.frameSink === "shm" ? this.handleCallbacks.size : this.frameCallbacks.size
980
- }
981
- });
982
- }
983
- if (this.frameSink === "shm") {
984
- this.emitShmFrame(data, format);
985
- return;
986
- }
987
- const decodedFrame = {
988
- data,
989
- width: this.outWidth,
990
- height: this.outHeight,
991
- format,
992
- timestamp: Date.now()
993
- };
994
- for (const cb of this.frameCallbacks) {
995
- cb(decodedFrame);
996
- }
997
- }
998
- /**
999
- * Write a decoded frame into this stream's shared-memory ring and deliver
1000
- * the resulting `FrameHandle` to `onFrameHandle` subscribers. No pixel
1001
- * `Buffer` is handed to callbacks — consumers open the segment and read the
1002
- * pixels zero-copy via a `FrameRingReader`.
1003
- */
1004
- emitShmFrame(data, format) {
1005
- const sink = this.ensureFrameRingSink();
1006
- const handle = sink.writeFrame(data, {
1007
- width: this.outWidth,
1008
- height: this.outHeight,
1009
- format,
1010
- // The decoder does not carry a source PTS down to this point; use a
1011
- // monotone-ish wall clock so a consumer can still order frames.
1012
- // NOTE: pts uses performance.now() (monotonic, process-relative) while
1013
- // DecodedFrameHandle.timestamp uses Date.now() (wall-clock epoch).
1014
- // The two clocks are intentionally distinct — pts is for ring-ordering,
1015
- // timestamp is for wall-clock correlation — and must not be compared.
1016
- pts: performance.now(),
1017
- byteLength: data.byteLength
1018
- });
1019
- if (handle === null) {
1020
- this.droppedFrames++;
1021
- return;
1022
- }
1023
- const frame = { handle, timestamp: Date.now() };
1024
- for (const cb of this.handleCallbacks) {
1025
- cb(frame);
1026
- }
1027
- }
1028
- onFrame(callback) {
1029
- this.frameCallbacks.add(callback);
1030
- return () => {
1031
- this.frameCallbacks.delete(callback);
1032
- };
1033
- }
1034
- /**
1035
- * Subscribe to shared-memory frame handles. Fires only when the session was
1036
- * created with `frameSink: 'shm'`; in the legacy `'callback'` mode no
1037
- * handles are produced and this subscription never fires.
1038
- */
1039
- onFrameHandle(callback) {
1040
- this.handleCallbacks.add(callback);
1041
- return () => {
1042
- this.handleCallbacks.delete(callback);
1043
- };
1044
- }
1045
- updateConfig(update) {
1046
- const prevFormat = this.config.outputFormat;
1047
- this.config = { ...this.config, ...update };
1048
- if (update.maxFps !== void 0) {
1049
- this.minIntervalMs = update.maxFps > 0 ? 1e3 / update.maxFps : 0;
1050
- }
1051
- if (update.outputFormat !== void 0 && update.outputFormat !== prevFormat) {
1052
- const prevMode = this.outputMode;
1053
- this.outputMode = NodeAvDecoderSession.resolveOutputMode(update.outputFormat);
1054
- if (this.outputMode === prevMode) return;
1055
- this.scalerEpoch++;
1056
- if (this.scaler) {
1057
- this.scaler[Symbol.dispose]?.();
1058
- this.scaler = null;
1059
- }
1060
- if (this.dstFrame) {
1061
- this.dstFrame[Symbol.dispose]?.();
1062
- this.dstFrame = null;
1063
- }
1064
- if (this.outputMode === "jpeg" && !this.sharpFn) {
1065
- getSharp().then((fn) => {
1066
- this.sharpFn = fn;
1067
- }).catch(() => {
1068
- });
1069
- }
1070
- this.logger.info("node-av: output format changed — scaler will reinit", {
1071
- meta: { from: prevFormat, to: update.outputFormat, mode: this.outputMode }
1072
- });
1073
- }
1074
- }
1075
- async destroy() {
1076
- if (this.destroyed) return;
1077
- this.destroyed = true;
1078
- this.frameCallbacks.clear();
1079
- this.handleCallbacks.clear();
1080
- this.frameRingSink?.destroy();
1081
- this.frameRingSink = null;
1082
- this.dstFrame?.[Symbol.dispose]?.();
1083
- this.avFrame?.[Symbol.dispose]?.();
1084
- this.avPacket?.[Symbol.dispose]?.();
1085
- this.scaler?.[Symbol.dispose]?.();
1086
- this.parser?.[Symbol.dispose]?.();
1087
- this.swTransferFrame?.[Symbol.dispose]?.();
1088
- this.codecCtx?.[Symbol.dispose]?.();
1089
- this.hwDevice?.free();
1090
- this.dstFrame = null;
1091
- this.avFrame = null;
1092
- this.avPacket = null;
1093
- this.scaler = null;
1094
- this.parser = null;
1095
- this.codecCtx = null;
1096
- this.swTransferFrame = null;
1097
- this.hwDevice = null;
1098
- }
1099
- getStats() {
1100
- const uptimeSec = Math.max((Date.now() - this.startTime) / 1e3, 1);
1101
- return {
1102
- inputFps: this.inputPackets / uptimeSec,
1103
- outputFps: this.outputFrames / uptimeSec,
1104
- avgDecodeTimeMs: this.outputFrames > 0 ? this.totalDecodeTimeMs / this.outputFrames : 0,
1105
- droppedFrames: this.droppedFrames
1106
- };
1107
- }
1108
- get isPullMode() {
1109
- return false;
1110
- }
1111
- }
1112
- const FRAME_BUFFER_CAPACITY = 32;
1113
- class DecoderNodeAvAddon extends BaseAddon {
1114
- /**
1115
- * Sessions are stored as the concrete `NodeAvDecoderSession` (the only type
1116
- * this addon ever creates) so `getShmStats` can reach the per-session shm
1117
- * ring sink — `IDecoderSession` does not expose it.
1118
- */
1119
- sessions = /* @__PURE__ */ new Map();
1120
- /** Pixel-frame buffers — populated only for `frameSink: 'callback'` sessions. */
1121
- frameBuffers = /* @__PURE__ */ new Map();
1122
- /** `FrameHandle` buffers — populated only for `frameSink: 'shm'` sessions. */
1123
- handleBuffers = /* @__PURE__ */ new Map();
1124
- unsubscribers = /* @__PURE__ */ new Map();
1125
- sessionMeta = /* @__PURE__ */ new Map();
1126
- /**
1127
- * Per-shm-segment `FrameRingReader` cache backing `getFrame`. Opens each
1128
- * named segment once and reuses the reader for every later handle on it;
1129
- * built in `onInitialize` (it needs the scoped logger) and closed in
1130
- * `onShutdown`.
1131
- */
1132
- frameReaders = null;
1133
- /** Running `getFrame` hit/miss counters surfaced via `getShmStats`. */
1134
- getFrameHits = 0;
1135
- getFrameMisses = 0;
1136
- constructor() {
1137
- super(DEFAULT_DECODER_HWACCEL_CONFIG);
1138
- }
1139
- globalSettingsSchema() {
1140
- return this.schema({
1141
- sections: [{
1142
- id: "hwaccel",
1143
- title: "Hardware acceleration",
1144
- tab: "decoder",
1145
- description: 'Backend used by node-av decoder sessions. "Auto" defers to the probed best; concrete backends force it. Changes apply to NEW sessions — existing sessions keep the backend they were created with.',
1146
- fields: [
1147
- this.field({
1148
- type: "select",
1149
- key: "hwaccel",
1150
- label: "Preferred backend",
1151
- options: [...HWACCEL_OPTIONS],
1152
- default: "auto",
1153
- immediate: true
1154
- }),
1155
- this.field({
1156
- type: "text",
1157
- key: "probedBestHwaccel",
1158
- label: "Probed best",
1159
- description: "Auto-detected best decoder backend on this host. Click the refresh icon to re-run the probe.",
1160
- readonlyField: true,
1161
- default: "",
1162
- actions: [
1163
- { action: "reprobe-hwaccel", icon: "refresh-cw", tooltip: "Re-probe hwaccel" }
1164
- ]
1165
- })
1166
- ]
1167
- }]
1168
- });
1169
- }
1170
- async onInitialize() {
1171
- this.ctx.logger.info("node-av decoder addon initialized");
1172
- this.frameReaders = new FrameRingReaderCache(this.ctx.logger);
1173
- if (!this.config.probedBestHwaccel) {
1174
- this.reprobeHwaccel().catch((err) => {
1175
- this.ctx.logger.warn("nodeav: auto-reprobe hwaccel failed", {
1176
- meta: { error: err instanceof Error ? err.message : String(err) }
1177
- });
1178
- });
1179
- }
1180
- return [{ capability: decoderCapability, provider: this }];
1181
- }
1182
- /**
1183
- * Resolve the effective hwaccel backend for a new session. Reads
1184
- * this addon's own `hwaccel` setting first. `'auto'` defers to the
1185
- * session's local resolver (`ctx.kernel.hwaccel`) which probes the
1186
- * host and picks. No more orchestrator round-trip — decoder addon
1187
- * is self-sufficient for this setting as of phase 2d.
1188
- */
1189
- resolveHwAccelPref() {
1190
- return this.config.hwaccel;
1191
- }
1192
- /**
1193
- * Re-run the platform probe on this host and persist the detected
1194
- * backend as `probedBestHwaccel`. The operator's `hwaccel` setting
1195
- * is intentionally left alone — the probe only updates the hint.
1196
- */
1197
- async reprobeHwaccel() {
1198
- const resolver = this.ctx.kernel.hwaccel;
1199
- if (!resolver) {
1200
- this.ctx.logger.warn("reprobeHwaccel: no kernel hwaccel resolver — returning none");
1201
- await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: "none" });
1202
- return { backend: "none" };
1203
- }
1204
- try {
1205
- const res = await resolver.resolve();
1206
- const backend = res.preferred[0] ?? "none";
1207
- await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: backend });
1208
- this.ctx.logger.info("reprobeHwaccel: wrote probedBestHwaccel", {
1209
- meta: { backend, rationale: res.rationale, preferred: res.preferred }
1210
- });
1211
- return { backend };
1212
- } catch (err) {
1213
- this.ctx.logger.warn("reprobeHwaccel failed", {
1214
- meta: { error: err instanceof Error ? err.message : String(err) }
1215
- });
1216
- await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: "none" });
1217
- return { backend: "none" };
1218
- }
1219
- }
1220
- async supportsCodec(input) {
1221
- return ["h264", "h265", "hevc"].includes(input.codec.toLowerCase());
1222
- }
1223
- async getInfo() {
1224
- return {
1225
- id: "decoder-nodeav",
1226
- name: "Decoder (node-av)",
1227
- isPullMode: false,
1228
- priority: 10
1229
- };
1230
- }
1231
- /**
1232
- * The cluster node id of this decoder — the Moleculer nodeID (`hub`,
1233
- * `dev-agent-0`, …), not the addon id. Stamped into session-owned
1234
- * `FrameHandle`s so a downstream consumer routes `getFrame` to the node
1235
- * that holds the shm ring. A hierarchical broker nodeID (`hub/...`) is
1236
- * collapsed to the cluster-visible parent; in-process boot is left as-is.
1237
- * Mirrors the resolution `PipelineRunnerAddon.onInitialize` performs.
1238
- */
1239
- resolveLocalNodeId() {
1240
- const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
1241
- return raw.includes("/") ? raw.split("/")[0] : raw;
1242
- }
1243
- async createSession(config) {
1244
- const sessionId = randomUUID();
1245
- const hwaccel = this.resolveHwAccelPref();
1246
- const { frameSink } = config;
1247
- const nodeId = this.resolveLocalNodeId();
1248
- const session = new NodeAvDecoderSession(config, this.ctx.logger, {
1249
- hwaccel,
1250
- hwaccelResolver: this.ctx.kernel.hwaccel,
1251
- frameSink,
1252
- nodeId
1253
- });
1254
- const unsub = frameSink === "shm" ? this.wireShmSink(sessionId, session) : this.wireCallbackSink(sessionId, session);
1255
- this.sessions.set(sessionId, session);
1256
- this.unsubscribers.set(sessionId, unsub);
1257
- this.sessionMeta.set(sessionId, {
1258
- codec: config.codec,
1259
- outputFormat: config.outputFormat,
1260
- createdAtMs: Date.now()
1261
- });
1262
- this.ctx.logger.info("node-av: created session", { meta: { sessionId, codec: config.codec, hwaccelPref: hwaccel, frameSink, nodeId } });
1263
- return { sessionId, nodeId };
1264
- }
1265
- /**
1266
- * Subscribe a `'callback'` session's pixel frames into a per-session
1267
- * ring buffer drained by `pullFrames`.
1268
- */
1269
- wireCallbackSink(sessionId, session) {
1270
- const ringBuffer = new RingBuffer(FRAME_BUFFER_CAPACITY);
1271
- this.frameBuffers.set(sessionId, ringBuffer);
1272
- return session.onFrame((frame) => {
1273
- const { format } = frame;
1274
- if (format !== "jpeg" && format !== "rgb" && format !== "bgr" && format !== "yuv420" && format !== "gray") return;
1275
- const arrayBuf = new ArrayBuffer(frame.data.byteLength);
1276
- new Uint8Array(arrayBuf).set(frame.data);
1277
- const capData = new Uint8Array(arrayBuf);
1278
- const capFrame = {
1279
- data: capData,
1280
- width: frame.width,
1281
- height: frame.height,
1282
- format,
1283
- timestamp: frame.timestamp
1284
- };
1285
- ringBuffer.push(capFrame);
1286
- });
1287
- }
1288
- /**
1289
- * Subscribe a `'shm'` session's `FrameHandle`s into a per-session ring
1290
- * buffer drained by `pullHandles`. No pixel bytes cross the cap boundary
1291
- * the broker opens the named segment and reads the pixels zero-copy.
1292
- */
1293
- wireShmSink(sessionId, session) {
1294
- const handleRing = new RingBuffer(FRAME_BUFFER_CAPACITY);
1295
- this.handleBuffers.set(sessionId, handleRing);
1296
- return session.onFrameHandle((frame) => {
1297
- handleRing.push(frame.handle);
1298
- });
1299
- }
1300
- async destroySession(input) {
1301
- const { sessionId } = input;
1302
- const session = this.sessions.get(sessionId);
1303
- if (!session) {
1304
- throw new Error(`decoder-nodeav: unknown sessionId ${sessionId}`);
1305
- }
1306
- const unsub = this.unsubscribers.get(sessionId);
1307
- if (unsub) unsub();
1308
- await session.destroy();
1309
- this.sessions.delete(sessionId);
1310
- this.frameBuffers.delete(sessionId);
1311
- this.handleBuffers.delete(sessionId);
1312
- this.unsubscribers.delete(sessionId);
1313
- this.sessionMeta.delete(sessionId);
1314
- this.ctx.logger.info("node-av: destroyed session", { meta: { sessionId } });
1315
- }
1316
- async listActiveSessions() {
1317
- const out = [];
1318
- for (const [sessionId, meta] of this.sessionMeta) {
1319
- out.push({ sessionId, codec: meta.codec, outputFormat: meta.outputFormat, createdAtMs: meta.createdAtMs });
1320
- }
1321
- return out;
1322
- }
1323
- async pushPacket(input) {
1324
- const session = this.sessions.get(input.sessionId);
1325
- if (!session) {
1326
- throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1327
- }
1328
- const rawData = input.packet.data;
1329
- const data = Buffer.isBuffer(rawData) ? rawData : rawData instanceof Uint8Array ? Buffer.from(rawData.buffer, rawData.byteOffset, rawData.byteLength) : Buffer.from(rawData);
1330
- session.pushPacket({ ...input.packet, data });
1331
- }
1332
- async openStream(input) {
1333
- const session = this.sessions.get(input.sessionId);
1334
- if (!session) {
1335
- throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1336
- }
1337
- void input.url;
1338
- }
1339
- async pullFrames(input) {
1340
- if (!this.sessions.has(input.sessionId)) {
1341
- throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1342
- }
1343
- const ringBuffer = this.frameBuffers.get(input.sessionId);
1344
- if (!ringBuffer) return [];
1345
- return ringBuffer.drain(input.maxCount);
1346
- }
1347
- async pullHandles(input) {
1348
- if (!this.sessions.has(input.sessionId)) {
1349
- throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1350
- }
1351
- const handleRing = this.handleBuffers.get(input.sessionId);
1352
- if (!handleRing) return [];
1353
- return handleRing.drain(input.maxCount);
1354
- }
1355
- async updateConfig(input) {
1356
- const session = this.sessions.get(input.sessionId);
1357
- if (!session) {
1358
- throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1359
- }
1360
- session.updateConfig(input.config);
1361
- }
1362
- async getStats(input) {
1363
- const session = this.sessions.get(input.sessionId);
1364
- if (!session) {
1365
- throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1366
- }
1367
- return session.getStats();
1368
- }
1369
- /**
1370
- * Read back the pixels a `FrameHandle` refers to from this node's shm ring
1371
- * (Phase 5 / D9 downstream access). Returns `null` when the slot was
1372
- * already recycled (latest-wins drop) or the segment could not be opened.
1373
- * The reader cache opens each named segment once and reuses the reader.
1374
- *
1375
- * The reader hands back a `Buffer` view over its **reusable scratch
1376
- * buffer**, only valid until the next `read` on the same reader; the
1377
- * cap caller may keep the frame across reads, so the pixels are copied
1378
- * into a fresh detached `ArrayBuffer` here. This matches the existing
1379
- * callback-path copy in `wireCallbackSink`.
1380
- */
1381
- async getFrame(input) {
1382
- const frame = this.frameReaders?.read(input.handle) ?? null;
1383
- if (!frame) {
1384
- this.getFrameMisses += 1;
1385
- return null;
1386
- }
1387
- this.getFrameHits += 1;
1388
- const arrayBuf = new ArrayBuffer(frame.data.byteLength);
1389
- new Uint8Array(arrayBuf).set(frame.data);
1390
- return {
1391
- data: new Uint8Array(arrayBuf),
1392
- width: frame.width,
1393
- height: frame.height,
1394
- format: frame.format,
1395
- timestamp: frame.timestamp
1396
- };
1397
- }
1398
- /**
1399
- * shm ring usage stats for a `frameSink: 'shm'` session — slot geometry,
1400
- * frames written, byte budget, plus this addon's running `getFrame`
1401
- * hit/miss counters. Returns `null` for an unknown session or one whose
1402
- * shm ring has not yet been armed by a first decoded frame.
1403
- */
1404
- async getShmStats(input) {
1405
- const session = this.sessions.get(input.sessionId);
1406
- const stats = session?.frameRingSinkOrNull?.getShmStats() ?? null;
1407
- if (!stats) return null;
1408
- return {
1409
- sessionId: input.sessionId,
1410
- ...stats,
1411
- budgetMb: RING_BUDGET_MB,
1412
- getFrameHits: this.getFrameHits,
1413
- getFrameMisses: this.getFrameMisses
1414
- };
1415
- }
1416
- async onShutdown() {
1417
- this.ctx.logger.info("node-av decoder addon shutdown — destroying all sessions");
1418
- const destroyPromises = [];
1419
- for (const [sessionId, session] of this.sessions) {
1420
- const unsub = this.unsubscribers.get(sessionId);
1421
- if (unsub) unsub();
1422
- destroyPromises.push(session.destroy());
1423
- }
1424
- await Promise.all(destroyPromises);
1425
- this.sessions.clear();
1426
- this.frameBuffers.clear();
1427
- this.handleBuffers.clear();
1428
- this.unsubscribers.clear();
1429
- this.sessionMeta.clear();
1430
- this.frameReaders?.close();
1431
- this.frameReaders = null;
1432
- }
1433
- }
1434
- export {
1435
- DecoderFrameRingSink,
1436
- DecoderNodeAvAddon,
1437
- NodeAvDecoderSession,
1438
- DecoderNodeAvAddon as default,
1439
- makeSegmentName
320
+ var NodeAvDecoderSession = class NodeAvDecoderSession {
321
+ config;
322
+ logger;
323
+ frameCallbacks = /* @__PURE__ */ new Set();
324
+ handleCallbacks = /* @__PURE__ */ new Set();
325
+ destroyed = false;
326
+ /**
327
+ * Frame delivery mode (see {@link DecoderFrameSink}). `'shm'` lazily
328
+ * constructs `frameRingSink` and routes every decoded frame through it.
329
+ */
330
+ frameSink;
331
+ /**
332
+ * The shared-memory ring writer for this stream. Created lazily on the
333
+ * first decoded frame in `'shm'` mode (the segment cannot be sized until
334
+ * the output geometry is known) and torn down in `destroy`.
335
+ */
336
+ frameRingSink = null;
337
+ parser = null;
338
+ codecCtx = null;
339
+ scaler = null;
340
+ avPacket = null;
341
+ avFrame = null;
342
+ dstFrame = null;
343
+ EAGAIN = -11;
344
+ PIX_FMT_GRAY8 = 8;
345
+ PIX_FMT_RGB24 = 2;
346
+ SWS_FAST_BILINEAR = 1;
347
+ /**
348
+ * Decoder output mode. Drives both the scaler's destination pixel
349
+ * format and whether sharp runs the JPEG encode at the end:
350
+ *
351
+ * - `'jpeg'` — scaler→RGB24 → sharp encode → emit JPEG bytes
352
+ * - `'rgb'` scaler→RGB24 → emit raw RGB24 (no sharp)
353
+ * - `'gray'` — scaler→GRAY8 → emit raw GRAY8 (no sharp)
354
+ *
355
+ * The broker holds the policy decision on which mode to request based
356
+ * on its active subscribers; on-the-fly conversion (e.g. RGB→JPEG for
357
+ * a WebRTC consumer that joined while detection holds the decoder in
358
+ * RGB mode) happens broker-side via the per-frame conversion cache.
359
+ */
360
+ outputMode;
361
+ sharpFn = null;
362
+ /**
363
+ * Backpressure for the sharp JPEG encode pipeline. The broker
364
+ * currently creates sessions with `maxFps: 0` (unlimited) and relies
365
+ * on per-subscriber throttling, so without a bound the
366
+ * fire-and-forget `sharp(...).toBuffer()` chain would accumulate
367
+ * unboundedly whenever sharp falls behind the decoder. Cap at
368
+ * `MAX_JPEG_INFLIGHT` pending encodes per session — any frame that
369
+ * arrives while the cap is saturated is dropped and counted.
370
+ */
371
+ static MAX_JPEG_INFLIGHT = 2;
372
+ jpegEncodeInFlight = 0;
373
+ /**
374
+ * Map a `DecoderSessionConfig.outputFormat` value to one of the three
375
+ * native scaler/encoder modes the session understands. The cap-level
376
+ * format vocabulary is broader (it accepts `bgr`, `yuv420`) than what
377
+ * libav's scaler is wired for here — anything else degrades to RGB
378
+ * (the canonical raw mode) and the broker is expected to convert
379
+ * downstream if a subscriber needs a different shape.
380
+ */
381
+ static resolveOutputMode(format) {
382
+ if (format === "jpeg" || format === void 0) return "jpeg";
383
+ if (format === "gray") return "gray";
384
+ return "rgb";
385
+ }
386
+ initialized = false;
387
+ initializing = false;
388
+ scalerInitializing = false;
389
+ /**
390
+ * Monotonic counter incremented by `updateConfig` whenever the
391
+ * scaler + dstFrame get invalidated (e.g. output format toggle).
392
+ * `initScaler` captures the current value at entry and aborts — or
393
+ * disposes the locally-built scaler — if the epoch moved while
394
+ * its async init was in flight. Without this, a toggle racing an
395
+ * in-flight init could leave two scalers allocated natively while
396
+ * `this.scaler` only holds a reference to one → libav leak.
397
+ */
398
+ scalerEpoch = 0;
399
+ /**
400
+ * One-shot guard for the "first frame" diagnostic log + raw frame
401
+ * dump. Setting this synchronously inside `emitDecodedFrame`
402
+ * prevents re-entry without it we were using `outputFrames === 0`
403
+ * which stays true until the async sharp encode callback runs, so
404
+ * several decoded frames could trigger the dump before the first
405
+ * JPEG landed.
406
+ */
407
+ firstFrameLogged = false;
408
+ outWidth = 0;
409
+ outHeight = 0;
410
+ lastEmitTime = 0;
411
+ minIntervalMs;
412
+ inputPackets = 0;
413
+ outputFrames = 0;
414
+ droppedFrames = 0;
415
+ totalDecodeTimeMs = 0;
416
+ startTime = Date.now();
417
+ hwaccelPref;
418
+ hwaccelResolver;
419
+ /** Cluster node id stamped into every `FrameHandle` the `'shm'` sink emits. */
420
+ nodeId;
421
+ /** The backend that actually initialised successfully — `'none'` = software fallback. */
422
+ activeHwAccel = "none";
423
+ hwDevice = null;
424
+ swTransferFrame = null;
425
+ constructor(config, logger = noopLogger, options) {
426
+ this.config = { ...config };
427
+ const sessionTags = {};
428
+ if (typeof config.deviceId === "number") sessionTags["deviceId"] = config.deviceId;
429
+ if (typeof config.tag === "string" && config.tag.length > 0) sessionTags["tag"] = config.tag;
430
+ this.logger = Object.keys(sessionTags).length > 0 ? logger.withTags(sessionTags) : logger;
431
+ this.minIntervalMs = config.maxFps > 0 ? 1e3 / config.maxFps : 0;
432
+ this.outputMode = NodeAvDecoderSession.resolveOutputMode(config.outputFormat);
433
+ this.hwaccelPref = options?.hwaccel ?? "auto";
434
+ this.hwaccelResolver = options?.hwaccelResolver ?? null;
435
+ this.frameSink = options?.frameSink ?? "callback";
436
+ this.nodeId = options?.nodeId ?? "local";
437
+ }
438
+ /**
439
+ * The shared-memory ring sink for this stream, or `null` before the first
440
+ * `'shm'`-mode frame lazily creates it. Exposed so the owning addon can
441
+ * surface ring stats via `decoder.getShmStats`.
442
+ */
443
+ get frameRingSinkOrNull() {
444
+ return this.frameRingSink;
445
+ }
446
+ /**
447
+ * Lazily build the shared-memory ring sink for this stream. The segment
448
+ * itself is still created lazily by the sink on its first `writeFrame`,
449
+ * once the decoded geometry is known.
450
+ */
451
+ ensureFrameRingSink() {
452
+ if (this.frameRingSink === null) {
453
+ const seedParts = [];
454
+ if (typeof this.config.deviceId === "number") seedParts.push(String(this.config.deviceId));
455
+ if (typeof this.config.tag === "string" && this.config.tag.length > 0) seedParts.push(this.config.tag);
456
+ const seed = seedParts.length > 0 ? seedParts.join(":") : "anon";
457
+ this.frameRingSink = new DecoderFrameRingSink({
458
+ seed,
459
+ logger: this.logger,
460
+ nodeId: this.nodeId
461
+ });
462
+ }
463
+ return this.frameRingSink;
464
+ }
465
+ /**
466
+ * Resolve the backend preference list and try each one against
467
+ * node-av's HW context APIs. The first backend whose
468
+ * `HardwareDeviceContext.create()` succeeds gets attached to
469
+ * `codecCtx.hwDeviceCtx` + its hw pixel format registered via
470
+ * `setHardwarePixelFormat`. On any failure, falls through to the
471
+ * next backend; if all fail, returns with `activeHwAccel='none'`
472
+ * and the decoder runs in software on the same context.
473
+ */
474
+ async tryAttachHwAccel(nav, C) {
475
+ if (!this.codecCtx) return;
476
+ if (this.hwaccelPref === "none") {
477
+ this.activeHwAccel = "none";
478
+ return;
479
+ }
480
+ const explicit = this.hwaccelPref === "auto" ? null : this.hwaccelPref;
481
+ const resolution = this.hwaccelResolver ? await this.hwaccelResolver.resolve(explicit) : explicit ? {
482
+ preferred: [explicit],
483
+ rationale: "explicit (no resolver)"
484
+ } : {
485
+ preferred: [],
486
+ rationale: "auto + no resolver → software"
487
+ };
488
+ if (resolution.preferred.length === 0) {
489
+ this.activeHwAccel = "none";
490
+ return;
491
+ }
492
+ for (const backend of resolution.preferred) {
493
+ const deviceType = backendToHwDeviceConst(backend, C);
494
+ const hwPixFmt = backendToHwPixFmt(backend, C);
495
+ if (!deviceType || !hwPixFmt) continue;
496
+ const device = new nav.HardwareDeviceContext();
497
+ const rc = device.create(deviceType);
498
+ if (rc < 0) {
499
+ this.logger.warn("node-av: hwaccel device create failed — trying next", { meta: {
500
+ backend,
501
+ rc
502
+ } });
503
+ device.free();
504
+ continue;
505
+ }
506
+ try {
507
+ this.codecCtx.hwDeviceCtx = device;
508
+ this.codecCtx.setHardwarePixelFormat(hwPixFmt);
509
+ } catch (err) {
510
+ this.logger.warn("node-av: hwaccel context attach failed — trying next", { meta: {
511
+ backend,
512
+ error: errMsg(err)
513
+ } });
514
+ device.free();
515
+ continue;
516
+ }
517
+ this.hwDevice = device;
518
+ this.activeHwAccel = backend;
519
+ return;
520
+ }
521
+ this.logger.warn("node-av: no hwaccel backend initialised — using software", { meta: {
522
+ attempted: resolution.preferred.join(","),
523
+ rationale: resolution.rationale
524
+ } });
525
+ this.activeHwAccel = "none";
526
+ }
527
+ /**
528
+ * Download a HW frame (format == hw pix fmt) into a SW frame so the
529
+ * rest of the pipeline (scaler, JPEG encoder, grayscale passthrough)
530
+ * handles it identically to the pure-software path. Uses the sync
531
+ * variant so the synchronous receive loop below doesn't need to be
532
+ * async-ified. Returns `null` on transfer failure, meaning the
533
+ * caller should drop the frame.
534
+ */
535
+ transferHwFrame(hwFrame) {
536
+ if (this.activeHwAccel === "none" || !this.swTransferFrame) return hwFrame;
537
+ const rc = hwFrame.hwframeTransferDataSync(this.swTransferFrame, 0);
538
+ if (rc < 0) {
539
+ this.logger.warn("node-av: hwframeTransferData failed", { meta: { rc } });
540
+ return null;
541
+ }
542
+ return this.swTransferFrame;
543
+ }
544
+ /**
545
+ * Initialize the decoder pipeline on the first keyframe.
546
+ * After this returns, all hot-path methods are fully synchronous (except JPEG encode).
547
+ */
548
+ async initDecoder() {
549
+ if (this.initialized || this.initializing || this.destroyed) return;
550
+ this.initializing = true;
551
+ try {
552
+ const nav = await getNodeAv();
553
+ const C = await getConstants();
554
+ this.EAGAIN = C.AVERROR_EAGAIN;
555
+ this.PIX_FMT_GRAY8 = C.AV_PIX_FMT_GRAY8;
556
+ this.PIX_FMT_RGB24 = C.AV_PIX_FMT_RGB24;
557
+ this.SWS_FAST_BILINEAR = C.SWS_FAST_BILINEAR;
558
+ if (this.outputMode === "jpeg") this.sharpFn = await getSharp();
559
+ nav.Log.setLevel(C.AV_LOG_FATAL);
560
+ const codecId = this.config.codec === "h265" || this.config.codec === "hevc" ? C.AV_CODEC_ID_HEVC : C.AV_CODEC_ID_H264;
561
+ this.parser = new nav.CodecParser();
562
+ this.parser.init(codecId);
563
+ const codec = nav.Codec.findDecoder(codecId);
564
+ if (!codec) {
565
+ this.logger.error("node-av: no decoder found", { meta: { codec: this.config.codec } });
566
+ return;
567
+ }
568
+ this.codecCtx = new nav.CodecContext();
569
+ this.codecCtx.allocContext3(codec);
570
+ if (this.config.width && this.config.height) {
571
+ this.codecCtx.width = this.config.width;
572
+ this.codecCtx.height = this.config.height;
573
+ }
574
+ this.codecCtx.threadCount = 1;
575
+ await this.tryAttachHwAccel(nav, C);
576
+ const ret = await this.codecCtx.open2(codec);
577
+ if (ret < 0) if (this.activeHwAccel !== "none") {
578
+ this.logger.warn("node-av: open2 failed with hwaccel — retrying in software", { meta: {
579
+ ret,
580
+ hwAccel: this.activeHwAccel
581
+ } });
582
+ this.hwDevice?.free();
583
+ this.hwDevice = null;
584
+ this.activeHwAccel = "none";
585
+ this.codecCtx = new nav.CodecContext();
586
+ this.codecCtx.allocContext3(codec);
587
+ if (this.config.width && this.config.height) {
588
+ this.codecCtx.width = this.config.width;
589
+ this.codecCtx.height = this.config.height;
590
+ }
591
+ this.codecCtx.threadCount = 1;
592
+ const retry = await this.codecCtx.open2(codec);
593
+ if (retry < 0) {
594
+ this.logger.error("node-av: failed to open decoder (sw fallback)", { meta: { ret: retry } });
595
+ return;
596
+ }
597
+ } else {
598
+ this.logger.error("node-av: failed to open decoder", { meta: { ret } });
599
+ return;
600
+ }
601
+ if (this.activeHwAccel !== "none") {
602
+ this.swTransferFrame = new nav.Frame();
603
+ this.swTransferFrame.alloc();
604
+ }
605
+ this.avPacket = new nav.Packet();
606
+ this.avPacket.alloc();
607
+ this.avFrame = new nav.Frame();
608
+ this.avFrame.alloc();
609
+ this.initialized = true;
610
+ this.logger.info("node-av push decoder initialized", { meta: {
611
+ codec: this.config.codec,
612
+ output: this.outputMode,
613
+ hwAccel: this.activeHwAccel
614
+ } });
615
+ } catch (err) {
616
+ this.logger.error("node-av init error", { meta: { error: errMsg(err) } });
617
+ } finally {
618
+ this.initializing = false;
619
+ }
620
+ }
621
+ /**
622
+ * Initialize the scaler after the first frame tells us the actual
623
+ * dimensions. Output pixel format: RGB24 for JPEG encoding, GRAY8
624
+ * for raw motion.
625
+ *
626
+ * Builds `scaler` + `dstFrame` on local variables and publishes
627
+ * them onto `this` in a single atomic step at the end. Captures
628
+ * `scalerEpoch` at entry; if `updateConfig` invalidated the scaler
629
+ * while this init was in flight (epoch mismatch), the locally
630
+ * built pair is disposed and discarded so the later init wins.
631
+ * Without the local-first approach, partial state (scaler set,
632
+ * dstFrame still null) could be observed by a concurrent
633
+ * `emitDecodedFrame` call.
634
+ */
635
+ async initScaler(srcW, srcH, srcFmt) {
636
+ if (this.scalerInitializing) return;
637
+ this.scalerInitializing = true;
638
+ const myEpoch = this.scalerEpoch;
639
+ let localScaler = null;
640
+ let localDstFrame = null;
641
+ try {
642
+ const nav = await getNodeAv();
643
+ if (this.destroyed || myEpoch !== this.scalerEpoch) return;
644
+ const scale = this.config.scale > 1 ? this.config.scale : 1;
645
+ const maxW = Math.floor(640 / scale);
646
+ const outWidth = Math.min(srcW, maxW);
647
+ const outHeight = Math.round(outWidth * srcH / srcW);
648
+ const dstFmt = this.outputMode === "gray" ? this.PIX_FMT_GRAY8 : this.PIX_FMT_RGB24;
649
+ const fmtName = this.outputMode === "gray" ? "gray8" : "rgb24";
650
+ localScaler = new nav.SoftwareScaleContext();
651
+ localScaler.getContext(srcW, srcH, srcFmt, outWidth, outHeight, dstFmt, this.SWS_FAST_BILINEAR);
652
+ const ret = localScaler.initContext();
653
+ if (ret < 0) {
654
+ this.logger.error("node-av: sws_init_context failed", { meta: { ret } });
655
+ return;
656
+ }
657
+ localDstFrame = new nav.Frame();
658
+ localDstFrame.alloc();
659
+ localDstFrame.width = outWidth;
660
+ localDstFrame.height = outHeight;
661
+ localDstFrame.format = dstFmt;
662
+ const allocRet = localDstFrame.allocBuffer();
663
+ if (allocRet < 0) {
664
+ this.logger.error("node-av: dst frame allocBuffer failed", { meta: { ret: allocRet } });
665
+ return;
666
+ }
667
+ if (this.destroyed || myEpoch !== this.scalerEpoch) return;
668
+ this.scaler?.[Symbol.dispose]?.();
669
+ this.dstFrame?.[Symbol.dispose]?.();
670
+ this.scaler = localScaler;
671
+ this.dstFrame = localDstFrame;
672
+ this.outWidth = outWidth;
673
+ this.outHeight = outHeight;
674
+ localScaler = null;
675
+ localDstFrame = null;
676
+ this.logger.info("node-av scaler initialized", { meta: {
677
+ srcWidth: srcW,
678
+ srcHeight: srcH,
679
+ outWidth,
680
+ outHeight,
681
+ format: fmtName
682
+ } });
683
+ } catch (err) {
684
+ this.logger.error("Scaler init failed", { meta: { error: errMsg(err) } });
685
+ } finally {
686
+ localScaler?.[Symbol.dispose]?.();
687
+ localDstFrame?.[Symbol.dispose]?.();
688
+ this.scalerInitializing = false;
689
+ }
690
+ }
691
+ pushPacket(packet) {
692
+ if (this.destroyed) return;
693
+ this.inputPackets++;
694
+ if (!this.initialized && !this.initializing && packet.keyframe) {
695
+ this.initDecoder().then(() => {
696
+ if (this.initialized) this.decodeRawData(packet.data, packet.pts ?? Date.now());
697
+ }).catch((err) => {
698
+ this.logger.error("node-av init failed", { meta: { error: errMsg(err) } });
699
+ });
700
+ return;
701
+ }
702
+ if (!this.initialized) return;
703
+ this.decodeRawData(packet.data, packet.pts ?? Date.now());
704
+ }
705
+ decodeRawData(data, pts) {
706
+ if (!this.parser || !this.codecCtx || !this.avPacket || !this.avFrame) return;
707
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
708
+ const bigPts = BigInt(pts);
709
+ let offset = 0;
710
+ while (offset < buf.length) {
711
+ const remaining = buf.subarray(offset);
712
+ const consumed = this.parser.parse2(this.codecCtx, this.avPacket, remaining, bigPts, bigPts, offset);
713
+ if (consumed < 0) {
714
+ this.logger.warn("node-av parser error", { meta: { ret: consumed } });
715
+ break;
716
+ }
717
+ offset += consumed;
718
+ if (this.avPacket.size > 0) {
719
+ this.decodePacket();
720
+ this.avPacket.unref();
721
+ }
722
+ }
723
+ }
724
+ decodePacket() {
725
+ if (!this.codecCtx || !this.avFrame) return;
726
+ const sendRet = this.codecCtx.sendPacketSync(this.avPacket);
727
+ if (sendRet < 0 && sendRet !== this.EAGAIN) {
728
+ this.logger.warn("node-av sendPacket error", { meta: { ret: sendRet } });
729
+ return;
730
+ }
731
+ while (true) {
732
+ if (this.codecCtx.receiveFrameSync(this.avFrame) < 0) break;
733
+ const frame = this.transferHwFrame(this.avFrame);
734
+ if (!frame) continue;
735
+ this.emitDecodedFrame(frame);
736
+ }
737
+ }
738
+ emitDecodedFrame(frame) {
739
+ const now = performance.now();
740
+ if (this.minIntervalMs > 0 && now - this.lastEmitTime < this.minIntervalMs) {
741
+ this.droppedFrames++;
742
+ return;
743
+ }
744
+ if (!this.scaler && !this.scalerInitializing) {
745
+ this.initScaler(frame.width, frame.height, frame.format);
746
+ return;
747
+ }
748
+ if (!this.dstFrame || !this.scaler) return;
749
+ const decodeStart = performance.now();
750
+ if (this.frameSink === "shm" && this.outputMode !== "jpeg") {
751
+ this.scaleIntoRingSlot(frame, decodeStart);
752
+ return;
753
+ }
754
+ try {
755
+ this.dstFrame.makeWritable();
756
+ this.scaler.scaleFrameSync(this.dstFrame, frame);
757
+ } catch (err) {
758
+ this.logger.warn("node-av scale error", { meta: { error: errMsg(err) } });
759
+ return;
760
+ }
761
+ if (!this.firstFrameLogged) {
762
+ this.firstFrameLogged = true;
763
+ const channels = this.outputMode === "gray" ? 1 : 3;
764
+ const ls = this.dstFrame.linesize;
765
+ const buf = this.dstFrame.toBuffer();
766
+ this.logger.info("dstFrame after scale", { meta: {
767
+ phase: "frame-debug",
768
+ width: this.dstFrame.width,
769
+ height: this.dstFrame.height,
770
+ linesize: [
771
+ ls[0],
772
+ ls[1],
773
+ ls[2]
774
+ ],
775
+ expectedStride: this.dstFrame.width * channels,
776
+ bufLen: buf.length,
777
+ expectedPacked: this.dstFrame.width * channels * this.dstFrame.height,
778
+ srcFormat: frame.format ?? "?"
779
+ } });
780
+ if (this.outputMode !== "gray") import("node:fs").then((fsModule) => {
781
+ import("node:path").then((pathModule) => {
782
+ const dumpPath = pathModule.join(process.cwd(), "camstack-data", "debug-frame-rgb24.raw");
783
+ fsModule.writeFileSync(dumpPath, buf);
784
+ this.logger.info("Dumped first RGB24 frame", { meta: {
785
+ phase: "frame-debug",
786
+ path: dumpPath,
787
+ bytes: buf.length
788
+ } });
789
+ }).catch(() => {});
790
+ }).catch(() => {});
791
+ }
792
+ const rawBuf = this.extractPackedBuffer(this.dstFrame);
793
+ if (this.outputMode === "jpeg") this.encodeAndEmitJpeg(rawBuf, decodeStart);
794
+ else if (this.outputMode === "rgb") this.emitRawFrame(rawBuf, "rgb", decodeStart);
795
+ else this.emitRawFrame(rawBuf, "gray", decodeStart);
796
+ }
797
+ /**
798
+ * Scale a decoded frame **directly into a shared-memory ring slot** — the
799
+ * zero write-side copy path (Phase 5 / D9 Task 7c).
800
+ *
801
+ * `beginFrame` reserves the slot (its seqlock open / odd — readers skip it);
802
+ * `SoftwareScaleContext.scaleSync` (the low-level `sws_scale` mapping) writes
803
+ * the packed pixels straight into the slot buffer with `linesize = width ×
804
+ * bpp` — no linesize padding, so there is nothing to strip and nothing to
805
+ * memcpy; `commitFrame` writes the metadata, closes the seqlock and publishes
806
+ * the slot. The decoded source planes feed `scaleSync` as `srcSlice` +
807
+ * `srcStride` (`frame.data` / `frame.linesize`).
808
+ *
809
+ * Used only for raw output formats (rgb/bgr/gray). The jpeg path keeps the
810
+ * `dstFrame` + sharp encode route because sharp must consume an RGB buffer
811
+ * before the final (variable-length) jpeg bytes exist.
812
+ */
813
+ scaleIntoRingSlot(frame, decodeStart) {
814
+ if (!this.scaler) return;
815
+ const format = this.outputMode === "gray" ? "gray" : "rgb";
816
+ const channels = this.outputMode === "gray" ? 1 : 3;
817
+ const sink = this.ensureFrameRingSink();
818
+ const reserved = sink.beginFrame(this.outWidth, this.outHeight, format);
819
+ if (reserved === null) {
820
+ this.droppedFrames++;
821
+ return;
822
+ }
823
+ const srcPlanes = frame.data;
824
+ if (!srcPlanes || srcPlanes.length === 0) {
825
+ sink.abortFrame(reserved.slot);
826
+ this.droppedFrames++;
827
+ return;
828
+ }
829
+ const dstStride = this.outWidth * channels;
830
+ try {
831
+ const srcStrides = Array.from(frame.linesize).slice(0, srcPlanes.length);
832
+ this.scaler.scaleSync(Array.from(srcPlanes), srcStrides, 0, frame.height, [reserved.buffer], [dstStride]);
833
+ } catch (err) {
834
+ this.logger.warn("node-av scale-into-slot error", { meta: { error: errMsg(err) } });
835
+ sink.abortFrame(reserved.slot);
836
+ this.droppedFrames++;
837
+ return;
838
+ }
839
+ const decodeMs = performance.now() - decodeStart;
840
+ this.totalDecodeTimeMs += decodeMs;
841
+ this.outputFrames++;
842
+ this.lastEmitTime = performance.now();
843
+ if (!this.firstFrameLogged) {
844
+ this.firstFrameLogged = true;
845
+ this.logger.info("node-av: scaled directly into shm ring slot", { meta: {
846
+ phase: "frame-debug",
847
+ width: this.outWidth,
848
+ height: this.outHeight,
849
+ dstStride,
850
+ format
851
+ } });
852
+ }
853
+ if (this.outputFrames === 1 || this.outputFrames % 500 === 0) this.logger.info("node-av frame emitted", { meta: {
854
+ frameNumber: this.outputFrames,
855
+ width: this.outWidth,
856
+ height: this.outHeight,
857
+ format,
858
+ decodeMs,
859
+ sink: "shm",
860
+ subs: this.handleCallbacks.size
861
+ } });
862
+ const handle = sink.commitFrame(reserved.slot, {
863
+ width: this.outWidth,
864
+ height: this.outHeight,
865
+ format,
866
+ pts: performance.now(),
867
+ byteLength: dstStride * this.outHeight
868
+ });
869
+ if (handle === null) {
870
+ this.droppedFrames++;
871
+ return;
872
+ }
873
+ const delivered = {
874
+ handle,
875
+ timestamp: Date.now()
876
+ };
877
+ for (const cb of this.handleCallbacks) cb(delivered);
878
+ }
879
+ /**
880
+ * Extract packed pixel buffer from a decoded frame.
881
+ * FFmpeg's av_frame_get_buffer() may pad each row to alignment (32/64 bytes).
882
+ * Sharp and WASM consumers expect tightly-packed rows (stride = width * channels).
883
+ * If linesize matches expected stride, return the buffer directly (zero-copy).
884
+ */
885
+ extractPackedBuffer(frame) {
886
+ const channels = this.outputMode === "gray" ? 1 : 3;
887
+ const expectedStride = frame.width * channels;
888
+ const actualStride = frame.linesize[0] ?? expectedStride;
889
+ const src = frame.data?.[0];
890
+ if (!src) return frame.toBuffer();
891
+ if (actualStride === expectedStride) return Buffer.from(src.buffer, src.byteOffset, expectedStride * frame.height);
892
+ const dst = Buffer.allocUnsafe(expectedStride * frame.height);
893
+ for (let y = 0; y < frame.height; y++) src.copy(dst, y * expectedStride, y * actualStride, y * actualStride + expectedStride);
894
+ return dst;
895
+ }
896
+ /**
897
+ * Encode RGB24 raw buffer as JPEG and emit.
898
+ *
899
+ * Drops the frame (and counts it) when `MAX_JPEG_INFLIGHT` encodes
900
+ * are already pending — prevents unbounded growth of the
901
+ * fire-and-forget promise chain when sharp cannot keep up with the
902
+ * decode rate.
903
+ */
904
+ encodeAndEmitJpeg(rgb, decodeStart) {
905
+ if (!this.sharpFn) return;
906
+ if (this.jpegEncodeInFlight >= NodeAvDecoderSession.MAX_JPEG_INFLIGHT) {
907
+ this.droppedFrames++;
908
+ return;
909
+ }
910
+ this.jpegEncodeInFlight++;
911
+ this.sharpFn(rgb, { raw: {
912
+ width: this.outWidth,
913
+ height: this.outHeight,
914
+ channels: 3
915
+ } }).jpeg({
916
+ quality: 80,
917
+ mozjpeg: false
918
+ }).toBuffer().then((jpegBuf) => {
919
+ if (this.destroyed) return;
920
+ this.emitRawFrame(jpegBuf, "jpeg", decodeStart);
921
+ }).catch((err) => {
922
+ this.logger.warn("sharp jpeg encode error", { meta: { error: errMsg(err) } });
923
+ }).finally(() => {
924
+ this.jpegEncodeInFlight--;
925
+ });
926
+ }
927
+ emitRawFrame(data, format, decodeStart) {
928
+ const decodeMs = performance.now() - decodeStart;
929
+ this.totalDecodeTimeMs += decodeMs;
930
+ this.outputFrames++;
931
+ this.lastEmitTime = performance.now();
932
+ if (this.outputFrames === 1 || this.outputFrames % 500 === 0) this.logger.info("node-av frame emitted", { meta: {
933
+ frameNumber: this.outputFrames,
934
+ width: this.outWidth,
935
+ height: this.outHeight,
936
+ format,
937
+ decodeMs,
938
+ sink: this.frameSink,
939
+ subs: this.frameSink === "shm" ? this.handleCallbacks.size : this.frameCallbacks.size
940
+ } });
941
+ if (this.frameSink === "shm") {
942
+ this.emitShmFrame(data, format);
943
+ return;
944
+ }
945
+ const decodedFrame = {
946
+ data,
947
+ width: this.outWidth,
948
+ height: this.outHeight,
949
+ format,
950
+ timestamp: Date.now()
951
+ };
952
+ for (const cb of this.frameCallbacks) cb(decodedFrame);
953
+ }
954
+ /**
955
+ * Write a decoded frame into this stream's shared-memory ring and deliver
956
+ * the resulting `FrameHandle` to `onFrameHandle` subscribers. No pixel
957
+ * `Buffer` is handed to callbacks — consumers open the segment and read the
958
+ * pixels zero-copy via a `FrameRingReader`.
959
+ */
960
+ emitShmFrame(data, format) {
961
+ const handle = this.ensureFrameRingSink().writeFrame(data, {
962
+ width: this.outWidth,
963
+ height: this.outHeight,
964
+ format,
965
+ pts: performance.now(),
966
+ byteLength: data.byteLength
967
+ });
968
+ if (handle === null) {
969
+ this.droppedFrames++;
970
+ return;
971
+ }
972
+ const frame = {
973
+ handle,
974
+ timestamp: Date.now()
975
+ };
976
+ for (const cb of this.handleCallbacks) cb(frame);
977
+ }
978
+ onFrame(callback) {
979
+ this.frameCallbacks.add(callback);
980
+ return () => {
981
+ this.frameCallbacks.delete(callback);
982
+ };
983
+ }
984
+ /**
985
+ * Subscribe to shared-memory frame handles. Fires only when the session was
986
+ * created with `frameSink: 'shm'`; in the legacy `'callback'` mode no
987
+ * handles are produced and this subscription never fires.
988
+ */
989
+ onFrameHandle(callback) {
990
+ this.handleCallbacks.add(callback);
991
+ return () => {
992
+ this.handleCallbacks.delete(callback);
993
+ };
994
+ }
995
+ updateConfig(update) {
996
+ const prevFormat = this.config.outputFormat;
997
+ this.config = {
998
+ ...this.config,
999
+ ...update
1000
+ };
1001
+ if (update.maxFps !== void 0) this.minIntervalMs = update.maxFps > 0 ? 1e3 / update.maxFps : 0;
1002
+ if (update.outputFormat !== void 0 && update.outputFormat !== prevFormat) {
1003
+ const prevMode = this.outputMode;
1004
+ this.outputMode = NodeAvDecoderSession.resolveOutputMode(update.outputFormat);
1005
+ if (this.outputMode === prevMode) return;
1006
+ this.scalerEpoch++;
1007
+ if (this.scaler) {
1008
+ this.scaler[Symbol.dispose]?.();
1009
+ this.scaler = null;
1010
+ }
1011
+ if (this.dstFrame) {
1012
+ this.dstFrame[Symbol.dispose]?.();
1013
+ this.dstFrame = null;
1014
+ }
1015
+ if (this.outputMode === "jpeg" && !this.sharpFn) getSharp().then((fn) => {
1016
+ this.sharpFn = fn;
1017
+ }).catch(() => {});
1018
+ this.logger.info("node-av: output format changed scaler will reinit", { meta: {
1019
+ from: prevFormat,
1020
+ to: update.outputFormat,
1021
+ mode: this.outputMode
1022
+ } });
1023
+ }
1024
+ }
1025
+ async destroy() {
1026
+ if (this.destroyed) return;
1027
+ this.destroyed = true;
1028
+ this.frameCallbacks.clear();
1029
+ this.handleCallbacks.clear();
1030
+ this.frameRingSink?.destroy();
1031
+ this.frameRingSink = null;
1032
+ this.dstFrame?.[Symbol.dispose]?.();
1033
+ this.avFrame?.[Symbol.dispose]?.();
1034
+ this.avPacket?.[Symbol.dispose]?.();
1035
+ this.scaler?.[Symbol.dispose]?.();
1036
+ this.parser?.[Symbol.dispose]?.();
1037
+ this.swTransferFrame?.[Symbol.dispose]?.();
1038
+ this.codecCtx?.[Symbol.dispose]?.();
1039
+ this.hwDevice?.free();
1040
+ this.dstFrame = null;
1041
+ this.avFrame = null;
1042
+ this.avPacket = null;
1043
+ this.scaler = null;
1044
+ this.parser = null;
1045
+ this.codecCtx = null;
1046
+ this.swTransferFrame = null;
1047
+ this.hwDevice = null;
1048
+ }
1049
+ getStats() {
1050
+ const uptimeSec = Math.max((Date.now() - this.startTime) / 1e3, 1);
1051
+ return {
1052
+ inputFps: this.inputPackets / uptimeSec,
1053
+ outputFps: this.outputFrames / uptimeSec,
1054
+ avgDecodeTimeMs: this.outputFrames > 0 ? this.totalDecodeTimeMs / this.outputFrames : 0,
1055
+ droppedFrames: this.droppedFrames
1056
+ };
1057
+ }
1058
+ get isPullMode() {
1059
+ return false;
1060
+ }
1061
+ };
1062
+ //#endregion
1063
+ //#region src/decoder-nodeav/addon/index.ts
1064
+ var FRAME_BUFFER_CAPACITY = 32;
1065
+ var DecoderNodeAvAddon = class extends BaseAddon {
1066
+ /**
1067
+ * Sessions are stored as the concrete `NodeAvDecoderSession` (the only type
1068
+ * this addon ever creates) so `getShmStats` can reach the per-session shm
1069
+ * ring sink — `IDecoderSession` does not expose it.
1070
+ */
1071
+ sessions = /* @__PURE__ */ new Map();
1072
+ /** Pixel-frame buffers populated only for `frameSink: 'callback'` sessions. */
1073
+ frameBuffers = /* @__PURE__ */ new Map();
1074
+ /** `FrameHandle` buffers — populated only for `frameSink: 'shm'` sessions. */
1075
+ handleBuffers = /* @__PURE__ */ new Map();
1076
+ unsubscribers = /* @__PURE__ */ new Map();
1077
+ sessionMeta = /* @__PURE__ */ new Map();
1078
+ /**
1079
+ * Per-shm-segment `FrameRingReader` cache backing `getFrame`. Opens each
1080
+ * named segment once and reuses the reader for every later handle on it;
1081
+ * built in `onInitialize` (it needs the scoped logger) and closed in
1082
+ * `onShutdown`.
1083
+ */
1084
+ frameReaders = null;
1085
+ /** Running `getFrame` hit/miss counters surfaced via `getShmStats`. */
1086
+ getFrameHits = 0;
1087
+ getFrameMisses = 0;
1088
+ constructor() {
1089
+ super(DEFAULT_DECODER_HWACCEL_CONFIG);
1090
+ }
1091
+ globalSettingsSchema() {
1092
+ return this.schema({ sections: [{
1093
+ id: "hwaccel",
1094
+ title: "Hardware acceleration",
1095
+ tab: "decoder",
1096
+ description: "Backend used by node-av decoder sessions. \"Auto\" defers to the probed best; concrete backends force it. Changes apply to NEW sessions — existing sessions keep the backend they were created with.",
1097
+ fields: [this.field({
1098
+ type: "select",
1099
+ key: "hwaccel",
1100
+ label: "Preferred backend",
1101
+ options: [...HWACCEL_OPTIONS],
1102
+ default: "auto",
1103
+ immediate: true
1104
+ }), this.field({
1105
+ type: "text",
1106
+ key: "probedBestHwaccel",
1107
+ label: "Probed best",
1108
+ description: "Auto-detected best decoder backend on this host. Click the refresh icon to re-run the probe.",
1109
+ readonlyField: true,
1110
+ default: "",
1111
+ actions: [{
1112
+ action: "reprobe-hwaccel",
1113
+ icon: "refresh-cw",
1114
+ tooltip: "Re-probe hwaccel"
1115
+ }]
1116
+ })]
1117
+ }] });
1118
+ }
1119
+ async onInitialize() {
1120
+ this.ctx.logger.info("node-av decoder addon initialized");
1121
+ this.frameReaders = new FrameRingReaderCache(this.ctx.logger);
1122
+ if (!this.config.probedBestHwaccel) this.reprobeHwaccel().catch((err) => {
1123
+ this.ctx.logger.warn("nodeav: auto-reprobe hwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
1124
+ });
1125
+ return [{
1126
+ capability: decoderCapability,
1127
+ provider: this
1128
+ }];
1129
+ }
1130
+ /**
1131
+ * Resolve the effective hwaccel backend for a new session. Reads
1132
+ * this addon's own `hwaccel` setting first. `'auto'` defers to the
1133
+ * session's local resolver (`ctx.kernel.hwaccel`) which probes the
1134
+ * host and picks. No more orchestrator round-trip — decoder addon
1135
+ * is self-sufficient for this setting as of phase 2d.
1136
+ */
1137
+ resolveHwAccelPref() {
1138
+ return this.config.hwaccel;
1139
+ }
1140
+ /**
1141
+ * Re-run the platform probe on this host and persist the detected
1142
+ * backend as `probedBestHwaccel`. The operator's `hwaccel` setting
1143
+ * is intentionally left alone — the probe only updates the hint.
1144
+ */
1145
+ async reprobeHwaccel() {
1146
+ const resolver = this.ctx.kernel.hwaccel;
1147
+ if (!resolver) {
1148
+ this.ctx.logger.warn("reprobeHwaccel: no kernel hwaccel resolver — returning none");
1149
+ await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: "none" });
1150
+ return { backend: "none" };
1151
+ }
1152
+ try {
1153
+ const res = await resolver.resolve();
1154
+ const backend = res.preferred[0] ?? "none";
1155
+ await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: backend });
1156
+ this.ctx.logger.info("reprobeHwaccel: wrote probedBestHwaccel", { meta: {
1157
+ backend,
1158
+ rationale: res.rationale,
1159
+ preferred: res.preferred
1160
+ } });
1161
+ return { backend };
1162
+ } catch (err) {
1163
+ this.ctx.logger.warn("reprobeHwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
1164
+ await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: "none" });
1165
+ return { backend: "none" };
1166
+ }
1167
+ }
1168
+ async supportsCodec(input) {
1169
+ return [
1170
+ "h264",
1171
+ "h265",
1172
+ "hevc"
1173
+ ].includes(input.codec.toLowerCase());
1174
+ }
1175
+ async getInfo() {
1176
+ return {
1177
+ id: "decoder-nodeav",
1178
+ name: "Decoder (node-av)",
1179
+ isPullMode: false,
1180
+ priority: 10
1181
+ };
1182
+ }
1183
+ /**
1184
+ * The cluster node id of this decoder the Moleculer nodeID (`hub`,
1185
+ * `dev-agent-0`, …), not the addon id. Stamped into session-owned
1186
+ * `FrameHandle`s so a downstream consumer routes `getFrame` to the node
1187
+ * that holds the shm ring. A hierarchical broker nodeID (`hub/...`) is
1188
+ * collapsed to the cluster-visible parent; in-process boot is left as-is.
1189
+ * Mirrors the resolution `PipelineRunnerAddon.onInitialize` performs.
1190
+ */
1191
+ resolveLocalNodeId() {
1192
+ const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
1193
+ return raw.includes("/") ? raw.split("/")[0] : raw;
1194
+ }
1195
+ async createSession(config) {
1196
+ const sessionId = randomUUID();
1197
+ const hwaccel = this.resolveHwAccelPref();
1198
+ const { frameSink } = config;
1199
+ const nodeId = this.resolveLocalNodeId();
1200
+ const session = new NodeAvDecoderSession(config, this.ctx.logger, {
1201
+ hwaccel,
1202
+ hwaccelResolver: this.ctx.kernel.hwaccel,
1203
+ frameSink,
1204
+ nodeId
1205
+ });
1206
+ const unsub = frameSink === "shm" ? this.wireShmSink(sessionId, session) : this.wireCallbackSink(sessionId, session);
1207
+ this.sessions.set(sessionId, session);
1208
+ this.unsubscribers.set(sessionId, unsub);
1209
+ this.sessionMeta.set(sessionId, {
1210
+ codec: config.codec,
1211
+ outputFormat: config.outputFormat,
1212
+ createdAtMs: Date.now()
1213
+ });
1214
+ this.ctx.logger.info("node-av: created session", { meta: {
1215
+ sessionId,
1216
+ codec: config.codec,
1217
+ hwaccelPref: hwaccel,
1218
+ frameSink,
1219
+ nodeId
1220
+ } });
1221
+ return {
1222
+ sessionId,
1223
+ nodeId
1224
+ };
1225
+ }
1226
+ /**
1227
+ * Subscribe a `'callback'` session's pixel frames into a per-session
1228
+ * ring buffer drained by `pullFrames`.
1229
+ */
1230
+ wireCallbackSink(sessionId, session) {
1231
+ const ringBuffer = new RingBuffer(FRAME_BUFFER_CAPACITY);
1232
+ this.frameBuffers.set(sessionId, ringBuffer);
1233
+ return session.onFrame((frame) => {
1234
+ const { format } = frame;
1235
+ if (format !== "jpeg" && format !== "rgb" && format !== "bgr" && format !== "yuv420" && format !== "gray") return;
1236
+ const arrayBuf = new ArrayBuffer(frame.data.byteLength);
1237
+ new Uint8Array(arrayBuf).set(frame.data);
1238
+ const capFrame = {
1239
+ data: new Uint8Array(arrayBuf),
1240
+ width: frame.width,
1241
+ height: frame.height,
1242
+ format,
1243
+ timestamp: frame.timestamp
1244
+ };
1245
+ ringBuffer.push(capFrame);
1246
+ });
1247
+ }
1248
+ /**
1249
+ * Subscribe a `'shm'` session's `FrameHandle`s into a per-session ring
1250
+ * buffer drained by `pullHandles`. No pixel bytes cross the cap boundary
1251
+ * the broker opens the named segment and reads the pixels zero-copy.
1252
+ */
1253
+ wireShmSink(sessionId, session) {
1254
+ const handleRing = new RingBuffer(FRAME_BUFFER_CAPACITY);
1255
+ this.handleBuffers.set(sessionId, handleRing);
1256
+ return session.onFrameHandle((frame) => {
1257
+ handleRing.push(frame.handle);
1258
+ });
1259
+ }
1260
+ async destroySession(input) {
1261
+ const { sessionId } = input;
1262
+ const session = this.sessions.get(sessionId);
1263
+ if (!session) throw new Error(`decoder-nodeav: unknown sessionId ${sessionId}`);
1264
+ const unsub = this.unsubscribers.get(sessionId);
1265
+ if (unsub) unsub();
1266
+ await session.destroy();
1267
+ this.sessions.delete(sessionId);
1268
+ this.frameBuffers.delete(sessionId);
1269
+ this.handleBuffers.delete(sessionId);
1270
+ this.unsubscribers.delete(sessionId);
1271
+ this.sessionMeta.delete(sessionId);
1272
+ this.ctx.logger.info("node-av: destroyed session", { meta: { sessionId } });
1273
+ }
1274
+ async listActiveSessions() {
1275
+ const out = [];
1276
+ for (const [sessionId, meta] of this.sessionMeta) out.push({
1277
+ sessionId,
1278
+ codec: meta.codec,
1279
+ outputFormat: meta.outputFormat,
1280
+ createdAtMs: meta.createdAtMs
1281
+ });
1282
+ return out;
1283
+ }
1284
+ async pushPacket(input) {
1285
+ const session = this.sessions.get(input.sessionId);
1286
+ if (!session) throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1287
+ const rawData = input.packet.data;
1288
+ const data = Buffer.isBuffer(rawData) ? rawData : rawData instanceof Uint8Array ? Buffer.from(rawData.buffer, rawData.byteOffset, rawData.byteLength) : Buffer.from(rawData);
1289
+ session.pushPacket({
1290
+ ...input.packet,
1291
+ data
1292
+ });
1293
+ }
1294
+ async openStream(input) {
1295
+ if (!this.sessions.get(input.sessionId)) throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1296
+ input.url;
1297
+ }
1298
+ async pullFrames(input) {
1299
+ if (!this.sessions.has(input.sessionId)) throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1300
+ const ringBuffer = this.frameBuffers.get(input.sessionId);
1301
+ if (!ringBuffer) return [];
1302
+ return ringBuffer.drain(input.maxCount);
1303
+ }
1304
+ async pullHandles(input) {
1305
+ if (!this.sessions.has(input.sessionId)) throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1306
+ const handleRing = this.handleBuffers.get(input.sessionId);
1307
+ if (!handleRing) return [];
1308
+ return handleRing.drain(input.maxCount);
1309
+ }
1310
+ async updateConfig(input) {
1311
+ const session = this.sessions.get(input.sessionId);
1312
+ if (!session) throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1313
+ session.updateConfig(input.config);
1314
+ }
1315
+ async getStats(input) {
1316
+ const session = this.sessions.get(input.sessionId);
1317
+ if (!session) throw new Error(`decoder-nodeav: unknown sessionId ${input.sessionId}`);
1318
+ return session.getStats();
1319
+ }
1320
+ /**
1321
+ * Read back the pixels a `FrameHandle` refers to from this node's shm ring
1322
+ * (Phase 5 / D9 downstream access). Returns `null` when the slot was
1323
+ * already recycled (latest-wins drop) or the segment could not be opened.
1324
+ * The reader cache opens each named segment once and reuses the reader.
1325
+ *
1326
+ * The reader hands back a `Buffer` view over its **reusable scratch
1327
+ * buffer**, only valid until the next `read` on the same reader; the
1328
+ * cap caller may keep the frame across reads, so the pixels are copied
1329
+ * into a fresh detached `ArrayBuffer` here. This matches the existing
1330
+ * callback-path copy in `wireCallbackSink`.
1331
+ */
1332
+ async getFrame(input) {
1333
+ const frame = this.frameReaders?.read(input.handle) ?? null;
1334
+ if (!frame) {
1335
+ this.getFrameMisses += 1;
1336
+ return null;
1337
+ }
1338
+ this.getFrameHits += 1;
1339
+ const arrayBuf = new ArrayBuffer(frame.data.byteLength);
1340
+ new Uint8Array(arrayBuf).set(frame.data);
1341
+ return {
1342
+ data: new Uint8Array(arrayBuf),
1343
+ width: frame.width,
1344
+ height: frame.height,
1345
+ format: frame.format,
1346
+ timestamp: frame.timestamp
1347
+ };
1348
+ }
1349
+ /**
1350
+ * shm ring usage stats for a `frameSink: 'shm'` session — slot geometry,
1351
+ * frames written, byte budget, plus this addon's running `getFrame`
1352
+ * hit/miss counters. Returns `null` for an unknown session or one whose
1353
+ * shm ring has not yet been armed by a first decoded frame.
1354
+ */
1355
+ async getShmStats(input) {
1356
+ const stats = this.sessions.get(input.sessionId)?.frameRingSinkOrNull?.getShmStats() ?? null;
1357
+ if (!stats) return null;
1358
+ return {
1359
+ sessionId: input.sessionId,
1360
+ ...stats,
1361
+ budgetMb: RING_BUDGET_MB,
1362
+ getFrameHits: this.getFrameHits,
1363
+ getFrameMisses: this.getFrameMisses
1364
+ };
1365
+ }
1366
+ async onShutdown() {
1367
+ this.ctx.logger.info("node-av decoder addon shutdown — destroying all sessions");
1368
+ const destroyPromises = [];
1369
+ for (const [sessionId, session] of this.sessions) {
1370
+ const unsub = this.unsubscribers.get(sessionId);
1371
+ if (unsub) unsub();
1372
+ destroyPromises.push(session.destroy());
1373
+ }
1374
+ await Promise.all(destroyPromises);
1375
+ this.sessions.clear();
1376
+ this.frameBuffers.clear();
1377
+ this.handleBuffers.clear();
1378
+ this.unsubscribers.clear();
1379
+ this.sessionMeta.clear();
1380
+ this.frameReaders?.close();
1381
+ this.frameReaders = null;
1382
+ }
1440
1383
  };
1441
- //# sourceMappingURL=index.mjs.map
1384
+ //#endregion
1385
+ export { DecoderFrameRingSink, DecoderNodeAvAddon, DecoderNodeAvAddon as default, NodeAvDecoderSession, makeSegmentName };