Skip to content

Commit d96aa0a

Browse files
committed
fix(compositor): split the zoom between the box and the source cut
Review follow-up on the three earlier #179 commits. They all grew the screen box (s_dst) while STILL passing the full p.zoom to screen_source_rect, so the two multiplied: with padding 50% (padding_scale 0.8, cap 1.25) a depth-1 region authored at 1.25x rendered at 1.56x, a depth-4 at 2.75x. The reference does not do that — applyZoomTransform (TS) scales the camera container by exactly zoomScale and never crops the source on top. Give the box a share g of the zoom and take that same share OFF the source cut (p.zoom / g). The product stays zoom, so: * the magnification is exactly what the region asks for; * (box, cut) is one affine image->screen map and splitting it does not move that map — the focus lands on the same pixel it did before. Everything riding the map (cursor layer, 3D tilt, motion blur) is therefore unchanged by construction. Only the drawn EXTENT grows, which is precisely what the issue asks for. Two bounds on g, both derived from the geometry rather than from padding_scale: * cover — the size that covers the frame. Past it, growing adds nothing (the rasterizer clips). Taken from the real rect, so a letterboxed crop (16:9 in a 9:16 output) also eats its bars; the old 1/padding_scale cap never reached them. * focus — the source cut must stay centreable on the target, else screen_source_rect pins it to the crop edge and the zoom stops aiming. Same constraint getFocusBoundsForScale (TS) already applies upstream, so it never blocks growth for an app-authored focus. Also drops the [0,1] clamp on the grown box: it slid off-centre rects (block presets) sideways as they grew, and pinned the rounded corners and shadow to the frame edge instead of letting them leave it — the artifact the second commit had neutralized the radius and shadow to hide. Uncapped matches the reference ("No stage clamping", frameRenderer.cameraAwareMaskRect); the rasterizer clips the overflow, as it already does for the blurred background. screen_zoom_growth / grow_around_center are free functions next to screen_source_rect so they can be tested; the previous closure could not. Three tests, the first of which fails on any of the three earlier versions: the map-invariance sweep (magnification and focus position unchanged across zooms and focus positions), the frame-edge reach, and the focus-limit arbitration. cargo test -p openscreen-compositor --lib --tests: 79 + 3 pass. Bench note: the fixture (zoom 1.8, focus [0.5, 0.32], 5% margin) now grows its box at deep zoom, so bench frames change there by design.
1 parent bed40c7 commit d96aa0a

1 file changed

Lines changed: 186 additions & 62 deletions

File tree

crates/compositor/src/compositor.rs

Lines changed: 186 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,63 @@ fn screen_source_rect(
190190
[su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv]
191191
}
192192

