diff --git a/crates/rustmotion/build.rs b/crates/rustmotion/build.rs index 9205003..50ba830 100644 --- a/crates/rustmotion/build.rs +++ b/crates/rustmotion/build.rs @@ -2,7 +2,9 @@ use std::env; use std::fs; use std::path::{Path, PathBuf}; -fn collect_md_files(dir: &Path, out: &mut Vec) { +fn collect_md_files(dir: &Path, out: &mut Vec, directories: &mut Vec) { + directories.push(dir.to_path_buf()); + let mut entries: Vec<_> = fs::read_dir(dir) .unwrap_or_else(|e| panic!("failed to read directory {}: {e}", dir.display())) .filter_map(|e| e.ok()) @@ -12,7 +14,7 @@ fn collect_md_files(dir: &Path, out: &mut Vec) { for entry in entries { let path = entry.path(); if path.is_dir() { - collect_md_files(&path, out); + collect_md_files(&path, out, directories); } else if path.extension().and_then(|e| e.to_str()) == Some("md") { out.push(path); } @@ -25,7 +27,6 @@ fn main() { let skills_root = manifest_dir.join("skills"); - println!("cargo:rerun-if-changed={}", skills_root.display()); println!("cargo:rerun-if-changed=build.rs"); let skill_md = skills_root.join("SKILL.md"); @@ -37,11 +38,16 @@ fn main() { let rules_dir = skills_root.join("rules"); let mut rule_files = Vec::new(); - collect_md_files(&rules_dir, &mut rule_files); + let mut walked_directories = vec![skills_root.clone()]; + collect_md_files(&rules_dir, &mut rule_files, &mut walked_directories); let mut all_files = vec![skill_md]; all_files.extend(rule_files); + for watched in walked_directories.iter().chain(all_files.iter()) { + println!("cargo:rerun-if-changed={}", watched.display()); + } + let mut generated = String::from("&[\n"); for path in &all_files { let rel_path = Path::new(".claude/skills/rustmotion") diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 6f71b6a..ffa78d4 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -235,6 +235,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/validate-json.md](rules/validate-json.md) - Always validate generated JSON with `rustmotion validate` before presenting - [rules/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds - [rules/clip-path.md](rules/clip-path.md) - Non-rectangular masking: the six `clip-path` shapes, how their percentages resolve, and why `node-path` is not one of them yet +- [rules/overlapping-scenes.md](rules/overlapping-scenes.md) - Make an element outlive a cut: overlapping `at` windows composite instead of replacing, who supplies the background, and why `snap` never creates an overlap - [rules/even-dimensions.md](rules/even-dimensions.md) - Use even width/height for H.264 encoding - [rules/composition-recipes.md](rules/composition-recipes.md) - **Read this before reaching for a UI-widget component.** Composing KPI cards, pill rows, progress bars, and other former "frozen composition" shapes from primitives, `components`, and `for-each` - [rules/templates-and-iteration.md](rules/templates-and-iteration.md) - `for-each`/`components`/`use` mechanics: bindings, param defaults, ordering of passes, named errors diff --git a/crates/rustmotion/skills/rules/overlapping-scenes.md b/crates/rustmotion/skills/rules/overlapping-scenes.md new file mode 100644 index 0000000..2a79069 --- /dev/null +++ b/crates/rustmotion/skills/rules/overlapping-scenes.md @@ -0,0 +1,66 @@ +# Scènes superposées : faire durer un élément à travers les coupes + +Dans une vue `slide`, chaque scène est rendue seule et la `transition` mélange +deux images déjà finies : **rien ne survit à la coupe**. C'est ce qui fait lire une +vidéo comme une suite de diapositives plutôt que comme un plan continu. + +En `timing: "v2"`, deux scènes dont les fenêtres se recouvrent ne se remplacent +plus — elles se **composent**. Une maquette qui monte au beat 3 et reste à l'écran +pendant que six libellés se succèdent par-dessus s'écrit comme une scène longue et +six scènes courtes : + +```json +"timing": "v2", +"bpm": 120, +"composition": [{ "type": "slide", "scenes": [ + { "duration": 12.0, "children": [ … le décor qui tient les 12 s … ] }, + { "duration": 3.0, "at": "@0b", "children": [ … beat 1 … ] }, + { "duration": 3.0, "at": "@6b", "children": [ … beat 2 … ] }, + { "duration": 3.0, "at": "@12b", "children": [ … beat 3 … ] } +]}] +``` + +Durée totale : `max(at + duration)`, soit 12 s — pas 21 s. Chaque scène garde +**son propre temps** : une scène qui commence à `@6b` voit son `t` repartir de 0 +quand sa fenêtre s'ouvre, donc ses animations d'entrée jouent à son arrivée et non +au début de la vidéo. + +## L'ordre de composition, et pourquoi il décide du fond + +Les participantes d'une frame sont empilées **dans l'ordre de déclaration** : la +première du tableau est en bas. Et c'est elle seule qui fournit l'arrière-plan — +les scènes au-dessus n'apportent que leurs enfants, sur un fond transparent. + +Conséquence à connaître : `background` et `animated-background` déclarés sur une +scène qui n'est pas la plus basse de son recouvrement **ne peignent rien**. Le +décor appartient à la scène qui porte, pas à celles qui passent. C'est aussi ce qui +permet au fond de se transformer en continu sous les beats, au lieu d'être recoupé +à chaque cut. + +Les `effects` de scène (grain, vignette, pixelate) suivent la même règle : ceux de +la scène du bas s'appliquent à l'image composée. + +## Trois pièges + +**Une scène ne peut pas à la fois se superposer et déclarer une `transition`.** Une +transition compose deux tampons de pixels finis ; un recouvrement compose des +scènes vivantes. Les deux ne peuvent pas décrire les mêmes frames. Rustmotion +avertit sur stderr et ignore la transition — retire-la, ou décale `at` pour que les +fenêtres ne se touchent plus. + +**`snap: "beat"` ne crée jamais de recouvrement.** Arrondir une coupe sur la grille +peut la tirer avant la fin de la scène précédente ; ce n'est pas une demande de +composition, et le départ est repoussé (avec un avertissement). Un recouvrement se +déclare dans `at`, à la main. Sans cette distinction, `migrate` + `snap` +raccourcirait silencieusement tout fichier qu'il touche. + +**Un trou gèle la dernière image.** Si aucune scène n'est vivante à un instant, la +dernière fenêtre fermée tient sa dernière frame — le rendu ne devient jamais noir +par accident. + +## Quand préférer une vue `world` + +Le recouvrement fait durer un élément **au même endroit du cadre**. La vue `world` +fait autre chose : une caméra traverse un espace où chaque scène occupe une +position. Prends `world` pour un travelling, le recouvrement pour un décor qui +tient pendant que le contenu change. Voir [world-view.md](world-view.md). diff --git a/crates/rustmotion/src/encode/video/tasks.rs b/crates/rustmotion/src/encode/video/tasks.rs index 8ad8ce1..750e4f7 100644 --- a/crates/rustmotion/src/encode/video/tasks.rs +++ b/crates/rustmotion/src/encode/video/tasks.rs @@ -6,6 +6,13 @@ use crate::schema::{ TimingMode, TransitionType, VideoConfig, ViewType, }; +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CompositeParticipant { + pub scene_idx: usize, + pub frame_in_scene: u32, + pub scene_total_frames: u32, +} + #[derive(Clone, Debug)] #[allow(dead_code)] pub enum FrameTask { @@ -31,6 +38,11 @@ pub enum FrameTask { transition_duration: f64, easing: EasingType, }, + Composite { + global_frame: u32, + view_idx: usize, + participants: Vec, + }, WorldFrame { global_frame: u32, view_idx: usize, @@ -125,6 +137,51 @@ pub fn render_frame_task_scaled( ); Ok(pixels) } + FrameTask::Composite { + global_frame, + view_idx, + participants, + } => { + use crate::engine::render::composite::composite_over; + let scenario_time = *global_frame as f64 / config.fps as f64; + let view = &scenario.views[*view_idx]; + let scaled_w = (config.width as f32 * scale_factor) as u32; + let scaled_h = (config.height as f32 * scale_factor) as u32; + + let bottom = participants + .first() + .ok_or(RustmotionError::SurfaceCreation)?; + let mut pixels = render_scene_frame_scaled( + config, + &view.scenes[bottom.scene_idx], + bottom.frame_in_scene, + scenario_time, + bottom.scene_total_frames, + scale_factor, + )?; + + for participant in &participants[1..] { + let overlay = render_scene_fg_scaled( + config, + &view.scenes[participant.scene_idx], + participant.frame_in_scene, + scenario_time, + participant.scene_total_frames, + scale_factor, + )?; + composite_over(&mut pixels, &overlay); + } + + apply_post_effects( + &mut pixels, + scaled_w, + scaled_h, + &view.scenes[bottom.scene_idx].effects, + bottom.frame_in_scene, + bottom.frame_in_scene as f64 / config.fps as f64, + ); + Ok(pixels) + } FrameTask::SlideTransition { global_frame, view_idx, @@ -607,7 +664,13 @@ fn snap_seconds_to_beat(seconds: f64, beat_offset: f64, bpm: f64) -> f64 { beat_offset + n * beat_len } -fn v2_resolve_at_frames(scene: &Scene, scene_idx: usize, fps: u32, fallback: u32) -> u32 { +fn v2_resolve_at_frames( + scene: &Scene, + scene_idx: usize, + fps: u32, + fallback: u32, + snap: SnapDuringPlacement, +) -> u32 { let SceneStart::At(ref tp) = scene.at else { return fallback; }; @@ -621,12 +684,12 @@ fn v2_resolve_at_frames(scene: &Scene, scene_idx: usize, fps: u32, fallback: u32 return fallback; } }; - let seconds = match scene.resolved_snap { - Some(SnapMode::Beat) => match scene.resolved_time_ctx.bpm { + let seconds = match (snap, scene.resolved_snap) { + (SnapDuringPlacement::Apply, Some(SnapMode::Beat)) => match scene.resolved_time_ctx.bpm { Some(bpm) => snap_seconds_to_beat(seconds, scene.resolved_time_ctx.beat_offset, bpm), None => seconds, }, - None => seconds, + _ => seconds, }; (seconds * fps as f64).round().max(0.0) as u32 } @@ -678,24 +741,94 @@ fn build_slide_view_tasks_v2( }) .collect(); - let mut cursor: u32 = 0; + let as_written = v2_scene_starts(scenes, &duration_frames, fps, SnapDuringPlacement::Ignore); + let author_overlaps = v2_has_overlap(&as_written, &duration_frames, &transition_frames); + let starts = v2_scene_starts(scenes, &duration_frames, fps, SnapDuringPlacement::Apply); + + if author_overlaps { + v2_build_composited(tasks, view_idx, scenes, &duration_frames, &starts); + return; + } + + let starts = v2_clamp_forward(&starts, &duration_frames); + v2_build_sequential( + tasks, + view_idx, + scenes, + &duration_frames, + &transition_frames, + &starts, + fps, + ); +} + +#[derive(Clone, Copy, PartialEq)] +enum SnapDuringPlacement { + Apply, + Ignore, +} + +fn v2_scene_starts( + scenes: &[Scene], + duration_frames: &[u32], + fps: u32, + snap: SnapDuringPlacement, +) -> Vec { + let mut starts = Vec::with_capacity(scenes.len()); + let mut cursor: u32 = 0; for (i, scene) in scenes.iter().enumerate() { - let requested_start = match scene.at { + let start = match scene.at { SceneStart::Auto(_) => cursor, - SceneStart::At(_) => v2_resolve_at_frames(scene, i, fps, cursor), + SceneStart::At(_) => v2_resolve_at_frames(scene, i, fps, cursor, snap), }; - let start = if requested_start < cursor { + starts.push(start); + cursor = start + duration_frames[i]; + } + starts +} + +fn v2_has_overlap(starts: &[u32], duration_frames: &[u32], transition_frames: &[u32]) -> bool { + (1..starts.len()).any(|i| { + let previous_end = starts[i - 1] + duration_frames[i - 1]; + starts[i] + transition_frames[i] < previous_end + }) +} + +fn v2_clamp_forward(starts: &[u32], duration_frames: &[u32]) -> Vec { + let mut out = Vec::with_capacity(starts.len()); + let mut cursor: u32 = 0; + for (i, requested) in starts.iter().enumerate() { + let start = if *requested < cursor { eprintln!( - "warning: scene {i}'s `at` resolves before the previous scene's own window \ - ends ({:.3}s) — clamped to avoid an overlap this workstream does not model", - cursor as f64 / fps as f64 + "warning: scene {i}'s `at` lands before the previous scene's own window ends \ + ({:.3}s of frames) once snapped to the beat grid, and has been pushed forward. \ + Snapping quantises a cut, it does not ask two scenes to play at once — write \ + the overlap into `at` itself if that is what you want.", + cursor as f64 ); cursor } else { - requested_start + *requested }; + out.push(start); + cursor = start + duration_frames[i]; + } + out +} +fn v2_build_sequential( + tasks: &mut Vec, + view_idx: usize, + scenes: &[Scene], + duration_frames: &[u32], + transition_frames: &[u32], + starts: &[u32], + fps: u32, +) { + let mut cursor: u32 = 0; + for (i, scene) in scenes.iter().enumerate() { + let start = starts[i]; if start > cursor { let gap = start - cursor; if i == 0 { @@ -761,6 +894,90 @@ fn build_slide_view_tasks_v2( } } +fn v2_build_composited( + tasks: &mut Vec, + view_idx: usize, + scenes: &[Scene], + duration_frames: &[u32], + starts: &[u32], +) { + for (i, scene) in scenes.iter().enumerate() { + if i > 0 && scene.transition.is_some() { + let previous_end = starts[i - 1] + duration_frames[i - 1]; + if starts[i] < previous_end { + eprintln!( + "warning: scene {i} both overlaps scene {} on the absolute timeline and \ + declares a `transition`. A transition composites two finished frame \ + buffers and an overlap composites live scenes; the two cannot both \ + describe the same frames. The transition is ignored here — remove it, or \ + move `at` so the scenes no longer overlap.", + i - 1 + ); + } + } + } + + let total_frames = starts + .iter() + .zip(duration_frames) + .map(|(start, duration)| start + duration) + .max() + .unwrap_or(0); + + for frame in 0..total_frames { + let participants: Vec = starts + .iter() + .zip(duration_frames) + .enumerate() + .filter(|(_, (start, duration))| frame >= **start && frame < **start + **duration) + .map(|(scene_idx, (start, duration))| CompositeParticipant { + scene_idx, + frame_in_scene: frame - start, + scene_total_frames: *duration, + }) + .collect(); + + match participants.len() { + 0 => { + let last_live = starts + .iter() + .zip(duration_frames) + .enumerate() + .filter(|(_, (start, duration))| **start + **duration <= frame) + .max_by_key(|(_, (start, duration))| **start + **duration); + match last_live { + Some((scene_idx, (_, duration))) => tasks.push(FrameTask::Normal { + global_frame: tasks.len() as u32, + view_idx, + scene_idx, + frame_in_scene: duration.saturating_sub(1), + scene_total_frames: *duration, + }), + None => tasks.push(FrameTask::Normal { + global_frame: tasks.len() as u32, + view_idx, + scene_idx: 0, + frame_in_scene: 0, + scene_total_frames: duration_frames[0], + }), + } + } + 1 => tasks.push(FrameTask::Normal { + global_frame: tasks.len() as u32, + view_idx, + scene_idx: participants[0].scene_idx, + frame_in_scene: participants[0].frame_in_scene, + scene_total_frames: participants[0].scene_total_frames, + }), + _ => tasks.push(FrameTask::Composite { + global_frame: tasks.len() as u32, + view_idx, + participants, + }), + } + } +} + fn build_world_view_tasks( tasks: &mut Vec, view_idx: usize, @@ -880,6 +1097,7 @@ impl FrameTask { match self { FrameTask::Normal { global_frame, .. } | FrameTask::SlideTransition { global_frame, .. } + | FrameTask::Composite { global_frame, .. } | FrameTask::WorldFrame { global_frame, .. } | FrameTask::ViewTransition { global_frame, .. } => *global_frame = frame, } @@ -1459,6 +1677,174 @@ mod timing_v2_tests { load_scenario_from_source(None, Some(json)).expect("load") } + fn overlapping_json(second_at: &str) -> String { + format!( + r##"{{ + "video": {{"width": 64, "height": 64, "fps": 10}}, + "timing": "v2", + "composition": [{{"type": "slide", "scenes": [ + {{"duration": 3.0, "children": []}}, + {{"duration": 1.0, "at": "{second_at}", "children": []}} + ]}}] + }}"## + ) + } + + fn composite_participants(tasks: &[FrameTask]) -> Vec> { + tasks + .iter() + .filter_map(|t| match t { + FrameTask::Composite { participants, .. } => { + Some(participants.iter().map(|p| p.scene_idx).collect()) + } + _ => None, + }) + .collect() + } + + #[test] + fn an_explicit_at_that_overlaps_composites_instead_of_being_clamped() { + let scenario = load(&overlapping_json("@1.0s")); + let tasks = build_frame_tasks(&scenario); + + let composites = composite_participants(&tasks); + assert_eq!( + composites.len(), + 10, + "scene 1 runs 1.0s at 10fps entirely inside scene 0, so every one of its frames \ + composites: got {} composite frames", + composites.len() + ); + assert!( + composites.iter().all(|p| p == &vec![0, 1]), + "each composite frame carries both scenes, bottom first: got {composites:?}" + ); + assert_eq!( + tasks.len(), + 30, + "the view lasts max(at + duration) = 3.0s, not the 4.0s a clamped timeline gave" + ); + } + + #[test] + fn a_composited_scene_advances_its_own_clock_from_its_own_at() { + let scenario = load(&overlapping_json("@1.5s")); + let tasks = build_frame_tasks(&scenario); + + let frames_of_scene_1: Vec = tasks + .iter() + .filter_map(|t| match t { + FrameTask::Composite { participants, .. } => participants + .iter() + .find(|p| p.scene_idx == 1) + .map(|p| p.frame_in_scene), + _ => None, + }) + .collect(); + + assert_eq!( + frames_of_scene_1, + (0..10).collect::>(), + "the overlapping scene starts its own clock at 0 when its window opens, and \ + advances one frame per output frame" + ); + } + + #[test] + fn a_scene_spanning_several_others_stays_in_every_one_of_their_frames() { + let scenario = load( + r##"{ + "video": {"width": 64, "height": 64, "fps": 10}, + "timing": "v2", + "composition": [{"type": "slide", "scenes": [ + {"duration": 3.0, "children": []}, + {"duration": 1.0, "at": "@0.0s", "children": []}, + {"duration": 1.0, "at": "@1.0s", "children": []}, + {"duration": 1.0, "at": "@2.0s", "children": []} + ]}] + }"##, + ); + let tasks = build_frame_tasks(&scenario); + + assert_eq!(tasks.len(), 30, "the spanning scene sets the view's length"); + let composites = composite_participants(&tasks); + assert_eq!( + composites.len(), + 30, + "scene 0 spans the whole view, so every frame has two live scenes" + ); + assert!( + composites.iter().all(|p| p[0] == 0), + "the spanning scene is always the bottom participant, so it supplies the \ + background every frame: got {composites:?}" + ); + let second: Vec = composites.iter().map(|p| p[1]).collect(); + assert_eq!(second[0], 1, "beat 1 on top at frame 0"); + assert_eq!(second[10], 2, "beat 2 on top at frame 10"); + assert_eq!(second[20], 3, "beat 3 on top at frame 20"); + } + + #[test] + fn a_gap_between_overlapping_scenes_holds_the_last_live_frame() { + let scenario = load( + r##"{ + "video": {"width": 64, "height": 64, "fps": 10}, + "timing": "v2", + "composition": [{"type": "slide", "scenes": [ + {"duration": 1.0, "children": []}, + {"duration": 1.0, "at": "@0.5s", "children": []}, + {"duration": 1.0, "at": "@3.0s", "children": []} + ]}] + }"##, + ); + let tasks = build_frame_tasks(&scenario); + assert_eq!(tasks.len(), 40, "the view runs to 3.0s + 1.0s"); + + let held: Vec<(usize, u32)> = tasks[15..30] + .iter() + .filter_map(|t| match t { + FrameTask::Normal { + scene_idx, + frame_in_scene, + .. + } => Some((*scene_idx, *frame_in_scene)), + _ => None, + }) + .collect(); + assert!( + held.iter().all(|(idx, frame)| *idx == 1 && *frame == 9), + "the gap holds the last frame of the scene that ended most recently: got {held:?}" + ); + } + + #[test] + fn snapping_a_cut_earlier_never_creates_an_overlap() { + let scenario = load( + r##"{ + "video": {"width": 64, "height": 64, "fps": 10}, + "timing": "v2", + "bpm": 24.0, + "snap": "beat", + "composition": [{"type": "slide", "scenes": [ + {"duration": 3.0, "children": []}, + {"duration": 3.0, "at": "@3.0s", "children": []} + ]}] + }"##, + ); + let tasks = build_frame_tasks(&scenario); + + assert!( + composite_participants(&tasks).is_empty(), + "snapping quantises a cut; it must never be read as asking two scenes to play at \ + once, or `migrate --snap` would silently shorten every file it touches" + ); + assert_eq!( + tasks.len(), + 60, + "both scenes keep their full duration once the snapped start is pushed forward" + ); + } + #[test] fn v2_timing_renders_the_full_declared_duration_with_no_subtraction() { let scenario = load(&six_scene_json(Some("v2"))); @@ -1507,6 +1893,7 @@ mod timing_v2_tests { let actual_global = match task { FrameTask::Normal { global_frame, .. } => *global_frame, FrameTask::SlideTransition { global_frame, .. } => *global_frame, + FrameTask::Composite { global_frame, .. } => *global_frame, FrameTask::WorldFrame { global_frame, .. } => *global_frame, FrameTask::ViewTransition { global_frame, .. } => *global_frame, }; diff --git a/crates/rustmotion/src/engine/render/composite.rs b/crates/rustmotion/src/engine/render/composite.rs new file mode 100644 index 0000000..132152a --- /dev/null +++ b/crates/rustmotion/src/engine/render/composite.rs @@ -0,0 +1,55 @@ +pub fn composite_over(base: &mut [u8], overlay: &[u8]) { + debug_assert_eq!(base.len(), overlay.len()); + let (base_px, _) = base.as_chunks_mut::<4>(); + let (overlay_px, _) = overlay.as_chunks::<4>(); + for (dst, src) in base_px.iter_mut().zip(overlay_px) { + let inverse_source_alpha = 255u32 - src[3] as u32; + for channel in 0..4 { + let kept = (dst[channel] as u32 * inverse_source_alpha + 127) / 255; + dst[channel] = (src[channel] as u32 + kept).min(255) as u8; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_fully_transparent_overlay_leaves_the_base_untouched() { + let mut base = vec![10, 20, 30, 255, 40, 50, 60, 255]; + let overlay = vec![0u8; 8]; + let before = base.clone(); + composite_over(&mut base, &overlay); + assert_eq!(base, before); + } + + #[test] + fn an_opaque_overlay_replaces_the_base() { + let mut base = vec![10, 20, 30, 255]; + let overlay = vec![200, 100, 50, 255]; + composite_over(&mut base, &overlay); + assert_eq!(base, vec![200, 100, 50, 255]); + } + + #[test] + fn a_half_transparent_overlay_blends_towards_it() { + let mut base = vec![0, 0, 0, 255]; + let overlay = vec![128, 128, 128, 128]; + composite_over(&mut base, &overlay); + assert_eq!(base[3], 255); + assert!( + base[0] > 120 && base[0] < 140, + "premultiplied src-over of a 50% grey onto black lands near 128, got {}", + base[0] + ); + } + + #[test] + fn compositing_is_idempotent_for_a_transparent_overlay_whatever_the_base() { + let mut base: Vec = (0..64).map(|i| (i * 3 % 256) as u8).collect(); + let before = base.clone(); + composite_over(&mut base, &[0u8; 64]); + assert_eq!(base, before); + } +} diff --git a/crates/rustmotion/src/engine/render/mod.rs b/crates/rustmotion/src/engine/render/mod.rs index e0d3782..a07d203 100644 --- a/crates/rustmotion/src/engine/render/mod.rs +++ b/crates/rustmotion/src/engine/render/mod.rs @@ -1,5 +1,6 @@ mod background; mod canvas_guard; +pub mod composite; pub mod post_effects; mod scene;