Skip to content

Commit 06bd7f2

Browse files
committed
fix: preserve layout zoom framing and camera roundness
1 parent d142499 commit 06bd7f2

8 files changed

Lines changed: 289 additions & 11 deletions

File tree

‎crates/compositor/src/frame_geometry.rs‎

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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};

‎crates/compositor/src/regions.rs‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,37 @@ fn connected_pairs(
835835
pairs
836836
}
837837

838+
/// Whether the same uninterrupted zoom is still running after `since` (source time).
839+
/// Uses the zoom sampler's envelope duration, screen clock and connected-pan graph.
840+
/// Once the image settles, a later independent zoom must not inherit a former layout's clip.
841+
pub fn zoom_continues_since(
842+
regions: &[SceneZoomRegion],
843+
since: f32,
844+
t: f32,
845+
clock: &ScreenClock,
846+
) -> bool {
847+
if regions.is_empty() || t < since { return false; }
848+
let (since, t) = (clock.at(since), clock.at(t));
849+
let mut spans: Vec<(f32, f32)> = regions.iter().map(|r| {
850+
let window = if r.under_trim { 0.0 } else { zoom_transition_s(r.scale) };
851+
(clock.at(r.start_sec as f32) - window, clock.at(r.end_sec as f32) + window)
852+
}).collect();
853+
spans.extend(connected_pairs(regions, clock).into_iter().map(|(_, ni, start, end)|
854+
(start, end.max(clock.at(regions[ni].start_sec as f32)))));
855+
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
856+
let mut covered_to: Option<f32> = None;
857+
for (start, end) in spans {
858+
match covered_to {
859+
Some(until) if start > until => return false,
860+
Some(until) => covered_to = Some(until.max(end)),
861+
None if start < since && since < end => covered_to = Some(end),
862+
None => continue,
863+
}
864+
if covered_to.is_some_and(|until| t < until) { return true; }
865+
}
866+
false
867+
}
868+
838869
/// `zoom_state_in` sans speed region ni piste pour l'orbite : ce que les tests comparent.
839870
#[cfg(test)]
840871
pub fn zoom_state_at(
@@ -1703,6 +1734,53 @@ mod zoom_focus_tests {
17031734
}
17041735
}
17051736

1737+
#[test]
1738+
fn layout_zoom_continuity_stops_before_a_later_independent_zoom() {
1739+
let first = region(1.8, 0.5);
1740+
let mut later = region(1.8, 0.5);
1741+
later.start_sec = 14.0;
1742+
later.end_sec = 16.0;
1743+
let window = zoom_transition_s(first.scale);
1744+
let regions = [first, later];
1745+
let clock = ScreenClock::default();
1746+
assert!(zoom_continues_since(&regions, 4.0, 8.0 + window * 0.9, &clock));
1747+
assert!(!zoom_continues_since(&regions, 4.0, 8.0 + window, &clock));
1748+
assert!(!zoom_continues_since(&regions, 4.0, 15.0, &clock));
1749+
assert!(!zoom_continues_since(&regions, 4.0, 3.0, &clock));
1750+
assert!(!zoom_continues_since(&regions, 0.0, 4.0, &clock));
1751+
}
1752+
1753+
#[test]
1754+
fn layout_zoom_continuity_includes_the_connected_pan_and_hold() {
1755+
let mut first = region(1.25, 0.5);
1756+
first.end_sec = 3.0;
1757+
let mut next = region(1.25, 0.7);
1758+
next.start_sec = 4.49;
1759+
next.end_sec = 6.0;
1760+
let regions = [first, next];
1761+
let clock = ScreenClock::default();
1762+
// The independent envelopes leave a hole; the connected pan fills it.
1763+
let middle = 3.745;
1764+
assert_eq!(zoom_region_strength(&regions[0], middle, &clock), 0.0);
1765+
assert_eq!(zoom_region_strength(&regions[1], middle, &clock), 0.0);
1766+
assert!(zoom_state_at(&regions, middle, None).strength > 0.0);
1767+
assert!(zoom_continues_since(&regions, 2.5, middle, &clock));
1768+
assert!(zoom_continues_since(&regions, 2.5, 6.2, &clock));
1769+
}
1770+
1771+
#[test]
1772+
fn layout_zoom_continuity_uses_the_same_speed_clock_and_trim_gate() {
1773+
let zoom = region(1.8, 0.5);
1774+
let window = zoom_transition_s(zoom.scale);
1775+
let speeds = [SceneSpeedRegion { start_sec: 0.0, end_sec: 30.0, speed: 2.0, clip_index: Some(0) }];
1776+
let clock = ScreenClock::new(&speeds, 0);
1777+
assert!(zoom_continues_since(&[zoom.clone()], 4.0, 8.0 + window * 1.5, &clock));
1778+
assert!(!zoom_continues_since(&[zoom.clone()], 4.0, 8.0 + window * 2.0 + 0.001, &clock));
1779+
let trimmed = SceneZoomRegion { under_trim: true, ..zoom };
1780+
assert!(zoom_continues_since(&[trimmed.clone()], 4.0, 7.99, &clock));
1781+
assert!(!zoom_continues_since(&[trimmed], 4.0, 8.01, &clock));
1782+
}
1783+
17061784
/// Le focus automatique ne « perd » jamais la piste : hors de ses bornes, il tient la
17071785
/// dernière (ou la première) position suivie au lieu de retomber sur le point statique de la
17081786
/// région. Sans ça, le plan net sauterait d'un bout de l'écran à l'autre à la fin de la

