diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index cc51e7152..461cccd0a 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -61,10 +61,14 @@ const BLUR_WGSL: &str = include_str!("vk_shaders/blur.wgsl"); /// en parcourant les 18 wallpapers livres) en laissant le jeu actif resident. const IMG_CACHE_BUDGET_BYTES: u64 = 512 * 1024 * 1024; -/// `&LayerCB` -> `&[u8; 128]`. `LayerCB` est `#[repr(C, align(16))]`, son layout -/// EST le buffer uniforme WGSL (16 vec4 + 1 vec2 + 2 f32 = 128 octets). +/// Taille du buffer uniforme d'un calque : `LayerCB` entier (176 octets), le `struct Layer` de +/// `layer.wgsl`. `blur.wgsl` n'en lit que les 128 premiers. +const LAYER_BYTES: u64 = std::mem::size_of::() as u64; + +/// `&LayerCB` -> ses octets. `LayerCB` est `#[repr(C, align(16))]`, son layout EST le buffer +/// uniforme WGSL (dix vec4 et un vec2 + 2 f32 = 176 octets). fn layer_bytes(cb: &LayerCB) -> &[u8] { - unsafe { std::slice::from_raw_parts(cb as *const LayerCB as *const u8, 128) } + unsafe { std::slice::from_raw_parts(cb as *const LayerCB as *const u8, LAYER_BYTES as usize) } } /// Un calque de fond deja lie, en attente de son `draw`. `_buf`/`_tex`/`_view` @@ -444,7 +448,7 @@ impl Compositor { ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, - min_binding_size: wgpu::BufferSize::new(128), + min_binding_size: wgpu::BufferSize::new(LAYER_BYTES), }, count: None, }, @@ -2093,20 +2097,18 @@ impl Compositor { // sinon. Place par plan_frame (cover-fit + coins arrondis) ; // `src = g.cut` (crop utilisateur + zoom en UV texture) dans les deux cas. // - // FLOU DE VELOCITE, ET POURQUOI SEULEMENT SUR LE MODE 0. `src_prev`/ - // `dst_prev` decrivent le MEME calque a la frame precedente ; le shader - // remappe chaque pixel de sortie par ce couple pour retrouver l'UV qu'il - // occupait alors, et floute le long du segment. `src_prev = g.cut` et non - // un `cut` d'avant : la coupe est identique aux deux frames (`plan_frame` - // ne fait varier que le rect de DESTINATION entre `s_dst` et - // `s_dst_prev`), ce que Windows documente aussi. Le mouvement vient donc - // entierement de `dst_prev`. + // FLOU DE VELOCITE. Au mode 0, `src_prev`/`dst_prev` decrivent le MEME + // calque a la frame precedente ; le shader remappe chaque pixel de sortie + // par ce couple pour retrouver l'UV qu'il occupait alors, et floute le long + // du segment. `src_prev = g.cut` et non un `cut` d'avant : la coupe est + // identique aux deux frames (`plan_frame` ne fait varier que le rect de + // DESTINATION entre `s_dst` et `s_dst_prev`), ce que Windows documente + // aussi. Le mouvement vient donc entierement de `dst_prev`. // - // Le mode 8 n'en recoit PAS, et ce n'est pas un oubli : ces deux champs y - // portent deja les coins projetes du quad (BR/BL dans `src_prev`, - // `plane_px` dans `dst_prev`). Les deux sens ne peuvent pas cohabiter dans - // un meme draw. macOS et Windows sautent egalement le flou sur le chemin - // incline, pour la meme raison. + // Au mode 8, ces deux champs portent deja les coins projetes du quad + // (BR/BL dans `src_prev`, `plane_px` dans `dst_prev`) : le plan d'avant + // arrive dans ses propres champs (`trail_a`/`trail_b`/`trail_mb`, + // `FrameGeometry::tilt_trail`), meme flou borne a une frame. // Sous un cadre de fenetre, l'ecran garde ses coins HAUTS carres (`mb.w` au mode 0, // `dst_prev.z` au mode 8) ; 0 sans cadre, soit le rendu d'avant. let square_top = g.screen_square_top(); @@ -2143,6 +2145,7 @@ impl Compositor { dof, [rw, rh], g.screen_mask, + g.tilt_trail([rw, rh]), ), }; // Bind group construit AVANT le pass (doit vivre pendant tout le pass) ; diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 4b08767ef..48c17e34b 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -1290,6 +1290,8 @@ impl Compositor { top_lift: f32, // Le slot d'un layout en bloc, qui rogne le plan (`FrameGeometry::screen_mask`). mask: Option, + // Le plan à la frame d'avant, pour son flou de mouvement (`FrameGeometry::tilt_trail`). + trail: Option, y: &metal::Texture, uv: &metal::Texture, dof_pyramid: Option<&metal::Texture>, @@ -1310,6 +1312,7 @@ impl Compositor { dof_pyramid.is_some(), render_px, mask, + trail, ); self.draw_video(enc, &cb, y, uv); } @@ -2282,6 +2285,7 @@ impl Compositor { g.s_radius, top_lift, g.screen_mask, + g.tilt_trail([rw, rh]), &sy, &suv, dof_pyramid.as_ref(), diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 731e417b7..bab031055 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -2003,6 +2003,7 @@ impl Compositor { dof, render_px, g.screen_mask, + g.tilt_trail(render_px), ), &sy, &suv, diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index ccaae5761..00c889f50 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -27,21 +27,21 @@ use crate::config::Cfg; use crate::scene::{Scene, SceneCrop}; -/// Constant buffer d'un calque : **128 octets**, un par draw. +/// Constant buffer d'un calque : **176 octets**, un par draw. /// /// C'est le contrat partagé par les trois côtés — `cbuffer Layer` dans `shaders.hlsl`, -/// `struct Layer` dans `shaders.metal`, et ce struct. Les trois doivent s'accorder champ -/// pour champ ET octet pour octet : un décalage ne produit pas d'erreur, il produit un -/// shader qui lit `color` là où on a écrit `fx`. +/// `struct Layer` dans `shaders.metal` et `vk_shaders/layer.wgsl`, et ce struct. Ils doivent +/// s'accorder champ pour champ ET octet pour octet : un décalage ne produit pas d'erreur, il +/// produit un shader qui lit `color` là où on a écrit `fx`. /// /// `align(16)` vient de la version macOS ; sous `repr(C)` seul, les offsets sont déjà -/// 0/16/32/40/44/48/64/80/96/112 des deux côtés — l'alignement Rust ne change que +/// 0/16/32/40/44/48/64/80/96/112/128/144/160 des deux côtés — l'alignement Rust ne change que /// l'adresse du struct, pas son contenu, et Windows le `copy_nonoverlapping` dans un /// constant buffer mappé où l'alignement source est sans effet. Les deux formes étaient /// donc compatibles ; les unifier évite qu'elles cessent de l'être. /// /// (Le commentaire d'origine annonçait « 64 octets ». Il n'a jamais été juste : dix champs, -/// trente-deux `f32`.) +/// trente-deux `f32`. Les trois derniers, le flou de mouvement de l'écran incliné, en font 176.) #[repr(C, align(16))] #[derive(Clone, Copy, Default)] pub struct LayerCB { @@ -55,6 +55,12 @@ pub struct LayerCB { pub src_prev: [f32; 4], pub dst_prev: [f32; 4], pub mb: [f32; 4], // mb[0] = nombre de taps de motion blur + /// Mode 8 : le plan à la frame PRÉCÉDENTE, coins TL, TR (`trail_a`) puis BR, BL (`trail_b`) + /// en px locaux comme `fx`/`src_prev`, et `trail_mb` = `[taps, force, 0, 0]` de son flou de + /// mouvement, ceux du mode 0 (`mb.xy`). `trail_mb` nul ailleurs : aucun tap, rien n'est lu. + pub trail_a: [f32; 4], + pub trail_b: [f32; 4], + pub trail_mb: [f32; 4], } pub const OUT_W: u32 = 1920; @@ -1135,7 +1141,7 @@ pub fn cursor_sprite_cb( src_prev: [br0, br1, bl0, bl1], // Le clip vit ici et NON dans `fx` (mode 7) : `fx` porte les coins. dst_prev: clip, - // `mb.x` : 1 = warp projectif (caméra réelle). + // `mb.x` : 1 = warp projectif (tout écran incliné, cf. `screen_tilt`). mb: [quad.warp_flag(), 0.0, 0.0, 0.0], ..Default::default() } @@ -1149,9 +1155,19 @@ pub fn cursor_sprite_cb( /// à l'autre vaut `0,42·gain` en 16:9, soit ±4 % ici. pub const CAMERA_LIGHT_GAIN: f32 = 0.2; +/// Le plan incliné une frame plus tôt, et le réglage du flou : ce que le mode 8 floute +/// (`FrameGeometry::tilt_trail`, `tilted_screen_cb`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TiltTrail { + /// Coins TL, TR, BR, BL du quad à la frame précédente, en px relatifs au centre de la boîte + /// COURANTE — le repère de `TiltedQuad::corners`. + pub corners: [(f32, f32); 4], + /// `[taps, force]` : ceux du mode 0 (`FrameGeometry::mb_taps`, `mb_amount`). + pub mb: [f32; 2], +} + /// `LayerCB` de l'écran incliné (mode 8), pour les trois backends : le quad projeté est dessiné -/// dans sa BBOX et le fragment remonte au (s, t) du plan par le warp inverse. Pas de flou de -/// mouvement sur ce chemin : `src_prev`/`dst_prev` y portent déjà les coins et le plan. +/// dans sa BBOX et le fragment remonte au (s, t) du plan par le warp inverse. /// /// - `src` : la coupe source en UV texture ; `center_px` : le centre du rect `s_px` à l'écran ; /// - `radius` : le rayon de l'écran droit, que le plan réduit de `quad.scale` ; @@ -1161,14 +1177,18 @@ pub const CAMERA_LIGHT_GAIN: f32 = 0.2; /// du cadre quand le rayon dépasse la barre (`color.z` = la même hauteur, px du plan) ; /// - `dof` : la profondeur de champ tourne (`FrameGeometry::depth_of_field_on`). /// -/// Sous la caméra réelle (`quad.projective`) : `dst_prev.w` = 1 (warp projectif), et `color.xy` -/// porte l'éclairage, `1 + color.x·(s − 0,5) + color.y·(t − 0,5)` : la lampe de la caméra, fixe -/// dans le monde comme l'œil, éclaire un peu plus le côté proche. Nuls sous un angle fixe, dont le -/// rendu reste celui d'avant à l'octet. +/// `quad.projective` : `dst_prev.w` = 1 (warp projectif). Sous la caméra réelle (`quad.lamp`), +/// `color.xy` porte l'éclairage, `1 + color.x·(s − 0,5) + color.y·(t − 0,5)` : la lampe de la +/// caméra, fixe dans le monde comme l'œil, éclaire un peu plus le côté proche. Nuls sous un angle +/// fixe, dont l'exposition reste celle d'avant. /// /// Sous le masque d'un layout en bloc (`mask`) : le plan penche DANS le slot, qui le rogne. Il est /// dessiné dans le rect du slot au lieu de sa bbox, et `color.w` porte le rayon des coins du slot, /// que le shader applique à ce rect. Nul sans masque, comme avant. +/// +/// `trail` : le plan à la frame précédente. Le shader floute le contenu le long du chemin qu'a +/// parcouru chaque pixel depuis, borné à une frame et réglé comme le mode 0 (`trail_a`, `trail_b`, +/// `trail_mb`). `None`, ou un seul tap : l'échantillon net, le rendu d'avant à l'octet. #[allow(clippy::too_many_arguments)] pub fn tilted_screen_cb( quad: &crate::regions::TiltedQuad, @@ -1181,6 +1201,7 @@ pub fn tilted_screen_cb( dof: bool, render_px: [f32; 2], mask: Option, + trail: Option, ) -> LayerCB { let [rw, rh] = render_px; let corners = quad.corners; @@ -1231,6 +1252,17 @@ pub fn tilted_screen_cb( mb: quad.depth_mb(s_px, focus_plane, dof), ..Default::default() }; + if let Some(trail) = trail { + // Même repère local que les coins courants : sans mouvement, les deux quads coïncident + // au bit près et le shader garde son échantillon net. + let [a0, a1] = local(trail.corners[0]); + let [b0, b1] = local(trail.corners[1]); + let [c0, c1] = local(trail.corners[2]); + let [d0, d1] = local(trail.corners[3]); + cb.trail_a = [a0, a1, b0, b1]; + cb.trail_b = [c0, c1, d0, d1]; + cb.trail_mb = [trail.mb[0], trail.mb[1], 0.0, 0.0]; + } if let Some(mask) = mask { reframe_warped(&mut cb, mask.rect, render_px); cb.color[3] = mask.radius_px; @@ -1656,6 +1688,12 @@ pub struct FrameGeometry { /// Caméra réelle de `follow-cursor` (`camera.rs`), `None` sans elle. Jamais en même temps /// qu'une `zoom_rotation` non nulle. pub camera: Option, + /// La rotation de base une frame d'écran plus tôt : avec `s_dst_prev` et `camera_prev`, le + /// zoom d'AVANT, que le mode 8 floute (`tilt_trail`). + pub zoom_rotation_prev: [f32; 3], + /// La caméra réelle une frame plus tôt (poids, visée, orbite, zoom), `None` si elle n'y était + /// pas active — même quand elle l'est à cette frame-ci, ou l'inverse. + pub camera_prev: Option, pub padding_scale: f32, /// Coupe source de l'écran en UV texture (crop utilisateur + zoom). pub cut: [f32; 4], @@ -1729,12 +1767,20 @@ impl ScreenMask { } /// Redessine un calque WARPÉ (modes 8 et 16 : coins TL/TR dans `fx`, BR/BL dans `src_prev`, en px -/// locaux à `dst`) dans une autre boîte `dst`. Le warp ne dépend pas de la boîte de dessin, qui ne -/// fait que borner les pixels rastérisés : il suffit de reporter les coins dans son repère. +/// locaux à `dst` ; au mode 8, ceux de la frame d'avant dans `trail_a`/`trail_b`) dans une autre +/// boîte `dst`. Le warp ne dépend pas de la boîte de dessin, qui ne fait que borner les pixels +/// rastérisés : il suffit de reporter les coins dans son repère. fn reframe_warped(cb: &mut LayerCB, dst: [f32; 4], render_px: [f32; 2]) { let shift = [(dst[0] - cb.dst[0]) * render_px[0], (dst[1] - cb.dst[1]) * render_px[1]]; - for c in [&mut cb.fx, &mut cb.src_prev] { + let reframe = |c: &mut [f32; 4]| { *c = [c[0] - shift[0], c[1] - shift[1], c[2] - shift[0], c[3] - shift[1]]; + }; + reframe(&mut cb.fx); + reframe(&mut cb.src_prev); + // La traînée, seulement s'il y en a une : sans elle, ses champs restent nuls. + if cb.trail_mb[0] > 0.0 { + reframe(&mut cb.trail_a); + reframe(&mut cb.trail_b); } cb.dst = dst; cb.quad_px = [dst[2] * render_px[0], dst[3] * render_px[1]]; @@ -1780,21 +1826,54 @@ impl FrameGeometry { self.zoom_rotation, self.zoom_rotation_dyn, ); - // Sous un cadre d'APPAREIL, l'écran passe au warp PROJECTIF même sous un angle fixe. - // Le mode 17 lance ses rayons dans la perspective exacte ; le warp bilinéaire s'en - // écarte de ~8 % de la largeur au milieu des bords (mesuré par - // `the_device_screen_face_lands_on_the_footage_plane`), soit une lunette à cent - // pixels du bord de l'image. Les quatre coins sont les mêmes des deux côtés, donc - // l'homographie qu'ils définissent EST cette projection exacte : passer le drapeau - // recolle l'image sur le modèle, partout et pas seulement aux coins. Sans appareil — - // sans cadre ou sous le chrome plat — rien ne change, à l'octet. - if self.window_frame.as_ref().is_some_and(|f| f.kind.is_device()) { - quad.projective = true; - } + // Un angle fixe se dessine au warp PROJECTIF, comme la caméra réelle. Les quatre coins + // sont ceux de la projection exacte, donc l'homographie qu'ils définissent EST cette + // projection, partout et pas seulement aux coins. Le warp bilinéaire s'en écartait de + // ~8 % de la largeur au milieu des bords (mesuré par + // `the_device_screen_face_lands_on_the_footage_plane`), et surtout il PENCHAIT le + // contenu : la verticale du milieu de l'écran, droite sous une caméra sans roulis, + // tombait de 2,9° sous `iso` et de 0,8° sous `left`/`right`, et ce roulis-là entrait + // avec le zoom. Le mode 17 (appareil) en dépend aussi : il lance ses rayons dans la + // perspective exacte. + quad.projective = true; quad }) } + /// Le plan incliné une frame d'écran plus tôt, avec le réglage du flou : ce que le mode 8 + /// floute (`tilted_screen_cb`). `None` quand l'écran est droit ou le flou coupé. + /// + /// La frame d'avant est celle du mode 0 : la boîte au zoom d'avant (`s_dst_prev`), et ici la + /// rotation de base d'avant (un angle fixe entre avec le zoom) ou la caméra réelle d'avant + /// (`camera_prev`). Parallaxe et impact restent ceux de la frame, comme le focus du mode 0 : le + /// flou suit le zoom et la caméra, borné à une frame. + pub fn tilt_trail(&self, render_px: [f32; 2]) -> Option { + if !self.tilted() || self.mb_taps < 2.0 || self.mb_amount <= 0.001 { + return None; + } + let d = self.s_dst_prev; + let px = [d[2] * render_px[0], d[3] * render_px[1]]; + let quad = match self.camera_prev { + Some(pose) => crate::camera::View::new(px, pose).quad(px), + None => crate::regions::rotated_quad_corners_px( + px[0], + px[1], + self.zoom_rotation_prev, + self.zoom_rotation_dyn, + ), + }; + // Les coins d'avant, reportés au centre de la boîte COURANTE (le repère des siens). + let now = self.screen_center_px(render_px); + let shift = [ + (d[0] + d[2] * 0.5) * render_px[0] - now[0], + (d[1] + d[3] * 0.5) * render_px[1] - now[1], + ]; + Some(TiltTrail { + corners: quad.corners.map(|(x, y)| (x + shift[0], y + shift[1])), + mb: [self.mb_taps, self.mb_amount], + }) + } + /// L'écran passe-t-il par le plan projeté (modes 8, 12, 13, 14) plutôt que par le rect droit ? pub fn tilted(&self) -> bool { self.camera.is_some() || !crate::regions::is_identity_rotation(self.zoom_rotation) @@ -2312,8 +2391,7 @@ impl FrameGeometry { return Some(mask); } - // Écran incliné (le mode 8 n'a pas de flou de mouvement : pas de trace à couvrir). - // Même quad et même centre que le dessin de l'écran et que `plan_cursor`. + // Écran incliné. Même quad et même centre que le dessin de l'écran et que `plan_cursor`. let s_px = [self.s_dst[2] * rw, self.s_dst[3] * rh]; let quad = self.screen_tilt(s_px)?; let centre = [ @@ -2333,6 +2411,42 @@ impl FrameGeometry { [centre[0] + px, centre[1] + py] }; let pts = [at(x0, y0), at(x1, y0), at(x1, y1), at(x0, y1)]; + // Le mode 8 étale aussi chaque pixel vers là où le plan était une frame plus tôt + // (`tilt_trail`) : une copie traînée du secret sort du quad courant. Le masque couvre alors + // la boîte englobante du secret aux deux frames, en rect droit, comme le chemin droit. + // Même warp que le shader sur les coins d'avant (le drapeau projectif est celui du plan). + // La perspective grossit localement le côté proche : la force suit l'arête la plus + // agrandie par rapport au rect droit zoomé, pour que nulle part le masque ne soit plus + // fin, rapporté au contenu, qu'au repos. + let (ew, eh) = ((x1 - x0) * s_px[0], (y1 - y0) * s_px[1]); + let len = |a: [f32; 2], b: [f32; 2]| (b[0] - a[0]).hypot(b[1] - a[1]); + let tilt_k = (len(pts[0], pts[1]) / ew) + .max(len(pts[3], pts[2]) / ew) + .max(len(pts[0], pts[3]) / eh) + .max(len(pts[1], pts[2]) / eh); + let strength = (zoom_k * tilt_k).max(1.0); + // Sans mouvement, la traînée EST le quad (au bit près) : rien de plus à couvrir. + if let Some(trail) = self.tilt_trail(render_px).filter(|t| t.corners != quad.corners) { + let before = crate::regions::TiltedQuad { corners: trail.corners, ..quad }; + let then = |fx: f32, fy: f32| { + let (px, py) = before.point_px(fx, fy); + [centre[0] + px, centre[1] + py] + }; + let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]); + for [px, py] in pts.into_iter().chain([then(x0, y0), then(x1, y0), then(x1, y1), then(x0, y1)]) { + (lo, hi) = ([lo[0].min(px), lo[1].min(py)], [hi[0].max(px), hi[1].max(py)]); + } + let rect = + [lo[0] / rw, lo[1] / rh, (hi[0] - lo[0]).max(1.0) / rw, (hi[1] - lo[1]).max(1.0) / rh]; + let rect = match self.screen_mask { + Some(m) => m.clip(rect)?, + None => rect, + }; + let mut mask = PrivacyMask::upright(rect, render_px, strength); + // Un ovale inscrit dans la boîte élargie ne couvrirait plus l'ovale courant. + mask.oval_ok = false; + return Some(mask); + } let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); let (mut max_x, mut max_y) = (f32::MIN, f32::MIN); for [px, py] in pts { @@ -2354,21 +2468,12 @@ impl FrameGeometry { quad_px = [c[2] * rw, c[3] * rh]; dst = c; } - // La perspective grossit localement le côté proche : la force suit l'arête la plus - // agrandie par rapport au rect droit zoomé, pour que nulle part le masque ne soit plus - // fin, rapporté au contenu, qu'au repos. - let (ew, eh) = ((x1 - x0) * s_px[0], (y1 - y0) * s_px[1]); - let len = |a: [f32; 2], b: [f32; 2]| (b[0] - a[0]).hypot(b[1] - a[1]); - let tilt_k = (len(pts[0], pts[1]) / ew) - .max(len(pts[3], pts[2]) / ew) - .max(len(pts[0], pts[3]) / eh) - .max(len(pts[1], pts[2]) / eh); Some(PrivacyMask { dst, quad_px, warp: Some(local), projective: quad.projective, - strength: (zoom_k * tilt_k).max(1.0), + strength, oval_ok: true, }) } @@ -2532,15 +2637,17 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { let source_t_prev = clock.source_at(clock.at(source_t) - 1.0 / FPS); // le focus "auto" (suivi curseur) réutilise la même piste que le rendu du curseur. let cursor_for_zoom = cursor; - // La rotation 3D (mode 8, pas de motion blur dans ce chemin — cf. le commentaire au - // point d'appel) n'est calculée QUE pour la frame courante ; `pp` ne sert qu'au zoom - // écran normal (vélocité pour le motion blur du chemin non-tilté). + // `pp` porte le zoom de la frame précédente, pour le flou de mouvement ; celui du plan + // incliné y ajoute sa rotation de base et le poids de la caméra réelle (`tilt_trail`). let mut zoom_rotation = [0.0f32; 3]; + let mut zoom_rotation_prev = [0.0f32; 3]; let mut zoom_tilt = 0.0f32; let mut zoom_click_impact = 0.0f32; let mut zoom_camera = 0.0f32; + let mut zoom_camera_prev = 0.0f32; let mut zoom_aim = [0.5f32; 2]; let mut zoom_orbit = [0.5f32; 2]; + let (mut zoom_aim_prev, mut zoom_orbit_prev) = ([0.5f32; 2], [0.5f32; 2]); // Curseur masqué → pas de piste pour ce qui anime le plan (parallaxe, impact, caméra // `follow-cursor`) : l'export ne charge la piste que si le curseur est affiché // (`timeline_walk`), la preview toujours. Sans cette porte, la preview pencherait un @@ -2570,15 +2677,21 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { zoom_camera = zs.camera; zoom_aim = zs.aim; zoom_orbit = zs.orbit; + // Même `CameraFrame` qu'à la frame courante : la caméra réelle d'avant (poids, visée, + // orbite) sert au flou du mode 8. Échelle et focus n'en dépendent pas. let zs_p = crate::regions::zoom_state_in( zoom_regions, source_t_prev, cursor_for_zoom, - &crate::regions::CameraFrame::NONE, + &camera, &clock, ); pp.zoom = zs_p.scale; pp.focus = zs_p.focus; + zoom_rotation_prev = zs_p.rotation; + zoom_camera_prev = zs_p.camera; + zoom_aim_prev = zs_p.aim; + zoom_orbit_prev = zs_p.orbit; } // Full Camera ignore le rétrécissement réactif de la webcam (design web : mélanger // "rétrécit pour le zoom" et "grandit en plein cadre" dans la même frame n'a pas de sens). @@ -2791,6 +2904,16 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { zoom: p.zoom, press, }); + // La caméra réelle de la frame d'avant, dès qu'elle y était active — y compris quand elle + // ne l'est plus à celle-ci (fin de sa moitié d'une transition vers un angle fixe) ; `None` + // quand elle ne l'était pas, et le mode 8 reprend alors la rotation d'avant. + let camera_prev = (zoom_camera_prev > 0.0).then(|| crate::camera::CameraPose { + weight: zoom_camera_prev, + aim: focus_in_cut(u_max, v_max, active_crop, zoom_aim_prev, cut), + orbit: focus_in_cut(u_max, v_max, active_crop, zoom_orbit_prev, cut), + zoom: pp.zoom, + press, + }); let focus_plane = match camera { Some(pose) => pose.orbit, None => focus_in_cut(u_max, v_max, active_crop, p.focus, cut), @@ -2981,6 +3104,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { zoom_rotation, zoom_rotation_dyn, camera, + zoom_rotation_prev, + camera_prev, padding_scale, cut, focus_plane, @@ -3642,7 +3767,10 @@ impl ModelView { ]) }); let top = corners.iter().fold(0.0f32, |m, p| m.max(p[2])) / self.unit; - let penumbra = (top / lz / MODEL_SOFTNESS).min(MODEL_SHADOW_PAD); + // La marche du shader court jusqu'à la sortie de la boîte ÉLARGIE de `MODEL_SHADOW_PAD` : + // sous une lumière rasante (`right`, `lz` ≈ 0,2), le rayon y va bien au-delà de `top / lz`, + // et la pénombre avec lui. + let penumbra = ((top + MODEL_SHADOW_PAD) / lz / MODEL_SOFTNESS).min(MODEL_SHADOW_PAD); let reach = (penumbra / lz + MODEL_CONTACT_RADIUS) * self.unit; let mut b = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; let mut add = |p: [f32; 2]| { @@ -3804,6 +3932,7 @@ fn cursor_impact_cb( src_prev: [br0, br1, bl0, bl1], dst_prev: [x0, y0, 2.0 * hx, 2.0 * hy], mb: [quad.warp_flag(), impact.spot_r, impact.spot_a, 0.0], + ..Default::default() }) } @@ -4425,6 +4554,7 @@ mod tests { false, RENDER, g.screen_mask, + None, ) }; for (name, _) in DEVICES { @@ -4432,10 +4562,10 @@ mod tests { assert_eq!(cb.dst_prev[3], 1.0, "{name}: l'écran est resté au warp bilinéaire"); assert_eq!(cb.color, [0.0; 4], "{name}: la lampe de la caméra s'est allumée"); } - // Sans appareil, l'angle fixe garde son warp bilinéaire ET son absence de lampe ; la - // caméra réelle, elle, garde les deux. + // Sans appareil, l'angle fixe a le même warp exact (`screen_tilt`) et toujours pas de + // lampe ; la caméra réelle, elle, a les deux. let plain = flat_cb(&framed_plan(&framed_scene("", r#""iso""#, 1.0, false))); - assert_eq!((plain.dst_prev[3], plain.color), (0.0, [0.0; 4])); + assert_eq!((plain.dst_prev[3], plain.color), (1.0, [0.0; 4])); let orbit = flat_cb(&framed_plan(&framed_scene("", r#""follow-cursor""#, 1.0, false))); assert_eq!(orbit.dst_prev[3], 1.0); assert!(orbit.color[0] != 0.0 || orbit.color[1] != 0.0, "la caméra réelle n'éclaire plus"); @@ -5423,7 +5553,7 @@ mod tests { assert!(hx > slot_px[0] * 0.5 && hy > slot_px[1] * 0.5, "{rotation} : garde, le plan zoomé déborde du slot"); let centre = g.screen_center_px(RENDER); - let draw = |m| tilted_screen_cb(&quad, s_px, centre, g.cut, g.focus_plane, g.s_radius, 0.0, false, RENDER, m); + let draw = |m| tilted_screen_cb(&quad, s_px, centre, g.cut, g.focus_plane, g.s_radius, 0.0, false, RENDER, m, None); let (cb, bare) = (draw(g.screen_mask), draw(None)); assert_eq!(cb.dst, SLOT, "{rotation} : dessiné dans le slot"); assert_eq!(cb.color[3], mask.radius_px); @@ -5583,6 +5713,116 @@ mod tests { assert_eq!(flat.zoom_rotation_dyn, [0.0; 3], "sans préset"); } + /// Le plan incliné a son flou de mouvement, borné à une frame comme celui du mode 0 : sa + /// traînée est le plan une frame d'écran plus tôt, boîte ET rotation de base — un angle fixe + /// entre avec le zoom. + #[test] + fn the_tilted_screen_trails_its_previous_frame() { + let cfg = crate::config::all().pop().expect("au moins une config"); + let render = [1170.0, 658.0]; + // La région démarre à 2 s : à 1,6 s, sa rampe d'entrée est en cours. + let ramp = |json: &str| json.replace(r#""startSec":0.0"#, r#""startSec":2.0"#); + let tilted = ramp(zoomed_golden_scene_json()).replace(r#""rotation":"none""#, r#""rotation":"iso""#); + let at = |json: &str, t: f32| { + let scene = Scene::from_json(json).expect("scène"); + plan_frame(&FrameGeometryInput { timeline_t_override: Some(t), ..golden_input(&scene, &cfg) }) + }; + let quad_of = |g: &FrameGeometry| { + g.screen_tilt([g.s_dst[2] * render[0], g.s_dst[3] * render[1]]).expect("incliné") + }; + let width = |c: &[(f32, f32); 4]| { + c.iter().map(|p| p.0).fold(f32::MIN, f32::max) - c.iter().map(|p| p.0).fold(f32::MAX, f32::min) + }; + + let g = at(&tilted, 1.6); + let trail = g.tilt_trail(render).expect("flou sur le plan incliné"); + assert_eq!(trail.mb, [g.mb_taps, g.mb_amount]); + assert_ne!(g.zoom_rotation_prev, g.zoom_rotation, "le préset entre avec le zoom"); + let (now, before) = (width(&quad_of(&g).corners), width(&trail.corners)); + // Zoom avant : le plan d'une frame plus tôt est plus petit, de quelques pour cent. + assert!(before < now && before > now * 0.9, "traînée {before} px, plan {now} px"); + + // Au palier, rien ne bouge : la traînée EST le plan, au bit près, et le shader garde son + // échantillon net. + let hold = at(&tilted, 3.0); + assert_eq!(hold.tilt_trail(render).expect("incliné").corners, quad_of(&hold).corners); + + // Flou coupé, ou écran droit (le mode 0 a le sien) : pas de traînée. + let off = tilted.replace(r#""roundnessFrac":0.0255,"motionBlur":0.35"#, r#""roundnessFrac":0.0255,"motionBlur":0"#); + assert_ne!(off, tilted, "la substitution doit couper le flou"); + assert_eq!(at(&off, 1.6).tilt_trail(render), None); + assert_eq!(at(&ramp(zoomed_golden_scene_json()), 1.6).tilt_trail(render), None); + } + + /// Transition chaînée caméra réelle → angle fixe : la frame d'avant peut encore être dans la + /// moitié `follow-cursor` quand celle-ci est dans la moitié fixe. La traînée doit alors partir + /// du plan vu par la caméra d'AVANT, pas d'un plan droit. + #[test] + fn the_trail_keeps_the_previous_camera_across_a_follow_to_fixed_handover() { + let cfg = crate::config::all().pop().expect("au moins une config"); + let render = [1170.0, 658.0]; + let track: &'static crate::cursor::CursorTrack = Box::leak(Box::new( + crate::cursor::CursorTrack::new((0..=300).map(|i| (i as f32 / 30.0, 0.8, 0.3)).collect(), vec![], vec![]), + )); + let json = zoomed_golden_scene_json().replace( + r#"[{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.5,"focusY":0.3,"rotation":"none"}]"#, + r#"[{"clipIndex":0,"startSec":1.0,"endSec":4.0,"scale":2.0,"focusX":0.5,"focusY":0.5,"rotation":"follow-cursor"}, + {"clipIndex":0,"startSec":4.5,"endSec":8.0,"scale":2.0,"focusX":0.5,"focusY":0.5,"rotation":"iso"}]"#, + ); + assert_ne!(json, zoomed_golden_scene_json(), "la substitution doit poser les deux régions"); + let scene = Scene::from_json(&json).expect("scène"); + let mut handover = None; + for k in 0..=600 { + let t = 4.0 + k as f32 / 600.0; + let g = plan_frame(&FrameGeometryInput { cursor: Some(track), timeline_t_override: Some(t), ..golden_input(&scene, &cfg) }); + assert_eq!(g.camera_prev.is_some(), g.camera_prev.is_some_and(|p| p.weight > 0.0)); + if g.camera.is_none() && g.camera_prev.is_some() { + handover = Some(g); + break; + } + } + let g = handover.expect("une frame où seule la frame d'avant a la caméra"); + let trail = g.tilt_trail(render).expect("traînée"); + let d = g.s_dst_prev; + let px = [d[2] * render[0], d[3] * render[1]]; + let seen = crate::camera::View::new(px, g.camera_prev.unwrap()).quad(px); + let upright = crate::regions::rotated_quad_corners_px(px[0], px[1], g.zoom_rotation_prev, g.zoom_rotation_dyn); + let shape = |c: [(f32, f32); 4]| c.map(|(x, y)| (x - c[0].0, y - c[0].1)); + let close = |a: [(f32, f32); 4], b: [(f32, f32); 4]| a.iter().zip(b).all(|(p, q)| (p.0 - q.0).abs() < 1e-3 && (p.1 - q.1).abs() < 1e-3); + assert!(close(shape(trail.corners), shape(seen.corners)), "la traînée n'est pas le plan de la caméra d'avant"); + assert!(!close(shape(trail.corners), shape(upright.corners)), "la traînée est retombée sur un plan droit"); + } + + /// Le calque du mode 8 porte la traînée dans le repère de ses propres coins, y compris + /// reporté dans le slot d'un layout en bloc ; sans elle, rien de ce qu'il portait ne change. + #[test] + fn the_tilted_screen_cb_carries_its_trail_in_its_own_frame() { + let quad = crate::regions::rotated_quad_corners_px(800.0, 450.0, [-23.0, -25.0, 0.0], [0.0; 3]); + let moved = TiltTrail { corners: quad.corners.map(|(x, y)| (x * 0.9 + 3.0, y * 0.9 - 2.0)), mb: [6.0, 0.35] }; + let mask = ScreenMask { rect: [0.1, 0.1, 0.6, 0.6], radius_px: 8.0 }; + let cb = |mask, trail| { + tilted_screen_cb(&quad, [800.0, 450.0], [600.0, 400.0], [0.0, 0.0, 1.0, 1.0], [0.5, 0.5], 12.0, 0.0, false, [1200.0, 800.0], mask, trail) + }; + let corner = |c: [f32; 4], i: usize| [c[2 * i], c[2 * i + 1]]; + for m in [None, Some(mask)] { + let (bare, with) = (cb(m, None), cb(m, Some(moved))); + assert_eq!((bare.trail_a, bare.trail_b, bare.trail_mb), ([0.0; 4], [0.0; 4], [0.0; 4]), "{m:?}"); + assert_eq!((with.dst, with.fx, with.src_prev, with.dst_prev, with.mb), (bare.dst, bare.fx, bare.src_prev, bare.dst_prev, bare.mb)); + assert_eq!(with.trail_mb, [6.0, 0.35, 0.0, 0.0]); + // Chaque coin de la traînée garde son écart au coin courant : même repère local. + let now = [corner(with.fx, 0), corner(with.fx, 1), corner(with.src_prev, 0), corner(with.src_prev, 1)]; + let then = [corner(with.trail_a, 0), corner(with.trail_a, 1), corner(with.trail_b, 0), corner(with.trail_b, 1)]; + for i in 0..4 { + let want = [moved.corners[i].0 - quad.corners[i].0, moved.corners[i].1 - quad.corners[i].1]; + let got = [then[i][0] - now[i][0], then[i][1] - now[i][1]]; + assert!((got[0] - want[0]).abs() < 1e-3 && (got[1] - want[1]).abs() < 1e-3, "{m:?} coin {i} : {got:?} != {want:?}"); + } + } + // Sans mouvement, les deux quads coïncident au bit près. + let still = cb(None, Some(TiltTrail { corners: quad.corners, mb: [6.0, 0.35] })); + assert_eq!((still.trail_a, still.trail_b), (still.fx, still.src_prev)); + } + /// La vitesse se mesure dans la coupe VISIBLE, zoom compris : sous un x2, le même geste /// traverse deux fois plus d'écran et penche donc plus le plan (hors saturation). #[test] @@ -5634,7 +5874,7 @@ mod tests { }; let same = |s: String| s; let on = dyn_of(&same, on_edge); - assert!(on[1] > 1.5, "clic à droite → +Y : {on:?}"); + assert!(on[1] > 0.75 * crate::regions::CLICK_IMPACT_DEG, "clic à droite → +Y : {on:?}"); let insert = |field: &'static str| { move |s: String| s.replace(r#""cursor":"#, &format!(r#"{field},"cursor":"#)) @@ -5942,7 +6182,7 @@ mod tests { #[test] fn layer_cb_matches_the_shader_constant_buffer() { use std::mem::{align_of, offset_of, size_of}; - assert_eq!(size_of::(), 128); + assert_eq!(size_of::(), 176); assert_eq!(align_of::(), 16); for (name, got, want) in [ ("dst", offset_of!(LayerCB, dst), 0), @@ -5955,6 +6195,9 @@ mod tests { ("src_prev", offset_of!(LayerCB, src_prev), 80), ("dst_prev", offset_of!(LayerCB, dst_prev), 96), ("mb", offset_of!(LayerCB, mb), 112), + ("trail_a", offset_of!(LayerCB, trail_a), 128), + ("trail_b", offset_of!(LayerCB, trail_b), 144), + ("trail_mb", offset_of!(LayerCB, trail_mb), 160), ] { assert_eq!(got, want, "offset de `{name}`"); } @@ -6350,6 +6593,8 @@ mod tests { zoom_rotation: [0.0, 0.0, 0.0], zoom_rotation_dyn: [0.0, 0.0, 0.0], camera: None, + zoom_rotation_prev: [0.0, 0.0, 0.0], + camera_prev: None, padding_scale: 1.0, cut: [0.0, 0.0, 1.0, 1.0], focus_plane: [0.5, 0.5], @@ -6500,8 +6745,8 @@ mod tests { .expect("plan cursor") } - const ISO: [f32; 3] = [-12.0, -18.0, -2.0]; - const LEFT: [f32; 3] = [-8.0, -16.0, -1.0]; + const ISO: [f32; 3] = [-23.0, -25.0, 0.0]; + const LEFT: [f32; 3] = [-23.0, -25.0, 0.0]; /// Le `LayerCB` du sprite incliné est, octet pour octet, celui que chaque backend construisait /// avant de le partager. La référence est le corps d'origine de `draw_cursor_sprite`. @@ -6548,14 +6793,18 @@ mod tests { fx: [tl0, tl1, tr0, tr1], src_prev: [br0, br1, bl0, bl1], dst_prev: clip, + // Le drapeau du warp : un angle fixe est maintenant projectif (`screen_tilt`), + // comme la caméra réelle l'était déjà. + mb: [quad.warp_flag(), 0.0, 0.0, 0.0], ..Default::default() } } let bytes = |cb: &LayerCB| -> Vec { - // SAFETY: `LayerCB` est `repr(C)`, 128 octets de f32 sans padding (test d'offsets). - unsafe { std::slice::from_raw_parts(cb as *const LayerCB as *const u8, 128) }.to_vec() + // SAFETY: `LayerCB` est `repr(C)`, des f32 sans padding (test d'offsets). + let len = std::mem::size_of::(); + unsafe { std::slice::from_raw_parts(cb as *const LayerCB as *const u8, len) }.to_vec() }; - for rot in [ISO, LEFT, [-8.0, 16.0, 1.0]] { + for rot in [ISO, LEFT, [-23.0, 25.0, 0.0]] { let plan = plan_with(rot); let clip = [0.1, 0.2, 0.7, 0.6]; let got = cursor_sprite_cb( @@ -6812,7 +7061,7 @@ mod tests { let scene = model_scene(); for key in MODEL_STATES { let (sdf, shape) = sprite_model(key); - for rot in [[0.0; 3], [-12.0, -18.0, -2.0]] { + for rot in [[0.0; 3], [-23.0, -25.0, 0.0]] { let track = still_track_as(Some(key), vec![0.5]); for (t, want) in [(0.3, MODEL_HOVER), (0.5 + CONTACT_S, 0.0)] { let plan = model_plan(rot, &scene, &track, t, true).expect("plan"); @@ -6936,8 +7185,8 @@ mod tests { assert!(model_plan([0.0; 3], &bare, &track, 0.3, true).expect("plan").model.is_none()); } - const LEFT_ROT: [f32; 3] = [-8.0, -16.0, -1.0]; - const ISO_ROT: [f32; 3] = [-12.0, -18.0, -2.0]; + const LEFT_ROT: [f32; 3] = [-23.0, -25.0, 0.0]; + const ISO_ROT: [f32; 3] = [-23.0, -25.0, 0.0]; /// Les poses d'essai, pour chaque état de `MODEL_STATES` : au repos, posé, tourné. fn model_cases() -> Vec<(String, CursorPlan, SpriteShape, crate::cursor_sdf::CursorSdf)> { @@ -6950,7 +7199,7 @@ mod tests { vec![], vec![(0.0, key.to_string())], ); - for (name, rot) in [("flat", [0.0; 3]), ("iso", ISO_ROT), ("left", LEFT_ROT), ("right", [-8.0, 16.0, 1.0])] { + for (name, rot) in [("flat", [0.0; 3]), ("iso", ISO_ROT), ("left", LEFT_ROT), ("right", [-23.0, 25.0, 0.0])] { for (pose, track, t) in [("hover", &clicked, 0.3), ("touch", &clicked, 0.5 + CONTACT_S), ("yaw", &moving, 1.0)] { let plan = model_plan(rot, &scene, track, t, true).expect("plan"); let (sdf, shape) = sprite_model(key); diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs index 471235127..27f0ba5e7 100644 --- a/crates/compositor/src/regions.rs +++ b/crates/compositor/src/regions.rs @@ -572,14 +572,25 @@ fn ease_connected_pan(t: f32) -> f32 { /// alors que le plan était rendu en entier — mesuré au pixel sur un export 1920×1080 : bord droit /// à 1539, coin calculé à 1540, arrondis présents aux quatre coins. /// -/// Chaque préset a donc maintenant ses trois composantes, choisies pour qu'aucune arête ne -/// s'approche d'un axe à moins de 2° (cf. `no_preset_has_an_axis_aligned_edge`) tout en gardant -/// l'identité du préset : left penche vers la gauche, right vers la droite, iso est le plus incliné. +/// Aucune arête ne s'approche donc d'un axe à moins de 2° (cf. +/// `no_preset_has_an_axis_aligned_edge`), et AUCUN préset ne roule : Z vaut 0. Le tilt entre avec +/// le zoom, et une caméra qui bouge ne roule jamais le métrage — le roulis de −2°/±1° qui tenait +/// la règle jusqu'ici penchait l'image pendant chaque zoom, et balayait 2° entre left et right. +/// +/// Sans roulis, la règle ne laisse que deux bandes de tangage (X). Une rotation Y pure garde les +/// verticales verticales, donc chaque préset tangue ; et entre 10° et 14° de tangage, le bord haut +/// d'un écran tourné ressort à plat (la remontée du rotateX annulée par la perspective), à tout +/// lacet. Les deux présets prennent donc la bande haute, le regard de l'ancien `iso` : tourné et +/// vu d'en haut franchement, arêtes à 4,8° au plus près. Ils sortent de la bande des 2° pendant +/// leur ease-in à 0,82 de force, avant que la parallaxe ne s'ouvre (`PARALLAX_GATE_START`), et +/// laissent au budget dynamique ses ±1,9° / ±3° d'origine (`DYNAMIC_TILT_BUDGET`). +/// +/// `iso` n'est plus proposé : c'était déjà ce regard-là, tourné à gauche. Un projet qui le porte +/// encore se lit donc comme `left` (côté app : `readRotation3DPreset`, et ici par sécurité). fn rotation3d_for(rotation: &Option) -> [f32; 3] { match rotation.as_deref() { - Some("iso") => [-12.0, -18.0, -2.0], - Some("left") => [-8.0, -16.0, -1.0], - Some("right") => [-8.0, 16.0, 1.0], + Some("left" | "iso") => [-23.0, -25.0, 0.0], + Some("right") => [-23.0, 25.0, 0.0], _ => [0.0, 0.0, 0.0], } } @@ -992,10 +1003,10 @@ pub(crate) const PERSPECTIVE_FACTOR: f32 = 1.6; /// /// Réglé une fois, en unités de P : cet écart ne dépend ni de la résolution ni du zoom (la /// perspective se déduit de la taille du plan lui-même). Sur iso en 16:9, focus au centre, le -/// coin lointain est à ~0.19 P, soit un flou de ~4.6 texels ; focus sur le coin proche, l'écart -/// double et le shader plafonne (`DOF_MAX_LOD`, niveau 1.5 de la pyramide demi-résolution, soit -/// ~5.7 texels). Un flou exprimé en texels suit le CONTENU : la même zone est aussi floue quel -/// que soit le zoom qui l'affiche. +/// coin lointain est à ~0.25 P, soit un flou de ~5.9 texels, au plafond du shader +/// (`DOF_MAX_LOD`, niveau 1.5 de la pyramide demi-résolution, soit ~5.7 texels) ; focus sur le +/// coin proche, l'écart double. Un flou exprimé en texels suit le CONTENU : la même zone est aussi +/// floue quel que soit le zoom qui l'affiche. pub const DOF_COC_PER_DEPTH: f32 = 24.0; /// Les 4 coins d'un quad `width`×`height` réduit de `scale`, projetés. `None` si un coin part @@ -1048,8 +1059,10 @@ pub struct TiltedQuad { /// Où tombe le centre du plan à l'image, en px relatifs au centre du rect d'origine. pub offset: [f32; 2], /// `true` : le plan se dessine par l'homographie EXACTE de ses coins ; `false` : par le warp - /// bilinéaire des angles fixes, inchangé à l'octet. La caméra réelle l'exige, et un cadre - /// d'appareil aussi (le mode 17 lance ses rayons dans la perspective exacte). + /// bilinéaire, qui penche le contenu d'un plan tourné ET tangué (sa verticale du milieu + /// suit les milieux des bords, pas la projection). Tout écran incliné passe au projectif + /// (`FrameGeometry::screen_tilt`) ; `rotated_quad_corners_px` rend `false`, la géométrie + /// seule. pub projective: bool, /// `true` : une lampe posée sur la caméra éclaire un peu plus le côté proche du plan /// (`CAMERA_LIGHT_GAIN`, cf. `tilted_screen_cb`). Elle appartient à la caméra RÉELLE, pas au @@ -1160,13 +1173,15 @@ fn contain_scale( /// anime le plan (la parallaxe ici ; l'impact du clic ensuite) : les contributions s'ADDITIONNENT /// puis la somme est bornée par `clamp_dynamic_tilt`. /// -/// - Z : 0. L'axe le plus étroit, à ~1° de casser la règle des 2°. -/// - X : ±1,9°. La limite de `left`/`right`, les plus serrés sur cet axe. -/// - Y : ±3°. Au-delà, `left` déborde sa boîte même à échelle gelée. +/// - Z : 0. Jamais de roulis : une caméra qui bouge ne roule pas le métrage. +/// - X : ±1,9°, Y : ±3°. Ensemble, à 1,15 fois ces valeurs, `left` sort d'une des deux règles +/// (arête à moins de 2° d'un axe, ou débordement de sa boîte) ; seul, Y y arrive vers 3,3°, X +/// laisse plus de marge (`the_budget_is_close_to_what_left_allows`). /// -/// Reproduits par `the_budget_sweep_keeps_every_edge_off_axis` et -/// `the_budget_sweep_stays_inside_the_original_rect` sur tout le balayage, pas seulement aux -/// présets. +/// Reproduits par `the_budget_sweep_keeps_every_edge_off_axis`, +/// `the_budget_sweep_stays_inside_the_original_rect` et +/// `the_chained_sweep_keeps_every_edge_off_axis_and_inside` sur tout le balayage, pas seulement +/// aux présets. pub const DYNAMIC_TILT_BUDGET: [f32; 3] = [1.9, 3.0, 0.0]; /// Borne une part dynamique au budget, axe par axe. @@ -1176,11 +1191,10 @@ pub fn clamp_dynamic_tilt(rot: [f32; 3]) -> [f32; 3] { } /// Force du préset sous laquelle la part dynamique est nulle ; elle s'installe en smoothstep -/// jusqu'à 1. La spec disait 0,5 ; les maths de ce crate disent autrement : `left`/`right` ne -/// sortent de la bande des 2° qu'à 0,635 de force, et le budget plein ne leur laisse que -/// 0,034° de marge à force 1. Juste sous 1, la base perd plus d'angle que la porte n'en retire -/// — il faut une porte raide. 0,78 passe encore sous 2° (de 0,001°), 0,8 tient ; 0,85 garde -/// du jeu. Cf. `the_budget_sweep_keeps_every_edge_off_axis`. +/// jusqu'à 1. La spec disait 0,5 ; les maths de ce crate disent autrement : en montant, +/// `left`/`right` traversent le tangage où leur bord haut ressort à plat, et ne sortent de la +/// bande des 2° qu'à 0,82 de force : la porte doit s'ouvrir après, et raide. Cf. +/// `the_budget_sweep_keeps_every_edge_off_axis`. const PARALLAX_GATE_START: f32 = 0.85; /// Degrés de parallaxe par unité de vitesse (largeurs — ou hauteurs — de coupe par seconde), /// avant saturation. Un balayage d'une demi-largeur par seconde penche d'environ 2° en Y, une @@ -1814,7 +1828,7 @@ mod tilt_tests { /// pose dessus (le curseur) se décale par rapport à l'image que le shader y a dessinée. #[test] fn the_plane_corners_map_to_the_projected_corners() { - let quad = rotated_quad_corners_px(1920.0, 1080.0, preset("iso"), [0.0; 3]); + let quad = rotated_quad_corners_px(1920.0, 1080.0, preset("left"), [0.0; 3]); for (i, (fx, fy)) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].into_iter().enumerate() { let (x, y) = quad.point_px(fx, fy); @@ -1840,7 +1854,7 @@ mod tilt_tests { #[test] fn a_tilted_plane_moves_the_cursor_off_the_upright_rect() { let (w, h) = (1920.0f32, 1080.0f32); - let quad = rotated_quad_corners_px(w, h, rotation3d_for(&Some("iso".into())), [0.0; 3]); + let quad = rotated_quad_corners_px(w, h, rotation3d_for(&Some("left".into())), [0.0; 3]); let mut worst: f32 = 0.0; for (fx, fy) in [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (0.5, 0.0)] { let (x, y) = quad.point_px(fx, fy); @@ -1856,7 +1870,6 @@ mod tilt_tests { #[test] fn every_preset_stays_inside_the_original_rect() { for (name, rot) in [ - ("iso", rotation3d_for(&Some("iso".into()))), ("left", rotation3d_for(&Some("left".into()))), ("right", rotation3d_for(&Some("right".into()))), ] { @@ -1875,13 +1888,21 @@ mod tilt_tests { } } + /// `iso` n'est plus proposé : il se lit comme `left`, qui a gardé son regard, et `right` en est + /// le miroir exact. + #[test] + fn iso_reads_as_left_and_right_mirrors_it() { + let left = rotation3d_for(&Some("left".into())); + assert_eq!(rotation3d_for(&Some("iso".into())), left); + assert_eq!(rotation3d_for(&Some("right".into())), [left[0], -left[1], 0.0]); + } + /// Aucune arête d'un préset ne doit longer un axe de l'image. C'est LE critère qui distingue /// « un écran incliné » d'« un enregistrement tronqué » : un bord parfaitement vertical qui /// coupe une phrase se lit comme un `overflow: hidden`, quelle que soit la justesse du reste. #[test] fn no_preset_has_an_axis_aligned_edge() { for (name, rot) in [ - ("iso", rotation3d_for(&Some("iso".into()))), ("left", rotation3d_for(&Some("left".into()))), ("right", rotation3d_for(&Some("right".into()))), ] { @@ -1904,9 +1925,8 @@ mod tilt_tests { } } - fn presets() -> [(&'static str, [f32; 3]); 3] { + fn presets() -> [(&'static str, [f32; 3]); 2] { [ - ("iso", rotation3d_for(&Some("iso".into()))), ("left", rotation3d_for(&Some("left".into()))), ("right", rotation3d_for(&Some("right".into()))), ] @@ -1955,7 +1975,7 @@ mod tilt_tests { #[test] fn iso_depth_rises_towards_the_corner_drawn_larger() { let (w, h) = (1920.0f32, 1080.0f32); - let rot = rotation3d_for(&Some("iso".into())); + let rot = rotation3d_for(&Some("left".into())); let quad = rotated_quad_corners_px(w, h, rot, [0.0; 3]); let mb = quad.depth_mb([w, h], [0.5, 0.5], false); let (z_tr, z_bl) = (depth_at(mb, 1.0, 0.0), depth_at(mb, 0.0, 1.0)); @@ -1994,7 +2014,7 @@ mod tilt_tests { /// unités de la distance de fuite, pas en pixels de sortie. #[test] fn the_circle_of_confusion_grows_away_from_the_focus_and_ignores_resolution() { - let rot = rotation3d_for(&Some("iso".into())); + let rot = rotation3d_for(&Some("left".into())); let coc = |w: f32, h: f32, focus: [f32; 2], s: f32, t: f32| { let quad = rotated_quad_corners_px(w, h, rot, [0.0; 3]); let mb = quad.depth_mb([w, h], focus, true); @@ -2002,7 +2022,7 @@ mod tilt_tests { }; let centre = [0.5, 0.5]; assert!(coc(1920.0, 1080.0, centre, 0.5, 0.5) < 1e-4); - // Coin lointain (BL) : ~0.19 P, donc ~4.6 texels, bien au-dessus du seuil net (0.5). + // Coin lointain (BL) : ~0.25 P, donc ~5.9 texels, bien au-dessus du seuil net (0.5). let far = coc(1920.0, 1080.0, centre, 0.0, 1.0); assert!((3.5..6.0).contains(&far), "coin lointain {far}"); // Focus posé sur le coin proche (TR) : le lointain s'en éloigne encore. @@ -2023,7 +2043,7 @@ mod tilt_tests { fn the_tilt_actually_tilts() { // Garde-fou contre un containment trop zélé qui aplatirait l'effet : les quatre coins // d'un quad incliné ne peuvent pas rester alignés deux à deux comme un rectangle droit. - let corners = rotated_quad_corners_px(1920.0, 1080.0, preset("iso"), [0.0; 3]).corners; + let corners = rotated_quad_corners_px(1920.0, 1080.0, preset("left"), [0.0; 3]).corners; let top_edge_slope = (corners[1].1 - corners[0].1).abs(); assert!(top_edge_slope > 1.0, "arête supérieure horizontale : le tilt a disparu"); } @@ -2059,13 +2079,13 @@ mod tilt_tests { /// La règle des 2° tient sur TOUT le balayage du budget, et pas qu'aux présets : à force 1 /// sur la boîte entière, et pendant l'ease-in, où la part dynamique ne doit jamais faire - /// passer une arête sous min(2°, ce que la base seule donne). Sous ~0,64 de force, `left` - /// et `right` sont DÉJÀ dans la bande (l'ease-in traverse 0°) ; on ne leur demande alors que - /// de ne pas empirer. + /// passer une arête sous min(2°, ce que la base seule donne). Sous ~0,82 de force, `left` + /// et `right` sont DÉJÀ dans la bande (l'ease-in traverse 0°, puis le tangage où leur bord + /// haut ressort à plat) ; on ne leur demande alors que de ne pas empirer. #[test] fn the_budget_sweep_keeps_every_edge_off_axis() { let (w, h) = (1920.0f32, 1080.0f32); - for name in ["iso", "left", "right"] { + for name in ["left", "right"] { for k in 0..=400 { let strength = k as f32 / 400.0; let base = lerp_rotation3d([0.0; 3], preset(name), strength); @@ -2083,20 +2103,23 @@ mod tilt_tests { } } - /// Les chiffres du budget sont ceux des maths du crate, à peu près au ras : 15 % de plus - /// sur X ou Y, ou 1,2° sur Z, et `left` casse une des deux règles. + /// Les chiffres du budget sont ceux des maths du crate, à peu près au ras : le balayage à 15 % + /// de plus sur X et Y ensemble fait casser à `left` une des deux règles, arête à moins de 2° + /// d'un axe (16:9) ou débordement de sa boîte (les trois formes), et Y seul aussi. X seul a + /// plus de marge : c'est avec Y qu'il la consomme. Z n'a pas de budget, c'est la règle produit. #[test] fn the_budget_is_close_to_what_left_allows() { - let (w, h) = (1920.0f32, 1080.0f32); let base = preset("left"); let breaks = |d: [f32; 3]| { - let c = rotated_quad_corners_px(w, h, base, d).corners; - min_edge_angle(&c) < 2.0 || overflow(&c, w, h) > 0.0 + [(1920.0f32, 1080.0f32), (1080.0, 1920.0), (800.0, 800.0)].into_iter().any(|(w, h)| { + let c = rotated_quad_corners_px(w, h, base, d).corners; + (w > h && min_edge_angle(&c) < 2.0) || overflow(&c, w, h) > 0.0 + }) }; let b = DYNAMIC_TILT_BUDGET; - assert!(breaks([b[0] * 1.15, 0.0, 0.0]) || breaks([-b[0] * 1.15, 0.0, 0.0]), "X"); + assert!(!budget_sweep(1.0).any(|d| breaks(d)), "le budget lui-même doit tenir"); + assert!(budget_sweep(1.15).any(|d| breaks(d)), "X et Y"); assert!(breaks([0.0, b[1] * 1.15, 0.0]) || breaks([0.0, -b[1] * 1.15, 0.0]), "Y"); - assert!(breaks([0.0, 0.0, 1.2]) && breaks([0.0, 0.0, -1.2]), "Z"); assert_eq!(b[2], 0.0, "Z n'a pas de budget"); } @@ -2105,7 +2128,7 @@ mod tilt_tests { /// `every_preset_stays_inside_the_original_rect`. #[test] fn the_budget_sweep_stays_inside_the_original_rect() { - for name in ["iso", "left", "right"] { + for name in ["left", "right"] { for (w, h) in [(1920.0f32, 1080.0f32), (1080.0, 1920.0), (800.0, 800.0)] { for k in 0..=40 { let strength = k as f32 / 40.0; @@ -2146,7 +2169,7 @@ mod tilt_tests { }; // `follow-cursor` n'incline pas l'écran (caméra réelle, `camera.rs`) : chaînée à un angle // fixe, la transition passe par l'écran droit, que ce balayage couvre aussi. - let presets = [None, Some("iso"), Some("left"), Some("right"), Some("follow-cursor")]; + let presets = [None, Some("left"), Some("right"), Some("follow-cursor")]; for a in presets { for b in presets { // Transition chaînée sur [4, 5] s. @@ -2199,7 +2222,7 @@ mod tilt_tests { /// son ombre) ne respire pas avec la parallaxe. #[test] fn the_scale_is_frozen_while_only_the_dynamic_part_moves() { - for name in ["iso", "left", "right"] { + for name in ["left", "right"] { let base = preset(name); let frozen = rotated_quad_corners_px(1920.0, 1080.0, base, [0.0; 3]); for d in budget_sweep(1.0) { @@ -2281,7 +2304,7 @@ mod tilt_tests { } } let fast = dynamic_tilt(1.0, Some(&swipe(1000.0, 9.0)), FULL_CUT, 1.0, [0.0; 3]); - assert!(fast[1] > 2.9, "saturation au budget : {fast:?}"); + assert!(fast[1] > b[1] - 0.1, "saturation au budget : {fast:?}"); } /// Pure fonction de `t` : l'ordre des appels (lecture, seek arrière) ne change rien. @@ -2351,7 +2374,7 @@ mod tilt_tests { assert!((bottom[0] + CLICK_IMPACT_DEG).abs() < 1e-2 && bottom[1].abs() < 1e-6, "{bottom:?}"); let z = |x: f32, y: f32, rot: [f32; 3]| rotate_corner(x, y, rot).2; - for name in ["iso", "left", "right"] { + for name in ["left", "right"] { let base = preset(name); let with = |d: [f32; 3]| [base[0] + d[0], base[1] + d[1], base[2] + d[2]]; // Milieu du bord droit / du bord bas, et le bord opposé qui avance. @@ -2440,7 +2463,7 @@ mod tilt_tests { #[test] fn a_corner_click_moves_the_plane_visibly_at_a_frozen_scale() { let track = still(1.0, 1.0, vec![1.0]); - for name in ["iso", "left", "right"] { + for name in ["left", "right"] { let base = rotated_quad_corners_px(1920.0, 1080.0, preset(name), [0.0; 3]); let mut worst = 0.0f32; for i in 0..=26 { @@ -2451,7 +2474,7 @@ mod tilt_tests { worst = worst.max((a.0 - b.0).hypot(a.1 - b.1)); } } - // Mesuré : 24 px (iso), 25 (left), 27 (right) à 1080p pour un clic dans un coin. + // Mesuré à 1080p pour un clic dans un coin : voir la sortie en cas d'échec. assert!(worst > 15.0, "{name} : {worst:.1} px, invisible"); } } @@ -2467,7 +2490,7 @@ mod tilt_tests { focus_x: 0.5, focus_y: 0.5, focus_mode: None, - rotation: Some("iso".into()), + rotation: Some("left".into()), under_trim: false, hide_cursor: false, click_impact, @@ -2494,7 +2517,7 @@ mod tilt_tests { hide_cursor: false, click_impact: false, }; - assert_eq!(zoom_state_at(&[region(Some("iso"))], 5.0, None).tilt, 1.0); + assert_eq!(zoom_state_at(&[region(Some("left"))], 5.0, None).tilt, 1.0); assert_eq!(zoom_state_at(&[region(None)], 5.0, None).tilt, 0.0); let easing = zoom_state_at(&[region(Some("left"))], 1.2, None); assert!(easing.tilt > 0.0 && easing.tilt < 1.0, "{}", easing.tilt); @@ -2579,7 +2602,6 @@ mod tilt_tests { // mapping inverse perd des pixels pourtant intérieurs au quad, le trou a exactement cette // allure — un bord net, sans rapport avec la géométrie visible. for (name, rot) in [ - ("iso", rotation3d_for(&Some("iso".into()))), ("left", rotation3d_for(&Some("left".into()))), ("right", rotation3d_for(&Some("right".into()))), ] { @@ -2831,8 +2853,8 @@ mod follow_camera_tests { let blind = zoom_state_at(&r, 5.0, None); assert_eq!((blind.aim, blind.orbit), ([0.5, 0.5], [0.5, 0.5])); // Un angle fixe garde exactement son état. - let iso = zoom_state_in(&[region("iso", 2.0, 8.0)], 5.0, Some(&tr), &f, &ScreenClock::default()); - assert_eq!((iso.rotation, iso.camera, iso.click_impact), ([-12.0, -18.0, -2.0], 0.0, 1.0)); + let iso = zoom_state_in(&[region("left", 2.0, 8.0)], 5.0, Some(&tr), &f, &ScreenClock::default()); + assert_eq!((iso.rotation, iso.camera, iso.click_impact), ([-23.0, -25.0, 0.0], 0.0, 1.0)); let unknown = zoom_state_in(&[region("orbit", 2.0, 8.0)], 5.0, Some(&tr), &f, &ScreenClock::default()); assert_eq!((unknown.rotation, unknown.tilt, unknown.camera), ([0.0; 3], 0.0, 0.0)); } @@ -2857,7 +2879,7 @@ mod follow_camera_tests { let tr = track(|t| (0.2 + 0.05 * t, 0.5)); let f = whole(&tr); for regions in [ - [region("iso", 1.0, 4.0), region("follow-cursor", 4.5, 8.0)], + [region("left", 1.0, 4.0), region("follow-cursor", 4.5, 8.0)], [region("follow-cursor", 1.0, 4.0), region("right", 4.5, 8.0)], [region("follow-cursor", 1.0, 4.0), region("follow-cursor", 4.5, 8.0)], ] { diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index ef0062c78..052acbfc5 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -13,6 +13,9 @@ cbuffer Layer : register(b0) float4 src_prev; // src à la frame précédente (flou de mouvement par vélocité) ; mode 15 : hotspot, lacet ; mode 17 : marges du corps (unités du modèle) float4 dst_prev; // dst à la frame précédente ; modes 13 et 15 : rect de clip ; mode 17 : angle du socle, rayon et recouvrement de l'ouverture, pénombre de l'ombre float4 mb; // mb.x = nombre de taps de motion blur (1 = désactivé) ; modes 15 et 17 : demi-taille du plan, translation + float4 trail_a; // mode 8 : coins TL, TR du plan à la frame précédente (px locaux, comme fx) + float4 trail_b; // mode 8 : coins BR, BL du plan à la frame précédente (comme src_prev) + float4 trail_mb; // mode 8 : x = taps, y = force du flou de mouvement (ceux du mode 0) ; 0 ailleurs }; // Mode 15 (curseur modélisé) : le détail des emplacements est dans `frame_geometry.rs`, en tête // de la section « Curseur modélisé » (`cursor_model_cb`). Mode 17 (appareil modelé) : en tête de @@ -250,8 +253,8 @@ float3 quad_inverse_projective(float2 P, float2 c00, float2 c10, float2 c11, flo return float3(s, t, ok); } -// Le warp inverse d'un calque posé sur le plan : projectif sous la caméra réelle (`projective` = -// 1, cf. `TiltedQuad::warp_flag`), bilinéaire sous un angle fixe, inchangé. +// Le warp inverse d'un calque posé sur le plan : projectif (`projective` = 1, cf. +// `TiltedQuad::warp_flag`, que tout écran incliné porte), bilinéaire sinon. float3 quad_inverse(float2 P, float2 c00, float2 c10, float2 c11, float2 c01, float projective) { if (projective > 0.5) @@ -261,6 +264,21 @@ float3 quad_inverse(float2 P, float2 c00, float2 c10, float2 c11, float2 c01, fl return quad_inverse_bilinear(P, c00, c10, c11, c01); } +// Un échantillon de l'écran incliné (mode 8) : la vidéo nette, fondue vers la pyramide de +// profondeur de champ (t2) au-delà d'un demi-texel de cercle de confusion `coc`. Sous ce seuil, +// l'échantillon net d'avant, à l'octet. +float3 tilted_sample(float2 uv, float coc) +{ + float3 rgb = sample_yuv(uv); + if (coc > 0.5) + { + float lod = clamp(log2(coc) - 1.0, 0.0, DOF_MAX_LOD); + float3 far_rgb = texImg.SampleLevel(samp, uv, lod).rgb; + rgb = lerp(rgb, far_rgb, saturate((coc - 0.5) / 1.5)); + } + return rgb; +} + // Hash 2D -> [0,1) sans sin() : le hash `frac(sin(x) * 43758)` dépend de la précision du GPU, // celui-ci (Hoskins, « hash12 ») ne fait que des produits de petites valeurs. float hash12(float2 p) @@ -1424,8 +1442,8 @@ float4 ps_main(VSOut i) : SV_Target // mode 8 : écran tilté en 3D (zoom regions "rotation" : iso/left/right) ou vu par la caméra // réelle (`follow-cursor`). `dst`/`quad_px` couvrent la BOUNDING BOX des 4 coins projetés // (`frame_geometry::tilted_screen_cb`) ; ce shader retrouve où tombe chaque pixel DANS le quad - // (warp inverse : bilinéaire sous un angle fixe, projectif exact sous la caméra réelle, - // dst_prev.w = 1) et échantillonne la vidéo à l'UV correspondant, sinon transparent. + // (warp inverse : projectif exact quand dst_prev.w = 1, ce que porte tout écran incliné, + // bilinéaire sinon) et échantillonne la vidéo à l'UV correspondant, sinon transparent. // fx.xy/fx.zw = coins TL/TR (px locaux, 0..quad_px) ; src_prev.xy/.zw = coins BR/BL. // color.xy (caméra réelle) : éclairage 1 + color.x·(s − 0.5) + color.y·(t − 0.5). // mb = [gx, gy, z_focus, k] : profondeur du point r du plan = (r.x - 0.5)*gx + (r.y - 0.5)*gy @@ -1433,6 +1451,8 @@ float4 ps_main(VSOut i) : SV_Target // k = texels source de flou par px d'écart de profondeur (0 = profondeur de champ coupée). // t2 (texImg) = pyramide demi-résolution de la vidéo, 5 niveaux, mêmes UV que t0/t1 ; liée // explicitement à chaque draw du mode 8 (`draw_video` ne lie que t0/t1). + // trail_a/trail_b = coins du plan à la frame précédente, trail_mb = [taps, force] : son flou + // de mouvement (`FrameGeometry::tilt_trail`). // mode 11 : texte d'annotation, rastérisé par Direct2D (voir text.rs). D2D écrit sur une // surface DXGI en alpha PRÉMULTIPLIÉ, donc contrairement au mode 7 (sprite curseur, alpha // droit) il ne faut SURTOUT pas re-multiplier ici : les bords adoucis des glyphes @@ -1613,16 +1633,32 @@ float4 ps_main(VSOut i) : SV_Target // jamais par le RGBA de la pyramide, et `k = 0` (réglage coupé) ne quitte jamais cette // voie. Au-delà, fondu vers la pyramide demi-résolution (t2) au niveau `log2(coc) - 1` // (son niveau 0 est déjà une moyenne 2x2), plafonné à DOF_MAX_LOD : au niveau 2, un - // bloc 4x4 soude les jambages d'un « m » en 1080p. - float3 rgb = sample_yuv(uv); + // bloc 4x4 soude les jambages d'un « m » en 1080p (`tilted_sample`). float2 rs = saturate(float2(r.x, r.y)); float z = (rs.x - 0.5) * mb.x + (rs.y - 0.5) * mb.y; float coc = mb.w * abs(z - mb.z); - if (coc > 0.5) + float3 rgb = tilted_sample(uv, coc); + // Flou de mouvement, celui du mode 0 : l'UV que CE pixel montrait à la frame précédente, + // par le même warp inverse sur les coins d'avant (`trail_a`/`trail_b`), puis `taps` + // échantillons de celui-là à celui-ci, raccourcis de la force. Borné à une frame. Hors du + // plan d'avant, le warp prolongé donne encore le bon point (sa racine proche). + int taps = (int) trail_mb.x; + if (taps > 1 && trail_mb.y > 0.001) { - float lod = clamp(log2(coc) - 1.0, 0.0, DOF_MAX_LOD); - float3 far_rgb = texImg.SampleLevel(samp, uv, lod).rgb; - rgb = lerp(rgb, far_rgb, saturate((coc - 0.5) / 1.5)); + float3 rp = quad_inverse(i.local, trail_a.xy, trail_a.zw, trail_b.xy, trail_b.zw, dst_prev.w); + float2 uv_prev = float2(lerp(src.x, src.z, rp.x), lerp(src.y, src.w, rp.y)); + float2 duv = (uv - uv_prev) * saturate(trail_mb.y); + if (dot(duv, duv) >= 1e-9) + { + float3 acc = 0.0; + [loop] for (int k = 0; k < 16; k++) + { + if (k >= taps) break; + float t = (float) k / (float) (taps - 1); + acc += tilted_sample(uv - duv * (1.0 - t), coc); + } + rgb = acc / (float) taps; + } } if (dst_prev.w > 0.5) { diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index 07b885425..fb53b114a 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -42,7 +42,7 @@ using namespace metal; // ================================================================================= // // Le moteur côté CPU upload ce buffer via `setVertexBytes` (vertex stage) et -// `setFragmentBytes` (fragment stage) avant chaque draw — la copie est de 128 octets, +// `setFragmentBytes` (fragment stage) avant chaque draw — la copie est de 176 octets, // ce qui est sous le seuil d'alignement 4K de Metal pour le mode « immediate ». struct Layer @@ -57,6 +57,9 @@ struct Layer float4 src_prev; // src à la frame précédente (flou de mouvement par vélocité) ; mode 15 : hotspot, lacet ; mode 17 : marges du corps (unités du modèle) float4 dst_prev; // dst à la frame précédente ; modes 13 et 15 : rect de clip ; mode 17 : angle du socle, rayon et recouvrement de l'ouverture, pénombre de l'ombre float4 mb; // mb.x = nombre de taps de motion blur (1 = désactivé) ; modes 15 et 17 : demi-taille du plan, translation + float4 trail_a; // mode 8 : coins TL, TR du plan à la frame précédente (px locaux, comme fx) + float4 trail_b; // mode 8 : coins BR, BL du plan à la frame précédente (comme src_prev) + float4 trail_mb; // mode 8 : x = taps, y = force du flou de mouvement (ceux du mode 0) ; 0 ailleurs }; // Mode 15 (curseur modélisé) : le détail des emplacements est dans `frame_geometry.rs`, en tête // de la section « Curseur modélisé » (`cursor_model_cb`). Mode 17 (appareil modelé) : en tête de @@ -298,8 +301,8 @@ inline float3 quad_inverse_projective(float2 P, float2 c00, float2 c10, float2 c return float3(s, t, ok); } -// Warp inverse d'un calque posé sur le plan : projectif sous la caméra réelle (`projective` = 1), -// bilinéaire sous un angle fixe, inchangé. +// Warp inverse d'un calque posé sur le plan : projectif (`projective` = 1, que tout écran incliné +// porte), bilinéaire sinon. inline float3 quad_inverse(float2 P, float2 c00, float2 c10, float2 c11, float2 c01, float projective) { if (projective > 0.5) @@ -309,6 +312,24 @@ inline float3 quad_inverse(float2 P, float2 c00, float2 c10, float2 c11, float2 return quad_inverse_bilinear(P, c00, c10, c11, c01); } +// Un échantillon de l'écran incliné (mode 8) : la vidéo nette, fondue vers la pyramide de +// profondeur de champ (texture(2)) au-delà d'un demi-texel de cercle de confusion `coc`. Sous ce +// seuil, l'échantillon net d'avant, à l'octet. Miroir de `tilted_sample` (HLSL). +inline float3 tilted_sample(float2 uv, float coc, + texture2d texY, + texture2d texUV, + texture2d texImg) +{ + float3 rgb = sample_yuv(uv, texY, texUV); + if (coc > 0.5) + { + float lod = clamp(log2(coc) - 1.0, 0.0, DOF_MAX_LOD); + float3 far_rgb = texImg.sample(samp, uv, level(lod)).rgb; + rgb = mix(rgb, far_rgb, clamp((coc - 0.5) / 1.5, 0.0, 1.0)); + } + return rgb; +} + // Couverture d'une pastille (disque) adoucie sur ~1.5 px, pour la barre de titre du mode 14. inline float disc_cov(float2 p, float2 c, float r) { @@ -1435,7 +1456,7 @@ fragment float4 ps_main(VSOut i [[stage_in]], } // mode 8 : écran tilté (zoom regions "rotation") ou vu par la caméra réelle. Warp inverse : - // bilinéaire sous un angle fixe, projectif exact sous la caméra réelle (dst_prev.w = 1), qui + // projectif exact (dst_prev.w = 1, tout écran incliné), bilinéaire sinon ; la caméra réelle // éclaire aussi le plan (color.xy : 1 + color.x·(s − 0.5) + color.y·(t − 0.5)). // mb = [gx, gy, z_focus, k] : profondeur du point r du plan = (r.x - 0.5)*gx + (r.y - 0.5)*gy // en px, positive vers la caméra ; z_focus = celle du focus du zoom (`TiltedQuad::depth_mb`) ; @@ -1478,15 +1499,31 @@ fragment float4 ps_main(VSOut i [[stage_in]], // Profondeur de champ : net sous un demi-texel de flou (l'échantillon d'avant, à // l'octet), fondu au-delà vers la pyramide au niveau `log2(coc) - 1`, plafonné. // `level(lod)` exige `mip_filter::linear` sur `samp` : sans lui, niveau 0 partout. - float3 rgb = sample_yuv(uv, texY, texUV); float2 rs = clamp(float2(r.x, r.y), 0.0, 1.0); float z = (rs.x - 0.5) * layer.mb.x + (rs.y - 0.5) * layer.mb.y; float coc = layer.mb.w * abs(z - layer.mb.z); - if (coc > 0.5) + float3 rgb = tilted_sample(uv, coc, texY, texUV, texImg); + // Flou de mouvement, celui du mode 0 : l'UV que CE pixel montrait à la frame précédente, + // par le même warp inverse sur les coins d'avant (`trail_a`/`trail_b`), puis `taps` + // échantillons de celui-là à celui-ci, raccourcis de la force. Borné à une frame. + int taps = int(layer.trail_mb.x); + if (taps > 1 && layer.trail_mb.y > 0.001) { - float lod = clamp(log2(coc) - 1.0, 0.0, DOF_MAX_LOD); - float3 far_rgb = texImg.sample(samp, uv, level(lod)).rgb; - rgb = mix(rgb, far_rgb, clamp((coc - 0.5) / 1.5, 0.0, 1.0)); + float3 rp = quad_inverse(i.local, layer.trail_a.xy, layer.trail_a.zw, + layer.trail_b.xy, layer.trail_b.zw, layer.dst_prev.w); + float2 uv_prev = float2(mix(layer.src.x, layer.src.z, rp.x), mix(layer.src.y, layer.src.w, rp.y)); + float2 duv = (uv - uv_prev) * clamp(layer.trail_mb.y, 0.0, 1.0); + if (dot(duv, duv) >= 1e-9) + { + float3 acc = float3(0.0); + for (int k = 0; k < 16; k++) + { + if (k >= taps) break; + float t = float(k) / float(taps - 1); + acc += tilted_sample(uv - duv * (1.0 - t), coc, texY, texUV, texImg); + } + rgb = acc / float(taps); + } } if (layer.dst_prev.w > 0.5) { diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index ed67dc147..cdf12c322 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -28,6 +28,9 @@ struct Layer { src_prev: vec4, // modes 8/12/13/14 : coins BR,BL du quad projeté ; mode 9 : barbe 1 ; mode 10 incliné : coins BR,BL du masque ; mode 15 : (hotspot du dessus, repere du plan en px ; lacet) ; mode 17 : marges du corps (gauche, haut, droite, bas ; unites du modele) dst_prev: vec4, // mode 8 : .xy = taille du plan en px AVANT projection (le rayon y vit), .z = 1 si coins hauts carres (sous un cadre), .w = 1 si warp projectif ; mode 14 : .xy = taille du plan du cadre, .z = hauteur de la barre, .w = epaisseur du filet (px du plan) ; modes 13 et 15 : rect de clip ; mode 9 : barbe 2 ; mode 10 incliné : coins TL,TR du masque ; mode 17 : (angle du socle depuis le plan en rad, rayon de l'ouverture et recouvrement de la lunette en unites du modele, penombre de l'ombre ou 0) mb: vec4, // mode 8 : [gx, gy, z_focus, k], profondeur du plan et flou (texels source) par px d'ecart, k = 0 coupe ; mode 0 : .x taps, .y force du flou, .w = 1 si coins hauts carres (sous un cadre) ; mode 5 : mb.x = aspect w/h de la sortie (fond anime) ; mode 12 : mb.y = spread de la pénombre en px ; mode 9 : mb.y = demi-épaisseur du trait en px ; mode 10 : mb.z = 1 si masque incliné, mb.w = 1 si son warp est projectif ; mode 13 : mb.x = 1 si warp projectif ; mode 14 : couleur du filet (alpha droit) ; modes 15 et 17 : .xy = demi-taille du plan dans son repere (px pour le 15, unites pour le 17), .zw = translation du plan (repere camera, px) + trail_a: vec4, // mode 8 : coins TL, TR du plan a la frame precedente (px locaux, comme fx) + trail_b: vec4, // mode 8 : coins BR, BL du plan a la frame precedente (comme src_prev) + trail_mb: vec4, // mode 8 : x = taps, y = force du flou de mouvement (ceux du mode 0) ; 0 ailleurs } @group(0) @binding(0) var layer: Layer; @@ -330,8 +333,8 @@ fn quad_inverse_projective(P: vec2, c00: vec2, c10: vec2, c11: ve return vec3(s, t, ok); } -// Warp inverse d'un calque pose sur le plan : projectif sous la camera reelle (`projective` = 1), -// bilineaire sous un angle fixe, inchange. +// Warp inverse d'un calque pose sur le plan : projectif (`projective` = 1, que tout ecran incline +// porte), bilineaire sinon. fn quad_inverse(P: vec2, c00: vec2, c10: vec2, c11: vec2, c01: vec2, projective: f32) -> vec3 { if projective > 0.5 { return quad_inverse_projective(P, c00, c10, c11, c01); @@ -339,6 +342,20 @@ fn quad_inverse(P: vec2, c00: vec2, c10: vec2, c11: vec2, c0 return quad_inverse_bilinear(P, c00, c10, c11, c01); } +// Un echantillon de l'ecran incline (mode 8) : la video nette, fondue vers la pyramide de +// profondeur de champ (binding 4, a la place du masque webcam) au-dela d'un demi-texel de cercle +// de confusion `coc`. Sous ce seuil, l'echantillon net d'avant, a l'octet. Miroir de +// `tilted_sample` (HLSL). LOD explicite : pas de derivees dans cette branche. +fn tilted_sample(uv: vec2, coc: f32) -> vec3 { + var rgb = sample_yuv(uv); + if coc > 0.5 { + let lod = clamp(log2(coc) - 1.0, 0.0, DOF_MAX_LOD); + let far_rgb = textureSampleLevel(texMask, samp, uv, lod).rgb; + rgb = mix(rgb, far_rgb, clamp((coc - 0.5) / 1.5, 0.0, 1.0)); + } + return rgb; +} + // Hash 2D -> [0,1) sans sin(). Miroir de `hash12` cote HLSL. fn hash12(p: vec2) -> f32 { var p3 = fract(vec3(p.x, p.y, p.x) * 0.1031); @@ -1376,8 +1393,8 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { } else if layer.mode > 7.5 && layer.mode < 8.5 { // Mode 8 -- ecran tilte (rotation 3D des zoom regions). Le quad projete est // dessine dans sa BBOX (le VS ne sait tracer qu'un rect) et chaque fragment - // remonte au (s,t) du plan par warp inverse : bilineaire sous un angle fixe, projectif exact - // sous la camera reelle (dst_prev.w = 1), qui eclaire aussi le plan (color.xy). + // remonte au (s,t) du plan par warp inverse : projectif exact (dst_prev.w = 1, tout ecran + // incline), bilineaire sinon ; la camera reelle eclaire aussi le plan (color.xy). // // PAS de test de clip sur `dst_prev` : en mode 8 `dst_prev.xy` porte // `plane_px`, la taille du plan en PIXELS (~1600), la ou `i.pout` vit dans @@ -1415,15 +1432,33 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { // Profondeur de champ (cf. HLSL) : net sous un demi-texel de flou, l'echantillon // d'avant a l'octet ; au-dela, fondu vers la pyramide demi-resolution liee en binding 4 // (a la place du masque webcam, que ce mode ne lit pas), au niveau `log2(coc) - 1`, - // plafonne. LOD explicite : pas de derivees dans cette branche. - var tilt_rgb = sample_yuv(uv); + // plafonne. LOD explicite : pas de derivees dans cette branche (`tilted_sample`). let rs = clamp(vec2(r.x, r.y), vec2(0.0), vec2(1.0)); let z = (rs.x - 0.5) * layer.mb.x + (rs.y - 0.5) * layer.mb.y; let coc = layer.mb.w * abs(z - layer.mb.z); - if coc > 0.5 { - let lod = clamp(log2(coc) - 1.0, 0.0, DOF_MAX_LOD); - let far_rgb = textureSampleLevel(texMask, samp, uv, lod).rgb; - tilt_rgb = mix(tilt_rgb, far_rgb, clamp((coc - 0.5) / 1.5, 0.0, 1.0)); + var tilt_rgb = tilted_sample(uv, coc); + // Flou de mouvement, celui du mode 0 (cf. HLSL) : l'UV que CE pixel montrait a la frame + // precedente, par le meme warp inverse sur les coins d'avant (`trail_a`/`trail_b`), puis + // `taps` echantillons de celui-la a celui-ci, raccourcis de la force. Borne a une frame. + let trail_taps = i32(layer.trail_mb.x); + if trail_taps > 1 && layer.trail_mb.y > 0.001 { + let rp = quad_inverse( + i.local, layer.trail_a.xy, layer.trail_a.zw, layer.trail_b.xy, layer.trail_b.zw, layer.dst_prev.w, + ); + let uv_prev = vec2( + mix(layer.src.x, layer.src.z, rp.x), + mix(layer.src.y, layer.src.w, rp.y), + ); + let duv = (uv - uv_prev) * clamp(layer.trail_mb.y, 0.0, 1.0); + if dot(duv, duv) >= 1e-9 { + var acc = vec3(0.0); + let step = 1.0 / f32(trail_taps - 1); + for (var k: i32 = 0; k < 16; k = k + 1) { + if k >= trail_taps { break; } + acc = acc + tilted_sample(uv - duv * (1.0 - f32(k) * step), coc); + } + tilt_rgb = acc / f32(trail_taps); + } } if layer.dst_prev.w > 0.5 { // La lampe de la camera reelle : le cote proche un peu plus clair. diff --git a/crates/compositor/tests/click_impact_render.rs b/crates/compositor/tests/click_impact_render.rs index 751e9b005..44aa73ffe 100644 --- a/crates/compositor/tests/click_impact_render.rs +++ b/crates/compositor/tests/click_impact_render.rs @@ -135,9 +135,9 @@ fn a_click_presses_the_clicked_side_of_the_tilted_plane() { ]; let probe = |rgba: &[u8]| { let (l, r) = left_and_right_edges(rgba); - // Colonnes fixes, à l'intérieur du plan à tous les instants (bords mesurés : ~430 et - // ~1660) : on compare la même tranche du plan d'une frame à l'autre. - (l, r, plane_height_at(rgba, 1600), plane_height_at(rgba, 480)) + // Colonnes fixes, à l'intérieur du plan à tous les instants (bords mesurés à mi-hauteur : + // ~525 et ~1590) : on compare la même tranche du plan d'une frame à l'autre. + (l, r, plane_height_at(rgba, 1400), plane_height_at(rgba, 560)) }; let mut frames = Vec::new(); for (name, t) in instants { @@ -166,9 +166,10 @@ fn a_click_presses_the_clicked_side_of_the_tilted_plane() { assert_eq!(unchanged, 0, "sans l'option, le clic ne doit rien changer"); assert_eq!(settled, 0, "le plan n'est pas revenu à sa pose"); assert!(pressed > 1_000, "{pressed} px : le contact ne se voit pas"); - // Au contact, le bord droit (cliqué) recule : il se rapproche du centre et rétrécit, le - // bord gauche avance et grandit. - assert!(contact.1 < rest.1, "bord droit {} au repos {}", contact.1, rest.1); + // Au contact, le côté droit (cliqué) recule et rétrécit, le côté gauche avance : il grandit + // et son bord s'écarte du centre. Sous `iso` (tourné ET vu d'en haut), le bord droit ne bouge + // presque pas à mi-hauteur : le pivot de l'impact passe près de lui. + assert!(contact.0 < rest.0, "bord gauche {} au repos {}", contact.0, rest.0); assert!(contact.2 < rest.2, "hauteur droite {} au repos {}", contact.2, rest.2); assert!(contact.3 > rest.3, "hauteur gauche {} au repos {}", contact.3, rest.3); } diff --git a/crates/compositor/tests/privacy_blur_under_zoom.rs b/crates/compositor/tests/privacy_blur_under_zoom.rs index 6542232c5..75d781890 100644 --- a/crates/compositor/tests/privacy_blur_under_zoom.rs +++ b/crates/compositor/tests/privacy_blur_under_zoom.rs @@ -135,3 +135,47 @@ fn a_privacy_mask_keeps_covering_its_content_under_zoom_and_tilt() { } assert!(leaks.is_empty(), "le masque laisse voir le secret :\n{}", leaks.join("\n")); } + +/// Pendant la rampe d'un zoom, le flou de mouvement étale chaque pixel vers là où il était une +/// frame plus tôt : une copie traînée du secret sort de la position courante. Le masque doit la +/// couvrir aussi, sur l'écran droit (mode 0) comme incliné (mode 8). Cas rapporté en revue : un +/// `iso` ×2 de 2 à 4 s visant (0,3 ; 0,3), flou au maximum, à 1,1 s en pleine montée. +#[test] +fn a_privacy_mask_covers_the_motion_blur_trail_of_a_zoom() { + let Ok(secret) = std::env::var("OPENSCREEN_PRIVACY_SECRET") else { + println!("SKIP: definir OPENSCREEN_PRIVACY_SECRET (voir l'en-tete du fichier)."); + return; + }; + let gpu = Gpu::create(false).expect("device d3d11"); + let render_at = |masked: bool, rotation: &str, blur: f32, t: f64| { + let json = scene_json(&secret, masked, Some((2.0, 0.3, 0.3, rotation))) + .replace(r#""startSec":0,"endSec":6,"scale""#, r#""startSec":2,"endSec":4,"scale""#) + .replace(r#""motionBlur":0.0}"#, &format!(r#""motionBlur":{blur}}}"#)); + let mut cfg = config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + let comp = Compositor::new_sized(&gpu, W, H).expect("compositor"); + let scene = Scene::from_json(&json).expect("scene valide"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + comp.clear_cursor(); + unsafe { + let mut player = Player::open(&secret, "", &gpu).expect("ouvrir la source"); + player.present_frame(&comp, &cfg, t).expect("composer la frame"); + comp.readback_resized(W, H).expect("readback") + } + }; + let mut leaks = Vec::new(); + for rotation in [r#""iso""#, "null"] { + for t in [1.1, 1.3, 1.6, 4.3] { + let clear = red_pixels(&render_at(false, rotation, 1.0, t)); + let exposed = red_pixels(&render_at(true, rotation, 1.0, t)); + println!("{rotation:<6} t={t:<4} flou 1 : rouge sans masque {clear:>6} avec masque {exposed:>4}"); + assert!(clear > 500, "{rotation} t={t} : le pave rouge n'est pas visible ({clear} px)"); + if exposed > 0 { + leaks.push(format!("{rotation} t={t} : {exposed} px rouges visibles")); + } + } + } + assert!(leaks.is_empty(), "la traînée du flou sort du masque :\n{}", leaks.join("\n")); +} diff --git a/docs/3d-effects-v2.md b/docs/3d-effects-v2.md index 5e68938fd..add92b6d4 100644 --- a/docs/3d-effects-v2.md +++ b/docs/3d-effects-v2.md @@ -15,9 +15,9 @@ Ce document tranche les décisions que la v1 laissait ouvertes et découpe les P ### A.1 Ce qui existe -Un zoom porte **une attitude figée** (`rotationPreset` : `iso`, `left`, `right`), animée par +Un zoom porte **une attitude figée** (`rotationPreset` : `left`, `right`), animée par deux effets qui partagent un unique budget d'angle dynamique (`DYNAMIC_TILT_BUDGET` -= ±1,9° X, ±3,0° Y, 0° Z, `clamp_dynamic_tilt`) : += ±1,9° X, ±3° Y, 0° Z, `clamp_dynamic_tilt`) : - **la parallaxe** (`dynamic_tilt`, PR 1) : pilotée par la **vitesse** lissée du curseur ; - **l'impact du clic** (PR 2b) : piloté par la **position** du clic, `regions::tap`. @@ -39,21 +39,26 @@ mobile, qui tourne autour de l'écran. | groupe | valeur | libellé (EN) | ce que ça fait | |---|---|---|---| | — | absent | Off | écran droit | -| Angle fixe | `iso` | Angled from above | tourné vers la gauche, plongée marquée | -| Angle fixe | `left` | Turned left | tourné vers la gauche | -| Angle fixe | `right` | Turned right | tourné vers la droite | +| Angle fixe | `left` | Screen turned left | tourné vers la gauche, vu d'en haut | +| Angle fixe | `right` | Screen turned right | tourné vers la droite, vu d'en haut | | Caméra mobile | `follow-cursor` | Orbits with the cursor | l'écran est immobile, une vraie caméra tourne autour de lui avec le curseur | `swing-clicks` et `orbit` sont retirés (jamais livrés). La caméra mobile qui reste est l'orbite ci-dessous, sous l'identifiant `follow-cursor`. Un projet qui les porte encore s'ouvre à plat (valeur inconnue). -Les trois angles fixes gardent leurs valeurs et leur rendu **à l'octet** (vérifié contre le -commit de base : présets, cadre, flou de confidentialité, profondeur de champ, parallaxe, impact -du clic, flèche modélisée), avec la parallaxe de vitesse et l'impact du clic. +Les angles fixes ne roulent plus : Z vaut 0, parce que le tilt entre avec le zoom et qu'une +caméra qui bouge ne roule jamais le métrage (l'ancien roulis de −2°/±1° penchait l'image pendant +chaque zoom). Sans roulis, la règle des 2° ne laisse que deux bandes de tangage, ≤ 9° ou ≥ 15°. +Les deux angles prennent la bande haute, le regard de l'ancien `iso` : `left` [−23, −25, 0] et son +miroir `right` [−23, 25, 0] (`regions::rotation3d_for`), qui rendent au budget dynamique ses +±1,9° / ±3°. `iso` n'est plus proposé : c'était déjà ce regard, tourné à gauche, et un projet qui +le porte se lit comme `left` (`readRotation3DPreset` et le schéma du document). Ils se dessinent +au warp **projectif** exact, comme la caméra réelle : le warp bilinéaire penchait à lui seul la +verticale du milieu de l'écran. -« Turned right » veut dire que la face de l'écran regarde vers la droite : le bord droit -recule. C'est ce que fait `right` [−8, 16, 1] depuis toujours. +« Screen turned right » veut dire que la face de l'écran regarde vers la droite : le bord droit +recule. C'est ce que fait `right` [−23, 25, 0], vu d'en haut. ### A.3 `follow-cursor` : une caméra en orbite @@ -123,10 +128,10 @@ Chaînée à un angle fixe, une région `follow-cursor` ne mélange jamais les d transition passe par l'écran droit à mi-course. Entre deux régions `follow-cursor`, le cadreur ne dépend pas de la région : l'orbite continue sans à-coup pendant que le zoom change. -**Rendu exact.** Le warp bilinéaire des angles fixes s'écarte de la projection de cette caméra de +**Rendu exact.** Le warp bilinéaire s'écarte de la projection de cette caméra de plusieurs centaines de px au pire pixel visible (703 px sur l'enveloppe, 264 px sur la grille rendue). Les modes 8, 10, 13 et 14 prennent donc un warp -**projectif** exact sous cette caméra : l'homographie des quatre coins (forme de Heckbert) résolue à +**projectif** exact sous cette caméra, et depuis sous les angles fixes aussi : l'homographie des quatre coins (forme de Heckbert) résolue à l'envers dans le shader, drapeau par mode (`TiltedQuad::warp_flag` : `dst_prev.w` au mode 8, `mb.w` au 10, `mb.x` au 13, `src.x` au 14). Mesuré sur une grille rendue par D3D11, caméra tournée vers un coin au zoom 2,2 : 0,07 px d'écart à la projection. `TiltedQuad` porte la caméra complète : @@ -162,8 +167,10 @@ lointain. - **`swing-clicks`** : à refaire sur `camera.rs`, l'orbite en donne la mécanique. - **`dolly`** (vertigo) : la distance de l'œil par frame, que `TiltedQuad::perspective` sait déjà transporter. -- **Flou de mouvement sous la caméra** : le mode 8 n'en a pas. Le lissage borne le mouvement (voir - les mesures ci-dessus), sans limite de vitesse explicite. +- **Flou de mouvement de la visée** : le mode 8 a le flou du mode 0, borné à une frame, sur le plan + d'une frame plus tôt (boîte du zoom, rotation de base, caméra réelle d'avant ; + `FrameGeometry::tilt_trail`, `LayerCB::trail_*`). La parallaxe et l'impact n'y entrent pas, + comme le focus au mode 0. Un masque de confidentialité couvre alors le secret aux deux frames. - **Lumière du curseur modélisé** : elle reste fixée à la caméra ; l'œil en orbite la déplace avec lui. À fixer au monde avec le propriétaire du mode 15. @@ -258,10 +265,9 @@ Deux décisions : 1. **Le hotspot est sur le rayon de vue** du point de contenu visé, à sa hauteur. Il ne glisse donc jamais à l'écran quand le modèle monte ou descend : seule l'ombre dit la hauteur. -2. **Ancrage** : sous un angle fixe, la vidéo est dessinée par un warp **bilinéaire** des coins - projetés, qui s'écarte de la perspective exacte de quelques pixels. Tout le rendu est décalé - de `point_px(plane_pt) − projection exacte`, pour que le hotspot tombe sur le pixel que - l'écran montre. Sous la caméra réelle le warp est exact et ce décalage est nul. +2. **Ancrage** : tout le rendu est décalé de `point_px(plane_pt) − projection exacte`, pour que + le hotspot tombe sur le pixel que l'écran montre. Le warp de l'écran étant exact (projectif), + sous un angle fixe comme sous la caméra réelle, ce décalage est nul. ### B.5 La pose (fonction pure de `t`) @@ -350,7 +356,7 @@ Seulement quand `model3d` est allumé (un curseur sans modèle n'a pas de contac **Rendu** : un carré du plan centré sur le point, dont les coins passent par la projection du contenu (`TiltedQuad::point_px`), comme le sprite du mode 13 ; le shader inverse le warp -(bilinéaire sous un angle fixe, projectif sous la caméra réelle) et dessine un disque dans le +(projectif, celui de l'écran) et dessine un disque dans le carré. L'anneau est donc posé sur le plan : ellipse sous `iso`, perspective exacte sous `follow-cursor`. Rien n'est dessiné hors de l'écran. Rust porte la courbe dans le temps (`impact_at`) ; les trois shaders ne dessinent que la forme de l'instant (`cursor_impact`). @@ -376,7 +382,7 @@ de la pastille ; plus rien après sa fenêtre. dans les mêmes conditions. Une lecture de texture coûte moins que les dix arêtes du polygone. - **Mémoire** : un champ par sprite chargé, sans éviction ; les seize sprites du thème pèsent ~2,6 Mo de R16F. -- **`LayerCB`** reste à 128 octets ; l'emploi des emplacements aux modes 15 et 16 est documenté +- **`LayerCB`** fait 176 octets (les trois derniers vec4 portent la traînée du mode 8) ; l'emploi des emplacements aux modes 15 et 16 est documenté en tête des sections « Curseur modélisé » et « Impact du clic » de `frame_geometry.rs` et dans les trois structs de shader. - **Traînée au contact** : elle lit la position convergée (B.5.1), donc se replie sur une seule @@ -681,8 +687,8 @@ dans la boîte de dessin. - **Emplacements du cbuffer** : en tête de `device_frame_cb` (`frame_geometry.rs`), qui fait foi. `radius_px` / `color.b` = rayons des coins hauts / bas, concentriques, calculés côté Rust ; `dst_prev` = (angle du socle, rayon de l'ouverture, recouvrement, pénombre de l'ombre). Le plan - proche n'a pas d'emplacement : les shaders le tirent de `src.z / src.w` et de `mb.xy`. `LayerCB` - reste à 128 octets. + proche n'a pas d'emplacement : les shaders le tirent de `src.z / src.w` et de `mb.xy`. + (`LayerCB` n'y gagne rien.) ### C.8 Limites diff --git a/src/components/ai-edition/depthOfFieldToggle.test.tsx b/src/components/ai-edition/depthOfFieldToggle.test.tsx index c3fd590c7..6b8e02d46 100644 --- a/src/components/ai-edition/depthOfFieldToggle.test.tsx +++ b/src/components/ai-edition/depthOfFieldToggle.test.tsx @@ -13,7 +13,7 @@ import { VideoEffectsPane } from "./RightPanes"; vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })); -function documentWithZoom(rotationPreset?: "iso"): AxcutDocument { +function documentWithZoom(rotationPreset?: "left"): AxcutDocument { const base = createEmptyDocument({ title: "T", projectId: "p1" }); return { ...base, @@ -61,7 +61,7 @@ describe("depth of field toggle", () => { }); it("is on by default with a tilted zoom, and turns off then on again", async () => { - const toggle = mount(documentWithZoom("iso")); + const toggle = mount(documentWithZoom("left")); expect(toggle).toBeEnabled(); expect(toggle).toHaveAttribute("aria-pressed", "true"); expect(screen.getByText("3D zooms")).toBeInTheDocument(); diff --git a/src/components/ai-edition/v4/FloatingInspector.test.tsx b/src/components/ai-edition/v4/FloatingInspector.test.tsx index b27d29424..9b9ccaeb3 100644 --- a/src/components/ai-edition/v4/FloatingInspector.test.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.test.tsx @@ -147,7 +147,7 @@ describe("FloatingInspector", () => { }); it("is disabled with its reason when the region hides the cursor", () => { - const { tl } = zoomTl({ rotationPreset: "iso", hideCursor: true }); + const { tl } = zoomTl({ rotationPreset: "left", hideCursor: true }); render(); expect( screen.getByRole("button", { name: "settings.zoom.clickImpact.title" }), @@ -168,7 +168,7 @@ describe("FloatingInspector", () => { it("is disabled with its reason when the cursor is hidden globally", () => { editorSettings.cursorShow = false; try { - const { tl } = zoomTl({ rotationPreset: "iso" }); + const { tl } = zoomTl({ rotationPreset: "left" }); render(); expect( screen.getByRole("button", { name: "settings.zoom.clickImpact.title" }), @@ -180,7 +180,7 @@ describe("FloatingInspector", () => { }); it("toggles the region's clickImpact under a 3D preset", () => { - const { tl, updateZoomClickImpact } = zoomTl({ rotationPreset: "iso" }); + const { tl, updateZoomClickImpact } = zoomTl({ rotationPreset: "left" }); render(); const box = screen.getByRole("button", { name: "settings.zoom.clickImpact.title" }); expect(box).toBeEnabled(); @@ -226,7 +226,7 @@ describe("FloatingInspector", () => { [...g.querySelectorAll("option")].map((o) => o.value), ]); expect(groups).toEqual([ - ["settings.zoom.camera.fixed", ["iso", "left", "right"]], + ["settings.zoom.camera.fixed", ["left", "right"]], ["settings.zoom.camera.moving", ["follow-cursor"]], ]); }); @@ -237,8 +237,8 @@ describe("FloatingInspector", () => { const select = screen.getByRole("combobox", { name: "settings.zoom.camera.title" }); expect(select).toHaveValue("follow-cursor"); expect(screen.getByText("settings.zoom.camera.description.followCursor")).toBeInTheDocument(); - fireEvent.change(select, { target: { value: "iso" } }); - expect(updateZoomRotation).toHaveBeenCalledWith("z", "iso"); + fireEvent.change(select, { target: { value: "left" } }); + expect(updateZoomRotation).toHaveBeenCalledWith("z", "left"); fireEvent.change(select, { target: { value: "off" } }); expect(updateZoomRotation).toHaveBeenLastCalledWith("z", undefined); }); diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx index 8a3a42e75..8878fcd19 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -344,7 +344,6 @@ function paneStack(label: string, control: React.ReactNode) { /** Clé i18n (`zoom.camera.preset.*` / `zoom.camera.description.*`) de chaque caméra 3D. */ const CAMERA_KEYS: Record = { - iso: "iso", left: "left", right: "right", "follow-cursor": "followCursor", diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 800fe4bba..ef3f76f12 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -203,7 +203,7 @@ describe("projectPersistence media compatibility", () => { const zoom = { startMs: 0, endMs: 1000, depth: 3 as const, focus: { cx: 0.5, cy: 0.5 } }; const [on, off, junk] = normalizeProjectEditor({ zoomRegions: [ - { ...zoom, id: "on", rotationPreset: "iso", clickImpact: true }, + { ...zoom, id: "on", rotationPreset: "left", clickImpact: true }, { ...zoom, id: "off" }, { ...zoom, id: "junk", clickImpact: "yes" as never }, ], @@ -213,6 +213,18 @@ describe("projectPersistence media compatibility", () => { expect("clickImpact" in junk).toBe(false); }); + it("reads the retired iso camera as Left, which kept its look", () => { + const zoom = { startMs: 0, endMs: 1000, depth: 3 as const, focus: { cx: 0.5, cy: 0.5 } }; + const [iso, unknown] = normalizeProjectEditor({ + zoomRegions: [ + { ...zoom, id: "iso", rotationPreset: "iso" as never }, + { ...zoom, id: "unknown", rotationPreset: "orbit" as never }, + ], + }).zoomRegions; + expect(iso.rotationPreset).toBe("left"); + expect("rotationPreset" in unknown).toBe(false); + }); + it("accepts the dual frame webcam layout preset", () => { expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe( "dual-frame", diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index db017a2f6..69b17428e 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -39,12 +39,12 @@ import { DEFAULT_WEBCAM_REACTIVE_ZOOM, DEFAULT_ZOOM_DEPTH, DEFAULT_ZOOM_MOTION_BLUR, - isRotation3DPreset, isWallpaperMotion, MAX_BLUR_BLOCK_SIZE, MAX_BLUR_INTENSITY, MIN_BLUR_BLOCK_SIZE, MIN_BLUR_INTENSITY, + readRotation3DPreset, type SpeedRegion, type TrimRegion, type WallpaperMotion, @@ -278,9 +278,8 @@ export function normalizeProjectEditor(editor: Partial): Pro const startMs = Math.max(0, Math.min(rawStart, rawEnd)); const endMs = Math.max(startMs + 1, rawEnd); - const validPreset = isRotation3DPreset(region.rotationPreset) - ? region.rotationPreset - : undefined; + // A retired angle reads as the one that kept its look (`iso` → Left). + const validPreset = readRotation3DPreset(region.rotationPreset); return { id: region.id, startMs, diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 93d9f763b..e12534f5c 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -77,7 +77,7 @@ export const DEFAULT_ROTATION_3D: Rotation3D = { }; /** A fixed 3D angle: the screen holds one pose for the whole zoom. */ -export const FIXED_ROTATION_3D_PRESETS = ["iso", "left", "right"] as const; +export const FIXED_ROTATION_3D_PRESETS = ["left", "right"] as const; export type FixedRotation3DPreset = (typeof FIXED_ROTATION_3D_PRESETS)[number]; /** @@ -101,12 +101,22 @@ export function isRotation3DPreset(value: unknown): value is Rotation3DPreset { return typeof value === "string" && (ROTATION_3D_PRESET_ORDER as string[]).includes(value); } -// Every preset carries all three components on purpose. With a single-axis rotation the projected -// quad keeps an edge exactly parallel to the frame — both vertical edges for a pure Y rotation, -// which is geometry rather than a setting — and a perfectly vertical edge cutting through text is -// indistinguishable from `overflow: hidden`. That is what got reported three times as "the -// recording is truncated" while the plane was in fact drawn whole. `regions.rs` holds the same -// numbers and a test asserting no edge comes within 2° of an axis. +/** + * A stored 3D camera, read: the presets that no longer exist map to the one that kept their look. + * `iso` (turned left, seen from above) became Left. Anything else unknown reads as a flat screen. + */ +export function readRotation3DPreset(value: unknown): Rotation3DPreset | undefined { + if (value === "iso") return "left"; + return isRotation3DPreset(value) ? value : undefined; +} + +// No preset rolls (Z is 0): the tilt enters with the zoom, and a camera that moves never rolls the +// footage. X and Y are chosen so that no edge of the projected quad comes within 2° of an axis — a +// perfectly vertical edge cutting through text is indistinguishable from `overflow: hidden`, which +// got reported three times as "the recording is truncated". A pure Y rotation keeps both vertical +// edges vertical, so every preset also pitches; and between 10° and 14° of pitch the top edge of a +// turned screen reads level, so both presets pitch well beyond it: turned and seen from above, +// the old `iso` look. `regions.rs` holds the same numbers and the tests. // // A moving camera has no single pose. Its entry is its resting angle (`ELEVATION_DEG` in // `camera.rs`: cursor centred, the camera 4° above the screen, facing it), what a renderer without @@ -114,9 +124,8 @@ export function isRotation3DPreset(value: unknown): value is Rotation3DPreset { // rotation of the screen. It is a camera angle, not a screen rotation, so this is the nearest // equivalent rather than the same picture. export const ROTATION_3D_PRESETS: Record = { - iso: { rotationX: -12, rotationY: -18, rotationZ: -2 }, - left: { rotationX: -8, rotationY: -16, rotationZ: -1 }, - right: { rotationX: -8, rotationY: 16, rotationZ: 1 }, + left: { rotationX: -23, rotationY: -25, rotationZ: 0 }, + right: { rotationX: -23, rotationY: 25, rotationZ: 0 }, "follow-cursor": { rotationX: -4, rotationY: 0, rotationZ: 0 }, }; diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 52f5b30db..cc2a43be1 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -362,16 +362,14 @@ "fixed": "زاوية ثابتة", "moving": "كاميرا متحركة", "preset": { - "iso": "مائلة من الأعلى", "left": "الشاشة مستديرة لليسار", "right": "الشاشة مستديرة لليمين", "followCursor": "يدور مع المؤشر" }, "description": { "off": "تبقى الشاشة مسطحة.", - "iso": "وضعية واحدة ثابتة: مستديرة لليسار ومرئية من الأعلى.", - "left": "وضعية واحدة ثابتة: الشاشة تتجه إلى اليسار وحافتها اليمنى تتقدم.", - "right": "وضعية واحدة ثابتة: الشاشة تتجه إلى اليمين وحافتها اليسرى تتقدم.", + "left": "وضعية واحدة ثابتة: مستديرة لليسار ومرئية من الأعلى.", + "right": "وضعية واحدة ثابتة: مستديرة لليمين ومرئية من الأعلى.", "followCursor": "تبقى الشاشة ثابتة، وتدور الكاميرا حولها نحو جهة المؤشر وترتفع أو تنخفض معه." }, "needsCursor": "المؤشر مخفي: لا شيء تتبعه هذه الكاميرا، فتحافظ على زاويتها الابتدائية." diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index 7fc99ec5b..423226410 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -362,16 +362,14 @@ "fixed": "Pevný úhel", "moving": "Pohyblivá kamera", "preset": { - "iso": "Šikmo shora", "left": "Obrazovka natočená doleva", "right": "Obrazovka natočená doprava", "followCursor": "Obíhá s kurzorem" }, "description": { "off": "Obrazovka zůstane rovná.", - "iso": "Drží jednu pózu: natočená doleva a viděná shora.", - "left": "Drží jednu pózu: obrazovka hledí doleva, její pravý okraj vystupuje dopředu.", - "right": "Drží jednu pózu: obrazovka hledí doprava, její levý okraj vystupuje dopředu.", + "left": "Drží jednu pózu: natočená doleva a viděná shora.", + "right": "Drží jednu pózu: natočená doprava a viděná shora.", "followCursor": "Obrazovka zůstává v klidu; kamera kolem ní krouží na stranu kurzoru a stoupá či klesá s ním." }, "needsCursor": "Kurzor je skrytý: tato kamera nemá co sledovat a drží svůj výchozí úhel." diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index 74784272e..241e25fea 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -362,16 +362,14 @@ "fixed": "Fester Winkel", "moving": "Bewegte Kamera", "preset": { - "iso": "Schräg von oben", "left": "Bildschirm nach links gedreht", "right": "Bildschirm nach rechts gedreht", "followCursor": "Kreist mit dem Cursor" }, "description": { "off": "Der Bildschirm bleibt flach.", - "iso": "Hält eine Pose: nach links gedreht und von oben gesehen.", - "left": "Hält eine Pose: Der Bildschirm zeigt nach links, seine rechte Kante kommt nach vorn.", - "right": "Hält eine Pose: Der Bildschirm zeigt nach rechts, seine linke Kante kommt nach vorn.", + "left": "Hält eine Pose: nach links gedreht und von oben gesehen.", + "right": "Hält eine Pose: nach rechts gedreht und von oben gesehen.", "followCursor": "Der Bildschirm bleibt stehen; die Kamera kreist um ihn, schwenkt zur Seite des Cursors und hebt oder senkt sich mit ihm." }, "needsCursor": "Der Cursor ist ausgeblendet: Diese Kamera hat nichts zu folgen und hält ihren Startwinkel." diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index aad2ad560..c870e3b98 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -362,16 +362,14 @@ "fixed": "Fixed angle", "moving": "Moving camera", "preset": { - "iso": "Angled from above", "left": "Screen turned left", "right": "Screen turned right", "followCursor": "Orbits with the cursor" }, "description": { "off": "The screen stays flat.", - "iso": "Holds one pose: turned left and seen from above.", - "left": "Holds one pose: the screen faces left, its right edge comes forward.", - "right": "Holds one pose: the screen faces right, its left edge comes forward.", + "left": "Holds one pose: turned left and seen from above.", + "right": "Holds one pose: turned right and seen from above.", "followCursor": "The screen stays still; the camera circles it, swinging to the cursor's side and rising or dipping with it." }, "needsCursor": "The cursor is hidden: this camera has nothing to follow and holds its starting angle." diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index e159d8e72..ffea51645 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -362,16 +362,14 @@ "fixed": "Ángulo fijo", "moving": "Cámara en movimiento", "preset": { - "iso": "Picado en diagonal", "left": "Pantalla girada a la izquierda", "right": "Pantalla girada a la derecha", "followCursor": "Gira con el cursor" }, "description": { "off": "La pantalla se queda plana.", - "iso": "Mantiene una sola pose: girada a la izquierda y vista desde arriba.", - "left": "Mantiene una sola pose: la pantalla mira a la izquierda y su borde derecho avanza.", - "right": "Mantiene una sola pose: la pantalla mira a la derecha y su borde izquierdo avanza.", + "left": "Mantiene una sola pose: girada a la izquierda y vista desde arriba.", + "right": "Mantiene una sola pose: girada a la derecha y vista desde arriba.", "followCursor": "La pantalla no se mueve; la cámara la rodea hacia el lado del cursor y sube o baja con él." }, "needsCursor": "El cursor está oculto: esta cámara no tiene nada que seguir y mantiene su ángulo inicial." diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 6b8e27c7a..43c1f38af 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -362,16 +362,14 @@ "fixed": "Angle fixe", "moving": "Caméra mobile", "preset": { - "iso": "Plongée de biais", "left": "Écran tourné à gauche", "right": "Écran tourné à droite", "followCursor": "Tourne avec le curseur" }, "description": { "off": "L'écran reste à plat.", - "iso": "Garde une seule pose : tourné vers la gauche et vu d'en haut.", - "left": "Garde une seule pose : l'écran regarde vers la gauche, son bord droit avance.", - "right": "Garde une seule pose : l'écran regarde vers la droite, son bord gauche avance.", + "left": "Garde une seule pose : tourné vers la gauche et vu d'en haut.", + "right": "Garde une seule pose : tourné vers la droite et vu d'en haut.", "followCursor": "L'écran reste immobile ; la caméra tourne autour, du côté du curseur, et monte ou descend avec lui." }, "needsCursor": "Le curseur est masqué : cette caméra n'a rien à suivre et garde son angle de départ." diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 085c6dd3f..55b6b0f3f 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -362,16 +362,14 @@ "fixed": "Angolo fisso", "moving": "Camera in movimento", "preset": { - "iso": "Dall'alto di sbieco", "left": "Schermo ruotato a sinistra", "right": "Schermo ruotato a destra", "followCursor": "Ruota con il cursore" }, "description": { "off": "Lo schermo resta piatto.", - "iso": "Mantiene una sola posa: ruotato a sinistra e visto dall'alto.", - "left": "Mantiene una sola posa: lo schermo guarda a sinistra e il bordo destro avanza.", - "right": "Mantiene una sola posa: lo schermo guarda a destra e il bordo sinistro avanza.", + "left": "Mantiene una sola posa: ruotato a sinistra e visto dall'alto.", + "right": "Mantiene una sola posa: ruotato a destra e visto dall'alto.", "followCursor": "Lo schermo resta fermo; la telecamera gli gira intorno dal lato del cursore e sale o scende con esso." }, "needsCursor": "Il cursore è nascosto: questa telecamera non ha nulla da seguire e mantiene l'angolo iniziale." diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 05a419369..def54d603 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -362,16 +362,14 @@ "fixed": "固定アングル", "moving": "動くカメラ", "preset": { - "iso": "斜め上から", "left": "画面を左向きに", "right": "画面を右向きに", "followCursor": "カーソルに合わせて回り込む" }, "description": { "off": "画面は平らなままです。", - "iso": "1つの姿勢を保ちます:左向きで、上から見下ろします。", - "left": "1つの姿勢を保ちます:画面が左を向き、右端が手前に出ます。", - "right": "1つの姿勢を保ちます:画面が右を向き、左端が手前に出ます。", + "left": "1つの姿勢を保ちます:左向きで、上から見下ろします。", + "right": "1つの姿勢を保ちます:右向きで、上から見下ろします。", "followCursor": "画面は動かず、カメラが画面の周りを回ってカーソルのある側へ移り、カーソルに合わせて上下します。" }, "needsCursor": "カーソルが非表示です:このカメラは追う対象がなく、最初のアングルのままです。" diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index c39788147..f4aeb6916 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -362,16 +362,14 @@ "fixed": "고정 각도", "moving": "움직이는 카메라", "preset": { - "iso": "위에서 비스듬히", "left": "화면 왼쪽으로 돌림", "right": "화면 오른쪽으로 돌림", "followCursor": "커서를 따라 회전" }, "description": { "off": "화면이 평평하게 유지됩니다.", - "iso": "한 자세를 유지합니다: 왼쪽으로 돌아가 있고 위에서 내려다봅니다.", - "left": "한 자세를 유지합니다: 화면이 왼쪽을 향하고 오른쪽 가장자리가 앞으로 나옵니다.", - "right": "한 자세를 유지합니다: 화면이 오른쪽을 향하고 왼쪽 가장자리가 앞으로 나옵니다.", + "left": "한 자세를 유지합니다: 왼쪽으로 돌아가 있고 위에서 내려다봅니다.", + "right": "한 자세를 유지합니다: 오른쪽으로 돌아가 있고 위에서 내려다봅니다.", "followCursor": "화면은 고정된 채, 카메라가 화면 주위를 돌아 커서가 있는 쪽으로 가고 커서를 따라 오르내립니다." }, "needsCursor": "커서가 숨겨져 있습니다. 이 카메라는 따라갈 대상이 없어 시작 각도를 유지합니다." diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 823d0c8b7..03e871546 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -362,16 +362,14 @@ "fixed": "Ângulo fixo", "moving": "Câmera em movimento", "preset": { - "iso": "De cima, na diagonal", "left": "Tela virada para a esquerda", "right": "Tela virada para a direita", "followCursor": "Gira com o cursor" }, "description": { "off": "A tela fica plana.", - "iso": "Mantém uma única pose: virada para a esquerda e vista de cima.", - "left": "Mantém uma única pose: a tela olha para a esquerda e a borda direita avança.", - "right": "Mantém uma única pose: a tela olha para a direita e a borda esquerda avança.", + "left": "Mantém uma única pose: virada para a esquerda e vista de cima.", + "right": "Mantém uma única pose: virada para a direita e vista de cima.", "followCursor": "A tela fica parada; a câmera gira ao redor dela, para o lado do cursor, e sobe ou desce com ele." }, "needsCursor": "O cursor está oculto: esta câmera não tem o que seguir e mantém seu ângulo inicial." diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 123fef3dd..93a5decdc 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -362,16 +362,14 @@ "fixed": "Фиксированный ракурс", "moving": "Подвижная камера", "preset": { - "iso": "Сверху под углом", "left": "Экран повёрнут влево", "right": "Экран повёрнут вправо", "followCursor": "Облетает за курсором" }, "description": { "off": "Экран остаётся плоским.", - "iso": "Одна поза: экран повёрнут влево и виден сверху.", - "left": "Одна поза: экран смотрит влево, его правый край выдвинут вперёд.", - "right": "Одна поза: экран смотрит вправо, его левый край выдвинут вперёд.", + "left": "Одна поза: экран повёрнут влево и виден сверху.", + "right": "Одна поза: экран повёрнут вправо и виден сверху.", "followCursor": "Экран неподвижен; камера облетает его, уходя в сторону курсора и поднимаясь или опускаясь вместе с ним." }, "needsCursor": "Курсор скрыт: этой камере нечего отслеживать, и она сохраняет начальный угол." diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 4d1609f86..5cf718750 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -362,16 +362,14 @@ "fixed": "Sabit açı", "moving": "Hareketli kamera", "preset": { - "iso": "Yukarıdan çapraz", "left": "Ekran sola dönük", "right": "Ekran sağa dönük", "followCursor": "İmleçle birlikte döner" }, "description": { "off": "Ekran düz kalır.", - "iso": "Tek bir pozda kalır: sola dönük ve yukarıdan görünür.", - "left": "Tek bir pozda kalır: ekran sola bakar, sağ kenarı öne çıkar.", - "right": "Tek bir pozda kalır: ekran sağa bakar, sol kenarı öne çıkar.", + "left": "Tek bir pozda kalır: sola dönük ve yukarıdan görünür.", + "right": "Tek bir pozda kalır: sağa dönük ve yukarıdan görünür.", "followCursor": "Ekran sabit kalır; kamera etrafında imlecin olduğu tarafa döner ve imleçle birlikte yükselip alçalır." }, "needsCursor": "İmleç gizli: bu kameranın izleyecek bir şeyi yok ve başlangıç açısında kalır." diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 8549a7c2e..786a0ec51 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -362,16 +362,14 @@ "fixed": "Góc cố định", "moving": "Camera chuyển động", "preset": { - "iso": "Chéo từ trên xuống", "left": "Màn hình xoay sang trái", "right": "Màn hình xoay sang phải", "followCursor": "Xoay quanh theo con trỏ" }, "description": { "off": "Màn hình giữ phẳng.", - "iso": "Giữ một tư thế: xoay sang trái và nhìn từ trên xuống.", - "left": "Giữ một tư thế: màn hình hướng sang trái, cạnh phải nhô về phía trước.", - "right": "Giữ một tư thế: màn hình hướng sang phải, cạnh trái nhô về phía trước.", + "left": "Giữ một tư thế: xoay sang trái và nhìn từ trên xuống.", + "right": "Giữ một tư thế: xoay sang phải và nhìn từ trên xuống.", "followCursor": "Màn hình đứng yên; máy quay bay vòng quanh, sang phía có con trỏ và nâng lên hay hạ xuống theo nó." }, "needsCursor": "Con trỏ đang bị ẩn: máy quay này không có gì để theo dõi và giữ nguyên góc ban đầu." diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 2b28817de..a6d0d60ea 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -362,16 +362,14 @@ "fixed": "固定角度", "moving": "运动镜头", "preset": { - "iso": "斜上方俯视", "left": "画面向左转", "right": "画面向右转", "followCursor": "随光标环绕" }, "description": { "off": "画面保持平整。", - "iso": "保持单一姿态:向左转,并从上方俯视。", - "left": "保持单一姿态:画面朝向左侧,右边缘向前。", - "right": "保持单一姿态:画面朝向右侧,左边缘向前。", + "left": "保持单一姿态:向左转,并从上方俯视。", + "right": "保持单一姿态:向右转,并从上方俯视。", "followCursor": "屏幕保持不动,镜头绕着屏幕转到光标所在的一侧,并随光标升降。" }, "needsCursor": "光标已隐藏:此镜头没有可跟随的对象,保持初始角度。" diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index d310920a8..642e13008 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -362,16 +362,14 @@ "fixed": "固定角度", "moving": "運動鏡頭", "preset": { - "iso": "斜上方俯視", "left": "畫面向左轉", "right": "畫面向右轉", "followCursor": "隨游標環繞" }, "description": { "off": "畫面保持平整。", - "iso": "保持單一姿態:向左轉,並從上方俯視。", - "left": "保持單一姿態:畫面朝向左側,右邊緣向前。", - "right": "保持單一姿態:畫面朝向右側,左邊緣向前。", + "left": "保持單一姿態:向左轉,並從上方俯視。", + "right": "保持單一姿態:向右轉,並從上方俯視。", "followCursor": "螢幕保持不動,鏡頭繞著螢幕轉到游標所在的一側,並隨游標升降。" }, "needsCursor": "游標已隱藏:此鏡頭沒有可跟隨的對象,維持初始角度。" diff --git a/src/lib/ai-edition/document/migrate.test.ts b/src/lib/ai-edition/document/migrate.test.ts index 6fd417c5f..5168e7e6a 100644 --- a/src/lib/ai-edition/document/migrate.test.ts +++ b/src/lib/ai-edition/document/migrate.test.ts @@ -110,7 +110,7 @@ describe("migrateProjectDataToAxcutDocument", () => { depth: 4, focus: { cx: 1.5, cy: -0.5 }, focusMode: "manual", - rotationPreset: "iso", + rotationPreset: "iso" as never, customScale: 2.5, source: "manual", hideCursor: true, @@ -128,7 +128,8 @@ describe("migrateProjectDataToAxcutDocument", () => { expect(z.startMs).toBe(0); expect(z.endMs).toBe(2000); expect(z.customScale).toBe(2.5); - expect(z.rotationPreset).toBe("iso"); + // `iso`, retired, reads as Left: the angle that kept its look. + expect(z.rotationPreset).toBe("left"); expect(z.hideCursor).toBe(true); expect(z.clickImpact).toBe(true); }); diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts index fc0923d73..c9e2b1994 100644 --- a/src/lib/ai-edition/schema/index.ts +++ b/src/lib/ai-edition/schema/index.ts @@ -499,8 +499,13 @@ export const zoomRegionSchema = endGteStart( focusMode: z.enum(["manual", "auto"]).optional(), /** The zoom's 3D camera (`Rotation3DPreset` in `components/video-editor/types.ts` — same * literal list, duplicated here): a fixed angle, or the camera that turns to the cursor - * (`crates/compositor/src/camera.rs`). Absent means a flat screen. */ - rotationPreset: z.enum(["iso", "left", "right", "follow-cursor"]).optional(), + * (`crates/compositor/src/camera.rs`). Absent means a flat screen. A stored `iso`, the + * retired "turned left, seen from above" angle, reads as `left`, which kept its look + * (`readRotation3DPreset`): every document load goes through this parse. */ + rotationPreset: z + .enum(["iso", "left", "right", "follow-cursor"]) + .transform((preset) => (preset === "iso" ? "left" : preset)) + .optional(), customScale: z.number().positive().optional(), source: z.enum(["auto", "manual"]).optional(), hideCursor: z.boolean().optional(), diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 8b9ef43b3..19462058d 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -764,11 +764,11 @@ describe("useTimeline zoom modifiers (rotation + focus mode)", () => { it("sets a 3D rotation preset on the region", async () => { const { result } = renderTimeline(); await act(async () => { - await result.current.updateZoomRotation("zoom_a", "iso"); + await result.current.updateZoomRotation("zoom_a", "left"); }); expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({ id: "zoom_a", - rotationPreset: "iso", + rotationPreset: "left", }); }); @@ -777,7 +777,7 @@ describe("useTimeline zoom modifiers (rotation + focus mode)", () => { // optional and the native side treats anything unrecognised as zero rotation. const { result } = renderTimeline(); await act(async () => { - await result.current.updateZoomRotation("zoom_a", "iso"); + await result.current.updateZoomRotation("zoom_a", "left"); }); await act(async () => { await result.current.updateZoomRotation("zoom_a", undefined); @@ -803,7 +803,7 @@ describe("useTimeline zoom modifiers (rotation + focus mode)", () => { // One control, one field: a moving camera replaces a fixed angle instead of stacking on it. const { result } = renderTimeline(); await act(async () => { - await result.current.updateZoomRotation("zoom_a", "iso"); + await result.current.updateZoomRotation("zoom_a", "left"); }); await act(async () => { await result.current.updateZoomRotation("zoom_a", "follow-cursor"); @@ -1258,7 +1258,7 @@ describe("useTimeline undo history", () => { const { result } = renderTimeline(); const pDepth = result.current.updateZoomDepth("zoom_a", 4); - const pRotation = result.current.updateZoomRotation("zoom_a", "iso"); + const pRotation = result.current.updateZoomRotation("zoom_a", "left"); await waitFor(() => expect(gate.release).toEqual(expect.any(Function))); await act(async () => { gate.release?.(); @@ -1267,7 +1267,7 @@ describe("useTimeline undo history", () => { expect(useProjectStore.getState().document?.zoomRanges[0]).toMatchObject({ depth: 4, - rotationPreset: "iso", + rotationPreset: "left", }); }); @@ -1418,7 +1418,7 @@ describe("useTimeline undo history", () => { expect(useProjectStore.getState().document?.zoomRanges[0]?.depth).toBe(4); const gate = gateFirstSave(); - const pRotation = result.current.updateZoomRotation("zoom_a", "iso"); + const pRotation = result.current.updateZoomRotation("zoom_a", "left"); const pCursor = result.current.updateZoomHideCursor("zoom_a", true); await waitFor(() => expect(gate.release).toEqual(expect.any(Function))); let undid = false; @@ -1452,7 +1452,7 @@ describe("useTimeline undo history", () => { }; const gate = gateFirstSave(); - const pRotation = result.current.updateZoomRotation("zoom_a", "iso"); + const pRotation = result.current.updateZoomRotation("zoom_a", "left"); const pCursor = result.current.updateZoomHideCursor("zoom_a", true); await waitFor(() => expect(gate.release).toEqual(expect.any(Function))); bridgeMocks.get.mockResolvedValue({ success: true, document: projectB }); @@ -1518,7 +1518,7 @@ describe("useTimeline undo history", () => { // A later zoom write is refused while that save is still unknown. let rotationOk: boolean | undefined; await act(async () => { - rotationOk = await result.current.updateZoomRotation("zoom_a", "iso"); + rotationOk = await result.current.updateZoomRotation("zoom_a", "left"); }); expect(rotationOk).toBe(false); expect(useProjectStore.getState().document?.zoomRanges[0]?.rotationPreset).toBeUndefined(); diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index 14724f53d..7ea4fe9b6 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -692,14 +692,14 @@ describe("buildSceneDescription.zoomRegions", () => { endMs: 5000, depth: 4, focus: { cx: 0.25, cy: 0.75 }, - rotationPreset: "iso", + rotationPreset: "left", hideCursor: true, }); const doc = makeDoc({ zoomRanges: [z] }); const { zoomRegions } = buildSceneDescription(doc); expect(zoomRegions[0].focusX).toBe(0.25); expect(zoomRegions[0].focusY).toBe(0.75); - expect(zoomRegions[0].rotation).toBe("iso"); + expect(zoomRegions[0].rotation).toBe("left"); expect(zoomRegions[0].hideCursor).toBe(true); }); @@ -709,7 +709,7 @@ describe("buildSceneDescription.zoomRegions", () => { endMs: 1000, depth: 3 as const, focus: { cx: 0.5, cy: 0.5 }, - rotationPreset: "iso" as const, + rotationPreset: "left" as const, }; const doc = makeDoc({ zoomRanges: [