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