@@ -9,9 +9,7 @@ use crate::error::{Result, RustmotionError};
99use crate :: schema:: ResolvedScenario as Scenario ;
1010
1111use 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 } ;
1513use 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