@pproenca/node-webcodecs 0.1.1-alpha.0 → 0.1.1-alpha.6

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.
Files changed (97) hide show
  1. package/README.md +75 -233
  2. package/binding.gyp +123 -0
  3. package/dist/audio-decoder.js +1 -2
  4. package/dist/audio-encoder.d.ts +4 -0
  5. package/dist/audio-encoder.js +28 -2
  6. package/dist/binding.d.ts +0 -2
  7. package/dist/binding.js +43 -125
  8. package/dist/control-message-queue.js +0 -1
  9. package/dist/control-message-queue.js.map +1 -1
  10. package/dist/demuxer.d.ts +7 -0
  11. package/dist/demuxer.js +9 -0
  12. package/dist/encoded-chunks.d.ts +16 -0
  13. package/dist/encoded-chunks.js +82 -2
  14. package/dist/image-decoder.js +4 -0
  15. package/dist/index.d.ts +11 -0
  16. package/dist/index.js +3 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/native-types.d.ts +20 -0
  19. package/dist/platform.d.ts +1 -10
  20. package/dist/platform.js +1 -39
  21. package/dist/resource-manager.d.ts +1 -2
  22. package/dist/resource-manager.js +3 -17
  23. package/dist/resource-manager.js.map +1 -1
  24. package/dist/types.d.ts +12 -0
  25. package/dist/types.js.map +1 -1
  26. package/dist/video-decoder.d.ts +21 -0
  27. package/dist/video-decoder.js +74 -2
  28. package/dist/video-encoder.d.ts +22 -0
  29. package/dist/video-encoder.js +83 -8
  30. package/lib/audio-decoder.ts +1 -2
  31. package/lib/audio-encoder.ts +31 -2
  32. package/lib/binding.ts +45 -104
  33. package/lib/control-message-queue.ts +0 -1
  34. package/lib/demuxer.ts +10 -0
  35. package/lib/encoded-chunks.ts +90 -2
  36. package/lib/image-decoder.ts +5 -0
  37. package/lib/index.ts +3 -0
  38. package/lib/native-types.ts +22 -0
  39. package/lib/platform.ts +1 -41
  40. package/lib/resource-manager.ts +3 -19
  41. package/lib/types.ts +13 -0
  42. package/lib/video-decoder.ts +84 -2
  43. package/lib/video-encoder.ts +90 -8
  44. package/package.json +48 -32
  45. package/src/addon.cc +57 -0
  46. package/src/async_decode_worker.cc +241 -33
  47. package/src/async_decode_worker.h +55 -3
  48. package/src/async_encode_worker.cc +103 -35
  49. package/src/async_encode_worker.h +23 -4
  50. package/src/audio_data.cc +38 -15
  51. package/src/audio_data.h +1 -0
  52. package/src/audio_decoder.cc +24 -3
  53. package/src/audio_encoder.cc +55 -4
  54. package/src/common.cc +125 -17
  55. package/src/common.h +34 -4
  56. package/src/demuxer.cc +16 -2
  57. package/src/encoded_audio_chunk.cc +10 -0
  58. package/src/encoded_audio_chunk.h +2 -0
  59. package/src/encoded_video_chunk.h +1 -0
  60. package/src/error_builder.cc +0 -4
  61. package/src/image_decoder.cc +127 -90
  62. package/src/image_decoder.h +11 -4
  63. package/src/muxer.cc +1 -0
  64. package/src/test_video_generator.cc +3 -2
  65. package/src/video_decoder.cc +169 -19
  66. package/src/video_decoder.h +9 -11
  67. package/src/video_encoder.cc +389 -32
  68. package/src/video_encoder.h +15 -0
  69. package/src/video_filter.cc +22 -11
  70. package/src/video_frame.cc +160 -5
  71. package/src/warnings.cc +0 -4
  72. package/dist/audio-data.js.map +0 -1
  73. package/dist/audio-decoder.js.map +0 -1
  74. package/dist/audio-encoder.js.map +0 -1
  75. package/dist/binding.js.map +0 -1
  76. package/dist/codec-base.js.map +0 -1
  77. package/dist/demuxer.js.map +0 -1
  78. package/dist/encoded-chunks.js.map +0 -1
  79. package/dist/errors.js.map +0 -1
  80. package/dist/ffmpeg.d.ts +0 -21
  81. package/dist/ffmpeg.js +0 -112
  82. package/dist/image-decoder.js.map +0 -1
  83. package/dist/image-track-list.js.map +0 -1
  84. package/dist/image-track.js.map +0 -1
  85. package/dist/is.js.map +0 -1
  86. package/dist/muxer.js.map +0 -1
  87. package/dist/native-types.js.map +0 -1
  88. package/dist/platform.js.map +0 -1
  89. package/dist/test-video-generator.js.map +0 -1
  90. package/dist/transfer.js.map +0 -1
  91. package/dist/video-decoder.js.map +0 -1
  92. package/dist/video-encoder.js.map +0 -1
  93. package/dist/video-filter.js.map +0 -1
  94. package/dist/video-frame.js.map +0 -1
  95. package/install/build.js +0 -51
  96. package/install/check.js +0 -192
  97. package/lib/ffmpeg.ts +0 -78
