From ca83dd0600bd4446dc58922bb030da886f526164 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 01:09:22 +0200 Subject: [PATCH] feat(timing): overlapping v2 scenes composite instead of being clamped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a slide view each scene renders alone and a `transition` blends two finished frame buffers, so nothing survives a cut. That is what makes a rustmotion render read as a series of slides rather than a continuous piece: an element cannot stay on screen while the next beat's content arrives over it. The groundwork was already there and refused on purpose. `build_slide_view_tasks_v2` resolved every scene's `at` onto an absolute timeline and then wrote: warning: scene {i}'s `at` resolves before the previous scene's own window ends — clamped to avoid an overlap this workstream does not model This models it. Scenes whose windows overlap now composite: participants are stacked in declaration order, the bottom one supplies the background and the scene `effects`, the ones above contribute only their children over a transparent surface. Each keeps its own clock, so a scene starting at `@6b` begins its own `t` at 0 when its window opens and its entry animations play on arrival. The emission model had to change with it. It walked scenes and appended frames, with `global_frame` implied by `tasks.len()`. It now computes each scene's absolute window first, then walks output frames and collects whoever is live — one participant emits `Normal` exactly as before, several emit `Composite`, none holds the most recently closed scene's last frame so a gap never goes black. ## The distinction that took the work Snapping must never create an overlap. `snap: "beat"` can round a cut *earlier* than the previous scene's end — at 24 BPM a beat is 2.5 s, so `at: "@3.0s"` lands on 2.5 s, half a second inside its predecessor. Reading that as "play both at once" would have made `migrate` + `snap` silently shorten every file it touched, which is what `snap_beat_on_top_of_the_migrated_file_moves_cuts_and_changes_duration` caught: 15.0 s became 13.3 s. So placement resolves twice — once as written, once snapped. If the author's own `at` already overlaps, that is intent and it composites. If only the snapped value does, it is an artefact of quantisation and the start is pushed forward, with the warning saying so. Snapping quantises a cut; it does not ask two scenes to play at once. A scene that both overlaps and declares a `transition` is contradictory — a transition composites two finished buffers, an overlap composites live scenes, and they cannot both describe the same frames. The transition is ignored and named on stderr rather than silently half-applied. ## Also here `build.rs` watched `skills/` for changes but not its subdirectories, and cargo watches a directory's own mtime. Adding a file under `skills/rules/` therefore did not invalidate the build cache, so a new rule could silently fail to reach `rustmotion skills install` until something else forced a rebuild — and `skill_files_match_disk` would fail with no obvious cause. Every walked directory and every collected file is now declared. Verified by adding a file and watching the crate recompile. Five task-level tests, one composite-buffer unit test group, and an end-to-end check: a 12 s scene and four 3 s scenes at `@0b`/`@6b`/`@12b`/`@18b` render 12.0 s with the backdrop present in all eight contact-sheet cells while the beats change over it. Audio and video both come out at 12.000 s. Refs #344 --- crates/rustmotion/build.rs | 14 +- crates/rustmotion/skills/SKILL.md | 1 + .../skills/rules/overlapping-scenes.md | 66 +++ crates/rustmotion/src/encode/video/tasks.rs | 411 +++++++++++++++++- .../rustmotion/src/engine/render/composite.rs | 55 +++ crates/rustmotion/src/engine/render/mod.rs | 1 + 6 files changed, 532 insertions(+), 16 deletions(-) create mode 100644 crates/rustmotion/skills/rules/overlapping-scenes.md create mode 100644 crates/rustmotion/src/engine/render/composite.rs 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;