Skip to main content

siglus_assets/
omv.rs

1//! OMV (Ogg/Theora wrapper) parser.
2//!
3//! OMV stores a fixed metadata header, Theora page/packet seek tables, and the
4//! raw Ogg bitstream. Native playback keeps the file open and reads Ogg pages
5//! on demand; this parser follows that layout and does not read the whole OMV
6//! merely to inspect its header.
7
8use anyhow::{Context, Result, anyhow, bail};
9use std::fs::File;
10use std::io::{BufReader, Read, Seek, SeekFrom};
11use std::path::Path;
12
13pub const OMV_THEORA_TYPE_RGB: u32 = 0;
14pub const OMV_THEORA_TYPE_RGBA: u32 = 1;
15pub const OMV_THEORA_TYPE_YUV: u32 = 2;
16
17const OMV_HEADER_MIN_SIZE: usize = 0x58;
18const OMV_THEORA_PAGE_SIZE: usize = 0x1C;
19const OMV_THEORA_PACKET_SIZE: usize = 0x20;
20const OGG_SCAN_CHUNK: usize = 64 * 1024;
21
22#[derive(Debug, Clone)]
23pub struct OmvHeader {
24    pub header_size: u32,
25    pub version: u32,
26    pub theora_type: u32,
27    pub display_width: u32,
28    pub display_height: u32,
29    pub frame_time_us: u32,
30    pub max_data_size: u32,
31    pub page_count_hint: u32,
32    pub packet_count_hint: u32,
33}
34
35#[derive(Debug, Clone, Copy)]
36pub struct OmvTheoraPage {
37    pub own_page_no: i32,
38    pub is_eos: bool,
39    pub is_key_page: bool,
40    pub page_size: i32,
41    pub seek_offset: i32,
42    pub seek_page_no: i32,
43    pub packet_count: i32,
44    pub top_packet_no: i32,
45}
46
47#[derive(Debug, Clone, Copy)]
48pub struct OmvTheoraPacket {
49    pub own_packet_no: i32,
50    pub own_page_no: i32,
51    pub own_packet_no_in_page: i32,
52    pub is_key_frame: bool,
53    pub key_frame_packet_no: i32,
54    pub key_frame_page_no: i32,
55    pub frame_time_start: i32,
56    pub frame_time_end: i32,
57}
58
59#[derive(Debug, Clone)]
60pub struct OmvFile {
61    pub header: OmvHeader,
62    pub pages: Vec<OmvTheoraPage>,
63    pub packets: Vec<OmvTheoraPacket>,
64    /// Absolute file offset that the page-table seek offsets are relative to.
65    pub seek_top: u64,
66    pub ogg_data_offset: u64,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct OmvSeekPoint {
71    /// Absolute file offset of the first Ogg page that must be fed to rebuild
72    /// the packet containing the selected key frame.
73    pub file_offset: u64,
74    /// Absolute start of the page whose completed packets are numbered from
75    /// `first_packet_no`. Packets completed on earlier back-pages are discarded.
76    pub key_page_file_offset: u64,
77    pub seek_page_no: usize,
78    /// Data-packet number tona3 assigns after the back-pages have been fed
79    /// and drained, i.e. `top_packet_no` of the key-frame page.  The seek page
80    /// itself may legitimately have `top_packet_no == -1` when it only starts
81    /// a packet that completes on a later page.
82    pub first_packet_no: usize,
83    pub key_frame_page_no: usize,
84    pub key_frame_packet_no: usize,
85    pub target_packet_no: usize,
86}
87
88impl OmvFile {
89    /// Read only the fixed metadata prefix required by [`OmvHeader`].
90    /// This is used by READY/metadata paths so they do not parse seek tables or
91    /// scan the movie payload before playback actually starts.
92    pub fn read_header(path: impl AsRef<Path>) -> Result<OmvHeader> {
93        let path = path.as_ref();
94        let mut file = File::open(path).with_context(|| format!("open OMV: {}", path.display()))?;
95        let mut header_prefix = [0u8; OMV_HEADER_MIN_SIZE];
96        file.read_exact(&mut header_prefix)
97            .with_context(|| format!("read OMV header: {}", path.display()))?;
98        read_header(&header_prefix)
99    }
100
101    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
102        let path = path.as_ref();
103        let mut file = File::open(path).with_context(|| format!("open OMV: {}", path.display()))?;
104        let mut header_prefix = [0u8; OMV_HEADER_MIN_SIZE];
105        file.read_exact(&mut header_prefix)
106            .with_context(|| format!("read OMV header: {}", path.display()))?;
107        let header = read_header(&header_prefix)?;
108        let header_size = usize::try_from(header.header_size)
109            .map_err(|_| anyhow!("OMV header size does not fit usize"))?;
110        if header_size < OMV_HEADER_MIN_SIZE {
111            bail!("invalid OMV header size: {:#x}", header.header_size);
112        }
113
114        file.seek(SeekFrom::Start(header.header_size as u64))
115            .with_context(|| format!("seek OMV tables: {}", path.display()))?;
116
117        let page_count = usize::try_from(header.page_count_hint)
118            .map_err(|_| anyhow!("OMV page count does not fit usize"))?;
119        let packet_count = usize::try_from(header.packet_count_hint)
120            .map_err(|_| anyhow!("OMV packet count does not fit usize"))?;
121        let pages_bytes = page_count
122            .checked_mul(OMV_THEORA_PAGE_SIZE)
123            .ok_or_else(|| anyhow!("OMV page table size overflow"))?;
124        let packets_bytes = packet_count
125            .checked_mul(OMV_THEORA_PACKET_SIZE)
126            .ok_or_else(|| anyhow!("OMV packet table size overflow"))?;
127        let seek_top = (header_size as u64)
128            .checked_add(pages_bytes as u64)
129            .and_then(|offset| offset.checked_add(packets_bytes as u64))
130            .ok_or_else(|| anyhow!("OMV table end offset overflow"))?;
131        let file_len = file
132            .metadata()
133            .with_context(|| format!("stat OMV: {}", path.display()))?
134            .len();
135        if seek_top > file_len {
136            bail!(
137                "OMV seek tables exceed file size: table_end={} file_size={}",
138                seek_top,
139                file_len
140            );
141        }
142
143        let mut page_table = vec![0u8; pages_bytes];
144        if !page_table.is_empty() {
145            file.read_exact(&mut page_table)
146                .with_context(|| format!("read OMV page table: {}", path.display()))?;
147        }
148        let mut packet_table = vec![0u8; packets_bytes];
149        if !packet_table.is_empty() {
150            file.read_exact(&mut packet_table)
151                .with_context(|| format!("read OMV packet table: {}", path.display()))?;
152        }
153
154        let pages = parse_pages(&page_table, page_count)?;
155        let packets = parse_packets(&packet_table, packet_count)?;
156
157        let candidate = pages
158            .first()
159            .and_then(|page| u64::try_from(page.seek_offset).ok())
160            .map(|offset| seek_top.saturating_add(offset));
161        let ogg_data_offset = match candidate {
162            Some(offset) if file_has_ogg_magic(&mut file, offset)? => offset,
163            _ => find_ogg_offset_in_file(&mut file, seek_top)?,
164        };
165
166        Ok(Self {
167            header,
168            pages,
169            packets,
170            seek_top,
171            ogg_data_offset,
172        })
173    }
174
175    /// Resolve the original tona3 key-page seek plan for a video frame.
176    ///
177    /// `seek_page_no` may precede the key-frame page because an Ogg packet can
178    /// continue across page boundaries. The decoder must begin at that page,
179    /// discard packets until the indexed key frame, and then decode forward to
180    /// the requested packet.
181    pub fn seek_point_for_frame(&self, frame_no: usize) -> Result<OmvSeekPoint> {
182        let packet = self
183            .packets
184            .get(frame_no)
185            .ok_or_else(|| anyhow!("OMV frame index out of range: {frame_no}"))?;
186        if packet.own_packet_no != frame_no as i32 {
187            bail!(
188                "OMV packet table row {} identifies itself as {}",
189                frame_no,
190                packet.own_packet_no
191            );
192        }
193        let key_frame_packet_no = usize::try_from(packet.key_frame_packet_no)
194            .map_err(|_| anyhow!("OMV key-frame packet is negative for frame {frame_no}"))?;
195        if key_frame_packet_no > frame_no || key_frame_packet_no >= self.packets.len() {
196            bail!(
197                "invalid OMV key-frame packet {} for frame {}",
198                key_frame_packet_no,
199                frame_no
200            );
201        }
202        let key_packet = &self.packets[key_frame_packet_no];
203        if !key_packet.is_key_frame {
204            bail!(
205                "OMV indexed key packet {} is not marked as a key frame",
206                key_frame_packet_no
207            );
208        }
209        let key_frame_page_no = usize::try_from(packet.key_frame_page_no)
210            .map_err(|_| anyhow!("OMV key-frame page is negative for frame {frame_no}"))?;
211        if key_packet.own_page_no != packet.key_frame_page_no {
212            bail!(
213                "OMV key packet {} belongs to page {}, not indexed page {}",
214                key_frame_packet_no,
215                key_packet.own_page_no,
216                packet.key_frame_page_no
217            );
218        }
219        let key_page = self.pages.get(key_frame_page_no).ok_or_else(|| {
220            anyhow!(
221                "OMV key-frame page {} is out of range for frame {}",
222                key_frame_page_no,
223                frame_no
224            )
225        })?;
226        if key_page.own_page_no != packet.key_frame_page_no {
227            bail!(
228                "OMV page table row {} identifies itself as {}",
229                key_frame_page_no,
230                key_page.own_page_no
231            );
232        }
233        let seek_page_no = usize::try_from(key_page.seek_page_no).map_err(|_| {
234            anyhow!(
235                "OMV seek page is negative for key-frame page {}",
236                key_frame_page_no
237            )
238        })?;
239        if seek_page_no > key_frame_page_no {
240            bail!(
241                "OMV seek page {} is after key-frame page {}",
242                seek_page_no,
243                key_frame_page_no
244            );
245        }
246        let seek_page = self.pages.get(seek_page_no).ok_or_else(|| {
247            anyhow!(
248                "OMV seek page {} is out of range for frame {}",
249                seek_page_no,
250                frame_no
251            )
252        })?;
253        if seek_page.own_page_no != seek_page_no as i32 {
254            bail!(
255                "OMV page table row {} identifies itself as {}",
256                seek_page_no,
257                seek_page.own_page_no
258            );
259        }
260        // Match tona3 exactly: `seek_page_no` is only the first Ogg page that
261        // must be fed back into the stream so a packet spanning page borders
262        // can be reconstructed.  tona3 drains those back-pages, then starts
263        // `decode_packet_no` from *the key-frame page's* `top_packet_no`.
264        //
265        // Therefore `seek_page.top_packet_no == -1` is valid (and common when
266        // that page contains only the beginning of a continued packet).  The
267        // old implementation incorrectly treated it as a corrupt index and
268        // fell back to reopening/decoding the Ogg stream from frame zero.
269        let first_packet_no = usize::try_from(key_page.top_packet_no).map_err(|_| {
270            anyhow!(
271                "OMV first packet is negative for key-frame page {}",
272                key_frame_page_no
273            )
274        })?;
275        if first_packet_no > key_frame_packet_no {
276            bail!(
277                "OMV key-frame page {} starts at packet {}, after key packet {}",
278                key_frame_page_no,
279                first_packet_no,
280                key_frame_packet_no
281            );
282        }
283        let seek_offset = u64::try_from(seek_page.seek_offset)
284            .map_err(|_| anyhow!("OMV seek offset is negative for page {}", seek_page_no))?;
285        let file_offset = self
286            .seek_top
287            .checked_add(seek_offset)
288            .ok_or_else(|| anyhow!("OMV seek offset overflow for page {seek_page_no}"))?;
289        let key_page_offset = u64::try_from(key_page.seek_offset)
290            .map_err(|_| anyhow!("OMV seek offset is negative for page {key_frame_page_no}"))?;
291        let key_page_file_offset = self
292            .seek_top
293            .checked_add(key_page_offset)
294            .ok_or_else(|| anyhow!("OMV key-page seek offset overflow"))?;
295        if key_page_file_offset < file_offset {
296            bail!("OMV key-frame page precedes its seek page in the file");
297        }
298
299        Ok(OmvSeekPoint {
300            file_offset,
301            key_page_file_offset,
302            seek_page_no,
303            first_packet_no,
304            key_frame_page_no,
305            key_frame_packet_no,
306            target_packet_no: frame_no,
307        })
308    }
309
310    /// Open a buffered reader positioned at the embedded Ogg bitstream.
311    pub fn open_embedded_ogg_reader(&self, path: impl AsRef<Path>) -> Result<BufReader<File>> {
312        let path = path.as_ref();
313        let mut file = File::open(path).with_context(|| format!("open OMV: {}", path.display()))?;
314        file.seek(SeekFrom::Start(self.ogg_data_offset))
315            .with_context(|| format!("seek embedded Ogg: {}", path.display()))?;
316        Ok(BufReader::new(file))
317    }
318
319    /// Read the embedded Ogg bitstream as bytes for legacy/full-decode callers.
320    /// Header inspection and streaming playback should use [`Self::open`] and
321    /// [`Self::open_embedded_ogg_reader`] instead.
322    pub fn read_embedded_ogg(path: impl AsRef<Path>) -> Result<Vec<u8>> {
323        let path = path.as_ref();
324        let omv = Self::open(path)?;
325        let mut reader = omv.open_embedded_ogg_reader(path)?;
326        let mut out = Vec::new();
327        reader
328            .read_to_end(&mut out)
329            .with_context(|| format!("read embedded Ogg: {}", path.display()))?;
330        Ok(out)
331    }
332}
333
334fn read_header(buf: &[u8]) -> Result<OmvHeader> {
335    if buf.len() < OMV_HEADER_MIN_SIZE {
336        bail!("OMV header too small");
337    }
338
339    let header_size = read_u32(buf, 0x00)?;
340    let version = read_u32(buf, 0x04)?;
341    let theora_type = read_u32(buf, 0x28)?;
342    let display_width = read_u32(buf, 0x2c)?;
343    let display_height = read_u32(buf, 0x30)?;
344    let frame_time_us = read_u32(buf, 0x3c)?;
345    // This field is the Theora serial number in the original v1.1 header. Keep
346    // the historical public name for API compatibility.
347    let max_data_size = read_u32(buf, 0x40)?;
348    let page_count_hint = read_u32(buf, 0x4c)?;
349    let packet_count_hint = read_u32(buf, 0x50)?;
350
351    if header_size < OMV_HEADER_MIN_SIZE as u32 {
352        bail!("invalid OMV header size: {header_size:#x}");
353    }
354    if theora_type > OMV_THEORA_TYPE_YUV {
355        bail!("invalid OMV theora type: {theora_type}");
356    }
357    if display_width == 0 || display_height == 0 {
358        bail!(
359            "invalid OMV display size: {}x{}",
360            display_width,
361            display_height
362        );
363    }
364
365    Ok(OmvHeader {
366        header_size,
367        version,
368        theora_type,
369        display_width,
370        display_height,
371        frame_time_us,
372        max_data_size,
373        page_count_hint,
374        packet_count_hint,
375    })
376}
377
378fn parse_pages(buf: &[u8], count: usize) -> Result<Vec<OmvTheoraPage>> {
379    let mut out = Vec::with_capacity(count);
380    for i in 0..count {
381        let base = i * OMV_THEORA_PAGE_SIZE;
382        let row = buf
383            .get(base..base + OMV_THEORA_PAGE_SIZE)
384            .ok_or_else(|| anyhow!("truncated OMV page table at row {i}"))?;
385        out.push(OmvTheoraPage {
386            own_page_no: read_i32(row, 0x00)?,
387            is_eos: row[0x04] != 0,
388            is_key_page: row[0x05] != 0,
389            page_size: read_i32(row, 0x08)?,
390            seek_offset: read_i32(row, 0x0c)?,
391            seek_page_no: read_i32(row, 0x10)?,
392            packet_count: read_i32(row, 0x14)?,
393            top_packet_no: read_i32(row, 0x18)?,
394        });
395    }
396    Ok(out)
397}
398
399fn parse_packets(buf: &[u8], count: usize) -> Result<Vec<OmvTheoraPacket>> {
400    let mut out = Vec::with_capacity(count);
401    for i in 0..count {
402        let base = i * OMV_THEORA_PACKET_SIZE;
403        let row = buf
404            .get(base..base + OMV_THEORA_PACKET_SIZE)
405            .ok_or_else(|| anyhow!("truncated OMV packet table at row {i}"))?;
406        out.push(OmvTheoraPacket {
407            own_packet_no: read_i32(row, 0x00)?,
408            own_page_no: read_i32(row, 0x04)?,
409            own_packet_no_in_page: read_i32(row, 0x08)?,
410            is_key_frame: row[0x0c] != 0,
411            key_frame_packet_no: read_i32(row, 0x10)?,
412            key_frame_page_no: read_i32(row, 0x14)?,
413            frame_time_start: read_i32(row, 0x18)?,
414            frame_time_end: read_i32(row, 0x1c)?,
415        });
416    }
417    Ok(out)
418}
419
420fn read_u32(buf: &[u8], offset: usize) -> Result<u32> {
421    let bytes: [u8; 4] = buf
422        .get(offset..offset + 4)
423        .ok_or_else(|| anyhow!("OMV field at {offset:#x} is truncated"))?
424        .try_into()
425        .map_err(|_| anyhow!("OMV field at {offset:#x} has invalid width"))?;
426    Ok(u32::from_le_bytes(bytes))
427}
428
429fn read_i32(buf: &[u8], offset: usize) -> Result<i32> {
430    Ok(read_u32(buf, offset)? as i32)
431}
432
433fn file_has_ogg_magic(file: &mut File, offset: u64) -> Result<bool> {
434    file.seek(SeekFrom::Start(offset))?;
435    let mut magic = [0u8; 4];
436    match file.read_exact(&mut magic) {
437        Ok(()) => Ok(&magic == b"OggS"),
438        Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
439        Err(err) => Err(err.into()),
440    }
441}
442
443fn find_ogg_offset_in_file(file: &mut File, start: u64) -> Result<u64> {
444    file.seek(SeekFrom::Start(start))?;
445    let mut absolute = start;
446    let mut carry = Vec::<u8>::new();
447    let mut chunk = vec![0u8; OGG_SCAN_CHUNK];
448
449    loop {
450        let n = file.read(&mut chunk)?;
451        if n == 0 {
452            break;
453        }
454        let mut window = Vec::with_capacity(carry.len() + n);
455        window.extend_from_slice(&carry);
456        window.extend_from_slice(&chunk[..n]);
457        if let Some(pos) = window.windows(4).position(|bytes| bytes == b"OggS") {
458            return Ok(absolute
459                .saturating_sub(carry.len() as u64)
460                .saturating_add(pos as u64));
461        }
462        carry.clear();
463        let keep = window.len().min(3);
464        carry.extend_from_slice(&window[window.len() - keep..]);
465        absolute = absolute.saturating_add(n as u64);
466    }
467
468    bail!("OggS not found in OMV payload")
469}
470
471#[cfg(test)]
472mod seek_index_tests {
473    use super::{OMV_THEORA_TYPE_RGB, OmvFile, OmvHeader, OmvTheoraPacket, OmvTheoraPage};
474
475    #[test]
476    fn seek_plan_uses_back_page_and_packet_table() {
477        let pages = vec![
478            OmvTheoraPage {
479                own_page_no: 0,
480                is_eos: false,
481                is_key_page: true,
482                page_size: 100,
483                seek_offset: 0,
484                seek_page_no: 0,
485                packet_count: 4,
486                top_packet_no: 0,
487            },
488            OmvTheoraPage {
489                own_page_no: 1,
490                is_eos: false,
491                is_key_page: false,
492                page_size: 100,
493                seek_offset: 100,
494                seek_page_no: 0,
495                packet_count: 0,
496                // Legal tona3 index state: this back-page starts a packet but
497                // does not complete one, so there is no packet number to emit
498                // until the key-frame page is fed.
499                top_packet_no: -1,
500            },
501            OmvTheoraPage {
502                own_page_no: 2,
503                is_eos: false,
504                is_key_page: true,
505                page_size: 100,
506                seek_offset: 200,
507                seek_page_no: 1,
508                packet_count: 2,
509                top_packet_no: 4,
510            },
511        ];
512        let packets = (0..6)
513            .map(|packet_no| OmvTheoraPacket {
514                own_packet_no: packet_no,
515                own_page_no: packet_no / 2,
516                own_packet_no_in_page: packet_no % 2,
517                is_key_frame: packet_no == 0 || packet_no == 4,
518                key_frame_packet_no: if packet_no < 4 { 0 } else { 4 },
519                key_frame_page_no: if packet_no < 4 { 0 } else { 2 },
520                frame_time_start: packet_no * 33,
521                frame_time_end: (packet_no + 1) * 33,
522            })
523            .collect();
524        let omv = OmvFile {
525            header: OmvHeader {
526                header_size: 0xb4,
527                version: 0x0001_0001,
528                theora_type: OMV_THEORA_TYPE_RGB,
529                display_width: 640,
530                display_height: 480,
531                frame_time_us: 33_333,
532                max_data_size: 0,
533                page_count_hint: 3,
534                packet_count_hint: 6,
535            },
536            pages,
537            packets,
538            seek_top: 1_000,
539            ogg_data_offset: 1_000,
540        };
541
542        let point = omv.seek_point_for_frame(5).expect("seek point");
543        assert_eq!(point.seek_page_no, 1);
544        assert_eq!(point.first_packet_no, 4);
545        assert_eq!(point.key_frame_page_no, 2);
546        assert_eq!(point.key_frame_packet_no, 4);
547        assert_eq!(point.target_packet_no, 5);
548        assert_eq!(point.file_offset, 1_100);
549        assert_eq!(point.key_page_file_offset, 1_200);
550    }
551}