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