Skip to content

Commit 0ff06be

Browse files
authored
feat(encode): incremental encoding for multi-view slide compositions (#43)
Closes #23. Slot-based segmentation (scenes + view transitions) with pure tested planning, full-builder equivalence lock, safe layout-change fallback, and an honest world-view refusal.
1 parent 3e2c876 commit 0ff06be

2 files changed

Lines changed: 367 additions & 42 deletions

File tree

crates/rustmotion/src/encode/video/h264.rs

Lines changed: 101 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@ use crate::error::{Result, RustmotionError};
99
use crate::schema::ResolvedScenario as Scenario;
1010

1111
use super::mux::mux_h264_to_mp4;
12-
use super::tasks::{
13-
build_frame_tasks, build_scene_frame_tasks, hash_scene, render_frame_task, SceneSegment,
14-
};
12+
use super::tasks::{build_frame_tasks, render_frame_task, SceneSegment};
1513
use super::EncodeProgress;
1614

1715
/// Create an OpenH264 encoder with standard settings for the given video dimensions.
@@ -117,56 +115,42 @@ pub fn encode_video_incremental(
117115
reason: "scenario has no views".to_string(),
118116
});
119117
}
120-
if scenario.views.len() > 1 {
118+
// All-slide compositions (any view count) segment cleanly: one slot per
119+
// scene plus one per inter-view transition. World views don't — camera
120+
// pans composite several scenes per frame.
121+
let Some(slots) = super::tasks::segment_slots(scenario) else {
121122
return Err(RustmotionError::IncrementalUnsupported {
122-
reason:
123-
"incremental encoding requires a single slide view (composition has multiple views)"
124-
.to_string(),
123+
reason: "incremental encoding requires slide views (got a world view)".to_string(),
125124
});
126-
}
127-
if !matches!(scenario.views[0].view_type, crate::schema::ViewType::Slide) {
128-
return Err(RustmotionError::IncrementalUnsupported {
129-
reason: "incremental encoding requires slide view (got world view)".to_string(),
130-
});
131-
}
125+
};
132126

133127
for view in &scenario.views {
134128
preextract_video_frames(&view.scenes, fps);
135129
prefetch_icons(&view.scenes);
136130
}
137131

138-
let num_scenes = scenario.views[0].scenes.len();
139-
140-
let scene_hashes: Vec<u64> = scenario.views[0].scenes.iter().map(hash_scene).collect();
141-
142-
// Determine which scenes need re-rendering
143-
let mut needs_render = vec![true; num_scenes];
144-
if let Some(prev) = prev_segments {
145-
if prev.len() == num_scenes {
146-
let scenes = &scenario.views[0].scenes;
147-
for i in 0..num_scenes {
148-
let hash_changed = scene_hashes[i] != prev[i].scene_hash;
149-
let next_changed_with_transition = if i + 1 < num_scenes {
150-
scene_hashes[i + 1] != prev[i + 1].scene_hash
151-
&& scenes[i + 1].transition.is_some()
152-
} else {
153-
false
154-
};
155-
needs_render[i] = hash_changed || next_changed_with_transition;
156-
}
157-
}
158-
}
132+
let num_slots = slots.len();
133+
let scene_hashes: Vec<u64> = slots
134+
.iter()
135+
.map(|s| super::tasks::slot_hash(scenario, s))
136+
.collect();
137+
let needs_render = super::tasks::plan_dirty(scenario, &slots, &scene_hashes, prev_segments);
159138

160139
let scenes_to_render: usize = needs_render.iter().filter(|&&r| r).count();
161140

162141
if !quiet && on_progress.is_none() {
163-
eprintln!("Re-rendering {}/{} scenes...", scenes_to_render, num_scenes);
142+
eprintln!(
143+
"Re-rendering {}/{} segments...",
144+
scenes_to_render, num_slots
145+
);
164146
}
165147