@@ -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
- return this._native.flush();
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 {
@@ -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
- const wrappedChunk = new EncodedVideoChunk({
42
- type: chunk.type as 'key' | 'delta',
43
- timestamp: chunk.timestamp,
44
- duration: chunk.duration ?? undefined,
45
- data: chunk.data,
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) => setImmediate(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/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.1-alpha.0",
4
+ "version": "0.1.1-alpha.6",
5
5
  "author": "Pedro Proenca",
6
6
  "homepage": "https://github.com/pproenca/node-webcodecs",
7
7
  "repository": {
@@ -12,29 +12,47 @@
12
12
  "url": "https://github.com/pproenca/node-webcodecs/issues"
13
13
  },
14
14
  "scripts": {
15
- "build": "npm run build:native && npm run build:ts",
16
- "build:ts": "tsc",
15
+ "build": "node-gyp rebuild && tsc",
17
16
  "build:native": "node-gyp rebuild",
18
- "build:native:debug": "node-gyp rebuild --debug",
19
- "install": "node install/check.js || npm run build",
17
+ "build:ts": "tsc",
18
+ "build:debug": "node-gyp rebuild --debug && tsc",
19
+ "rebuild": "npm run clean && npm run build",
20
20
  "clean": "rm -rf src/build/ .nyc_output/ coverage/ test/fixtures/output.*",
21
- "test": "npm run lint && npm run test-unit",
22
- "test-unit": "vitest run --config test/vitest.config.ts",
23
- "lint": "npm run lint-cpp && npm run lint-js && npm run lint-types",
24
- "lint-cpp": "cpplint --quiet src/*.h src/*.cc",
25
- "lint-js": "biome lint",
26
- "lint-types": "tsd",
27
- "package-from-local-build": "node npm/from-local-build.js",
28
- "package-release-notes": "node npm/release-notes.js"
21
+ "check": "npm run lint && npm test",
22
+ "test": "npm run test:fast && npm run test:guardrails",
23
+ "test:fast": "tsx --test --test-concurrency=1 --import ./test/setup.ts --test-timeout=30000 test/golden/*.test.ts test/unit/*.test.ts",
24
+ "test:golden": "tsx --test --test-concurrency=1 --import ./test/setup.ts --test-timeout=30000 test/golden/*.test.ts",
25
+ "test:unit": "tsx --test --test-concurrency=1 --import ./test/setup.ts --test-timeout=30000 test/unit/*.test.ts",
26
+ "test:stress": "tsx --test --test-concurrency=1 --import ./test/setup.ts --test-timeout=60000 test/stress/*.test.ts",
27
+ "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",
28
+ "test:guardrails": "node test/guardrails/fuzzer.js && node test/guardrails/event_loop_lag.js",
29
+ "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",
30
+ "lint": "npm run lint:cpp && npm run lint:ts && npm run lint:types && npm run lint:md",
31
+ "lint:cpp": "cpplint --quiet src/*.h src/*.cc",
32
+ "lint:ts": "biome lint",
33
+ "lint:types": "tsd",
34
+ "lint:md": "prettier --check \"**/*.md\"",
35
+ "format": "npm run format:md",
36
+ "format:md": "prettier --write \"**/*.md\"",
37
+ "version:bump": "node scripts/bump-version.js",
38
+ "create-platform-packages": "node scripts/create-platform-packages.mjs"
29
39
  },
30
40
  "type": "commonjs",
31
41
  "main": "dist/index.js",
32
42
  "types": "dist/index.d.ts",
43
+ "exports": {
44
+ ".": {
45
+ "types": "./dist/index.d.ts",
46
+ "require": "./dist/index.js",
47
+ "import": "./dist/index.js",
48
+ "default": "./dist/index.js"
49
+ }
50
+ },
33
51
  "files": [
34
- "install",
35
52
  "lib",
36
53
  "dist",
37
- "src/*.{cc,h,gyp}"
54
+ "src/*.{cc,h,gyp}",
55
+ "binding.gyp"
38
56
  ],
39
57
  "keywords": [
40
58
  "webcodecs",
@@ -46,35 +64,33 @@
46
64
  "tsd": {
47
65
  "directory": "test/types"
48
66
  },
67
+ "optionalDependencies": {
68
+ "@pproenca/node-webcodecs-darwin-arm64": "0.1.1-alpha.5",
69
+ "@pproenca/node-webcodecs-darwin-x64": "0.1.1-alpha.5",
70
+ "@pproenca/node-webcodecs-linux-x64": "0.1.1-alpha.5"
71
+ },
49
72
  "dependencies": {
50
- "detect-libc": "^2.0.3",
51
73
  "node-gyp-build": "^4.8.0"
52
74
  },
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
75
  "devDependencies": {
61
76
  "@biomejs/biome": "^2.3.10",
62
77
  "@cpplint/cli": "^0.1.0",
63
- "@types/node": "*",
64
- "c8": "^10.1.2",
78
+ "@types/node": "^25.0.3",
79
+ "c8": "^10.1.3",
65
80
  "express": "^5.2.1",
66
- "mediabunny": "^1.25.4",
67
- "node-addon-api": "^8.0.0",
68
- "node-gyp": "^10.0.0",
81
+ "mediabunny": "^1.27.3",
82
+ "node-addon-api": "^8.5.0",
83
+ "node-gyp": "^12.1.0",
84
+ "prebuildify": "^6.0.1",
85
+ "prettier": "^3.7.4",
69
86
  "tsd": "^0.33.0",
70
- "tsx": "^4.20.6",
87
+ "tsx": "^4.21.0",
71
88
  "typedoc": "^0.28.15",
72
- "typescript": "^5.6.3",
73
- "vitest": "^4.0.15"
89
+ "typescript": "^5.9.3"
74
90
  },
75
91
  "license": "MIT",
76
92
  "engines": {
77
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
93
+ "node": "^20.17.0 || ^22.9.0 || >=24"
78
94
  },
79
95
  "publishConfig": {
80
96
  "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