@camstack/shm-ring 1.0.11 → 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.
@@ -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 so importing
6
- * these helpers does not pull `node-av` into a production consumer.
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, MissingClipError, } from './looping-clip-frame-source.js';
9
- export type { LoopingClipFrameSource, LoopingClipFrameSourceOptions, ClipFrame, ClipCodec, EmittedFrameFormat, OnFrame, } from './looping-clip-frame-source.js';
8
+ export { createLoopingClipFrameSource } from './looping-clip-frame-source.js';
9
+ export type { LoopingClipFrameSource, LoopingClipFrameSourceOptions, ClipFrame, EmittedFrameFormat, OnFrame, } from './looping-clip-frame-source.js';
@@ -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
- * `LoopingClipFrameSource` a real-video decoded-frame source for Phase 5 (D9)
6
- * shm-frame-plane tests.
7
- *
8
- * A metadata-only synthetic camera has no decodable video. The intensive shm
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-clip decoded-frame source.
13
+ * Create a looping synthetic-frame source.
45
14
  *
46
- * Validates the clip path eagerly: a missing clip throws `MissingClipError`
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 { clipPath, targetFps } = options;
18
+ const { targetFps } = options;
51
19
  const format = options.format ?? "rgb";
52
- const codec = options.codec ?? inferCodec(clipPath);
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, node_fs.existsSync)(clipPath)) throw new MissingClipError(clipPath);
55
- return new LoopingClipFrameSourceImpl(clipPath, targetFps, format, codec);
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 LoopingClipFrameSourceImpl = class {
58
- clipPath;
28
+ var SyntheticFrameSourceImpl = class {
59
29
  targetFps;
60
30
  format;
61
- codec;
31
+ width;
32
+ height;
62
33
  timer = null;
63
- clip = null;
34
+ frames;
64
35
  nextIndex = 0;
65
36
  nextPts = 0;
66
37
  running = false;
67
- constructor(clipPath, targetFps, format, codec) {
68
- this.clipPath = clipPath;
38
+ constructor(targetFps, format, width, height, frameCount) {
69
39
  this.targetFps = targetFps;
70
40
  this.format = format;
71
- this.codec = codec;
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 || this.clip === null) return;
86
- const { frames, width, height } = this.clip;
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;
@@ -1,97 +1,70 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
1
  //#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
- * A metadata-only synthetic camera has no decodable video. The intensive shm
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-clip decoded-frame source.
12
+ * Create a looping synthetic-frame source.
44
13
  *
45
- * Validates the clip path eagerly: a missing clip throws `MissingClipError`
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 { clipPath, targetFps } = options;
17
+ const { targetFps } = options;
50
18
  const format = options.format ?? "rgb";
51
- const codec = options.codec ?? inferCodec(clipPath);
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 (!existsSync(clipPath)) throw new MissingClipError(clipPath);
54
- return new LoopingClipFrameSourceImpl(clipPath, targetFps, format, codec);
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 LoopingClipFrameSourceImpl = class {
57
- clipPath;
27
+ var SyntheticFrameSourceImpl = class {
58
28
  targetFps;
59
29
  format;
60
- codec;
30
+ width;
31
+ height;
61
32
  timer = null;
62
- clip = null;
33
+ frames;
63
34
  nextIndex = 0;
64
35
  nextPts = 0;
65
36
  running = false;
66
- constructor(clipPath, targetFps, format, codec) {
67
- this.clipPath = clipPath;
37
+ constructor(targetFps, format, width, height, frameCount) {
68
38
  this.targetFps = targetFps;
69
39
  this.format = format;
70
- this.codec = codec;
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 || this.clip === null) return;
85
- const { frames, width, height } = this.clip;
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 { MissingClipError, createLoopingClipFrameSource };
82
+ export { createLoopingClipFrameSource };
@@ -1,5 +1,5 @@
1
1
  import { FrameFormat } from '@camstack/types';
2
- /** A decoded frame handed to the `onFrame` callback. */
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 decoded frame. */
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 is scaled to. Defaults to `rgb`.
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
- * Codec of the clip. Defaults to inference from the file extension
29
- * (`.hevc` / `.h265` `h265`, otherwise `h264`).
30
- */
31
- readonly codec?: ClipCodec;
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 decoding + emitting frames to `onFrame` at the target fps. Resolves
39
- * once emission has started; rejects if the decoder cannot be initialised.
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 and release the decoder. Idempotent. */
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-clip decoded-frame source.
43
+ * Create a looping synthetic-frame source.
52
44
  *
53
- * Validates the clip path eagerly: a missing clip throws `MissingClipError`
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.11",
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",