use crate::error::Result;
use crate::metadata::Chapter;
use crate::mp4::mux::bytes::invalid;
use crate::mp4::mux::entry::{CHAPTER_TEXT_ENTRY, build_entry};
use crate::mp4::mux::metadata::rescale;
use crate::mp4::mux::spec::{PacketIn, SampleEntrySpec, TrackSpec};
use crate::mp4::mux::track::TrackState;
use crate::track::{FourCc, TrackKind};
const MAX_TRACK_TIMESCALE: u32 = 100_000;
const FALLBACK_TIMESCALE: u32 = 1000;
const ENCD: [u8; 12] = [0, 0, 0, 12, b'e', b'n', b'c', b'd', 0, 0, 1, 0];
fn sample(title: &str) -> Result<Vec<u8>> {
let len = u16::try_from(title.len()).map_err(|_| {
invalid(format!(
"a chapter title holds {} bytes, above the 65 535 a text sample holds. Shorten the title",
title.len()
))
})?;
let mut out = Vec::with_capacity(2 + title.len() + ENCD.len());
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(title.as_bytes());
out.extend_from_slice(&ENCD);
Ok(out)
}
pub(crate) fn chapter_track(
id: u32,
chapters: &[Chapter],
movie_timescale: u32,
movie_duration: u64,
chunk_ms: u32,
) -> Result<TrackState> {
let Some(first) = chapters.first() else {
return Err(invalid(
"a chapter track needs one chapter or more".to_owned(),
));
};
let timescale = if first.timescale > MAX_TRACK_TIMESCALE {
FALLBACK_TIMESCALE
} else {
first.timescale
};
let mut spec = TrackSpec::new(
TrackKind::Subtitle,
timescale,
SampleEntrySpec::Raw(CHAPTER_TEXT_ENTRY.to_vec()),
);
spec.handler = FourCc(*b"text");
spec.enabled = false;
let (_, entry) = build_entry(&spec.sample_entry, None, None)?;
let mut track = TrackState::new(id, spec, entry, chunk_ms);
let starts: Vec<u64> = chapters
.iter()
.map(|chapter| rescale(chapter.start, chapter.timescale, timescale))
.collect();
let origin = starts.first().copied().unwrap_or(0);
let movie_end = rescale(movie_duration, movie_timescale, timescale);
let too_late = |value: u64| {
i64::try_from(value)
.map_err(|_| invalid(format!("a chapter start of {value} ticks exceeds 2^63")))
};
for (n, chapter) in chapters.iter().enumerate() {
let start = starts.get(n).copied().unwrap_or(0);
let duration = match starts.get(n + 1) {
Some(&next) => next.saturating_sub(start),
None => chapter
.duration
.map(|d| rescale(d, chapter.timescale, timescale))
.unwrap_or_else(|| movie_end.saturating_sub(start)),
};
let bytes = sample(&chapter.title)?;
track.push(
&PacketIn {
data: &bytes,
pts: too_late(start)?,
dts: too_late(start.saturating_sub(origin))?,
duration: Some(duration),
sync: true,
},
true,
)?;
}
track.finish()?;
Ok(track)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metadata::ChapterSource;
fn chapter(start: u64, title: &str) -> Chapter {
Chapter {
start,
duration: None,
timescale: 1000,
title: title.to_owned(),
source: ChapterSource::Chpl,
}
}
#[test]
fn samples_last_until_the_next_chapter_and_the_movie_end() {
let chapters = [chapter(0, "Opening"), chapter(800, "Closing")];
let track = chapter_track(3, &chapters, 1000, 2000, 500).expect("the track builds");
assert_eq!(track.table.count, 2);
assert_eq!(track.table.media_duration, 2000);
assert_eq!(track.closed.len(), 2);
assert_eq!(&track.closed[0].data[..9], b"\0\x07Opening");
}
#[test]
fn a_late_first_chapter_presents_at_its_start() {
let chapters = [chapter(300, "Late")];
let track = chapter_track(2, &chapters, 1000, 1000, 500).expect("the track builds");
assert_eq!(track.table.offset_range, Some((300, 300)));
assert_eq!(track.table.media_duration, 700);
}
}