@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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
//#region src/testing/looping-clip-frame-source.ts
|
|
3
|
+
/**
|
|
4
|
+
* `LoopingClipFrameSource` — a real-video decoded-frame source for Phase 5 (D9)
|
|
5
|
+
* shm-frame-plane tests.
|
|
6
|
+
*
|
|
7
|
+
* `addon-test-integration`'s `TestCamera` is metadata-only — it has no
|
|
8
|
+
* decodable video. The intensive shm tests (perf comparison, frame-integrity,
|
|
9
|
+
* soak) need a stream of *genuine* decoded pixels at a controlled fps. This
|
|
10
|
+
* utility decodes a real `.h264` / `.hevc` clip via `node-av` and emits decoded
|
|
11
|
+
* frames to a callback at a target rate, looping back to the clip start at EOF.
|
|
12
|
+
*
|
|
13
|
+
* The decoder wiring is ported from `scripts/bench-nodeav.mts` (the working
|
|
14
|
+
* decode-from-file path) — software decode, no hardware accel, kept
|
|
15
|
+
* dependency-light. Decoded frames are scaled to a known packed pixel format
|
|
16
|
+
* (`rgb` / `bgr` / `gray`) so every emitted buffer has a deterministic
|
|
17
|
+
* `width × height × bpp` byte length the tests can assert on.
|
|
18
|
+
*
|
|
19
|
+
* The clips live under `test-output/bench/` which is gitignored — they are NOT
|
|
20
|
+
* committed. `createLoopingClipFrameSource` therefore validates the clip path
|
|
21
|
+
* up front and surfaces a missing clip as a typed `MissingClipError`, so a
|
|
22
|
+
* test can skip-with-a-clear-message rather than fail opaquely.
|
|
23
|
+
*/
|
|
24
|
+
/** Thrown when the requested clip file does not exist (clips are gitignored). */
|
|
25
|
+
var MissingClipError = class extends Error {
|
|
26
|
+
clipPath;
|
|
27
|
+
constructor(clipPath) {
|
|
28
|
+
super(`LoopingClipFrameSource: clip not found at "${clipPath}". Bench clips under test-output/bench/ are gitignored and not committed — this test must be skipped when the clip is absent.`);
|
|
29
|
+
this.name = "MissingClipError";
|
|
30
|
+
this.clipPath = clipPath;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/** Bytes per pixel for an emitted packed format. */
|
|
34
|
+
function bytesPerPixel(format) {
|
|
35
|
+
return format === "gray" ? 1 : 3;
|
|
36
|
+
}
|
|
37
|
+
/** Infer the clip codec from its file extension. */
|
|
38
|
+
function inferCodec(clipPath) {
|
|
39
|
+
const lower = clipPath.toLowerCase();
|
|
40
|
+
return lower.endsWith(".hevc") || lower.endsWith(".h265") ? "h265" : "h264";
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Create a looping-clip decoded-frame source.
|
|
44
|
+
*
|
|
45
|
+
* Validates the clip path eagerly: a missing clip throws `MissingClipError`
|
|
46
|
+
* synchronously so callers can `try { … } catch (MissingClipError) { skip }`.
|
|
47
|
+
*/
|
|
48
|
+
function createLoopingClipFrameSource(options) {
|
|
49
|
+
const { clipPath, targetFps } = options;
|
|
50
|
+
const format = options.format ?? "rgb";
|
|
51
|
+
const codec = options.codec ?? inferCodec(clipPath);
|
|
52
|
+
if (!Number.isFinite(targetFps) || targetFps <= 0) throw new Error(`LoopingClipFrameSource: targetFps must be > 0, got ${targetFps}`);
|
|
53
|
+
if (!existsSync(clipPath)) throw new MissingClipError(clipPath);
|
|
54
|
+
return new LoopingClipFrameSourceImpl(clipPath, targetFps, format, codec);
|
|
55
|
+
}
|
|
56
|
+
var LoopingClipFrameSourceImpl = class {
|
|
57
|
+
clipPath;
|
|
58
|
+
targetFps;
|
|
59
|
+
format;
|
|
60
|
+
codec;
|
|
61
|
+
timer = null;
|
|
62
|
+
clip = null;
|
|
63
|
+
nextIndex = 0;
|
|
64
|
+
nextPts = 0;
|
|
65
|
+
running = false;
|
|
66
|
+
constructor(clipPath, targetFps, format, codec) {
|
|
67
|
+
this.clipPath = clipPath;
|
|
68
|
+
this.targetFps = targetFps;
|
|
69
|
+
this.format = format;
|
|
70
|
+
this.codec = codec;
|
|
71
|
+
}
|
|
72
|
+
async start(onFrame) {
|
|
73
|
+
if (this.running) throw new Error("LoopingClipFrameSource: already started");
|
|
74
|
+
this.running = true;
|
|
75
|
+
const clip = await decodeClip(this.clipPath, this.codec, this.format);
|
|
76
|
+
if (clip.frames.length === 0) {
|
|
77
|
+
this.running = false;
|
|
78
|
+
throw new Error(`LoopingClipFrameSource: clip "${this.clipPath}" decoded to zero frames`);
|
|
79
|
+
}
|
|
80
|
+
this.clip = clip;
|
|
81
|
+
const periodMs = 1e3 / this.targetFps;
|
|
82
|
+
const ptsStepUs = Math.round(1e6 / this.targetFps);
|
|
83
|
+
this.timer = setInterval(() => {
|
|
84
|
+
if (!this.running || this.clip === null) return;
|
|
85
|
+
const { frames, width, height } = this.clip;
|
|
86
|
+
const pixels = frames[this.nextIndex];
|
|
87
|
+
if (pixels === void 0) return;
|
|
88
|
+
const pts = this.nextPts;
|
|
89
|
+
this.nextPts += ptsStepUs;
|
|
90
|
+
this.nextIndex = (this.nextIndex + 1) % frames.length;
|
|
91
|
+
onFrame({
|
|
92
|
+
pixels,
|
|
93
|
+
width,
|
|
94
|
+
height,
|
|
95
|
+
format: this.format,
|
|
96
|
+
pts
|
|
97
|
+
});
|
|
98
|
+
}, periodMs);
|
|
99
|
+
}
|
|
100
|
+
async stop() {
|
|
101
|
+
this.running = false;
|
|
102
|
+
if (this.timer !== null) {
|
|
103
|
+
clearInterval(this.timer);
|
|
104
|
+
this.timer = null;
|
|
105
|
+
}
|
|
106
|
+
this.clip = null;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Decode an entire raw Annex-B clip to packed pixel buffers.
|
|
111
|
+
*
|
|
112
|
+
* Ported from `scripts/bench-nodeav.mts` — software decode (no hwaccel, so the
|
|
113
|
+
* path is deterministic and headless-safe), then a `SoftwareScaleContext`
|
|
114
|
+
* scales each frame to the requested packed format at native resolution.
|
|
115
|
+
*/
|
|
116
|
+
async function decodeClip(clipPath, codec, format) {
|
|
117
|
+
const rawData = readFileSync(clipPath);
|
|
118
|
+
const nav = await import("node-av");
|
|
119
|
+
const C = await import("node-av/constants");
|
|
120
|
+
nav.Log.setLevel(C.AV_LOG_FATAL);
|
|
121
|
+
const codecId = codec === "h265" ? C.AV_CODEC_ID_HEVC : C.AV_CODEC_ID_H264;
|
|
122
|
+
const decoderCodec = nav.Codec.findDecoder(codecId);
|
|
123
|
+
if (!decoderCodec) throw new Error(`LoopingClipFrameSource: no decoder for ${codec}`);
|
|
124
|
+
const parser = new nav.CodecParser();
|
|
125
|
+
parser.init(codecId);
|
|
126
|
+
const ctx = new nav.CodecContext();
|
|
127
|
+
ctx.allocContext3(decoderCodec);
|
|
128
|
+
ctx.threadCount = 2;
|
|
129
|
+
const openRet = await ctx.open2(decoderCodec);
|
|
130
|
+
if (openRet < 0) throw new Error(`LoopingClipFrameSource: decoder open2 failed (rc=${openRet})`);
|
|
131
|
+
const navPixFmt = pixelFormatConstant(C, format);
|
|
132
|
+
const channels = bytesPerPixel(format);
|
|
133
|
+
const pkt = new nav.Packet();
|
|
134
|
+
pkt.alloc();
|
|
135
|
+
const frame = new nav.Frame();
|
|
136
|
+
frame.alloc();
|
|
137
|
+
const scaler = new nav.SoftwareScaleContext();
|
|
138
|
+
let scalerReady = false;
|
|
139
|
+
const dstFrame = new nav.Frame();
|
|
140
|
+
dstFrame.alloc();
|
|
141
|
+
const frames = [];
|
|
142
|
+
let width = 0;
|
|
143
|
+
let height = 0;
|
|
144
|
+
try {
|
|
145
|
+
let offset = 0;
|
|
146
|
+
while (offset < rawData.length) {
|
|
147
|
+
const remaining = rawData.subarray(offset);
|
|
148
|
+
const consumed = parser.parse2(ctx, pkt, remaining, BigInt(offset), BigInt(offset), offset);
|
|
149
|
+
if (consumed < 0) break;
|
|
150
|
+
offset += consumed;
|
|
151
|
+
if (consumed === 0 && pkt.size === 0) break;
|
|
152
|
+
if (pkt.size > 0) {
|
|
153
|
+
const sendRet = ctx.sendPacketSync(pkt);
|
|
154
|
+
pkt.unref();
|
|
155
|
+
if (sendRet < 0 && sendRet !== C.AVERROR_EAGAIN) continue;
|
|
156
|
+
drainFrames();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
ctx.sendPacketSync(null);
|
|
160
|
+
drainFrames();
|
|
161
|
+
} finally {
|
|
162
|
+
parser[Symbol.dispose]?.();
|
|
163
|
+
pkt[Symbol.dispose]?.();
|
|
164
|
+
frame[Symbol.dispose]?.();
|
|
165
|
+
dstFrame[Symbol.dispose]?.();
|
|
166
|
+
scaler[Symbol.dispose]?.();
|
|
167
|
+
ctx[Symbol.dispose]?.();
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
frames,
|
|
171
|
+
width,
|
|
172
|
+
height
|
|
173
|
+
};
|
|
174
|
+
/** Receive + scale every frame currently buffered in the decoder. */
|
|
175
|
+
function drainFrames() {
|
|
176
|
+
while (true) {
|
|
177
|
+
if (ctx.receiveFrameSync(frame) < 0) break;
|
|
178
|
+
if (width === 0) {
|
|
179
|
+
width = frame.width;
|
|
180
|
+
height = frame.height;
|
|
181
|
+
}
|
|
182
|
+
if (!scalerReady) {
|
|
183
|
+
dstFrame.width = width;
|
|
184
|
+
dstFrame.height = height;
|
|
185
|
+
dstFrame.format = navPixFmt;
|
|
186
|
+
dstFrame.allocBuffer();
|
|
187
|
+
const srcPixFmt = frame.format;
|
|
188
|
+
scaler.getContext(frame.width, frame.height, srcPixFmt, width, height, navPixFmt, C.SWS_FAST_BILINEAR);
|
|
189
|
+
scaler.initContext();
|
|
190
|
+
scalerReady = true;
|
|
191
|
+
}
|
|
192
|
+
dstFrame.makeWritable();
|
|
193
|
+
scaler.scaleFrameSync(dstFrame, frame);
|
|
194
|
+
frames.push(extractPacked(dstFrame, width, height, channels));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Map an emitted packed format to its `node-av` `AV_PIX_FMT_*` constant. */
|
|
199
|
+
function pixelFormatConstant(C, format) {
|
|
200
|
+
switch (format) {
|
|
201
|
+
case "rgb": return C.AV_PIX_FMT_RGB24;
|
|
202
|
+
case "bgr": return C.AV_PIX_FMT_BGR24;
|
|
203
|
+
case "gray": return C.AV_PIX_FMT_GRAY8;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Extract a tightly packed (stride === width*channels) pixel buffer from a
|
|
208
|
+
* scaled `node-av` frame. The scaler may pad each row to a wider linesize —
|
|
209
|
+
* copy row-by-row when so, ported from `bench-nodeav.mts`.
|
|
210
|
+
*/
|
|
211
|
+
function extractPacked(dstFrame, width, height, channels) {
|
|
212
|
+
const expectedStride = width * channels;
|
|
213
|
+
const actualStride = dstFrame.linesize[0] ?? expectedStride;
|
|
214
|
+
const plane = dstFrame.data?.[0];
|
|
215
|
+
if (!plane) throw new Error("LoopingClipFrameSource: scaled frame has no pixel plane");
|
|
216
|
+
if (actualStride === expectedStride) return Buffer.from(plane.subarray(0, expectedStride * height));
|
|
217
|
+
const packed = Buffer.allocUnsafe(expectedStride * height);
|
|
218
|
+
for (let y = 0; y < height; y += 1) plane.copy(packed, y * expectedStride, y * actualStride, y * actualStride + expectedStride);
|
|
219
|
+
return packed;
|
|
220
|
+
}
|
|
221
|
+
//#endregion
|
|
222
|
+
export { MissingClipError, createLoopingClipFrameSource };
|
|
223
|
+
|
|
224
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/testing/looping-clip-frame-source.ts"],"sourcesContent":["/**\n * `LoopingClipFrameSource` — a real-video decoded-frame source for Phase 5 (D9)\n * shm-frame-plane tests.\n *\n * `addon-test-integration`'s `TestCamera` is metadata-only — it has no\n * decodable video. The intensive shm tests (perf comparison, frame-integrity,\n * soak) need a stream of *genuine* decoded pixels at a controlled fps. This\n * utility decodes a real `.h264` / `.hevc` clip via `node-av` and emits decoded\n * frames to a callback at a target rate, looping back to the clip start at EOF.\n *\n * The decoder wiring is ported from `scripts/bench-nodeav.mts` (the working\n * decode-from-file path) — software decode, no hardware accel, kept\n * dependency-light. Decoded frames are scaled to a known packed pixel format\n * (`rgb` / `bgr` / `gray`) so every emitted buffer has a deterministic\n * `width × height × bpp` byte length the tests can assert on.\n *\n * The clips live under `test-output/bench/` which is gitignored — they are NOT\n * committed. `createLoopingClipFrameSource` therefore validates the clip path\n * up front and surfaces a missing clip as a typed `MissingClipError`, so a\n * test can skip-with-a-clear-message rather than fail opaquely.\n */\nimport { existsSync, readFileSync } from 'node:fs'\n\nimport type { FrameFormat } from '@camstack/types'\nimport type { AVPixelFormat } from 'node-av/constants'\n\n/** A decoded frame handed to the `onFrame` callback. */\nexport interface ClipFrame {\n /** Packed pixel bytes in `format` — length is exactly `width * height * bpp`. */\n readonly pixels: Buffer\n readonly width: number\n readonly height: number\n readonly format: EmittedFrameFormat\n /** Monotonic presentation timestamp in microseconds, assigned per emitted frame. */\n readonly pts: number\n}\n\n/** Pixel formats this source can emit — the packed, fixed-bpp subset of `FrameFormat`. */\nexport type EmittedFrameFormat = Extract<FrameFormat, 'rgb' | 'bgr' | 'gray'>\n\n/** Callback invoked once per emitted decoded frame. */\nexport type OnFrame = (frame: ClipFrame) => void\n\n/** Options for `createLoopingClipFrameSource`. */\nexport interface LoopingClipFrameSourceOptions {\n /** Absolute (or cwd-relative) path to a raw `.h264` / `.hevc` Annex-B clip. */\n readonly clipPath: string\n /** Target emission rate, frames per second. Must be > 0. */\n readonly targetFps: number\n /**\n * Packed pixel format every emitted frame is scaled to. Defaults to `rgb`.\n * `gray` is 1 byte/px; `rgb` / `bgr` are 3 bytes/px.\n */\n readonly format?: EmittedFrameFormat\n /**\n * Codec of the clip. Defaults to inference from the file extension\n * (`.hevc` / `.h265` → `h265`, otherwise `h264`).\n */\n readonly codec?: ClipCodec\n}\n\n/** Supported clip codecs. */\nexport type ClipCodec = 'h264' | 'h265'\n\n/** The running frame source returned by `createLoopingClipFrameSource`. */\nexport interface LoopingClipFrameSource {\n /**\n * Begin decoding + emitting frames to `onFrame` at the target fps. Resolves\n * once emission has started; rejects if the decoder cannot be initialised.\n */\n start(onFrame: OnFrame): Promise<void>\n /** Halt emission and release the decoder. Idempotent. */\n stop(): Promise<void>\n}\n\n/** Thrown when the requested clip file does not exist (clips are gitignored). */\nexport class MissingClipError extends Error {\n readonly clipPath: string\n constructor(clipPath: string) {\n super(\n `LoopingClipFrameSource: clip not found at \"${clipPath}\". ` +\n 'Bench clips under test-output/bench/ are gitignored and not committed — ' +\n 'this test must be skipped when the clip is absent.',\n )\n this.name = 'MissingClipError'\n this.clipPath = clipPath\n }\n}\n\n/** Bytes per pixel for an emitted packed format. */\nfunction bytesPerPixel(format: EmittedFrameFormat): number {\n return format === 'gray' ? 1 : 3\n}\n\n/** Infer the clip codec from its file extension. */\nfunction inferCodec(clipPath: string): ClipCodec {\n const lower = clipPath.toLowerCase()\n return lower.endsWith('.hevc') || lower.endsWith('.h265') ? 'h265' : 'h264'\n}\n\n/**\n * Create a looping-clip decoded-frame source.\n *\n * Validates the clip path eagerly: a missing clip throws `MissingClipError`\n * synchronously so callers can `try { … } catch (MissingClipError) { skip }`.\n */\nexport function createLoopingClipFrameSource(\n options: LoopingClipFrameSourceOptions,\n): LoopingClipFrameSource {\n const { clipPath, targetFps } = options\n const format: EmittedFrameFormat = options.format ?? 'rgb'\n const codec: ClipCodec = options.codec ?? inferCodec(clipPath)\n\n if (!Number.isFinite(targetFps) || targetFps <= 0) {\n throw new Error(`LoopingClipFrameSource: targetFps must be > 0, got ${targetFps}`)\n }\n if (!existsSync(clipPath)) {\n throw new MissingClipError(clipPath)\n }\n\n return new LoopingClipFrameSourceImpl(clipPath, targetFps, format, codec)\n}\n\n/**\n * A pre-decoded clip: the full sequence of packed pixel buffers plus geometry.\n * Decoding the whole clip up front (it is a short test fixture, a few hundred\n * KB) keeps the per-frame emit path a trivial array index + copy, so the\n * emission cadence is governed only by the timer, not by decode jitter.\n */\ninterface DecodedClip {\n readonly frames: readonly Buffer[]\n readonly width: number\n readonly height: number\n}\n\nclass LoopingClipFrameSourceImpl implements LoopingClipFrameSource {\n private readonly clipPath: string\n private readonly targetFps: number\n private readonly format: EmittedFrameFormat\n private readonly codec: ClipCodec\n\n private timer: NodeJS.Timeout | null = null\n private clip: DecodedClip | null = null\n private nextIndex = 0\n private nextPts = 0\n private running = false\n\n constructor(\n clipPath: string,\n targetFps: number,\n format: EmittedFrameFormat,\n codec: ClipCodec,\n ) {\n this.clipPath = clipPath\n this.targetFps = targetFps\n this.format = format\n this.codec = codec\n }\n\n async start(onFrame: OnFrame): Promise<void> {\n if (this.running) {\n throw new Error('LoopingClipFrameSource: already started')\n }\n this.running = true\n\n const clip = await decodeClip(this.clipPath, this.codec, this.format)\n if (clip.frames.length === 0) {\n this.running = false\n throw new Error(\n `LoopingClipFrameSource: clip \"${this.clipPath}\" decoded to zero frames`,\n )\n }\n this.clip = clip\n\n const periodMs = 1000 / this.targetFps\n const ptsStepUs = Math.round(1_000_000 / this.targetFps)\n\n this.timer = setInterval(() => {\n if (!this.running || this.clip === null) {\n return\n }\n const { frames, width, height } = this.clip\n const pixels = frames[this.nextIndex]\n if (pixels === undefined) {\n return\n }\n const pts = this.nextPts\n this.nextPts += ptsStepUs\n this.nextIndex = (this.nextIndex + 1) % frames.length // loop at EOF\n\n onFrame({ pixels, width, height, format: this.format, pts })\n }, periodMs)\n }\n\n async stop(): Promise<void> {\n this.running = false\n if (this.timer !== null) {\n clearInterval(this.timer)\n this.timer = null\n }\n this.clip = null\n }\n}\n\n/**\n * Decode an entire raw Annex-B clip to packed pixel buffers.\n *\n * Ported from `scripts/bench-nodeav.mts` — software decode (no hwaccel, so the\n * path is deterministic and headless-safe), then a `SoftwareScaleContext`\n * scales each frame to the requested packed format at native resolution.\n */\nasync function decodeClip(\n clipPath: string,\n codec: ClipCodec,\n format: EmittedFrameFormat,\n): Promise<DecodedClip> {\n const rawData = readFileSync(clipPath)\n\n // Dynamic import — mirrors how the decoder addon and bench script load node-av.\n const nav = await import('node-av')\n const C = await import('node-av/constants')\n\n nav.Log.setLevel(C.AV_LOG_FATAL)\n\n const codecId = codec === 'h265' ? C.AV_CODEC_ID_HEVC : C.AV_CODEC_ID_H264\n const decoderCodec = nav.Codec.findDecoder(codecId)\n if (!decoderCodec) {\n throw new Error(`LoopingClipFrameSource: no decoder for ${codec}`)\n }\n\n const parser = new nav.CodecParser()\n parser.init(codecId)\n\n const ctx = new nav.CodecContext()\n ctx.allocContext3(decoderCodec)\n ctx.threadCount = 2\n\n const openRet = await ctx.open2(decoderCodec)\n if (openRet < 0) {\n throw new Error(`LoopingClipFrameSource: decoder open2 failed (rc=${openRet})`)\n }\n\n const navPixFmt = pixelFormatConstant(C, format)\n const channels = bytesPerPixel(format)\n\n const pkt = new nav.Packet()\n pkt.alloc()\n const frame = new nav.Frame()\n frame.alloc()\n\n const scaler = new nav.SoftwareScaleContext()\n let scalerReady = false\n const dstFrame = new nav.Frame()\n dstFrame.alloc()\n\n const frames: Buffer[] = []\n let width = 0\n let height = 0\n\n try {\n let offset = 0\n while (offset < rawData.length) {\n const remaining = rawData.subarray(offset)\n const consumed = parser.parse2(\n ctx,\n pkt,\n remaining,\n BigInt(offset),\n BigInt(offset),\n offset,\n )\n if (consumed < 0) {\n break\n }\n offset += consumed\n if (consumed === 0 && pkt.size === 0) {\n break // no progress and no packet — avoid an infinite loop\n }\n\n if (pkt.size > 0) {\n const sendRet = ctx.sendPacketSync(pkt)\n pkt.unref()\n if (sendRet < 0 && sendRet !== C.AVERROR_EAGAIN) {\n continue\n }\n drainFrames()\n }\n }\n\n // Flush: send a null packet so the decoder emits any buffered frames.\n ctx.sendPacketSync(null)\n drainFrames()\n } finally {\n parser[Symbol.dispose]?.()\n pkt[Symbol.dispose]?.()\n frame[Symbol.dispose]?.()\n dstFrame[Symbol.dispose]?.()\n scaler[Symbol.dispose]?.()\n ctx[Symbol.dispose]?.()\n }\n\n return { frames, width, height }\n\n /** Receive + scale every frame currently buffered in the decoder. */\n function drainFrames(): void {\n while (true) {\n const recvRet = ctx.receiveFrameSync(frame)\n if (recvRet < 0) {\n break\n }\n if (width === 0) {\n width = frame.width\n height = frame.height\n }\n if (!scalerReady) {\n dstFrame.width = width\n dstFrame.height = height\n dstFrame.format = navPixFmt\n dstFrame.allocBuffer()\n // node-av API boundary: `Frame.format` is typed `AVPixelFormat |\n // AVSampleFormat` (it serves both video and audio frames). A video\n // decoder always produces a pixel format here — the same documented\n // boundary narrowing `nodeav-decoder-session.ts` uses for its scaler.\n const srcPixFmt: AVPixelFormat = frame.format as AVPixelFormat\n scaler.getContext(\n frame.width,\n frame.height,\n srcPixFmt,\n width,\n height,\n navPixFmt,\n C.SWS_FAST_BILINEAR,\n )\n scaler.initContext()\n scalerReady = true\n }\n\n dstFrame.makeWritable()\n scaler.scaleFrameSync(dstFrame, frame)\n frames.push(extractPacked(dstFrame, width, height, channels))\n }\n }\n}\n\n/** Map an emitted packed format to its `node-av` `AV_PIX_FMT_*` constant. */\nfunction pixelFormatConstant(\n C: typeof import('node-av/constants'),\n format: EmittedFrameFormat,\n): AVPixelFormat {\n switch (format) {\n case 'rgb':\n return C.AV_PIX_FMT_RGB24\n case 'bgr':\n return C.AV_PIX_FMT_BGR24\n case 'gray':\n return C.AV_PIX_FMT_GRAY8\n }\n}\n\n/** The minimal `node-av` `Frame` surface `extractPacked` reads. */\ninterface ScaledFrameView {\n readonly linesize: readonly number[]\n readonly data: readonly Buffer[] | null\n}\n\n/**\n * Extract a tightly packed (stride === width*channels) pixel buffer from a\n * scaled `node-av` frame. The scaler may pad each row to a wider linesize —\n * copy row-by-row when so, ported from `bench-nodeav.mts`.\n */\nfunction extractPacked(\n dstFrame: ScaledFrameView,\n width: number,\n height: number,\n channels: number,\n): Buffer {\n const expectedStride = width * channels\n const actualStride = dstFrame.linesize[0] ?? expectedStride\n const plane = dstFrame.data?.[0]\n if (!plane) {\n throw new Error('LoopingClipFrameSource: scaled frame has no pixel plane')\n }\n if (actualStride === expectedStride) {\n // A detached copy — the source frame buffer is reused across decodes.\n return Buffer.from(plane.subarray(0, expectedStride * height))\n }\n const packed = Buffer.allocUnsafe(expectedStride * height)\n for (let y = 0; y < height; y += 1) {\n plane.copy(\n packed,\n y * expectedStride,\n y * actualStride,\n y * actualStride + expectedStride,\n )\n }\n return packed\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { FrameFormat } from '@camstack/types';
|
|
2
|
+
/** A decoded frame handed to the `onFrame` callback. */
|
|
3
|
+
export interface ClipFrame {
|
|
4
|
+
/** Packed pixel bytes in `format` — length is exactly `width * height * bpp`. */
|
|
5
|
+
readonly pixels: Buffer;
|
|
6
|
+
readonly width: number;
|
|
7
|
+
readonly height: number;
|
|
8
|
+
readonly format: EmittedFrameFormat;
|
|
9
|
+
/** Monotonic presentation timestamp in microseconds, assigned per emitted frame. */
|
|
10
|
+
readonly pts: number;
|
|
11
|
+
}
|
|
12
|
+
/** Pixel formats this source can emit — the packed, fixed-bpp subset of `FrameFormat`. */
|
|
13
|
+
export type EmittedFrameFormat = Extract<FrameFormat, 'rgb' | 'bgr' | 'gray'>;
|
|
14
|
+
/** Callback invoked once per emitted decoded frame. */
|
|
15
|
+
export type OnFrame = (frame: ClipFrame) => void;
|
|
16
|
+
/** Options for `createLoopingClipFrameSource`. */
|
|
17
|
+
export interface LoopingClipFrameSourceOptions {
|
|
18
|
+
/** Absolute (or cwd-relative) path to a raw `.h264` / `.hevc` Annex-B clip. */
|
|
19
|
+
readonly clipPath: string;
|
|
20
|
+
/** Target emission rate, frames per second. Must be > 0. */
|
|
21
|
+
readonly targetFps: number;
|
|
22
|
+
/**
|
|
23
|
+
* Packed pixel format every emitted frame is scaled to. Defaults to `rgb`.
|
|
24
|
+
* `gray` is 1 byte/px; `rgb` / `bgr` are 3 bytes/px.
|
|
25
|
+
*/
|
|
26
|
+
readonly format?: EmittedFrameFormat;
|
|
27
|
+
/**
|
|
28
|
+
* Codec of the clip. Defaults to inference from the file extension
|
|
29
|
+
* (`.hevc` / `.h265` → `h265`, otherwise `h264`).
|
|
30
|
+
*/
|
|
31
|
+
readonly codec?: ClipCodec;
|
|
32
|
+
}
|
|
33
|
+
/** Supported clip codecs. */
|
|
34
|
+
export type ClipCodec = 'h264' | 'h265';
|
|
35
|
+
/** The running frame source returned by `createLoopingClipFrameSource`. */
|
|
36
|
+
export interface LoopingClipFrameSource {
|
|
37
|
+
/**
|
|
38
|
+
* Begin decoding + emitting frames to `onFrame` at the target fps. Resolves
|
|
39
|
+
* once emission has started; rejects if the decoder cannot be initialised.
|
|
40
|
+
*/
|
|
41
|
+
start(onFrame: OnFrame): Promise<void>;
|
|
42
|
+
/** Halt emission and release the decoder. Idempotent. */
|
|
43
|
+
stop(): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
/** Thrown when the requested clip file does not exist (clips are gitignored). */
|
|
46
|
+
export declare class MissingClipError extends Error {
|
|
47
|
+
readonly clipPath: string;
|
|
48
|
+
constructor(clipPath: string);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Create a looping-clip decoded-frame source.
|
|
52
|
+
*
|
|
53
|
+
* Validates the clip path eagerly: a missing clip throws `MissingClipError`
|
|
54
|
+
* synchronously so callers can `try { … } catch (MissingClipError) { skip }`.
|
|
55
|
+
*/
|
|
56
|
+
export declare function createLoopingClipFrameSource(options: LoopingClipFrameSourceOptions): LoopingClipFrameSource;
|
|
57
|
+
//# sourceMappingURL=looping-clip-frame-source.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"looping-clip-frame-source.d.ts","sourceRoot":"","sources":["../../src/testing/looping-clip-frame-source.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAGlD,wDAAwD;AACxD,MAAM,WAAW,SAAS;IACxB,iFAAiF;IACjF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAA;IACnC,oFAAoF;IACpF,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CACrB;AAED,0FAA0F;AAC1F,MAAM,MAAM,kBAAkB,GAAG,OAAO,CAAC,WAAW,EAAE,KAAK,GAAG,KAAK,GAAG,MAAM,CAAC,CAAA;AAE7E,uDAAuD;AACvD,MAAM,MAAM,OAAO,GAAG,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAA;AAEhD,kDAAkD;AAClD,MAAM,WAAW,6BAA6B;IAC5C,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,4DAA4D;IAC5D,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAA;IACpC;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAA;CAC3B;AAED,6BAA6B;AAC7B,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,MAAM,CAAA;AAEvC,2EAA2E;AAC3E,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACtC,yDAAyD;IACzD,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACtB;AAED,iFAAiF;AACjF,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;gBACb,QAAQ,EAAE,MAAM;CAS7B;AAaD;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,6BAA6B,GACrC,sBAAsB,CAaxB"}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@camstack/shm-ring",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "CamStack shared-memory frame ring — cross-platform N-API segment mapping + seqlock ring",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"camstack",
|
|
7
|
+
"shared-memory",
|
|
8
|
+
"napi"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"main": "dist/index.js",
|
|
12
|
+
"module": "dist/index.mjs",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.mjs",
|
|
18
|
+
"require": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./testing": {
|
|
21
|
+
"types": "./dist/testing/index.d.ts",
|
|
22
|
+
"import": "./dist/testing/index.mjs",
|
|
23
|
+
"require": "./dist/testing/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"camstack": {
|
|
28
|
+
"system": true,
|
|
29
|
+
"displayName": "CamStack Shared-Memory Ring",
|
|
30
|
+
"description": "Native shared-memory segment mapping and zero-copy frame ring."
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"prebuilds",
|
|
35
|
+
"src/shm.cc",
|
|
36
|
+
"binding.gyp"
|
|
37
|
+
],
|
|
38
|
+
"gypfile": true,
|
|
39
|
+
"scripts": {
|
|
40
|
+
"install": "node-gyp-build",
|
|
41
|
+
"build": "vite build",
|
|
42
|
+
"build:native": "prebuildify --napi --strip",
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"test": "vitest run"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@camstack/types": "*",
|
|
48
|
+
"node-addon-api": "^8.7.0",
|
|
49
|
+
"node-gyp-build": "^4.8.4"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"node-av": "^5.2.0",
|
|
53
|
+
"prebuildify": "^6.0.1",
|
|
54
|
+
"typescript": "~5.9.0",
|
|
55
|
+
"vite": "^8.0.11",
|
|
56
|
+
"vite-plugin-dts": "^5.0.0",
|
|
57
|
+
"vitest": "^3.0.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
Binary file
|