@camstack/addon-pipeline 1.1.26 → 1.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,316 @@
1
+ import { FrameRingWriter, MIN_RING_SLOTS, computeSegmentSize, computeSlotByteLength, createSegment, deriveSlotCount } from "@camstack/shm-ring";
2
+ //#region src/shared/decoder-backend.ts
3
+ /** Settings section holding the backend selection. */
4
+ var DECODER_BACKEND_SECTION = "decoder";
5
+ /** Cluster-wide default when nothing is configured — the subprocess decoder. */
6
+ var DEFAULT_DECODER_BACKEND = "ffmpeg";
7
+ /** Narrow an unknown settings value to a {@link DecoderBackend}, else `null`. */
8
+ function parseDecoderBackend(value) {
9
+ return value === "ffmpeg" || value === "nodeav" ? value : null;
10
+ }
11
+ /** Type guard for a plain record (the `nodeBackends` map shape). */
12
+ function isRecord(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+ /**
16
+ * Pure selection from an already-read `decoder` section — extracted so the
17
+ * precedence (per-node override → cluster default → built-in default) is
18
+ * unit-testable without a settings store.
19
+ */
20
+ function pickDecoderBackend(section, nodeId) {
21
+ const overrides = section["nodeBackends"];
22
+ if (isRecord(overrides)) {
23
+ const perNode = parseDecoderBackend(overrides[nodeId]);
24
+ if (perNode !== null) return perNode;
25
+ }
26
+ const clusterWide = parseDecoderBackend(section["backend"]);
27
+ if (clusterWide !== null) return clusterWide;
28
+ return DEFAULT_DECODER_BACKEND;
29
+ }
30
+ /**
31
+ * Resolve the decoder backend this node should run. Called by BOTH decoder
32
+ * addons at the start of `onInitialize`; the addon whose backend is NOT
33
+ * selected registers nothing.
34
+ *
35
+ * Fails safe: no settings access or a read error resolves to
36
+ * {@link DEFAULT_DECODER_BACKEND} — the default backend still registers,
37
+ * and the non-default one stands down (never both).
38
+ */
39
+ async function resolveDecoderBackend(settings, nodeId, logger) {
40
+ if (!settings) return DEFAULT_DECODER_BACKEND;
41
+ try {
42
+ return pickDecoderBackend(await settings.getSection(DECODER_BACKEND_SECTION), nodeId);
43
+ } catch (err) {
44
+ logger.warn("decoder-backend: settings read failed — using default backend", { meta: {
45
+ default: DEFAULT_DECODER_BACKEND,
46
+ error: err instanceof Error ? err.message : String(err)
47
+ } });
48
+ return DEFAULT_DECODER_BACKEND;
49
+ }
50
+ }
51
+ //#endregion
52
+ //#region src/decoder-ffmpeg/frame-ring-sink.ts
53
+ /**
54
+ * `DecoderFrameRingSink` — the decoder's shared-memory write side (Phase 5 / D9).
55
+ *
56
+ * When a decoder session is configured with `frameSink: 'shm'`, the decoder
57
+ * **owns** the shared-memory ring segment for that stream: it creates the
58
+ * segment on the first decoded frame (when the output geometry is known),
59
+ * writes every subsequent decoded frame into the ring via a `FrameRingWriter`,
60
+ * and closes + unlinks the segment when the session is destroyed.
61
+ *
62
+ * What leaves the decoder is no longer the pixel `Buffer` — it is a tiny,
63
+ * serialisable `FrameHandle` (`FrameRingWriter.writeFrame`'s return value).
64
+ * Same-host consumers (motion, detection, the WebRTC encoder) open the same
65
+ * segment with a `FrameRingReader` and read the pixels zero-copy.
66
+ *
67
+ * ## Lazy segment creation
68
+ *
69
+ * The segment cannot be sized until the first frame: `slotByteLength` is
70
+ * `width × height × bytesPerPixel`, and the output dimensions are only known
71
+ * once the scaler has produced its first `dstFrame`. So `writeFrame` is a
72
+ * no-op-until-armed: the first call sizes + creates the segment, every later
73
+ * call writes into it.
74
+ *
75
+ * ## Resolution-change decision
76
+ *
77
+ * A live camera stream can change resolution mid-stream (the decoder's scaler
78
+ * is rebuilt on a config toggle, or the source renegotiates). The slot is
79
+ * sized for the **first** frame's geometry. A later frame that no longer fits
80
+ * the slot triggers a **segment re-create**: the old segment is closed +
81
+ * unlinked and a fresh, larger segment is created under a new generation-tagged
82
+ * name. This is simpler and leak-free versus over-allocating slots for a
83
+ * worst-case 4K frame on every stream; resolution changes on a live camera are
84
+ * rare, and a brief gap while consumers re-open the segment is acceptable
85
+ * (latest-wins — a missed frame is correct behaviour).
86
+ */
87
+ /**
88
+ * Per-ring shared-memory budget (MB) — the slot count is derived per-resolution
89
+ * from this budget via {@link deriveSlotCount}, so a 360p stream gets many
90
+ * slots and a 4K stream a few, both inside the same memory footprint.
91
+ *
92
+ * Read ONCE at module load from `CAMSTACK_SHM_RING_BUDGET_MB`; a non-finite or
93
+ * non-positive value falls back to the 16 MB default.
94
+ *
95
+ * The default is deliberately small (16 MB) so many concurrent per-camera rings
96
+ * fit inside a bounded `/dev/shm`. The decoder output is capped at 640px wide, so
97
+ * a slot is ≤ ~920 KB and 16 MB still yields ~17–70 latest-wins slots. A ring
98
+ * segment larger than the container's `/dev/shm` tmpfs backing (Docker default is
99
+ * only 64 MB) faults an **uncatchable SIGBUS** on write past `st_size` — the old
100
+ * 128 MB default overflowed a 64 MB `/dev/shm` and crashed the decoder on 8MP
101
+ * streams. Pair this with a `--shm-size` that scales with concurrent-decode load.
102
+ */
103
+ var RING_BUDGET_MB = (() => {
104
+ const raw = Number(process.env["CAMSTACK_SHM_RING_BUDGET_MB"]);
105
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 16;
106
+ })();
107
+ /** {@link RING_BUDGET_MB} in bytes — the budget passed to `deriveSlotCount`. */
108
+ var RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
109
+ /** A unique, stable shared-memory segment name for a decoder stream.
110
+ *
111
+ * macOS POSIX shm names are capped at ~31 characters (`PSHMNAMLEN`). A
112
+ * `camstack.frames.<deviceId>.<streamId>` scheme overflows that for realistic
113
+ * ids, so the sink uses a short, collision-resistant scheme instead:
114
+ * `csf.<base36 hash>.<gen>`. The hash folds the device id, the session tag
115
+ * and a per-process random salt; the generation suffix makes a re-created
116
+ * segment (resolution change) a distinct name so a stale consumer mapping is
117
+ * never silently reused.
118
+ */
119
+ function makeSegmentName(seed, generation) {
120
+ let hash = 5381;
121
+ for (let i = 0; i < seed.length; i += 1) hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
122
+ return `csf.${(hash >>> 0).toString(36)}.${generation}`;
123
+ }
124
+ /**
125
+ * The decoder-side owner of one stream's shared-memory frame ring.
126
+ *
127
+ * Not constructed until a session actually uses the shm sink; the segment
128
+ * itself is created lazily on the first `writeFrame`.
129
+ */
130
+ var DecoderFrameRingSink = class {
131
+ seed;
132
+ logger;
133
+ nodeId;
134
+ segment = null;
135
+ writer = null;
136
+ segmentName = null;
137
+ slotByteLength = 0;
138
+ generation = 0;
139
+ destroyed = false;
140
+ /** Frames committed into the ring across this sink's lifetime (all generations). */
141
+ framesWritten = 0;
142
+ constructor(options) {
143
+ const salt = Math.random().toString(36).slice(2, 8);
144
+ this.seed = `${options.seed}.${salt}`;
145
+ this.logger = options.logger;
146
+ this.nodeId = options.nodeId;
147
+ }
148
+ /** Whether a segment has been created (i.e. at least one frame written). */
149
+ get isArmed() {
150
+ return this.writer !== null;
151
+ }
152
+ /** The current segment name, or `null` before the first frame. */
153
+ get currentSegmentName() {
154
+ return this.segmentName;
155
+ }
156
+ /**
157
+ * Write one decoded frame into the ring and return its `FrameHandle`.
158
+ *
159
+ * On the first call (or after a geometry change that overflows the current
160
+ * slot) the segment is created / re-created sized for this frame. Returns
161
+ * `null` only when the sink has been destroyed.
162
+ *
163
+ * This is the copy-in convenience form (it copies `pixels` into the slot).
164
+ * The decoder's hot path uses the zero-copy {@link beginFrame} /
165
+ * {@link commitFrame} scatter-write pair instead — the scaler produces its
166
+ * packed output directly into the slot, eliminating the write-side memcpy.
167
+ */
168
+ writeFrame(pixels, meta) {
169
+ if (this.destroyed) return null;
170
+ if (this.writer === null || computeSlotByteLength(meta.width, meta.height, meta.format) > this.slotByteLength) this.recreateSegment(computeSlotByteLength(meta.width, meta.height, meta.format));
171
+ const writer = this.writer;
172
+ if (writer === null) return null;
173
+ const handle = writer.writeFrame(pixels, meta);
174
+ this.framesWritten += 1;
175
+ return handle;
176
+ }
177
+ /**
178
+ * Reserve a ring slot for a frame of the given geometry — the **zero-copy**
179
+ * scatter-write entry point (Phase 5 / D9 Task 7c).
180
+ *
181
+ * The segment is created / re-created here if this is the first frame or the
182
+ * geometry overflows the current slot capacity, so the slot is correctly
183
+ * sized before the caller fills it. The returned `buffer` is a writable view
184
+ * **directly over the mapped segment** — the node-av scaler scatters its
185
+ * packed output straight into it, with no intermediate copy. The caller MUST
186
+ * call {@link commitFrame} with the returned `slot` once the slot is filled.
187
+ *
188
+ * Returns `null` when the sink is destroyed or the segment cannot be created.
189
+ */
190
+ beginFrame(width, height, format) {
191
+ if (this.destroyed) return null;
192
+ const requiredSlotBytes = computeSlotByteLength(width, height, format);
193
+ if (this.writer === null || requiredSlotBytes > this.slotByteLength) this.recreateSegment(requiredSlotBytes);
194
+ const writer = this.writer;
195
+ if (writer === null) return null;
196
+ const { slot, buffer } = writer.beginFrame();
197
+ return {
198
+ slot,
199
+ buffer
200
+ };
201
+ }
202
+ /**
203
+ * Publish the frame whose slot was reserved by {@link beginFrame} and filled
204
+ * in place by the caller. `slot` MUST be the value from the matching
205
+ * `beginFrame`. Returns the published `FrameHandle`, or `null` if the sink
206
+ * was destroyed (or the segment lost) between begin and commit.
207
+ */
208
+ commitFrame(slot, meta) {
209
+ if (this.destroyed) return null;
210
+ const writer = this.writer;
211
+ if (writer === null) return null;
212
+ const handle = writer.commitFrame(slot, meta);
213
+ this.framesWritten += 1;
214
+ return handle;
215
+ }
216
+ /**
217
+ * Current shm ring usage — `null` until the first frame arms the segment.
218
+ * Surfaced through `decoder.getShmStats` so a downstream consumer can
219
+ * observe ring pressure (slot depth, byte budget, frames written).
220
+ */
221
+ getShmStats() {
222
+ if (this.writer === null) return null;
223
+ return {
224
+ slotCount: this.writer.slotCount,
225
+ slotByteLength: this.slotByteLength,
226
+ segmentBytes: computeSegmentSize(this.writer.slotCount, this.slotByteLength),
227
+ framesWritten: this.framesWritten
228
+ };
229
+ }
230
+ /**
231
+ * Abandon a slot reserved by {@link beginFrame} **without publishing it** —
232
+ * the degenerate-path counterpart of {@link commitFrame}.
233
+ *
234
+ * A caller that reserved a slot but then could not produce valid pixels (no
235
+ * decoded source planes, or the scaler threw) MUST call this instead of
236
+ * `commitFrame`: it closes the open seqlock without advancing `writeIndex`,
237
+ * so no reader ever sees the slot's uninitialised bytes as a real frame, and
238
+ * no `FrameHandle` is handed downstream. `slot` MUST be the value from the
239
+ * matching `beginFrame`. A no-op if the sink was destroyed (or the segment
240
+ * lost) between begin and abort.
241
+ */
242
+ abortFrame(slot) {
243
+ if (this.destroyed) return;
244
+ const writer = this.writer;
245
+ if (writer === null) return;
246
+ writer.abortFrame(slot);
247
+ }
248
+ /** Close + unlink the segment. Idempotent. */
249
+ destroy() {
250
+ if (this.destroyed) return;
251
+ this.destroyed = true;
252
+ this.releaseSegment();
253
+ }
254
+ /**
255
+ * Create a fresh segment sized for at least `slotByteLength` bytes per slot,
256
+ * replacing any prior one. A re-create bumps the generation so the new
257
+ * segment has a distinct name — a consumer holding the old mapping is never
258
+ * silently handed a resized segment.
259
+ */
260
+ recreateSegment(slotByteLength) {
261
+ this.releaseSegment();
262
+ this.generation += 1;
263
+ const name = makeSegmentName(this.seed, this.generation);
264
+ const slotCount = deriveSlotCount(RING_BUDGET_BYTES, slotByteLength);
265
+ 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: {
266
+ slotByteLength,
267
+ budgetMb: RING_BUDGET_MB
268
+ } });
269
+ const totalBytes = computeSegmentSize(slotCount, slotByteLength);
270
+ try {
271
+ const segment = createSegment(name, totalBytes);
272
+ this.segment = segment;
273
+ this.segmentName = name;
274
+ this.slotByteLength = slotByteLength;
275
+ this.writer = new FrameRingWriter(segment.buffer, name, slotCount, slotByteLength, this.nodeId);
276
+ this.logger.info("decoder shm ring: segment created", { meta: {
277
+ segment: name,
278
+ slotCount,
279
+ slotByteLength,
280
+ totalBytes,
281
+ generation: this.generation
282
+ } });
283
+ } catch (err) {
284
+ this.segment = null;
285
+ this.writer = null;
286
+ this.segmentName = null;
287
+ this.slotByteLength = 0;
288
+ this.logger.error("decoder shm ring: segment create failed", { meta: {
289
+ segment: name,
290
+ slotByteLength,
291
+ error: err instanceof Error ? err.message : String(err)
292
+ } });
293
+ }
294
+ }
295
+ /** Unmap + unlink the current segment, if any. */
296
+ releaseSegment() {
297
+ const segment = this.segment;
298
+ if (segment === null) return;
299
+ this.segment = null;
300
+ this.writer = null;
301
+ const name = this.segmentName;
302
+ this.segmentName = null;
303
+ try {
304
+ segment.close();
305
+ segment.unlink();
306
+ this.logger.info("decoder shm ring: segment released", { meta: { segment: name } });
307
+ } catch (err) {
308
+ this.logger.warn("decoder shm ring: segment release failed", { meta: {
309
+ segment: name,
310
+ error: err instanceof Error ? err.message : String(err)
311
+ } });
312
+ }
313
+ }
314
+ };
315
+ //#endregion
316
+ export { resolveDecoderBackend as i, RING_BUDGET_MB as n, makeSegmentName as r, DecoderFrameRingSink as t };
@@ -0,0 +1,339 @@
1
+ let _camstack_shm_ring = require("@camstack/shm-ring");
2
+ //#region src/shared/decoder-backend.ts
3
+ /** Settings section holding the backend selection. */
4
+ var DECODER_BACKEND_SECTION = "decoder";
5
+ /** Cluster-wide default when nothing is configured — the subprocess decoder. */
6
+ var DEFAULT_DECODER_BACKEND = "ffmpeg";
7
+ /** Narrow an unknown settings value to a {@link DecoderBackend}, else `null`. */
8
+ function parseDecoderBackend(value) {
9
+ return value === "ffmpeg" || value === "nodeav" ? value : null;
10
+ }
11
+ /** Type guard for a plain record (the `nodeBackends` map shape). */
12
+ function isRecord(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+ /**
16
+ * Pure selection from an already-read `decoder` section — extracted so the
17
+ * precedence (per-node override → cluster default → built-in default) is
18
+ * unit-testable without a settings store.
19
+ */
20
+ function pickDecoderBackend(section, nodeId) {
21
+ const overrides = section["nodeBackends"];
22
+ if (isRecord(overrides)) {
23
+ const perNode = parseDecoderBackend(overrides[nodeId]);
24
+ if (perNode !== null) return perNode;
25
+ }
26
+ const clusterWide = parseDecoderBackend(section["backend"]);
27
+ if (clusterWide !== null) return clusterWide;
28
+ return DEFAULT_DECODER_BACKEND;
29
+ }
30
+ /**
31
+ * Resolve the decoder backend this node should run. Called by BOTH decoder
32
+ * addons at the start of `onInitialize`; the addon whose backend is NOT
33
+ * selected registers nothing.
34
+ *
35
+ * Fails safe: no settings access or a read error resolves to
36
+ * {@link DEFAULT_DECODER_BACKEND} — the default backend still registers,
37
+ * and the non-default one stands down (never both).
38
+ */
39
+ async function resolveDecoderBackend(settings, nodeId, logger) {
40
+ if (!settings) return DEFAULT_DECODER_BACKEND;
41
+ try {
42
+ return pickDecoderBackend(await settings.getSection(DECODER_BACKEND_SECTION), nodeId);
43
+ } catch (err) {
44
+ logger.warn("decoder-backend: settings read failed — using default backend", { meta: {
45
+ default: DEFAULT_DECODER_BACKEND,
46
+ error: err instanceof Error ? err.message : String(err)
47
+ } });
48
+ return DEFAULT_DECODER_BACKEND;
49
+ }
50
+ }
51
+ //#endregion
52
+ //#region src/decoder-ffmpeg/frame-ring-sink.ts
53
+ /**
54
+ * `DecoderFrameRingSink` — the decoder's shared-memory write side (Phase 5 / D9).
55
+ *
56
+ * When a decoder session is configured with `frameSink: 'shm'`, the decoder
57
+ * **owns** the shared-memory ring segment for that stream: it creates the
58
+ * segment on the first decoded frame (when the output geometry is known),
59
+ * writes every subsequent decoded frame into the ring via a `FrameRingWriter`,
60
+ * and closes + unlinks the segment when the session is destroyed.
61
+ *
62
+ * What leaves the decoder is no longer the pixel `Buffer` — it is a tiny,
63
+ * serialisable `FrameHandle` (`FrameRingWriter.writeFrame`'s return value).
64
+ * Same-host consumers (motion, detection, the WebRTC encoder) open the same
65
+ * segment with a `FrameRingReader` and read the pixels zero-copy.
66
+ *
67
+ * ## Lazy segment creation
68
+ *
69
+ * The segment cannot be sized until the first frame: `slotByteLength` is
70
+ * `width × height × bytesPerPixel`, and the output dimensions are only known
71
+ * once the scaler has produced its first `dstFrame`. So `writeFrame` is a
72
+ * no-op-until-armed: the first call sizes + creates the segment, every later
73
+ * call writes into it.
74
+ *
75
+ * ## Resolution-change decision
76
+ *
77
+ * A live camera stream can change resolution mid-stream (the decoder's scaler
78
+ * is rebuilt on a config toggle, or the source renegotiates). The slot is
79
+ * sized for the **first** frame's geometry. A later frame that no longer fits
80
+ * the slot triggers a **segment re-create**: the old segment is closed +
81
+ * unlinked and a fresh, larger segment is created under a new generation-tagged
82
+ * name. This is simpler and leak-free versus over-allocating slots for a
83
+ * worst-case 4K frame on every stream; resolution changes on a live camera are
84
+ * rare, and a brief gap while consumers re-open the segment is acceptable
85
+ * (latest-wins — a missed frame is correct behaviour).
86
+ */
87
+ /**
88
+ * Per-ring shared-memory budget (MB) — the slot count is derived per-resolution
89
+ * from this budget via {@link deriveSlotCount}, so a 360p stream gets many
90
+ * slots and a 4K stream a few, both inside the same memory footprint.
91
+ *
92
+ * Read ONCE at module load from `CAMSTACK_SHM_RING_BUDGET_MB`; a non-finite or
93
+ * non-positive value falls back to the 16 MB default.
94
+ *
95
+ * The default is deliberately small (16 MB) so many concurrent per-camera rings
96
+ * fit inside a bounded `/dev/shm`. The decoder output is capped at 640px wide, so
97
+ * a slot is ≤ ~920 KB and 16 MB still yields ~17–70 latest-wins slots. A ring
98
+ * segment larger than the container's `/dev/shm` tmpfs backing (Docker default is
99
+ * only 64 MB) faults an **uncatchable SIGBUS** on write past `st_size` — the old
100
+ * 128 MB default overflowed a 64 MB `/dev/shm` and crashed the decoder on 8MP
101
+ * streams. Pair this with a `--shm-size` that scales with concurrent-decode load.
102
+ */
103
+ var RING_BUDGET_MB = (() => {
104
+ const raw = Number(process.env["CAMSTACK_SHM_RING_BUDGET_MB"]);
105
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 16;
106
+ })();
107
+ /** {@link RING_BUDGET_MB} in bytes — the budget passed to `deriveSlotCount`. */
108
+ var RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
109
+ /** A unique, stable shared-memory segment name for a decoder stream.
110
+ *
111
+ * macOS POSIX shm names are capped at ~31 characters (`PSHMNAMLEN`). A
112
+ * `camstack.frames.<deviceId>.<streamId>` scheme overflows that for realistic
113
+ * ids, so the sink uses a short, collision-resistant scheme instead:
114
+ * `csf.<base36 hash>.<gen>`. The hash folds the device id, the session tag
115
+ * and a per-process random salt; the generation suffix makes a re-created
116
+ * segment (resolution change) a distinct name so a stale consumer mapping is
117
+ * never silently reused.
118
+ */
119
+ function makeSegmentName(seed, generation) {
120
+ let hash = 5381;
121
+ for (let i = 0; i < seed.length; i += 1) hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
122
+ return `csf.${(hash >>> 0).toString(36)}.${generation}`;
123
+ }
124
+ /**
125
+ * The decoder-side owner of one stream's shared-memory frame ring.
126
+ *
127
+ * Not constructed until a session actually uses the shm sink; the segment
128
+ * itself is created lazily on the first `writeFrame`.
129
+ */
130
+ var DecoderFrameRingSink = class {
131
+ seed;
132
+ logger;
133
+ nodeId;
134
+ segment = null;
135
+ writer = null;
136
+ segmentName = null;
137
+ slotByteLength = 0;
138
+ generation = 0;
139
+ destroyed = false;
140
+ /** Frames committed into the ring across this sink's lifetime (all generations). */
141
+ framesWritten = 0;
142
+ constructor(options) {
143
+ const salt = Math.random().toString(36).slice(2, 8);
144
+ this.seed = `${options.seed}.${salt}`;
145
+ this.logger = options.logger;
146
+ this.nodeId = options.nodeId;
147
+ }
148
+ /** Whether a segment has been created (i.e. at least one frame written). */
149
+ get isArmed() {
150
+ return this.writer !== null;
151
+ }
152
+ /** The current segment name, or `null` before the first frame. */
153
+ get currentSegmentName() {
154
+ return this.segmentName;
155
+ }
156
+ /**
157
+ * Write one decoded frame into the ring and return its `FrameHandle`.
158
+ *
159
+ * On the first call (or after a geometry change that overflows the current
160
+ * slot) the segment is created / re-created sized for this frame. Returns
161
+ * `null` only when the sink has been destroyed.
162
+ *
163
+ * This is the copy-in convenience form (it copies `pixels` into the slot).
164
+ * The decoder's hot path uses the zero-copy {@link beginFrame} /
165
+ * {@link commitFrame} scatter-write pair instead — the scaler produces its
166
+ * packed output directly into the slot, eliminating the write-side memcpy.
167
+ */
168
+ writeFrame(pixels, meta) {
169
+ if (this.destroyed) return null;
170
+ if (this.writer === null || (0, _camstack_shm_ring.computeSlotByteLength)(meta.width, meta.height, meta.format) > this.slotByteLength) this.recreateSegment((0, _camstack_shm_ring.computeSlotByteLength)(meta.width, meta.height, meta.format));
171
+ const writer = this.writer;
172
+ if (writer === null) return null;
173
+ const handle = writer.writeFrame(pixels, meta);
174
+ this.framesWritten += 1;
175
+ return handle;
176
+ }
177
+ /**
178
+ * Reserve a ring slot for a frame of the given geometry — the **zero-copy**
179
+ * scatter-write entry point (Phase 5 / D9 Task 7c).
180
+ *
181
+ * The segment is created / re-created here if this is the first frame or the
182
+ * geometry overflows the current slot capacity, so the slot is correctly
183
+ * sized before the caller fills it. The returned `buffer` is a writable view
184
+ * **directly over the mapped segment** — the node-av scaler scatters its
185
+ * packed output straight into it, with no intermediate copy. The caller MUST
186
+ * call {@link commitFrame} with the returned `slot` once the slot is filled.
187
+ *
188
+ * Returns `null` when the sink is destroyed or the segment cannot be created.
189
+ */
190
+ beginFrame(width, height, format) {
191
+ if (this.destroyed) return null;
192
+ const requiredSlotBytes = (0, _camstack_shm_ring.computeSlotByteLength)(width, height, format);
193
+ if (this.writer === null || requiredSlotBytes > this.slotByteLength) this.recreateSegment(requiredSlotBytes);
194
+ const writer = this.writer;
195
+ if (writer === null) return null;
196
+ const { slot, buffer } = writer.beginFrame();
197
+ return {
198
+ slot,
199
+ buffer
200
+ };
201
+ }
202
+ /**
203
+ * Publish the frame whose slot was reserved by {@link beginFrame} and filled
204
+ * in place by the caller. `slot` MUST be the value from the matching
205
+ * `beginFrame`. Returns the published `FrameHandle`, or `null` if the sink
206
+ * was destroyed (or the segment lost) between begin and commit.
207
+ */
208
+ commitFrame(slot, meta) {
209
+ if (this.destroyed) return null;
210
+ const writer = this.writer;
211
+ if (writer === null) return null;
212
+ const handle = writer.commitFrame(slot, meta);
213
+ this.framesWritten += 1;
214
+ return handle;
215
+ }
216
+ /**
217
+ * Current shm ring usage — `null` until the first frame arms the segment.
218
+ * Surfaced through `decoder.getShmStats` so a downstream consumer can
219
+ * observe ring pressure (slot depth, byte budget, frames written).
220
+ */
221
+ getShmStats() {
222
+ if (this.writer === null) return null;
223
+ return {
224
+ slotCount: this.writer.slotCount,
225
+ slotByteLength: this.slotByteLength,
226
+ segmentBytes: (0, _camstack_shm_ring.computeSegmentSize)(this.writer.slotCount, this.slotByteLength),
227
+ framesWritten: this.framesWritten
228
+ };
229
+ }
230
+ /**
231
+ * Abandon a slot reserved by {@link beginFrame} **without publishing it** —
232
+ * the degenerate-path counterpart of {@link commitFrame}.
233
+ *
234
+ * A caller that reserved a slot but then could not produce valid pixels (no
235
+ * decoded source planes, or the scaler threw) MUST call this instead of
236
+ * `commitFrame`: it closes the open seqlock without advancing `writeIndex`,
237
+ * so no reader ever sees the slot's uninitialised bytes as a real frame, and
238
+ * no `FrameHandle` is handed downstream. `slot` MUST be the value from the
239
+ * matching `beginFrame`. A no-op if the sink was destroyed (or the segment
240
+ * lost) between begin and abort.
241
+ */
242
+ abortFrame(slot) {
243
+ if (this.destroyed) return;
244
+ const writer = this.writer;
245
+ if (writer === null) return;
246
+ writer.abortFrame(slot);
247
+ }
248
+ /** Close + unlink the segment. Idempotent. */
249
+ destroy() {
250
+ if (this.destroyed) return;
251
+ this.destroyed = true;
252
+ this.releaseSegment();
253
+ }
254
+ /**
255
+ * Create a fresh segment sized for at least `slotByteLength` bytes per slot,
256
+ * replacing any prior one. A re-create bumps the generation so the new
257
+ * segment has a distinct name — a consumer holding the old mapping is never
258
+ * silently handed a resized segment.
259
+ */
260
+ recreateSegment(slotByteLength) {
261
+ this.releaseSegment();
262
+ this.generation += 1;
263
+ const name = makeSegmentName(this.seed, this.generation);
264
+ const slotCount = (0, _camstack_shm_ring.deriveSlotCount)(RING_BUDGET_BYTES, slotByteLength);
265
+ if (slotCount === _camstack_shm_ring.MIN_RING_SLOTS && _camstack_shm_ring.MIN_RING_SLOTS * slotByteLength > RING_BUDGET_BYTES) this.logger.warn("decoder shm ring: budget too small for resolution — using MIN slots", { meta: {
266
+ slotByteLength,
267
+ budgetMb: RING_BUDGET_MB
268
+ } });
269
+ const totalBytes = (0, _camstack_shm_ring.computeSegmentSize)(slotCount, slotByteLength);
270
+ try {
271
+ const segment = (0, _camstack_shm_ring.createSegment)(name, totalBytes);
272
+ this.segment = segment;
273
+ this.segmentName = name;
274
+ this.slotByteLength = slotByteLength;
275
+ this.writer = new _camstack_shm_ring.FrameRingWriter(segment.buffer, name, slotCount, slotByteLength, this.nodeId);
276
+ this.logger.info("decoder shm ring: segment created", { meta: {
277
+ segment: name,
278
+ slotCount,
279
+ slotByteLength,
280
+ totalBytes,
281
+ generation: this.generation
282
+ } });
283
+ } catch (err) {
284
+ this.segment = null;
285
+ this.writer = null;
286
+ this.segmentName = null;
287
+ this.slotByteLength = 0;
288
+ this.logger.error("decoder shm ring: segment create failed", { meta: {
289
+ segment: name,
290
+ slotByteLength,
291
+ error: err instanceof Error ? err.message : String(err)
292
+ } });
293
+ }
294
+ }
295
+ /** Unmap + unlink the current segment, if any. */
296
+ releaseSegment() {
297
+ const segment = this.segment;
298
+ if (segment === null) return;
299
+ this.segment = null;
300
+ this.writer = null;
301
+ const name = this.segmentName;
302
+ this.segmentName = null;
303
+ try {
304
+ segment.close();
305
+ segment.unlink();
306
+ this.logger.info("decoder shm ring: segment released", { meta: { segment: name } });
307
+ } catch (err) {
308
+ this.logger.warn("decoder shm ring: segment release failed", { meta: {
309
+ segment: name,
310
+ error: err instanceof Error ? err.message : String(err)
311
+ } });
312
+ }
313
+ }
314
+ };
315
+ //#endregion
316
+ Object.defineProperty(exports, "DecoderFrameRingSink", {
317
+ enumerable: true,
318
+ get: function() {
319
+ return DecoderFrameRingSink;
320
+ }
321
+ });
322
+ Object.defineProperty(exports, "RING_BUDGET_MB", {
323
+ enumerable: true,
324
+ get: function() {
325
+ return RING_BUDGET_MB;
326
+ }
327
+ });
328
+ Object.defineProperty(exports, "makeSegmentName", {
329
+ enumerable: true,
330
+ get: function() {
331
+ return makeSegmentName;
332
+ }
333
+ });
334
+ Object.defineProperty(exports, "resolveDecoderBackend", {
335
+ enumerable: true,
336
+ get: function() {
337
+ return resolveDecoderBackend;
338
+ }
339
+ });