Skip to main content

mp4_atom/moov/udta/
chpl.rs

1use crate::*;
2
3/// One chapter entry inside a [`Chpl`].
4#[derive(Debug, Clone, PartialEq, Eq)]
5#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
6pub struct ChplEntry {
7    /// Start time, in 100ns units, relative to the start of the presentation.
8    pub start: u64,
9    pub title: String,
10}
11
12/// Nero-style Chapter List Box (`chpl`).
13///
14/// Not part of the ISO/QuickTime spec, but a de facto standard written and read
15/// by e.g. Nero, `ffmpeg`'s `mov` muxer/demuxer, and MP4Box, as an alternative
16/// to the QuickTime `chap` track reference + text track convention.
17///
18/// Version 0 and version 1 only differ in an extra 4-byte reserved field
19/// present after the FullBox header in version 1 (absent in version 0); it
20/// carries no known meaning in either case, so it's not exposed here. This
21/// always encodes as version 1, matching the common convention used by
22/// `ffmpeg`'s `mov` muxer and MP4Box.
23#[derive(Debug, Clone, PartialEq, Eq, Default)]
24#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
25pub struct Chpl {
26    pub entries: Vec<ChplEntry>,
27}
28
29impl Atom for Chpl {
30    const KIND: FourCC = FourCC::new(b"chpl");
31
32    fn decode_body<B: Buf>(buf: &mut B) -> Result<Self> {
33        let version = u8::decode(buf)?;
34        <[u8; 3]>::decode(buf)?; // flags
35        if version != 0 {
36            <[u8; 4]>::decode(buf)?; // reserved, version 1 only
37        }
38
39        let count = u8::decode(buf)?;
40        let mut entries = Vec::with_capacity(count as usize);
41        for _ in 0..count {
42            let start = u64::decode(buf)?;
43
44            let title_len = u8::decode(buf)? as usize;
45            if title_len > buf.remaining() {
46                return Err(Error::OutOfBounds);
47            }
48            let title_bytes = buf.slice(title_len).to_vec();
49            buf.advance(title_len);
50            let title = String::from_utf8(title_bytes)
51                .map_err(|err| Error::InvalidString(err.to_string()))?;
52
53            entries.push(ChplEntry { start, title });
54        }
55
56        Ok(Self { entries })
57    }
58
59    fn encode_body<B: BufMut>(&self, buf: &mut B) -> Result<()> {
60        1u8.encode(buf)?; // version
61        [0u8; 3].encode(buf)?; // flags
62        [0u8; 4].encode(buf)?; // reserved
63
64        let count: u8 = self
65            .entries
66            .len()
67            .try_into()
68            .map_err(|_| Error::TooLarge(Self::KIND))?;
69        count.encode(buf)?;
70
71        for entry in &self.entries {
72            entry.start.encode(buf)?;
73
74            let title_bytes = entry.title.as_bytes();
75            let title_len: u8 = title_bytes
76                .len()
77                .try_into()
78                .map_err(|_| Error::TooLarge(Self::KIND))?;
79            title_len.encode(buf)?;
80            buf.append_slice(title_bytes);
81        }
82
83        Ok(())
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn test_chpl_roundtrip() {
93        let expected = Chpl {
94            entries: vec![
95                ChplEntry {
96                    start: 0,
97                    title: "Intro".into(),
98                },
99                ChplEntry {
100                    start: 30_000_000,
101                    title: "Chapter 2".into(),
102                },
103            ],
104        };
105
106        let mut buf = Vec::new();
107        expected.encode(&mut buf).unwrap();
108
109        let decoded = Chpl::decode(&mut buf.as_slice()).expect("failed to decode chpl");
110        assert_eq!(decoded, expected);
111    }
112
113    #[test]
114    fn test_chpl_empty() {
115        let expected = Chpl { entries: vec![] };
116
117        let mut buf = Vec::new();
118        expected.encode(&mut buf).unwrap();
119
120        let decoded = Chpl::decode(&mut buf.as_slice()).expect("failed to decode chpl");
121        assert_eq!(decoded, expected);
122    }
123
124    #[test]
125    fn test_chpl_version0_no_reserved_field() {
126        // Per ffmpeg's mov.c (mov_read_chpl): version 0 omits the 4-byte
127        // reserved field that version 1 has after the FullBox header.
128        let expected = Chpl {
129            entries: vec![
130                ChplEntry {
131                    start: 0,
132                    title: "Intro".into(),
133                },
134                ChplEntry {
135                    start: 30_000_000,
136                    title: "Chapter 2".into(),
137                },
138            ],
139        };
140
141        let mut buf = Vec::new();
142        0u8.encode(&mut buf).unwrap(); // version 0
143        [0u8; 3].encode(&mut buf).unwrap(); // flags
144        let count: u8 = expected.entries.len().try_into().unwrap();
145        count.encode(&mut buf).unwrap();
146        for entry in &expected.entries {
147            entry.start.encode(&mut buf).unwrap();
148            let title_bytes = entry.title.as_bytes();
149            let title_len: u8 = title_bytes.len().try_into().unwrap();
150            title_len.encode(&mut buf).unwrap();
151            buf.extend_from_slice(title_bytes);
152        }
153
154        let decoded = Chpl::decode_body(&mut buf.as_slice()).expect("failed to decode chpl");
155        assert_eq!(decoded, expected);
156    }
157}