1use 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#[derive(Clone, Debug)]
16pub struct CueSheet {
17 duration: Duration,
18 captions: CaptionTrack,
19 markers: BTreeMap<String, Duration>,
20}
21
22impl CueSheet {
23 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 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 pub fn duration(&self) -> Duration {
65 self.duration
66 }
67
68 pub fn captions(&self) -> &CaptionTrack {
70 &self.captions
71 }
72
73 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 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 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 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 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}