Skip to main content

alpha2_eval/
evaluate.rs

1use crate::{
2    ActiveCaption, ActiveClip, ActiveComposition, ActiveEffect, ActiveIndex, ActiveTransition,
3    EvalError, FrameDesc, FrameNeed, collect_active, map_source_time, sample_visual,
4};
5use alpha2_model::{Project, TrackKind};
6use alpha2_time::{FrameRate, MediaTime, TICKS_PER_SECOND, TimeRange};
7
8/// Evaluates visual and audio clip descriptions at one timeline timestamp.
9///
10/// The result borrows identities and effects from `index`. Vectors are sized exactly, so an
11/// evaluation allocates one scratch list plus one list per non-empty field.
12///
13/// # Errors
14/// Returns an error for mismatched indexes or unrepresentable retiming.
15pub fn evaluate<'a>(
16    project: &Project,
17    index: &'a ActiveIndex,
18    time: MediaTime,
19) -> Result<FrameDesc<'a>, EvalError> {
20    ensure_index_matches(project, index)?;
21    let aspect = f64::from(project.settings.width) / f64::from(project.settings.height.max(1));
22    // No instant has more clips active than the index's deepest overlap.
23    let mut active = Vec::with_capacity(index.max_active);
24    if let Some(root) = index.root {
25        collect_active(&index.nodes, root, time, &mut active);
26    }
27    active.sort_unstable_by_key(|entry| (entry.track_order, entry.clip_order));
28
29    let visual_count = active
30        .iter()
31        .filter(|entry| entry.track_kind == TrackKind::Video)
32        .count();
33    let audio_count = active
34        .iter()
35        .filter(|entry| entry.track_kind == TrackKind::Audio)
36        .count();
37    // A caption lies over the clips of the tracks before it.
38    let captions = index
39        .captions
40        .iter()
41        .filter_map(|track| {
42            let cues = &track.captions.cues;
43            // Cues are ordered and disjoint, so only the last one starting at or before `time` can
44            // be active.
45            let candidate = cues.partition_point(|cue| cue.timeline_range.start() <= time);
46            let index = candidate.checked_sub(1)?;
47            let cue = &cues[index];
48            cue.timeline_range.contains(time).then(|| ActiveCaption {
49                track_id: &track.track_id,
50                text: &cue.text,
51                local_time: MediaTime(time.ticks() - cue.timeline_range.start().ticks()),
52                style: &track.styles[track.cue_styles[index]],
53                layers_below: active
54                    .iter()
55                    .filter(|entry| {
56                        entry.track_kind == TrackKind::Video
57                            && entry.track_order < track.track_order
58                    })
59                    .count(),
60            })
61        })
62        .collect();
63    let mut visual = Vec::with_capacity(visual_count);
64    let mut audio = Vec::with_capacity(audio_count);
65    let frame_mirror = crate::Mirror::frame(project.settings.frame_rate);
66    for entry in active {
67        let item = active_clip(entry, frame_mirror, time, aspect)?;
68        match entry.track_kind {
69            TrackKind::Video => visual.push(item),
70            TrackKind::Audio => audio.push(item),
71            _ => {}
72        }
73    }
74    let frame_rate = project.settings.frame_rate;
75    let active_transitions = || {
76        index
77            .transitions
78            .iter()
79            .take_while(move |transition| transition.timeline_range.start() <= time)
80            .filter(move |transition| transition.timeline_range.contains(time))
81    };
82    let mut transitions = Vec::with_capacity(active_transitions().count());
83    transitions.extend(active_transitions().map(|transition| {
84        let linear_progress = centred_progress(transition.timeline_range, frame_rate, time);
85        // Transition shaders are defined on 0..=1; clamp overshooting curves.
86        #[allow(clippy::cast_possible_truncation)]
87        let progress = transition.easing.apply(linear_progress).clamp(0.0, 1.0) as f32;
88        ActiveTransition {
89            from_clip: &transition.from_clip,
90            to_clip: &transition.to_clip,
91            effect: ActiveEffect::new(&transition.effect, time),
92            progress,
93        }
94    }));
95    Ok(FrameDesc {
96        time,
97        background: Some(project.settings.background),
98        blend_space: project.settings.blend_space,
99        visual,
100        audio,
101        transitions,
102        captions,
103    })
104}
105
106fn active_clip(
107    entry: &crate::IndexedClip,
108    frame_mirror: crate::Mirror,
109    time: MediaTime,
110    aspect: f64,
111) -> Result<ActiveClip<'_>, EvalError> {
112    let source_time = map_source_time(
113        entry.timeline_range,
114        entry.source_range,
115        entry.time_map,
116        frame_mirror,
117        time,
118    )
119    .ok_or_else(|| EvalError::UnmappableClip {
120        clip: entry.clip_id.clone(),
121    })?;
122    Ok(ActiveClip {
123        clip_id: &entry.clip_id,
124        source_id: &entry.source_id,
125        track_id: &entry.track_id,
126        source_time,
127        visual: (entry.track_kind == TrackKind::Video)
128            .then(|| sample_visual(&entry.visual, time, aspect)),
129        composition: active_composition(entry, time)?,
130        blend_mode: entry.blend_mode,
131        cache: entry.cache,
132        masks: &entry.masks,
133        matte: entry.matte.as_ref(),
134        is_matte: entry.is_matte,
135        generator: entry.generator.as_ref(),
136        plan: entry.plan.as_deref(),
137        source_composition: active_source_composition(entry, source_time)?,
138    })
139}
140
141fn active_source_composition(
142    entry: &crate::IndexedClip,
143    source_time: MediaTime,
144) -> Result<Option<ActiveComposition<'_>>, EvalError> {
145    entry
146        .source_composition
147        .as_ref()
148        .filter(|_| entry.track_kind == TrackKind::Video)
149        .map(|(id, plan)| {
150            plan.as_ref()
151                .as_ref()
152                .map(|plan| ActiveComposition::new(plan, source_time))
153                .map_err(|reason| EvalError::IncompleteComposition {
154                    clip: entry.clip_id.clone(),
155                    composition: id.clone(),
156                    reason: reason.clone(),
157                })
158        })
159        .transpose()
160}
161
162fn active_composition(
163    entry: &crate::IndexedClip,
164    time: MediaTime,
165) -> Result<Option<ActiveComposition<'_>>, EvalError> {
166    let Some(indexed) = entry
167        .composition
168        .as_ref()
169        .filter(|_| entry.track_kind == TrackKind::Video)
170    else {
171        return Ok(None);
172    };
173    let plan =
174        indexed
175            .plan
176            .as_ref()
177            .as_ref()
178            .map_err(|reason| EvalError::IncompleteComposition {
179                clip: entry.clip_id.clone(),
180                composition: indexed.reference.id.clone(),
181                reason: reason.clone(),
182            })?;
183    let local = indexed
184        .reference
185        .local_time(entry.timeline_range.start(), time)
186        .ok_or_else(|| EvalError::UnmappableClip {
187            clip: entry.clip_id.clone(),
188        })?;
189    Ok(Some(ActiveComposition::new(plan, local)))
190}
191
192/// A transition's linear progress, `clamp((t - start + F/2) / D, 0, 1)`.
193///
194/// `F` is one project frame and `D` the duration. Sampling at frame centres makes frame `i` of
195/// `N` show `(i + 0.5) / N`, so every frame mixes both clips and reversed playback shows `1 - p`.
196fn centred_progress(range: TimeRange, frame_rate: FrameRate, time: MediaTime) -> f64 {
197    let elapsed = i128::from(time.ticks()) - i128::from(range.start().ticks());
198    let duration = i128::from(range.end().ticks()) - i128::from(range.start().ticks());
199    // F / 2 = TICKS_PER_SECOND * den / (2 * num); scale by 2 * num to stay exact until the division.
200    let num = i128::from(frame_rate.numerator());
201    let den = i128::from(frame_rate.denominator());
202    let numerator = 2 * num * elapsed + i128::from(TICKS_PER_SECOND) * den;
203    let denominator = 2 * num * duration;
204    #[allow(clippy::cast_precision_loss)]
205    let progress = (numerator as f64 / denominator as f64).clamp(0.0, 1.0);
206    progress
207}
208
209/// Describes all source frames needed at a timestamp.
210///
211/// The list is in evaluation order with no duplicate `(source, timestamp)` pairs. Generated
212/// sources need no frames. A visual clip needs its own source only when it draws it
213/// ([`ActiveClip::needs_source`]) plus every source its composition reads. A matte clip needs
214/// frames only while an active clip names it ([`FrameDesc::draws`]).
215///
216/// # Errors
217///
218/// Returns any error emitted by [`evaluate`].
219pub fn frame_needs(
220    project: &Project,
221    index: &ActiveIndex,
222    time: MediaTime,
223) -> Result<Vec<FrameNeed>, EvalError> {
224    let frame = evaluate(project, index, time)?;
225    let mut needs = Needs::default();
226    for item in frame
227        .visual
228        .iter()
229        .filter(|item| frame.draws(item))
230        .chain(&frame.audio)
231    {
232        if item.needs_source() {
233            needs.ask(item.clip_id, item.source_id, item.source_time, || None);
234        }
235        let plans = item
236            .composition
237            .iter()
238            .map(|composition| (composition, false))
239            .chain(
240                item.source_composition
241                    .iter()
242                    .map(|composition| (composition, true)),
243            );
244        for (composition, of_source) in plans {
245            let steps = &composition.plan().steps;
246            for (step, source_id, time) in composition.media() {
247                let node = &steps[step].node;
248                // Prefix source-composition steps with `/`, which no own-composition path starts with.
249                needs.ask(item.clip_id, source_id, time, || {
250                    if of_source {
251                        alpha2_model::NodeId::new(format!("/{node}")).ok()
252                    } else {
253                        Some(node.clone())
254                    }
255                });
256            }
257        }
258    }
259    Ok(needs.list)
260}
261
262/// Needs deduplicated by linear scan before switching to a hash set. Frames usually need only a
263/// few sources, but a composition may read tens of thousands.
264pub(crate) const SCANNED_NEEDS: usize = 32;
265
266#[cfg(test)]
267thread_local! {
268    /// Comparisons made by [`Needs::ask`] on this thread.
269    pub(crate) static COMPARED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
270}
271
272#[derive(Default)]
273struct Needs<'a> {
274    list: Vec<FrameNeed>,
275    /// Needs past the first [`SCANNED_NEEDS`], borrowing IDs so lookups clone nothing.
276    hashed: std::collections::HashSet<(&'a alpha2_media::SourceId, MediaTime)>,
277}
278
279impl<'a> Needs<'a> {
280    /// Records `source_id` at `media_time` unless already recorded. `reader` runs only for a new
281    /// need.
282    fn ask(
283        &mut self,
284        clip_id: &alpha2_model::ClipId,
285        source_id: &'a alpha2_media::SourceId,
286        media_time: MediaTime,
287        reader: impl FnOnce() -> Option<alpha2_model::NodeId>,
288    ) {
289        let scanned = &self.list[..self.list.len().min(SCANNED_NEEDS)];
290        #[cfg(test)]
291        COMPARED.with(|compared| compared.set(compared.get() + scanned.len()));
292        let repeat = scanned
293            .iter()
294            .any(|need| need.media_time == media_time && need.source_id == *source_id)
295            || (self.list.len() > SCANNED_NEEDS && self.hashed.contains(&(source_id, media_time)));
296        if repeat {
297            return;
298        }
299        if self.list.len() >= SCANNED_NEEDS {
300            self.hashed.insert((source_id, media_time));
301        }
302        self.list.push(FrameNeed {
303            clip_id: clip_id.clone(),
304            reader: reader(),
305            source_id: source_id.clone(),
306            media_time,
307        });
308    }
309}
310
311pub(crate) fn ensure_index_matches(
312    project: &Project,
313    index: &ActiveIndex,
314) -> Result<(), EvalError> {
315    if project.id == index.project_id {
316        Ok(())
317    } else {
318        Err(EvalError::IndexProjectMismatch {
319            index_project: index.project_id.clone(),
320            evaluated_project: project.id.clone(),
321        })
322    }
323}