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