lumen-engine 0.2.2

Core composition model and rendering orchestration for Lumen.
Documentation
use std::collections::{BTreeMap, BTreeSet};

use crate::{
    composition::Composition,
    error::{GraphValidationError, LumenError, MediaError},
    expr::ExpressionContext,
    node::{
        NodeId, NodeKind, NodeParamEvalContext, NodeParams, PortRef,
        processing::time_remap::{TimeRemapSettings, remap_frame},
        source::media_in,
    },
};

use super::MediaStore;

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FrameRequirements {
    pub images: Vec<String>,
    pub videos: Vec<VideoFrameRequirement>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VideoFrameRequirement {
    pub stream_id: String,
    pub frames: Vec<u32>,
}

#[derive(Debug, Clone, Default)]
pub struct RenderRequirements {
    images: BTreeSet<String>,
    videos: BTreeMap<String, BTreeSet<u32>>,
}

impl RenderRequirements {
    pub fn add_image(&mut self, image_id: impl Into<String>) {
        self.images.insert(image_id.into());
    }

    pub fn add_video_frame(&mut self, stream_id: impl Into<String>, frame: u32) {
        self.videos
            .entry(stream_id.into())
            .or_default()
            .insert(frame);
    }

    pub fn merge(&mut self, other: FrameRequirements) {
        self.images.extend(other.images);
        for video in other.videos {
            self.videos
                .entry(video.stream_id)
                .or_default()
                .extend(video.frames);
        }
    }
}

impl From<RenderRequirements> for FrameRequirements {
    fn from(value: RenderRequirements) -> Self {
        Self {
            images: value.images.into_iter().collect(),
            videos: value
                .videos
                .into_iter()
                .map(|(stream_id, frames)| VideoFrameRequirement {
                    stream_id,
                    frames: frames.into_iter().collect(),
                })
                .collect(),
        }
    }
}

pub fn collect_frame_requirements<M: MediaStore>(
    composition: &Composition,
    media_store: &M,
    frame: u32,
) -> Result<FrameRequirements, LumenError> {
    tracing::trace!(target: "lumen_media", frame, "collect frame requirements");
    let output_port = media_output_port(composition)?;
    let mut collector = RenderRequirements::default();
    let mut context = RequirementContext {
        composition,
        media_store,
        frame,
    };
    context.collect_port(&output_port, &mut collector)?;
    let requirements = FrameRequirements::from(collector);
    tracing::trace!(
        target: "lumen_media",
        frame,
        images = requirements.images.len(),
        videos = requirements.videos.len(),
        "collected frame requirements"
    );
    Ok(requirements)
}

struct RequirementContext<'a, M: MediaStore> {
    composition: &'a Composition,
    media_store: &'a M,
    frame: u32,
}

impl<'a, M: MediaStore> RequirementContext<'a, M> {
    fn collect_port(
        &mut self,
        port: &PortRef,
        collector: &mut RenderRequirements,
    ) -> Result<(), LumenError> {
        if port.is_empty() {
            return Ok(());
        }

        let Some(node) = self.composition.graph.nodes.get(&port.id) else {
            return Ok(());
        };
        self.collect_node(port.id, node, collector)
    }

    fn collect_node(
        &mut self,
        node_id: NodeId,
        node: &NodeKind,
        collector: &mut RenderRequirements,
    ) -> Result<(), LumenError> {
        match node {
            NodeKind::MediaOutput(media_output) => {
                self.collect_port(&media_output.source, collector)?;
            }
            NodeKind::MediaIn(media_in_node) => {
                self.collect_media_in(media_in_node, collector)?;
            }
            NodeKind::TimeRemap(time_remap) => {
                let target_frame = self.remapped_frame(time_remap)?;
                self.with_frame(target_frame, |context| {
                    context.collect_port(&time_remap.source, collector)
                })?;
            }
            NodeKind::Switch(switch) => {
                if let Some(layer) = crate::node::compositing::switch::selected_layer_for_frame(
                    switch,
                    &self.expr_context("switch_requirements"),
                )?
                .and_then(|index| switch.layers.get(index))
                {
                    self.collect_port(layer, collector)?;
                }
            }
            _ => self.collect_default_inputs(node_id, collector)?,
        }

        Ok(())
    }

