Skip to main content

dioxuscut_rasterizer/
video_cache.rs

1//! Bounded video metadata, decoder-session, and decoded-frame caches.
2
3use crate::backend::RasterError;
4use image::RgbaImage;
5use serde::{Deserialize, Serialize};
6use std::collections::{HashMap, VecDeque};
7use std::io::Read;
8use std::path::{Path, PathBuf};
9use std::process::{Child, ChildStdout, Command, Stdio};
10use std::sync::{Arc, Mutex};
11use std::thread::JoinHandle;
12
13const MAX_CACHE_BYTES: usize = 128 * 1024 * 1024;
14const MAX_DECODER_SOURCES: usize = 4;
15const MAX_FORWARD_DECODE_SECONDS: f64 = 5.0;
16const MAX_DECODED_VIDEO_PIXELS: u64 = 64 * 1024 * 1024;
17const STDERR_LIMIT_BYTES: u64 = 64 * 1024;
18
19/// Video stream information reported by FFprobe.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct VideoMetadata {
22    pub width: u32,
23    pub height: u32,
24    /// Dimensions after applying the stream rotation metadata.
25    pub display_width: u32,
26    pub display_height: u32,
27    pub duration: Option<f64>,
28    pub fps: Option<f64>,
29    /// Clockwise display rotation normalized to `0..360`.
30    pub rotation: u16,
31    pub video_stream_index: usize,
32    pub audio_stream_indices: Vec<usize>,
33    pub codec_name: Option<String>,
34    pub pixel_format: Option<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38struct VideoFrameKey {
39    path: PathBuf,
40    sampling_rate_micros: u64,
41    frame_index: u64,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45struct DecoderKey {
46    path: PathBuf,
47    sampling_rate_micros: u64,
48}
49
50#[derive(Default)]
51struct CacheState {
52    frames: VecDeque<(VideoFrameKey, Arc<RgbaImage>)>,
53    bytes: usize,
54}
55
56#[derive(Default)]
57struct DecoderPool {
58    sessions: VecDeque<(DecoderKey, Arc<Mutex<DecoderSession>>)>,
59}
60
61#[derive(Default)]
62pub(crate) struct VideoFrameCache {
63    state: Mutex<CacheState>,
64    metadata: Mutex<HashMap<PathBuf, Arc<VideoMetadata>>>,
65    decoders: Mutex<DecoderPool>,
66    #[cfg(test)]
67    spawn_count: std::sync::atomic::AtomicUsize,
68}
69
70impl VideoFrameCache {
71    pub(crate) fn load(
72        &self,
73        src: &str,
74        time: f64,
75        sampling_fps: f64,
76        looped: bool,
77    ) -> Result<Arc<RgbaImage>, RasterError> {
78        if !time.is_finite() || time < 0.0 {
79            return Err(media_error(
80                src,
81                "video time must be finite and non-negative",
82            ));
83        }
84        if !sampling_fps.is_finite() || sampling_fps <= 0.0 {
85            return Err(media_error(
86                src,
87                "video sampling FPS must be finite and greater than zero",
88            ));
89        }
90
91        let path = canonical_local_path(src)?;
92        let metadata = self.metadata_for(&path)?;
93        let sampling_rate_micros = fps_key(sampling_fps);
94        let frame_index = normalize_frame_index(time, sampling_fps, metadata.duration, looped);
95        let key = VideoFrameKey {
96            path: path.clone(),
97            sampling_rate_micros,
98            frame_index,
99        };
100
101        if let Some(frame) = self.cached(&key) {
102            return Ok(frame);
103        }
104
105        let decoder_key = DecoderKey {
106            path: path.clone(),
107            sampling_rate_micros,
108        };
109        let decoder = self.decoder_for(decoder_key, &metadata, sampling_fps, frame_index)?;
110        let mut decoder = decoder.lock().expect("video decoder lock poisoned");
111
112        if let Some(frame) = self.cached(&key) {
113            return Ok(frame);
114        }
115
116        let should_restart = frame_index < decoder.next_frame
117            || frame_index.saturating_sub(decoder.next_frame)
118                > (sampling_fps * MAX_FORWARD_DECODE_SECONDS).ceil() as u64;
119        if should_restart {
120            *decoder = self.spawn_decoder(&path, &metadata, sampling_fps, frame_index)?;
121        }
122
123        while decoder.next_frame <= frame_index {
124            let decoded_index = decoder.next_frame;
125            let frame = Arc::new(decoder.read_frame()?);
126            decoder.next_frame += 1;
127            self.insert(
128                VideoFrameKey {
129                    path: path.clone(),
130                    sampling_rate_micros,
131                    frame_index: decoded_index,
132                },
133                Arc::clone(&frame),
134            );
135        }
136
137        self.cached(&key).ok_or_else(|| {
138            media_error(
139                path.display().to_string(),
140                format!("failed to cache decoded video frame {frame_index}"),
141            )
142        })
143    }
144
145    fn metadata_for(&self, path: &Path) -> Result<Arc<VideoMetadata>, RasterError> {
146        if let Some(metadata) = self
147            .metadata
148            .lock()
149            .expect("video metadata lock poisoned")
150            .get(path)
151            .cloned()
152        {
153            return Ok(metadata);
154        }
155
156        let metadata = Arc::new(probe_path(path)?);
157        self.metadata
158            .lock()
159            .expect("video metadata lock poisoned")
160            .insert(path.to_path_buf(), Arc::clone(&metadata));
161        Ok(metadata)
162    }
163
164    fn decoder_for(
165        &self,
166        key: DecoderKey,
167        metadata: &VideoMetadata,
168        sampling_fps: f64,
169        first_frame: u64,
170    ) -> Result<Arc<Mutex<DecoderSession>>, RasterError> {
171        let mut pool = self.decoders.lock().expect("video decoder pool poisoned");
172        if let Some(index) = pool
173            .sessions
174            .iter()
175            .position(|(candidate, _)| candidate == &key)
176        {
177            let entry = pool.sessions.remove(index).expect("decoder entry exists");
178            let decoder = Arc::clone(&entry.1);
179            pool.sessions.push_back(entry);
180            return Ok(decoder);
181        }
182
183        if pool.sessions.len() >= MAX_DECODER_SOURCES {
184            let evictable = pool
185                .sessions
186                .iter()
187                .position(|(_, decoder)| Arc::strong_count(decoder) == 1)
188                .ok_or_else(|| {
189                    media_error(
190                        key.path.display().to_string(),
191                        format!(
192                            "video decoder source limit ({MAX_DECODER_SOURCES}) reached; reduce render concurrency or active video sources"
193                        ),
194                    )
195                })?;
196            pool.sessions.remove(evictable);
197        }
198
199        let forward_window = (sampling_fps * MAX_FORWARD_DECODE_SECONDS).ceil() as u64;
200        let decoder_start = if first_frame <= forward_window {
201            0
202        } else {
203            first_frame
204        };
205        let decoder = Arc::new(Mutex::new(self.spawn_decoder(
206            &key.path,
207            metadata,
208            sampling_fps,
209            decoder_start,
210        )?));
211        pool.sessions.push_back((key, Arc::clone(&decoder)));
212        Ok(decoder)
213    }
214
215    fn spawn_decoder(
216        &self,
217        path: &Path,
218        metadata: &VideoMetadata,
219        sampling_fps: f64,
220        first_frame: u64,
221    ) -> Result<DecoderSession, RasterError> {
222        #[cfg(test)]
223        self.spawn_count
224            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
225        DecoderSession::spawn(path, metadata, sampling_fps, first_frame)
226    }
227
228    fn cached(&self, key: &VideoFrameKey) -> Option<Arc<RgbaImage>> {
229        let mut state = self.state.lock().expect("video cache lock poisoned");
230        let index = state
231            .frames
232            .iter()
233            .position(|(candidate, _)| candidate == key)?;
234        let entry = state.frames.remove(index)?;
235        let frame = Arc::clone(&entry.1);
236        state.frames.push_back(entry);
237        Some(frame)
238    }
239
240    fn insert(&self, key: VideoFrameKey, frame: Arc<RgbaImage>) {
241        let frame_bytes = frame.as_raw().len();
242        if frame_bytes > MAX_CACHE_BYTES {
243            return;
244        }
245
246        let mut state = self.state.lock().expect("video cache lock poisoned");
247        if let Some(index) = state
248            .frames
249            .iter()
250            .position(|(candidate, _)| candidate == &key)
251        {
252            if let Some((_, duplicate)) = state.frames.remove(index) {
253                state.bytes = state.bytes.saturating_sub(duplicate.as_raw().len());
254            }
255        }
256        while state.bytes.saturating_add(frame_bytes) > MAX_CACHE_BYTES {
257            let Some((_, evicted)) = state.frames.pop_front() else {
258                break;
259            };
260            state.bytes = state.bytes.saturating_sub(evicted.as_raw().len());
261        }
262        state.bytes += frame_bytes;
263        state.frames.push_back((key, frame));
264    }
265
266    pub(crate) fn shutdown(&self) {
267        self.decoders
268            .lock()
269            .expect("video decoder pool poisoned")
270            .sessions
271            .clear();
272    }
273
274    #[cfg(test)]
275    pub(crate) fn bytes(&self) -> usize {
276        self.state.lock().expect("video cache lock poisoned").bytes
277    }
278
279    #[cfg(test)]
280    pub(crate) fn decoder_count(&self) -> usize {
281        self.decoders
282            .lock()
283            .expect("video decoder pool poisoned")
284            .sessions
285            .len()
286    }
287
288    #[cfg(test)]
289    pub(crate) fn spawn_count(&self) -> usize {
290        self.spawn_count.load(std::sync::atomic::Ordering::Relaxed)
291    }
292}
293
294struct DecoderSession {
295    path: PathBuf,
296    width: u32,
297    height: u32,
298    next_frame: u64,
299    child: Child,
300    stdout: ChildStdout,
301    stderr: Arc<Mutex<Vec<u8>>>,
302    stderr_thread: Option<JoinHandle<()>>,
303}
304
305impl DecoderSession {
306    fn spawn(
307        path: &Path,
308        metadata: &VideoMetadata,
309        sampling_fps: f64,
310        first_frame: u64,
311    ) -> Result<Self, RasterError> {
312        let seek_time = first_frame as f64 / sampling_fps;
313        let mut command = Command::new("ffmpeg");
314        command.args(["-hide_banner", "-loglevel", "error"]);
315        if first_frame > 0 {
316            command.args(["-ss", &format!("{seek_time:.9}")]);
317        }
318        let mut child = command
319            .arg("-i")
320            .arg(path)
321            .args([
322                "-map",
323                "0:v:0",
324                "-vf",
325                &format!("fps={sampling_fps:.9}"),
326                "-an",
327                "-sn",
328                "-dn",
329                "-f",
330                "rawvideo",
331                "-pix_fmt",
332                "rgba",
333                "pipe:1",
334            ])
335            .stdin(Stdio::null())
336            .stdout(Stdio::piped())
337            .stderr(Stdio::piped())
338            .spawn()
339            .map_err(|error| {
340                media_error(
341                    path.display().to_string(),
342                    format!("failed to start persistent FFmpeg decoder: {error}"),
343                )
344            })?;
345
346        let stdout = child.stdout.take().ok_or_else(|| {
347            media_error(
348                path.display().to_string(),
349                "failed to capture FFmpeg decoder stdout",
350            )
351        })?;
352        let child_stderr = child.stderr.take().ok_or_else(|| {
353            media_error(
354                path.display().to_string(),
355                "failed to capture FFmpeg decoder stderr",
356            )
357        })?;
358        let stderr = Arc::new(Mutex::new(Vec::new()));
359        let stderr_writer = Arc::clone(&stderr);
360        let stderr_thread = std::thread::spawn(move || {
361            let mut bytes = Vec::new();
362            let _ = child_stderr
363                .take(STDERR_LIMIT_BYTES)
364                .read_to_end(&mut bytes);
365            *stderr_writer.lock().expect("FFmpeg stderr lock poisoned") = bytes;
366        });
367
368        Ok(Self {
369            path: path.to_path_buf(),
370            width: metadata.display_width,
371            height: metadata.display_height,
372            next_frame: first_frame,
373            child,
374            stdout,
375            stderr,
376            stderr_thread: Some(stderr_thread),
377        })
378    }
379
380    fn read_frame(&mut self) -> Result<RgbaImage, RasterError> {
381        let byte_len = u64::from(self.width)
382            .checked_mul(u64::from(self.height))
383            .and_then(|pixels| pixels.checked_mul(4))
384            .and_then(|bytes| usize::try_from(bytes).ok())
385            .ok_or_else(|| {
386                media_error(
387                    self.path.display().to_string(),
388                    "decoded video dimensions exceed the addressable frame size",
389                )
390            })?;
391        let mut raw = vec![0; byte_len];
392        if let Err(error) = self.stdout.read_exact(&mut raw) {
393            let _ = self.child.wait();
394            if let Some(thread) = self.stderr_thread.take() {
395                let _ = thread.join();
396            }
397            let details =
398                String::from_utf8_lossy(&self.stderr.lock().expect("FFmpeg stderr lock poisoned"))
399                    .trim()
400                    .to_string();
401            let reason = if details.is_empty() {
402                format!(
403                    "no decoded frame exists at frame {} ({error})",
404                    self.next_frame
405                )
406            } else {
407                format!(
408                    "FFmpeg decode failed at frame {}: {details}",
409                    self.next_frame
410                )
411            };
412            return Err(media_error(self.path.display().to_string(), reason));
413        }
414
415        RgbaImage::from_raw(self.width, self.height, raw).ok_or_else(|| {
416            media_error(
417                self.path.display().to_string(),
418                "FFmpeg returned an invalid RGBA frame length",
419            )
420        })
421    }
422}
423
424impl Drop for DecoderSession {
425    fn drop(&mut self) {
426        if self.child.try_wait().ok().flatten().is_none() {
427            let _ = self.child.kill();
428        }
429        let _ = self.child.wait();
430        if let Some(thread) = self.stderr_thread.take() {
431            let _ = thread.join();
432        }
433    }
434}
435
436/// Probe a local video with FFprobe.
437pub fn probe_video_metadata(src: &str) -> Result<VideoMetadata, RasterError> {
438    let path = canonical_local_path(src)?;
439    probe_path(&path)
440}
441
442pub(crate) fn canonical_local_path(src: &str) -> Result<PathBuf, RasterError> {
443    let src = src.trim();
444    if src.is_empty() {
445        return Err(media_error(src, "source path is empty"));
446    }
447    let path = if let Some(path) = src.strip_prefix("file://") {
448        path
449    } else if src.contains("://") || src.starts_with("data:") {
450        return Err(media_error(
451            src,
452            "only local paths and file:// URIs are supported",
453        ));
454    } else {
455        src
456    };
457    Path::new(path)
458        .canonicalize()
459        .map_err(|error| media_error(src, error.to_string()))
460}
461
462fn probe_path(path: &Path) -> Result<VideoMetadata, RasterError> {
463    let output = Command::new("ffprobe")
464        .args([
465            "-v",
466            "error",
467            "-show_entries",
468            "stream=index,codec_type,codec_name,width,height,avg_frame_rate,r_frame_rate,pix_fmt,duration:stream_tags=rotate:stream_side_data=rotation:format=duration",
469            "-of",
470            "json",
471        ])
472        .arg(path)
473        .output()
474        .map_err(|error| {
475            media_error(
476                path.display().to_string(),
477                format!("failed to run FFprobe: {error}"),
478            )
479        })?;
480
481    if !output.status.success() {
482        return Err(media_error(
483            path.display().to_string(),
484            format!(
485                "FFprobe failed: {}",
486                String::from_utf8_lossy(&output.stderr).trim()
487            ),
488        ));
489    }
490
491    let probe: ProbeOutput = serde_json::from_slice(&output.stdout).map_err(|error| {
492        media_error(
493            path.display().to_string(),
494            format!("invalid FFprobe JSON: {error}"),
495        )
496    })?;
497    let video = probe
498        .streams
499        .iter()
500        .find(|stream| stream.codec_type.as_deref() == Some("video"))
501        .ok_or_else(|| media_error(path.display().to_string(), "no video stream found"))?;
502    let width = video
503        .width
504        .filter(|value| *value > 0)
505        .ok_or_else(|| media_error(path.display().to_string(), "video width is missing"))?;
506    let height = video
507        .height
508        .filter(|value| *value > 0)
509        .ok_or_else(|| media_error(path.display().to_string(), "video height is missing"))?;
510    let pixels = u64::from(width) * u64::from(height);
511    if pixels > MAX_DECODED_VIDEO_PIXELS {
512        return Err(media_error(
513            path.display().to_string(),
514            format!(
515                "video dimensions {width}x{height} exceed the decoded-frame limit of {MAX_DECODED_VIDEO_PIXELS} pixels"
516            ),
517        ));
518    }
519    let rotation = stream_rotation(video);
520    let (display_width, display_height) = if rotation == 90 || rotation == 270 {
521        (height, width)
522    } else {
523        (width, height)
524    };
525    let duration = positive_finite(video.duration.as_deref())
526        .or_else(|| positive_finite(probe.format.duration.as_deref()));
527    let fps = parse_rate(video.avg_frame_rate.as_deref())
528        .or_else(|| parse_rate(video.r_frame_rate.as_deref()));
529
530    Ok(VideoMetadata {
531        width,
532        height,
533        display_width,
534        display_height,
535        duration,
536        fps,
537        rotation,
538        video_stream_index: video.index,
539        audio_stream_indices: probe
540            .streams
541            .iter()
542            .filter(|stream| stream.codec_type.as_deref() == Some("audio"))
543            .map(|stream| stream.index)
544            .collect(),
545        codec_name: video.codec_name.clone(),
546        pixel_format: video.pix_fmt.clone(),
547    })
548}
549
550fn normalize_frame_index(time: f64, sampling_fps: f64, duration: Option<f64>, looped: bool) -> u64 {
551    let normalized_time = match duration.filter(|duration| duration.is_finite() && *duration > 0.0)
552    {
553        Some(duration) if looped => time % duration,
554        Some(duration) => time.min(duration),
555        None => time,
556    };
557    let requested = (normalized_time * sampling_fps)
558        .round()
559        .clamp(0.0, u64::MAX as f64) as u64;
560    if let Some(duration) = duration.filter(|duration| duration.is_finite() && *duration > 0.0) {
561        let frame_count = (duration * sampling_fps).ceil().clamp(1.0, u64::MAX as f64) as u64;
562        requested.min(frame_count - 1)
563    } else {
564        requested
565    }
566}
567
568fn fps_key(fps: f64) -> u64 {
569    (fps * 1_000_000.0).round().clamp(1.0, u64::MAX as f64) as u64
570}
571
572fn positive_finite(value: Option<&str>) -> Option<f64> {
573    value?
574        .parse::<f64>()
575        .ok()
576        .filter(|value| value.is_finite() && *value > 0.0)
577}
578
579fn parse_rate(value: Option<&str>) -> Option<f64> {
580    let value = value?;
581    let (numerator, denominator) = value.split_once('/')?;
582    let numerator = numerator.parse::<f64>().ok()?;
583    let denominator = denominator.parse::<f64>().ok()?;
584    let rate = numerator / denominator;
585    (rate.is_finite() && rate > 0.0).then_some(rate)
586}
587
588fn stream_rotation(stream: &ProbeStream) -> u16 {
589    let rotation = stream
590        .side_data_list
591        .iter()
592        .find_map(|data| data.rotation)
593        .or_else(|| {
594            stream
595                .tags
596                .get("rotate")
597                .and_then(|value| value.parse::<i32>().ok())
598        })
599        .unwrap_or(0);
600    rotation.rem_euclid(360) as u16
601}
602
603#[derive(Debug, Deserialize)]
604struct ProbeOutput {
605    #[serde(default)]
606    streams: Vec<ProbeStream>,
607    #[serde(default)]
608    format: ProbeFormat,
609}
610
611#[derive(Debug, Deserialize)]
612struct ProbeStream {
613    index: usize,
614    codec_type: Option<String>,
615    codec_name: Option<String>,
616    width: Option<u32>,
617    height: Option<u32>,
618    avg_frame_rate: Option<String>,
619    r_frame_rate: Option<String>,
620    pix_fmt: Option<String>,
621    duration: Option<String>,
622    #[serde(default)]
623    tags: HashMap<String, String>,
624    #[serde(default)]
625    side_data_list: Vec<ProbeSideData>,
626}
627
628#[derive(Debug, Default, Deserialize)]
629struct ProbeFormat {
630    duration: Option<String>,
631}
632
633#[derive(Debug, Deserialize)]
634struct ProbeSideData {
635    rotation: Option<i32>,
636}
637
638fn media_error(path: impl Into<String>, reason: impl Into<String>) -> RasterError {
639    RasterError::MediaAsset {
640        path: path.into(),
641        reason: reason.into(),
642    }
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    #[test]
650    fn rejects_remote_video_sources() {
651        let error = canonical_local_path("https://example.com/video.mp4").unwrap_err();
652        assert!(error.to_string().contains("only local paths"));
653    }
654
655    #[test]
656    fn replacing_a_cached_key_does_not_double_count_bytes() {
657        let cache = VideoFrameCache::default();
658        let key = VideoFrameKey {
659            path: PathBuf::from("clip.mp4"),
660            sampling_rate_micros: 30_000_000,
661            frame_index: 0,
662        };
663        let frame = Arc::new(RgbaImage::new(2, 2));
664        cache.insert(key.clone(), Arc::clone(&frame));
665        cache.insert(key, frame);
666
667        let state = cache.state.lock().unwrap();
668        assert_eq!(state.frames.len(), 1);
669        assert_eq!(state.bytes, 16);
670    }
671
672    #[test]
673    fn frame_time_is_clamped_or_looped_at_eof() {
674        assert_eq!(normalize_frame_index(4.0, 2.0, Some(1.0), false), 1);
675        assert_eq!(normalize_frame_index(1.25, 4.0, Some(1.0), true), 1);
676        assert_eq!(normalize_frame_index(2.0, 4.0, Some(1.0), true), 0);
677    }
678
679    #[test]
680    fn rational_frame_rates_are_parsed() {
681        let rate = parse_rate(Some("30000/1001")).unwrap();
682        assert!((rate - 29.970_029_97).abs() < 0.000_001);
683        assert_eq!(parse_rate(Some("0/0")), None);
684    }
685
686    #[test]
687    fn variable_frame_rate_input_is_sampled_on_the_output_timeline() {
688        if Command::new("ffmpeg").arg("-version").output().is_err()
689            || Command::new("ffprobe").arg("-version").output().is_err()
690        {
691            eprintln!("skipping VFR decode test: FFmpeg or FFprobe is unavailable");
692            return;
693        }
694
695        let dir =
696            std::env::temp_dir().join(format!("dioxuscut-vfr-video-test-{}", std::process::id()));
697        let _ = std::fs::remove_dir_all(&dir);
698        std::fs::create_dir_all(&dir).unwrap();
699        let source = dir.join("vfr.mkv");
700        let generated = Command::new("ffmpeg")
701            .args([
702                "-y",
703                "-loglevel",
704                "error",
705                "-f",
706                "lavfi",
707                "-i",
708                "testsrc2=size=16x16:rate=10:duration=1",
709                "-vf",
710                "select=eq(n\\,0)+eq(n\\,1)+eq(n\\,4)+eq(n\\,9)",
711                "-fps_mode",
712                "vfr",
713                "-c:v",
714                "ffv1",
715            ])
716            .arg(&source)
717            .status()
718            .unwrap();
719        assert!(generated.success());
720
721        let cache = VideoFrameCache::default();
722        for time in [0.0, 0.2, 0.4, 0.6, 0.8] {
723            let frame = cache
724                .load(source.to_str().unwrap(), time, 5.0, false)
725                .unwrap();
726            assert_eq!(frame.dimensions(), (16, 16));
727        }
728        assert_eq!(cache.spawn_count(), 1);
729        cache.shutdown();
730        std::fs::remove_dir_all(dir).unwrap();
731    }
732}