193+
/// Part du zoom absorbée par la BOÎTE de destination (le « zoom frame ») plutôt que par la
194+
/// coupe source — issue #179.
195+
///
196+
/// Le zoom natif se joue entièrement dans le rect SOURCE (`screen_source_rect` rétrécit la
197+
/// coupe autour du focus) pendant que la boîte, elle, ne bouge pas : le zoom s'arrête donc
198+
/// à la frontière paddée au lieu d'atteindre les bords du cadre. La référence web fait
199+
/// l'inverse — `applyZoomTransform` (TS) met à l'échelle le CONTENEUR CAMÉRA, masque
200+
/// compris, donc la boîte paddée grandit avec le zoom et le padding s'efface.
201+
///
202+
/// On répartit donc le zoom entre les deux : la boîte grandit d'un facteur `g`, la coupe
203+
/// source ne porte plus que le reste (`zoom / g`). Le PRODUIT reste `zoom`, donc le
204+
/// grossissement à l'écran est exactement celui demandé par la région. C'est tout l'enjeu
205+
/// du partage : un `g` qui *multiplierait* le zoom au lieu de le partager rendrait une
206+
/// région 1.25× à 1.56× (padding 50 %) — pas ce que la timeline annonce.
207+
///
208+
/// Deux bornes sur `g` :
209+
/// * `cover` — la taille qui couvre le cadre. Au-delà, grandir n'ajoute rien (le rasterizer
210+
/// coupe) ; c'est exactement le point où le padding a fini de s'effacer. Elle se déduit du
211+
/// rect RÉEL, pas de `padding_scale` : une coupe letterboxée (crop 16:9 dans une sortie
212+
/// 9:16) a des bandes qu'aucun facteur de padding ne décrit.
213+
/// * le focus — la coupe source doit rester CENTRABLE sur le point visé, sinon
214+
/// `screen_source_rect` la recale contre le bord du crop et le zoom cesse de viser. Elle
215+
/// tient tant que `zoom / g >= 1 / (2·min(c, 1-c))`. C'est la même contrainte que
216+
/// `getFocusBoundsForScale` (TS) applique en amont en bornant le focus à `1/(2·zoom)` du
217+
/// bord : sur cette borne le budget vaut exactement 1 (le zoom part entièrement dans le
218+
/// pan), et il ne dépasse 1 qu'à l'intérieur. Un focus au bord garde donc son padding,
219+
/// un focus plus central le mange.
220+
fn screen_zoom_growth(zoom: f32, focus: [f32; 2], base: [f32; 4]) -> f32 {
221+
if !zoom.is_finite() || zoom <= 1.0 {
222+
return 1.0;
223+
}
224+
let cover = (1.0 / base[2].max(1e-4)).max(1.0 / base[3].max(1e-4));
225+
// Marge restante de part et d'autre du focus, normalisée : 1 au centre, 0 sur un bord.
226+
// Même normalisation du focus que `screen_source_rect`, pour que les deux bornes parlent
227+
// du même point.
228+
let room = |c: f32| {
229+
let c = if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.5 };
230+
2.0 * c.min(1.0 - c)
231+
};
232+
zoom.min(cover).min(zoom * room(focus[0]).min(room(focus[1]))).max(1.0)
233+
}
234+
235+
/// Grandit un rect autour de son CENTRE. C'est le seul ancrage qui laisse le point de focus
236+
/// tomber exactement là où il tombait sans croissance (cf. le test
237+
/// `growing_the_zoom_frame_leaves_the_focus_where_it_was`) : la boîte grandit de `g`, la
238+
/// coupe source rétrécit de `g`, et les deux décalages s'annulent au centre.
239+
///
240+
/// Volontairement PAS clampé dans le cadre : un rect décentré (presets en bloc) glisserait
241+
/// de côté en grandissant — ce qui casserait justement cette annulation — et ses coins
242+
/// arrondis resteraient collés au bord du cadre au lieu d'en sortir. La référence ne clampe
243+
/// pas non plus (« No stage clamping », `frameRenderer.cameraAwareMaskRect`), et le
244+
/// rasterizer coupe ce qui dépasse (le fond flouté sort déjà du cadre de la même façon).
245+
fn grow_around_center(rect: [f32; 4], g: f32) -> [f32; 4] {
246+
let (nw, nh) = (rect[2] * g, rect[3] * g);
247+
[rect[0] + (rect[2] - nw) * 0.5, rect[1] + (rect[3] - nh) * 0.5, nw, nh]
248+
}
249+
193250
/// Sous-rect SOURCE (en UV de texture) qui remplit une boîte de ratio `box_ar` **sans
194251
/// déformer** l'image : le plus grand rect centré ayant ce ratio, tiré de la frame
195252
/// visible — l'équivalent de `object-fit: cover` côté web.
@@ -1842,46 +1899,26 @@ impl Compositor {
18421899
fit_dst_to_aspect(scale_frame(dst, padding_scale), crop_aspect)
18431900
}
18441901
};
1845-
// ponytail (issue #179) : le rect destination de l'écran ("zoom frame")
1846-
// grandit avec le zoom, du rect paddé jusqu'au cadre complet — plus
1847-
// jamais le switch binaire d'avant (padded → [0,0,1,1] en une frame,
1848-
// qui faisait disparaître le padding instantanément et envoyait l'ombre
1849-
// et les coins sur tout l'output). Le source rect (computé par
1850-
// `screen_source_rect` plus bas) et le zoom frame sont liés : le GPU
1851-
// mappe le source rect sur le zoom frame, et le padding est le delta
1852-
// entre le zoom frame et le cadre. Le padding s'efface smoothly à
1853-
// mesure que le zoom monte, et disparaît pile quand le zoom atteint
1854-
// `1 / padding_scale` (= cadre / padded_size) — au-delà, le zoom
1855-
// frame reste au cadre et seul le source rect continue de rétrécir
1856-
// (le GPU upscale davantage). Le media reste à pleine résolution
1857-
// toute la durée : c'est le *mapping* qui s'ajuste, pas la texture.
1902+
// Issue #179 : le zoom se jouait entièrement dans la coupe source, donc la boîte
1903+
// écran restait au rect paddé et le zoom s'arrêtait à cette frontière au lieu
1904+
// d'atteindre les bords du cadre. On en donne désormais une part à la BOÎTE
1905+
// (`screen_zoom_growth`) et on retire cette même part à la coupe source (plus bas,
1906+
// `p.zoom / s_growth`).
18581907
//
1859-
// TODO : si on veut que l'ombre et les coins restent ancrés au rect
1860-
// paddé pendant que le contenu déborde (effet "fenêtre"), il faudra
1861-
// séparer le rect "cadre" du rect "contenu" et appliquer l'ombre et
1862-
// les coins au cadre seul. Pour l'instant ils suivent le zoom frame,
1863-
// ce qui donne un rendu cohérent (le cadre "suit" le zoom) sans
1864-
// l'abrupt switch.
1865-
let zoom_grow = |zoom: f32, base: [f32; 4]| -> [f32; 4] {
1866-
// growth = min(zoom, 1/padding_scale) : on sature pile quand le
1867-
// zoom frame atteint le cadre. padding_scale est dans (0, 1] ;
1868-
// `max(0.0001)` évite une division par 0 sur des fixtures
1869-
// pathologiques (padding = 100% clampé à 0.6 → 1.667, jamais
1870-
// infini en pratique).
1871-
let growth = zoom.min(1.0 / padding_scale.max(0.0001));
1872-
let cx = base[0] + base[2] * 0.5;
1873-
let cy = base[1] + base[3] * 0.5;
1874-
let nw = (base[2] * growth).min(1.0);
1875-
let nh = (base[3] * growth).min(1.0);
1876-
[
1877-
(cx - nw * 0.5).max(0.0).min(1.0 - nw),
1878-
(cy - nh * 0.5).max(0.0).min(1.0 - nh),
1879-
nw,
1880-
nh,
1881-
]
1882-
};
1883-
let s_dst = zoom_grow(p.zoom, fit_screen(p.screen.dst));
1884-
let s_dst_prev = zoom_grow(pp.zoom, fit_screen(pp.screen.dst));
1908+
// Le point clé : (boîte, coupe) forment UN SEUL mapping affine image→écran, et le
1909+
// partager ne le change pas — même grossissement, même point de focus au même
1910+
// endroit (les deux tests plus bas le figent). Tout ce qui roule sur ce mapping —
1911+
// le curseur, le tilt 3D, le motion blur — est donc inchangé par construction ; la
1912+
// seule différence est l'ÉTENDUE dessinée, qui déborde maintenant le padding.
1913+
// C'est exactement ce que demande l'issue, et rien de plus.
1914+
let s_base = fit_screen(p.screen.dst);
1915+
let s_base_prev = fit_screen(pp.screen.dst);
1916+
// `p.focus` (et non `pp.focus`) pour la frame précédente : même choix que la coupe
1917+
// source plus bas, qui garde volontairement le focus courant.
1918+
let s_growth = screen_zoom_growth(p.zoom, p.focus, s_base);
1919+
let s_growth_prev = screen_zoom_growth(pp.zoom, p.focus, s_base_prev);
1920+
let s_dst = grow_around_center(s_base, s_growth);
1921+
let s_dst_prev = grow_around_center(s_base_prev, s_growth_prev);
18851922
// le padding n'affecte QUE l'écran (la quantité de fond révélée). La webcam reste ancrée
18861923
// en bas-droite à sa marge fixe, quelle que soit la valeur de padding (pas de scale_frame)
18871924
// — SAUF quand l'app a résolu un placement explicite (`app_webcam_rect`, drag-to-reposition
@@ -1952,19 +1989,9 @@ impl Compositor {
19521989
let s_min_px = (s_dst[2] * self.rw()).min(s_dst[3] * self.rh());
19531990
let app_screen_radius_frac = scene_ref.as_ref().and_then(|s| s.layout.screen_radius_frac);
19541991
let scene_roundness_frac = scene_ref.as_ref().map(|s| s.effects.roundness_frac);
1955-
// Quand le contenu déborde du padding (zoom actif), il n'y a plus de cadre visible
1956-
// (le rect écran a été étendu aux bords du cadre de sortie, cf. issue #179) :
1957-
// neutraliser le rayon plutôt que de le laisser suivre l'expansion. Sur le rect
1958-
// plein cadre, `f * s_min_px` arrondit TOUT l'output (le `s_min_px` devient
1959-
// `min(rw, rh)`), pas seulement l'écran — et `f * frame_min_px` fait pareil
1960-
// (les deux quantités coïncident quand s_dst = [0,0,1,1]). TODO : à la place,
1961-
// séparer "rect contenu" de "rect cadre" pour garder les coins au rect paddé
1962-
// pendant que le contenu déborde (cf. le TODO sur `s_dst` plus haut).
1963-
//
1964-
// (révision : la croissance de `s_dst` est *continue* via `zoom_grow` au-dessus,
1965-
// donc `s_min_px` suit le zoom frame et le rayon aussi — pas de switch
1966-
// binaire. Le `if p.zoom > 1.0 { 0.0 }` n'est plus nécessaire ; on le
1967-
// retire pour laisser le rayon suivre naturellement le zoom frame.)
1992+
// Le rayon suit la boîte : quand le zoom l'agrandit (issue #179), les coins grandissent
1993+
// avec elle puis sortent du cadre — comme le masque de la référence, qui porte le même
1994+
// `br: maskBorderRadius * camS` et quitte l'étage au même moment.
19681995
let s_radius = match (cfg.rounded, app_screen_radius_frac, scene_roundness_frac) {
19691996
(false, _, _) => 0.0,
19701997
// Preset en bloc : le rayon appartient à la boîte écran (parité exacte avec la caméra).
@@ -2114,12 +2141,20 @@ impl Compositor {
21142141
None => uv,
21152142
}
21162143
};
2144+
// `/ s_growth` : la boîte a déjà absorbé cette part du zoom (cf. `screen_zoom_growth`).
2145+
// Passer `p.zoom` entier ici rezoomerait par-dessus la boîte agrandie et rendrait la
2146+
// région plus profonde que ce que la timeline annonce.
21172147
let [su0, sv0, su1, sv1] =
2118-
cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, p.focus));
2148+
cover(screen_source_rect(u_max, v_max, active_crop, p.zoom / s_growth, p.focus));
21192149
let (hu, hv) = ((su1 - su0) * 0.5, (sv1 - sv0) * 0.5);
21202150
// Le focus courant reste volontairement utilisé pour la frame précédente, comme avant.
2121-
let [su0_p, sv0_p, su1_p, sv1_p] =
2122-
cover(screen_source_rect(u_max, v_max, active_crop, pp.zoom, p.focus));
2151+
let [su0_p, sv0_p, su1_p, sv1_p] = cover(screen_source_rect(
2152+
u_max,
2153+
v_max,
2154+
active_crop,
2155+
pp.zoom / s_growth_prev,
2156+
p.focus,
2157+
));
21232158
let (hu_p, hv_p) = ((su1_p - su0_p) * 0.5, (sv1_p - sv0_p) * 0.5);
21242159
// Géométrie du tilt, calculée UNE fois : l'ombre et l'écran doivent porter exactement le
21252160
// même quadrilatère. Deux calculs séparés, c'est une ombre qui se décolle dès qu'un des
@@ -2130,13 +2165,9 @@ impl Compositor {
21302165
[(s_dst[0] + s_dst[2] * 0.5) * self.rw(), (s_dst[1] + s_dst[3] * 0.5) * self.rh()];
21312166
// L'ombre suit la silhouette réellement affichée : le rect arrondi quand l'écran est
21322167
// droit, le quadrilatère projeté quand il est incliné. Un rect droit derrière un écran
2133-
// penché ne se lisait pas comme son ombre mais comme une seconde surface.
2134-
//
2135-
// (révision : `s_dst` croît continuously avec le zoom via `zoom_grow`, donc
2136-
// l'ombre suit naturellement le zoom frame. Le `p.zoom <= 1.0` gate d'avant
2137-
// n'est plus nécessaire et on le retire — sinon on aurait une ombre
2138-
// qui disparaît d'un coup quand le zoom démarre, aussi abrupt que le
2139-
// switch binaire de `s_dst`.)
2168+
// penché ne se lisait pas comme son ombre mais comme une seconde surface. Elle suit
2169+
// aussi la croissance de la boîte pendant un zoom (issue #179) : quand la boîte sort
2170+
// du cadre, l'ombre en sort avec elle, sans jamais se lire comme une bande noire.
21402171
if cfg.shadow {
21412172
let spread = SCREEN_SHADOW_SPREAD_FRAC * frame_min_px;
21422173
let offset = [0.0, SCREEN_SHADOW_OFFSET_FRAC * frame_min_px];
@@ -3376,6 +3407,99 @@ mod tests {
33763407
assert_rect(screen_source_rect(0.8, 0.9, Some(crop), 2.0, [1.0, 1.0]), [0.4, 0.36, 0.6, 0.63]);
33773408
}
33783409

3410+
// --- partage du zoom entre la boîte et la coupe (issue #179) ------------
3411+
// La boîte prend une part du zoom pour déborder le padding. Le mapping image→écran,
3412+
// lui, ne doit PAS bouger : ces deux tests le figent, parce que c'est précisément ce
3413+
// qu'un partage mal fait casse en silence (zoom trop profond, focus qui dérive).
3414+
3415+
/// Boîte paddée (padding 50 % → `scale_frame` 0.8) dans une sortie carrée : le cas
3416+
/// plein cadre de l'issue.
3417+
const PADDED: [f32; 4] = [0.1, 0.1, 0.8, 0.8];
3418+
3419+
/// Le couple (boîte, coupe) réellement envoyé au GPU, pour une croissance donnée.
3420+
/// `u_max`/`v_max` à 1 et pas de crop : la coupe est donc directement en fractions.
3421+
fn mapping(base: [f32; 4], zoom: f32, focus: [f32; 2], g: f32) -> ([f32; 4], [f32; 4]) {
3422+
(grow_around_center(base, g), screen_source_rect(1.0, 1.0, None, zoom / g, focus))
3423+
}
3424+
3425+
/// Où le point de focus atterrit à l'écran, en fraction du CADRE.
3426+
fn focus_on_screen(base: [f32; 4], zoom: f32, focus: [f32; 2], g: f32) -> [f32; 2] {
3427+
let (dst, src) = mapping(base, zoom, focus, g);
3428+
let at = |f: f32, s0: f32, s1: f32, d0: f32, dw: f32| d0 + dw * (f - s0) / (s1 - s0);
3429+
[
3430+
at(focus[0], src[0], src[2], dst[0], dst[2]),
3431+
at(focus[1], src[1], src[3], dst[1], dst[3]),
3432+
]
3433+
}
3434+
3435+
/// Grossissement à l'écran : pixels de cadre par unité de source.
3436+
fn magnification(base: [f32; 4], zoom: f32, focus: [f32; 2], g: f32) -> f32 {
3437+
let (dst, src) = mapping(base, zoom, focus, g);
3438+
dst[2] / (src[2] - src[0])
3439+
}
3440+
3441+
/// L'invariant qui compte : donner une part du zoom à la boîte ne change NI le
3442+
/// grossissement NI la position du focus — seulement l'étendue dessinée. Balayé sur
3443+
/// des zooms et des focus variés, dont les bords.
3444+
#[test]
3445+
fn growing_the_zoom_frame_leaves_the_focus_where_it_was() {
3446+
for &zoom in &[1.0, 1.25, 1.5, 1.8, 2.2, 3.5, 5.0] {
3447+
for &focus in &[[0.5, 0.5], [0.3, 0.5], [0.5, 0.8], [0.12, 0.9], [0.0, 1.0]] {
3448+
let g = screen_zoom_growth(zoom, focus, PADDED);
3449+
assert!(g >= 1.0, "la boîte ne rétrécit jamais (zoom {zoom}, g {g})");
3450+
let (was, now) = (
3451+
focus_on_screen(PADDED, zoom, focus, 1.0),
3452+
focus_on_screen(PADDED, zoom, focus, g),
3453+
);
3454+
assert!(
3455+
(was[0] - now[0]).abs() < 1e-4 && (was[1] - now[1]).abs() < 1e-4,
3456+
"focus déplacé (zoom {zoom}, focus {focus:?}, g {g}) : {was:?} → {now:?}"
3457+
);
3458+
let (mw, mn) = (
3459+
magnification(PADDED, zoom, focus, 1.0),
3460+
magnification(PADDED, zoom, focus, g),
3461+
);
3462+
assert!(
3463+
(mw - mn).abs() < 1e-3,
3464+
"grossissement changé (zoom {zoom}, focus {focus:?}) : {mw} → {mn}"
3465+
);
3466+
}
3467+
}
3468+
}
3469+
3470+
/// Ce que l'issue demande : passé le zoom qui couvre le cadre, la boîte l'atteint —
3471+
/// le padding a disparu. Et sans padding il n'y a rien à déborder : pas de croissance.
3472+
#[test]
3473+
fn the_zoom_frame_reaches_the_frame_edges_and_stops_there() {
3474+
let g = screen_zoom_growth(2.2, [0.5, 0.5], PADDED);
3475+
let dst = grow_around_center(PADDED, g);
3476+
assert_rect(dst, [0.0, 0.0, 1.0, 1.0]);
3477+
// La borne est bien la couverture du cadre, pas une valeur de padding : une coupe
3478+
// letterboxée (bandes hautes/basses) continue de grandir jusqu'à les manger.
3479+
let letterboxed = [0.1, 0.36, 0.8, 0.28];
3480+
let g = screen_zoom_growth(5.0, [0.5, 0.5], letterboxed);
3481+
let dst = grow_around_center(letterboxed, g);
3482+
assert!(dst[1] <= 0.0 && dst[1] + dst[3] >= 1.0, "bandes non résorbées : {dst:?}");
3483+
3484+
assert_eq!(screen_zoom_growth(2.0, [0.5, 0.5], [0.0, 0.0, 1.0, 1.0]), 1.0);
3485+
assert_eq!(screen_zoom_growth(1.0, [0.5, 0.5], PADDED), 1.0);
3486+
}
3487+
3488+
/// Un zoom posé sur sa limite de focus a déjà tout dépensé à viser : rien pour la
3489+
/// boîte, et c'est le bon arbitrage — viser juste prime sur manger le padding.
3490+
#[test]
3491+
fn a_zoom_pinned_on_its_focus_limit_keeps_its_pan_rather_than_the_padding() {
3492+
// `getFocusBoundsForScale` (TS) borne déjà le focus à `1/(2·zoom)` du bord : pile
3493+
// l'endroit où la coupe source touche le bord du crop. Quelle que soit la
3494+
// profondeur, un focus posé DESSUS ne laisse aucune marge.
3495+
for &zoom in &[1.5, 2.2, 3.5] {
3496+
let edge = 1.0 / (2.0 * zoom);
3497+
assert_eq!(screen_zoom_growth(zoom, [edge, 0.5], PADDED), 1.0);
3498+
}
3499+
// À l'intérieur de ces bornes, en revanche, la boîte profite de la marge.
3500+
assert!(screen_zoom_growth(3.5, [0.35, 0.5], PADDED) > 1.0);
3501+
}
3502+
33793503
// --- cover_crop_uv : la caméra n'est jamais étirée --------------------
33803504
// Le ratio de la coupe source, ramené en pixels d'image, doit TOUJOURS égaler
33813505
// celui de la boîte : c'est la définition de « pas de déformation ».

0 commit comments

Comments
 (0)