@matjash/pixi-native-win32-x64 0.1.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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/THIRD_PARTY_NOTICES.md +83 -0
  3. package/index.cjs +25 -0
  4. package/index.d.ts +11 -0
  5. package/native/audio/binding-path.js +16 -0
  6. package/native/audio/dist/win32-x64/native_audio.node +0 -0
  7. package/native/audio/package.json +7 -0
  8. package/native/audio/src/index.d.ts +63 -0
  9. package/native/audio/src/index.js +5 -0
  10. package/native/audio/src/lib.rs +1520 -0
  11. package/native/gpu/dist/win32-x64/d3dcompiler_47.dll +0 -0
  12. package/native/gpu/dist/win32-x64/pixi_native_gpu.node +0 -0
  13. package/native/gpu/package.json +24 -0
  14. package/native/gpu/src/binding-path.js +16 -0
  15. package/native/gpu/src/index.d.ts +2847 -0
  16. package/native/gpu/src/index.js +48 -0
  17. package/native/video/dist/win32-x64/FFMPEG_BUILD_INFO.txt +46 -0
  18. package/native/video/dist/win32-x64/FFMPEG_LICENSE.txt +502 -0
  19. package/native/video/dist/win32-x64/FFMPEG_SHA256SUMS +4 -0
  20. package/native/video/dist/win32-x64/ffmpeg.exe +0 -0
  21. package/native/video/dist/win32-x64/ffprobe.exe +0 -0
  22. package/native/video/dist/win32-x64/native_video.node +0 -0
  23. package/native/video/package.json +7 -0
  24. package/native/video/src/binding-path.js +26 -0
  25. package/native/video/src/index.d.ts +36 -0
  26. package/native/video/src/index.js +61 -0
  27. package/native/video/src/lib.rs +994 -0
  28. package/native/window/binding-path.js +11 -0
  29. package/native/window/dist/win32-x64/native_window.node +0 -0
  30. package/native/window/package.json +7 -0
  31. package/native/window/src/index.d.ts +17 -0
  32. package/native/window/src/index.js +32 -0
  33. package/native/window/src/lib.rs +308 -0
  34. package/package.json +41 -0
  35. package/third_party/ffmpeg-source-8.0-140fd653ae.tar.gz +0 -0
  36. package/third_party/ffmpeg-source-8.0-140fd653ae.tar.gz.sha256 +1 -0
