@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/binding.gyp +35 -0
- package/dist/frame-ring-reader-cache.d.ts +22 -0
- package/dist/frame-ring-reader-cache.d.ts.map +1 -0
- package/dist/frame-ring.d.ts +379 -0
- package/dist/frame-ring.d.ts.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +655 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +643 -0
- package/dist/index.mjs.map +1 -0
- package/dist/native.d.ts +34 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/testing/index.d.ts +10 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +226 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/index.mjs +224 -0
- package/dist/testing/index.mjs.map +1 -0
- package/dist/testing/looping-clip-frame-source.d.ts +57 -0
- package/dist/testing/looping-clip-frame-source.d.ts.map +1 -0
- package/package.json +59 -0
- package/prebuilds/linux-x64/@camstack+shm-ring.node +0 -0
- package/src/shm.cc +321 -0
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
|