Skip to main content

alpha2_engine/
passthrough.rs

1//! Whether an export range is its sources' pictures, untouched.
2//!
3//! Copying sources' compressed packets into the output is only correct when
4//! rendering would reproduce exactly those pictures: opaque clips that tile
5//! the whole range one after another, each shown at its own size and speed,
6//! with nothing drawn over, under, between, or through them. This decides that
7//! from the project alone, before any file is opened. What the files must also
8//! satisfy (codec, frame grid, keyframes, color tags, and a bitstream
9//! contract shared across the joins) is the native host's to check; a
10//! project-level yes here is necessary, not sufficient.
11//!
12//! The answer is conservative. Anything that could change a pixel or a
13//! frame's timing, even an identity-valued keyframe track, refuses, and the
14//! refusal names what it saw, so a caller learns why the export will render
15//! instead. The audio never refuses: it is classified, and the host copies or
16//! encodes it.
17//!
18//! What the render draws and hears is decided by the same predicates the
19//! renderer uses: [`alpha2_model::Timeline::matte_sources`] for the clips it
20//! never draws, [`alpha2_model::TrackAudio::is_silenced`] and
21//! [`alpha2_model::AudioProperties::is_audible`] for what reaches the mix.
22
23use 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/// What an export range copies: its segments in timeline order, and its audio.
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct PassthroughPlan {
32    /// The clips that tile the range, one after another.
33    pub segments: Vec<PassthroughSegment>,
34    /// What the export's audio is over the range.
35    pub audio: PassthroughAudio,
36}
37
38/// One clip's share of a copied range.
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct PassthroughSegment {
41    /// The clip whose source is copied.
42    pub clip_id: ClipId,
43    /// The source copied.
44    pub source_id: SourceId,
45    /// The part of the export range this clip shows.
46    pub timeline_range: TimeRange,
47    /// The source interval shown there.
48    pub source_range: TimeRange,
49}
50
51impl PassthroughSegment {
52    /// The source time shown at timeline time `time`, inside this segment.
53    #[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/// What an export's audio is over a copied range. The picture decides
63/// whether a range can be copied; the audio only decides whether it is
64/// copied too or encoded from the mix beside the copied video.
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub enum PassthroughAudio {
67    /// The export carries no audio.
68    Absent,
69    /// One copied clip's own audio, with nothing processing it and nothing
70    /// else heard: the source's audio packets are the mix.
71    Untouched,
72    /// A mix the engine renders: several clips, processing (gain, pan,
73    /// fades, effects, ducking, the master limiter), or silence, where
74    /// nothing in the range is heard. The reason says which.
75    Mixed(String),
76}
77
78/// Whether `range` of `project` shows its sources untouched.
79///
80/// `range` is the export's effective range, already on the frame grid.
81/// `include_audio` is whether the export carries audio.
82///
83/// # Errors
84/// Names the first thing in the project that rendering would change.
85pub 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
140/// What an export that carries audio has over `range`, whose picture
141/// `tiling` copies.
142fn 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
163/// The clips whose sound reaches the mix over `range`, as the renderer
164/// decides it: an audible clip of an audible source, on a bus that is not
165/// silenced, in the range, or before it on a bus whose effects can ring on
166/// after the clip ends.
167fn 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
189/// The visual clips that tile `range`, in timeline order, with their tracks.
190fn 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
266/// Whether `clip` shows `source`'s decoded pictures at their own size and
267/// speed, with nothing applied to them.
268fn 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    // A declared color wins over the stream's tags in a render; a copy keeps the stream's
277    // tags.
278    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
326/// Whether the mix is `clip`'s audio alone, with no gain, pan, fade, effect,
327/// ducking, or limiter anywhere between it and the output. `clip` is the only
328/// one heard, so its bus is not silenced.
329fn 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;