1use alpha2_media::SourceId;
24use alpha2_model::{
25 Animated, Clip, ClipBlendMode, ClipId, MediaKind, Project, TimeMap, Track, TrackKind, Vec2,
26};
27use alpha2_time::{MediaTime, TimeRange};
28
29#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct PassthroughPlan {
32 pub segments: Vec<PassthroughSegment>,
34 pub audio: PassthroughAudio,
36}
37
38#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct PassthroughSegment {
41 pub clip_id: ClipId,
43 pub source_id: SourceId,
45 pub timeline_range: TimeRange,
47 pub source_range: TimeRange,
49}
50
51impl PassthroughSegment {
52 #[must_use]
54 pub fn source_time(&self, time: MediaTime) -> MediaTime {
55 MediaTime::from_ticks(
56 self.source_range.start().ticks()
57 + (time.ticks() - self.timeline_range.start().ticks()),
58 )
59 }
60}
61
62#[derive(Clone, Debug, Eq, PartialEq)]
66pub enum PassthroughAudio {
67 Absent,
69 Untouched,
72 Mixed(String),
76}
77
78pub fn passthrough_plan(
86 project: &Project,
87 range: TimeRange,
88 include_audio: bool,
89) -> Result<PassthroughPlan, String> {
90 let clips = tiling_clips(project, range)?;
91 let mattes = project.timeline.matte_sources();
92 let mut segments = Vec::with_capacity(clips.len());
93 for &(_, clip) in &clips {
94 let source = project
95 .sources
96 .iter()
97 .find(|source| source.id == clip.source_id)
98 .ok_or_else(|| format!("clip {} has no source", clip.id))?;
99 shows_source_untouched(clip, source)?;
100 if mattes.contains(&clip.id) {
101 return Err(format!(
102 "clip {} is another clip's matte, which is never drawn itself",
103 clip.id
104 ));
105 }
106 let timeline_range = TimeRange::new(
107 clip.timeline_range.start().max(range.start()),
108 clip.timeline_range.end().min(range.end()),
109 )
110 .map_err(|error| {
111 format!(
112 "clip {}'s share of the range is invalid: {error:?}",
113 clip.id
114 )
115 })?;
116 let offset = timeline_range.start().ticks() - clip.timeline_range.start().ticks();
117 let source_start = clip.source_range.start().ticks().saturating_add(offset);
118 let source_range = TimeRange::new(
119 MediaTime::from_ticks(source_start),
120 MediaTime::from_ticks(
121 source_start.saturating_add(timeline_range.duration().map_or(0, MediaTime::ticks)),
122 ),
123 )
124 .map_err(|error| format!("the copied source interval is invalid: {error:?}"))?;
125 segments.push(PassthroughSegment {
126 clip_id: clip.id.clone(),
127 source_id: clip.source_id.clone(),
128 timeline_range,
129 source_range,
130 });
131 }
132 let audio = if include_audio {
133 mix_over(project, range, &clips)
134 } else {
135 PassthroughAudio::Absent
136 };
137 Ok(PassthroughPlan { segments, audio })
138}
139
140fn mix_over(project: &Project, range: TimeRange, tiling: &[(&Track, &Clip)]) -> PassthroughAudio {
143 let heard = heard_clips(project, range);
144 let copied = |clip: &Clip| tiling.iter().any(|(_, tiled)| tiled.id == clip.id);
145 if heard.is_empty() {
146 return PassthroughAudio::Mixed(
147 "nothing in the range is heard, so the mix is silence".to_owned(),
148 );
149 }
150 if let Some((track, clip)) = heard.iter().find(|(_, clip)| !copied(clip)) {
151 return PassthroughAudio::Mixed(format!(
152 "clip {} on track {} is heard in the range too",
153 clip.id, track.id
154 ));
155 }
156 match (&heard[..], tiling) {
157 ([(track, clip)], [_]) => heard_untouched(project, track, clip)
158 .map_or_else(PassthroughAudio::Mixed, |()| PassthroughAudio::Untouched),
159 _ => PassthroughAudio::Mixed("the range's audio joins more than one clip".to_owned()),
160 }
161}
162
163fn heard_clips(project: &Project, range: TimeRange) -> Vec<(&Track, &Clip)> {
168 let any_solo = project.timeline.any_solo();
169 let audible_source = |clip: &Clip| {
170 project
171 .sources
172 .iter()
173 .any(|source| source.id == clip.source_id && source.is_audible())
174 };
175 project
176 .timeline
177 .tracks
178 .iter()
179 .filter(|track| !track.audio.is_silenced(any_solo))
180 .flat_map(|track| track.clips.iter().map(move |clip| (track, clip)))
181 .filter(|(track, clip)| {
182 let reaches = clip.timeline_range.start() < range.end()
183 && (range.start() < clip.timeline_range.end() || !track.audio.effects.is_empty());
184 reaches && clip.audio.is_audible() && audible_source(clip)
185 })
186 .collect()
187}
188
189fn tiling_clips(project: &Project, range: TimeRange) -> Result<Vec<(&Track, &Clip)>, String> {
191 let overlaps = |other: TimeRange| other.start() < range.end() && range.start() < other.end();
192 let mut visual = Vec::new();
193 for track in &project.timeline.tracks {
194 match track.kind {
195 TrackKind::Caption => {
196 if track.captions.as_ref().is_some_and(|captions| {
197 captions.cues.iter().any(|cue| overlaps(cue.timeline_range))
198 }) {
199 return Err(format!(
200 "caption track {} shows text in the range",
201 track.id
202 ));
203 }
204 }
205 TrackKind::Video => {
206 visual.extend(
207 track
208 .clips
209 .iter()
210 .filter(|clip| overlaps(clip.timeline_range))
211 .map(|clip| (track, clip)),
212 );
213 }
214 TrackKind::Audio => {}
215 _ => {
216 return Err(format!(
217 "track {} is of a kind this check does not know",
218 track.id
219 ));
220 }
221 }
222 }
223 visual.sort_by_key(|(_, clip)| clip.timeline_range.start());
224 let first = visual
225 .first()
226 .ok_or_else(|| "no visual clip is in the range".to_owned())?;
227 if first.1.timeline_range.start() > range.start() {
228 return Err(format!(
229 "clip {} starts after the range does, so the background would show",
230 first.1.id
231 ));
232 }
233 for pair in visual.windows(2) {
234 let (before, after) = (pair[0].1, pair[1].1);
235 if after.timeline_range.start() < before.timeline_range.end() {
236 return Err(format!(
237 "clips {} and {} are both visible at once, so the frame is a composite",
238 before.id, after.id
239 ));
240 }
241 if after.timeline_range.start() > before.timeline_range.end() {
242 return Err(format!(
243 "the background shows between clips {} and {}",
244 before.id, after.id
245 ));
246 }
247 }
248 let last = visual.last().map_or(first.1, |(_, clip)| clip);
249 if last.timeline_range.end() < range.end() {
250 return Err(format!(
251 "clip {} ends before the range does, so the background would show",
252 last.id
253 ));
254 }
255 for &(track, clip) in &visual {
256 if track.transitions.iter().any(|transition| {
257 (transition.from_clip == clip.id || transition.to_clip == clip.id)
258 && overlaps(transition.timeline_range)
259 }) {
260 return Err(format!("clip {} is in a transition in the range", clip.id));
261 }
262 }
263 Ok(visual)
264}
265
266fn shows_source_untouched(clip: &Clip, source: &alpha2_model::MediaSource) -> Result<(), String> {
269 if source.kind != MediaKind::Video || source.provider.is_some() {
270 return Err(format!(
271 "clip {} shows a {} source, not a decoded video file",
272 clip.id,
273 media_kind(source.kind)
274 ));
275 }
276 if source
279 .color_metadata
280 .is_some_and(|declared| declared != alpha2_media::ColorMetadata::EXPORT)
281 {
282 return Err(format!(
283 "clip {}'s source declares a color other than the output's, which a render converts",
284 clip.id
285 ));
286 }
287 if clip.composition.is_some() {
288 return Err(format!(
289 "clip {} is shown through a composition graph",
290 clip.id
291 ));
292 }
293 if !clip.masks.is_empty() || clip.matte.is_some() {
294 return Err(format!("clip {} is masked or matted", clip.id));
295 }
296 if clip.blend_mode != ClipBlendMode::Normal {
297 return Err(format!(
298 "clip {} blends with a mode other than normal",
299 clip.id
300 ));
301 }
302 let visual_identity = matches!(
303 clip.visual.position,
304 alpha2_model::AnimatedPoint::Constant { value } if value == Vec2::new(0.5, 0.5)
305 ) && clip.visual.anchor == Vec2::new(0.5, 0.5)
306 && constant(&clip.visual.scale) == Some(Vec2::new(1.0, 1.0))
307 && constant(&clip.visual.rotation_degrees) == Some(0.0)
308 && constant(&clip.visual.opacity) == Some(1.0);
309 if !visual_identity {
310 return Err(format!(
311 "clip {} is moved, scaled, rotated, or faded; only a constant identity transform is copied",
312 clip.id
313 ));
314 }
315 if clip.time_map != TimeMap::Forward
316 || clip.source_range.duration() != clip.timeline_range.duration()
317 {
318 return Err(format!(
319 "clip {} is retimed, reversed, frozen, or looped",
320 clip.id
321 ));
322 }
323 Ok(())
324}
325
326fn heard_untouched(project: &Project, track: &Track, clip: &Clip) -> Result<(), String> {
330 let clip_audio = &clip.audio;
331 if constant(&clip_audio.gain) != Some(1.0)
332 || constant(&clip_audio.pan) != Some(0.0)
333 || clip_audio.fade_in.ticks() != 0
334 || clip_audio.fade_out.ticks() != 0
335 || !clip_audio.effects.is_empty()
336 {
337 return Err(format!("clip {}'s audio is processed", clip.id));
338 }
339 if constant(&track.audio.gain) != Some(1.0)
340 || constant(&track.audio.pan) != Some(0.0)
341 || !track.audio.effects.is_empty()
342 || track.audio.ducking.is_some()
343 {
344 return Err(format!("track {}'s audio bus processes the clip", track.id));
345 }
346 let master = &project.audio;
347 if constant(&master.gain) != Some(1.0) || !master.effects.is_empty() || master.limiter.is_some()
348 {
349 return Err("the master bus processes the mix (gain, effects, or its limiter)".to_owned());
350 }
351 Ok(())
352}
353
354fn constant<T: Copy>(animated: &Animated<T>) -> Option<T> {
355 match animated {
356 Animated::Constant { value } => Some(*value),
357 Animated::Keyframes { .. } => None,
358 }
359}
360
361const fn media_kind(kind: MediaKind) -> &'static str {
362 match kind {
363 MediaKind::Video => "video",
364 MediaKind::Audio => "audio",
365 MediaKind::Image => "image",
366 MediaKind::Generated => "generated",
367 MediaKind::Composition => "composition",
368 }
369}
370
371#[cfg(test)]
372#[path = "passthrough_tests.rs"]
373mod tests;