166-
// Build per-scene tasks
167-
let scene_tasks: Vec<Vec<super::tasks::FrameTask>> = (0..num_scenes)
168-
.map(|i| build_scene_frame_tasks(scenario, i))
148+
// Build per-slot tasks
149+
let scene_tasks: Vec<Vec<super::tasks::FrameTask>> = slots
150+
.iter()
151+
.map(|s| super::tasks::build_slot_frame_tasks(scenario, s))
169152
.collect();
153+
let num_scenes = num_slots;
170154

171155
let total_frames: u32 = scene_tasks.iter().map(|t| t.len() as u32).sum();
172156

@@ -290,3 +274,82 @@ pub fn encode_video_incremental(
290274

291275
Ok(new_segments)
292276
}
277+
278+
#[cfg(test)]
279+
mod incremental_tests {
280+
use super::*;
281+
use crate::loader::load_scenario_from_source;
282+
283+
fn two_view_json(second_text: &str) -> String {
284+
format!(
285+
r##"{{
286+
"video": {{"width": 32, "height": 32, "fps": 10}},
287+
"composition": [
288+
{{"type": "slide", "scenes": [
289+
{{"duration": 0.2, "children": [{{"type": "text", "content": "one"}}]}},
290+
{{"duration": 0.2, "children": [{{"type": "text", "content": "{second_text}"}}]}}
291+
]}},
292+
{{"type": "slide", "transition": {{"type": "fade", "duration": 0.2}}, "scenes": [
293+
{{"duration": 0.2, "children": [{{"type": "text", "content": "three"}}]}}
294+
]}}
295+
]
296+
}}"##
297+
)
298+
}
299+
300+
#[test]
301+
fn multi_view_slide_composition_encodes_incrementally() {
302+
// Used to fail with IncrementalUnsupported ("single slide view").
303+
// Second run with one scene changed must re-render only that scene
304+
// and the segments it feeds — strictly fewer frames than run one.
305+
let out = std::env::temp_dir().join("rustmotion_incr_multiview_test.mp4");
306+
let out_str = out.to_str().unwrap();
307+
308+
let base = load_scenario_from_source(None, Some(&two_view_json("two"))).unwrap();
309+
let mut first_total = 0u32;
310+
let mut cb = |p: EncodeProgress| {
311+
if let EncodeProgress::Rendering(_, total) = p {
312+
first_total = total;
313+
}
314+
};
315+
let segments =
316+
encode_video_incremental(&base, out_str, true, None, Some(&mut cb)).expect("first run");
317+
assert_eq!(segments.len(), 4, "2 + VT + 1 slots");
318+
assert!(out.exists() && std::fs::metadata(&out).unwrap().len() > 0);
319+
320+
let changed = load_scenario_from_source(None, Some(&two_view_json("TWO CHANGED"))).unwrap();
321+
let mut second_total = 0u32;
322+
let mut cb2 = |p: EncodeProgress| {
323+
if let EncodeProgress::Rendering(_, total) = p {
324+
second_total = total;
325+
}
326+
};
327+
let segments2 =
328+
encode_video_incremental(&changed, out_str, true, Some(&segments), Some(&mut cb2))
329+
.expect("second run");
330+
assert_eq!(segments2.len(), 4);
331+
assert!(
332+
second_total > 0 && second_total < first_total,
333+
"second run must re-render a strict subset (first={first_total}, second={second_total})"
334+
);
335+
let _ = std::fs::remove_file(&out);
336+
}
337+
338+
#[test]
339+
fn world_views_still_refuse_incremental_with_a_clear_reason() {
340+
let json = r##"{
341+
"video": {"width": 32, "height": 32, "fps": 10},
342+
"composition": [
343+
{"type": "world", "scenes": [
344+
{"duration": 0.2, "children": [{"type": "text", "content": "w"}]}
345+
]}
346+
]
347+
}"##;
348+
let s = load_scenario_from_source(None, Some(json)).unwrap();
349+
let err = encode_video_incremental(&s, "/tmp/never.mp4", true, None, None).unwrap_err();
350+
assert!(
351+
err.to_string().contains("world"),
352+
"reason must name world views: {err}"
353+
);
354+
}
355+
}

0 commit comments

Comments
 (0)