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