Skip to main content

siglus_scene_vm/movie/
mod.rs

1use crate::platform_time::Duration;
2use std::collections::{HashMap, HashSet, VecDeque};
3use std::fs;
4use std::io::{BufReader, Cursor, Read, Seek, SeekFrom};
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
7use std::sync::{
8    Arc,
9    mpsc::{self, Receiver, TryRecvError},
10};
11use std::thread;
12
13use anyhow::{Context, Result, anyhow, bail};
14#[cfg(not(target_arch = "wasm32"))]
15use kira::Frame;
16use kira::sound::PlaybackState;
17use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
18#[cfg(not(target_arch = "wasm32"))]
19use kira::sound::streaming::{
20    Decoder as KiraStreamingDecoder, StreamingSoundData, StreamingSoundHandle,
21};
22
23use crate::assets::RgbaImage;
24use crate::audio::{AudioHub, TrackKind};
25
26const MPEG2_HEADER_PROBE_BYTES: usize = 256 * 1024;
27const MPEG2_STREAM_CHUNK_BYTES: usize = 256 * 1024;
28const MPEG_AUDIO_HEAD_PROBE_MAX_BYTES: u64 = 16 * 1024 * 1024;
29const MPEG_AUDIO_TAIL_PROBE_INITIAL_BYTES: u64 = 4 * 1024 * 1024;
30const MPEG_AUDIO_TAIL_PROBE_MAX_BYTES: u64 = 32 * 1024 * 1024;
31const MPEG_AUDIO_SEEK_INITIAL_BACKTRACK_BYTES: u64 = 512 * 1024;
32const MPEG_AUDIO_SEEK_MAX_PRIME_BYTES: u64 = 16 * 1024 * 1024;
33const MPEG_VIDEO_SEEK_BACKTRACK_BYTES: u64 = 8 * 1024 * 1024;
34const MPEG_VIDEO_SEEK_FORWARD_PROBE_BYTES: u64 = 1024 * 1024;
35#[cfg(not(target_os = "vita"))]
36const MPEG2_STREAM_CHANNEL_CAPACITY: usize = 4;
37#[cfg(target_os = "vita")]
38const MPEG2_STREAM_CHANNEL_CAPACITY: usize = 1;
39const MPEG2_STREAM_MAX_DRAIN_EVENTS: usize = 8;
40#[cfg(not(target_os = "vita"))]
41const MPEG2_STREAM_FRAME_KEEP: usize = 6;
42#[cfg(target_os = "vita")]
43const MPEG2_STREAM_FRAME_KEEP: usize = 1;
44#[cfg(not(target_os = "vita"))]
45const MPEG2_STREAM_DECODE_LEAD_FRAMES: usize = 3;
46#[cfg(target_os = "vita")]
47const MPEG2_STREAM_DECODE_LEAD_FRAMES: usize = 1;
48// Desktop OMV queues: a few frames of lead are enough for a desktop CPU
49// (decoding a 1920x1440 frame takes a few milliseconds), and each frame is
50// 3.5 to 8.6 MiB; GameData's title plays four streams at once.
51#[cfg(not(any(target_os = "horizon", target_os = "vita")))]
52const OMV_STREAM_CHANNEL_CAPACITY: usize = 4;
53#[cfg(target_os = "horizon")]
54const OMV_STREAM_CHANNEL_CAPACITY: usize = 4;
55#[cfg(target_os = "vita")]
56const OMV_STREAM_CHANNEL_CAPACITY: usize = 1;
57#[cfg(not(any(target_os = "horizon", target_os = "vita")))]
58const OMV_STREAM_MAX_DRAIN_EVENTS: usize = 16;
59#[cfg(any(target_os = "horizon", target_os = "vita"))]
60const OMV_STREAM_MAX_DRAIN_EVENTS: usize = 8;
61#[cfg(not(any(target_os = "horizon", target_os = "vita")))]
62const OMV_STREAM_FRAME_KEEP: usize = 6;
63#[cfg(target_os = "horizon")]
64const OMV_STREAM_FRAME_KEEP: usize = 6;
65#[cfg(target_os = "vita")]
66const OMV_STREAM_FRAME_KEEP: usize = 1;
67#[cfg(not(any(target_os = "vita", feature = "virtual-clock")))]
68const OMV_STREAM_DECODE_LEAD_FRAMES: usize = 2;
69/// Virtual-clock replays decode no frame ahead, so the frame on screen does
70/// not depend on how fast the decoder thread ran.
71#[cfg(all(not(target_os = "vita"), feature = "virtual-clock"))]
72const OMV_STREAM_DECODE_LEAD_FRAMES: usize = 0;
73#[cfg(target_os = "vita")]
74const OMV_STREAM_DECODE_LEAD_FRAMES: usize = 1;
75// A loop restart seeks by the index and decodes again (as on consoles);
76// the few cached head frames only bridge that.
77const OMV_LOOP_HEAD_CACHE_MAX_FRAMES: usize = 4;
78#[cfg(not(target_os = "vita"))]
79const OMV_LOOP_HEAD_CACHE_MAX_BYTES: usize = 8 * 1024 * 1024;
80#[cfg(target_os = "vita")]
81const OMV_LOOP_HEAD_CACHE_MAX_BYTES: usize = 4 * 1024 * 1024;
82#[cfg(not(target_os = "vita"))]
83const WMV_STREAM_CHANNEL_CAPACITY: usize = 8;
84#[cfg(target_os = "vita")]
85const WMV_STREAM_CHANNEL_CAPACITY: usize = 1;
86const WMV_STREAM_MAX_DRAIN_EVENTS: usize = 16;
87#[cfg(not(target_os = "vita"))]
88const WMV_STREAM_FRAME_KEEP: usize = 12;
89#[cfg(target_os = "vita")]
90const WMV_STREAM_FRAME_KEEP: usize = 2;
91#[cfg(not(target_os = "vita"))]
92const WMV_STREAM_DECODE_LEAD_MS: usize = 750;
93#[cfg(target_os = "vita")]
94const WMV_STREAM_DECODE_LEAD_MS: usize = 150;
95
96/// Movie decoding runs below the game thread on PS Vita: with several
97/// 1080p OMV streams (GameData's title menu) the decoders took cores the
98/// script and renderer needed, and a late movie frame costs less than a
99/// late game frame.
100fn lower_worker_thread_priority() {
101    #[cfg(target_os = "vita")]
102    unsafe {
103        unsafe extern "C" {
104            fn sceKernelGetThreadId() -> i32;
105            fn sceKernelGetThreadCurrentPriority() -> i32;
106            fn sceKernelChangeThreadPriority(thid: i32, priority: i32) -> i32;
107        }
108        let current = sceKernelGetThreadCurrentPriority();
109        if current > 0 {
110            // User priorities run from 64 (most urgent) to 191.
111            sceKernelChangeThreadPriority(sceKernelGetThreadId(), (current + 16).min(191));
112        }
113    }
114}
115
116fn movie_output_dimensions(width: u32, height: u32) -> (u32, u32) {
117    #[cfg(target_os = "vita")]
118    {
119        const MAX_WIDTH: u32 = 960;
120        const MAX_HEIGHT: u32 = 544;
121        if width > MAX_WIDTH || height > MAX_HEIGHT {
122            if u64::from(width) * u64::from(MAX_HEIGHT) >= u64::from(height) * u64::from(MAX_WIDTH)
123            {
124                return (
125                    MAX_WIDTH,
126                    ((u64::from(height) * u64::from(MAX_WIDTH) / u64::from(width)) as u32).max(1),
127                );
128            }
129            return (
130                ((u64::from(width) * u64::from(MAX_HEIGHT) / u64::from(height)) as u32).max(1),
131                MAX_HEIGHT,
132            );
133        }
134    }
135    (width, height)
136}
137
138#[derive(Debug, Clone)]
139pub struct MovieInfo {
140    pub path: PathBuf,
141    pub width: Option<u32>,
142    pub height: Option<u32>,
143    pub fps: Option<f32>,
144    pub decoded_frames: Option<usize>,
145    pub audio_duration_ms: Option<u64>,
146}
147
148impl MovieInfo {
149    pub fn duration_ms(&self) -> Option<u64> {
150        if let Some(ms) = self.audio_duration_ms {
151            return Some(ms);
152        }
153        let fps = self.fps?;
154        let frames = self.decoded_frames?;
155        if fps <= 0.0 || frames == 0 {
156            return None;
157        }
158        Some(((frames as f64) * 1000.0 / (fps as f64)).round() as u64)
159    }
160}
161
162#[derive(Debug, Clone)]
163pub struct MovieStreamFrame {
164    pub frame: Arc<RgbaImage>,
165    pub frame_idx: usize,
166    pub fps: Option<f32>,
167    pub total_ms: Option<u64>,
168    pub audio: Option<MovieAudio>,
169    pub audio_ready: bool,
170    pub decoded_now: bool,
171    /// True when `frame_idx` is the coded/decode-order index rather than a
172    /// monotonically increasing presentation-order index. WMV3 with B pictures
173    /// has this property even though frame selection itself is PTS ordered.
174    pub frame_idx_is_decode_order: bool,
175    pub clamped_timer_ms: Option<u64>,
176    /// The video's own size. `frame` can be smaller (Vita decodes to at
177    /// most 960x544), so objects size themselves by this, not by `frame`.
178    pub source_size: Option<(u32, u32)>,
179}
180
181enum Mpeg2StreamEvent {
182    Info {
183        width: Option<u32>,
184        height: Option<u32>,
185        fps: Option<f32>,
186    },
187    Video {
188        frame_idx: usize,
189        pts_90k: Option<i64>,
190        frame: Arc<RgbaImage>,
191    },
192    Done,
193}
194
195#[derive(Clone)]
196struct Mpeg2DecodedFrame {
197    frame_idx: usize,
198    pts_90k: Option<i64>,
199    frame: Arc<RgbaImage>,
200}
201
202struct Mpeg2StreamState {
203    rx: Receiver<Result<Mpeg2StreamEvent, String>>,
204    frames: VecDeque<Mpeg2DecodedFrame>,
205    width: Option<u32>,
206    height: Option<u32>,
207    fps: Option<f32>,
208    decoded_frames: usize,
209    first_video_pts_90k: Option<i64>,
210    last_video_timeline_ms: Option<u64>,
211    last_requested_timer_ms: u64,
212    done: bool,
213    audio: Option<MovieAudio>,
214    decoded_any_this_poll: bool,
215    request_frames: Arc<AtomicUsize>,
216    /// Engine frames since this stream was last polled (Vita eviction).
217    #[cfg(target_os = "vita")]
218    idle_frames: u32,
219}
220
221impl Drop for Mpeg2StreamState {
222    fn drop(&mut self) {
223        self.request_frames.store(usize::MAX, Ordering::Release);
224    }
225}
226
227struct Mpeg2AudioTask {
228    rx: Receiver<Result<Option<MovieAudio>, String>>,
229    cancel: Arc<AtomicBool>,
230}
231
232impl Drop for Mpeg2AudioTask {
233    fn drop(&mut self) {
234        self.cancel.store(true, Ordering::Release);
235    }
236}
237
238enum WmvStreamEvent {
239    Info {
240        width: u32,
241        height: u32,
242    },
243    Video {
244        frame_idx: usize,
245        source_pts_ms: u64,
246        frame: Arc<RgbaImage>,
247    },
248    Done,
249}
250
251#[derive(Clone)]
252struct WmvDecodedFrame {
253    frame_idx: usize,
254    /// ASF presentation timestamp, after ASF preroll handling but before
255    /// rebasing to the movie-local clock. WMV3/VC-1 decode order is not PTS
256    /// order when B pictures are present, so keep this raw until the consumer
257    /// has sorted the presentation queue.
258    source_pts_ms: u64,
259    frame: Arc<RgbaImage>,
260}
261
262struct WmvStreamState {
263    rx: Receiver<Result<WmvStreamEvent, String>>,
264    frames: VecDeque<WmvDecodedFrame>,
265    width: Option<u32>,
266    height: Option<u32>,
267    fps: Option<f32>,
268    decoded_frames: usize,
269    timeline_origin_ms: Option<u64>,
270    last_video_source_pts_ms: Option<u64>,
271    last_frame_delta_ms: Option<u64>,
272    total_ms_hint: Option<u64>,
273    last_effective_timer_ms: u64,
274    done: bool,
275    audio: Option<MovieAudio>,
276    decoded_any_this_poll: bool,
277    request_ms: Arc<AtomicUsize>,
278    /// Engine frames since this stream was last polled (Vita eviction).
279    #[cfg(target_os = "vita")]
280    idle_frames: u32,
281}
282
283impl Drop for WmvStreamState {
284    fn drop(&mut self) {
285        self.request_ms.store(usize::MAX, Ordering::Release);
286    }
287}
288
289struct WmvAudioTask {
290    rx: Receiver<Result<Option<MovieAudio>, String>>,
291    cancel: Arc<AtomicBool>,
292}
293
294impl Drop for WmvAudioTask {
295    fn drop(&mut self) {
296        self.cancel.store(true, Ordering::Release);
297    }
298}
299
300enum OmvStreamEvent {
301    Info {
302        width: u32,
303        height: u32,
304        fps: Option<f32>,
305        frame_time_ms: Option<f64>,
306        total_frames_hint: Option<usize>,
307        total_ms_hint: Option<u64>,
308        frame_times: Arc<Vec<(u64, u64)>>,
309    },
310    Reset {
311        frame_idx: usize,
312    },
313    Video {
314        frame_idx: usize,
315        frame: Arc<RgbaImage>,
316    },
317    Done,
318}
319
320struct OmvStreamState {
321    rx: Receiver<Result<OmvStreamEvent, String>>,
322    frames: VecDeque<(usize, Arc<RgbaImage>)>,
323    loop_head_frames: VecDeque<(usize, Arc<RgbaImage>)>,
324    loop_head_bytes: usize,
325    width: Option<u32>,
326    height: Option<u32>,
327    fps: Option<f32>,
328    frame_time_ms: Option<f64>,
329    total_frames_hint: Option<usize>,
330    total_ms_hint: Option<u64>,
331    frame_times: Option<Arc<Vec<(u64, u64)>>>,
332    decoded_frames: usize,
333    done: bool,
334    request_frame: Arc<AtomicUsize>,
335    /// Highest frame index this poll loop has served to the consumer. Requests
336    /// are clamped to never go backward from here so timer jitter cannot force
337    /// an indexed seek (which rewinds the stream and visibly flashes).
338    last_served_frame_idx: usize,
339    /// Effective timer value of the previous poll, used to detect genuine
340    /// loop wraps that legitimately rewind the stream.
341    last_effective_timer_ms: u64,
342    /// The frame served last. When a loop wrap forces the worker to rewind
343    /// and re-decode from a key frame, the live queue lags behind the timer
344    /// again; serving its fast-catch-up tail would flash stale dark head
345    /// frames. Hold this frame until the stream catches back up.
346    held_frame: Option<(usize, Arc<RgbaImage>)>,
347    /// Engine frames since this stream was last polled (Vita eviction).
348    #[cfg(target_os = "vita")]
349    idle_frames: u32,
350}
351
352impl Drop for OmvStreamState {
353    fn drop(&mut self) {
354        self.request_frame.store(usize::MAX, Ordering::Release);
355    }
356}
357
358/// Minimal movie state holder.
359///
360/// The original Siglus engine plays MOV via a native playback pipeline.
361/// Here we provide a deterministic, cross-platform metadata path:
362/// - MPEG2 (`.mpg` / `.mpeg`) via `siglus_assets::mpeg2`
363/// - ASF/WMV (`.wmv`) via `wmv_decoder`
364/// - OMV (`.omv`) via `siglus_assets::omv`
365pub struct MovieManager {
366    project_dir: PathBuf,
367    current_append_dir: String,
368    current: Option<MovieInfo>,
369    cache: HashMap<PathBuf, MovieAsset>,
370    preview_cache: HashMap<PathBuf, Arc<RgbaImage>>,
371    decode_tasks: HashMap<PathBuf, Receiver<Result<MovieAsset, String>>>,
372    mpeg2_audio_cache: HashMap<PathBuf, Option<MovieAudio>>,
373    mpeg2_audio_tasks: HashMap<PathBuf, Mpeg2AudioTask>,
374    mpeg2_streams: HashMap<PathBuf, Mpeg2StreamState>,
375    wmv_audio_cache: HashMap<PathBuf, Option<MovieAudio>>,
376    wmv_audio_tasks: HashMap<PathBuf, WmvAudioTask>,
377    wmv_streams: HashMap<PathBuf, WmvStreamState>,
378    omv_streams: HashMap<PathBuf, OmvStreamState>,
379    playbacks: HashMap<u64, MoviePlayback>,
380    next_playback_id: u64,
381    /// Resolved movie paths keyed by (append_dir, file_name). The per-frame
382    /// movie poll re-resolves the path on every frame, and on Android this
383    /// translates into slow FUSE directory scans.
384    path_cache: HashMap<(String, String), PathBuf>,
385}
386
387impl std::fmt::Debug for MovieManager {
388    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389        f.debug_struct("MovieManager")
390            .field("project_dir", &self.project_dir)
391            .field("current_append_dir", &self.current_append_dir)
392            .field("current", &self.current)
393            .field("cache_len", &self.cache.len())
394            .field("preview_cache_len", &self.preview_cache.len())
395            .field("decode_tasks_len", &self.decode_tasks.len())
396            .field("mpeg2_audio_cache_len", &self.mpeg2_audio_cache.len())
397            .field("mpeg2_audio_tasks_len", &self.mpeg2_audio_tasks.len())
398            .field("mpeg2_streams_len", &self.mpeg2_streams.len())
399            .field("wmv_audio_cache_len", &self.wmv_audio_cache.len())
400            .field("wmv_audio_tasks_len", &self.wmv_audio_tasks.len())
401            .field("wmv_streams_len", &self.wmv_streams.len())
402            .field("omv_streams_len", &self.omv_streams.len())
403            .field("playbacks_len", &self.playbacks.len())
404            .finish()
405    }
406}
407
408#[derive(Debug, Clone, Copy, Default)]
409pub struct MovieMemoryStats {
410    pub video_bytes: usize,
411    pub video_frames: usize,
412    pub audio_pcm_bytes: usize,
413    pub audio_buffers: usize,
414    pub asset_cache_entries: usize,
415    pub preview_cache_entries: usize,
416    pub active_streams: usize,
417}
418
419impl MovieManager {
420    /// Release decoders whose OBJECT movies are no longer visited by the
421    /// scene. Active streams update their timestamp on every presentation
422    /// poll, so several simultaneous movies remain resident.
423    ///
424    /// Idleness is counted in engine frames (this runs once per frame), not
425    /// wall time: a frame that stalls for seconds (card writes, the
426    /// emulator compiling code) must not evict the movie playing now, which
427    /// then restarts from a seek and on a slow decoder never catches up.
428    #[cfg(target_os = "vita")]
429    pub fn evict_idle_streams(&mut self) {
430        const IDLE_FRAMES: u32 = 120;
431        let old_mpeg_count = self.mpeg2_streams.len();
432        let old_wmv_count = self.wmv_streams.len();
433        let keep = |idle: &mut u32| {
434            *idle = idle.saturating_add(1);
435            *idle <= IDLE_FRAMES
436        };
437        self.mpeg2_streams
438            .retain(|_, state| keep(&mut state.idle_frames));
439        self.wmv_streams
440            .retain(|_, state| keep(&mut state.idle_frames));
441        self.omv_streams
442            .retain(|_, state| keep(&mut state.idle_frames));
443        if self.mpeg2_streams.len() != old_mpeg_count {
444            self.mpeg2_audio_tasks
445                .retain(|path, _| self.mpeg2_streams.contains_key(path));
446            self.mpeg2_audio_cache
447                .retain(|path, _| self.mpeg2_streams.contains_key(path));
448        }
449        if self.wmv_streams.len() != old_wmv_count {
450            self.wmv_audio_tasks
451                .retain(|path, _| self.wmv_streams.contains_key(path));
452            self.wmv_audio_cache
453                .retain(|path, _| self.wmv_streams.contains_key(path));
454        }
455    }
456
457    /// HUD-only accounting of decoded movie data owned by MovieManager.
458    /// This deliberately performs the cache walk only when the caller asks.
459    pub fn debug_memory_stats(&self) -> MovieMemoryStats {
460        let mut stats = MovieMemoryStats {
461            asset_cache_entries: self.cache.len(),
462            preview_cache_entries: self.preview_cache.len(),
463            active_streams: self.mpeg2_streams.len()
464                + self.wmv_streams.len()
465                + self.omv_streams.len(),
466            ..MovieMemoryStats::default()
467        };
468        fn add_frame(
469            seen: &mut HashSet<usize>,
470            stats: &mut MovieMemoryStats,
471            frame: &Arc<RgbaImage>,
472        ) {
473            let key = Arc::as_ptr(frame) as usize;
474            if seen.insert(key) {
475                stats.video_bytes = stats.video_bytes.saturating_add(frame.rgba.len());
476                stats.video_frames += 1;
477            }
478        }
479        fn add_audio(seen: &mut HashSet<usize>, stats: &mut MovieMemoryStats, audio: &MovieAudio) {
480            let key = Arc::as_ptr(&audio.samples) as usize;
481            if seen.insert(key) {
482                stats.audio_pcm_bytes = stats.audio_pcm_bytes.saturating_add(
483                    audio
484                        .samples
485                        .len()
486                        .saturating_mul(std::mem::size_of::<i16>()),
487                );
488                stats.audio_buffers += 1;
489            }
490        }
491        let mut seen_frames = HashSet::new();
492        let mut seen_audio = HashSet::new();
493        for asset in self.cache.values() {
494            for frame in &asset.frames {
495                add_frame(&mut seen_frames, &mut stats, frame);
496            }
497            if let Some(audio) = asset.audio.as_ref() {
498                add_audio(&mut seen_audio, &mut stats, audio);
499            }
500        }
501        for frame in self.preview_cache.values() {
502            add_frame(&mut seen_frames, &mut stats, frame);
503        }
504        for state in self.mpeg2_streams.values() {
505            for frame in &state.frames {
506                add_frame(&mut seen_frames, &mut stats, &frame.frame);
507            }
508            if let Some(audio) = state.audio.as_ref() {
509                add_audio(&mut seen_audio, &mut stats, audio);
510            }
511        }
512        for state in self.wmv_streams.values() {
513            for frame in &state.frames {
514                add_frame(&mut seen_frames, &mut stats, &frame.frame);
515            }
516            if let Some(audio) = state.audio.as_ref() {
517                add_audio(&mut seen_audio, &mut stats, audio);
518            }
519        }
520        for state in self.omv_streams.values() {
521            for (_, frame) in &state.frames {
522                add_frame(&mut seen_frames, &mut stats, frame);
523            }
524            for (_, frame) in &state.loop_head_frames {
525                add_frame(&mut seen_frames, &mut stats, frame);
526            }
527            if let Some((_, frame)) = state.held_frame.as_ref() {
528                add_frame(&mut seen_frames, &mut stats, frame);
529            }
530        }
531        for audio in self.mpeg2_audio_cache.values().flatten() {
532            add_audio(&mut seen_audio, &mut stats, audio);
533        }
534        for audio in self.wmv_audio_cache.values().flatten() {
535            add_audio(&mut seen_audio, &mut stats, audio);
536        }
537        stats
538    }
539
540    pub fn new(project_dir: PathBuf) -> Self {
541        Self {
542            project_dir,
543            current_append_dir: String::new(),
544            current: None,
545            cache: HashMap::new(),
546            preview_cache: HashMap::new(),
547            decode_tasks: HashMap::new(),
548            mpeg2_audio_cache: HashMap::new(),
549            mpeg2_audio_tasks: HashMap::new(),
550            mpeg2_streams: HashMap::new(),
551            wmv_audio_cache: HashMap::new(),
552            wmv_audio_tasks: HashMap::new(),
553            wmv_streams: HashMap::new(),
554            omv_streams: HashMap::new(),
555            playbacks: HashMap::new(),
556            next_playback_id: 1,
557            path_cache: HashMap::new(),
558        }
559    }
560
561    pub fn current(&self) -> Option<&MovieInfo> {
562        self.current.as_ref()
563    }
564
565    pub fn set_current_append_dir(&mut self, append_dir: impl Into<String>) {
566        let append_dir = append_dir.into();
567        if self.current_append_dir != append_dir {
568            self.current_append_dir = append_dir;
569        }
570    }
571
572    pub fn set_current_append_dir_ref(&mut self, append_dir: &str) {
573        if self.current_append_dir != append_dir {
574            self.current_append_dir.clear();
575            self.current_append_dir.push_str(append_dir);
576        }
577    }
578
579    pub fn stop(&mut self) {
580        self.current = None;
581        self.mpeg2_streams.clear();
582        self.mpeg2_audio_tasks.clear();
583        self.wmv_streams.clear();
584        self.wmv_audio_tasks.clear();
585        self.omv_streams.clear();
586        #[cfg(target_os = "vita")]
587        {
588            // The desktop cache favors replay latency. On Vita, retaining
589            // decoded frames and whole-track PCM after playback can exhaust
590            // user memory as the scenario visits more movies.
591            self.cache.clear();
592            self.preview_cache.clear();
593            self.decode_tasks.clear();
594            self.mpeg2_audio_cache.clear();
595            self.wmv_audio_cache.clear();
596        }
597    }
598
599    pub fn prepare(&mut self, file_name: &str) -> Result<MovieInfo> {
600        self.play(file_name, false, false)
601    }
602
603    pub fn prepare_omv(&mut self, file_name: &str) -> Result<MovieInfo> {
604        let path = crate::resource::find_omv_path_with_append_dir(
605            &self.project_dir,
606            &self.current_append_dir,
607            file_name,
608        )?;
609        let header = read_omv_header_for_path(&path)
610            .with_context(|| format!("open OMV: {}", path.display()))?;
611        let w = header.display_width;
612        let h = header.display_height;
613        let fps = if header.frame_time_us != 0 {
614            Some(1_000_000.0 / (header.frame_time_us as f32))
615        } else {
616            None
617        };
618        let info = MovieInfo {
619            path,
620            width: (w > 0).then_some(w),
621            height: (h > 0).then_some(h),
622            fps,
623            decoded_frames: (header.packet_count_hint > 0)
624                .then_some(header.packet_count_hint as usize),
625            audio_duration_ms: None,
626        };
627        self.current = Some(info.clone());
628        Ok(info)
629    }
630
631    pub fn play(&mut self, file_name: &str, _wait: bool, _key_skip: bool) -> Result<MovieInfo> {
632        let path = resolve_mov_path(&self.project_dir, &self.current_append_dir, file_name)?;
633        let ext = path
634            .extension()
635            .and_then(|s| s.to_str())
636            .unwrap_or("")
637            .to_ascii_lowercase();
638
639        let info = if ext == "omv" {
640            let header = read_omv_header_for_path(&path)
641                .with_context(|| format!("open OMV: {}", path.display()))?;
642            let w = header.display_width;
643            let h = header.display_height;
644            let fps = if header.frame_time_us != 0 {
645                Some(1_000_000.0 / (header.frame_time_us as f32))
646            } else {
647                None
648            };
649            MovieInfo {
650                path,
651                width: (w > 0).then_some(w),
652                height: (h > 0).then_some(h),
653                fps,
654                decoded_frames: (header.packet_count_hint > 0)
655                    .then_some(header.packet_count_hint as usize),
656                audio_duration_ms: None,
657            }
658        } else if is_wmv_extension(&ext) {
659            probe_wmv_movie_info(&path)?
660        } else {
661            let prefix = read_file_prefix(&path, MPEG2_HEADER_PROBE_BYTES)
662                .with_context(|| format!("read movie header: {}", path.display()))?;
663
664            let mut width = None;
665            let mut height = None;
666            let mut fps = None;
667
668            if let Some(h) = siglus_assets::mpeg2::find_sequence_header(&prefix) {
669                width = Some(h.width as u32);
670                height = Some(h.height as u32);
671                fps = siglus_assets::mpeg2::fps_from_frame_rate_code(h.frame_rate_code);
672            }
673
674            let decoded_frames = decode_frames_if_enabled(&path)?;
675
676            MovieInfo {
677                path,
678                width,
679                height,
680                fps,
681                decoded_frames,
682                audio_duration_ms: None,
683            }
684        };
685
686        self.current = Some(info.clone());
687        Ok(info)
688    }
689
690    /// Resolve and decode a movie asset into RGBA frames (cached).
691    pub fn ensure_asset(&mut self, file_name: &str) -> Result<(&MovieAsset, bool)> {
692        let path = resolve_mov_path(&self.project_dir, &self.current_append_dir, file_name)?;
693        self.ensure_asset_for_path(path)
694    }
695
696    pub fn ensure_omv_asset(&mut self, file_name: &str) -> Result<(&MovieAsset, bool)> {
697        let path = crate::resource::find_omv_path_with_append_dir(
698            &self.project_dir,
699            &self.current_append_dir,
700            file_name,
701        )?;
702        self.ensure_asset_for_path(path)
703    }
704
705    fn ensure_asset_for_path(&mut self, path: PathBuf) -> Result<(&MovieAsset, bool)> {
706        let existed = self.cache.contains_key(&path);
707        if !existed {
708            let asset = decode_asset_for_path(&path)?;
709            self.cache.insert(path.clone(), asset);
710        }
711        let asset = self.cache.get(&path).expect("asset cached");
712        Ok((asset, !existed))
713    }
714
715    pub fn poll_asset(&mut self, file_name: &str) -> Result<Option<(&MovieAsset, bool)>> {
716        let path = resolve_mov_path(&self.project_dir, &self.current_append_dir, file_name)?;
717        self.poll_asset_for_path(path)
718    }
719
720    pub fn poll_omv_asset(&mut self, file_name: &str) -> Result<Option<(&MovieAsset, bool)>> {
721        let path = crate::resource::find_omv_path_with_append_dir(
722            &self.project_dir,
723            &self.current_append_dir,
724            file_name,
725        )?;
726        self.poll_asset_for_path(path)
727    }
728
729    fn poll_asset_for_path(&mut self, path: PathBuf) -> Result<Option<(&MovieAsset, bool)>> {
730        if self.cache.contains_key(&path) {
731            let asset = self.cache.get(&path).expect("asset cached");
732            return Ok(Some((asset, false)));
733        }
734
735        let mut completed = None;
736        let mut failed = None;
737        if let Some(rx) = self.decode_tasks.get(&path) {
738            match rx.try_recv() {
739                Ok(Ok(asset)) => completed = Some(asset),
740                Ok(Err(err)) => failed = Some(err),
741                Err(TryRecvError::Empty) => {}
742                Err(TryRecvError::Disconnected) => {
743                    failed = Some(format!(
744                        "movie decode worker disconnected: {}",
745                        path.display()
746                    ));
747                }
748            }
749        } else {
750            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
751            {
752                let asset = decode_asset_for_path(&path)?;
753                self.cache.insert(path.clone(), asset);
754                let asset = self.cache.get(&path).expect("asset cached");
755                return Ok(Some((asset, true)));
756            }
757            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
758            {
759                let (tx, rx) = mpsc::channel();
760                let worker_path = path.clone();
761                thread::spawn(move || {
762                    lower_worker_thread_priority();
763                    let result =
764                        decode_asset_for_path(&worker_path).map_err(|e| format!("{:#}", e));
765                    let _ = tx.send(result);
766                });
767                self.decode_tasks.insert(path.clone(), rx);
768            }
769        }
770
771        if let Some(err) = failed {
772            self.decode_tasks.remove(&path);
773            return Err(anyhow!(err));
774        }
775        if let Some(asset) = completed {
776            self.decode_tasks.remove(&path);
777            self.cache.insert(path.clone(), asset);
778            let asset = self.cache.get(&path).expect("asset cached");
779            return Ok(Some((asset, true)));
780        }
781
782        Ok(None)
783    }
784
785    pub fn poll_global_movie_frame(
786        &mut self,
787        file_name: &str,
788        timer_ms: u64,
789    ) -> Result<Option<MovieStreamFrame>> {
790        self.poll_global_movie_frame_with_loop(file_name, timer_ms, false)
791    }
792
793    pub fn poll_global_movie_frame_with_loop(
794        &mut self,
795        file_name: &str,
796        timer_ms: u64,
797        loop_flag: bool,
798    ) -> Result<Option<MovieStreamFrame>> {
799        let path = self.resolve_mov_path_cached(file_name)?;
800        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
801        {
802            return self.poll_cached_movie_frame_for_path_with_loop(path, timer_ms, loop_flag);
803        }
804        let ext = path
805            .extension()
806            .and_then(|s| s.to_str())
807            .unwrap_or("")
808            .to_ascii_lowercase();
809        if ext == "omv" {
810            return self.poll_omv_stream_frame_for_path(path, timer_ms, loop_flag);
811        }
812        if is_wmv_extension(&ext) {
813            return self.poll_wmv_stream_frame_for_path(path, timer_ms, loop_flag);
814        }
815        self.poll_mpeg2_stream_frame_for_path(path, timer_ms)
816    }
817
818    fn resolve_mov_path_cached(&mut self, file_name: &str) -> Result<PathBuf> {
819        let key = (self.current_append_dir.clone(), file_name.to_string());
820        if let Some(path) = self.path_cache.get(&key) {
821            return Ok(path.clone());
822        }
823        let path = resolve_mov_path(&self.project_dir, &self.current_append_dir, file_name)?;
824        self.path_cache.insert(key, path.clone());
825        Ok(path)
826    }
827
828    fn poll_cached_movie_frame_for_path(
829        &mut self,
830        path: PathBuf,
831        timer_ms: u64,
832    ) -> Result<Option<MovieStreamFrame>> {
833        self.poll_cached_movie_frame_for_path_with_loop(path, timer_ms, false)
834    }
835
836    fn poll_cached_movie_frame_for_path_with_loop(
837        &mut self,
838        path: PathBuf,
839        timer_ms: u64,
840        loop_flag: bool,
841    ) -> Result<Option<MovieStreamFrame>> {
842        let (asset, decoded_now) = match self.poll_asset_for_path(path)? {
843            Some(v) => v,
844            None => return Ok(None),
845        };
846        if asset.frames.is_empty() {
847            return Ok(None);
848        }
849
850        let fps = asset.info.fps.unwrap_or_else(|| {
851            asset
852                .info
853                .duration_ms()
854                .filter(|ms| *ms > 0)
855                .map(|ms| (asset.frames.len() as f32) * 1000.0 / (ms as f32))
856                .unwrap_or(0.0)
857        });
858        let effective_timer_ms = if loop_flag {
859            asset
860                .info
861                .duration_ms()
862                .filter(|ms| *ms > 0)
863                .map(|ms| timer_ms % ms)
864                .unwrap_or(timer_ms)
865        } else {
866            timer_ms
867        };
868        let mut idx = frame_index_for_timer(effective_timer_ms, fps, asset.frames.len());
869        if idx >= asset.frames.len() {
870            idx = asset.frames.len() - 1;
871        }
872
873        Ok(Some(MovieStreamFrame {
874            frame: asset.frames[idx].clone(),
875            frame_idx: idx,
876            fps: (fps > 0.0).then_some(fps),
877            total_ms: asset.info.duration_ms(),
878            audio: asset.audio.clone(),
879            audio_ready: true,
880            decoded_now,
881            frame_idx_is_decode_order: false,
882            clamped_timer_ms: None,
883            source_size: asset.info.width.zip(asset.info.height),
884        }))
885    }
886
887    fn poll_mpeg2_stream_frame_for_path(
888        &mut self,
889        path: PathBuf,
890        timer_ms: u64,
891    ) -> Result<Option<MovieStreamFrame>> {
892        let (audio, audio_ready) = self.poll_mpeg2_audio_for_path(path.as_path());
893        if !self.mpeg2_streams.contains_key(&path) {
894            let state = spawn_mpeg2_stream_state(path.clone(), audio.clone(), timer_ms)?;
895            self.mpeg2_streams.insert(path.clone(), state);
896        } else if audio_ready && let Some(state) = self.mpeg2_streams.get_mut(&path) {
897            state.audio = audio.clone();
898            reindex_mpeg_stream_frames(state);
899        }
900
901        let desired_frame_idx = self.mpeg2_streams.get(&path).and_then(|state| {
902            state
903                .fps
904                .filter(|f| *f > 0.0)
905                .map(|fps| ((timer_ms as f64) * (fps as f64) / 1000.0).floor() as usize)
906        });
907        let restart_stream = self
908            .mpeg2_streams
909            .get(&path)
910            .is_some_and(|state| mpeg_stream_needs_restart(state, timer_ms));
911        if restart_stream {
912            self.mpeg2_streams.remove(&path);
913            let state = spawn_mpeg2_stream_state(path.clone(), audio.clone(), timer_ms)?;
914            self.mpeg2_streams.insert(path.clone(), state);
915        }
916
917        let state = self
918            .mpeg2_streams
919            .get_mut(&path)
920            .expect("mpeg2 stream state exists");
921        #[cfg(target_os = "vita")]
922        {
923            state.idle_frames = 0;
924        }
925        state.last_requested_timer_ms = timer_ms;
926        let request_until = desired_frame_idx
927            .unwrap_or(0)
928            .saturating_add(MPEG2_STREAM_DECODE_LEAD_FRAMES);
929        state.request_frames.store(request_until, Ordering::Release);
930        drain_mpeg2_stream_state(path.as_path(), state, desired_frame_idx, timer_ms)?;
931
932        if state.frames.is_empty() {
933            return Ok(None);
934        }
935
936        let Some(chosen) =
937            select_mpeg_stream_frame(&state.frames, state, timer_ms, desired_frame_idx).cloned()
938        else {
939            return Ok(None);
940        };
941
942        let video_total_ms = if state.done {
943            state.last_video_timeline_ms.map(|last| {
944                let frame_ms = state
945                    .fps
946                    .filter(|fps| *fps > 0.0)
947                    .map(|fps| (1000.0 / fps as f64).round() as u64)
948                    .unwrap_or(0);
949                last.saturating_add(frame_ms)
950            })
951        } else {
952            None
953        };
954        let audio_total_ms = state.audio.as_ref().map(|a| a.end_ms());
955        let total_ms = match (audio_total_ms, video_total_ms) {
956            (Some(a), Some(v)) => Some(a.max(v)),
957            (Some(a), None) => Some(a),
958            (None, Some(v)) => Some(v),
959            (None, None) => None,
960        };
961
962        let audio = state
963            .audio
964            .as_ref()
965            .filter(|track| timer_ms < track.end_ms())
966            .cloned();
967        let decoded_now = state.decoded_any_this_poll;
968        state.decoded_any_this_poll = false;
969
970        Ok(Some(MovieStreamFrame {
971            frame: chosen.frame,
972            frame_idx: chosen.frame_idx,
973            fps: state.fps,
974            total_ms,
975            audio,
976            audio_ready,
977            decoded_now,
978            frame_idx_is_decode_order: false,
979            clamped_timer_ms: None,
980            source_size: state.width.zip(state.height),
981        }))
982    }
983
984    fn poll_mpeg2_audio_for_path(&mut self, path: &Path) -> (Option<MovieAudio>, bool) {
985        if let Some(audio) = self.mpeg2_audio_cache.get(path) {
986            return (audio.clone(), true);
987        }
988
989        let mut completed = None;
990        let mut failed = None;
991        if let Some(task) = self.mpeg2_audio_tasks.get(path) {
992            match task.rx.try_recv() {
993                Ok(Ok(audio)) => completed = Some(audio),
994                Ok(Err(err)) => failed = Some(err),
995                Err(TryRecvError::Empty) => return (None, false),
996                Err(TryRecvError::Disconnected) => {
997                    failed = Some(format!(
998                        "mpeg2 audio worker disconnected: {}",
999                        path.display()
1000                    ));
1001                }
1002            }
1003        } else {
1004            let (tx, rx) = mpsc::channel();
1005            let cancel = Arc::new(AtomicBool::new(false));
1006            let worker_cancel = cancel.clone();
1007            let worker_path = path.to_path_buf();
1008            thread::spawn(move || {
1009                lower_worker_thread_priority();
1010                let result = decode_mpeg2_audio_for_path(&worker_path, worker_cancel.as_ref())
1011                    .map_err(|err| format!("{:#}", err));
1012                let _ = tx.send(result);
1013            });
1014            self.mpeg2_audio_tasks
1015                .insert(path.to_path_buf(), Mpeg2AudioTask { rx, cancel });
1016            return (None, false);
1017        }
1018
1019        self.mpeg2_audio_tasks.remove(path);
1020        if let Some(err) = failed {
1021            eprintln!(
1022                "[SG_MOV] mpeg2 audio decode failed path={} err={}",
1023                path.display(),
1024                err
1025            );
1026            self.mpeg2_audio_cache.insert(path.to_path_buf(), None);
1027            return (None, true);
1028        }
1029        let audio = completed.unwrap_or(None);
1030        self.mpeg2_audio_cache
1031            .insert(path.to_path_buf(), audio.clone());
1032        (audio, true)
1033    }
1034
1035    fn poll_wmv_stream_frame_for_path(
1036        &mut self,
1037        path: PathBuf,
1038        timer_ms: u64,
1039        loop_flag: bool,
1040    ) -> Result<Option<MovieStreamFrame>> {
1041        let (audio, audio_ready) = self.poll_wmv_audio_for_path(path.as_path());
1042        if !self.wmv_streams.contains_key(&path) {
1043            let state = spawn_wmv_stream_state(path.clone(), audio.clone(), timer_ms)?;
1044            self.wmv_streams.insert(path.clone(), state);
1045        } else if audio_ready && let Some(state) = self.wmv_streams.get_mut(&path) {
1046            state.audio = audio.clone();
1047        }
1048
1049        let duration_hint = self.wmv_streams.get(&path).and_then(|state| {
1050            let video_total = state.total_ms_hint.or_else(|| {
1051                if state.done {
1052                    match (state.timeline_origin_ms, state.last_video_source_pts_ms) {
1053                        (Some(origin), Some(last)) => Some(
1054                            last.saturating_sub(origin)
1055                                .saturating_add(state.last_frame_delta_ms.unwrap_or(0)),
1056                        ),
1057                        _ => None,
1058                    }
1059                } else {
1060                    None
1061                }
1062            });
1063            let audio_total = state.audio.as_ref().map(MovieAudio::end_ms);
1064            match (video_total, audio_total) {
1065                (Some(v), Some(a)) => Some(v.max(a)),
1066                (Some(v), None) => Some(v),
1067                (None, Some(a)) => Some(a),
1068                (None, None) => None,
1069            }
1070        });
1071
1072        // Video presentation is always driven by the movie clock. Audio probing/decoding
1073        // is asynchronous and must never clamp the WMV timeline to zero: doing so freezes
1074        // the picture on the first frame whenever the soundtrack is not ready yet. Once
1075        // decoded PCM becomes available, audio starts at the current movie position and
1076        // may then become the master clock through the normal playback-handle path.
1077        let effective_timer_ms = if loop_flag {
1078            duration_hint
1079                .filter(|duration| *duration > 0)
1080                .map(|duration| timer_ms % duration)
1081                .unwrap_or(timer_ms)
1082        } else {
1083            timer_ms
1084        };
1085
1086        let restart_stream = self
1087            .wmv_streams
1088            .get(&path)
1089            .map(|state| effective_timer_ms.saturating_add(100) < state.last_effective_timer_ms)
1090            .unwrap_or(false);
1091        if restart_stream {
1092            self.wmv_streams.remove(&path);
1093            let state = spawn_wmv_stream_state(path.clone(), audio.clone(), effective_timer_ms)?;
1094            self.wmv_streams.insert(path.clone(), state);
1095        }
1096
1097        let state = self
1098            .wmv_streams
1099            .get_mut(&path)
1100            .expect("wmv stream state exists");
1101        #[cfg(target_os = "vita")]
1102        {
1103            state.idle_frames = 0;
1104        }
1105        state.last_effective_timer_ms = effective_timer_ms;
1106        let request_ms = effective_timer_ms
1107            .saturating_add(WMV_STREAM_DECODE_LEAD_MS as u64)
1108            .min(usize::MAX as u64) as usize;
1109        state.request_ms.store(request_ms, Ordering::Release);
1110        drain_wmv_stream_state(path.as_path(), state, effective_timer_ms)?;
1111
1112        // Match wmv-player-wgpu: the decode thread must never choose the movie
1113        // clock origin. WMV3 B pictures are emitted in coded order, so first
1114        // sort by ASF PTS and only then establish the presentation origin from
1115        // the earliest queued presentation frame.
1116        if state.timeline_origin_ms.is_none()
1117            && let Some(first) = state.frames.front()
1118        {
1119            let origin = first.source_pts_ms;
1120            state.timeline_origin_ms = Some(origin);
1121            state.total_ms_hint = state
1122                .total_ms_hint
1123                .and_then(|total| wmv_rebase_duration_ms(total, origin));
1124        }
1125        let Some(origin_ms) = state.timeline_origin_ms else {
1126            return Ok(None);
1127        };
1128        let presentation_pts_ms = origin_ms.saturating_add(effective_timer_ms);
1129
1130        // Now that the presentation origin is known, stale-frame trimming can
1131        // use the same absolute PTS domain as selection.
1132        discard_wmv_stream_frames(state, presentation_pts_ms);
1133
1134        let Some(chosen) = select_wmv_stream_frame(&state.frames, presentation_pts_ms).cloned()
1135        else {
1136            return Ok(None);
1137        };
1138
1139        let video_total_ms = state.total_ms_hint.or_else(|| {
1140            if state.done {
1141                state.last_video_source_pts_ms.map(|last| {
1142                    last.saturating_sub(origin_ms)
1143                        .saturating_add(state.last_frame_delta_ms.unwrap_or(0))
1144                })
1145            } else {
1146                None
1147            }
1148        });
1149        let audio_total_ms = state.audio.as_ref().map(MovieAudio::end_ms);
1150        let total_ms = match (audio_total_ms, video_total_ms) {
1151            (Some(a), Some(v)) => Some(a.max(v)),
1152            (Some(a), None) => Some(a),
1153            (None, Some(v)) => Some(v),
1154            (None, None) => None,
1155        };
1156        let audio = state
1157            .audio
1158            .as_ref()
1159            .filter(|track| loop_flag || effective_timer_ms < track.end_ms())
1160            .cloned();
1161        let decoded_now = state.decoded_any_this_poll;
1162        state.decoded_any_this_poll = false;
1163
1164        Ok(Some(MovieStreamFrame {
1165            frame: chosen.frame,
1166            frame_idx: chosen.frame_idx,
1167            fps: state.fps,
1168            total_ms,
1169            audio,
1170            audio_ready,
1171            decoded_now,
1172            frame_idx_is_decode_order: true,
1173            clamped_timer_ms: loop_flag.then_some(effective_timer_ms),
1174            source_size: state.width.zip(state.height),
1175        }))
1176    }
1177
1178    fn poll_wmv_audio_for_path(&mut self, path: &Path) -> (Option<MovieAudio>, bool) {
1179        if let Some(audio) = self.wmv_audio_cache.get(path) {
1180            return (audio.clone(), true);
1181        }
1182
1183        let mut completed = None;
1184        let mut failed = None;
1185        if let Some(task) = self.wmv_audio_tasks.get(path) {
1186            match task.rx.try_recv() {
1187                Ok(Ok(audio)) => completed = Some(audio),
1188                Ok(Err(err)) => failed = Some(err),
1189                Err(TryRecvError::Empty) => return (None, false),
1190                Err(TryRecvError::Disconnected) => {
1191                    failed = Some(format!("wmv audio worker disconnected: {}", path.display()));
1192                }
1193            }
1194        } else {
1195            let (tx, rx) = mpsc::channel();
1196            let cancel = Arc::new(AtomicBool::new(false));
1197            let worker_cancel = cancel.clone();
1198            let worker_path = path.to_path_buf();
1199            thread::spawn(move || {
1200                lower_worker_thread_priority();
1201                let result = decode_wmv_audio_for_path(&worker_path, worker_cancel.as_ref())
1202                    .map_err(|err| format!("{:#}", err));
1203                let _ = tx.send(result);
1204            });
1205            self.wmv_audio_tasks
1206                .insert(path.to_path_buf(), WmvAudioTask { rx, cancel });
1207            return (None, false);
1208        }
1209
1210        self.wmv_audio_tasks.remove(path);
1211        if let Some(err) = failed {
1212            eprintln!(
1213                "[SG_MOV] wmv audio decode failed path={} err={}",
1214                path.display(),
1215                err
1216            );
1217            self.wmv_audio_cache.insert(path.to_path_buf(), None);
1218            return (None, true);
1219        }
1220        let audio = completed.unwrap_or(None);
1221        self.wmv_audio_cache
1222            .insert(path.to_path_buf(), audio.clone());
1223        (audio, true)
1224    }
1225
1226    fn poll_omv_stream_frame_for_path(
1227        &mut self,
1228        path: PathBuf,
1229        timer_ms: u64,
1230        loop_flag: bool,
1231    ) -> Result<Option<MovieStreamFrame>> {
1232        if !self.omv_streams.contains_key(&path) {
1233            let state = spawn_omv_stream_state(path.clone())?;
1234            self.omv_streams.insert(path.clone(), state);
1235        }
1236        #[cfg(feature = "virtual-clock")]
1237        if let Some(state) = self.omv_streams.get_mut(&path) {
1238            settle_omv_stream(&path, state, None, |state| {
1239                state.fps.is_some() || state.frame_times.is_some()
1240            })?;
1241        }
1242
1243        let effective_timer_ms = self
1244            .omv_streams
1245            .get(&path)
1246            .and_then(|state| {
1247                if !loop_flag {
1248                    return None;
1249                }
1250                state
1251                    .total_ms_hint
1252                    .or_else(|| {
1253                        state.total_frames_hint.and_then(|frames| {
1254                            state
1255                                .frame_time_ms
1256                                .filter(|ms| *ms > 0.0)
1257                                .map(|ms| ((frames as f64) * ms).round() as u64)
1258                        })
1259                    })
1260                    .filter(|duration| *duration > 0)
1261            })
1262            .map(|duration| timer_ms % duration)
1263            .unwrap_or(timer_ms);
1264        let desired_before_drain = self.omv_streams.get(&path).and_then(|state| {
1265            if let Some(frame_times) = state.frame_times.as_deref() {
1266                return omv_frame_for_time(frame_times, effective_timer_ms);
1267            }
1268            if let Some(ms) = state.frame_time_ms.filter(|v| *v > 0.0) {
1269                Some(((effective_timer_ms as f64) / ms).floor() as usize)
1270            } else {
1271                state.fps.filter(|f| *f > 0.0).map(|fps| {
1272                    ((effective_timer_ms as f64) * (fps as f64) / 1000.0).floor() as usize
1273                })
1274            }
1275        });
1276
1277        let state = self
1278            .omv_streams
1279            .get_mut(&path)
1280            .expect("omv stream state exists");
1281        #[cfg(target_os = "vita")]
1282        {
1283            state.idle_frames = 0;
1284        }
1285        let timer_rewound = effective_timer_ms < state.last_effective_timer_ms.saturating_sub(200);
1286        state.last_effective_timer_ms = effective_timer_ms;
1287        let desired_before_drain = if timer_rewound {
1288            // Genuine loop wrap / explicit rewind: let the stream seek back and
1289            // restart the replay from the head.
1290            state.last_served_frame_idx = 0;
1291            desired_before_drain
1292        } else {
1293            // Timer jitter must never make the requested frame go backward:
1294            // an indexed seek would rewind the displayed picture (flash).
1295            desired_before_drain.map(|idx| idx.max(state.last_served_frame_idx))
1296        };
1297        let requested_frame = desired_before_drain.unwrap_or(0);
1298        state
1299            .request_frame
1300            .store(requested_frame, Ordering::Release);
1301        drain_omv_stream_state(
1302            path.as_path(),
1303            state,
1304            desired_before_drain,
1305            false,
1306            !cfg!(target_os = "vita"),
1307        )?;
1308        #[cfg(feature = "virtual-clock")]
1309        if let Some(desired) = desired_before_drain {
1310            settle_omv_stream(&path, state, desired_before_drain, |state| {
1311                state.frames.back().is_some_and(|(idx, _)| *idx >= desired)
1312            })?;
1313        }
1314
1315        let has_loop_head = loop_flag && !state.loop_head_frames.is_empty();
1316        if state.frames.is_empty() && !has_loop_head {
1317            return Ok(None);
1318        }
1319
1320        let latest_idx = state.decoded_frames.saturating_sub(1);
1321        let desired_idx = desired_before_drain.unwrap_or(latest_idx);
1322        let selected = if loop_flag || timer_rewound {
1323            // Loop wraps (and object-driven restarts) rewind the worker to a
1324            // key frame. Serve the cached loop head during that window so the
1325            // picture restarts seamlessly instead of freezing or fast-forwarding.
1326            select_omv_loop_frame(state, desired_idx)
1327        } else {
1328            select_stream_frame(&state.frames, desired_idx.min(latest_idx))
1329        };
1330        let Some((mut actual_frame_idx, mut frame)) = selected else {
1331            return Ok(None);
1332        };
1333        // A loop wrap rewinds the worker to a key frame; while it re-decodes
1334        // from the head, the live queue tail is far behind the timer. Serving
1335        // that tail would fast-forward stale head frames (visible flash).
1336        if !timer_rewound
1337            && actual_frame_idx < state.last_served_frame_idx
1338            && let Some((held_idx, held_frame)) = state.held_frame.clone()
1339        {
1340            actual_frame_idx = held_idx;
1341            frame = held_frame;
1342        }
1343        state.last_served_frame_idx = if timer_rewound {
1344            actual_frame_idx
1345        } else {
1346            state.last_served_frame_idx.max(actual_frame_idx)
1347        };
1348        state.held_frame = Some((actual_frame_idx, frame.clone()));
1349
1350        let video_total_ms = state.total_ms_hint.or_else(|| {
1351            if state.done && state.decoded_frames > 0 {
1352                state
1353                    .frame_time_ms
1354                    .map(|ms| ((state.decoded_frames as f64) * ms).round() as u64)
1355                    .or_else(|| {
1356                        state.fps.filter(|f| *f > 0.0).map(|fps| {
1357                            ((state.decoded_frames as f64) * 1000.0 / (fps as f64)).round() as u64
1358                        })
1359                    })
1360            } else {
1361                state.total_frames_hint.and_then(|frames| {
1362                    state
1363                        .frame_time_ms
1364                        .map(|ms| ((frames as f64) * ms).round() as u64)
1365                        .or_else(|| {
1366                            state
1367                                .fps
1368                                .filter(|f| *f > 0.0)
1369                                .map(|fps| ((frames as f64) * 1000.0 / (fps as f64)).round() as u64)
1370                        })
1371                })
1372            }
1373        });
1374
1375        Ok(Some(MovieStreamFrame {
1376            frame,
1377            frame_idx: actual_frame_idx,
1378            fps: state.fps,
1379            total_ms: video_total_ms,
1380            audio: None,
1381            audio_ready: true,
1382            decoded_now: false,
1383            frame_idx_is_decode_order: false,
1384            clamped_timer_ms: None,
1385            source_size: state.width.zip(state.height),
1386        }))
1387    }
1388
1389    pub fn ensure_preview_frame(&mut self, file_name: &str) -> Result<Arc<RgbaImage>> {
1390        let path = resolve_mov_path(&self.project_dir, &self.current_append_dir, file_name)?;
1391        self.ensure_preview_frame_for_path(path)
1392    }
1393
1394    pub fn ensure_omv_preview_frame(&mut self, file_name: &str) -> Result<Arc<RgbaImage>> {
1395        let path = crate::resource::find_omv_path_with_append_dir(
1396            &self.project_dir,
1397            &self.current_append_dir,
1398            file_name,
1399        )?;
1400        self.ensure_preview_frame_for_path(path)
1401    }
1402
1403    fn ensure_preview_frame_for_path(&mut self, path: PathBuf) -> Result<Arc<RgbaImage>> {
1404        if let Some(frame) = self.preview_cache.get(&path) {
1405            return Ok(frame.clone());
1406        }
1407        let ext = path
1408            .extension()
1409            .and_then(|s| s.to_str())
1410            .unwrap_or("")
1411            .to_ascii_lowercase();
1412        let frame = if ext == "omv" {
1413            decode_omv_preview_frame(&path)?
1414        } else if is_wmv_extension(&ext) {
1415            decode_wmv_preview_frame(&path)?
1416        } else {
1417            decode_mpeg2_preview_frame(&path)?
1418        };
1419        self.preview_cache.insert(path, frame.clone());
1420        Ok(frame)
1421    }
1422    pub fn start_audio(
1423        &mut self,
1424        audio: &mut AudioHub,
1425        track: &MovieAudio,
1426        offset_ms: u64,
1427        loop_flag: bool,
1428    ) -> Result<u64> {
1429        #[cfg(target_os = "horizon")]
1430        crate::switch_host::report_switch_marker(b"siglus_switch: movie audio start begin\n\0");
1431        let local_offset_ms = offset_ms.saturating_sub(track.start_ms);
1432
1433        // Kira 0.9 intentionally does not expose `sound::streaming` on wasm32.
1434        // The wasm movie path already decodes MPEG/WMV/OMV audio into
1435        // `track.samples` from the browser VFS. Native keeps compressed MPEG
1436        // and ASF/WMA audio bounded and incremental.
1437        #[cfg(not(target_arch = "wasm32"))]
1438        let (handle, timeline_base_ms) = if let Some(stream) = track.mpeg_stream.as_ref() {
1439            let decoder = MpegMovieAudioDecoder::new(stream.clone(), track.sample_rate)?;
1440            let mut data = StreamingSoundData::from_decoder(decoder)
1441                .start_position(local_offset_ms as f64 / 1000.0);
1442            if loop_flag {
1443                data = data.loop_region(..);
1444            }
1445            (
1446                MoviePlaybackHandle::Streaming(audio.play_streaming(TrackKind::Mov, data)?),
1447                0,
1448            )
1449        } else if let Some(stream) = track.wmv_stream.as_ref() {
1450            let mut decoder = WmvMovieAudioDecoder::new(stream.clone(), track.sample_rate)?;
1451
1452            // The standalone WMV player feeds AsfWmaDecoder strictly forward and does
1453            // not ask Kira to seek the stateful WMA bit reservoir before playback.
1454            // Keep the engine path identical for normal (non-looping) MOV playback:
1455            // advance to the requested movie offset synchronously, then hand the
1456            // already-positioned decoder to Kira at source position zero.  Calling
1457            // StreamingSoundData::start_position() here makes Kira drive Decoder::seek
1458            // on its decode scheduler; that path was the only material difference from
1459            // the standalone player and could leave WMA1/2 superframe state misaligned
1460            // ("frame_len overflow") while the same file decoded correctly standalone.
1461            let timeline_base_ms = if !loop_flag && local_offset_ms > 0 {
1462                let requested_frames = (((local_offset_ms as u128) * track.sample_rate as u128)
1463                    / 1000)
1464                    .min(stream.num_frames.saturating_sub(1) as u128)
1465                    as usize;
1466                let actual_frames = decoder.prime_to_timeline_frame(requested_frames)?;
1467                track.start_ms.saturating_add(
1468                    ((actual_frames as u128) * 1000 / track.sample_rate as u128)
1469                        .min(u64::MAX as u128) as u64,
1470                )
1471            } else {
1472                track.start_ms
1473            };
1474
1475            let mut data = StreamingSoundData::from_decoder(decoder);
1476            if loop_flag {
1477                // Looping object movies still need Kira's source-relative loop/seek
1478                // semantics.  Their normal restart path begins from zero.
1479                data = data
1480                    .start_position(local_offset_ms as f64 / 1000.0)
1481                    .loop_region(..);
1482            }
1483            (
1484                MoviePlaybackHandle::Streaming(audio.play_streaming(TrackKind::Mov, data)?),
1485                timeline_base_ms,
1486            )
1487        } else {
1488            make_static_movie_playback(audio, track, local_offset_ms, loop_flag)?
1489        };
1490
1491        #[cfg(target_arch = "wasm32")]
1492        let (handle, timeline_base_ms) =
1493            make_static_movie_playback(audio, track, local_offset_ms, loop_flag)?;
1494
1495        let id = self.next_playback_id;
1496        self.next_playback_id = self.next_playback_id.saturating_add(1).max(1);
1497        self.playbacks.insert(
1498            id,
1499            MoviePlayback {
1500                handle,
1501                timeline_base_ms,
1502            },
1503        );
1504        #[cfg(target_os = "horizon")]
1505        crate::switch_host::report_switch_marker(b"siglus_switch: movie audio start complete\n\0");
1506        Ok(id)
1507    }
1508
1509    pub fn pause_audio(&mut self, id: u64) {
1510        if let Some(p) = self.playbacks.get_mut(&id) {
1511            p.pause();
1512        }
1513    }
1514
1515    pub fn resume_audio(&mut self, id: u64) {
1516        if let Some(p) = self.playbacks.get_mut(&id) {
1517            p.resume();
1518        }
1519    }
1520
1521    pub fn stop_audio(&mut self, id: u64) {
1522        if let Some(mut p) = self.playbacks.remove(&id) {
1523            p.stop();
1524        }
1525    }
1526
1527    pub fn audio_playback_position_ms(&mut self, id: u64) -> Option<u64> {
1528        let (position_ms, stream_error) = {
1529            let p = self.playbacks.get_mut(&id)?;
1530            (p.movie_position_ms(), p.take_stream_error())
1531        };
1532        if let Some(err) = stream_error {
1533            eprintln!("[SG_MOV] streaming audio decoder error: {err:#}");
1534            if let Some(mut p) = self.playbacks.remove(&id) {
1535                p.stop();
1536            }
1537            return None;
1538        }
1539        Some(position_ms)
1540    }
1541
1542    pub fn audio_playback_finished(&mut self, id: u64) -> bool {
1543        let (finished, stream_error) = {
1544            let Some(p) = self.playbacks.get_mut(&id) else {
1545                return false;
1546            };
1547            (p.state() == PlaybackState::Stopped, p.take_stream_error())
1548        };
1549        if let Some(err) = stream_error {
1550            // A decoder failure is not the end of the movie.  Detach the failed
1551            // audio clock and let video continue on its own timer.  In particular,
1552            // never turn a WMA decode error into MOV.PLAY/PLAY_WAIT completion.
1553            eprintln!("[SG_MOV] streaming audio decoder error: {err:#}");
1554            if let Some(mut p) = self.playbacks.remove(&id) {
1555                p.stop();
1556            }
1557            return false;
1558        }
1559        if finished {
1560            self.playbacks.remove(&id);
1561        }
1562        finished
1563    }
1564}
1565
1566fn resolve_mov_path(
1567    project_dir: &Path,
1568    current_append_dir: &str,
1569    file_name: &str,
1570) -> Result<PathBuf> {
1571    let (path, _ty) =
1572        crate::resource::find_mov_path_with_append_dir(project_dir, current_append_dir, file_name)?;
1573    Ok(path)
1574}
1575
1576fn decode_frames_if_enabled(_path: &Path) -> Result<Option<usize>> {
1577    Ok(None)
1578}
1579
1580fn read_file_prefix(path: &Path, max_len: usize) -> Result<Vec<u8>> {
1581    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1582    {
1583        let mut out = read_movie_bytes(path)?;
1584        out.truncate(max_len.min(out.len()));
1585        return Ok(out);
1586    }
1587    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1588    {
1589        let mut file =
1590            fs::File::open(path).with_context(|| format!("open file: {}", path.display()))?;
1591        let mut out = vec![0u8; max_len.max(1)];
1592        let n = file
1593            .read(&mut out)
1594            .with_context(|| format!("read file prefix: {}", path.display()))?;
1595        out.truncate(n);
1596        Ok(out)
1597    }
1598}
1599
1600fn frame_index_for_timer(timer_ms: u64, fps: f32, frame_count: usize) -> usize {
1601    if frame_count == 0 {
1602        return 0;
1603    }
1604    if fps <= 0.0 {
1605        return 0;
1606    }
1607    ((timer_ms as f64) * (fps as f64) / 1000.0).floor() as usize
1608}
1609
1610// Cache position is not evidence of a seek: decoding can lag behind the
1611// audio clock, and sparse PTS can put the next cached frame ahead of it.
1612// Restart only for a request discontinuity outside the cached interval.
1613// In particular, MPEG files with only an initial sequence header must decode
1614// from the beginning after a seek. Repeatedly restarting a lagging worker
1615// prevents it from ever catching up.
1616fn mpeg_stream_needs_restart(state: &Mpeg2StreamState, timer_ms: u64) -> bool {
1617    let rewound = timer_ms.saturating_add(40) < state.last_requested_timer_ms;
1618    let jumped_forward = timer_ms > state.last_requested_timer_ms.saturating_add(5_000);
1619    let before_cache = state
1620        .frames
1621        .front()
1622        .and_then(|frame| mpeg_frame_timeline_ms(frame, state))
1623        .is_some_and(|front| timer_ms.saturating_add(40) < front);
1624    let after_cache = state
1625        .frames
1626        .back()
1627        .and_then(|frame| mpeg_frame_timeline_ms(frame, state))
1628        .is_some_and(|back| timer_ms > back.saturating_add(5_000));
1629    (rewound && (before_cache || state.frames.is_empty())) || (jumped_forward && after_cache)
1630}
1631
1632fn spawn_mpeg2_stream_state(
1633    path: PathBuf,
1634    audio: Option<MovieAudio>,
1635    target_ms: u64,
1636) -> Result<Mpeg2StreamState> {
1637    let prefix = read_file_prefix(&path, MPEG2_HEADER_PROBE_BYTES)?;
1638    let mut width = None;
1639    let mut height = None;
1640    let mut fps = None;
1641    if let Some(h) = siglus_assets::mpeg2::find_sequence_header(&prefix) {
1642        width = Some(h.width as u32);
1643        height = Some(h.height as u32);
1644        fps = siglus_assets::mpeg2::fps_from_frame_rate_code(h.frame_rate_code);
1645    }
1646
1647    let first_video_pts_90k = audio
1648        .as_ref()
1649        .and_then(|track| track.mpeg_stream.as_ref())
1650        .and_then(|stream| stream.first_video_pts_90k)
1651        .or_else(|| probe_first_video_pts_90k(&prefix));
1652    let start_offset = estimate_mpeg_video_seek_offset(&path, target_ms, audio.as_ref())?;
1653    let requested_frame_idx = fps
1654        .filter(|value| *value > 0.0)
1655        .map(|value| ((target_ms as f64) * value as f64 / 1000.0).floor() as usize)
1656        .unwrap_or(0);
1657    // The worker's decode counter is local to the chosen random-access start.
1658    // The receiver converts PTS back to a global movie frame index.
1659    let initial_frame_idx = 0usize;
1660    let (tx, rx) = mpsc::sync_channel(MPEG2_STREAM_CHANNEL_CAPACITY);
1661    let request_frames = Arc::new(AtomicUsize::new(
1662        requested_frame_idx.saturating_add(MPEG2_STREAM_DECODE_LEAD_FRAMES),
1663    ));
1664    let worker_request_frames = request_frames.clone();
1665    let video_path = path.clone();
1666    thread::spawn(move || {
1667        lower_worker_thread_priority();
1668        let result = stream_mpeg2_video_worker(
1669            video_path.as_path(),
1670            tx.clone(),
1671            worker_request_frames,
1672            start_offset,
1673            initial_frame_idx,
1674            first_video_pts_90k,
1675        );
1676        if let Err(err) = result {
1677            let _ = tx.send(Err(format!("{:#}", err)));
1678        }
1679    });
1680
1681    Ok(Mpeg2StreamState {
1682        rx,
1683        frames: VecDeque::new(),
1684        width,
1685        height,
1686        fps,
1687        decoded_frames: 0,
1688        first_video_pts_90k,
1689        last_video_timeline_ms: None,
1690        last_requested_timer_ms: target_ms,
1691        done: false,
1692        audio,
1693        decoded_any_this_poll: false,
1694        request_frames,
1695        #[cfg(target_os = "vita")]
1696        idle_frames: 0,
1697    })
1698}
1699
1700fn probe_first_video_pts_90k(data: &[u8]) -> Option<i64> {
1701    let mut demux = na_mpeg2_decoder::Demuxer::new_auto();
1702    demux.push(data, None).into_iter().find_map(|packet| {
1703        (packet.stream_type == na_mpeg2_decoder::StreamType::MpegVideo)
1704            .then_some(packet.pts_90k)
1705            .flatten()
1706    })
1707}
1708
1709fn estimate_mpeg_video_seek_offset(
1710    path: &Path,
1711    target_ms: u64,
1712    audio: Option<&MovieAudio>,
1713) -> Result<u64> {
1714    if target_ms < 2_000 {
1715        return Ok(0);
1716    }
1717    let file_len = fs::metadata(path)
1718        .with_context(|| format!("stat movie file: {}", path.display()))?
1719        .len();
1720    // Without the decoded audio track (not kept on Vita), the duration comes
1721    // from the last video timestamp in the file; seeking to 0 instead would
1722    // leave a slow decoder behind the clock for the rest of the movie.
1723    let duration_ms = audio
1724        .and_then(|track| track.duration_ms)
1725        .or_else(|| probe_mpeg_video_duration_ms(path))
1726        .unwrap_or(0);
1727
1728    if file_len <= MPEG2_STREAM_CHUNK_BYTES as u64 || duration_ms == 0 {
1729        return Ok(0);
1730    }
1731    let proportional = ((file_len as u128).saturating_mul(target_ms.min(duration_ms) as u128)
1732        / duration_ms as u128)
1733        .min(file_len as u128) as u64;
1734    let transport = audio
1735        .and_then(|track| track.mpeg_stream.as_ref())
1736        .map(|stream| stream.transport_stream)
1737        .unwrap_or_else(|| {
1738            read_file_prefix(path, 188 * 3)
1739                .map(|prefix| is_transport_stream_prefix(&prefix))
1740                .unwrap_or(false)
1741        });
1742
1743    let mut region_start =
1744        proportional.saturating_sub(MPEG_VIDEO_SEEK_BACKTRACK_BYTES.min(proportional));
1745    if transport {
1746        region_start -= region_start % 188;
1747    }
1748    let region_end = proportional
1749        .saturating_add(MPEG_VIDEO_SEEK_FORWARD_PROBE_BYTES)
1750        .min(file_len);
1751    let mut file = fs::File::open(path)
1752        .with_context(|| format!("open movie seek probe: {}", path.display()))?;
1753    file.seek(SeekFrom::Start(region_start))
1754        .with_context(|| format!("seek movie seek probe: {}", path.display()))?;
1755    let mut probe = vec![0u8; region_end.saturating_sub(region_start) as usize];
1756    let n = file
1757        .read(&mut probe)
1758        .with_context(|| format!("read movie seek probe: {}", path.display()))?;
1759    probe.truncate(n);
1760    if probe.is_empty() {
1761        return Ok(region_start);
1762    }
1763
1764    let target_in_probe = proportional.saturating_sub(region_start) as usize;
1765    // Start at a sequence header or, in files with only one (common for
1766    // MPEG-1), at a GOP: the worker gives the decoder the file's sequence
1767    // header first (see `mpeg_sequence_header_es`).
1768    let sequence_pos =
1769        rfind_byte_pattern_before(&probe, b"\0\0\x01\xb3", target_in_probe.min(probe.len()))
1770            .or_else(|| {
1771                rfind_byte_pattern_before(&probe, b"\0\0\x01\xb8", target_in_probe.min(probe.len()))
1772            });
1773    let Some(sequence_pos) = sequence_pos else {
1774        return Ok(0);
1775    };
1776
1777    if transport {
1778        let packet = sequence_pos / 188;
1779        for packet_index in (0..=packet).rev() {
1780            let pos = packet_index * 188;
1781            if pos + 188 > probe.len() || probe[pos] != 0x47 || (probe[pos + 1] & 0x40) == 0 {
1782                continue;
1783            }
1784            let afc = (probe[pos + 3] >> 4) & 0x3;
1785            let mut payload = pos + 4;
1786            if afc == 2 || afc == 3 {
1787                if payload >= pos + 188 {
1788                    continue;
1789                }
1790                payload = payload.saturating_add(1 + probe[payload] as usize);
1791            }
1792            if payload + 4 <= pos + 188
1793                && probe[payload] == 0
1794                && probe[payload + 1] == 0
1795                && probe[payload + 2] == 1
1796                && (0xE0..=0xEF).contains(&probe[payload + 3])
1797            {
1798                return Ok(region_start.saturating_add(pos as u64));
1799            }
1800        }
1801        return Ok(region_start.saturating_add((packet * 188) as u64));
1802    }
1803
1804    let pack_pos =
1805        rfind_byte_pattern_before(&probe, b"\0\0\x01\xba", sequence_pos).unwrap_or(sequence_pos);
1806    Ok(region_start.saturating_add(pack_pos as u64))
1807}
1808
1809/// The video elementary-stream bytes of the file's first sequence header
1810/// (with its extensions), up to the first GOP or picture.
1811fn mpeg_sequence_header_es(head: &[u8]) -> Option<Vec<u8>> {
1812    let mut demux = na_mpeg2_decoder::Demuxer::new_auto();
1813    let es: Vec<u8> = demux
1814        .push(head, None)
1815        .into_iter()
1816        .filter(|packet| packet.stream_type == na_mpeg2_decoder::StreamType::MpegVideo)
1817        .flat_map(|packet| packet.data.to_vec())
1818        .collect();
1819    let start = es.windows(4).position(|w| w == b"\0\0\x01\xb3")?;
1820    let end = es[start + 4..]
1821        .windows(4)
1822        .position(|w| w[..3] == [0, 0, 1] && (w[3] == 0xb8 || w[3] == 0x00))
1823        .map(|at| start + 4 + at)?;
1824    Some(es[start..end].to_vec())
1825}
1826
1827/// The span between the first and last video timestamps of an MPEG file,
1828/// from its head and last MiB.
1829fn probe_mpeg_video_duration_ms(path: &Path) -> Option<u64> {
1830    const TAIL_BYTES: u64 = 1024 * 1024;
1831    let head = read_file_prefix(path, MPEG2_HEADER_PROBE_BYTES).ok()?;
1832    let first = probe_first_video_pts_90k(&head)?;
1833    let mut file = fs::File::open(path).ok()?;
1834    let len = file.metadata().ok()?.len();
1835    let start = len.saturating_sub(TAIL_BYTES);
1836    file.seek(SeekFrom::Start(start)).ok()?;
1837    let mut tail = Vec::new();
1838    file.read_to_end(&mut tail).ok()?;
1839    // Begin at a pack (program stream) or packet sync (transport stream).
1840    let sync = if is_transport_stream_prefix(&head) {
1841        tail.iter().position(|&b| b == 0x47).unwrap_or(0)
1842    } else {
1843        tail.windows(4)
1844            .position(|w| w == b"\0\0\x01\xba")
1845            .unwrap_or(0)
1846    };
1847    let mut demux = na_mpeg2_decoder::Demuxer::new_auto();
1848    let last = demux
1849        .push(&tail[sync..], None)
1850        .into_iter()
1851        .filter(|packet| packet.stream_type == na_mpeg2_decoder::StreamType::MpegVideo)
1852        .filter_map(|packet| packet.pts_90k)
1853        .max()?;
1854    (last > first).then(|| ((last - first) / 90) as u64)
1855}
1856
1857fn rfind_byte_pattern_before(haystack: &[u8], needle: &[u8], end: usize) -> Option<usize> {
1858    if needle.is_empty() || haystack.len() < needle.len() {
1859        return None;
1860    }
1861    let end = end.min(haystack.len());
1862    if end < needle.len() {
1863        return None;
1864    }
1865    haystack[..end]
1866        .windows(needle.len())
1867        .rposition(|window| window == needle)
1868}
1869
1870fn wait_for_mpeg2_frame_request(request_frames: &Arc<AtomicUsize>, frame_idx: usize) {
1871    while frame_idx > request_frames.load(Ordering::Acquire) {
1872        if request_frames.load(Ordering::Acquire) == usize::MAX {
1873            return;
1874        }
1875        thread::sleep(Duration::from_millis(1));
1876    }
1877}
1878
1879fn stream_mpeg2_video_worker(
1880    path: &Path,
1881    tx: mpsc::SyncSender<Result<Mpeg2StreamEvent, String>>,
1882    request_frames: Arc<AtomicUsize>,
1883    start_offset: u64,
1884    initial_frame_idx: usize,
1885    #[allow(unused_variables)] video_origin_pts_90k: Option<i64>,
1886) -> Result<()> {
1887    let prefix = read_file_prefix(path, MPEG2_HEADER_PROBE_BYTES)?;
1888    let mut width = None;
1889    let mut height = None;
1890    let mut fps = None;
1891    if let Some(h) = siglus_assets::mpeg2::find_sequence_header(&prefix) {
1892        width = Some(h.width as u32);
1893        height = Some(h.height as u32);
1894        fps = siglus_assets::mpeg2::fps_from_frame_rate_code(h.frame_rate_code);
1895    }
1896    if tx
1897        .send(Ok(Mpeg2StreamEvent::Info { width, height, fps }))
1898        .is_err()
1899    {
1900        return Ok(());
1901    }
1902
1903    let mut file =
1904        fs::File::open(path).with_context(|| format!("open movie file: {}", path.display()))?;
1905    if start_offset > 0 {
1906        file.seek(SeekFrom::Start(start_offset))
1907            .with_context(|| format!("seek movie video: {}", path.display()))?;
1908    }
1909    let mut pipeline = na_mpeg2_decoder::MpegVideoPipeline::new();
1910    if start_offset > 0
1911        && let Some(header) = mpeg_sequence_header_es(&prefix)
1912    {
1913        // Starting mid-file: the decoder needs the sequence header, which a
1914        // file may carry only at its start.
1915        let _ = pipeline.decoder_mut().decode_shared(&header, None);
1916    }
1917    let mut buf = vec![0u8; MPEG2_STREAM_CHUNK_BYTES];
1918    let mut frame_idx = initial_frame_idx;
1919    let mut send_failed = false;
1920    #[cfg(target_os = "vita")]
1921    let mut sent_video = false;
1922
1923    loop {
1924        let n = file
1925            .read(&mut buf)
1926            .with_context(|| format!("read movie stream: {}", path.display()))?;
1927        if n == 0 {
1928            break;
1929        }
1930        pipeline
1931            .push_with(&buf[..n], None, |f| {
1932                if send_failed {
1933                    return;
1934                }
1935                wait_for_mpeg2_frame_request(&request_frames, frame_idx);
1936                if request_frames.load(Ordering::Acquire) == usize::MAX {
1937                    send_failed = true;
1938                    return;
1939                }
1940                #[cfg(target_os = "vita")]
1941                if sent_video
1942                    && vita_mpeg_frame_is_late(
1943                        f.pts_90k,
1944                        video_origin_pts_90k,
1945                        fps,
1946                        request_frames.load(Ordering::Acquire),
1947                    )
1948                {
1949                    frame_idx = frame_idx.saturating_add(1);
1950                    return;
1951                }
1952                let w = f.width as u32;
1953                let h = f.height as u32;
1954                let (w, h) = movie_output_dimensions(w, h);
1955                let mut rgba = vec![0u8; (w as usize).saturating_mul(h as usize).saturating_mul(4)];
1956                na_mpeg2_decoder::frame_to_rgba_bt601_limited_scaled(
1957                    &f, &mut rgba, w as usize, h as usize,
1958                );
1959                let frame = Arc::new(RgbaImage {
1960                    width: w,
1961                    height: h,
1962                    center_x: 0,
1963                    center_y: 0,
1964                    rgba,
1965                });
1966                let ev = Mpeg2StreamEvent::Video {
1967                    frame_idx,
1968                    pts_90k: f.pts_90k,
1969                    frame,
1970                };
1971                frame_idx = frame_idx.saturating_add(1);
1972                if tx.send(Ok(ev)).is_err() {
1973                    send_failed = true;
1974                } else {
1975                    #[cfg(target_os = "vita")]
1976                    {
1977                        sent_video = true;
1978                    }
1979                }
1980            })
1981            .context("mpeg2 stream video decode")?;
1982        if send_failed {
1983            return Ok(());
1984        }
1985    }
1986
1987    pipeline.flush_with(|f| {
1988        if send_failed {
1989            return;
1990        }
1991        wait_for_mpeg2_frame_request(&request_frames, frame_idx);
1992        if request_frames.load(Ordering::Acquire) == usize::MAX {
1993            send_failed = true;
1994            return;
1995        }
1996        #[cfg(target_os = "vita")]
1997        if sent_video
1998            && vita_mpeg_frame_is_late(
1999                f.pts_90k,
2000                video_origin_pts_90k,
2001                fps,
2002                request_frames.load(Ordering::Acquire),
2003            )
2004        {
2005            frame_idx = frame_idx.saturating_add(1);
2006            return;
2007        }
2008        let w = f.width as u32;
2009        let h = f.height as u32;
2010        let (w, h) = movie_output_dimensions(w, h);
2011        let mut rgba = vec![0u8; (w as usize).saturating_mul(h as usize).saturating_mul(4)];
2012        na_mpeg2_decoder::frame_to_rgba_bt601_limited_scaled(&f, &mut rgba, w as usize, h as usize);
2013        let frame = Arc::new(RgbaImage {
2014            width: w,
2015            height: h,
2016            center_x: 0,
2017            center_y: 0,
2018            rgba,
2019        });
2020        let ev = Mpeg2StreamEvent::Video {
2021            frame_idx,
2022            pts_90k: f.pts_90k,
2023            frame,
2024        };
2025        frame_idx = frame_idx.saturating_add(1);
2026        if tx.send(Ok(ev)).is_err() {
2027            send_failed = true;
2028        }
2029    })?;
2030
2031    if !send_failed {
2032        let _ = tx.send(Ok(Mpeg2StreamEvent::Done));
2033    }
2034    Ok(())
2035}
2036
2037#[cfg(target_os = "vita")]
2038fn vita_mpeg_frame_is_late(
2039    pts_90k: Option<i64>,
2040    origin_90k: Option<i64>,
2041    fps: Option<f32>,
2042    request_until: usize,
2043) -> bool {
2044    let (Some(pts), Some(origin), Some(fps)) = (pts_90k, origin_90k, fps) else {
2045        return false;
2046    };
2047    if fps <= 0.0 || request_until == usize::MAX {
2048        return false;
2049    }
2050    let frame_index = ((pts.saturating_sub(origin).max(0) as f64) * fps as f64 / 90_000.0) as usize;
2051    frame_index.saturating_add(4) < request_until.saturating_sub(MPEG2_STREAM_DECODE_LEAD_FRAMES)
2052}
2053
2054fn drain_mpeg2_stream_state(
2055    path: &Path,
2056    state: &mut Mpeg2StreamState,
2057    target_frame_idx: Option<usize>,
2058    target_timer_ms: u64,
2059) -> Result<()> {
2060    state.decoded_any_this_poll = false;
2061
2062    let decode_until =
2063        target_frame_idx.map(|idx| idx.saturating_add(MPEG2_STREAM_DECODE_LEAD_FRAMES));
2064
2065    for _ in 0..MPEG2_STREAM_MAX_DRAIN_EVENTS {
2066        if let Some(limit) = decode_until
2067            && state.decoded_frames > limit
2068            && !state.frames.is_empty()
2069        {
2070            break;
2071        }
2072        match state.rx.try_recv() {
2073            Ok(Ok(Mpeg2StreamEvent::Info { width, height, fps })) => {
2074                state.width = width.or(state.width);
2075                state.height = height.or(state.height);
2076                state.fps = fps.or(state.fps);
2077            }
2078            Ok(Ok(Mpeg2StreamEvent::Video {
2079                frame_idx,
2080                pts_90k,
2081                frame,
2082            })) => {
2083                if state.first_video_pts_90k.is_none() {
2084                    state.first_video_pts_90k = pts_90k;
2085                }
2086                let mut decoded = Mpeg2DecodedFrame {
2087                    frame_idx,
2088                    pts_90k,
2089                    frame,
2090                };
2091                if let Some(timeline_ms) = mpeg_frame_timeline_ms(&decoded, state) {
2092                    if let Some(fps) = state.fps.filter(|value| *value > 0.0) {
2093                        decoded.frame_idx = ((timeline_ms as f64) * fps as f64 / 1000.0)
2094                            .round()
2095                            .max(0.0) as usize;
2096                    }
2097                    state.last_video_timeline_ms = Some(
2098                        state
2099                            .last_video_timeline_ms
2100                            .map(|last| last.max(timeline_ms))
2101                            .unwrap_or(timeline_ms),
2102                    );
2103                }
2104                state.decoded_frames = state
2105                    .decoded_frames
2106                    .max(decoded.frame_idx.saturating_add(1));
2107                state.frames.push_back(decoded);
2108                state.decoded_any_this_poll = true;
2109            }
2110            Ok(Ok(Mpeg2StreamEvent::Done)) => {
2111                state.done = true;
2112                break;
2113            }
2114            Ok(Err(err)) => {
2115                return Err(anyhow!(
2116                    "mpeg2 stream decode failed for {}: {}",
2117                    path.display(),
2118                    err
2119                ));
2120            }
2121            Err(TryRecvError::Empty) => break,
2122            Err(TryRecvError::Disconnected) => {
2123                state.done = true;
2124                break;
2125            }
2126        }
2127    }
2128
2129    while state.frames.len() > 2 {
2130        let discard = state
2131            .frames
2132            .get(1)
2133            .and_then(|frame| mpeg_frame_timeline_ms(frame, state))
2134            .map(|next_ms| next_ms.saturating_add(100) < target_timer_ms)
2135            .unwrap_or_else(|| {
2136                target_frame_idx
2137                    .zip(state.frames.get(1).map(|frame| frame.frame_idx))
2138                    .map(|(target, next)| next.saturating_add(2) < target)
2139                    .unwrap_or(false)
2140            });
2141        if !discard {
2142            break;
2143        }
2144        state.frames.pop_front();
2145    }
2146    while state.frames.len() > MPEG2_STREAM_FRAME_KEEP {
2147        state.frames.pop_front();
2148    }
2149
2150    Ok(())
2151}
2152
2153fn mpeg_timeline_origin_90k(state: &Mpeg2StreamState) -> Option<i64> {
2154    let video = state.first_video_pts_90k.or_else(|| {
2155        state
2156            .audio
2157            .as_ref()
2158            .and_then(|track| track.mpeg_stream.as_ref())
2159            .and_then(|stream| stream.first_video_pts_90k)
2160    });
2161    let audio = state
2162        .audio
2163        .as_ref()
2164        .and_then(|track| track.mpeg_stream.as_ref())
2165        .map(|stream| stream.first_audio_pts_90k);
2166    match (video, audio) {
2167        (Some(video), Some(audio)) => Some(earlier_mpeg_pts_90k(video, audio)),
2168        (Some(video), None) => Some(video),
2169        (None, Some(audio)) => Some(audio),
2170        (None, None) => None,
2171    }
2172}
2173
2174fn mpeg_frame_timeline_ms(frame: &Mpeg2DecodedFrame, state: &Mpeg2StreamState) -> Option<u64> {
2175    if let (Some(pts), Some(origin)) = (frame.pts_90k, mpeg_timeline_origin_90k(state)) {
2176        return mpeg_pts_delta_90k(pts, origin)
2177            .map(|ticks| ((ticks as i128) * 1000 / 90_000).max(0) as u64);
2178    }
2179    state
2180        .fps
2181        .filter(|fps| *fps > 0.0)
2182        .map(|fps| ((frame.frame_idx as f64) * 1000.0 / fps as f64).round() as u64)
2183}
2184
2185fn reindex_mpeg_stream_frames(state: &mut Mpeg2StreamState) {
2186    let origin = mpeg_timeline_origin_90k(state);
2187    let fps = state.fps.filter(|value| *value > 0.0);
2188    let mut last_timeline = None;
2189    for frame in &mut state.frames {
2190        let timeline_ms = match (frame.pts_90k, origin) {
2191            (Some(pts), Some(origin)) => mpeg_pts_delta_90k(pts, origin)
2192                .map(|ticks| ((ticks as i128) * 1000 / 90_000).max(0) as u64),
2193            _ => fps.map(|fps| ((frame.frame_idx as f64) * 1000.0 / fps as f64).round() as u64),
2194        };
2195        if let Some(timeline_ms) = timeline_ms {
2196            if let Some(fps) = fps {
2197                frame.frame_idx = ((timeline_ms as f64) * fps as f64 / 1000.0)
2198                    .round()
2199                    .max(0.0) as usize;
2200            }
2201            last_timeline = Some(
2202                last_timeline
2203                    .map(|last: u64| last.max(timeline_ms))
2204                    .unwrap_or(timeline_ms),
2205            );
2206        }
2207    }
2208    if let Some(last) = last_timeline {
2209        state.last_video_timeline_ms = Some(
2210            state
2211                .last_video_timeline_ms
2212                .map(|current| current.max(last))
2213                .unwrap_or(last),
2214        );
2215    }
2216    state.decoded_frames = state
2217        .frames
2218        .iter()
2219        .map(|frame| frame.frame_idx.saturating_add(1))
2220        .max()
2221        .unwrap_or(state.decoded_frames);
2222}
2223
2224fn select_mpeg_stream_frame<'a>(
2225    frames: &'a VecDeque<Mpeg2DecodedFrame>,
2226    state: &Mpeg2StreamState,
2227    target_ms: u64,
2228    fallback_idx: Option<usize>,
2229) -> Option<&'a Mpeg2DecodedFrame> {
2230    let mut before = None;
2231    let mut after = None;
2232    for frame in frames {
2233        if let Some(frame_ms) = mpeg_frame_timeline_ms(frame, state) {
2234            if frame_ms <= target_ms {
2235                before = Some(frame);
2236            } else {
2237                after = Some(frame);
2238                break;
2239            }
2240        }
2241    }
2242    if let Some(frame) = before.or(after) {
2243        return Some(frame);
2244    }
2245
2246    let chosen_idx =
2247        fallback_idx.unwrap_or_else(|| frames.back().map(|f| f.frame_idx).unwrap_or(0));
2248    frames
2249        .iter()
2250        .rev()
2251        .find(|frame| frame.frame_idx <= chosen_idx)
2252        .or_else(|| frames.front())
2253}
2254
2255fn is_wmv_extension(ext: &str) -> bool {
2256    ext == "wmv"
2257}
2258
2259fn probe_wmv_movie_info(path: &Path) -> Result<MovieInfo> {
2260    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
2261    let decoder = {
2262        let bytes = read_movie_bytes(path)?;
2263        wmv_decoder::AsfWmv2Decoder::open(Cursor::new(bytes))
2264            .with_context(|| format!("open ASF/WMV: {}", path.display()))?
2265    };
2266    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
2267    let decoder = {
2268        let file =
2269            fs::File::open(path).with_context(|| format!("open ASF/WMV: {}", path.display()))?;
2270        wmv_decoder::AsfWmv2Decoder::open(BufReader::new(file))
2271            .with_context(|| format!("parse ASF/WMV: {}", path.display()))?
2272    };
2273    let duration_ms = decoder.duration_ms();
2274    let video = decoder.video_stream_info();
2275    Ok(MovieInfo {
2276        path: path.to_path_buf(),
2277        width: (video.width > 0).then_some(video.width),
2278        height: (video.height > 0).then_some(video.height),
2279        fps: None,
2280        decoded_frames: None,
2281        // MovieInfo::duration_ms treats this field as the authoritative media
2282        // duration. ASF exposes a container-level play duration, not merely audio.
2283        audio_duration_ms: duration_ms,
2284    })
2285}
2286
2287fn spawn_wmv_stream_state(
2288    path: PathBuf,
2289    audio: Option<MovieAudio>,
2290    target_ms: u64,
2291) -> Result<WmvStreamState> {
2292    let info = probe_wmv_movie_info(&path)?;
2293    let (tx, rx) = mpsc::sync_channel(WMV_STREAM_CHANNEL_CAPACITY);
2294    let request_ms = Arc::new(AtomicUsize::new(
2295        target_ms
2296            .saturating_add(WMV_STREAM_DECODE_LEAD_MS as u64)
2297            .min(usize::MAX as u64) as usize,
2298    ));
2299    let worker_request_ms = request_ms.clone();
2300    let worker_path = path.clone();
2301    thread::spawn(move || {
2302        lower_worker_thread_priority();
2303        let result = stream_wmv_video_worker(worker_path.as_path(), tx.clone(), worker_request_ms);
2304        if let Err(err) = result {
2305            let _ = tx.send(Err(format!("{:#}", err)));
2306        }
2307    });
2308
2309    Ok(WmvStreamState {
2310        rx,
2311        frames: VecDeque::new(),
2312        width: info.width,
2313        height: info.height,
2314        fps: None,
2315        decoded_frames: 0,
2316        timeline_origin_ms: None,
2317        last_video_source_pts_ms: None,
2318        last_frame_delta_ms: None,
2319        total_ms_hint: info.duration_ms(),
2320        last_effective_timer_ms: target_ms,
2321        done: false,
2322        audio,
2323        decoded_any_this_poll: false,
2324        request_ms,
2325        #[cfg(target_os = "vita")]
2326        idle_frames: 0,
2327    })
2328}
2329
2330fn stream_wmv_video_worker(
2331    path: &Path,
2332    tx: mpsc::SyncSender<Result<WmvStreamEvent, String>>,
2333    request_ms: Arc<AtomicUsize>,
2334) -> Result<()> {
2335    let file =
2336        fs::File::open(path).with_context(|| format!("open WMV movie file: {}", path.display()))?;
2337    let mut decoder = wmv_decoder::AsfWmv2Decoder::open(BufReader::new(file))
2338        .with_context(|| format!("open WMV video decoder: {}", path.display()))?;
2339    let info = decoder.video_stream_info().clone();
2340    if tx
2341        .send(Ok(WmvStreamEvent::Info {
2342            width: info.width,
2343            height: info.height,
2344        }))
2345        .is_err()
2346    {
2347        return Ok(());
2348    }
2349
2350    let trace = env_is_set!("SG_MOVIE_TRACE");
2351    if trace {
2352        eprintln!(
2353            "[SG_MOVIE_TRACE][WMV] decoder.open path={} size={}x{}",
2354            path.display(),
2355            info.width,
2356            info.height
2357        );
2358    }
2359
2360    let mut frame_idx = 0usize;
2361    loop {
2362        // The bounded channel is the decode-ahead/backpressure mechanism.  Do
2363        // not gate decoding on the current presentation clock here: WMV3/VC-1
2364        // B pictures are decoded in coded order, so a future P anchor can have
2365        // a later PTS than B pictures which have not been decoded yet.  Waiting
2366        // on that anchor's PTS before decoding the following B pictures can
2367        // stall presentation.  This matches the standalone WMV player: decode
2368        // continuously into a small bounded queue, then order/present by PTS on
2369        // the consumer side.  request_ms is retained only as the cancellation
2370        // sentinel used by WmvStreamState::drop().
2371        if request_ms.load(Ordering::Acquire) == usize::MAX {
2372            return Ok(());
2373        }
2374        let decoded = decoder
2375            .next_frame()
2376            .with_context(|| format!("decode WMV video: {}", path.display()))?;
2377        let Some(decoded) = decoded else {
2378            break;
2379        };
2380        let source_pts_ms = decoded.pts_ms as u64;
2381
2382        if trace && (frame_idx < 5 || frame_idx.is_multiple_of(60)) {
2383            eprintln!(
2384                "[SG_MOVIE_TRACE][WMV] decoded idx={} pts={}",
2385                frame_idx, source_pts_ms
2386            );
2387        }
2388
2389        let frame = Arc::new(wmv_yuv_frame_to_rgba(&decoded.frame));
2390        if tx
2391            .send(Ok(WmvStreamEvent::Video {
2392                frame_idx,
2393                source_pts_ms,
2394                frame,
2395            }))
2396            .is_err()
2397        {
2398            return Ok(());
2399        }
2400        frame_idx = frame_idx.saturating_add(1);
2401    }
2402
2403    let _ = tx.send(Ok(WmvStreamEvent::Done));
2404    Ok(())
2405}
2406
2407fn drain_wmv_stream_state(
2408    path: &Path,
2409    state: &mut WmvStreamState,
2410    target_timer_ms: u64,
2411) -> Result<()> {
2412    state.decoded_any_this_poll = false;
2413
2414    // Keep the game path bounded in exactly the same sense as
2415    // `wmv-player-wgpu`: the receiver-side presentation queue is part of the
2416    // decode-ahead bound.  Draining a bounded channel into an effectively
2417    // unbounded local queue defeats backpressure and lets the decoder race
2418    // arbitrarily far ahead of the movie/audio clock.  The previous game path
2419    // then "fixed" that growth by dropping the oldest presentation frames,
2420    // which can discard the very PTS range the renderer currently needs.
2421    //
2422    // First retire frames that are genuinely stale for the current clock. This
2423    // makes room for new decode output without ever deleting future frames just
2424    // because the decoder is faster than presentation.
2425    if let Some(origin_ms) = state.timeline_origin_ms {
2426        discard_wmv_stream_frames(state, origin_ms.saturating_add(target_timer_ms));
2427    }
2428
2429    let mut video_budget = WMV_STREAM_FRAME_KEEP.saturating_sub(state.frames.len());
2430    for _ in 0..WMV_STREAM_MAX_DRAIN_EVENTS {
2431        // Once the presentation queue is full, stop receiving Video events and
2432        // let the bounded mpsc channel block the decoder thread.  This mirrors
2433        // the standalone player's `MAX_PENDING_VIDEO` policy.  Info is always
2434        // sent before Video, and Done cannot be queued ahead of undrained Video,
2435        // so breaking here cannot hide control events that are actionable now.
2436        if video_budget == 0 {
2437            break;
2438        }
2439        match state.rx.try_recv() {
2440            Ok(Ok(WmvStreamEvent::Info { width, height })) => {
2441                state.width = (width > 0).then_some(width).or(state.width);
2442                state.height = (height > 0).then_some(height).or(state.height);
2443            }
2444            Ok(Ok(WmvStreamEvent::Video {
2445                frame_idx,
2446                source_pts_ms,
2447                frame,
2448            })) => {
2449                state.last_video_source_pts_ms = Some(
2450                    state
2451                        .last_video_source_pts_ms
2452                        .map(|last| last.max(source_pts_ms))
2453                        .unwrap_or(source_pts_ms),
2454                );
2455                state.decoded_frames = state.decoded_frames.max(frame_idx.saturating_add(1));
2456                let decoded = WmvDecodedFrame {
2457                    frame_idx,
2458                    source_pts_ms,
2459                    frame,
2460                };
2461                // WMV3/VC-1 with B pictures is decoded in coded order, while ASF PTS
2462                // is presentation order. Keep raw PTS here and sort before selecting
2463                // the presentation origin, exactly like wmv-player-wgpu.
2464                let insert_at = state
2465                    .frames
2466                    .iter()
2467                    .position(|queued| queued.source_pts_ms > decoded.source_pts_ms)
2468                    .unwrap_or(state.frames.len());
2469                state.frames.insert(insert_at, decoded);
2470                video_budget = video_budget.saturating_sub(1);
2471
2472                // Derive cadence only from adjacent presentation-order PTS. Decode-order
2473                // deltas are invalid whenever B pictures reorder around a future anchor.
2474                if insert_at > 0 {
2475                    let prev = state.frames[insert_at - 1].source_pts_ms;
2476                    let cur = state.frames[insert_at].source_pts_ms;
2477                    let delta = cur.saturating_sub(prev);
2478                    if (2..=1_000).contains(&delta) {
2479                        state.last_frame_delta_ms = Some(delta);
2480                        state.fps = Some(1000.0 / delta as f32);
2481                    }
2482                }
2483                if insert_at + 1 < state.frames.len() {
2484                    let cur = state.frames[insert_at].source_pts_ms;
2485                    let next = state.frames[insert_at + 1].source_pts_ms;
2486                    let delta = next.saturating_sub(cur);
2487                    if (2..=1_000).contains(&delta) {
2488                        state.last_frame_delta_ms = Some(delta);
2489                        state.fps = Some(1000.0 / delta as f32);
2490                    }
2491                }
2492                state.decoded_any_this_poll = true;
2493            }
2494            Ok(Ok(WmvStreamEvent::Done)) => {
2495                state.done = true;
2496                break;
2497            }
2498            Ok(Err(err)) => {
2499                return Err(anyhow!(
2500                    "wmv stream decode failed for {}: {}",
2501                    path.display(),
2502                    err
2503                ));
2504            }
2505            Err(TryRecvError::Empty) => break,
2506            Err(TryRecvError::Disconnected) => {
2507                state.done = true;
2508                break;
2509            }
2510        }
2511    }
2512
2513    // Do not discard against a movie-local timer until the consumer has chosen
2514    // a presentation origin from the PTS-sorted queue. This second pass handles
2515    // frames inserted during this drain that are already stale for a clock which
2516    // advanced while decoding.
2517    if let Some(origin_ms) = state.timeline_origin_ms {
2518        discard_wmv_stream_frames(state, origin_ms.saturating_add(target_timer_ms));
2519    }
2520    Ok(())
2521}
2522
2523fn discard_wmv_stream_frames(state: &mut WmvStreamState, target_source_pts_ms: u64) {
2524    while state.frames.len() > 2 {
2525        let discard = state
2526            .frames
2527            .get(1)
2528            .map(|frame| frame.source_pts_ms.saturating_add(100) < target_source_pts_ms)
2529            .unwrap_or(false);
2530        if !discard {
2531            break;
2532        }
2533        state.frames.pop_front();
2534    }
2535    // Do not impose the queue bound by dropping the front here.  Future frames
2536    // are valid presentation data; the bound is enforced by receiver
2537    // backpressure in `drain_wmv_stream_state`.
2538}
2539
2540fn select_wmv_stream_frame(
2541    frames: &VecDeque<WmvDecodedFrame>,
2542    target_source_pts_ms: u64,
2543) -> Option<&WmvDecodedFrame> {
2544    // Presentation is PTS-driven. Never expose a frame whose PTS is still in
2545    // the future merely because decoding has run ahead. The target is kept in
2546    // the same absolute ASF PTS domain as queued frames; rebasing happens only
2547    // at the presentation clock boundary, not in the decoder thread.
2548    let mut before = None;
2549    for frame in frames {
2550        if frame.source_pts_ms <= target_source_pts_ms {
2551            before = Some(frame);
2552        } else {
2553            break;
2554        }
2555    }
2556    before
2557}
2558
2559fn wmv_yuv_frame_to_rgba(frame: &wmv_decoder::YuvFrame) -> RgbaImage {
2560    // The original desktop engine delegates WMV presentation to the Windows
2561    // media stack. For WMV streams without explicit matrix metadata, match the
2562    // Windows/DXVA fallback: BT.601 for <=576-line SD, BT.709 for HD.
2563    let (width, height) = movie_output_dimensions(frame.width, frame.height);
2564    let rgba = wmv_decoder::yuv420p_to_rgba_scaled(frame, width, height);
2565    RgbaImage {
2566        width,
2567        height,
2568        center_x: 0,
2569        center_y: 0,
2570        rgba,
2571    }
2572}
2573
2574fn spawn_omv_stream_state(path: PathBuf) -> Result<OmvStreamState> {
2575    let (tx, rx) = mpsc::sync_channel(OMV_STREAM_CHANNEL_CAPACITY);
2576    let request_frame = Arc::new(AtomicUsize::new(0));
2577    let worker_request_frame = request_frame.clone();
2578    thread::spawn(move || {
2579        lower_worker_thread_priority();
2580        let result = stream_omv_video_worker(path.as_path(), tx.clone(), worker_request_frame);
2581        if let Err(err) = result {
2582            let _ = tx.send(Err(format!("{:#}", err)));
2583        }
2584    });
2585    Ok(OmvStreamState {
2586        rx,
2587        frames: VecDeque::new(),
2588        loop_head_frames: VecDeque::new(),
2589        loop_head_bytes: 0,
2590        width: None,
2591        height: None,
2592        fps: None,
2593        frame_time_ms: None,
2594        total_frames_hint: None,
2595        total_ms_hint: None,
2596        frame_times: None,
2597        decoded_frames: 0,
2598        done: false,
2599        request_frame,
2600        last_served_frame_idx: 0,
2601        last_effective_timer_ms: 0,
2602        held_frame: None,
2603        #[cfg(target_os = "vita")]
2604        idle_frames: 0,
2605    })
2606}
2607
2608fn stream_omv_video_worker(
2609    path: &Path,
2610    tx: mpsc::SyncSender<Result<OmvStreamEvent, String>>,
2611    request_frame: Arc<AtomicUsize>,
2612) -> Result<()> {
2613    let omv = siglus_assets::omv::OmvFile::open(path)
2614        .with_context(|| format!("open OMV index: {}", path.display()))?;
2615    let reader = omv
2616        .open_embedded_ogg_reader(path)
2617        .with_context(|| format!("open embedded Ogg stream: {}", path.display()))?;
2618    let mut video_tf = siglus_omv_decoder::TheoraVideoStream::open(reader)
2619        .with_context(|| format!("open streaming Theora decoder: {}", path.display()))?;
2620    let vinfo = video_tf.info();
2621
2622    let display_w = omv.header.display_width as i32;
2623    let display_h = omv.header.display_height as i32;
2624    let width = display_w.max(1) as u32;
2625    let height = display_h.max(1) as u32;
2626    let fps = if omv.header.frame_time_us != 0 {
2627        Some(1_000_000.0 / (omv.header.frame_time_us as f32))
2628    } else if vinfo.fps > 0.0 {
2629        Some(vinfo.fps as f32)
2630    } else {
2631        None
2632    };
2633    let frame_time_ms = omv_frame_duration_ms(Some(&omv.header), fps);
2634    let total_frames_hint = (!omv.packets.is_empty())
2635        .then_some(omv.packets.len())
2636        .or_else(|| {
2637            (omv.header.packet_count_hint > 0).then_some(omv.header.packet_count_hint as usize)
2638        });
2639    let frame_times = Arc::new(
2640        omv.packets
2641            .iter()
2642            .map(|packet| {
2643                (
2644                    u64::try_from(packet.frame_time_start.max(0)).unwrap_or(0),
2645                    u64::try_from(packet.frame_time_end.max(0)).unwrap_or(0),
2646                )
2647            })
2648            .collect::<Vec<_>>(),
2649    );
2650    let total_ms_hint = frame_times
2651        .last()
2652        .map(|(_, end)| *end)
2653        .filter(|end| *end > 0);
2654    let theora_type = omv.header.theora_type;
2655    let mut pool = OmvFramePool::default();
2656
2657    if tx
2658        .send(Ok(OmvStreamEvent::Info {
2659            width,
2660            height,
2661            fps,
2662            frame_time_ms,
2663            total_frames_hint,
2664            total_ms_hint,
2665            frame_times,
2666        }))
2667        .is_err()
2668    {
2669        return Ok(());
2670    }
2671
2672    let total_frames = total_frames_hint.unwrap_or(0);
2673    let mut next_frame_idx = 0usize;
2674    let mut last_requested_frame = 0usize;
2675    let mut eof_reported = false;
2676
2677    loop {
2678        let requested = request_frame.load(Ordering::Acquire);
2679        if requested == usize::MAX {
2680            return Ok(());
2681        }
2682        let target_frame = if total_frames > 0 {
2683            requested.min(total_frames.saturating_sub(1))
2684        } else {
2685            requested
2686        };
2687        let indexed_seek = omv_should_index_seek(&omv, last_requested_frame, target_frame);
2688
2689        if indexed_seek {
2690            let seek_result = omv.seek_point_for_frame(target_frame).and_then(|point| {
2691                video_tf.seek_to_indexed_frame(
2692                    point.file_offset,
2693                    point.key_page_file_offset,
2694                    point.first_packet_no,
2695                    point.key_frame_packet_no,
2696                    point.target_packet_no,
2697                )
2698            });
2699            let packed = match seek_result {
2700                Ok(frame) => frame,
2701                Err(index_err) => {
2702                    eprintln!(
2703                        "[SG_MOV] OMV indexed seek fallback path={} frame={} err={:#}",
2704                        path.display(),
2705                        target_frame,
2706                        index_err
2707                    );
2708                    let reader = omv.open_embedded_ogg_reader(path).with_context(|| {
2709                        format!("reopen embedded Ogg stream: {}", path.display())
2710                    })?;
2711                    let mut restarted = siglus_omv_decoder::TheoraVideoStream::open(reader)
2712                        .with_context(|| {
2713                            format!("restart streaming Theora decoder: {}", path.display())
2714                        })?;
2715                    let mut frame = None;
2716                    for _ in 0..=target_frame {
2717                        frame = restarted.read_video_frame()?;
2718                        if frame.is_none() {
2719                            break;
2720                        }
2721                    }
2722                    video_tf = restarted;
2723                    frame
2724                }
2725            };
2726
2727            if tx
2728                .send(Ok(OmvStreamEvent::Reset {
2729                    frame_idx: target_frame,
2730                }))
2731                .is_err()
2732            {
2733                return Ok(());
2734            }
2735            if let Some(buf) = packed
2736                && !send_omv_video_frame(
2737                    &tx,
2738                    &mut pool,
2739                    target_frame,
2740                    &buf,
2741                    vinfo,
2742                    display_h,
2743                    theora_type,
2744                    width,
2745                    height,
2746                )?
2747            {
2748                return Ok(());
2749            }
2750            // The indexed seek consumed the target packet even when Theora
2751            // reports a duplicate frame and produces no new pixels. Continue
2752            // with the following packet instead of relabelling it as target.
2753            next_frame_idx = target_frame.saturating_add(1);
2754            eof_reported = false;
2755        }
2756
2757        last_requested_frame = target_frame;
2758        let decode_until = if total_frames > 0 {
2759            target_frame
2760                .saturating_add(OMV_STREAM_DECODE_LEAD_FRAMES)
2761                .min(total_frames.saturating_sub(1))
2762        } else {
2763            target_frame.saturating_add(OMV_STREAM_DECODE_LEAD_FRAMES)
2764        };
2765        let mut did_work = false;
2766        while next_frame_idx <= decode_until {
2767            if request_frame.load(Ordering::Acquire) == usize::MAX {
2768                return Ok(());
2769            }
2770            let Some(frame) = video_tf.read_video_frame_with(|ycbcr| {
2771                omv_rgba_frame(
2772                    &DecoderOmvPlanes(ycbcr),
2773                    vinfo,
2774                    display_h,
2775                    theora_type,
2776                    width,
2777                    height,
2778                    &mut pool,
2779                )
2780            })?
2781            else {
2782                if !eof_reported {
2783                    if tx.send(Ok(OmvStreamEvent::Done)).is_err() {
2784                        return Ok(());
2785                    }
2786                    eof_reported = true;
2787                }
2788                break;
2789            };
2790            if tx
2791                .send(Ok(OmvStreamEvent::Video {
2792                    frame_idx: next_frame_idx,
2793                    frame,
2794                }))
2795                .is_err()
2796            {
2797                return Ok(());
2798            }
2799            next_frame_idx = next_frame_idx.saturating_add(1);
2800            did_work = true;
2801        }
2802
2803        if !did_work {
2804            thread::sleep(Duration::from_millis(1));
2805        }
2806    }
2807}
2808
2809fn omv_should_index_seek(
2810    omv: &siglus_assets::omv::OmvFile,
2811    current_frame: usize,
2812    target_frame: usize,
2813) -> bool {
2814    if target_frame < current_frame {
2815        return true;
2816    }
2817    let Some(current_packet) = omv.packets.get(current_frame) else {
2818        return false;
2819    };
2820    let Some(target_packet) = omv.packets.get(target_frame) else {
2821        return false;
2822    };
2823    if current_packet.key_frame_packet_no == target_packet.key_frame_packet_no {
2824        return false;
2825    }
2826    let Ok(key_page_no) = usize::try_from(target_packet.key_frame_page_no) else {
2827        return false;
2828    };
2829    let Some(key_page) = omv.pages.get(key_page_no) else {
2830        return false;
2831    };
2832    current_packet.own_page_no < key_page.seek_page_no
2833}
2834
2835fn send_omv_video_frame(
2836    tx: &mpsc::SyncSender<Result<OmvStreamEvent, String>>,
2837    pool: &mut OmvFramePool,
2838    frame_idx: usize,
2839    buf: &[u8],
2840    vinfo: siglus_omv_decoder::VideoInfo,
2841    display_h: i32,
2842    theora_type: u32,
2843    width: u32,
2844    height: u32,
2845) -> Result<bool> {
2846    let (uv_w, _uv_h, y_plane_len, u_plane_len, _v_plane_len) =
2847        omv_plane_layout(vinfo.frame_width, vinfo.frame_height, vinfo.fmt);
2848    let planes = PackedOmvPlanes {
2849        data: buf,
2850        offsets: [0, y_plane_len, y_plane_len.saturating_add(u_plane_len)],
2851        widths: [vinfo.frame_width.max(1) as usize, uv_w, uv_w],
2852    };
2853    let frame = omv_rgba_frame(&planes, vinfo, display_h, theora_type, width, height, pool);
2854    Ok(tx
2855        .send(Ok(OmvStreamEvent::Video { frame_idx, frame }))
2856        .is_ok())
2857}
2858
2859/// One OMV frame converted for display (downscaled on consoles, see
2860/// `movie_output_dimensions`).
2861fn omv_rgba_frame(
2862    planes: &impl OmvPlanes,
2863    vinfo: siglus_omv_decoder::VideoInfo,
2864    display_h: i32,
2865    theora_type: u32,
2866    width: u32,
2867    height: u32,
2868    pool: &mut OmvFramePool,
2869) -> Arc<RgbaImage> {
2870    let (output_width, output_height) = movie_output_dimensions(width, height);
2871    let mut frame = pool.take().unwrap_or_else(|| {
2872        Arc::new(RgbaImage {
2873            width: 0,
2874            height: 0,
2875            center_x: 0,
2876            center_y: 0,
2877            rgba: Vec::new(),
2878        })
2879    });
2880    let image = Arc::get_mut(&mut frame).expect("a pooled frame is held only by the pool");
2881    convert_omv_planes_into(
2882        planes,
2883        vinfo.frame_width,
2884        vinfo.frame_height,
2885        vinfo.fmt,
2886        width as i32,
2887        display_h,
2888        theora_type,
2889        output_width as usize,
2890        output_height as usize,
2891        &mut image.rgba,
2892    );
2893    image.width = output_width;
2894    image.height = output_height;
2895    pool.track(&frame);
2896    frame
2897}
2898
2899/// The frames an OMV worker sent, so that one the engine has let go of is
2900/// decoded into again instead of allocating a new frame (3.5 MiB at 720p,
2901/// 8.6 MiB for a 1920x1440 OMV, for every frame).
2902#[derive(Default)]
2903struct OmvFramePool {
2904    sent: VecDeque<Arc<RgbaImage>>,
2905}
2906
2907impl OmvFramePool {
2908    /// Frames followed; older ones are forgotten (they are freed as before).
2909    const MAX_TRACKED: usize = 64;
2910
2911    /// A frame nothing but the pool holds any more. The other released
2912    /// frames are freed, so no more memory is kept than before.
2913    fn take(&mut self) -> Option<Arc<RgbaImage>> {
2914        let unique = |frame: &Arc<RgbaImage>| {
2915            Arc::strong_count(frame) == 1 && Arc::weak_count(frame) == 0
2916        };
2917        let index = self.sent.iter().position(unique)?;
2918        let frame = self.sent.remove(index);
2919        self.sent.retain(|frame| !unique(frame));
2920        frame
2921    }
2922
2923    fn track(&mut self, frame: &Arc<RgbaImage>) {
2924        self.sent.push_back(frame.clone());
2925        if self.sent.len() > Self::MAX_TRACKED {
2926            self.sent.pop_front();
2927        }
2928    }
2929}
2930
2931fn select_stream_frame(
2932    frames: &VecDeque<(usize, Arc<RgbaImage>)>,
2933    chosen_idx: usize,
2934) -> Option<(usize, Arc<RgbaImage>)> {
2935    let (front_idx, _) = frames.front()?;
2936    let (back_idx, back_frame) = frames.back()?;
2937    if chosen_idx < *front_idx {
2938        let (_, front_frame) = frames.front()?;
2939        return Some((*front_idx, front_frame.clone()));
2940    }
2941    if chosen_idx >= *back_idx {
2942        return Some((*back_idx, back_frame.clone()));
2943    }
2944
2945    let direct = chosen_idx.saturating_sub(*front_idx);
2946    if let Some((idx, frame)) = frames.get(direct)
2947        && *idx == chosen_idx
2948    {
2949        return Some((*idx, frame.clone()));
2950    }
2951
2952    let mut i = direct.min(frames.len().saturating_sub(1));
2953    loop {
2954        if let Some((idx, frame)) = frames.get(i)
2955            && *idx <= chosen_idx
2956        {
2957            return Some((*idx, frame.clone()));
2958        }
2959        if i == 0 {
2960            break;
2961        }
2962        i -= 1;
2963    }
2964
2965    Some((*back_idx, back_frame.clone()))
2966}
2967
2968fn select_omv_loop_frame(
2969    state: &OmvStreamState,
2970    chosen_idx: usize,
2971) -> Option<(usize, Arc<RgbaImage>)> {
2972    let live_queue_is_after_target = state
2973        .frames
2974        .front()
2975        .map(|(idx, _)| *idx > chosen_idx)
2976        .unwrap_or(true);
2977    let target_is_in_cached_head = state
2978        .loop_head_frames
2979        .back()
2980        .map(|(idx, _)| chosen_idx <= *idx)
2981        .unwrap_or(false);
2982    if live_queue_is_after_target || target_is_in_cached_head {
2983        // During the short indexed rewind window, keep displaying the cached
2984        // beginning of the loop. If the requested frame advances past the
2985        // cache, hold its final frame instead of flashing the old loop tail.
2986        if let Some(frame) = select_stream_frame(&state.loop_head_frames, chosen_idx) {
2987            return Some(frame);
2988        }
2989    }
2990    select_stream_frame(&state.frames, chosen_idx)
2991        .or_else(|| select_stream_frame(&state.loop_head_frames, chosen_idx))
2992}
2993
2994fn cache_omv_loop_head_frame(state: &mut OmvStreamState, frame_idx: usize, frame: &Arc<RgbaImage>) {
2995    if frame_idx >= OMV_LOOP_HEAD_CACHE_MAX_FRAMES
2996        || state
2997            .loop_head_frames
2998            .iter()
2999            .any(|(cached_idx, _)| *cached_idx == frame_idx)
3000    {
3001        return;
3002    }
3003    let frame_bytes = frame.rgba.len();
3004    if !state.loop_head_frames.is_empty()
3005        && state.loop_head_bytes.saturating_add(frame_bytes) > OMV_LOOP_HEAD_CACHE_MAX_BYTES
3006    {
3007        return;
3008    }
3009    state.loop_head_bytes = state.loop_head_bytes.saturating_add(frame_bytes);
3010    state.loop_head_frames.push_back((frame_idx, frame.clone()));
3011}
3012
3013/// Virtual-clock replays only: waits (on the real clock) until the decoder
3014/// thread has delivered what a fast machine would have by now, so the frame
3015/// shown does not depend on thread timing.
3016#[cfg(feature = "virtual-clock")]
3017fn settle_omv_stream(
3018    path: &Path,
3019    state: &mut OmvStreamState,
3020    target_frame_idx: Option<usize>,
3021    ready: impl Fn(&OmvStreamState) -> bool,
3022) -> Result<()> {
3023    let start = std::time::Instant::now();
3024    let deadline = start + std::time::Duration::from_secs(5);
3025    while !ready(state) && !state.done && std::time::Instant::now() < deadline {
3026        std::thread::sleep(std::time::Duration::from_millis(1));
3027        drain_omv_stream_state(path, state, target_frame_idx, false, true)?;
3028    }
3029    OMV_SETTLE_NS.fetch_add(
3030        start.elapsed().as_nanos() as u64,
3031        std::sync::atomic::Ordering::Relaxed,
3032    );
3033    Ok(())
3034}
3035
3036/// Real time spent in `settle_omv_stream` (replays leave it out of the VM's
3037/// time).
3038#[cfg(feature = "virtual-clock")]
3039pub static OMV_SETTLE_NS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3040
3041fn drain_omv_stream_state(
3042    path: &Path,
3043    state: &mut OmvStreamState,
3044    target_frame_idx: Option<usize>,
3045    retain_from_start: bool,
3046    cache_loop_head: bool,
3047) -> Result<()> {
3048    for _ in 0..OMV_STREAM_MAX_DRAIN_EVENTS {
3049        match state.rx.try_recv() {
3050            Ok(Ok(OmvStreamEvent::Info {
3051                width,
3052                height,
3053                fps,
3054                frame_time_ms,
3055                total_frames_hint,
3056                total_ms_hint,
3057                frame_times,
3058            })) => {
3059                state.width = Some(width);
3060                state.height = Some(height);
3061                state.fps = fps.or(state.fps);
3062                state.frame_time_ms = frame_time_ms.or(state.frame_time_ms);
3063                state.total_frames_hint = total_frames_hint.or(state.total_frames_hint);
3064                state.total_ms_hint = total_ms_hint.or(state.total_ms_hint);
3065                state.frame_times = Some(frame_times);
3066            }
3067            Ok(Ok(OmvStreamEvent::Reset { frame_idx })) => {
3068                state.frames.clear();
3069                state.decoded_frames = frame_idx;
3070                state.done = false;
3071            }
3072            Ok(Ok(OmvStreamEvent::Video { frame_idx, frame })) => {
3073                state.decoded_frames = state.decoded_frames.max(frame_idx.saturating_add(1));
3074                if cache_loop_head {
3075                    cache_omv_loop_head_frame(state, frame_idx, &frame);
3076                }
3077                state.frames.push_back((frame_idx, frame));
3078            }
3079            Ok(Ok(OmvStreamEvent::Done)) => {
3080                // The worker remains alive after EOF so a loop rewind can seek
3081                // through the OMV index. Continue draining: Reset and the first
3082                // post-rewind frame may already be queued behind Done.
3083                state.done = true;
3084            }
3085            Ok(Err(err)) => {
3086                return Err(anyhow!(
3087                    "omv stream decode failed for {}: {}",
3088                    path.display(),
3089                    err
3090                ));
3091            }
3092            Err(TryRecvError::Empty) => break,
3093            Err(TryRecvError::Disconnected) => {
3094                state.done = true;
3095                break;
3096            }
3097        }
3098    }
3099
3100    if !retain_from_start {
3101        if let Some(target) = target_frame_idx {
3102            let keep_from = target.saturating_sub(2);
3103            while state
3104                .frames
3105                .front()
3106                .map(|(idx, _)| *idx < keep_from)
3107                .unwrap_or(false)
3108            {
3109                state.frames.pop_front();
3110            }
3111        }
3112        while state.frames.len() > OMV_STREAM_FRAME_KEEP {
3113            state.frames.pop_front();
3114        }
3115    }
3116    Ok(())
3117}
3118
3119fn omv_frame_for_time(frame_times: &[(u64, u64)], now_ms: u64) -> Option<usize> {
3120    if frame_times.is_empty() {
3121        return None;
3122    }
3123    let mut low = 0usize;
3124    let mut high = frame_times.len();
3125    while low < high {
3126        let mid = low + (high - low) / 2;
3127        if now_ms <= frame_times[mid].1 {
3128            high = mid;
3129        } else {
3130            low = mid.saturating_add(1);
3131        }
3132    }
3133    Some(low.min(frame_times.len().saturating_sub(1)))
3134}
3135
3136fn omv_frame_duration_ms(
3137    header: Option<&siglus_assets::omv::OmvHeader>,
3138    fps: Option<f32>,
3139) -> Option<f64> {
3140    if let Some(h) = header
3141        && h.frame_time_us != 0
3142    {
3143        return Some((h.frame_time_us as f64) / 1000.0);
3144    }
3145    let f = fps?;
3146    if f > 0.0 {
3147        Some(1000.0 / (f as f64))
3148    } else {
3149        None
3150    }
3151}
3152
3153fn omv_plane_layout(
3154    frame_width: i32,
3155    frame_height: i32,
3156    fmt: i32,
3157) -> (usize, usize, usize, usize, usize) {
3158    // The decoder now transports the complete th_decode_ycbcr_out() planes,
3159    // not the Theora picture rectangle. Plane sizes therefore follow the
3160    // coded-frame geometry and pixel format regardless of the Siglus OMV
3161    // interpretation (RGB/RGBA/YUV). RGB/RGBA assets used by the original
3162    // engine are 4:4:4, so all three planes are full-size in those files.
3163    let w = frame_width.max(1) as usize;
3164    let h = frame_height.max(1) as usize;
3165    let y_len = w.saturating_mul(h);
3166    let (uv_w, uv_h) = yuv_plane_size(frame_width, frame_height, fmt);
3167    let uv_len = uv_w.saturating_mul(uv_h);
3168    (uv_w, uv_h, y_len, uv_len, uv_len)
3169}
3170
3171#[derive(Debug, Clone)]
3172pub struct MovieAsset {
3173    pub info: MovieInfo,
3174    pub frames: Vec<Arc<RgbaImage>>,
3175    pub audio: Option<MovieAudio>,
3176}
3177
3178#[derive(Debug, Clone)]
3179pub struct MpegStreamAudio {
3180    pub path: Arc<PathBuf>,
3181    pub num_frames: usize,
3182    pub first_audio_pts_ms: i64,
3183    pub first_video_pts_ms: Option<i64>,
3184    pub first_audio_pts_90k: i64,
3185    pub first_video_pts_90k: Option<i64>,
3186    pub audio_end_pts_90k: i64,
3187    pub file_len: u64,
3188    pub transport_stream: bool,
3189}
3190
3191#[derive(Debug, Clone)]
3192pub struct WmvStreamAudio {
3193    pub path: Arc<PathBuf>,
3194    pub num_frames: usize,
3195    pub initial_silence_frames: usize,
3196    pub source_skip_frames: usize,
3197}
3198
3199#[derive(Debug, Clone)]
3200pub struct MovieAudio {
3201    /// Present for OMV and for the retained DVD-private static fallback.
3202    pub samples: Arc<Vec<i16>>,
3203    /// Present for MPEG Layer I/II/III program-stream audio.  The audio is
3204    /// decoded by Kira's bounded streaming scheduler instead of materializing
3205    /// a complete WAV before playback begins.
3206    pub mpeg_stream: Option<MpegStreamAudio>,
3207    /// Present for native ASF/WMA playback. The compressed stream is decoded
3208    /// incrementally by Kira rather than materializing the entire soundtrack.
3209    pub wmv_stream: Option<WmvStreamAudio>,
3210    pub channels: u16,
3211    pub sample_rate: u32,
3212    pub start_ms: u64,
3213    pub duration_ms: Option<u64>,
3214}
3215
3216impl MovieAudio {
3217    fn end_ms(&self) -> u64 {
3218        self.start_ms.saturating_add(self.duration_ms.unwrap_or(0))
3219    }
3220}
3221
3222fn make_static_movie_playback(
3223    audio: &mut AudioHub,
3224    track: &MovieAudio,
3225    local_offset_ms: u64,
3226    loop_flag: bool,
3227) -> Result<(MoviePlaybackHandle, u64)> {
3228    let wav = encode_wav_i16_interleaved(track.samples.as_ref(), track.channels, track.sample_rate);
3229    let mut data = StaticSoundData::from_cursor(Cursor::new(wav))
3230        .context("kira: decode movie WAV bytes")?
3231        .start_position(local_offset_ms as f64 / 1000.0);
3232    if loop_flag {
3233        data = data.loop_region(..);
3234    }
3235    Ok((
3236        MoviePlaybackHandle::Static(audio.play_static(TrackKind::Mov, data)?),
3237        track.start_ms,
3238    ))
3239}
3240
3241#[derive(Debug)]
3242enum MoviePlaybackHandle {
3243    Static(StaticSoundHandle),
3244    #[cfg(not(target_arch = "wasm32"))]
3245    Streaming(StreamingSoundHandle<anyhow::Error>),
3246}
3247
3248#[derive(Debug)]
3249struct MoviePlayback {
3250    handle: MoviePlaybackHandle,
3251    /// Kira reports a position relative to the audio source. This offset maps
3252    /// that value back to the Siglus movie timeline.
3253    timeline_base_ms: u64,
3254}
3255
3256impl MoviePlayback {
3257    fn state(&self) -> PlaybackState {
3258        match &self.handle {
3259            MoviePlaybackHandle::Static(handle) => handle.state(),
3260            #[cfg(not(target_arch = "wasm32"))]
3261            MoviePlaybackHandle::Streaming(handle) => handle.state(),
3262        }
3263    }
3264
3265    fn movie_position_ms(&self) -> u64 {
3266        let seconds = match &self.handle {
3267            MoviePlaybackHandle::Static(handle) => handle.position(),
3268            #[cfg(not(target_arch = "wasm32"))]
3269            MoviePlaybackHandle::Streaming(handle) => handle.position(),
3270        };
3271        self.timeline_base_ms
3272            .saturating_add((seconds.max(0.0) * 1000.0).round() as u64)
3273    }
3274
3275    fn pause(&mut self) {
3276        match &mut self.handle {
3277            MoviePlaybackHandle::Static(handle) => {
3278                handle.pause(kira::tween::Tween::default());
3279            }
3280            #[cfg(not(target_arch = "wasm32"))]
3281            MoviePlaybackHandle::Streaming(handle) => {
3282                handle.pause(kira::tween::Tween::default());
3283            }
3284        }
3285    }
3286
3287    fn resume(&mut self) {
3288        match &mut self.handle {
3289            MoviePlaybackHandle::Static(handle) => {
3290                handle.resume(kira::tween::Tween::default());
3291            }
3292            #[cfg(not(target_arch = "wasm32"))]
3293            MoviePlaybackHandle::Streaming(handle) => {
3294                handle.resume(kira::tween::Tween::default());
3295            }
3296        }
3297    }
3298
3299    fn stop(&mut self) {
3300        match &mut self.handle {
3301            MoviePlaybackHandle::Static(handle) => {
3302                handle.stop(kira::tween::Tween::default());
3303            }
3304            #[cfg(not(target_arch = "wasm32"))]
3305            MoviePlaybackHandle::Streaming(handle) => {
3306                handle.stop(kira::tween::Tween::default());
3307            }
3308        }
3309    }
3310
3311    fn take_stream_error(&mut self) -> Option<anyhow::Error> {
3312        match &mut self.handle {
3313            MoviePlaybackHandle::Static(_) => None,
3314            #[cfg(not(target_arch = "wasm32"))]
3315            MoviePlaybackHandle::Streaming(handle) => handle.pop_error(),
3316        }
3317    }
3318}
3319
3320#[cfg(not(target_arch = "wasm32"))]
3321const WMV_AUDIO_DECODE_FRAMES: usize = 4096;
3322
3323#[cfg(not(target_arch = "wasm32"))]
3324struct WmvMovieAudioDecoder {
3325    info: WmvStreamAudio,
3326    sample_rate: u32,
3327    decoder: wmv_decoder::AsfWmaDecoder<BufReader<fs::File>>,
3328    pending: VecDeque<Frame>,
3329    initial_silence_remaining: usize,
3330    source_skip_remaining: usize,
3331    produced_frames: usize,
3332    eof: bool,
3333}
3334
3335#[cfg(not(target_arch = "wasm32"))]
3336impl WmvMovieAudioDecoder {
3337    fn new(info: WmvStreamAudio, sample_rate: u32) -> Result<Self> {
3338        if sample_rate == 0 || info.num_frames == 0 {
3339            bail!("invalid WMV movie audio stream description");
3340        }
3341        let decoder = Self::open_decoder(info.path.as_ref())?;
3342        let initial_silence_remaining = info.initial_silence_frames;
3343        let source_skip_remaining = info.source_skip_frames;
3344        Ok(Self {
3345            info,
3346            sample_rate,
3347            decoder,
3348            pending: VecDeque::new(),
3349            initial_silence_remaining,
3350            source_skip_remaining,
3351            produced_frames: 0,
3352            eof: false,
3353        })
3354    }
3355
3356    fn open_decoder(path: &Path) -> Result<wmv_decoder::AsfWmaDecoder<BufReader<fs::File>>> {
3357        let file = fs::File::open(path)
3358            .with_context(|| format!("open WMV movie audio: {}", path.display()))?;
3359        wmv_decoder::AsfWmaDecoder::open(BufReader::new(file))
3360            .with_context(|| format!("open WMA decoder: {}", path.display()))
3361    }
3362
3363    fn reset(&mut self) -> Result<()> {
3364        self.decoder = Self::open_decoder(self.info.path.as_ref())?;
3365        self.pending.clear();
3366        self.initial_silence_remaining = self.info.initial_silence_frames;
3367        self.source_skip_remaining = self.info.source_skip_frames;
3368        self.produced_frames = 0;
3369        self.eof = false;
3370        Ok(())
3371    }
3372
3373    fn decode_more(&mut self) -> Result<()> {
3374        if self.eof {
3375            return Ok(());
3376        }
3377        match self.decoder.next_frame()? {
3378            Some(decoded) => {
3379                let frames = convert_movie_audio_chunk_to_frames(
3380                    &decoded.frame.samples,
3381                    decoded.frame.channels,
3382                    decoded.frame.sample_rate,
3383                    self.sample_rate,
3384                );
3385                let skip = self.source_skip_remaining.min(frames.len());
3386                self.source_skip_remaining -= skip;
3387                self.pending.extend(frames.into_iter().skip(skip));
3388            }
3389            None => self.eof = true,
3390        }
3391        Ok(())
3392    }
3393
3394    /// Advance from the beginning of the movie timeline without invoking Kira's
3395    /// streaming seek machinery.  Every compressed WMA packet is still decoded in
3396    /// forward order, preserving the superframe/bit-reservoir state exactly like the
3397    /// standalone WMV player.
3398    fn prime_to_timeline_frame(&mut self, target: usize) -> Result<usize> {
3399        let target = target.min(self.info.num_frames.saturating_sub(1));
3400        let mut advanced = 0usize;
3401
3402        let silence = target.min(self.initial_silence_remaining);
3403        self.initial_silence_remaining -= silence;
3404        advanced = advanced.saturating_add(silence);
3405
3406        while advanced < target && !self.eof {
3407            if self.pending.is_empty() {
3408                self.decode_more()?;
3409            }
3410            while advanced < target {
3411                let Some(_frame) = self.pending.pop_front() else {
3412                    break;
3413                };
3414                advanced = advanced.saturating_add(1);
3415            }
3416        }
3417        self.produced_frames = advanced;
3418        Ok(advanced)
3419    }
3420}
3421
3422#[cfg(not(target_arch = "wasm32"))]
3423impl KiraStreamingDecoder for WmvMovieAudioDecoder {
3424    type Error = anyhow::Error;
3425
3426    fn sample_rate(&self) -> u32 {
3427        self.sample_rate
3428    }
3429
3430    fn num_frames(&self) -> usize {
3431        self.info.num_frames
3432    }
3433
3434    fn decode(&mut self) -> Result<Vec<Frame>, Self::Error> {
3435        let remaining = self.info.num_frames.saturating_sub(self.produced_frames);
3436        if remaining == 0 {
3437            return Ok(Vec::new());
3438        }
3439        let target = remaining.min(WMV_AUDIO_DECODE_FRAMES);
3440        let mut out = Vec::with_capacity(target);
3441        while out.len() < target {
3442            if self.initial_silence_remaining > 0 {
3443                let count = self
3444                    .initial_silence_remaining
3445                    .min(target.saturating_sub(out.len()));
3446                out.resize(out.len().saturating_add(count), Frame::ZERO);
3447                self.initial_silence_remaining -= count;
3448                continue;
3449            }
3450            while out.len() < target {
3451                let Some(frame) = self.pending.pop_front() else {
3452                    break;
3453                };
3454                out.push(frame);
3455            }
3456            if out.len() >= target {
3457                break;
3458            }
3459            if self.eof {
3460                // ASF play duration is the scheduler's finite length. If the
3461                // compressed audio ends slightly earlier, preserve the movie
3462                // timeline with silence instead of spinning at EOF.
3463                out.resize(target, Frame::ZERO);
3464                break;
3465            }
3466            self.decode_more()?;
3467        }
3468        self.produced_frames = self.produced_frames.saturating_add(out.len());
3469        Ok(out)
3470    }
3471
3472    fn seek(&mut self, index: usize) -> Result<usize, Self::Error> {
3473        self.reset()?;
3474        self.prime_to_timeline_frame(index)
3475    }
3476}
3477
3478#[cfg(not(target_arch = "wasm32"))]
3479const MPEG_AUDIO_DECODE_FRAMES: usize = 4096;
3480
3481#[cfg(not(target_arch = "wasm32"))]
3482struct MpegMovieAudioDecoder {
3483    info: MpegStreamAudio,
3484    sample_rate: u32,
3485    file: fs::File,
3486    pipeline: na_mpeg2_decoder::MpegAudioPipeline,
3487    read_buf: Vec<u8>,
3488    pending: VecDeque<Frame>,
3489    initial_silence_frames: usize,
3490    produced_frames: usize,
3491    eof: bool,
3492}
3493
3494#[cfg(not(target_arch = "wasm32"))]
3495impl MpegMovieAudioDecoder {
3496    fn new(info: MpegStreamAudio, sample_rate: u32) -> Result<Self> {
3497        if sample_rate == 0 || info.num_frames == 0 {
3498            bail!("invalid MPEG movie audio stream description");
3499        }
3500        let file = fs::File::open(info.path.as_ref())
3501            .with_context(|| format!("open MPEG movie audio: {}", info.path.display()))?;
3502        let initial_silence_frames = mpeg_initial_silence_frames(&info, sample_rate);
3503        Ok(Self {
3504            info,
3505            sample_rate,
3506            file,
3507            pipeline: na_mpeg2_decoder::MpegAudioPipeline::new(),
3508            read_buf: vec![0u8; MPEG2_STREAM_CHUNK_BYTES],
3509            pending: VecDeque::new(),
3510            initial_silence_frames,
3511            produced_frames: 0,
3512            eof: false,
3513        })
3514    }
3515
3516    fn reset(&mut self) -> Result<()> {
3517        self.reset_at_offset(0, 0)
3518    }
3519
3520    fn reset_at_offset(&mut self, offset: u64, produced_frames: usize) -> Result<()> {
3521        self.file = fs::File::open(self.info.path.as_ref())
3522            .with_context(|| format!("reopen MPEG movie audio: {}", self.info.path.display()))?;
3523        if offset > 0 {
3524            self.file
3525                .seek(SeekFrom::Start(offset))
3526                .with_context(|| format!("seek MPEG movie audio: {}", self.info.path.display()))?;
3527        }
3528        self.pipeline = na_mpeg2_decoder::MpegAudioPipeline::new();
3529        self.pending.clear();
3530        self.initial_silence_frames = if offset == 0 {
3531            mpeg_initial_silence_frames(&self.info, self.sample_rate)
3532        } else {
3533            0
3534        };
3535        self.produced_frames = produced_frames;
3536        self.eof = false;
3537        Ok(())
3538    }
3539
3540    fn seek_start_offset(&self, target_audio_frames: usize, backtrack: u64) -> Result<u64> {
3541        let initial_silence = mpeg_initial_silence_frames(&self.info, self.sample_rate);
3542        let audio_frames = self.info.num_frames.saturating_sub(initial_silence).max(1);
3543        let proportional = ((self.info.file_len as u128)
3544            .saturating_mul(target_audio_frames.min(audio_frames) as u128)
3545            / audio_frames as u128)
3546            .min(self.info.file_len as u128) as u64;
3547        let start = proportional.saturating_sub(backtrack.min(proportional));
3548        align_mpeg_container_seek(self.info.path.as_ref(), start, self.info.transport_stream)
3549    }
3550
3551    fn prime_seek_from_offset(
3552        &mut self,
3553        offset: u64,
3554        target_index: usize,
3555    ) -> Result<Option<usize>> {
3556        self.reset_at_offset(offset, 0)?;
3557        let mut bytes_read = 0u64;
3558        while bytes_read < MPEG_AUDIO_SEEK_MAX_PRIME_BYTES {
3559            let n = self.file.read(&mut self.read_buf).with_context(|| {
3560                format!("prime MPEG movie audio seek: {}", self.info.path.display())
3561            })?;
3562            if n == 0 {
3563                break;
3564            }
3565            bytes_read = bytes_read.saturating_add(n as u64);
3566            let input = self.read_buf[..n].to_vec();
3567            let mut chunks = Vec::new();
3568            self.pipeline
3569                .push_with(&input, None, |chunk| chunks.push(chunk))
3570                .context("prime MPEG movie audio decoder")?;
3571            let mut actual_index = None;
3572            for chunk in chunks {
3573                if actual_index.is_none() {
3574                    let Some(chunk_index) =
3575                        mpeg_audio_chunk_timeline_frame(&self.info, chunk.pts_ms, self.sample_rate)
3576                    else {
3577                        continue;
3578                    };
3579                    if chunk_index > target_index {
3580                        return Ok(None);
3581                    }
3582                    actual_index = Some(chunk_index);
3583                    self.initial_silence_frames = 0;
3584                    self.produced_frames = chunk_index;
3585                }
3586                let converted = convert_movie_audio_chunk_to_frames(
3587                    &chunk.samples,
3588                    chunk.channels,
3589                    chunk.sample_rate,
3590                    self.sample_rate,
3591                );
3592                self.pending.extend(converted);
3593            }
3594            if let Some(actual) = actual_index.filter(|_| !self.pending.is_empty()) {
3595                return Ok(Some(actual));
3596            }
3597        }
3598        Ok(None)
3599    }
3600
3601    fn append_chunk(&mut self, chunk: na_mpeg2_decoder::MpegAudioF32) {
3602        let converted = convert_movie_audio_chunk_to_frames(
3603            &chunk.samples,
3604            chunk.channels,
3605            chunk.sample_rate,
3606            self.sample_rate,
3607        );
3608        self.pending.extend(converted);
3609    }
3610
3611    fn decode_more(&mut self) -> Result<()> {
3612        if self.eof {
3613            return Ok(());
3614        }
3615        let n = self
3616            .file
3617            .read(&mut self.read_buf)
3618            .with_context(|| format!("read MPEG movie audio: {}", self.info.path.display()))?;
3619        if n == 0 {
3620            let mut chunks = Vec::new();
3621            self.pipeline
3622                .flush_with(|chunk| chunks.push(chunk))
3623                .context("flush MPEG movie audio")?;
3624            for chunk in chunks {
3625                self.append_chunk(chunk);
3626            }
3627            self.eof = true;
3628            return Ok(());
3629        }
3630
3631        // Keep the source bytes independent from `self` while mutably driving
3632        // the pipeline; this avoids a whole-struct borrow conflict.
3633        let input = self.read_buf[..n].to_vec();
3634        let mut chunks = Vec::new();
3635        self.pipeline
3636            .push_with(&input, None, |chunk| chunks.push(chunk))
3637            .context("decode MPEG movie audio chunk")?;
3638        for chunk in chunks {
3639            self.append_chunk(chunk);
3640        }
3641        Ok(())
3642    }
3643}
3644
3645#[cfg(not(target_arch = "wasm32"))]
3646impl KiraStreamingDecoder for MpegMovieAudioDecoder {
3647    type Error = anyhow::Error;
3648
3649    fn sample_rate(&self) -> u32 {
3650        self.sample_rate
3651    }
3652
3653    fn num_frames(&self) -> usize {
3654        self.info.num_frames
3655    }
3656
3657    fn decode(&mut self) -> Result<Vec<Frame>, Self::Error> {
3658        let remaining = self.info.num_frames.saturating_sub(self.produced_frames);
3659        if remaining == 0 {
3660            return Ok(Vec::new());
3661        }
3662        let target = remaining.min(MPEG_AUDIO_DECODE_FRAMES);
3663        let mut out = Vec::with_capacity(target);
3664
3665        while out.len() < target {
3666            if self.initial_silence_frames > 0 {
3667                let count = self
3668                    .initial_silence_frames
3669                    .min(target.saturating_sub(out.len()));
3670                out.resize(out.len().saturating_add(count), Frame::ZERO);
3671                self.initial_silence_frames -= count;
3672                continue;
3673            }
3674
3675            while out.len() < target {
3676                let Some(frame) = self.pending.pop_front() else {
3677                    break;
3678                };
3679                out.push(frame);
3680            }
3681            if out.len() >= target {
3682                break;
3683            }
3684
3685            if self.eof {
3686                // The compressed-frame probe supplies the finite length Kira
3687                // requires. A damaged frame may be counted but rejected by the
3688                // sample decoder; preserve the timeline with silence instead of
3689                // making the scheduler spin forever waiting for missing frames.
3690                out.resize(target, Frame::ZERO);
3691                break;
3692            }
3693            self.decode_more()?;
3694        }
3695
3696        self.produced_frames = self.produced_frames.saturating_add(out.len());
3697        Ok(out)
3698    }
3699
3700    fn seek(&mut self, index: usize) -> Result<usize, Self::Error> {
3701        let target = index.min(self.info.num_frames.saturating_sub(1));
3702        let initial_silence = mpeg_initial_silence_frames(&self.info, self.sample_rate);
3703        if target <= initial_silence || self.info.file_len == 0 {
3704            self.reset()?;
3705            return Ok(0);
3706        }
3707
3708        let target_audio_frames = target.saturating_sub(initial_silence);
3709        let mut backtrack = MPEG_AUDIO_SEEK_INITIAL_BACKTRACK_BYTES;
3710        loop {
3711            let offset = self.seek_start_offset(target_audio_frames, backtrack)?;
3712            if let Some(actual) = self.prime_seek_from_offset(offset, target)? {
3713                return Ok(actual);
3714            }
3715            if offset == 0 || backtrack >= MPEG_AUDIO_SEEK_MAX_PRIME_BYTES {
3716                break;
3717            }
3718            backtrack = backtrack
3719                .saturating_mul(4)
3720                .min(MPEG_AUDIO_SEEK_MAX_PRIME_BYTES);
3721        }
3722
3723        self.reset()?;
3724        Ok(0)
3725    }
3726}
3727
3728#[cfg(not(target_arch = "wasm32"))]
3729fn align_mpeg_container_seek(path: &Path, offset: u64, transport_stream: bool) -> Result<u64> {
3730    if offset == 0 {
3731        return Ok(0);
3732    }
3733    if transport_stream {
3734        return Ok(offset - offset % 188);
3735    }
3736
3737    let file_len = fs::metadata(path)
3738        .with_context(|| format!("stat MPEG seek source: {}", path.display()))?
3739        .len();
3740    let mut file = fs::File::open(path)
3741        .with_context(|| format!("open MPEG seek source: {}", path.display()))?;
3742    file.seek(SeekFrom::Start(offset))
3743        .with_context(|| format!("seek MPEG source: {}", path.display()))?;
3744    let probe_len = (1024 * 1024u64).min(file_len.saturating_sub(offset)) as usize;
3745    let mut probe = vec![0u8; probe_len];
3746    let n = file
3747        .read(&mut probe)
3748        .with_context(|| format!("read MPEG seek source: {}", path.display()))?;
3749    probe.truncate(n);
3750    if let Some(pos) = find_byte_pattern(&probe, b"\0\0\x01\xba")
3751        .or_else(|| find_byte_pattern(&probe, b"\0\0\x01\xb3"))
3752    {
3753        Ok(offset.saturating_add(pos as u64))
3754    } else {
3755        Ok(offset)
3756    }
3757}
3758
3759#[cfg(not(target_arch = "wasm32"))]
3760fn mpeg_audio_chunk_timeline_frame(
3761    info: &MpegStreamAudio,
3762    chunk_pts_ms: i64,
3763    sample_rate: u32,
3764) -> Option<usize> {
3765    if sample_rate == 0 || (chunk_pts_ms == 0 && info.first_audio_pts_ms != 0) {
3766        return None;
3767    }
3768    let origin = match info.first_video_pts_90k {
3769        Some(video) if mpeg_pts_delta_90k(video, info.first_audio_pts_90k).is_none() => video,
3770        Some(_) | None => info.first_audio_pts_90k,
3771    };
3772    let chunk_pts_90k = chunk_pts_ms.saturating_mul(90);
3773    let ticks = mpeg_pts_delta_90k(chunk_pts_90k, origin)?;
3774    Some(pts90k_ticks_to_audio_frames(ticks, sample_rate))
3775}
3776
3777#[cfg(not(target_arch = "wasm32"))]
3778fn mpeg_initial_silence_frames(info: &MpegStreamAudio, sample_rate: u32) -> usize {
3779    let origin = info
3780        .first_video_pts_90k
3781        .map(|video| earlier_mpeg_pts_90k(video, info.first_audio_pts_90k))
3782        .unwrap_or(info.first_audio_pts_90k);
3783    pts90k_ticks_to_audio_frames(
3784        mpeg_pts_delta_90k(info.first_audio_pts_90k, origin).unwrap_or(0),
3785        sample_rate,
3786    )
3787}
3788
3789#[cfg(not(target_arch = "wasm32"))]
3790fn convert_movie_audio_chunk_to_frames(
3791    samples: &[f32],
3792    src_channels: u16,
3793    src_sample_rate: u32,
3794    dst_sample_rate: u32,
3795) -> Vec<Frame> {
3796    let src_channels = src_channels as usize;
3797    if src_channels == 0 || src_sample_rate == 0 || dst_sample_rate == 0 {
3798        return Vec::new();
3799    }
3800    let src_frames = samples.len() / src_channels;
3801    if src_frames == 0 {
3802        return Vec::new();
3803    }
3804    let dst_frames = ((src_frames as u128) * (dst_sample_rate as u128) / (src_sample_rate as u128))
3805        .max(1) as usize;
3806    let mut out = Vec::with_capacity(dst_frames);
3807
3808    for dst_index in 0..dst_frames {
3809        let src_num = (dst_index as u128) * (src_sample_rate as u128);
3810        let src_index = (src_num / dst_sample_rate as u128) as usize;
3811        let src_index = src_index.min(src_frames - 1);
3812        let next_index = src_index.saturating_add(1).min(src_frames - 1);
3813        let fraction = (src_num % dst_sample_rate as u128) as f32 / dst_sample_rate as f32;
3814        let sample = |frame: usize, channel: usize| -> f32 {
3815            samples
3816                .get(
3817                    frame
3818                        .saturating_mul(src_channels)
3819                        .saturating_add(channel.min(src_channels - 1)),
3820                )
3821                .copied()
3822                .unwrap_or(0.0)
3823        };
3824        let interpolate = |channel: usize| {
3825            let a = sample(src_index, channel);
3826            let b = sample(next_index, channel);
3827            a + (b - a) * fraction
3828        };
3829        let left = interpolate(0);
3830        let right = if src_channels == 1 {
3831            left
3832        } else {
3833            interpolate(1)
3834        };
3835        out.push(Frame::new(left, right));
3836    }
3837    out
3838}
3839
3840#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
3841fn read_movie_bytes(path: &Path) -> Result<Vec<u8>> {
3842    crate::resource::read_file_bytes(path)
3843        .with_context(|| format!("read movie file: {}", path.display()))
3844}
3845
3846#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
3847fn read_movie_bytes(path: &Path) -> Result<Vec<u8>> {
3848    crate::resource::read_file_bytes(path)
3849        .with_context(|| format!("read movie file: {}", path.display()))
3850}
3851
3852#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
3853fn read_omv_header_for_path(path: &Path) -> Result<siglus_assets::omv::OmvHeader> {
3854    let bytes = read_movie_bytes(path)?;
3855    read_omv_header_from_bytes(&bytes)
3856}
3857
3858#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
3859fn read_omv_header_for_path(path: &Path) -> Result<siglus_assets::omv::OmvHeader> {
3860    siglus_assets::omv::OmvFile::read_header(path)
3861}
3862
3863fn read_omv_header_from_bytes(buf: &[u8]) -> Result<siglus_assets::omv::OmvHeader> {
3864    if buf.len() < 0x58 {
3865        bail!("OMV header too small");
3866    }
3867    let header_size = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
3868    let version = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
3869    let theora_type = u32::from_le_bytes([buf[0x28], buf[0x29], buf[0x2a], buf[0x2b]]);
3870    let display_width = u32::from_le_bytes([buf[0x2c], buf[0x2d], buf[0x2e], buf[0x2f]]);
3871    let display_height = u32::from_le_bytes([buf[0x30], buf[0x31], buf[0x32], buf[0x33]]);
3872    let frame_time_us = u32::from_le_bytes([buf[0x3c], buf[0x3d], buf[0x3e], buf[0x3f]]);
3873    let max_data_size = u32::from_le_bytes([buf[0x40], buf[0x41], buf[0x42], buf[0x43]]);
3874    let page_count_hint = u32::from_le_bytes([buf[0x4c], buf[0x4d], buf[0x4e], buf[0x4f]]);
3875    let packet_count_hint = u32::from_le_bytes([buf[0x50], buf[0x51], buf[0x52], buf[0x53]]);
3876    if header_size < 0x58 {
3877        bail!("invalid OMV header size: {header_size:#x}");
3878    }
3879    if theora_type > siglus_assets::omv::OMV_THEORA_TYPE_YUV {
3880        bail!("invalid OMV theora type: {theora_type}");
3881    }
3882    if display_width == 0 || display_height == 0 {
3883        bail!(
3884            "invalid OMV display size: {}x{}",
3885            display_width,
3886            display_height
3887        );
3888    }
3889    Ok(siglus_assets::omv::OmvHeader {
3890        header_size,
3891        version,
3892        theora_type,
3893        display_width,
3894        display_height,
3895        frame_time_us,
3896        max_data_size,
3897        page_count_hint,
3898        packet_count_hint,
3899    })
3900}
3901
3902fn extract_ogg_from_bytes(bytes: &[u8]) -> Result<Vec<u8>> {
3903    let needle = b"OggS";
3904    let pos = bytes
3905        .windows(needle.len())
3906        .position(|w| w == needle)
3907        .ok_or_else(|| anyhow!("OggS not found in OMV payload"))?;
3908    Ok(bytes[pos..].to_vec())
3909}
3910
3911#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
3912fn decode_mpeg2_asset_from_bytes(path: &Path, bytes: Vec<u8>) -> Result<MovieAsset> {
3913    let mut width = None;
3914    let mut height = None;
3915    let mut fps = None;
3916    if let Some(h) = siglus_assets::mpeg2::find_sequence_header(
3917        &bytes[..bytes.len().min(MPEG2_HEADER_PROBE_BYTES)],
3918    ) {
3919        width = Some(h.width as u32);
3920        height = Some(h.height as u32);
3921        fps = siglus_assets::mpeg2::fps_from_frame_rate_code(h.frame_rate_code);
3922    }
3923
3924    let mut frames: Vec<Arc<RgbaImage>> = Vec::new();
3925    let mut audio_samples: Vec<i16> = Vec::new();
3926    let mut audio_channels: Option<u16> = None;
3927    let mut audio_sample_rate: Option<u32> = None;
3928    let mut dropped_audio_format_changes = 0u32;
3929    let mut pipeline = na_mpeg2_decoder::MpegAvPipeline::new();
3930    pipeline
3931        .push_with(&bytes, None, |ev| match ev {
3932            na_mpeg2_decoder::MpegAvEvent::Video(f) => {
3933                let w = f.width;
3934                let h = f.height;
3935                frames.push(Arc::new(RgbaImage {
3936                    width: w,
3937                    height: h,
3938                    center_x: 0,
3939                    center_y: 0,
3940                    rgba: f.rgba,
3941                }));
3942            }
3943            na_mpeg2_decoder::MpegAvEvent::Audio(a) => {
3944                append_mpeg2_audio_chunk_for_asset(
3945                    path,
3946                    &mut audio_channels,
3947                    &mut audio_sample_rate,
3948                    &mut audio_samples,
3949                    &mut dropped_audio_format_changes,
3950                    a,
3951                );
3952            }
3953        })
3954        .context("mpeg2 wasm full decode")?;
3955    pipeline.flush_with(|ev| match ev {
3956        na_mpeg2_decoder::MpegAvEvent::Video(f) => {
3957            let w = f.width;
3958            let h = f.height;
3959            frames.push(Arc::new(RgbaImage {
3960                width: w,
3961                height: h,
3962                center_x: 0,
3963                center_y: 0,
3964                rgba: f.rgba,
3965            }));
3966        }
3967        na_mpeg2_decoder::MpegAvEvent::Audio(a) => {
3968            append_mpeg2_audio_chunk_for_asset(
3969                path,
3970                &mut audio_channels,
3971                &mut audio_sample_rate,
3972                &mut audio_samples,
3973                &mut dropped_audio_format_changes,
3974                a,
3975            );
3976        }
3977    })?;
3978
3979    if frames.is_empty() {
3980        bail!("mpeg2 decoder produced no frames: {}", path.display());
3981    }
3982    let audio =
3983        build_movie_audio_from_parts(path, audio_samples, audio_channels, audio_sample_rate)?;
3984    let audio_duration_ms = audio.as_ref().and_then(|a| a.duration_ms);
3985    let first = frames.first().expect("frames not empty");
3986    let info = MovieInfo {
3987        path: path.to_path_buf(),
3988        width: width.or(Some(first.width)),
3989        height: height.or(Some(first.height)),
3990        fps,
3991        decoded_frames: Some(frames.len()),
3992        audio_duration_ms,
3993    };
3994    Ok(MovieAsset {
3995        info,
3996        frames,
3997        audio,
3998    })
3999}
4000
4001#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
4002fn decode_wmv_asset_from_bytes(path: &Path, bytes: Vec<u8>) -> Result<MovieAsset> {
4003    let mut decoder = wmv_decoder::AsfWmv2Decoder::open(Cursor::new(bytes.clone()))
4004        .with_context(|| format!("open ASF/WMV decoder: {}", path.display()))?;
4005    let stream_info = decoder.video_stream_info().clone();
4006    let mut frames = Vec::<Arc<RgbaImage>>::new();
4007    let mut pts = Vec::<u64>::new();
4008    while let Some(decoded) = decoder
4009        .next_frame()
4010        .with_context(|| format!("decode ASF/WMV frame: {}", path.display()))?
4011    {
4012        pts.push(decoded.pts_ms as u64);
4013        frames.push(Arc::new(wmv_yuv_frame_to_rgba(&decoded.frame)));
4014    }
4015    if frames.is_empty() {
4016        bail!("wmv decoder produced no frames: {}", path.display());
4017    }
4018
4019    let timeline_origin_ms = pts.first().copied().unwrap_or(0);
4020    for pts_ms in &mut pts {
4021        *pts_ms = pts_ms.saturating_sub(timeline_origin_ms);
4022    }
4023    let cancel = AtomicBool::new(false);
4024    let audio = decode_wmv_audio_full_from_reader(Cursor::new(bytes), &cancel, timeline_origin_ms)?;
4025    let fps = wmv_fps_from_pts(&pts);
4026    let video_duration_ms = pts
4027        .last()
4028        .copied()
4029        .map(|last| last.saturating_add(wmv_frame_duration_ms(&pts).unwrap_or(0)));
4030    let audio_duration_ms = audio.as_ref().map(MovieAudio::end_ms);
4031    let total_ms = match (audio_duration_ms, video_duration_ms) {
4032        (Some(a), Some(v)) => Some(a.max(v)),
4033        (Some(a), None) => Some(a),
4034        (None, Some(v)) => Some(v),
4035        (None, None) => None,
4036    };
4037    let info = MovieInfo {
4038        path: path.to_path_buf(),
4039        width: (stream_info.width > 0).then_some(stream_info.width),
4040        height: (stream_info.height > 0).then_some(stream_info.height),
4041        fps,
4042        decoded_frames: Some(frames.len()),
4043        audio_duration_ms: total_ms,
4044    };
4045    Ok(MovieAsset {
4046        info,
4047        frames,
4048        audio,
4049    })
4050}
4051
4052#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
4053fn decode_omv_asset_from_bytes(path: &Path, bytes: Vec<u8>) -> Result<MovieAsset> {
4054    let header = read_omv_header_from_bytes(&bytes).ok();
4055    let ogg_data = extract_ogg_from_bytes(&bytes)
4056        .with_context(|| format!("read embedded ogg: {}", path.display()))?;
4057    let mut tf = siglus_omv_decoder::TheoraFile::open_from_memory(ogg_data)
4058        .with_context(|| format!("open theora: {}", path.display()))?;
4059    let vinfo = tf.info();
4060    let display_w = header
4061        .as_ref()
4062        .map(|h| h.display_width as i32)
4063        .unwrap_or(vinfo.width);
4064    let display_h = header
4065        .as_ref()
4066        .map(|h| h.display_height as i32)
4067        .unwrap_or(vinfo.height);
4068    let width = display_w.max(1) as u32;
4069    let height = display_h.max(1) as u32;
4070    let fps = header
4071        .as_ref()
4072        .and_then(|h| {
4073            if h.frame_time_us != 0 {
4074                Some(1_000_000.0 / (h.frame_time_us as f32))
4075            } else if vinfo.fps > 0.0 {
4076                Some(vinfo.fps as f32)
4077            } else {
4078                None
4079            }
4080        })
4081        .or_else(|| (vinfo.fps > 0.0).then_some(vinfo.fps as f32));
4082    let theora_type = header
4083        .as_ref()
4084        .map(|h| h.theora_type)
4085        .unwrap_or(siglus_assets::omv::OMV_THEORA_TYPE_YUV);
4086    let (_uv_w, _uv_h, y_len, u_len, v_len) =
4087        omv_plane_layout(vinfo.frame_width, vinfo.frame_height, vinfo.fmt);
4088    let mut packed = vec![0u8; y_len.saturating_add(u_len).saturating_add(v_len)];
4089    let mut frames = Vec::<Arc<RgbaImage>>::new();
4090    while tf.read_video_frame(&mut packed)? {
4091        let rgba = convert_omv_frame(
4092            &packed,
4093            vinfo.frame_width,
4094            vinfo.frame_height,
4095            vinfo.fmt,
4096            display_w,
4097            display_h,
4098            theora_type,
4099        );
4100        frames.push(Arc::new(RgbaImage {
4101            width,
4102            height,
4103            center_x: 0,
4104            center_y: 0,
4105            rgba,
4106        }));
4107    }
4108    if frames.is_empty() {
4109        bail!("omv decoder produced no frames: {}", path.display());
4110    }
4111    tf.reset();
4112    let audio = decode_omv_audio(&mut tf)?;
4113    let frame_time_ms = omv_frame_duration_ms(header.as_ref(), fps);
4114    let video_duration_ms =
4115        frame_time_ms.map(|ms| ((frames.len() as f64) * ms).round().max(1.0) as u64);
4116    let audio_duration_ms = audio
4117        .as_ref()
4118        .and_then(|a| a.duration_ms)
4119        .or(video_duration_ms);
4120    let info = MovieInfo {
4121        path: path.to_path_buf(),
4122        width: Some(width),
4123        height: Some(height),
4124        fps,
4125        decoded_frames: Some(frames.len()),
4126        audio_duration_ms,
4127    };
4128    Ok(MovieAsset {
4129        info,
4130        frames,
4131        audio,
4132    })
4133}
4134
4135#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
4136fn append_mpeg2_audio_chunk_for_asset(
4137    _path: &Path,
4138    audio_channels: &mut Option<u16>,
4139    audio_sample_rate: &mut Option<u32>,
4140    audio_samples: &mut Vec<i16>,
4141    dropped_audio_format_changes: &mut u32,
4142    a: na_mpeg2_decoder::MpegAudioF32,
4143) {
4144    match (*audio_channels, *audio_sample_rate) {
4145        (None, None) => {
4146            *audio_channels = Some(a.channels);
4147            *audio_sample_rate = Some(a.sample_rate);
4148        }
4149        (Some(ch), Some(sr)) if ch == a.channels && sr == a.sample_rate => {}
4150        (Some(_), Some(_)) => {
4151            *dropped_audio_format_changes = (*dropped_audio_format_changes).saturating_add(1);
4152            return;
4153        }
4154        _ => return,
4155    }
4156    audio_samples.extend(a.samples.into_iter().map(f32_to_i16_sample));
4157}
4158
4159#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
4160fn build_movie_audio_from_parts(
4161    path: &Path,
4162    audio_samples: Vec<i16>,
4163    audio_channels: Option<u16>,
4164    audio_sample_rate: Option<u32>,
4165) -> Result<Option<MovieAudio>> {
4166    match (audio_channels, audio_sample_rate, audio_samples.is_empty()) {
4167        (Some(channels), Some(sample_rate), false) => {
4168            if channels == 0 || sample_rate == 0 {
4169                bail!(
4170                    "movie audio stream has invalid format in {}: channels={} sample_rate={}",
4171                    path.display(),
4172                    channels,
4173                    sample_rate
4174                );
4175            }
4176            let frames_len = (audio_samples.len() as u64) / (channels as u64);
4177            let duration_ms =
4178                Some(((frames_len as f64) * 1000.0 / sample_rate as f64).round() as u64);
4179            Ok(Some(MovieAudio {
4180                samples: Arc::new(audio_samples),
4181                mpeg_stream: None,
4182                wmv_stream: None,
4183                channels,
4184                sample_rate,
4185                start_ms: 0,
4186                duration_ms,
4187            }))
4188        }
4189        (None, None, true) => Ok(None),
4190        (Some(_), Some(_), true) => Ok(None),
4191        _ => bail!(
4192            "movie audio decoder produced incomplete format metadata for {}",
4193            path.display()
4194        ),
4195    }
4196}
4197
4198fn decode_asset_for_path(path: &Path) -> Result<MovieAsset> {
4199    let ext = path
4200        .extension()
4201        .and_then(|s| s.to_str())
4202        .unwrap_or("")
4203        .to_ascii_lowercase();
4204    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
4205    {
4206        let bytes = read_movie_bytes(path)?;
4207        if ext == "omv" {
4208            return decode_omv_asset_from_bytes(path, bytes);
4209        }
4210        if is_wmv_extension(&ext) {
4211            return decode_wmv_asset_from_bytes(path, bytes);
4212        }
4213        return decode_mpeg2_asset_from_bytes(path, bytes);
4214    }
4215    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
4216    {
4217        if ext == "omv" {
4218            decode_omv_asset(path)
4219        } else if is_wmv_extension(&ext) {
4220            decode_wmv_asset(path)
4221        } else {
4222            decode_mpeg2_asset(path)
4223        }
4224    }
4225}
4226
4227fn wmv_frame_duration_ms(pts: &[u64]) -> Option<u64> {
4228    let mut deltas = pts
4229        .windows(2)
4230        .filter_map(|pair| {
4231            let delta = pair[1].saturating_sub(pair[0]);
4232            (delta > 0 && delta <= 1_000).then_some(delta)
4233        })
4234        .collect::<Vec<_>>();
4235    if deltas.is_empty() {
4236        return None;
4237    }
4238    deltas.sort_unstable();
4239    Some(deltas[deltas.len() / 2])
4240}
4241
4242fn wmv_fps_from_pts(pts: &[u64]) -> Option<f32> {
4243    wmv_frame_duration_ms(pts)
4244        .filter(|delta| *delta > 0)
4245        .map(|delta| 1000.0 / delta as f32)
4246}
4247
4248fn wmv_rebase_duration_ms(source_duration_ms: u64, timeline_origin_ms: u64) -> Option<u64> {
4249    let rebased = source_duration_ms.saturating_sub(timeline_origin_ms);
4250    (rebased > 0).then_some(rebased)
4251}
4252
4253fn wmv_audio_alignment_frames(
4254    first_audio_pts_ms: u64,
4255    timeline_origin_ms: u64,
4256    sample_rate: u32,
4257) -> (usize, usize) {
4258    if sample_rate == 0 {
4259        return (0, 0);
4260    }
4261    let silence_ms = first_audio_pts_ms.saturating_sub(timeline_origin_ms);
4262    let skip_ms = timeline_origin_ms.saturating_sub(first_audio_pts_ms);
4263    let initial_silence_frames = ((silence_ms as u128) * sample_rate as u128)
4264        .div_ceil(1000)
4265        .min(usize::MAX as u128) as usize;
4266    let source_skip_frames =
4267        ((skip_ms as u128) * sample_rate as u128 / 1000).min(usize::MAX as u128) as usize;
4268    (initial_silence_frames, source_skip_frames)
4269}
4270
4271fn probe_wmv_first_video_pts_ms(path: &Path) -> Result<Option<u64>> {
4272    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
4273    let mut decoder = {
4274        let bytes = read_movie_bytes(path)?;
4275        wmv_decoder::AsfWmv2Decoder::open(Cursor::new(bytes))
4276            .with_context(|| format!("open ASF/WMV timeline probe: {}", path.display()))?
4277    };
4278    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
4279    let mut decoder = {
4280        let file = fs::File::open(path)
4281            .with_context(|| format!("open WMV timeline probe: {}", path.display()))?;
4282        wmv_decoder::AsfWmv2Decoder::open(BufReader::new(file))
4283            .with_context(|| format!("open WMV timeline probe decoder: {}", path.display()))?
4284    };
4285
4286    Ok(decoder
4287        .next_frame()
4288        .with_context(|| format!("decode WMV timeline probe: {}", path.display()))?
4289        .map(|frame| frame.pts_ms as u64))
4290}
4291
4292fn decode_wmv_preview_frame(path: &Path) -> Result<Arc<RgbaImage>> {
4293    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
4294    let mut decoder = {
4295        let bytes = read_movie_bytes(path)?;
4296        wmv_decoder::AsfWmv2Decoder::open(Cursor::new(bytes))
4297            .with_context(|| format!("open WMV preview decoder: {}", path.display()))?
4298    };
4299    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
4300    let mut decoder = {
4301        let file = fs::File::open(path)
4302            .with_context(|| format!("open WMV preview: {}", path.display()))?;
4303        wmv_decoder::AsfWmv2Decoder::open(BufReader::new(file))
4304            .with_context(|| format!("open WMV preview decoder: {}", path.display()))?
4305    };
4306
4307    let decoded = decoder
4308        .next_frame()
4309        .with_context(|| format!("decode WMV preview: {}", path.display()))?
4310        .ok_or_else(|| anyhow!("wmv preview frame missing: {}", path.display()))?;
4311    Ok(Arc::new(wmv_yuv_frame_to_rgba(&decoded.frame)))
4312}
4313
4314fn decode_mpeg2_preview_frame(path: &Path) -> Result<Arc<RgbaImage>> {
4315    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
4316    {
4317        let bytes = read_movie_bytes(path)?;
4318        let asset = decode_mpeg2_asset_from_bytes(path, bytes)?;
4319        return asset
4320            .frames
4321            .into_iter()
4322            .next()
4323            .ok_or_else(|| anyhow!("mpeg2 preview frame missing: {}", path.display()));
4324    }
4325
4326    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
4327    {
4328        let mut file =
4329            fs::File::open(path).with_context(|| format!("open movie file: {}", path.display()))?;
4330        let mut pipeline = na_mpeg2_decoder::MpegVideoPipeline::new();
4331        let mut first = None;
4332        let mut buf = vec![0u8; MPEG2_STREAM_CHUNK_BYTES];
4333        loop {
4334            let n = file
4335                .read(&mut buf)
4336                .with_context(|| format!("read movie preview stream: {}", path.display()))?;
4337            if n == 0 {
4338                break;
4339            }
4340            pipeline
4341                .push_with(&buf[..n], None, |f| {
4342                    if first.is_none() {
4343                        let w = f.width as u32;
4344                        let h = f.height as u32;
4345                        let (w, h) = movie_output_dimensions(w, h);
4346                        let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
4347                        na_mpeg2_decoder::frame_to_rgba_bt601_limited_scaled(
4348                            &f, &mut rgba, w as usize, h as usize,
4349                        );
4350                        first = Some(Arc::new(RgbaImage {
4351                            width: w,
4352                            height: h,
4353                            center_x: 0,
4354                            center_y: 0,
4355                            rgba,
4356                        }));
4357                    }
4358                })
4359                .context("mpeg2 preview decode")?;
4360            if first.is_some() {
4361                break;
4362            }
4363        }
4364        if first.is_none() {
4365            pipeline.flush_with(|f| {
4366                if first.is_none() {
4367                    let w = f.width as u32;
4368                    let h = f.height as u32;
4369                    let (w, h) = movie_output_dimensions(w, h);
4370                    let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
4371                    na_mpeg2_decoder::frame_to_rgba_bt601_limited_scaled(
4372                        &f, &mut rgba, w as usize, h as usize,
4373                    );
4374                    first = Some(Arc::new(RgbaImage {
4375                        width: w,
4376                        height: h,
4377                        center_x: 0,
4378                        center_y: 0,
4379                        rgba,
4380                    }));
4381                }
4382            })?;
4383        }
4384        first.ok_or_else(|| anyhow!("mpeg2 preview frame missing: {}", path.display()))
4385    }
4386}
4387
4388fn decode_omv_preview_frame(path: &Path) -> Result<Arc<RgbaImage>> {
4389    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
4390    {
4391        let bytes = read_movie_bytes(path)?;
4392        let asset = decode_omv_asset_from_bytes(path, bytes)?;
4393        return asset
4394            .frames
4395            .into_iter()
4396            .next()
4397            .ok_or_else(|| anyhow!("omv preview frame missing: {}", path.display()));
4398    }
4399
4400    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
4401    {
4402        let omv = siglus_assets::omv::OmvFile::open(path)
4403            .with_context(|| format!("open OMV index: {}", path.display()))?;
4404        let reader = omv
4405            .open_embedded_ogg_reader(path)
4406            .with_context(|| format!("open embedded Ogg stream: {}", path.display()))?;
4407        let mut stream = siglus_omv_decoder::TheoraVideoStream::open(reader)
4408            .with_context(|| format!("open streaming Theora decoder: {}", path.display()))?;
4409        let vinfo = stream.info();
4410        let packed = stream
4411            .read_video_frame()?
4412            .ok_or_else(|| anyhow!("omv preview frame missing: {}", path.display()))?;
4413        let display_h = omv.header.display_height as i32;
4414        let width = omv.header.display_width.max(1);
4415        let height = display_h.max(1) as u32;
4416        let (output_width, output_height) = movie_output_dimensions(width, height);
4417        let rgba = convert_omv_frame_to_size(
4418            &packed,
4419            vinfo.frame_width,
4420            vinfo.frame_height,
4421            vinfo.fmt,
4422            width as i32,
4423            display_h,
4424            omv.header.theora_type,
4425            output_width as usize,
4426            output_height as usize,
4427        );
4428        Ok(Arc::new(RgbaImage {
4429            width: output_width,
4430            height: output_height,
4431            center_x: 0,
4432            center_y: 0,
4433            rgba,
4434        }))
4435    }
4436}
4437
4438fn open_wmv_wma_decoder<R: Read + Seek>(
4439    mut reader: R,
4440) -> Result<Option<wmv_decoder::AsfWmaDecoder<R>>> {
4441    let asf = wmv_decoder::asf::AsfFile::open(&mut reader).context("probe ASF audio streams")?;
4442    if asf.audio_streams.is_empty() {
4443        return Ok(None);
4444    }
4445    if !asf
4446        .audio_streams
4447        .iter()
4448        .any(|stream| matches!(stream.format_tag, 0x0160..=0x0162))
4449    {
4450        let tags = asf
4451            .audio_streams
4452            .iter()
4453            .map(|stream| format!("{:#06x}", stream.format_tag))
4454            .collect::<Vec<_>>()
4455            .join(", ");
4456        bail!("unsupported ASF/WMA audio format tag(s): {tags}");
4457    }
4458    reader.seek(SeekFrom::Start(0))?;
4459    let decoder = wmv_decoder::AsfWmaDecoder::open(reader).context("open ASF/WMA decoder")?;
4460    Ok(Some(decoder))
4461}
4462
4463fn decode_wmv_audio_full_from_reader<R: Read + Seek>(
4464    reader: R,
4465    cancel: &AtomicBool,
4466    timeline_origin_ms: u64,
4467) -> Result<Option<MovieAudio>> {
4468    let Some(mut decoder) = open_wmv_wma_decoder(reader)? else {
4469        return Ok(None);
4470    };
4471    let channels = decoder.channels();
4472    let sample_rate = decoder.sample_rate();
4473    if channels == 0 || sample_rate == 0 {
4474        return Ok(None);
4475    }
4476
4477    let mut samples = Vec::<i16>::new();
4478    let mut first_pts_ms = None::<u64>;
4479    while !cancel.load(Ordering::Acquire) {
4480        let decoded = decoder.next_frame()?;
4481        let Some(decoded) = decoded else {
4482            break;
4483        };
4484        first_pts_ms.get_or_insert(decoded.pts_ms as u64);
4485        samples.extend(decoded.frame.samples.iter().copied().map(f32_to_i16_sample));
4486    }
4487    if cancel.load(Ordering::Acquire) || samples.is_empty() {
4488        return Ok(None);
4489    }
4490    let first_audio_pts_ms = first_pts_ms.unwrap_or(timeline_origin_ms);
4491    let (initial_silence_frames, source_skip_frames) =
4492        wmv_audio_alignment_frames(first_audio_pts_ms, timeline_origin_ms, sample_rate);
4493    let source_skip_samples = source_skip_frames.saturating_mul(channels as usize);
4494    if source_skip_samples >= samples.len() {
4495        return Ok(None);
4496    }
4497    if source_skip_samples > 0 {
4498        samples.drain(..source_skip_samples);
4499    }
4500    if initial_silence_frames > 0 {
4501        let mut timeline_samples = Vec::with_capacity(
4502            initial_silence_frames
4503                .saturating_mul(channels as usize)
4504                .saturating_add(samples.len()),
4505        );
4506        timeline_samples.resize(initial_silence_frames.saturating_mul(channels as usize), 0);
4507        timeline_samples.extend_from_slice(&samples);
4508        samples = timeline_samples;
4509    }
4510    let frames = samples.len() as u64 / channels as u64;
4511    let duration_ms = Some(
4512        ((frames as f64) * 1000.0 / sample_rate as f64)
4513            .round()
4514            .max(1.0) as u64,
4515    );
4516    Ok(Some(MovieAudio {
4517        samples: Arc::new(samples),
4518        mpeg_stream: None,
4519        wmv_stream: None,
4520        channels,
4521        sample_rate,
4522        start_ms: 0,
4523        duration_ms,
4524    }))
4525}
4526
4527fn decode_wmv_audio_for_path(path: &Path, cancel: &AtomicBool) -> Result<Option<MovieAudio>> {
4528    // Native WMV playback must become ready after a bounded head probe, not after
4529    // decoding the whole WMA soundtrack. `WmvMovieAudioDecoder` already provides
4530    // the forward-only Kira streaming path; this probe supplies only the metadata
4531    // and A/V timeline alignment it needs. MPEG uses its own independent probe and
4532    // decoder path and is intentionally untouched here.
4533    let timeline_origin_ms = probe_wmv_first_video_pts_ms(path)?.unwrap_or(0);
4534    if cancel.load(Ordering::Acquire) {
4535        return Ok(None);
4536    }
4537
4538    let file = fs::File::open(path)
4539        .with_context(|| format!("open WMV audio source: {}", path.display()))?;
4540    let Some(mut decoder) = open_wmv_wma_decoder(BufReader::new(file))? else {
4541        return Ok(None);
4542    };
4543    let channels = decoder.channels();
4544    let sample_rate = decoder.sample_rate();
4545    if channels == 0 || sample_rate == 0 {
4546        return Ok(None);
4547    }
4548
4549    // Decode only until the first PCM frame. Besides validating that the selected
4550    // WMA stream can start, its ASF PTS determines whether the movie timeline needs
4551    // leading silence or source-frame skipping. The streaming decoder later reopens
4552    // the file from byte zero and consumes every compressed media object in order.
4553    let first = decoder
4554        .next_frame()
4555        .with_context(|| format!("probe first WMA frame: {}", path.display()))?;
4556    if cancel.load(Ordering::Acquire) {
4557        return Ok(None);
4558    }
4559    let Some(first) = first else {
4560        return Ok(None);
4561    };
4562    let first_audio_pts_ms = first.pts_ms as u64;
4563    let (initial_silence_frames, source_skip_frames) =
4564        wmv_audio_alignment_frames(first_audio_pts_ms, timeline_origin_ms, sample_rate);
4565
4566    // ASF File Properties carries the finite movie duration. Rebase it to the same
4567    // local timeline used by `poll_wmv_stream_frame_for_path` (whose zero is the first
4568    // presented video PTS), then let Kira request decoded WMA frames incrementally.
4569    let duration_ms = decoder
4570        .duration_ms()
4571        .and_then(|duration| wmv_rebase_duration_ms(duration, timeline_origin_ms));
4572
4573    let Some(duration_ms) = duration_ms.filter(|duration| *duration > 0) else {
4574        // Rare malformed/streaming ASF files may omit a usable play duration. Keep
4575        // the old static fallback for that case because Kira requires a finite
4576        // `num_frames`; normal WMV files never take this full-decode path.
4577        let file = fs::File::open(path)
4578            .with_context(|| format!("reopen WMV audio fallback: {}", path.display()))?;
4579        return decode_wmv_audio_full_from_reader(BufReader::new(file), cancel, timeline_origin_ms)
4580            .with_context(|| format!("decode WMA audio fallback: {}", path.display()));
4581    };
4582
4583    let num_frames = ((duration_ms as u128) * sample_rate as u128)
4584        .div_ceil(1000)
4585        .max(1)
4586        .min(usize::MAX as u128) as usize;
4587
4588    Ok(Some(MovieAudio {
4589        samples: Arc::new(Vec::new()),
4590        mpeg_stream: None,
4591        wmv_stream: Some(WmvStreamAudio {
4592            path: Arc::new(path.to_path_buf()),
4593            num_frames,
4594            initial_silence_frames,
4595            source_skip_frames,
4596        }),
4597        channels,
4598        sample_rate,
4599        start_ms: 0,
4600        duration_ms: Some(duration_ms),
4601    }))
4602}
4603
4604fn decode_mpeg2_audio_for_path(path: &Path, cancel: &AtomicBool) -> Result<Option<MovieAudio>> {
4605    if let Some(audio) = quick_probe_mpeg2_stream_audio(path, cancel)? {
4606        return Ok(Some(audio));
4607    }
4608
4609    // Files without usable MPEG-audio PTS (and DVD private audio) retain the
4610    // compatibility fallback.  Normal MP1/2/3 program streams never take this
4611    // full-scan path, so playback can start after bounded head/tail reads.
4612    decode_mpeg2_audio_full_probe_or_static(path, cancel)
4613}
4614
4615fn quick_probe_mpeg2_stream_audio(path: &Path, cancel: &AtomicBool) -> Result<Option<MovieAudio>> {
4616    let mut file =
4617        fs::File::open(path).with_context(|| format!("open movie file: {}", path.display()))?;
4618    let file_len = file
4619        .metadata()
4620        .with_context(|| format!("stat movie file: {}", path.display()))?
4621        .len();
4622    if file_len == 0 {
4623        return Ok(None);
4624    }
4625
4626    let mut head_probe = na_mpeg2_decoder::MpegAudioProbePipeline::new();
4627    let mut buf = vec![0u8; MPEG2_STREAM_CHUNK_BYTES];
4628    let mut head_read = 0u64;
4629    let head_limit = file_len.min(MPEG_AUDIO_HEAD_PROBE_MAX_BYTES);
4630    let mut prefix = Vec::new();
4631    while head_read < head_limit {
4632        if cancel.load(Ordering::Acquire) {
4633            return Ok(None);
4634        }
4635        let want = (head_limit - head_read).min(buf.len() as u64) as usize;
4636        let n = file
4637            .read(&mut buf[..want])
4638            .with_context(|| format!("probe movie audio head: {}", path.display()))?;
4639        if n == 0 {
4640            break;
4641        }
4642        if prefix.len() < 188 * 3 {
4643            let take = (188 * 3 - prefix.len()).min(n);
4644            prefix.extend_from_slice(&buf[..take]);
4645        }
4646        head_probe.push(&buf[..n], None);
4647        head_read = head_read.saturating_add(n as u64);
4648        if head_probe
4649            .stream_info()
4650            .is_some_and(|info| info.first_video_pts_90k.is_some())
4651        {
4652            break;
4653        }
4654    }
4655    let Some(head) = head_probe.stream_info() else {
4656        return Ok(None);
4657    };
4658    if head.sample_rate == 0 || head.channels == 0 {
4659        return Ok(None);
4660    }
4661    let transport_stream = is_transport_stream_prefix(&prefix);
4662
4663    let mut tail_window = MPEG_AUDIO_TAIL_PROBE_INITIAL_BYTES.min(file_len);
4664    let tail = loop {
4665        if cancel.load(Ordering::Acquire) {
4666            return Ok(None);
4667        }
4668        let mut start = file_len.saturating_sub(tail_window);
4669        if transport_stream {
4670            start -= start % 188;
4671        }
4672        file.seek(SeekFrom::Start(start))
4673            .with_context(|| format!("seek movie audio tail: {}", path.display()))?;
4674        let mut tail_bytes = Vec::with_capacity((file_len - start).min(usize::MAX as u64) as usize);
4675        file.read_to_end(&mut tail_bytes)
4676            .with_context(|| format!("read movie audio tail: {}", path.display()))?;
4677        let probe_bytes = if transport_stream {
4678            tail_bytes.as_slice()
4679        } else if let Some(pack) = find_byte_pattern(&tail_bytes, b"\0\0\x01\xba") {
4680            &tail_bytes[pack..]
4681        } else {
4682            tail_bytes.as_slice()
4683        };
4684        let mut tail_probe = na_mpeg2_decoder::MpegAudioTailProbePipeline::new();
4685        tail_probe.push(probe_bytes);
4686        if let Some(info) = tail_probe
4687            .finish()
4688            .filter(|info| info.sample_rate == head.sample_rate && info.channels == head.channels)
4689        {
4690            break Some(info);
4691        }
4692        if tail_window >= file_len || tail_window >= MPEG_AUDIO_TAIL_PROBE_MAX_BYTES {
4693            break None;
4694        }
4695        tail_window = tail_window
4696            .saturating_mul(2)
4697            .min(file_len)
4698            .min(MPEG_AUDIO_TAIL_PROBE_MAX_BYTES);
4699    };
4700    let Some(tail) = tail else {
4701        return Ok(None);
4702    };
4703
4704    let origin_pts_90k = head
4705        .first_video_pts_90k
4706        .map(|video| earlier_mpeg_pts_90k(video, head.first_audio_pts_90k))
4707        .unwrap_or(head.first_audio_pts_90k);
4708    let delay_ticks = mpeg_pts_delta_90k(head.first_audio_pts_90k, origin_pts_90k).unwrap_or(0);
4709    let before_tail_ticks = match mpeg_pts_delta_90k(tail.anchor_pts_90k, head.first_audio_pts_90k)
4710    {
4711        Some(value) => value,
4712        None => return Ok(None),
4713    };
4714    let delay_frames = pts90k_ticks_to_audio_frames(delay_ticks, head.sample_rate);
4715    let before_tail_frames = pts90k_ticks_to_audio_frames(before_tail_ticks, head.sample_rate);
4716    let num_frames = delay_frames
4717        .saturating_add(before_tail_frames)
4718        .saturating_add(tail.output_frames);
4719    if num_frames == 0 {
4720        return Ok(None);
4721    }
4722    let tail_ticks = audio_frames_to_pts90k_ticks(tail.output_frames, head.sample_rate);
4723    let audio_end_pts_90k = tail.anchor_pts_90k.saturating_add(tail_ticks) & ((1i64 << 33) - 1);
4724    let duration_ms = Some(
4725        ((num_frames as u128).saturating_mul(1000) / head.sample_rate as u128).min(u64::MAX as u128)
4726            as u64,
4727    );
4728
4729    Ok(Some(MovieAudio {
4730        samples: Arc::new(Vec::new()),
4731        mpeg_stream: Some(MpegStreamAudio {
4732            path: Arc::new(path.to_path_buf()),
4733            num_frames,
4734            first_audio_pts_ms: pts90k_to_ms(head.first_audio_pts_90k),
4735            first_video_pts_ms: head.first_video_pts_90k.map(pts90k_to_ms),
4736            first_audio_pts_90k: head.first_audio_pts_90k,
4737            first_video_pts_90k: head.first_video_pts_90k,
4738            audio_end_pts_90k,
4739            file_len,
4740            transport_stream,
4741        }),
4742        wmv_stream: None,
4743        channels: head.channels,
4744        sample_rate: head.sample_rate,
4745        start_ms: 0,
4746        duration_ms,
4747    }))
4748}
4749
4750fn decode_mpeg2_audio_full_probe_or_static(
4751    path: &Path,
4752    cancel: &AtomicBool,
4753) -> Result<Option<MovieAudio>> {
4754    let mut file =
4755        fs::File::open(path).with_context(|| format!("open movie file: {}", path.display()))?;
4756    let file_len = file.metadata().map(|m| m.len()).unwrap_or(0);
4757    let mut probe = na_mpeg2_decoder::MpegAudioProbePipeline::new();
4758    let mut buf = vec![0u8; MPEG2_STREAM_CHUNK_BYTES];
4759    let mut prefix = Vec::new();
4760
4761    loop {
4762        if cancel.load(Ordering::Acquire) {
4763            return Ok(None);
4764        }
4765        let n = file
4766            .read(&mut buf)
4767            .with_context(|| format!("probe movie audio stream: {}", path.display()))?;
4768        if n == 0 {
4769            break;
4770        }
4771        if prefix.len() < 188 * 3 {
4772            let take = (188 * 3 - prefix.len()).min(n);
4773            prefix.extend_from_slice(&buf[..take]);
4774        }
4775        probe.push(&buf[..n], None);
4776    }
4777
4778    if let Some(info) = probe.finish() {
4779        let duration_ms = Some(
4780            ((info.num_frames as u128).saturating_mul(1000) / info.sample_rate as u128)
4781                .min(u64::MAX as u128) as u64,
4782        );
4783        let audio_duration_ticks = audio_frames_to_pts90k_ticks(
4784            info.num_frames.saturating_sub(pts90k_ticks_to_audio_frames(
4785                mpeg_pts_delta_90k(
4786                    info.first_audio_pts_90k,
4787                    info.first_video_pts_90k
4788                        .map(|video| earlier_mpeg_pts_90k(video, info.first_audio_pts_90k))
4789                        .unwrap_or(info.first_audio_pts_90k),
4790                )
4791                .unwrap_or(0),
4792                info.sample_rate,
4793            )),
4794            info.sample_rate,
4795        );
4796        return Ok(Some(MovieAudio {
4797            samples: Arc::new(Vec::new()),
4798            mpeg_stream: Some(MpegStreamAudio {
4799                path: Arc::new(path.to_path_buf()),
4800                num_frames: info.num_frames,
4801                first_audio_pts_ms: info.first_audio_pts_ms,
4802                first_video_pts_ms: info.first_video_pts_ms,
4803                first_audio_pts_90k: info.first_audio_pts_90k,
4804                first_video_pts_90k: info.first_video_pts_90k,
4805                audio_end_pts_90k: info
4806                    .first_audio_pts_90k
4807                    .saturating_add(audio_duration_ticks),
4808                file_len,
4809                transport_stream: is_transport_stream_prefix(&prefix),
4810            }),
4811            wmv_stream: None,
4812            channels: info.channels,
4813            sample_rate: info.sample_rate,
4814            start_ms: 0,
4815            duration_ms,
4816        }));
4817    }
4818
4819    decode_mpeg2_audio_static_fallback(path, cancel)
4820}
4821
4822fn is_transport_stream_prefix(prefix: &[u8]) -> bool {
4823    prefix.len() >= 188 * 3 && prefix[0] == 0x47 && prefix[188] == 0x47 && prefix[376] == 0x47
4824}
4825
4826fn find_byte_pattern(haystack: &[u8], needle: &[u8]) -> Option<usize> {
4827    if needle.is_empty() || haystack.len() < needle.len() {
4828        return None;
4829    }
4830    haystack
4831        .windows(needle.len())
4832        .position(|window| window == needle)
4833}
4834
4835fn earlier_mpeg_pts_90k(lhs: i64, rhs: i64) -> i64 {
4836    if mpeg_pts_delta_90k(lhs, rhs).is_some() {
4837        rhs
4838    } else {
4839        lhs
4840    }
4841}
4842
4843fn mpeg_pts_delta_90k(later: i64, earlier: i64) -> Option<i64> {
4844    const PTS_WRAP: i64 = 1i64 << 33;
4845    let mut delta = later.saturating_sub(earlier);
4846    if delta < 0 {
4847        delta = delta.saturating_add(PTS_WRAP);
4848    }
4849    // A movie longer than half the 33-bit PTS range is outside the supported
4850    // Siglus media use case and more likely indicates unrelated/missing PTS.
4851    (delta <= PTS_WRAP / 2).then_some(delta)
4852}
4853
4854fn pts90k_ticks_to_audio_frames(ticks: i64, sample_rate: u32) -> usize {
4855    if ticks <= 0 || sample_rate == 0 {
4856        return 0;
4857    }
4858    (((ticks as i128) * (sample_rate as i128) + 45_000) / 90_000).clamp(0, usize::MAX as i128)
4859        as usize
4860}
4861
4862fn audio_frames_to_pts90k_ticks(frames: usize, sample_rate: u32) -> i64 {
4863    if frames == 0 || sample_rate == 0 {
4864        return 0;
4865    }
4866    (((frames as i128) * 90_000 + sample_rate as i128 / 2) / sample_rate as i128)
4867        .clamp(0, i64::MAX as i128) as i64
4868}
4869
4870fn pts90k_to_ms(pts: i64) -> i64 {
4871    ((pts as i128) * 1000 / 90_000).clamp(i64::MIN as i128, i64::MAX as i128) as i64
4872}
4873
4874fn decode_mpeg2_audio_static_fallback(
4875    path: &Path,
4876    cancel: &AtomicBool,
4877) -> Result<Option<MovieAudio>> {
4878    let mut chunks = Vec::<na_mpeg2_decoder::MpegAudioF32>::new();
4879    let mut file =
4880        fs::File::open(path).with_context(|| format!("open movie file: {}", path.display()))?;
4881    let mut pipeline = na_mpeg2_decoder::MpegAudioPipeline::new();
4882    let mut buf = vec![0u8; MPEG2_STREAM_CHUNK_BYTES];
4883
4884    loop {
4885        if cancel.load(Ordering::Acquire) {
4886            return Ok(None);
4887        }
4888        let n = file
4889            .read(&mut buf)
4890            .with_context(|| format!("read movie audio stream: {}", path.display()))?;
4891        if n == 0 {
4892            break;
4893        }
4894        pipeline
4895            .push_with(&buf[..n], None, |audio| chunks.push(audio))
4896            .context("mpeg2 audio-only decode")?;
4897    }
4898    if cancel.load(Ordering::Acquire) {
4899        return Ok(None);
4900    }
4901    pipeline
4902        .flush_with(|audio| chunks.push(audio))
4903        .context("mpeg2 audio-only flush")?;
4904
4905    build_mpeg2_audio_timeline(path, chunks, pipeline.first_video_pts_ms(), cancel)
4906}
4907
4908fn build_mpeg2_audio_timeline(
4909    path: &Path,
4910    chunks: Vec<na_mpeg2_decoder::MpegAudioF32>,
4911    first_video_pts_ms: Option<i64>,
4912    cancel: &AtomicBool,
4913) -> Result<Option<MovieAudio>> {
4914    let Some(first) = chunks
4915        .iter()
4916        .find(|chunk| chunk.channels > 0 && chunk.sample_rate > 0 && !chunk.samples.is_empty())
4917    else {
4918        return Ok(None);
4919    };
4920
4921    let channels = first.channels;
4922    let sample_rate = first.sample_rate;
4923    let first_audio_pts_ms = chunks
4924        .iter()
4925        .find(|chunk| chunk.channels > 0 && chunk.sample_rate > 0 && !chunk.samples.is_empty())
4926        .map(|chunk| chunk.pts_ms)
4927        .unwrap_or(0);
4928    let timeline_origin_ms = match first_video_pts_ms {
4929        Some(video_pts) => video_pts.min(first_audio_pts_ms),
4930        None => first_audio_pts_ms,
4931    };
4932
4933    // MPEG audio is a continuous elementary stream. PES PTS values are sparse
4934    // anchors, not edit instructions for every decoded frame. The previous
4935    // implementation rounded every anchor to integer milliseconds and then
4936    // inserted silence or discarded PCM whenever the rounded target differed
4937    // from the accumulated sample count. On 44.1 kHz MP2 this periodically
4938    // manufactured audible gaps/overlaps (the reported "disc skipping" sound).
4939    //
4940    // Preserve only the initial A/V offset here and append decoded audio frames
4941    // contiguously. The decoder already emits frames in elementary-stream order.
4942    let initial_delay_ms = first_audio_pts_ms.saturating_sub(timeline_origin_ms).max(0);
4943    let initial_delay_frames = ((initial_delay_ms as i128) * (sample_rate as i128) / 1000)
4944        .clamp(0, usize::MAX as i128) as usize;
4945    let mut samples = vec![0i16; initial_delay_frames.saturating_mul(channels as usize)];
4946    let mut decoded_frames = 0usize;
4947    let mut discontinuity_count = 0usize;
4948    let mut max_abs_drift_frames = 0usize;
4949    let tolerance_frames = ((sample_rate as u64) * 5 / 1000).max(1) as usize;
4950
4951    for chunk in chunks {
4952        if cancel.load(Ordering::Acquire) {
4953            return Ok(None);
4954        }
4955        if chunk.channels == 0 || chunk.sample_rate == 0 || chunk.samples.is_empty() {
4956            continue;
4957        }
4958        let converted = convert_movie_audio_chunk(
4959            &chunk.samples,
4960            chunk.channels,
4961            chunk.sample_rate,
4962            channels,
4963            sample_rate,
4964        );
4965        if converted.is_empty() {
4966            continue;
4967        }
4968
4969        // Keep PTS drift as diagnostics only. Do not splice the PCM stream at
4970        // each PES boundary: those boundaries may split an MPEG audio frame and
4971        // integer-millisecond timestamps cannot represent 1152 / 44100 seconds.
4972        let relative_pts_ms = chunk.pts_ms.saturating_sub(first_audio_pts_ms);
4973        if relative_pts_ms >= 0 {
4974            let pts_frames = ((relative_pts_ms as i128) * (sample_rate as i128) / 1000)
4975                .clamp(0, usize::MAX as i128) as usize;
4976            let abs_drift = pts_frames.abs_diff(decoded_frames);
4977            max_abs_drift_frames = max_abs_drift_frames.max(abs_drift);
4978            if abs_drift > tolerance_frames {
4979                discontinuity_count = discontinuity_count.saturating_add(1);
4980            }
4981        }
4982
4983        decoded_frames = decoded_frames.saturating_add(converted.len() / channels as usize);
4984        samples.extend_from_slice(&converted);
4985    }
4986
4987    if (env_is_set!("SG_MOVIE_TRACE") || env_is_set!("SG_DEBUG")) && discontinuity_count > 0 {
4988        eprintln!(
4989            "[SG_DEBUG][MOV] audio_pts.contiguous path={} anchors_over_tolerance={} max_drift_frames={} max_drift_ms={}",
4990            path.display(),
4991            discontinuity_count,
4992            max_abs_drift_frames,
4993            (max_abs_drift_frames as u64).saturating_mul(1000) / sample_rate as u64
4994        );
4995    }
4996
4997    if samples.is_empty() {
4998        return Ok(None);
4999    }
5000    let frames_len = samples.len() as u64 / channels as u64;
5001    let duration_ms = Some(((frames_len as f64) * 1000.0 / sample_rate as f64).round() as u64);
5002    Ok(Some(MovieAudio {
5003        samples: Arc::new(samples),
5004        mpeg_stream: None,
5005        wmv_stream: None,
5006        channels,
5007        sample_rate,
5008        start_ms: 0,
5009        duration_ms,
5010    }))
5011}
5012
5013fn convert_movie_audio_chunk(
5014    samples: &[f32],
5015    src_channels: u16,
5016    src_sample_rate: u32,
5017    dst_channels: u16,
5018    dst_sample_rate: u32,
5019) -> Vec<i16> {
5020    let src_channels_usize = src_channels as usize;
5021    let dst_channels_usize = dst_channels as usize;
5022    if src_channels_usize == 0
5023        || dst_channels_usize == 0
5024        || src_sample_rate == 0
5025        || dst_sample_rate == 0
5026    {
5027        return Vec::new();
5028    }
5029    let src_frames = samples.len() / src_channels_usize;
5030    if src_frames == 0 {
5031        return Vec::new();
5032    }
5033
5034    if src_channels == dst_channels && src_sample_rate == dst_sample_rate {
5035        return samples.iter().copied().map(f32_to_i16_sample).collect();
5036    }
5037
5038    let dst_frames = ((src_frames as u128) * (dst_sample_rate as u128) / (src_sample_rate as u128))
5039        .max(1) as usize;
5040    let mut out = Vec::with_capacity(dst_frames.saturating_mul(dst_channels_usize));
5041
5042    for dst_frame in 0..dst_frames {
5043        let src_pos_num = (dst_frame as u128) * (src_sample_rate as u128);
5044        let src_index = (src_pos_num / dst_sample_rate as u128) as usize;
5045        let src_index = src_index.min(src_frames - 1);
5046        let next_index = src_index.saturating_add(1).min(src_frames - 1);
5047        let frac_num = (src_pos_num % dst_sample_rate as u128) as f32;
5048        let frac = frac_num / dst_sample_rate as f32;
5049
5050        for dst_channel in 0..dst_channels_usize {
5051            let sample_at = |frame: usize| -> f32 {
5052                if dst_channels_usize == 1 && src_channels_usize > 1 {
5053                    let base = frame * src_channels_usize;
5054                    let sum: f32 = samples[base..base + src_channels_usize]
5055                        .iter()
5056                        .copied()
5057                        .sum();
5058                    sum / src_channels_usize as f32
5059                } else if src_channels_usize == 1 {
5060                    samples[frame]
5061                } else {
5062                    let src_channel = dst_channel.min(src_channels_usize - 1);
5063                    samples[frame * src_channels_usize + src_channel]
5064                }
5065            };
5066            let a = sample_at(src_index);
5067            let b = sample_at(next_index);
5068            out.push(f32_to_i16_sample(a + (b - a) * frac));
5069        }
5070    }
5071    out
5072}
5073
5074fn decode_wmv_asset(path: &Path) -> Result<MovieAsset> {
5075    let info = probe_wmv_movie_info(path)?;
5076    let frame = decode_wmv_preview_frame(path)?;
5077    Ok(MovieAsset {
5078        info: MovieInfo {
5079            width: info.width.or(Some(frame.width)),
5080            height: info.height.or(Some(frame.height)),
5081            decoded_frames: Some(1),
5082            ..info
5083        },
5084        frames: vec![frame],
5085        audio: None,
5086    })
5087}
5088
5089fn decode_mpeg2_asset(path: &Path) -> Result<MovieAsset> {
5090    let prefix = read_file_prefix(path, MPEG2_HEADER_PROBE_BYTES)
5091        .with_context(|| format!("read movie header: {}", path.display()))?;
5092    let mut width = None;
5093    let mut height = None;
5094    let mut fps = None;
5095    if let Some(h) = siglus_assets::mpeg2::find_sequence_header(&prefix) {
5096        width = Some(h.width as u32);
5097        height = Some(h.height as u32);
5098        fps = siglus_assets::mpeg2::fps_from_frame_rate_code(h.frame_rate_code);
5099    }
5100    let frame = decode_mpeg2_preview_frame(path)?;
5101    let info = MovieInfo {
5102        path: path.to_path_buf(),
5103        width: width.or(Some(frame.width)),
5104        height: height.or(Some(frame.height)),
5105        fps,
5106        decoded_frames: Some(1),
5107        audio_duration_ms: None,
5108    };
5109    Ok(MovieAsset {
5110        info,
5111        frames: vec![frame],
5112        audio: None,
5113    })
5114}
5115
5116fn f32_to_i16_sample(s: f32) -> i16 {
5117    let clamped = if s.is_nan() { -1.0 } else { s.clamp(-1.0, 1.0) };
5118    (clamped * 32767.0).round() as i16
5119}
5120
5121fn decode_omv_asset(path: &Path) -> Result<MovieAsset> {
5122    let omv = siglus_assets::omv::OmvFile::open(path).ok();
5123    let frame = decode_omv_preview_frame(path)?;
5124    let fps = omv.as_ref().and_then(|m| {
5125        if m.header.frame_time_us != 0 {
5126            Some(1_000_000.0 / (m.header.frame_time_us as f32))
5127        } else {
5128            None
5129        }
5130    });
5131    let decoded_frames = omv
5132        .as_ref()
5133        .and_then(|m| {
5134            (m.header.packet_count_hint > 0).then_some(m.header.packet_count_hint as usize)
5135        })
5136        .or(Some(1));
5137    let audio_duration_ms = decoded_frames.and_then(|frames| {
5138        omv_frame_duration_ms(omv.as_ref().map(|m| &m.header), fps)
5139            .map(|ms| ((frames as f64) * ms).round().max(1.0) as u64)
5140    });
5141    let info = MovieInfo {
5142        path: path.to_path_buf(),
5143        width: Some(frame.width),
5144        height: Some(frame.height),
5145        fps,
5146        decoded_frames,
5147        audio_duration_ms,
5148    };
5149    Ok(MovieAsset {
5150        info,
5151        frames: vec![frame],
5152        audio: None,
5153    })
5154}
5155
5156fn decode_omv_audio(tf: &mut siglus_omv_decoder::TheoraFile) -> Result<Option<MovieAudio>> {
5157    if !tf.has_audio() {
5158        return Ok(None);
5159    }
5160    let Some((channels, sample_rate)) = tf.audio_info() else {
5161        return Ok(None);
5162    };
5163    if channels <= 0 || sample_rate <= 0 {
5164        return Ok(None);
5165    }
5166    let channels_u16 = channels as u16;
5167    let sample_rate_u32 = sample_rate as u32;
5168
5169    let mut samples: Vec<f32> = Vec::new();
5170    let mut buf = vec![0.0f32; (4096usize).saturating_mul(channels as usize)];
5171    loop {
5172        let read = tf.read_audio_samples(&mut buf)?;
5173        if read == 0 {
5174            break;
5175        }
5176        samples.extend_from_slice(&buf[..read]);
5177    }
5178
5179    if samples.is_empty() {
5180        return Ok(None);
5181    }
5182    let mut samples_i16: Vec<i16> = Vec::with_capacity(samples.len());
5183    for &s in &samples {
5184        let clamped = if s.is_nan() { -1.0 } else { s.clamp(-1.0, 1.0) };
5185        let v = (clamped * 32767.0).round() as i16;
5186        samples_i16.push(v);
5187    }
5188    let frames = (samples_i16.len() as u64) / (channels_u16 as u64);
5189    let duration_ms = if sample_rate_u32 > 0 {
5190        Some(((frames as f64) * 1000.0 / (sample_rate_u32 as f64)).round() as u64)
5191    } else {
5192        None
5193    };
5194
5195    Ok(Some(MovieAudio {
5196        samples: Arc::new(samples_i16),
5197        mpeg_stream: None,
5198        wmv_stream: None,
5199        channels: channels_u16,
5200        sample_rate: sample_rate_u32,
5201        start_ms: 0,
5202        duration_ms,
5203    }))
5204}
5205
5206fn encode_wav_i16_interleaved(samples: &[i16], channels: u16, sample_rate: u32) -> Vec<u8> {
5207    let bytes_per_sample = 2u16;
5208    let block_align = channels.saturating_mul(bytes_per_sample);
5209    let byte_rate = (sample_rate as u64).saturating_mul(block_align as u64) as u32;
5210    let data_bytes = samples.len().saturating_mul(bytes_per_sample as usize) as u32;
5211    let riff_size = 36u32.saturating_add(data_bytes);
5212
5213    let mut out = Vec::with_capacity((data_bytes as usize) + 44);
5214    out.extend_from_slice(b"RIFF");
5215    out.extend_from_slice(&riff_size.to_le_bytes());
5216    out.extend_from_slice(b"WAVE");
5217    out.extend_from_slice(b"fmt ");
5218    out.extend_from_slice(&16u32.to_le_bytes());
5219    out.extend_from_slice(&1u16.to_le_bytes());
5220    out.extend_from_slice(&channels.to_le_bytes());
5221    out.extend_from_slice(&sample_rate.to_le_bytes());
5222    out.extend_from_slice(&byte_rate.to_le_bytes());
5223    out.extend_from_slice(&block_align.to_le_bytes());
5224    out.extend_from_slice(&16u16.to_le_bytes());
5225    out.extend_from_slice(b"data");
5226    out.extend_from_slice(&data_bytes.to_le_bytes());
5227
5228    for &s in samples {
5229        out.extend_from_slice(&s.to_le_bytes());
5230    }
5231    out
5232}
5233
5234fn convert_omv_frame(
5235    data: &[u8],
5236    frame_width: i32,
5237    frame_height: i32,
5238    fmt: i32,
5239    display_width: i32,
5240    display_height: i32,
5241    theora_type: u32,
5242) -> Vec<u8> {
5243    convert_omv_frame_to_size(
5244        data,
5245        frame_width,
5246        frame_height,
5247        fmt,
5248        display_width,
5249        display_height,
5250        theora_type,
5251        display_width.max(1) as usize,
5252        display_height.max(1) as usize,
5253    )
5254}
5255
5256fn convert_omv_frame_to_size(
5257    data: &[u8],
5258    frame_width: i32,
5259    frame_height: i32,
5260    fmt: i32,
5261    display_width: i32,
5262    display_height: i32,
5263    theora_type: u32,
5264    output_width: usize,
5265    output_height: usize,
5266) -> Vec<u8> {
5267    // tona3 does not apply Theora pic_x/pic_y to OMV video. It asks
5268    // th_decode_ycbcr_out() for the decoded planes, starts at plane.data, and
5269    // advances each row by the plane stride while using the OMV header's
5270    // theora_size as the visible rectangle. `data` is the same set of full
5271    // decoded planes repacked tightly, so frame_width/frame_height are the
5272    // source strides here and display_width/display_height are the output size.
5273    let (uv_w, _uv_h, y_plane_len, u_plane_len, _v_plane_len) =
5274        omv_plane_layout(frame_width, frame_height, fmt);
5275    let planes = PackedOmvPlanes {
5276        data,
5277        offsets: [0, y_plane_len, y_plane_len.saturating_add(u_plane_len)],
5278        widths: [frame_width.max(1) as usize, uv_w, uv_w],
5279    };
5280    convert_omv_planes_to_size(
5281        &planes,
5282        frame_width,
5283        frame_height,
5284        fmt,
5285        display_width,
5286        display_height,
5287        theora_type,
5288        output_width,
5289        output_height,
5290    )
5291}
5292
5293/// Decoded OMV planes (Y/B, U/G, V/R) for `convert_omv_planes_to_size`.
5294trait OmvPlanes {
5295    /// Plane `pli`'s sample at (`x`, `y`), or `default` outside it.
5296    fn sample(&self, pli: usize, x: usize, y: usize, default: u8) -> u8;
5297
5298    /// Row `y` of plane `pli` as `len` bytes, when byte `x` of it is
5299    /// `sample(pli, x, y, _)` for every `x < len` (None otherwise).
5300    fn row(&self, pli: usize, y: usize, len: usize) -> Option<&[u8]>;
5301}
5302
5303/// The planes repacked tightly one after another (worker messages).
5304struct PackedOmvPlanes<'a> {
5305    data: &'a [u8],
5306    offsets: [usize; 3],
5307    widths: [usize; 3],
5308}
5309
5310impl OmvPlanes for PackedOmvPlanes<'_> {
5311    fn sample(&self, pli: usize, x: usize, y: usize, default: u8) -> u8 {
5312        get_plane_sample(
5313            self.data,
5314            self.offsets[pli],
5315            self.widths[pli],
5316            x,
5317            y,
5318            default,
5319        )
5320    }
5321
5322    fn row(&self, pli: usize, y: usize, len: usize) -> Option<&[u8]> {
5323        let width = self.widths[pli];
5324        if width == 0 {
5325            return None;
5326        }
5327        let start = self.offsets[pli].checked_add(y.checked_mul(width)?)?;
5328        self.data.get(start..start.checked_add(len)?)
5329    }
5330}
5331
5332/// The planes as the Theora decoder holds them (no repacked copy: at
5333/// 1920x1440 4:4:4 that was 8 MiB per frame per stream).
5334struct DecoderOmvPlanes<'a>(&'a siglus_omv_decoder::YCbCrRef<'a>);
5335
5336impl OmvPlanes for DecoderOmvPlanes<'_> {
5337    fn sample(&self, pli: usize, x: usize, y: usize, default: u8) -> u8 {
5338        let plane = &self.0[pli];
5339        let (width, height) = (plane.width.max(0) as usize, plane.height.max(0) as usize);
5340        if x >= width || y >= height {
5341            return default;
5342        }
5343        let index = plane.data_offset as isize + y as isize * plane.stride as isize + x as isize;
5344        usize::try_from(index)
5345            .ok()
5346            .and_then(|index| plane.data.get(index))
5347            .copied()
5348            .unwrap_or(default)
5349    }
5350
5351    fn row(&self, pli: usize, y: usize, len: usize) -> Option<&[u8]> {
5352        let plane = &self.0[pli];
5353        if len > plane.width.max(0) as usize || y >= plane.height.max(0) as usize {
5354            return None;
5355        }
5356        let start = plane.data_offset as isize + y as isize * plane.stride as isize;
5357        let start = usize::try_from(start).ok()?;
5358        plane.data.get(start..start.checked_add(len)?)
5359    }
5360}
5361
5362fn bilinear_omv_sample(
5363    planes: &impl OmvPlanes,
5364    pli: usize,
5365    x: (usize, usize, u32),
5366    y: (usize, usize, u32),
5367    default: u8,
5368) -> u8 {
5369    let (x0, x1, fx) = x;
5370    let (y0, y1, fy) = y;
5371    let p00 = u64::from(planes.sample(pli, x0, y0, default));
5372    let p10 = u64::from(planes.sample(pli, x1, y0, default));
5373    let p01 = u64::from(planes.sample(pli, x0, y1, default));
5374    let p11 = u64::from(planes.sample(pli, x1, y1, default));
5375    const ONE: u64 = 1 << 16;
5376    let fx = u64::from(fx);
5377    let fy = u64::from(fy);
5378    let top = p00 * (ONE - fx) + p10 * fx;
5379    let bottom = p01 * (ONE - fx) + p11 * fx;
5380    ((top * (ONE - fy) + bottom * fy + (1 << 31)) >> 32) as u8
5381}
5382
5383#[allow(clippy::too_many_arguments)]
5384#[allow(clippy::too_many_arguments)]
5385fn convert_omv_planes_to_size(
5386    planes: &impl OmvPlanes,
5387    frame_width: i32,
5388    frame_height: i32,
5389    fmt: i32,
5390    display_width: i32,
5391    display_height: i32,
5392    theora_type: u32,
5393    output_width: usize,
5394    output_height: usize,
5395) -> Vec<u8> {
5396    let mut rgba = Vec::new();
5397    convert_omv_planes_into(
5398        planes,
5399        frame_width,
5400        frame_height,
5401        fmt,
5402        display_width,
5403        display_height,
5404        theora_type,
5405        output_width,
5406        output_height,
5407        &mut rgba,
5408    );
5409    rgba
5410}
5411
5412/// `convert_omv_planes_to_size` into `rgba` (reused: every pixel is
5413/// written, so a buffer of the right size is not cleared first).
5414#[allow(clippy::too_many_arguments)]
5415fn convert_omv_planes_into(
5416    planes: &impl OmvPlanes,
5417    frame_width: i32,
5418    frame_height: i32,
5419    fmt: i32,
5420    display_width: i32,
5421    display_height: i32,
5422    theora_type: u32,
5423    output_width: usize,
5424    output_height: usize,
5425    rgba: &mut Vec<u8>,
5426) {
5427    let sw = frame_width.max(1) as usize;
5428    let sh = frame_height.max(1) as usize;
5429    let dw = display_width.max(1) as usize;
5430    let dh = display_height.max(1) as usize;
5431    let (uv_w, uv_h) = yuv_plane_size(frame_width, frame_height, fmt);
5432
5433    rgba.resize(output_width.saturating_mul(output_height).saturating_mul(4), 0);
5434    // Source column of each output column, and how much of a row they
5435    // span: rows are then read as slices (per-pixel sampling with a division
5436    // and bounds checks took longer than decoding a 1080p frame).
5437    let source_x: Vec<usize> = (0..output_width)
5438        .map(|out_x| out_x * dw / output_width)
5439        .collect();
5440    let row_len = source_x.last().map_or(0, |&x| x + 1);
5441
5442    match (theora_type, fmt) {
5443        (siglus_assets::omv::OMV_THEORA_TYPE_RGB, _) => {
5444            for out_y in 0..output_height {
5445                let y = out_y * dh / output_height;
5446                if let (Some(b_row), Some(g_row), Some(r_row)) = (
5447                    planes.row(0, y, row_len),
5448                    planes.row(1, y, row_len),
5449                    planes.row(2, y, row_len),
5450                ) {
5451                    let out_row =
5452                        &mut rgba[out_y * output_width * 4..(out_y + 1) * output_width * 4];
5453                    for (px, &x) in out_row.chunks_exact_mut(4).zip(&source_x) {
5454                        px[0] = r_row[x];
5455                        px[1] = g_row[x];
5456                        px[2] = b_row[x];
5457                        px[3] = 0xff;
5458                    }
5459                    continue;
5460                }
5461                for out_x in 0..output_width {
5462                    let x = out_x * dw / output_width;
5463                    let b = planes.sample(0, x, y, 0);
5464                    let g = planes.sample(1, x, y, 0);
5465                    let r = planes.sample(2, x, y, 0);
5466                    let out = (out_y * output_width + out_x) * 4;
5467                    rgba[out] = r;
5468                    rgba[out + 1] = g;
5469                    rgba[out + 2] = b;
5470                    rgba[out + 3] = 0xff;
5471                }
5472            }
5473        }
5474        (siglus_assets::omv::OMV_THEORA_TYPE_RGBA, _) => {
5475            // Original layout: visible B/G/R occupy the first `dh` rows of
5476            // the three 4:4:4 planes. Alpha follows below that visible region:
5477            // top third in Y, middle third in U, bottom third in V.
5478            let alpha_h = dh.div_ceil(3);
5479            let alpha_h_2 = alpha_h * 2;
5480            for out_y in 0..output_height {
5481                let y = out_y * dh / output_height;
5482                let (a_pli, local_y) = if y < alpha_h {
5483                    (0, y)
5484                } else if y < alpha_h_2 {
5485                    (1, y - alpha_h)
5486                } else {
5487                    (2, y - alpha_h_2)
5488                };
5489                let alpha_y = dh.saturating_add(local_y);
5490                if let (Some(b_row), Some(g_row), Some(r_row), Some(a_row)) = (
5491                    planes.row(0, y, row_len),
5492                    planes.row(1, y, row_len),
5493                    planes.row(2, y, row_len),
5494                    planes.row(a_pli, alpha_y, row_len),
5495                ) {
5496                    let out_row =
5497                        &mut rgba[out_y * output_width * 4..(out_y + 1) * output_width * 4];
5498                    for (px, &x) in out_row.chunks_exact_mut(4).zip(&source_x) {
5499                        px[0] = r_row[x];
5500                        px[1] = g_row[x];
5501                        px[2] = b_row[x];
5502                        px[3] = a_row[x];
5503                    }
5504                    continue;
5505                }
5506                for out_x in 0..output_width {
5507                    let x = out_x * dw / output_width;
5508                    let b = planes.sample(0, x, y, 0);
5509                    let g = planes.sample(1, x, y, 0);
5510                    let r = planes.sample(2, x, y, 0);
5511                    let a = planes.sample(a_pli, x, alpha_y, 0xff);
5512                    let out = (out_y * output_width + out_x) * 4;
5513                    rgba[out] = r;
5514                    rgba[out + 1] = g;
5515                    rgba[out + 2] = b;
5516                    rgba[out + 3] = a;
5517                }
5518            }
5519        }
5520        (_, siglus_omv_decoder::TH_PF_444) => {
5521            // This is the exact tona3 YUV path: all three source planes are
5522            // sampled at the same x/y and the float result is truncated by the
5523            // C++ `(int)` cast before clamping.
5524            for out_y in 0..output_height {
5525                let y = out_y * dh / output_height;
5526                for out_x in 0..output_width {
5527                    let x = out_x * dw / output_width;
5528                    let yv = planes.sample(0, x, y, 0) as f32;
5529                    let u = planes.sample(1, x, y, 128) as f32 - 128.0;
5530                    let v = planes.sample(2, x, y, 128) as f32 - 128.0;
5531                    let out = (out_y * output_width + out_x) * 4;
5532                    rgba[out] = clamp_f(yv + 1.40200 * v);
5533                    rgba[out + 1] = clamp_f(yv - 0.34414 * u - 0.71414 * v);
5534                    rgba[out + 2] = clamp_f(yv + 1.77200 * u);
5535                    rgba[out + 3] = 0xff;
5536                }
5537            }
5538        }
5539        _ => {
5540            // Siglus-authored RGB/RGBA/YUV OMVs are 4:4:4 in the original
5541            // decoder path. Retain the existing 4:2:0/4:2:2 compatibility
5542            // fallback for nonstandard files, but base it on the full coded
5543            // frame rather than the Theora picture rectangle.
5544            let chroma_x: Vec<_> = (0..dw)
5545                .map(|x| centred_resample_coordinate(x, uv_w, sw))
5546                .collect();
5547            let chroma_y: Vec<_> = (0..dh)
5548                .map(|y| centred_resample_coordinate(y, uv_h, sh))
5549                .collect();
5550
5551            for out_y in 0..output_height {
5552                let y = out_y * dh / output_height;
5553                let y_coord = chroma_y[y];
5554                for out_x in 0..output_width {
5555                    let x = out_x * dw / output_width;
5556                    let yv = planes.sample(0, x, y, 0) as f32;
5557                    let x_coord = chroma_x[x];
5558                    let u = bilinear_omv_sample(planes, 1, x_coord, y_coord, 128) as f32 - 128.0;
5559                    let v = bilinear_omv_sample(planes, 2, x_coord, y_coord, 128) as f32 - 128.0;
5560
5561                    let out = (out_y * output_width + out_x) * 4;
5562                    rgba[out] = clamp_f(yv + 1.40200 * v);
5563                    rgba[out + 1] = clamp_f(yv - 0.34414 * u - 0.71414 * v);
5564                    rgba[out + 2] = clamp_f(yv + 1.77200 * u);
5565                    rgba[out + 3] = 0xff;
5566                }
5567            }
5568        }
5569    }
5570}
5571
5572fn get_plane_sample(
5573    data: &[u8],
5574    plane_off: usize,
5575    plane_width: usize,
5576    x: usize,
5577    y: usize,
5578    default: u8,
5579) -> u8 {
5580    if plane_width == 0 {
5581        return default;
5582    }
5583    data.get(
5584        plane_off
5585            .saturating_add(y.saturating_mul(plane_width))
5586            .saturating_add(x),
5587    )
5588    .copied()
5589    .unwrap_or(default)
5590}
5591
5592fn get_bilinear_plane_sample(
5593    data: &[u8],
5594    plane_off: usize,
5595    plane_width: usize,
5596    x: (usize, usize, u32),
5597    y: (usize, usize, u32),
5598    default: u8,
5599) -> u8 {
5600    let (x0, x1, fx) = x;
5601    let (y0, y1, fy) = y;
5602    let p00 = u64::from(get_plane_sample(
5603        data,
5604        plane_off,
5605        plane_width,
5606        x0,
5607        y0,
5608        default,
5609    ));
5610    let p10 = u64::from(get_plane_sample(
5611        data,
5612        plane_off,
5613        plane_width,
5614        x1,
5615        y0,
5616        default,
5617    ));
5618    let p01 = u64::from(get_plane_sample(
5619        data,
5620        plane_off,
5621        plane_width,
5622        x0,
5623        y1,
5624        default,
5625    ));
5626    let p11 = u64::from(get_plane_sample(
5627        data,
5628        plane_off,
5629        plane_width,
5630        x1,
5631        y1,
5632        default,
5633    ));
5634
5635    const ONE: u64 = 1 << 16;
5636    let fx = u64::from(fx);
5637    let fy = u64::from(fy);
5638    let top = p00 * (ONE - fx) + p10 * fx;
5639    let bottom = p01 * (ONE - fx) + p11 * fx;
5640    ((top * (ONE - fy) + bottom * fy + (1 << 31)) >> 32) as u8
5641}
5642
5643fn centred_resample_coordinate(
5644    output_index: usize,
5645    input_len: usize,
5646    output_len: usize,
5647) -> (usize, usize, u32) {
5648    if input_len <= 1 || output_len <= 1 {
5649        return (0, 0, 0);
5650    }
5651
5652    // Map pixel centres instead of aligning sample corners. For a 2:1
5653    // subsampled plane this maps the first luma pixel to -0.25 chroma pixels
5654    // (clamped at the edge), and then advances by 0.5 per output pixel.
5655    let numerator = (2_i128 * output_index as i128 + 1) * input_len as i128 * (1_i128 << 15);
5656    let mut position = numerator / output_len as i128 - (1_i128 << 15);
5657    let max_position = ((input_len - 1) as i128) << 16;
5658    position = position.clamp(0, max_position);
5659
5660    let i0 = (position >> 16) as usize;
5661    let i1 = (i0 + 1).min(input_len - 1);
5662    let fraction = (position & 0xFFFF) as u32;
5663    (i0, i1, fraction)
5664}
5665
5666fn clamp_f(v: f32) -> u8 {
5667    if v <= 0.0 {
5668        0
5669    } else if v >= 255.0 {
5670        255
5671    } else {
5672        v as u8
5673    }
5674}
5675
5676fn yuv_plane_size(width: i32, height: i32, fmt: i32) -> (usize, usize) {
5677    let w = width.max(1) as usize;
5678    let h = height.max(1) as usize;
5679    match fmt {
5680        siglus_omv_decoder::TH_PF_420 => (w / 2, h / 2),
5681        siglus_omv_decoder::TH_PF_422 => (w / 2, h),
5682        siglus_omv_decoder::TH_PF_444 => (w, h),
5683        _ => (w / 2, h / 2),
5684    }
5685}
5686
5687#[cfg(test)]
5688mod omv_conversion_parity_tests {
5689    use super::convert_omv_frame;
5690
5691    #[test]
5692    fn rgb_uses_omv_display_rect_over_full_coded_planes() {
5693        // Each source row is four pixels wide, while the OMV header exposes
5694        // only three. The fourth coded pixel must be skipped as row padding,
5695        // not allowed to shift the next visible row.
5696        let b = [1, 2, 3, 90, 4, 5, 6, 91, 7, 8, 9, 92];
5697        let g = [11, 12, 13, 93, 14, 15, 16, 94, 17, 18, 19, 95];
5698        let r = [21, 22, 23, 96, 24, 25, 26, 97, 27, 28, 29, 98];
5699        let mut packed = Vec::new();
5700        packed.extend_from_slice(&b);
5701        packed.extend_from_slice(&g);
5702        packed.extend_from_slice(&r);
5703
5704        let rgba = convert_omv_frame(
5705            &packed,
5706            4,
5707            3,
5708            siglus_omv_decoder::TH_PF_444,
5709            3,
5710            2,
5711            siglus_assets::omv::OMV_THEORA_TYPE_RGB,
5712        );
5713        assert_eq!(
5714            rgba,
5715            vec![
5716                21, 11, 1, 255, 22, 12, 2, 255, 23, 13, 3, 255, 24, 14, 4, 255, 25, 15, 5, 255, 26,
5717                16, 6, 255,
5718            ]
5719        );
5720    }
5721
5722    /// Converting from the decoder's planes gives the same pixels as the
5723    /// repacked copy, full size and downscaled (real OMVs, when present).
5724    #[test]
5725    fn decoder_planes_convert_like_packed_planes() {
5726        use super::{DecoderOmvPlanes, convert_omv_frame_to_size, convert_omv_planes_to_size};
5727        let files = [
5728            "/Users/xmoe/Documents/GameData/mov/ef_wind_dust01.omv",
5729            "/Users/xmoe/Documents/GameData/mov/ef_wind04.omv",
5730            "/Users/xmoe/Documents/GameData/mov/ef_fog01.omv",
5731            "/Users/xmoe/Documents/RewriteHF/mov/ny_hf_waterscreen.omv",
5732        ];
5733        for file in files {
5734            let path = std::path::Path::new(file);
5735            if !path.is_file() {
5736                continue;
5737            }
5738            let omv = siglus_assets::omv::OmvFile::open(path).unwrap();
5739            let open = || {
5740                siglus_omv_decoder::TheoraVideoStream::open(
5741                    omv.open_embedded_ogg_reader(path).unwrap(),
5742                )
5743                .unwrap()
5744            };
5745            let (mut packed_stream, mut direct_stream) = (open(), open());
5746            let vinfo = packed_stream.info();
5747            let width = omv.header.display_width as i32;
5748            let height = omv.header.display_height as i32;
5749            let theora_type = omv.header.theora_type;
5750            let sizes = [
5751                (width as usize, height as usize),
5752                (width as usize / 2, height as usize / 2),
5753            ];
5754            for frame in 0..40 {
5755                let packed = packed_stream.read_video_frame().unwrap().unwrap();
5756                let direct = direct_stream
5757                    .read_video_frame_with(|ycbcr| {
5758                        sizes.map(|(w, h)| {
5759                            convert_omv_planes_to_size(
5760                                &DecoderOmvPlanes(ycbcr),
5761                                vinfo.frame_width,
5762                                vinfo.frame_height,
5763                                vinfo.fmt,
5764                                width,
5765                                height,
5766                                theora_type,
5767                                w,
5768                                h,
5769                            )
5770                        })
5771                    })
5772                    .unwrap()
5773                    .unwrap();
5774                for (i, (w, h)) in sizes.into_iter().enumerate() {
5775                    let expected = convert_omv_frame_to_size(
5776                        &packed,
5777                        vinfo.frame_width,
5778                        vinfo.frame_height,
5779                        vinfo.fmt,
5780                        width,
5781                        height,
5782                        theora_type,
5783                        w,
5784                        h,
5785                    );
5786                    assert!(direct[i] == expected, "{file} frame {frame} at {w}x{h}");
5787                }
5788            }
5789        }
5790    }
5791
5792    #[test]
5793    fn rgba_alpha_rows_start_below_omv_display_height() {
5794        // display=3x3 => one alpha row per source plane, beginning at source
5795        // row 3. The coded row width is 4, so this also checks stride parity.
5796        let mut y = vec![1u8; 16];
5797        let mut u = vec![2u8; 16];
5798        let mut v = vec![3u8; 16];
5799        y[12..16].copy_from_slice(&[10, 11, 12, 99]);
5800        u[12..16].copy_from_slice(&[20, 21, 22, 99]);
5801        v[12..16].copy_from_slice(&[30, 31, 32, 99]);
5802        let mut packed = Vec::new();
5803        packed.extend_from_slice(&y);
5804        packed.extend_from_slice(&u);
5805        packed.extend_from_slice(&v);
5806
5807        let rgba = convert_omv_frame(
5808            &packed,
5809            4,
5810            4,
5811            siglus_omv_decoder::TH_PF_444,
5812            3,
5813            3,
5814            siglus_assets::omv::OMV_THEORA_TYPE_RGBA,
5815        );
5816        let alpha: Vec<u8> = rgba.as_chunks::<4>().0.iter().map(|px| px[3]).collect();
5817        assert_eq!(alpha, vec![10, 11, 12, 20, 21, 22, 30, 31, 32]);
5818    }
5819}
5820
5821#[cfg(test)]
5822mod mpeg_video_pts_tests {
5823    use std::collections::VecDeque;
5824    use std::sync::atomic::AtomicUsize;
5825    use std::sync::{Arc, mpsc};
5826
5827    use super::{
5828        Mpeg2DecodedFrame, Mpeg2StreamEvent, Mpeg2StreamState, RgbaImage,
5829        mpeg_stream_needs_restart, select_mpeg_stream_frame,
5830    };
5831
5832    fn frame(index: usize, pts_90k: i64) -> Mpeg2DecodedFrame {
5833        Mpeg2DecodedFrame {
5834            frame_idx: index,
5835            pts_90k: Some(pts_90k),
5836            frame: Arc::new(RgbaImage {
5837                width: 1,
5838                height: 1,
5839                center_x: 0,
5840                center_y: 0,
5841                rgba: vec![0, 0, 0, 255],
5842            }),
5843        }
5844    }
5845
5846    fn state(frames: VecDeque<Mpeg2DecodedFrame>, timer_ms: u64) -> Mpeg2StreamState {
5847        let (_tx, rx) = mpsc::channel::<Result<Mpeg2StreamEvent, String>>();
5848        Mpeg2StreamState {
5849            rx,
5850            frames,
5851            width: Some(1),
5852            height: Some(1),
5853            fps: Some(30.0),
5854            decoded_frames: 3,
5855            first_video_pts_90k: Some(0),
5856            last_video_timeline_ms: Some(300),
5857            last_requested_timer_ms: timer_ms,
5858            done: false,
5859            audio: None,
5860            decoded_any_this_poll: false,
5861            request_frames: Arc::new(AtomicUsize::new(0)),
5862        }
5863    }
5864
5865    #[test]
5866    fn lagging_decoder_is_not_restarted_during_continuous_playback() {
5867        // A seek into a file with no later sequence header starts decoding at
5868        // zero. Let it catch up, even if it is over five seconds behind audio.
5869        let mut state = state(VecDeque::from([frame(30, 90_000)]), 60_000);
5870        for timer in (60_016..80_000).step_by(16) {
5871            assert!(!mpeg_stream_needs_restart(&state, timer));
5872            state.last_requested_timer_ms = timer;
5873        }
5874    }
5875
5876    #[test]
5877    fn future_pts_does_not_turn_continuous_playback_into_a_rewind() {
5878        let state = state(VecDeque::from([frame(1806, 5_418_000)]), 60_000);
5879        assert!(!mpeg_stream_needs_restart(&state, 60_016));
5880    }
5881
5882    #[test]
5883    fn explicit_seek_outside_cache_restarts_once() {
5884        let mut state = state(VecDeque::from([frame(1800, 5_400_000)]), 60_000);
5885        assert!(mpeg_stream_needs_restart(&state, 90_000));
5886        state.last_requested_timer_ms = 90_000;
5887        assert!(!mpeg_stream_needs_restart(&state, 90_016));
5888        assert!(mpeg_stream_needs_restart(&state, 30_000));
5889    }
5890
5891    #[test]
5892    fn rewind_while_seek_is_still_loading_restarts() {
5893        let state = state(VecDeque::new(), 60_000);
5894        assert!(mpeg_stream_needs_restart(&state, 0));
5895    }
5896
5897    #[test]
5898    fn rewind_inside_cache_does_not_restart_decoder() {
5899        let state = state(VecDeque::from([frame(0, 0), frame(9, 27_000)]), 200);
5900        assert!(!mpeg_stream_needs_restart(&state, 100));
5901    }
5902
5903    #[test]
5904    fn frame_selection_uses_pts_instead_of_fixed_fps_index() {
5905        let state = state(
5906            VecDeque::from([frame(0, 0), frame(1, 9_000), frame(2, 27_000)]),
5907            0,
5908        );
5909
5910        // Fixed 30 fps arithmetic would request index 6 at 200 ms.  PTS says
5911        // frame 1 is still the last frame whose presentation time has passed.
5912        let selected =
5913            select_mpeg_stream_frame(&state.frames, &state, 200, Some(6)).expect("selected frame");
5914        assert_eq!(selected.frame_idx, 1);
5915    }
5916}
5917
5918#[cfg(test)]
5919mod mpeg_audio_timeline_tests {
5920    use std::path::Path;
5921    use std::sync::atomic::AtomicBool;
5922
5923    use super::build_mpeg2_audio_timeline;
5924
5925    #[test]
5926    fn pes_pts_discontinuities_do_not_splice_continuous_pcm() {
5927        let frame_samples = 1152usize * 2;
5928        let chunks = vec![
5929            na_mpeg2_decoder::MpegAudioF32 {
5930                pts_ms: 0,
5931                sample_rate: 44_100,
5932                channels: 2,
5933                samples: vec![0.25; frame_samples],
5934            },
5935            na_mpeg2_decoder::MpegAudioF32 {
5936                pts_ms: 26,
5937                sample_rate: 44_100,
5938                channels: 2,
5939                samples: vec![0.5; frame_samples],
5940            },
5941            // A bad/sparse PES anchor must not manufacture silence or drop PCM.
5942            na_mpeg2_decoder::MpegAudioF32 {
5943                pts_ms: 1_000,
5944                sample_rate: 44_100,
5945                channels: 2,
5946                samples: vec![0.75; frame_samples],
5947            },
5948        ];
5949        let cancel = AtomicBool::new(false);
5950        let audio =
5951            build_mpeg2_audio_timeline(Path::new("synthetic.mpg"), chunks, Some(0), &cancel)
5952                .expect("timeline build")
5953                .expect("audio track");
5954
5955        assert_eq!(audio.channels, 2);
5956        assert_eq!(audio.sample_rate, 44_100);
5957        assert_eq!(audio.samples.len(), frame_samples * 3);
5958    }
5959}
5960
5961#[cfg(test)]
5962mod chroma_resampling_tests {
5963    use super::{centred_resample_coordinate, get_bilinear_plane_sample};
5964
5965    #[test]
5966    fn half_rate_coordinates_are_centre_aligned() {
5967        assert_eq!(centred_resample_coordinate(0, 2, 4), (0, 1, 0));
5968        assert_eq!(centred_resample_coordinate(1, 2, 4), (0, 1, 16_384));
5969        assert_eq!(centred_resample_coordinate(2, 2, 4), (0, 1, 49_152));
5970        assert_eq!(centred_resample_coordinate(3, 2, 4), (1, 1, 0));
5971    }
5972
5973    #[test]
5974    fn bilinear_sample_blends_both_axes() {
5975        let plane = [0_u8, 100, 200, 255];
5976        let half = (0, 1, 32_768);
5977        assert_eq!(
5978            get_bilinear_plane_sample(&plane, 0, 2, half, half, 128),
5979            139
5980        );
5981    }
5982}
5983
5984#[cfg(test)]
5985mod omv_loop_rewind_tests {
5986    use std::collections::VecDeque;
5987    use std::sync::atomic::AtomicUsize;
5988    use std::sync::{Arc, mpsc};
5989
5990    use super::{
5991        OmvStreamEvent, OmvStreamState, RgbaImage, drain_omv_stream_state, omv_frame_for_time,
5992        select_omv_loop_frame, select_stream_frame,
5993    };
5994
5995    fn image(value: u8) -> Arc<RgbaImage> {
5996        Arc::new(RgbaImage {
5997            width: 1,
5998            height: 1,
5999            center_x: 0,
6000            center_y: 0,
6001            rgba: vec![value, value, value, 255],
6002        })
6003    }
6004
6005    fn state() -> OmvStreamState {
6006        let (_tx, rx) = mpsc::channel::<Result<OmvStreamEvent, String>>();
6007        OmvStreamState {
6008            rx,
6009            frames: VecDeque::from([(100, image(100)), (101, image(101))]),
6010            loop_head_frames: VecDeque::from([(0, image(0)), (1, image(1)), (2, image(2))]),
6011            loop_head_bytes: 12,
6012            width: Some(1),
6013            height: Some(1),
6014            fps: Some(30.0),
6015            frame_time_ms: Some(1000.0 / 30.0),
6016            total_frames_hint: Some(102),
6017            total_ms_hint: Some(3_400),
6018            frame_times: None,
6019            decoded_frames: 102,
6020            done: true,
6021            request_frame: Arc::new(AtomicUsize::new(0)),
6022            last_served_frame_idx: 100,
6023            last_effective_timer_ms: 0,
6024            held_frame: None,
6025        }
6026    }
6027
6028    #[test]
6029    fn loop_wrap_selects_cached_head_instead_of_stale_tail() {
6030        let state = state();
6031        let (idx, frame) = select_omv_loop_frame(&state, 0).expect("loop frame");
6032        assert_eq!(idx, 0);
6033        assert_eq!(frame.rgba[0], 0);
6034    }
6035
6036    #[test]
6037    fn request_before_live_queue_returns_its_front_not_its_tail() {
6038        let frames = VecDeque::from([(10, image(10)), (11, image(11))]);
6039        let (idx, _) = select_stream_frame(&frames, 0).expect("front frame");
6040        assert_eq!(idx, 10);
6041    }
6042
6043    #[test]
6044    fn done_does_not_hide_the_queued_rewind_reset() {
6045        let (tx, rx) = mpsc::channel::<Result<OmvStreamEvent, String>>();
6046        let mut state = state();
6047        state.rx = rx;
6048        tx.send(Ok(OmvStreamEvent::Done)).expect("queue done");
6049        tx.send(Ok(OmvStreamEvent::Reset { frame_idx: 0 }))
6050            .expect("queue reset");
6051        tx.send(Ok(OmvStreamEvent::Video {
6052            frame_idx: 0,
6053            frame: image(0),
6054        }))
6055        .expect("queue video");
6056
6057        drain_omv_stream_state(
6058            std::path::Path::new("synthetic.omv"),
6059            &mut state,
6060            Some(0),
6061            false,
6062            true,
6063        )
6064        .expect("drain rewind events");
6065
6066        assert!(!state.done);
6067        assert_eq!(state.frames.front().map(|(idx, _)| *idx), Some(0));
6068    }
6069
6070    #[test]
6071    fn packet_time_boundaries_match_original_lookup() {
6072        let times = [(0, 32), (33, 65), (66, 99)];
6073        assert_eq!(omv_frame_for_time(&times, 0), Some(0));
6074        assert_eq!(omv_frame_for_time(&times, 32), Some(0));
6075        assert_eq!(omv_frame_for_time(&times, 33), Some(1));
6076        assert_eq!(omv_frame_for_time(&times, 99), Some(2));
6077    }
6078}
6079
6080#[cfg(test)]
6081mod wmv_movie_adapter_tests {
6082    use std::collections::VecDeque;
6083    use std::sync::Arc;
6084
6085    use super::{
6086        RgbaImage, WmvDecodedFrame, is_wmv_extension, select_wmv_stream_frame,
6087        wmv_audio_alignment_frames, wmv_frame_duration_ms, wmv_rebase_duration_ms,
6088        wmv_yuv_frame_to_rgba,
6089    };
6090
6091    fn frame(index: usize, pts_ms: u64) -> WmvDecodedFrame {
6092        WmvDecodedFrame {
6093            frame_idx: index,
6094            source_pts_ms: pts_ms,
6095            frame: Arc::new(RgbaImage {
6096                width: 1,
6097                height: 1,
6098                center_x: 0,
6099                center_y: 0,
6100                rgba: vec![0, 0, 0, 255],
6101            }),
6102        }
6103    }
6104
6105    #[test]
6106    fn source_pts_are_rebased_to_movie_time_zero() {
6107        let origin = 3_000u64;
6108        assert_eq!(3_000u64.saturating_sub(origin), 0);
6109        assert_eq!(3_040u64.saturating_sub(origin), 40);
6110        assert_eq!(wmv_rebase_duration_ms(63_000, origin), Some(60_000));
6111    }
6112
6113    #[test]
6114    fn audio_alignment_uses_video_origin() {
6115        assert_eq!(wmv_audio_alignment_frames(3_000, 3_000, 48_000), (0, 0));
6116        assert_eq!(wmv_audio_alignment_frames(3_100, 3_000, 48_000), (4_800, 0));
6117        assert_eq!(wmv_audio_alignment_frames(2_900, 3_000, 48_000), (0, 4_800));
6118    }
6119
6120    #[test]
6121    fn recognizes_siglus_wmv_extension() {
6122        assert!(is_wmv_extension("wmv"));
6123        assert!(!is_wmv_extension("asf"));
6124        assert!(!is_wmv_extension("mpg"));
6125    }
6126
6127    #[test]
6128    fn frame_selection_follows_asf_pts() {
6129        let frames = VecDeque::from([frame(0, 3_000), frame(1, 3_040), frame(2, 3_120)]);
6130        let selected = select_wmv_stream_frame(&frames, 3_080).expect("selected frame");
6131        assert_eq!(selected.frame_idx, 1);
6132    }
6133
6134    #[test]
6135    fn frame_selection_never_presents_future_pts() {
6136        let frames = VecDeque::from([frame(7, 3_040), frame(8, 3_080)]);
6137        assert!(select_wmv_stream_frame(&frames, 3_020).is_none());
6138        assert_eq!(
6139            select_wmv_stream_frame(&frames, 3_040).map(|frame| frame.frame_idx),
6140            Some(7)
6141        );
6142    }
6143
6144    #[test]
6145    fn b_picture_decode_order_does_not_choose_clock_origin() {
6146        let mut frames = VecDeque::new();
6147        for decoded in [
6148            frame(0, 3_000),
6149            frame(1, 3_120),
6150            frame(2, 3_040),
6151            frame(3, 3_080),
6152        ] {
6153            let pos = frames
6154                .iter()
6155                .position(|queued: &WmvDecodedFrame| queued.source_pts_ms > decoded.source_pts_ms)
6156                .unwrap_or(frames.len());
6157            frames.insert(pos, decoded);
6158        }
6159        assert_eq!(
6160            frames.iter().map(|f| f.source_pts_ms).collect::<Vec<_>>(),
6161            vec![3_000, 3_040, 3_080, 3_120]
6162        );
6163        let origin = frames
6164            .front()
6165            .expect("first presentation frame")
6166            .source_pts_ms;
6167        assert_eq!(origin, 3_000);
6168        assert_eq!(
6169            select_wmv_stream_frame(&frames, origin + 80).map(|frame| frame.frame_idx),
6170            Some(3)
6171        );
6172    }
6173
6174    #[test]
6175    fn frame_duration_uses_median_positive_pts_delta() {
6176        assert_eq!(wmv_frame_duration_ms(&[0, 40, 80, 200]), Some(40));
6177    }
6178
6179    #[test]
6180    fn yuv_black_converts_to_opaque_black() {
6181        let yuv = wmv_decoder::YuvFrame {
6182            width: 2,
6183            height: 2,
6184            y: vec![16; 4],
6185            cb: vec![128],
6186            cr: vec![128],
6187        };
6188        let rgba = wmv_yuv_frame_to_rgba(&yuv);
6189        assert_eq!(rgba.rgba, [0, 0, 0, 255].repeat(4));
6190    }
6191}