use std::collections::HashMap;
use std::io::{self, Read};
use std::num::NonZeroUsize;
use std::process::{Child, ChildStdout, Command, Stdio};
use std::sync::{Mutex, OnceLock};
use lru::LruCache;
use crate::raster::{PixelFormat, RasterImage, Resolution};
const FRAME_CACHE_CAPACITY: usize = 8;
const DECODER_POOL_CAPACITY: usize = 8;
const MAX_ADVANCE_FRAMES: u64 = 16;
const MAX_CURSORS: usize = 4;
const DECODE_FPS: f64 = 30.0;
#[derive(Clone)]
struct Frame {
width: u32,
height: u32,
pixels: Vec<u8>,
}
impl Frame {
fn to_image(&self) -> RasterImage {
RasterImage::cpu(
self.width,
self.height,
PixelFormat::Rgba8,
self.pixels.clone(),
)
}
}
fn duration_cache() -> &'static Mutex<HashMap<String, f32>> {
static CACHE: OnceLock<Mutex<HashMap<String, f32>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn probe_duration(path: &str) -> Option<f32> {
if let Some(d) = duration_cache().lock().unwrap().get(path).copied() {
return Some(d);
}
let d = run_ffprobe_duration(path)?;
duration_cache().lock().unwrap().insert(path.to_string(), d);
Some(d)
}
fn run_ffprobe_duration(path: &str) -> Option<f32> {
let output = Command::new("ffprobe")
.args(["-v", "error"])
.args(["-show_entries", "format=duration"])
.args(["-of", "default=noprint_wrappers=1:nokey=1"])
.arg(path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let secs: f32 = text.trim().parse().ok()?;
if secs.is_finite() && secs >= 0.0 {
Some(secs)
} else {
None
}
}
pub fn source_time(local_secs: f32, trim: Option<(f32, f32)>) -> f32 {
let trim_start = trim.map(|(a, _)| a).unwrap_or(0.0);
(trim_start + local_secs).max(0.0)
}
fn frame_index_for(source_secs: f32) -> u64 {
(source_secs as f64 * DECODE_FPS).round().max(0.0) as u64
}
#[derive(Debug, PartialEq, Eq)]
enum Action {
Cache,
Advance(u64),
Seek,
}
fn decide(target: u64, current: Option<u64>, cached: bool) -> Action {
if cached {
return Action::Cache;
}
match current {
Some(cur) if target >= cur && target - cur <= MAX_ADVANCE_FRAMES => {
Action::Advance(target - cur)
}
_ => Action::Seek,
}
}
struct VideoDecoder {
path: String,
target: Resolution,
cursors: Vec<Cursor>,
cache: LruCache<u64, Frame>,
tick: u64,
}
struct Cursor {
running: Running,
next_index: u64,
last_used: u64,
}
struct Running {
child: Child,
stdout: ChildStdout,
}
impl Drop for Running {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl VideoDecoder {
fn new(path: String, target: Resolution) -> Self {
Self {
path,
target,
cursors: Vec::new(),
cache: LruCache::new(
NonZeroUsize::new(FRAME_CACHE_CAPACITY).expect("cache capacity is non-zero"),
),
tick: 0,
}
}
fn frame_at(&mut self, source_secs: f32) -> Option<RasterImage> {
let target_index = frame_index_for(source_secs);
self.tick = self.tick.wrapping_add(1);
if let Some(frame) = self.cache.get(&target_index) {
return Some(frame.to_image());
}
let best = self
.cursors
.iter()
.enumerate()
.filter_map(
|(i, c)| match decide(target_index, Some(c.next_index), false) {
Action::Advance(n) => Some((i, n)),
_ => None,
},
)
.min_by_key(|&(_, n)| n);
match best {
Some((i, n)) => self.advance(i, target_index, n),
None => self.seek(target_index, source_secs),
}
}
fn advance(&mut self, i: usize, target_index: u64, n: u64) -> Option<RasterImage> {
let frame_bytes = self.frame_bytes();
let target = self.target;
let mut last: Option<Frame> = None;
for _ in 0..=n {
let idx = self.cursors[i].next_index;
let frame = match read_frame(&mut self.cursors[i].running.stdout, frame_bytes, target) {
Some(f) => f,
None => {
self.cursors.remove(i);
return self.cache.get(&target_index).map(Frame::to_image);
}
};
self.cursors[i].next_index = idx + 1;
self.cache.put(idx, frame.clone());
if idx == target_index {
last = Some(frame);
}
}
self.cursors[i].last_used = self.tick;
last.or_else(|| self.cache.get(&target_index).cloned())
.map(|f| f.to_image())
}
fn seek(&mut self, target_index: u64, source_secs: f32) -> Option<RasterImage> {
if self.cursors.len() >= MAX_CURSORS {
if let Some((stalest, _)) = self
.cursors
.iter()
.enumerate()
.min_by_key(|(_, c)| c.last_used)
{
self.cursors.remove(stalest);
}
}
let mut running = spawn_decoder(&self.path, self.target, source_secs)?;
let frame_bytes = self.frame_bytes();
let frame = read_frame(&mut running.stdout, frame_bytes, self.target)?;
self.cache.put(target_index, frame.clone());
self.cursors.push(Cursor {
running,
next_index: target_index + 1,
last_used: self.tick,
});
Some(frame.to_image())
}
fn frame_bytes(&self) -> usize {
(self.target.width as usize) * (self.target.height as usize) * 4
}
}
fn read_frame(stdout: &mut ChildStdout, frame_bytes: usize, target: Resolution) -> Option<Frame> {
let mut buf = vec![0u8; frame_bytes];
match read_exact_or_eof(stdout, &mut buf) {
Ok(true) => Some(Frame {
width: target.width,
height: target.height,
pixels: buf,
}),
Ok(false) | Err(_) => None,
}
}
fn read_exact_or_eof(reader: &mut impl Read, buf: &mut [u8]) -> io::Result<bool> {
let mut filled = 0;
while filled < buf.len() {
match reader.read(&mut buf[filled..]) {
Ok(0) => {
return Ok(filled == buf.len());
}
Ok(n) => filled += n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(true)
}
fn spawn_decoder(path: &str, target: Resolution, start_secs: f32) -> Option<Running> {
let ss = format!("{:.6}", start_secs.max(0.0));
let vf = format!(
"fps={},scale={}:{}:flags=bilinear",
DECODE_FPS, target.width, target.height
);
let mut child = Command::new("ffmpeg")
.args(["-v", "error"])
.args(["-ss", &ss])
.args(["-i", path])
.args(["-vf", &vf])
.args(["-pix_fmt", "rgba"])
.args(["-f", "rawvideo"])
.arg("-")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let stdout = child.stdout.take()?;
Some(Running { child, stdout })
}
#[derive(Clone, PartialEq, Eq, Hash)]
struct DecoderKey {
path: String,
width: u32,
height: u32,
}
fn decoder_pool() -> &'static Mutex<LruCache<DecoderKey, VideoDecoder>> {
static POOL: OnceLock<Mutex<LruCache<DecoderKey, VideoDecoder>>> = OnceLock::new();
POOL.get_or_init(|| {
Mutex::new(LruCache::new(
NonZeroUsize::new(DECODER_POOL_CAPACITY).expect("pool capacity is non-zero"),
))
})
}
pub fn decode_frame(
path: &str,
local_secs: f32,
trim: Option<(f32, f32)>,
target: Resolution,
) -> Option<RasterImage> {
let source = source_time(local_secs, trim);
let key = DecoderKey {
path: path.to_string(),
width: target.width,
height: target.height,
};
let mut pool = decoder_pool().lock().unwrap();
let decoder = pool.get_or_insert_mut(key, || VideoDecoder::new(path.to_string(), target));
decoder.frame_at(source)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(test)]
fn write_testsrc_mp4(path: &std::path::Path, secs: u32, w: u32, h: u32) {
let lavfi = format!("testsrc=size={w}x{h}:rate=30:duration={secs}");
let status = Command::new("ffmpeg")
.args(["-y", "-v", "error"])
.args(["-f", "lavfi", "-i", &lavfi])
.args(["-c:v", "libx264", "-pix_fmt", "yuv420p"])
.arg(path)
.status()
.expect("spawn ffmpeg testsrc fixture");
assert!(status.success(), "ffmpeg testsrc fixture write failed");
}
fn frame_bytes_at(path: &str, t: f32, target: Resolution) -> Vec<u8> {
let img = decode_frame(path, t, None, target)
.unwrap_or_else(|| panic!("decoded a frame at t={t}"));
img.as_cpu().expect("cpu frame").pixels.to_vec()
}
#[test]
#[ignore = "requires ffmpeg on PATH"]
fn frame_for_time_is_independent_of_access_order() {
let dir = std::env::temp_dir();
let pid = std::process::id();
let a = dir.join(format!("tellur_order_a_{pid}.mp4"));
let b = dir.join(format!("tellur_order_b_{pid}.mp4"));
write_testsrc_mp4(&a, 2, 160, 120);
std::fs::copy(&a, &b).expect("copy fixture");
let (a_str, b_str) = (a.to_str().unwrap(), b.to_str().unwrap());
let target = Resolution::new(64, 48);
let times: Vec<f32> = (0..30).map(|i| i as f32 / DECODE_FPS as f32).collect();
let monotonic: Vec<Vec<u8>> = times
.iter()
.map(|&t| frame_bytes_at(a_str, t, target))
.collect();
let mut order: Vec<usize> = Vec::with_capacity(times.len());
let (mut lo, mut hi) = (0usize, times.len() - 1);
while lo <= hi {
order.push(lo);
if hi != lo {
order.push(hi);
}
lo += 1;
if hi == 0 {
break;
}
hi -= 1;
}
let mut thrash = vec![Vec::new(); times.len()];
for &i in &order {
thrash[i] = frame_bytes_at(b_str, times[i], target);
}
for (i, &t) in times.iter().enumerate() {
assert_eq!(
monotonic[i], thrash[i],
"frame at t={t} differs between forward-advance and seek access — \
the time→frame mapping is NOT order-independent"
);
}
let _ = std::fs::remove_file(&a);
let _ = std::fs::remove_file(&b);
}
#[test]
fn source_time_no_trim_is_identity() {
assert_eq!(source_time(0.0, None), 0.0);
assert_eq!(source_time(1.5, None), 1.5);
}
#[test]
fn source_time_adds_trim_start() {
let trim = Some((2.0, 5.0));
assert_eq!(source_time(0.0, trim), 2.0);
assert_eq!(source_time(1.0, trim), 3.0);
}
#[test]
fn source_time_clamps_at_zero() {
assert_eq!(source_time(-1.0, None), 0.0);
assert_eq!(source_time(-1.0, Some((0.5, 1.0))), 0.0);
}
#[test]
fn frame_index_quantizes_to_grid() {
assert_eq!(frame_index_for(0.0), 0);
assert_eq!(frame_index_for(1.0), 30);
assert_eq!(frame_index_for(0.5), 15);
}
#[test]
fn cached_frame_serves_from_cache() {
assert_eq!(decide(10, Some(0), true), Action::Cache);
assert_eq!(decide(10, None, true), Action::Cache);
}
#[test]
fn no_child_yet_seeks() {
assert_eq!(decide(0, None, false), Action::Seek);
assert_eq!(decide(100, None, false), Action::Seek);
}
#[test]
fn small_forward_gap_advances() {
assert_eq!(decide(5, Some(5), false), Action::Advance(0));
assert_eq!(decide(6, Some(5), false), Action::Advance(1));
assert_eq!(
decide(5 + MAX_ADVANCE_FRAMES, Some(5), false),
Action::Advance(MAX_ADVANCE_FRAMES)
);
}
#[test]
fn backward_jump_seeks() {
assert_eq!(decide(3, Some(10), false), Action::Seek);
}
#[test]
fn far_forward_jump_seeks() {
assert_eq!(
decide(5 + MAX_ADVANCE_FRAMES + 1, Some(5), false),
Action::Seek
);
}
}