Skip to main content

alpha2_eval/
index.rs

1//! The per-snapshot interval index that frame and audio evaluation query.
2//!
3//! The evaluator has no GPU, decoder, filesystem, clock, or global state. A
4//! host builds an [`ActiveIndex`] once for an immutable [`Project`] and may then
5//! evaluate arbitrary timestamps on any thread.
6
7use crate::{EvalError, IndexedCaptions, IndexedClip, IndexedTransition, IntervalNode, build_tree};
8use alpha2_media::SourceId;
9use alpha2_model::{CompositionId, Project, ProjectId};
10use alpha2_time::TimeRange;
11
12/// A precomputed interval index for a validated project snapshot.
13#[derive(Clone, Debug)]
14pub struct ActiveIndex {
15    pub(crate) project_id: ProjectId,
16    pub(crate) nodes: Vec<IntervalNode>,
17    pub(crate) root: Option<usize>,
18    pub(crate) transitions: Vec<IndexedTransition>,
19    /// Caption tracks in track order.
20    pub(crate) captions: Vec<IndexedCaptions>,
21    /// The most clips active at any one instant.
22    pub(crate) max_active: usize,
23}
24
25impl ActiveIndex {
26    /// Builds an immutable interval tree from a structurally valid project.
27    ///
28    /// # Errors
29    ///
30    /// Returns [`EvalError::InvalidProject`] when the model is invalid.
31    #[allow(clippy::too_many_lines)]
32    pub fn build(project: &Project) -> Result<Self, EvalError> {
33        project.validate().map_err(EvalError::InvalidProject)?;
34        let mut entries = Vec::new();
35        let mut transitions = Vec::new();
36        let matte_sources = project.timeline.matte_sources();
37        let lookup = crate::composition::Lookup::new(&project.compositions, &project.sources);
38        let (plans, source_plans) = compile_plans(project, &lookup);
39        let sources = project
40            .sources
41            .iter()
42            .map(|source| (&source.id, source))
43            .collect::<std::collections::HashMap<_, _>>();
44        let shapes = shape_plans(project);
45        for (track_order, track) in project.timeline.tracks.iter().enumerate() {
46            for (clip_order, clip) in track.clips.iter().enumerate() {
47                // Validation guarantees the source exists.
48                let source = sources.get(&clip.source_id).copied();
49                let source_is_audible = source.is_some_and(alpha2_model::MediaSource::is_audible);
50                let generator = source.and_then(|source| source.generator.clone());
51                // A composition source is the clip's picture: it is drawn, and so planned, read,
52                // and required, only where the clip's own composition draws the clip.
53                let drawn = clip
54                    .composition
55                    .as_ref()
56                    .is_none_or(|reference| lookup.reads().draws_clip(&reference.id));
57                let source_composition = source
58                    .filter(|_| drawn)
59                    .and_then(|source| source_composition_of(source, &source_plans));
60                entries.push(IndexedClip {
61                    clip_id: clip.id.clone(),
62                    source_id: clip.source_id.clone(),
63                    track_id: track.id.clone(),
64                    track_kind: track.kind,
65                    timeline_range: clip.timeline_range,
66                    source_range: clip.source_range,
67                    time_map: clip.time_map,
68                    visual: clip.visual.clone(),
69                    // Validation guarantees the composition exists.
70                    composition: clip.composition.as_ref().and_then(|reference| {
71                        plans
72                            .get(&reference.id)
73                            .map(|plan| crate::IndexedComposition {
74                                plan: std::sync::Arc::clone(plan),
75                                reference: reference.clone(),
76                            })
77                    }),
78                    blend_mode: clip.blend_mode,
79                    cache: clip.cache,
80                    masks: clip.masks.clone(),
81                    matte: clip.matte.clone(),
82                    is_matte: matte_sources.contains(&clip.id),
83                    audio: clip.audio.clone(),
84                    source_is_audible,
85                    generator,
86                    shapes: shapes.get(&clip.source_id).cloned(),
87                    source_composition,
88                    track_order,
89                    clip_order,
90                });
91            }
92            transitions.extend(
93                track
94                    .transitions
95                    .iter()
96                    .map(|transition| IndexedTransition {
97                        from_clip: transition.from_clip.clone(),
98                        to_clip: transition.to_clip.clone(),
99                        timeline_range: transition.timeline_range,
100                        easing: transition.easing,
101                        effect: transition.effect.clone(),
102                        track_order,
103                    }),
104            );
105        }
106        let captions = project
107            .timeline
108            .tracks
109            .iter()
110            .enumerate()
111            .filter_map(|(track_order, track)| {
112                track.captions.as_ref().map(|captions| IndexedCaptions {
113                    track_id: track.id.clone(),
114                    track_order,
115                    captions: captions.clone(),
116                })
117            })
118            .collect();
119        entries.sort_unstable_by_key(|entry| {
120            (
121                entry.timeline_range.start(),
122                entry.track_order,
123                entry.clip_order,
124            )
125        });
126        let max_active = deepest_overlap(&entries);
127        let (nodes, root) = build_tree(entries);
128        transitions.sort_unstable_by_key(|transition| {
129            (transition.timeline_range.start(), transition.track_order)
130        });
131        Ok(Self {
132            project_id: project.id.clone(),
133            nodes,
134            root,
135            transitions,
136            captions,
137            max_active,
138        })
139    }
140
141    /// Returns the project identity used to build this index.
142    #[must_use]
143    pub fn project_id(&self) -> &ProjectId {
144        &self.project_id
145    }
146
147    /// Returns the number of clips indexed.
148    #[must_use]
149    pub fn len(&self) -> usize {
150        self.nodes.len()
151    }
152
153    /// Returns whether the index has no clips.
154    #[must_use]
155    pub fn is_empty(&self) -> bool {
156        self.nodes.is_empty()
157    }
158
159    /// Every effect invocation the project can draw: the steps of each
160    /// composition a clip shows or draws as its source (those that compile),
161    /// and each transition. A compiled composition is shared by every clip
162    /// that shows it, so its steps come once however many clips show it, in
163    /// index order of its first use: the walk is linear in the clips and the
164    /// distinct compositions, never in their product.
165    pub fn effects(&self) -> impl Iterator<Item = &alpha2_model::ClipEffect> {
166        let mut seen = std::collections::HashSet::new();
167        let plans = self
168            .nodes
169            .iter()
170            .flat_map(|node| clip_plans(node.entry()))
171            .flatten()
172            .filter(|slot| seen.insert(std::sync::Arc::as_ptr(slot)))
173            .filter_map(|slot| slot.as_ref().as_ref().ok())
174            .collect::<Vec<_>>();
175        plans
176            .into_iter()
177            .flat_map(crate::CompositionPlan::effects)
178            .chain(self.transitions.iter().map(|transition| &transition.effect))
179    }
180
181    /// The most sequential reads of host-supplied sources that `counted`
182    /// selects at any one instant of `range`, or zero when none is read
183    /// there. A clip reads its own source while it draws it
184    /// ([`crate::ActiveClip::needs_source`]), and the source of each media
185    /// step of its compositions; [`crate::frame_needs`] asks for each of these
186    /// frames through its own decoder cursor, so this says how many cursors
187    /// decode at once.
188    ///
189    /// Each compiled composition's reads are counted once however many clips
190    /// show it, so this is linear in the clips and the distinct
191    /// compositions' steps, never in their product.
192    #[must_use]
193    pub fn peak_source_reads(
194        &self,
195        range: TimeRange,
196        mut counted: impl FnMut(&SourceId) -> bool,
197    ) -> usize {
198        // The reads of each compiled composition, by its shared allocation.
199        let mut plan_reads = std::collections::HashMap::new();
200        let mut events = Vec::new();
201        for entry in self.nodes.iter().map(IntervalNode::entry) {
202            let Some(span) = entry.timeline_range.intersection(range) else {
203                continue;
204            };
205            let [composition, source_composition] = clip_plans(entry);
206            let draws_source = entry.generator.is_none()
207                && source_composition.is_none()
208                && composition.is_none_or(|slot| {
209                    slot.as_ref()
210                        .as_ref()
211                        .is_ok_and(crate::CompositionPlan::uses_clip)
212                });
213            let mut reads = usize::from(draws_source && counted(&entry.source_id));
214            for slot in [composition, source_composition].into_iter().flatten() {
215                reads += *plan_reads
216                    .entry(std::sync::Arc::as_ptr(slot))
217                    .or_insert_with(|| {
218                        slot.as_ref().as_ref().map_or(0, |plan| {
219                            plan.steps
220                                .iter()
221                                .filter(|step| match &step.op {
222                                    crate::StepOp::Media { source_id, .. } => counted(source_id),
223                                    _ => false,
224                                })
225                                .count()
226                        })
227                    });
228            }
229            if reads > 0 {
230                let delta = isize::try_from(reads).unwrap_or(isize::MAX);
231                events.extend([(span.start(), delta), (span.end(), -delta)]);
232            }
233        }
234        // Ranges are half-open: at one instant, the reads ending leave before those starting enter.
235        events.sort_unstable_by_key(|&(time, delta)| (time, delta.signum()));
236        let mut active = 0_usize;
237        let mut peak = 0;
238        for (_, delta) in events {
239            active = active.saturating_add_signed(delta);
240            peak = peak.max(active);
241        }
242        peak
243    }
244}
245
246/// The compiled compositions `entry` draws: its own, then its composition
247/// source's.
248fn clip_plans(entry: &IndexedClip) -> [Option<&PlanSlot>; 2] {
249    [
250        entry
251            .composition
252            .as_ref()
253            .map(|composition| &composition.plan),
254        entry.source_composition.as_ref().map(|(_, plan)| plan),
255    ]
256}
257
258/// The compiled compositions of a project: each a clip shows, and each a
259/// composition source shows.
260type Plans<'a> = std::collections::HashMap<&'a CompositionId, PlanSlot>;
261
262/// Each composition a clip shows, compiled once however many clips show it,
263/// and each a composition source shows, compiled once to draw it. They hold
264/// at most [`crate::MAX_PLAN_BYTES`] together: past it none is drawn, so
265/// whether a composition draws does not depend on which others were
266/// compiled first.
267fn compile_plans<'a>(
268    project: &'a Project,
269    lookup: &crate::composition::Lookup<'a>,
270) -> (Plans<'a>, Plans<'a>) {
271    let shown = project
272        .timeline
273        .tracks
274        .iter()
275        .flat_map(|track| &track.clips)
276        .filter_map(|clip| clip.composition.as_ref().map(|reference| &reference.id))
277        .collect::<std::collections::HashSet<_>>();
278    // The composition sources a clip draws, or a media node may read.
279    let sourced = project
280        .sources
281        .iter()
282        .filter_map(|source| source.composition.as_ref())
283        .collect::<std::collections::HashSet<_>>();
284    let mut budget = crate::composition::PlanBudget::new(crate::MAX_PLAN_BYTES);
285    let mut plans = Plans::new();
286    let mut source_plans = Plans::new();
287    for composition in &project.compositions {
288        if shown.contains(&composition.id) {
289            let plan =
290                crate::composition::plan_composition_within(composition, lookup, &mut budget);
291            plans.insert(&composition.id, std::sync::Arc::new(plan));
292        }
293        if sourced.contains(&composition.id) {
294            let plan = crate::composition::plan_source_composition_within(
295                composition,
296                lookup,
297                &mut budget,
298            );
299            source_plans.insert(&composition.id, std::sync::Arc::new(plan));
300        }
301    }
302    if budget.exceeded() {
303        let refused = std::sync::Arc::new(Err(crate::PlanError::TooLarge));
304        for plan in plans.values_mut().chain(source_plans.values_mut()) {
305            *plan = std::sync::Arc::clone(&refused);
306        }
307    }
308    (plans, source_plans)
309}
310
311/// A composition source's composition and its plan.
312fn source_composition_of(
313    source: &alpha2_model::MediaSource,
314    plans: &Plans<'_>,
315) -> Option<(CompositionId, PlanSlot)> {
316    let id = source.composition.as_ref()?;
317    Some((id.clone(), std::sync::Arc::clone(plans.get(id)?)))
318}
319
320/// A compiled plan shared by every clip showing its composition, or the reason it cannot draw.
321pub(crate) type PlanSlot = std::sync::Arc<Result<crate::CompositionPlan, crate::PlanError>>;
322
323/// The most half-open clip intervals that contain one instant.
324fn deepest_overlap(entries: &[IndexedClip]) -> usize {
325    // An interval ending where another starts does not overlap it, so at one instant ends (-1)
326    // sort before starts (+1).
327    let mut edges: Vec<_> = entries
328        .iter()
329        .flat_map(|entry| {
330            [
331                (entry.timeline_range.start(), 1_i8),
332                (entry.timeline_range.end(), -1),
333            ]
334        })
335        .collect();
336    edges.sort_unstable();
337    let mut depth = 0_usize;
338    let mut deepest = 0;
339    for (_, step) in edges {
340        depth = depth.saturating_add_signed(isize::from(step));
341        deepest = deepest.max(depth);
342    }
343    deepest
344}
345
346/// Each shapes source's tree, planned once however many clips show it.
347fn shape_plans(
348    project: &Project,
349) -> std::collections::HashMap<&SourceId, std::sync::Arc<crate::ShapesPlan>> {
350    project
351        .sources
352        .iter()
353        .filter_map(|source| match &source.generator {
354            Some(alpha2_model::Generator::Shapes { items }) => Some((
355                &source.id,
356                std::sync::Arc::new(crate::ShapesPlan::new(items)),
357            )),
358            _ => None,
359        })
360        .collect()
361}