@@ -3366,7 +3366,16 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
33663366 // ratio de boîte se lit sur `s_base` : `remap_box` met les deux axes à la même
33673367 // échelle, donc la boîte finale a le même ratio et le cover ne dépend pas d'elle
33683368 // (ce qui casserait la circularité coupe → boîte → coupe).
3369- let screen_cover = sampled_layout.as_ref().map(|s| s.layout.screen_cover)
3369+ // A zoom already running inside a block keeps that viewport until it settles.
3370+ // Releasing its clip halfway into PiP would inflate the frame and then shrink it
3371+ // during the same zoom-out. This is derived from time, so seeks and exports agree.
3372+ let keep_screen_clip = zoom_strength > 0.0 && sampled_layout.as_ref()
3373+ .and_then(|s| s.keep_screen_clip_from_sec)
3374+ .is_some_and(|from| crate::regions::zoom_continues_since(zoom_regions, from, source_t, &clock));
3375+ let keep_screen_clip_prev = zoom_strength_prev > 0.0 && previous_layout.as_ref()
3376+ .and_then(|s| s.keep_screen_clip_from_sec)
3377+ .is_some_and(|from| crate::regions::zoom_continues_since(zoom_regions, from, source_t_prev, &clock));
3378+ let screen_cover = keep_screen_clip || sampled_layout.as_ref().map(|s| s.layout.screen_cover)
33703379 .unwrap_or_else(|| scene.map(|s| s.layout.screen_cover).unwrap_or(false));
33713380 let cover_box_ar = screen_cover.then_some((s_base[2] * rw) / (s_base[3] * rh).max(0.0001));
33723381 // Remplissage du format (`screen_follow`) : la fenêtre du cover suit le curseur lissé,
@@ -3376,8 +3385,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
33763385 let (x, y) = cursor.filter(|_| follow)?.follow_at(t)?;
33773386 Some([x * u_max, y * v_max])
33783387 };
3379- let previous_cover_box_ar = previous_layout.as_ref()
3380- .map(|sample| sample.layout.screen_cover).unwrap_or(screen_cover)
3388+ let previous_cover_box_ar = (keep_screen_clip_prev || previous_layout.as_ref()
3389+ .map(|sample| sample.layout.screen_cover).unwrap_or(screen_cover))
33813390 .then_some((s_base_prev[2] * rw) / (s_base_prev[3] * rh).max(0.0001));
33823391 let cover_for = |uv: [f32; 4], at: Option<[f32; 2]>, box_ar: Option<f32>| -> [f32; 4] {
33833392 match (box_ar, at) {
@@ -3602,7 +3611,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
36023611 // Le remplissage incliné n'a pas de masque : sa boîte est le slot, et la carte penche entière.
36033612 let screen_mask = (screen_cover && !fill_tilt).then(|| {
36043613 let slot_px = [s_base[2] * rw, s_base[3] * rh];
3605- let cover_mix = sampled_layout.as_ref().map(|s| s.screen_cover_mix).unwrap_or(1.0);
3614+ let cover_mix = if keep_screen_clip { 1.0 }
3615+ else { sampled_layout.as_ref().map(|s| s.screen_cover_mix).unwrap_or(1.0) };
36063616 let slot_radius = screen_corner_radius_px(outer_radius_of(slot_px), slot_px);
36073617 ScreenMask {
36083618 // A free PiP has no clipping; its equivalent mask is the zoomed box.
@@ -4854,7 +4864,8 @@ mod tests {
48544864 webcam_rect: camera, screen_radius_frac: None, webcam_radius_frac: Some(0.0),
48554865 webcam_shape: Some("rectangle".to_owned()), screen_cover: false,
48564866 },
4857- webcam_reactive_zoom: false, from_layout: None, from_preset: None, from_webcam_reactive_zoom: None, transition_start_sec: start, transition_sec: 0.0,
4867+ webcam_reactive_zoom: false, from_layout: None, from_preset: None, from_webcam_reactive_zoom: None,
4868+ keep_screen_clip_from_sec: None, transition_start_sec: start, transition_sec: 0.0,
48584869 }
48594870 }
48604871
@@ -4977,6 +4988,90 @@ mod tests {
49774988 assert_ne!(g.w_dst, g.w_dst_prev, "an animated camera keeps its motion trail");
49784989 }
49794990
4991+ #[test]
4992+ fn layout_animation_does_not_puff_the_footage_during_an_existing_zoom() {
4993+ use crate::scene::SceneRect;
4994+ let mut scene = golden_scene();
4995+ scene.effects.frame = crate::scene::SceneFrame::Phone;
4996+ scene.crop_by_clip = vec![None];
4997+ let mut zoom = zoomed_golden_scene().zoom_regions.remove(0);
4998+ zoom.start_sec = 0.963;
4999+ zoom.end_sec = 1.301;
5000+ zoom.scale = 1.8;
5001+ let mut later_zoom = zoom.clone();
5002+ later_zoom.start_sec = 5.0;
5003+ later_zoom.end_sec = 6.0;
5004+ scene.zoom_regions = vec![zoom, later_zoom];
5005+ let screen = SceneRect { x: 0.114792, y: 0.077604, width: 0.770416, height: 0.292708 };
5006+ let mut block = layout_region("vertical-stack", 0.0, 1.129, None);
5007+ block.layout.screen_rect = screen;
5008+ block.layout.screen_cover = true;
5009+ let mut pip = layout_region("picture-in-picture", 1.129, 2.163, None);
5010+ pip.layout.screen_rect = SceneRect { y: 0.353646, ..screen };
5011+ pip.from_layout = Some(block.layout.clone());
5012+ pip.transition_sec = 0.2;
5013+ pip.keep_screen_clip_from_sec = Some(1.129);
5014+ let mut no_camera = layout_region("no-webcam", 2.163, 10.0, None);
5015+ no_camera.layout.screen_rect = pip.layout.screen_rect;
5016+ no_camera.from_layout = Some(pip.layout.clone());
5017+ no_camera.transition_sec = 0.1345;
5018+ no_camera.keep_screen_clip_from_sec = Some(1.129);
5019+ scene.layout_regions = vec![block, pip, no_camera];
5020+ let cfg = crate::config::all().pop().expect("compositor config");
5021+ let sample = |t| {
5022+ let mut input = golden_input(&scene, &cfg);
5023+ input.render_px = [1298.0, 1920.0];
5024+ input.timeline_t_override = Some(t);
5025+ plan_frame(&input)
5026+ };
5027+ // Same footage size at both endpoints: moving from top to centre must not inflate it.
5028+ for frame in 0..=180 {
5029+ let t = 1.129 + frame as f32 / 120.0;
5030+ let g = sample(t);
5031+ assert!((g.frame_box()[2] - screen.width).abs() < 0.0001,
5032+ "at {t}: the footage grew to {} instead of {}", g.frame_box()[2], screen.width);
5033+ assert!((g.frame_box()[3] - screen.height).abs() < 0.0001,
5034+ "at {t}: the footage height pulsed");
5035+ }
5036+ assert!(sample(1.25).s_dst[2] > screen.width + 0.1, "the existing zoom still runs inside");
5037+ let next = sample(5.5);
5038+ assert!(next.screen_mask.is_none(), "a later independent PiP zoom must stay free");
5039+ assert!(next.s_dst[2] > screen.width + 0.1);
5040+ }
5041+
5042+ #[test]
5043+ fn layout_animation_preserves_camera_roundness_while_it_disappears() {
5044+ use crate::scene::SceneRect;
5045+ let cfg = crate::config::all().pop().expect("compositor config");
5046+ for radius in [0.0, 0.06, 0.3, 0.5] {
5047+ for appearing in [false, true] {
5048+ let mut scene = golden_scene();
5049+ let mut camera = layout_region("picture-in-picture", 0.0, 3.0,
5050+ Some(SceneRect { x: 0.7, y: 0.6, width: 0.2, height: 0.2 * 16.0 / 9.0 }));
5051+ camera.layout.webcam_radius_frac = Some(radius);
5052+ let mut hidden = layout_region("no-webcam", 0.0, 3.0, None);
5053+ hidden.layout.webcam_radius_frac = None;
5054+ let (source, mut target) = if appearing { (hidden, camera) } else { (camera, hidden) };
5055+ target.start_sec = 3.0;
5056+ target.end_sec = 10.0;
5057+ target.transition_start_sec = 3.0;
5058+ target.transition_sec = 0.2;
5059+ target.from_layout = Some(source.layout.clone());
5060+ scene.layout_regions = vec![source, target];
5061+ for progress in [0.05, 0.25, 0.5, 0.75, 0.95] {
5062+ let mut input = golden_input(&scene, &cfg);
5063+ input.render_px = [1920.0, 1080.0];
5064+ input.timeline_t_override = Some(3.0 + 0.2 * progress);
5065+ let g = plan_frame(&input);
5066+ let side = (g.w_dst[2] * 1920.0).min(g.w_dst[3] * 1080.0);
5067+ assert!(side > 0.0);
5068+ assert!((g.w_radius / side - radius).abs() < 0.0001,
5069+ "roundness {radius}, appearing {appearing}, progress {progress}: got {}", g.w_radius / side);
5070+ }
5071+ }
5072+ }
5073+ }
5074+
49805075 #[test]
49815076 fn every_layout_pair_has_continuous_footage_at_both_transition_edges() {
49825077 use crate::scene::{SceneFrame, SceneRect};
0 commit comments