‎crates/compositor/src/scene.rs‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ pub struct SceneLayoutRegion {
175175
pub from_preset: Option<String>,
176176
#[serde(default)]
177177
pub from_webcam_reactive_zoom: Option<bool>,
178+
/// Last contained-layout exit in source time. A zoom crossing it keeps its viewport.
179+
#[serde(default)]
180+
pub keep_screen_clip_from_sec: Option<f32>,
178181
pub transition_start_sec: f32,
179182
pub transition_sec: f32,
180183
}
@@ -188,6 +191,7 @@ pub struct SampledLayout<'a> {
188191
pub screen_cover_mix: f32,
189192
pub webcam_reactive_zoom: f32,
190193
pub webcam_shadow: f32,
194+
pub keep_screen_clip_from_sec: Option<f32>,
191195
}
192196

193197
impl SceneLayoutRegion {
@@ -225,13 +229,20 @@ impl SceneLayoutRegion {
225229
// Keep the crop policy through the morph. Its mask is blended separately;
226230
// switching the flag at the boundary exposes the entire zoomed footage.
227231
layout.screen_cover = from.screen_cover || layout.screen_cover;
228-
layout.webcam_radius_frac = Some(mix(from.webcam_radius_frac.unwrap_or(0.0),
229-
layout.webcam_radius_frac.unwrap_or(0.0)));
232+
// A missing camera is a scaled-down endpoint of the visible one, not a
233+
// square mask. Keep its relative roundness while its rectangle collapses.
234+
layout.webcam_radius_frac = match (from.webcam_rect, self.layout.webcam_rect) {
235+
(Some(_), None) => from.webcam_radius_frac,
236+
(None, Some(_)) => self.layout.webcam_radius_frac,
237+
_ => Some(mix(from.webcam_radius_frac.unwrap_or(0.0),
238+
layout.webcam_radius_frac.unwrap_or(0.0))),
239+
};
230240
layout.webcam_shape = Some("rectangle".to_owned());
231241
}
232242
}
233243
SampledLayout { preset: &self.preset, layout, from_layout: self.from_layout.as_ref(),
234244
hard_cut: self.from_layout.is_none() || self.transition_sec <= 0.0,
245+
keep_screen_clip_from_sec: self.keep_screen_clip_from_sec,
235246
progress, screen_cover_mix, webcam_reactive_zoom: reactive, webcam_shadow }
236247
}
237248
}

‎src/lib/compositeLayout.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,20 @@ describe("interpolateCompositeLayout", () => {
791791
expect(interpolateCompositeLayout(pip, screenOnly, 0.999).webcamRect?.width).toBeGreaterThan(0);
792792
expect(interpolateCompositeLayout(screenOnly, screenOnly, 0.5).webcamRect).toBeNull();
793793
});
794+
795+
it("preserves the camera's relative corner radius throughout appearance and disappearance", () => {
796+
for (const progress of [0.05, 0.25, 0.5, 0.75, 0.95]) {
797+
for (const [from, to] of [
798+
[pip, screenOnly],
799+
[screenOnly, pip],
800+
]) {
801+
const camera = interpolateCompositeLayout(from, to, progress).webcamRect;
802+
expect(camera).not.toBeNull();
803+
if (camera)
804+
expect(camera.borderRadius / Math.min(camera.width, camera.height)).toBeCloseTo(0.5);
805+
}
806+
}
807+
});
794808
});
795809

