1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct VideoMetadata {
22 pub width: u32,
23 pub height: u32,
24 pub display_width: u32,
26 pub display_height: u32,
27 pub duration: Option<f64>,
28 pub fps: Option<f64>,
29 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
436pub 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}