@matjash/pixi-native-win32-x64 0.2.1 → 0.2.3

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.
@@ -228,15 +228,51 @@ impl Voice {
228
228
  self.timeline_updated_at = now;
229
229
  return;
230
230
  }
231
- self.timeline_seconds +=
232
- now.duration_since(self.timeline_updated_at).as_secs_f64() * self.playback_rate;
233
- if self.looped {
234
- if let Some(duration_seconds) = self.duration_seconds {
235
- self.timeline_seconds %= duration_seconds;
236
- }
237
- }
231
+ self.timeline_seconds = advance_timeline(
232
+ self.timeline_seconds,
233
+ now.duration_since(self.timeline_updated_at).as_secs_f64(),
234
+ self.playback_rate,
235
+ self.duration_seconds,
236
+ self.looped,
237
+ );
238
238
  self.timeline_updated_at = now;
239
239
  }
240
+
241
+ fn store_snapshot_time(&self, elapsed_seconds: f64) {
242
+ if let Some(time) = finite_absolute_time(self.offset_seconds, elapsed_seconds) {
243
+ self.snapshot
244
+ .time_bits
245
+ .store(time.to_bits(), Ordering::Release);
246
+ }
247
+ }
248
+ }
249
+
250
+ fn advance_timeline(
251
+ current_seconds: f64,
252
+ elapsed_seconds: f64,
253
+ playback_rate: f64,
254
+ duration_seconds: Option<f64>,
255
+ looped: bool,
256
+ ) -> f64 {
257
+ let current_seconds = if current_seconds.is_finite() {
258
+ current_seconds.max(0.0)
259
+ } else {
260
+ 0.0
261
+ };
262
+ let advanced_seconds = current_seconds + elapsed_seconds * playback_rate;
263
+ if !advanced_seconds.is_finite() {
264
+ return current_seconds;
265
+ }
266
+ match duration_seconds {
267
+ Some(duration) if looped => advanced_seconds % duration,
268
+ Some(duration) => advanced_seconds.min(duration),
269
+ None => advanced_seconds,
270
+ }
271
+ }
272
+
273
+ fn finite_absolute_time(offset_seconds: f64, elapsed_seconds: f64) -> Option<f64> {
274
+ let time = offset_seconds + elapsed_seconds;
275
+ time.is_finite().then_some(time)
240
276
  }
241
277
 
