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(
38            &project.compositions,
39            &project.sources,
40            (project.settings.width, project.settings.height),
41        );
42        let (plans, source_plans) = compile_plans(project, &lookup);
43        let sources = project
44            .sources
45            .iter()
46            .map(|source| (&source.id, source))
47            .collect::<std::collections::HashMap<_, _>>();
48        let generators = generator_plans(project);
49        for (track_order, track) in project.timeline.tracks.iter().enumerate() {
50            for (clip_order, clip) in track.clips.iter().enumerate() {
51                // Validation guarantees the source exists.
52                let source = sources.get(&clip.source_id).copied();
53                let source_is_audible = source.is_some_and(alpha2_model::MediaSource::is_audible);
54                let generator = source.and_then(|source| source.generator.clone());
55                // A composition source is the clip's picture: it is drawn, and so planned, read,
56                // and required, only where the clip's own composition draws the clip.
57                let drawn = clip
58                    .composition
59                    .as_ref()
60                    .is_none_or(|reference| lookup.reads().draws_clip(&reference.id));
61                let source_composition = source
62                    .filter(|_| drawn)
63                    .and_then(|source| source_composition_of(source, &source_plans));
64                entries.push(IndexedClip {
65                    clip_id: clip.id.clone(),
66                    source_id: clip.source_id.clone(),
67                    track_id: track.id.clone(),
68                    track_kind: track.kind,
69                    timeline_range: clip.timeline_range,
70                    source_range: clip.source_range,
71                    time_map: clip.time_map,
72                    visual: clip.visual.clone(),
73                    // Validation guarantees the composition exists.
74                    composition: clip.composition.as_ref().and_then(|reference| {
75                        plans
76                            .get(&reference.id)
77                            .map(|plan| crate::IndexedComposition {
78                                plan: std::sync::Arc::clone(plan),
79                                reference: reference.clone(),
80                            })
81                    }),
82                    blend_mode: clip.blend_mode,
83                    cache: clip.cache,
84                    masks: clip.masks.clone(),
85                    matte: clip.matte.clone(),
86                    is_matte: matte_sources.contains(&clip.id),
87                    audio: clip.audio.clone(),
88                    source_is_audible,
89                    generator,
90                    plan: generators.get(&clip.source_id).cloned(),
91                    source_composition,
92                    track_order,
93                    clip_order,
94                });
95            }
96            transitions.extend(
97                track
98                    .transitions
99                    .iter()
100                    .map(|transition| IndexedTransition {
101                        from_clip: transition.from_clip.clone(),
102                        to_clip: transition.to_clip.clone(),
103                        timeline_range: transition.timeline_range,
104                        easing: transition.easing,
105                        effect: transition.effect.clone(),
106                        track_order,
107                    }),
108            );
109        }
110        let captions = project
111            .timeline
112            .tracks
113            .iter()
114            .enumerate()
115            .filter_map(|(track_order, track)| {
116                track.captions.as_ref().map(|captions| IndexedCaptions {
117                    track_id: track.id.clone(),
118                    track_order,
119                    captions: captions.clone(),
120                })
121            })
122            .collect();
123        entries.sort_unstable_by_key(|entry| {
124            (
125                entry.timeline_range.start(),
126                entry.track_order,
127                entry.clip_order,
128            )
129        });
130        let max_active = deepest_overlap(&entries);
131        let (nodes, root) = build_tree(entries);
132        transitions.sort_unstable_by_key(|transition| {
133            (transition.timeline_range.start(), transition.track_order)
134        });
135        Ok(Self {
136            project_id: project.id.clone(),
137            nodes,
138            root,
139            transitions,
140            captions,
141            max_active,
142        })
143    }
144
145    /// Returns the project identity used to build this index.
146    #[must_use]
147    pub fn project_id(&self) -> &ProjectId {
148        &self.project_id
149    }
150
151    /// Returns the number of clips indexed.
152    #[must_use]
153    pub fn len(&self) -> usize {
154        self.nodes.len()
155    }
156
157    /// Returns whether the index has no clips.
158    #[must_use]
159    pub fn is_empty(&self) -> bool {
160        self.nodes.is_empty()
161    }
162
163    /// Every effect invocation the project can draw: the steps of each
164    /// composition a clip shows or draws as its source (those that compile),
165    /// and each transition. A compiled composition is shared by every clip
166    /// that shows it, so its steps come once however many clips show it, in
167    /// index order of its first use: the walk is linear in the clips and the
168    /// distinct compositions, never in their product.
169    pub fn effects(&self) -> impl Iterator<Item = &alpha2_model::ClipEffect> {
170        let mut seen = std::collections::HashSet::new();
171        let plans = self
172            .nodes
173            .iter()
174            .flat_map(|node| clip_plans(node.entry()))
175            .flatten()
176            .filter(|slot| seen.insert(std::sync::Arc::as_ptr(slot)))
177            .filter_map(|slot| slot.as_ref().as_ref().ok())
178            .collect::<Vec<_>>();
179        plans
180            .into_iter()
181            .flat_map(crate::CompositionPlan::effects)
182            .chain(self.transitions.iter().map(|transition| &transition.effect))
183    }
184
185    /// The most sequential reads of host-supplied sources that `counted`
186    /// selects at any one instant of `range`, or zero when none is read
187    /// there. A clip reads its own source while it draws it
188    /// ([`crate::ActiveClip::needs_source`]), and the source of each media
189    /// step of its compositions; [`crate::frame_needs`] asks for each of these
190    /// frames through its own decoder cursor, so this says how many cursors
191    /// decode at once.
192    ///
193    /// Each compiled composition's reads are counted once however many clips
194    /// show it, so this is linear in the clips and the distinct
195    /// compositions' steps, never in their product.
196    #[must_use]
197    pub fn peak_source_reads(
198        &self,
199        range: TimeRange,
200        mut counted: impl FnMut(&SourceId) -> bool,
201    ) -> usize {
202        // The reads of each compiled composition, by its shared allocation.
203        let mut plan_reads = std::collections::HashMap::new();
204        let mut events = Vec::new();
205        for entry in self.nodes.iter().map(IntervalNode::entry) {
206            let Some(span) = entry.timeline_range.intersection(range) else {
207                continue;
208            };
209            let [composition, source_composition] = clip_plans(entry);
210            let mut reads = usize::from(draws_own_source(entry) && counted(&entry.source_id));
211            for slot in [composition, source_composition].into_iter().flatten() {
212                reads += *plan_reads
213                    .entry(std::sync::Arc::as_ptr(slot))
214                    .or_insert_with(|| {
215                        slot.as_ref().as_ref().map_or(0, |plan| {
216                            plan.steps
217                                .iter()
218                                .filter(|step| match &step.op {
219                                    crate::StepOp::Media { source_id, .. } => counted(source_id),
220                                    _ => false,
221                                })
222                                .count()
223                        })
224                    });
225            }
226            if reads > 0 {
227                let delta = isize::try_from(reads).unwrap_or(isize::MAX);
228                events.extend([(span.start(), delta), (span.end(), -delta)]);
229            }
230        }
231        // Ranges are half-open: at one instant, the reads ending leave before those starting enter.
232        events.sort_unstable_by_key(|&(time, delta)| (time, delta.signum()));
233        let mut active = 0_usize;
234        let mut peak = 0;
235        for (_, delta) in events {
236            active = active.saturating_add_signed(delta);
237            peak = peak.max(active);
238        }
239        peak
240    }
241
242    /// Every sequential read of a host-supplied source that `counted`
243    /// selects over `range`: the reader, named as [`crate::frame_needs`]
244    /// names it (the clip, and the composition step reading for it, `/`
245    /// before a step of its composition source), and the part of `range` it
246    /// may read in, the clip's span there. The reads
247    /// [`Self::peak_source_reads`] counts, one entry each, in index order.
248    ///
249    /// A reader reads only inside its span: a host can close the decoder of
250    /// a reader whose span has ended, and tell which open decoder it reads
251    /// last. Within its span a reader may still skip frames (a frame another
252    /// reader of the same source frame asks for, or a matte no clip names).
253    #[must_use]
254    pub fn source_reads(
255        &self,
256        range: TimeRange,
257        mut counted: impl FnMut(&SourceId) -> bool,
258    ) -> Vec<SourceRead> {
259        let mut reads = Vec::new();
260        for entry in self.nodes.iter().map(IntervalNode::entry) {
261            let Some(span) = entry.timeline_range.intersection(range) else {
262                continue;
263            };
264            let read = |source_id: &SourceId, reader| SourceRead {
265                source_id: source_id.clone(),
266                clip_id: entry.clip_id.clone(),
267                reader,
268                span,
269            };
270            if draws_own_source(entry) && counted(&entry.source_id) {
271                reads.push(read(&entry.source_id, None));
272            }
273            let [composition, source_composition] = clip_plans(entry);
274            for (slot, of_source) in [(composition, false), (source_composition, true)] {
275                let Some(Ok(plan)) = slot.map(|slot| slot.as_ref().as_ref()) else {
276                    continue;
277                };
278                for step in &plan.steps {
279                    let crate::StepOp::Media { source_id, .. } = &step.op else {
280                        continue;
281                    };
282                    if !counted(source_id) {
283                        continue;
284                    }
285                    let reader = if of_source {
286                        alpha2_model::NodeId::new(format!("/{}", step.node)).ok()
287                    } else {
288                        Some(step.node.clone())
289                    };
290                    reads.push(read(source_id, reader));
291                }
292            }
293        }
294        reads
295    }
296}
297
298/// One reader of a source over an export range ([`ActiveIndex::source_reads`]).
299#[derive(Clone, Debug, Eq, PartialEq)]
300pub struct SourceRead {
301    /// The source read.
302    pub source_id: SourceId,
303    /// The clip that reads it, or shows the composition that does.
304    pub clip_id: alpha2_model::ClipId,
305    /// The composition step that reads, as [`crate::FrameNeed::reader`]
306    /// names it; `None` for the clip's own source.
307    pub reader: Option<alpha2_model::NodeId>,
308    /// Where on the timeline it may read.
309    pub span: TimeRange,
310}
311
312/// Whether `entry` draws its own source picture: it has no generator or
313/// composition source, and any composition it shows uses its clip.
314fn draws_own_source(entry: &IndexedClip) -> bool {
315    let [composition, source_composition] = clip_plans(entry);
316    entry.generator.is_none()
317        && source_composition.is_none()
318        && composition.is_none_or(|slot| {
319            slot.as_ref()
320                .as_ref()
321                .is_ok_and(crate::CompositionPlan::uses_clip)
322        })
323}
324
325/// The compiled compositions `entry` draws: its own, then its composition
326/// source's.
327fn clip_plans(entry: &IndexedClip) -> [Option<&PlanSlot>; 2] {
328    [
329        entry
330            .composition
331            .as_ref()
332            .map(|composition| &composition.plan),
333        entry.source_composition.as_ref().map(|(_, plan)| plan),
334    ]
335}
336
337/// The compiled compositions of a project: each a clip shows, and each a
338/// composition source shows.
339type Plans<'a> = std::collections::HashMap<&'a CompositionId, PlanSlot>;
340
341/// Each composition a clip shows, compiled once however many clips show it,
342/// and each a composition source shows, compiled once to draw it. They hold
343/// at most [`crate::MAX_PLAN_BYTES`] together: past it none is drawn, so
344/// whether a composition draws does not depend on which others were
345/// compiled first.
346fn compile_plans<'a>(
347    project: &'a Project,
348    lookup: &crate::composition::Lookup<'a>,
349) -> (Plans<'a>, Plans<'a>) {
350    let shown = project
351        .timeline
352        .tracks
353        .iter()
354        .flat_map(|track| &track.clips)
355        .filter_map(|clip| clip.composition.as_ref().map(|reference| &reference.id))
356        .collect::<std::collections::HashSet<_>>();
357    // The composition sources a clip draws, or a media node may read.
358    let sourced = project
359        .sources
360        .iter()
361        .filter_map(|source| source.composition.as_ref())
362        .collect::<std::collections::HashSet<_>>();
363    let mut budget = crate::composition::PlanBudget::new(crate::MAX_PLAN_BYTES);
364    let mut plans = Plans::new();
365    let mut source_plans = Plans::new();
366    for composition in &project.compositions {
367        if shown.contains(&composition.id) {
368            let plan =
369                crate::composition::plan_composition_within(composition, lookup, &mut budget);
370            plans.insert(&composition.id, std::sync::Arc::new(plan));
371        }
372        if sourced.contains(&composition.id) {
373            let plan = crate::composition::plan_source_composition_within(
374                composition,
375                lookup,
376                &mut budget,
377            );
378            source_plans.insert(&composition.id, std::sync::Arc::new(plan));
379        }
380    }
381    if budget.exceeded() {
382        let refused = std::sync::Arc::new(Err(crate::PlanError::TooLarge));
383        for plan in plans.values_mut().chain(source_plans.values_mut()) {
384            *plan = std::sync::Arc::clone(&refused);
385        }
386    }
387    (plans, source_plans)
388}
389
390/// A composition source's composition and its plan.
391fn source_composition_of(
392    source: &alpha2_model::MediaSource,
393    plans: &Plans<'_>,
394) -> Option<(CompositionId, PlanSlot)> {
395    let id = source.composition.as_ref()?;
396    Some((id.clone(), std::sync::Arc::clone(plans.get(id)?)))
397}
398
399/// A compiled plan shared by every clip showing its composition, or the reason it cannot draw.
400pub(crate) type PlanSlot = std::sync::Arc<Result<crate::CompositionPlan, crate::PlanError>>;
401
402/// The most half-open clip intervals that contain one instant.
403fn deepest_overlap(entries: &[IndexedClip]) -> usize {
404    // An interval ending where another starts does not overlap it, so at one instant ends (-1)
405    // sort before starts (+1).
406    let mut edges: Vec<_> = entries
407        .iter()
408        .flat_map(|entry| {
409            [
410                (entry.timeline_range.start(), 1_i8),
411                (entry.timeline_range.end(), -1),
412            ]
413        })
414        .collect();
415    edges.sort_unstable();
416    let mut depth = 0_usize;
417    let mut deepest = 0;
418    for (_, step) in edges {
419        depth = depth.saturating_add_signed(isize::from(step));
420        deepest = deepest.max(depth);
421    }
422    deepest
423}
424
425/// Each shapes or layout source's tree, planned once however many clips
426/// show it.
427fn generator_plans(
428    project: &Project,
429) -> std::collections::HashMap<&SourceId, std::sync::Arc<crate::GeneratorPlan>> {
430    project
431        .sources
432        .iter()
433        .filter_map(|source| {
434            let plan = crate::GeneratorPlan::new(
435                source.generator.as_ref()?,
436                (project.settings.width, project.settings.height),
437            )?;
438            Some((&source.id, std::sync::Arc::new(plan)))
439        })
440        .collect()
441}