@camstack/shm-ring 0.1.2

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.
package/dist/index.js ADDED
@@ -0,0 +1,655 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_module = require("node:module");
3
+ let node_url = require("node:url");
4
+ let node_path = require("node:path");
5
+ let _camstack_types = require("@camstack/types");
6
+ //#region src/native.ts
7
+ /**
8
+ * Typed wrapper over the `shm_ring` N-API addon.
9
+ *
10
+ * The addon maps/unmaps a named OS shared-memory segment and hands JS a
11
+ * zero-copy `Buffer` over the mapping. The ring/seqlock logic lives in
12
+ * `frame-ring.ts` (Task 2) — this module is purely the segment plumbing.
13
+ */
14
+ var require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
15
+ var here = (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
16
+ /**
17
+ * `node-gyp-build` resolves a prebuilt binary if one exists, otherwise the
18
+ * locally compiled `build/Release/shm_ring.node`. Pointed at the package root
19
+ * (one level above `dist/` / `src/`) so both layouts resolve.
20
+ */
21
+ function loadAddon() {
22
+ const addon = require$1("node-gyp-build")((0, node_path.dirname)(here));
23
+ if (!isShmAddon(addon)) throw new Error("@camstack/shm-ring: native addon did not expose the expected surface");
24
+ return addon;
25
+ }
26
+ function hasFunction(value, key) {
27
+ return typeof Reflect.get(value, key) === "function";
28
+ }
29
+ function isShmAddon(value) {
30
+ if (typeof value !== "object" || value === null) return false;
31
+ return hasFunction(value, "create") && hasFunction(value, "open") && hasFunction(value, "close") && hasFunction(value, "unlink");
32
+ }
33
+ var addon = loadAddon();
34
+ function wrap(name, native) {
35
+ let closed = false;
36
+ let unlinked = false;
37
+ return {
38
+ buffer: native.buffer,
39
+ close() {
40
+ if (closed) return;
41
+ closed = true;
42
+ addon.close(native.handle);
43
+ },
44
+ unlink() {
45
+ if (unlinked) return;
46
+ unlinked = true;
47
+ addon.unlink(name);
48
+ }
49
+ };
50
+ }
51
+ /**
52
+ * Create a new named shared-memory segment of `byteLength` bytes and map it.
53
+ * The segment is zero-filled. Fails if a segment with this name already exists.
54
+ */
55
+ function createSegment(name, byteLength) {
56
+ return wrap(name, addon.create(name, byteLength));
57
+ }
58
+ /**
59
+ * Map an existing named shared-memory segment. `byteLength` must match (or be
60
+ * smaller than) the size the segment was created with. Throws if the segment
61
+ * does not exist.
62
+ */
63
+ function openSegment(name, byteLength) {
64
+ return wrap(name, addon.open(name, byteLength));
65
+ }
66
+ /**
67
+ * Remove a segment name from the OS namespace without holding a mapping.
68
+ * Convenience for cleanup paths that never mapped the segment themselves.
69
+ */
70
+ function unlinkSegment(name) {
71
+ addon.unlink(name);
72
+ }
73
+ //#endregion
74
+ //#region src/frame-ring.ts
75
+ /** Header slot indices in the `Int32Array` header view. */
76
+ var HDR_SLOT_COUNT = 0;
77
+ var HDR_SLOT_BYTE_LENGTH = 1;
78
+ var HDR_WRITE_INDEX = 2;
79
+ /** Fixed header fields before the per-slot `seq[]` array begins. */
80
+ var HDR_FIXED_FIELDS = 3;
81
+ /** Bytes of metadata reserved per slot (24 used + 8 padding → 8-byte aligned). */
82
+ var SLOT_META_BYTES = 32;
83
+ var META_OFF_WIDTH = 0;
84
+ var META_OFF_HEIGHT = 4;
85
+ var META_OFF_FORMAT = 8;
86
+ var META_OFF_BYTE_LENGTH = 12;
87
+ var META_OFF_PTS = 16;
88
+ /**
89
+ * Stable integer codes for `FrameFormat` — metadata stores the index, not the
90
+ * string, so a slot's metadata is a fixed-width struct. Append-only: never
91
+ * reorder or remove an entry, or existing segments would mis-decode.
92
+ */
93
+ var FRAME_FORMAT_CODES = [
94
+ "jpeg",
95
+ "rgb",
96
+ "bgr",
97
+ "yuv420",
98
+ "gray"
99
+ ];
100
+ function encodeFormat(format) {
101
+ const code = FRAME_FORMAT_CODES.indexOf(format);
102
+ if (code < 0) throw new Error(`FrameRing: unknown frame format "${format}"`);
103
+ return code;
104
+ }
105
+ function decodeFormat(code) {
106
+ const format = FRAME_FORMAT_CODES[code];
107
+ if (format === void 0) throw new Error(`FrameRing: unknown frame format code ${code}`);
108
+ return format;
109
+ }
110
+ /** Round `value` up to the next multiple of `align` (a power of two). */
111
+ function alignUp(value, align) {
112
+ return Math.ceil(value / align) * align;
113
+ }
114
+ /** Compute the geometry of a ring with the given slot count and slot size. */
115
+ function computeGeometry(slotCount, slotByteLength) {
116
+ if (!Number.isInteger(slotCount) || slotCount <= 0) throw new Error(`FrameRing: slotCount must be a positive integer, got ${slotCount}`);
117
+ if (!Number.isInteger(slotByteLength) || slotByteLength <= 0) throw new Error(`FrameRing: slotByteLength must be a positive integer, got ${slotByteLength}`);
118
+ const headerBytes = alignUp((HDR_FIXED_FIELDS + slotCount) * 4, 8);
119
+ const metaOffset = headerBytes;
120
+ const pixelsOffset = alignUp(metaOffset + slotCount * SLOT_META_BYTES, 8);
121
+ return {
122
+ slotCount,
123
+ slotByteLength,
124
+ headerBytes,
125
+ metaOffset,
126
+ pixelsOffset,
127
+ totalBytes: pixelsOffset + slotCount * slotByteLength
128
+ };
129
+ }
130
+ /**
131
+ * Total byte length a segment must have to hold a `slotCount`-slot ring with
132
+ * `slotByteLength`-byte pixel slots. The decoder uses this to size the
133
+ * shared-memory segment before constructing a `FrameRingWriter` over it.
134
+ */
135
+ function computeSegmentSize(slotCount, slotByteLength) {
136
+ return computeGeometry(slotCount, slotByteLength).totalBytes;
137
+ }
138
+ /**
139
+ * Slot-count bounds for a per-resolution ring. {@link deriveSlotCount} clamps
140
+ * the budget-derived count into `[MIN_RING_SLOTS, MAX_RING_SLOTS]`:
141
+ * - `MIN` keeps a tiny floor even when one frame nearly exhausts the budget
142
+ * (a 4K RGB frame is ~24.9 MB — a 128 MB budget yields only ~5 raw slots);
143
+ * - `MAX` caps the ring for small frames so a 360p stream does not allocate
144
+ * hundreds of slots' worth of shared memory it will never use latest-wins.
145
+ */
146
+ var MIN_RING_SLOTS = 6;
147
+ var MAX_RING_SLOTS = 64;
148
+ /**
149
+ * Slot count for a ring, derived from a per-ring shared-memory budget.
150
+ *
151
+ * `budgetBytes` is the shared memory a single stream's ring may consume; the
152
+ * raw count is `floor(budgetBytes / slotByteLength)`, clamped into
153
+ * `[MIN_RING_SLOTS, MAX_RING_SLOTS]`. Live video is latest-wins, so the slot
154
+ * count only needs to absorb the writer-commit / reader-read window — the
155
+ * budget trades shared-memory footprint against that window per resolution.
156
+ */
157
+ function deriveSlotCount(budgetBytes, slotByteLength) {
158
+ const raw = Math.floor(budgetBytes / slotByteLength);
159
+ return Math.min(64, Math.max(6, raw));
160
+ }
161
+ /**
162
+ * Bytes per pixel for each packed decode format a frame ring slot can hold.
163
+ *
164
+ * This is the single source of truth shared by the decoder write side
165
+ * (`DecoderFrameRingSink`) and every consumer read side
166
+ * (`FrameRingReaderCache`): if the two computed different values, a reader
167
+ * would mis-map the shared-memory segment and read corrupt pixels.
168
+ *
169
+ * `jpeg` has no fixed bytes-per-pixel — `computeSlotByteLength` short-circuits
170
+ * it to the equivalent RGB24 raster before this is ever consulted, so the `3`
171
+ * here is only a safe placeholder for exhaustiveness over `FrameFormat`.
172
+ */
173
+ function bytesPerPixel(format) {
174
+ switch (format) {
175
+ case "gray": return 1;
176
+ case "rgb":
177
+ case "bgr": return 3;
178
+ case "yuv420": return 2;
179
+ case "jpeg": return 3;
180
+ }
181
+ }
182
+ /**
183
+ * Per-slot byte capacity for a frame of the given geometry — the single source
184
+ * of truth for both the decoder write side and the consumer read side.
185
+ *
186
+ * For raw formats this is exactly `width × height × bpp`. For `jpeg` (variable
187
+ * length) the slot is sized to the equivalent RGB24 raster — a JPEG is always
188
+ * far smaller than its source raster, so an RGB24-sized slot is a safe upper
189
+ * bound. Note: this upper bound holds only while the JPEG encoder is pinned to
190
+ * its current quality (~80). A near-lossless quality setting can produce a
191
+ * JPEG that exceeds the RGB24 byte count; if quality is raised significantly,
192
+ * this slot size must be revisited.
193
+ */
194
+ function computeSlotByteLength(width, height, format) {
195
+ if (format === "jpeg") return width * height * 3;
196
+ return width * height * bytesPerPixel(format);
197
+ }
198
+ /**
199
+ * Shared geometry + typed views over a segment buffer. The header is an
200
+ * `Int32Array` so `Atomics` ops apply directly; metadata and pixels are read
201
+ * and written through `DataView` / `Buffer` slices.
202
+ */
203
+ var FrameRingBase = class {
204
+ segment;
205
+ shmId;
206
+ geometry;
207
+ /** `Int32Array` over the header — slotCount, slotByteLength, writeIndex, seq[]. */
208
+ header;
209
+ view;
210
+ constructor(segment, shmId, slotCount, slotByteLength) {
211
+ this.segment = segment;
212
+ this.shmId = shmId;
213
+ this.geometry = computeGeometry(slotCount, slotByteLength);
214
+ if (segment.byteLength < this.geometry.totalBytes) throw new Error(`FrameRing: segment is ${segment.byteLength} bytes, need ${this.geometry.totalBytes} for ${slotCount}×${slotByteLength}`);
215
+ const headerInts = HDR_FIXED_FIELDS + slotCount;
216
+ this.header = new Int32Array(segment.buffer, segment.byteOffset, headerInts);
217
+ this.view = new DataView(segment.buffer, segment.byteOffset, segment.byteLength);
218
+ }
219
+ /** Header index of `seq[slot]`. */
220
+ seqIndex(slot) {
221
+ return HDR_FIXED_FIELDS + slot;
222
+ }
223
+ /** Byte offset of slot `slot`'s metadata struct. */
224
+ metaOffsetOf(slot) {
225
+ return this.geometry.metaOffset + slot * SLOT_META_BYTES;
226
+ }
227
+ /** Byte offset of slot `slot`'s pixel region. */
228
+ pixelOffsetOf(slot) {
229
+ return this.geometry.pixelsOffset + slot * this.geometry.slotByteLength;
230
+ }
231
+ };
232
+ /**
233
+ * The single writer for a ring. Constructed by whoever owns the segment (the
234
+ * decoder). On construction it stamps `slotCount` / `slotByteLength` into the
235
+ * header so a reader opening the same segment can self-describe.
236
+ */
237
+ var FrameRingWriter = class extends FrameRingBase {
238
+ /** Slot index of an in-flight `beginWrite()`, or `null` when idle. */
239
+ pendingSlot = null;
240
+ /** Cluster node id stamped into every `FrameHandle` this writer produces. */
241
+ nodeId;
242
+ constructor(segment, shmId, slotCount, slotByteLength, nodeId) {
243
+ super(segment, shmId, slotCount, slotByteLength);
244
+ this.nodeId = nodeId;
245
+ Atomics.store(this.header, HDR_SLOT_COUNT, slotCount);
246
+ Atomics.store(this.header, HDR_SLOT_BYTE_LENGTH, slotByteLength);
247
+ }
248
+ /** The number of slots in this ring — derived per-resolution from the budget. */
249
+ get slotCount() {
250
+ return this.geometry.slotCount;
251
+ }
252
+ /** The slot the next `writeFrame` / `beginFrame` will target. */
253
+ targetSlot() {
254
+ return Atomics.load(this.header, HDR_WRITE_INDEX) % this.geometry.slotCount;
255
+ }
256
+ /**
257
+ * Open the seqlock on the next slot and hand the caller a writable view
258
+ * directly over that slot's pixel region — the **scatter-write** entry point.
259
+ *
260
+ * The seqlock contract (see {@link FrameSlotWrite}): this bumps the slot's
261
+ * `seq` to **odd** (write in progress), so a concurrent reader skips the slot
262
+ * for the entire fill window. The caller fills `buffer` in place — there is
263
+ * NO intermediate copy: a producer (the node-av scaler) writes its packed
264
+ * output straight into the mapped segment — then MUST call
265
+ * {@link commitFrame} with the returned `slot` to publish the frame.
266
+ *
267
+ * Exactly one `beginFrame` may be open per writer at a time.
268
+ */
269
+ beginFrame() {
270
+ if (this.pendingSlot !== null) throw new Error("FrameRingWriter: a frame write is already in progress");
271
+ const slot = this.targetSlot();
272
+ Atomics.add(this.header, this.seqIndex(slot), 1);
273
+ this.pendingSlot = slot;
274
+ const pixelOffset = this.pixelOffsetOf(slot);
275
+ return {
276
+ slot,
277
+ buffer: this.segment.subarray(pixelOffset, pixelOffset + this.geometry.slotByteLength)
278
+ };
279
+ }
280
+ /**
281
+ * Close the seqlock opened by {@link beginFrame}: write the metadata, bump
282
+ * `seq` back to **even** (committed) and advance `writeIndex` so readers see
283
+ * this slot as the latest. Returns the published frame's `FrameHandle`.
284
+ *
285
+ * `slot` must be the value from the matching `beginFrame`'s
286
+ * {@link FrameSlotWrite}. The pixels are assumed already filled in place by
287
+ * the caller (the scatter-write contract) — `commitFrame` copies nothing.
288
+ */
289
+ commitFrame(slot, meta) {
290
+ if (this.pendingSlot === null) throw new Error("FrameRingWriter: commitFrame called without beginFrame");
291
+ if (slot !== this.pendingSlot) throw new Error(`FrameRingWriter: commitFrame slot ${slot} does not match the in-progress beginFrame slot ${this.pendingSlot}`);
292
+ const { slotByteLength } = this.geometry;
293
+ if (meta.byteLength > slotByteLength) throw new Error(`FrameRingWriter: frame is ${meta.byteLength} bytes, slot capacity is ${slotByteLength}`);
294
+ this.pendingSlot = null;
295
+ const metaOffset = this.metaOffsetOf(slot);
296
+ this.view.setInt32(metaOffset + META_OFF_WIDTH, meta.width, true);
297
+ this.view.setInt32(metaOffset + META_OFF_HEIGHT, meta.height, true);
298
+ this.view.setInt32(metaOffset + META_OFF_FORMAT, encodeFormat(meta.format), true);
299
+ this.view.setInt32(metaOffset + META_OFF_BYTE_LENGTH, meta.byteLength, true);
300
+ this.view.setFloat64(metaOffset + META_OFF_PTS, meta.pts, true);
301
+ const committedSeq = Atomics.add(this.header, this.seqIndex(slot), 1) + 1;
302
+ Atomics.add(this.header, HDR_WRITE_INDEX, 1);
303
+ return {
304
+ shmId: this.shmId,
305
+ slot,
306
+ seq: committedSeq,
307
+ width: meta.width,
308
+ height: meta.height,
309
+ format: meta.format,
310
+ pts: meta.pts,
311
+ byteLength: meta.byteLength,
312
+ nodeId: this.nodeId,
313
+ slotCount: this.geometry.slotCount
314
+ };
315
+ }
316
+ /**
317
+ * Abandon the seqlock opened by {@link beginFrame} **without publishing the
318
+ * slot** — the degenerate-path counterpart of {@link commitFrame}.
319
+ *
320
+ * `beginFrame` has bumped the slot's `seq` to **odd**. A caller that opened a
321
+ * slot but then could not fill it with valid pixels (the producer failed, or
322
+ * there were no source planes) MUST NOT `commitFrame` — that would close the
323
+ * seqlock over uninitialised / stale shared-memory bytes and advance
324
+ * `writeIndex`, so a reader would see those garbage bytes as a real, latest
325
+ * frame. `abortFrame` instead:
326
+ *
327
+ * 1. bumps `seq` from odd back to **even** — the slot is not left stuck
328
+ * odd, so the writer can reuse it on a later cycle;
329
+ * 2. does **NOT** advance `writeIndex` — `readLatest` therefore never
330
+ * surfaces this slot as the latest (the prior latest stays latest);
331
+ * 3. returns no `FrameHandle` — nothing is handed downstream.
332
+ *
333
+ * The slot's pixel bytes after `abortFrame` are **undefined** (whatever was
334
+ * there before, or a half-written producer output) — but that is harmless
335
+ * because no consumer ever sees the slot as committed: `readLatest` skips it
336
+ * (writeIndex did not move) and any handle that happened to point at this
337
+ * slot from an earlier commit sees the changed `seq` and is correctly
338
+ * rejected by `readHandle`'s seq check.
339
+ *
340
+ * `slot` must be the value from the matching `beginFrame` — validated
341
+ * exactly as {@link commitFrame} does.
342
+ */
343
+ abortFrame(slot) {
344
+ if (this.pendingSlot === null) throw new Error("FrameRingWriter: abortFrame called without beginFrame");
345
+ if (slot !== this.pendingSlot) throw new Error(`FrameRingWriter: abortFrame slot ${slot} does not match the in-progress beginFrame slot ${this.pendingSlot}`);
346
+ this.pendingSlot = null;
347
+ Atomics.add(this.header, this.seqIndex(slot), 1);
348
+ }
349
+ /**
350
+ * Publish one frame from a caller-provided pixel `Buffer` — the convenience
351
+ * wrapper over the scatter-write {@link beginFrame} / {@link commitFrame}
352
+ * pair: it opens the slot, copies `pixels` into it, then commits.
353
+ *
354
+ * Prefer the `beginFrame` / `commitFrame` pair when the pixels can be
355
+ * produced directly into the slot (the node-av scaler scattering its packed
356
+ * output into the mapped segment) — that path has zero write-side copy.
357
+ * `writeFrame` keeps the single-call form for callers that already hold a
358
+ * detached pixel `Buffer`.
359
+ */
360
+ writeFrame(pixels, meta) {
361
+ if (pixels.byteLength < meta.byteLength) throw new Error(`FrameRingWriter: pixels buffer (${pixels.byteLength} bytes) shorter than meta.byteLength (${meta.byteLength})`);
362
+ const { slot, buffer } = this.beginFrame();
363
+ buffer.set(pixels.subarray(0, meta.byteLength));
364
+ return this.commitFrame(slot, meta);
365
+ }
366
+ };
367
+ /**
368
+ * A reader for a ring. Many readers may share a segment concurrently with the
369
+ * single writer. Every read is a seqlock read — a torn or recycled slot is
370
+ * dropped (returns `null`), never returned as garbage pixels.
371
+ */
372
+ var FrameRingReader = class extends FrameRingBase {
373
+ /**
374
+ * The reusable per-reader scratch buffer the seqlock-validated pixels are
375
+ * copied into — see the `FrameRead` doc comment for the borrow contract.
376
+ *
377
+ * Sized to `slotByteLength` on construction (the segment's stamped slot
378
+ * capacity) and lazily grown if a slot ever reports a larger `byteLength`.
379
+ * Reusing one buffer per reader replaces the per-read `Buffer.allocUnsafe` —
380
+ * the D9 perf gate's regression (Task 7b): a fresh ~5.93 MB allocation per
381
+ * 1080p frame churned ~1.39 GB/s and stalled on GC. The single memcpy stays
382
+ * (it IS the seqlock safety copy); only the allocation is removed.
383
+ */
384
+ scratch;
385
+ /**
386
+ * Cluster node id of the node that OWNS this segment (i.e. the writer's
387
+ * node — the node whose shared memory physically holds the ring), stamped
388
+ * into every `FrameHandle` this reader produces. `FrameHandle.nodeId` always
389
+ * identifies the segment-owning node, never the reading node, so callers
390
+ * pass the writer's node id here (e.g. `FrameRingReaderCache` passes
391
+ * `handle.nodeId` straight through).
392
+ */
393
+ nodeId;
394
+ constructor(segment, shmId, slotCount, slotByteLength, nodeId) {
395
+ super(segment, shmId, slotCount, slotByteLength);
396
+ this.nodeId = nodeId;
397
+ this.scratch = Buffer.allocUnsafe(slotByteLength);
398
+ }
399
+ /**
400
+ * Return the reader's scratch buffer as a view of exactly `byteLength` bytes,
401
+ * growing the backing buffer first if a slot ever exceeds the stamped slot
402
+ * capacity (defensive — `slotByteLength` should always bound `byteLength`).
403
+ */
404
+ scratchFor(byteLength) {
405
+ if (byteLength > this.scratch.byteLength) this.scratch = Buffer.allocUnsafe(byteLength);
406
+ return this.scratch.subarray(0, byteLength);
407
+ }
408
+ /**
409
+ * Read the latest committed frame, latest-wins. Returns `null` when the ring
410
+ * is empty (no frame ever published) or the latest slot is mid-write /
411
+ * recycled (a slow reader simply drops that frame and retries next tick).
412
+ *
413
+ * The returned `FrameRead.pixels` is **borrowed** from this reader's reusable
414
+ * scratch buffer — valid only until this reader's next read. A consumer that
415
+ * retains it past the next read MUST copy. See the `FrameRead` doc comment.
416
+ */
417
+ readLatest() {
418
+ const writeIndex = Atomics.load(this.header, HDR_WRITE_INDEX);
419
+ if (writeIndex === 0) return null;
420
+ const slot = (writeIndex - 1) % this.geometry.slotCount;
421
+ return this.seqlockRead(slot, null);
422
+ }
423
+ /**
424
+ * Read the exact frame a `FrameHandle` refers to. Returns `null` when the
425
+ * slot has been recycled (its committed `seq` no longer matches `handle.seq`)
426
+ * or is mid-write — i.e. the frame is gone, not torn.
427
+ *
428
+ * The returned `FrameRead.pixels` is **borrowed** from this reader's reusable
429
+ * scratch buffer — valid only until this reader's next read. A consumer that
430
+ * retains it past the next read MUST copy. See the `FrameRead` doc comment.
431
+ */
432
+ readHandle(handle) {
433
+ if (handle.shmId !== this.shmId) throw new Error(`FrameRingReader: handle is for segment "${handle.shmId}", this reader is on "${this.shmId}"`);
434
+ if (handle.slot < 0 || handle.slot >= this.geometry.slotCount) return null;
435
+ return this.seqlockRead(handle.slot, handle.seq);
436
+ }
437
+ /**
438
+ * The seqlock read of a single slot.
439
+ *
440
+ * `expectedSeq` is the committed seq a `readHandle` caller demands; pass
441
+ * `null` for `readLatest`, which accepts whatever even seq it finds.
442
+ */
443
+ seqlockRead(slot, expectedSeq) {
444
+ const seqIdx = this.seqIndex(slot);
445
+ const s1 = Atomics.load(this.header, seqIdx);
446
+ if ((s1 & 1) !== 0) return null;
447
+ if (expectedSeq !== null && s1 !== expectedSeq) return null;
448
+ const metaOffset = this.metaOffsetOf(slot);
449
+ const width = this.view.getInt32(metaOffset + META_OFF_WIDTH, true);
450
+ const height = this.view.getInt32(metaOffset + META_OFF_HEIGHT, true);
451
+ const formatCode = this.view.getInt32(metaOffset + META_OFF_FORMAT, true);
452
+ const byteLength = this.view.getInt32(metaOffset + META_OFF_BYTE_LENGTH, true);
453
+ const pts = this.view.getFloat64(metaOffset + META_OFF_PTS, true);
454
+ if (byteLength < 0 || byteLength > this.geometry.slotByteLength) return null;
455
+ const pixelOffset = this.pixelOffsetOf(slot);
456
+ const pixels = this.scratchFor(byteLength);
457
+ this.segment.copy(pixels, 0, pixelOffset, pixelOffset + byteLength);
458
+ if (s1 !== Atomics.load(this.header, seqIdx)) return null;
459
+ let format;
460
+ try {
461
+ format = decodeFormat(formatCode);
462
+ } catch {
463
+ return null;
464
+ }
465
+ return {
466
+ pixels,
467
+ meta: {
468
+ width,
469
+ height,
470
+ format,
471
+ pts,
472
+ byteLength
473
+ },
474
+ handle: {
475
+ shmId: this.shmId,
476
+ slot,
477
+ seq: s1,
478
+ width,
479
+ height,
480
+ format,
481
+ pts,
482
+ byteLength,
483
+ nodeId: this.nodeId,
484
+ slotCount: this.geometry.slotCount
485
+ }
486
+ };
487
+ }
488
+ /**
489
+ * Read the latest committed frame **zero-copy** — `FrameView.pixels` is a
490
+ * `subarray` over the shared mapping, no memcpy. The caller MUST process it
491
+ * synchronously and then call `validate()`; see the `FrameView` contract.
492
+ * Returns `null` when the ring is empty or the latest slot is mid-write.
493
+ */
494
+ readLatestView() {
495
+ const writeIndex = Atomics.load(this.header, HDR_WRITE_INDEX);
496
+ if (writeIndex === 0) return null;
497
+ const slot = (writeIndex - 1) % this.geometry.slotCount;
498
+ return this.seqlockReadView(slot, null);
499
+ }
500
+ /**
501
+ * Read the frame a `FrameHandle` refers to **zero-copy** — `FrameView.pixels`
502
+ * is a `subarray` over the shared mapping, no memcpy. The caller MUST process
503
+ * it synchronously and then call `validate()`; see the `FrameView` contract.
504
+ * Returns `null` when the slot is recycled (seq mismatch) or mid-write.
505
+ */
506
+ readHandleView(handle) {
507
+ if (handle.shmId !== this.shmId) throw new Error(`FrameRingReader: handle is for segment "${handle.shmId}", this reader is on "${this.shmId}"`);
508
+ if (handle.slot < 0 || handle.slot >= this.geometry.slotCount) return null;
509
+ return this.seqlockReadView(handle.slot, handle.seq);
510
+ }
511
+ /**
512
+ * The zero-copy half of the seqlock read: validate the opening seqlock, then
513
+ * return a `subarray` over the slot plus a `validate()` closure that re-reads
514
+ * the seqlock. No pixel copy — the consumer owns the closing recheck.
515
+ */
516
+ seqlockReadView(slot, expectedSeq) {
517
+ const seqIdx = this.seqIndex(slot);
518
+ const s1 = Atomics.load(this.header, seqIdx);
519
+ if ((s1 & 1) !== 0) return null;
520
+ if (expectedSeq !== null && s1 !== expectedSeq) return null;
521
+ const metaOffset = this.metaOffsetOf(slot);
522
+ const width = this.view.getInt32(metaOffset + META_OFF_WIDTH, true);
523
+ const height = this.view.getInt32(metaOffset + META_OFF_HEIGHT, true);
524
+ const formatCode = this.view.getInt32(metaOffset + META_OFF_FORMAT, true);
525
+ const byteLength = this.view.getInt32(metaOffset + META_OFF_BYTE_LENGTH, true);
526
+ const pts = this.view.getFloat64(metaOffset + META_OFF_PTS, true);
527
+ if (byteLength < 0 || byteLength > this.geometry.slotByteLength) return null;
528
+ let format;
529
+ try {
530
+ format = decodeFormat(formatCode);
531
+ } catch {
532
+ return null;
533
+ }
534
+ const pixelOffset = this.pixelOffsetOf(slot);
535
+ const pixels = this.segment.subarray(pixelOffset, pixelOffset + byteLength);
536
+ const meta = {
537
+ width,
538
+ height,
539
+ format,
540
+ pts,
541
+ byteLength
542
+ };
543
+ const handle = {
544
+ shmId: this.shmId,
545
+ slot,
546
+ seq: s1,
547
+ width,
548
+ height,
549
+ format,
550
+ pts,
551
+ byteLength,
552
+ nodeId: this.nodeId,
553
+ slotCount: this.geometry.slotCount
554
+ };
555
+ const validate = () => Atomics.load(this.header, seqIdx) === s1;
556
+ return {
557
+ pixels,
558
+ meta,
559
+ handle,
560
+ validate
561
+ };
562
+ }
563
+ };
564
+ //#endregion
565
+ //#region src/frame-ring-reader-cache.ts
566
+ /**
567
+ * Opens (and caches) a `FrameRingReader` per `shmId` and reads the pixels a
568
+ * `FrameHandle` refers to. Single-consumer — one cache per frame subscription.
569
+ */
570
+ var FrameRingReaderCache = class {
571
+ rings = /* @__PURE__ */ new Map();
572
+ logger;
573
+ closed = false;
574
+ constructor(logger) {
575
+ this.logger = logger;
576
+ }
577
+ /**
578
+ * Read the pixels a `FrameHandle` refers to and return them as a
579
+ * `DecodedFrame`. Returns `null` when the ring slot was recycled before the
580
+ * read (a dropped frame) or the segment could not be opened.
581
+ */
582
+ read(handle) {
583
+ if (this.closed) return null;
584
+ const ring = this.ringFor(handle);
585
+ if (!ring) return null;
586
+ let frame;
587
+ try {
588
+ frame = ring.reader.readHandle(handle);
589
+ } catch (err) {
590
+ this.logger?.warn("frame-ring reader: readHandle failed", { meta: {
591
+ shmId: handle.shmId,
592
+ error: (0, _camstack_types.errMsg)(err)
593
+ } });
594
+ return null;
595
+ }
596
+ if (!frame) return null;
597
+ return {
598
+ data: frame.pixels,
599
+ width: frame.meta.width,
600
+ height: frame.meta.height,
601
+ format: frame.meta.format,
602
+ timestamp: frame.meta.pts
603
+ };
604
+ }
605
+ /** Close every cached segment. Idempotent. */
606
+ close() {
607
+ if (this.closed) return;
608
+ this.closed = true;
609
+ for (const [shmId, ring] of this.rings) try {
610
+ ring.segment.close();
611
+ } catch (err) {
612
+ this.logger?.warn("frame-ring reader: segment close failed", { meta: {
613
+ shmId,
614
+ error: (0, _camstack_types.errMsg)(err)
615
+ } });
616
+ }
617
+ this.rings.clear();
618
+ }
619
+ /** Get the cached reader for a handle's segment, opening it on first use. */
620
+ ringFor(handle) {
621
+ const cached = this.rings.get(handle.shmId);
622
+ if (cached) return cached;
623
+ const slotByteLength = computeSlotByteLength(handle.width, handle.height, handle.format);
624
+ try {
625
+ const segment = openSegment(handle.shmId, computeSegmentSize(handle.slotCount, slotByteLength));
626
+ const ring = {
627
+ segment,
628
+ reader: new FrameRingReader(segment.buffer, handle.shmId, handle.slotCount, slotByteLength, handle.nodeId)
629
+ };
630
+ this.rings.set(handle.shmId, ring);
631
+ return ring;
632
+ } catch (err) {
633
+ this.logger?.warn("frame-ring reader: openSegment failed", { meta: {
634
+ shmId: handle.shmId,
635
+ error: (0, _camstack_types.errMsg)(err)
636
+ } });
637
+ return null;
638
+ }
639
+ }
640
+ };
641
+ //#endregion
642
+ exports.FrameRingReader = FrameRingReader;
643
+ exports.FrameRingReaderCache = FrameRingReaderCache;
644
+ exports.FrameRingWriter = FrameRingWriter;
645
+ exports.MAX_RING_SLOTS = MAX_RING_SLOTS;
646
+ exports.MIN_RING_SLOTS = MIN_RING_SLOTS;
647
+ exports.bytesPerPixel = bytesPerPixel;
648
+ exports.computeSegmentSize = computeSegmentSize;
649
+ exports.computeSlotByteLength = computeSlotByteLength;
650
+ exports.createSegment = createSegment;
651
+ exports.deriveSlotCount = deriveSlotCount;
652
+ exports.openSegment = openSegment;
653
+ exports.unlinkSegment = unlinkSegment;
654
+
655
+ //# sourceMappingURL=index.js.map