242
278
  enum EngineCommand {
@@ -507,7 +543,8 @@ impl NativeAudioEngine {
507
543
  #[napi]
508
544
  pub fn current_time(&self, id: u32) -> Option<f64> {
509
545
  let snapshot = self.state.snapshots.lock().ok()?.get(&id).cloned()?;
510
- Some(f64::from_bits(snapshot.time_bits.load(Ordering::Acquire)))
546
+ let time = f64::from_bits(snapshot.time_bits.load(Ordering::Acquire));
547
+ time.is_finite().then_some(time)
511
548
  }
512
549
 
513
550
  #[napi]
@@ -917,11 +954,7 @@ impl EngineRuntime {
917
954
  } else {
918
955
  voice.timeline_seconds
919
956
  };
920
- let time = voice.offset_seconds + elapsed_seconds;
921
- voice
922
- .snapshot
923
- .time_bits
924
- .store(time.to_bits(), Ordering::Release);
957
+ voice.store_snapshot_time(elapsed_seconds);
925
958
  let current_volume = if voice.fade.is_some() {
926
959
  sound.current_fade_volume()
927
960
  } else {
@@ -1345,4 +1378,27 @@ mod tests {
1345
1378
  "https://***:***@example.test/audio.mp3"
1346
1379
  );
1347
1380
  }
1381
+
1382
+ #[test]
1383
+ fn static_timeline_stays_finite_and_within_sprite_duration() {
1384
+ assert_eq!(advance_timeline(0.2, 0.05, 1.0, Some(0.8), false), 0.25);
1385
+ assert_eq!(advance_timeline(0.7, 0.2, 1.0, Some(0.8), false), 0.8);
1386
+ let looped = advance_timeline(0.7, 0.2, 1.0, Some(0.8), true);
1387
+ assert!((looped - 0.1).abs() < f64::EPSILON);
1388
+ assert_eq!(
1389
+ advance_timeline(f64::INFINITY, 0.05, 1.0, Some(0.8), false),
1390
+ 0.05
1391
+ );
1392
+ assert_eq!(
1393
+ advance_timeline(0.2, f64::INFINITY, 1.0, Some(0.8), false),
1394
+ 0.2
1395
+ );
1396
+ }
1397
+
1398
+ #[test]
1399
+ fn snapshot_time_rejects_non_finite_values() {
1400
+ assert_eq!(finite_absolute_time(1.0, 0.2), Some(1.2));
1401
+ assert_eq!(finite_absolute_time(0.0, f64::INFINITY), None);
1402
+ assert_eq!(finite_absolute_time(f64::INFINITY, 0.0), None);
1403
+ }
1348
1404
  }
@@ -19,11 +19,27 @@ export interface VideoFrame {
19
19
  data: Uint8Array;
20
20
  }
21
21
 
22
+ export interface VideoFrameInfo {
23
+ width: number;
24
+ height: number;
25
+ timestampUs: number;
26
+ }
27
+
28
+ export interface VideoShutdownDiagnostics {
29
+ activeDecoderWorkers: number;
30
+ pendingDecoderShutdowns: number;
31
+ completedDecoderShutdowns: number;
32
+ maxDecoderShutdownMs: number;
33
+ }
34
+
22
35
  export class NativeVideoDecoder {
23
36
  public constructor(options: DecoderOptions);
24
37
  public open(source: string): void;
25
38
  public pollLatest(): VideoFrame | null;
26
39
  public pollNext(): VideoFrame | null;
40
+ public supportsFrameBufferReuse(): boolean;
41
+ public pollLatestInto(target: Buffer): VideoFrameInfo | null;
42
+ public pollNextInto(target: Buffer): VideoFrameInfo | null;
27
43
  public queuedFrames(): number;
28
44
  public catchUpTo(timestampUs: number): void;
29
45
  public pollError(): string | null;
@@ -31,6 +47,11 @@ export class NativeVideoDecoder {
31
47
  public decodedFrames(): number;
32
48
  public droppedFrames(): number;
33
49
  public skippedFrames(): number;
50
+ public frameBufferAllocations(): number;
51
+ public frameBufferReuses(): number;
52
+ public recycledFrameBuffers(): number;
34
53
  public isFinished(): boolean;
35
54
  public close(): void;
36
55
  }
56
+
57
+ export function videoShutdownDiagnostics(): VideoShutdownDiagnostics;
@@ -21,6 +21,18 @@ class NativeVideoDecoder {
21
21
  return this.decoder.pollNext();
22
22
  }
23
23
 
24
+ supportsFrameBufferReuse() {
25
+ return typeof this.decoder.pollNextInto === "function";
26
+ }
27
+
28
+ pollLatestInto(target) {
29
+ return this.pollIntoFallback("pollLatest", "pollLatestInto", target);
30
+ }
31
+
32
+ pollNextInto(target) {
33
+ return this.pollIntoFallback("pollNext", "pollNextInto", target);
34
+ }
35
+
24
36
  queuedFrames() {
25
37
  return this.decoder.queuedFrames();
26
38
  }
@@ -49,6 +61,18 @@ class NativeVideoDecoder {
49
61
  return this.decoder.skippedFrames();
50
62
  }
51
63
 
64
+ frameBufferAllocations() {
65
+ return this.decoder.frameBufferAllocations?.() ?? 0;
66
+ }
67
+
68
+ frameBufferReuses() {
69
+ return this.decoder.frameBufferReuses?.() ?? 0;
70
+ }
71
+
72
+ recycledFrameBuffers() {
73
+ return this.decoder.recycledFrameBuffers?.() ?? 0;
74
+ }
75
+
52
76
  isFinished() {
53
77
  return this.decoder.isFinished();
54
78
  }
@@ -56,6 +80,34 @@ class NativeVideoDecoder {
56
80
  close() {
57
81
  this.decoder.close();
58
82
  }
83
+
84
+ pollIntoFallback(pollName, pollIntoName, target) {
85
+ if (typeof this.decoder[pollIntoName] === "function") {
86
+ return this.decoder[pollIntoName](target);
87
+ }
88
+ const frame = this.decoder[pollName]();
89
+ if (!frame) return null;
90
+ if (target.byteLength !== frame.data.byteLength) {
91
+ throw new RangeError(
92
+ `NV12 target has ${target.byteLength} bytes; expected ${frame.data.byteLength}`,
93
+ );
94
+ }
95
+ target.set(frame.data);
96
+ return {
97
+ width: frame.width,
98
+ height: frame.height,
99
+ timestampUs: frame.timestampUs,
100
+ };
101
+ }
102
+ }
103
+
104
+ function videoShutdownDiagnostics() {
105
+ return native.videoShutdownDiagnostics?.() ?? {
106
+ activeDecoderWorkers: 0,
107
+ pendingDecoderShutdowns: 0,
108
+ completedDecoderShutdowns: 0,
109
+ maxDecoderShutdownMs: 0,
110
+ };
59
111
  }
60
112
 
61
- module.exports = { NativeVideoDecoder };
113
+ module.exports = { NativeVideoDecoder, videoShutdownDiagnostics };
@@ -6,7 +6,7 @@ use std::process::{Child, ChildStdout, Command, Stdio};
6
6
  use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
7
7
  use std::sync::{Arc, Condvar, Mutex};
8
8
  use std::thread;
9
- use std::time::Duration;
9
+ use std::time::{Duration, Instant};
10
10
 
11
11
  use napi::bindgen_prelude::*;
12
12
  use napi_derive::napi;
@@ -15,6 +15,11 @@ const FRAME_QUEUE_CAPACITY: usize = 4;
15
15
  const HARDWARE_DECODE_ATTEMPTS: usize = 5;
16
16
  const HARDWARE_RETRY_DELAY: Duration = Duration::from_millis(500);
17
17
 
18
+ static ACTIVE_DECODER_WORKERS: AtomicU64 = AtomicU64::new(0);
19
+ static PENDING_DECODER_SHUTDOWNS: AtomicU64 = AtomicU64::new(0);
20
+ static COMPLETED_DECODER_SHUTDOWNS: AtomicU64 = AtomicU64::new(0);
21
+ static MAX_DECODER_SHUTDOWN_MS: AtomicU64 = AtomicU64::new(0);
22
+
18
23
  #[napi(object)]
19
24
  pub struct DecoderOptions {
20
25
  pub width: i64,
@@ -38,6 +43,31 @@ pub struct VideoFrame {
38
43
  pub data: Buffer,
39
44
  }
40
45
 
46
+ #[napi(object)]
47
+ pub struct VideoFrameInfo {
48
+ pub width: i64,
49
+ pub height: i64,
50
+ pub timestamp_us: i64,
51
+ }
52
+
53
+ #[napi(object)]
54
+ pub struct VideoShutdownDiagnostics {
55
+ pub active_decoder_workers: i64,
56
+ pub pending_decoder_shutdowns: i64,
57
+ pub completed_decoder_shutdowns: i64,
58
+ pub max_decoder_shutdown_ms: i64,
59
+ }
60
+
61
+ #[napi]
62
+ pub fn video_shutdown_diagnostics() -> VideoShutdownDiagnostics {
63
+ VideoShutdownDiagnostics {
64
+ active_decoder_workers: atomic_i64(&ACTIVE_DECODER_WORKERS),
65
+ pending_decoder_shutdowns: atomic_i64(&PENDING_DECODER_SHUTDOWNS),
66
+ completed_decoder_shutdowns: atomic_i64(&COMPLETED_DECODER_SHUTDOWNS),
67
+ max_decoder_shutdown_ms: atomic_i64(&MAX_DECODER_SHUTDOWN_MS),
68
+ }
69
+ }
70
+
41
71
  #[derive(Clone, Copy, Debug, Eq, PartialEq)]
42
72
  enum DecoderBackend {
43
73
  D3d11va,
@@ -84,7 +114,10 @@ struct DecoderState {
84
114
  decoded_frames: Arc<AtomicU64>,
85
115
  dropped_frames: Arc<AtomicU64>,
86
116
  skipped_frames: Arc<AtomicU64>,
117
+ frame_buffer_allocations: Arc<AtomicU64>,
118
+ frame_buffer_reuses: Arc<AtomicU64>,
87
119
  backend: Arc<Mutex<String>>,
120
+ stderr_workers: Arc<Mutex<Vec<thread::JoinHandle<()>>>>,
88
121
  }
89
122
 
90
123
  struct PendingFrame {
@@ -95,6 +128,7 @@ struct PendingFrame {
95
128
  #[derive(Default)]
96
129
  struct FrameQueue {
97
130
  frames: VecDeque<PendingFrame>,
131
+ recycled: Vec<Vec<u8>>,
98
132
  }
99
133
 
100
134
  impl FrameQueue {
@@ -108,6 +142,19 @@ impl FrameQueue {
108
142
  recycled
109
143
  }
110
144
 
145
+ fn take_recycled(&mut self, frame_bytes: usize) -> Option<Vec<u8>> {
146
+ while let Some(buffer) = self.recycled.pop() {
147
+ if buffer.len() == frame_bytes {
148
+ return Some(buffer);
149
+ }
150
+ }
151
+ None
152
+ }
153
+
154
+ fn recycle(&mut self, buffer: Vec<u8>) {
155
+ self.recycled.push(buffer);
156
+ }
157
+
111
158
  fn push_back(&mut self, frame: PendingFrame) {
112
159
  self.frames.push_back(frame);
113
160
  }
@@ -119,7 +166,9 @@ impl FrameQueue {
119
166
  fn pop_latest(&mut self) -> (Option<PendingFrame>, usize) {
120
167
  let latest = self.frames.pop_back();
121
168
  let skipped = self.frames.len();
122
- self.frames.clear();
169
+ while let Some(frame) = self.frames.pop_front() {
170
+ self.recycle(frame.data);
171
+ }
123
172
  (latest, skipped)
124
173
  }
125
174
 
@@ -127,14 +176,22 @@ impl FrameQueue {
127
176
  self.frames.len()
128
177
  }
129
178
 
179
+ fn recycle_queued(&mut self) {
180
+ while let Some(frame) = self.frames.pop_front() {
181
+ self.recycle(frame.data);
182
+ }
183
+ }
184
+
130
185
  fn clear(&mut self) {
131
186
  self.frames.clear();
187
+ self.recycled.clear();
132
188
  }
133
189
  }
134
190
 
135
191
  struct SpawnedFfmpeg {
136
192
  child: Child,
137
193
  stdout: ChildStdout,
194
+ stderr_worker: Option<thread::JoinHandle<()>>,
138
195
  }
139
196
 
140
197
  #[derive(Clone)]
@@ -155,6 +212,8 @@ struct FfmpegRequest {
155
212
  pub struct NativeVideoDecoder {
156
213
  options: DecoderOptions,
157
214
  state: DecoderState,
215
+ worker: Option<thread::JoinHandle<()>>,
216
+ retired: bool,
158
217
  }
159
218
 
160
219
  #[napi]
@@ -171,6 +230,8 @@ impl NativeVideoDecoder {
171
230
 
172
231
  Ok(Self {
173
232
  options,
233
+ worker: None,
234
+ retired: false,
174
235
  state: DecoderState {
175
236
  closed: Arc::new(AtomicBool::new(true)),
176
237
  finished: Arc::new(AtomicBool::new(false)),
@@ -182,13 +243,24 @@ impl NativeVideoDecoder {
182
243
  decoded_frames: Arc::new(AtomicU64::new(0)),
183
244
  dropped_frames: Arc::new(AtomicU64::new(0)),
184
245
  skipped_frames: Arc::new(AtomicU64::new(0)),
246
+ frame_buffer_allocations: Arc::new(AtomicU64::new(0)),
247
+ frame_buffer_reuses: Arc::new(AtomicU64::new(0)),
185
248
  backend: Arc::new(Mutex::new(backend)),
249
+ stderr_workers: Arc::new(Mutex::new(Vec::new())),
186
250
  },
187
251
  })
188
252
  }
189
253
 
190
254
  #[napi]
191
255
  pub fn open(&mut self, source: String) -> Result<()> {
256
+ if self.retired {
257
+ return Err(Error::from_reason(
258
+ "Video decoder cannot reopen after shutdown",
259
+ ));
260
+ }
261
+ if self.state.closed.load(Ordering::SeqCst) {
262
+ self.join_worker();
263
+ }
192
264
  if !self.state.closed.swap(false, Ordering::SeqCst) {
193
265
  return Err(Error::from_reason("Video decoder is already open"));
194
266
  }
@@ -197,6 +269,10 @@ impl NativeVideoDecoder {
197
269
  self.state.decoded_frames.store(0, Ordering::SeqCst);
198
270
  self.state.dropped_frames.store(0, Ordering::SeqCst);
199
271
  self.state.skipped_frames.store(0, Ordering::SeqCst);
272
+ self.state
273
+ .frame_buffer_allocations
274
+ .store(0, Ordering::SeqCst);
275
+ self.state.frame_buffer_reuses.store(0, Ordering::SeqCst);
200
276
 
201
277
  if let Ok(mut error) = self.state.error.lock() {
202
278
  *error = None;
@@ -268,7 +344,9 @@ impl NativeVideoDecoder {
268
344
  let initial_stdout = install_child(&state, spawned)
269
345
  .map_err(|error| Error::from_reason(error.to_string()))?;
270
346
 
271
- thread::spawn(move || {
347
+ ACTIVE_DECODER_WORKERS.fetch_add(1, Ordering::SeqCst);
348
+ self.worker = Some(thread::spawn(move || {
349
+ let _active_worker = ActiveDecoderWorker;
272
350
  let mut result =
273
351
  consume_decoder_attempt(initial_stdout, width, height, fps, start_time, &state);
274
352
 
@@ -334,7 +412,7 @@ impl NativeVideoDecoder {
334
412
  state.finished.store(true, Ordering::SeqCst);
335
413
  }
336
414
  state.closed.store(true, Ordering::SeqCst);
337
- });
415
+ }));
338
416
 
339
417
  Ok(())
340
418
  }
@@ -360,6 +438,41 @@ impl NativeVideoDecoder {
360
438
  self.to_video_frame(pending)
361
439
  }
362
440
 
441
+ #[napi]
442
+ pub fn poll_latest_into(&self, mut target: BufferSlice) -> Result<Option<VideoFrameInfo>> {
443
+ let (frames, available) = &*self.state.frames;
444
+ let (pending, skipped) = frames
445
+ .lock()
446
+ .map_err(|_| Error::from_reason("Video frame queue lock poisoned"))?
447
+ .pop_latest();
448
+ if skipped > 0 {
449
+ self.state
450
+ .skipped_frames
451
+ .fetch_add(skipped as u64, Ordering::SeqCst);
452
+ }
453
+ let result = pending
454
+ .map(|pending| self.copy_video_frame_into(pending, &mut target))
455
+ .transpose();
456
+ available.notify_all();
457
+ result
458
+ }
459
+
460
+ #[napi]
461
+ pub fn poll_next_into(&self, mut target: BufferSlice) -> Result<Option<VideoFrameInfo>> {
462
+ let (frames, available) = &*self.state.frames;
463
+ let pending = frames
464
+ .lock()
465
+ .map_err(|_| Error::from_reason("Video frame queue lock poisoned"))?
466
+ .pop_next();
467
+ let result = pending
468
+ .map(|pending| self.copy_video_frame_into(pending, &mut target))
469
+ .transpose();
470
+ if result.as_ref().is_ok_and(Option::is_some) {
471
+ available.notify_one();
472
+ }
473
+ result
474
+ }
475
+
363
476
  #[napi]
364
477
  pub fn queued_frames(&self) -> i64 {
365
478
  let (frames, _) = &*self.state.frames;
@@ -383,7 +496,7 @@ impl NativeVideoDecoder {
383
496
  let (frames, available) = &*self.state.frames;
384
497
  if let Ok(mut frames) = frames.lock() {
385
498
  let skipped = frames.len();
386
- frames.clear();
499
+ frames.recycle_queued();
387
500
  self.state
388
501
  .skipped_frames
389
502
  .fetch_add(skipped as u64, Ordering::SeqCst);
@@ -430,6 +543,26 @@ impl NativeVideoDecoder {
430
543
  i64::try_from(self.state.skipped_frames.load(Ordering::SeqCst)).unwrap_or(i64::MAX)
431
544
  }
432
545
 
546
+ #[napi]
547
+ pub fn frame_buffer_allocations(&self) -> i64 {
548
+ i64::try_from(self.state.frame_buffer_allocations.load(Ordering::SeqCst))
549
+ .unwrap_or(i64::MAX)
550
+ }
551
+
552
+ #[napi]
553
+ pub fn frame_buffer_reuses(&self) -> i64 {
554
+ i64::try_from(self.state.frame_buffer_reuses.load(Ordering::SeqCst)).unwrap_or(i64::MAX)
555
+ }
556
+
557
+ #[napi]
558
+ pub fn recycled_frame_buffers(&self) -> i64 {
559
+ let (frames, _) = &*self.state.frames;
560
+ frames
561
+ .lock()
562
+ .map(|frames| i64::try_from(frames.recycled.len()).unwrap_or(i64::MAX))
563
+ .unwrap_or(0)
564
+ }
565
+
433
566
  #[napi]
434
567
  pub fn is_finished(&self) -> bool {
435
568
  self.state.finished.load(Ordering::SeqCst)
@@ -437,13 +570,54 @@ impl NativeVideoDecoder {
437
570
 
438
571
  #[napi]
439
572
  pub fn close(&mut self) {
440
- close_state(&self.state);
573
+ self.begin_shutdown();
574
+ }
575
+
576
+ fn begin_shutdown(&mut self) {
577
+ if self.retired {
578
+ return;
579
+ }
580
+ self.retired = true;
581
+ let child = request_close_state(&self.state);
582
+ retire_decoder_cleanup(self.worker.take(), child);
583
+ }
584
+
585
+ fn join_worker(&mut self) {
586
+ if let Some(worker) = self.worker.take() {
587
+ let _ = worker.join();
588
+ }
589
+ }
590
+
591
+ fn copy_video_frame_into(
592
+ &self,
593
+ pending: PendingFrame,
594
+ target: &mut [u8],
595
+ ) -> Result<VideoFrameInfo> {
596
+ if target.len() != pending.data.len() {
597
+ let actual = target.len();
598
+ let expected = pending.data.len();
599
+ if let Ok(mut frames) = self.state.frames.0.lock() {
600
+ frames.recycle(pending.data);
601
+ }
602
+ return Err(Error::from_reason(format!(
603
+ "NV12 target has {actual} bytes; expected {expected}"
604
+ )));
605
+ }
606
+ target.copy_from_slice(&pending.data);
607
+ if let Ok(mut frames) = self.state.frames.0.lock() {
608
+ frames.recycle(pending.data);
609
+ }
610
+ Ok(VideoFrameInfo {
611
+ width: self.options.width,
612
+ height: self.options.height,
613
+ timestamp_us: pending.timestamp_us,
614
+ })
441
615
  }
442
616
  }
443
617
 
444
618
  impl Drop for NativeVideoDecoder {
445
619
  fn drop(&mut self) {
446
- close_state(&self.state);
620
+ self.begin_shutdown();
447
621
  }
448
622
  }
449
623
 
@@ -582,14 +756,18 @@ fn spawn_ffmpeg(request: &FfmpegRequest, backend: DecoderBackend) -> io::Result<
582
756
  .stdout
583
757
  .take()
584
758
  .ok_or_else(|| io::Error::other("FFmpeg stdout unavailable"))?;
585
- if let Some(stderr) = child.stderr.take() {
759
+ let stderr_worker = child.stderr.take().map(|stderr| {
586
760
  thread::spawn(move || {
587
761
  for line in BufReader::new(stderr).lines().map_while(|line| line.ok()) {
588
762
  eprintln!("{}", redact_url_credentials(&line));
589
763
  }
590
- });
591
- }
592
- Ok(SpawnedFfmpeg { child, stdout })
764
+ })
765
+ });
766
+ Ok(SpawnedFfmpeg {
767
+ child,
768
+ stdout,
769
+ stderr_worker,
770
+ })
593
771
  }
594
772
 
595
773
  fn redact_url_credentials(value: &str) -> String {
@@ -612,11 +790,32 @@ fn redact_url_credentials(value: &str) -> String {
612
790
  result
613
791
  }
614
792
 
615
- fn install_child(state: &DecoderState, spawned: SpawnedFfmpeg) -> io::Result<ChildStdout> {
616
- *state
793
+ fn install_child(state: &DecoderState, mut spawned: SpawnedFfmpeg) -> io::Result<ChildStdout> {
794
+ let mut child = state
617
795
  .child
618
796
  .lock()
619
- .map_err(|_| io::Error::other("FFmpeg process lock poisoned"))? = Some(spawned.child);
797
+ .map_err(|_| io::Error::other("FFmpeg process lock poisoned"))?;
798
+ if state.closed.load(Ordering::SeqCst) {
799
+ drop(child);
800
+ let _ = spawned.child.kill();
801
+ let _ = spawned.child.wait();
802
+ if let Some(worker) = spawned.stderr_worker {
803
+ let _ = worker.join();
804
+ }
805
+ return Err(io::Error::new(
806
+ io::ErrorKind::Interrupted,
807
+ "Video decoder closed before FFmpeg startup completed",
808
+ ));
809
+ }
810
+ *child = Some(spawned.child);
811
+ drop(child);
812
+ if let Some(worker) = spawned.stderr_worker {
813
+ state
814
+ .stderr_workers
815
+ .lock()
816
+ .map_err(|_| io::Error::other("FFmpeg stderr worker lock poisoned"))?
817
+ .push(worker);
818
+ }
620
819
  Ok(spawned.stdout)
621
820
  }
622
821
 
@@ -631,7 +830,7 @@ fn consume_ffmpeg_output(
631
830
  let frame_bytes = nv12_frame_bytes(width, height)?;
632
831
  let start_timestamp_us = (start_time * 1_000_000.0).round() as i64;
633
832
  let mut frame_index = 0_i64;
634
- let mut data = vec![0_u8; frame_bytes];
833
+ let mut data = acquire_frame_buffer(frame_bytes, state)?;
635
834
  let read_result = loop {
636
835
  if state.closed.load(Ordering::SeqCst) {
637
836
  break Ok(());
@@ -644,20 +843,16 @@ fn consume_ffmpeg_output(
644
843
  frame_index += 1;
645
844
  state.decoded_frames.fetch_add(1, Ordering::SeqCst);
646
845
 
647
- data = enqueue_frame(PendingFrame { timestamp_us, data }, frame_bytes, state)?;
846
+ enqueue_frame(PendingFrame { timestamp_us, data }, state)?;
847
+ data = acquire_frame_buffer(frame_bytes, state)?;
648
848
  }
649
849
  Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break Ok(()),
650
850
  Err(error) => break Err(error),
651
851
  }
652
852
  };
653
853
 
654
- let status = state
655
- .child
656
- .lock()
657
- .map_err(|_| io::Error::other("FFmpeg process lock poisoned"))?
658
- .take()
659
- .map(|mut process| process.wait())
660
- .transpose()?;
854
+ let status = wait_for_child_exit(state)?;
855
+ join_stderr_workers(state);
661
856
 
662
857
  read_result?;
663
858
  if !state.closed.load(Ordering::SeqCst) && status.is_some_and(|status| !status.success()) {
@@ -709,15 +904,16 @@ fn wait_for_hardware_retry(state: &DecoderState) -> io::Result<bool> {
709
904
  Ok(!state.closed.load(Ordering::SeqCst))
710
905
  }
711
906
 
712
- fn enqueue_frame(
713
- frame: PendingFrame,
714
- frame_bytes: usize,
715
- state: &DecoderState,
716
- ) -> io::Result<Vec<u8>> {
907
+ fn enqueue_frame(frame: PendingFrame, state: &DecoderState) -> io::Result<()> {
717
908
  let catch_up_timestamp_us = state.catch_up_timestamp_us.load(Ordering::SeqCst);
718
909
  if catch_up_timestamp_us >= 0 && frame.timestamp_us < catch_up_timestamp_us {
719
910
  state.skipped_frames.fetch_add(1, Ordering::SeqCst);
720
- return Ok(frame.data);
911
+ let (frames, _) = &*state.frames;
912
+ frames
913
+ .lock()
914
+ .map_err(|_| io::Error::other("Video frame queue lock poisoned"))?
915
+ .recycle(frame.data);
916
+ return Ok(());
721
917
  }
722
918
  if catch_up_timestamp_us >= 0 {
723
919
  state.catch_up_timestamp_us.store(-1, Ordering::SeqCst);
@@ -731,7 +927,7 @@ fn enqueue_frame(
731
927
  if state.source_paced {
732
928
  if let Some(recycled) = frames.push_latest(frame) {
733
929
  state.dropped_frames.fetch_add(1, Ordering::SeqCst);
734
- return Ok(recycled);
930
+ frames.recycle(recycled);
735
931
  }
736
932
  } else {
737
933
  while frames.len() >= FRAME_QUEUE_CAPACITY
@@ -743,12 +939,14 @@ fn enqueue_frame(
743
939
  .map_err(|_| io::Error::other("Video frame queue lock poisoned"))?;
744
940
  }
745
941
  if state.closed.load(Ordering::SeqCst) {
746
- return Ok(frame.data);
942
+ frames.recycle(frame.data);
943
+ return Ok(());
747
944
  }
748
945
  let target = state.catch_up_timestamp_us.load(Ordering::SeqCst);
749
946
  if target >= 0 && frame.timestamp_us < target {
750
947
  state.skipped_frames.fetch_add(1, Ordering::SeqCst);
751
- return Ok(frame.data);
948
+ frames.recycle(frame.data);
949
+ return Ok(());
752
950
  }
753
951
  if target >= 0 {
754
952
  state.catch_up_timestamp_us.store(-1, Ordering::SeqCst);
@@ -756,9 +954,36 @@ fn enqueue_frame(
756
954
  frames.push_back(frame);
757
955
  }
758
956
 
957
+ Ok(())
958
+ }
959
+
960
+ fn acquire_frame_buffer(frame_bytes: usize, state: &DecoderState) -> io::Result<Vec<u8>> {
961
+ let (frames, _) = &*state.frames;
962
+ if let Some(buffer) = frames
963
+ .lock()
964
+ .map_err(|_| io::Error::other("Video frame queue lock poisoned"))?
965
+ .take_recycled(frame_bytes)
966
+ {
967
+ state.frame_buffer_reuses.fetch_add(1, Ordering::SeqCst);
968
+ return Ok(buffer);
969
+ }
970
+ state
971
+ .frame_buffer_allocations
972
+ .fetch_add(1, Ordering::SeqCst);
759
973
  Ok(vec![0_u8; frame_bytes])
760
974
  }
761
975
 
976
+ fn join_stderr_workers(state: &DecoderState) {
977
+ let workers = state
978
+ .stderr_workers
979
+ .lock()
980
+ .map(|mut workers| workers.drain(..).collect::<Vec<_>>())
981
+ .unwrap_or_default();
982
+ for worker in workers {
983
+ let _ = worker.join();
984
+ }
985
+ }
986
+
762
987
  fn set_backend_name(state: &DecoderState, name: &str) {
763
988
  if let Ok(mut backend) = state.backend.lock() {
764
989
  *backend = name.to_string();
@@ -772,18 +997,78 @@ fn store_error(state: &DecoderState, message: String) {
772
997
  }
773
998
  }
774
999
 
775
- fn close_state(state: &DecoderState) {
1000
+ /** Polls without holding the child mutex while the FFmpeg process exits. */
1001
+ fn wait_for_child_exit(state: &DecoderState) -> io::Result<Option<std::process::ExitStatus>> {
1002
+ loop {
1003
+ let status = {
1004
+ let mut child = state
1005
+ .child
1006
+ .lock()
1007
+ .map_err(|_| io::Error::other("FFmpeg process lock poisoned"))?;
1008
+ match child.as_mut() {
1009
+ Some(process) => process.try_wait()?,
1010
+ None => return Ok(None),
1011
+ }
1012
+ };
1013
+ if let Some(status) = status {
1014
+ if let Ok(mut child) = state.child.lock() {
1015
+ child.take();
1016
+ }
1017
+ return Ok(Some(status));
1018
+ }
1019
+ if state.closed.load(Ordering::SeqCst) {
1020
+ return Ok(None);
1021
+ }
1022
+ thread::sleep(Duration::from_millis(10));
1023
+ }
1024
+ }
1025
+
1026
+ /** Signals decoder shutdown without waiting on FFmpeg or worker threads. */
1027
+ fn request_close_state(state: &DecoderState) -> Option<Child> {
776
1028
  state.closed.store(true, Ordering::SeqCst);
777
1029
  let (frames, available) = &*state.frames;
778
1030
  available.notify_all();
779
- if let Ok(mut child) = state.child.lock() {
780
- if let Some(mut process) = child.take() {
781
- let _ = process.kill();
782
- }
1031
+ let mut child = state.child.lock().ok().and_then(|mut child| child.take());
1032
+ if let Some(process) = child.as_mut() {
1033
+ let _ = process.kill();
783
1034
  }
784
1035
  if let Ok(mut frames) = frames.lock() {
785
1036
  frames.clear();
786
1037
  }
1038
+ child
1039
+ }
1040
+
1041
+ /** Reaps the killed process and joins workers away from the N-API thread. */
1042
+ fn retire_decoder_cleanup(worker: Option<thread::JoinHandle<()>>, child: Option<Child>) {
1043
+ if worker.is_none() && child.is_none() {
1044
+ return;
1045
+ }
1046
+ PENDING_DECODER_SHUTDOWNS.fetch_add(1, Ordering::SeqCst);
1047
+ thread::spawn(move || {
1048
+ let started = Instant::now();
1049
+ if let Some(mut process) = child {
1050
+ let _ = process.wait();
1051
+ }
1052
+ if let Some(worker) = worker {
1053
+ let _ = worker.join();
1054
+ }
1055
+ let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
1056
+ MAX_DECODER_SHUTDOWN_MS.fetch_max(elapsed_ms, Ordering::SeqCst);
1057
+ COMPLETED_DECODER_SHUTDOWNS.fetch_add(1, Ordering::SeqCst);
1058
+ PENDING_DECODER_SHUTDOWNS.fetch_sub(1, Ordering::SeqCst);
1059
+ });
1060
+ }
1061
+
1062
+ fn atomic_i64(value: &AtomicU64) -> i64 {
1063
+ i64::try_from(value.load(Ordering::SeqCst)).unwrap_or(i64::MAX)
1064
+ }
1065
+
1066
+ struct ActiveDecoderWorker;
1067
+
1068
+ impl Drop for ActiveDecoderWorker {
1069
+ fn drop(&mut self) {
1070
+ ACTIVE_DECODER_WORKERS.fetch_sub(1, Ordering::SeqCst);
1071
+ }
787
1072
  }
788
1073
 
789
1074
  #[cfg(test)]
@@ -906,13 +1191,13 @@ mod tests {
906
1191
  fn file_queue_applies_backpressure_until_a_frame_is_consumed() {
907
1192
  let state = decoder_state(false);
908
1193
  for timestamp_us in 0..FRAME_QUEUE_CAPACITY as i64 {
909
- enqueue_frame(pending(timestamp_us), 1, &state).unwrap();
1194
+ enqueue_frame(pending(timestamp_us), &state).unwrap();
910
1195
  }
911
1196
 
912
1197
  let producer_state = state.clone();
913
1198
  let (sent, received) = mpsc::channel();
914
1199
  thread::spawn(move || {
915
- let result = enqueue_frame(pending(99), 1, &producer_state);
1200
+ let result = enqueue_frame(pending(99), &producer_state);
916
1201
  sent.send(result.is_ok()).unwrap();
917
1202
  });
918
1203
 
@@ -928,14 +1213,110 @@ mod tests {
928
1213
  let state = decoder_state(false);
929
1214
  state.catch_up_timestamp_us.store(30, Ordering::SeqCst);
930
1215
 
931
- assert_eq!(enqueue_frame(pending(10), 1, &state).unwrap(), vec![10]);
932
- assert_eq!(enqueue_frame(pending(20), 1, &state).unwrap(), vec![20]);
933
- enqueue_frame(pending(30), 1, &state).unwrap();
1216
+ enqueue_frame(pending(10), &state).unwrap();
1217
+ enqueue_frame(pending(20), &state).unwrap();
1218
+ enqueue_frame(pending(30), &state).unwrap();
934
1219
 
935
1220
  assert_eq!(state.skipped_frames.load(Ordering::SeqCst), 2);
936
1221
  let (frames, _) = &*state.frames;
937
1222
  let mut frames = frames.lock().unwrap();
938
1223
  assert_eq!(frames.pop_next().unwrap().timestamp_us, 30);
1224
+ assert_eq!(frames.recycled.len(), 2);
1225
+ }
1226
+
1227
+ #[test]
1228
+ fn copies_frame_into_caller_buffer_and_recycles_native_storage() {
1229
+ let decoder = NativeVideoDecoder::new(DecoderOptions {
1230
+ width: 2,
1231
+ height: 2,
1232
+ fps: Some(30.0),
1233
+ start_time: None,
1234
+ ffmpeg_path: None,
1235
+ vaapi_device: None,
1236
+ playback_rate: None,
1237
+ end_time: None,
1238
+ source_paced: None,
1239
+ input_args: None,
1240
+ output_args: None,
1241
+ })
1242
+ .unwrap();
1243
+ let mut target = vec![0; 6];
1244
+ let info = decoder
1245
+ .copy_video_frame_into(
1246
+ PendingFrame {
1247
+ timestamp_us: 42,
1248
+ data: vec![1, 2, 3, 4, 5, 6],
1249
+ },
1250
+ &mut target,
1251
+ )
1252
+ .unwrap();
1253
+
1254
+ assert_eq!(target, vec![1, 2, 3, 4, 5, 6]);
1255
+ assert_eq!(info.timestamp_us, 42);
1256
+ assert_eq!(decoder.state.frames.0.lock().unwrap().recycled.len(), 1);
1257
+ }
1258
+
1259
+ #[test]
1260
+ fn rejects_wrong_caller_buffer_size_without_losing_native_storage() {
1261
+ let decoder = NativeVideoDecoder::new(DecoderOptions {
1262
+ width: 2,
1263
+ height: 2,
1264
+ fps: Some(30.0),
1265
+ start_time: None,
1266
+ ffmpeg_path: None,
1267
+ vaapi_device: None,
1268
+ playback_rate: None,
1269
+ end_time: None,
1270
+ source_paced: None,
1271
+ input_args: None,
1272
+ output_args: None,
1273
+ })
1274
+ .unwrap();
1275
+ let mut target = vec![0; 5];
1276
+ let result = decoder.copy_video_frame_into(
1277
+ PendingFrame {
1278
+ timestamp_us: 42,
1279
+ data: vec![1, 2, 3, 4, 5, 6],
1280
+ },
1281
+ &mut target,
1282
+ );
1283
+
1284
+ assert!(result.is_err());
1285
+ assert_eq!(decoder.state.frames.0.lock().unwrap().recycled.len(), 1);
1286
+ }
1287
+
1288
+ #[test]
1289
+ fn close_retires_a_blocked_worker_without_waiting_for_it() {
1290
+ let mut decoder = test_decoder();
1291
+ let (started_tx, started_rx) = mpsc::channel();
1292
+ let (release_tx, release_rx) = mpsc::channel();
1293
+ decoder.worker = Some(thread::spawn(move || {
1294
+ started_tx.send(()).unwrap();
1295
+ release_rx.recv().unwrap();
1296
+ }));
1297
+ started_rx.recv_timeout(Duration::from_secs(1)).unwrap();
1298
+ let completed_before = COMPLETED_DECODER_SHUTDOWNS.load(Ordering::SeqCst);
1299
+
1300
+ let started = Instant::now();
1301
+ decoder.close();
1302
+ assert!(started.elapsed() < Duration::from_millis(100));
1303
+ assert!(PENDING_DECODER_SHUTDOWNS.load(Ordering::SeqCst) >= 1);
1304
+
1305
+ release_tx.send(()).unwrap();
1306
+ let deadline = Instant::now() + Duration::from_secs(1);
1307
+ while COMPLETED_DECODER_SHUTDOWNS.load(Ordering::SeqCst) == completed_before {
1308
+ assert!(Instant::now() < deadline);
1309
+ thread::sleep(Duration::from_millis(5));
1310
+ }
1311
+ }
1312
+
1313
+ #[test]
1314
+ fn explicit_close_makes_the_decoder_terminal_without_blocking_reopen() {
1315
+ let mut decoder = test_decoder();
1316
+ decoder.close();
1317
+
1318
+ let error = decoder.open("unused.mp4".to_string()).unwrap_err();
1319
+ assert!(error.to_string().contains("cannot reopen after shutdown"));
939
1320
  }
940
1321
 
941
1322
  #[test]
@@ -961,6 +1342,23 @@ mod tests {
961
1342
  }
962
1343
  }
963
1344
 
1345
+ fn test_decoder() -> NativeVideoDecoder {
1346
+ NativeVideoDecoder::new(DecoderOptions {
1347
+ width: 2,
1348
+ height: 2,
1349
+ fps: Some(30.0),
1350
+ start_time: None,
1351
+ ffmpeg_path: None,
1352
+ vaapi_device: None,
1353
+ playback_rate: None,
1354
+ end_time: None,
1355
+ source_paced: None,
1356
+ input_args: None,
1357
+ output_args: None,
1358
+ })
1359
+ .unwrap()
1360
+ }
1361
+
964
1362
  fn decoder_state(source_paced: bool) -> DecoderState {
965
1363
  DecoderState {
966
1364
  closed: Arc::new(AtomicBool::new(false)),
@@ -973,7 +1371,10 @@ mod tests {
973
1371
  decoded_frames: Arc::new(AtomicU64::new(0)),
974
1372
  dropped_frames: Arc::new(AtomicU64::new(0)),
975
1373
  skipped_frames: Arc::new(AtomicU64::new(0)),
1374
+ frame_buffer_allocations: Arc::new(AtomicU64::new(0)),
1375
+ frame_buffer_reuses: Arc::new(AtomicU64::new(0)),
976
1376
  backend: Arc::new(Mutex::new("test".to_string())),
1377
+ stderr_workers: Arc::new(Mutex::new(Vec::new())),
977
1378
  }
978
1379
  }
979
1380
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matjash/pixi-native-win32-x64",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "commonjs",
5
5
  "description": "Windows x64 native binaries for @matjash/pixi-native",
6
6
  "license": "MIT",