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