@@ -0,0 +1,994 @@
1
+ #![deny(clippy::all)]
2
+
3
+ use std::collections::VecDeque;
4
+ use std::io::{self, BufRead, BufReader, Read};
5
+ use std::process::{Child, ChildStdout, Command, Stdio};
6
+ use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
7
+ use std::sync::{Arc, Condvar, Mutex};
8
+ use std::thread;
9
+ use std::time::Duration;
10
+
11
+ use napi::bindgen_prelude::*;
12
+ use napi_derive::napi;
13
+
14
+ const FRAME_QUEUE_CAPACITY: usize = 4;
15
+ const HARDWARE_DECODE_ATTEMPTS: usize = 5;
16
+ const HARDWARE_RETRY_DELAY: Duration = Duration::from_millis(500);
17
+
18
+ #[napi(object)]
19
+ pub struct DecoderOptions {
20
+ pub width: i64,
21
+ pub height: i64,
22
+ pub fps: Option<f64>,
23
+ pub start_time: Option<f64>,
24
+ pub ffmpeg_path: Option<String>,
25
+ pub vaapi_device: Option<String>,
26
+ pub playback_rate: Option<f64>,
27
+ pub end_time: Option<f64>,
28
+ pub source_paced: Option<bool>,
29
+ pub input_args: Option<Vec<String>>,
30
+ pub output_args: Option<Vec<String>>,
31
+ }
32
+
33
+ #[napi(object)]
34
+ pub struct VideoFrame {
35
+ pub width: i64,
36
+ pub height: i64,
37
+ pub timestamp_us: i64,
38
+ pub data: Buffer,
39
+ }
40
+
41
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
42
+ enum DecoderBackend {
43
+ D3d11va,
44
+ Vaapi,
45
+ Cpu,
46
+ }
47
+
48
+ impl DecoderBackend {
49
+ fn name(self) -> &'static str {
50
+ match self {
51
+ Self::D3d11va => "D3D11VA",
52
+ Self::Vaapi => "VA-API",
53
+ Self::Cpu => "CPU",
54
+ }
55
+ }
56
+
57
+ fn hardware() -> Option<Self> {
58
+ #[cfg(target_os = "windows")]
59
+ {
60
+ Some(Self::D3d11va)
61
+ }
62
+
63
+ #[cfg(target_os = "linux")]
64
+ {
65
+ Some(Self::Vaapi)
66
+ }
67
+
68
+ #[cfg(not(any(target_os = "windows", target_os = "linux")))]
69
+ {
70
+ None
71
+ }
72
+ }
73
+ }
74
+
75
+ #[derive(Clone)]
76
+ struct DecoderState {
77
+ closed: Arc<AtomicBool>,
78
+ finished: Arc<AtomicBool>,
79
+ child: Arc<Mutex<Option<Child>>>,
80
+ frames: Arc<(Mutex<FrameQueue>, Condvar)>,
81
+ catch_up_timestamp_us: Arc<AtomicI64>,
82
+ source_paced: bool,
83
+ error: Arc<Mutex<Option<String>>>,
84
+ decoded_frames: Arc<AtomicU64>,
85
+ dropped_frames: Arc<AtomicU64>,
86
+ skipped_frames: Arc<AtomicU64>,
87
+ backend: Arc<Mutex<String>>,
88
+ }
89
+
90
+ struct PendingFrame {
91
+ timestamp_us: i64,
92
+ data: Vec<u8>,
93
+ }
94
+
95
+ #[derive(Default)]
96
+ struct FrameQueue {
97
+ frames: VecDeque<PendingFrame>,
98
+ }
99
+
100
+ impl FrameQueue {
101
+ fn push_latest(&mut self, frame: PendingFrame) -> Option<Vec<u8>> {
102
+ let recycled = if self.frames.len() >= FRAME_QUEUE_CAPACITY {
103
+ self.frames.pop_front().map(|dropped| dropped.data)
104
+ } else {
105
+ None
106
+ };
107
+ self.frames.push_back(frame);
108
+ recycled
109
+ }
110
+
111
+ fn push_back(&mut self, frame: PendingFrame) {
112
+ self.frames.push_back(frame);
113
+ }
114
+
115
+ fn pop_next(&mut self) -> Option<PendingFrame> {
116
+ self.frames.pop_front()
117
+ }
118
+
119
+ fn pop_latest(&mut self) -> (Option<PendingFrame>, usize) {
120
+ let latest = self.frames.pop_back();
121
+ let skipped = self.frames.len();
122
+ self.frames.clear();
123
+ (latest, skipped)
124
+ }
125
+
126
+ fn len(&self) -> usize {
127
+ self.frames.len()
128
+ }
129
+
130
+ fn clear(&mut self) {
131
+ self.frames.clear();
132
+ }
133
+ }
134
+
135
+ struct SpawnedFfmpeg {
136
+ child: Child,
137
+ stdout: ChildStdout,
138
+ }
139
+
140
+ #[derive(Clone)]
141
+ struct FfmpegRequest {
142
+ ffmpeg_path: String,
143
+ source: String,
144
+ vaapi_device: String,
145
+ width: usize,
146
+ height: usize,
147
+ fps: f64,
148
+ start_time: f64,
149
+ end_time: Option<f64>,
150
+ input_args: Vec<String>,
151
+ output_args: Vec<String>,
152
+ }
153
+
154
+ #[napi]
155
+ pub struct NativeVideoDecoder {
156
+ options: DecoderOptions,
157
+ state: DecoderState,
158
+ }
159
+
160
+ #[napi]
161
+ impl NativeVideoDecoder {
162
+ #[napi(constructor)]
163
+ pub fn new(options: DecoderOptions) -> Result<Self> {
164
+ validate_options(&options)?;
165
+ let source_paced = options.source_paced.unwrap_or(false);
166
+
167
+ let backend = DecoderBackend::hardware()
168
+ .map(DecoderBackend::name)
169
+ .unwrap_or("CPU")
170
+ .to_string();
171
+
172
+ Ok(Self {
173
+ options,
174
+ state: DecoderState {
175
+ closed: Arc::new(AtomicBool::new(true)),
176
+ finished: Arc::new(AtomicBool::new(false)),
177
+ child: Arc::new(Mutex::new(None)),
178
+ frames: Arc::new((Mutex::new(FrameQueue::default()), Condvar::new())),
179
+ catch_up_timestamp_us: Arc::new(AtomicI64::new(-1)),
180
+ source_paced,
181
+ error: Arc::new(Mutex::new(None)),
182
+ decoded_frames: Arc::new(AtomicU64::new(0)),
183
+ dropped_frames: Arc::new(AtomicU64::new(0)),
184
+ skipped_frames: Arc::new(AtomicU64::new(0)),
185
+ backend: Arc::new(Mutex::new(backend)),
186
+ },
187
+ })
188
+ }
189
+
190
+ #[napi]
191
+ pub fn open(&mut self, source: String) -> Result<()> {
192
+ if !self.state.closed.swap(false, Ordering::SeqCst) {
193
+ return Err(Error::from_reason("Video decoder is already open"));
194
+ }
195
+
196
+ self.state.finished.store(false, Ordering::SeqCst);
197
+ self.state.decoded_frames.store(0, Ordering::SeqCst);
198
+ self.state.dropped_frames.store(0, Ordering::SeqCst);
199
+ self.state.skipped_frames.store(0, Ordering::SeqCst);
200
+
201
+ if let Ok(mut error) = self.state.error.lock() {
202
+ *error = None;
203
+ }
204
+ let (frames, _) = &*self.state.frames;
205
+ if let Ok(mut frames) = frames.lock() {
206
+ frames.clear();
207
+ }
208
+ self.state.catch_up_timestamp_us.store(-1, Ordering::SeqCst);
209
+
210
+ let width = usize::try_from(self.options.width)
211
+ .map_err(|_| Error::from_reason("Invalid video width"))?;
212
+ let height = usize::try_from(self.options.height)
213
+ .map_err(|_| Error::from_reason("Invalid video height"))?;
214
+ let fps = self.options.fps.unwrap_or(30.0);
215
+ let start_time = self.options.start_time.unwrap_or(0.0);
216
+ let ffmpeg_path = self
217
+ .options
218
+ .ffmpeg_path
219
+ .clone()
220
+ .unwrap_or_else(|| "ffmpeg".to_string());
221
+ let vaapi_device = self
222
+ .options
223
+ .vaapi_device
224
+ .clone()
225
+ .or_else(|| std::env::var("FFMPEG_VAAPI_DEVICE").ok())
226
+ .unwrap_or_else(|| "/dev/dri/renderD128".to_string());
227
+
228
+ let requested_backend = DecoderBackend::hardware().unwrap_or(DecoderBackend::Cpu);
229
+ let request = FfmpegRequest {
230
+ ffmpeg_path,
231
+ source,
232
+ vaapi_device,
233
+ width,
234
+ height,
235
+ fps,
236
+ start_time,
237
+ end_time: self.options.end_time,
238
+ input_args: self.options.input_args.clone().unwrap_or_default(),
239
+ output_args: self.options.output_args.clone().unwrap_or_default(),
240
+ };
241
+ let (spawned, active_backend) = match spawn_ffmpeg(&request, requested_backend) {
242
+ Ok(spawned) => (spawned, requested_backend),
243
+ Err(hardware_error) if requested_backend != DecoderBackend::Cpu => {
244
+ eprintln!(
245
+ "FFmpeg {} process failed to start; retrying with CPU decoder: {hardware_error}",
246
+ requested_backend.name()
247
+ );
248
+ let spawned = spawn_ffmpeg(&request, DecoderBackend::Cpu)
249
+ .map_err(|error| Error::from_reason(error.to_string()))?;
250
+ (spawned, DecoderBackend::Cpu)
251
+ }
252
+ Err(error) => {
253
+ self.state.closed.store(true, Ordering::SeqCst);
254
+ return Err(Error::from_reason(error.to_string()));
255
+ }
256
+ };
257
+
258
+ set_backend_name(
259
+ &self.state,
260
+ if active_backend == DecoderBackend::Cpu && requested_backend != DecoderBackend::Cpu {
261
+ "CPU fallback"
262
+ } else {
263
+ active_backend.name()
264
+ },
265
+ );
266
+
267
+ let state = self.state.clone();
268
+ let initial_stdout = install_child(&state, spawned)
269
+ .map_err(|error| Error::from_reason(error.to_string()))?;
270
+
271
+ thread::spawn(move || {
272
+ let mut result =
273
+ consume_decoder_attempt(initial_stdout, width, height, fps, start_time, &state);
274
+
275
+ if active_backend != DecoderBackend::Cpu {
276
+ for attempt in 2..=HARDWARE_DECODE_ATTEMPTS {
277
+ if result.is_ok() || !should_retry_hardware(attempt - 1, &state) {
278
+ break;
279
+ }
280
+ let error = result
281
+ .as_ref()
282
+ .expect_err("failed hardware attempt checked above");
283
+ eprintln!(
284
+ "FFmpeg {} decoder attempt {}/{} failed: {error}; retrying in {} ms",
285
+ active_backend.name(),
286
+ attempt - 1,
287
+ HARDWARE_DECODE_ATTEMPTS,
288
+ HARDWARE_RETRY_DELAY.as_millis(),
289
+ );
290
+ match wait_for_hardware_retry(&state) {
291
+ Ok(true) => {}
292
+ Ok(false) => break,
293
+ Err(wait_error) => {
294
+ result = Err(wait_error);
295
+ break;
296
+ }
297
+ }
298
+ result = spawn_ffmpeg(&request, active_backend)
299
+ .and_then(|spawned| install_child(&state, spawned))
300
+ .and_then(|stdout| {
301
+ consume_decoder_attempt(stdout, width, height, fps, start_time, &state)
302
+ });
303
+ }
304
+ }
305
+
306
+ if let Err(error) = result {
307
+ let has_frames = state.decoded_frames.load(Ordering::SeqCst) > 0;
308
+ if active_backend != DecoderBackend::Cpu
309
+ && !has_frames
310
+ && !state.closed.load(Ordering::SeqCst)
311
+ {
312
+ eprintln!(
313
+ "FFmpeg {} decoder failed after {} attempts; retrying with CPU decoder: {error}",
314
+ active_backend.name(),
315
+ HARDWARE_DECODE_ATTEMPTS,
316
+ );
317
+ set_backend_name(&state, "CPU fallback");
318
+
319
+ let fallback_result = spawn_ffmpeg(&request, DecoderBackend::Cpu)
320
+ .and_then(|spawned| install_child(&state, spawned))
321
+ .and_then(|stdout| {
322
+ consume_decoder_attempt(stdout, width, height, fps, start_time, &state)
323
+ });
324
+
325
+ if let Err(fallback_error) = fallback_result {
326
+ store_error(&state, fallback_error.to_string());
327
+ }
328
+ } else if !state.closed.load(Ordering::SeqCst) {
329
+ store_error(&state, error.to_string());
330
+ }
331
+ }
332
+
333
+ if !state.closed.load(Ordering::SeqCst) {
334
+ state.finished.store(true, Ordering::SeqCst);
335
+ }
336
+ state.closed.store(true, Ordering::SeqCst);
337
+ });
338
+
339
+ Ok(())
340
+ }
341
+
342
+ #[napi]
343
+ pub fn poll_latest(&self) -> Option<VideoFrame> {
344
+ let (frames, available) = &*self.state.frames;
345
+ let (pending, skipped) = frames.lock().ok()?.pop_latest();
346
+ available.notify_all();
347
+ if skipped > 0 {
348
+ self.state
349
+ .skipped_frames
350
+ .fetch_add(skipped as u64, Ordering::SeqCst);
351
+ }
352
+ self.to_video_frame(pending?)
353
+ }
354
+
355
+ #[napi]
356
+ pub fn poll_next(&self) -> Option<VideoFrame> {
357
+ let (frames, available) = &*self.state.frames;
358
+ let pending = frames.lock().ok()?.pop_next()?;
359
+ available.notify_one();
360
+ self.to_video_frame(pending)
361
+ }
362
+
363
+ #[napi]
364
+ pub fn queued_frames(&self) -> i64 {
365
+ let (frames, _) = &*self.state.frames;
366
+ frames
367
+ .lock()
368
+ .map(|frames| i64::try_from(frames.len()).unwrap_or(i64::MAX))
369
+ .unwrap_or(0)
370
+ }
371
+
372
+ #[napi]
373
+ pub fn catch_up_to(&self, timestamp_us: i64) -> Result<()> {
374
+ if timestamp_us < 0 {
375
+ return Err(Error::from_reason(
376
+ "Catch-up timestamp must be non-negative",
377
+ ));
378
+ }
379
+
380
+ self.state
381
+ .catch_up_timestamp_us
382
+ .store(timestamp_us, Ordering::SeqCst);
383
+ let (frames, available) = &*self.state.frames;
384
+ if let Ok(mut frames) = frames.lock() {
385
+ let skipped = frames.len();
386
+ frames.clear();
387
+ self.state
388
+ .skipped_frames
389
+ .fetch_add(skipped as u64, Ordering::SeqCst);
390
+ }
391
+ available.notify_all();
392
+ Ok(())
393
+ }
394
+
395
+ fn to_video_frame(&self, pending: PendingFrame) -> Option<VideoFrame> {
396
+ Some(VideoFrame {
397
+ width: self.options.width,
398
+ height: self.options.height,
399
+ timestamp_us: pending.timestamp_us,
400
+ data: Buffer::from(pending.data),
401
+ })
402
+ }
403
+
404
+ #[napi]
405
+ pub fn poll_error(&self) -> Option<String> {
406
+ self.state.error.lock().ok()?.take()
407
+ }
408
+
409
+ #[napi]
410
+ pub fn backend(&self) -> String {
411
+ self.state
412
+ .backend
413
+ .lock()
414
+ .map(|backend| backend.clone())
415
+ .unwrap_or_else(|_| "unknown".to_string())
416
+ }
417
+
418
+ #[napi]
419
+ pub fn decoded_frames(&self) -> i64 {
420
+ i64::try_from(self.state.decoded_frames.load(Ordering::SeqCst)).unwrap_or(i64::MAX)
421
+ }
422
+
423
+ #[napi]
424
+ pub fn dropped_frames(&self) -> i64 {
425
+ i64::try_from(self.state.dropped_frames.load(Ordering::SeqCst)).unwrap_or(i64::MAX)
426
+ }
427
+
428
+ #[napi]
429
+ pub fn skipped_frames(&self) -> i64 {
430
+ i64::try_from(self.state.skipped_frames.load(Ordering::SeqCst)).unwrap_or(i64::MAX)
431
+ }
432
+
433
+ #[napi]
434
+ pub fn is_finished(&self) -> bool {
435
+ self.state.finished.load(Ordering::SeqCst)
436
+ }
437
+
438
+ #[napi]
439
+ pub fn close(&mut self) {
440
+ close_state(&self.state);
441
+ }
442
+ }
443
+
444
+ impl Drop for NativeVideoDecoder {
445
+ fn drop(&mut self) {
446
+ close_state(&self.state);
447
+ }
448
+ }
449
+
450
+ fn validate_options(options: &DecoderOptions) -> Result<()> {
451
+ if options.width <= 0 || options.height <= 0 {
452
+ return Err(Error::from_reason("Video dimensions must be positive"));
453
+ }
454
+ if options.width % 2 != 0 || options.height % 2 != 0 {
455
+ return Err(Error::from_reason("NV12 video dimensions must be even"));
456
+ }
457
+
458
+ let fps = options.fps.unwrap_or(30.0);
459
+ if !fps.is_finite() || fps <= 0.0 {
460
+ return Err(Error::from_reason("Video FPS must be positive and finite"));
461
+ }
462
+
463
+ let start_time = options.start_time.unwrap_or(0.0);
464
+ if !start_time.is_finite() || start_time < 0.0 {
465
+ return Err(Error::from_reason(
466
+ "Video start time must be non-negative and finite",
467
+ ));
468
+ }
469
+
470
+ let playback_rate = options.playback_rate.unwrap_or(1.0);
471
+ if !playback_rate.is_finite() || playback_rate <= 0.0 {
472
+ return Err(Error::from_reason(
473
+ "Video playback rate must be positive and finite",
474
+ ));
475
+ }
476
+
477
+ if let Some(end_time) = options.end_time {
478
+ if !end_time.is_finite() || end_time <= start_time {
479
+ return Err(Error::from_reason(
480
+ "Video end time must be finite and greater than start time",
481
+ ));
482
+ }
483
+ }
484
+ Ok(())
485
+ }
486
+
487
+ fn nv12_frame_bytes(width: usize, height: usize) -> io::Result<usize> {
488
+ let y_bytes = width
489
+ .checked_mul(height)
490
+ .ok_or_else(|| io::Error::other("Video frame size overflow"))?;
491
+ y_bytes
492
+ .checked_add(y_bytes / 2)
493
+ .ok_or_else(|| io::Error::other("Video frame size overflow"))
494
+ }
495
+
496
+ fn ffmpeg_args(request: &FfmpegRequest, backend: DecoderBackend) -> Vec<String> {
497
+ let mut args = vec![
498
+ "-hide_banner".to_string(),
499
+ "-loglevel".to_string(),
500
+ "error".to_string(),
501
+ "-nostdin".to_string(),
502
+ ];
503
+
504
+ match backend {
505
+ DecoderBackend::D3d11va => {
506
+ args.extend(["-hwaccel".to_string(), "d3d11va".to_string()]);
507
+ }
508
+ DecoderBackend::Vaapi => {
509
+ args.extend([
510
+ "-hwaccel".to_string(),
511
+ "vaapi".to_string(),
512
+ "-hwaccel_device".to_string(),
513
+ request.vaapi_device.clone(),
514
+ "-hwaccel_output_format".to_string(),
515
+ "vaapi".to_string(),
516
+ ]);
517
+ }
518
+ DecoderBackend::Cpu => {}
519
+ }
520
+
521
+ if request.start_time > 0.0 {
522
+ args.extend(["-ss".to_string(), request.start_time.to_string()]);
523
+ }
524
+ args.extend(request.input_args.iter().cloned());
525
+ args.extend(["-i".to_string(), request.source.clone(), "-an".to_string()]);
526
+ if let Some(end_time) = request.end_time {
527
+ args.extend([
528
+ "-t".to_string(),
529
+ (end_time - request.start_time).to_string(),
530
+ ]);
531
+ }
532
+ args.extend(request.output_args.iter().cloned());
533
+
534
+ let scale = format!(
535
+ "fps={},scale={}:{}:flags=fast_bilinear:in_range=auto:out_range=tv:in_color_matrix=auto:out_color_matrix=bt709,format=nv12",
536
+ request.fps, request.width, request.height
537
+ );
538
+ let filter = match backend {
539
+ DecoderBackend::Vaapi => format!("hwdownload,format=nv12,{scale}"),
540
+ DecoderBackend::D3d11va => scale,
541
+ // Keep the CPU fallback independent from the color-negotiation path
542
+ // used after VA-API download. Some FFmpeg builds fail to initialize
543
+ // auto_scale when that path is reused for software yuv420p frames.
544
+ DecoderBackend::Cpu => format!(
545
+ "fps={},scale={}:{}:flags=fast_bilinear,format=nv12",
546
+ request.fps, request.width, request.height
547
+ ),
548
+ };
549
+
550
+ if backend == DecoderBackend::Cpu {
551
+ // The bundled/minimal FFmpeg builds can fail filter negotiation when
552
+ // multiple filter workers initialize the software graph concurrently.
553
+ args.extend([
554
+ "-filter_threads".to_string(),
555
+ "1".to_string(),
556
+ "-filter_complex_threads".to_string(),
557
+ "1".to_string(),
558
+ ]);
559
+ }
560
+
561
+ args.extend([
562
+ "-vf".to_string(),
563
+ filter,
564
+ "-f".to_string(),
565
+ "rawvideo".to_string(),
566
+ "-pix_fmt".to_string(),
567
+ "nv12".to_string(),
568
+ "pipe:1".to_string(),
569
+ ]);
570
+ args
571
+ }
572
+
573
+ fn spawn_ffmpeg(request: &FfmpegRequest, backend: DecoderBackend) -> io::Result<SpawnedFfmpeg> {
574
+ let mut child = Command::new(&request.ffmpeg_path)
575
+ .args(ffmpeg_args(request, backend))
576
+ .stdin(Stdio::null())
577
+ .stdout(Stdio::piped())
578
+ .stderr(Stdio::piped())
579
+ .spawn()?;
580
+
581
+ let stdout = child
582
+ .stdout
583
+ .take()
584
+ .ok_or_else(|| io::Error::other("FFmpeg stdout unavailable"))?;
585
+ if let Some(stderr) = child.stderr.take() {
586
+ thread::spawn(move || {
587
+ for line in BufReader::new(stderr).lines().map_while(|line| line.ok()) {
588
+ eprintln!("{}", redact_url_credentials(&line));
589
+ }
590
+ });
591
+ }
592
+ Ok(SpawnedFfmpeg { child, stdout })
593
+ }
594
+
595
+ fn redact_url_credentials(value: &str) -> String {
596
+ let mut result = value.to_string();
597
+ let mut search_from = 0;
598
+ while let Some(relative_scheme) = result[search_from..].find("://") {
599
+ let authority_start = search_from + relative_scheme + 3;
600
+ let authority_end = result[authority_start..]
601
+ .find(|character: char| character == '/' || character.is_whitespace())
602
+ .map(|offset| authority_start + offset)
603
+ .unwrap_or(result.len());
604
+ let Some(relative_at) = result[authority_start..authority_end].find('@') else {
605
+ search_from = authority_end.min(result.len());
606
+ continue;
607
+ };
608
+ let at = authority_start + relative_at;
609
+ result.replace_range(authority_start..at, "***:***");
610
+ search_from = authority_start + "***:***@".len();
611
+ }
612
+ result
613
+ }
614
+
615
+ fn install_child(state: &DecoderState, spawned: SpawnedFfmpeg) -> io::Result<ChildStdout> {
616
+ *state
617
+ .child
618
+ .lock()
619
+ .map_err(|_| io::Error::other("FFmpeg process lock poisoned"))? = Some(spawned.child);
620
+ Ok(spawned.stdout)
621
+ }
622
+
623
+ fn consume_ffmpeg_output(
624
+ mut stdout: ChildStdout,
625
+ width: usize,
626
+ height: usize,
627
+ fps: f64,
628
+ start_time: f64,
629
+ state: &DecoderState,
630
+ ) -> io::Result<()> {
631
+ let frame_bytes = nv12_frame_bytes(width, height)?;
632
+ let start_timestamp_us = (start_time * 1_000_000.0).round() as i64;
633
+ let mut frame_index = 0_i64;
634
+ let mut data = vec![0_u8; frame_bytes];
635
+ let read_result = loop {
636
+ if state.closed.load(Ordering::SeqCst) {
637
+ break Ok(());
638
+ }
639
+
640
+ match stdout.read_exact(&mut data) {
641
+ Ok(()) => {
642
+ let timestamp_us =
643
+ start_timestamp_us + (frame_index as f64 * 1_000_000.0 / fps).round() as i64;
644
+ frame_index += 1;
645
+ state.decoded_frames.fetch_add(1, Ordering::SeqCst);
646
+
647
+ data = enqueue_frame(PendingFrame { timestamp_us, data }, frame_bytes, state)?;
648
+ }
649
+ Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break Ok(()),
650
+ Err(error) => break Err(error),
651
+ }
652
+ };
653
+
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()?;
661
+
662
+ read_result?;
663
+ if !state.closed.load(Ordering::SeqCst) && status.is_some_and(|status| !status.success()) {
664
+ return Err(io::Error::other(format!(
665
+ "FFmpeg exited with status {}",
666
+ status.expect("status checked above")
667
+ )));
668
+ }
669
+ Ok(())
670
+ }
671
+
672
+ fn consume_decoder_attempt(
673
+ stdout: ChildStdout,
674
+ width: usize,
675
+ height: usize,
676
+ fps: f64,
677
+ start_time: f64,
678
+ state: &DecoderState,
679
+ ) -> io::Result<()> {
680
+ let result = consume_ffmpeg_output(stdout, width, height, fps, start_time, state);
681
+ result?;
682
+ if !state.closed.load(Ordering::SeqCst) && state.decoded_frames.load(Ordering::SeqCst) == 0 {
683
+ return Err(io::Error::other(
684
+ "FFmpeg ended before producing its first video frame",
685
+ ));
686
+ }
687
+ Ok(())
688
+ }
689
+
690
+ fn should_retry_hardware(completed_attempts: usize, state: &DecoderState) -> bool {
691
+ completed_attempts < HARDWARE_DECODE_ATTEMPTS
692
+ && state.decoded_frames.load(Ordering::SeqCst) == 0
693
+ && !state.closed.load(Ordering::SeqCst)
694
+ }
695
+
696
+ fn wait_for_hardware_retry(state: &DecoderState) -> io::Result<bool> {
697
+ if state.closed.load(Ordering::SeqCst) {
698
+ return Ok(false);
699
+ }
700
+ let (frames, available) = &*state.frames;
701
+ let frames = frames
702
+ .lock()
703
+ .map_err(|_| io::Error::other("Video frame queue lock poisoned"))?;
704
+ let (_frames, _) = available
705
+ .wait_timeout_while(frames, HARDWARE_RETRY_DELAY, |_| {
706
+ !state.closed.load(Ordering::SeqCst)
707
+ })
708
+ .map_err(|_| io::Error::other("Video frame queue lock poisoned"))?;
709
+ Ok(!state.closed.load(Ordering::SeqCst))
710
+ }
711
+
712
+ fn enqueue_frame(
713
+ frame: PendingFrame,
714
+ frame_bytes: usize,
715
+ state: &DecoderState,
716
+ ) -> io::Result<Vec<u8>> {
717
+ let catch_up_timestamp_us = state.catch_up_timestamp_us.load(Ordering::SeqCst);
718
+ if catch_up_timestamp_us >= 0 && frame.timestamp_us < catch_up_timestamp_us {
719
+ state.skipped_frames.fetch_add(1, Ordering::SeqCst);
720
+ return Ok(frame.data);
721
+ }
722
+ if catch_up_timestamp_us >= 0 {
723
+ state.catch_up_timestamp_us.store(-1, Ordering::SeqCst);
724
+ }
725
+
726
+ let (frames, available) = &*state.frames;
727
+ let mut frames = frames
728
+ .lock()
729
+ .map_err(|_| io::Error::other("Video frame queue lock poisoned"))?;
730
+
731
+ if state.source_paced {
732
+ if let Some(recycled) = frames.push_latest(frame) {
733
+ state.dropped_frames.fetch_add(1, Ordering::SeqCst);
734
+ return Ok(recycled);
735
+ }
736
+ } else {
737
+ while frames.len() >= FRAME_QUEUE_CAPACITY
738
+ && !state.closed.load(Ordering::SeqCst)
739
+ && state.catch_up_timestamp_us.load(Ordering::SeqCst) < 0
740
+ {
741
+ frames = available
742
+ .wait(frames)
743
+ .map_err(|_| io::Error::other("Video frame queue lock poisoned"))?;
744
+ }
745
+ if state.closed.load(Ordering::SeqCst) {
746
+ return Ok(frame.data);
747
+ }
748
+ let target = state.catch_up_timestamp_us.load(Ordering::SeqCst);
749
+ if target >= 0 && frame.timestamp_us < target {
750
+ state.skipped_frames.fetch_add(1, Ordering::SeqCst);
751
+ return Ok(frame.data);
752
+ }
753
+ if target >= 0 {
754
+ state.catch_up_timestamp_us.store(-1, Ordering::SeqCst);
755
+ }
756
+ frames.push_back(frame);
757
+ }
758
+
759
+ Ok(vec![0_u8; frame_bytes])
760
+ }
761
+
762
+ fn set_backend_name(state: &DecoderState, name: &str) {
763
+ if let Ok(mut backend) = state.backend.lock() {
764
+ *backend = name.to_string();
765
+ }
766
+ }
767
+
768
+ fn store_error(state: &DecoderState, message: String) {
769
+ eprintln!("FFmpeg decoder failed: {message}");
770
+ if let Ok(mut error) = state.error.lock() {
771
+ *error = Some(message);
772
+ }
773
+ }
774
+
775
+ fn close_state(state: &DecoderState) {
776
+ state.closed.store(true, Ordering::SeqCst);
777
+ let (frames, available) = &*state.frames;
778
+ 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
+ }
783
+ }
784
+ if let Ok(mut frames) = frames.lock() {
785
+ frames.clear();
786
+ }
787
+ }
788
+
789
+ #[cfg(test)]
790
+ mod tests {
791
+ use super::*;
792
+ use std::sync::mpsc;
793
+ use std::time::Duration;
794
+
795
+ #[test]
796
+ fn calculates_nv12_frame_size() {
797
+ assert_eq!(nv12_frame_bytes(1280, 720).unwrap(), 1_382_400);
798
+ assert_eq!(nv12_frame_bytes(1920, 1080).unwrap(), 3_110_400);
799
+ }
800
+
801
+ #[test]
802
+ fn builds_windows_nv12_seek_args() {
803
+ let args = ffmpeg_args(&request(24.0, 12.5), DecoderBackend::D3d11va);
804
+ assert!(args.windows(2).any(|pair| pair == ["-hwaccel", "d3d11va"]));
805
+ assert!(args.windows(2).any(|pair| pair == ["-ss", "12.5"]));
806
+ assert!(args
807
+ .iter()
808
+ .any(|arg| arg.contains("out_color_matrix=bt709")));
809
+ assert_eq!(args[args.len() - 3..], ["-pix_fmt", "nv12", "pipe:1"]);
810
+ }
811
+
812
+ #[test]
813
+ fn builds_linux_vaapi_download_filter() {
814
+ let args = ffmpeg_args(&request(30.0, 0.0), DecoderBackend::Vaapi);
815
+ assert!(args.iter().any(|arg| arg == "/dev/dri/test"));
816
+ assert!(args
817
+ .iter()
818
+ .any(|arg| arg.starts_with("hwdownload,format=nv12,fps=")));
819
+ assert!(!args.iter().any(|arg| arg == "-ss"));
820
+ }
821
+
822
+ #[test]
823
+ fn builds_custom_arguments_without_readrate() {
824
+ let mut request = request(25.0, 0.0);
825
+ request.input_args = vec!["-fflags".to_string(), "nobuffer".to_string()];
826
+ request.output_args = vec!["-threads".to_string(), "1".to_string()];
827
+ let args = ffmpeg_args(&request, DecoderBackend::Cpu);
828
+ assert!(!args.iter().any(|arg| arg == "-readrate" || arg == "-re"));
829
+ let input_index = args.iter().position(|arg| arg == "-i").unwrap();
830
+ let fflags_index = args.iter().position(|arg| arg == "-fflags").unwrap();
831
+ let threads_index = args.iter().position(|arg| arg == "-threads").unwrap();
832
+ let pipe_index = args.iter().position(|arg| arg == "pipe:1").unwrap();
833
+ assert!(fflags_index < input_index);
834
+ assert!(threads_index > input_index && threads_index < pipe_index);
835
+ }
836
+
837
+ #[test]
838
+ fn builds_serial_software_fallback_filter() {
839
+ let args = ffmpeg_args(&request(30.0, 0.0), DecoderBackend::Cpu);
840
+ assert!(args.windows(2).any(|pair| pair == ["-filter_threads", "1"]));
841
+ assert!(args
842
+ .windows(2)
843
+ .any(|pair| pair == ["-filter_complex_threads", "1"]));
844
+ assert!(args
845
+ .iter()
846
+ .any(|arg| arg == "fps=30,scale=1280:720:flags=fast_bilinear,format=nv12"));
847
+ assert!(!args.iter().any(|arg| arg.contains("in_color_matrix=auto")));
848
+ }
849
+
850
+ #[test]
851
+ fn redacts_url_credentials_from_ffmpeg_errors() {
852
+ assert_eq!(
853
+ redact_url_credentials("failed http://root:secret@10.1.2.3/live.sdp"),
854
+ "failed http://***:***@10.1.2.3/live.sdp"
855
+ );
856
+ }
857
+
858
+ #[test]
859
+ fn rejects_odd_nv12_dimensions() {
860
+ let result = validate_options(&DecoderOptions {
861
+ width: 1279,
862
+ height: 720,
863
+ fps: Some(24.0),
864
+ start_time: None,
865
+ ffmpeg_path: None,
866
+ vaapi_device: None,
867
+ playback_rate: None,
868
+ end_time: None,
869
+ source_paced: None,
870
+ input_args: None,
871
+ output_args: None,
872
+ });
873
+ assert!(result.is_err());
874
+ }
875
+
876
+ #[test]
877
+ fn frame_queue_preserves_order_and_drops_oldest_on_overflow() {
878
+ let mut queue = FrameQueue::default();
879
+ for timestamp_us in 0..FRAME_QUEUE_CAPACITY as i64 {
880
+ assert!(queue.push_latest(pending(timestamp_us)).is_none());
881
+ }
882
+
883
+ let recycled = queue
884
+ .push_latest(pending(FRAME_QUEUE_CAPACITY as i64))
885
+ .expect("oldest frame should be recycled");
886
+ assert_eq!(recycled, vec![0]);
887
+ assert_eq!(queue.len(), FRAME_QUEUE_CAPACITY);
888
+ assert_eq!(queue.pop_next().unwrap().timestamp_us, 1);
889
+ assert_eq!(queue.pop_next().unwrap().timestamp_us, 2);
890
+ }
891
+
892
+ #[test]
893
+ fn frame_queue_can_take_latest_and_reports_skipped_frames() {
894
+ let mut queue = FrameQueue::default();
895
+ queue.push_latest(pending(10));
896
+ queue.push_latest(pending(20));
897
+ queue.push_latest(pending(30));
898
+
899
+ let (latest, skipped) = queue.pop_latest();
900
+ assert_eq!(latest.unwrap().timestamp_us, 30);
901
+ assert_eq!(skipped, 2);
902
+ assert_eq!(queue.len(), 0);
903
+ }
904
+
905
+ #[test]
906
+ fn file_queue_applies_backpressure_until_a_frame_is_consumed() {
907
+ let state = decoder_state(false);
908
+ for timestamp_us in 0..FRAME_QUEUE_CAPACITY as i64 {
909
+ enqueue_frame(pending(timestamp_us), 1, &state).unwrap();
910
+ }
911
+
912
+ let producer_state = state.clone();
913
+ let (sent, received) = mpsc::channel();
914
+ thread::spawn(move || {
915
+ let result = enqueue_frame(pending(99), 1, &producer_state);
916
+ sent.send(result.is_ok()).unwrap();
917
+ });
918
+
919
+ assert!(received.recv_timeout(Duration::from_millis(25)).is_err());
920
+ let (frames, available) = &*state.frames;
921
+ frames.lock().unwrap().pop_next();
922
+ available.notify_one();
923
+ assert!(received.recv_timeout(Duration::from_secs(1)).unwrap());
924
+ }
925
+
926
+ #[test]
927
+ fn catch_up_discards_obsolete_file_frames_before_rebuffering() {
928
+ let state = decoder_state(false);
929
+ state.catch_up_timestamp_us.store(30, Ordering::SeqCst);
930
+
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();
934
+
935
+ assert_eq!(state.skipped_frames.load(Ordering::SeqCst), 2);
936
+ let (frames, _) = &*state.frames;
937
+ let mut frames = frames.lock().unwrap();
938
+ assert_eq!(frames.pop_next().unwrap().timestamp_us, 30);
939
+ }
940
+
941
+ #[test]
942
+ fn hardware_retry_stops_after_five_attempts_or_first_frame() {
943
+ let state = decoder_state(false);
944
+ for completed_attempts in 1..HARDWARE_DECODE_ATTEMPTS {
945
+ assert!(should_retry_hardware(completed_attempts, &state));
946
+ }
947
+ assert!(!should_retry_hardware(HARDWARE_DECODE_ATTEMPTS, &state));
948
+
949
+ state.decoded_frames.store(1, Ordering::SeqCst);
950
+ assert!(!should_retry_hardware(1, &state));
951
+
952
+ state.decoded_frames.store(0, Ordering::SeqCst);
953
+ state.closed.store(true, Ordering::SeqCst);
954
+ assert!(!should_retry_hardware(1, &state));
955
+ }
956
+
957
+ fn pending(timestamp_us: i64) -> PendingFrame {
958
+ PendingFrame {
959
+ timestamp_us,
960
+ data: vec![timestamp_us as u8],
961
+ }
962
+ }
963
+
964
+ fn decoder_state(source_paced: bool) -> DecoderState {
965
+ DecoderState {
966
+ closed: Arc::new(AtomicBool::new(false)),
967
+ finished: Arc::new(AtomicBool::new(false)),
968
+ child: Arc::new(Mutex::new(None)),
969
+ frames: Arc::new((Mutex::new(FrameQueue::default()), Condvar::new())),
970
+ catch_up_timestamp_us: Arc::new(AtomicI64::new(-1)),
971
+ source_paced,
972
+ error: Arc::new(Mutex::new(None)),
973
+ decoded_frames: Arc::new(AtomicU64::new(0)),
974
+ dropped_frames: Arc::new(AtomicU64::new(0)),
975
+ skipped_frames: Arc::new(AtomicU64::new(0)),
976
+ backend: Arc::new(Mutex::new("test".to_string())),
977
+ }
978
+ }
979
+
980
+ fn request(fps: f64, start_time: f64) -> FfmpegRequest {
981
+ FfmpegRequest {
982
+ ffmpeg_path: "ffmpeg".to_string(),
983
+ source: "video.mp4".to_string(),
984
+ vaapi_device: "/dev/dri/test".to_string(),
985
+ width: 1280,
986
+ height: 720,
987
+ fps,
988
+ start_time,
989
+ end_time: None,
990
+ input_args: Vec::new(),
991
+ output_args: Vec::new(),
992
+ }
993
+ }
994
+ }