@matjash/pixi-native-win32-x64 0.2.0 → 0.2.2

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.
@@ -7,10 +7,13 @@ use std::thread;
7
7
  use std::time::{Duration, Instant};
8
8
 
9
9
  use maudio::audio::sample_rate::SampleRate;
10
- use maudio::data_source::sources::buffer::{AudioBuffer, AudioBufferBuilder};
10
+ use maudio::data_source::data_source_builder::DataSourceBuilder;
11
+ use maudio::data_source::pcm_source::PcmSource;
11
12
  use maudio::data_source::sources::pcm_ring_buffer::{PcmRbRecv, PcmRbSend, PcmRingBuffer};
13
+ use maudio::data_source::{DataSource, SourceContext};
12
14
  use maudio::engine::{engine_builder::EngineBuilder, Engine};
13
15
  use maudio::sound::{notifier::EndNotifier, sound_builder::SoundBuilder, Sound};
16
+ use maudio::{ErrorKinds, MaResult, MaudioError};
14
17
  use napi::bindgen_prelude::*;
15
18
  use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
16
19
  use napi_derive::napi;
@@ -112,9 +115,64 @@ impl StreamingControl {
112
115
  }
113
116
  }
114
117
 
118
+ struct SharedPcmSource {
119
+ samples: Arc<Vec<f32>>,
120
+ }
121
+
122
+ impl SharedPcmSource {
123
+ fn new(samples: Arc<Vec<f32>>) -> Self {
124
+ Self { samples }
125
+ }
126
+ }
127
+
128
+ impl PcmSource<f32> for SharedPcmSource {
129
+ fn fill_pcm_frames(
130
+ &mut self,
131
+ output: &mut [f32],
132
+ context: &mut SourceContext,
133
+ ) -> MaResult<usize> {
134
+ let channels = context.data_format.channels as usize;
135
+ let frame_count = self.samples.len() / channels;
136
+ let cursor = usize::try_from(context.cursor).unwrap_or(usize::MAX);
137
+ if cursor >= frame_count {
138
+ return Ok(0);
139
+ }
140
+
141
+ let output_frames = output.len() / channels;
142
+ let frames_to_copy = output_frames.min(frame_count - cursor);
143
+ let sample_start = cursor * channels;
144
+ let samples_to_copy = frames_to_copy * channels;
145
+ output[..samples_to_copy]
146
+ .copy_from_slice(&self.samples[sample_start..sample_start + samples_to_copy]);
147
+ context.cursor += frames_to_copy as u64;
148
+ Ok(frames_to_copy)
149
+ }
150
+
151
+ fn seek_to_pcm_frame(&mut self, frame_index: u64, context: &mut SourceContext) -> MaResult<()> {
152
+ let frame_count = self.samples.len() / context.data_format.channels as usize;
153
+ if frame_index > frame_count as u64 {
154
+ return Err(MaudioError::new_ma_error(ErrorKinds::InvalidOperation(
155
+ "Audio seek is outside the cached PCM source",
156
+ )));
157
+ }
158
+ context.cursor = frame_index;
159
+ Ok(())
160
+ }
161
+
162
+ fn cursor_in_pcm_frames(&self, context: &SourceContext) -> MaResult<u64> {
163
+ Ok(context.cursor)
164
+ }
165
+
166
+ fn length_in_pcm_frames(&self, context: &SourceContext) -> MaResult<u64> {
167
+ Ok((self.samples.len() / context.data_format.channels as usize) as u64)
168
+ }
169
+ }
170
+
171
+ type StaticDataSource = DataSource<f32, SharedPcmSource>;
172
+
115
173
  enum VoiceSource {
116
174
  Loading,
117
- Static(Box<AudioBuffer<f32>>),
175
+ Static(Box<StaticDataSource>),
118
176
  Streaming {
119
177
  receiver: Box<PcmRbRecv<f32>>,
120
178
  control: Arc<StreamingControl>,
@@ -170,15 +228,51 @@ impl Voice {
170
228
  self.timeline_updated_at = now;
171
229
  return;
172
230
  }
173
- self.timeline_seconds +=
174
- now.duration_since(self.timeline_updated_at).as_secs_f64() * self.playback_rate;
175
- if self.looped {
176
- if let Some(duration_seconds) = self.duration_seconds {
177
- self.timeline_seconds %= duration_seconds;
178
- }
179
- }
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
+ );
180
238
  self.timeline_updated_at = now;
181
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)
182
276
  }
183
277
 
