Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions crates/rustmotion/src/cli/commands/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ pub fn run_checks(loaded: &LoadedScenario, strict_anim: bool) -> ValidationRepor
warnings.extend(warn_misplaced_animation(&loaded.raw));
warnings.extend(check_legibility(&loaded.scenario));
warnings.extend(check_off_grid_cuts(&loaded.scenario));
warnings.extend(transitions_with_nothing_to_come_from(&loaded.scenario));
schema_errors.extend(check_node_references(&loaded.scenario));
let (attr_errors, mut attr_warnings) =
super::validate_attrs::check_component_attrs(&loaded.scenario);
Expand Down Expand Up @@ -254,6 +255,23 @@ pub fn warn_on_silent_defaults(loaded: &LoadedScenario) {
}
}

pub fn transitions_with_nothing_to_come_from(scenario: &ResolvedScenario) -> Vec<String> {
scenario
.views
.iter()
.enumerate()
.filter(|(_, view)| view.scenes.first().is_some_and(|s| s.transition.is_some()))
.map(|(view_idx, _)| {
format!(
"the `transition` on view {view_idx}'s first scene has no effect — a \
transition belongs to the scene being entered, and the first scene has \
nothing to come from. Move it to the next scene, or use the view's own \
`transition` to come in from the view before it."
)
})
.collect()
}

pub fn check_codec(codec: Option<&str>) -> Result<()> {
if let Some(c) = codec {
let allowed = ["h264", "h265", "vp9", "prores"];
Expand Down
130 changes: 128 additions & 2 deletions crates/rustmotion/src/encode/video/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -747,7 +747,15 @@ fn build_slide_view_tasks_v2(
let starts = v2_scene_starts(scenes, &duration_frames, fps, SnapDuringPlacement::Apply);

if author_overlaps {
v2_build_composited(tasks, view_idx, scenes, &duration_frames, &starts);
v2_build_composited(
tasks,
view_idx,
scenes,
&duration_frames,
&transition_frames,
&starts,
fps,
);
return;
}

Expand Down Expand Up @@ -894,17 +902,36 @@ fn v2_build_sequential(
}
}

fn v2_transition_window(
scenes: &[Scene],
duration_frames: &[u32],
transition_frames: &[u32],
starts: &[u32],
incoming: usize,
) -> Option<std::ops::Range<u32>> {
if incoming == 0 || transition_frames[incoming] == 0 || scenes[incoming].transition.is_none() {
return None;
}
let previous_end = starts[incoming - 1] + duration_frames[incoming - 1];
if starts[incoming] + transition_frames[incoming] < previous_end {
return None;
}
Some(starts[incoming]..starts[incoming] + transition_frames[incoming])
}

fn v2_build_composited(
tasks: &mut Vec<FrameTask>,
view_idx: usize,
scenes: &[Scene],
duration_frames: &[u32],
transition_frames: &[u32],
starts: &[u32],
fps: 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 {
if starts[i] + transition_frames[i] < previous_end {
eprintln!(
"warning: scene {i} both overlaps scene {} on the absolute timeline and \
declares a `transition`. A transition composites two finished frame \
Expand Down Expand Up @@ -937,6 +964,45 @@ fn v2_build_composited(
})
.collect();

let transitioning = (1..scenes.len()).find(|&incoming| {
v2_transition_window(scenes, duration_frames, transition_frames, starts, incoming)
.is_some_and(|window| window.contains(&frame))
});
if let Some(incoming) = transitioning {
if let Some(window) =
v2_transition_window(scenes, duration_frames, transition_frames, starts, incoming)
{
{
let outgoing = incoming - 1;
let transition = scenes[incoming]
.transition
.as_ref()
.expect("v2_transition_window returns None without a transition");
let advance = matches!(scenes[outgoing].tail, SceneTail::Continue);
tasks.push(FrameTask::SlideTransition {
global_frame: tasks.len() as u32,
view_idx,
scene_a_idx: outgoing,
scene_b_idx: incoming,
frame_in_transition: frame - window.start,
scene_a_frame_offset: if advance {
duration_frames[outgoing]
} else {
duration_frames[outgoing].saturating_sub(1)
},
scene_a_frame_advance: advance,
scene_a_total_frames: duration_frames[outgoing],
scene_b_total_frames: duration_frames[incoming],
transition_type: transition.transition_type.clone(),
options: transition.into(),
transition_duration: transition_frames[incoming] as f64 / fps as f64,
easing: transition.easing.clone(),
});
continue;
}
}
}

match participants.len() {
0 => {
let last_live = starts
Expand Down Expand Up @@ -1702,6 +1768,66 @@ mod timing_v2_tests {
.collect()
}

#[test]
fn one_overlapping_pair_does_not_disable_the_other_scenes_transitions() {
let scenario = load(
r##"{
"video": {"width": 32, "height": 32, "fps": 30},
"timing": "v2",
"composition": [{"type": "slide", "scenes": [
{"at": 0, "duration": 2.0, "children": []},
{"at": 1.0, "duration": 2.0, "children": []},
{"duration": 2.0, "transition": {"type": "fade", "duration": 1.0}, "children": []}
]}]
}"##,
);
let tasks = build_frame_tasks(&scenario);

let transition_frames: Vec<u32> = tasks
.iter()
.filter_map(|t| match t {
FrameTask::SlideTransition {
scene_a_idx,
scene_b_idx,
frame_in_transition,
..
} => (*scene_a_idx == 1 && *scene_b_idx == 2).then_some(*frame_in_transition),
_ => None,
})
.collect();

assert_eq!(
transition_frames,
(0..30).collect::<Vec<u32>>(),
"scene 2 does not overlap anything and declares a 1.0s fade, so it must still get \
its 30 transition frames — one overlapping pair earlier in the view routed the \
whole thing through the composited path and dropped every transition silently"
);
}

#[test]
fn a_scene_that_overlaps_beyond_its_own_transition_still_loses_it() {
let scenario = load(
r##"{
"video": {"width": 32, "height": 32, "fps": 30},
"timing": "v2",
"composition": [{"type": "slide", "scenes": [
{"at": 0, "duration": 2.0, "children": []},
{"at": 0.5, "duration": 2.0,
"transition": {"type": "fade", "duration": 0.2}, "children": []}
]}]
}"##,
);
let tasks = build_frame_tasks(&scenario);
assert!(
!tasks
.iter()
.any(|t| matches!(t, FrameTask::SlideTransition { .. })),
"a 0.2s transition cannot describe a 1.5s overlap, so the overlap wins and the \
transition is dropped — loudly, which the warning covers"
);
}

#[test]
fn an_explicit_at_that_overlaps_composites_instead_of_being_clamped() {
let scenario = load(&overlapping_json("@1.0s"));
Expand Down
Loading