Skip to main content

alpha2_engine/
frame.rs

1//! Rendering of evaluated timeline frames.
2//!
3//! The renderer knows render graphs, not timelines. This module is where an
4//! evaluated [`FrameDesc`] becomes one: it checks that every clip's source
5//! frame has arrived, compiles clips, effect chains, and transitions into a
6//! [`RenderGraph`], and hands that graph to the renderer's submission paths.
7
8use alpha2_eval::{
9    ActiveClip, ActiveComposition, ActiveEffect, ActiveTransition, EvaluatedVisual, FrameDesc,
10    StepOp, sample_scalar, sample_vec2, sample_visual,
11};
12use alpha2_media::{SourceId, YuvEncoding};
13use alpha2_model::{
14    ClipBlendMode, EffectValue, Generator, MaskMode, MaskShape, MatteMode, Rgb, ShapeMask,
15    TextStyle,
16};
17use alpha2_render::{
18    BlendMode, CacheIntent, Color, EffectId, EffectParams, EffectResources, EffectRuntime,
19    FrameSubmitTimings, GENERATOR_EFFECT_ID, GraphError, GraphLayer, LayerTransform,
20    MAX_SHAPE_MASKS, Nv12PlaneTargets, ParamSpec, ParamValue, PendingSourceWork, RenderCacheKey,
21    RenderError, RenderGraph, RenderNodeId, RenderNodeKind, RenderSize, RenderedTexture, Renderer,
22    SHAPE_MASK_EFFECT_ID, SHAPE_MASK_PARAMS, SourceView, TRACK_MATTE_EFFECT_ID, Yuv420pReadback,
23};
24use alpha2_time::MediaTime;
25
26use crate::TextTexture;
27use std::collections::{HashMap, HashSet};
28use std::time::Duration;
29use web_time::Instant;
30
31/// Supplies decoded source frames as GPU textures to the renderer.
32///
33/// Implementations own decode caches and the lifetime of their textures, and
34/// lend them as [`SourceView`]s. The pure model and evaluator crates never
35/// observe this platform-specific type. Textures must store premultiplied,
36/// sRGB-encoded RGBA in a non-sRGB GPU format, as returned by
37/// `Renderer::upload_rgba8` or `Renderer::render_yuv420p`.
38pub trait FrameProvider {
39    /// Returns a texture for `source` at the requested source timestamp.
40    ///
41    /// The texture is the frame presented at `time`: the one whose
42    /// presentation interval contains it, the last frame whose timestamp is
43    /// at or before `time` plus the stream's presentation slack, as
44    /// [`alpha2_media::Decoder::seek`] resolves a time. Before the stream's
45    /// first frame that frame is shown; past the last, the last. A host that
46    /// looks frames up by the requested time stores each under every time it
47    /// was asked for.
48    ///
49    /// `None` means the frame has not arrived yet; real-time runners may hold
50    /// their previous output while deterministic exporters wait and retry.
51    fn texture_for(&self, source: &SourceId, time: MediaTime) -> Option<SourceView<'_>>;
52
53    /// A key for the texture [`Self::texture_for`] returns: the host's
54    /// promise that the texture's pixels depend on nothing the key leaves
55    /// out: the source's content, the frame, and how it was decoded,
56    /// converted, and presented. Pictures drawn from keyed sources can be
57    /// kept and reused (see [`alpha2_render::Renderer::set_result_cache_budget`]);
58    /// the default, no key, keeps nothing drawn from the source.
59    fn source_key(&self, _source: &SourceId, _time: MediaTime) -> Option<RenderCacheKey> {
60        None
61    }
62
63    /// Returns presentation work that has not yet been submitted for this
64    /// physical source texture. The default covers already-ready textures.
65    fn pending_source_work(
66        &self,
67        _source: &SourceId,
68        _time: MediaTime,
69    ) -> Option<&PendingSourceWork> {
70        None
71    }
72
73    /// Returns the text [`crate::text_key`] names, a caption or a text
74    /// generator prepared by the host's [`crate::TextTextures`]. The default
75    /// holds none: a frame that shows text then fails with
76    /// [`RenderError::MissingText`].
77    fn text(&self, _key: u64) -> Option<TextTexture<'_>> {
78        None
79    }
80}
81
82/// Outcome of one render attempt when sources may arrive asynchronously.
83#[derive(Clone, Debug, Default, PartialEq, Eq)]
84pub struct RenderReport {
85    /// Sources whose requested frame was unavailable.
86    pub missing_sources: Vec<SourceId>,
87}
88
89impl RenderReport {
90    /// Returns whether every requested source frame was available.
91    #[must_use]
92    pub fn is_complete(&self) -> bool {
93        self.missing_sources.is_empty()
94    }
95}
96
97/// Result of attempting to render one evaluated timeline frame.
98#[derive(Debug)]
99pub struct FrameRender {
100    /// Availability information collected before any GPU work is submitted.
101    pub report: RenderReport,
102    /// Rendered output, absent when one or more source frames are unavailable.
103    pub texture: Option<RenderedTexture>,
104}
105
106impl FrameRender {
107    /// Returns whether a texture was rendered from a complete source set.
108    #[must_use]
109    pub fn is_complete(&self) -> bool {
110        self.texture.is_some()
111    }
112}
113
114/// Result of rendering and scheduling one frame for planar YUV420 readback.
115#[derive(Debug)]
116pub struct FrameYuv420pRender {
117    /// Availability information collected before any GPU work is submitted.
118    pub report: RenderReport,
119    /// Submitted readback, absent when source frames are unavailable.
120    pub readback: Option<Yuv420pReadback>,
121}
122
123impl FrameYuv420pRender {
124    /// Returns whether a complete frame was submitted for readback.
125    #[must_use]
126    pub fn is_complete(&self) -> bool {
127        self.readback.is_some()
128    }
129}
130
131/// Result of rendering one frame into caller-owned NV12 plane textures.
132#[derive(Debug)]
133#[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
134pub struct FrameNv12Render {
135    /// Availability information collected before any GPU work is submitted.
136    pub report: RenderReport,
137    /// Whether the pack was submitted; false when a source was missing.
138    pub submitted: bool,
139}
140
141/// One evaluated frame compiled into a render graph, not yet submitted.
142#[derive(Debug)]
143pub struct FrameGraph<'a> {
144    /// Availability information collected before the graph was built.
145    pub report: RenderReport,
146    /// The frame's graph, absent when source frames are unavailable.
147    pub graph: Option<RenderGraph<'a>>,
148    /// Presentation work behind the graph's source textures that has not
149    /// been submitted yet, for the submission to fold in.
150    pub pending: Vec<&'a PendingSourceWork>,
151}
152
153/// Timeline-frame rendering on top of a [`Renderer`].
154///
155/// Implemented for [`Renderer`]; import it to call these methods on one.
156pub trait FrameRenderer {
157    /// Resolves the textures required by an evaluated frame.
158    ///
159    /// This performs no fallback rendering: it is the common availability pass
160    /// used by a real-time player (which may reuse an older image) and a
161    /// deterministic exporter (which waits and retries).
162    #[must_use]
163    fn inspect_frame(&self, frame: &FrameDesc, frames: &dyn FrameProvider) -> RenderReport;
164
165    /// Compiles one evaluator result into the render graph every render
166    /// method below submits, without encoding GPU work.
167    ///
168    /// The graph borrows the frame's effect and parameter names and the
169    /// provider's textures; an incomplete source set returns its report and
170    /// no graph.
171    ///
172    /// # Errors
173    /// Returns [`RenderError`] when an effect is unknown or a visual or
174    /// caption is invalid.
175    fn build_frame_graph<'a>(
176        &self,
177        size: RenderSize,
178        frame: &'a FrameDesc<'a>,
179        frames: &'a dyn FrameProvider,
180    ) -> Result<FrameGraph<'a>, RenderError>;
181
182    /// Renders one pure evaluator result using textures supplied by the host.
183    ///
184    /// Source availability is checked before encoding GPU work. An incomplete
185    /// attempt returns its missing-source report with no partially rendered
186    /// texture, allowing real-time and deterministic runners to apply their
187    /// own hold or wait policy.
188    ///
189    /// # Errors
190    /// Returns [`RenderError`] when a visual or render target is invalid.
191    fn render_frame(
192        &self,
193        size: RenderSize,
194        frame: &FrameDesc,
195        frames: &dyn FrameProvider,
196    ) -> Result<FrameRender, RenderError>;
197
198    /// Renders one evaluator result, converts it to YUV420, and submits both
199    /// workloads in one command buffer.
200    ///
201    /// An incomplete source set returns without encoding or submitting work.
202    /// The output size must be even for chroma subsampling.
203    ///
204    /// # Errors
205    /// Returns [`RenderError`] for invalid graph, target, or YUV layout data.
206    fn render_frame_yuv420p(
207        &self,
208        size: RenderSize,
209        frame: &FrameDesc,
210        frames: &dyn FrameProvider,
211        encoding: YuvEncoding,
212    ) -> Result<FrameYuv420pRender, RenderError>;
213
214    /// Renders one frame for YUV420 readback and reports where the call went.
215    ///
216    /// This produces exactly what [`FrameRenderer::render_frame_yuv420p`]
217    /// produces; the only difference is that it reads a clock between stages
218    /// and writes the spans to `timings`. An incomplete source set leaves
219    /// `timings` holding only the stages that ran.
220    ///
221    /// # Errors
222    /// Returns [`RenderError`] for invalid graph, target, or YUV layout data.
223    fn render_frame_yuv420p_timed(
224        &self,
225        size: RenderSize,
226        frame: &FrameDesc,
227        frames: &dyn FrameProvider,
228        encoding: YuvEncoding,
229        timings: &mut FrameSubmitTimings,
230    ) -> Result<FrameYuv420pRender, RenderError>;
231
232    /// Renders one frame and packs it straight into caller-owned NV12 plane
233    /// textures, with no readback.
234    ///
235    /// This is [`FrameRenderer::render_frame_yuv420p_timed`] with the staging
236    /// buffer removed: the same sources, the same graph and the same
237    /// conversion, ending in two textures a hardware encoder can be fed from
238    /// instead of a mapped buffer. An incomplete source set submits nothing.
239    ///
240    /// # Errors
241    /// Returns [`RenderError`] for invalid graph or plane-target data.
242    #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
243    fn render_frame_nv12_timed(
244        &self,
245        size: RenderSize,
246        frame: &FrameDesc,
247        frames: &dyn FrameProvider,
248        planes: Nv12PlaneTargets<'_>,
249        encoding: YuvEncoding,
250        timings: &mut FrameSubmitTimings,
251    ) -> Result<FrameNv12Render, RenderError>;
252}
253
254impl FrameRenderer for Renderer {
255    fn inspect_frame(&self, frame: &FrameDesc, frames: &dyn FrameProvider) -> RenderReport {
256        available_textures(frame, frames).0
257    }
258
259    fn build_frame_graph<'a>(
260        &self,
261        size: RenderSize,
262        frame: &'a FrameDesc<'a>,
263        frames: &'a dyn FrameProvider,
264    ) -> Result<FrameGraph<'a>, RenderError> {
265        validate_project_effects(self, frame)?;
266        let (report, mut available) = available_textures(frame, frames);
267        if !report.is_complete() {
268            return Ok(FrameGraph {
269                report,
270                graph: None,
271                pending: Vec::new(),
272            });
273        }
274        attach_texts(&mut available, frames, size);
275        let captions = caption_layers(frame, frames, size)?;
276        let graph = frame_graph(self, size, frame, &available, &captions)?;
277        Ok(FrameGraph {
278            report,
279            graph: Some(graph),
280            pending: pending_source_work(&available, frames),
281        })
282    }
283
284    fn render_frame(
285        &self,
286        size: RenderSize,
287        frame: &FrameDesc,
288        frames: &dyn FrameProvider,
289    ) -> Result<FrameRender, RenderError> {
290        let FrameGraph {
291            report,
292            graph,
293            pending,
294        } = self.build_frame_graph(size, frame, frames)?;
295        let texture = match graph {
296            Some(graph) => Some(self.render_graph(&graph, &pending)?),
297            None => None,
298        };
299        Ok(FrameRender { report, texture })
300    }
301
302    fn render_frame_yuv420p(
303        &self,
304        size: RenderSize,
305        frame: &FrameDesc,
306        frames: &dyn FrameProvider,
307        encoding: YuvEncoding,
308    ) -> Result<FrameYuv420pRender, RenderError> {
309        render_frame_yuv420p_measured(self, size, frame, frames, encoding, None)
310    }
311
312    fn render_frame_yuv420p_timed(
313        &self,
314        size: RenderSize,
315        frame: &FrameDesc,
316        frames: &dyn FrameProvider,
317        encoding: YuvEncoding,
318        timings: &mut FrameSubmitTimings,
319    ) -> Result<FrameYuv420pRender, RenderError> {
320        render_frame_yuv420p_measured(self, size, frame, frames, encoding, Some(timings))
321    }
322
323    #[cfg_attr(not(feature = "wgpu-interop"), doc(hidden))]
324    fn render_frame_nv12_timed(
325        &self,
326        size: RenderSize,
327        frame: &FrameDesc,
328        frames: &dyn FrameProvider,
329        planes: Nv12PlaneTargets<'_>,
330        encoding: YuvEncoding,
331        timings: &mut FrameSubmitTimings,
332    ) -> Result<FrameNv12Render, RenderError> {
333        let mut clock = StageClock::start(true);
334        let mut collected = FrameSubmitTimings::default();
335        let FrameGraph {
336            report,
337            graph,
338            pending,
339        } = self.build_frame_graph(size, frame, frames)?;
340        collected.graph_build = clock.lap();
341        let submitted = graph.is_some();
342        if let Some(graph) = graph {
343            self.render_graph_nv12_timed(&graph, &pending, planes, encoding, &mut collected)?;
344        }
345        *timings = collected;
346        Ok(FrameNv12Render { report, submitted })
347    }
348}
349
350fn render_frame_yuv420p_measured(
351    renderer: &Renderer,
352    size: RenderSize,
353    frame: &FrameDesc,
354    frames: &dyn FrameProvider,
355    encoding: YuvEncoding,
356    timings: Option<&mut FrameSubmitTimings>,
357) -> Result<FrameYuv420pRender, RenderError> {
358    let measure = timings.is_some();
359    let mut clock = StageClock::start(measure);
360    let mut collected = FrameSubmitTimings::default();
361    let FrameGraph {
362        report,
363        graph,
364        pending,
365    } = renderer.build_frame_graph(size, frame, frames)?;
366    collected.graph_build = clock.lap();
367    let readback = match graph {
368        Some(graph) if measure => {
369            Some(renderer.render_graph_yuv420p_timed(&graph, &pending, encoding, &mut collected)?)
370        }
371        Some(graph) => Some(renderer.render_graph_yuv420p(&graph, &pending, encoding)?),
372        None => None,
373    };
374    if let Some(timings) = timings {
375        *timings = collected;
376    }
377    Ok(FrameYuv420pRender { report, readback })
378}
379
380/// A lap timer that reads no clock unless a caller asked for timings.
381#[derive(Copy, Clone)]
382struct StageClock(Option<Instant>);
383
384impl StageClock {
385    fn start(enabled: bool) -> Self {
386        Self(enabled.then(Instant::now))
387    }
388
389    /// Returns the span since the previous lap and starts a new one.
390    fn lap(&mut self) -> Duration {
391        let Some(mark) = self.0 else {
392            return Duration::ZERO;
393        };
394        let now = Instant::now();
395        self.0 = Some(now);
396        now.duration_since(mark)
397    }
398}
399
400fn validate_project_effects(renderer: &Renderer, frame: &FrameDesc) -> Result<(), RenderError> {
401    require_effects(renderer, frame_effects(frame)).map_err(|_| RenderError::UnknownEffect)
402}
403
404/// Every effect `frame` draws, by id and version: its compositions' steps
405/// and its transitions.
406fn frame_effects<'a>(frame: &'a FrameDesc) -> impl Iterator<Item = (&'a str, u32)> {
407    let composition_effects = frame
408        .visual
409        .iter()
410        .flat_map(|clip| clip.composition.into_iter().chain(clip.source_composition))
411        .flat_map(|composition| composition.plan().effects())
412        .map(|effect| (effect.id.as_str(), effect.version));
413    let transition_effects = frame
414        .transitions
415        .iter()
416        .map(|transition| (transition.effect.id(), transition.effect.version()));
417    composition_effects.chain(transition_effects)
418}
419
420/// Checks that `renderer` draws every effect `frame` uses, naming the first
421/// it does not. [`FrameRenderer`]'s methods check the same, but answer with
422/// a [`RenderError`], which cannot say which effect.
423///
424/// # Errors
425/// Returns [`crate::EngineError::UnregisteredEffect`].
426pub(crate) fn check_frame_effects(
427    renderer: &Renderer,
428    frame: &FrameDesc,
429) -> Result<(), crate::EngineError> {
430    require_effects(renderer, frame_effects(frame))
431}
432
433/// The first of `effects` `renderer` does not draw, as an error.
434pub(crate) fn require_effects<'a>(
435    renderer: &Renderer,
436    effects: impl IntoIterator<Item = (&'a str, u32)>,
437) -> Result<(), crate::EngineError> {
438    for (effect, version) in effects {
439        if renderer.project_effect_id(effect, version).is_none() {
440            return Err(crate::EngineError::UnregisteredEffect {
441                effect: effect.to_owned(),
442                version,
443            });
444        }
445    }
446    Ok(())
447}
448
449/// Collects the pending presentation work behind each available clip's
450/// textures, in composition order, for the renderer to fold into its
451/// submission.
452fn pending_source_work<'a>(
453    available: &[(&ActiveClip, ClipFrames<'_>)],
454    frames: &'a dyn FrameProvider,
455) -> Vec<&'a PendingSourceWork> {
456    let mut work = Vec::new();
457    for (clip, textures) in available {
458        if textures.source.is_some() {
459            work.extend(frames.pending_source_work(clip.source_id, clip.source_time));
460        }
461        for composition in clip.composition.iter().chain(&clip.source_composition) {
462            for (_, source, time) in composition.media() {
463                work.extend(frames.pending_source_work(source, time));
464            }
465        }
466    }
467    work
468}
469
470/// A host-supplied frame and, when the host vouches for it, its key.
471#[derive(Copy, Clone, Debug)]
472struct Frame<'t> {
473    view: SourceView<'t>,
474    key: Option<RenderCacheKey>,
475}
476
477impl<'t> Frame<'t> {
478    /// The frame as a graph source: keyed when the host gave a key.
479    fn add_to(self, graph: &mut RenderGraph<'t>) -> RenderNodeId {
480        match self.key {
481            Some(key) => graph.add_source_with_key(self.view, key),
482            None => graph.add_source(self.view),
483        }
484    }
485}
486
487/// Text a generator draws: its prepared texture, placed on the
488/// generator's transparent, canvas-sized picture.
489#[derive(Copy, Clone, Debug)]
490struct PlacedText<'t> {
491    texture: TextTexture<'t>,
492    visual: LayerTransform,
493}
494
495impl<'t> PlacedText<'t> {
496    /// The generator's picture: the texture, keyed as its store keys it,
497    /// over transparency.
498    fn add_to(self, graph: &mut RenderGraph<'t>) -> Result<RenderNodeId, RenderError> {
499        let source = match self.texture.cache_key {
500            Some(key) => graph.add_source_with_key(self.texture.texture, key),
501            None => graph.add_source(self.texture.texture),
502        };
503        graph
504            .add_composite(vec![GraphLayer {
505                source,
506                visual: self.visual,
507                blend_mode: BlendMode::Normal,
508            }])
509            .map_err(GraphError::into_render_error)
510    }
511}
512
513/// What one composition step draws from outside the graph.
514#[derive(Copy, Clone, Debug)]
515enum StepFrame<'t> {
516    /// A media step's host-supplied frame.
517    Media(Frame<'t>),
518    /// A text generator step's text.
519    Text(PlacedText<'t>),
520}
521
522/// The host-supplied frames one visual clip draws.
523#[derive(Clone, Debug, Default)]
524struct ClipFrames<'t> {
525    /// The clip's index in [`FrameDesc::visual`], its place in track order,
526    /// which a caption's [`alpha2_eval::ActiveCaption::layers_below`] counts
527    /// in: the clips drawn are a subset, so their positions among
528    /// themselves do not say which tracks a caption lies between.
529    layer: usize,
530    /// The clip's own source frame; `None` when the engine draws the source
531    /// or the clip's composition does not show it.
532    source: Option<Frame<'t>>,
533    /// The text of the clip's text generator source, once prepared.
534    text: Option<PlacedText<'t>>,
535    /// Each media step's frame and each text generator step's text, by step
536    /// index; `None` for other steps.
537    media: Vec<Option<StepFrame<'t>>>,
538    /// The same for the clip's composition source.
539    source_media: Vec<Option<StepFrame<'t>>>,
540}
541
542/// Each visual clip with the source frames it draws. A frame the host has
543/// not supplied is reported missing, and its clip left out.
544fn available_textures<'frame, 'eval, 'texture>(
545    frame: &'frame FrameDesc<'eval>,
546    frames: &'texture dyn FrameProvider,
547) -> (
548    RenderReport,
549    Vec<(&'frame ActiveClip<'eval>, ClipFrames<'texture>)>,
550) {
551    let mut report = RenderReport::default();
552    let mut available = Vec::with_capacity(frame.visual.len());
553    let mut lookup = |source: &SourceId, time| {
554        let texture = frames.texture_for(source, time);
555        if texture.is_none() && !report.missing_sources.contains(source) {
556            report.missing_sources.push(source.clone());
557        }
558        texture.map(|view| Frame {
559            view,
560            key: frames.source_key(source, time),
561        })
562    };
563    // A matte no active clip names is drawn nowhere; it needs no frame.
564    for (layer, clip) in frame.visual.iter().enumerate() {
565        if !frame.draws(clip) {
566            continue;
567        }
568        let mut textures = ClipFrames {
569            layer,
570            ..ClipFrames::default()
571        };
572        let mut complete = true;
573        if clip.needs_source() {
574            textures.source = lookup(clip.source_id, clip.source_time);
575            complete &= textures.source.is_some();
576        }
577        for (composition, media) in [
578            (clip.composition, &mut textures.media),
579            (clip.source_composition, &mut textures.source_media),
580        ] {
581            let Some(composition) = composition else {
582                continue;
583            };
584            *media = vec![None; composition.plan().steps.len()];
585            for (step, source, time) in composition.media() {
586                media[step] = lookup(source, time).map(StepFrame::Media);
587                complete &= media[step].is_some();
588            }
589        }
590        if complete {
591            available.push((clip, textures));
592        }
593    }
594    (report, available)
595}
596
597/// Each available clip's text generators with their prepared text, the
598/// clip's own source and its compositions' steps. Text `frames` does not
599/// hold is left out, for the graph to report.
600fn attach_texts<'t>(
601    available: &mut [(&ActiveClip<'_>, ClipFrames<'t>)],
602    frames: &'t dyn FrameProvider,
603    size: RenderSize,
604) {
605    let lookup = |generator: &Generator| match generator {
606        Generator::Text { text, style } => {
607            frames
608                .text(crate::text_key(text, style, size))
609                .map(|texture| PlacedText {
610                    texture,
611                    visual: text_placement(texture, style, size),
612                })
613        }
614        _ => None,
615    };
616    for (clip, textures) in available {
617        textures.text = clip.generator.and_then(lookup);
618        for (composition, media) in [
619            (clip.composition, &mut textures.media),
620            (clip.source_composition, &mut textures.source_media),
621        ] {
622            let Some(composition) = composition else {
623                continue;
624            };
625            for (step, slot) in composition.plan().steps.iter().zip(media.iter_mut()) {
626                if let StepOp::Generator(generator) = &step.op {
627                    *slot = lookup(generator).map(StepFrame::Text);
628                }
629            }
630        }
631    }
632}
633
634/// Where prepared text lies on the canvas: its texture keeps its pixel
635/// size, and the anchor point of the text with its decorations lies at its
636/// position.
637fn text_placement(texture: TextTexture<'_>, style: &TextStyle, size: RenderSize) -> LayerTransform {
638    #[allow(clippy::cast_precision_loss)]
639    let scale = [
640        texture.width as f32 / size.width as f32,
641        texture.height as f32 / size.height as f32,
642    ];
643    LayerTransform {
644        position: [style.position.x, style.position.y],
645        anchor: [
646            box_anchor(style.anchor.x, texture.width, texture.margin[0]),
647            box_anchor(style.anchor.y, texture.height, texture.margin[1]),
648        ],
649        scale,
650        rotation_degrees: 0.0,
651        opacity: 1.0,
652    }
653}
654
655/// A prepared caption and where it goes in the layer stack.
656struct CaptionLayer<'a> {
657    /// How many of the frame's visual clips, drawn or not, lie under it.
658    layers_below: usize,
659    texture: TextTexture<'a>,
660    visual: LayerTransform,
661}
662
663/// A caption's texture as a layer of `graph`: keyed as its store keys it,
664/// so a frame showing the same caption over the same picture is found again.
665fn caption_graph_layer<'a>(graph: &mut RenderGraph<'a>, caption: &CaptionLayer<'a>) -> GraphLayer {
666    let source = match caption.texture.cache_key {
667        Some(key) => graph.add_source_with_key(caption.texture.texture, key),
668        None => graph.add_source(caption.texture.texture),
669    };
670    GraphLayer {
671        source,
672        visual: caption.visual,
673        blend_mode: BlendMode::Normal,
674    }
675}
676
677/// Each caption `frame` shows, from `frames`, placed on the canvas
678/// ([`text_placement`]).
679fn caption_layers<'a>(
680    frame: &FrameDesc,
681    frames: &'a dyn FrameProvider,
682    size: RenderSize,
683) -> Result<Vec<CaptionLayer<'a>>, RenderError> {
684    frame
685        .captions
686        .iter()
687        .map(|caption| {
688            let texture = frames
689                .text(crate::text_key(caption.text, caption.style, size))
690                .ok_or(RenderError::MissingText)?;
691            Ok(CaptionLayer {
692                layers_below: caption.layers_below,
693                visual: text_placement(texture, caption.style, size),
694                texture,
695            })
696        })
697        .collect()
698}
699
700/// `anchor`, a point of a text with its decorations, as a point of its
701/// texture, which has `margin` pixels around that for ink past it: so an
702/// accent or a descender never moves the text.
703#[allow(clippy::cast_precision_loss)]
704fn box_anchor(anchor: f32, extent: u32, margin: u32) -> f32 {
705    if margin == 0 {
706        return anchor;
707    }
708    let inner = extent.saturating_sub(margin * 2) as f32;
709    (margin as f32 + anchor * inner) / extent as f32
710}
711
712/// The runtime a frame's passes see: its time and the canvas's aspect ratio.
713fn frame_runtime(frame: &FrameDesc, size: RenderSize) -> EffectRuntime {
714    #[allow(clippy::cast_precision_loss)]
715    let ratio = size.width as f32 / size.height as f32;
716    EffectRuntime {
717        time: frame.time,
718        ratio,
719        ..EffectRuntime::default()
720    }
721}
722
723fn frame_graph<'a>(
724    renderer: &Renderer,
725    size: RenderSize,
726    frame: &'a FrameDesc<'a>,
727    available: &[(&'a ActiveClip<'a>, ClipFrames<'a>)],
728    captions: &[CaptionLayer<'a>],
729) -> Result<RenderGraph<'a>, RenderError> {
730    let mut graph = RenderGraph::new(size);
731    graph.set_blend_space(match frame.blend_space {
732        alpha2_model::BlendSpace::LinearLight => alpha2_render::BlendSpace::LinearLight,
733        alpha2_model::BlendSpace::SrgbEncoded => alpha2_render::BlendSpace::SrgbEncoded,
734    });
735    let mut nodes = HashMap::with_capacity(available.len());
736    let mut inputs = Vec::with_capacity(available.len() + frame.transitions.len());
737    // Each matte's coverage, drawn once however many clips it mattes.
738    let mut coverages = HashMap::new();
739    let runtime = frame_runtime(frame, size);
740    for (clip, textures) in available {
741        if clip.is_matte {
742            continue;
743        }
744        let mut layer = clip_layer(renderer, &mut graph, clip, textures, frame.time, runtime)?;
745        if let Some(matte) = clip.matte {
746            layer = apply_matte(
747                renderer,
748                &mut graph,
749                layer,
750                matte,
751                available,
752                (frame.time, runtime),
753                &mut coverages,
754            )?;
755        }
756        nodes.insert(clip.clip_id, inputs.len());
757        inputs.push(FrameInput {
758            kind: FrameInputKind::Layer(layer),
759            uses_backdrop: clip.blend_mode != ClipBlendMode::Normal,
760        });
761    }
762    let background = background_color(frame.background);
763    let mut suppressed = HashSet::new();
764    for transition in &frame.transitions {
765        let from = *nodes
766            .get(transition.from_clip)
767            .ok_or(RenderError::InvalidGraph)?;
768        let to = *nodes
769            .get(transition.to_clip)
770            .ok_or(RenderError::InvalidGraph)?;
771        let output = inputs.len();
772        inputs.push(FrameInput {
773            kind: FrameInputKind::Transition {
774                from,
775                to,
776                transition,
777            },
778            uses_backdrop: inputs[from].uses_backdrop || inputs[to].uses_backdrop,
779        });
780        nodes.insert(transition.to_clip, output);
781        suppressed.insert(transition.from_clip);
782    }
783    let mut layers = Vec::with_capacity(available.len() + captions.len());
784    let mut captions = captions.iter().peekable();
785    for (clip, textures) in available {
786        // Captions on tracks before this clip's, by track order: a clip before it that is not
787        // drawn (a matte nothing names) does not move them.
788        while let Some(caption) = captions.next_if(|caption| caption.layers_below <= textures.layer)
789        {
790            layers.push(caption_graph_layer(&mut graph, caption));
791        }
792        if clip.is_matte || suppressed.contains(clip.clip_id) {
793            continue;
794        }
795        let index = *nodes.get(clip.clip_id).ok_or(RenderError::InvalidGraph)?;
796        if let FrameInputKind::Layer(layer) = inputs[index].kind {
797            layers.push(layer);
798            continue;
799        }
800        // Normal transitions operate on isolated, transformed layers. Artistic modes need the
801        // same backdrop on both sides: transition their composed scenes, then replace that
802        // backdrop exactly once.
803        let uses_backdrop = inputs[index].uses_backdrop;
804        let backdrop = if uses_backdrop {
805            layers.as_slice()
806        } else {
807            &[]
808        };
809        // The background is the bottom layer of that backdrop too, so an artistic mode blends
810        // against the same color inside a transition as outside one.
811        let source = compile_frame_input(
812            &mut graph,
813            &inputs,
814            index,
815            backdrop,
816            uses_backdrop.then_some(background),
817            runtime,
818            renderer,
819        )?;
820        if uses_backdrop {
821            layers.clear();
822        }
823        layers.push(identity_layer(source));
824    }
825    for caption in captions {
826        layers.push(caption_graph_layer(&mut graph, caption));
827    }
828    let output = graph
829        .add_composite_over(layers, background)
830        .map_err(GraphError::into_render_error)?;
831    graph
832        .set_output(output)
833        .map_err(GraphError::into_render_error)?;
834    graph
835        .set_cache_intent(output, CacheIntent::Auto)
836        .map_err(GraphError::into_render_error)?;
837    Ok(graph)
838}
839
840const fn cache_intent(intent: alpha2_model::CacheIntent) -> CacheIntent {
841    match intent {
842        alpha2_model::CacheIntent::Auto => CacheIntent::Auto,
843        alpha2_model::CacheIntent::Pin => CacheIntent::Pin,
844        alpha2_model::CacheIntent::Bypass => CacheIntent::Bypass,
845    }
846}
847
848/// `layer` kept where `matte` says, the matte clip drawn only into it.
849fn apply_matte<'a, 'c>(
850    renderer: &Renderer,
851    graph: &mut RenderGraph<'a>,
852    mut layer: GraphLayer,
853    matte: &'c alpha2_model::Matte,
854    available: &[(&'a ActiveClip<'a>, ClipFrames<'a>)],
855    (time, runtime): (MediaTime, EffectRuntime),
856    coverages: &mut HashMap<&'c alpha2_model::ClipId, RenderNodeId>,
857) -> Result<GraphLayer, RenderError> {
858    let source = available
859        .iter()
860        .find(|(candidate, _)| candidate.clip_id == &matte.source);
861    match source {
862        Some((source, textures)) => {
863            let coverage = if let Some(coverage) = coverages.get(&matte.source) {
864                *coverage
865            } else {
866                let matte_layer = clip_layer(renderer, graph, source, textures, time, runtime)?;
867                let coverage = alone(graph, matte_layer)?;
868                coverages.insert(&matte.source, coverage);
869                coverage
870            };
871            layer = matted_layer(graph, layer, coverage, matte.mode, runtime)?;
872        }
873        // An inactive matte covers nothing: the clip is hidden, or wholly visible when the
874        // matte is inverted. A hidden clip keeps its node so a transition can still name it.
875        None if is_inverted(matte.mode) => {}
876        None => layer.visual.opacity = 0.0,
877    }
878    Ok(layer)
879}
880
881/// The project background as an opaque, sRGB-encoded storage value.
882fn background_color(background: Rgb) -> Color {
883    Color::rgba(
884        f64::from(background.red),
885        f64::from(background.green),
886        f64::from(background.blue),
887        1.0,
888    )
889}
890
891struct FrameInput<'a> {
892    kind: FrameInputKind<'a>,
893    uses_backdrop: bool,
894}
895
896fn clip_layer<'a>(
897    renderer: &Renderer,
898    graph: &mut RenderGraph<'a>,
899    clip: &'a ActiveClip<'a>,
900    textures: &ClipFrames<'a>,
901    time: MediaTime,
902    runtime: EffectRuntime,
903) -> Result<GraphLayer, RenderError> {
904    let mut source = match clip.composition {
905        None => clip_picture(renderer, graph, clip, textures, runtime)?,
906        Some(composition) => plan_node(
907            renderer,
908            graph,
909            composition,
910            &textures.media,
911            Some((clip, textures)),
912            runtime,
913        )?,
914    };
915    if !clip.masks.is_empty() {
916        source = graph
917            .add_effect_with_resources(
918                EffectId::from_static(SHAPE_MASK_EFFECT_ID, 1),
919                source,
920                mask_params(clip.masks, time),
921                EffectResources::new(),
922                timeless(runtime),
923            )
924            .map_err(GraphError::into_render_error)?;
925    }
926    // The clip's finished picture is where reuse pays: everything above it
927    // changes with the layer stack, everything below it is in its key. A bare
928    // source frame is already the host's; only a bypass on it matters.
929    let intent = cache_intent(clip.cache);
930    if intent == CacheIntent::Bypass || graph.node_kind(source) != Some(RenderNodeKind::Source) {
931        graph
932            .set_cache_intent(source, intent)
933            .map_err(GraphError::into_render_error)?;
934    }
935    Ok(GraphLayer {
936        source,
937        visual: layer_transform(clip.visual.ok_or(RenderError::InvalidVisual)?),
938        blend_mode: blend_mode(clip.blend_mode),
939    })
940}
941
942/// The clip's own picture: its composition source, its generated source,
943/// or its source frame.
944fn clip_picture<'a>(
945    renderer: &Renderer,
946    graph: &mut RenderGraph<'a>,
947    clip: &'a ActiveClip<'a>,
948    textures: &ClipFrames<'a>,
949    runtime: EffectRuntime,
950) -> Result<RenderNodeId, RenderError> {
951    if let Some(source) = clip.source_composition {
952        // A composition source's plan has no clip steps.
953        return plan_node(
954            renderer,
955            graph,
956            source,
957            &textures.source_media,
958            None,
959            runtime,
960        );
961    }
962    match (clip.generator, textures.source) {
963        (Some(Generator::Text { .. }), _) => {
964            textures.text.ok_or(RenderError::MissingText)?.add_to(graph)
965        }
966        (Some(generator), _) => Ok(generator_node(graph, generator, runtime)),
967        (None, Some(frame)) => Ok(frame.add_to(graph)),
968        (None, None) => Err(RenderError::InvalidGraph),
969    }
970}
971
972/// A generated picture does not change over time, so its node has the same
973/// cache key in every frame.
974fn generator_node(
975    graph: &mut RenderGraph<'_>,
976    generator: &alpha2_model::Generator,
977    runtime: EffectRuntime,
978) -> RenderNodeId {
979    graph.add_generator(
980        EffectId::from_static(GENERATOR_EFFECT_ID, 1),
981        crate::generated::generator_params(generator),
982        timeless(runtime),
983    )
984}
985
986/// The node drawing a compiled composition's output: each step in order,
987/// with its settings sampled at the composition time plus its offset. Its
988/// clip steps draw `clip`'s picture; a plan without them takes none.
989fn plan_node<'a>(
990    renderer: &Renderer,
991    graph: &mut RenderGraph<'a>,
992    composition: ActiveComposition<'a>,
993    media: &[Option<StepFrame<'a>>],
994    clip: Option<(&'a ActiveClip<'a>, &ClipFrames<'a>)>,
995    runtime: EffectRuntime,
996) -> Result<RenderNodeId, RenderError> {
997    let plan = composition.plan();
998    let time = composition.time();
999    let mut built: Vec<RenderNodeId> = Vec::with_capacity(plan.steps.len());
1000    let mut picture = None;
1001    for (index, step) in plan.steps.iter().enumerate() {
1002        let at = step.time(time);
1003        let step_runtime = EffectRuntime {
1004            time: at,
1005            ..runtime
1006        };
1007        let node = match &step.op {
1008            // Every clip step draws the same picture.
1009            StepOp::Clip => {
1010                if let Some(node) = picture {
1011                    node
1012                } else {
1013                    let (clip, textures) = clip.ok_or(RenderError::InvalidGraph)?;
1014                    *picture.insert(clip_picture(renderer, graph, clip, textures, runtime)?)
1015                }
1016            }
1017            StepOp::Media { .. } => match media.get(index).copied().flatten() {
1018                Some(StepFrame::Media(frame)) => frame.add_to(graph),
1019                _ => return Err(RenderError::InvalidGraph),
1020            },
1021            StepOp::Generator(Generator::Text { .. }) => {
1022                match media.get(index).copied().flatten() {
1023                    Some(StepFrame::Text(text)) => text.add_to(graph)?,
1024                    _ => return Err(RenderError::MissingText),
1025                }
1026            }
1027            op => step_node(renderer, graph, op, &built, at, step_runtime)?,
1028        };
1029        built.push(node);
1030    }
1031    step_input(&built, plan.output)
1032}
1033
1034/// The node an earlier step drew.
1035fn step_input(built: &[RenderNodeId], step: usize) -> Result<RenderNodeId, RenderError> {
1036    built.get(step).copied().ok_or(RenderError::InvalidGraph)
1037}
1038
1039/// The node for a step that draws from its inputs, or from nothing.
1040fn step_node<'a>(
1041    renderer: &Renderer,
1042    graph: &mut RenderGraph<'a>,
1043    op: &'a StepOp,
1044    built: &[RenderNodeId],
1045    time: MediaTime,
1046    runtime: EffectRuntime,
1047) -> Result<RenderNodeId, RenderError> {
1048    let input = |step: usize| step_input(built, step);
1049    let node = match op {
1050        StepOp::Clip | StepOp::Media { .. } => return Err(RenderError::InvalidGraph),
1051        StepOp::Generator(generator) => generator_node(graph, generator, runtime),
1052        StepOp::Effect {
1053            input: from,
1054            effect,
1055        } => {
1056            let id = project_effect_id(renderer, &effect.id, effect.version)?;
1057            let active = ActiveEffect::new(effect, time);
1058            let params = effect_params(renderer, &id, active);
1059            let runtime = effect_runtime(renderer, &id, runtime);
1060            graph.add_effect_with_resources(
1061                id,
1062                input(*from)?,
1063                params,
1064                effect_resources(active),
1065                runtime,
1066            )
1067        }
1068        .map_err(GraphError::into_render_error)?,
1069        StepOp::Transition {
1070            from,
1071            to,
1072            effect,
1073            progress,
1074        } => {
1075            let id = project_effect_id(renderer, &effect.id, effect.version)?;
1076            let active = ActiveEffect::new(effect, time);
1077            let params = effect_params(renderer, &id, active);
1078            let runtime = EffectRuntime {
1079                progress: sample_scalar(progress, time).clamp(0.0, 1.0),
1080                ..effect_runtime(renderer, &id, runtime)
1081            };
1082            graph.add_transition_with_resources(
1083                id,
1084                input(*from)?,
1085                input(*to)?,
1086                params,
1087                effect_resources(active),
1088                runtime,
1089            )
1090        }
1091        .map_err(GraphError::into_render_error)?,
1092        StepOp::Transform {
1093            input: from,
1094            transform,
1095        } => graph
1096            .add_composite(vec![GraphLayer {
1097                source: input(*from)?,
1098                visual: layer_transform(sample_visual(transform, time)),
1099                blend_mode: BlendMode::Normal,
1100            }])
1101            .map_err(GraphError::into_render_error)?,
1102        StepOp::Merge { layers } => {
1103            let layers = layers
1104                .iter()
1105                .map(|layer| {
1106                    let mut placed = identity_layer(input(layer.input)?);
1107                    placed.visual.opacity = sample_scalar(&layer.opacity, time).clamp(0.0, 1.0);
1108                    placed.blend_mode = blend_mode(layer.blend_mode);
1109                    Ok(placed)
1110                })
1111                .collect::<Result<Vec<_>, RenderError>>()?;
1112            graph
1113                .add_composite(layers)
1114                .map_err(GraphError::into_render_error)?
1115        }
1116        StepOp::Matte {
1117            input: from,
1118            matte,
1119            mode,
1120        } => matte_node(graph, input(*from)?, input(*matte)?, *mode, runtime)?,
1121        StepOp::ShapeMask { input: from, masks } => graph
1122            .add_effect_with_resources(
1123                EffectId::from_static(SHAPE_MASK_EFFECT_ID, 1),
1124                input(*from)?,
1125                mask_params(masks, time),
1126                EffectResources::new(),
1127                timeless(runtime),
1128            )
1129            .map_err(GraphError::into_render_error)?,
1130    };
1131    Ok(node)
1132}
1133
1134/// A clip's shape masks sampled at `time`, as the shape-mask pass's
1135/// parameters. An overshooting curve cannot make a size, radius, or feather
1136/// negative here.
1137fn mask_params(masks: &[ShapeMask], time: MediaTime) -> EffectParams<'static> {
1138    let shown = masks.len().min(MAX_SHAPE_MASKS);
1139    let mut params = EffectParams::with_capacity(1 + shown * 8);
1140    #[allow(clippy::cast_precision_loss)]
1141    params.insert("count", ParamValue::Float(masks.len() as f32));
1142    for (mask, names) in masks.iter().zip(SHAPE_MASK_PARAMS) {
1143        let center = sample_vec2(&mask.center, time);
1144        let size = sample_vec2(&mask.size, time);
1145        let (ellipse, radius) = match &mask.shape {
1146            MaskShape::Ellipse => (true, 0.0),
1147            MaskShape::Rectangle { corner_radius } => (false, sample_scalar(corner_radius, time)),
1148        };
1149        params.insert(names.center, ParamValue::Vec2([center.x, center.y]));
1150        params.insert(
1151            names.size,
1152            ParamValue::Vec2([size.x.max(0.0), size.y.max(0.0)]),
1153        );
1154        params.insert(
1155            names.rotation,
1156            ParamValue::Float(sample_scalar(&mask.rotation_degrees, time).to_radians()),
1157        );
1158        params.insert(names.radius, ParamValue::Float(radius.max(0.0)));
1159        params.insert(
1160            names.feather,
1161            ParamValue::Float(sample_scalar(&mask.feather, time).max(0.0)),
1162        );
1163        params.insert(names.ellipse, ParamValue::Bool(ellipse));
1164        params.insert(names.invert, ParamValue::Bool(mask.invert));
1165        params.insert(
1166            names.mode,
1167            ParamValue::Enum(match mask.mode {
1168                MaskMode::Add => 0,
1169                MaskMode::Subtract => 1,
1170                MaskMode::Intersect => 2,
1171            }),
1172        );
1173    }
1174    params
1175}
1176
1177const fn is_inverted(mode: MatteMode) -> bool {
1178    matches!(mode, MatteMode::AlphaInverted | MatteMode::LumaInverted)
1179}
1180
1181/// `layer` kept where `matte`'s picture says, as an untransformed layer with
1182/// `layer`'s blend mode. Both are drawn alone onto the canvas first, so the
1183/// matte applies where the two land, whatever their transforms.
1184fn matted_layer(
1185    graph: &mut RenderGraph<'_>,
1186    layer: GraphLayer,
1187    coverage: RenderNodeId,
1188    mode: MatteMode,
1189    runtime: EffectRuntime,
1190) -> Result<GraphLayer, RenderError> {
1191    let picture = alone(graph, layer)?;
1192    let matted = matte_node(graph, picture, coverage, mode, runtime)?;
1193    Ok(GraphLayer {
1194        blend_mode: layer.blend_mode,
1195        ..identity_layer(matted)
1196    })
1197}
1198
1199/// `layer` drawn alone, as it covers the canvas.
1200fn alone(graph: &mut RenderGraph<'_>, layer: GraphLayer) -> Result<RenderNodeId, RenderError> {
1201    graph
1202        .add_composite(vec![GraphLayer {
1203            blend_mode: BlendMode::Normal,
1204            ..layer
1205        }])
1206        .map_err(GraphError::into_render_error)
1207}
1208
1209/// The runtime `effect` is drawn with: `runtime`, without the frame's time
1210/// when the effect cannot read it ([`Renderer::effect_reads_time`]). Its
1211/// result then has the same cache key in every frame that draws it from the
1212/// same inputs and parameters, and is found again there; an effect that
1213/// reads the time keeps a key of its own in each frame.
1214fn effect_runtime(renderer: &Renderer, effect: &EffectId, runtime: EffectRuntime) -> EffectRuntime {
1215    if renderer.effect_reads_time(effect) {
1216        runtime
1217    } else {
1218        EffectRuntime {
1219            time: MediaTime::ZERO,
1220            ..runtime
1221        }
1222    }
1223}
1224
1225/// The runtime of a pass the engine writes whose picture does not change
1226/// with time (a shape mask or a track matte; [`generator_node`] likewise):
1227/// without the frame's time, so its cache key is the same in every frame.
1228fn timeless(runtime: EffectRuntime) -> EffectRuntime {
1229    EffectRuntime {
1230        ratio: runtime.ratio,
1231        ..EffectRuntime::default()
1232    }
1233}
1234
1235/// `picture` kept where `coverage`, drawn on the same canvas, says.
1236fn matte_node(
1237    graph: &mut RenderGraph<'_>,
1238    picture: RenderNodeId,
1239    coverage: RenderNodeId,
1240    mode: MatteMode,
1241    runtime: EffectRuntime,
1242) -> Result<RenderNodeId, RenderError> {
1243    graph
1244        .add_transition_with_resources(
1245            EffectId::from_static(TRACK_MATTE_EFFECT_ID, 1),
1246            picture,
1247            coverage,
1248            EffectParams::new()
1249                .with(
1250                    "luma",
1251                    ParamValue::Bool(matches!(mode, MatteMode::Luma | MatteMode::LumaInverted)),
1252                )
1253                .with("inverted", ParamValue::Bool(is_inverted(mode))),
1254            EffectResources::new(),
1255            // The matte ignores progress and time; an interior progress runs its body.
1256            EffectRuntime {
1257                progress: 0.5,
1258                ..timeless(runtime)
1259            },
1260        )
1261        .map_err(GraphError::into_render_error)
1262}
1263
1264#[derive(Copy, Clone)]
1265enum FrameInputKind<'a> {
1266    Layer(GraphLayer),
1267    Transition {
1268        from: usize,
1269        to: usize,
1270        transition: &'a ActiveTransition<'a>,
1271    },
1272}
1273
1274fn compile_frame_input<'a>(
1275    graph: &mut RenderGraph<'a>,
1276    inputs: &[FrameInput<'a>],
1277    index: usize,
1278    backdrop: &[GraphLayer],
1279    background: Option<Color>,
1280    runtime: EffectRuntime,
1281    renderer: &Renderer,
1282) -> Result<RenderNodeId, RenderError> {
1283    if index >= inputs.len() {
1284        return Err(RenderError::InvalidGraph);
1285    }
1286    let mut compiled = vec![None; inputs.len()];
1287    let mut pending = vec![(index, false)];
1288    while let Some((current, expanded)) = pending.pop() {
1289        if compiled[current].is_some() {
1290            continue;
1291        }
1292        match inputs[current].kind {
1293            FrameInputKind::Layer(layer) => {
1294                let mut layers = Vec::with_capacity(backdrop.len() + 1);
1295                layers.extend_from_slice(backdrop);
1296                layers.push(layer);
1297                let composite = match background {
1298                    Some(background) => graph.add_composite_over(layers, background),
1299                    None => graph.add_composite(layers),
1300                };
1301                compiled[current] = Some(composite.map_err(GraphError::into_render_error)?);
1302            }
1303            FrameInputKind::Transition {
1304                from,
1305                to,
1306                transition,
1307            } => {
1308                if from >= inputs.len() || to >= inputs.len() {
1309                    return Err(RenderError::InvalidGraph);
1310                }
1311                if !expanded {
1312                    pending.push((current, true));
1313                    if compiled[to].is_none() {
1314                        pending.push((to, false));
1315                    }
1316                    if compiled[from].is_none() {
1317                        pending.push((from, false));
1318                    }
1319                    continue;
1320                }
1321                let from = compiled[from].ok_or(RenderError::InvalidGraph)?;
1322                let to = compiled[to].ok_or(RenderError::InvalidGraph)?;
1323                let id = project_effect_id(
1324                    renderer,
1325                    transition.effect.id(),
1326                    transition.effect.version(),
1327                )?;
1328                let params = effect_params(renderer, &id, transition.effect);
1329                let runtime = EffectRuntime {
1330                    progress: transition.progress,
1331                    ..effect_runtime(renderer, &id, runtime)
1332                };
1333                compiled[current] = Some(
1334                    graph
1335                        .add_transition_with_resources(
1336                            id,
1337                            from,
1338                            to,
1339                            params,
1340                            effect_resources(transition.effect),
1341                            runtime,
1342                        )
1343                        .map_err(GraphError::into_render_error)?,
1344                );
1345            }
1346        }
1347    }
1348    compiled[index].ok_or(RenderError::InvalidGraph)
1349}
1350
1351fn identity_layer(source: RenderNodeId) -> GraphLayer {
1352    GraphLayer {
1353        source,
1354        visual: LayerTransform {
1355            position: [0.5, 0.5],
1356            anchor: [0.5, 0.5],
1357            scale: [1.0, 1.0],
1358            rotation_degrees: 0.0,
1359            opacity: 1.0,
1360        },
1361        blend_mode: BlendMode::Normal,
1362    }
1363}
1364
1365/// Carries the evaluator's presentation values into the renderer's layer
1366/// transform unchanged.
1367const fn layer_transform(visual: EvaluatedVisual) -> LayerTransform {
1368    LayerTransform {
1369        position: [visual.position.x, visual.position.y],
1370        anchor: [visual.anchor.x, visual.anchor.y],
1371        scale: [visual.scale.x, visual.scale.y],
1372        rotation_degrees: visual.rotation_degrees,
1373        opacity: visual.opacity,
1374    }
1375}
1376
1377/// The invocation's parameters at the frame's time.
1378///
1379/// A value between two keyframes is clamped to its parameter's range: a
1380/// timing curve may overshoot, and the overshoot is part of the motion, not an
1381/// authoring error. An authored value (a constant, or the value on a
1382/// keyframe) is passed as written, and the renderer rejects one outside the
1383/// range.
1384fn effect_params<'a>(
1385    renderer: &Renderer,
1386    id: &EffectId,
1387    effect: ActiveEffect<'a>,
1388) -> EffectParams<'a> {
1389    let mut params = EffectParams::with_capacity(effect.authored().params.len());
1390    for (name, sample) in effect.params() {
1391        let mut value = param_value(sample.value);
1392        if sample.interpolated
1393            && let Some(spec) = renderer
1394                .effect_definition(id)
1395                .and_then(|definition| definition.params.iter().find(|spec| spec.name == name))
1396        {
1397            value = clamp_to_range(spec, value);
1398        }
1399        params.insert(name, value);
1400    }
1401    params
1402}
1403
1404/// The identity a project's effect is registered under, which the graph
1405/// holds without copying its text.
1406fn project_effect_id(renderer: &Renderer, id: &str, version: u32) -> Result<EffectId, RenderError> {
1407    renderer
1408        .project_effect_id(id, version)
1409        .cloned()
1410        .ok_or(RenderError::UnknownEffect)
1411}
1412
1413fn clamp_to_range(spec: &ParamSpec, value: ParamValue) -> ParamValue {
1414    let clamp = |value: f32| {
1415        let value = spec.min.map_or(value, |minimum| value.max(minimum));
1416        spec.max.map_or(value, |maximum| value.min(maximum))
1417    };
1418    match value {
1419        ParamValue::Float(value) => ParamValue::Float(clamp(value)),
1420        ParamValue::Vec2([x, y]) => ParamValue::Vec2([clamp(x), clamp(y)]),
1421        ParamValue::Vec4(values) => ParamValue::Vec4(values.map(clamp)),
1422        other => other,
1423    }
1424}
1425
1426fn effect_resources(effect: ActiveEffect<'_>) -> EffectResources {
1427    effect
1428        .resources()
1429        .fold(EffectResources::new(), |resources, (name, id)| {
1430            resources.with(name, id.as_str())
1431        })
1432}
1433
1434const fn param_value(value: EffectValue) -> ParamValue {
1435    match value {
1436        EffectValue::Float(value) => ParamValue::Float(value),
1437        EffectValue::Vec2(value) => ParamValue::Vec2(value),
1438        EffectValue::Vec4(value) => ParamValue::Vec4(value),
1439        EffectValue::Color(value) => ParamValue::Color(value),
1440        EffectValue::Enum(value) => ParamValue::Enum(value),
1441        EffectValue::Bool(value) => ParamValue::Bool(value),
1442    }
1443}
1444
1445const fn blend_mode(mode: ClipBlendMode) -> BlendMode {
1446    match mode {
1447        ClipBlendMode::Normal => BlendMode::Normal,
1448        ClipBlendMode::Multiply => BlendMode::Multiply,
1449        ClipBlendMode::Screen => BlendMode::Screen,
1450        ClipBlendMode::Overlay => BlendMode::Overlay,
1451        ClipBlendMode::Darken => BlendMode::Darken,
1452        ClipBlendMode::Lighten => BlendMode::Lighten,
1453        ClipBlendMode::ColorDodge => BlendMode::ColorDodge,
1454        ClipBlendMode::ColorBurn => BlendMode::ColorBurn,
1455        ClipBlendMode::HardLight => BlendMode::HardLight,
1456        ClipBlendMode::SoftLight => BlendMode::SoftLight,
1457        ClipBlendMode::Difference => BlendMode::Difference,
1458        ClipBlendMode::Exclusion => BlendMode::Exclusion,
1459        ClipBlendMode::Hue => BlendMode::Hue,
1460        ClipBlendMode::Saturation => BlendMode::Saturation,
1461        ClipBlendMode::Color => BlendMode::Color,
1462        ClipBlendMode::Luminosity => BlendMode::Luminosity,
1463    }
1464}
1465
1466#[cfg(test)]
1467mod tests;