@camstack/shm-ring 1.0.12 → 1.0.13
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/testing/index.d.ts +4 -4
- package/dist/testing/index.js +35 -176
- package/dist/testing/index.mjs +36 -176
- package/dist/testing/looping-clip-frame-source.d.ts +14 -23
- package/package.json +1 -3
package/dist/testing/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* `@camstack/shm-ring/testing` — test-only utilities for the Phase 5 (D9)
|
|
3
3
|
* shared-memory frame plane.
|
|
4
4
|
*
|
|
5
|
-
* Kept on a separate entry from the package's runtime surface
|
|
6
|
-
*
|
|
5
|
+
* Kept on a separate entry from the package's runtime surface. The frame source
|
|
6
|
+
* is synthetic (no `node-av`), so these helpers pull no native decoder.
|
|
7
7
|
*/
|
|
8
|
-
export { createLoopingClipFrameSource
|
|
9
|
-
export type { LoopingClipFrameSource, LoopingClipFrameSourceOptions, ClipFrame,
|
|
8
|
+
export { createLoopingClipFrameSource } from './looping-clip-frame-source.js';
|
|
9
|
+
export type { LoopingClipFrameSource, LoopingClipFrameSourceOptions, ClipFrame, EmittedFrameFormat, OnFrame, } from './looping-clip-frame-source.js';
|
package/dist/testing/index.js
CHANGED
|
@@ -1,98 +1,71 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
let node_fs = require("node:fs");
|
|
3
2
|
//#region src/testing/looping-clip-frame-source.ts
|
|
4
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* tests (perf comparison, frame-integrity,
|
|
10
|
-
* soak) need a stream of *genuine* decoded pixels at a controlled fps. This
|
|
11
|
-
* utility decodes a real `.h264` / `.hevc` clip via `node-av` and emits decoded
|
|
12
|
-
* frames to a callback at a target rate, looping back to the clip start at EOF.
|
|
13
|
-
*
|
|
14
|
-
* The decoder wiring is ported from `scripts/bench-nodeav.mts` (the working
|
|
15
|
-
* decode-from-file path) — software decode, no hardware accel, kept
|
|
16
|
-
* dependency-light. Decoded frames are scaled to a known packed pixel format
|
|
17
|
-
* (`rgb` / `bgr` / `gray`) so every emitted buffer has a deterministic
|
|
18
|
-
* `width × height × bpp` byte length the tests can assert on.
|
|
19
|
-
*
|
|
20
|
-
* The clips live under `test-output/bench/` which is gitignored — they are NOT
|
|
21
|
-
* committed. `createLoopingClipFrameSource` therefore validates the clip path
|
|
22
|
-
* up front and surfaces a missing clip as a typed `MissingClipError`, so a
|
|
23
|
-
* test can skip-with-a-clear-message rather than fail opaquely.
|
|
24
|
-
*/
|
|
25
|
-
/** Thrown when the requested clip file does not exist (clips are gitignored). */
|
|
26
|
-
var MissingClipError = class extends Error {
|
|
27
|
-
clipPath;
|
|
28
|
-
constructor(clipPath) {
|
|
29
|
-
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.`);
|
|
30
|
-
this.name = "MissingClipError";
|
|
31
|
-
this.clipPath = clipPath;
|
|
32
|
-
}
|
|
33
|
-
};
|
|
3
|
+
/** Default synthetic frame geometry when the caller does not specify one. */
|
|
4
|
+
var DEFAULT_WIDTH = 640;
|
|
5
|
+
var DEFAULT_HEIGHT = 360;
|
|
6
|
+
/** How many distinct synthetic frames to cycle through (looped at EOF). */
|
|
7
|
+
var DEFAULT_FRAME_COUNT = 30;
|
|
34
8
|
/** Bytes per pixel for an emitted packed format. */
|
|
35
9
|
function bytesPerPixel(format) {
|
|
36
10
|
return format === "gray" ? 1 : 3;
|
|
37
11
|
}
|
|
38
|
-
/** Infer the clip codec from its file extension. */
|
|
39
|
-
function inferCodec(clipPath) {
|
|
40
|
-
const lower = clipPath.toLowerCase();
|
|
41
|
-
return lower.endsWith(".hevc") || lower.endsWith(".h265") ? "h265" : "h264";
|
|
42
|
-
}
|
|
43
12
|
/**
|
|
44
|
-
* Create a looping
|
|
13
|
+
* Create a looping synthetic-frame source.
|
|
45
14
|
*
|
|
46
|
-
*
|
|
47
|
-
* synchronously so callers can `try { … } catch (MissingClipError) { skip }`.
|
|
15
|
+
* @throws If `targetFps`, `width`, `height`, or `frameCount` are not positive.
|
|
48
16
|
*/
|
|
49
17
|
function createLoopingClipFrameSource(options) {
|
|
50
|
-
const {
|
|
18
|
+
const { targetFps } = options;
|
|
51
19
|
const format = options.format ?? "rgb";
|
|
52
|
-
const
|
|
20
|
+
const width = options.width ?? DEFAULT_WIDTH;
|
|
21
|
+
const height = options.height ?? DEFAULT_HEIGHT;
|
|
22
|
+
const frameCount = options.frameCount ?? DEFAULT_FRAME_COUNT;
|
|
53
23
|
if (!Number.isFinite(targetFps) || targetFps <= 0) throw new Error(`LoopingClipFrameSource: targetFps must be > 0, got ${targetFps}`);
|
|
54
|
-
if (!(0
|
|
55
|
-
|
|
24
|
+
if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) throw new Error(`LoopingClipFrameSource: width/height must be positive integers`);
|
|
25
|
+
if (!Number.isInteger(frameCount) || frameCount <= 0) throw new Error(`LoopingClipFrameSource: frameCount must be a positive integer`);
|
|
26
|
+
return new SyntheticFrameSourceImpl(targetFps, format, width, height, frameCount);
|
|
56
27
|
}
|
|
57
|
-
var
|
|
58
|
-
clipPath;
|
|
28
|
+
var SyntheticFrameSourceImpl = class {
|
|
59
29
|
targetFps;
|
|
60
30
|
format;
|
|
61
|
-
|
|
31
|
+
width;
|
|
32
|
+
height;
|
|
62
33
|
timer = null;
|
|
63
|
-
|
|
34
|
+
frames;
|
|
64
35
|
nextIndex = 0;
|
|
65
36
|
nextPts = 0;
|
|
66
37
|
running = false;
|
|
67
|
-
constructor(
|
|
68
|
-
this.clipPath = clipPath;
|
|
38
|
+
constructor(targetFps, format, width, height, frameCount) {
|
|
69
39
|
this.targetFps = targetFps;
|
|
70
40
|
this.format = format;
|
|
71
|
-
this.
|
|
41
|
+
this.width = width;
|
|
42
|
+
this.height = height;
|
|
43
|
+
const byteLength = width * height * bytesPerPixel(format);
|
|
44
|
+
const frames = [];
|
|
45
|
+
for (let f = 0; f < frameCount; f += 1) {
|
|
46
|
+
const buf = Buffer.allocUnsafe(byteLength);
|
|
47
|
+
const base = f * 37 + 11;
|
|
48
|
+
for (let i = 0; i < byteLength; i += 1) buf[i] = base + i & 255;
|
|
49
|
+
frames.push(buf);
|
|
50
|
+
}
|
|
51
|
+
this.frames = frames;
|
|
72
52
|
}
|
|
73
53
|
async start(onFrame) {
|
|
74
54
|
if (this.running) throw new Error("LoopingClipFrameSource: already started");
|
|
75
55
|
this.running = true;
|
|
76
|
-
const clip = await decodeClip(this.clipPath, this.codec, this.format);
|
|
77
|
-
if (clip.frames.length === 0) {
|
|
78
|
-
this.running = false;
|
|
79
|
-
throw new Error(`LoopingClipFrameSource: clip "${this.clipPath}" decoded to zero frames`);
|
|
80
|
-
}
|
|
81
|
-
this.clip = clip;
|
|
82
56
|
const periodMs = 1e3 / this.targetFps;
|
|
83
57
|
const ptsStepUs = Math.round(1e6 / this.targetFps);
|
|
84
58
|
this.timer = setInterval(() => {
|
|
85
|
-
if (!this.running
|
|
86
|
-
const
|
|
87
|
-
const pixels = frames[this.nextIndex];
|
|
59
|
+
if (!this.running) return;
|
|
60
|
+
const pixels = this.frames[this.nextIndex];
|
|
88
61
|
if (pixels === void 0) return;
|
|
89
62
|
const pts = this.nextPts;
|
|
90
63
|
this.nextPts += ptsStepUs;
|
|
91
|
-
this.nextIndex = (this.nextIndex + 1) % frames.length;
|
|
64
|
+
this.nextIndex = (this.nextIndex + 1) % this.frames.length;
|
|
92
65
|
onFrame({
|
|
93
66
|
pixels,
|
|
94
|
-
width,
|
|
95
|
-
height,
|
|
67
|
+
width: this.width,
|
|
68
|
+
height: this.height,
|
|
96
69
|
format: this.format,
|
|
97
70
|
pts
|
|
98
71
|
});
|
|
@@ -104,121 +77,7 @@ var LoopingClipFrameSourceImpl = class {
|
|
|
104
77
|
clearInterval(this.timer);
|
|
105
78
|
this.timer = null;
|
|
106
79
|
}
|
|
107
|
-
this.clip = null;
|
|
108
80
|
}
|
|
109
81
|
};
|
|
110
|
-
/**
|
|
111
|
-
* Decode an entire raw Annex-B clip to packed pixel buffers.
|
|
112
|
-
*
|
|
113
|
-
* Ported from `scripts/bench-nodeav.mts` — software decode (no hwaccel, so the
|
|
114
|
-
* path is deterministic and headless-safe), then a `SoftwareScaleContext`
|
|
115
|
-
* scales each frame to the requested packed format at native resolution.
|
|
116
|
-
*/
|
|
117
|
-
async function decodeClip(clipPath, codec, format) {
|
|
118
|
-
const rawData = (0, node_fs.readFileSync)(clipPath);
|
|
119
|
-
const nav = await import("node-av");
|
|
120
|
-
const C = await import("node-av/constants");
|
|
121
|
-
nav.Log.setLevel(C.AV_LOG_FATAL);
|
|
122
|
-
const codecId = codec === "h265" ? C.AV_CODEC_ID_HEVC : C.AV_CODEC_ID_H264;
|
|
123
|
-
const decoderCodec = nav.Codec.findDecoder(codecId);
|
|
124
|
-
if (!decoderCodec) throw new Error(`LoopingClipFrameSource: no decoder for ${codec}`);
|
|
125
|
-
const parser = new nav.CodecParser();
|
|
126
|
-
parser.init(codecId);
|
|
127
|
-
const ctx = new nav.CodecContext();
|
|
128
|
-
ctx.allocContext3(decoderCodec);
|
|
129
|
-
ctx.threadCount = 2;
|
|
130
|
-
const openRet = await ctx.open2(decoderCodec);
|
|
131
|
-
if (openRet < 0) throw new Error(`LoopingClipFrameSource: decoder open2 failed (rc=${openRet})`);
|
|
132
|
-
const navPixFmt = pixelFormatConstant(C, format);
|
|
133
|
-
const channels = bytesPerPixel(format);
|
|
134
|
-
const pkt = new nav.Packet();
|
|
135
|
-
pkt.alloc();
|
|
136
|
-
const frame = new nav.Frame();
|
|
137
|
-
frame.alloc();
|
|
138
|
-
const scaler = new nav.SoftwareScaleContext();
|
|
139
|
-
let scalerReady = false;
|
|
140
|
-
const dstFrame = new nav.Frame();
|
|
141
|
-
dstFrame.alloc();
|
|
142
|
-
const frames = [];
|
|
143
|
-
let width = 0;
|
|
144
|
-
let height = 0;
|
|
145
|
-
try {
|
|
146
|
-
let offset = 0;
|
|
147
|
-
while (offset < rawData.length) {
|
|
148
|
-
const remaining = rawData.subarray(offset);
|
|
149
|
-
const consumed = parser.parse2(ctx, pkt, remaining, BigInt(offset), BigInt(offset), offset);
|
|
150
|
-
if (consumed < 0) break;
|
|
151
|
-
offset += consumed;
|
|
152
|
-
if (consumed === 0 && pkt.size === 0) break;
|
|
153
|
-
if (pkt.size > 0) {
|
|
154
|
-
const sendRet = ctx.sendPacketSync(pkt);
|
|
155
|
-
pkt.unref();
|
|
156
|
-
if (sendRet < 0 && sendRet !== C.AVERROR_EAGAIN) continue;
|
|
157
|
-
drainFrames();
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
ctx.sendPacketSync(null);
|
|
161
|
-
drainFrames();
|
|
162
|
-
} finally {
|
|
163
|
-
parser[Symbol.dispose]?.();
|
|
164
|
-
pkt[Symbol.dispose]?.();
|
|
165
|
-
frame[Symbol.dispose]?.();
|
|
166
|
-
dstFrame[Symbol.dispose]?.();
|
|
167
|
-
scaler[Symbol.dispose]?.();
|
|
168
|
-
ctx[Symbol.dispose]?.();
|
|
169
|
-
}
|
|
170
|
-
return {
|
|
171
|
-
frames,
|
|
172
|
-
width,
|
|
173
|
-
height
|
|
174
|
-
};
|
|
175
|
-
/** Receive + scale every frame currently buffered in the decoder. */
|
|
176
|
-
function drainFrames() {
|
|
177
|
-
while (true) {
|
|
178
|
-
if (ctx.receiveFrameSync(frame) < 0) break;
|
|
179
|
-
if (width === 0) {
|
|
180
|
-
width = frame.width;
|
|
181
|
-
height = frame.height;
|
|
182
|
-
}
|
|
183
|
-
if (!scalerReady) {
|
|
184
|
-
dstFrame.width = width;
|
|
185
|
-
dstFrame.height = height;
|
|
186
|
-
dstFrame.format = navPixFmt;
|
|
187
|
-
dstFrame.allocBuffer();
|
|
188
|
-
const srcPixFmt = frame.format;
|
|
189
|
-
scaler.getContext(frame.width, frame.height, srcPixFmt, width, height, navPixFmt, C.SWS_FAST_BILINEAR);
|
|
190
|
-
scaler.initContext();
|
|
191
|
-
scalerReady = true;
|
|
192
|
-
}
|
|
193
|
-
dstFrame.makeWritable();
|
|
194
|
-
scaler.scaleFrameSync(dstFrame, frame);
|
|
195
|
-
frames.push(extractPacked(dstFrame, width, height, channels));
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
/** Map an emitted packed format to its `node-av` `AV_PIX_FMT_*` constant. */
|
|
200
|
-
function pixelFormatConstant(C, format) {
|
|
201
|
-
switch (format) {
|
|
202
|
-
case "rgb": return C.AV_PIX_FMT_RGB24;
|
|
203
|
-
case "bgr": return C.AV_PIX_FMT_BGR24;
|
|
204
|
-
case "gray": return C.AV_PIX_FMT_GRAY8;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
/**
|
|
208
|
-
* Extract a tightly packed (stride === width*channels) pixel buffer from a
|
|
209
|
-
* scaled `node-av` frame. The scaler may pad each row to a wider linesize —
|
|
210
|
-
* copy row-by-row when so, ported from `bench-nodeav.mts`.
|
|
211
|
-
*/
|
|
212
|
-
function extractPacked(dstFrame, width, height, channels) {
|
|
213
|
-
const expectedStride = width * channels;
|
|
214
|
-
const actualStride = dstFrame.linesize[0] ?? expectedStride;
|
|
215
|
-
const plane = dstFrame.data?.[0];
|
|
216
|
-
if (!plane) throw new Error("LoopingClipFrameSource: scaled frame has no pixel plane");
|
|
217
|
-
if (actualStride === expectedStride) return Buffer.from(plane.subarray(0, expectedStride * height));
|
|
218
|
-
const packed = Buffer.allocUnsafe(expectedStride * height);
|
|
219
|
-
for (let y = 0; y < height; y += 1) plane.copy(packed, y * expectedStride, y * actualStride, y * actualStride + expectedStride);
|
|
220
|
-
return packed;
|
|
221
|
-
}
|
|
222
82
|
//#endregion
|
|
223
|
-
exports.MissingClipError = MissingClipError;
|
|
224
83
|
exports.createLoopingClipFrameSource = createLoopingClipFrameSource;
|
package/dist/testing/index.mjs
CHANGED
|
@@ -1,97 +1,70 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
1
|
//#region src/testing/looping-clip-frame-source.ts
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
* 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
|
-
};
|
|
2
|
+
/** Default synthetic frame geometry when the caller does not specify one. */
|
|
3
|
+
var DEFAULT_WIDTH = 640;
|
|
4
|
+
var DEFAULT_HEIGHT = 360;
|
|
5
|
+
/** How many distinct synthetic frames to cycle through (looped at EOF). */
|
|
6
|
+
var DEFAULT_FRAME_COUNT = 30;
|
|
33
7
|
/** Bytes per pixel for an emitted packed format. */
|
|
34
8
|
function bytesPerPixel(format) {
|
|
35
9
|
return format === "gray" ? 1 : 3;
|
|
36
10
|
}
|
|
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
11
|
/**
|
|
43
|
-
* Create a looping
|
|
12
|
+
* Create a looping synthetic-frame source.
|
|
44
13
|
*
|
|
45
|
-
*
|
|
46
|
-
* synchronously so callers can `try { … } catch (MissingClipError) { skip }`.
|
|
14
|
+
* @throws If `targetFps`, `width`, `height`, or `frameCount` are not positive.
|
|
47
15
|
*/
|
|
48
16
|
function createLoopingClipFrameSource(options) {
|
|
49
|
-
const {
|
|
17
|
+
const { targetFps } = options;
|
|
50
18
|
const format = options.format ?? "rgb";
|
|
51
|
-
const
|
|
19
|
+
const width = options.width ?? DEFAULT_WIDTH;
|
|
20
|
+
const height = options.height ?? DEFAULT_HEIGHT;
|
|
21
|
+
const frameCount = options.frameCount ?? DEFAULT_FRAME_COUNT;
|
|
52
22
|
if (!Number.isFinite(targetFps) || targetFps <= 0) throw new Error(`LoopingClipFrameSource: targetFps must be > 0, got ${targetFps}`);
|
|
53
|
-
if (!
|
|
54
|
-
|
|
23
|
+
if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) throw new Error(`LoopingClipFrameSource: width/height must be positive integers`);
|
|
24
|
+
if (!Number.isInteger(frameCount) || frameCount <= 0) throw new Error(`LoopingClipFrameSource: frameCount must be a positive integer`);
|
|
25
|
+
return new SyntheticFrameSourceImpl(targetFps, format, width, height, frameCount);
|
|
55
26
|
}
|
|
56
|
-
var
|
|
57
|
-
clipPath;
|
|
27
|
+
var SyntheticFrameSourceImpl = class {
|
|
58
28
|
targetFps;
|
|
59
29
|
format;
|
|
60
|
-
|
|
30
|
+
width;
|
|
31
|
+
height;
|
|
61
32
|
timer = null;
|
|
62
|
-
|
|
33
|
+
frames;
|
|
63
34
|
nextIndex = 0;
|
|
64
35
|
nextPts = 0;
|
|
65
36
|
running = false;
|
|
66
|
-
constructor(
|
|
67
|
-
this.clipPath = clipPath;
|
|
37
|
+
constructor(targetFps, format, width, height, frameCount) {
|
|
68
38
|
this.targetFps = targetFps;
|
|
69
39
|
this.format = format;
|
|
70
|
-
this.
|
|
40
|
+
this.width = width;
|
|
41
|
+
this.height = height;
|
|
42
|
+
const byteLength = width * height * bytesPerPixel(format);
|
|
43
|
+
const frames = [];
|
|
44
|
+
for (let f = 0; f < frameCount; f += 1) {
|
|
45
|
+
const buf = Buffer.allocUnsafe(byteLength);
|
|
46
|
+
const base = f * 37 + 11;
|
|
47
|
+
for (let i = 0; i < byteLength; i += 1) buf[i] = base + i & 255;
|
|
48
|
+
frames.push(buf);
|
|
49
|
+
}
|
|
50
|
+
this.frames = frames;
|
|
71
51
|
}
|
|
72
52
|
async start(onFrame) {
|
|
73
53
|
if (this.running) throw new Error("LoopingClipFrameSource: already started");
|
|
74
54
|
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
55
|
const periodMs = 1e3 / this.targetFps;
|
|
82
56
|
const ptsStepUs = Math.round(1e6 / this.targetFps);
|
|
83
57
|
this.timer = setInterval(() => {
|
|
84
|
-
if (!this.running
|
|
85
|
-
const
|
|
86
|
-
const pixels = frames[this.nextIndex];
|
|
58
|
+
if (!this.running) return;
|
|
59
|
+
const pixels = this.frames[this.nextIndex];
|
|
87
60
|
if (pixels === void 0) return;
|
|
88
61
|
const pts = this.nextPts;
|
|
89
62
|
this.nextPts += ptsStepUs;
|
|
90
|
-
this.nextIndex = (this.nextIndex + 1) % frames.length;
|
|
63
|
+
this.nextIndex = (this.nextIndex + 1) % this.frames.length;
|
|
91
64
|
onFrame({
|
|
92
65
|
pixels,
|
|
93
|
-
width,
|
|
94
|
-
height,
|
|
66
|
+
width: this.width,
|
|
67
|
+
height: this.height,
|
|
95
68
|
format: this.format,
|
|
96
69
|
pts
|
|
97
70
|
});
|
|
@@ -103,120 +76,7 @@ var LoopingClipFrameSourceImpl = class {
|
|
|
103
76
|
clearInterval(this.timer);
|
|
104
77
|
this.timer = null;
|
|
105
78
|
}
|
|
106
|
-
this.clip = null;
|
|
107
79
|
}
|
|
108
80
|
};
|
|
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
81
|
//#endregion
|
|
222
|
-
export {
|
|
82
|
+
export { createLoopingClipFrameSource };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FrameFormat } from '@camstack/types';
|
|
2
|
-
/** A
|
|
2
|
+
/** A generated frame handed to the `onFrame` callback. */
|
|
3
3
|
export interface ClipFrame {
|
|
4
4
|
/** Packed pixel bytes in `format` — length is exactly `width * height * bpp`. */
|
|
5
5
|
readonly pixels: Buffer;
|
|
@@ -11,46 +11,37 @@ export interface ClipFrame {
|
|
|
11
11
|
}
|
|
12
12
|
/** Pixel formats this source can emit — the packed, fixed-bpp subset of `FrameFormat`. */
|
|
13
13
|
export type EmittedFrameFormat = Extract<FrameFormat, 'rgb' | 'bgr' | 'gray'>;
|
|
14
|
-
/** Callback invoked once per emitted
|
|
14
|
+
/** Callback invoked once per emitted frame. */
|
|
15
15
|
export type OnFrame = (frame: ClipFrame) => void;
|
|
16
16
|
/** Options for `createLoopingClipFrameSource`. */
|
|
17
17
|
export interface LoopingClipFrameSourceOptions {
|
|
18
|
-
/** Absolute (or cwd-relative) path to a raw `.h264` / `.hevc` Annex-B clip. */
|
|
19
|
-
readonly clipPath: string;
|
|
20
18
|
/** Target emission rate, frames per second. Must be > 0. */
|
|
21
19
|
readonly targetFps: number;
|
|
22
20
|
/**
|
|
23
|
-
* Packed pixel format every emitted frame
|
|
21
|
+
* Packed pixel format every emitted frame uses. Defaults to `rgb`.
|
|
24
22
|
* `gray` is 1 byte/px; `rgb` / `bgr` are 3 bytes/px.
|
|
25
23
|
*/
|
|
26
24
|
readonly format?: EmittedFrameFormat;
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
/** Emitted frame width in pixels. Defaults to {@link DEFAULT_WIDTH}. */
|
|
26
|
+
readonly width?: number;
|
|
27
|
+
/** Emitted frame height in pixels. Defaults to {@link DEFAULT_HEIGHT}. */
|
|
28
|
+
readonly height?: number;
|
|
29
|
+
/** How many distinct frames to cycle through. Defaults to {@link DEFAULT_FRAME_COUNT}. */
|
|
30
|
+
readonly frameCount?: number;
|
|
32
31
|
}
|
|
33
|
-
/** Supported clip codecs. */
|
|
34
|
-
export type ClipCodec = 'h264' | 'h265';
|
|
35
32
|
/** The running frame source returned by `createLoopingClipFrameSource`. */
|
|
36
33
|
export interface LoopingClipFrameSource {
|
|
37
34
|
/**
|
|
38
|
-
* Begin
|
|
39
|
-
*
|
|
35
|
+
* Begin emitting frames to `onFrame` at the target fps. Resolves once
|
|
36
|
+
* emission has started.
|
|
40
37
|
*/
|
|
41
38
|
start(onFrame: OnFrame): Promise<void>;
|
|
42
|
-
/** Halt emission
|
|
39
|
+
/** Halt emission. Idempotent. */
|
|
43
40
|
stop(): Promise<void>;
|
|
44
41
|
}
|
|
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
42
|
/**
|
|
51
|
-
* Create a looping
|
|
43
|
+
* Create a looping synthetic-frame source.
|
|
52
44
|
*
|
|
53
|
-
*
|
|
54
|
-
* synchronously so callers can `try { … } catch (MissingClipError) { skip }`.
|
|
45
|
+
* @throws If `targetFps`, `width`, `height`, or `frameCount` are not positive.
|
|
55
46
|
*/
|
|
56
47
|
export declare function createLoopingClipFrameSource(options: LoopingClipFrameSourceOptions): LoopingClipFrameSource;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/shm-ring",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"description": "CamStack shared-memory frame ring — cross-platform N-API segment mapping + seqlock ring",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|
|
@@ -46,11 +46,9 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@camstack/types": "*",
|
|
48
48
|
"node-addon-api": "^8.7.0",
|
|
49
|
-
"node-av": "^6.0.0",
|
|
50
49
|
"node-gyp-build": "^4.8.4"
|
|
51
50
|
},
|
|
52
51
|
"devDependencies": {
|
|
53
|
-
"node-av": "^6.0.0",
|
|
54
52
|
"prebuildify": "^6.0.1",
|
|
55
53
|
"typescript": "~6.0.3",
|
|
56
54
|
"vite": "^8.0.11",
|