Skip to main content

siglus_scene_vm/movie/
mod.rs

1use std::collections::{HashMap, VecDeque};
2use std::fs;
3use std::io::{Cursor, Read};
4use std::path::{Path, PathBuf};
5use std::sync::{
6    mpsc::{self, Receiver, TryRecvError},
7    Arc,
8};
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::thread;
11use crate::platform_time::{Duration, Instant};
12
13use anyhow::{anyhow, bail, Context, Result};
14use kira::sound::static_sound::{StaticSoundData, StaticSoundHandle};
15
16use crate::assets::RgbaImage;
17use crate::audio::{AudioHub, TrackKind};
18
19const MPEG2_HEADER_PROBE_BYTES: usize = 256 * 1024;
20const MPEG2_STREAM_CHUNK_BYTES: usize = 256 * 1024;
21const MPEG2_STREAM_CHANNEL_CAPACITY: usize = 4;
22const MPEG2_STREAM_MAX_DRAIN_EVENTS: usize = 8;
23const MPEG2_STREAM_FRAME_KEEP: usize = 6;
24const MPEG2_STREAM_DECODE_LEAD_FRAMES: usize = 3;
25const OMV_STREAM_CHANNEL_CAPACITY: usize = 12;
26const OMV_STREAM_MAX_DRAIN_EVENTS: usize = 16;
27const OMV_STREAM_FRAME_KEEP: usize = 16;
28const OMV_STREAM_DECODE_LEAD_FRAMES: usize = 4;
29
30#[derive(Debug, Clone)]
31pub struct MovieInfo {
32    pub path: PathBuf,
33    pub width: Option<u32>,
34    pub height: Option<u32>,
35    pub fps: Option<f32>,
36    pub decoded_frames: Option<usize>,
37    pub audio_duration_ms: Option<u64>,
38}
39
40impl MovieInfo {
41    pub fn duration_ms(&self) -> Option<u64> {
42        if let Some(ms) = self.audio_duration_ms {
43            return Some(ms);
44        }
45        let fps = self.fps?;
46        let frames = self.decoded_frames?;
47        if fps <= 0.0 || frames == 0 {
48            return None;
49        }
50        Some(((frames as f64) * 1000.0 / (fps as f64)).round() as u64)
51    }
52}
53
54#[derive(Debug, Clone)]
55pub struct MovieStreamFrame {
56    pub frame: Arc<RgbaImage>,
57    pub frame_idx: usize,
58    pub fps: Option<f32>,
59    pub total_ms: Option<u64>,
60    pub audio: Option<MovieAudio>,
61    pub audio_ready: bool,
62    pub decoded_now: bool,
63    pub clamped_timer_ms: Option<u64>,
64}
65
66enum Mpeg2StreamEvent {
67    Info {
68        width: Option<u32>,
69        height: Option<u32>,
70        fps: Option<f32>,
71    },
72    Video {
73        frame_idx: usize,
74        frame: Arc<RgbaImage>,
75    },
76    Done,
77}
78
79struct Mpeg2StreamState {
80    rx: Receiver<Result<Mpeg2StreamEvent, String>>,
81    frames: VecDeque<(usize, Arc<RgbaImage>)>,
82    width: Option<u32>,
83    height: Option<u32>,
84    fps: Option<f32>,
85    decoded_frames: usize,
86    done: bool,
87    audio: Option<MovieAudio>,
88    decoded_any_this_poll: bool,
89    request_frames: Arc<AtomicUsize>,
90}
91
92impl Drop for Mpeg2StreamState {
93    fn drop(&mut self) {
94        self.request_frames.store(usize::MAX, Ordering::Release);
95    }
96}
97
98enum OmvStreamEvent {
99    Info {
100        width: u32,
101        height: u32,
102        fps: Option<f32>,
103        frame_time_ms: Option<f64>,
104        total_frames_hint: Option<usize>,
105    },
106    Video {
107        frame_idx: usize,
108        frame: Arc<RgbaImage>,
109    },
110    Done,
111}
112
113struct OmvStreamState {
114    rx: Receiver<Result<OmvStreamEvent, String>>,
115    frames: VecDeque<(usize, Arc<RgbaImage>)>,
116    width: Option<u32>,
117    height: Option<u32>,
118    fps: Option<f32>,
119    frame_time_ms: Option<f64>,
120    total_frames_hint: Option<usize>,
121    decoded_frames: usize,
122    done: bool,
123    request_frames: Arc<AtomicUsize>,
124}
125
126impl Drop for OmvStreamState {
127    fn drop(&mut self) {
128        self.request_frames.store(usize::MAX, Ordering::Release);
129    }
130}
131
132/// Minimal movie state holder.
133///
134/// The original Siglus engine plays MOV via a native playback pipeline.
135/// Here we provide a deterministic, cross-platform metadata path:
136/// - MPEG2 (`.mpg` / `.mpeg`) via `siglus_assets::mpeg2`
137/// - OMV (`.omv`) via `siglus_assets::omv`
138pub struct MovieManager {
139    project_dir: PathBuf,
140    current_append_dir: String,
141    current: Option<MovieInfo>,
142    cache: HashMap<PathBuf, MovieAsset>,
143    preview_cache: HashMap<PathBuf, Arc<RgbaImage>>,
144    decode_tasks: HashMap<PathBuf, Receiver<Result<MovieAsset, String>>>,
145    mpeg2_audio_cache: HashMap<PathBuf, Option<MovieAudio>>,
146    mpeg2_streams: HashMap<PathBuf, Mpeg2StreamState>,
147    omv_streams: HashMap<PathBuf, OmvStreamState>,
148    playbacks: HashMap<u64, MoviePlayback>,
149    next_playback_id: u64,
150}
151
152impl std::fmt::Debug for MovieManager {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.debug_struct("MovieManager")
155            .field("project_dir", &self.project_dir)
156            .field("current_append_dir", &self.current_append_dir)
157            .field("current", &self.current)
158            .field("cache_len", &self.cache.len())
159            .field("preview_cache_len", &self.preview_cache.len())
160            .field("decode_tasks_len", &self.decode_tasks.len())
161            .field("mpeg2_audio_cache_len", &self.mpeg2_audio_cache.len())
162            .field("mpeg2_streams_len", &self.mpeg2_streams.len())
163            .field("omv_streams_len", &self.omv_streams.len())
164            .field("playbacks_len", &self.playbacks.len())
165            .finish()
166    }
167}
168
169impl MovieManager {
170    pub fn new(project_dir: PathBuf) -> Self {
171        Self {
172            project_dir,
173            current_append_dir: String::new(),
174            current: None,
175            cache: HashMap::new(),
176            preview_cache: HashMap::new(),
177            decode_tasks: HashMap::new(),
178            mpeg2_audio_cache: HashMap::new(),
179            mpeg2_streams: HashMap::new(),
180            omv_streams: HashMap::new(),
181            playbacks: HashMap::new(),
182            next_playback_id: 1,
183        }
184    }
185
186    pub fn current(&self) -> Option<&MovieInfo> {
187        self.current.as_ref()
188    }
189
190    pub fn set_current_append_dir(&mut self, append_dir: impl Into<String>) {
191        self.current_append_dir = append_dir.into();
192    }
193
194    pub fn stop(&mut self) {
195        self.current = None;
196        self.mpeg2_streams.clear();
197        self.omv_streams.clear();
198    }
199
200    pub fn prepare(&mut self, file_name: &str) -> Result<MovieInfo> {
201        self.play(file_name, false, false)
202    }
203
204    pub fn prepare_omv(&mut self, file_name: &str) -> Result<MovieInfo> {
205        let path = crate::resource::find_omv_path_with_append_dir(
206            &self.project_dir,
207            &self.current_append_dir,
208            file_name,
209        )?;
210        let header = read_omv_header_for_path(&path)
211            .with_context(|| format!("open OMV: {}", path.display()))?;
212        let w = header.display_width;
213        let h = header.display_height;
214        let fps = if header.frame_time_us != 0 {
215            Some(1_000_000.0 / (header.frame_time_us as f32))
216        } else {
217            None
218        };
219        let info = MovieInfo {
220            path,
221            width: (w > 0).then_some(w),
222            height: (h > 0).then_some(h),
223            fps,
224            decoded_frames: (header.packet_count_hint > 0)
225                .then_some(header.packet_count_hint as usize),
226            audio_duration_ms: None,
227        };
228        self.current = Some(info.clone());
229        Ok(info)
230    }
231
232    pub fn play(&mut self, file_name: &str, _wait: bool, _key_skip: bool) -> Result<MovieInfo> {
233        let path = resolve_mov_path(&self.project_dir, &self.current_append_dir, file_name)?;
234        let ext = path
235            .extension()
236            .and_then(|s| s.to_str())
237            .unwrap_or("")
238            .to_ascii_lowercase();
239
240        let info = if ext == "omv" {
241            let header = read_omv_header_for_path(&path)
242                .with_context(|| format!("open OMV: {}", path.display()))?;
243            let w = header.display_width;
244            let h = header.display_height;
245            let fps = if header.frame_time_us != 0 {
246                Some(1_000_000.0 / (header.frame_time_us as f32))
247            } else {
248                None
249            };
250            MovieInfo {
251                path,
252                width: (w > 0).then_some(w),
253                height: (h > 0).then_some(h),
254                fps,
255                decoded_frames: (header.packet_count_hint > 0)
256                    .then_some(header.packet_count_hint as usize),
257                audio_duration_ms: None,
258            }
259        } else {
260            let prefix = read_file_prefix(&path, MPEG2_HEADER_PROBE_BYTES)
261                .with_context(|| format!("read movie header: {}", path.display()))?;
262
263            let mut width = None;
264            let mut height = None;
265            let mut fps = None;
266
267            if let Some(h) = siglus_assets::mpeg2::find_sequence_header(&prefix) {
268                width = Some(h.width as u32);
269                height = Some(h.height as u32);
270                fps = siglus_assets::mpeg2::fps_from_frame_rate_code(h.frame_rate_code);
271            }
272
273            let decoded_frames = decode_frames_if_enabled(&path)?;
274
275            MovieInfo {
276                path,
277                width,
278                height,
279                fps,
280                decoded_frames,
281                audio_duration_ms: None,
282            }
283        };
284
285        self.current = Some(info.clone());
286        Ok(info)
287    }
288
289    /// Resolve and decode a movie asset into RGBA frames (cached).
290    pub fn ensure_asset(&mut self, file_name: &str) -> Result<(&MovieAsset, bool)> {
291        let path = resolve_mov_path(&self.project_dir, &self.current_append_dir, file_name)?;
292        self.ensure_asset_for_path(path)
293    }
294
295    pub fn ensure_omv_asset(&mut self, file_name: &str) -> Result<(&MovieAsset, bool)> {
296        let path = crate::resource::find_omv_path_with_append_dir(
297            &self.project_dir,
298            &self.current_append_dir,
299            file_name,
300        )?;
301        self.ensure_asset_for_path(path)
302    }
303
304    fn ensure_asset_for_path(&mut self, path: PathBuf) -> Result<(&MovieAsset, bool)> {
305        let existed = self.cache.contains_key(&path);
306        if !existed {
307            let asset = decode_asset_for_path(&path)?;
308            self.cache.insert(path.clone(), asset);
309        }
310        let asset = self.cache.get(&path).expect("asset cached");
311        Ok((asset, !existed))
312    }
313
314    pub fn poll_asset(&mut self, file_name: &str) -> Result<Option<(&MovieAsset, bool)>> {
315        let path = resolve_mov_path(&self.project_dir, &self.current_append_dir, file_name)?;
316        self.poll_asset_for_path(path)
317    }
318
319    pub fn poll_omv_asset(&mut self, file_name: &str) -> Result<Option<(&MovieAsset, bool)>> {
320        let path = crate::resource::find_omv_path_with_append_dir(
321            &self.project_dir,
322            &self.current_append_dir,
323            file_name,
324        )?;
325        self.poll_asset_for_path(path)
326    }
327
328    fn poll_asset_for_path(&mut self, path: PathBuf) -> Result<Option<(&MovieAsset, bool)>> {
329        if self.cache.contains_key(&path) {
330            let asset = self.cache.get(&path).expect("asset cached");
331            return Ok(Some((asset, false)));
332        }
333
334        let mut completed = None;
335        let mut failed = None;
336        if let Some(rx) = self.decode_tasks.get(&path) {
337            match rx.try_recv() {
338                Ok(Ok(asset)) => completed = Some(asset),
339                Ok(Err(err)) => failed = Some(err),
340                Err(TryRecvError::Empty) => {}
341                Err(TryRecvError::Disconnected) => {
342                    failed = Some(format!(
343                        "movie decode worker disconnected: {}",
344                        path.display()
345                    ));
346                }
347            }
348        } else {
349            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
350            {
351                let asset = decode_asset_for_path(&path)?;
352                self.cache.insert(path.clone(), asset);
353                let asset = self.cache.get(&path).expect("asset cached");
354                return Ok(Some((asset, true)));
355            }
356            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
357            {
358                let (tx, rx) = mpsc::channel();
359                let worker_path = path.clone();
360                thread::spawn(move || {
361                    let result = decode_asset_for_path(&worker_path).map_err(|e| format!("{:#}", e));
362                    let _ = tx.send(result);
363                });
364                self.decode_tasks.insert(path.clone(), rx);
365            }
366        }
367
368        if let Some(err) = failed {
369            self.decode_tasks.remove(&path);
370            return Err(anyhow!(err));
371        }
372        if let Some(asset) = completed {
373            self.decode_tasks.remove(&path);
374            self.cache.insert(path.clone(), asset);
375            let asset = self.cache.get(&path).expect("asset cached");
376            return Ok(Some((asset, true)));
377        }
378
379        Ok(None)
380    }
381
382    pub fn poll_global_movie_frame(
383        &mut self,
384        file_name: &str,
385        timer_ms: u64,
386    ) -> Result<Option<MovieStreamFrame>> {
387        self.poll_global_movie_frame_with_loop(file_name, timer_ms, false)
388    }
389
390    pub fn poll_global_movie_frame_with_loop(
391        &mut self,
392        file_name: &str,
393        timer_ms: u64,
394        loop_flag: bool,
395    ) -> Result<Option<MovieStreamFrame>> {
396        let path = resolve_mov_path(&self.project_dir, &self.current_append_dir, file_name)?;
397        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
398        {
399            return self.poll_cached_movie_frame_for_path_with_loop(path, timer_ms, loop_flag);
400        }
401        let ext = path
402            .extension()
403            .and_then(|s| s.to_str())
404            .unwrap_or("")
405            .to_ascii_lowercase();
406        if ext == "omv" {
407            return self.poll_omv_stream_frame_for_path(path, timer_ms, loop_flag);
408        }
409        self.poll_mpeg2_stream_frame_for_path(path, timer_ms)
410    }
411
412    fn poll_cached_movie_frame_for_path(
413        &mut self,
414        path: PathBuf,
415        timer_ms: u64,
416    ) -> Result<Option<MovieStreamFrame>> {
417        self.poll_cached_movie_frame_for_path_with_loop(path, timer_ms, false)
418    }
419
420    fn poll_cached_movie_frame_for_path_with_loop(
421        &mut self,
422        path: PathBuf,
423        timer_ms: u64,
424        loop_flag: bool,
425    ) -> Result<Option<MovieStreamFrame>> {
426        let (asset, decoded_now) = match self.poll_asset_for_path(path)? {
427            Some(v) => v,
428            None => return Ok(None),
429        };
430        if asset.frames.is_empty() {
431            return Ok(None);
432        }
433
434        let fps = asset.info.fps.unwrap_or_else(|| {
435            asset
436                .info
437                .duration_ms()
438                .filter(|ms| *ms > 0)
439                .map(|ms| (asset.frames.len() as f32) * 1000.0 / (ms as f32))
440                .unwrap_or(0.0)
441        });
442        let effective_timer_ms = if loop_flag {
443            asset
444                .info
445                .duration_ms()
446                .filter(|ms| *ms > 0)
447                .map(|ms| timer_ms % ms)
448                .unwrap_or(timer_ms)
449        } else {
450            timer_ms
451        };
452        let mut idx = frame_index_for_timer(effective_timer_ms, fps, asset.frames.len());
453        if idx >= asset.frames.len() {
454            idx = asset.frames.len() - 1;
455        }
456
457        Ok(Some(MovieStreamFrame {
458            frame: asset.frames[idx].clone(),
459            frame_idx: idx,
460            fps: (fps > 0.0).then_some(fps),
461            total_ms: asset.info.duration_ms(),
462            audio: asset.audio.clone(),
463            audio_ready: true,
464            decoded_now,
465            clamped_timer_ms: None,
466        }))
467    }
468
469    fn poll_mpeg2_stream_frame_for_path(
470        &mut self,
471        path: PathBuf,
472        timer_ms: u64,
473    ) -> Result<Option<MovieStreamFrame>> {
474        let audio = self.ensure_mpeg2_audio_for_path(path.as_path());
475        if !self.mpeg2_streams.contains_key(&path) {
476            let state = spawn_mpeg2_stream_state(path.clone(), audio.clone())?;
477            self.mpeg2_streams.insert(path.clone(), state);
478        }
479
480        let desired_before_drain = self.mpeg2_streams.get(&path).and_then(|state| {
481            state
482                .fps
483                .filter(|f| *f > 0.0)
484                .map(|fps| ((timer_ms as f64) * (fps as f64) / 1000.0).floor() as usize)
485        });
486        let restart_stream = desired_before_drain
487            .and_then(|desired| {
488                self.mpeg2_streams.get(&path).map(|state| {
489                    let front_after_target = state
490                        .frames
491                        .front()
492                        .map(|(idx, _)| *idx > desired)
493                        .unwrap_or(false);
494                    let decoder_already_past_target = state.frames.is_empty()
495                        && state.decoded_frames > desired.saturating_add(MPEG2_STREAM_DECODE_LEAD_FRAMES)
496                        && !state.done;
497                    front_after_target || decoder_already_past_target
498                })
499            })
500            .unwrap_or(false);
501        if restart_stream {
502            self.mpeg2_streams.remove(&path);
503            let state = spawn_mpeg2_stream_state(path.clone(), audio)?;
504            self.mpeg2_streams.insert(path.clone(), state);
505        }
506
507        let state = self
508            .mpeg2_streams
509            .get_mut(&path)
510            .expect("mpeg2 stream state exists");
511        let request_until = desired_before_drain
512            .unwrap_or(0)
513            .saturating_add(MPEG2_STREAM_DECODE_LEAD_FRAMES);
514        state.request_frames.store(request_until, Ordering::Release);
515        drain_mpeg2_stream_state(path.as_path(), state, desired_before_drain, timer_ms)?;
516
517        if state.frames.is_empty() {
518            return Ok(None);
519        }
520
521        let latest_idx = state.decoded_frames.saturating_sub(1);
522        let desired_idx = desired_before_drain.unwrap_or(latest_idx);
523        let chosen_idx = desired_idx.min(latest_idx);
524
525        let Some((actual_frame_idx, frame)) = select_stream_frame(&state.frames, chosen_idx) else {
526            return Ok(None);
527        };
528
529        let video_total_ms = if state.done && state.decoded_frames > 0 {
530            state
531                .fps
532                .filter(|f| *f > 0.0)
533                .map(|fps| ((state.decoded_frames as f64) * 1000.0 / (fps as f64)).round() as u64)
534        } else {
535            None
536        };
537        let audio_total_ms = state.audio.as_ref().map(|a| a.end_ms());
538        let total_ms = match (audio_total_ms, video_total_ms) {
539            (Some(a), Some(v)) => Some(a.max(v)),
540            (Some(a), None) => Some(a),
541            (None, Some(v)) => Some(v),
542            (None, None) => None,
543        };
544
545        let audio = state
546            .audio
547            .as_ref()
548            .filter(|track| timer_ms < track.end_ms())
549            .cloned();
550        state.decoded_any_this_poll = false;
551
552        Ok(Some(MovieStreamFrame {
553            frame,
554            frame_idx: actual_frame_idx,
555            fps: state.fps,
556            total_ms,
557            audio,
558            audio_ready: true,
559            decoded_now: false,
560            clamped_timer_ms: None,
561        }))
562    }
563
564    fn ensure_mpeg2_audio_for_path(&mut self, path: &Path) -> Option<MovieAudio> {
565        if let Some(audio) = self.mpeg2_audio_cache.get(path) {
566            return audio.clone();
567        }
568        let audio = match decode_mpeg2_audio_for_path(path) {
569            Ok(audio) => audio,
570            Err(err) => {
571                eprintln!(
572                    "[SG_MOV] mpeg2 audio predecode failed path={} err={:#}",
573                    path.display(),
574                    err
575                );
576                None
577            }
578        };
579        self.mpeg2_audio_cache
580            .insert(path.to_path_buf(), audio.clone());
581        audio
582    }
583
584    fn poll_omv_stream_frame_for_path(
585        &mut self,
586        path: PathBuf,
587        timer_ms: u64,
588        loop_flag: bool,
589    ) -> Result<Option<MovieStreamFrame>> {
590        if !self.omv_streams.contains_key(&path) {
591            let state = spawn_omv_stream_state(path.clone())?;
592            self.omv_streams.insert(path.clone(), state);
593        }
594
595        let desired_before_drain = self.omv_streams.get(&path).and_then(|state| {
596            if let Some(ms) = state.frame_time_ms.filter(|v| *v > 0.0) {
597                Some(((timer_ms as f64) / ms).floor() as usize)
598            } else {
599                state
600                    .fps
601                    .filter(|f| *f > 0.0)
602                    .map(|fps| ((timer_ms as f64) * (fps as f64) / 1000.0).floor() as usize)
603            }
604        });
605        let restart_stream = desired_before_drain
606            .and_then(|desired| {
607                self.omv_streams.get(&path).map(|state| {
608                    let cached_target = state.frames.iter().any(|(idx, _)| *idx == desired);
609                    let front_after_target = state
610                        .frames
611                        .front()
612                        .map(|(idx, _)| *idx > desired)
613                        .unwrap_or(false);
614                    let decoder_already_past_target = state.frames.is_empty()
615                        && state.decoded_frames > desired.saturating_add(OMV_STREAM_DECODE_LEAD_FRAMES)
616                        && !state.done;
617                    !cached_target && (front_after_target || decoder_already_past_target)
618                })
619            })
620            .unwrap_or(false);
621        if restart_stream {
622            self.omv_streams.remove(&path);
623            let state = spawn_omv_stream_state(path.clone())?;
624            self.omv_streams.insert(path.clone(), state);
625        }
626
627        let state = self
628            .omv_streams
629            .get_mut(&path)
630            .expect("omv stream state exists");
631        let mut request_until = desired_before_drain
632            .unwrap_or(0)
633            .saturating_add(OMV_STREAM_DECODE_LEAD_FRAMES);
634        if loop_flag {
635            if let Some(total_frames) = state.total_frames_hint.filter(|v| *v > 0) {
636                request_until = request_until.max(total_frames.saturating_sub(1));
637            }
638        }
639        state.request_frames.store(request_until, Ordering::Release);
640        drain_omv_stream_state(path.as_path(), state, desired_before_drain, loop_flag)?;
641        if loop_flag {
642            if let Some(total_frames) = state.total_frames_hint.filter(|v| *v > 0) {
643                state
644                    .request_frames
645                    .store(total_frames.saturating_sub(1), Ordering::Release);
646            }
647        }
648
649        if state.frames.is_empty() {
650            return Ok(None);
651        }
652
653        let latest_idx = state.decoded_frames.saturating_sub(1);
654        let desired_idx = desired_before_drain.unwrap_or(latest_idx);
655        let chosen_idx = desired_idx.min(latest_idx);
656        let Some((actual_frame_idx, frame)) = select_stream_frame(&state.frames, chosen_idx) else {
657            return Ok(None);
658        };
659
660        let video_total_ms = if state.done && state.decoded_frames > 0 {
661            state
662                .frame_time_ms
663                .map(|ms| ((state.decoded_frames as f64) * ms).round() as u64)
664                .or_else(|| {
665                    state
666                        .fps
667                        .filter(|f| *f > 0.0)
668                        .map(|fps| ((state.decoded_frames as f64) * 1000.0 / (fps as f64)).round() as u64)
669                })
670        } else {
671            state.total_frames_hint.and_then(|frames| {
672                state
673                    .frame_time_ms
674                    .map(|ms| ((frames as f64) * ms).round() as u64)
675                    .or_else(|| {
676                        state
677                            .fps
678                            .filter(|f| *f > 0.0)
679                            .map(|fps| ((frames as f64) * 1000.0 / (fps as f64)).round() as u64)
680                    })
681            })
682        };
683
684        Ok(Some(MovieStreamFrame {
685            frame,
686            frame_idx: actual_frame_idx,
687            fps: state.fps,
688            total_ms: video_total_ms,
689            audio: None,
690            audio_ready: true,
691            decoded_now: false,
692            clamped_timer_ms: None,
693        }))
694    }
695
696    pub fn ensure_preview_frame(&mut self, file_name: &str) -> Result<Arc<RgbaImage>> {
697        let path = resolve_mov_path(&self.project_dir, &self.current_append_dir, file_name)?;
698        self.ensure_preview_frame_for_path(path)
699    }
700
701    pub fn ensure_omv_preview_frame(&mut self, file_name: &str) -> Result<Arc<RgbaImage>> {
702        let path = crate::resource::find_omv_path_with_append_dir(
703            &self.project_dir,
704            &self.current_append_dir,
705            file_name,
706        )?;
707        self.ensure_preview_frame_for_path(path)
708    }
709
710    fn ensure_preview_frame_for_path(&mut self, path: PathBuf) -> Result<Arc<RgbaImage>> {
711        if let Some(frame) = self.preview_cache.get(&path) {
712            return Ok(frame.clone());
713        }
714        let ext = path
715            .extension()
716            .and_then(|s| s.to_str())
717            .unwrap_or("")
718            .to_ascii_lowercase();
719        let frame = if ext == "omv" {
720            decode_omv_preview_frame(&path)?
721        } else {
722            decode_mpeg2_preview_frame(&path)?
723        };
724        self.preview_cache.insert(path, frame.clone());
725        Ok(frame)
726    }
727    pub fn start_audio(
728        &mut self,
729        audio: &mut AudioHub,
730        track: &MovieAudio,
731        offset_ms: u64,
732    ) -> Result<u64> {
733        let local_offset_ms = offset_ms.saturating_sub(track.start_ms);
734        let remaining_ms = track
735            .duration_ms
736            .map(|d| d.saturating_sub(local_offset_ms.min(d)));
737        let wav = encode_wav_i16_interleaved_offset(track, local_offset_ms);
738        let data = StaticSoundData::from_cursor(Cursor::new(wav))
739            .context("kira: decode movie WAV bytes")?;
740        let handle = audio.play_static(TrackKind::Mov, data)?;
741        let id = self.next_playback_id;
742        self.next_playback_id = self.next_playback_id.saturating_add(1).max(1);
743        self.playbacks.insert(
744            id,
745            MoviePlayback {
746                handle,
747                started_at: Instant::now(),
748                duration_ms: remaining_ms,
749            },
750        );
751        Ok(id)
752    }
753
754    pub fn pause_audio(&mut self, id: u64) {
755        let Some(p) = self.playbacks.get_mut(&id) else {
756            return;
757        };
758        let _ = p.handle.pause(kira::tween::Tween::default());
759    }
760
761    pub fn resume_audio(&mut self, id: u64) {
762        let Some(p) = self.playbacks.get_mut(&id) else {
763            return;
764        };
765        let _ = p.handle.resume(kira::tween::Tween::default());
766    }
767
768    pub fn stop_audio(&mut self, id: u64) {
769        if let Some(mut p) = self.playbacks.remove(&id) {
770            let _ = p.handle.stop(kira::tween::Tween::default());
771        }
772    }
773
774    pub fn audio_playback_finished(&mut self, id: u64) -> bool {
775        let Some(p) = self.playbacks.get(&id) else {
776            return true;
777        };
778        let Some(duration_ms) = p.duration_ms else {
779            return false;
780        };
781        if p.started_at.elapsed() >= Duration::from_millis(duration_ms) {
782            if let Some(mut p) = self.playbacks.remove(&id) {
783                let _ = p.handle.stop(kira::tween::Tween::default());
784            }
785            true
786        } else {
787            false
788        }
789    }
790}
791
792fn resolve_mov_path(
793    project_dir: &Path,
794    current_append_dir: &str,
795    file_name: &str,
796) -> Result<PathBuf> {
797    let (path, _ty) =
798        crate::resource::find_mov_path_with_append_dir(project_dir, current_append_dir, file_name)?;
799    Ok(path)
800}
801
802fn decode_frames_if_enabled(_path: &Path) -> Result<Option<usize>> {
803    Ok(None)
804}
805
806fn read_file_prefix(path: &Path, max_len: usize) -> Result<Vec<u8>> {
807    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
808    {
809        let mut out = read_movie_bytes(path)?;
810        out.truncate(max_len.min(out.len()));
811        return Ok(out);
812    }
813    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
814    {
815        let mut file = fs::File::open(path).with_context(|| format!("open file: {}", path.display()))?;
816        let mut out = vec![0u8; max_len.max(1)];
817        let n = file
818            .read(&mut out)
819            .with_context(|| format!("read file prefix: {}", path.display()))?;
820        out.truncate(n);
821        Ok(out)
822    }
823}
824
825fn frame_index_for_timer(timer_ms: u64, fps: f32, frame_count: usize) -> usize {
826    if frame_count == 0 {
827        return 0;
828    }
829    if fps <= 0.0 {
830        return 0;
831    }
832    ((timer_ms as f64) * (fps as f64) / 1000.0).floor() as usize
833}
834
835fn spawn_mpeg2_stream_state(path: PathBuf, audio: Option<MovieAudio>) -> Result<Mpeg2StreamState> {
836    let prefix = read_file_prefix(&path, MPEG2_HEADER_PROBE_BYTES)?;
837    let mut width = None;
838    let mut height = None;
839    let mut fps = None;
840    if let Some(h) = siglus_assets::mpeg2::find_sequence_header(&prefix) {
841        width = Some(h.width as u32);
842        height = Some(h.height as u32);
843        fps = siglus_assets::mpeg2::fps_from_frame_rate_code(h.frame_rate_code);
844    }
845
846    let (tx, rx) = mpsc::sync_channel(MPEG2_STREAM_CHANNEL_CAPACITY);
847    let request_frames = Arc::new(AtomicUsize::new(MPEG2_STREAM_DECODE_LEAD_FRAMES));
848    let worker_request_frames = request_frames.clone();
849    let video_path = path.clone();
850    thread::spawn(move || {
851        let result = stream_mpeg2_video_worker(video_path.as_path(), tx.clone(), worker_request_frames);
852        if let Err(err) = result {
853            let _ = tx.send(Err(format!("{:#}", err)));
854        }
855    });
856
857    Ok(Mpeg2StreamState {
858        rx,
859        frames: VecDeque::new(),
860        width,
861        height,
862        fps,
863        decoded_frames: 0,
864        done: false,
865        audio,
866        decoded_any_this_poll: false,
867        request_frames,
868    })
869}
870
871fn wait_for_mpeg2_frame_request(request_frames: &Arc<AtomicUsize>, frame_idx: usize) {
872    while frame_idx > request_frames.load(Ordering::Acquire) {
873        if request_frames.load(Ordering::Acquire) == usize::MAX {
874            return;
875        }
876        thread::sleep(Duration::from_millis(1));
877    }
878}
879
880fn stream_mpeg2_video_worker(
881    path: &Path,
882    tx: mpsc::SyncSender<Result<Mpeg2StreamEvent, String>>,
883    request_frames: Arc<AtomicUsize>,
884) -> Result<()> {
885    let prefix = read_file_prefix(path, MPEG2_HEADER_PROBE_BYTES)?;
886    let mut width = None;
887    let mut height = None;
888    let mut fps = None;
889    if let Some(h) = siglus_assets::mpeg2::find_sequence_header(&prefix) {
890        width = Some(h.width as u32);
891        height = Some(h.height as u32);
892        fps = siglus_assets::mpeg2::fps_from_frame_rate_code(h.frame_rate_code);
893    }
894    if tx
895        .send(Ok(Mpeg2StreamEvent::Info { width, height, fps }))
896        .is_err()
897    {
898        return Ok(());
899    }
900
901    let mut file = fs::File::open(path).with_context(|| format!("open movie file: {}", path.display()))?;
902    let mut pipeline = na_mpeg2_decoder::MpegVideoPipeline::new();
903    let mut buf = vec![0u8; MPEG2_STREAM_CHUNK_BYTES];
904    let mut frame_idx = 0usize;
905    let mut send_failed = false;
906
907    loop {
908        let n = file
909            .read(&mut buf)
910            .with_context(|| format!("read movie stream: {}", path.display()))?;
911        if n == 0 {
912            break;
913        }
914        pipeline
915            .push_with(&buf[..n], None, |f| {
916                if send_failed {
917                    return;
918                }
919                wait_for_mpeg2_frame_request(&request_frames, frame_idx);
920                if request_frames.load(Ordering::Acquire) == usize::MAX {
921                    send_failed = true;
922                    return;
923                }
924                let w = f.width as u32;
925                let h = f.height as u32;
926                let mut rgba = vec![0u8; (w as usize).saturating_mul(h as usize).saturating_mul(4)];
927                na_mpeg2_decoder::frame_to_rgba_bt601_limited(&f, &mut rgba);
928                let frame = Arc::new(RgbaImage {
929                    width: w,
930                    height: h,
931                    center_x: 0,
932                    center_y: 0,
933                    rgba,
934                });
935                let ev = Mpeg2StreamEvent::Video { frame_idx, frame };
936                frame_idx = frame_idx.saturating_add(1);
937                if tx.send(Ok(ev)).is_err() {
938                    send_failed = true;
939                }
940            })
941            .context("mpeg2 stream video decode")?;
942        if send_failed {
943            return Ok(());
944        }
945    }
946
947    pipeline.flush_with(|f| {
948        if send_failed {
949            return;
950        }
951        wait_for_mpeg2_frame_request(&request_frames, frame_idx);
952        if request_frames.load(Ordering::Acquire) == usize::MAX {
953            send_failed = true;
954            return;
955        }
956        let w = f.width as u32;
957        let h = f.height as u32;
958        let mut rgba = vec![0u8; (w as usize).saturating_mul(h as usize).saturating_mul(4)];
959        na_mpeg2_decoder::frame_to_rgba_bt601_limited(&f, &mut rgba);
960        let frame = Arc::new(RgbaImage {
961            width: w,
962            height: h,
963            center_x: 0,
964            center_y: 0,
965            rgba,
966        });
967        let ev = Mpeg2StreamEvent::Video { frame_idx, frame };
968        frame_idx = frame_idx.saturating_add(1);
969        if tx.send(Ok(ev)).is_err() {
970            send_failed = true;
971        }
972    })?;
973
974    if !send_failed {
975        let _ = tx.send(Ok(Mpeg2StreamEvent::Done));
976    }
977    Ok(())
978}
979
980fn drain_mpeg2_stream_state(
981    path: &Path,
982    state: &mut Mpeg2StreamState,
983    target_frame_idx: Option<usize>,
984    _target_timer_ms: u64,
985) -> Result<()> {
986    state.decoded_any_this_poll = false;
987
988    let decode_until = target_frame_idx
989        .map(|idx| idx.saturating_add(MPEG2_STREAM_DECODE_LEAD_FRAMES));
990
991    for _ in 0..MPEG2_STREAM_MAX_DRAIN_EVENTS {
992        if let Some(limit) = decode_until {
993            if state.decoded_frames > limit && !state.frames.is_empty() {
994                break;
995            }
996        }
997        match state.rx.try_recv() {
998            Ok(Ok(Mpeg2StreamEvent::Info { width, height, fps })) => {
999                state.width = width.or(state.width);
1000                state.height = height.or(state.height);
1001                state.fps = fps.or(state.fps);
1002            }
1003            Ok(Ok(Mpeg2StreamEvent::Video { frame_idx, frame })) => {
1004                state.decoded_frames = state.decoded_frames.max(frame_idx.saturating_add(1));
1005                state.frames.push_back((frame_idx, frame));
1006                state.decoded_any_this_poll = true;
1007            }
1008            Ok(Ok(Mpeg2StreamEvent::Done)) => {
1009                state.done = true;
1010                break;
1011            }
1012            Ok(Err(err)) => {
1013                return Err(anyhow!("mpeg2 stream decode failed for {}: {}", path.display(), err));
1014            }
1015            Err(TryRecvError::Empty) => break,
1016            Err(TryRecvError::Disconnected) => {
1017                state.done = true;
1018                break;
1019            }
1020        }
1021    }
1022
1023    if let Some(target) = target_frame_idx {
1024        let keep_from = target.saturating_sub(2);
1025        while state
1026            .frames
1027            .front()
1028            .map(|(idx, _)| *idx < keep_from)
1029            .unwrap_or(false)
1030        {
1031            state.frames.pop_front();
1032        }
1033    }
1034    while state.frames.len() > MPEG2_STREAM_FRAME_KEEP {
1035        state.frames.pop_front();
1036    }
1037
1038    Ok(())
1039}
1040
1041fn spawn_omv_stream_state(path: PathBuf) -> Result<OmvStreamState> {
1042    let (tx, rx) = mpsc::sync_channel(OMV_STREAM_CHANNEL_CAPACITY);
1043    let request_frames = Arc::new(AtomicUsize::new(OMV_STREAM_DECODE_LEAD_FRAMES));
1044    let worker_request_frames = request_frames.clone();
1045    thread::spawn(move || {
1046        let result = stream_omv_video_worker(path.as_path(), tx.clone(), worker_request_frames);
1047        if let Err(err) = result {
1048            let _ = tx.send(Err(format!("{:#}", err)));
1049        }
1050    });
1051    Ok(OmvStreamState {
1052        rx,
1053        frames: VecDeque::new(),
1054        width: None,
1055        height: None,
1056        fps: None,
1057        frame_time_ms: None,
1058        total_frames_hint: None,
1059        decoded_frames: 0,
1060        done: false,
1061        request_frames,
1062    })
1063}
1064
1065fn wait_for_omv_frame_request(request_frames: &Arc<AtomicUsize>, frame_idx: usize) {
1066    while frame_idx > request_frames.load(Ordering::Acquire) {
1067        if request_frames.load(Ordering::Acquire) == usize::MAX {
1068            return;
1069        }
1070        thread::sleep(Duration::from_millis(1));
1071    }
1072}
1073
1074fn stream_omv_video_worker(
1075    path: &Path,
1076    tx: mpsc::SyncSender<Result<OmvStreamEvent, String>>,
1077    request_frames: Arc<AtomicUsize>,
1078) -> Result<()> {
1079    let omv = siglus_assets::omv::OmvFile::open(path).ok();
1080    let ogg_data = siglus_assets::omv::OmvFile::read_embedded_ogg(path)
1081        .or_else(|_| extract_ogg_by_scan(path))
1082        .with_context(|| format!("read embedded ogg: {}", path.display()))?;
1083    let mut video_tf = siglus_omv_decoder::TheoraFile::open_from_memory(ogg_data)
1084        .with_context(|| format!("open theora: {}", path.display()))?;
1085    let vinfo = video_tf.info();
1086
1087    let display_w = omv
1088        .as_ref()
1089        .map(|m| m.header.display_width as i32)
1090        .unwrap_or(vinfo.width);
1091    let display_h = omv
1092        .as_ref()
1093        .map(|m| m.header.display_height as i32)
1094        .unwrap_or(vinfo.height);
1095    let width = display_w.max(1) as u32;
1096    let height = display_h.max(1) as u32;
1097    let fps = if let Some(m) = omv.as_ref() {
1098        if m.header.frame_time_us != 0 {
1099            Some(1_000_000.0 / (m.header.frame_time_us as f32))
1100        } else if vinfo.fps > 0.0 {
1101            Some(vinfo.fps as f32)
1102        } else {
1103            None
1104        }
1105    } else if vinfo.fps > 0.0 {
1106        Some(vinfo.fps as f32)
1107    } else {
1108        None
1109    };
1110    let frame_time_ms = omv_frame_duration_ms(omv.as_ref().map(|m| &m.header), fps);
1111    let total_frames_hint = omv
1112        .as_ref()
1113        .and_then(|m| (m.header.packet_count_hint > 0).then_some(m.header.packet_count_hint as usize));
1114    let theora_type = omv
1115        .as_ref()
1116        .map(|m| m.header.theora_type)
1117        .unwrap_or(siglus_assets::omv::OMV_THEORA_TYPE_YUV);
1118
1119    if tx
1120        .send(Ok(OmvStreamEvent::Info {
1121            width,
1122            height,
1123            fps,
1124            frame_time_ms,
1125            total_frames_hint,
1126        }))
1127        .is_err()
1128    {
1129        return Ok(());
1130    }
1131
1132    let (_uv_w, _uv_h, y_len, u_len, v_len) =
1133        omv_plane_layout(vinfo.width, vinfo.height, theora_type, vinfo.fmt);
1134    let mut buf = vec![0u8; y_len.saturating_add(u_len).saturating_add(v_len)];
1135    let mut frame_idx = 0usize;
1136    while video_tf.read_video_frame(&mut buf)? {
1137        wait_for_omv_frame_request(&request_frames, frame_idx);
1138        if request_frames.load(Ordering::Acquire) == usize::MAX {
1139            return Ok(());
1140        }
1141        let rgba = convert_omv_frame(
1142            &buf,
1143            vinfo.width,
1144            vinfo.height,
1145            vinfo.fmt,
1146            display_h,
1147            theora_type,
1148        );
1149        let frame = Arc::new(RgbaImage { width, height, center_x: 0, center_y: 0, rgba });
1150        if tx.send(Ok(OmvStreamEvent::Video { frame_idx, frame })).is_err() {
1151            return Ok(());
1152        }
1153        frame_idx = frame_idx.saturating_add(1);
1154    }
1155
1156    let _ = tx.send(Ok(OmvStreamEvent::Done));
1157    Ok(())
1158}
1159
1160fn select_stream_frame(
1161    frames: &VecDeque<(usize, Arc<RgbaImage>)>,
1162    chosen_idx: usize,
1163) -> Option<(usize, Arc<RgbaImage>)> {
1164    let (front_idx, _) = frames.front()?;
1165    let (back_idx, back_frame) = frames.back()?;
1166    if chosen_idx >= *back_idx || chosen_idx < *front_idx {
1167        return Some((*back_idx, back_frame.clone()));
1168    }
1169
1170    let direct = chosen_idx.saturating_sub(*front_idx);
1171    if let Some((idx, frame)) = frames.get(direct) {
1172        if *idx == chosen_idx {
1173            return Some((*idx, frame.clone()));
1174        }
1175    }
1176
1177    let mut i = direct.min(frames.len().saturating_sub(1));
1178    loop {
1179        if let Some((idx, frame)) = frames.get(i) {
1180            if *idx <= chosen_idx {
1181                return Some((*idx, frame.clone()));
1182            }
1183        }
1184        if i == 0 {
1185            break;
1186        }
1187        i -= 1;
1188    }
1189
1190    Some((*back_idx, back_frame.clone()))
1191}
1192
1193fn drain_omv_stream_state(
1194    path: &Path,
1195    state: &mut OmvStreamState,
1196    target_frame_idx: Option<usize>,
1197    retain_from_start: bool,
1198) -> Result<()> {
1199    let decode_until = target_frame_idx
1200        .map(|idx| idx.saturating_add(OMV_STREAM_DECODE_LEAD_FRAMES));
1201
1202    for _ in 0..OMV_STREAM_MAX_DRAIN_EVENTS {
1203        if let Some(limit) = decode_until {
1204            if state.decoded_frames > limit && !state.frames.is_empty() {
1205                break;
1206            }
1207        }
1208        match state.rx.try_recv() {
1209            Ok(Ok(OmvStreamEvent::Info { width, height, fps, frame_time_ms, total_frames_hint })) => {
1210                state.width = Some(width);
1211                state.height = Some(height);
1212                state.fps = fps.or(state.fps);
1213                state.frame_time_ms = frame_time_ms.or(state.frame_time_ms);
1214                state.total_frames_hint = total_frames_hint.or(state.total_frames_hint);
1215            }
1216            Ok(Ok(OmvStreamEvent::Video { frame_idx, frame })) => {
1217                state.decoded_frames = state.decoded_frames.max(frame_idx.saturating_add(1));
1218                state.frames.push_back((frame_idx, frame));
1219            }
1220            Ok(Ok(OmvStreamEvent::Done)) => {
1221                state.done = true;
1222                break;
1223            }
1224            Ok(Err(err)) => {
1225                return Err(anyhow!("omv stream decode failed for {}: {}", path.display(), err));
1226            }
1227            Err(TryRecvError::Empty) => break,
1228            Err(TryRecvError::Disconnected) => {
1229                state.done = true;
1230                break;
1231            }
1232        }
1233    }
1234
1235    if !retain_from_start {
1236        if let Some(target) = target_frame_idx {
1237            let keep_from = target.saturating_sub(2);
1238            while state
1239                .frames
1240                .front()
1241                .map(|(idx, _)| *idx < keep_from)
1242                .unwrap_or(false)
1243            {
1244                state.frames.pop_front();
1245            }
1246        }
1247        while state.frames.len() > OMV_STREAM_FRAME_KEEP {
1248            state.frames.pop_front();
1249        }
1250    }
1251    Ok(())
1252}
1253
1254fn omv_frame_duration_ms(
1255    header: Option<&siglus_assets::omv::OmvHeader>,
1256    fps: Option<f32>,
1257) -> Option<f64> {
1258    if let Some(h) = header {
1259        if h.frame_time_us != 0 {
1260            return Some((h.frame_time_us as f64) / 1000.0);
1261        }
1262    }
1263    let f = fps?;
1264    if f > 0.0 {
1265        Some(1000.0 / (f as f64))
1266    } else {
1267        None
1268    }
1269}
1270
1271fn omv_plane_layout(
1272    width: i32,
1273    video_height: i32,
1274    theora_type: u32,
1275    fmt: i32,
1276) -> (usize, usize, usize, usize, usize) {
1277    let w = width.max(1) as usize;
1278    let vh = video_height.max(1) as usize;
1279    match theora_type {
1280        siglus_assets::omv::OMV_THEORA_TYPE_RGB | siglus_assets::omv::OMV_THEORA_TYPE_RGBA => {
1281            // OMV RGB/RGBA is not YCbCr even though it is carried by a Theora 4:4:4 stream.
1282            // Original tona3 copies three full-size planes as B, G, R.  RGBA stores alpha
1283            // in hidden rows below the visible picture area, split across those same planes.
1284            let plane_len = w.saturating_mul(vh);
1285            (w, vh, plane_len, plane_len, plane_len)
1286        }
1287        _ => {
1288            let y_len = w.saturating_mul(vh);
1289            let (uv_w, uv_h) = yuv_plane_size(width, video_height, fmt);
1290            let uv_len = uv_w.saturating_mul(uv_h);
1291            (uv_w, uv_h, y_len, uv_len, uv_len)
1292        }
1293    }
1294}
1295
1296#[derive(Debug, Clone)]
1297pub struct MovieAsset {
1298    pub info: MovieInfo,
1299    pub frames: Vec<Arc<RgbaImage>>,
1300    pub audio: Option<MovieAudio>,
1301}
1302
1303#[derive(Debug, Clone)]
1304pub struct MovieAudio {
1305    pub samples: Arc<Vec<i16>>,
1306    pub channels: u16,
1307    pub sample_rate: u32,
1308    pub start_ms: u64,
1309    pub duration_ms: Option<u64>,
1310}
1311
1312impl MovieAudio {
1313    fn end_ms(&self) -> u64 {
1314        self.start_ms.saturating_add(self.duration_ms.unwrap_or(0))
1315    }
1316}
1317
1318#[derive(Debug)]
1319struct MoviePlayback {
1320    handle: StaticSoundHandle,
1321    started_at: Instant,
1322    duration_ms: Option<u64>,
1323}
1324
1325
1326#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1327fn read_movie_bytes(path: &Path) -> Result<Vec<u8>> {
1328    crate::resource::read_file_bytes(path)
1329        .with_context(|| format!("read movie file: {}", path.display()))
1330}
1331
1332#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1333fn read_movie_bytes(path: &Path) -> Result<Vec<u8>> {
1334    fs::read(path).with_context(|| format!("read movie file: {}", path.display()))
1335}
1336
1337#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1338fn read_omv_header_for_path(path: &Path) -> Result<siglus_assets::omv::OmvHeader> {
1339    let bytes = read_movie_bytes(path)?;
1340    read_omv_header_from_bytes(&bytes)
1341}
1342
1343#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1344fn read_omv_header_for_path(path: &Path) -> Result<siglus_assets::omv::OmvHeader> {
1345    Ok(siglus_assets::omv::OmvFile::open(path)?.header)
1346}
1347
1348fn read_omv_header_from_bytes(buf: &[u8]) -> Result<siglus_assets::omv::OmvHeader> {
1349    if buf.len() < 0x58 {
1350        bail!("OMV header too small");
1351    }
1352    let header_size = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
1353    let version = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
1354    let theora_type = u32::from_le_bytes([buf[0x28], buf[0x29], buf[0x2a], buf[0x2b]]);
1355    let display_width = u32::from_le_bytes([buf[0x2c], buf[0x2d], buf[0x2e], buf[0x2f]]);
1356    let display_height = u32::from_le_bytes([buf[0x30], buf[0x31], buf[0x32], buf[0x33]]);
1357    let frame_time_us = u32::from_le_bytes([buf[0x3c], buf[0x3d], buf[0x3e], buf[0x3f]]);
1358    let max_data_size = u32::from_le_bytes([buf[0x40], buf[0x41], buf[0x42], buf[0x43]]);
1359    let page_count_hint = u32::from_le_bytes([buf[0x4c], buf[0x4d], buf[0x4e], buf[0x4f]]);
1360    let packet_count_hint = u32::from_le_bytes([buf[0x50], buf[0x51], buf[0x52], buf[0x53]]);
1361    if header_size < 0x58 {
1362        bail!("invalid OMV header size: {header_size:#x}");
1363    }
1364    if theora_type > siglus_assets::omv::OMV_THEORA_TYPE_YUV {
1365        bail!("invalid OMV theora type: {theora_type}");
1366    }
1367    if display_width == 0 || display_height == 0 {
1368        bail!("invalid OMV display size: {}x{}", display_width, display_height);
1369    }
1370    Ok(siglus_assets::omv::OmvHeader {
1371        header_size,
1372        version,
1373        theora_type,
1374        display_width,
1375        display_height,
1376        frame_time_us,
1377        max_data_size,
1378        page_count_hint,
1379        packet_count_hint,
1380    })
1381}
1382
1383fn extract_ogg_from_bytes(bytes: &[u8]) -> Result<Vec<u8>> {
1384    let needle = b"OggS";
1385    let pos = bytes
1386        .windows(needle.len())
1387        .position(|w| w == needle)
1388        .ok_or_else(|| anyhow!("OggS not found in OMV payload"))?;
1389    Ok(bytes[pos..].to_vec())
1390}
1391
1392#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1393fn decode_mpeg2_asset_from_bytes(path: &Path, bytes: Vec<u8>) -> Result<MovieAsset> {
1394    let mut width = None;
1395    let mut height = None;
1396    let mut fps = None;
1397    if let Some(h) = siglus_assets::mpeg2::find_sequence_header(&bytes[..bytes.len().min(MPEG2_HEADER_PROBE_BYTES)]) {
1398        width = Some(h.width as u32);
1399        height = Some(h.height as u32);
1400        fps = siglus_assets::mpeg2::fps_from_frame_rate_code(h.frame_rate_code);
1401    }
1402
1403    let mut frames: Vec<Arc<RgbaImage>> = Vec::new();
1404    let mut audio_samples: Vec<i16> = Vec::new();
1405    let mut audio_channels: Option<u16> = None;
1406    let mut audio_sample_rate: Option<u32> = None;
1407    let mut dropped_audio_format_changes = 0u32;
1408    let mut pipeline = na_mpeg2_decoder::MpegAvPipeline::new();
1409    pipeline
1410        .push_with(&bytes, None, |ev| match ev {
1411            na_mpeg2_decoder::MpegAvEvent::Video(f) => {
1412                let w = f.width;
1413                let h = f.height;
1414                frames.push(Arc::new(RgbaImage { width: w, height: h, center_x: 0, center_y: 0, rgba: f.rgba }));
1415            }
1416            na_mpeg2_decoder::MpegAvEvent::Audio(a) => {
1417                append_mpeg2_audio_chunk_for_asset(
1418                    path,
1419                    &mut audio_channels,
1420                    &mut audio_sample_rate,
1421                    &mut audio_samples,
1422                    &mut dropped_audio_format_changes,
1423                    a,
1424                );
1425            }
1426        })
1427        .context("mpeg2 wasm full decode")?;
1428    pipeline.flush_with(|ev| match ev {
1429        na_mpeg2_decoder::MpegAvEvent::Video(f) => {
1430            let w = f.width;
1431            let h = f.height;
1432            frames.push(Arc::new(RgbaImage { width: w, height: h, center_x: 0, center_y: 0, rgba: f.rgba }));
1433        }
1434        na_mpeg2_decoder::MpegAvEvent::Audio(a) => {
1435            append_mpeg2_audio_chunk_for_asset(
1436                path,
1437                &mut audio_channels,
1438                &mut audio_sample_rate,
1439                &mut audio_samples,
1440                &mut dropped_audio_format_changes,
1441                a,
1442            );
1443        }
1444    })?;
1445
1446    if frames.is_empty() {
1447        bail!("mpeg2 decoder produced no frames: {}", path.display());
1448    }
1449    let audio = build_movie_audio_from_parts(path, audio_samples, audio_channels, audio_sample_rate)?;
1450    let audio_duration_ms = audio.as_ref().and_then(|a| a.duration_ms);
1451    let first = frames.first().expect("frames not empty");
1452    let info = MovieInfo {
1453        path: path.to_path_buf(),
1454        width: width.or(Some(first.width)),
1455        height: height.or(Some(first.height)),
1456        fps,
1457        decoded_frames: Some(frames.len()),
1458        audio_duration_ms,
1459    };
1460    Ok(MovieAsset { info, frames, audio })
1461}
1462
1463#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1464fn decode_omv_asset_from_bytes(path: &Path, bytes: Vec<u8>) -> Result<MovieAsset> {
1465    let header = read_omv_header_from_bytes(&bytes).ok();
1466    let ogg_data = extract_ogg_from_bytes(&bytes)
1467        .with_context(|| format!("read embedded ogg: {}", path.display()))?;
1468    let mut tf = siglus_omv_decoder::TheoraFile::open_from_memory(ogg_data)
1469        .with_context(|| format!("open theora: {}", path.display()))?;
1470    let vinfo = tf.info();
1471    let display_w = header.as_ref().map(|h| h.display_width as i32).unwrap_or(vinfo.width);
1472    let display_h = header.as_ref().map(|h| h.display_height as i32).unwrap_or(vinfo.height);
1473    let width = display_w.max(1) as u32;
1474    let height = display_h.max(1) as u32;
1475    let fps = header.as_ref().and_then(|h| {
1476        if h.frame_time_us != 0 {
1477            Some(1_000_000.0 / (h.frame_time_us as f32))
1478        } else if vinfo.fps > 0.0 {
1479            Some(vinfo.fps as f32)
1480        } else {
1481            None
1482        }
1483    }).or_else(|| (vinfo.fps > 0.0).then_some(vinfo.fps as f32));
1484    let theora_type = header
1485        .as_ref()
1486        .map(|h| h.theora_type)
1487        .unwrap_or(siglus_assets::omv::OMV_THEORA_TYPE_YUV);
1488    let (_uv_w, _uv_h, y_len, u_len, v_len) =
1489        omv_plane_layout(vinfo.width, vinfo.height, theora_type, vinfo.fmt);
1490    let mut packed = vec![0u8; y_len.saturating_add(u_len).saturating_add(v_len)];
1491    let mut frames = Vec::<Arc<RgbaImage>>::new();
1492    while tf.read_video_frame(&mut packed)? {
1493        let rgba = convert_omv_frame(
1494            &packed,
1495            vinfo.width,
1496            vinfo.height,
1497            vinfo.fmt,
1498            display_h,
1499            theora_type,
1500        );
1501        frames.push(Arc::new(RgbaImage { width, height, center_x: 0, center_y: 0, rgba }));
1502    }
1503    if frames.is_empty() {
1504        bail!("omv decoder produced no frames: {}", path.display());
1505    }
1506    tf.reset();
1507    let audio = decode_omv_audio(&mut tf)?;
1508    let frame_time_ms = omv_frame_duration_ms(header.as_ref(), fps);
1509    let video_duration_ms = frame_time_ms.map(|ms| ((frames.len() as f64) * ms).round().max(1.0) as u64);
1510    let audio_duration_ms = audio.as_ref().and_then(|a| a.duration_ms).or(video_duration_ms);
1511    let info = MovieInfo {
1512        path: path.to_path_buf(),
1513        width: Some(width),
1514        height: Some(height),
1515        fps,
1516        decoded_frames: Some(frames.len()),
1517        audio_duration_ms,
1518    };
1519    Ok(MovieAsset { info, frames, audio })
1520}
1521
1522#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1523fn append_mpeg2_audio_chunk_for_asset(
1524    _path: &Path,
1525    audio_channels: &mut Option<u16>,
1526    audio_sample_rate: &mut Option<u32>,
1527    audio_samples: &mut Vec<i16>,
1528    dropped_audio_format_changes: &mut u32,
1529    a: na_mpeg2_decoder::MpegAudioF32,
1530) {
1531    match (*audio_channels, *audio_sample_rate) {
1532        (None, None) => {
1533            *audio_channels = Some(a.channels);
1534            *audio_sample_rate = Some(a.sample_rate);
1535        }
1536        (Some(ch), Some(sr)) if ch == a.channels && sr == a.sample_rate => {}
1537        (Some(_), Some(_)) => {
1538            *dropped_audio_format_changes = (*dropped_audio_format_changes).saturating_add(1);
1539            return;
1540        }
1541        _ => return,
1542    }
1543    audio_samples.extend(a.samples.into_iter().map(f32_to_i16_sample));
1544}
1545
1546#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1547fn build_movie_audio_from_parts(
1548    path: &Path,
1549    audio_samples: Vec<i16>,
1550    audio_channels: Option<u16>,
1551    audio_sample_rate: Option<u32>,
1552) -> Result<Option<MovieAudio>> {
1553    match (audio_channels, audio_sample_rate, audio_samples.is_empty()) {
1554        (Some(channels), Some(sample_rate), false) => {
1555            if channels == 0 || sample_rate == 0 {
1556                bail!(
1557                    "movie audio stream has invalid format in {}: channels={} sample_rate={}",
1558                    path.display(), channels, sample_rate
1559                );
1560            }
1561            let frames_len = (audio_samples.len() as u64) / (channels as u64);
1562            let duration_ms = Some(((frames_len as f64) * 1000.0 / sample_rate as f64).round() as u64);
1563            Ok(Some(MovieAudio {
1564                samples: Arc::new(audio_samples),
1565                channels,
1566                sample_rate,
1567                start_ms: 0,
1568                duration_ms,
1569            }))
1570        }
1571        (None, None, true) => Ok(None),
1572        (Some(_), Some(_), true) => Ok(None),
1573        _ => bail!("movie audio decoder produced incomplete format metadata for {}", path.display()),
1574    }
1575}
1576
1577fn decode_asset_for_path(path: &Path) -> Result<MovieAsset> {
1578    let ext = path
1579        .extension()
1580        .and_then(|s| s.to_str())
1581        .unwrap_or("")
1582        .to_ascii_lowercase();
1583    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1584    {
1585        let bytes = read_movie_bytes(path)?;
1586        if ext == "omv" {
1587            return decode_omv_asset_from_bytes(path, bytes);
1588        }
1589        return decode_mpeg2_asset_from_bytes(path, bytes);
1590    }
1591    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1592    {
1593        if ext == "omv" {
1594            decode_omv_asset(path)
1595        } else {
1596            decode_mpeg2_asset(path)
1597        }
1598    }
1599}
1600
1601fn decode_mpeg2_preview_frame(path: &Path) -> Result<Arc<RgbaImage>> {
1602    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1603    {
1604        let bytes = read_movie_bytes(path)?;
1605        let asset = decode_mpeg2_asset_from_bytes(path, bytes)?;
1606        return asset
1607            .frames
1608            .into_iter()
1609            .next()
1610            .ok_or_else(|| anyhow!("mpeg2 preview frame missing: {}", path.display()));
1611    }
1612
1613    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1614    {
1615        let mut file = fs::File::open(path).with_context(|| format!("open movie file: {}", path.display()))?;
1616        let mut pipeline = na_mpeg2_decoder::MpegVideoPipeline::new();
1617        let mut first = None;
1618        let mut buf = vec![0u8; MPEG2_STREAM_CHUNK_BYTES];
1619        loop {
1620            let n = file
1621                .read(&mut buf)
1622                .with_context(|| format!("read movie preview stream: {}", path.display()))?;
1623            if n == 0 {
1624                break;
1625            }
1626            pipeline
1627                .push_with(&buf[..n], None, |f| {
1628                    if first.is_none() {
1629                        let w = f.width as u32;
1630                        let h = f.height as u32;
1631                        let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
1632                        na_mpeg2_decoder::frame_to_rgba_bt601_limited(&f, &mut rgba);
1633                        first = Some(Arc::new(RgbaImage {
1634                            width: w,
1635                            height: h,
1636                            center_x: 0,
1637                            center_y: 0,
1638                            rgba,
1639                        }));
1640                    }
1641                })
1642                .context("mpeg2 preview decode")?;
1643            if first.is_some() {
1644                break;
1645            }
1646        }
1647        if first.is_none() {
1648            pipeline.flush_with(|f| {
1649                if first.is_none() {
1650                    let w = f.width as u32;
1651                    let h = f.height as u32;
1652                    let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
1653                    na_mpeg2_decoder::frame_to_rgba_bt601_limited(&f, &mut rgba);
1654                    first = Some(Arc::new(RgbaImage {
1655                        width: w,
1656                        height: h,
1657                        center_x: 0,
1658                        center_y: 0,
1659                        rgba,
1660                    }));
1661                }
1662            })?;
1663        }
1664        first.ok_or_else(|| anyhow!("mpeg2 preview frame missing: {}", path.display()))
1665    }
1666}
1667
1668fn decode_omv_preview_frame(path: &Path) -> Result<Arc<RgbaImage>> {
1669    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1670    {
1671        let bytes = read_movie_bytes(path)?;
1672        let asset = decode_omv_asset_from_bytes(path, bytes)?;
1673        return asset
1674            .frames
1675            .into_iter()
1676            .next()
1677            .ok_or_else(|| anyhow!("omv preview frame missing: {}", path.display()));
1678    }
1679
1680    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1681    {
1682        let omv = siglus_assets::omv::OmvFile::open(path).ok();
1683        let ogg_data = siglus_assets::omv::OmvFile::read_embedded_ogg(path)
1684            .or_else(|_| extract_ogg_by_scan(path))
1685            .with_context(|| format!("read embedded ogg: {}", path.display()))?;
1686        let (vinfo, packed) = siglus_omv_decoder::decode_first_video_frame_from_memory(ogg_data)
1687            .with_context(|| format!("decode first omv frame: {}", path.display()))?;
1688        let display_h = omv
1689            .as_ref()
1690            .map(|m| m.header.display_height as i32)
1691            .unwrap_or(vinfo.height);
1692        let width = omv
1693            .as_ref()
1694            .map(|m| m.header.display_width.max(1))
1695            .unwrap_or(vinfo.width.max(1) as u32);
1696        let height = display_h.max(1) as u32;
1697        let rgba = convert_omv_frame(
1698            &packed,
1699            vinfo.width,
1700            vinfo.height,
1701            vinfo.fmt,
1702            display_h,
1703            omv.as_ref()
1704                .map(|m| m.header.theora_type)
1705                .unwrap_or(siglus_assets::omv::OMV_THEORA_TYPE_YUV),
1706        );
1707        Ok(Arc::new(RgbaImage {
1708            width,
1709            height,
1710            center_x: 0,
1711            center_y: 0,
1712            rgba,
1713        }))
1714    }
1715}
1716
1717fn decode_mpeg2_audio_for_path(path: &Path) -> Result<Option<MovieAudio>> {
1718    let mut audio_samples: Vec<i16> = Vec::new();
1719    let mut audio_channels: Option<u16> = None;
1720    let mut audio_sample_rate: Option<u32> = None;
1721    let mut dropped_audio_format_changes: u32 = 0;
1722
1723    fn append_chunk(
1724        path: &Path,
1725        phase: &str,
1726        audio_channels: &mut Option<u16>,
1727        audio_sample_rate: &mut Option<u32>,
1728        audio_samples: &mut Vec<i16>,
1729        dropped_audio_format_changes: &mut u32,
1730        a: na_mpeg2_decoder::MpegAudioF32,
1731    ) {
1732        match (*audio_channels, *audio_sample_rate) {
1733            (None, None) => {
1734                *audio_channels = Some(a.channels);
1735                *audio_sample_rate = Some(a.sample_rate);
1736            }
1737            (Some(ch), Some(sr)) if ch == a.channels && sr == a.sample_rate => {}
1738            (Some(ch), Some(sr)) => {
1739                *dropped_audio_format_changes = (*dropped_audio_format_changes).saturating_add(1);
1740                if std::env::var_os("SG_MOVIE_TRACE").is_some()
1741                    || std::env::var_os("SG_DEBUG").is_some()
1742                {
1743                    eprintln!(
1744                        "[SG_DEBUG][MOV] mpeg2_audio_format_change.drop phase={} path={} base={}ch/{}Hz got={}ch/{}Hz samples={}",
1745                        phase,
1746                        path.display(),
1747                        ch,
1748                        sr,
1749                        a.channels,
1750                        a.sample_rate,
1751                        a.samples.len()
1752                    );
1753                }
1754                return;
1755            }
1756            _ => return,
1757        }
1758        audio_samples.extend(a.samples.into_iter().map(f32_to_i16_sample));
1759    }
1760
1761    let mut file = fs::File::open(path).with_context(|| format!("open movie file: {}", path.display()))?;
1762    let mut pipeline = na_mpeg2_decoder::MpegAvPipeline::new();
1763    let mut buf = vec![0u8; MPEG2_STREAM_CHUNK_BYTES];
1764
1765    loop {
1766        let n = file
1767            .read(&mut buf)
1768            .with_context(|| format!("read movie audio stream: {}", path.display()))?;
1769        if n == 0 {
1770            break;
1771        }
1772        pipeline
1773            .push_with(&buf[..n], None, |ev| {
1774                if let na_mpeg2_decoder::MpegAvEvent::Audio(a) = ev {
1775                    append_chunk(
1776                        path,
1777                        "decode",
1778                        &mut audio_channels,
1779                        &mut audio_sample_rate,
1780                        &mut audio_samples,
1781                        &mut dropped_audio_format_changes,
1782                        a,
1783                    );
1784                }
1785            })
1786            .context("mpeg2 audio decode")?;
1787    }
1788
1789    pipeline.flush_with(|ev| {
1790        if let na_mpeg2_decoder::MpegAvEvent::Audio(a) = ev {
1791            append_chunk(
1792                path,
1793                "flush",
1794                &mut audio_channels,
1795                &mut audio_sample_rate,
1796                &mut audio_samples,
1797                &mut dropped_audio_format_changes,
1798                a,
1799            );
1800        }
1801    })?;
1802
1803    match (audio_channels, audio_sample_rate, audio_samples.is_empty()) {
1804        (Some(channels), Some(sample_rate), false) => {
1805            if channels == 0 || sample_rate == 0 {
1806                bail!(
1807                    "mpeg2 audio stream has invalid format in {}: channels={} sample_rate={}",
1808                    path.display(), channels, sample_rate
1809                );
1810            }
1811            let frames_len = (audio_samples.len() as u64) / (channels as u64);
1812            let duration_ms = Some(
1813                ((frames_len as f64) * 1000.0 / sample_rate as f64).round() as u64,
1814            );
1815            Ok(Some(MovieAudio {
1816                samples: Arc::new(audio_samples),
1817                channels,
1818                sample_rate,
1819                start_ms: 0,
1820                duration_ms,
1821            }))
1822        }
1823        (None, None, true) => Ok(None),
1824        (Some(_), Some(_), true) => Ok(None),
1825        _ => bail!(
1826            "mpeg2 audio decoder produced incomplete format metadata for {}",
1827            path.display()
1828        ),
1829    }
1830}
1831
1832fn decode_mpeg2_asset(path: &Path) -> Result<MovieAsset> {
1833    let prefix = read_file_prefix(path, MPEG2_HEADER_PROBE_BYTES)
1834        .with_context(|| format!("read movie header: {}", path.display()))?;
1835    let mut width = None;
1836    let mut height = None;
1837    let mut fps = None;
1838    if let Some(h) = siglus_assets::mpeg2::find_sequence_header(&prefix) {
1839        width = Some(h.width as u32);
1840        height = Some(h.height as u32);
1841        fps = siglus_assets::mpeg2::fps_from_frame_rate_code(h.frame_rate_code);
1842    }
1843    let frame = decode_mpeg2_preview_frame(path)?;
1844    let info = MovieInfo {
1845        path: path.to_path_buf(),
1846        width: width.or(Some(frame.width)),
1847        height: height.or(Some(frame.height)),
1848        fps,
1849        decoded_frames: Some(1),
1850        audio_duration_ms: None,
1851    };
1852    Ok(MovieAsset {
1853        info,
1854        frames: vec![frame],
1855        audio: None,
1856    })
1857}
1858
1859fn f32_to_i16_sample(s: f32) -> i16 {
1860    let clamped = s.max(-1.0).min(1.0);
1861    (clamped * 32767.0).round() as i16
1862}
1863
1864fn decode_omv_asset(path: &Path) -> Result<MovieAsset> {
1865    let omv = siglus_assets::omv::OmvFile::open(path).ok();
1866    let frame = decode_omv_preview_frame(path)?;
1867    let fps = omv.as_ref().and_then(|m| {
1868        if m.header.frame_time_us != 0 {
1869            Some(1_000_000.0 / (m.header.frame_time_us as f32))
1870        } else {
1871            None
1872        }
1873    });
1874    let decoded_frames = omv
1875        .as_ref()
1876        .and_then(|m| (m.header.packet_count_hint > 0).then_some(m.header.packet_count_hint as usize))
1877        .or(Some(1));
1878    let audio_duration_ms = decoded_frames.and_then(|frames| {
1879        omv_frame_duration_ms(omv.as_ref().map(|m| &m.header), fps)
1880            .map(|ms| ((frames as f64) * ms).round().max(1.0) as u64)
1881    });
1882    let info = MovieInfo {
1883        path: path.to_path_buf(),
1884        width: Some(frame.width),
1885        height: Some(frame.height),
1886        fps,
1887        decoded_frames,
1888        audio_duration_ms,
1889    };
1890    Ok(MovieAsset {
1891        info,
1892        frames: vec![frame],
1893        audio: None,
1894    })
1895}
1896
1897fn extract_ogg_by_scan(path: &Path) -> Result<Vec<u8>> {
1898    let bytes = read_movie_bytes(path)?;
1899    extract_ogg_from_bytes(&bytes).with_context(|| format!("OggS not found in OMV: {}", path.display()))
1900}
1901
1902fn decode_omv_audio(tf: &mut siglus_omv_decoder::TheoraFile) -> Result<Option<MovieAudio>> {
1903    if !tf.has_audio() {
1904        return Ok(None);
1905    }
1906    let Some((channels, sample_rate)) = tf.audio_info() else {
1907        return Ok(None);
1908    };
1909    if channels <= 0 || sample_rate <= 0 {
1910        return Ok(None);
1911    }
1912    let channels_u16 = channels as u16;
1913    let sample_rate_u32 = sample_rate as u32;
1914
1915    let mut samples: Vec<f32> = Vec::new();
1916    let mut buf = vec![0.0f32; (4096usize).saturating_mul(channels as usize)];
1917    loop {
1918        let read = tf.read_audio_samples(&mut buf)?;
1919        if read == 0 {
1920            break;
1921        }
1922        samples.extend_from_slice(&buf[..read]);
1923    }
1924
1925    if samples.is_empty() {
1926        return Ok(None);
1927    }
1928    let mut samples_i16: Vec<i16> = Vec::with_capacity(samples.len());
1929    for &s in &samples {
1930        let clamped = s.max(-1.0).min(1.0);
1931        let v = (clamped * 32767.0).round() as i16;
1932        samples_i16.push(v);
1933    }
1934    let frames = (samples_i16.len() as u64) / (channels_u16 as u64);
1935    let duration_ms = if sample_rate_u32 > 0 {
1936        Some(((frames as f64) * 1000.0 / (sample_rate_u32 as f64)).round() as u64)
1937    } else {
1938        None
1939    };
1940
1941    Ok(Some(MovieAudio {
1942        samples: Arc::new(samples_i16),
1943        channels: channels_u16,
1944        sample_rate: sample_rate_u32,
1945        start_ms: 0,
1946        duration_ms,
1947    }))
1948}
1949
1950fn encode_wav_i16_interleaved_offset(track: &MovieAudio, offset_ms: u64) -> Vec<u8> {
1951    let channels = track.channels;
1952    let sample_rate = track.sample_rate;
1953    let samples = track.samples.as_ref();
1954    let frames_offset = ((offset_ms as u64) * (sample_rate as u64) / 1000) as usize;
1955    let start = frames_offset.saturating_mul(channels as usize);
1956    let slice = if start < samples.len() {
1957        &samples[start..]
1958    } else {
1959        &samples[samples.len()..]
1960    };
1961    encode_wav_i16_interleaved(slice, channels, sample_rate)
1962}
1963
1964fn encode_wav_i16_interleaved(samples: &[i16], channels: u16, sample_rate: u32) -> Vec<u8> {
1965    let bytes_per_sample = 2u16;
1966    let block_align = channels.saturating_mul(bytes_per_sample);
1967    let byte_rate = (sample_rate as u64).saturating_mul(block_align as u64) as u32;
1968    let data_bytes = samples.len().saturating_mul(bytes_per_sample as usize) as u32;
1969    let riff_size = 36u32.saturating_add(data_bytes);
1970
1971    let mut out = Vec::with_capacity((data_bytes as usize) + 44);
1972    out.extend_from_slice(b"RIFF");
1973    out.extend_from_slice(&riff_size.to_le_bytes());
1974    out.extend_from_slice(b"WAVE");
1975    out.extend_from_slice(b"fmt ");
1976    out.extend_from_slice(&16u32.to_le_bytes());
1977    out.extend_from_slice(&1u16.to_le_bytes());
1978    out.extend_from_slice(&channels.to_le_bytes());
1979    out.extend_from_slice(&sample_rate.to_le_bytes());
1980    out.extend_from_slice(&byte_rate.to_le_bytes());
1981    out.extend_from_slice(&block_align.to_le_bytes());
1982    out.extend_from_slice(&16u16.to_le_bytes());
1983    out.extend_from_slice(b"data");
1984    out.extend_from_slice(&data_bytes.to_le_bytes());
1985
1986    for &s in samples {
1987        out.extend_from_slice(&s.to_le_bytes());
1988    }
1989    out
1990}
1991
1992fn convert_omv_frame(
1993    data: &[u8],
1994    width: i32,
1995    video_height: i32,
1996    fmt: i32,
1997    display_height: i32,
1998    theora_type: u32,
1999) -> Vec<u8> {
2000    let w = width.max(1) as usize;
2001    let vh = video_height.max(1) as usize;
2002    let dh = display_height.max(1) as usize;
2003
2004    let (uv_w, _uv_h, y_plane_len, u_plane_len, _v_plane_len) =
2005        omv_plane_layout(width, video_height, theora_type, fmt);
2006    let y_off = 0usize;
2007    let u_off = y_off.saturating_add(y_plane_len);
2008    let v_off = u_off.saturating_add(u_plane_len);
2009
2010    let mut rgba = vec![0u8; w.saturating_mul(dh).saturating_mul(4)];
2011
2012    match theora_type {
2013        siglus_assets::omv::OMV_THEORA_TYPE_RGB => {
2014            for y in 0..dh {
2015                for x in 0..w {
2016                    let b = get_plane_sample(data, y_off, w, x, y, 0);
2017                    let g = get_plane_sample(data, u_off, uv_w, x, y, 0);
2018                    let r = get_plane_sample(data, v_off, uv_w, x, y, 0);
2019                    let out = (y * w + x) * 4;
2020                    rgba[out] = r;
2021                    rgba[out + 1] = g;
2022                    rgba[out + 2] = b;
2023                    rgba[out + 3] = 0xff;
2024                }
2025            }
2026        }
2027        siglus_assets::omv::OMV_THEORA_TYPE_RGBA => {
2028            let alpha_h = (dh + 2) / 3;
2029            let alpha_h_2 = alpha_h * 2;
2030            for y in 0..dh {
2031                let (a_off, local_y, a_width) = if y < alpha_h {
2032                    (y_off, y, w)
2033                } else if y < alpha_h_2 {
2034                    (u_off, y - alpha_h, uv_w)
2035                } else {
2036                    (v_off, y - alpha_h_2, uv_w)
2037                };
2038                let alpha_y = dh.saturating_add(local_y);
2039                for x in 0..w {
2040                    let b = get_plane_sample(data, y_off, w, x, y, 0);
2041                    let g = get_plane_sample(data, u_off, uv_w, x, y, 0);
2042                    let r = get_plane_sample(data, v_off, uv_w, x, y, 0);
2043                    let a = get_plane_sample(data, a_off, a_width, x, alpha_y, 0xff);
2044                    let out = (y * w + x) * 4;
2045                    rgba[out] = r;
2046                    rgba[out + 1] = g;
2047                    rgba[out + 2] = b;
2048                    rgba[out + 3] = a;
2049                }
2050            }
2051        }
2052        _ => {
2053            for y in 0..dh {
2054                let y_row = y * w;
2055                let uv_y = match fmt {
2056                    siglus_omv_decoder::TH_PF_420 => y / 2,
2057                    _ => y,
2058                };
2059                for x in 0..w {
2060                    let y_idx = y_row + x;
2061                    let yv = data.get(y_idx).copied().unwrap_or(0) as f32;
2062
2063                    let uv_x = match fmt {
2064                        siglus_omv_decoder::TH_PF_420 | siglus_omv_decoder::TH_PF_422 => x / 2,
2065                        _ => x,
2066                    };
2067                    let u_idx = u_off
2068                        .saturating_add(uv_y.saturating_mul(uv_w))
2069                        .saturating_add(uv_x);
2070                    let v_idx = v_off
2071                        .saturating_add(uv_y.saturating_mul(uv_w))
2072                        .saturating_add(uv_x);
2073                    let u = data.get(u_idx).copied().unwrap_or(128) as f32 - 128.0;
2074                    let v = data.get(v_idx).copied().unwrap_or(128) as f32 - 128.0;
2075
2076                    let r = clamp_f(yv + 1.40200 * v);
2077                    let g = clamp_f(yv - 0.34414 * u - 0.71414 * v);
2078                    let b = clamp_f(yv + 1.77200 * u);
2079
2080                    let out = (y * w + x) * 4;
2081                    rgba[out] = r;
2082                    rgba[out + 1] = g;
2083                    rgba[out + 2] = b;
2084                    rgba[out + 3] = 0xff;
2085                }
2086            }
2087        }
2088    }
2089
2090    rgba
2091}
2092
2093fn get_plane_sample(
2094    data: &[u8],
2095    plane_off: usize,
2096    plane_width: usize,
2097    x: usize,
2098    y: usize,
2099    default: u8,
2100) -> u8 {
2101    if plane_width == 0 {
2102        return default;
2103    }
2104    data.get(
2105        plane_off
2106            .saturating_add(y.saturating_mul(plane_width))
2107            .saturating_add(x),
2108    )
2109    .copied()
2110    .unwrap_or(default)
2111}
2112
2113fn clamp_f(v: f32) -> u8 {
2114    if v <= 0.0 {
2115        0
2116    } else if v >= 255.0 {
2117        255
2118    } else {
2119        v.round() as u8
2120    }
2121}
2122
2123fn yuv_plane_size(width: i32, height: i32, fmt: i32) -> (usize, usize) {
2124    let w = width.max(1) as usize;
2125    let h = height.max(1) as usize;
2126    match fmt {
2127        siglus_omv_decoder::TH_PF_420 => (w / 2, h / 2),
2128        siglus_omv_decoder::TH_PF_422 => (w / 2, h),
2129        siglus_omv_decoder::TH_PF_444 => (w, h),
2130        _ => (w / 2, h / 2),
2131    }
2132}