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 host builds an
4//! [`ActiveIndex`] once per immutable [`Project`] and may evaluate any timestamp on any thread.
5
6use crate::{EvalError, IndexedCaptions, IndexedClip, IndexedTransition, IntervalNode, build_tree};
7use alpha2_media::SourceId;
8use alpha2_model::{CompositionId, Project, ProjectId};
9use alpha2_time::TimeRange;
10
11/// A precomputed interval index for a validated project snapshot.
12#[derive(Clone, Debug)]
13pub struct ActiveIndex {
14    pub(crate) project_id: ProjectId,
15    pub(crate) nodes: Vec<IntervalNode>,
16    pub(crate) root: Option<usize>,
17    pub(crate) transitions: Vec<IndexedTransition>,
18    /// Caption tracks in track order.
19    pub(crate) captions: Vec<IndexedCaptions>,
20    /// The most clips active at any one instant.
21    pub(crate) max_active: usize,
22}
23
24impl ActiveIndex {
25    /// Builds an immutable interval tree from a structurally valid project.
26    ///
27    /// # Errors
28    ///
29    /// Returns [`EvalError::InvalidProject`] when the model is invalid.
30    #[allow(clippy::too_many_lines)]
31    pub fn build(project: &Project) -> Result<Self, EvalError> {
32        project.validate().map_err(EvalError::InvalidProject)?;
33        let mut entries = Vec::new();
34        let mut transitions = Vec::new();
35        let matte_sources = project.timeline.matte_sources();
36        let lookup = crate::composition::Lookup::new(
37            &project.compositions,
38            &project.sources,
39            (project.settings.width, project.settings.height),
40        );
41        let (plans, source_plans) = compile_plans(project, &lookup);
42        let sources = project
43            .sources
44            .iter()
45            .map(|source| (&source.id, source))
46            .collect::<std::collections::HashMap<_, _>>();
47        let generators = generator_plans(project);
48        for (track_order, track) in project.timeline.tracks.iter().enumerate() {
49            for (clip_order, clip) in track.clips.iter().enumerate() {
50                // Validation guarantees the source exists.
51                let source = sources.get(&clip.source_id).copied();
52                let source_is_audible = source.is_some_and(alpha2_model::MediaSource::is_audible);
53                let generator = source.and_then(|source| source.generator.clone());
54                // A composition source is planned only if the clip's own composition draws the clip.
55                let drawn = clip
56                    .composition
57                    .as_ref()
58                    .is_none_or(|reference| lookup.reads().draws_clip(&reference.id));
59                let source_composition = source
60                    .filter(|_| drawn)
61                    .and_then(|source| source_composition_of(source, &source_plans));
62                entries.push(IndexedClip {
63                    clip_id: clip.id.clone(),
64                    source_id: clip.source_id.clone(),
65                    track_id: track.id.clone(),
66                    track_kind: track.kind,
67                    timeline_range: clip.timeline_range,
68                    source_range: clip.source_range,
69                    time_map: clip.time_map,
70                    visual: clip.visual.clone(),
71                    // Validation guarantees the composition exists.
72                    composition: clip.composition.as_ref().and_then(|reference| {
73                        plans
74                            .get(&reference.id)
75                            .map(|plan| crate::IndexedComposition {
76                                plan: std::sync::Arc::clone(plan),
77                                reference: reference.clone(),
78                            })
79                    }),
80                    blend_mode: clip.blend_mode,
81                    cache: clip.cache,
82                    masks: clip.masks.clone(),
83                    matte: clip.matte.clone(),
84                    is_matte: matte_sources.contains(&clip.id),
85                    audio: clip.audio.clone(),
86                    source_is_audible,
87                    generator,
88                    plan: generators.get(&clip.source_id).cloned(),
89                    source_composition,
90                    track_order,
91                    clip_order,
92                });
93            }
94            transitions.extend(
95                track
96                    .transitions
97                    .iter()
98                    .map(|transition| IndexedTransition {
99                        from_clip: transition.from_clip.clone(),
100                        to_clip: transition.to_clip.clone(),
101                        timeline_range: transition.timeline_range,
102                        easing: transition.easing,
103                        effect: transition.effect.clone(),
104                        track_order,
105                    }),
106            );
107        }
108        let captions = project
109            .timeline
110            .tracks
111            .iter()
112            .enumerate()
113            .filter_map(|(track_order, track)| {
114                track.captions.as_ref().map(|captions| {
115                    IndexedCaptions::new(track.id.clone(), track_order, 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: steps of each compiled composition (once
160    /// each, in order of first use) and each transition.
161    pub fn effects(&self) -> impl Iterator<Item = &alpha2_model::ClipEffect> {
162        let mut seen = std::collections::HashSet::new();
163        let plans = self
164            .nodes
165            .iter()
166            .flat_map(|node| clip_plans(node.entry()))
167            .flatten()
168            .filter(|slot| seen.insert(std::sync::Arc::as_ptr(slot)))
169            .filter_map(|slot| slot.as_ref().as_ref().ok())
170            .collect::<Vec<_>>();
171        plans
172            .into_iter()
173            .flat_map(crate::CompositionPlan::effects)
174            .chain(self.transitions.iter().map(|transition| &transition.effect))
175    }
176
177    /// Peak number of concurrent host-source reads selected by `counted` in `range`, or 0.
178    ///
179    /// Each read (a clip's own source per [`crate::ActiveClip::needs_source`], or a media step of
180    /// its compositions) gets its own decoder cursor in [`crate::frame_needs`], so this is the
181    /// number of decoders active at once.
182    #[must_use]
183    pub fn peak_source_reads(
184        &self,
185        range: TimeRange,
186        mut counted: impl FnMut(&SourceId) -> bool,
187    ) -> usize {
188        // Keyed by the shared plan allocation so each composition is counted once.
189        let mut plan_reads = std::collections::HashMap::new();
190        let mut events = Vec::new();
191        for entry in self.nodes.iter().map(IntervalNode::entry) {
192            let Some(span) = entry.timeline_range.intersection(range) else {
193                continue;
194            };
195            let [composition, source_composition] = clip_plans(entry);
196            let mut reads = usize::from(draws_own_source(entry) && counted(&entry.source_id));
197            for slot in [composition, source_composition].into_iter().flatten() {
198                reads += *plan_reads
199                    .entry(std::sync::Arc::as_ptr(slot))
200                    .or_insert_with(|| {
201                        slot.as_ref().as_ref().map_or(0, |plan| {
202                            plan.steps
203                                .iter()
204                                .filter(|step| match &step.op {
205                                    crate::StepOp::Media { source_id, .. } => counted(source_id),
206                                    _ => false,
207                                })
208                                .count()
209                        })
210                    });
211            }
212            if reads > 0 {
213                let delta = isize::try_from(reads).unwrap_or(isize::MAX);
214                events.extend([(span.start(), delta), (span.end(), -delta)]);
215            }
216        }
217        // Half-open ranges: ends sort before starts at the same instant.
218        events.sort_unstable_by_key(|&(time, delta)| (time, delta.signum()));
219        let mut active = 0_usize;
220        let mut peak = 0;
221        for (_, delta) in events {
222            active = active.saturating_add_signed(delta);
223            peak = peak.max(active);
224        }
225        peak
226    }
227
228    /// Every host-source read selected by `counted` over `range`, in index order, with readers
229    /// named as in [`crate::frame_needs`]. These are the reads [`Self::peak_source_reads`] counts.
230    ///
231    /// A reader reads only inside its span, so a host can close its decoder once the span ends.
232    /// Within the span it may still skip frames (deduplicated needs, unused mattes).
233    #[must_use]
234    pub fn source_reads(
235        &self,
236        range: TimeRange,
237        mut counted: impl FnMut(&SourceId) -> bool,
238    ) -> Vec<SourceRead> {
239        let mut reads = Vec::new();
240        for entry in self.nodes.iter().map(IntervalNode::entry) {
241            let Some(span) = entry.timeline_range.intersection(range) else {
242                continue;
243            };
244            let read = |source_id: &SourceId, reader| SourceRead {
245                source_id: source_id.clone(),
246                clip_id: entry.clip_id.clone(),
247                reader,
248                span,
249            };
250            if draws_own_source(entry) && counted(&entry.source_id) {
251                reads.push(read(&entry.source_id, None));
252            }
253            let [composition, source_composition] = clip_plans(entry);
254            for (slot, of_source) in [(composition, false), (source_composition, true)] {
255                let Some(Ok(plan)) = slot.map(|slot| slot.as_ref().as_ref()) else {
256                    continue;
257                };
258                for step in &plan.steps {
259                    let crate::StepOp::Media { source_id, .. } = &step.op else {
260                        continue;
261                    };
262                    if !counted(source_id) {
263                        continue;
264                    }
265                    let reader = if of_source {
266                        alpha2_model::NodeId::new(format!("/{}", step.node)).ok()
267                    } else {
268                        Some(step.node.clone())
269                    };
270                    reads.push(read(source_id, reader));
271                }
272            }
273        }
274        reads
275    }
276}
277
278/// One reader of a source over an export range ([`ActiveIndex::source_reads`]).
279#[derive(Clone, Debug, Eq, PartialEq)]
280pub struct SourceRead {
281    /// The source read.
282    pub source_id: SourceId,
283    /// The clip that reads it or shows the reading composition.
284    pub clip_id: alpha2_model::ClipId,
285    /// The reading composition step as in [`crate::FrameNeed::reader`]; `None` for the clip.
286    pub reader: Option<alpha2_model::NodeId>,
287    /// Where on the timeline it may read.
288    pub span: TimeRange,
289}
290
291fn draws_own_source(entry: &IndexedClip) -> bool {
292    let [composition, source_composition] = clip_plans(entry);
293    entry.generator.is_none()
294        && source_composition.is_none()
295        && composition.is_none_or(|slot| {
296            slot.as_ref()
297                .as_ref()
298                .is_ok_and(crate::CompositionPlan::uses_clip)
299        })
300}
301
302/// The compiled compositions `entry` draws: its own, then its composition source's.
303fn clip_plans(entry: &IndexedClip) -> [Option<&PlanSlot>; 2] {
304    [
305        entry
306            .composition
307            .as_ref()
308            .map(|composition| &composition.plan),
309        entry.source_composition.as_ref().map(|(_, plan)| plan),
310    ]
311}
312
313type Plans<'a> = std::collections::HashMap<&'a CompositionId, PlanSlot>;
314
315/// Compiles each used composition once. Together they hold at most [`crate::MAX_PLAN_BYTES`];
316/// past that none draws, so the outcome does not depend on compile order.
317fn compile_plans<'a>(
318    project: &'a Project,
319    lookup: &crate::composition::Lookup<'a>,
320) -> (Plans<'a>, Plans<'a>) {
321    let shown = project
322        .timeline
323        .tracks
324        .iter()
325        .flat_map(|track| &track.clips)
326        .filter_map(|clip| clip.composition.as_ref().map(|reference| &reference.id))
327        .collect::<std::collections::HashSet<_>>();
328    // The composition sources a clip draws, or a media node may read.
329    let sourced = project
330        .sources
331        .iter()
332        .filter_map(|source| source.composition.as_ref())
333        .collect::<std::collections::HashSet<_>>();
334    let mut budget = crate::composition::PlanBudget::new(crate::MAX_PLAN_BYTES);
335    let mut plans = Plans::new();
336    let mut source_plans = Plans::new();
337    for composition in &project.compositions {
338        if shown.contains(&composition.id) {
339            let plan =
340                crate::composition::plan_composition_within(composition, lookup, &mut budget);
341            plans.insert(&composition.id, std::sync::Arc::new(plan));
342        }
343        if sourced.contains(&composition.id) {
344            let plan = crate::composition::plan_source_composition_within(
345                composition,
346                lookup,
347                &mut budget,
348            );
349            source_plans.insert(&composition.id, std::sync::Arc::new(plan));
350        }
351    }
352    if budget.exceeded() {
353        let refused = std::sync::Arc::new(Err(crate::PlanError::TooLarge));
354        for plan in plans.values_mut().chain(source_plans.values_mut()) {
355            *plan = std::sync::Arc::clone(&refused);
356        }
357    }
358    (plans, source_plans)
359}
360
361fn source_composition_of(
362    source: &alpha2_model::MediaSource,
363    plans: &Plans<'_>,
364) -> Option<(CompositionId, PlanSlot)> {
365    let id = source.composition.as_ref()?;
366    Some((id.clone(), std::sync::Arc::clone(plans.get(id)?)))
367}
368
369/// A compiled plan shared by every clip showing its composition, or the reason it cannot draw.
370pub(crate) type PlanSlot = std::sync::Arc<Result<crate::CompositionPlan, crate::PlanError>>;
371
372fn deepest_overlap(entries: &[IndexedClip]) -> usize {
373    // Half-open intervals: ends (-1) sort before starts (+1) at the same instant.
374    let mut edges: Vec<_> = entries
375        .iter()
376        .flat_map(|entry| {
377            [
378                (entry.timeline_range.start(), 1_i8),
379                (entry.timeline_range.end(), -1),
380            ]
381        })
382        .collect();
383    edges.sort_unstable();
384    let mut depth = 0_usize;
385    let mut deepest = 0;
386    for (_, step) in edges {
387        depth = depth.saturating_add_signed(isize::from(step));
388        deepest = deepest.max(depth);
389    }
390    deepest
391}
392
393/// Plans each shapes or layout source once, however many clips show it.
394fn generator_plans(
395    project: &Project,
396) -> std::collections::HashMap<&SourceId, std::sync::Arc<crate::GeneratorPlan>> {
397    project
398        .sources
399        .iter()
400        .filter_map(|source| {
401            let plan = crate::GeneratorPlan::new(
402                source.generator.as_ref()?,
403                (project.settings.width, project.settings.height),
404            )?;
405            Some((&source.id, std::sync::Arc::new(plan)))
406        })
407        .collect()
408}