796810
describe("computeCameraFullscreenRect", () => {

‎src/native/layoutScene.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,54 @@ function footage() {
4343
}
4444

4545
describe("layout scene consumed by preview, MP4 and GIF", () => {
46+
it("carries the last contained exit across free passages even when a trim hides it", () => {
47+
const base = footage();
48+
base.legacyEditor = { webcamLayoutPreset: "vertical-stack" };
49+
const doc = addLayoutChange(addLayoutChange(base, 2, "picture-in-picture"), 4, "no-webcam");
50+
const regions = buildSceneDescription(doc).layoutRegions;
51+
expect(regions?.map((r) => r.keepScreenClipFromSec)).toEqual([undefined, 22, 22]);
52+
doc.timeline.trimRanges = [
53+
{
54+
id: "before",
55+
clipId: "clip",
56+
assetId: "asset",
57+
startSec: 20,
58+
endSec: 24.5,
59+
origin: "user",
60+
reason: "test",
61+
},
62+
];
63+
const visible = buildSceneDescription(doc).layoutRegions?.find((r) => r.preset === "no-webcam");
64+
expect(visible?.startSec).toBe(24.5);
65+
expect(visible?.keepScreenClipFromSec).toBe(22);
66+
});
67+
68+
it("does not inherit another clip's contained-layout history", () => {
69+
const base = footage();
70+
base.timeline.clips.push({
71+
...base.timeline.clips[0],
72+
id: "second",
73+
timelineStartSec: 10,
74+
timelineEndSec: 20,
75+
});
76+
const doc = addLayoutChange(
77+
addLayoutChange(base, 2, "vertical-stack"),
78+
6,
79+
"picture-in-picture",
80+
);
81+
const scene = buildSceneDescription(doc);
82+
expect(scene.layoutRegions?.filter((r) => r.clipIndex === 1)).toHaveLength(1);
83+
expect(
84+
scene.layoutRegions
85+
?.filter((r) => r.clipIndex === 1)
86+
.every((r) => r.keepScreenClipFromSec === undefined),
87+
).toBe(true);
88+
expect(
89+
scene.layoutRegions?.find((r) => r.clipIndex === 0 && r.startSec === 26)
90+
?.keepScreenClipFromSec,
91+
).toBe(26);
92+
});
93+
4694
it("projects passages into source time with full camera and no-webcam geometry", () => {
4795
const doc = addLayoutChange(addLayoutChange(footage(), 2, "camera-only"), 4, "no-webcam");
4896
const scene = buildSceneDescription(doc);

‎src/native/sceneDescription.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
} from "@/lib/ai-edition/timeline/zoom-scale";
5858
import {
5959
computeCompositeLayout,
60+
isWebcamBlockLayout,
6061
paddedContentSize,
6162
type RenderRect,
6263
resolveWebcamLayoutPreset,
@@ -418,6 +419,8 @@ export interface SceneLayoutRegion {
418419
fromLayout?: ResolvedClipLayout;
419420
fromPreset?: LayoutPreset;
420421
fromWebcamReactiveZoom?: boolean;
422+
/** Last contained-layout exit in source seconds. An ongoing zoom keeps that viewport until it settles. */
423+
keepScreenClipFromSec?: number;
421424
/** Original passage start in source seconds; trimming must not restart its animation. */
422425
transitionStartSec: number;
423426
/** Duration in source seconds, or 0 without fromLayout (including legacy-camera neighbours). */
@@ -1372,6 +1375,22 @@ export function buildSceneDescription(
13721375
);
13731376
const fromLayout =
13741377
!region.legacyCamera && previous && !previous.legacyCamera ? resolve(previous.preset) : null;
1378+
// Carry this history through later free passages and trim projections. Native can
1379+
// then keep a zoom already in flight inside its viewport, without constraining a new one.
1380+
const keepScreenClipFromSec = storedLayouts.reduce<number | undefined>((last, candidate) => {
1381+
if (
1382+
candidate.clipId !== region.clipId ||
1383+
candidate.sourceEndSec === undefined ||
1384+
region.sourceStartSec === undefined ||
1385+
candidate.sourceEndSec > region.sourceStartSec + 0.0001 ||
1386+
!(
1387+
formatFill ||
1388+
(hasCamera && isWebcamBlockLayout(candidate.legacyCamera?.basePreset ?? candidate.preset))
1389+
)
1390+
)
1391+
return last;
1392+
return Math.max(last ?? -Infinity, candidate.sourceEndSec);
1393+
}, undefined);
13751394
return [
13761395
{
13771396
clipIndex: region.clipIndex,
@@ -1380,6 +1399,7 @@ export function buildSceneDescription(
13801399
preset,
13811400
layout,
13821401
webcamReactiveZoom: resolveWebcamReactiveZoom(preset, settings.webcamReactiveZoom),
1402+
...(keepScreenClipFromSec === undefined ? {} : { keepScreenClipFromSec }),
13831403
...(fromLayout && previous
13841404
? {
13851405
fromLayout,

0 commit comments

Comments
 (0)