    fn collect_default_inputs(
        &mut self,
        node_id: NodeId,
        collector: &mut RenderRequirements,
    ) -> Result<(), LumenError> {
        let inputs: Vec<_> = self
            .composition
            .graph
            .connections
            .iter()
            .filter(|connection| connection.to_node == node_id)
            .map(|connection| PortRef::new(connection.from_node, connection.from_port.clone()))
            .collect();

        for input in inputs {
            self.collect_port(&input, collector)?;
        }

        Ok(())
    }

    fn collect_media_in(
        &self,
        media_in_node: &media_in::MediaIn,
        collector: &mut RenderRequirements,
    ) -> Result<(), LumenError> {
        match media_in::resolve_for_context(
            media_in_node,
            &self.expr_context("media_requirements"),
        )? {
            media_in::MediaInKind::Image { image_id } => {
                tracing::trace!(
                    target: "lumen_media",
                    frame = self.frame,
                    image_id = %image_id,
                    "require image"
                );
                collector.add_image(image_id);
            }
            media_in::MediaInKind::Video {
                stream_id,
                range,
                speed,
                loop_mode,
            } => {
                let resolver =
                    self.media_store
                        .get_video_resolver(&stream_id)
                        .ok_or_else(|| MediaError::SourceNotFound {
                            media_source: stream_id.clone(),
                        })?;
                let metadata = resolver.metadata();
                let source_frame = media_in::map_to_source_frame(
                    self.frame,
                    self.composition.timeline.fps,
                    metadata.fps,
                    metadata.frame_count,
                    range.as_ref(),
                    speed,
                    loop_mode,
                )
                .ok_or_else(|| MediaError::FrameOutOfRange {
                    media_source: stream_id.clone(),
                    frame: self.frame,
                    frame_count: metadata.frame_count,
                })?;
                tracing::trace!(
                    target: "lumen_media",
                    frame = self.frame,
                    stream_id = %stream_id,
                    source_frame,
                    "require video frame"
                );
                collector.add_video_frame(stream_id, source_frame);
            }
        }

        Ok(())
    }

    fn remapped_frame(
        &self,
        time_remap: &crate::node::processing::time_remap::TimeRemap,
    ) -> Result<u32, LumenError> {
        let expr_context = self.expr_context("time_remap_requirements");
        let params = time_remap.params.eval(&NodeParamEvalContext {
            node_id: time_remap.id,
            expr: &expr_context,
        })?;
        Ok(remap_frame(TimeRemapSettings {
            frame: params.frame,
            loop_enabled: params.loop_enabled,
            loop_start: params.loop_start,
            loop_end: params.loop_end,
        }))
    }

    fn with_frame<T>(
        &mut self,
        frame: u32,
        f: impl FnOnce(&mut Self) -> Result<T, LumenError>,
    ) -> Result<T, LumenError> {
        let original_frame = self.frame;
        self.frame = frame;
        let result = f(self);
        self.frame = original_frame;
        result
    }

    fn expr_context(&self, path: &str) -> ExpressionContext<'_> {
        ExpressionContext {
            frame: self.frame,
            fps: self.composition.timeline.fps,
            width: self.composition.render_settings.width,
            height: self.composition.render_settings.height,
            duration_frames: self.composition.timeline.duration_frames,
            path: Some(path.to_string()),
            graph: Some(&self.composition.graph),
        }
    }
}

fn media_output_port(composition: &Composition) -> Result<PortRef, LumenError> {
    let mut media_outputs = composition
        .graph
        .nodes
        .iter()
        .filter_map(|(node_id, node)| matches!(node, NodeKind::MediaOutput(_)).then_some(*node_id));
    let Some(output_node_id) = media_outputs.next() else {
        return Err(GraphValidationError::MissingMediaOutput.into());
    };
    if media_outputs.next().is_some() {
        return Err(GraphValidationError::MultipleMediaOutputs { count: 2 }.into());
    }

    Ok(PortRef::new(output_node_id, "output".to_string()))
}