184
278
  enum EngineCommand {
@@ -449,7 +543,8 @@ impl NativeAudioEngine {
449
543
  #[napi]
450
544
  pub fn current_time(&self, id: u32) -> Option<f64> {
451
545
  let snapshot = self.state.snapshots.lock().ok()?.get(&id).cloned()?;
452
- 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)
453
548
  }
454
549
 
455
550
  #[napi]
@@ -643,8 +738,8 @@ impl EngineRuntime {
643
738
  let Some(mut voice) = self.voices.remove(&id) else {
644
739
  continue;
645
740
  };
646
- let result = AudioBufferBuilder::build_f32(CHANNELS as u32, &samples)
647
- .map_err(|error| format!("Cannot create miniaudio buffer: {error}"))
741
+ let result = build_static_data_source(samples)
742
+ .map_err(|error| format!("Cannot create miniaudio data source: {error}"))
648
743
  .and_then(|buffer| {
649
744
  voice.source = VoiceSource::Static(Box::new(buffer));
650
745
  self.attach_sound(&mut voice)
@@ -830,11 +925,12 @@ impl EngineRuntime {
830
925
  .snapshot
831
926
  .queued_frames
832
927
  .store(queued, Ordering::Release);
833
- if control.ready.load(Ordering::Acquire) && !voice.play_announced {
834
- if sound.play_sound().is_ok() {
835
- voice.play_announced = true;
836
- state.queue_event(voice.owner_id, "play", Some(*id), None, None);
837
- }
928
+ if control.ready.load(Ordering::Acquire)
929
+ && !voice.play_announced
930
+ && sound.play_sound().is_ok()
931
+ {
932
+ voice.play_announced = true;
933
+ state.queue_event(voice.owner_id, "play", Some(*id), None, None);
838
934
  }
839
935
  let starved = voice.play_announced
840
936
  && voice.playing
@@ -858,11 +954,7 @@ impl EngineRuntime {
858
954
  } else {
859
955
  voice.timeline_seconds
860
956
  };
861
- let time = voice.offset_seconds + elapsed_seconds;
862
- voice
863
- .snapshot
864
- .time_bits
865
- .store(time.to_bits(), Ordering::Release);
957
+ voice.store_snapshot_time(elapsed_seconds);
866
958
  let current_volume = if voice.fade.is_some() {
867
959
  sound.current_fade_volume()
868
960
  } else {
@@ -913,30 +1005,25 @@ fn start_static_decode(
913
1005
  preload: bool,
914
1006
  request_id: Option<u32>,
915
1007
  ) {
1008
+ let key = cache_key(&options);
1009
+ if let Some(samples) = state
1010
+ .cache
1011
+ .lock()
1012
+ .ok()
1013
+ .and_then(|cache| cache.get(&key).cloned())
1014
+ {
1015
+ complete_static_decode(&state, &options, preload, request_id, samples);
1016
+ return;
1017
+ }
1018
+
916
1019
  thread::spawn(move || {
917
- let key = cache_key(&options);
918
- let decoded = state
919
- .cache
920
- .lock()
921
- .ok()
922
- .and_then(|cache| cache.get(&key).cloned())
923
- .map_or_else(
924
- || decode_static(&options, state.sample_rate).map(Arc::new),
925
- Ok,
926
- );
1020
+ let decoded = decode_static(&options, state.sample_rate).map(Arc::new);
927
1021
  match decoded {
928
1022
  Ok(samples) => {
929
1023
  if let Ok(mut cache) = state.cache.lock() {
930
1024
  cache.insert(key, Arc::clone(&samples));
931
1025
  }
932
- if preload {
933
- state.queue_event(options.owner_id, "load", request_id, None, None);
934
- } else {
935
- state.push_command(EngineCommand::AttachStatic {
936
- id: options.id,
937
- samples,
938
- });
939
- }
1026
+ complete_static_decode(&state, &options, preload, request_id, samples);
940
1027
  }
941
1028
  Err(message) => {
942
1029
  if !preload {
@@ -954,6 +1041,28 @@ fn start_static_decode(
954
1041
  });
955
1042
  }
956
1043
 
1044
+ fn complete_static_decode(
1045
+ state: &SharedState,
1046
+ options: &NativeVoiceOptions,
1047
+ preload: bool,
1048
+ request_id: Option<u32>,
1049
+ samples: Arc<Vec<f32>>,
1050
+ ) {
1051
+ if preload {
1052
+ state.queue_event(options.owner_id, "load", request_id, None, None);
1053
+ } else {
1054
+ state.push_command(EngineCommand::AttachStatic {
1055
+ id: options.id,
1056
+ samples,
1057
+ });
1058
+ }
1059
+ }
1060
+
1061
+ fn build_static_data_source(samples: Arc<Vec<f32>>) -> MaResult<StaticDataSource> {
1062
+ DataSourceBuilder::new(CHANNELS as u32, SampleRate::Sr48000)
1063
+ .build_f32(SharedPcmSource::new(samples))
1064
+ }
1065
+
957
1066
  fn start_streaming_decode(
958
1067
  state: Arc<SharedState>,
959
1068
  options: NativeVoiceOptions,
@@ -1233,6 +1342,29 @@ fn redact_credentials(message: &str) -> String {
1233
1342
  mod tests {
1234
1343
  use super::*;
1235
1344
 
1345
+ #[test]
1346
+ fn static_voices_share_pcm_with_independent_cursors() {
1347
+ let samples = Arc::new(vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6]);
1348
+ let mut first = build_static_data_source(Arc::clone(&samples)).unwrap();
1349
+ let second = build_static_data_source(Arc::clone(&samples)).unwrap();
1350
+
1351
+ assert_eq!(Arc::strong_count(&samples), 3);
1352
+ assert_eq!(first.cursor_in_pcm_frames().unwrap(), 0);
1353
+ assert_eq!(second.cursor_in_pcm_frames().unwrap(), 0);
1354
+
1355
+ assert_eq!(first.read_pcm_frames(1).unwrap().frames(), 1);
1356
+ assert_eq!(first.cursor_in_pcm_frames().unwrap(), 1);
1357
+ assert_eq!(second.cursor_in_pcm_frames().unwrap(), 0);
1358
+
1359
+ first.seek_to_pcm_frame(0).unwrap();
1360
+ assert_eq!(first.cursor_in_pcm_frames().unwrap(), 0);
1361
+ assert!(first.seek_to_pcm_frame(4).is_err());
1362
+
1363
+ drop(first);
1364
+ drop(second);
1365
+ assert_eq!(Arc::strong_count(&samples), 1);
1366
+ }
1367
+
1236
1368
  #[test]
1237
1369
  fn atempo_decomposes_extreme_rates() {
1238
1370
  assert_eq!(build_atempo_filter(4.0).unwrap(), "atempo=2,atempo=2");
@@ -1246,4 +1378,27 @@ mod tests {
1246
1378
  "https://***:***@example.test/audio.mp3"
1247
1379
  );
1248
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
+ }
1249
1404
  }
@@ -19,11 +19,20 @@ 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
+
22
28
  export class NativeVideoDecoder {
23
29
  public constructor(options: DecoderOptions);
24
30
  public open(source: string): void;
25
31
  public pollLatest(): VideoFrame | null;
26
32
  public pollNext(): VideoFrame | null;
33
+ public supportsFrameBufferReuse(): boolean;
34
+ public pollLatestInto(target: Buffer): VideoFrameInfo | null;
35
+ public pollNextInto(target: Buffer): VideoFrameInfo | null;
27
36
  public queuedFrames(): number;
28
37
  public catchUpTo(timestampUs: number): void;
29
38
  public pollError(): string | null;
@@ -31,6 +40,9 @@ export class NativeVideoDecoder {
31
40
  public decodedFrames(): number;
32
41
  public droppedFrames(): number;
33
42
  public skippedFrames(): number;
43
+ public frameBufferAllocations(): number;
44
+ public frameBufferReuses(): number;
45
+ public recycledFrameBuffers(): number;
34
46
  public isFinished(): boolean;
35
47
  public close(): void;
36
48
  }
@@ -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,25 @@ 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
+ }
59
102
  }
60
103
 
61
104
  module.exports = { NativeVideoDecoder };
@@ -38,6 +38,13 @@ pub struct VideoFrame {
38
38
  pub data: Buffer,
39
39
  }
40
40
 
41
+ #[napi(object)]
42
+ pub struct VideoFrameInfo {
43
+ pub width: i64,
44
+ pub height: i64,
45
+ pub timestamp_us: i64,
46
+ }
47
+
41
48
  #[derive(Clone, Copy, Debug, Eq, PartialEq)]
42
49
  enum DecoderBackend {
43
50
  D3d11va,
@@ -84,7 +91,10 @@ struct DecoderState {
84
91
  decoded_frames: Arc<AtomicU64>,
85
92
  dropped_frames: Arc<AtomicU64>,
86
93
  skipped_frames: Arc<AtomicU64>,
94
+ frame_buffer_allocations: Arc<AtomicU64>,
95
+ frame_buffer_reuses: Arc<AtomicU64>,
87
96
  backend: Arc<Mutex<String>>,
97
+ stderr_workers: Arc<Mutex<Vec<thread::JoinHandle<()>>>>,
88
98
  }
89
99
 
90
100
  struct PendingFrame {
@@ -95,6 +105,7 @@ struct PendingFrame {
95
105
  #[derive(Default)]
96
106
  struct FrameQueue {
97
107
  frames: VecDeque<PendingFrame>,
108
+ recycled: Vec<Vec<u8>>,
98
109
  }
99
110
 
100
111
  impl FrameQueue {
@@ -108,6 +119,19 @@ impl FrameQueue {
108
119
  recycled
109
120
  }
110
121
 
122
+ fn take_recycled(&mut self, frame_bytes: usize) -> Option<Vec<u8>> {
123
+ while let Some(buffer) = self.recycled.pop() {
124
+ if buffer.len() == frame_bytes {
125
+ return Some(buffer);
126
+ }
127
+ }
128
+ None
129
+ }
130
+
131
+ fn recycle(&mut self, buffer: Vec<u8>) {
132
+ self.recycled.push(buffer);
133
+ }
134
+
111
135
  fn push_back(&mut self, frame: PendingFrame) {
112
136
  self.frames.push_back(frame);
113
137
  }
@@ -119,7 +143,9 @@ impl FrameQueue {
119
143
  fn pop_latest(&mut self) -> (Option<PendingFrame>, usize) {
120
144
  let latest = self.frames.pop_back();
121
145
  let skipped = self.frames.len();
122
- self.frames.clear();
146
+ while let Some(frame) = self.frames.pop_front() {
147
+ self.recycle(frame.data);
148
+ }
123
149
  (latest, skipped)
124
150
  }
125
151
 
@@ -127,14 +153,22 @@ impl FrameQueue {
127
153
  self.frames.len()
128
154
  }
129
155
 
156
+ fn recycle_queued(&mut self) {
157
+ while let Some(frame) = self.frames.pop_front() {
158
+ self.recycle(frame.data);
159
+ }
160
+ }
161
+
130
162
  fn clear(&mut self) {
131
163
  self.frames.clear();
164
+ self.recycled.clear();
132
165
  }
133
166
  }
134
167
 
135
168
  struct SpawnedFfmpeg {
136
169
  child: Child,
137
170
  stdout: ChildStdout,
171
+ stderr_worker: Option<thread::JoinHandle<()>>,
138
172
  }
139
173
 
140
174
  #[derive(Clone)]
@@ -155,6 +189,7 @@ struct FfmpegRequest {
155
189
  pub struct NativeVideoDecoder {
156
190
  options: DecoderOptions,
157
191
  state: DecoderState,
192
+ worker: Option<thread::JoinHandle<()>>,
158
193
  }
159
194
 
160
195
  #[napi]
@@ -171,6 +206,7 @@ impl NativeVideoDecoder {
171
206
 
172
207
  Ok(Self {
173
208
  options,
209
+ worker: None,
174
210
  state: DecoderState {
175
211
  closed: Arc::new(AtomicBool::new(true)),
176
212
  finished: Arc::new(AtomicBool::new(false)),
@@ -182,13 +218,19 @@ impl NativeVideoDecoder {
182
218
  decoded_frames: Arc::new(AtomicU64::new(0)),
183
219
  dropped_frames: Arc::new(AtomicU64::new(0)),
184
220
  skipped_frames: Arc::new(AtomicU64::new(0)),
221
+ frame_buffer_allocations: Arc::new(AtomicU64::new(0)),
222
+ frame_buffer_reuses: Arc::new(AtomicU64::new(0)),
185
223
  backend: Arc::new(Mutex::new(backend)),
224
+ stderr_workers: Arc::new(Mutex::new(Vec::new())),
186
225
  },
187
226
  })
188
227
  }
189
228
 
190
229
  #[napi]
191
230
  pub fn open(&mut self, source: String) -> Result<()> {
231
+ if self.state.closed.load(Ordering::SeqCst) {
232
+ self.join_worker();
233
+ }
192
234
  if !self.state.closed.swap(false, Ordering::SeqCst) {
193
235
  return Err(Error::from_reason("Video decoder is already open"));
194
236
  }
@@ -197,6 +239,10 @@ impl NativeVideoDecoder {
197
239
  self.state.decoded_frames.store(0, Ordering::SeqCst);
198
240
  self.state.dropped_frames.store(0, Ordering::SeqCst);
199
241
  self.state.skipped_frames.store(0, Ordering::SeqCst);
242
+ self.state
243
+ .frame_buffer_allocations
244
+ .store(0, Ordering::SeqCst);
245
+ self.state.frame_buffer_reuses.store(0, Ordering::SeqCst);
200
246
 
201
247
  if let Ok(mut error) = self.state.error.lock() {
202
248
  *error = None;
@@ -268,7 +314,7 @@ impl NativeVideoDecoder {
268
314
  let initial_stdout = install_child(&state, spawned)
269
315
  .map_err(|error| Error::from_reason(error.to_string()))?;
270
316
 
271
- thread::spawn(move || {
317
+ self.worker = Some(thread::spawn(move || {
272
318
  let mut result =
273
319
  consume_decoder_attempt(initial_stdout, width, height, fps, start_time, &state);
274
320
 
@@ -334,7 +380,7 @@ impl NativeVideoDecoder {
334
380
  state.finished.store(true, Ordering::SeqCst);
335
381
  }
336
382
  state.closed.store(true, Ordering::SeqCst);
337
- });
383
+ }));
338
384
 
339
385
  Ok(())
340
386
  }
@@ -360,6 +406,41 @@ impl NativeVideoDecoder {
360
406
  self.to_video_frame(pending)
361
407
  }
362
408
 
409
+ #[napi]
410
+ pub fn poll_latest_into(&self, mut target: BufferSlice) -> Result<Option<VideoFrameInfo>> {
411
+ let (frames, available) = &*self.state.frames;
412
+ let (pending, skipped) = frames
413
+ .lock()
414
+ .map_err(|_| Error::from_reason("Video frame queue lock poisoned"))?
415
+ .pop_latest();
416
+ if skipped > 0 {
417
+ self.state
418
+ .skipped_frames
419
+ .fetch_add(skipped as u64, Ordering::SeqCst);
420
+ }
421
+ let result = pending
422
+ .map(|pending| self.copy_video_frame_into(pending, &mut target))
423
+ .transpose();
424
+ available.notify_all();
425
+ result
426
+ }
427
+
428
+ #[napi]
429
+ pub fn poll_next_into(&self, mut target: BufferSlice) -> Result<Option<VideoFrameInfo>> {
430
+ let (frames, available) = &*self.state.frames;
431
+ let pending = frames
432
+ .lock()
433
+ .map_err(|_| Error::from_reason("Video frame queue lock poisoned"))?
434
+ .pop_next();
435
+ let result = pending
436
+ .map(|pending| self.copy_video_frame_into(pending, &mut target))
437
+ .transpose();
438
+ if result.as_ref().is_ok_and(Option::is_some) {
439
+ available.notify_one();
440
+ }
441
+ result
442
+ }
443
+
363
444
  #[napi]
364
445
  pub fn queued_frames(&self) -> i64 {
365
446
  let (frames, _) = &*self.state.frames;
@@ -383,7 +464,7 @@ impl NativeVideoDecoder {
383
464
  let (frames, available) = &*self.state.frames;
384
465
  if let Ok(mut frames) = frames.lock() {
385
466
  let skipped = frames.len();
386
- frames.clear();
467
+ frames.recycle_queued();
387
468
  self.state
388
469
  .skipped_frames
389
470
  .fetch_add(skipped as u64, Ordering::SeqCst);
@@ -430,6 +511,26 @@ impl NativeVideoDecoder {
430
511
  i64::try_from(self.state.skipped_frames.load(Ordering::SeqCst)).unwrap_or(i64::MAX)
431
512
  }
432
513
 
514
+ #[napi]
515
+ pub fn frame_buffer_allocations(&self) -> i64 {
516
+ i64::try_from(self.state.frame_buffer_allocations.load(Ordering::SeqCst))
517
+ .unwrap_or(i64::MAX)
518
+ }
519
+
520
+ #[napi]
521
+ pub fn frame_buffer_reuses(&self) -> i64 {
522
+ i64::try_from(self.state.frame_buffer_reuses.load(Ordering::SeqCst)).unwrap_or(i64::MAX)
523
+ }
524
+
525
+ #[napi]
526
+ pub fn recycled_frame_buffers(&self) -> i64 {
527
+ let (frames, _) = &*self.state.frames;
528
+ frames
529
+ .lock()
530
+ .map(|frames| i64::try_from(frames.recycled.len()).unwrap_or(i64::MAX))
531
+ .unwrap_or(0)
532
+ }
533
+
433
534
  #[napi]
434
535
  pub fn is_finished(&self) -> bool {
435
536
  self.state.finished.load(Ordering::SeqCst)
@@ -438,12 +539,46 @@ impl NativeVideoDecoder {
438
539
  #[napi]
439
540
  pub fn close(&mut self) {
440
541
  close_state(&self.state);
542
+ self.join_worker();
543
+ }
544
+
545
+ fn join_worker(&mut self) {
546
+ if let Some(worker) = self.worker.take() {
547
+ let _ = worker.join();
548
+ }
549
+ }
550
+
551
+ fn copy_video_frame_into(
552
+ &self,
553
+ pending: PendingFrame,
554
+ target: &mut [u8],
555
+ ) -> Result<VideoFrameInfo> {
556
+ if target.len() != pending.data.len() {
557
+ let actual = target.len();
558
+ let expected = pending.data.len();
559
+ if let Ok(mut frames) = self.state.frames.0.lock() {
560
+ frames.recycle(pending.data);
561
+ }
562
+ return Err(Error::from_reason(format!(
563
+ "NV12 target has {actual} bytes; expected {expected}"
564
+ )));
565
+ }
566
+ target.copy_from_slice(&pending.data);
567
+ if let Ok(mut frames) = self.state.frames.0.lock() {
568
+ frames.recycle(pending.data);
569
+ }
570
+ Ok(VideoFrameInfo {
571
+ width: self.options.width,
572
+ height: self.options.height,
573
+ timestamp_us: pending.timestamp_us,
574
+ })
441
575
  }
442
576
  }
443
577
 
444
578
  impl Drop for NativeVideoDecoder {
445
579
  fn drop(&mut self) {
446
580
  close_state(&self.state);
581
+ self.join_worker();
447
582
  }
448
583
  }
449
584
 
@@ -582,14 +717,18 @@ fn spawn_ffmpeg(request: &FfmpegRequest, backend: DecoderBackend) -> io::Result<
582
717
  .stdout
583
718
  .take()
584
719
  .ok_or_else(|| io::Error::other("FFmpeg stdout unavailable"))?;
585
- if let Some(stderr) = child.stderr.take() {
720
+ let stderr_worker = child.stderr.take().map(|stderr| {
586
721
  thread::spawn(move || {
587
722
  for line in BufReader::new(stderr).lines().map_while(|line| line.ok()) {
588
723
  eprintln!("{}", redact_url_credentials(&line));
589
724
  }
590
- });
591
- }
592
- Ok(SpawnedFfmpeg { child, stdout })
725
+ })
726
+ });
727
+ Ok(SpawnedFfmpeg {
728
+ child,
729
+ stdout,
730
+ stderr_worker,
731
+ })
593
732
  }
594
733
 
595
734
  fn redact_url_credentials(value: &str) -> String {
@@ -613,6 +752,13 @@ fn redact_url_credentials(value: &str) -> String {
613
752
  }
614
753
 
615
754
  fn install_child(state: &DecoderState, spawned: SpawnedFfmpeg) -> io::Result<ChildStdout> {
755
+ if let Some(worker) = spawned.stderr_worker {
756
+ state
757
+ .stderr_workers
758
+ .lock()
759
+ .map_err(|_| io::Error::other("FFmpeg stderr worker lock poisoned"))?
760
+ .push(worker);
761
+ }
616
762
  *state
617
763
  .child
618
764
  .lock()
@@ -631,7 +777,7 @@ fn consume_ffmpeg_output(
631
777
  let frame_bytes = nv12_frame_bytes(width, height)?;
632
778
  let start_timestamp_us = (start_time * 1_000_000.0).round() as i64;
633
779
  let mut frame_index = 0_i64;
634
- let mut data = vec![0_u8; frame_bytes];
780
+ let mut data = acquire_frame_buffer(frame_bytes, state)?;
635
781
  let read_result = loop {
636
782
  if state.closed.load(Ordering::SeqCst) {
637
783
  break Ok(());
@@ -644,7 +790,8 @@ fn consume_ffmpeg_output(
644
790
  frame_index += 1;
645
791
  state.decoded_frames.fetch_add(1, Ordering::SeqCst);
646
792
 
647
- data = enqueue_frame(PendingFrame { timestamp_us, data }, frame_bytes, state)?;
793
+ enqueue_frame(PendingFrame { timestamp_us, data }, state)?;
794
+ data = acquire_frame_buffer(frame_bytes, state)?;
648
795
  }
649
796
  Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break Ok(()),
650
797
  Err(error) => break Err(error),
@@ -658,6 +805,7 @@ fn consume_ffmpeg_output(
658
805
  .take()
659
806
  .map(|mut process| process.wait())
660
807
  .transpose()?;
808
+ join_stderr_workers(state);
661
809
 
662
810
  read_result?;
663
811
  if !state.closed.load(Ordering::SeqCst) && status.is_some_and(|status| !status.success()) {
@@ -709,15 +857,16 @@ fn wait_for_hardware_retry(state: &DecoderState) -> io::Result<bool> {
709
857
  Ok(!state.closed.load(Ordering::SeqCst))
710
858
  }
711
859
 
712
- fn enqueue_frame(
713
- frame: PendingFrame,
714
- frame_bytes: usize,
715
- state: &DecoderState,
716
- ) -> io::Result<Vec<u8>> {
860
+ fn enqueue_frame(frame: PendingFrame, state: &DecoderState) -> io::Result<()> {
717
861
  let catch_up_timestamp_us = state.catch_up_timestamp_us.load(Ordering::SeqCst);
718
862
  if catch_up_timestamp_us >= 0 && frame.timestamp_us < catch_up_timestamp_us {
719
863
  state.skipped_frames.fetch_add(1, Ordering::SeqCst);
720
- return Ok(frame.data);
864
+ let (frames, _) = &*state.frames;
865
+ frames
866
+ .lock()
867
+ .map_err(|_| io::Error::other("Video frame queue lock poisoned"))?
868
+ .recycle(frame.data);
869
+ return Ok(());
721
870
  }
722
871
  if catch_up_timestamp_us >= 0 {
723
872
  state.catch_up_timestamp_us.store(-1, Ordering::SeqCst);
@@ -731,7 +880,7 @@ fn enqueue_frame(
731
880
  if state.source_paced {
732
881
  if let Some(recycled) = frames.push_latest(frame) {
733
882
  state.dropped_frames.fetch_add(1, Ordering::SeqCst);
734
- return Ok(recycled);
883
+ frames.recycle(recycled);
735
884
  }
736
885
  } else {
737
886
  while frames.len() >= FRAME_QUEUE_CAPACITY
@@ -743,12 +892,14 @@ fn enqueue_frame(
743
892
  .map_err(|_| io::Error::other("Video frame queue lock poisoned"))?;
744
893
  }
745
894
  if state.closed.load(Ordering::SeqCst) {
746
- return Ok(frame.data);
895
+ frames.recycle(frame.data);
896
+ return Ok(());
747
897
  }
748
898
  let target = state.catch_up_timestamp_us.load(Ordering::SeqCst);
749
899
  if target >= 0 && frame.timestamp_us < target {
750
900
  state.skipped_frames.fetch_add(1, Ordering::SeqCst);
751
- return Ok(frame.data);
901
+ frames.recycle(frame.data);
902
+ return Ok(());
752
903
  }
753
904
  if target >= 0 {
754
905
  state.catch_up_timestamp_us.store(-1, Ordering::SeqCst);
@@ -756,9 +907,36 @@ fn enqueue_frame(
756
907
  frames.push_back(frame);
757
908
  }
758
909
 
910
+ Ok(())
911
+ }
912
+
913
+ fn acquire_frame_buffer(frame_bytes: usize, state: &DecoderState) -> io::Result<Vec<u8>> {
914
+ let (frames, _) = &*state.frames;
915
+ if let Some(buffer) = frames
916
+ .lock()
917
+ .map_err(|_| io::Error::other("Video frame queue lock poisoned"))?
918
+ .take_recycled(frame_bytes)
919
+ {
920
+ state.frame_buffer_reuses.fetch_add(1, Ordering::SeqCst);
921
+ return Ok(buffer);
922
+ }
923
+ state
924
+ .frame_buffer_allocations
925
+ .fetch_add(1, Ordering::SeqCst);
759
926
  Ok(vec![0_u8; frame_bytes])
760
927
  }
761
928
 
929
+ fn join_stderr_workers(state: &DecoderState) {
930
+ let workers = state
931
+ .stderr_workers
932
+ .lock()
933
+ .map(|mut workers| workers.drain(..).collect::<Vec<_>>())
934
+ .unwrap_or_default();
935
+ for worker in workers {
936
+ let _ = worker.join();
937
+ }
938
+ }
939
+
762
940
  fn set_backend_name(state: &DecoderState, name: &str) {
763
941
  if let Ok(mut backend) = state.backend.lock() {
764
942
  *backend = name.to_string();
@@ -779,11 +957,13 @@ fn close_state(state: &DecoderState) {
779
957
  if let Ok(mut child) = state.child.lock() {
780
958
  if let Some(mut process) = child.take() {
781
959
  let _ = process.kill();
960
+ let _ = process.wait();
782
961
  }
783
962
  }
784
963
  if let Ok(mut frames) = frames.lock() {
785
964
  frames.clear();
786
965
  }
966
+ join_stderr_workers(state);
787
967
  }
788
968
 
789
969
  #[cfg(test)]
@@ -906,13 +1086,13 @@ mod tests {
906
1086
  fn file_queue_applies_backpressure_until_a_frame_is_consumed() {
907
1087
  let state = decoder_state(false);
908
1088
  for timestamp_us in 0..FRAME_QUEUE_CAPACITY as i64 {
909
- enqueue_frame(pending(timestamp_us), 1, &state).unwrap();
1089
+ enqueue_frame(pending(timestamp_us), &state).unwrap();
910
1090
  }
911
1091
 
912
1092
  let producer_state = state.clone();
913
1093
  let (sent, received) = mpsc::channel();
914
1094
  thread::spawn(move || {
915
- let result = enqueue_frame(pending(99), 1, &producer_state);
1095
+ let result = enqueue_frame(pending(99), &producer_state);
916
1096
  sent.send(result.is_ok()).unwrap();
917
1097
  });
918
1098
 
@@ -928,14 +1108,76 @@ mod tests {
928
1108
  let state = decoder_state(false);
929
1109
  state.catch_up_timestamp_us.store(30, Ordering::SeqCst);
930
1110
 
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();
1111
+ enqueue_frame(pending(10), &state).unwrap();
1112
+ enqueue_frame(pending(20), &state).unwrap();
1113
+ enqueue_frame(pending(30), &state).unwrap();
934
1114
 
935
1115
  assert_eq!(state.skipped_frames.load(Ordering::SeqCst), 2);
936
1116
  let (frames, _) = &*state.frames;
937
1117
  let mut frames = frames.lock().unwrap();
938
1118
  assert_eq!(frames.pop_next().unwrap().timestamp_us, 30);
1119
+ assert_eq!(frames.recycled.len(), 2);
1120
+ }
1121
+
1122
+ #[test]
1123
+ fn copies_frame_into_caller_buffer_and_recycles_native_storage() {
1124
+ let decoder = NativeVideoDecoder::new(DecoderOptions {
1125
+ width: 2,
1126
+ height: 2,
1127
+ fps: Some(30.0),
1128
+ start_time: None,
1129
+ ffmpeg_path: None,
1130
+ vaapi_device: None,
1131
+ playback_rate: None,
1132
+ end_time: None,
1133
+ source_paced: None,
1134
+ input_args: None,
1135
+ output_args: None,
1136
+ })
1137
+ .unwrap();
1138
+ let mut target = vec![0; 6];
1139
+ let info = decoder
1140
+ .copy_video_frame_into(
1141
+ PendingFrame {
1142
+ timestamp_us: 42,
1143
+ data: vec![1, 2, 3, 4, 5, 6],
1144
+ },
1145
+ &mut target,
1146
+ )
1147
+ .unwrap();
1148
+
1149
+ assert_eq!(target, vec![1, 2, 3, 4, 5, 6]);
1150
+ assert_eq!(info.timestamp_us, 42);
1151
+ assert_eq!(decoder.state.frames.0.lock().unwrap().recycled.len(), 1);
1152
+ }
1153
+
1154
+ #[test]
1155
+ fn rejects_wrong_caller_buffer_size_without_losing_native_storage() {
1156
+ let decoder = NativeVideoDecoder::new(DecoderOptions {
1157
+ width: 2,
1158
+ height: 2,
1159
+ fps: Some(30.0),
1160
+ start_time: None,
1161
+ ffmpeg_path: None,
1162
+ vaapi_device: None,
1163
+ playback_rate: None,
1164
+ end_time: None,
1165
+ source_paced: None,
1166
+ input_args: None,
1167
+ output_args: None,
1168
+ })
1169
+ .unwrap();
1170
+ let mut target = vec![0; 5];
1171
+ let result = decoder.copy_video_frame_into(
1172
+ PendingFrame {
1173
+ timestamp_us: 42,
1174
+ data: vec![1, 2, 3, 4, 5, 6],
1175
+ },
1176
+ &mut target,
1177
+ );
1178
+
1179
+ assert!(result.is_err());
1180
+ assert_eq!(decoder.state.frames.0.lock().unwrap().recycled.len(), 1);
939
1181
  }
940
1182
 
941
1183
  #[test]
@@ -973,7 +1215,10 @@ mod tests {
973
1215
  decoded_frames: Arc::new(AtomicU64::new(0)),
974
1216
  dropped_frames: Arc::new(AtomicU64::new(0)),
975
1217
  skipped_frames: Arc::new(AtomicU64::new(0)),
1218
+ frame_buffer_allocations: Arc::new(AtomicU64::new(0)),
1219
+ frame_buffer_reuses: Arc::new(AtomicU64::new(0)),
976
1220
  backend: Arc::new(Mutex::new("test".to_string())),
1221
+ stderr_workers: Arc::new(Mutex::new(Vec::new())),
977
1222
  }
978
1223
  }
979
1224
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matjash/pixi-native-win32-x64",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "commonjs",
5
5
  "description": "Windows x64 native binaries for @matjash/pixi-native",
6
6
  "license": "MIT",