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