Skip to main content

alpha2_engine/
passthrough.rs

1//! Whether an export range is its sources' pictures, untouched.
2//!
3//! Copying compressed packets is correct only when rendering would reproduce those pictures
4//! exactly: opaque clips tiling the range at their own size and speed with nothing else drawn.
5//! This checks the project alone; the native host still checks the files (codec, frame grid,
6//! keyframes, color tags, bitstream compatibility across joins).
7//!
8//! The check is conservative: anything that could change a pixel or frame timing refuses, even an
9//! identity-valued keyframe track, and the refusal names the cause. Audio never refuses; it is
10//! classified for the host to copy or encode.
11//!
12//! It uses the renderer's own predicates: [`alpha2_model::Timeline::matte_sources`],
13//! [`alpha2_model::TrackAudio::is_silenced`] and [`alpha2_model::AudioProperties::is_audible`].
14
15use 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/// What an export range copies: its segments in timeline order, and its audio.
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct PassthroughPlan {
24    /// The clips that tile the range, one after another.
25    pub segments: Vec<PassthroughSegment>,
26    /// What the export's audio is over the range.
27    pub audio: PassthroughAudio,
28}
29
30/// One clip's share of a copied range.
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct PassthroughSegment {
33    /// The clip whose source is copied.
34    pub clip_id: ClipId,
35    /// The source copied.
36    pub source_id: SourceId,
37    /// The part of the export range this clip shows.
38    pub timeline_range: TimeRange,
39    /// The source interval shown there.
40    pub source_range: TimeRange,
41}
42
43impl PassthroughSegment {
44    /// The source time shown at timeline time `time`, inside this segment.
45    #[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/// What an export's audio is over a copied range: copied too, or encoded from the mix.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub enum PassthroughAudio {
57    /// The export carries no audio.
58    Absent,
59    /// One copied clip's unprocessed audio is the whole mix, so its packets can be copied.
60    Untouched,
61    /// A mix the engine renders (several clips, any processing, or silence); the reason says
62    /// which.
63    Mixed(String),
64}
65
66/// Whether `range` of `project` shows its sources untouched.
67///
68/// `range` is the export's effective range, already on the frame grid.
69/// `include_audio` is whether the export carries audio.
70///
71/// # Errors
72/// Names the first thing in the project that rendering would change.
73pub 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
149/// Clips whose sound reaches the mix over `range`, including earlier clips on buses whose
150/// effects can ring past the clip's end.
151fn 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
173/// The visual clips that tile `range`, in timeline order, with their tracks.
174fn 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
250/// Whether `clip` shows `source`'s pictures at their own size and speed with nothing applied.
251fn 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    // A render honours a declared color over stream tags; a copy keeps the stream's tags.
260    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
308/// Whether the mix is `clip`'s audio alone with no processing on the way to the output. `clip`
309/// is the only one heard.
310fn 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;