Skip to main content

motion_content/
captions.rs

1//! Shared narration markers and measured, safe-area subtitle production.
2
3use motion_core::ValidationError;
4use std::{collections::BTreeMap, time::Duration};
5
6mod layout;
7mod track;
8
9pub use track::{CaptionCue, CaptionTrack};
10
11pub use layout::{CaptionLayout, CaptionStyle};
12
13/// One composition clock for narration, captions and named animation markers.
14/// This plans media; it never synthesizes speech or reads a playback clock.
15#[derive(Clone, Debug)]
16pub struct CueSheet {
17    duration: Duration,
18    captions: CaptionTrack,
19    markers: BTreeMap<String, Duration>,
20}
21
22impl CueSheet {
23    /// Validates a positive duration of at most 24 hours and contained captions.
24    pub fn new(duration: Duration, captions: CaptionTrack) -> Result<Self, ValidationError> {
25        if duration.is_zero()
26            || duration > Duration::from_secs(86400)
27            || captions.cues().iter().any(|cue| cue.end() > duration)
28        {
29            return Err(invalid(
30                "Cue sheet requires 0 < duration <= 24h and contained captions",
31            ));
32        }
33        Ok(Self {
34            duration,
35            captions,
36            markers: BTreeMap::new(),
37        })
38    }
39
40    /// Adds one unique marker, including an optional exact end marker.
41    /// At most 256 names of 1..=128 UTF-8 bytes without controls are accepted.
42    pub fn mark(
43        mut self,
44        name: impl Into<String>,
45        time: Duration,
46    ) -> Result<Self, ValidationError> {
47        let name = name.into();
48        if name.is_empty()
49            || name.len() > 128
50            || name.chars().any(char::is_control)
51            || time > self.duration
52            || self.markers.len() == 256
53            || self.markers.contains_key(&name)
54        {
55            return Err(invalid(
56                "Invalid, duplicate or out-of-range narration marker",
57            ));
58        }
59        self.markers.insert(name, time);
60        Ok(self)
61    }
62
63    /// Returns the declared duration, without rounding it to a frame rate.
64    pub fn duration(&self) -> Duration {
65        self.duration
66    }
67
68    /// Returns captions in their original order, including intentional overlaps.
69    pub fn captions(&self) -> &CaptionTrack {
70        &self.captions
71    }
72
73    /// Uniformly retimes captions and markers to a new duration in `(0,24h]`.
74    /// Exact integer ratios round nanoseconds to nearest, ties upward; collapsed
75    /// cues are errors. Rebuild cached captions afterward. Audio is NOT stretched:
76    /// remeasure/re-record it and use `audio_clip` to check the new intervals.
77    pub fn retimed(&self, duration: Duration) -> Result<Self, ValidationError> {
78        if duration.is_zero() || duration > Duration::from_secs(86400) {
79            return Err(invalid("Retimed cue sheet requires duration in (0,24h]"));
80        }
81        let map = |time: Duration| {
82            Duration::from_nanos(
83                ((time.as_nanos() * duration.as_nanos() + self.duration.as_nanos() / 2)
84                    / self.duration.as_nanos()) as u64,
85            )
86        };
87        let cues = self
88            .captions
89            .cues()
90            .iter()
91            .map(|cue| CaptionCue::new(map(cue.start()), map(cue.end()), cue.text()))
92            .collect::<Result<Vec<_>, ValidationError>>()?;
93        let mut sheet = Self::new(duration, CaptionTrack::new(cues)?)?;
94        for (name, time) in &self.markers {
95            sheet = sheet.mark(name, map(*time))?;
96        }
97        Ok(sheet)
98    }
99
100    /// Resolves a name; missing names are errors rather than time zero.
101    pub fn time(&self, name: &str) -> Result<Duration, ValidationError> {
102        self.markers
103            .get(name)
104            .copied()
105            .ok_or_else(|| invalid("Unknown narration marker"))
106    }
107
108    /// Returns elapsed time since a marker while this composition is active.
109    /// Frame and marker times share one clock; sequence-local frames are not rebased.
110    /// Comparisons use exact integer cross-products; elapsed nanoseconds round down.
111    pub fn elapsed(
112        &self,
113        time: motion_core::Time,
114        name: &str,
115    ) -> Result<Option<Duration>, ValidationError> {
116        let rate = motion_core::TICKS_PER_SECOND as u128;
117        let marker = self.time(name)?.as_nanos() * rate;
118        let Some(ticks) = u128::try_from(time.ticks()).ok() else {
119            return Ok(None);
120        };
121        let now = ticks * 1_000_000_000;
122        if now < marker || now >= self.duration.as_nanos() * rate {
123            return Ok(None);
124        }
125        Ok(Some(Duration::from_nanos(((now - marker) / rate) as u64)))
126    }
127
128    /// Samples clamped animation progress between two strictly ordered markers.
129    pub fn progress(
130        &self,
131        time: motion_core::Time,
132        start: &str,
133        end: &str,
134    ) -> Result<f64, ValidationError> {
135        let rate = motion_core::TICKS_PER_SECOND as u128;
136        let start = self.time(start)?.as_nanos() * rate;
137        let end = self.time(end)?.as_nanos() * rate;
138        if start >= end {
139            return Err(invalid("Animation markers require start < end"));
140        }
141        let now = u128::try_from(time.ticks()).unwrap_or(0) * 1_000_000_000;
142        Ok((now.saturating_sub(start) as f64 / (end - start) as f64).clamp(0.0, 1.0))
143    }
144
145    /// Places a measured source interval at a caption's start, without truncation.
146    /// The caller obtains source duration separately; this does not probe media.
147    pub fn audio_clip(
148        &self,
149        cue: usize,
150        audio: std::sync::Arc<dyn motion_core::AudioSource>,
151        source: (motion_core::Time, motion_core::Time),
152    ) -> motion_core::Result<motion_core::AudioClip> {
153        let cue = self
154            .captions
155            .cues()
156            .get(cue)
157            .ok_or_else(|| invalid("Unknown narration cue"))?;
158        if source.0 < motion_core::Time::ZERO || source.1 <= source.0 {
159            return Err(invalid("Narration source requires 0 <= in < out").into());
160        }
161        let duration = source
162            .1
163            .duration_since(source.0)
164            .ok_or_else(|| invalid("Narration source requires 0 <= in < out"))?;
165        if duration.ticks() as u128 * 1_000_000_000
166            > (cue.end() - cue.start()).as_nanos() * motion_core::TICKS_PER_SECOND as u128
167        {
168            return Err(
169                invalid("Narration exceeds its caption interval; retime explicitly").into(),
170            );
171        }
172        let start = (cue.start().as_nanos() * motion_core::TICKS_PER_SECOND as u128 + 500_000_000)
173            / 1_000_000_000;
174        Ok(motion_core::AudioClip::source(audio)
175            .start(motion_core::Time::from_ticks(start as i64))
176            .offset(source.0.since_origin().magnitude())
177            .duration(duration))
178    }
179}
180
181fn invalid(message: &str) -> ValidationError {
182    ValidationError::invalid("captions", "input", message)
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::CaptionCue;
189    use motion_core::{AudioSource, Time, TimeLiteral};
190
191    #[test]
192    fn markers_use_the_frame_clock_and_reject_ambiguous_names() -> Result<(), ValidationError> {
193        let sheet = CueSheet::new(Duration::from_secs(10), CaptionTrack::default())?
194            .mark("in", Duration::from_millis(100))?
195            .mark("out", Duration::from_secs(1))?;
196        let frame = |index| Time::from_frame(index, motion_core::fps!(30));
197        assert_eq!(sheet.elapsed(frame(2), "in")?, None);
198        assert_eq!(sheet.elapsed(frame(3), "in")?, Some(Duration::ZERO));
199        assert_eq!(sheet.progress(frame(30), "in", "out")?, 1.0);
200        assert!(sheet.progress(frame(3), "out", "in").is_err());
201        assert!(sheet.time("missing").is_err());
202        assert_eq!(sheet.elapsed(frame(300), "in")?, None);
203        let fractional = sheet
204            .clone()
205            .mark("fractional", Duration::from_nanos(100_000_001))?;
206        assert_eq!(fractional.elapsed(frame(3), "fractional")?, None);
207        assert!(sheet.clone().mark("in", Duration::ZERO).is_err());
208        let captions = CaptionTrack::new([CaptionCue::new(
209            Duration::ZERO,
210            Duration::from_secs(11),
211            "long",
212        )?])?;
213        assert!(CueSheet::new(Duration::from_secs(10), captions).is_err());
214        Ok(())
215    }
216
217    #[test]
218    fn retiming_preserves_shared_boundaries_and_audio_must_fit() -> motion_core::Result {
219        let track = CaptionTrack::new([
220            CaptionCue::new(Duration::from_secs(1), Duration::from_secs(3), "one")?,
221            CaptionCue::new(Duration::from_secs(3), Duration::from_secs(5), "two")?,
222        ])?;
223        let sheet =
224            CueSheet::new(Duration::from_secs(10), track)?.mark("cut", Duration::from_secs(3))?;
225        let short = sheet.retimed(Duration::from_secs(5))?;
226        assert_eq!(short.time("cut")?, Duration::from_millis(1500));
227        assert_eq!(
228            short.captions().cues()[0].end(),
229            short.captions().cues()[1].start()
230        );
231        assert!(sheet.retimed(Duration::ZERO).is_err());
232        assert!(sheet.retimed(Duration::from_nanos(1)).is_err());
233        let audio: std::sync::Arc<dyn AudioSource> =
234            std::sync::Arc::new(motion_encode::PcmBuffer::new(48_000, 1, vec![0.1; 96_000])?);
235        assert!(
236            short
237                .audio_clip(
238                    0,
239                    audio.clone(),
240                    (Time::ZERO, 1001.milliseconds().after_origin())
241                )
242                .is_err()
243        );
244        let clip = short.audio_clip(0, audio.clone(), (Time::ZERO, 1.seconds().after_origin()))?;
245        let mixed = motion_encode::MixedAudio::new(
246            motion_encode::MixSource::clips(&[clip], 2.seconds()),
247            motion_encode::MixOptions::new(48_000),
248        )?;
249        let mut sample = [0.0; 2];
250        assert_eq!(mixed.read(0, &mut sample), 1);
251        assert_eq!(sample, [0.0; 2]);
252        assert_eq!(mixed.read(24_000, &mut sample), 1);
253        assert!(sample.iter().all(|value| *value > 0.0));
254        assert!(
255            short
256                .audio_clip(2, audio, (Time::ZERO, 1.seconds().after_origin()))
257                .is_err()
258        );
259        Ok(())
260    }
261}