simple_editor\media/
mod.rs

1//! Media decoding: a small trait layer over two backends.
2//!  * `mf`     — Windows Media Foundation (native software decode, no external deps, instant seeks). Primary.
3//!  * `ffpipe` — ffmpeg.exe / ffprobe.exe child processes. Universal fallback, images, probing, export.
4//! Everything produces top-down RGBA8 frames and interleaved stereo f32 audio at SAMPLE_RATE.
5
6pub mod ffpipe;
7pub mod mf;
8pub mod proxy;
9pub mod thumbs;
10pub mod waveform;
11pub mod ytdlp;
12
13use crate::model::Asset;
14use std::collections::HashMap;
15
16pub const SAMPLE_RATE: u32 = 48000;
17pub const CHANNELS: usize = 2;
18
19/// Top-down RGBA8 image. `rgba.len() == width*height*4`.
20#[derive(Clone, Default)]
21pub struct Frame {
22    pub width: u32,
23    pub height: u32,
24    /// Source/timeline time this frame represents (informational).
25    pub pts: f64,
26    pub rgba: Vec<u8>,
27}
28
29impl Frame {
30    pub fn new(width: u32, height: u32) -> Self {
31        Self { width, height, pts: 0.0, rgba: vec![0; (width * height * 4) as usize] }
32    }
33    /// Resize (contents undefined/zero when the size changes).
34    pub fn resize(&mut self, width: u32, height: u32) {
35        if self.width != width || self.height != height {
36            self.width = width;
37            self.height = height;
38            self.rgba.clear();
39            self.rgba.resize((width * height * 4) as usize, 0);
40        }
41    }
42    pub fn fill(&mut self, rgba: [u8; 4]) {
43        for px in self.rgba.chunks_exact_mut(4) {
44            px.copy_from_slice(&rgba);
45        }
46    }
47    pub fn stride(&self) -> usize {
48        self.width as usize * 4
49    }
50    pub fn is_empty(&self) -> bool {
51        self.width == 0 || self.height == 0
52    }
53}
54
55pub trait VideoSource: Send {
56    /// Native (coded) size.
57    fn size(&self) -> (u32, u32);
58    /// Decode the frame displayed at source time `t`, scaled (aspect-ignorant, caller picks w/h; the
59    /// compositor never asks for more than native size, so upscaling need only be correct) into `out`.
60    /// Returns false at/after EOF or on error (then `out` is untouched). Must be cheap for sequential
61    /// increasing `t` (playback: keep decoding forward); may seek for other jumps.
62    fn frame_at(&mut self, t: f64, w: u32, h: u32, out: &mut Frame) -> bool;
63}
64
65pub trait AudioSource: Send {
66    fn duration(&self) -> f64;
67    /// Fill `out` (interleaved stereo f32 @ SAMPLE_RATE, frames = out.len()/2) starting at source time `t`.
68    /// Zero-fill past the end. Must be cheap for sequential calls (t advancing by exactly the previous block).
69    fn read_at(&mut self, t: f64, out: &mut [f32]);
70}
71
72#[derive(Clone, Copy, PartialEq, Eq, Debug)]
73pub enum Backend {
74    Auto,
75    Mf,
76    Ffmpeg,
77}
78
79impl Backend {
80    pub fn parse(s: &str) -> Self {
81        match s {
82            "mf" => Backend::Mf,
83            "ffmpeg" => Backend::Ffmpeg,
84            _ => Backend::Auto,
85        }
86    }
87}
88
89/// Lower-case file extension ("" when none).
90pub fn ext(path: &str) -> String {
91    std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
92}
93
94pub fn is_image_path(path: &str) -> bool {
95    matches!(ext(path).as_str(), "png" | "jpg" | "jpeg" | "bmp" | "gif" | "webp" | "tif" | "tiff" | "tga" | "psd")
96}
97
98/// Probe a media file into an Asset (id = 0). ffprobe gives the richest stream metadata, so it is
99/// preferred when present; Media Foundation otherwise.
100pub fn probe(path: &str, backend: Backend) -> Result<Asset, String> {
101    match backend {
102        Backend::Ffmpeg => ffpipe::probe(path),
103        Backend::Mf => mf::probe(path),
104        Backend::Auto => {
105            if ffpipe::ffprobe_exe().is_some() {
106                ffpipe::probe(path).or_else(|e| mf::probe(path).map_err(|e2| format!("{e}; {e2}")))
107            } else {
108                mf::probe(path).or_else(|e| ffpipe::probe(path).map_err(|e2| format!("{e}; {e2}")))
109            }
110        }
111    }
112}
113
114pub fn open_video(path: &str, backend: Backend) -> Result<Box<dyn VideoSource>, String> {
115    if is_image_path(path) {
116        return ffpipe::open_video(path);
117    }
118    match backend {
119        Backend::Ffmpeg => ffpipe::open_video(path),
120        Backend::Mf => mf::open_video(path),
121        Backend::Auto => mf::open_video(path).or_else(|e| ffpipe::open_video(path).map_err(|e2| format!("{e}; {e2}"))),
122    }
123}
124
125pub fn open_audio(path: &str, stream: usize, backend: Backend) -> Result<Box<dyn AudioSource>, String> {
126    match backend {
127        Backend::Ffmpeg => ffpipe::open_audio(path, stream),
128        Backend::Mf => mf::open_audio(path, stream),
129        Backend::Auto => mf::open_audio(path, stream)
130            .or_else(|e| ffpipe::open_audio(path, stream).map_err(|e2| format!("{e}; {e2}"))),
131    }
132}
133
134/// Lazily opened decoders, one per (path) / (path, audio stream). Failed opens are remembered
135/// (None) so a missing file doesn't re-spawn work every frame.
136pub struct DecoderPool {
137    backend: Backend,
138    videos: HashMap<String, (u64, Option<Box<dyn VideoSource>>)>,
139    audios: HashMap<(String, usize), (u64, Option<Box<dyn AudioSource>>)>,
140    /// Use counter for LRU eviction: a long timeline must not accumulate one live MF reader (or
141    /// ffmpeg.exe child) per distinct file forever.
142    tick: u64,
143    /// source path -> proxy file: preview decode opens the proxy instead of the original. Empty for
144    /// export / one-shot pools, which must always read the real footage. Video only.
145    proxies: std::collections::HashMap<String, String>,
146}
147
148/// Live decoders kept per pool (LRU past this). Failed opens (None) are cheap and never counted.
149const POOL_VIDEOS: usize = 16;
150const POOL_AUDIOS: usize = 32;
151
152impl DecoderPool {
153    pub fn new(backend: Backend) -> Self {
154        Self { backend, videos: HashMap::new(), audios: HashMap::new(), tick: 0, proxies: HashMap::new() }
155    }
156    /// Swap the proxy map (drops every open decoder: cached paths may now resolve differently).
157    pub fn set_proxies(&mut self, map: HashMap<String, String>) {
158        if map != self.proxies {
159            self.proxies = map;
160            self.clear();
161        }
162    }
163    pub fn set_backend(&mut self, b: Backend) {
164        if b != self.backend {
165            self.backend = b;
166            self.clear();
167        }
168    }
169    pub fn video(&mut self, path: &str) -> Option<&mut (dyn VideoSource + 'static)> {
170        // preview pools decode the proxy when one exists (export pools carry an empty map)
171        let path = self.proxies.get(path).cloned().unwrap_or_else(|| path.to_string());
172        let path = path.as_str();
173        let b = self.backend;
174        self.tick += 1;
175        let tick = self.tick;
176        let e = self.videos.entry(path.to_string()).or_insert_with(|| (tick, open_video(path, b).ok()));
177        e.0 = tick;
178        let hit = e.1.is_some();
179        if hit && self.videos.values().filter(|(_, v)| v.is_some()).count() > POOL_VIDEOS {
180            let evict = self
181                .videos
182                .iter()
183                .filter(|(p, (_, v))| v.is_some() && p.as_str() != path)
184                .min_by_key(|(_, (t, _))| *t)
185                .map(|(p, _)| p.clone());
186            if let Some(p) = evict {
187                self.videos.remove(&p);
188            }
189        }
190        self.videos.get_mut(path).and_then(|(_, v)| v.as_deref_mut())
191    }
192    pub fn audio(&mut self, path: &str, stream: usize) -> Option<&mut (dyn AudioSource + 'static)> {
193        let b = self.backend;
194        self.tick += 1;
195        let tick = self.tick;
196        let key = (path.to_string(), stream);
197        let e = self.audios.entry(key.clone()).or_insert_with(|| (tick, open_audio(path, stream, b).ok()));
198        e.0 = tick;
199        let hit = e.1.is_some();
200        if hit && self.audios.values().filter(|(_, v)| v.is_some()).count() > POOL_AUDIOS {
201            let evict = self
202                .audios
203                .iter()
204                .filter(|(k, (_, v))| v.is_some() && **k != key)
205                .min_by_key(|(_, (t, _))| *t)
206                .map(|(k, _)| k.clone());
207            if let Some(k) = evict {
208                self.audios.remove(&k);
209            }
210        }
211        self.audios.get_mut(&key).and_then(|(_, v)| v.as_deref_mut())
212    }
213    /// Inject a ready-made source (tests / synthetic media).
214    #[cfg(test)]
215    pub fn insert_video(&mut self, path: &str, v: Box<dyn VideoSource>) {
216        self.videos.insert(path.to_string(), (self.tick, Some(v)));
217    }
218    #[cfg(test)]
219    pub fn insert_audio(&mut self, path: &str, stream: usize, a: Box<dyn AudioSource>) {
220        self.audios.insert((path.to_string(), stream), (self.tick, Some(a)));
221    }
222    /// Drop every decoder (releases file handles — required before overwriting a source file). Also
223    /// forgets failed opens, so they are retried next time.
224    pub fn clear(&mut self) {
225        self.videos.clear();
226        self.audios.clear();
227    }
228}