simple_editor\media/
mf.rs

1//! Windows Media Foundation backend (IMFSourceReader). Native software decode, no external deps, instant seeks.
2//!
3//! Contract (see media/mod.rs):
4//!  * `probe(path)`      -> Asset with duration, size, fps, every audio stream (language/title if available).
5//!  * `open_video(path)` -> VideoSource producing top-down RGBA8 at the requested size (RGB32 output type +
6//!                          MF_SOURCE_READER_ENABLE_ADVANCED_VIDEO_PROCESSING; handle negative stride / bottom-up;
7//!                          scale to (w,h) — either via the reader's output type or a simple resize in Rust).
8//!  * `open_audio(path, stream)` -> AudioSource producing stereo f32 @ 48 kHz (PCM float output type; stream
9//!                          = Nth audio stream in container order; resample/upmix in Rust if MF refuses).
10//! Seeking: SetCurrentPosition then decode forward until pts >= t. Sequential reads must not seek.
11//! COM: MFStartup / CoInitializeEx per thread (decoders live on the thread that created them; they are `Send`
12//! in the sense that a thread creates and owns them — mark types `unsafe impl Send` if needed, see ARCHITECTURE.md).
13
14use super::{AudioSource, Frame, VideoSource, CHANNELS, SAMPLE_RATE};
15use crate::model::{Asset, AudioStreamInfo, ClipKind};
16use std::sync::OnceLock;
17use windows::core::{Interface, BOOL, GUID, HSTRING};
18use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
19use windows::Win32::Graphics::Direct3D11::{
20    D3D11CreateDevice, ID3D11Device, ID3D11Multithread, D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION,
21};
22use windows::Win32::Media::MediaFoundation::*;
23use windows::Win32::System::Com::StructuredStorage::PROPVARIANT;
24use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
25
26/// 100 ns ticks per second (MF time base).
27const HNS: f64 = 1e7;
28const GUID_NULL: GUID = GUID::zeroed();
29/// Video: read forward (no seek) when the target is less than this far ahead of the cached frame.
30const VIDEO_FWD_HNS: i64 = 15_000_000;
31/// Audio: read forward (no seek) when the target is less than this many output frames past the FIFO.
32const AUDIO_FWD_FRAMES: i64 = SAMPLE_RATE as i64 / 2;
33/// Audio: seek this many output frames early (imprecise MP3 seeks), discard by timestamp.
34const AUDIO_PREROLL_FRAMES: i64 = SAMPLE_RATE as i64 / 4;
35/// Safety cap on ReadSample calls per request (no busy loops on a misbehaving source).
36const MAX_READS: u32 = 4096;
37/// Consecutive identical size requests before the decoder's output type follows them
38/// (see `ensure_decode_size`).
39const STABLE_REQS: u32 = 3;
40
41fn err(e: windows::core::Error) -> String {
42    format!("MF: {e}")
43}
44
45fn hns(t: f64) -> i64 {
46    (t.max(0.0) * HNS).round() as i64
47}
48
49/// Process-wide MFStartup + per-thread COM init. Cheap; called on every open.
50fn init() -> Result<(), String> {
51    static MF: OnceLock<Result<(), String>> = OnceLock::new();
52    // ponytail: CoInitializeEx on every open, never CoUninitialize — S_FALSE / RPC_E_CHANGED_MODE are fine,
53    // the refcount just grows for the process lifetime.
54    unsafe {
55        let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
56    }
57    MF.get_or_init(|| unsafe { MFStartup(MF_VERSION, MFSTARTUP_NOSOCKET).map_err(err) }).clone()
58}
59
60/// `IMFDXGIDeviceManager` is documented as safe to share and call from multiple threads (that's its
61/// purpose: every decoder MFT on every thread opens a handle to the same device through it).
62struct SharedDeviceManager(IMFDXGIDeviceManager);
63unsafe impl Send for SharedDeviceManager {}
64unsafe impl Sync for SharedDeviceManager {}
65
66/// D3D11 device + DXGI device manager for hardware-accelerated decode (DXVA). Built once per process;
67/// every source reader attaches to the same manager. `None` when the adapter/driver can't do it — callers
68/// fall back to the source reader's normal software decode, so this is purely a speed opt-in.
69fn dxgi_device_manager() -> Option<IMFDXGIDeviceManager> {
70    static MANAGER: OnceLock<Option<SharedDeviceManager>> = OnceLock::new();
71    MANAGER
72        .get_or_init(|| unsafe {
73            let mut device: Option<ID3D11Device> = None;
74            D3D11CreateDevice(
75                None,
76                D3D_DRIVER_TYPE_HARDWARE,
77                Default::default(),
78                D3D11_CREATE_DEVICE_VIDEO_SUPPORT,
79                None,
80                D3D11_SDK_VERSION,
81                Some(&mut device),
82                None,
83                None,
84            )
85            .ok()?;
86            let device = device?;
87            // MF decoder MFTs call the device from their own thread; the app must opt the device into that.
88            let mt: ID3D11Multithread = device.cast().ok()?;
89            let _ = mt.SetMultithreadProtected(true);
90            let mut token = 0u32;
91            let mut manager: Option<IMFDXGIDeviceManager> = None;
92            MFCreateDXGIDeviceManager(&mut token, &mut manager).ok()?;
93            let manager = manager?;
94            manager.ResetDevice(&device, token).ok()?;
95            Some(SharedDeviceManager(manager))
96        })
97        .as_ref()
98        .map(|m| m.0.clone())
99}
100
101/// DXVA pays for itself only on big frames: a hardware decode is a GPU round-trip per frame
102/// (submit, decode, video-process, sync, copy back), which costs more than just software-decoding
103/// anything SD-sized. Attach the D3D manager at 720p and up.
104const DXVA_MIN_PIXELS: u64 = 1280 * 720;
105
106fn open_reader(path: &str, dxva: bool) -> Result<IMFSourceReader, String> {
107    if super::is_image_path(path) {
108        return Err("MF: images are decoded by ffmpeg".into());
109    }
110    // ponytail: MF's MPEG-2 source seeks to the *next* keyframe (EOF past the last one, verified) and reports
111    // a short duration; ffmpeg seeks TS/PS exactly, so let Auto fall through to it.
112    if matches!(super::ext(path).as_str(), "ts" | "m2ts" | "mts" | "m2t" | "mpg" | "mpeg" | "vob") {
113        return Err("MF: MPEG-TS/PS seeks are keyframe-coarse, use ffmpeg".into());
114    }
115    init()?;
116    unsafe {
117        let mut attrs = None;
118        MFCreateAttributes(&mut attrs, 2).map_err(err)?;
119        let attrs = attrs.ok_or("MF: MFCreateAttributes returned null")?;
120        attrs.SetUINT32(&MF_SOURCE_READER_ENABLE_ADVANCED_VIDEO_PROCESSING, 1).map_err(err)?;
121        // The Matroska source reports half the real frame rate; without this the video processor
122        // frame-rate-converts to it and drops every second MKV/WebM frame.
123        attrs.SetUINT32(&MF_XVP_DISABLE_FRC, 1).map_err(err)?;
124        // Hardware decode (DXVA) when the adapter supports it; the reader copies decoded frames back to
125        // system memory for our RGB32 output type either way, so nothing downstream changes.
126        if dxva {
127            if let Some(manager) = dxgi_device_manager() {
128                let _ = attrs.SetUnknown(&MF_SOURCE_READER_D3D_MANAGER, &manager);
129            }
130        }
131        let url = HSTRING::from(path);
132        let reader = MFCreateSourceReaderFromURL(&url, &attrs).map_err(err)?;
133        reader.SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS.0 as u32, false).map_err(err)?;
134        Ok(reader)
135    }
136}
137
138struct StreamInfo {
139    /// Source reader stream index.
140    index: u32,
141    major: GUID,
142    ty: IMFMediaType,
143    language: String,
144    title: String,
145}
146
147/// Every stream in container order (ffmpeg's 0:v:N / 0:a:N). Language/title are best effort via the
148/// presentation descriptor (descriptor index == reader stream index).
149fn streams(reader: &IMFSourceReader) -> Vec<StreamInfo> {
150    let mut v = Vec::new();
151    for i in 0..64u32 {
152        let Ok(ty) = (unsafe { reader.GetNativeMediaType(i, 0) }) else {
153            break;
154        };
155        let major = unsafe { ty.GetGUID(&MF_MT_MAJOR_TYPE) }.unwrap_or(GUID_NULL);
156        v.push(StreamInfo { index: i, major, ty, language: String::new(), title: String::new() });
157    }
158    unsafe {
159        let mut p = std::ptr::null_mut();
160        let r =
161            reader.GetServiceForStream(MF_SOURCE_READER_MEDIASOURCE.0 as u32, &GUID_NULL, &IMFMediaSource::IID, &mut p);
162        if r.is_ok() && !p.is_null() {
163            let src = IMFMediaSource::from_raw(p);
164            if let Ok(pd) = src.CreatePresentationDescriptor() {
165                for s in v.iter_mut() {
166                    let mut sel = BOOL(0);
167                    let mut sd = None;
168                    let _ = pd.GetStreamDescriptorByIndex(s.index, &mut sel, &mut sd);
169                    if let Some(sd) = sd {
170                        s.language = get_string(&sd, &MF_SD_LANGUAGE);
171                        s.title = get_string(&sd, &MF_SD_STREAM_NAME);
172                    }
173                }
174            }
175        }
176    }
177    // ponytail: MF's MPEG-4 source enumerates tracks in reverse `trak` order (verified); MKV/ASF/TS/AVI
178    // sources are in container order. Reverse to match ffmpeg.
179    if is_mp4(reader) {
180        v.reverse();
181    }
182    v
183}
184
185/// MF's MPEG-4 source (mp4/mov/m4a: MIME */mp4, video/quicktime).
186fn is_mp4(reader: &IMFSourceReader) -> bool {
187    let mime = unsafe { reader.GetPresentationAttribute(MF_SOURCE_READER_MEDIASOURCE.0 as u32, &MF_PD_MIME_TYPE) }
188        .map(|pv| pv.to_string())
189        .unwrap_or_default();
190    mime.ends_with("/mp4") || mime == "video/quicktime"
191}
192
193fn duration_secs(reader: &IMFSourceReader) -> f64 {
194    unsafe { reader.GetPresentationAttribute(MF_SOURCE_READER_MEDIASOURCE.0 as u32, &MF_PD_DURATION) }
195        .ok()
196        .and_then(|pv| u64::try_from(&pv).ok())
197        .map(|d| d as f64 / HNS)
198        .unwrap_or(0.0)
199}
200
201fn get_string(a: &IMFAttributes, key: &GUID) -> String {
202    unsafe {
203        let Ok(len) = a.GetStringLength(key) else {
204            return String::new();
205        };
206        let mut buf = vec![0u16; len as usize + 1];
207        if a.GetString(key, &mut buf, None).is_err() {
208            return String::new();
209        }
210        String::from_utf16_lossy(&buf[..len as usize])
211    }
212}
213
214/// MF subtype -> ffmpeg-style codec name (cheap; "" when unknown).
215fn codec_name(sub: GUID) -> &'static str {
216    const CODECS: &[(GUID, &str)] = &[
217        (MFVideoFormat_H264, "h264"),
218        (MFVideoFormat_HEVC, "hevc"),
219        (MFVideoFormat_H265, "hevc"),
220        (MFVideoFormat_VP90, "vp9"),
221        (MFVideoFormat_VP80, "vp8"),
222        (MFVideoFormat_AV1, "av1"),
223        (MFVideoFormat_MPEG2, "mpeg2video"),
224        (MFVideoFormat_MP4V, "mpeg4"),
225        (MFVideoFormat_WMV3, "wmv3"),
226        (MFVideoFormat_MJPG, "mjpeg"),
227        (MFAudioFormat_AAC, "aac"),
228        (MFAudioFormat_MP3, "mp3"),
229        (MFAudioFormat_Opus, "opus"),
230        (MFAudioFormat_FLAC, "flac"),
231        (MFAudioFormat_Vorbis, "vorbis"),
232        (MFAudioFormat_ALAC, "alac"),
233        (MFAudioFormat_Dolby_AC3, "ac3"),
234        (MFAudioFormat_Dolby_DDPlus, "eac3"),
235        (MFAudioFormat_WMAudioV8, "wmav2"),
236        (MFAudioFormat_PCM, "pcm"),
237        (MFAudioFormat_Float, "pcm"),
238    ];
239    CODECS.iter().find(|(g, _)| *g == sub).map_or("", |(_, n)| n)
240}
241
242pub fn probe(path: &str) -> Result<Asset, String> {
243    let reader = open_reader(path, false)?; // probe never decodes a frame
244    let mut asset = Asset {
245        id: 0,
246        path: path.to_string(),
247        kind: ClipKind::Audio,
248        duration: duration_secs(&reader),
249        width: 0,
250        height: 0,
251        fps: 0.0,
252        audio_streams: Vec::new(),
253        codec: String::new(),
254        folder: String::new(),
255        tags: Vec::new(),
256        label: 0,
257        description: String::new(),
258    };
259    let mut has_video = false;
260    for s in streams(&reader) {
261        let sub = unsafe { s.ty.GetGUID(&MF_MT_SUBTYPE) }.unwrap_or(GUID_NULL);
262        if s.major == MFMediaType_Video && !has_video {
263            has_video = true;
264            let size = unsafe { s.ty.GetUINT64(&MF_MT_FRAME_SIZE) }.unwrap_or(0);
265            asset.width = (size >> 32) as u32;
266            asset.height = size as u32;
267            // The reader auto-rotates (ADVANCED_VIDEO_PROCESSING), so report display dims like ffmpeg does.
268            if unsafe { s.ty.GetUINT32(&MF_MT_VIDEO_ROTATION) }.unwrap_or(0) % 180 == 90 {
269                std::mem::swap(&mut asset.width, &mut asset.height);
270            }
271            asset.fps = frame_rate(&s.ty);
272            asset.codec = codec_name(sub).into();
273        } else if s.major == MFMediaType_Audio {
274            asset.audio_streams.push(AudioStreamInfo {
275                index: asset.audio_streams.len(),
276                channels: unsafe { s.ty.GetUINT32(&MF_MT_AUDIO_NUM_CHANNELS) }.unwrap_or(0),
277                sample_rate: unsafe { s.ty.GetUINT32(&MF_MT_AUDIO_SAMPLES_PER_SECOND) }.unwrap_or(0),
278                language: s.language,
279                title: s.title,
280                codec: codec_name(sub).into(),
281            });
282        }
283    }
284    if has_video {
285        asset.kind = ClipKind::Video;
286    } else if let Some(a) = asset.audio_streams.first() {
287        asset.codec = a.codec.clone();
288    } else {
289        return Err("MF: no audio or video streams".into());
290    }
291    Ok(asset)
292}
293
294fn frame_rate(ty: &IMFMediaType) -> f64 {
295    let r = unsafe { ty.GetUINT64(&MF_MT_FRAME_RATE) }.unwrap_or(0);
296    let (num, den) = ((r >> 32) as u32, r as u32);
297    if num > 0 && den > 0 {
298        num as f64 / den as f64
299    } else {
300        0.0
301    }
302}
303
304// ---------------------------------------------------------------- video
305
306struct MfVideo {
307    reader: IMFSourceReader,
308    stream: u32,
309    /// True source dimensions (from the first negotiated type, before any decode-size scaling).
310    /// Reported by `size()` — placement/export/asset math must see native size regardless of what
311    /// the decoder is currently asked to output.
312    native_w: u32,
313    native_h: u32,
314    /// False once a scaled `MF_MT_FRAME_SIZE` negotiation has failed once: some decoders reject an
315    /// arbitrary output size, so we stop retrying and decode at native size for the rest of the session.
316    scale_ok: bool,
317    /// Last even-aligned request size and how many consecutive calls asked for it (see
318    /// `ensure_decode_size`: only a settled request is worth a ~100 ms renegotiation).
319    req_last: (u32, u32),
320    req_streak: u32,
321    /// True after the first `SetCurrentMediaType` size negotiation (successful or not).
322    negotiated: bool,
323    width: u32,
324    height: u32,
325    /// MF_MT_DEFAULT_STRIDE of the current type (0 = unknown; negative = bottom-up).
326    stride: i32,
327    /// Fallback frame duration when a sample has none.
328    frame_hns: i64,
329    /// Native-size top-down RGBA of the cached frame.
330    native: Vec<u8>,
331    have: bool,
332    /// Cached frame covers [pts, end) (pts may be pulled back to the requested t when the reader
333    /// returned a later frame); `rpts` is the real sample time.
334    pts: i64,
335    end: i64,
336    rpts: i64,
337    /// End of the last sample seen from the reader since the last seek.
338    last_end: Option<i64>,
339    /// Where the stream ends (known once EOF was hit).
340    eof_at: Option<i64>,
341    /// MF time of the first frame = content t=0. MF keeps container time (mp4 initial empty edit, MKV
342    /// offset), ffmpeg/ffprobe are content-relative; requests are shifted into MF time.
343    origin: i64,
344    /// MPEG-4 source: SetCurrentPosition takes content-relative time while samples carry container time.
345    mp4: bool,
346    // Scaled copy of the cached frame (valid when svalid && size matches).
347    scaled: Vec<u8>,
348    svalid: bool,
349    sw: u32,
350    sh: u32,
351    // Box-filter tables: source x/y edges for the (native, scaled) size pair in `tab_key`.
352    xs: Vec<u32>,
353    ys: Vec<u32>,
354    acc: Vec<u32>,
355    tab_key: (u32, u32, u32, u32),
356}
357
358// SAFETY: the source reader is created on and then owned by exactly one thread at a time (DecoderPool
359// per render/audio/waveform thread); MF reader objects are free-threaded and are never used concurrently.
360unsafe impl Send for MfVideo {}
361
362pub fn open_video(path: &str) -> Result<Box<dyn VideoSource>, String> {
363    let mut reader = open_reader(path, false)?;
364    // Big sources reopen with the D3D manager attached (see DXVA_MIN_PIXELS); a second open is a
365    // container parse, pennies against the per-frame decode it buys.
366    {
367        let vs = streams(&reader).into_iter().find(|s| s.major == MFMediaType_Video).ok_or("MF: no video stream")?;
368        let size = unsafe { vs.ty.GetUINT64(&MF_MT_FRAME_SIZE) }.unwrap_or(0);
369        if ((size >> 32) & 0xffff_ffff) * (size & 0xffff_ffff) >= DXVA_MIN_PIXELS {
370            if let Ok(r) = open_reader(path, true) {
371                reader = r;
372            }
373        }
374    }
375    let vs = streams(&reader).into_iter().find(|s| s.major == MFMediaType_Video).ok_or("MF: no video stream")?;
376    let (stream, nat) = (vs.index, vs.ty);
377    let mp4 = is_mp4(&reader);
378    unsafe {
379        reader.SetStreamSelection(stream, true).map_err(err)?;
380        let mt = MFCreateMediaType().map_err(err)?;
381        mt.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video).map_err(err)?;
382        mt.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_RGB32).map_err(err)?;
383        reader.SetCurrentMediaType(stream, None, &mt).map_err(err)?;
384    }
385    let fps = frame_rate(&nat);
386    let mut v = MfVideo {
387        reader,
388        stream,
389        native_w: 0,
390        native_h: 0,
391        scale_ok: true,
392        req_last: (0, 0),
393        req_streak: 0,
394        negotiated: false,
395        width: 0,
396        height: 0,
397        stride: 0,
398        frame_hns: if fps > 0.0 { (HNS / fps) as i64 } else { 333_333 },
399        native: Vec::new(),
400        have: false,
401        pts: 0,
402        end: 0,
403        rpts: 0,
404        last_end: None,
405        eof_at: None,
406        origin: 0,
407        mp4,
408        scaled: Vec::new(),
409        svalid: false,
410        sw: 0,
411        sh: 0,
412        xs: Vec::new(),
413        ys: Vec::new(),
414        acc: Vec::new(),
415        tab_key: (0, 0, 0, 0),
416    };
417    if !v.refresh_type() {
418        return Err("MF: cannot read video output type".into());
419    }
420    (v.native_w, v.native_h) = (v.width, v.height);
421    // Decode the first frame now: surfaces "codec not decodable" at open (so Auto falls back to ffmpeg),
422    // fixes the time origin and warms the cache for the usual first request at t=0.
423    if !v.read_until(0) {
424        return Err("MF: cannot decode first video frame".into());
425    }
426    v.origin = v.rpts.max(0);
427    v.pts = v.rpts;
428    Ok(Box::new(v))
429}
430
431impl MfVideo {
432    /// Re-read size/stride from the current output type (after open or CURRENTMEDIATYPECHANGED).
433    fn refresh_type(&mut self) -> bool {
434        let Ok(cur) = (unsafe { self.reader.GetCurrentMediaType(self.stream) }) else {
435            return false;
436        };
437        let size = unsafe { cur.GetUINT64(&MF_MT_FRAME_SIZE) }.unwrap_or(0);
438        let (w, h) = ((size >> 32) as u32, size as u32);
439        if w == 0 || h == 0 || w > 16384 || h > 16384 {
440            return false;
441        }
442        self.width = w;
443        self.height = h;
444        self.stride = unsafe { cur.GetUINT32(&MF_MT_DEFAULT_STRIDE) }.map(|s| s as i32).unwrap_or(0);
445        self.native.resize((w * h * 4) as usize, 0);
446        self.have = false;
447        self.svalid = false;
448        true
449    }
450
451    /// Ask MF to decode+scale straight to the requested size instead of always paying for a full
452    /// native-resolution decode + copy + Rust rescale. `MF_SOURCE_READER_ENABLE_ADVANCED_VIDEO_
453    /// PROCESSING` (set at open) lets the reader's video processor MFT do this — hardware-accelerated
454    /// when DXVA is active — so a 4K source previewed at 1280 wide never copies a 4K (or even half-4K)
455    /// frame to system memory at all: the sample that crosses the bus already is preview-sized.
456    ///
457    /// A renegotiation (`SetCurrentMediaType` mid-stream) costs ~100 ms, so only a *settled* request is
458    /// followed: the same size on `STABLE_REQS` consecutive calls. A clip animating its on-screen scale
459    /// asks for a different size every call, never settles, and keeps decoding at the last settled size
460    /// with the CPU scaler covering the difference. The very first request after open negotiates at
461    /// once — that is the everyday "open a 4K file, preview it small" case, and warm-up frames at
462    /// native 4K would cost more than the negotiation.
463    fn ensure_decode_size(&mut self, req_w: u32, req_h: u32) {
464        if !self.scale_ok || self.native_w < 4 || self.native_h < 4 {
465            return;
466        }
467        // even-aligned (4:2:0 sources dislike odd output) and capped at native — never upscale in MF
468        let t = (((req_w + 1) & !1).clamp(2, self.native_w & !1), ((req_h + 1) & !1).clamp(2, self.native_h & !1));
469        if t == (self.width, self.height) {
470            self.req_last = t;
471            self.req_streak = STABLE_REQS; // already decoding at the requested size
472            return;
473        }
474        if t == self.req_last {
475            self.req_streak = self.req_streak.saturating_add(1);
476        } else {
477            self.req_last = t;
478            self.req_streak = 1;
479        }
480        if self.req_streak < STABLE_REQS && self.negotiated {
481            return; // not settled yet: serve the current size, `rescale` bridges the gap
482        }
483        self.negotiated = true;
484        let negotiated = unsafe {
485            (|| -> Result<(), String> {
486                let mt = MFCreateMediaType().map_err(err)?;
487                mt.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video).map_err(err)?;
488                mt.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_RGB32).map_err(err)?;
489                mt.SetUINT64(&MF_MT_FRAME_SIZE, ((t.0 as u64) << 32) | t.1 as u64).map_err(err)?;
490                self.reader.SetCurrentMediaType(self.stream, None, &mt).map_err(err)
491            })()
492        };
493        // Position is untouched by an output-type change (only the sample format changes), so no reseek:
494        // the next read_until just picks up the new size on the following sample.
495        if negotiated.is_err() || !self.refresh_type() {
496            self.scale_ok = false; // this source refuses arbitrary output sizes: stop asking, decode native
497        }
498    }
499
500    fn seek(&mut self, tt: i64) -> bool {
501        let pv = PROPVARIANT::from(if self.mp4 { tt - self.origin } else { tt });
502        let ok = unsafe { self.reader.SetCurrentPosition(&GUID_NULL, &pv) }.is_ok();
503        self.have = false;
504        self.last_end = None;
505        ok
506    }
507
508    /// Read forward until a sample whose end is past `tt`; cache it. False at EOF / error.
509    fn read_until(&mut self, tt: i64) -> bool {
510        for _ in 0..MAX_READS {
511            let mut flags = 0u32;
512            let mut ts = 0i64;
513            let mut sample = None;
514            let r = unsafe {
515                self.reader.ReadSample(self.stream, 0, None, Some(&mut flags), Some(&mut ts), Some(&mut sample))
516            };
517            if r.is_err() || flags & MF_SOURCE_READERF_ERROR.0 as u32 != 0 {
518                return false;
519            }
520            if flags & MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED.0 as u32 != 0 && !self.refresh_type() {
521                return false;
522            }
523            if flags & MF_SOURCE_READERF_ENDOFSTREAM.0 as u32 != 0 {
524                let e = self.last_end.unwrap_or(tt);
525                self.eof_at = Some(self.eof_at.map_or(e, |x| x.min(e)));
526                return false;
527            }
528            let Some(sample) = sample else { continue }; // stream tick / gap
529            let dur = unsafe { sample.GetSampleDuration() }.unwrap_or(0);
530            let end = ts + if dur > 0 { dur } else { self.frame_hns };
531            self.last_end = Some(end);
532            if end <= tt {
533                continue;
534            }
535            if !self.copy_sample(&sample) {
536                return false;
537            }
538            self.have = true;
539            self.rpts = ts;
540            self.pts = ts.min(tt);
541            self.end = end;
542            self.svalid = false;
543            return true;
544        }
545        false
546    }
547
548    /// BGRX sample -> top-down RGBA `native`.
549    fn copy_sample(&mut self, sample: &IMFSample) -> bool {
550        let (w, h) = (self.width as usize, self.height as usize);
551        unsafe {
552            let Ok(buf) = sample.ConvertToContiguousBuffer() else {
553                return false;
554            };
555            if let Ok(b2) = buf.cast::<IMF2DBuffer>() {
556                let mut p: *mut u8 = std::ptr::null_mut();
557                let mut pitch = 0i32;
558                if b2.Lock2D(&mut p, &mut pitch).is_err() || p.is_null() {
559                    return false;
560                }
561                // Lock2D: p = top row, pitch signed.
562                convert_rows(p, pitch as isize, w, h, &mut self.native);
563                let _ = b2.Unlock2D();
564            } else {
565                let mut p: *mut u8 = std::ptr::null_mut();
566                let mut len = 0u32;
567                if buf.Lock(&mut p, None, Some(&mut len)).is_err() || p.is_null() {
568                    return false;
569                }
570                let stride = if self.stride != 0 { self.stride as isize } else { (w * 4) as isize };
571                if (len as usize) < h * stride.unsigned_abs() {
572                    let _ = buf.Unlock();
573                    return false;
574                }
575                // Negative stride: memory starts with the bottom row, so the top row is the last one.
576                let top = if stride < 0 { p.offset((h as isize - 1) * -stride) } else { p };
577                convert_rows(top, stride, w, h, &mut self.native);
578                let _ = buf.Unlock();
579            }
580        }
581        true
582    }
583
584    /// Write the cached frame at (w,h) into `out`.
585    fn emit(&mut self, w: u32, h: u32, out: &mut Frame) {
586        out.resize(w, h);
587        out.pts = (self.rpts - self.origin) as f64 / HNS;
588        if w == self.width && h == self.height {
589            out.rgba.copy_from_slice(&self.native);
590            return;
591        }
592        if !(self.svalid && self.sw == w && self.sh == h) {
593            self.rescale(w, h);
594        }
595        out.rgba.copy_from_slice(&self.scaled);
596    }
597
598    fn rescale(&mut self, w: u32, h: u32) {
599        self.scaled.resize((w * h * 4) as usize, 0);
600        if w <= self.width && h <= self.height {
601            let key = (self.width, self.height, w, h);
602            if self.tab_key != key {
603                self.tab_key = key;
604                self.xs = (0..=w).map(|i| (i as u64 * self.width as u64 / w as u64) as u32).collect();
605                self.ys = (0..=h).map(|j| (j as u64 * self.height as u64 / h as u64) as u32).collect();
606                self.acc.resize(w as usize * 3, 0);
607            }
608            box_down(&self.native, self.width, &self.xs, &self.ys, &mut self.acc, &mut self.scaled);
609        } else {
610            // ponytail: bilinear per pixel — the compositor never asks for more than native size.
611            bilinear(&self.native, self.width, self.height, w, h, &mut self.scaled);
612        }
613        self.svalid = true;
614        self.sw = w;
615        self.sh = h;
616    }
617}
618
619/// Copy `h` rows of `w` BGRX pixels starting at `top` (row stride `pitch`, signed) into top-down RGBA.
620unsafe fn convert_rows(top: *const u8, pitch: isize, w: usize, h: usize, dst: &mut [u8]) {
621    for (y, drow) in dst.chunks_exact_mut(w * 4).take(h).enumerate() {
622        let src = std::slice::from_raw_parts(top.offset(y as isize * pitch), w * 4);
623        for (s, d) in src.chunks_exact(4).zip(drow.chunks_exact_mut(4)) {
624            d[0] = s[2];
625            d[1] = s[1];
626            d[2] = s[0];
627            d[3] = 255;
628        }
629    }
630}
631
632/// Area-average downscale. `xs`/`ys` are the source edges per destination column/row (len = n+1).
633fn box_down(src: &[u8], sw: u32, xs: &[u32], ys: &[u32], acc: &mut [u32], dst: &mut [u8]) {
634    let (sw, w, h) = (sw as usize, xs.len() - 1, ys.len() - 1);
635    for (j, drow) in dst.chunks_exact_mut(w * 4).take(h).enumerate() {
636        acc.fill(0);
637        let (y0, y1) = (ys[j] as usize, ys[j + 1] as usize);
638        for row in src[y0 * sw * 4..y1 * sw * 4].chunks_exact(sw * 4) {
639            for (i, a) in acc.chunks_exact_mut(3).enumerate() {
640                let (x0, x1) = (xs[i] as usize, xs[i + 1] as usize);
641                for px in row[x0 * 4..x1 * 4].chunks_exact(4) {
642                    a[0] += px[0] as u32;
643                    a[1] += px[1] as u32;
644                    a[2] += px[2] as u32;
645                }
646            }
647        }
648        let rows = (y1 - y0) as u32;
649        for (i, (d, a)) in drow.chunks_exact_mut(4).zip(acc.chunks_exact(3)).enumerate() {
650            let n = (rows * (xs[i + 1] - xs[i])).max(1);
651            d[0] = (a[0] / n) as u8;
652            d[1] = (a[1] / n) as u8;
653            d[2] = (a[2] / n) as u8;
654            d[3] = 255;
655        }
656    }
657}
658
659fn bilinear(src: &[u8], sw: u32, sh: u32, w: u32, h: u32, dst: &mut [u8]) {
660    let (sw, sh) = (sw as usize, sh as usize);
661    let px = |x: usize, y: usize| &src[(y * sw + x) * 4..(y * sw + x) * 4 + 4];
662    for (j, drow) in dst.chunks_exact_mut(w as usize * 4).enumerate() {
663        let fy = ((j as f32 + 0.5) * sh as f32 / h as f32 - 0.5).clamp(0.0, (sh - 1) as f32);
664        let (y0, wy) = (fy as usize, fy.fract());
665        let y1 = (y0 + 1).min(sh - 1);
666        for (i, d) in drow.chunks_exact_mut(4).enumerate() {
667            let fx = ((i as f32 + 0.5) * sw as f32 / w as f32 - 0.5).clamp(0.0, (sw - 1) as f32);
668            let (x0, wx) = (fx as usize, fx.fract());
669            let x1 = (x0 + 1).min(sw - 1);
670            let (a, b, c, e) = (px(x0, y0), px(x1, y0), px(x0, y1), px(x1, y1));
671            for k in 0..3 {
672                let top = a[k] as f32 + (b[k] as f32 - a[k] as f32) * wx;
673                let bot = c[k] as f32 + (e[k] as f32 - c[k] as f32) * wx;
674                d[k] = (top + (bot - top) * wy + 0.5) as u8;
675            }
676            d[3] = 255;
677        }
678    }
679}
680
681impl VideoSource for MfVideo {
682    fn size(&self) -> (u32, u32) {
683        (self.native_w, self.native_h)
684    }
685    fn frame_at(&mut self, t: f64, w: u32, h: u32, out: &mut Frame) -> bool {
686        if w == 0 || h == 0 {
687            return false;
688        }
689        self.ensure_decode_size(w, h);
690        // +5 ms display-time tolerance: MKV pts are ms-rounded (frame n's pts can land just after n/fps),
691        // raw mp4 pts jitter ±1 hns — without it every other frame repeats when stepping at n/fps.
692        let tt = hns(t) + 50_000 + self.origin;
693        if self.eof_at.is_some_and(|e| tt >= e) {
694            return false;
695        }
696        if !(self.have && tt >= self.pts && tt < self.end) {
697            let forward = self.have && tt >= self.end && tt < self.pts + VIDEO_FWD_HNS;
698            if (!forward && !self.seek(tt)) || !self.read_until(tt) {
699                return false;
700            }
701        }
702        self.emit(w, h, out);
703        true
704    }
705}
706
707// ---------------------------------------------------------------- audio
708
709/// Linear resampler state carried across decoded buffers.
710#[derive(Default)]
711struct Resamp {
712    /// Position in "virtual source" frames where index 0 is `last` and 1.. are the buffer's frames.
713    pos: f64,
714    last: [f32; 2],
715    primed: bool,
716}
717
718/// Append `src` (interleaved `ch` channels at `rate`) to `fifo` as interleaved stereo @ SAMPLE_RATE.
719/// Mono is duplicated, >2 channels keep L/R. Non-48k input is linearly resampled.
720fn push_audio(src: &[f32], ch: usize, rate: u32, rs: &mut Resamp, fifo: &mut Vec<f32>) {
721    if ch == 0 || src.len() < ch {
722        return;
723    }
724    let n = src.len() / ch;
725    let rc = 1.min(ch - 1);
726    let frame = |k: usize| [src[k * ch], src[k * ch + rc]];
727    if rate == SAMPLE_RATE {
728        fifo.reserve(n * 2);
729        for k in 0..n {
730            fifo.extend_from_slice(&frame(k));
731        }
732        return;
733    }
734    if !rs.primed {
735        *rs = Resamp { pos: 1.0, last: frame(0), primed: true };
736    }
737    // ponytail: linear interpolation — a windowed-sinc resampler if aliasing ever matters.
738    let step = rate as f64 / SAMPLE_RATE as f64;
739    let v = |k: usize| if k == 0 { rs.last } else { frame(k - 1) };
740    let mut pos = rs.pos;
741    while pos < n as f64 {
742        let k = pos as usize;
743        let f = (pos - k as f64) as f32;
744        let (a, b) = (v(k), v(k + 1));
745        fifo.push(a[0] + (b[0] - a[0]) * f);
746        fifo.push(a[1] + (b[1] - a[1]) * f);
747        pos += step;
748    }
749    rs.pos = pos - n as f64;
750    rs.last = frame(n - 1);
751}
752
753struct MfAudio {
754    reader: IMFSourceReader,
755    stream: u32,
756    duration: f64,
757    /// Format the reader delivers (48000/2 when MF converts for us).
758    rate: u32,
759    ch: u32,
760    /// Decoded stereo @ SAMPLE_RATE; `fifo_pos` = output frame index of fifo[0] (valid when `located`).
761    fifo: Vec<f32>,
762    fifo_pos: i64,
763    located: bool,
764    /// Output frame index where the data ends (known once EOF was hit).
765    eof_at: Option<i64>,
766    /// Output frame index of the first sample = content t=0 (see MfVideo::origin).
767    origin: i64,
768    mp4: bool,
769    rs: Resamp,
770    tmp: Vec<f32>,
771}
772
773// SAFETY: see MfVideo — owned and used by one thread at a time.
774unsafe impl Send for MfAudio {}
775
776pub fn open_audio(path: &str, stream: usize) -> Result<Box<dyn AudioSource>, String> {
777    let reader = open_reader(path, false)?; // audio decode has nothing for DXVA to do
778    let s = streams(&reader)
779        .into_iter()
780        .filter(|s| s.major == MFMediaType_Audio)
781        .nth(stream)
782        .ok_or_else(|| format!("MF: no audio stream {stream}"))?;
783    // ponytail: MF's FLAC decoder mis-timestamps (drifts 60-120 ms after seeks in .flac, a block late in
784    // MKV/OGG, +96 ms in MP4 'fLaC'); ffmpeg seeks FLAC exactly, so let Auto fall through to it.
785    const FLAC_MP4: GUID = GUID { data1: 0x664C_6143, ..MFMPEG4Format_Base }; // 'fLaC'
786    let sub = unsafe { s.ty.GetGUID(&MF_MT_SUBTYPE) }.unwrap_or(GUID_NULL);
787    if sub == MFAudioFormat_FLAC || sub == FLAC_MP4 {
788        return Err("MF: FLAC timestamps unreliable, use ffmpeg".into());
789    }
790    let idx = s.index;
791    let mp4 = is_mp4(&reader);
792    let duration = duration_secs(&reader);
793    let (rate, ch) = unsafe {
794        reader.SetStreamSelection(idx, true).map_err(err)?;
795        let full = MFCreateMediaType().map_err(err)?;
796        full.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Audio).map_err(err)?;
797        full.SetGUID(&MF_MT_SUBTYPE, &MFAudioFormat_Float).map_err(err)?;
798        full.SetUINT32(&MF_MT_AUDIO_BITS_PER_SAMPLE, 32).map_err(err)?;
799        full.SetUINT32(&MF_MT_AUDIO_NUM_CHANNELS, CHANNELS as u32).map_err(err)?;
800        full.SetUINT32(&MF_MT_AUDIO_SAMPLES_PER_SECOND, SAMPLE_RATE).map_err(err)?;
801        full.SetUINT32(&MF_MT_AUDIO_BLOCK_ALIGNMENT, 4 * CHANNELS as u32).map_err(err)?;
802        full.SetUINT32(&MF_MT_AUDIO_AVG_BYTES_PER_SECOND, 4 * CHANNELS as u32 * SAMPLE_RATE).map_err(err)?;
803        if reader.SetCurrentMediaType(idx, None, &full).is_err() {
804            // MF won't convert rate/channels: take native rate/channels as float, convert in Rust.
805            let mt = MFCreateMediaType().map_err(err)?;
806            mt.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Audio).map_err(err)?;
807            mt.SetGUID(&MF_MT_SUBTYPE, &MFAudioFormat_Float).map_err(err)?;
808            reader.SetCurrentMediaType(idx, None, &mt).map_err(err)?;
809        }
810        audio_format(&reader, idx)?
811    };
812    let mut a = MfAudio {
813        reader,
814        stream: idx,
815        duration,
816        rate,
817        ch,
818        fifo: Vec::new(),
819        fifo_pos: 0,
820        located: false,
821        eof_at: None,
822        origin: 0,
823        mp4,
824        rs: Resamp::default(),
825        tmp: Vec::new(),
826    };
827    // Decode the first sample now: surfaces "not decodable" at open (so Auto falls back to ffmpeg),
828    // fixes the time origin and warms the FIFO for the usual first read at t=0.
829    let mut reads = 0;
830    while !a.located {
831        reads += 1;
832        if reads > MAX_READS || !a.decode_more(0) {
833            return Err("MF: cannot decode first audio sample".into());
834        }
835    }
836    a.origin = a.fifo_pos;
837    Ok(Box::new(a))
838}
839
840/// (sample rate, channels) of the reader's current output type.
841fn audio_format(reader: &IMFSourceReader, stream: u32) -> Result<(u32, u32), String> {
842    let cur = unsafe { reader.GetCurrentMediaType(stream) }.map_err(err)?;
843    let rate = unsafe { cur.GetUINT32(&MF_MT_AUDIO_SAMPLES_PER_SECOND) }.map_err(err)?;
844    let ch = unsafe { cur.GetUINT32(&MF_MT_AUDIO_NUM_CHANNELS) }.map_err(err)?;
845    if rate == 0 || ch == 0 || ch > 64 {
846        return Err(format!("MF: bad audio format {rate} Hz / {ch} ch"));
847    }
848    Ok((rate, ch))
849}
850
851impl MfAudio {
852    fn fifo_end(&self) -> i64 {
853        self.fifo_pos + (self.fifo.len() / CHANNELS) as i64
854    }
855
856    fn seek(&mut self, f0: i64) -> bool {
857        // ponytail: MF's MP3 source lands ~60 ms *after* the requested position; pre-roll and let the
858        // timestamp-based locate discard the excess (audio decode is cheap).
859        let f = f0 - if self.mp4 { self.origin } else { 0 } - AUDIO_PREROLL_FRAMES;
860        let pv = PROPVARIANT::from(f.max(0) * HNS as i64 / SAMPLE_RATE as i64);
861        let ok = unsafe { self.reader.SetCurrentPosition(&GUID_NULL, &pv) }.is_ok();
862        self.fifo.clear();
863        self.located = false;
864        self.rs.primed = false;
865        ok
866    }
867
868    fn mark_eof(&mut self, f0: i64) {
869        let e = if self.located { self.fifo_end() } else { f0 };
870        self.eof_at = Some(self.eof_at.map_or(e, |x| x.min(e)));
871    }
872
873    /// Decode one more sample into the FIFO. False at EOF / error.
874    fn decode_more(&mut self, f0: i64) -> bool {
875        let mut flags = 0u32;
876        let mut ts = 0i64;
877        let mut sample = None;
878        let r =
879            unsafe { self.reader.ReadSample(self.stream, 0, None, Some(&mut flags), Some(&mut ts), Some(&mut sample)) };
880        if r.is_err() || flags & (MF_SOURCE_READERF_ERROR.0 | MF_SOURCE_READERF_ENDOFSTREAM.0) as u32 != 0 {
881            // ponytail: read errors are treated as end of stream (no retry storm at audio rate).
882            self.mark_eof(f0);
883            return false;
884        }
885        if flags & MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED.0 as u32 != 0 {
886            match audio_format(&self.reader, self.stream) {
887                Ok((rate, ch)) => {
888                    self.rate = rate;
889                    self.ch = ch;
890                    self.rs.primed = false;
891                }
892                Err(_) => {
893                    self.mark_eof(f0);
894                    return false;
895                }
896            }
897        }
898        let Some(sample) = sample else { return true }; // stream tick
899        unsafe {
900            let Ok(buf) = sample.ConvertToContiguousBuffer() else {
901                return true;
902            };
903            let mut p: *mut u8 = std::ptr::null_mut();
904            let mut len = 0u32;
905            if buf.Lock(&mut p, None, Some(&mut len)).is_err() || p.is_null() {
906                return true;
907            }
908            let bytes = std::slice::from_raw_parts(p, len as usize);
909            self.tmp.clear();
910            self.tmp.extend(bytes.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])));
911            let _ = buf.Unlock();
912        }
913        if !self.located {
914            self.fifo_pos = (ts as f64 * SAMPLE_RATE as f64 / HNS).round() as i64;
915            self.located = true;
916        }
917        push_audio(&self.tmp, self.ch as usize, self.rate, &mut self.rs, &mut self.fifo);
918        true
919    }
920}
921
922impl AudioSource for MfAudio {
923    fn duration(&self) -> f64 {
924        self.duration
925    }
926    fn read_at(&mut self, t: f64, out: &mut [f32]) {
927        out.fill(0.0);
928        let n = (out.len() / CHANNELS) as i64;
929        let f0 = (t.max(0.0) * SAMPLE_RATE as f64).round() as i64 + self.origin;
930        let f1 = f0 + n;
931        if n == 0 || self.eof_at.is_some_and(|e| f0 >= e) {
932            return;
933        }
934        if (!self.located || f0 < self.fifo_pos || f0 > self.fifo_end() + AUDIO_FWD_FRAMES) && !self.seek(f0) {
935            return;
936        }
937        let mut reads = 0;
938        while !(self.located && self.fifo_end() >= f1) && reads < MAX_READS {
939            reads += 1;
940            if !self.decode_more(f0) {
941                break;
942            }
943        }
944        if !self.located {
945            return;
946        }
947        let (a, b) = (f0.max(self.fifo_pos), f1.min(self.fifo_end()));
948        if b > a {
949            let so = ((a - self.fifo_pos) as usize) * CHANNELS;
950            let len = ((b - a) as usize) * CHANNELS;
951            let doff = ((a - f0) as usize) * CHANNELS;
952            out[doff..doff + len].copy_from_slice(&self.fifo[so..so + len]);
953        }
954        // Drop everything before this block (a repeat read of the same t stays free).
955        let drop = ((f0 - self.fifo_pos).max(0) as usize).min(self.fifo.len() / CHANNELS);
956        if drop > 0 {
957            self.fifo.drain(..drop * CHANNELS);
958            self.fifo_pos += drop as i64;
959        }
960    }
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966    use std::path::PathBuf;
967    use std::process::Command;
968    use std::time::Instant;
969
970    /// red 0–2 s (bottom half blue), green 2–4 s; audio 0 = 440 Hz "eng", audio 1 = 880 Hz "Music".
971    fn media() -> Option<String> {
972        static P: OnceLock<Option<String>> = OnceLock::new();
973        P.get_or_init(|| {
974            let dir = std::env::temp_dir().join("simple-editor-mf-test");
975            std::fs::create_dir_all(&dir).ok()?;
976            let out: PathBuf = dir.join("test.mp4");
977            let st = Command::new("ffmpeg")
978                .args(["-v", "error", "-y"])
979                .args(["-f", "lavfi", "-i", "color=red:s=320x240:d=2,drawbox=y=120:h=120:color=blue:t=fill"])
980                .args(["-f", "lavfi", "-i", "color=lime:s=320x240:d=2"])
981                .args(["-f", "lavfi", "-i", "sine=frequency=440:duration=4,volume=4"])
982                .args(["-f", "lavfi", "-i", "sine=frequency=880:duration=4,volume=4"])
983                .args(["-filter_complex", "[0:v][1:v]concat=n=2:v=1[v]", "-map", "[v]", "-map", "2:a", "-map", "3:a"])
984                .args(["-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-c:a", "aac"])
985                .args(["-metadata:s:a:0", "language=eng", "-metadata:s:a:1", "title=Music"])
986                .arg(&out)
987                .status()
988                .ok()?;
989            st.success().then(|| out.to_string_lossy().into_owned())
990        })
991        .clone()
992    }
993
994    /// `ffmpeg <pre> -i test.mp4 <post> <name>` next to the test clip.
995    fn derive(name: &str, pre: &[&str], post: &[&str]) -> Option<String> {
996        let src = media()?;
997        let out = PathBuf::from(&src).with_file_name(format!("{}-{name}", std::process::id()));
998        let st = Command::new("ffmpeg")
999            .args(["-v", "error", "-y"])
1000            .args(pre)
1001            .args(["-i", &src])
1002            .args(post)
1003            .arg(&out)
1004            .status()
1005            .ok()?;
1006        st.success().then(|| out.to_string_lossy().into_owned())
1007    }
1008
1009    fn px(f: &Frame, x: u32, y: u32) -> [u8; 4] {
1010        let i = ((y * f.width + x) * 4) as usize;
1011        [f.rgba[i], f.rgba[i + 1], f.rgba[i + 2], f.rgba[i + 3]]
1012    }
1013    fn is_red(p: [u8; 4]) -> bool {
1014        p[0] > 200 && p[1] < 70 && p[2] < 70
1015    }
1016    fn is_green(p: [u8; 4]) -> bool {
1017        p[0] < 70 && p[1] > 200 && p[2] < 70
1018    }
1019    fn is_blue(p: [u8; 4]) -> bool {
1020        p[0] < 70 && p[1] < 70 && p[2] > 200
1021    }
1022    fn zero_crossings(stereo: &[f32]) -> usize {
1023        let l: Vec<f32> = stereo.chunks_exact(2).map(|c| c[0]).collect();
1024        l.windows(2).filter(|w| (w[0] < 0.0) != (w[1] < 0.0)).count()
1025    }
1026    fn rms(s: &[f32]) -> f64 {
1027        (s.iter().map(|v| (*v as f64) * (*v as f64)).sum::<f64>() / s.len().max(1) as f64).sqrt()
1028    }
1029
1030    #[test]
1031    fn probe_reports_streams() {
1032        let Some(p) = media() else {
1033            eprintln!("ffmpeg missing; skipped");
1034            return;
1035        };
1036        let a = probe(&p).expect("probe");
1037        assert_eq!(a.kind, ClipKind::Video);
1038        assert!((a.duration - 4.0).abs() < 0.1, "duration {}", a.duration);
1039        assert_eq!((a.width, a.height), (320, 240));
1040        assert!((a.fps - 30.0).abs() < 0.05, "fps {}", a.fps);
1041        assert_eq!(a.audio_streams.len(), 2);
1042        assert_eq!(a.audio_streams[0].index, 0);
1043        assert_eq!(a.audio_streams[1].index, 1);
1044        assert_eq!(a.codec, "h264");
1045        // metadata is best effort (MF exposes "en" / "Music" for this mp4 on Win10+): log, don't fail
1046        eprintln!("audio streams: {:?}", a.audio_streams);
1047        assert_eq!(a.audio_streams[0].channels, 1);
1048        assert_eq!(a.audio_streams[0].sample_rate, 44100);
1049        assert!(probe("C:/definitely/missing.mp4").is_err());
1050        assert!(probe("C:/x.png").is_err());
1051    }
1052
1053    #[test]
1054    fn video_frames_seek_scale() {
1055        let Some(p) = media() else {
1056            eprintln!("ffmpeg missing; skipped");
1057            return;
1058        };
1059        let mut v = open_video(&p).expect("open_video");
1060        assert_eq!(v.size(), (320, 240));
1061        let mut f = Frame::default();
1062        assert!(v.frame_at(0.5, 320, 240, &mut f));
1063        assert_eq!((f.width, f.height), (320, 240));
1064        assert!(is_red(px(&f, 10, 10)), "top-left {:?}", px(&f, 10, 10));
1065        assert!(is_blue(px(&f, 10, 230)), "bottom-left {:?} (orientation)", px(&f, 10, 230));
1066        assert!(v.frame_at(2.5, 320, 240, &mut f));
1067        assert!(is_green(px(&f, 160, 120)), "{:?}", px(&f, 160, 120));
1068        assert!(v.frame_at(0.5, 320, 240, &mut f), "seek back");
1069        assert!(is_red(px(&f, 10, 10)));
1070        assert!(v.frame_at(1.0, 160, 120, &mut f));
1071        assert_eq!((f.width, f.height), (160, 120));
1072        assert!(is_red(px(&f, 5, 5)));
1073        assert!(is_blue(px(&f, 5, 115)));
1074        assert!(v.frame_at(1.0, 400, 300, &mut f), "upscale");
1075        assert_eq!((f.width, f.height), (400, 300));
1076        assert!(is_red(px(&f, 5, 5)));
1077        assert!(is_blue(px(&f, 5, 295)));
1078        assert!(!v.frame_at(10.0, 320, 240, &mut f), "past end");
1079        assert!(!v.frame_at(4.5, 320, 240, &mut f), "past end (cached eof)");
1080        assert!(v.frame_at(3.9, 320, 240, &mut f), "near end still decodes");
1081        assert!(is_green(px(&f, 160, 120)));
1082        // sequential read timing
1083        let t0 = Instant::now();
1084        let mut n = 0;
1085        for i in 0..100 {
1086            if v.frame_at(i as f64 / 30.0, 320, 240, &mut f) {
1087                n += 1;
1088            }
1089        }
1090        let dt = t0.elapsed();
1091        eprintln!("100 sequential 320x240 frames: {dt:?} ({n} ok)");
1092        assert_eq!(n, 100);
1093        assert!(dt.as_secs_f64() < 1.0, "too slow: {dt:?}");
1094        let t0 = Instant::now();
1095        for _ in 0..100 {
1096            assert!(v.frame_at(1.5, 160, 120, &mut f));
1097        }
1098        eprintln!("100 repeated frame_at(1.5) @160x120: {:?}", t0.elapsed());
1099    }
1100
1101    /// Where the milliseconds go on a 4K source: `cargo test --release bench_4k_preview -- --ignored --nocapture`.
1102    /// Not a regular test (generates a 4K file, timing-only) — run it after touching the decode path.
1103    #[test]
1104    #[ignore]
1105    fn bench_4k_preview() {
1106        let dir = std::env::temp_dir().join("se-bench");
1107        let _ = std::fs::create_dir_all(&dir);
1108        let p = dir.join("4k30.mp4");
1109        if !p.exists() {
1110            let ok = std::process::Command::new("ffmpeg")
1111                .args(["-y", "-f", "lavfi", "-i", "testsrc2=size=3840x2160:rate=30", "-t", "2"])
1112                .args(["-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p"])
1113                .arg(&p)
1114                .status()
1115                .map(|s| s.success())
1116                .unwrap_or(false);
1117            if !ok {
1118                eprintln!("ffmpeg missing; skipped");
1119                return;
1120            }
1121        }
1122        let path = p.to_string_lossy().to_string();
1123        let t0 = Instant::now();
1124        let mut v = open_video(&path).expect("open 4k");
1125        eprintln!("open_video(4K): {:?}", t0.elapsed());
1126        let mut f = Frame::default();
1127        for (dw, dh, label) in [(1280u32, 720u32, "1280x720 preview"), (3840, 2160, "native 4K")] {
1128            assert!(v.frame_at(0.0, dw, dh, &mut f), "first frame at {label}");
1129            let t0 = Instant::now();
1130            let mut n = 0;
1131            for i in 1..60 {
1132                if v.frame_at(i as f64 / 30.0, dw, dh, &mut f) {
1133                    n += 1;
1134                }
1135            }
1136            let dt = t0.elapsed();
1137            eprintln!(
1138                "4K -> {label}: {n} frames in {dt:?} ({:.1} ms/frame)",
1139                dt.as_secs_f64() * 1000.0 / n.max(1) as f64
1140            );
1141            assert_eq!(n, 59);
1142            v.frame_at(0.0, dw, dh, &mut f); // seek back so the next size starts cold
1143        }
1144    }
1145
1146    #[test]
1147    fn audio_streams_read() {
1148        let Some(p) = media() else {
1149            eprintln!("ffmpeg missing; skipped");
1150            return;
1151        };
1152        let mut a = open_audio(&p, 0).expect("open_audio 0");
1153        assert!((a.duration() - 4.0).abs() < 0.1);
1154        let mut buf = vec![0f32; 4800 * 2];
1155        a.read_at(1.0, &mut buf);
1156        let r = rms(&buf);
1157        assert!((0.1..=1.0).contains(&r), "rms {r}");
1158        // L == R (mono upmixed)
1159        assert!(buf.chunks_exact(2).all(|c| (c[0] - c[1]).abs() < 1e-4));
1160        // sequential block continues without a gap: the sine must stay continuous across the boundary
1161        let last = buf[buf.len() - 2];
1162        a.read_at(1.0 + 4800.0 / 48000.0, &mut buf);
1163        assert!((buf[0] - last).abs() < 0.15, "discontinuity {last} -> {}", buf[0]);
1164        assert!((0.1..=1.0).contains(&rms(&buf)));
1165        // repeat read of the same block is served from the FIFO
1166        let mut again = vec![0f32; 4800 * 2];
1167        a.read_at(1.0 + 4800.0 / 48000.0, &mut again);
1168        assert_eq!(buf, again);
1169        a.read_at(10.0, &mut buf);
1170        assert!(buf.iter().all(|v| *v == 0.0), "past end must be silent");
1171        a.read_at(0.5, &mut buf);
1172        assert!((0.1..=1.0).contains(&rms(&buf)), "seek back after eof");
1173        // straddling the end: tail zero-filled
1174        a.read_at(3.95, &mut buf);
1175        assert!(buf[buf.len() - 2..].iter().all(|v| *v == 0.0));
1176        // container order: stream 0 = 440 Hz (~88 zero crossings / 0.1 s), stream 1 = 880 Hz (~176)
1177        a.read_at(1.0, &mut buf);
1178        let zc0 = zero_crossings(&buf);
1179        let mut b = open_audio(&p, 1).expect("open_audio 1");
1180        b.read_at(2.0, &mut buf);
1181        assert!((0.1..=1.0).contains(&rms(&buf)), "stream 1 rms");
1182        let zc1 = zero_crossings(&buf);
1183        eprintln!("zero crossings: stream0={zc0} stream1={zc1}");
1184        assert!((80..=96).contains(&zc0) && (168..=184).contains(&zc1), "stream order {zc0} {zc1}");
1185        assert!(open_audio(&p, 2).is_err());
1186        let t0 = Instant::now();
1187        let mut t = 0.0;
1188        for _ in 0..100 {
1189            a.read_at(t, &mut buf);
1190            t += 4800.0 / 48000.0;
1191        }
1192        eprintln!("100 sequential audio blocks (10 s): {:?}", t0.elapsed());
1193    }
1194
1195    /// Stepping at n/fps must visit every frame once (MKV: FRC disabled + ms-rounded pts tolerance).
1196    #[test]
1197    fn steps_every_frame_mkv_and_mp4() {
1198        let Some(mkv) = derive("v30.mkv", &[], &["-c", "copy"]) else {
1199            eprintln!("ffmpeg missing; skipped");
1200            return;
1201        };
1202        for p in [mkv, media().unwrap()] {
1203            let mut v = open_video(&p).expect("open_video");
1204            let mut f = Frame::default();
1205            for n in 0..120 {
1206                let t = n as f64 / 30.0;
1207                assert!(v.frame_at(t, 32, 24, &mut f), "{p} frame {n}");
1208                assert!((f.pts - t).abs() < 0.002, "{p} frame {n}: pts {} (dup/skip)", f.pts);
1209                let c = px(&f, 1, 1);
1210                assert!(if n < 60 { is_red(c) } else { is_green(c) }, "{p} frame {n}: {c:?}");
1211            }
1212        }
1213    }
1214
1215    /// Display-matrix rotation: both probes report display dims, both decoders deliver rotated frames.
1216    #[test]
1217    fn rotated_video_reports_display_size() {
1218        let Some(p) = derive("rot90.mp4", &["-display_rotation", "90"], &["-c", "copy"]) else {
1219            eprintln!("ffmpeg missing; skipped");
1220            return;
1221        };
1222        assert_eq!(probe(&p).map(|a| (a.width, a.height)), Ok((240, 320)), "mf probe");
1223        assert_eq!(crate::media::ffpipe::probe(&p).map(|a| (a.width, a.height)), Ok((240, 320)), "ffprobe");
1224        for mut v in [open_video(&p).expect("mf"), crate::media::ffpipe::open_video(&p).expect("ffpipe")] {
1225            assert_eq!(v.size(), (240, 320));
1226            let mut f = Frame::default();
1227            assert!(v.frame_at(0.5, 240, 320, &mut f));
1228            let (l, r) = (px(&f, 10, 160), px(&f, 230, 160));
1229            assert!((is_red(l) && is_blue(r)) || (is_blue(l) && is_red(r)), "halves {l:?} {r:?}");
1230        }
1231    }
1232
1233    /// Containers whose first timestamp is not 0 (mp4 with an initial empty edit, MKV with an offset):
1234    /// content time 0 = first sample, like ffmpeg/ffprobe, so the clip neither starts frozen nor loses
1235    /// its tail. MPEG-TS (MF seeks it to the next keyframe only) is left to ffmpeg.
1236    #[test]
1237    fn offset_start_is_content_relative() {
1238        let (Some(ts), Some(mp4), Some(mkv)) = (
1239            derive("off.ts", &[], &["-c", "copy", "-f", "mpegts"]),
1240            derive("off10.mp4", &[], &["-c", "copy", "-output_ts_offset", "10"]),
1241            derive("off10.mkv", &[], &["-c", "copy", "-output_ts_offset", "10"]),
1242        ) else {
1243            eprintln!("ffmpeg missing; skipped");
1244            return;
1245        };
1246        assert!(open_video(&ts).is_err() && open_audio(&ts, 0).is_err(), "TS goes to ffmpeg");
1247        assert!(crate::media::open_video(&ts, crate::media::Backend::Auto).is_ok());
1248        for p in [mp4, mkv] {
1249            let mut v = open_video(&p).expect("open_video");
1250            let mut f = Frame::default();
1251            for (t, green) in [(0.5, false), (2.5, true), (0.5, false), (3.9, true), (0.0, false)] {
1252                assert!(v.frame_at(t, 32, 24, &mut f), "{p} frame_at({t})");
1253                let c = px(&f, 1, 1);
1254                assert!(if green { is_green(c) } else { is_red(c) }, "{p} t={t}: {c:?} pts {}", f.pts);
1255                assert!((f.pts - t).abs() < 0.05, "{p} t={t}: pts {}", f.pts);
1256            }
1257            assert!(!v.frame_at(4.5, 32, 24, &mut f), "{p} past end");
1258            let mut a = open_audio(&p, 0).expect("open_audio");
1259            let mut buf = vec![0f32; 4800 * 2];
1260            for t in [0.0, 1.0, 3.8, 0.5] {
1261                a.read_at(t, &mut buf);
1262                let r = rms(&buf);
1263                assert!((0.1..=1.0).contains(&r), "{p} audio t={t}: rms {r}");
1264            }
1265        }
1266    }
1267
1268    #[test]
1269    fn push_audio_converts() {
1270        // 48k stereo passthrough
1271        let mut fifo = Vec::new();
1272        push_audio(&[1.0, 2.0, 3.0, 4.0], 2, 48000, &mut Resamp::default(), &mut fifo);
1273        assert_eq!(fifo, [1.0, 2.0, 3.0, 4.0]);
1274        // mono dup + 6ch keeps L/R
1275        fifo.clear();
1276        push_audio(&[0.5], 1, 48000, &mut Resamp::default(), &mut fifo);
1277        push_audio(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 6, 48000, &mut Resamp::default(), &mut fifo);
1278        assert_eq!(fifo, [0.5, 0.5, 1.0, 2.0]);
1279        // 24k mono ramp -> 48k: interpolated midpoints, continuous across buffers
1280        fifo.clear();
1281        let mut rs = Resamp::default();
1282        push_audio(&[0.0, 2.0, 4.0], 1, 24000, &mut rs, &mut fifo);
1283        push_audio(&[6.0, 8.0], 1, 24000, &mut rs, &mut fifo);
1284        let l: Vec<f32> = fifo.chunks_exact(2).map(|c| c[0]).collect();
1285        // (the last source sample waits for the next buffer: it is only an interpolation endpoint)
1286        assert_eq!(l, [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]);
1287        // 96k -> 48k: one out per two in
1288        fifo.clear();
1289        push_audio(&[0.0, 1.0, 2.0, 3.0], 1, 96000, &mut Resamp::default(), &mut fifo);
1290        assert_eq!(fifo.len() / 2, 2);
1291    }
1292
1293    #[test]
1294    fn scalers() {
1295        // 4x2 source: left half red, right half blue
1296        let mut src = vec![0u8; 4 * 2 * 4];
1297        for (i, p) in src.chunks_exact_mut(4).enumerate() {
1298            p.copy_from_slice(if i % 4 < 2 { &[255, 0, 0, 255] } else { &[0, 0, 255, 255] });
1299        }
1300        let xs = [0u32, 2, 4];
1301        let ys = [0u32, 2];
1302        let mut acc = vec![0u32; 6];
1303        let mut dst = vec![0u8; 2 * 4];
1304        box_down(&src, 4, &xs, &ys, &mut acc, &mut dst);
1305        assert_eq!(dst, [255, 0, 0, 255, 0, 0, 255, 255]);
1306        let mut up = vec![0u8; 8 * 4 * 4];
1307        bilinear(&src, 4, 2, 8, 4, &mut up);
1308        assert_eq!(&up[..4], &[255, 0, 0, 255]);
1309        assert_eq!(&up[7 * 4..8 * 4], &[0, 0, 255, 255]);
1310        // a BGRX row with negative pitch (bottom-up memory) lands top-down
1311        let rows: [[u8; 8]; 2] = [[1, 2, 3, 0, 4, 5, 6, 0], [7, 8, 9, 0, 10, 11, 12, 0]]; // mem: bottom row first
1312        let mut out = vec![0u8; 16];
1313        unsafe { convert_rows(rows[1].as_ptr(), -8, 2, 2, &mut out) };
1314        assert_eq!(out, [9, 8, 7, 255, 12, 11, 10, 255, 3, 2, 1, 255, 6, 5, 4, 255]);
1315    }
1316}