mod scene_emitter;
pub use scene_emitter::{
SceneEmitter, SceneEmitterComposition, SceneFrameContext, SceneFreeze, SceneGroup, SceneLayer,
SceneSequence, SceneStack, SceneTextBlock,
};
use dioxuscut_rasterizer::{Color, GradientStop, Scene, SceneNode};
use serde_json::Value;
use std::collections::BTreeMap;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NativeCompositionContext {
pub width: u32,
pub height: u32,
pub fps: f64,
pub duration_in_frames: u32,
}
impl NativeCompositionContext {
pub fn progress(self, frame: u32) -> f32 {
let last_frame = self.duration_in_frames.saturating_sub(1).max(1);
(frame.min(last_frame) as f32 / last_frame as f32).clamp(0.0, 1.0)
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum CompositionError {
#[error("Failed to prepare composition: {0}")]
Prepare(String),
#[error("Failed to render frame {frame}: {reason}")]
Render { frame: u32, reason: String },
}
impl CompositionError {
pub fn render(frame: u32, reason: impl Into<String>) -> Self {
Self::Render {
frame,
reason: reason.into(),
}
}
}
pub trait PreparedComposition: Send + Sync {
fn render(&self, frame: u32) -> Result<Scene, CompositionError>;
}
pub trait Composition: Send + Sync {
fn id(&self) -> &str;
fn prepare(
&self,
props: &Value,
context: NativeCompositionContext,
) -> Result<Box<dyn PreparedComposition + '_>, CompositionError>;
}
pub trait NativeComposition: Send + Sync {
fn id(&self) -> &str;
fn render(
&self,
frame: u32,
props: &Value,
context: NativeCompositionContext,
) -> Result<Scene, CompositionError>;
}
struct PreparedNativeComposition<'a, C> {
composition: &'a C,
props: Value,
context: NativeCompositionContext,
}
impl<C> PreparedComposition for PreparedNativeComposition<'_, C>
where
C: NativeComposition,
{
fn render(&self, frame: u32) -> Result<Scene, CompositionError> {
self.composition.render(frame, &self.props, self.context)
}
}
impl<C> Composition for C
where
C: NativeComposition,
{
fn id(&self) -> &str {
NativeComposition::id(self)
}
fn prepare(
&self,
props: &Value,
context: NativeCompositionContext,
) -> Result<Box<dyn PreparedComposition + '_>, CompositionError> {
Ok(Box::new(PreparedNativeComposition {
composition: self,
props: props.clone(),
context,
}))
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum CompositionRegistryError {
#[error("Composition '{0}' is already registered")]
Duplicate(String),
#[error("Unknown composition '{requested}'. Available compositions: {available}")]
Unknown {
requested: String,
available: String,
},
}
#[derive(Default)]
pub struct CompositionRegistry {
compositions: BTreeMap<String, Box<dyn Composition>>,
}
impl CompositionRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register<C>(&mut self, composition: C) -> Result<(), CompositionRegistryError>
where
C: Composition + 'static,
{
let id = composition.id().to_string();
if self.compositions.contains_key(&id) {
return Err(CompositionRegistryError::Duplicate(id));
}
self.compositions.insert(id, Box::new(composition));
Ok(())
}
pub fn get(&self, id: &str) -> Result<&dyn Composition, CompositionRegistryError> {
self.compositions.get(id).map(Box::as_ref).ok_or_else(|| {
CompositionRegistryError::Unknown {
requested: id.to_string(),
available: self.ids().join(", "),
}
})
}
pub fn ids(&self) -> Vec<&str> {
self.compositions.keys().map(String::as_str).collect()
}
}
pub fn built_in_registry() -> CompositionRegistry {
let mut registry = CompositionRegistry::new();
registry
.register(HelloWorldComposition)
.expect("built-in composition IDs must be unique");
registry
}
pub struct HelloWorldComposition;
impl NativeComposition for HelloWorldComposition {
fn id(&self) -> &str {
"HelloWorld"
}
fn render(
&self,
frame: u32,
props: &Value,
context: NativeCompositionContext,
) -> Result<Scene, CompositionError> {
let width = context.width as f32;
let height = context.height as f32;
let t = context.progress(frame);
let bg_start = color_prop(props, "background_start", Color::rgb(15, 23, 42));
let bg_end = color_prop(props, "background_end", Color::rgb(30, 27, 75));
let accent = color_prop(props, "accent_color", Color::rgb(108, 99, 255));
let title = string_prop(props, "title", "Hello Dioxuscut");
let subtitle = string_prop(props, "subtitle", "Declarative programmatic video in Rust");
let mut scene = Scene::new();
scene.push(SceneNode::LinearGradient {
x: 0.0,
y: 0.0,
w: width,
h: height,
angle_deg: 135.0 + t * 90.0,
stops: vec![
GradientStop {
position: 0.0,
color: bg_start,
},
GradientStop {
position: 1.0,
color: bg_end,
},
],
});
let center_x = width * 0.5;
let center_y = height * 0.5;
let shortest_side = width.min(height);
let r1 = shortest_side * 0.2 + (t * std::f32::consts::TAU).sin() * 20.0;
scene.push(SceneNode::Circle {
cx: center_x,
cy: center_y,
r: r1,
fill: accent.with_opacity(0.12),
stroke: Some(accent),
stroke_width: 2.0,
});
let r2 = shortest_side * 0.3 + (t * std::f32::consts::PI).cos() * 30.0;
scene.push(SceneNode::Circle {
cx: center_x,
cy: center_y,
r: r2,
fill: Color::TRANSPARENT,
stroke: Some(Color::rgba(0, 242, 254, 180)),
stroke_width: 1.5,
});
let rect_size = 80.0 + (t * std::f32::consts::TAU).sin() * 15.0;
scene.push(SceneNode::Rect {
x: width * 0.15,
y: height * 0.2,
w: rect_size,
h: rect_size,
fill: Color::rgba(0, 242, 254, 40),
stroke: Some(Color::rgb(0, 242, 254)),
stroke_width: 2.0,
corner_radius: 12.0,
});
scene.push(SceneNode::Rect {
x: width * 0.78,
y: height * 0.65,
w: rect_size * 1.2,
h: rect_size * 1.2,
fill: Color::rgba(255, 230, 0, 30),
stroke: Some(Color::rgb(255, 230, 0)),
stroke_width: 2.0,
corner_radius: 16.0,
});
scene.push(SceneNode::Rect {
x: 0.0,
y: height - 6.0,
w: width * t,
h: 6.0,
fill: Color::rgb(0, 242, 254),
stroke: None,
stroke_width: 0.0,
corner_radius: 0.0,
});
let font_size = (width * 0.045).max(28.0);
let text_x = width * 0.12;
let text_y = height * 0.45;
scene.push(SceneNode::Text {
x: text_x,
y: text_y,
content: title,
font_size,
color: Color::WHITE,
font_weight: 700,
font_sources: Vec::new(),
});
scene.push(SceneNode::Text {
x: text_x,
y: text_y + font_size * 0.8,
content: subtitle,
font_size: font_size * 0.45,
color: Color::rgb(0, 242, 254),
font_weight: 400,
font_sources: Vec::new(),
});
Ok(scene)
}
}
fn color_prop(props: &Value, key: &str, fallback: Color) -> Color {
props
.get(key)
.and_then(Value::as_str)
.and_then(Color::from_hex)
.unwrap_or(fallback)
}
fn string_prop(props: &Value, key: &str, fallback: &str) -> String {
props
.get(key)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or(fallback)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_rejects_duplicate_ids_and_reports_known_ids() {
let mut registry = CompositionRegistry::new();
registry.register(HelloWorldComposition).unwrap();
assert_eq!(
registry.register(HelloWorldComposition),
Err(CompositionRegistryError::Duplicate("HelloWorld".into()))
);
let error = match registry.get("Missing") {
Ok(_) => panic!("missing composition unexpectedly resolved"),
Err(error) => error,
};
assert_eq!(
error,
CompositionRegistryError::Unknown {
requested: "Missing".into(),
available: "HelloWorld".into(),
}
);
}
#[test]
fn hello_world_uses_props_and_reaches_full_progress() {
let context = NativeCompositionContext {
width: 100,
height: 100,
fps: 30.0,
duration_in_frames: 3,
};
let props = serde_json::json!({"title": "Custom title"});
let scene = HelloWorldComposition.render(2, &props, context).unwrap();
assert!(scene.nodes.iter().any(|node| matches!(
node,
SceneNode::Text { content, .. } if content == "Custom title"
)));
assert!(scene.nodes.iter().any(|node| matches!(
node,
SceneNode::Rect { w, h, .. } if *w == 100.0 && *h == 6.0
)));
}
}