@pproenca/node-webcodecs 0.1.0 → 0.1.1-alpha.5
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/README.md +78 -206
- package/binding.gyp +123 -0
- package/dist/audio-decoder.js +1 -2
- package/dist/audio-encoder.d.ts +4 -0
- package/dist/audio-encoder.js +28 -2
- package/dist/binding.d.ts +0 -2
- package/dist/binding.js +43 -124
- package/dist/control-message-queue.js +0 -1
- package/dist/demuxer.d.ts +7 -0
- package/dist/demuxer.js +9 -0
- package/dist/encoded-chunks.d.ts +16 -0
- package/dist/encoded-chunks.js +82 -2
- package/dist/image-decoder.js +4 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.js +9 -4
- package/dist/is.d.ts +18 -0
- package/dist/is.js +14 -0
- package/dist/native-types.d.ts +20 -0
- package/dist/platform.d.ts +1 -10
- package/dist/platform.js +1 -39
- package/dist/resource-manager.d.ts +1 -2
- package/dist/resource-manager.js +3 -17
- package/dist/types.d.ts +46 -0
- package/dist/video-decoder.d.ts +21 -0
- package/dist/video-decoder.js +74 -2
- package/dist/video-encoder.d.ts +22 -0
- package/dist/video-encoder.js +83 -8
- package/dist/video-frame.d.ts +6 -3
- package/dist/video-frame.js +36 -4
- package/lib/audio-decoder.ts +1 -2
- package/lib/audio-encoder.ts +31 -2
- package/lib/binding.ts +45 -104
- package/lib/control-message-queue.ts +0 -1
- package/lib/demuxer.ts +10 -0
- package/lib/encoded-chunks.ts +90 -2
- package/lib/image-decoder.ts +5 -0
- package/lib/index.ts +9 -3
- package/lib/is.ts +32 -0
- package/lib/native-types.ts +22 -0
- package/lib/platform.ts +1 -41
- package/lib/resource-manager.ts +3 -19
- package/lib/types.ts +52 -1
- package/lib/video-decoder.ts +84 -2
- package/lib/video-encoder.ts +90 -8
- package/lib/video-frame.ts +52 -7
- package/package.json +49 -32
- package/src/addon.cc +57 -0
- package/src/async_decode_worker.cc +243 -36
- package/src/async_decode_worker.h +55 -4
- package/src/async_encode_worker.cc +155 -44
- package/src/async_encode_worker.h +38 -12
- package/src/audio_data.cc +38 -15
- package/src/audio_data.h +1 -0
- package/src/audio_decoder.cc +24 -3
- package/src/audio_encoder.cc +55 -4
- package/src/common.cc +125 -17
- package/src/common.h +34 -4
- package/src/demuxer.cc +16 -2
- package/src/encoded_audio_chunk.cc +10 -0
- package/src/encoded_audio_chunk.h +2 -0
- package/src/encoded_video_chunk.h +1 -0
- package/src/error_builder.cc +0 -4
- package/src/image_decoder.cc +127 -90
- package/src/image_decoder.h +11 -4
- package/src/muxer.cc +1 -0
- package/src/test_video_generator.cc +3 -2
- package/src/video_decoder.cc +169 -19
- package/src/video_decoder.h +9 -11
- package/src/video_encoder.cc +428 -35
- package/src/video_encoder.h +16 -0
- package/src/video_filter.cc +22 -11
- package/src/video_frame.cc +160 -5
- package/src/warnings.cc +0 -4
- package/dist/audio-data.js.map +0 -1
- package/dist/audio-decoder.js.map +0 -1
- package/dist/audio-encoder.js.map +0 -1
- package/dist/binding.js.map +0 -1
- package/dist/codec-base.js.map +0 -1
- package/dist/control-message-queue.js.map +0 -1
- package/dist/demuxer.js.map +0 -1
- package/dist/encoded-chunks.js.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/ffmpeg.d.ts +0 -21
- package/dist/ffmpeg.js +0 -112
- package/dist/image-decoder.js.map +0 -1
- package/dist/image-track-list.js.map +0 -1
- package/dist/image-track.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/is.js.map +0 -1
- package/dist/muxer.js.map +0 -1
- package/dist/native-types.js.map +0 -1
- package/dist/platform.js.map +0 -1
- package/dist/resource-manager.js.map +0 -1
- package/dist/test-video-generator.js.map +0 -1
- package/dist/transfer.js.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/video-decoder.js.map +0 -1
- package/dist/video-encoder.js.map +0 -1
- package/dist/video-filter.js.map +0 -1
- package/dist/video-frame.js.map +0 -1
- package/install/build.js +0 -51
- package/install/check.js +0 -192
- package/lib/ffmpeg.ts +0 -78
package/lib/types.ts
CHANGED
|
@@ -19,6 +19,34 @@ export type BufferSource = ArrayBuffer | ArrayBufferView;
|
|
|
19
19
|
/** WebIDL: typedef double DOMHighResTimeStamp */
|
|
20
20
|
export type DOMHighResTimeStamp = number;
|
|
21
21
|
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// NODE.JS EXTENSIONS
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* ImageData interface for canvas integration.
|
|
28
|
+
* Matches the return type of canvas.getContext('2d').getImageData().
|
|
29
|
+
* Self-describing: contains width, height, and RGBA pixel data.
|
|
30
|
+
*
|
|
31
|
+
* Note: This is a Node.js extension to WebCodecs, providing equivalent
|
|
32
|
+
* functionality to CanvasImageSource for server-side canvas integration.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* import { createCanvas } from 'canvas';
|
|
37
|
+
* const canvas = createCanvas(640, 480);
|
|
38
|
+
* const ctx = canvas.getContext('2d');
|
|
39
|
+
* // ... draw to canvas ...
|
|
40
|
+
* const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
41
|
+
* const frame = new VideoFrame(imageData, { timestamp: 0 });
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export interface ImageDataLike {
|
|
45
|
+
readonly width: number;
|
|
46
|
+
readonly height: number;
|
|
47
|
+
readonly data: Uint8ClampedArray;
|
|
48
|
+
}
|
|
49
|
+
|
|
22
50
|
// =============================================================================
|
|
23
51
|
// CODEC STATE
|
|
24
52
|
// =============================================================================
|
|
@@ -54,6 +82,17 @@ export type HardwareAcceleration = 'no-preference' | 'prefer-hardware' | 'prefer
|
|
|
54
82
|
*/
|
|
55
83
|
export type AlphaOption = 'keep' | 'discard';
|
|
56
84
|
|
|
85
|
+
/**
|
|
86
|
+
* WebIDL:
|
|
87
|
+
* enum PremultiplyAlpha { "none", "premultiply", "default" };
|
|
88
|
+
*
|
|
89
|
+
* Per W3C WebCodecs spec:
|
|
90
|
+
* - "none": Do not premultiply alpha
|
|
91
|
+
* - "premultiply": Premultiply RGB values by alpha
|
|
92
|
+
* - "default": Use default behavior (typically none)
|
|
93
|
+
*/
|
|
94
|
+
export type PremultiplyAlpha = 'none' | 'premultiply' | 'default';
|
|
95
|
+
|
|
57
96
|
// =============================================================================
|
|
58
97
|
// LATENCY MODE
|
|
59
98
|
// =============================================================================
|
|
@@ -788,6 +827,7 @@ export type ImageBufferSource = AllowSharedBufferSource | ReadableStream<Uint8Ar
|
|
|
788
827
|
* required DOMString type;
|
|
789
828
|
* required ImageBufferSource data;
|
|
790
829
|
* ColorSpaceConversion colorSpaceConversion = "default";
|
|
830
|
+
* PremultiplyAlpha premultiplyAlpha = "default";
|
|
791
831
|
* [EnforceRange] unsigned long desiredWidth;
|
|
792
832
|
* [EnforceRange] unsigned long desiredHeight;
|
|
793
833
|
* boolean preferAnimation;
|
|
@@ -798,6 +838,7 @@ export interface ImageDecoderInit {
|
|
|
798
838
|
type: string;
|
|
799
839
|
data: ImageBufferSource;
|
|
800
840
|
colorSpaceConversion?: ColorSpaceConversion;
|
|
841
|
+
premultiplyAlpha?: PremultiplyAlpha;
|
|
801
842
|
desiredWidth?: number;
|
|
802
843
|
desiredHeight?: number;
|
|
803
844
|
preferAnimation?: boolean;
|
|
@@ -1075,10 +1116,20 @@ export interface ImageDecoderConstructor {
|
|
|
1075
1116
|
* WebIDL:
|
|
1076
1117
|
* constructor(CanvasImageSource image, optional VideoFrameInit init = {});
|
|
1077
1118
|
* constructor(AllowSharedBufferSource data, VideoFrameBufferInit init);
|
|
1119
|
+
*
|
|
1120
|
+
* Note: CanvasImageSource (DOM-based) not available in Node.js.
|
|
1121
|
+
* Node.js alternatives:
|
|
1122
|
+
* - ImageDataLike: Self-describing RGBA buffer from canvas.getImageData()
|
|
1123
|
+
* - VideoFrame: Clone/crop existing frames
|
|
1124
|
+
* - AllowSharedBufferSource: Raw pixel data with explicit format/dimensions
|
|
1078
1125
|
*/
|
|
1079
1126
|
export interface VideoFrameConstructor {
|
|
1080
|
-
|
|
1127
|
+
/** Construct from ImageData (canvas.getImageData() compatible) */
|
|
1128
|
+
new (imageData: ImageDataLike, init?: VideoFrameInit): VideoFrame;
|
|
1129
|
+
/** Construct from raw pixel buffer with explicit format */
|
|
1081
1130
|
new (data: AllowSharedBufferSource, init: VideoFrameBufferInit): VideoFrame;
|
|
1131
|
+
/** Construct from existing VideoFrame (clone/crop) */
|
|
1132
|
+
new (source: VideoFrame, init?: VideoFrameInit): VideoFrame;
|
|
1082
1133
|
}
|
|
1083
1134
|
|
|
1084
1135
|
/**
|
package/lib/video-decoder.ts
CHANGED
|
@@ -17,6 +17,9 @@ import { VideoFrame } from './video-frame';
|
|
|
17
17
|
// Load native addon with type assertion
|
|
18
18
|
const native = binding as NativeModule;
|
|
19
19
|
|
|
20
|
+
// Default backpressure threshold for limiting in-flight chunks
|
|
21
|
+
const DEFAULT_MAX_QUEUE_DEPTH = 16;
|
|
22
|
+
|
|
20
23
|
export class VideoDecoder extends CodecBase {
|
|
21
24
|
private _native: NativeVideoDecoder;
|
|
22
25
|
private _controlQueue: ControlMessageQueue;
|
|
@@ -25,6 +28,9 @@ export class VideoDecoder extends CodecBase {
|
|
|
25
28
|
private _errorCallback: (error: DOMException) => void;
|
|
26
29
|
private _resourceId: symbol;
|
|
27
30
|
|
|
31
|
+
// Backpressure support
|
|
32
|
+
private _maxQueueDepth: number = DEFAULT_MAX_QUEUE_DEPTH;
|
|
33
|
+
|
|
28
34
|
constructor(init: VideoDecoderInit) {
|
|
29
35
|
super();
|
|
30
36
|
// W3C spec: output and error callbacks are required
|
|
@@ -38,7 +44,6 @@ export class VideoDecoder extends CodecBase {
|
|
|
38
44
|
this._resourceId = ResourceManager.getInstance().register(this);
|
|
39
45
|
|
|
40
46
|
const outputCallback: VideoDecoderOutputCallback = (nativeFrame) => {
|
|
41
|
-
// Decrement queue size when output received
|
|
42
47
|
this._decodeQueueSize = Math.max(0, this._decodeQueueSize - 1);
|
|
43
48
|
|
|
44
49
|
// Wrap the native frame as a VideoFrame
|
|
@@ -71,11 +76,78 @@ export class VideoDecoder extends CodecBase {
|
|
|
71
76
|
return this._native.codecSaturated;
|
|
72
77
|
}
|
|
73
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Returns a Promise that resolves when the decoder has capacity for more chunks.
|
|
81
|
+
* Use this to implement backpressure in high-throughput decoding pipelines.
|
|
82
|
+
*
|
|
83
|
+
* When the internal queue is full (decodeQueueSize >= maxQueueDepth), calling
|
|
84
|
+
* `await decoder.ready` will pause until capacity is available.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* for (const chunk of chunks) {
|
|
88
|
+
* await decoder.ready; // Wait for capacity
|
|
89
|
+
* decoder.decode(chunk);
|
|
90
|
+
* }
|
|
91
|
+
*/
|
|
92
|
+
get ready(): Promise<void> {
|
|
93
|
+
// If we have capacity, resolve immediately
|
|
94
|
+
if (this._decodeQueueSize < this._maxQueueDepth) {
|
|
95
|
+
return Promise.resolve();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Otherwise, poll until capacity is available.
|
|
99
|
+
// We use setTimeout(1ms) polling to allow TSFN output callbacks to execute.
|
|
100
|
+
// setTimeout ensures we yield through the full event loop cycle, including
|
|
101
|
+
// the I/O phase where TSFN callbacks are delivered.
|
|
102
|
+
return new Promise<void>((resolve) => {
|
|
103
|
+
const checkCapacity = () => {
|
|
104
|
+
if (this._decodeQueueSize < this._maxQueueDepth) {
|
|
105
|
+
resolve();
|
|
106
|
+
} else {
|
|
107
|
+
// Yield full event loop cycle to allow output callbacks to run
|
|
108
|
+
setTimeout(checkCapacity, 1);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
// Initial yield to allow any pending callbacks to run
|
|
112
|
+
setTimeout(checkCapacity, 1);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The maximum queue depth before backpressure is applied.
|
|
118
|
+
* Default is 16. Adjust based on memory constraints and frame size.
|
|
119
|
+
*/
|
|
120
|
+
get maxQueueDepth(): number {
|
|
121
|
+
return this._maxQueueDepth;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
set maxQueueDepth(value: number) {
|
|
125
|
+
if (value < 1) {
|
|
126
|
+
throw new RangeError('maxQueueDepth must be at least 1');
|
|
127
|
+
}
|
|
128
|
+
this._maxQueueDepth = value;
|
|
129
|
+
}
|
|
130
|
+
|
|
74
131
|
configure(config: VideoDecoderConfig): void {
|
|
75
132
|
// W3C spec: throw if closed
|
|
76
133
|
if (this.state === 'closed') {
|
|
77
134
|
throw new DOMException('Decoder is closed', 'InvalidStateError');
|
|
78
135
|
}
|
|
136
|
+
|
|
137
|
+
// Validate rotation (node-webcodecs extension)
|
|
138
|
+
if ('rotation' in config && config.rotation !== undefined) {
|
|
139
|
+
if (![0, 90, 180, 270].includes(config.rotation)) {
|
|
140
|
+
throw new TypeError(`rotation must be 0, 90, 180, or 270, got ${config.rotation}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Validate flip (node-webcodecs extension)
|
|
145
|
+
if ('flip' in config && config.flip !== undefined) {
|
|
146
|
+
if (typeof config.flip !== 'boolean') {
|
|
147
|
+
throw new TypeError('flip must be a boolean');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
79
151
|
this._needsKeyFrame = true;
|
|
80
152
|
// Configure synchronously to set state immediately per W3C spec
|
|
81
153
|
this._native.configure(config);
|
|
@@ -111,7 +183,17 @@ export class VideoDecoder extends CodecBase {
|
|
|
111
183
|
return Promise.reject(new DOMException('Decoder is closed', 'InvalidStateError'));
|
|
112
184
|
}
|
|
113
185
|
await this._controlQueue.flush();
|
|
114
|
-
|
|
186
|
+
|
|
187
|
+
// Flush the native decoder (waits for worker queue to drain)
|
|
188
|
+
this._native.flush();
|
|
189
|
+
|
|
190
|
+
// Poll for pending TSFN callbacks to complete.
|
|
191
|
+
// This allows the event loop to run (delivering callbacks) while we wait.
|
|
192
|
+
// Using setTimeout(1ms) instead of setImmediate to ensure other event loop
|
|
193
|
+
// phases (timers, I/O) can run, preventing event loop starvation.
|
|
194
|
+
while (this._native.pendingFrames > 0) {
|
|
195
|
+
await new Promise((resolve) => setTimeout(resolve, 1)); // 1ms poll
|
|
196
|
+
}
|
|
115
197
|
}
|
|
116
198
|
|
|
117
199
|
reset(): void {
|
package/lib/video-encoder.ts
CHANGED
|
@@ -17,12 +17,18 @@ import type { VideoFrame } from './video-frame';
|
|
|
17
17
|
// Load native addon with type assertion
|
|
18
18
|
const native = binding as NativeModule;
|
|
19
19
|
|
|
20
|
+
// Default backpressure threshold for limiting in-flight frames
|
|
21
|
+
const DEFAULT_MAX_QUEUE_DEPTH = 16;
|
|
22
|
+
|
|
20
23
|
export class VideoEncoder extends CodecBase {
|
|
21
24
|
private _native: NativeVideoEncoder;
|
|
22
25
|
private _controlQueue: ControlMessageQueue;
|
|
23
26
|
private _encodeQueueSize: number = 0;
|
|
24
27
|
private _resourceId: symbol;
|
|
25
28
|
|
|
29
|
+
// Backpressure support
|
|
30
|
+
private _maxQueueDepth: number = DEFAULT_MAX_QUEUE_DEPTH;
|
|
31
|
+
|
|
26
32
|
constructor(init: VideoEncoderInit) {
|
|
27
33
|
super();
|
|
28
34
|
// W3C spec: output and error callbacks are required
|
|
@@ -35,19 +41,32 @@ export class VideoEncoder extends CodecBase {
|
|
|
35
41
|
this._resourceId = ResourceManager.getInstance().register(this);
|
|
36
42
|
|
|
37
43
|
const outputCallback: VideoEncoderOutputCallback = (chunk, metadata) => {
|
|
38
|
-
// Decrement queue size when output received
|
|
39
44
|
this._encodeQueueSize = Math.max(0, this._encodeQueueSize - 1);
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
// The native layer now returns an EncodedVideoChunk directly (not a plain object).
|
|
47
|
+
// Check if it's already a native chunk (has close method) vs plain object (has data buffer).
|
|
48
|
+
// Native chunks have close() but no 'data' property; plain objects have 'data' buffer.
|
|
49
|
+
let wrappedChunk: EncodedVideoChunk;
|
|
50
|
+
if ('data' in chunk && chunk.data instanceof Buffer) {
|
|
51
|
+
// Legacy path: plain object from sync encoder - wrap it
|
|
52
|
+
wrappedChunk = new EncodedVideoChunk({
|
|
53
|
+
type: chunk.type as 'key' | 'delta',
|
|
54
|
+
timestamp: chunk.timestamp,
|
|
55
|
+
duration: chunk.duration ?? undefined,
|
|
56
|
+
data: chunk.data,
|
|
57
|
+
});
|
|
58
|
+
} else {
|
|
59
|
+
// New path: already a native EncodedVideoChunk from async encoder
|
|
60
|
+
// Wrap without copying data
|
|
61
|
+
wrappedChunk = EncodedVideoChunk._fromNative(
|
|
62
|
+
chunk as unknown as import('./native-types').NativeEncodedVideoChunk,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
47
65
|
init.output(wrappedChunk, metadata);
|
|
48
66
|
|
|
49
67
|
// Fire ondequeue after output
|
|
50
68
|
this._triggerDequeue();
|
|
69
|
+
|
|
51
70
|
};
|
|
52
71
|
|
|
53
72
|
this._native = new native.VideoEncoder({
|
|
@@ -68,6 +87,59 @@ export class VideoEncoder extends CodecBase {
|
|
|
68
87
|
return this._native.codecSaturated;
|
|
69
88
|
}
|
|
70
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Returns a Promise that resolves when the encoder has capacity for more frames.
|
|
92
|
+
* Use this to implement backpressure in high-throughput encoding pipelines.
|
|
93
|
+
*
|
|
94
|
+
* When the internal queue is full (encodeQueueSize >= maxQueueDepth), calling
|
|
95
|
+
* `await encoder.ready` will pause until capacity is available.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* for (const frame of frames) {
|
|
99
|
+
* await encoder.ready; // Wait for capacity
|
|
100
|
+
* encoder.encode(frame);
|
|
101
|
+
* frame.close();
|
|
102
|
+
* }
|
|
103
|
+
*/
|
|
104
|
+
get ready(): Promise<void> {
|
|
105
|
+
// If we have capacity, resolve immediately
|
|
106
|
+
if (this._encodeQueueSize < this._maxQueueDepth) {
|
|
107
|
+
return Promise.resolve();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Otherwise, poll until capacity is available.
|
|
111
|
+
// We use setTimeout(1ms) polling to allow TSFN output callbacks to execute.
|
|
112
|
+
// setTimeout ensures we yield through the full event loop cycle, including
|
|
113
|
+
// the I/O phase where TSFN callbacks are delivered.
|
|
114
|
+
return new Promise<void>((resolve) => {
|
|
115
|
+
const checkCapacity = () => {
|
|
116
|
+
if (this._encodeQueueSize < this._maxQueueDepth) {
|
|
117
|
+
resolve();
|
|
118
|
+
} else {
|
|
119
|
+
// Yield full event loop cycle to allow output callbacks to run
|
|
120
|
+
setTimeout(checkCapacity, 1);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
// Initial yield to allow any pending callbacks to run
|
|
124
|
+
setTimeout(checkCapacity, 1);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The maximum queue depth before backpressure is applied.
|
|
130
|
+
* Default is 16. Adjust based on memory constraints and frame size.
|
|
131
|
+
*/
|
|
132
|
+
get maxQueueDepth(): number {
|
|
133
|
+
return this._maxQueueDepth;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
set maxQueueDepth(value: number) {
|
|
137
|
+
if (value < 1) {
|
|
138
|
+
throw new RangeError('maxQueueDepth must be at least 1');
|
|
139
|
+
}
|
|
140
|
+
this._maxQueueDepth = value;
|
|
141
|
+
}
|
|
142
|
+
|
|
71
143
|
configure(config: VideoEncoderConfig): void {
|
|
72
144
|
// W3C spec: throw if closed
|
|
73
145
|
if (this.state === 'closed') {
|
|
@@ -84,6 +156,10 @@ export class VideoEncoder extends CodecBase {
|
|
|
84
156
|
}
|
|
85
157
|
|
|
86
158
|
encode(frame: VideoFrame, options?: { keyFrame?: boolean }): void {
|
|
159
|
+
// W3C spec: throw if not configured
|
|
160
|
+
if (this.state !== 'configured') {
|
|
161
|
+
throw new DOMException(`Encoder is ${this.state}`, 'InvalidStateError');
|
|
162
|
+
}
|
|
87
163
|
ResourceManager.getInstance().recordActivity(this._resourceId);
|
|
88
164
|
this._encodeQueueSize++;
|
|
89
165
|
// Call native encode directly - frame must be valid at call time
|
|
@@ -105,12 +181,18 @@ export class VideoEncoder extends CodecBase {
|
|
|
105
181
|
|
|
106
182
|
// Poll for pending TSFN callbacks to complete.
|
|
107
183
|
// This allows the event loop to run (delivering callbacks) while we wait.
|
|
184
|
+
// Using setTimeout(1ms) instead of setImmediate to ensure other event loop
|
|
185
|
+
// phases (timers, I/O) can run, preventing event loop starvation.
|
|
108
186
|
while (this._native.pendingChunks > 0) {
|
|
109
|
-
await new Promise((resolve) =>
|
|
187
|
+
await new Promise((resolve) => setTimeout(resolve, 1)); // 1ms poll
|
|
110
188
|
}
|
|
111
189
|
}
|
|
112
190
|
|
|
113
191
|
reset(): void {
|
|
192
|
+
// W3C spec: throw if closed
|
|
193
|
+
if (this.state === 'closed') {
|
|
194
|
+
throw new DOMException('Encoder is closed', 'InvalidStateError');
|
|
195
|
+
}
|
|
114
196
|
this._controlQueue.clear();
|
|
115
197
|
this._encodeQueueSize = 0;
|
|
116
198
|
this._native.reset();
|
package/lib/video-frame.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { binding } from './binding';
|
|
8
|
+
import { isImageData, type ImageDataLike } from './is';
|
|
8
9
|
import type { NativeModule, NativeVideoFrame } from './native-types';
|
|
9
10
|
import { detachArrayBuffers } from './transfer';
|
|
10
11
|
import type {
|
|
@@ -62,21 +63,65 @@ export class VideoFrame {
|
|
|
62
63
|
};
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
|
-
* Constructs a VideoFrame from raw data or from an existing VideoFrame.
|
|
66
|
+
* Constructs a VideoFrame from raw data, ImageData, or from an existing VideoFrame.
|
|
66
67
|
*
|
|
67
68
|
* Per W3C WebCodecs spec, VideoFrame can be constructed from:
|
|
68
|
-
* 1.
|
|
69
|
-
* 2.
|
|
69
|
+
* 1. ImageData - self-describing RGBA buffer (Node.js alternative to CanvasImageSource)
|
|
70
|
+
* 2. Raw buffer data with VideoFrameBufferInit
|
|
71
|
+
* 3. An existing VideoFrame with optional VideoFrameInit overrides
|
|
70
72
|
*/
|
|
73
|
+
constructor(imageData: ImageDataLike, init?: VideoFrameInit);
|
|
71
74
|
constructor(data: Buffer | Uint8Array | ArrayBuffer, init: VideoFrameBufferInit);
|
|
72
75
|
constructor(source: VideoFrame, init?: VideoFrameInit);
|
|
73
76
|
constructor(
|
|
74
|
-
|
|
77
|
+
dataOrSourceOrImageData: Buffer | Uint8Array | ArrayBuffer | VideoFrame | ImageDataLike,
|
|
75
78
|
init?: VideoFrameBufferInit | VideoFrameInit,
|
|
76
79
|
) {
|
|
80
|
+
// Check if constructing from ImageData (self-describing RGBA)
|
|
81
|
+
// ImageData is canvas.getContext('2d').getImageData() compatible
|
|
82
|
+
if (isImageData(dataOrSourceOrImageData)) {
|
|
83
|
+
const imageData = dataOrSourceOrImageData;
|
|
84
|
+
const frameInit = init as VideoFrameInit | undefined;
|
|
85
|
+
|
|
86
|
+
// Validate data size matches dimensions (width * height * 4 for RGBA)
|
|
87
|
+
const expectedSize = imageData.width * imageData.height * 4;
|
|
88
|
+
if (imageData.data.length !== expectedSize) {
|
|
89
|
+
throw new TypeError(
|
|
90
|
+
`ImageData.data length (${imageData.data.length}) does not match ` +
|
|
91
|
+
`expected size (${expectedSize}) for ${imageData.width}x${imageData.height} RGBA`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Convert Uint8ClampedArray to Buffer for native binding
|
|
96
|
+
const dataBuffer = Buffer.from(
|
|
97
|
+
imageData.data.buffer,
|
|
98
|
+
imageData.data.byteOffset,
|
|
99
|
+
imageData.data.byteLength,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Build VideoFrameBufferInit from ImageData dimensions
|
|
103
|
+
// ImageData is always RGBA format from canvas
|
|
104
|
+
const bufferInit: VideoFrameBufferInit = {
|
|
105
|
+
format: 'RGBA',
|
|
106
|
+
codedWidth: imageData.width,
|
|
107
|
+
codedHeight: imageData.height,
|
|
108
|
+
timestamp: frameInit?.timestamp ?? 0,
|
|
109
|
+
duration: frameInit?.duration,
|
|
110
|
+
visibleRect: frameInit?.visibleRect,
|
|
111
|
+
displayWidth: frameInit?.displayWidth ?? imageData.width,
|
|
112
|
+
displayHeight: frameInit?.displayHeight ?? imageData.height,
|
|
113
|
+
metadata: frameInit?.metadata,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
this._native = new native.VideoFrame(dataBuffer, bufferInit);
|
|
117
|
+
this._metadata = bufferInit.metadata ?? {};
|
|
118
|
+
this._closed = false;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
77
122
|
// Check if constructing from existing VideoFrame
|
|
78
|
-
if (
|
|
79
|
-
const source =
|
|
123
|
+
if (dataOrSourceOrImageData instanceof VideoFrame) {
|
|
124
|
+
const source = dataOrSourceOrImageData;
|
|
80
125
|
const frameInit = init as VideoFrameInit | undefined;
|
|
81
126
|
|
|
82
127
|
// W3C spec: throw InvalidStateError if source frame is closed (detached)
|
|
@@ -125,7 +170,7 @@ export class VideoFrame {
|
|
|
125
170
|
}
|
|
126
171
|
|
|
127
172
|
// Original buffer-based construction
|
|
128
|
-
const data =
|
|
173
|
+
const data = dataOrSourceOrImageData;
|
|
129
174
|
const bufferInit = init as VideoFrameBufferInit;
|
|
130
175
|
|
|
131
176
|
// Convert to Buffer if needed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pproenca/node-webcodecs",
|
|
3
3
|
"description": "WebCodecs API implementation for Node.js using FFmpeg",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.1-alpha.5",
|
|
5
5
|
"author": "Pedro Proenca",
|
|
6
6
|
"homepage": "https://github.com/pproenca/node-webcodecs",
|
|
7
7
|
"repository": {
|
|
@@ -12,29 +12,48 @@
|
|
|
12
12
|
"url": "https://github.com/pproenca/node-webcodecs/issues"
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
|
-
"build": "
|
|
16
|
-
"build:ts": "tsc",
|
|
15
|
+
"build": "node-gyp rebuild && tsc",
|
|
17
16
|
"build:native": "node-gyp rebuild",
|
|
18
|
-
"build:
|
|
19
|
-
"
|
|
17
|
+
"build:ts": "tsc",
|
|
18
|
+
"build:debug": "node-gyp rebuild --debug && tsc",
|
|
19
|
+
"rebuild": "npm run clean && npm run build",
|
|
20
|
+
"install": "node-gyp-build",
|
|
20
21
|
"clean": "rm -rf src/build/ .nyc_output/ coverage/ test/fixtures/output.*",
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"test
|
|
27
|
-
"
|
|
28
|
-
"
|
|
22
|
+
"check": "npm run lint && npm test",
|
|
23
|
+
"test": "npm run test:fast && npm run test:guardrails",
|
|
24
|
+
"test:fast": "tsx --test --test-concurrency=1 --import ./test/setup.ts --test-timeout=30000 test/golden/*.test.ts test/unit/*.test.ts",
|
|
25
|
+
"test:golden": "tsx --test --test-concurrency=1 --import ./test/setup.ts --test-timeout=30000 test/golden/*.test.ts",
|
|
26
|
+
"test:unit": "tsx --test --test-concurrency=1 --import ./test/setup.ts --test-timeout=30000 test/unit/*.test.ts",
|
|
27
|
+
"test:stress": "tsx --test --test-concurrency=1 --import ./test/setup.ts --test-timeout=60000 test/stress/*.test.ts",
|
|
28
|
+
"test:contracts": "node test/contracts/video_encoder/state_machine.js && node test/contracts/audio_encoder/state_machine.js && node test/contracts/video_decoder/state_machine.js && node test/contracts/audio_decoder/state_machine.js",
|
|
29
|
+
"test:guardrails": "node test/guardrails/fuzzer.js && node test/guardrails/event_loop_lag.js",
|
|
30
|
+
"test:coverage": "c8 --check-coverage --lines 70 --branches 60 --functions 70 --statements 70 --include lib/**/*.ts --exclude lib/**/*.d.ts --exclude lib/types.ts npm run test:fast",
|
|
31
|
+
"lint": "npm run lint:cpp && npm run lint:ts && npm run lint:types && npm run lint:md",
|
|
32
|
+
"lint:cpp": "cpplint --quiet src/*.h src/*.cc",
|
|
33
|
+
"lint:ts": "biome lint",
|
|
34
|
+
"lint:types": "tsd",
|
|
35
|
+
"lint:md": "prettier --check \"**/*.md\"",
|
|
36
|
+
"format": "npm run format:md",
|
|
37
|
+
"format:md": "prettier --write \"**/*.md\"",
|
|
38
|
+
"version:bump": "node scripts/bump-version.js",
|
|
39
|
+
"create-platform-packages": "node scripts/create-platform-packages.mjs"
|
|
29
40
|
},
|
|
30
41
|
"type": "commonjs",
|
|
31
42
|
"main": "dist/index.js",
|
|
32
43
|
"types": "dist/index.d.ts",
|
|
44
|
+
"exports": {
|
|
45
|
+
".": {
|
|
46
|
+
"types": "./dist/index.d.ts",
|
|
47
|
+
"require": "./dist/index.js",
|
|
48
|
+
"import": "./dist/index.js",
|
|
49
|
+
"default": "./dist/index.js"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
33
52
|
"files": [
|
|
34
|
-
"install",
|
|
35
53
|
"lib",
|
|
36
54
|
"dist",
|
|
37
|
-
"src/*.{cc,h,gyp}"
|
|
55
|
+
"src/*.{cc,h,gyp}",
|
|
56
|
+
"binding.gyp"
|
|
38
57
|
],
|
|
39
58
|
"keywords": [
|
|
40
59
|
"webcodecs",
|
|
@@ -46,35 +65,33 @@
|
|
|
46
65
|
"tsd": {
|
|
47
66
|
"directory": "test/types"
|
|
48
67
|
},
|
|
68
|
+
"optionalDependencies": {
|
|
69
|
+
"@pproenca/node-webcodecs-darwin-arm64": "0.1.1-alpha.5",
|
|
70
|
+
"@pproenca/node-webcodecs-darwin-x64": "0.1.1-alpha.5",
|
|
71
|
+
"@pproenca/node-webcodecs-linux-x64": "0.1.1-alpha.5"
|
|
72
|
+
},
|
|
49
73
|
"dependencies": {
|
|
50
|
-
"detect-libc": "^2.0.3",
|
|
51
74
|
"node-gyp-build": "^4.8.0"
|
|
52
75
|
},
|
|
53
|
-
"optionalDependencies": {
|
|
54
|
-
"@pproenca/node-webcodecs-darwin-arm64": "0.1.0",
|
|
55
|
-
"@pproenca/node-webcodecs-darwin-x64": "0.1.0",
|
|
56
|
-
"@pproenca/node-webcodecs-linux-x64": "0.1.0",
|
|
57
|
-
"@pproenca/node-webcodecs-linuxmusl-x64": "0.1.0",
|
|
58
|
-
"@pproenca/node-webcodecs-win32-x64": "0.1.0"
|
|
59
|
-
},
|
|
60
76
|
"devDependencies": {
|
|
61
77
|
"@biomejs/biome": "^2.3.10",
|
|
62
78
|
"@cpplint/cli": "^0.1.0",
|
|
63
|
-
"@types/node": "
|
|
64
|
-
"c8": "^10.1.
|
|
79
|
+
"@types/node": "^25.0.3",
|
|
80
|
+
"c8": "^10.1.3",
|
|
65
81
|
"express": "^5.2.1",
|
|
66
|
-
"mediabunny": "^1.
|
|
67
|
-
"node-addon-api": "^8.
|
|
68
|
-
"node-gyp": "^
|
|
82
|
+
"mediabunny": "^1.27.3",
|
|
83
|
+
"node-addon-api": "^8.5.0",
|
|
84
|
+
"node-gyp": "^12.1.0",
|
|
85
|
+
"prebuildify": "^6.0.1",
|
|
86
|
+
"prettier": "^3.7.4",
|
|
69
87
|
"tsd": "^0.33.0",
|
|
70
|
-
"tsx": "^4.
|
|
88
|
+
"tsx": "^4.21.0",
|
|
71
89
|
"typedoc": "^0.28.15",
|
|
72
|
-
"typescript": "^5.
|
|
73
|
-
"vitest": "^4.0.15"
|
|
90
|
+
"typescript": "^5.9.3"
|
|
74
91
|
},
|
|
75
92
|
"license": "MIT",
|
|
76
93
|
"engines": {
|
|
77
|
-
"node": "^
|
|
94
|
+
"node": "^20.17.0 || ^22.9.0 || >=24"
|
|
78
95
|
},
|
|
79
96
|
"publishConfig": {
|
|
80
97
|
"access": "public",
|
package/src/addon.cc
CHANGED
|
@@ -41,6 +41,45 @@ void ClearFFmpegWarningsJS(const Napi::CallbackInfo& info) {
|
|
|
41
41
|
webcodecs::ClearFFmpegWarnings();
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Counter accessor functions (following sharp pattern for observability)
|
|
45
|
+
Napi::Value GetCounterQueueJS(const Napi::CallbackInfo& info) {
|
|
46
|
+
return Napi::Number::New(info.Env(), webcodecs::counterQueue.load());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
Napi::Value GetCounterProcessJS(const Napi::CallbackInfo& info) {
|
|
50
|
+
return Napi::Number::New(info.Env(), webcodecs::counterProcess.load());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Napi::Value GetCounterFramesJS(const Napi::CallbackInfo& info) {
|
|
54
|
+
return Napi::Number::New(info.Env(), webcodecs::counterFrames.load());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
Napi::Value GetCountersJS(const Napi::CallbackInfo& info) {
|
|
58
|
+
Napi::Env env = info.Env();
|
|
59
|
+
Napi::Object counters = Napi::Object::New(env);
|
|
60
|
+
|
|
61
|
+
// New per-class counters
|
|
62
|
+
counters.Set("videoFrames",
|
|
63
|
+
static_cast<double>(webcodecs::counterVideoFrames.load()));
|
|
64
|
+
counters.Set("audioData",
|
|
65
|
+
static_cast<double>(webcodecs::counterAudioData.load()));
|
|
66
|
+
counters.Set("videoEncoders",
|
|
67
|
+
static_cast<double>(webcodecs::counterVideoEncoders.load()));
|
|
68
|
+
counters.Set("videoDecoders",
|
|
69
|
+
static_cast<double>(webcodecs::counterVideoDecoders.load()));
|
|
70
|
+
counters.Set("audioEncoders",
|
|
71
|
+
static_cast<double>(webcodecs::counterAudioEncoders.load()));
|
|
72
|
+
counters.Set("audioDecoders",
|
|
73
|
+
static_cast<double>(webcodecs::counterAudioDecoders.load()));
|
|
74
|
+
|
|
75
|
+
// Legacy counters (for backwards compatibility)
|
|
76
|
+
counters.Set("queue", webcodecs::counterQueue.load());
|
|
77
|
+
counters.Set("process", webcodecs::counterProcess.load());
|
|
78
|
+
counters.Set("frames", webcodecs::counterFrames.load());
|
|
79
|
+
|
|
80
|
+
return counters;
|
|
81
|
+
}
|
|
82
|
+
|
|
44
83
|
// Test helper for AttrAsEnum template
|
|
45
84
|
Napi::Value TestAttrAsEnum(const Napi::CallbackInfo& info) {
|
|
46
85
|
Napi::Env env = info.Env();
|
|
@@ -54,11 +93,22 @@ Napi::Value TestAttrAsEnum(const Napi::CallbackInfo& info) {
|
|
|
54
93
|
return Napi::String::New(env, webcodecs::ColorPrimariesToString(primaries));
|
|
55
94
|
}
|
|
56
95
|
|
|
96
|
+
// Cleanup hook called when the Node.js environment is being torn down.
|
|
97
|
+
// This prevents the static destruction order fiasco where FFmpeg's log
|
|
98
|
+
// callback might access destroyed static objects during process exit.
|
|
99
|
+
static void CleanupCallback(void* arg) {
|
|
100
|
+
webcodecs::ShutdownFFmpegLogging();
|
|
101
|
+
}
|
|
102
|
+
|
|
57
103
|
Napi::Object InitAll(Napi::Env env, Napi::Object exports) {
|
|
58
104
|
// Thread-safe FFmpeg initialization
|
|
59
105
|
webcodecs::InitFFmpeg();
|
|
60
106
|
webcodecs::InitFFmpegLogging();
|
|
61
107
|
|
|
108
|
+
// Register cleanup hook to disable FFmpeg logging before static destructors.
|
|
109
|
+
// This fixes crashes on macOS x64 where FFmpeg logs during process exit.
|
|
110
|
+
napi_add_env_cleanup_hook(env, CleanupCallback, nullptr);
|
|
111
|
+
|
|
62
112
|
InitVideoEncoder(env, exports);
|
|
63
113
|
InitVideoDecoder(env, exports);
|
|
64
114
|
InitVideoFrame(env, exports);
|
|
@@ -82,6 +132,13 @@ Napi::Object InitAll(Napi::Env env, Napi::Object exports) {
|
|
|
82
132
|
exports.Set("clearFFmpegWarnings",
|
|
83
133
|
Napi::Function::New(env, ClearFFmpegWarningsJS));
|
|
84
134
|
|
|
135
|
+
// Export global counter functions (following sharp pattern for observability)
|
|
136
|
+
exports.Set("getCounterQueue", Napi::Function::New(env, GetCounterQueueJS));
|
|
137
|
+
exports.Set("getCounterProcess",
|
|
138
|
+
Napi::Function::New(env, GetCounterProcessJS));
|
|
139
|
+
exports.Set("getCounterFrames", Napi::Function::New(env, GetCounterFramesJS));
|
|
140
|
+
exports.Set("getCounters", Napi::Function::New(env, GetCountersJS));
|
|
141
|
+
|
|
85
142
|
// Export test helpers
|
|
86
143
|
exports.Set("testAttrAsEnum", Napi::Function::New(env, TestAttrAsEnum));
|
|
87
144
|
|