Skip to main content

mp4_edit/
chapter_track_builder.rs

1use std::time::Duration;
2
3use bon::bon;
4
5use crate::atom::{
6    container::{DINF, EDTS, MDIA, MINF, STBL, TRAK},
7    dref::DataReferenceEntry,
8    elst::{EditEntry, ELST},
9    gmin::GMIN,
10    hdlr::{HandlerReferenceAtom, HandlerType},
11    mdhd::{LanguageCode, MediaHeaderAtom},
12    stco_co64::ChunkOffsetAtom,
13    stsc::{SampleToChunkAtom, SampleToChunkEntry},
14    stsd::{
15        SampleDescriptionTableAtom, SampleEntry, SampleEntryData, SampleEntryType, TextSampleEntry,
16    },
17    stsz::SampleSizeAtom,
18    stts::{TimeToSampleAtom, TimeToSampleEntry},
19    text::TEXT,
20    tkhd::TrackHeaderAtom,
21    util::{mp4_timestamp_now, scaled_duration},
22    Atom, AtomHeader, BaseMediaInfoAtom, DataReferenceAtom, EditListAtom, TextMediaInfoAtom, GMHD,
23};
24use crate::writer::SerializeAtom;
25
26/// Represents a chapter from input JSON (milliseconds-based)
27#[derive(Debug, Clone)]
28pub struct InputChapter {
29    pub title: String,
30    pub offset_ms: u64,
31    pub duration_ms: u64,
32}
33
34#[bon]
35impl InputChapter {
36    #[builder]
37    pub fn new(
38        #[builder(into, finish_fn)] title: String,
39        start_offset: Duration,
40        duration: Duration,
41    ) -> Self {
42        Self {
43            title,
44            offset_ms: start_offset.as_millis() as u64,
45            duration_ms: duration.as_millis() as u64,
46        }
47    }
48}
49
50/// Represents a chapter track that can generate TRAK atoms and sample data
51pub struct ChapterTrack {
52    language: LanguageCode,
53    timescale: u32,
54    track_id: u32,
55    creation_time: u64,
56    modification_time: u64,
57    sample_data: Vec<Vec<u8>>,
58    sample_durations: Vec<u32>,
59    sample_sizes: Vec<u32>, // Individual sample sizes
60    media_duration: u64,
61    movie_duration: u64,
62    handler_name: String,
63}
64
65#[bon]
66impl ChapterTrack {
67    #[builder]
68    pub fn new(
69        #[builder(finish_fn, into)] chapters: Vec<InputChapter>,
70        #[builder(default = LanguageCode::Undetermined)] language: LanguageCode,
71        #[builder(default = 44100)] timescale: u32,
72        #[builder(default = 600)] movie_timescale: u32,
73        #[builder(default = 2)] track_id: u32,
74        #[builder(into)] total_duration: Duration,
75        #[builder(default = mp4_timestamp_now())] creation_time: u64,
76        #[builder(default = mp4_timestamp_now())] modification_time: u64,
77        #[builder(default = "Apple Text Media Handler".to_string(), into)] handler_name: String,
78    ) -> Self {
79        let (sample_data, sample_durations, sample_sizes) =
80            Self::create_samples_durations_and_sizes(&chapters, total_duration, timescale);
81
82        Self {
83            language,
84            timescale,
85            track_id,
86            creation_time,
87            modification_time,
88            sample_data,
89            sample_durations,
90            sample_sizes,
91            // Calculate duration in media timescale (for MDHD and STTS)
92            media_duration: scaled_duration(total_duration, u64::from(timescale)),
93            // Calculate duration in movie timescale (for TKHD)
94            movie_duration: scaled_duration(total_duration, u64::from(movie_timescale)),
95            handler_name,
96        }
97    }
98
99    /// Create chapter marker samples with variable sizes based on content
100    /// This creates a contiguous timeline by extending chapters to fill gaps
101    fn create_samples_durations_and_sizes(
102        chapters: &[InputChapter],
103        total_duration: Duration,
104        timescale: u32,
105    ) -> (Vec<Vec<u8>>, Vec<u32>, Vec<u32>) {
106        if chapters.is_empty() {
107            return (vec![], vec![], vec![]);
108        }
109
110        let total_duration_ms = total_duration.as_millis() as u64;
111        let mut samples = Vec::new();
112        let mut durations = Vec::new();
113        let mut sizes = Vec::new();
114
115        for (i, chapter) in chapters.iter().enumerate() {
116            // Create chapter marker data with variable size based on content
117            let chapter_marker = Self::create_variable_chapter_marker(&chapter.title);
118            let sample_size = chapter_marker.len() as u32;
119
120            samples.push(chapter_marker);
121            sizes.push(sample_size);
122
123            // Calculate the end time for this chapter
124            let chapter_end_ms = if i + 1 < chapters.len() {
125                // Extend this chapter until the next chapter starts
126                chapters[i + 1].offset_ms
127            } else {
128                // Last chapter extends to the end of the media
129                total_duration_ms
130            };
131
132            // Calculate the actual duration this chapter should span
133            let actual_duration_ms = chapter_end_ms - chapter.offset_ms;
134            let chapter_duration_seconds = actual_duration_ms as f64 / 1000.0;
135            let chapter_duration_scaled = (chapter_duration_seconds * f64::from(timescale)) as u32;
136
137            durations.push(chapter_duration_scaled);
138        }
139
140        (samples, durations, sizes)
141    }
142
143    /// Create variable-size chapter marker data based on actual text content
144    fn create_variable_chapter_marker(title: &str) -> Vec<u8> {
145        // QuickTime text sample format:
146        // - 2 bytes: text length (big-endian)
147        // - N bytes: UTF-8 text
148        // - Optional padding for compatibility
149
150        let title_bytes = title.as_bytes();
151        let text_len = title_bytes.len() as u16;
152
153        // Calculate total size: text length field + text content
154        let total_size = 2 + title_bytes.len();
155        let mut data = Vec::with_capacity(total_size);
156
157        // Write text length (big-endian)
158        data.extend_from_slice(&text_len.to_be_bytes());
159
160        // Write text data
161        data.extend_from_slice(title_bytes);
162
163        data
164    }
165
166    /// Returns all sample data concatenated for writing to mdat
167    pub fn sample_bytes(&self) -> Vec<u8> {
168        let mut result = Vec::new();
169        for sample in &self.sample_data {
170            result.extend_from_slice(sample);
171        }
172        result
173    }
174
175    /// Returns the individual sample data
176    pub fn individual_samples(&self) -> &[Vec<u8>] {
177        &self.sample_data
178    }
179
180    /// Returns the individual sample sizes
181    pub fn sample_sizes(&self) -> &[u32] {
182        &self.sample_sizes
183    }
184
185    /// Returns the total size of all samples combined
186    pub fn total_sample_size(&self) -> usize {
187        self.sample_sizes.iter().map(|&s| s as usize).sum()
188    }
189
190    /// Returns the number of chapter samples
191    pub fn sample_count(&self) -> u32 {
192        self.sample_data.len() as u32
193    }
194
195    /// Check if all samples have the same size (for STSZ optimization)
196    pub fn has_uniform_sample_size(&self) -> bool {
197        if self.sample_sizes.is_empty() {
198            return true;
199        }
200        let first_size = self.sample_sizes[0];
201        self.sample_sizes.iter().all(|&size| size == first_size)
202    }
203
204    /// Get the uniform sample size if all samples are the same size
205    pub fn uniform_sample_size(&self) -> Option<u32> {
206        if self.has_uniform_sample_size() {
207            self.sample_sizes.first().copied()
208        } else {
209            None
210        }
211    }
212
213    /// Creates a TRAK atom for the chapter track at the specified chunk offset
214    pub fn create_trak_atom(&self, chunk_offset: u64) -> Atom {
215        let track_header = self.create_track_header();
216        let edit_list = self.create_edit_list_atom();
217        let media_atom = self.create_media_atom(chunk_offset);
218
219        Atom::builder()
220            .header(AtomHeader::new(*TRAK))
221            .children(vec![track_header, edit_list, media_atom])
222            .build()
223    }
224
225    fn create_track_header(&self) -> Atom {
226        let tkhd = TrackHeaderAtom {
227            version: 0,
228            flags: [0, 0, 2], // Track enabled, not in movie - standard for chapter tracks
229            creation_time: self.creation_time,
230            modification_time: self.modification_time,
231            track_id: self.track_id,
232            duration: self.movie_duration,
233            layer: 0,
234            alternate_group: 0,
235            volume: 0.0,  // Text track has no volume
236            matrix: None, // Use default identity matrix
237            width: 0.0,   // Text track dimensions
238            height: 0.0,
239        };
240
241        Atom::builder()
242            .header(AtomHeader::new(tkhd.atom_type()))
243            .data(tkhd)
244            .build()
245    }
246
247    fn create_edit_list_atom(&self) -> Atom {
248        Atom::builder()
249            .header(AtomHeader::new(*EDTS))
250            .children(vec![Atom::builder()
251                .header(AtomHeader::new(*ELST))
252                .data(EditListAtom::new(vec![EditEntry {
253                    segment_duration: self.movie_duration,
254                    media_time: 0,
255                    media_rate: 1.0,
256                }]))
257                .build()])
258            .build()
259    }
260
261    fn create_media_atom(&self, chunk_offset: u64) -> Atom {
262        let mdhd = self.create_media_header();
263        let hdlr = self.create_handler_reference();
264        let minf = self.create_media_information(chunk_offset);
265
266        Atom::builder()
267            .header(AtomHeader::new(*MDIA))
268            .children(vec![mdhd, hdlr, minf])
269            .build()
270    }
271
272    fn create_media_header(&self) -> Atom {
273        let mdhd = MediaHeaderAtom::builder()
274            .creation_time(self.creation_time)
275            .modification_time(self.modification_time)
276            .timescale(self.timescale)
277            .duration(self.media_duration)
278            .language(self.language)
279            .build();
280
281        Atom::builder()
282            .header(AtomHeader::new(mdhd.atom_type()))
283            .data(mdhd)
284            .build()
285    }
286
287    fn create_handler_reference(&self) -> Atom {
288        let hdlr = HandlerReferenceAtom::builder()
289            .handler_type(HandlerType::Text)
290            .name(&self.handler_name)
291            .build();
292
293        Atom::builder()
294            .header(AtomHeader::new(hdlr.atom_type()))
295            .data(hdlr)
296            .build()
297    }
298
299    fn create_media_information(&self, chunk_offset: u64) -> Atom {
300        let stbl = self.create_sample_table(chunk_offset);
301
302        // Create DINF (Data Information) with proper data reference
303        let dref = DataReferenceAtom::builder()
304            .entry(
305                DataReferenceEntry::builder()
306                    .url(String::new())
307                    .flags(
308                        [0, 0, 1], // Self-contained flag
309                    )
310                    .build(),
311            )
312            .build();
313
314        let dinf = Atom::builder()
315            .header(AtomHeader::new(*DINF))
316            .children(vec![Atom::builder()
317                .header(AtomHeader::new(dref.atom_type()))
318                .data(dref)
319                .build()])
320            .build();
321
322        Atom::builder()
323            .header(AtomHeader::new(*MINF))
324            .children(vec![
325                Atom::builder()
326                    .header(AtomHeader::new(*GMHD))
327                    .children(vec![
328                        Atom::builder()
329                            .header(AtomHeader::new(*GMIN))
330                            .data(BaseMediaInfoAtom::default())
331                            .build(),
332                        Atom::builder()
333                            .header(AtomHeader::new(*TEXT))
334                            .data(TextMediaInfoAtom::default())
335                            .build(),
336                    ])
337                    .build(),
338                dinf,
339                stbl,
340            ])
341            .build()
342    }
343
344    fn create_sample_table(&self, chunk_offset: u64) -> Atom {
345        let stsd = self.create_sample_description();
346        let stts = self.create_time_to_sample();
347        let stsc = self.create_sample_to_chunk();
348        let stsz = self.create_sample_size();
349        let stco = self.create_chunk_offset(chunk_offset);
350
351        Atom::builder()
352            .header(AtomHeader::new(*STBL))
353            .children(vec![stsd, stts, stsc, stsz, stco])
354            .build()
355    }
356
357    fn create_sample_description(&self) -> Atom {
358        // Text sample entry with configurable parameters
359        let text_sample_entry = SampleEntry {
360            entry_type: SampleEntryType::Text,
361            data_reference_index: 1,
362            data: SampleEntryData::Text(TextSampleEntry::builder().font_name("Sarif").build()),
363        };
364
365        let stsd = SampleDescriptionTableAtom::from(vec![text_sample_entry]);
366
367        Atom::builder()
368            .header(AtomHeader::new(stsd.atom_type()))
369            .data(stsd)
370            .build()
371    }
372
373    fn create_time_to_sample(&self) -> Atom {
374        // Create individual entries for each sample duration
375        // This is essential for proper chapter timing recognition in audiobook players
376        let entries: Vec<TimeToSampleEntry> = self
377            .sample_durations
378            .iter()
379            .map(|&duration| TimeToSampleEntry {
380                sample_count: 1, // Each entry represents exactly 1 sample
381                sample_duration: duration,
382            })
383            .collect();
384
385        let stts = TimeToSampleAtom::from(entries);
386
387        Atom::builder()
388            .header(AtomHeader::new(stts.atom_type()))
389            .data(stts)
390            .build()
391    }
392
393    fn create_sample_to_chunk(&self) -> Atom {
394        // All samples in a single chunk
395        let stsc = SampleToChunkAtom::from(vec![SampleToChunkEntry {
396            first_chunk: 1,
397            samples_per_chunk: self.sample_count(),
398            sample_description_index: 1,
399        }]);
400
401        Atom::builder()
402            .header(AtomHeader::new(stsc.atom_type()))
403            .data(stsc)
404            .build()
405    }
406
407    fn create_sample_size(&self) -> Atom {
408        let stsz = if let Some(uniform_size) = self.uniform_sample_size() {
409            SampleSizeAtom::builder()
410                .sample_size(uniform_size)
411                .sample_count(self.sample_count())
412                .build()
413        } else {
414            SampleSizeAtom::builder()
415                .entry_sizes(self.sample_sizes.clone())
416                .build()
417        };
418
419        Atom::builder()
420            .header(AtomHeader::new(stsz.atom_type()))
421            .data(stsz)
422            .build()
423    }
424
425    fn create_chunk_offset(&self, chunk_offset: u64) -> Atom {
426        // Single chunk containing all chapter marker samples
427        let stco = ChunkOffsetAtom::builder()
428            .chunk_offset(chunk_offset)
429            .build();
430
431        Atom::builder()
432            .header(AtomHeader::new(stco.atom_type()))
433            .data(stco)
434            .build()
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    #[test]
442    fn test_chapter_track_builder() {
443        let chapters = vec![
444            InputChapter {
445                title: "Opening Credits".to_string(),
446                offset_ms: 0,
447                duration_ms: 19758,
448            },
449            InputChapter {
450                title: "Dedication".to_string(),
451                offset_ms: 19758,
452                duration_ms: 4510,
453            },
454            InputChapter {
455                title: "Epigraph".to_string(),
456                offset_ms: 24268,
457                duration_ms: 12364,
458            },
459        ];
460
461        // Calculate total duration from the last chapter
462        let last_chapter = chapters.last().unwrap();
463        let total_duration =
464            Duration::from_millis(last_chapter.offset_ms + last_chapter.duration_ms);
465
466        let track = ChapterTrack::builder()
467            .track_id(2)
468            .timescale(44100) // Standard audio timescale
469            .movie_timescale(600) // Standard movie timescale
470            .total_duration(total_duration)
471            .language(LanguageCode::Undetermined)
472            .build(chapters);
473
474        // Verify we have the correct number of samples
475        assert_eq!(track.sample_count(), 3);
476
477        // Verify sample sizes are based on chapter title length
478        let samples = track.individual_samples();
479        assert_eq!(samples.len(), 3);
480
481        // Expected sizes: 2 bytes (length) + title length
482        // "Opening Credits" (15 chars) = 2 + 15 = 17 bytes
483        // "Dedication" (10 chars) = 2 + 10 = 12 bytes
484        // "Epigraph" (8 chars) = 2 + 8 = 10 bytes
485        assert_eq!(samples[0].len(), 17);
486        assert_eq!(samples[1].len(), 12);
487        assert_eq!(samples[2].len(), 10);
488
489        // Verify we have individual duration entries for each chapter
490        assert_eq!(track.sample_durations.len(), 3);
491
492        println!(
493            "Generated {} chapter samples with durations: {:?}",
494            track.sample_count(),
495            track.sample_durations
496        );
497    }
498}