From 4f5f8c2a35613e04968bbabf0c20dd50557fd2c5 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 17 Sep 2026 19:53:34 +0200 Subject: [PATCH 01/11] test(compositor): measure the Linux cursor trail over a one-frame shutter Formerly #684. --- crates/compositor/tests/compose_linux.rs | 47 ++++++++++++++---------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/crates/compositor/tests/compose_linux.rs b/crates/compositor/tests/compose_linux.rs index 62361572d..81ee2cfb8 100644 --- a/crates/compositor/tests/compose_linux.rs +++ b/crates/compositor/tests/compose_linux.rs @@ -660,7 +660,8 @@ fn compose_linux_flou_de_velocite_camera() { /// Trainee fantome du curseur (accumulation temporelle, pas un mode de shader). /// /// Le curseur traverse le cadre ; a `cursor.motionBlur = 1` `plan_cursor` rend -/// 11 taps entre sa position d'il y a 8 frames (8/60 s) et sa position courante. +/// 2 a 16 prises, ponderees vers la tete, entre sa position d'il y a UNE frame +/// (1/60 s, l'obturateur borne depuis 34b2e5847) et sa position courante. /// On compare au meme rendu sans trainee : la seule difference possible etant le /// curseur, un exces de vert la ou le curseur N'EST PAS (mais est PASSE) est la /// signature de la trainee. @@ -686,13 +687,15 @@ fn compose_linux_trainee_de_curseur() { let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); let mut dec = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); - // Piste : deplacement horizontal regulier cx 0,1 -> 0,9 en 0,4 s, a cy fixe. - // Assez rapide pour que les 8/60 s de recul de la trainee separent nettement - // les deux extremites (~256 px a 960 de large) : sans quoi la trainee se - // superpose au curseur lui-meme et on ne pourrait plus les distinguer. + // Piste : deplacement horizontal regulier cx 0,1 -> 0,9 en 0,1 s, a cy fixe. + // L'obturateur de la trainee est borne a UNE frame (`trail_dt = blur01 / FPS` + // dans `plan_cursor`, 1/60 s a flou 100 %), donc le curseur doit aller vite + // pour que la trainee separe nettement ses deux extremites : 8 cx/s donne + // ~128 px a 960 de large. Plus lent, la trainee se superpose au curseur + // lui-meme (51 px de cote) et on ne peut plus les distinguer. let mut samples = String::new(); for k in 0..=8 { - let (ms, cx) = (k * 50, 0.1 + 0.1 * k as f32); + let (ms, cx) = (k as f32 * 12.5, 0.1 + 0.1 * k as f32); if k > 0 { samples.push(','); } @@ -704,7 +707,7 @@ fn compose_linux_trainee_de_curseur() { std::fs::write(&track_path, format!(r#"{{"samples":[{samples}]}}"#)).expect("write track"); let track = CursorTrack::load(track_path.to_str().unwrap(), 0.0, 2.0).expect("CursorTrack::load"); comp.set_cursor(track); - comp.set_cursor_time(Some(0.35)); + comp.set_cursor_time(Some(0.0875)); let scene_of = |mblur: f32| { format!( @@ -730,13 +733,15 @@ fn compose_linux_trainee_de_curseur() { write_ppm("compose_linux_cursor_trail_off", W, H, &sharp); write_ppm("compose_linux_cursor_trail_on", W, H, &trail); - // A t = 0,35 s le curseur est en cx 0,8 (x ~ 768 px) et 8/60 s plus tot en - // cx ~ 0,533 (x ~ 512 px) ; le sprite fait 51 px de cote a size 3 (34/1080 - // de frame_min_px, x3), donc le curseur COURANT occupe x = 742..794. La - // fenetre ci-dessous couvre le milieu du trajet, franchement a sa gauche : - // sans trainee il n'y a rien du tout. La bande miroir est son reflet par - // rapport a l'axe horizontal de l'image (cy = 0,28 est hors de cet axe - // exprès), donc un `accum` composite a l'envers y atterrirait. + // A t = 0,0875 s le curseur est en cx 0,8 (x ~ 768 px) et une frame (1/60 s) + // plus tot en cx ~ 0,667 (x ~ 640 px) ; le sprite fait 51 px de cote a + // size 3 (34/1080 de frame_min_px, x3), donc le curseur COURANT occupe + // x = 742..794 et la copie la plus ancienne x = 614..666. La fenetre + // ci-dessous tient entre les deux (x = 670..738, y = 130..172 autour de + // cy = 0,28 soit y ~ 151) : sans trainee il n'y a rien du tout. La bande + // miroir est son reflet par rapport a l'axe horizontal de l'image (cy = 0,28 + // est hors de cet axe expres), donc un `accum` composite a l'envers y + // atterrirait. let greener = |x0: u32, x1: u32, y0: u32, y1: u32| -> usize { let excess = |img: &[u8], i: usize| { img[i + 1] as i32 - (img[i] as i32).max(img[i + 2] as i32) @@ -752,15 +757,17 @@ fn compose_linux_trainee_de_curseur() { } n }; - let on_path = greener(530, 700, 130, 172); - let mirrored = greener(530, 700, 368, 410); + let on_path = greener(670, 738, 130, 172); + let mirrored = greener(670, 738, 368, 410); println!("compose_linux trainee curseur : sur le trajet={on_path} bande miroir={mirrored}"); - // La fenetre fait 170x42 = 7140 px et la trainee la remplit entierement. - // Le seuil a 4000 laisse de la marge tout en refusant une trainee qui ne - // couvrirait qu'un bout du trajet. + // La fenetre fait 68x42 = 2856 px et la trainee la remplit entierement : les + // copies sont espacees de ~8,5 px (16 prises sur 128 px) pour un sprite de + // 51 px, et chaque pixel en recoit au moins cinq. Le seuil a 1700 laisse de + // la marge tout en refusant une trainee qui ne couvrirait qu'un bout du + // trajet. assert!( - on_path > 4000, + on_path > 1700, "pas de trainee au milieu du trajet ({on_path} px plus verts) — le curseur n'est dessine qu'a sa position courante" ); assert!( From 21182d4033615a341d5cf061fae6dbfc8b20efff Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 17 Sep 2026 19:53:34 +0200 Subject: [PATCH 02/11] fix(dev): bypass the HTTP cache when loading the Vite dev server Every worktree serves Vite on localhost:5173 into the same userData profile, so Chromium replayed another server's response and the HUD stayed a black rectangle. Formerly #696. --- electron/dev-http-cache.test.ts | 17 +++++++++++++++++ electron/dev-http-cache.ts | 12 ++++++++++++ electron/main.ts | 3 +++ 3 files changed, 32 insertions(+) create mode 100644 electron/dev-http-cache.test.ts create mode 100644 electron/dev-http-cache.ts diff --git a/electron/dev-http-cache.test.ts b/electron/dev-http-cache.test.ts new file mode 100644 index 000000000..2ef9f30a6 --- /dev/null +++ b/electron/dev-http-cache.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from "vitest"; +import { disableHttpCacheForDevServer } from "./dev-http-cache"; + +describe("disableHttpCacheForDevServer", () => { + it("disables the HTTP cache when loading from a Vite dev server", () => { + const commandLine = { appendSwitch: vi.fn() }; + disableHttpCacheForDevServer(commandLine, { VITE_DEV_SERVER_URL: "http://localhost:5173" }); + expect(commandLine.appendSwitch).toHaveBeenCalledTimes(1); + expect(commandLine.appendSwitch).toHaveBeenCalledWith("disable-http-cache"); + }); + + it("leaves the HTTP cache alone without a dev server", () => { + const commandLine = { appendSwitch: vi.fn() }; + disableHttpCacheForDevServer(commandLine, {}); + expect(commandLine.appendSwitch).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/dev-http-cache.ts b/electron/dev-http-cache.ts new file mode 100644 index 000000000..562872c9b --- /dev/null +++ b/electron/dev-http-cache.ts @@ -0,0 +1,12 @@ +// Dev only: every worktree serves its own Vite on localhost:5173 into the same userData +// profile, so the HTTP cache replays another server's response (seen: HTML for +// /src/styles/fonts.css). The module graph dies, the HUD never paints and shows as a black +// rectangle. Nothing on a dev server is worth caching. +export function disableHttpCacheForDevServer( + commandLine: { appendSwitch(name: string): void }, + env: Record, +): void { + if (env["VITE_DEV_SERVER_URL"]) { + commandLine.appendSwitch("disable-http-cache"); + } +} diff --git a/electron/main.ts b/electron/main.ts index ec961aead..c01cfdfba 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -39,6 +39,7 @@ import { } from "./background-update"; import { parseCliArgs } from "./cli/args"; import { runCli } from "./cli/cliMain"; +import { disableHttpCacheForDevServer } from "./dev-http-cache"; import { isDiagnosticModeEnabled, mainLogBuffer } from "./diagnostics/main-log-buffer"; import { buildEditMenuSubmenu, type EditorUndoRedoChannel, routeEditorUndoRedo } from "./edit-menu"; import { @@ -100,6 +101,8 @@ if (process.platform === "linux") { } } +disableHttpCacheForDevServer(app.commandLine, process.env); + installMainProcessErrorGuards(); export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); From 213997375744c6ea8d6efaff2bac493efaa12f55 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 17 Sep 2026 19:53:34 +0200 Subject: [PATCH 03/11] feat(zoom): drive the 3D tilt from cursor motion Formerly #685. --- crates/compositor/src/compositor_linux.rs | 3 +- crates/compositor/src/compositor_macos.rs | 3 +- crates/compositor/src/compositor_windows.rs | 11 +- crates/compositor/src/frame_geometry.rs | 136 +++++- crates/compositor/src/regions.rs | 419 +++++++++++++++++- .../compositor/tests/tilt_parallax_render.rs | 155 +++++++ crates/fixture/scene-tilt.json | 74 ++++ crates/poc-d3d/src/bench.rs | 2 - 8 files changed, 779 insertions(+), 24 deletions(-) create mode 100644 crates/compositor/tests/tilt_parallax_render.rs create mode 100644 crates/fixture/scene-tilt.json diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 7e08ebcad..23749cb33 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -1950,8 +1950,7 @@ impl Compositor { // change. `regions` fait toute la trigo (partagee avec macOS/Windows) ; ici // on ne fait que l'empaqueter. let s_px = [g.s_dst[2] * rw, g.s_dst[3] * rh]; - let tilt = (!crate::regions::is_identity_rotation(g.zoom_rotation)) - .then(|| crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], g.zoom_rotation)); + let tilt = g.screen_tilt(s_px); let quad_center_px = [ (g.s_dst[0] + g.s_dst[2] * 0.5) * rw, (g.s_dst[1] + g.s_dst[3] * 0.5) * rh, diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index b4acfba47..6cf64dcf3 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -2072,8 +2072,7 @@ impl Compositor { let s_px = [g.s_dst[2] * rw, g.s_dst[3] * rh]; // Géométrie du tilt calculée UNE fois : l'ombre et l'écran doivent porter exactement // le même quadrilatère, sinon l'ombre se décolle dès que l'un des deux change. - let tilt = (!crate::regions::is_identity_rotation(g.zoom_rotation)) - .then(|| crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], g.zoom_rotation)); + let tilt = g.screen_tilt(s_px); let quad_center_px = [ (g.s_dst[0] + g.s_dst[2] * 0.5) * rw, (g.s_dst[1] + g.s_dst[3] * 0.5) * rh, diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index da8d12021..7a309f1f6 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -1648,6 +1648,7 @@ impl Compositor { let mb_amount = g.mb_amount; let source_t = g.source_t; let zoom_rotation = g.zoom_rotation; + let zoom_rotation_dyn = g.zoom_rotation_dyn; let _padding_scale = g.padding_scale; let cut = g.cut; let s_dst = g.s_dst; @@ -1767,8 +1768,7 @@ impl Compositor { // Géométrie du tilt, calculée UNE fois : l'ombre et l'écran doivent porter exactement le // même quadrilatère. Deux calculs séparés, c'est une ombre qui se décolle dès qu'un des // deux change. - let tilt = (!crate::regions::is_identity_rotation(zoom_rotation)) - .then(|| crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], zoom_rotation)); + let tilt = g.screen_tilt(s_px); let quad_center_px = [(s_dst[0] + s_dst[2] * 0.5) * self.rw(), (s_dst[1] + s_dst[3] * 0.5) * self.rh()]; // L'ombre suit la silhouette réellement affichée : le rect arrondi quand l'écran est @@ -1818,7 +1818,12 @@ impl Compositor { // une découpe (« un overflow hidden qui tronque l'enregistrement ») là où il devrait // lire une inclinaison. Ils sont donc rendus, dans le repère DU PLAN. let quad = tilt.unwrap_or_else(|| { - crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], zoom_rotation) + crate::regions::rotated_quad_corners_px( + s_px[0], + s_px[1], + zoom_rotation, + zoom_rotation_dyn, + ) }); let corners = quad.corners; // Taille du plan dans son propre repère, avant projection : c'est là que vit le rayon, diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 8973425ee..a92ff06ec 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -738,7 +738,12 @@ pub struct FrameGeometry { pub mb_taps: f32, pub mb_amount: f32, pub source_t: f32, + /// Rotation 3D de BASE (préset × force) : elle seule fixe l'échelle de containment et le + /// choix mode 0 / mode 8. pub zoom_rotation: [f32; 3], + /// Part dynamique du tilt (parallaxe, `regions::dynamic_tilt`), ajoutée à la base à la + /// projection. Nulle quand la base est neutre. + pub zoom_rotation_dyn: [f32; 3], pub padding_scale: f32, /// Coupe source de l'écran en UV texture (crop utilisateur + zoom). pub cut: [f32; 4], @@ -790,6 +795,20 @@ pub fn annotation_dst_in(anchor: [f32; 4], x: f32, y: f32, w: f32, h: f32) -> [f } impl FrameGeometry { + /// Le quad de l'écran incliné pour une boîte de `s_px` px, `None` quand l'écran est droit. + /// LE point de passage de l'écran, de son ombre, du curseur et du masque de flou : tous + /// doivent porter la même séparation base / dynamique, sinon ils se décollent. + pub fn screen_tilt(&self, s_px: [f32; 2]) -> Option { + (!crate::regions::is_identity_rotation(self.zoom_rotation)).then(|| { + crate::regions::rotated_quad_corners_px( + s_px[0], + s_px[1], + self.zoom_rotation, + self.zoom_rotation_dyn, + ) + }) + } + /// `annotation_dst_in` appliqué à `s_ann`, pour les backends qui tiennent la géométrie /// entière — c'est-à-dire ceux qui n'ont aucune raison de choisir un rect. pub fn annotation_dst(&self, x: f32, y: f32, w: f32, h: f32) -> [f32; 4] { @@ -871,7 +890,7 @@ impl FrameGeometry { // É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`. let s_px = [self.s_dst[2] * rw, self.s_dst[3] * rh]; - let quad = crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], self.zoom_rotation); + let quad = self.screen_tilt(s_px)?; let centre = [ (self.s_dst[0] + self.s_dst[2] * 0.5) * rw, (self.s_dst[1] + self.s_dst[3] * 0.5) * rh, @@ -1064,11 +1083,13 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // 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é). let mut zoom_rotation = [0.0f32; 3]; + let mut zoom_tilt = 0.0f32; if !zoom_regions.is_empty() { let zs = crate::regions::zoom_state_at(zoom_regions, source_t, cursor_for_zoom); p.zoom = zs.scale; p.focus = zs.focus; zoom_rotation = zs.rotation; + zoom_tilt = zs.tilt; let zs_p = crate::regions::zoom_state_at(zoom_regions, source_t_prev, cursor_for_zoom); pp.zoom = zs_p.scale; pp.focus = zs_p.focus; @@ -1236,6 +1257,22 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { let cut_ref_prev = cover(screen_source_rect(u_max, v_max, active_crop, pp.zoom, p.focus)); let cut = cover(screen_source_rect(u_max, v_max, active_crop, 1.0, p.focus)); let s_dst = remap_box(s_base, cut_ref, cut); + // Parallaxe : calculée ici, une fois la coupe connue — elle mesure la vitesse du curseur + // en coupes VISIBLES par seconde. La coupe visible est `cut_ref`, zoom compris : `cut` + // ne porte plus que le crop depuis #179, et sous un x2 le même geste traverse deux fois + // plus d'écran. La coupe passe au repère normalisé du curseur. + // Curseur masqué → pas de parallaxe : l'export ne charge la piste que si le curseur est + // affiché (`timeline_walk`), la preview toujours. Sans cette porte, la preview pencherait + // un plan que l'export laisse immobile. + let parallax_track = cursor_for_zoom.filter(|_| scene.is_some_and(|s| s.cursor.show)); + let cut_norm = [ + cut_ref[0] / u_max.max(1e-6), + cut_ref[1] / v_max.max(1e-6), + cut_ref[2] / u_max.max(1e-6), + cut_ref[3] / v_max.max(1e-6), + ]; + let zoom_rotation_dyn = + crate::regions::dynamic_tilt(source_t, parallax_track, cut_norm, zoom_tilt); let s_dst_prev = remap_box(s_base_prev, cut_ref_prev, cut); // le padding n'affecte QUE l'écran (la quantité de fond révélée). La webcam reste ancrée // en bas-droite à sa marge fixe, quelle que soit la valeur de padding (pas de scale_frame) @@ -1353,6 +1390,7 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { mb_amount, source_t, zoom_rotation, + zoom_rotation_dyn, padding_scale, cut, s_dst, @@ -1425,8 +1463,7 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option 0.1, "vers la droite → +Y : {:?}", g.zoom_rotation_dyn); + + let render = [1170.0, 658.0]; + let s_px = [g.s_dst[2] * render[0], g.s_dst[3] * render[1]]; + let quad = g.screen_tilt(s_px).expect("iso incline"); + let still_quad = still.screen_tilt(s_px).expect("iso incline"); + assert_eq!(quad.scale, still_quad.scale, "échelle gelée"); + assert_ne!(quad.corners, still_quad.corners, "la parallaxe bouge les coins"); + + let plan = plan_cursor( + &g, + &CursorPlanInput { + render_px: render, + u_max: 1.0, + v_max: 1080.0 / 1088.0, + cfg: &cfg, + live: live_params_from_scene(&tilted_golden_scene()), + scene: Some(&tilted_golden_scene()), + track, + t: 1.5, + }, + ) + .expect("curseur visible"); + match plan.placement { + CursorPlacement::Tilted { quad: cursor_quad, .. } => { + assert_eq!(cursor_quad.corners, quad.corners); + assert_eq!(cursor_quad.scale, quad.scale); + } + _ => panic!("le curseur doit suivre le plan incliné"), + } + + let hidden_json = zoomed_golden_scene_json() + .replace(r#""rotation":"none""#, r#""rotation":"iso""#) + .replace(r#""show":true"#, r#""show":false"#); + let hidden = plan_frame(&with_track(&Scene::from_json(&hidden_json).expect("scène"))); + assert_eq!(hidden.zoom_rotation_dyn, [0.0; 3], "curseur masqué : pas de piste en export"); + + let flat = plan_frame(&with_track(&zoomed_golden_scene())); + assert_eq!(flat.zoom_rotation_dyn, [0.0; 3], "sans préset"); + } + + /// 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] + fn the_parallax_speed_is_measured_in_the_zoomed_cut() { + let cfg = crate::config::all().pop().expect("au moins une config"); + let track: &'static crate::cursor::CursorTrack = Box::leak(Box::new( + crate::cursor::CursorTrack::new( + (0..=90).map(|i| (i as f32 / 30.0, 0.05 + 0.1 * i as f32 / 30.0, 0.3)).collect(), + vec![], + vec![], + ), + )); + let lean = |scale: &str| { + let json = zoomed_golden_scene_json() + .replace(r#""rotation":"none""#, r#""rotation":"iso""#) + .replace(r#""scale":2.0"#, scale); + let scene = Scene::from_json(&json).expect("scène"); + plan_frame(&FrameGeometryInput { cursor: Some(track), ..golden_input(&scene, &cfg) }) + .zoom_rotation_dyn[1] + }; + let (flat, zoomed) = (lean(r#""scale":1.0"#), lean(r#""scale":2.0"#)); + let budget = crate::regions::DYNAMIC_TILT_BUDGET[1]; + assert!(flat > 0.1 && zoomed < budget * 0.9, "garde, hors saturation : {flat} {zoomed}"); + assert!(zoomed > flat * 1.4, "zoom x2 : {zoomed} devrait dépasser {flat} nettement"); + } + /// Sous un préset 3D, le masque est le quad du contenu, warpé comme le mode 8 le dessine. #[test] fn a_privacy_mask_follows_the_tilted_content() { @@ -1889,7 +2016,7 @@ mod tests { // Le même quad et le même centre que le dessin du mode 8 (`compose_frame`). let s_px = [g.s_dst[2] * render[0], g.s_dst[3] * render[1]]; - let quad = crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], g.zoom_rotation); + let quad = g.screen_tilt(s_px).expect("iso incline"); let centre = [ (g.s_dst[0] + g.s_dst[2] * 0.5) * render[0], (g.s_dst[1] + g.s_dst[3] * 0.5) * render[1], @@ -2421,6 +2548,7 @@ mod tests { mb_amount: 0.0, source_t: 0.0, zoom_rotation: [0.0, 0.0, 0.0], + zoom_rotation_dyn: [0.0, 0.0, 0.0], padding_scale: 1.0, cut: [0.0, 0.0, 1.0, 1.0], s_dst: [0.0, 0.0, 1.0, 1.0], diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs index e6f645b89..b1bd00719 100644 --- a/crates/compositor/src/regions.rs +++ b/crates/compositor/src/regions.rs @@ -275,9 +275,19 @@ pub struct ZoomState { pub scale: f32, pub focus: [f32; 2], pub rotation: [f32; 3], + /// À quel point un préset 3D est installé (0..1) : la force de la région quand elle en porte + /// un, 0 sinon ; interpolé entre deux régions chaînées, et refermé en milieu de course + /// quand leurs présets diffèrent. C'est la porte de `dynamic_tilt`. + pub tilt: f32, } -const IDENTITY_ZOOM: ZoomState = ZoomState { scale: 1.0, focus: [0.5, 0.5], rotation: [0.0, 0.0, 0.0] }; +const IDENTITY_ZOOM: ZoomState = + ZoomState { scale: 1.0, focus: [0.5, 0.5], rotation: [0.0, 0.0, 0.0], tilt: 0.0 }; + +/// 1 si la région porte un préset 3D, 0 sinon. +fn has_tilt(region: &SceneZoomRegion) -> f32 { + if is_identity_rotation(rotation3d_for(®ion.rotation)) { 0.0 } else { 1.0 } +} /// Port de `easeConnectedPan` (TS) : cubic-bezier(0.1, 0, 0.2, 1). fn ease_connected_pan(t: f32) -> f32 { @@ -424,10 +434,18 @@ pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&Cursor let (cur, next) = (®ions[ci], ®ions[ni]); let cur_focus = resolve_focus(cur, t, cursor); let next_focus = resolve_focus(next, t, cursor); + // Entre deux présets DIFFÉRENTS, la base traverse des poses qu'aucun préset ne valide + // (left→right passe par Y = 0) : la marge que l'échelle gelée laissait au budget n'y est + // plus. La porte se ferme donc en milieu de course et ne se rouvre qu'au ras des bouts — + // 1 − 4p(1−p) vaut 1 en p = 0 et p = 1, donc aucun saut avec la région d'avant ni avec + // le palier d'après. Cf. `the_chained_sweep_keeps_every_edge_off_axis_and_inside`. + let (cur_rot, next_rot) = (rotation3d_for(&cur.rotation), rotation3d_for(&next.rotation)); + let crossing = if cur_rot == next_rot { 1.0 } else { 1.0 - 4.0 * progress * (1.0 - progress) }; return ZoomState { scale: lerp(cur.scale, next.scale, progress), focus: [lerp(cur_focus[0], next_focus[0], progress), lerp(cur_focus[1], next_focus[1], progress)], - rotation: lerp_rotation3d(rotation3d_for(&cur.rotation), rotation3d_for(&next.rotation), progress), + rotation: lerp_rotation3d(cur_rot, next_rot, progress), + tilt: lerp(has_tilt(cur), has_tilt(next), progress) * crossing, }; } @@ -440,6 +458,7 @@ pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&Cursor scale: next.scale, focus: resolve_focus(next, t, cursor), rotation: rotation3d_for(&next.rotation), + tilt: has_tilt(next), }; } } @@ -484,6 +503,7 @@ pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&Cursor scale, focus: [ease(focus[0]), ease(focus[1])], rotation: lerp_rotation3d([0.0, 0.0, 0.0], rotation3d_for(&r.rotation), strength), + tilt: has_tilt(r) * strength, } } None => IDENTITY_ZOOM, @@ -612,7 +632,20 @@ pub struct TiltedQuad { /// (0,0 = centre — l'appelant les recentre sur le centre réel à l'écran). `width`/`height` en /// px = la taille du rect d'origine, aussi utilisée comme référence de perspective (comme le /// web : la perspective/le containScale sont calculés sur la taille de l'élément lui-même). -pub fn rotated_quad_corners_px(width: f32, height: f32, rot: [f32; 3]) -> TiltedQuad { +/// +/// `rot` = la rotation de BASE (préset × force), `rot_dyn` = la part dynamique (parallaxe, +/// cf. `dynamic_tilt`). L'échelle de containment se calcule sur la base SEULE ; la part +/// dynamique ne fait que re-projeter les coins à cette échelle gelée. Recalculer l'échelle +/// sur l'angle instantané faisait respirer tout le plan de 28 à 57 px à 1080p pour 3° de +/// mouvement — et l'arrondi de l'ombre et la boîte de clip du curseur avec lui. La marge que +/// la boucle laisse déjà (3,8 à 6 %) absorbe `DYNAMIC_TILT_BUDGET` +/// (cf. `the_budget_sweep_stays_inside_the_original_rect`). +pub fn rotated_quad_corners_px( + width: f32, + height: f32, + rot: [f32; 3], + rot_dyn: [f32; 3], +) -> TiltedQuad { // ROTATION_3D_PERSPECTIVE_FACTOR (TS) — à garder synchronisé avec `types.ts`, que la passe 3D // de l'exporteur canvas lit encore. Distance de fuite = facteur × min(w,h) : plus le facteur // est grand, plus la caméra est loin et plus la convergence des arêtes s'aplatit. À 2.6 elle @@ -665,9 +698,95 @@ pub fn rotated_quad_corners_px(width: f32, height: f32, rot: [f32; 3]) -> Tilted None => break, } } + if rot_dyn != [0.0; 3] { + let full = [rot[0] + rot_dyn[0], rot[1] + rot_dyn[1], rot[2] + rot_dyn[2]]; + // Un coin qui passerait derrière le plan de fuite : on garde la pose de base. + if let Some(c) = project_scaled_corners(width, height, scale, full, perspective) { + corners = c; + } + } TiltedQuad { corners, scale } } +/// Amplitude maximale de la part dynamique du tilt, en degrés X/Y/Z. Partagée par tout ce qui +/// 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. +/// +/// 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. +pub const DYNAMIC_TILT_BUDGET: [f32; 3] = [1.9, 3.0, 0.0]; + +/// Borne une part dynamique au budget, axe par axe. +pub fn clamp_dynamic_tilt(rot: [f32; 3]) -> [f32; 3] { + let b = DYNAMIC_TILT_BUDGET; + [rot[0].clamp(-b[0], b[0]), rot[1].clamp(-b[1], b[1]), rot[2].clamp(-b[2], b[2])] +} + +/// 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`. +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 +/// dérive lente (un dixième par seconde) d'un demi-degré. Près de la base des présets, 1° de Y +/// ne déplace le bord droit que d'environ 2 px à 1080p (le cosinus compense en partie la +/// perspective) : un gain plus faible ne se voyait pas. +const PARALLAX_DEG_PER_SPEED: f32 = 5.0; +/// Demi-fenêtre de la différence centrée qui mesure la vitesse, en secondes. Bien plus large +/// que le pas de télémétrie (33 ms) : la dérivée d'une interpolation linéaire est en escalier, +/// la moyenner sur 200 ms la rend continue. +const PARALLAX_VELOCITY_HALF_WINDOW_S: f32 = 0.1; + +/// La part dynamique du tilt au temps `t`, en degrés X/Y/Z, à AJOUTER à la rotation de base +/// (`rotated_quad_corners_px(.., base, dyn)`). Appelée UNE fois par frame, depuis `plan_frame`, +/// pour que l'écran, son ombre et le curseur portent le même angle. +/// +/// - `track` : la piste curseur ; `None` (pas de sidecar) → rotation nulle. +/// - `cut` : la coupe visible, dans le repère NORMALISÉ du curseur (`[x0, y0, x1, y1]`). La +/// vitesse s'y mesure en coupes par seconde — ce que voit le spectateur. L'impact du clic +/// visera depuis ce même point. +/// - `strength` : à quel point le tilt est installé (0..1, cf. `ZoomState::tilt`). Rien sous +/// `PARALLAX_GATE_START` : pendant l'ease-in la base est encore dans (ou près de) la bande +/// des 2°, y ajouter du mouvement ferait longer un axe à une arête. +/// +/// La vitesse est prise sur la piste de SUIVI (`follow_at`, déjà lissée), jamais sur `at()`, et +/// par différence centrée : aucune intégration d'une frame à l'autre, la frame reste une pure +/// fonction de `t` (cf. `smooth_follow_samples`). +/// +/// Sens : le plan se penche vers le geste — curseur vers la droite → le bord droit recule +/// (+Y), vers le bas → le bord bas recule (−X). Même convention que l'impact du clic. +pub fn dynamic_tilt(t: f32, track: Option<&CursorTrack>, cut: [f32; 4], strength: f32) -> [f32; 3] { + let gate = smoothstep(PARALLAX_GATE_START, 1.0, strength); + if gate <= 0.0 { + return [0.0; 3]; + } + let Some(track) = track else { return [0.0; 3] }; + let h = PARALLAX_VELOCITY_HALF_WINDOW_S; + let (Some(a), Some(b)) = (track.follow_at(t - h), track.follow_at(t + h)) else { + return [0.0; 3]; + }; + let (cw, ch) = ((cut[2] - cut[0]).max(1e-3), (cut[3] - cut[1]).max(1e-3)); + let (vx, vy) = ((b.0 - a.0) / (2.0 * h * cw), (b.1 - a.1) / (2.0 * h * ch)); + // Saturation douce : jamais au-delà du budget, sans plateau sec pendant un geste rapide. + let soft = |v: f32, budget: f32| budget * (PARALLAX_DEG_PER_SPEED * v / budget).tanh(); + let b = DYNAMIC_TILT_BUDGET; + let parallax = [soft(-vy, b[0]), soft(vx, b[1]), 0.0]; + clamp_dynamic_tilt(parallax).map(|d| d * gate) +} + +fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 { + let u = clamp01((x - e0) / (e1 - e0)); + u * u * (3.0 - 2.0 * u) +} + impl TiltedQuad { /// Où tombe le point `(fx, fy)` du plan (0..1 depuis son coin haut-gauche), en px relatifs /// au CENTRE du rect d'origine — même repère que `corners`. @@ -967,12 +1086,13 @@ mod arrow_tests { #[cfg(test)] mod tilt_tests { use super::*; + use crate::scene::SceneZoomRegion; /// `point_px` doit rendre les coins du plan sur les coins projetés, sans quoi tout ce qu'on /// 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, rotation3d_for(&Some("iso".into()))); + let quad = rotated_quad_corners_px(1920.0, 1080.0, preset("iso"), [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); @@ -985,7 +1105,7 @@ mod tilt_tests { /// bilinéaire (ce que fait le shader) d'un simple placement dans la bounding box. #[test] fn the_plane_centre_maps_to_the_centroid() { - let quad = rotated_quad_corners_px(1920.0, 1080.0, rotation3d_for(&Some("left".into()))); + let quad = rotated_quad_corners_px(1920.0, 1080.0, preset("left"), [0.0; 3]); let (x, y) = quad.point_px(0.5, 0.5); let cx = quad.corners.iter().map(|c| c.0).sum::() / 4.0; let cy = quad.corners.iter().map(|c| c.1).sum::() / 4.0; @@ -998,7 +1118,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()))); + let quad = rotated_quad_corners_px(w, h, rotation3d_for(&Some("iso".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); @@ -1020,7 +1140,7 @@ mod tilt_tests { ] { // Plusieurs formes de boîte : le débordement dépend du ratio. for (w, h) in [(1920.0f32, 1080.0f32), (1080.0, 1920.0), (800.0, 800.0)] { - let corners = rotated_quad_corners_px(w, h, rot).corners; + let corners = rotated_quad_corners_px(w, h, rot, [0.0; 3]).corners; let (max_x, max_y) = projected_extents(&corners); // Tolérance d'un demi-pixel : l'itération s'arrête à 0.1 % près. assert!( @@ -1043,7 +1163,7 @@ mod tilt_tests { ("left", rotation3d_for(&Some("left".into()))), ("right", rotation3d_for(&Some("right".into()))), ] { - let c = rotated_quad_corners_px(1920.0, 1080.0, rot).corners; + let c = rotated_quad_corners_px(1920.0, 1080.0, rot, [0.0; 3]).corners; // haut, bas (contre l'horizontale) ; gauche, droite (contre la verticale) let h_angle = |p: (f32, f32), q: (f32, f32)| (q.1 - p.1).atan2(q.0 - p.0).to_degrees(); let v_angle = |p: (f32, f32), q: (f32, f32)| (q.0 - p.0).atan2(q.1 - p.1).to_degrees(); @@ -1065,7 +1185,7 @@ mod tilt_tests { #[test] fn a_flat_quad_is_left_alone() { // Sans rotation, aucun containment ne doit s'appliquer : les coins sont ceux du rect. - let corners = rotated_quad_corners_px(1000.0, 600.0, [0.0, 0.0, 0.0]).corners; + let corners = rotated_quad_corners_px(1000.0, 600.0, [0.0, 0.0, 0.0], [0.0; 3]).corners; let (max_x, max_y) = projected_extents(&corners); assert!((max_x - 500.0).abs() < 0.5 && (max_y - 300.0).abs() < 0.5); } @@ -1074,11 +1194,288 @@ 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, rotation3d_for(&Some("iso".into()))).corners; + let corners = rotated_quad_corners_px(1920.0, 1080.0, preset("iso"), [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"); } + fn preset(name: &str) -> [f32; 3] { + rotation3d_for(&Some(name.into())) + } + + /// Plus petit écart d'une arête du quad à son axe, en degrés. + fn min_edge_angle(c: &[(f32, f32); 4]) -> f32 { + let h = |p: (f32, f32), q: (f32, f32)| (q.1 - p.1).atan2(q.0 - p.0).to_degrees().abs(); + let v = |p: (f32, f32), q: (f32, f32)| (q.0 - p.0).atan2(q.1 - p.1).to_degrees().abs(); + h(c[0], c[1]).min(h(c[3], c[2])).min(v(c[0], c[3])).min(v(c[1], c[2])) + } + + /// Débordement relatif du quad hors du rect d'origine (> 0 = déborde). + fn overflow(c: &[(f32, f32); 4], w: f32, h: f32) -> f32 { + let (mx, my) = projected_extents(c); + (mx / (w * 0.5)).max(my / (h * 0.5)) - 1.0 + } + + /// Toutes les parts dynamiques d'un budget réduit de `g`, sur une grille qui inclut les + /// coins de la boîte (le pire cas combine les deux axes). + fn budget_sweep(g: f32) -> impl Iterator { + let n = 6; + let b = DYNAMIC_TILT_BUDGET; + (-n..=n).flat_map(move |i| { + (-n..=n).map(move |j| { + [b[0] * g * i as f32 / n as f32, b[1] * g * j as f32 / n as f32, 0.0] + }) + }) + } + + /// 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. + #[test] + fn the_budget_sweep_keeps_every_edge_off_axis() { + let (w, h) = (1920.0f32, 1080.0f32); + for name in ["iso", "left", "right"] { + for k in 0..=400 { + let strength = k as f32 / 400.0; + let base = lerp_rotation3d([0.0; 3], preset(name), strength); + let floor = + min_edge_angle(&rotated_quad_corners_px(w, h, base, [0.0; 3]).corners).min(2.0); + let gate = smoothstep(PARALLAX_GATE_START, 1.0, strength); + for d in budget_sweep(gate) { + let a = min_edge_angle(&rotated_quad_corners_px(w, h, base, d).corners); + assert!( + a >= floor - 1e-4, + "{name} force {strength} dyn {d:?} : arête à {a:.3}°, plancher {floor:.3}°" + ); + } + } + } + } + + /// 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. + #[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 + }; + 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!(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"); + } + + /// Échelle gelée : la part dynamique ne fait que re-projeter les coins, et le quad ne + /// déborde toujours pas du rect d'origine — sur les trois formes de boîte de + /// `every_preset_stays_inside_the_original_rect`. + #[test] + fn the_budget_sweep_stays_inside_the_original_rect() { + for name in ["iso", "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; + let base = lerp_rotation3d([0.0; 3], preset(name), strength); + let gate = smoothstep(PARALLAX_GATE_START, 1.0, strength); + for d in budget_sweep(gate) { + let c = rotated_quad_corners_px(w, h, base, d).corners; + let (mx, my) = projected_extents(&c); + assert!( + mx <= w * 0.5 + 0.5 && my <= h * 0.5 + 0.5, + "{name} {w}x{h} force {strength} dyn {d:?} : ({mx:.1}, {my:.1})" + ); + } + } + } + } + } + + /// Même double règle pendant une transition CHAÎNÉE, par le vrai `zoom_state_at` : entre + /// deux présets différents, la base traverse des poses qu'aucun préset ne valide + /// (left→right passe par Y = 0), et l'échelle gelée n'y laisse plus de marge. C'est la + /// porte (`ZoomState::tilt`) qui doit tenir le budget à l'écart. + #[test] + fn the_chained_sweep_keeps_every_edge_off_axis_and_inside() { + let region = |rotation: Option<&str>, start: f64, end: f64| SceneZoomRegion { + id: "z".into(), + clip_index: None, + start_sec: start, + end_sec: end, + scale: 2.0, + focus_x: 0.5, + focus_y: 0.5, + focus_mode: None, + rotation: rotation.map(Into::into), + under_trim: false, + hide_cursor: false, + }; + let presets = [None, Some("iso"), Some("left"), Some("right")]; + for a in presets { + for b in presets { + // Transition chaînée sur [4, 5] s. + let regions = [region(a, 1.0, 4.0), region(b, 4.5, 8.0)]; + for k in 0..=200 { + let t = 4.0 + k as f32 / 200.0; + let state = zoom_state_at(®ions, t, None); + let base = state.rotation; + let gate = smoothstep(PARALLAX_GATE_START, 1.0, state.tilt); + for (w, h) in [(1920.0f32, 1080.0f32), (1080.0, 1920.0), (800.0, 800.0)] { + let still = rotated_quad_corners_px(w, h, base, [0.0; 3]).corners; + let floor = min_edge_angle(&still).min(2.0); + // La boucle de containment s'arrête à `fit >= 0.999` : la base seule peut + // déjà dépasser d'un pixel. On juge la part dynamique, pas cette tolérance. + let (sx, sy) = projected_extents(&still); + let (lx, ly) = (sx.max(w * 0.5) + 0.5, sy.max(h * 0.5) + 0.5); + for d in budget_sweep(gate) { + let c = rotated_quad_corners_px(w, h, base, d).corners; + let (mx, my) = projected_extents(&c); + assert!( + mx <= lx && my <= ly, + "{a:?}→{b:?} {w}x{h} t {t} dyn {d:?} : ({mx:.1}, {my:.1})" + ); + // La règle des 2° n'est posée qu'en 16:9, comme pour les présets. + if w <= h { + continue; + } + let angle = min_edge_angle(&c); + assert!( + angle >= floor - 1e-4, + "{a:?}→{b:?} {w}x{h} t {t} dyn {d:?} : arête à {angle:.3}°, plancher {floor:.3}°" + ); + } + } + } + // Garde : entre deux présets identiques, la porte reste ouverte tout du long. + if a.is_some() && a == b { + assert_eq!(zoom_state_at(®ions, 4.5, None).tilt, 1.0, "{a:?}"); + } + } + } + } + + /// `quad.scale` ne dépend que de la base : ce qui se règle dessus (rayon de l'écran et de + /// 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"] { + 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) { + let q = rotated_quad_corners_px(1920.0, 1080.0, base, d); + assert_eq!(q.scale, frozen.scale, "{name} {d:?}"); + } + // Garde : la part dynamique bouge bien les coins. + let moved = rotated_quad_corners_px(1920.0, 1080.0, base, [1.9, 3.0, 0.0]); + assert!((moved.corners[1].0 - frozen.corners[1].0).abs() > 5.0); + } + } + + /// Une piste qui file vers la droite à `speed` écrans/s puis s'arrête à `stop`. + fn swipe(speed: f32, stop: f32) -> CursorTrack { + let samples = (0..=300) + .map(|i| { + let t = i as f32 / 30.0; + (t, 0.2 + speed * t.min(stop), 0.5) + }) + .collect(); + CursorTrack::new(samples, vec![], vec![]) + } + + const FULL_CUT: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + + #[test] + fn no_track_means_no_dynamic_tilt() { + assert_eq!(dynamic_tilt(1.0, None, FULL_CUT, 1.0), [0.0; 3]); + } + + #[test] + fn nothing_moves_during_the_ease_in() { + let track = swipe(0.5, 1.5); + for k in 0..=85 { + let strength = k as f32 / 100.0; + assert_eq!( + dynamic_tilt(1.0, Some(&track), FULL_CUT, strength), + [0.0; 3], + "force {strength}" + ); + } + assert_ne!(dynamic_tilt(1.0, Some(&track), FULL_CUT, 0.95), [0.0; 3]); + } + + /// Le geste penche le plan dans son sens, puis le plan revient à la pose du préset au repos. + #[test] + fn the_plane_leans_into_the_gesture_and_settles_at_rest() { + let track = swipe(0.5, 1.5); + let moving = dynamic_tilt(1.0, Some(&track), FULL_CUT, 1.0); + assert!(moving[1] > 0.5, "vers la droite → +Y : {moving:?}"); + assert!(moving[0].abs() < 1e-3 && moving[2] == 0.0, "{moving:?}"); + // La piste de suivi rattrape en quelques centaines de ms. + let rest = dynamic_tilt(4.0, Some(&track), FULL_CUT, 1.0); + assert!(rest[1].abs() < 0.05, "au repos : {rest:?}"); + // Vers le bas → le bord bas recule (−X). + let down = CursorTrack::new( + (0..=60).map(|i| (i as f32 / 30.0, 0.5, 0.1 + 0.4 * i as f32 / 30.0)).collect(), + vec![], + vec![], + ); + assert!(dynamic_tilt(1.0, Some(&down), FULL_CUT, 1.0)[0] < -0.5); + } + + /// Quelle que soit la vitesse, la parallaxe reste dans le budget. Une coupe serrée (crop + /// utilisateur) grossit la vitesse perçue, pas l'amplitude permise. + #[test] + fn the_amplitude_is_bounded() { + let b = DYNAMIC_TILT_BUDGET; + for speed in [0.1f32, 1.0, 10.0, 1000.0, -1000.0] { + let track = swipe(speed, 9.0); + for cut in [FULL_CUT, [0.4, 0.4, 0.45, 0.45]] { + let d = dynamic_tilt(1.0, Some(&track), cut, 1.0); + assert!(d[0].abs() <= b[0] && d[1].abs() <= b[1] && d[2] == 0.0, "{speed} {d:?}"); + } + } + let fast = dynamic_tilt(1.0, Some(&swipe(1000.0, 9.0)), FULL_CUT, 1.0); + assert!(fast[1] > 2.9, "saturation au budget : {fast:?}"); + } + + /// Pure fonction de `t` : l'ordre des appels (lecture, seek arrière) ne change rien. + #[test] + fn the_dynamic_tilt_is_a_pure_function_of_time() { + let track = swipe(0.7, 2.0); + let at = |i: usize| dynamic_tilt(i as f32 / 30.0, Some(&track), FULL_CUT, 1.0); + let forward: Vec<_> = (0..90).map(at).collect(); + for i in (0..90).rev() { + assert_eq!(at(i), forward[i]); + } + } + + /// `ZoomState::tilt` : la force pour une région à préset, 0 sans préset. + #[test] + fn the_tilt_gate_follows_the_region_strength_only_with_a_preset() { + let region = |rotation: Option<&str>| SceneZoomRegion { + id: "z".into(), + clip_index: None, + start_sec: 2.0, + end_sec: 8.0, + scale: 2.0, + focus_x: 0.5, + focus_y: 0.5, + focus_mode: None, + rotation: rotation.map(Into::into), + under_trim: false, + hide_cursor: false, + }; + assert_eq!(zoom_state_at(&[region(Some("iso"))], 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); + assert_eq!(zoom_state_at(&[region(Some("left"))], 0.0, None).tilt, 0.0); + } + // ---- Mapping inverse du mode 8, reproduit à l'identique ------------------------------- // Le pixel shader retrouve (s,t) dans le quad projeté en résolvant un système quadratique. // Le miroir ci-dessous est une COPIE de `shaders.hlsl` (mode 8) : même algèbre, même choix @@ -1161,7 +1558,7 @@ mod tilt_tests { ("left", rotation3d_for(&Some("left".into()))), ("right", rotation3d_for(&Some("right".into()))), ] { - let corners = rotated_quad_corners_px(1920.0, 1080.0, rot).corners; + let corners = rotated_quad_corners_px(1920.0, 1080.0, rot, [0.0; 3]).corners; let mut dropped = Vec::new(); let n = 64; for i in 0..=n { diff --git a/crates/compositor/tests/tilt_parallax_render.rs b/crates/compositor/tests/tilt_parallax_render.rs new file mode 100644 index 000000000..56f83ed46 --- /dev/null +++ b/crates/compositor/tests/tilt_parallax_render.rs @@ -0,0 +1,155 @@ +//! Sous un préset 3D, le plan penche vers le geste du curseur puis revient à la pose du préset +//! au repos (`regions::dynamic_tilt`). Rend de vraies frames par le compositeur D3D11. +//! +//! Piloté par l'environnement, comme `privacy_blur_under_zoom.rs` : il faut une source +//! 1920×1080 de 6 s, de préférence quadrillée pour que l'inclinaison se lise à l'œil. La piste +//! curseur est écrite par le test lui-même (même format que le sidecar `.cursor.json`). +//! +//! ```powershell +//! ffmpeg -f lavfi -i "color=c=0x707070:s=1920x1080:r=30:d=6,drawgrid=w=96:h=96:t=3:c=white" -c:v h264_mf -b:v 6M -pix_fmt nv12 grid.mp4 +//! $env:OPENSCREEN_TILT_SOURCE = "...\grid.mp4" +//! cargo test -p openscreen-compositor --test tilt_parallax_render -- --nocapture +//! ``` +//! +//! `OPENSCREEN_TILT_OUT` (facultatif) reçoit un PPM par instant, pour l'inspection à l'œil. + +// Windows seulement : le readback et le décodage D3D11VA de ce harnais n'existent que là. +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config; +use openscreen_compositor::cursor::CursorTrack; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::frame_geometry::live_params_from_scene; +use openscreen_compositor::live::Player; +use openscreen_compositor::scene::Scene; + +const W: u32 = 1920; +const H: u32 = 1080; + +/// Au repos jusqu'à 1,6 s ; vers la droite de 1,6 à 2,2 s ; repos ; vers le bas de 2,7 à 3,3 s ; +/// repos jusqu'au bout. +fn cursor_at(t: f32) -> (f32, f32) { + let ramp = |a: f32, b: f32| ((t - a) / (b - a)).clamp(0.0, 1.0); + (0.3 + 0.4 * ramp(1.6, 2.2), 0.4 + 0.35 * ramp(2.7, 3.3)) +} + +fn write_sidecar(path: &std::path::Path) { + let samples: Vec = (0..=180) + .map(|i| { + let t = i as f32 / 30.0; + let (cx, cy) = cursor_at(t); + format!(r#"{{"timeMs":{},"cx":{cx},"cy":{cy}}}"#, (t * 1000.0).round()) + }) + .collect(); + std::fs::write(path, format!(r#"{{"samples":[{}]}}"#, samples.join(","))).expect("sidecar"); +} + +fn scene_json(source: &str) -> String { + let s = source.replace('\\', "/"); + format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":6,"webcamOffsetSec":0,"hasAudio":false}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.15,"y":0.15,"width":0.7,"height":0.7}}}}, + "effects": {{"padding":0.1,"blur":false,"shadow":0.5,"roundnessFrac":0.02,"motionBlur":0.0}}, + "background": {{"kind":"color","color":"#2060d0"}}, + "zoomRegions": [{{"id":"z","startSec":0,"endSec":6,"scale":1.15,"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":"iso"}}], + "annotations": [], + "speedRegions": [], + "cursor": {{"show":true,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":{W},"height":{H},"fps":null}} + }}"## + ) +} + +fn write_ppm(path: &std::path::Path, rgba: &[u8]) { + let mut out = format!("P6\n{W} {H}\n255\n").into_bytes(); + for px in rgba.chunks_exact(4) { + out.extend_from_slice(&px[..3]); + } + std::fs::write(path, out).expect("ecrire le ppm"); +} + +/// Un pixel du plan : tout ce qui n'est pas le fond bleu (ombre comprise, qui reste bleue). +fn is_plane(rgba: &[u8], x: u32, y: u32) -> bool { + let p = &rgba[((y * W + x) * 4) as usize..]; + !(p[2] as i32 > p[0] as i32 + 60) +} + +/// Pixels dont l'appartenance au plan diffère entre deux frames : la silhouette a bougé. +fn silhouette_diff(a: &[u8], b: &[u8]) -> usize { + (0..H) + .flat_map(|y| (0..W).map(move |x| (x, y))) + .filter(|&(x, y)| is_plane(a, x, y) != is_plane(b, x, y)) + .count() +} + +/// Bord droit du plan sur la rangée du milieu, bord bas sur la colonne du milieu. +fn right_and_bottom_edges(rgba: &[u8]) -> (u32, u32) { + let right = (0..W).rev().find(|&x| is_plane(rgba, x, H / 2)).unwrap_or(0); + let bottom = (0..H).rev().find(|&y| is_plane(rgba, W / 2, y)).unwrap_or(0); + (right, bottom) +} + +#[test] +fn the_tilted_plane_leans_into_the_cursor_gesture() { + let Ok(source) = std::env::var("OPENSCREEN_TILT_SOURCE") else { + println!("SKIP: definir OPENSCREEN_TILT_SOURCE (voir l'en-tete du fichier)."); + return; + }; + let out_dir = std::env::var("OPENSCREEN_TILT_OUT").ok().map(std::path::PathBuf::from); + let sidecar_dir = out_dir.clone().unwrap_or_else(std::env::temp_dir); + std::fs::create_dir_all(&sidecar_dir).expect("creer le dossier de sortie"); + let sidecar = sidecar_dir.join(format!("tilt-parallax-{}.cursor.json", std::process::id())); + write_sidecar(&sidecar); + let track = CursorTrack::load(sidecar.to_str().expect("chemin utf-8"), 0.0, 6.0).expect("piste"); + + let gpu = Gpu::create(false).expect("device d3d11"); + 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(&scene_json(&source)).expect("scene valide"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + comp.set_cursor(track.smoothed(0.0)); + + let mut edges = Vec::new(); + let mut frames = Vec::new(); + let instants = [("rest-before", 1.2), ("moving-right", 2.0), ("moving-down", 3.1), ("rest-after", 5.0)]; + for (name, t) in instants { + let rgba = unsafe { + let mut player = Player::open(&source, "", &gpu).expect("ouvrir la source"); + player.present_frame(&comp, &cfg, t).expect("composer la frame"); + comp.readback_resized(W, H).expect("readback") + }; + if let Some(dir) = &out_dir { + write_ppm(&dir.join(format!("{name}.ppm")), &rgba); + } + let e = right_and_bottom_edges(&rgba); + println!("{name:<13} t={t:<4} bord droit {:>4} bord bas {:>4}", e.0, e.1); + edges.push(e); + frames.push(rgba); + } + let _ = std::fs::remove_file(&sidecar); + let [rest, right, down, rest_after] = [edges[0], edges[1], edges[2], edges[3]]; + + let settled = silhouette_diff(&frames[0], &frames[3]); + let leaned_right = silhouette_diff(&frames[0], &frames[1]); + let leaned_down = silhouette_diff(&frames[0], &frames[2]); + println!("silhouette : repos/repos {settled} px, droite {leaned_right} px, bas {leaned_down} px"); + + // Au repos, le plan reprend la pose du préset (le curseur, lui, a bougé, mais il reste + // DANS le plan : il ne change pas la silhouette). + assert!(settled < 50, "{settled} px de silhouette changés entre deux repos"); + let still = rest.0.abs_diff(rest_after.0) <= 1 && rest.1.abs_diff(rest_after.1) <= 1; + assert!(still, "{rest:?} {rest_after:?}"); + // En mouvement, tout le contour bouge. + assert!(leaned_right > 1_000 && leaned_down > 1_000, "{leaned_right} / {leaned_down}"); + // Vers la droite : le bord droit recule, donc se rapproche du centre. + assert!(right.0 < rest.0, "bord droit {} au repos {}", right.0, rest.0); + // Vers le bas : le bord bas recule. + assert!(down.1 < rest.1, "bord bas {} au repos {}", down.1, rest.1); +} diff --git a/crates/fixture/scene-tilt.json b/crates/fixture/scene-tilt.json new file mode 100644 index 000000000..77d43fc7b --- /dev/null +++ b/crates/fixture/scene-tilt.json @@ -0,0 +1,74 @@ +{ + "_comment": "Scène de mesure du tilt 3D pour `poc-d3d --scene` : les trois présets (iso en suivi auto, left, right en manuel), séparés de plus de 1,5 s pour garder chacun son ease-in/ease-out, curseur affiché pour que la parallaxe tourne. Couvre les 6 s de la fixture ; le bench preview (300 frames à 60 fps) en voit 5.", + "clips": [ + { + "screenPath": "fixture/screen.mp4", + "webcamPath": "fixture/webcam.mp4", + "sourceStartSec": 0, + "sourceEndSec": 6, + "webcamOffsetSec": 0, + "hasAudio": false + } + ], + "layout": { + "preset": "picture-in-picture", + "webcamSize": 0.3, + "webcamShape": "circle", + "webcamMirror": false, + "webcamPosition": null, + "webcamReactiveZoom": false + }, + "effects": { + "padding": 0.3, + "blur": false, + "shadow": 0.5, + "roundnessFrac": 0.02, + "motionBlur": 0.35 + }, + "background": { "kind": "color", "color": "#1e1e2e" }, + "zoomRegions": [ + { + "id": "iso", + "startSec": 0.0, + "endSec": 1.0, + "scale": 1.6, + "focusX": 0.5, + "focusY": 0.5, + "focusMode": "auto", + "rotation": "iso" + }, + { + "id": "left", + "startSec": 2.6, + "endSec": 3.2, + "scale": 1.4, + "focusX": 0.4, + "focusY": 0.5, + "focusMode": "manual", + "rotation": "left" + }, + { + "id": "right", + "startSec": 4.8, + "endSec": 5.5, + "scale": 1.4, + "focusX": 0.6, + "focusY": 0.5, + "focusMode": "manual", + "rotation": "right" + } + ], + "annotations": [], + "speedRegions": [], + "cursor": { + "show": true, + "size": 1, + "smoothing": 0, + "motionBlur": 0.35, + "clickBounce": 1, + "clipToBounds": false, + "theme": "default" + }, + "cropByClip": [null], + "output": { "width": 1920, "height": 1080, "fps": 60 } +} diff --git a/crates/poc-d3d/src/bench.rs b/crates/poc-d3d/src/bench.rs index 34460d9aa..aa2de7d60 100644 --- a/crates/poc-d3d/src/bench.rs +++ b/crates/poc-d3d/src/bench.rs @@ -150,7 +150,6 @@ fn run_bench(args: &[String]) -> Result<()> { source_end_sec: 6.0, // la fixture entière (§ fixture.json : 6 s, 360 frames) webcam_offset_sec: 0.0, has_audio: false, - hold_sec: 0.0, }; let path = format!("{out}/{}_{:?}.mp4", cfg.name, backend).to_lowercase(); let s = pipeline::run_composited_multi( @@ -293,7 +292,6 @@ fn run_gif_bench( source_end_sec: f64::MAX, webcam_offset_sec: 0.0, has_audio: false, - hold_sec: 0.0, }]; for r in 0..repeat { // Each run writes to the same path — the last frame wins. The From 7611cc1ca0c974320c95dccc43045e1627f49568 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 17 Sep 2026 19:53:35 +0200 Subject: [PATCH 04/11] feat(zoom): rock the tilted screen on each click Formerly #690. --- crates/compositor/src/cursor.rs | 8 + crates/compositor/src/frame_geometry.rs | 187 ++++++++- crates/compositor/src/regions.rs | 355 ++++++++++++++++-- crates/compositor/src/scene.rs | 5 + .../compositor/tests/click_impact_render.rs | 174 +++++++++ .../ai-edition/v4/FloatingInspector.test.tsx | 71 ++++ .../ai-edition/v4/FloatingInspector.tsx | 58 +++ .../video-editor/projectPersistence.test.ts | 14 + .../video-editor/projectPersistence.ts | 1 + src/components/video-editor/types.ts | 2 + src/i18n/locales/ar/settings.json | 6 + src/i18n/locales/cs/settings.json | 6 + src/i18n/locales/en/settings.json | 6 + src/i18n/locales/es/settings.json | 6 + src/i18n/locales/fr/settings.json | 6 + src/i18n/locales/it/settings.json | 6 + src/i18n/locales/ja-JP/settings.json | 6 + src/i18n/locales/ko-KR/settings.json | 6 + src/i18n/locales/pt-BR/settings.json | 6 + src/i18n/locales/ru/settings.json | 6 + src/i18n/locales/tr/settings.json | 6 + src/i18n/locales/vi/settings.json | 6 + src/i18n/locales/zh-CN/settings.json | 6 + src/i18n/locales/zh-TW/settings.json | 6 + src/lib/ai-edition/document/migrate.test.ts | 7 + src/lib/ai-edition/document/migrate.ts | 2 + src/lib/ai-edition/schema/index.ts | 3 + .../store/documentWriteAudit.test.ts | 1 + src/lib/ai-edition/store/useTimeline.test.ts | 13 + src/lib/ai-edition/store/useTimeline.ts | 17 + src/native/sceneDescription.test.ts | 20 + src/native/sceneDescription.ts | 4 + 32 files changed, 975 insertions(+), 51 deletions(-) create mode 100644 crates/compositor/tests/click_impact_render.rs diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index 667c5d4f3..02642a969 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -196,6 +196,14 @@ impl CursorTrack { } } + /// Les instants de clic dans `(lo, hi]`, triés. L'impact du clic sur le plan + /// (`regions::click_impact`) les somme tous, là où `bounce` ne garde que le dernier. + pub fn clicks_between(&self, lo: f32, hi: f32) -> &[f32] { + let a = self.clicks.partition_point(|&tc| tc <= lo); + let b = self.clicks.partition_point(|&tc| tc <= hi); + &self.clicks[a..b.max(a)] + } + /// Piste repositionnée par un ressort-amortisseur (parité `cursorPathSmoothing.ts` : /// resample à 240 Hz + intégration semi-implicite d'Euler). `factor` 0..1 = valeur brute /// du slider (0 = passthrough, retourne un clone). Les clics restent sur leurs instants diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index a92ff06ec..febf6cc3a 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -1084,12 +1084,14 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // écran normal (vélocité pour le motion blur du chemin non-tilté). let mut zoom_rotation = [0.0f32; 3]; let mut zoom_tilt = 0.0f32; + let mut zoom_click_impact = 0.0f32; if !zoom_regions.is_empty() { let zs = crate::regions::zoom_state_at(zoom_regions, source_t, cursor_for_zoom); p.zoom = zs.scale; p.focus = zs.focus; zoom_rotation = zs.rotation; zoom_tilt = zs.tilt; + zoom_click_impact = zs.click_impact; let zs_p = crate::regions::zoom_state_at(zoom_regions, source_t_prev, cursor_for_zoom); pp.zoom = zs_p.scale; pp.focus = zs_p.focus; @@ -1271,8 +1273,16 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { cut_ref[2] / u_max.max(1e-6), cut_ref[3] / v_max.max(1e-6), ]; + // Impact du clic : mêmes piste, coupe, porte et budget que la parallaxe. + let impact = match (scene, parallax_track) { + (Some(s), Some(track)) if zoom_click_impact > 0.0 => { + click_impact_at(s, cfg, &lp, track, source_t, cut, [u_max, v_max]) + .map(|d| d * zoom_click_impact) + } + _ => [0.0; 3], + }; let zoom_rotation_dyn = - crate::regions::dynamic_tilt(source_t, parallax_track, cut_norm, zoom_tilt); + crate::regions::dynamic_tilt(source_t, parallax_track, cut_norm, zoom_tilt, impact); let s_dst_prev = remap_box(s_base_prev, cut_ref_prev, cut); // le padding n'affecte QUE l'écran (la quantité de fond révélée). La webcam reste ancrée // en bas-droite à sa marge fixe, quelle que soit la valeur de padding (pas de scale_frame) @@ -1442,22 +1452,83 @@ pub struct CursorPlanInput<'a> { pub t: f32, } +/// Opacité du curseur à `t`, avant placement : 0 quand il est masqué (`cursor.show`), sinon +/// l'auto-hide × le `hideCursor` des régions de zoom. Partagée par `plan_cursor` et l'impact du +/// clic : le plan ne bascule que sous un pointeur qu'on voit. +pub fn cursor_alpha( + scene: Option<&Scene>, + cfg: &Cfg, + live: &LiveParams, + track: &crate::cursor::CursorTrack, + t: f32, +) -> f32 { + if !scene.map(|s| s.cursor.show).unwrap_or(cfg.cursor) { + return 0.0; + } + let idle_alpha = track.opacity_at(t, live.cursor_auto_hide); + let zoom_alpha = match scene { + Some(s) => crate::regions::zoom_cursor_alpha(&s.zoom_regions, t), + None => 1.0, + }; + idle_alpha * zoom_alpha +} + +/// Où tombe la position curseur `p` (repère normalisé de l'écran) dans la coupe `cut` (UV +/// texture), en fraction 0..1 par axe ; `None` hors coupe. Le test « pointeur dans la coupe » +/// de `plan_cursor`, repris tel quel par l'impact du clic. +pub fn cursor_plane_point(cut: [f32; 4], uv_max: [f32; 2], p: (f32, f32)) -> Option<[f32; 2]> { + let [su0, sv0, su1, sv1] = cut; + let (hu, hv) = ((su1 - su0) * 0.5, (sv1 - sv0) * 0.5); + let fx = (p.0 * uv_max[0] - su0) / (2.0 * hu); + let fy = (p.1 * uv_max[1] - sv0) / (2.0 * hv); + ((0.0..=1.0).contains(&fx) && (0.0..=1.0).contains(&fy)).then_some([fx, fy]) +} + +/// L'impact des clics (`regions::click_impact`) avec les portes qui dépendent de la scène, à +/// multiplier encore par le poids des régions (`ZoomState::click_impact`) ; la porte du préset +/// vient ensuite, dans `dynamic_tilt`. +/// +/// - curseur visible (`cursor_alpha`) : `cursor.show` explicite, parce que l'export ne charge la +/// piste que si le curseur est affiché alors que la preview la charge toujours ; +/// - clics dans la fenêtre source du clip actif seulement (cf. `click_impact`) ; +/// - vitesse : poids `clamp(2 − vitesse, 0, 1)`. À 100× une frame couvre 3,3 s de source, la +/// courbe serait échantillonnée une fois, au hasard : une secousse d'une frame ; +/// - masques de confidentialité : rien tant qu'un flou/mosaïque est visible. Le masque suit +/// déjà le quad dynamique (`privacy_mask`), c'est une marge de sûreté que la spec demande. +fn click_impact_at( + scene: &Scene, + cfg: &Cfg, + live: &LiveParams, + track: &crate::cursor::CursorTrack, + t: f32, + cut: [f32; 4], + uv_max: [f32; 2], +) -> [f32; 3] { + let Some(clip) = scene.clips.get(scene.active_clip_index) else { return [0.0; 3] }; + let masked = scene.annotations.iter().any(|a| { + a.kind == "blur" && a.blur.is_some() && t >= a.start_sec as f32 && t < a.end_sec as f32 + }); + if masked { + return [0.0; 3]; + } + let speed = crate::regions::speed_at(&scene.speed_regions, scene.active_clip_index, t as f64); + let speed_weight = (2.0 - speed as f32).clamp(0.0, 1.0); + let weight = cursor_alpha(Some(scene), cfg, live, track, t) * speed_weight; + if weight <= 0.0 { + return [0.0; 3]; + } + let window = [clip.source_start_sec as f32, clip.source_end_sec as f32]; + crate::regions::click_impact(t, track, window, |p| cursor_plane_point(cut, uv_max, p)) + .map(|d| d * weight) +} + /// `None` = rien à dessiner cette frame : curseur masqué, pointeur hors du rect source /// courant (zoom serré, hors écran), ou sprite réduit à rien au creux d'un click bounce /// extrême — un état normal en lecture, pas une erreur. pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option { let (rw, rh) = (input.render_px[0], input.render_px[1]); - let show = input.scene.map(|s| s.cursor.show).unwrap_or(input.cfg.cursor); - if !show { - return None; - } - let idle_alpha = input.track.opacity_at(input.t, input.live.cursor_auto_hide); - let zoom_alpha = match input.scene { - Some(s) => crate::regions::zoom_cursor_alpha(&s.zoom_regions, input.t), - None => 1.0, - }; - let alpha = idle_alpha * zoom_alpha; + let alpha = cursor_alpha(input.scene, input.cfg, &input.live, input.track, input.t); if alpha <= 0.001 { return None; } @@ -1485,15 +1556,9 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option [-1.0, -1.0, 3.0, 3.0], }; - let [su0, sv0, su1, sv1] = g.cut; - let (hu, hv) = ((su1 - su0) * 0.5, (sv1 - sv0) * 0.5); let place = |cxy: Option<(f32, f32)>, dst: [f32; 4]| -> Option { - cxy.and_then(|(cx2, cy2)| { - let fx = (cx2 * input.u_max - su0) / (2.0 * hu); - let fy = (cy2 * input.v_max - sv0) / (2.0 * hv); - if !(0.0..=1.0).contains(&fx) || !(0.0..=1.0).contains(&fy) { - return None; - } + cxy.and_then(|p| { + let [fx, fy] = cursor_plane_point(g.cut, [input.u_max, input.v_max], p)?; Some(match tilt.as_ref() { Some(&quad) => CursorPlacement::Tilted { plane_pt: [fx, fy], @@ -2003,6 +2068,90 @@ mod tests { assert!(zoomed > flat * 1.4, "zoom x2 : {zoomed} devrait dépasser {flat} nettement"); } + /// L'impact du clic passe par `plan_frame` et chacune de ses portes l'éteint : région sans + /// l'option ou sans préset, curseur masqué (réglage ou région), clic hors du clip actif ou + /// de la coupe, vitesse ≥ 2×, masque de flou visible. + #[test] + fn every_gate_cancels_the_click_impact() { + let cfg = crate::config::all().pop().expect("au moins une config"); + // Pointeur immobile près du bord droit du crop (0,61 de large), clic 50 ms avant + // l'instant du golden (1,5 s) : le creux de `tap`. + let track_at = |x: f32| -> &'static crate::cursor::CursorTrack { + Box::leak(Box::new(crate::cursor::CursorTrack::new( + (0..=90).map(|i| (i as f32 / 30.0, x, 0.3)).collect(), + vec![1.45], + vec![], + ))) + }; + let on_edge = track_at(0.6); + let impact_json = zoomed_golden_scene_json() + .replace(r#""rotation":"none""#, r#""rotation":"iso","clickImpact":true"#); + let dyn_of = |edit: &dyn Fn(String) -> String, track| { + let scene = Scene::from_json(&edit(impact_json.clone())).expect("scène"); + plan_frame(&FrameGeometryInput { cursor: Some(track), ..golden_input(&scene, &cfg) }) + .zoom_rotation_dyn + }; + let same = |s: String| s; + let on = dyn_of(&same, on_edge); + assert!(on[1] > 1.5, "clic à droite → +Y : {on:?}"); + + let insert = |field: &'static str| { + move |s: String| s.replace(r#""cursor":"#, &format!(r#"{field},"cursor":"#)) + }; + let cases: [(&str, Box String>); 7] = [ + ("option absente", Box::new(|s: String| s.replace(r#","clickImpact":true"#, ""))), + ("sans préset", Box::new(|s: String| s.replace(r#""rotation":"iso""#, r#""rotation":"none""#))), + ("curseur masqué", Box::new(|s: String| s.replace(r#""show":true"#, r#""show":false"#))), + ("région hideCursor", Box::new(|s: String| s.replace(r#""clickImpact":true"#, r#""clickImpact":true,"hideCursor":true"#))), + ("clic avant le clip", Box::new(|s: String| s.replace(r#""sourceStartSec":0"#, r#""sourceStartSec":1.46"#))), + ("vitesse 2x", Box::new(insert(r#""speedRegions":[{"clipIndex":0,"startSec":1.0,"endSec":2.0,"speed":2.0}]"#))), + ("flou visible", Box::new(insert(r#""annotations":[{"id":"b","startSec":1.0,"endSec":2.0,"kind":"blur","x":0.1,"y":0.1,"w":0.2,"h":0.2,"blur":{"style":"mosaic","shape":"rectangle","color":"black","intensity":8,"blockSize":16}}]"#))), + ]; + for (name, edit) in &cases { + assert_eq!(dyn_of(edit.as_ref(), on_edge), [0.0; 3], "{name}"); + } + assert_eq!(dyn_of(&same, track_at(0.9)), [0.0; 3], "clic hors du crop"); + + // Un flou qui n'est plus visible ne retient rien ; une vitesse 1,5× pèse moitié. + let later_blur = insert(r#""annotations":[{"id":"b","startSec":3.0,"endSec":4.0,"kind":"blur","x":0.1,"y":0.1,"w":0.2,"h":0.2,"blur":{"style":"blur","shape":"rectangle","color":"black","intensity":8,"blockSize":16}}]"#); + assert_eq!(dyn_of(&later_blur, on_edge), on); + let slow = dyn_of( + &insert(r#""speedRegions":[{"clipIndex":0,"startSec":1.0,"endSec":2.0,"speed":1.5}]"#), + on_edge, + ); + assert!((slow[1] - on[1] * 0.5).abs() < 1e-4, "{slow:?} vs {on:?}"); + + // Le curseur monte sur le même plan basculé que l'écran. + let g = plan_frame(&FrameGeometryInput { + cursor: Some(on_edge), + ..golden_input(&Scene::from_json(&impact_json).expect("scène"), &cfg) + }); + let render = [1170.0, 658.0]; + let s_px = [g.s_dst[2] * render[0], g.s_dst[3] * render[1]]; + let quad = g.screen_tilt(s_px).expect("iso incline"); + let scene = Scene::from_json(&impact_json).expect("scène"); + let plan = plan_cursor( + &g, + &CursorPlanInput { + render_px: render, + u_max: 1.0, + v_max: 1080.0 / 1088.0, + cfg: &cfg, + live: live_params_from_scene(&scene), + scene: Some(&scene), + track: on_edge, + t: 1.5, + }, + ) + .expect("curseur visible"); + match plan.placement { + CursorPlacement::Tilted { quad: cursor_quad, .. } => { + assert_eq!(cursor_quad.corners, quad.corners) + } + _ => panic!("le curseur doit suivre le plan incliné"), + } + } + /// Sous un préset 3D, le masque est le quad du contenu, warpé comme le mode 8 le dessine. #[test] fn a_privacy_mask_follows_the_tilted_content() { diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs index b1bd00719..7ea8dee1f 100644 --- a/crates/compositor/src/regions.rs +++ b/crates/compositor/src/regions.rs @@ -279,16 +279,30 @@ pub struct ZoomState { /// un, 0 sinon ; interpolé entre deux régions chaînées, et refermé en milieu de course /// quand leurs présets diffèrent. C'est la porte de `dynamic_tilt`. pub tilt: f32, + /// Poids de l'impact du clic (0..1) : 1 sur une région qui l'active (`click_impact`), + /// interpolé entre deux régions chaînées. Ne suffit pas seul : l'impact passe aussi par la + /// porte de `tilt` (`dynamic_tilt`), donc rien sans préset. + pub click_impact: f32, } -const IDENTITY_ZOOM: ZoomState = - ZoomState { scale: 1.0, focus: [0.5, 0.5], rotation: [0.0, 0.0, 0.0], tilt: 0.0 }; +const IDENTITY_ZOOM: ZoomState = ZoomState { + scale: 1.0, + focus: [0.5, 0.5], + rotation: [0.0, 0.0, 0.0], + tilt: 0.0, + click_impact: 0.0, +}; /// 1 si la région porte un préset 3D, 0 sinon. fn has_tilt(region: &SceneZoomRegion) -> f32 { if is_identity_rotation(rotation3d_for(®ion.rotation)) { 0.0 } else { 1.0 } } +/// 1 si la région active l'impact du clic, 0 sinon. +fn impact_flag(region: &SceneZoomRegion) -> f32 { + if region.click_impact { 1.0 } else { 0.0 } +} + /// Port de `easeConnectedPan` (TS) : cubic-bezier(0.1, 0, 0.2, 1). fn ease_connected_pan(t: f32) -> f32 { cubic_bezier(0.1, 0.0, 0.2, 1.0, t) @@ -446,6 +460,7 @@ pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&Cursor focus: [lerp(cur_focus[0], next_focus[0], progress), lerp(cur_focus[1], next_focus[1], progress)], rotation: lerp_rotation3d(cur_rot, next_rot, progress), tilt: lerp(has_tilt(cur), has_tilt(next), progress) * crossing, + click_impact: lerp(impact_flag(cur), impact_flag(next), progress), }; } @@ -459,6 +474,7 @@ pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&Cursor focus: resolve_focus(next, t, cursor), rotation: rotation3d_for(&next.rotation), tilt: has_tilt(next), + click_impact: impact_flag(next), }; } } @@ -504,6 +520,7 @@ pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&Cursor focus: [ease(focus[0]), ease(focus[1])], rotation: lerp_rotation3d([0.0, 0.0, 0.0], rotation3d_for(&r.rotation), strength), tilt: has_tilt(r) * strength, + click_impact: impact_flag(r), } } None => IDENTITY_ZOOM, @@ -566,6 +583,22 @@ pub fn is_identity_rotation(r: [f32; 3]) -> bool { /// perspective `perspective` (distance en px ; <=0 = orthographique). `None` si le point /// passe derrière le plan de projection (cas pathologique, comme le `return 1` du TS). fn project_corner(x0: f32, y0: f32, rot: [f32; 3], perspective: f32) -> Option<(f32, f32)> { + let (mut px, mut py, pz) = rotate_corner(x0, y0, rot); + if perspective > 0.0 { + let denom = perspective - pz; + if denom <= 0.0 { + return None; + } + let f = perspective / denom; + px *= f; + py *= f; + } + Some((px, py)) +} + +/// Le point local (x0,y0,0) tourné par `rot` (degrés X/Y/Z), avant perspective. `z` > 0 vient +/// vers la caméra, < 0 recule. +fn rotate_corner(x0: f32, y0: f32, rot: [f32; 3]) -> (f32, f32, f32) { let (a, b, g) = (rot[0].to_radians(), rot[1].to_radians(), rot[2].to_radians()); let (ca, sa) = (a.cos(), a.sin()); let (cb, sb) = (b.cos(), b.sin()); @@ -583,16 +616,7 @@ fn project_corner(x0: f32, y0: f32, rot: [f32; 3], perspective: f32) -> Option<( let (xy, xz) = (py * ca - pz * sa, py * sa + pz * ca); py = xy; pz = xz; - if perspective > 0.0 { - let denom = perspective - pz; - if denom <= 0.0 { - return None; - } - let f = perspective / denom; - px *= f; - py *= f; - } - Some((px, py)) + (px, py, pz) } /// Les 4 coins d'un quad `width`×`height` réduit de `scale`, projetés. `None` si un coin part @@ -763,23 +787,105 @@ const PARALLAX_VELOCITY_HALF_WINDOW_S: f32 = 0.1; /// /// Sens : le plan se penche vers le geste — curseur vers la droite → le bord droit recule /// (+Y), vers le bas → le bord bas recule (−X). Même convention que l'impact du clic. -pub fn dynamic_tilt(t: f32, track: Option<&CursorTrack>, cut: [f32; 4], strength: f32) -> [f32; 3] { +/// +/// `impact` : l'impact du clic (`click_impact`), déjà pondéré par ses propres portes. Il +/// s'ADDITIONNE à la parallaxe, la somme est bornée au budget, puis la porte d'ease-in +/// s'applique au tout : les deux effets passent par la même porte et le même budget. +pub fn dynamic_tilt( + t: f32, + track: Option<&CursorTrack>, + cut: [f32; 4], + strength: f32, + impact: [f32; 3], +) -> [f32; 3] { let gate = smoothstep(PARALLAX_GATE_START, 1.0, strength); if gate <= 0.0 { return [0.0; 3]; } - let Some(track) = track else { return [0.0; 3] }; let h = PARALLAX_VELOCITY_HALF_WINDOW_S; - let (Some(a), Some(b)) = (track.follow_at(t - h), track.follow_at(t + h)) else { - return [0.0; 3]; + let parallax = match track.map(|tr| (tr.follow_at(t - h), tr.follow_at(t + h))) { + Some((Some(a), Some(b))) => { + let (cw, ch) = ((cut[2] - cut[0]).max(1e-3), (cut[3] - cut[1]).max(1e-3)); + let (vx, vy) = ((b.0 - a.0) / (2.0 * h * cw), (b.1 - a.1) / (2.0 * h * ch)); + // Saturation douce : jamais au-delà du budget, sans plateau sec pendant un geste + // rapide. + let soft = |v: f32, budget: f32| budget * (PARALLAX_DEG_PER_SPEED * v / budget).tanh(); + let b = DYNAMIC_TILT_BUDGET; + [soft(-vy, b[0]), soft(vx, b[1]), 0.0] + } + _ => [0.0; 3], }; - let (cw, ch) = ((cut[2] - cut[0]).max(1e-3), (cut[3] - cut[1]).max(1e-3)); - let (vx, vy) = ((b.0 - a.0) / (2.0 * h * cw), (b.1 - a.1) / (2.0 * h * ch)); - // Saturation douce : jamais au-delà du budget, sans plateau sec pendant un geste rapide. - let soft = |v: f32, budget: f32| budget * (PARALLAX_DEG_PER_SPEED * v / budget).tanh(); - let b = DYNAMIC_TILT_BUDGET; - let parallax = [soft(-vy, b[0]), soft(vx, b[1]), 0.0]; - clamp_dynamic_tilt(parallax).map(|d| d * gate) + let sum = [parallax[0] + impact[0], parallax[1] + impact[1], parallax[2] + impact[2]]; + clamp_dynamic_tilt(sum).map(|d| d * gate) +} + +/// Durée de l'impact du clic : la fenêtre de `CursorTrack::bounce`, pour que le plan et le +/// pointeur lisent comme un seul contact. +pub const CLICK_IMPACT_WINDOW_S: f32 = 0.26; +/// Amplitude `A` de l'impact, en degrés, au creux de `tap` pour un clic au bord de la coupe. +/// Calée sur le budget X (`DYNAMIC_TILT_BUDGET`, le plus serré) : un clic dans un coin pèse +/// autant sur les deux axes sans que X sature seul et ne tourne l'axe du pivot. Ce n'est PAS +/// le réglage `clickBounce` du curseur (brut sur [0, 5], 2,5 par défaut) : il rendrait le plan +/// 2,5 fois trop fort. Cf. `a_corner_click_moves_the_plane_visibly_at_a_frozen_scale`. +pub const CLICK_IMPACT_DEG: f32 = 1.9; +/// Valeur du pic de `sin(2πe)·(1−e)²` (en e ≈ 0,1904), pour que le creux de `tap` vaille −1. +const TAP_NORM: f32 = 0.610; + +/// L'enveloppe de l'impact, `e` = temps depuis le clic / `CLICK_IMPACT_WINDOW_S`. +/// +/// Courbe sœur de `CursorTrack::bounce`, qui reste intouchée : même instant de contact (creux +/// −1 à 49,5 ms) et même fenêtre (260 ms), mais sans la cassure de pente de `bounce` à 98,8 ms +/// (×2,45) — invisible sur un sprite de 30 px, brutale sur un plan entier. Rebond plus mou +/// (+0,164 à 165 ms contre 0,67) : un écran pèse plus qu'un pointeur. Retour à zéro avec une +/// pente nulle à 260 ms. Nulle hors de `[0, 1)`. +pub fn tap(e: f32) -> f32 { + if !(0.0..1.0).contains(&e) { + return 0.0; + } + let o = 1.0 - e; + -(std::f32::consts::TAU * e).sin() * o * o / TAP_NORM +} + +/// L'impact des clics au temps `t`, en degrés X/Y/Z, SANS ses portes (préset installé, +/// curseur visible, vitesse, masques : cf. `plan_frame`). À passer à `dynamic_tilt`. +/// +/// Un pivot RIGIDE autour du centre : le côté cliqué recule, le plan ne se déforme pas +/// localement — la géométrie ne sait pas dessiner une bosse (`project_corner` part d'un point +/// z = 0, le warp est bilinéaire). +/// +/// - `window` : la fenêtre source `[début, fin)` du clip actif. Un clic hors fenêtre est sur +/// une portion coupée : ses 260 ms déborderaient sur les frames gardées. +/// - `aim` : où tombe une position curseur dans la coupe VISIBLE (0..1 par axe), `None` hors +/// coupe — `frame_geometry::cursor_plane_point`, le test même de `plan_cursor`. Un clic hors +/// de ce qu'on voit ne bascule rien. +/// +/// Loi : `A · Σ tap((t − t_c) / 0,26) · [+dy, −dx, 0]`, chaque composante de la somme bornée à +/// [−1, 1] avant `A` — un double clic à 33 ms d'écart atteindrait 1,81. `dx`, `dy` ∈ [−1, 1] : +/// le décalage du clic au centre de la coupe, y vers le bas, visé à `at(t_c)` sur la piste +/// brute (pas `at(t)` : un glisser ferait vaciller l'axe en plein impact). `tap` vaut −1 au +/// contact : clic à droite → +Y → le bord droit recule ; clic en bas → −X → le bord bas recule. +pub fn click_impact( + t: f32, + track: &CursorTrack, + window: [f32; 2], + aim: impl Fn((f32, f32)) -> Option<[f32; 2]>, +) -> [f32; 3] { + let mut sum = [0.0f32; 2]; + for &tc in track.clicks_between(t - CLICK_IMPACT_WINDOW_S, t) { + if tc < window[0] || tc >= window[1] { + continue; + } + let Some([fx, fy]) = track.at(tc).and_then(&aim) else { continue }; + let (dx, dy) = ((2.0 * fx - 1.0).clamp(-1.0, 1.0), (2.0 * fy - 1.0).clamp(-1.0, 1.0)); + let k = tap((t - tc) / CLICK_IMPACT_WINDOW_S); + sum[0] += k * dy; + sum[1] -= k * dx; + } + [ + CLICK_IMPACT_DEG * sum[0].clamp(-1.0, 1.0), + CLICK_IMPACT_DEG * sum[1].clamp(-1.0, 1.0), + 0.0, + ] } fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 { @@ -828,6 +934,7 @@ mod zoom_focus_tests { rotation: None, under_trim: false, hide_cursor: false, + click_impact: false, } } @@ -1313,6 +1420,7 @@ mod tilt_tests { rotation: rotation.map(Into::into), under_trim: false, hide_cursor: false, + click_impact: false, }; let presets = [None, Some("iso"), Some("left"), Some("right")]; for a in presets { @@ -1390,7 +1498,7 @@ mod tilt_tests { #[test] fn no_track_means_no_dynamic_tilt() { - assert_eq!(dynamic_tilt(1.0, None, FULL_CUT, 1.0), [0.0; 3]); + assert_eq!(dynamic_tilt(1.0, None, FULL_CUT, 1.0, [0.0; 3]), [0.0; 3]); } #[test] @@ -1399,23 +1507,23 @@ mod tilt_tests { for k in 0..=85 { let strength = k as f32 / 100.0; assert_eq!( - dynamic_tilt(1.0, Some(&track), FULL_CUT, strength), + dynamic_tilt(1.0, Some(&track), FULL_CUT, strength, [0.0; 3]), [0.0; 3], "force {strength}" ); } - assert_ne!(dynamic_tilt(1.0, Some(&track), FULL_CUT, 0.95), [0.0; 3]); + assert_ne!(dynamic_tilt(1.0, Some(&track), FULL_CUT, 0.95, [0.0; 3]), [0.0; 3]); } /// Le geste penche le plan dans son sens, puis le plan revient à la pose du préset au repos. #[test] fn the_plane_leans_into_the_gesture_and_settles_at_rest() { let track = swipe(0.5, 1.5); - let moving = dynamic_tilt(1.0, Some(&track), FULL_CUT, 1.0); + let moving = dynamic_tilt(1.0, Some(&track), FULL_CUT, 1.0, [0.0; 3]); assert!(moving[1] > 0.5, "vers la droite → +Y : {moving:?}"); assert!(moving[0].abs() < 1e-3 && moving[2] == 0.0, "{moving:?}"); // La piste de suivi rattrape en quelques centaines de ms. - let rest = dynamic_tilt(4.0, Some(&track), FULL_CUT, 1.0); + let rest = dynamic_tilt(4.0, Some(&track), FULL_CUT, 1.0, [0.0; 3]); assert!(rest[1].abs() < 0.05, "au repos : {rest:?}"); // Vers le bas → le bord bas recule (−X). let down = CursorTrack::new( @@ -1423,7 +1531,7 @@ mod tilt_tests { vec![], vec![], ); - assert!(dynamic_tilt(1.0, Some(&down), FULL_CUT, 1.0)[0] < -0.5); + assert!(dynamic_tilt(1.0, Some(&down), FULL_CUT, 1.0, [0.0; 3])[0] < -0.5); } /// Quelle que soit la vitesse, la parallaxe reste dans le budget. Une coupe serrée (crop @@ -1434,11 +1542,11 @@ mod tilt_tests { for speed in [0.1f32, 1.0, 10.0, 1000.0, -1000.0] { let track = swipe(speed, 9.0); for cut in [FULL_CUT, [0.4, 0.4, 0.45, 0.45]] { - let d = dynamic_tilt(1.0, Some(&track), cut, 1.0); + let d = dynamic_tilt(1.0, Some(&track), cut, 1.0, [0.0; 3]); assert!(d[0].abs() <= b[0] && d[1].abs() <= b[1] && d[2] == 0.0, "{speed} {d:?}"); } } - let fast = dynamic_tilt(1.0, Some(&swipe(1000.0, 9.0)), FULL_CUT, 1.0); + 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:?}"); } @@ -1446,13 +1554,195 @@ mod tilt_tests { #[test] fn the_dynamic_tilt_is_a_pure_function_of_time() { let track = swipe(0.7, 2.0); - let at = |i: usize| dynamic_tilt(i as f32 / 30.0, Some(&track), FULL_CUT, 1.0); + let at = |i: usize| dynamic_tilt(i as f32 / 30.0, Some(&track), FULL_CUT, 1.0, [0.0; 3]); let forward: Vec<_> = (0..90).map(at).collect(); for i in (0..90).rev() { assert_eq!(at(i), forward[i]); } } + // ---- Impact du clic ---------------------------------------------------------------- + + const MS: f32 = 1.0 / 260.0; + + /// Creux −1 à 49,5 ms, rebond +0,164 à 165 ms, zéro à pente nulle à 260 ms, rien hors + /// fenêtre. + #[test] + fn the_tap_envelope_has_its_contact_rebound_and_flat_landing() { + let (mut lo, mut lo_ms) = (f32::MAX, 0.0); + let (mut hi, mut hi_ms) = (f32::MIN, 0.0); + for i in 0..2600 { + let ms = i as f32 * 0.1; + let v = tap(ms * MS); + if v < lo { + (lo, lo_ms) = (v, ms); + } + if v > hi { + (hi, hi_ms) = (v, ms); + } + } + assert!((lo + 1.0).abs() < 1e-3 && (lo_ms - 49.5).abs() < 0.3, "creux {lo} à {lo_ms} ms"); + assert!((hi - 0.164).abs() < 1e-3 && (hi_ms - 165.3).abs() < 0.5, "rebond {hi} à {hi_ms} ms"); + assert_eq!(tap(1.0), 0.0); + assert_eq!(tap(-1e-4), 0.0); + assert_eq!(tap(0.0), 0.0); + // Pente nulle à l'atterrissage : à 1 ms de la fin, la valeur est déjà sous 1e-4. + assert!(tap(259.0 * MS).abs() < 1e-4, "{}", tap(259.0 * MS)); + // Pas de cassure de pente (celle de `bounce` à 98,8 ms) : dérivée seconde bornée partout. + let d2 = |e: f32| (tap(e + 1e-3) - 2.0 * tap(e) + tap(e - 1e-3)) / 1e-6; + for i in 2..998 { + assert!(d2(i as f32 / 1000.0).abs() < 60.0, "cassure à e = {}", i as f32 / 1000.0); + } + } + + /// Une piste immobile au point `(x, y)`, avec des clics. + fn still(x: f32, y: f32, clicks: Vec) -> CursorTrack { + CursorTrack::new((0..=90).map(|i| (i as f32 / 30.0, x, y)).collect(), clicks, vec![]) + } + + /// La coupe entière, sans restriction : le point de la piste EST le point du plan. + fn full_aim(p: (f32, f32)) -> Option<[f32; 2]> { + ((0.0..=1.0).contains(&p.0) && (0.0..=1.0).contains(&p.1)).then_some([p.0, p.1]) + } + + const WHOLE: [f32; 2] = [0.0, 100.0]; + + /// Clic à droite → +Y ; en bas → −X ; au contact, le côté cliqué recule (z diminue). + #[test] + fn the_clicked_side_recedes() { + let t = 1.0 + 49.5 / 1000.0; + let right = click_impact(t, &still(1.0, 0.5, vec![1.0]), WHOLE, full_aim); + assert!((right[1] - CLICK_IMPACT_DEG).abs() < 1e-2 && right[0].abs() < 1e-6, "{right:?}"); + let bottom = click_impact(t, &still(0.5, 1.0, vec![1.0]), WHOLE, full_aim); + 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"] { + 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. + assert!(z(960.0, 0.0, with(right)) < z(960.0, 0.0, base), "{name} droite"); + assert!(z(-960.0, 0.0, with(right)) > z(-960.0, 0.0, base), "{name} gauche"); + assert!(z(0.0, 540.0, with(bottom)) < z(0.0, 540.0, base), "{name} bas"); + assert!(z(0.0, -540.0, with(bottom)) > z(0.0, -540.0, base), "{name} haut"); + } + // Au centre : pas d'axe, rien ne bouge. + assert_eq!(click_impact(t, &still(0.5, 0.5, vec![1.0]), WHOLE, full_aim), [0.0; 3]); + } + + /// Double clic : la somme est bornée — à 33 ms d'écart elle atteindrait 1,81. + #[test] + fn a_double_click_stays_bounded() { + let track = still(1.0, 1.0, vec![1.0, 1.033]); + let mut peak = 0.0f32; + for i in 0..400 { + let d = click_impact(1.0 + i as f32 / 1000.0, &track, WHOLE, full_aim); + assert!(d[0].abs() <= CLICK_IMPACT_DEG && d[1].abs() <= CLICK_IMPACT_DEG, "{d:?}"); + peak = peak.max(d[1]); + } + assert_eq!(peak, CLICK_IMPACT_DEG, "la somme brute dépasse 1 et se fait borner"); + } + + /// Rien hors de la fenêtre du clic, du clip actif ou de la coupe visible. + #[test] + fn clicks_outside_the_window_the_clip_or_the_crop_do_nothing() { + let track = still(1.0, 0.5, vec![1.0]); + assert_eq!(click_impact(0.99, &track, WHOLE, full_aim), [0.0; 3], "avant le clic"); + assert_eq!(click_impact(1.26, &track, WHOLE, full_aim), [0.0; 3], "après 260 ms"); + let t = 1.05; + assert_eq!(click_impact(t, &track, [1.01, 9.0], full_aim), [0.0; 3], "clic coupé avant"); + assert_eq!(click_impact(t, &track, [0.0, 1.0], full_aim), [0.0; 3], "fin exclusive"); + assert_ne!(click_impact(t, &track, [1.0, 9.0], full_aim), [0.0; 3], "début inclus"); + assert_eq!(click_impact(t, &track, WHOLE, |_| None), [0.0; 3], "hors coupe"); + assert_eq!(dynamic_tilt(t, None, FULL_CUT, 1.0, [0.0; 3]), [0.0; 3]); + } + + /// L'axe est visé à l'instant du clic : un glisser qui suit ne le fait pas vaciller. + #[test] + fn the_aim_is_frozen_at_the_click() { + let drag = CursorTrack::new( + (0..=90) + .map(|i| { + let t = i as f32 / 30.0; + (t, if t <= 1.0 { 1.0 } else { (1.0 - 3.0 * (t - 1.0)).max(0.0) }, 0.5) + }) + .collect(), + vec![1.0], + vec![], + ); + let rest = still(1.0, 0.5, vec![1.0]); + for i in 0..26 { + let t = 1.0 + i as f32 / 100.0; + assert_eq!( + click_impact(t, &drag, WHOLE, full_aim), + click_impact(t, &rest, WHOLE, full_aim), + "{t}" + ); + } + } + + /// L'impact passe par la porte de la parallaxe : rien pendant l'ease-in, et la somme des + /// deux reste dans le budget — donc dans le balayage des tests de la règle des 2°. + #[test] + fn the_impact_shares_the_parallax_gate_and_budget() { + let impact = [-CLICK_IMPACT_DEG, CLICK_IMPACT_DEG, 0.0]; + for k in 0..=85 { + let strength = k as f32 / 100.0; + assert_eq!(dynamic_tilt(1.0, None, FULL_CUT, strength, impact), [0.0; 3]); + } + assert_eq!(dynamic_tilt(1.0, None, FULL_CUT, 1.0, impact), impact); + let b = DYNAMIC_TILT_BUDGET; + assert!(CLICK_IMPACT_DEG <= b[0] && CLICK_IMPACT_DEG <= b[1]); + // Parallaxe saturée dans le même sens : la SOMME est bornée. + let fast = swipe(1000.0, 9.0); + let d = dynamic_tilt(1.0, Some(&fast), FULL_CUT, 1.0, impact); + assert!(d[1] <= b[1] && d[1] > b[1] - 1e-3, "{d:?}"); + let d = dynamic_tilt(1.0, Some(&fast), FULL_CUT, 0.95, impact); + let gate = smoothstep(PARALLAX_GATE_START, 1.0, 0.95); + assert!(d[1] <= b[1] * gate + 1e-5 && d[0].abs() <= b[0] * gate + 1e-5, "{d:?}"); + } + + /// Échelle gelée pendant l'impact : seuls les coins bougent, et assez pour se voir. + #[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"] { + 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 { + let d = click_impact(1.0 + i as f32 / 100.0, &track, WHOLE, full_aim); + let q = rotated_quad_corners_px(1920.0, 1080.0, preset(name), d); + assert_eq!(q.scale, base.scale, "{name}"); + for (a, b) in q.corners.iter().zip(base.corners) { + 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. + assert!(worst > 15.0, "{name} : {worst:.1} px, invisible"); + } + } + + #[test] + fn the_click_impact_weight_follows_the_region_flag() { + let region = |click_impact: bool| SceneZoomRegion { + id: "z".into(), + clip_index: None, + start_sec: 2.0, + end_sec: 8.0, + scale: 2.0, + focus_x: 0.5, + focus_y: 0.5, + focus_mode: None, + rotation: Some("iso".into()), + under_trim: false, + hide_cursor: false, + click_impact, + }; + assert_eq!(zoom_state_at(&[region(true)], 5.0, None).click_impact, 1.0); + assert_eq!(zoom_state_at(&[region(false)], 5.0, None).click_impact, 0.0); + assert_eq!(zoom_state_at(&[region(true)], 0.0, None).click_impact, 0.0, "hors région"); + } + /// `ZoomState::tilt` : la force pour une région à préset, 0 sans préset. #[test] fn the_tilt_gate_follows_the_region_strength_only_with_a_preset() { @@ -1468,6 +1758,7 @@ mod tilt_tests { rotation: rotation.map(Into::into), under_trim: false, 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(None)], 5.0, None).tilt, 0.0); diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 3371aa8cd..0d226c0c8 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -353,6 +353,11 @@ pub struct SceneZoomRegion { /// Masque le curseur pendant cette région de zoom. #[serde(default)] pub hide_cursor: bool, + /// Chaque clic enfonce le plan incliné (`regions::click_impact`). Sans effet hors préset + /// 3D : c'est le préset qui installe le plan que le clic fait basculer. + /// `#[serde(default)]` : l'app omet la clé quand elle est fausse. + #[serde(default)] + pub click_impact: bool, } /// Une zone de vitesse portée par le temps source d'un clip. diff --git a/crates/compositor/tests/click_impact_render.rs b/crates/compositor/tests/click_impact_render.rs new file mode 100644 index 000000000..751e9b005 --- /dev/null +++ b/crates/compositor/tests/click_impact_render.rs @@ -0,0 +1,174 @@ +//! Sous un préset 3D avec « Click impact », un clic fait basculer le plan du côté cliqué puis le +//! laisse revenir (`regions::click_impact`). Rend de vraies frames par le compositeur D3D11. +//! +//! Même harnais que `tilt_parallax_render.rs`, mêmes variables d'environnement : une source +//! 1920×1080 de 6 s, de préférence quadrillée. La piste curseur (immobile, un clic) est écrite +//! par le test lui-même, au format du sidecar `.cursor.json`. +//! +//! ```powershell +//! ffmpeg -f lavfi -i "color=c=0x707070:s=1920x1080:r=30:d=6,drawgrid=w=96:h=96:t=3:c=white" -c:v h264_mf -b:v 6M -pix_fmt nv12 grid.mp4 +//! $env:OPENSCREEN_TILT_SOURCE = "...\grid.mp4" +//! cargo test -p openscreen-compositor --test click_impact_render -- --nocapture +//! ``` +//! +//! `OPENSCREEN_TILT_OUT` (facultatif) reçoit un PPM par instant, pour l'inspection à l'œil. + +// Windows seulement : le readback et le décodage D3D11VA de ce harnais n'existent que là. +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config; +use openscreen_compositor::cursor::CursorTrack; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::frame_geometry::live_params_from_scene; +use openscreen_compositor::live::Player; +use openscreen_compositor::scene::Scene; + +const W: u32 = 1920; +const H: u32 = 1080; +const CLICK_S: f64 = 2.0; + +/// Pointeur immobile près du bord droit, à mi-hauteur ; un clic à `CLICK_S`. +fn write_sidecar(path: &std::path::Path) { + let samples: Vec = (0..=180) + .map(|i| { + let ms = (i as f64 * 1000.0 / 30.0).round(); + let click = if (ms - CLICK_S * 1000.0).abs() < 1.0 { r#","interactionType":"click""# } else { "" }; + format!(r#"{{"timeMs":{ms},"cx":0.9,"cy":0.5{click}}}"#) + }) + .collect(); + std::fs::write(path, format!(r#"{{"samples":[{}]}}"#, samples.join(","))).expect("sidecar"); +} + +fn scene_json(source: &str, click_impact: bool) -> String { + let s = source.replace('\\', "/"); + let flag = if click_impact { r#","clickImpact":true"# } else { "" }; + format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":6,"webcamOffsetSec":0,"hasAudio":false}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.15,"y":0.15,"width":0.7,"height":0.7}}}}, + "effects": {{"padding":0.1,"blur":false,"shadow":0.5,"roundnessFrac":0.02,"motionBlur":0.0}}, + "background": {{"kind":"color","color":"#2060d0"}}, + "zoomRegions": [{{"id":"z","startSec":0,"endSec":6,"scale":1.15,"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":"iso"{flag}}}], + "annotations": [], + "speedRegions": [], + "cursor": {{"show":true,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":{W},"height":{H},"fps":null}} + }}"## + ) +} + +fn write_ppm(path: &std::path::Path, rgba: &[u8]) { + let mut out = format!("P6\n{W} {H}\n255\n").into_bytes(); + for px in rgba.chunks_exact(4) { + out.extend_from_slice(&px[..3]); + } + std::fs::write(path, out).expect("ecrire le ppm"); +} + +/// Un pixel du plan : tout ce qui n'est pas le fond bleu (ombre comprise, qui reste bleue). +fn is_plane(rgba: &[u8], x: u32, y: u32) -> bool { + let p = &rgba[((y * W + x) * 4) as usize..]; + !(p[2] as i32 > p[0] as i32 + 60) +} + +/// Pixels dont l'appartenance au plan diffère entre deux frames : la silhouette a bougé. Pas +/// d'égalité au pixel : deux frames de la source ne sont pas identiques après l'encodeur. +fn silhouette_diff(a: &[u8], b: &[u8]) -> usize { + (0..H) + .flat_map(|y| (0..W).map(move |x| (x, y))) + .filter(|&(x, y)| is_plane(a, x, y) != is_plane(b, x, y)) + .count() +} + +/// Hauteur du plan sur une colonne : le bord qui recule rétrécit. +fn plane_height_at(rgba: &[u8], x: u32) -> u32 { + (0..H).filter(|&y| is_plane(rgba, x, y)).count() as u32 +} + +/// Bord droit et bord gauche du plan sur la rangée du milieu. +fn left_and_right_edges(rgba: &[u8]) -> (u32, u32) { + let left = (0..W).find(|&x| is_plane(rgba, x, H / 2)).unwrap_or(0); + let right = (0..W).rev().find(|&x| is_plane(rgba, x, H / 2)).unwrap_or(0); + (left, right) +} + +#[test] +fn a_click_presses_the_clicked_side_of_the_tilted_plane() { + let Ok(source) = std::env::var("OPENSCREEN_TILT_SOURCE") else { + println!("SKIP: definir OPENSCREEN_TILT_SOURCE (voir l'en-tete du fichier)."); + return; + }; + let out_dir = std::env::var("OPENSCREEN_TILT_OUT").ok().map(std::path::PathBuf::from); + let sidecar_dir = out_dir.clone().unwrap_or_else(std::env::temp_dir); + std::fs::create_dir_all(&sidecar_dir).expect("creer le dossier de sortie"); + let sidecar = sidecar_dir.join(format!("click-impact-{}.cursor.json", std::process::id())); + write_sidecar(&sidecar); + let track = CursorTrack::load(sidecar.to_str().expect("chemin utf-8"), 0.0, 6.0).expect("piste"); + let _ = std::fs::remove_file(&sidecar); + + let gpu = Gpu::create(false).expect("device d3d11"); + 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"); + comp.set_cursor(track.smoothed(0.0)); + + let render = |click_impact: bool, t: f64| -> Vec { + let scene = Scene::from_json(&scene_json(&source, click_impact)).expect("scene valide"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + unsafe { + let mut player = Player::open(&source, "", &gpu).expect("ouvrir la source"); + player.present_frame(&comp, &cfg, t).expect("composer la frame"); + comp.readback_resized(W, H).expect("readback") + } + }; + + let instants = [ + ("rest-before", CLICK_S - 0.5), + ("contact", CLICK_S + 0.0495), + ("rebound", CLICK_S + 0.165), + ("rest-after", CLICK_S + 1.0), + ]; + 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)) + }; + let mut frames = Vec::new(); + for (name, t) in instants { + let rgba = render(true, t); + if let Some(dir) = &out_dir { + write_ppm(&dir.join(format!("click-{name}.ppm")), &rgba); + } + let (l, r, hr, hl) = probe(&rgba); + println!("{name:<12} t={t:<6} bords {l:>4}..{r:>4} hauteur droite {hr:>4} gauche {hl:>4}"); + frames.push((rgba, (l, r, hr, hl))); + } + let off = render(false, CLICK_S + 0.0495); + if let Some(dir) = &out_dir { + write_ppm(&dir.join("click-off-at-contact.ppm"), &off); + } + + let rest = frames[0].1; + let contact = frames[1].1; + let (unchanged, settled, pressed) = ( + silhouette_diff(&off, &frames[0].0), + silhouette_diff(&frames[3].0, &frames[0].0), + silhouette_diff(&frames[1].0, &frames[0].0), + ); + println!("silhouette : option éteinte {unchanged} px, repos/repos {settled} px, contact {pressed} px"); + // Option éteinte : le clic ne change rien. Au repos après l'impact : la pose du préset. + 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); + 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/src/components/ai-edition/v4/FloatingInspector.test.tsx b/src/components/ai-edition/v4/FloatingInspector.test.tsx index 39f644435..fdf8cd5d7 100644 --- a/src/components/ai-edition/v4/FloatingInspector.test.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.test.tsx @@ -27,6 +27,20 @@ vi.mock("../RightPanes", () => ({ VideoEffectsPane: () =>
VideoEffectsPane
, })); +const editorSettings = vi.hoisted(() => ({ cursorShow: true })); +vi.mock("@/lib/ai-edition/store/useEditorSettings", async (importOriginal) => { + const actual = await importOriginal(); + return { + useEditorSettings: () => { + const result = actual.useEditorSettings(); + return { + ...result, + settings: { ...result.settings, cursorShow: editorSettings.cursorShow }, + }; + }, + }; +}); + vi.mock("../CaptionsPane", () => ({ CaptionsPane: () =>
CaptionsPane
, })); @@ -86,4 +100,61 @@ describe("FloatingInspector", () => { fireEvent.click(closeBtn); expect(clearSelection).toHaveBeenCalledTimes(1); }); + + describe("click impact checkbox", () => { + const zoomTl = (region: Record) => { + const updateZoomClickImpact = vi.fn(); + const tl = { + ...defaultProps.tl, + selection: { kind: "zoom", id: "z" }, + zoomRegions: [ + { id: "z", startMs: 0, endMs: 1000, depth: 3, focus: { cx: 0.5, cy: 0.5 }, ...region }, + ], + updateZoomClickImpact, + } as unknown as React.ComponentProps["tl"]; + return { tl, updateZoomClickImpact }; + }; + + it("is off by default and disabled with its reason when there is no 3D preset", () => { + const { tl } = zoomTl({}); + render(); + const box = screen.getByRole("checkbox", { name: "settings.zoom.clickImpact.title" }); + expect(box).not.toBeChecked(); + expect(box).toBeDisabled(); + expect(screen.getByText("settings.zoom.clickImpact.needsRotation")).toBeInTheDocument(); + }); + + it("is disabled with its reason when the region hides the cursor", () => { + const { tl } = zoomTl({ rotationPreset: "iso", hideCursor: true }); + render(); + expect( + screen.getByRole("checkbox", { name: "settings.zoom.clickImpact.title" }), + ).toBeDisabled(); + expect(screen.getByText("settings.zoom.clickImpact.needsCursor")).toBeInTheDocument(); + }); + + it("is disabled with its reason when the cursor is hidden globally", () => { + editorSettings.cursorShow = false; + try { + const { tl } = zoomTl({ rotationPreset: "iso" }); + render(); + expect( + screen.getByRole("checkbox", { name: "settings.zoom.clickImpact.title" }), + ).toBeDisabled(); + expect(screen.getByText("settings.zoom.clickImpact.needsCursor")).toBeInTheDocument(); + } finally { + editorSettings.cursorShow = true; + } + }); + + it("toggles the region's clickImpact under a 3D preset", () => { + const { tl, updateZoomClickImpact } = zoomTl({ rotationPreset: "iso" }); + render(); + const box = screen.getByRole("checkbox", { name: "settings.zoom.clickImpact.title" }); + expect(box).toBeEnabled(); + expect(screen.getByText("settings.zoom.clickImpact.description")).toBeInTheDocument(); + fireEvent.click(box); + expect(updateZoomClickImpact).toHaveBeenCalledWith("z", true); + }); + }); }); diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx index 2a4a4904c..008d36ba5 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -303,6 +303,49 @@ function paneRow(label: string, control: React.ReactNode) { ); } +/** « Click impact » : une case à cocher, et dessous ce qu'elle fait — ou pourquoi elle ne peut + * rien faire ici. */ +function ClickImpactToggle({ + checked, + blocker, + label, + description, + onChange, +}: { + checked: boolean; + blocker: string | null; + label: string; + description: string; + onChange: (on: boolean) => void; +}) { + const disabled = blocker !== null; + return ( +
+ +

+ {blocker ?? description} +

+
+ ); +} + type AnnotationKind = AxcutAnnotationRegion["type"]; type ArrowDirectionKind = NonNullable["arrowDirection"]; @@ -559,6 +602,21 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void } , )} + void tl.updateZoomClickImpact(region.id, on)} + /> {paneRow( ts("zoom.focusMode.title"), // While the global toggle is on it OVERRIDES every region, so the control shows diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 1ba5192c7..9c610eda3 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -151,6 +151,20 @@ describe("projectPersistence media compatibility", () => { expect(editor.annotationRegions[1].blurData?.blockSize).toBe(4); }); + it("keeps clickImpact only when it is exactly true", () => { + 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: "off" }, + { ...zoom, id: "junk", clickImpact: "yes" as never }, + ], + }).zoomRegions; + expect(on.clickImpact).toBe(true); + expect("clickImpact" in off).toBe(false); + expect("clickImpact" in junk).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 627402844..ccbb31567 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -271,6 +271,7 @@ export function normalizeProjectEditor(editor: Partial): Pro focusMode: region.focusMode === "auto" ? "auto" : "manual", source: region.source === "auto" ? "auto" : "manual", ...(validPreset ? { rotationPreset: validPreset } : {}), + ...(region.clickImpact === true ? { clickImpact: true as const } : {}), }; }) : []; diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 9922231a2..2a908c5b1 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -111,6 +111,8 @@ export interface ZoomRegion { source?: ZoomRegionSource; /** When true, cursor is hidden during this zoom region. */ hideCursor?: boolean; + /** When true, each click presses the tilted plane (needs `rotationPreset`). Omitted when off. */ + clickImpact?: true; } export function getRotation3D(region: Pick): Rotation3D { diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 26c9dcd3d..d8c252b74 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -332,6 +332,12 @@ "none": "بلا", "title": "دوران ثلاثي الأبعاد" }, + "clickImpact": { + "title": "أثر النقر", + "description": "كل نقرة تضغط الشاشة المائلة: ينخفض الجانب المنقور ثم يعود.", + "needsRotation": "اختر دورانًا ثلاثي الأبعاد أولًا: لا يمكن ضغط إلا شاشة مائلة.", + "needsCursor": "المؤشر مخفي: لا تتفاعل الشاشة إلا مع النقرات المرئية." + }, "level": "مستوى التكبير", "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير", "customScale": "تكبير مخصص", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index 447186de1..e15a6e168 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -332,6 +332,12 @@ "none": "Žádné", "title": "3D rotace" }, + "clickImpact": { + "title": "Odezva kliknutí", + "description": "Každé kliknutí zatlačí nakloněnou obrazovku: kliknutá strana se prohne a vrátí zpět.", + "needsRotation": "Nejprve zvolte 3D rotaci: zatlačit lze jen nakloněnou obrazovku.", + "needsCursor": "Kurzor je skrytý: obrazovka reaguje jen na viditelná kliknutí." + }, "level": "Úroveň přiblížení", "previewHold": "Podržte pro náhled efektu přiblížení", "customScale": "Vlastní přiblížení", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 8f2021ed5..893a15f4a 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -332,6 +332,12 @@ "none": "None", "title": "3D Rotation" }, + "clickImpact": { + "title": "Click impact", + "description": "Each click presses the tilted screen: the clicked side dips, then springs back.", + "needsRotation": "Pick a 3D rotation first: only a tilted screen can be pressed.", + "needsCursor": "The cursor is hidden: the screen only reacts to clicks you can see." + }, "level": "Zoom Level", "previewHold": "Hold to preview zoom effect", "customScale": "Custom Zoom", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index b416a152e..796c4a003 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -332,6 +332,12 @@ "none": "Ninguna", "title": "Rotación 3D" }, + "clickImpact": { + "title": "Impacto del clic", + "description": "Cada clic presiona la pantalla inclinada: el lado pulsado se hunde y luego vuelve.", + "needsRotation": "Elige primero una rotación 3D: solo se puede presionar una pantalla inclinada.", + "needsCursor": "El cursor está oculto: la pantalla solo reacciona a los clics visibles." + }, "level": "Nivel de zoom", "previewHold": "Mantener para previsualizar el efecto de zoom", "customScale": "Zoom personalizado", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index cca7f87df..cb39bca7a 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -332,6 +332,12 @@ "none": "Aucune", "title": "Rotation 3D" }, + "clickImpact": { + "title": "Impact du clic", + "description": "Chaque clic enfonce l'écran incliné : le côté cliqué s'abaisse, puis revient.", + "needsRotation": "Choisissez d'abord une rotation 3D : seul un écran incliné peut être enfoncé.", + "needsCursor": "Le curseur est masqué : l'écran ne réagit qu'aux clics visibles." + }, "level": "Niveau de zoom", "previewHold": "Maintenir pour prévisualiser l'effet de zoom", "customScale": "Zoom personnalisé", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index e17ac4cd9..914a94589 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -332,6 +332,12 @@ "none": "Nessuna", "title": "Rotazione 3D" }, + "clickImpact": { + "title": "Impatto del clic", + "description": "Ogni clic preme lo schermo inclinato: il lato cliccato si abbassa, poi torna.", + "needsRotation": "Scegli prima una rotazione 3D: si può premere solo uno schermo inclinato.", + "needsCursor": "Il cursore è nascosto: lo schermo reagisce solo ai clic visibili." + }, "level": "Livello zoom", "previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom", "customScale": "Zoom personalizzato", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 87ebd99bd..fa92b1a10 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -332,6 +332,12 @@ "none": "なし", "title": "3D回転" }, + "clickImpact": { + "title": "クリックの押し込み", + "description": "クリックするたびに傾いた画面が押され、クリックした側が沈んでから戻ります。", + "needsRotation": "先に3D回転を選んでください。押し込めるのは傾いた画面だけです。", + "needsCursor": "カーソルが非表示です。画面は見えているクリックにだけ反応します。" + }, "level": "ズーム倍率", "previewHold": "押している間ズーム効果をプレビュー", "customScale": "カスタムズーム", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index fbab36004..7efc0f368 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -332,6 +332,12 @@ "none": "없음", "title": "3D 회전" }, + "clickImpact": { + "title": "클릭 눌림", + "description": "클릭할 때마다 기울어진 화면이 눌려 클릭한 쪽이 내려갔다가 돌아옵니다.", + "needsRotation": "먼저 3D 회전을 선택하세요. 기울어진 화면만 누를 수 있습니다.", + "needsCursor": "커서가 숨겨져 있습니다. 화면은 보이는 클릭에만 반응합니다." + }, "level": "줌 레벨", "previewHold": "누르고 있으면 줌 효과 미리보기", "customScale": "커스텀 줌", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 32daeea43..a2254e20e 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -332,6 +332,12 @@ "none": "Nenhuma", "title": "Rotação 3D" }, + "clickImpact": { + "title": "Impacto do clique", + "description": "Cada clique pressiona a tela inclinada: o lado clicado afunda e depois volta.", + "needsRotation": "Escolha primeiro uma rotação 3D: só uma tela inclinada pode ser pressionada.", + "needsCursor": "O cursor está oculto: a tela só reage a cliques visíveis." + }, "level": "Nível de Zoom", "previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom", "customScale": "Zoom Personalizado", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 35cc24dfa..3552e62b3 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -332,6 +332,12 @@ "none": "Нет", "title": "3D вращение" }, + "clickImpact": { + "title": "Отклик на клик", + "description": "Каждый клик вдавливает наклонённый экран: сторона клика прогибается и возвращается.", + "needsRotation": "Сначала выберите 3D-поворот: вдавить можно только наклонённый экран.", + "needsCursor": "Курсор скрыт: экран реагирует только на видимые клики." + }, "level": "Уровень масштабирования", "previewHold": "Удерживайте для предпросмотра эффекта зума", "customScale": "Пользовательский масштаб", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index b6bf0f521..9ce904456 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -332,6 +332,12 @@ "none": "Yok", "title": "3D Döndürme" }, + "clickImpact": { + "title": "Tıklama etkisi", + "description": "Her tıklama eğik ekranı bastırır: tıklanan taraf çöker, sonra geri döner.", + "needsRotation": "Önce bir 3D döndürme seçin: yalnızca eğik bir ekran bastırılabilir.", + "needsCursor": "İmleç gizli: ekran yalnızca görünen tıklamalara tepki verir." + }, "level": "Yakınlaştırma Seviyesi", "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun", "customScale": "Özel Yakınlaştırma", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 43be7d8fd..8a2d7138a 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -332,6 +332,12 @@ "none": "Không", "title": "Xoay 3D" }, + "clickImpact": { + "title": "Hiệu ứng nhấp", + "description": "Mỗi lần nhấp sẽ ấn màn hình nghiêng: phía được nhấp lún xuống rồi bật lại.", + "needsRotation": "Hãy chọn xoay 3D trước: chỉ màn hình nghiêng mới ấn được.", + "needsCursor": "Con trỏ đang ẩn: màn hình chỉ phản ứng với các lần nhấp nhìn thấy được." + }, "level": "Mức độ thu phóng", "previewHold": "Giữ để xem trước hiệu ứng phóng to", "customScale": "Thu phóng tùy chỉnh", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 2c17ad141..bb59240f5 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -332,6 +332,12 @@ "none": "无", "title": "3D 旋转" }, + "clickImpact": { + "title": "点击按压", + "description": "每次点击都会按压倾斜的画面:被点击的一侧下沉,然后弹回。", + "needsRotation": "请先选择 3D 旋转:只有倾斜的画面才能被按压。", + "needsCursor": "光标已隐藏:画面只对可见的点击做出反应。" + }, "level": "缩放级别", "previewHold": "按住预览放大效果", "customScale": "自定义缩放", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index d4a2c5c12..59326e857 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -332,6 +332,12 @@ "none": "無", "title": "3D 旋轉" }, + "clickImpact": { + "title": "點擊按壓", + "description": "每次點擊都會按壓傾斜的畫面:被點擊的一側下沉,然後彈回。", + "needsRotation": "請先選擇 3D 旋轉:只有傾斜的畫面才能被按壓。", + "needsCursor": "游標已隱藏:畫面只對看得見的點擊做出反應。" + }, "level": "縮放級別", "previewHold": "按住預覽放大效果", "customScale": "自訂縮放", diff --git a/src/lib/ai-edition/document/migrate.test.ts b/src/lib/ai-edition/document/migrate.test.ts index da2f74c2b..efbc01379 100644 --- a/src/lib/ai-edition/document/migrate.test.ts +++ b/src/lib/ai-edition/document/migrate.test.ts @@ -112,6 +112,7 @@ describe("migrateProjectDataToAxcutDocument", () => { customScale: 2.5, source: "manual", hideCursor: true, + clickImpact: true, }, ], }, @@ -127,6 +128,7 @@ describe("migrateProjectDataToAxcutDocument", () => { expect(z.customScale).toBe(2.5); expect(z.rotationPreset).toBe("iso"); expect(z.hideCursor).toBe(true); + expect(z.clickImpact).toBe(true); }); it("converts annotationRegions to seconds with type and content preserved", () => { @@ -272,7 +274,9 @@ describe("migrateAxcutDocumentToProjectData", () => { depth: 4, focus: { cx: 0.5, cy: 0.5 }, hideCursor: true, + clickImpact: true, }, + { id: "z_2", startMs: 3000, endMs: 4000, depth: 2, focus: { cx: 0.5, cy: 0.5 } }, ], annotationRegions: [ { @@ -303,6 +307,9 @@ describe("migrateAxcutDocumentToProjectData", () => { expect(back.editor.zoomRegions[0].startMs).toBe(0); expect(back.editor.zoomRegions[0].endMs).toBe(2000); expect(back.editor.zoomRegions[0].hideCursor).toBe(true); + expect(back.editor.zoomRegions[0].clickImpact).toBe(true); + expect("clickImpact" in back.editor.zoomRegions[1]).toBe(false); + expect("clickImpact" in doc.zoomRanges[1]).toBe(false); expect(back.editor.annotationRegions[0].startMs).toBe(1000); expect(back.editor.annotationRegions[0].endMs).toBe(3000); }); diff --git a/src/lib/ai-edition/document/migrate.ts b/src/lib/ai-edition/document/migrate.ts index 1f7dcc088..ba6d7fda5 100644 --- a/src/lib/ai-edition/document/migrate.ts +++ b/src/lib/ai-edition/document/migrate.ts @@ -194,6 +194,7 @@ export function migrateProjectDataToAxcutDocument( ...(typeof region.customScale === "number" ? { customScale: region.customScale } : {}), ...(region.source === "auto" || region.source === "manual" ? { source: region.source } : {}), ...(region.hideCursor ? { hideCursor: true } : {}), + ...(region.clickImpact === true ? { clickImpact: true as const } : {}), })); const migratedAnnotations: AxcutAnnotationRegion[] = annotationRegions @@ -330,6 +331,7 @@ export function migrateAxcutDocumentToProjectData(input: AxcutDocument): EditorP ...(typeof region.customScale === "number" ? { customScale: region.customScale } : {}), ...(region.source ? { source: region.source } : {}), ...(region.hideCursor ? { hideCursor: true } : {}), + ...(region.clickImpact ? { clickImpact: true as const } : {}), })); editor.zoomRegions = reverseZoomRegions; diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts index 5f6afb7b2..f1b5cfbde 100644 --- a/src/lib/ai-edition/schema/index.ts +++ b/src/lib/ai-edition/schema/index.ts @@ -492,6 +492,9 @@ export const zoomRegionSchema = endGteStart( customScale: z.number().positive().optional(), source: z.enum(["auto", "manual"]).optional(), hideCursor: z.boolean().optional(), + /** Each click presses the tilted plane toward the clicked side. Only meaningful with a + * `rotationPreset`; omitted (never `false`) when off. */ + clickImpact: z.literal(true).optional(), }), "endMs", "startMs", diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts index eaf367edd..0d054eb83 100644 --- a/src/lib/ai-edition/store/documentWriteAudit.test.ts +++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts @@ -293,6 +293,7 @@ const DECLARED: WritePath[] = [ w("src/lib/ai-edition/store/useTimeline.ts", "updateSpeedSpan", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateSpeedValue", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateTrim", "save", "gesture"), + w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomClickImpact", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomDepth", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomFocusLive", "set", "automatic"), w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomFocusMode", "save", "gesture"), diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 080cd83e6..430c2693b 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -812,6 +812,19 @@ describe("useTimeline zoom modifiers (rotation + focus mode)", () => { expect(useProjectStore.getState().document?.zoomRanges[0].hideCursor).toBeUndefined(); }); + it("updates clickImpact on a zoom region and drops the key when off", async () => { + const { result } = renderTimeline(); + await act(async () => { + await result.current.updateZoomClickImpact("zoom_a", true); + }); + expect(useProjectStore.getState().document?.zoomRanges[0].clickImpact).toBe(true); + + await act(async () => { + await result.current.updateZoomClickImpact("zoom_a", false); + }); + expect(useProjectStore.getState().document?.zoomRanges[0].clickImpact).toBeUndefined(); + }); + it("rolls a live focus edit back when its commit cannot be saved", async () => { bridgeMocks.save.mockResolvedValueOnce({ success: false, error: "project file locked" }); const { result } = renderTimeline(); diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index e262d81db..ef927e618 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -773,6 +773,22 @@ export function useTimeline() { [document, saveDocument], ); + // Per-region, like the preset it animates. `undefined` rather than `false` so the document + // keeps omitting the key when the option is off. + const updateZoomClickImpact = useCallback( + async (id: string, clickImpact: boolean) => { + if (!document) return; + const next: AxcutDocument = { + ...document, + zoomRanges: patchPillById(document.zoomRanges, id, { + clickImpact: clickImpact ? true : undefined, + }) as AxcutDocument["zoomRanges"], + }; + await saveDocument(next, { history: true }); + }, + [document, saveDocument], + ); + const updateAnnotationSpan = useCallback( async (id: string, startMs: number, endMs: number) => { if (!document) return; @@ -1522,6 +1538,7 @@ export function useTimeline() { updateZoomRotation, updateZoomFocusMode, updateZoomHideCursor, + updateZoomClickImpact, updateAnnotationSpan, updateAnnotationLive, commitAnnotationChange, diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index a581b5439..26bc6e986 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -529,6 +529,26 @@ describe("buildSceneDescription.zoomRegions", () => { expect(zoomRegions[0].hideCursor).toBe(true); }); + it("emits clickImpact only when it is on", () => { + const base = { + startMs: 0, + endMs: 1000, + depth: 3 as const, + focus: { cx: 0.5, cy: 0.5 }, + rotationPreset: "iso" as const, + }; + const doc = makeDoc({ + zoomRanges: [ + makeZoom({ ...base, id: "on", clickImpact: true }), + makeZoom({ ...base, id: "off" }), + ], + }); + const { zoomRegions } = buildSceneDescription(doc); + expect(zoomRegions[0].clickImpact).toBe(true); + // Omitted, not `false`: scene payloads without the option stay byte-identical. + expect("clickImpact" in zoomRegions[1]).toBe(false); + }); + it("converts ms→sec for start/end", () => { const z = makeZoom({ id: "z", diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index 4fdbeae76..945d93e20 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -92,6 +92,9 @@ export interface SceneZoomRegion { underTrim?: boolean; /** When true, cursor is hidden during this zoom region. */ hideCursor?: boolean; + /** Each click presses the tilted plane toward the clicked side (`regions::click_impact`). + * Native ignores it without a `rotation`. Omitted (not `false`) when off. */ + clickImpact?: true; } /** A "Full Camera" timeline region (from `legacyEditor.cameraFullscreenRegions`). Times in seconds. */ @@ -1071,6 +1074,7 @@ export function buildSceneDescription( clipIndex: region.clipIndex, ...(region.underTrim ? { underTrim: true } : {}), ...(region.hideCursor ? { hideCursor: true } : {}), + ...(region.clickImpact ? { clickImpact: true as const } : {}), })), annotations: projectedAnnotations .map((region) => { From 38f05f2ee9fa1ef3ceea2ba85800b857d58e5e0c Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 17 Sep 2026 19:53:35 +0200 Subject: [PATCH 05/11] feat(cursor): extrude the cursor along the screen normal Superseded later in this series by the modelled 3D cursor. Formerly #686. --- crates/compositor/src/compositor_linux.rs | 78 +-- crates/compositor/src/compositor_macos.rs | 98 ++- crates/compositor/src/compositor_windows.rs | 85 +-- crates/compositor/src/frame_geometry.rs | 569 +++++++++++++++++- crates/compositor/src/live.rs | 5 + crates/compositor/src/scene.rs | 4 + crates/compositor/src/shaders.hlsl | 37 +- crates/compositor/src/shaders.metal | 29 + crates/compositor/src/vk_shaders/layer.wgsl | 35 +- crates/compositor/tests/compose_linux.rs | 70 +++ .../compositor/tests/cursor_volume_render.rs | 158 +++++ .../ai-edition/style-preset-service.test.ts | 9 +- electron/ipc/nativeBridge.presets.test.ts | 9 +- src/components/ai-edition/RightPanes.tsx | 23 +- src/components/video-editor/editorDefaults.ts | 2 + src/components/video-editor/types.ts | 4 + src/i18n/locales/ar/settings.json | 2 + src/i18n/locales/cs/settings.json | 2 + src/i18n/locales/en/settings.json | 2 + src/i18n/locales/es/settings.json | 2 + src/i18n/locales/fr/settings.json | 2 + src/i18n/locales/it/settings.json | 2 + src/i18n/locales/ja-JP/settings.json | 2 + src/i18n/locales/ko-KR/settings.json | 2 + src/i18n/locales/pt-BR/settings.json | 2 + src/i18n/locales/ru/settings.json | 2 + src/i18n/locales/tr/settings.json | 2 + src/i18n/locales/vi/settings.json | 2 + src/i18n/locales/zh-CN/settings.json | 2 + src/i18n/locales/zh-TW/settings.json | 2 + .../ai-edition/store/editorSettings.test.ts | 4 + src/lib/ai-edition/store/editorSettings.ts | 5 + src/lib/ai-edition/stylePresets.test.ts | 28 +- src/lib/ai-edition/stylePresets.ts | 11 +- src/lib/ai-edition/stylePresetsEditor.test.ts | 9 +- src/lib/ai-edition/stylePresetsEditor.ts | 1 + src/lib/projectDefaults.ts | 2 + src/native/browserShim.presets.test.ts | 9 +- src/native/nativeCompositorStore.test.ts | 4 +- src/native/nativeCompositorStore.ts | 1 + src/native/sceneDescription.test.ts | 8 + src/native/sceneDescription.ts | 3 + 42 files changed, 1150 insertions(+), 178 deletions(-) create mode 100644 crates/compositor/tests/cursor_volume_render.rs diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 23749cb33..6a6ae128a 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -43,7 +43,7 @@ pub use crate::frame_geometry::{ live_params_from_scene, webcam_shape_code, FIXTURE_FRAMES, LayerCB, LiveParams, OUT_H, OUT_W, }; use crate::frame_geometry::{ - cursor_sprite_dst, parse_hex, plan_cursor, plan_frame, CursorPlacement, CursorPlanInput, + cursor_sprite_cb, parse_hex, plan_cursor, plan_frame, CursorPlacement, CursorPlanInput, FrameGeometryInput, }; use crate::scene::{Scene, SceneBackground}; @@ -2558,6 +2558,9 @@ impl Compositor { _tex: wgpu::Texture, _view: wgpu::TextureView, binds: Vec, + /// Ombre de contact du volume (mode 12), dessinee une fois SOUS le + /// curseur et sa trainee. `None` sans volume. + shadow: Option<(wgpu::Buffer, wgpu::BindGroup)>, } let cursor_draw: Option = (|| { let track = cursor_ref.as_ref()?; @@ -2627,62 +2630,27 @@ impl Compositor { let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); let (mut bufs, mut binds) = (Vec::new(), Vec::new()); for placement in placements { - let cb = match placement { - CursorPlacement::Upright { center } => LayerCB { - dst: cursor_sprite_dst(center, pw / rw, ph / rh, hotspot), - src: [0.0, 0.0, 1.0, 1.0], - mode: 7.0, - color: [1.0, 1.0, 1.0, plan.alpha], - fx: plan.clip, - ..Default::default() - }, - CursorPlacement::Tilted { plane_pt, quad, center_px, screen_px, .. } => { - // Le sprite est pose DANS le plan : sa taille devient une fraction - // du plan et ses quatre coins traversent la meme projection que la - // video. La reduction due au tilt vient donc de la projection -- - // rien a multiplier a la main. - let (wf, hf) = (pw / screen_px[0], ph / screen_px[1]); - let x0 = plane_pt[0] - hotspot[0] * wf; - let y0 = plane_pt[1] - hotspot[1] * hf; - let corners = [(x0, y0), (x0 + wf, y0), (x0 + wf, y0 + hf), (x0, y0 + hf)] - .map(|(fx, fy)| { - let (px, py) = quad.point_px(fx, fy); - (center_px[0] + px, center_px[1] + py) - }); - let (min_x, max_x) = corners - .iter() - .fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); - let (min_y, max_y) = corners - .iter() - .fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); - // Le quad projete d'un sprite peut etre tres fin de biais : une bbox - // d'un pixel de large ferait diverger le warp inverse, d'ou le - // plancher a 1 px. - let (bw, bh) = ((max_x - min_x).max(1.0), (max_y - min_y).max(1.0)); - let local = |(x, y): (f32, f32)| [x - min_x, y - min_y]; - let [tl0, tl1] = local(corners[0]); - let [tr0, tr1] = local(corners[1]); - let [br0, br1] = local(corners[2]); - let [bl0, bl1] = local(corners[3]); - LayerCB { - dst: [min_x / rw, min_y / rh, bw / rw, bh / rh], - quad_px: [bw, bh], - mode: 13.0, - color: [1.0, 1.0, 1.0, plan.alpha], - fx: [tl0, tl1, tr0, tr1], - src_prev: [br0, br1, bl0, bl1], - // Le clip vit ici et NON dans `fx` (mode 7) : `fx` porte les coins. - dst_prev: plan.clip, - ..Default::default() - } - } - }; + // Geometrie partagee avec Windows et macOS (`cursor_sprite_cb`) : + // mode 7 droit, ou mode 13 pose sur le plan -- le clip vit alors + // dans `dst_prev`, et le volume dans `mb`. + let cb = cursor_sprite_cb( + placement, + [pw, ph], + hotspot, + plan.alpha, + plan.clip, + [rw, rh], + plan.volume.as_ref(), + ); // Sprite RGBA au binding 1 (texY) que le mode 7 echantillonne. let (buf, bind) = self.make_bind(&cb, Some((&view, &view, &view)), &dummy); bufs.push(buf); binds.push(bind); } - Some(CursorDraw { _bufs: bufs, _tex: tex, _view: view, binds }) + // Le sprite existe (sinon on serait deja sorti) : l'ombre de contact + // peut suivre. Pas de sprite, pas de volume -- comme sur macOS. + let shadow = plan.volume.as_ref().map(|v| self.make_bind(&v.shadow, None, &dummy)); + Some(CursorDraw { _bufs: bufs, _tex: tex, _view: view, binds, shadow }) })(); // Bind group de la passe de composition d'`accum` (layout du blur : // uniform + texture + sampler). Construit hors de la pass, comme les @@ -2832,6 +2800,12 @@ impl Compositor { rpass.set_bind_group(0, &a.bind, &[]); rpass.draw(0..4, 0..1); } + // Ombre de contact du volume : sur la scene, sous le curseur ET sous + // sa trainee (composee apres cette pass). Parite Windows/macOS. + if let Some((_buf, bind)) = cursor_draw.as_ref().and_then(|c| c.shadow.as_ref()) { + rpass.set_bind_group(0, bind, &[]); + rpass.draw(0..4, 0..1); + } // Curseur en dernier : au-dessus de l'ecran et des annotations. // Une seule copie = curseur net, il tient dans cette pass. La // trainee, elle, a besoin de sa propre cible (voir plus bas). diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 6cf64dcf3..2ec061a4c 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -1827,8 +1827,10 @@ impl Compositor { Ok(enc) } - /// Sprite de curseur (mode 7). Rend `Err` quand l'art n'est pas chargeable, pour que - /// l'appelant retombe sur le curseur dessiné. + /// Sprite de curseur (mode 7, ou 13 posé sur le plan). Rend `Err` quand l'art n'est pas + /// chargeable, pour que l'appelant retombe sur le curseur dessiné. La géométrie vient de + /// `frame_geometry::cursor_sprite_cb`, partagée avec Windows et Linux. + #[allow(clippy::too_many_arguments)] unsafe fn draw_cursor_sprite( &self, enc: &metal::RenderCommandEncoderRef, @@ -1837,61 +1839,21 @@ impl Compositor { a: f32, sprite: &crate::scene::SceneCursorSprite, clip: [f32; 4], + volume: Option<&crate::frame_geometry::CursorVolume>, ) -> Result<()> { let (tex, iw, ih) = self.cached_image(sprite.path.as_str())?; let (rw, rh) = (self.render_w as f32, self.render_h as f32); let ar = iw as f32 / ih.max(1) as f32; let (pw, ph) = if ar >= 1.0 { (size_px, size_px / ar) } else { (size_px * ar, size_px) }; - let hotspot = [sprite.hotspot_x, sprite.hotspot_y]; - let cb = match placement { - crate::frame_geometry::CursorPlacement::Upright { center } => LayerCB { - dst: crate::frame_geometry::cursor_sprite_dst(center, pw / rw, ph / rh, hotspot), - src: [0.0, 0.0, 1.0, 1.0], - mode: 7.0, - color: [1.0, 1.0, 1.0, a], - fx: clip, - ..Default::default() - }, - crate::frame_geometry::CursorPlacement::Tilted { - plane_pt, quad, center_px, screen_px, .. - } => { - // Le sprite est posé DANS le plan : sa taille devient une fraction du plan et - // ses quatre coins traversent la même projection que la vidéo. La réduction - // due au tilt vient donc de la projection — rien à multiplier à la main. - let (wf, hf) = (pw / screen_px[0], ph / screen_px[1]); - let x0 = plane_pt[0] - hotspot[0] * wf; - let y0 = plane_pt[1] - hotspot[1] * hf; - let corners = [(x0, y0), (x0 + wf, y0), (x0 + wf, y0 + hf), (x0, y0 + hf)] - .map(|(fx, fy)| { - let (px, py) = quad.point_px(fx, fy); - (center_px[0] + px, center_px[1] + py) - }); - let (min_x, max_x) = corners - .iter() - .fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); - let (min_y, max_y) = corners - .iter() - .fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); - // Le quad projeté d'un sprite peut être très fin de biais : une bbox d'un pixel - // de large ferait diverger le warp inverse, donc plancher à 1 px. - let (bw, bh) = ((max_x - min_x).max(1.0), (max_y - min_y).max(1.0)); - let local = |(x, y): (f32, f32)| [x - min_x, y - min_y]; - let [tl0, tl1] = local(corners[0]); - let [tr0, tr1] = local(corners[1]); - let [br0, br1] = local(corners[2]); - let [bl0, bl1] = local(corners[3]); - LayerCB { - dst: [min_x / rw, min_y / rh, bw / rw, bh / rh], - quad_px: [bw, bh], - mode: 13.0, - color: [1.0, 1.0, 1.0, a], - fx: [tl0, tl1, tr0, tr1], - src_prev: [br0, br1, bl0, bl1], - dst_prev: clip, - ..Default::default() - } - } - }; + let cb = crate::frame_geometry::cursor_sprite_cb( + placement, + [pw, ph], + [sprite.hotspot_x, sprite.hotspot_y], + a, + clip, + [rw, rh], + volume, + ); enc.set_fragment_texture(2, Some(&tex)); self.draw_solid(enc, &cb); Ok(()) @@ -1902,7 +1864,9 @@ impl Compositor { /// Le repli « dot + ring » mathématique (mode 4) du chemin Windows n'est pas porté : /// l'app résout toujours un jeu de sprites, et l'art intégré couvre les états qu'un /// thème ne fournit pas. S'il n'y a vraiment aucun sprite, ne rien dessiner est plus - /// honnête qu'un curseur qui ne ressemble à aucun réglage. + /// honnête qu'un curseur qui ne ressemble à aucun réglage. Le volume disparaît avec lui : + /// sans sprite, pas de silhouette à extruder ni d'ombre de contact. + #[allow(clippy::too_many_arguments)] unsafe fn draw_cur_themed( &self, enc: &metal::RenderCommandEncoderRef, @@ -1912,10 +1876,13 @@ impl Compositor { size_px: f32, a: f32, clip: [f32; 4], + volume: Option<&crate::frame_geometry::CursorVolume>, ) { let sprite = cursor_type.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow")); if let Some(sprite) = sprite { - if let Err(e) = self.draw_cursor_sprite(enc, placement, size_px, a, sprite, clip) { + if let Err(e) = + self.draw_cursor_sprite(enc, placement, size_px, a, sprite, clip, volume) + { eprintln!("[compositor] sprite curseur \"{}\" : {e:#}", sprite.path); } } @@ -2144,9 +2111,29 @@ impl Compositor { .map(|s| s.cursor.cursor_sprites.clone()) .unwrap_or_default(); let kind = plan.cursor_type.as_deref(); + let volume = plan.volume.as_ref(); + // Ombre de contact du volume : une fois, sur la scène, SOUS le curseur et sa + // traînée — et seulement si le sprite existe. Parité `compositor_windows.rs`. + if let Some(v) = volume { + let sprite = kind.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow")); + if sprite.is_some_and(|s| self.cached_image(s.path.as_str()).is_ok()) { + let e = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; + self.draw_solid(e, &v.shadow); + e.end_encoding(); + } + } if plan.taps <= 1 { let e = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; - self.draw_cur_themed(e, &sprites, kind, plan.placement, plan.size_px, plan.alpha, plan.clip); + self.draw_cur_themed( + e, + &sprites, + kind, + plan.placement, + plan.size_px, + plan.alpha, + plan.clip, + volume, + ); e.end_encoding(); } else { // Flou RÉEL, pas des copies discrètes : les N échantillons s'accumulent dans @@ -2171,6 +2158,7 @@ impl Compositor { plan.size_px, plan.alpha, plan.clip, + volume, ); } e.end_encoding(); diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 7a309f1f6..99a64b8c6 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -9,7 +9,7 @@ use crate::config::Cfg; pub use crate::frame_geometry::{live_params_from_scene, webcam_shape_code, LayerCB, LiveParams, FIXTURE_FRAMES, HALF_H, HALF_W, OUT_H, OUT_W}; use crate::frame_geometry::{ - cover_crop_uv, cover_uv_rect, cursor_sprite_dst, decode_data_uri, ease_in_out_cubic, lerp, + cover_crop_uv, cover_uv_rect, decode_data_uri, ease_in_out_cubic, lerp, lerp4, parse_hex, preset_placements, remap_box, screen_source_rect, timeline, CursorPlacement, FrameParams, Placement, CURSOR_BASE_SIZE_FRAC, FPS, SCREEN_SHADOW_OFFSET_FRAC, SCREEN_SHADOW_SPREAD_FRAC, SHADOW_TUNING_REF_PX, WEBCAM_SHADOW_OFFSET_FRAC, @@ -1416,7 +1416,8 @@ impl Compositor { /// Curseur thème (sprite PNG, ex. arrow.png) dont le PIVOT `hotspot` (fraction 0..1 de /// l'image) tombe sur `center`, à la taille de référence `size_px`. `Err` → l'appelant - /// retombe sur `draw_cursor` (math dot+ring). + /// retombe sur `draw_cursor` (math dot+ring). La géométrie vient de + /// `frame_geometry::cursor_sprite_cb`, partagée avec macOS et Linux. unsafe fn draw_cursor_sprite( &self, placement: CursorPlacement, @@ -1424,62 +1425,21 @@ impl Compositor { a: f32, sprite: &SceneCursorSprite, clip: [f32; 4], + volume: Option<&crate::frame_geometry::CursorVolume>, ) -> Result<()> { let path = sprite.path.as_str(); let (srv, iw, ih) = self.cached_image(path)?; let ar = iw as f32 / ih as f32; let (pw, ph) = if ar >= 1.0 { (size_px, size_px / ar) } else { (size_px * ar, size_px) }; - let hotspot = [sprite.hotspot_x, sprite.hotspot_y]; - - let cb = match placement { - CursorPlacement::Upright { center } => LayerCB { - dst: cursor_sprite_dst(center, pw / self.rw(), ph / self.rh(), hotspot), - src: [0.0, 0.0, 1.0, 1.0], - mode: 7.0, - color: [1.0, 1.0, 1.0, a], - fx: clip, - ..Default::default() - }, - CursorPlacement::Tilted { plane_pt, quad, center_px, screen_px, .. } => { - // Le sprite est posé DANS le plan : sa taille devient une fraction du plan - // (l'unité de `size_px` est le rect d'écran non incliné), et ses 4 coins - // traversent la même projection que la vidéo. La réduction due au tilt vient - // donc de la projection elle-même — rien à multiplier à la main. - let (wf, hf) = (pw / screen_px[0], ph / screen_px[1]); - let x0 = plane_pt[0] - hotspot[0] * wf; - let y0 = plane_pt[1] - hotspot[1] * hf; - let corners = [(x0, y0), (x0 + wf, y0), (x0 + wf, y0 + hf), (x0, y0 + hf)] - .map(|(fx, fy)| { - let (px, py) = quad.point_px(fx, fy); - (center_px[0] + px, center_px[1] + py) - }); - let (min_x, max_x) = corners - .iter() - .fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); - let (min_y, max_y) = corners - .iter() - .fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); - // Le quad projeté d'un sprite peut être très fin de biais : une bbox d'un pixel - // de large ferait diverger le warp inverse, donc plancher à 1 px. - let (bw, bh) = ((max_x - min_x).max(1.0), (max_y - min_y).max(1.0)); - let local = |(x, y): (f32, f32)| [x - min_x, y - min_y]; - let [tl0, tl1] = local(corners[0]); - let [tr0, tr1] = local(corners[1]); - let [br0, br1] = local(corners[2]); - let [bl0, bl1] = local(corners[3]); - LayerCB { - dst: [min_x / self.rw(), min_y / self.rh(), bw / self.rw(), bh / self.rh()], - quad_px: [bw, bh], - mode: 13.0, - color: [1.0, 1.0, 1.0, a], - fx: [tl0, tl1, tr0, tr1], - src_prev: [br0, br1, bl0, bl1], - dst_prev: clip, - ..Default::default() - } - } - }; - + let cb = crate::frame_geometry::cursor_sprite_cb( + placement, + [pw, ph], + [sprite.hotspot_x, sprite.hotspot_y], + a, + clip, + [self.rw(), self.rh()], + volume, + ); self.upload_cb(&cb); self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); self.ctx.Draw(4, 0); @@ -1492,6 +1452,7 @@ impl Compositor { /// Le repli sur la flèche compte : un thème n'apporte que sa flèche et son pointeur, les /// autres états venant de l'art intégrée — mais si un état inconnu apparaît, mieux vaut /// une flèche qu'un point dans un cercle. + #[allow(clippy::too_many_arguments)] unsafe fn draw_cur_themed( &self, sprites: &HashMap, @@ -1500,16 +1461,17 @@ impl Compositor { size_px: f32, a: f32, clip: [f32; 4], + volume: Option<&crate::frame_geometry::CursorVolume>, ) { let sprite = cursor_type.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow")); if let Some(sprite) = sprite { - if self.draw_cursor_sprite(placement, size_px, a, sprite, clip).is_ok() { + if self.draw_cursor_sprite(placement, size_px, a, sprite, clip, volume).is_ok() { return; } } // Le repli math reste droit même sur un plan incliné : il ne devrait plus apparaître // maintenant que l'art par défaut existe, et lui donner sa propre passe de warp pour - // un cas de secours ne se justifie pas. + // un cas de secours ne se justifie pas. Il n'a donc pas de volume non plus. self.draw_cursor(placement.upright_center(), size_px, a, clip); } @@ -1892,6 +1854,17 @@ impl Compositor { .map(|s| s.cursor.cursor_sprites.clone()) .unwrap_or_default(); let cursor_type = plan.cursor_type.as_deref(); + let volume = plan.volume.as_ref(); + // Ombre de contact du volume : une fois, sur la scène, SOUS le curseur et sa + // traînée — et seulement si le sprite existe (le repli math n'a pas de volume). + if let Some(v) = volume { + let sprite = cursor_type + .and_then(|t| cursor_sprites.get(t)) + .or_else(|| cursor_sprites.get("arrow")); + if sprite.is_some_and(|s| self.cached_image(&s.path).is_ok()) { + self.draw_solid(&v.shadow); + } + } if plan.taps <= 1 { self.draw_cur_themed( &cursor_sprites, @@ -1900,6 +1873,7 @@ impl Compositor { plan.size_px, plan.alpha, plan.clip, + volume, ); } else { // Flou RÉEL, pas des copies discrètes : accumule les N échantillons dans un @@ -1917,6 +1891,7 @@ impl Compositor { plan.size_px, plan.alpha, plan.clip, + volume, ); } // composite le buffer accumulé sur la scène (blend "over" normal, prémultiplié). diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index febf6cc3a..70cb33dbf 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -400,16 +400,35 @@ impl CursorPlacement { /// Interpolation entre deux placements, pour les copies de la traînée de flou. Sur un plan /// incliné on interpole DANS le plan : la traînée suit alors la surface au lieu de couper /// droit à travers la perspective. + /// + /// Le plan lui-même est interpolé aussi. Sous un vrai tilt les deux bornes partagent le même + /// quad (`a + (a - a) * f` rend `a` au bit près, rien ne bouge) ; mais un curseur en volume + /// sur écran droit porte un quad identité taillé dans `s_dst` d'un côté et `s_dst_prev` de + /// l'autre, et garder celui de la queue décalerait la tête quand le zoom bouge. pub(crate) fn lerp(self, other: CursorPlacement, f: f32) -> CursorPlacement { match (self, other) { ( CursorPlacement::Tilted { plane_pt: a, quad, center_px, screen_px, render_px }, - CursorPlacement::Tilted { plane_pt: b, .. }, + CursorPlacement::Tilted { + plane_pt: b, + quad: quad_b, + center_px: center_b, + screen_px: screen_b, + .. + }, ) => CursorPlacement::Tilted { - plane_pt: [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f], - quad, - center_px, - screen_px, + plane_pt: [lerp(a[0], b[0], f), lerp(a[1], b[1], f)], + quad: crate::regions::TiltedQuad { + corners: std::array::from_fn(|i| { + ( + lerp(quad.corners[i].0, quad_b.corners[i].0, f), + lerp(quad.corners[i].1, quad_b.corners[i].1, f), + ) + }), + scale: lerp(quad.scale, quad_b.scale, f), + }, + center_px: [lerp(center_px[0], center_b[0], f), lerp(center_px[1], center_b[1], f)], + screen_px: [lerp(screen_px[0], screen_b[0], f), lerp(screen_px[1], screen_b[1], f)], render_px, }, (a, b) => { @@ -433,6 +452,149 @@ impl CursorPlacement { } } } + +/// `LayerCB` du sprite de curseur, pour les trois backends : mode 7 sur écran droit, mode 13 +/// posé sur le plan (incliné, ou identité quand le curseur a du volume). +/// +/// `sprite_px` = taille du sprite en px de sortie (ratio de l'image déjà appliqué). `volume` +/// ajoute l'extrusion : la bbox s'étend jusqu'aux faces arrière et `mb` porte `e` et le nombre +/// de copies. Sans volume, `mb` reste à zéro et le shader prend le chemin plat d'avant. +pub fn cursor_sprite_cb( + placement: CursorPlacement, + sprite_px: [f32; 2], + hotspot: [f32; 2], + alpha: f32, + clip: [f32; 4], + render_px: [f32; 2], + volume: Option<&CursorVolume>, +) -> LayerCB { + let [pw, ph] = sprite_px; + let [rw, rh] = render_px; + match placement { + // Pas de volume ici : `plan_cursor` rend un placement `Tilted` dès qu'il y en a. + CursorPlacement::Upright { center } => LayerCB { + dst: cursor_sprite_dst(center, pw / rw, ph / rh, hotspot), + src: [0.0, 0.0, 1.0, 1.0], + mode: 7.0, + color: [1.0, 1.0, 1.0, alpha], + fx: clip, + ..Default::default() + }, + CursorPlacement::Tilted { plane_pt, quad, center_px, screen_px, .. } => { + // Le sprite est posé DANS le plan : sa taille devient une fraction du plan + // (l'unité de `size_px` est le rect d'écran non incliné), et ses 4 coins + // traversent la même projection que la vidéo. La réduction due au tilt vient + // donc de la projection elle-même — rien à multiplier à la main. + let (wf, hf) = (pw / screen_px[0], ph / screen_px[1]); + let x0 = plane_pt[0] - hotspot[0] * wf; + let y0 = plane_pt[1] - hotspot[1] * hf; + let corners = + [(x0, y0), (x0 + wf, y0), (x0 + wf, y0 + hf), (x0, y0 + hf)].map(|(fx, fy)| { + let (px, py) = quad.point_px(fx, fy); + (center_px[0] + px, center_px[1] + py) + }); + let (mut min_x, mut max_x) = corners + .iter() + .fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (mut min_y, mut max_y) = corners + .iter() + .fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + // Les faces arrière sont la même silhouette décalée de `e` : la bbox doit les + // contenir aussi, sinon l'extrusion se coupe net au bord du sprite. + let mb = match volume { + Some(v) => { + let [ex, ey] = v.extrude_px; + (min_x, max_x) = (min_x.min(min_x + ex), max_x.max(max_x + ex)); + (min_y, max_y) = (min_y.min(min_y + ey), max_y.max(max_y + ey)); + [ex, ey, v.taps as f32, 0.0] + } + None => [0.0; 4], + }; + // Le quad projeté d'un sprite peut être très fin de biais : une bbox d'un pixel + // de large ferait diverger le warp inverse, donc plancher à 1 px. + let (bw, bh) = ((max_x - min_x).max(1.0), (max_y - min_y).max(1.0)); + let local = |(x, y): (f32, f32)| [x - min_x, y - min_y]; + let [tl0, tl1] = local(corners[0]); + let [tr0, tr1] = local(corners[1]); + let [br0, br1] = local(corners[2]); + let [bl0, bl1] = local(corners[3]); + LayerCB { + dst: [min_x / rw, min_y / rh, bw / rw, bh / rh], + quad_px: [bw, bh], + mode: 13.0, + color: [1.0, 1.0, 1.0, alpha], + fx: [tl0, tl1, tr0, tr1], + src_prev: [br0, br1, bl0, bl1], + // Le clip vit ici et NON dans `fx` (mode 7) : `fx` porte les coins. + dst_prev: clip, + mb, + ..Default::default() + } + } + } +} + +/// `LayerCB` d'une ombre portée par un quadrilatère (mode 12). `corners` (TL, TR, BR, BL) en px +/// de sortie ABSOLUS. Même boîte que `draw_quad_shadow` des backends : le quad élargi du +/// `spread`, pour que la pénombre ne se coupe pas net. +pub(crate) fn quad_shadow_cb( + corners: &[(f32, f32); 4], + radius: f32, + spread: f32, + opacity: f32, + render_px: [f32; 2], +) -> LayerCB { + let (min_x, max_x) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (min_y, max_y) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + let box_w = (max_x - min_x) + 2.0 * spread; + let box_h = (max_y - min_y) + 2.0 * spread; + let local = |(x, y): (f32, f32)| -> [f32; 2] { [x - min_x + spread, y - min_y + spread] }; + let [tl0, tl1] = local(corners[0]); + let [tr0, tr1] = local(corners[1]); + let [br0, br1] = local(corners[2]); + let [bl0, bl1] = local(corners[3]); + LayerCB { + dst: [ + (min_x - spread) / render_px[0], + (min_y - spread) / render_px[1], + box_w / render_px[0], + box_h / render_px[1], + ], + quad_px: [box_w, box_h], + radius_px: radius, + mode: 12.0, + color: [0.0, 0.0, 0.0, opacity], + fx: [tl0, tl1, tr0, tr1], + src_prev: [br0, br1, bl0, bl1], + mb: [0.0, spread, 1.0, 0.0], + ..Default::default() + } +} + +/// Le plus grand rayon que le mode 12 accepte sur ce quad sans se retourner. +/// +/// Le shader rentre chaque arête de `r` (`line_cross`) : dès que `r` dépasse la demi-distance +/// entre deux arêtes opposées, les arêtes rentrées se croisent et le quad « rentré » est retourné +/// — la pénombre sort alors n'importe où. La demi-distance se mesure du milieu d'une arête à la +/// droite de l'arête opposée (exact pour des arêtes parallèles, proche sous nos petits angles), +/// et `0.8×` garde une marge pour l'écart au parallélisme. +pub(crate) fn quad_shadow_max_radius(c: &[(f32, f32); 4]) -> f32 { + let dist_to_line = |p: (f32, f32), a: (f32, f32), b: (f32, f32)| { + let (ex, ey) = (b.0 - a.0, b.1 - a.1); + ((p.0 - a.0) * ey - (p.1 - a.1) * ex).abs() / ex.hypot(ey).max(1e-6) + }; + let mid = |a: (f32, f32), b: (f32, f32)| ((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5); + let half_min = (0..4) + .map(|k| { + let (a, b) = (c[k], c[(k + 1) % 4]); + let (o0, o1) = (c[(k + 2) % 4], c[(k + 3) % 4]); + dist_to_line(mid(o0, o1), a, b) * 0.5 + }) + .fold(f32::MAX, f32::min); + 0.8 * half_min +} pub(crate) fn ease_in_out_cubic(x: f32) -> f32 { let x = x.clamp(0.0, 1.0); if x < 0.5 { @@ -615,6 +777,9 @@ pub struct LiveParams { /// vélocité), pas par un flou gaussien variable comme le canvas web — plus simple à /// réutiliser côté GPU, effet de streak équivalent. pub cursor_motion_blur: f32, + /// 0..1 : volume du curseur (extrusion le long de la normale du plan + ombre de contact). + /// 0 = sprite plat, rendu d'avant au bit près. + pub cursor_volume: f32, /// Masquage auto du curseur en cas d'inactivité. pub cursor_auto_hide: bool, /// False when the "webcam" decoder is actually just the screen video again (the TS side @@ -664,6 +829,7 @@ impl Default for LiveParams { cursor_size_scale: 1.0, cursor_bounce_scale: 1.0, cursor_motion_blur: 0.0, + cursor_volume: 0.0, cursor_auto_hide: false, has_webcam: true, } @@ -702,6 +868,7 @@ pub fn live_params_from_scene(s: &crate::scene::Scene) -> LiveParams { cursor_size_scale: s.cursor.size, cursor_bounce_scale: s.cursor.click_bounce, cursor_motion_blur: s.cursor.motion_blur, + cursor_volume: s.cursor.volume, cursor_auto_hide: s.cursor.auto_hide, ..LiveParams::default() } @@ -1437,6 +1604,56 @@ pub struct CursorPlan { pub cursor_type: Option, /// Opacité effective (0..1) tenant compte de l'inactivité (auto-hide) et du zoom. pub alpha: f32, + /// Volume du curseur. `None` au réglage 0 : sprite plat, rendu d'avant. + pub volume: Option, +} + +/// Épaisseur du curseur à volume 1, en fraction de la taille du sprite. +const CURSOR_VOLUME_DEPTH_FRAC: f32 = 0.6; +/// Écran droit : la normale se projette sur un point, l'extrusion ne se verrait pas. On la +/// remplace par un décalage bas-droite de 3 px pour un sprite de 34 px (volume 1). +const CURSOR_VOLUME_FLAT_FRAC: f32 = 3.0 / 34.0; +/// Plafond de copies de la silhouette : la borne de boucle des trois shaders. À la taille par +/// défaut, `iso` extrude ~37 px en 4K : 48 garde l'écart entre copies sous 1 px. Seuls les +/// pixels de flanc paient la boucle — la face avant opaque sort dès la copie 0. +const CURSOR_VOLUME_MAX_TAPS: u32 = 48; + +/// L'extrusion du curseur et son ombre de contact, calculées une fois pour les trois backends. +/// +/// Limites connues, assumées puisque le réglage part de 0 : un pack dont le PNG peint déjà une +/// ombre la voit extrudée avec le reste (ombre doublée) ; le curseur math de secours (mode 4, +/// Windows) reste plat ; et sans sprite il n'y a plus rien à extruder — macOS, qui n'a pas de +/// repli, ne dessine alors ni curseur ni volume. +#[derive(Clone, Copy)] +pub struct CursorVolume { + /// Où tombent les faces arrière par rapport à la face avant, en px de sortie. Le shader le + /// reçoit tel quel dans `mb.xy` : une translation ne dépend pas du repère local. + pub extrude_px: [f32; 2], + /// Copies de la silhouette, face avant comprise (>= 2) — `mb.z`. + pub taps: u32, + /// Ombre de contact sous la pointe (mode 12), prête à dessiner AVANT le sprite. + pub shadow: LayerCB, +} + +/// Vecteur d'extrusion en px : la normale du plan tournée par `rot`, projetée, fois l'épaisseur. +/// +/// La normale arrière (0,0,-1) passe par la même composition que `rotated_quad_corners_px` +/// (Z, puis Y, puis X) ; Z la laisse fixe, il reste `(-sin b, cos b · sin a)`. C'est la +/// direction où l'on voit les flancs : un plan dont le bord droit s'éloigne (b > 0) fait face à +/// droite, on le regarde depuis sa gauche, et c'est à gauche que dépassent les côtés du curseur. +/// Projection orthographique : la perspective ne ferait varier `e` que de quelques pour cent +/// d'un bout à l'autre de l'écran. +/// +/// À plat, cette projection s'annule. Le repli bas-droite prend le relais en fondu (et non par +/// un seuil) pour que la mise en place d'un tilt ne fasse pas sauter l'extrusion. +pub(crate) fn cursor_extrusion_px(rot: [f32; 3], thickness_px: f32, flat_px: f32) -> [f32; 2] { + let (a, b) = (rot[0].to_radians(), rot[1].to_radians()); + let e = [-b.sin() * thickness_px, b.cos() * a.sin() * thickness_px]; + let flat = [flat_px * std::f32::consts::FRAC_1_SQRT_2; 2]; + let len = e[0].hypot(e[1]); + let k = if flat_px > 0.0 { (len / flat_px).clamp(0.0, 1.0) } else { 1.0 }; + let k = k * k * (3.0 - 2.0 * k); + [lerp(flat[0], e[0], k), lerp(flat[1], e[1], k)] } /// Ce que `plan_cursor` doit savoir en plus de `FrameGeometry`. @@ -1556,6 +1773,8 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option [-1.0, -1.0, 3.0, 3.0], }; + let volume01 = input.live.cursor_volume.clamp(0.0, 1.0); + let has_volume = volume01 > 0.001; let place = |cxy: Option<(f32, f32)>, dst: [f32; 4]| -> Option { cxy.and_then(|p| { let [fx, fy] = cursor_plane_point(g.cut, [input.u_max, input.v_max], p)?; @@ -1567,6 +1786,24 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option { + let screen_px = [dst[2] * rw, dst[3] * rh]; + CursorPlacement::Tilted { + plane_pt: [fx, fy], + quad: crate::regions::rotated_quad_corners_px( + screen_px[0], + screen_px[1], + [0.0; 3], + ), + center_px: [(dst[0] + dst[2] * 0.5) * rw, (dst[1] + dst[3] * 0.5) * rh], + screen_px, + render_px: [rw, rh], + } + } None => CursorPlacement::Upright { center: [dst[0] + fx * dst[2], dst[1] + fy * dst[3]], }, @@ -1620,6 +1857,12 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option Option Option { + let CursorPlacement::Tilted { plane_pt, quad, center_px, screen_px, render_px } = placement + else { + return None; + }; + let scale = quad.scale; + // Tout en fraction de la taille du sprite : l'épaisseur suit le réglage Size et le padding, + // et la réduction du plan incliné (`scale`) comme le sprite lui-même. + let unit = size_px * volume01 * scale; + let e = cursor_extrusion_px( + rot, + CURSOR_VOLUME_DEPTH_FRAC * unit, + CURSOR_VOLUME_FLAT_FRAC * unit, + ); + // Une copie par pixel de profondeur : au-delà de 1 px d'écart, les copies se lisent comme + // des marches sur le contour. + let taps = ((e[0].hypot(e[1]).ceil() as u32) + 1).clamp(2, CURSOR_VOLUME_MAX_TAPS); + + // Ombre de contact : un petit carré DANS le plan autour de la pointe, projeté comme le + // sprite, puis poussé de `e` jusqu'au pied de l'extrusion — là où le curseur touche l'écran. + let half = 0.08 * size_px; + let (hx, hy) = (half / screen_px[0], half / screen_px[1]); + let corners = [(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)].map(|(dx, dy)| { + let (px, py) = quad.point_px(plane_pt[0] + dx, plane_pt[1] + dy); + (center_px[0] + px + e[0], center_px[1] + py + e[1]) + }); + // Rayon au plafond : la tache est ronde, et `quad_shadow_max_radius` garde le mode 12 à + // l'endroit (au-delà de 0.8× le demi-côté, le quad rentré se retourne). + let radius = quad_shadow_max_radius(&corners); + let spread = 0.14 * size_px * scale; + let shadow = quad_shadow_cb(&corners, radius, spread, 0.35 * volume01 * alpha, render_px); + Some(CursorVolume { extrude_px: e, taps, shadow }) +} + /// Poids d'un échantillon du flou de mouvement de curseur (0 = queue/passé, taps-1 = tête/courant). /// /// Poids croissant de 0.25 (queue) à 1.0 (tête), normalisé pour que la somme valle 1.0. @@ -2819,5 +3105,278 @@ mod tests { let peak = plan_at(0.5 + 0.1794).expect("au pic").size_px; assert!((peak / rest - 1.8).abs() < 1e-3, "pic = 1 + 0.16 * 5, got {}", peak / rest); } + + fn plan_with(rot: [f32; 3], volume: f32) -> CursorPlan { + let cfg = crate::config::all().pop().expect("cfg"); + let scene = zoomed_golden_scene(); + let fg = FrameGeometry { zoom_rotation: rot, ..full_frame_geometry() }; + let track = + crate::cursor::CursorTrack::new(vec![(0.0, 0.4, 0.6), (2.0, 0.4, 0.6)], vec![], vec![]); + plan_cursor( + &fg, + &CursorPlanInput { + render_px: [1920.0, 1080.0], + u_max: 1.0, + v_max: 1.0, + cfg: &cfg, + live: LiveParams { cursor_volume: volume, ..LiveParams::default() }, + scene: Some(&scene), + track: &track, + t: 0.5, + }, + ) + .expect("plan cursor") + } + + const ISO: [f32; 3] = [-12.0, -18.0, -2.0]; + const LEFT: [f32; 3] = [-8.0, -16.0, -1.0]; + + /// Le sens de `e` : à plat, le repli bas-droite ; sous `iso` et `left` (bord droit qui + /// s'éloigne, bord haut qui recule), les flancs dépassent vers la droite et vers le haut. + #[test] + fn cursor_extrusion_points_along_the_projected_plane_normal() { + let flat = cursor_extrusion_px([0.0; 3], 20.0, 3.0); + assert!((flat[0] - flat[1]).abs() < 1e-6 && flat[0] > 0.0, "bas-droite : {flat:?}"); + assert!((flat[0].hypot(flat[1]) - 3.0).abs() < 1e-4, "3 px à plat : {flat:?}"); + + let iso = cursor_extrusion_px(ISO, 20.0, 3.0); + let expect = [ + -(-18f32).to_radians().sin() * 20.0, + (-18f32).to_radians().cos() * (-12f32).to_radians().sin() * 20.0, + ]; + assert!((iso[0] - expect[0]).abs() < 1e-4 && (iso[1] - expect[1]).abs() < 1e-4, "{iso:?}"); + assert!(iso[0] > 5.0 && iso[1] < -3.0, "iso : droite et haut, {iso:?}"); + + let left = cursor_extrusion_px(LEFT, 20.0, 3.0); + assert!(left[0] > 5.0 && left[1] < -2.0, "left : droite et haut, {left:?}"); + // `right` est le miroir de `left` : les flancs passent à gauche. + let right = cursor_extrusion_px([-8.0, 16.0, 1.0], 20.0, 3.0); + assert!((right[0] + left[0]).abs() < 1e-5 && (right[1] - left[1]).abs() < 1e-5); + + // Entre les deux, pas de saut : une rotation de 0.1° reste à côté du repli. + let tiny = cursor_extrusion_px([0.0, -0.1, 0.0], 20.0, 3.0); + assert!((tiny[0] - flat[0]).abs() < 0.1 && (tiny[1] - flat[1]).abs() < 0.1, "{tiny:?}"); + } + + /// Le plan porte `e`, les copies et l'ombre ; à volume 0 il n'en porte aucun, et le sprite + /// reste sur le chemin droit (mode 7) quand l'écran est plat. + #[test] + fn cursor_plan_carries_the_volume_only_when_it_is_on() { + let off = plan_with([0.0; 3], 0.0); + assert!(off.volume.is_none()); + assert!(matches!(off.placement, CursorPlacement::Upright { .. })); + + let flat = plan_with([0.0; 3], 1.0); + assert!(matches!(flat.placement, CursorPlacement::Tilted { .. }), "volume ⇒ mode 13"); + let v = flat.volume.expect("volume"); + let e = v.extrude_px; + assert!(e[0] > 0.0 && (e[0] - e[1]).abs() < 1e-4, "repli bas-droite : {e:?}"); + let expect_len = CURSOR_VOLUME_FLAT_FRAC * flat.size_px; + assert!((e[0].hypot(e[1]) - expect_len).abs() < 1e-3, "{e:?} vs {expect_len}"); + assert!(v.taps >= 2 && v.taps <= CURSOR_VOLUME_MAX_TAPS); + assert_eq!(v.shadow.mode, 12.0); + + let iso = plan_with(ISO, 1.0).volume.expect("volume").extrude_px; + let left = plan_with(LEFT, 1.0).volume.expect("volume").extrude_px; + assert!(iso[0] > 0.0 && iso[1] < 0.0, "iso : {iso:?}"); + assert!(left[0] > 0.0 && left[1] < 0.0, "left : {left:?}"); + assert!(iso[1] < left[1], "iso est plus penché en X que left : {iso:?} / {left:?}"); + // Moitié de volume, moitié d'épaisseur. + let half = plan_with(ISO, 0.5).volume.expect("volume").extrude_px; + assert!((half[0] * 2.0 - iso[0]).abs() < 1e-3, "{half:?} / {iso:?}"); + } + + /// Une copie par pixel d'extrusion jusqu'en 4K : au-delà de 1 px d'écart, les flancs se + /// lisent en marches. `plan_with` rend en 1080p à la taille 1 ; l'éditeur part de la taille 3 + /// (`DEFAULT_CURSOR_SIZE`), et la 4K double encore `size_px` (et donc `e`). + #[test] + fn cursor_volume_copies_stay_under_a_pixel_apart_in_4k() { + let plan = plan_with(ISO, 1.0); + for size_px in [plan.size_px * 3.0, plan.size_px * 6.0] { + let v = cursor_volume(ISO, plan.placement, size_px, 1.0, 1.0).expect("volume"); + let len = v.extrude_px[0].hypot(v.extrude_px[1]); + let gap = len / (v.taps - 1) as f32; + assert!(gap <= 1.0, "|e| {len} px, {} copies : écart {gap} px", v.taps); + } + } + + /// À rotation nulle, le quad identité rend EXACTEMENT le rect du mode 7 : passer le curseur + /// en mode 13 pour lui donner du volume ne le déplace pas d'un pixel. + #[test] + fn an_identity_quad_reproduces_the_upright_sprite_rect() { + let render = [1920.0, 1080.0]; + let (sprite, hotspot) = ([34.0, 40.0], [0.1, 0.05]); + for dst in [[0.0, 0.0, 1.0, 1.0], [0.1, 0.07, 0.8, 0.8], [0.23, 0.11, 0.5, 0.61]] { + let (fx, fy) = (0.37, 0.58); + let upright = + CursorPlacement::Upright { center: [dst[0] + fx * dst[2], dst[1] + fy * dst[3]] }; + let screen_px = [dst[2] * render[0], dst[3] * render[1]]; + let quad = + crate::regions::rotated_quad_corners_px(screen_px[0], screen_px[1], [0.0; 3]); + assert_eq!(quad.scale, 1.0); + let tilted = CursorPlacement::Tilted { + plane_pt: [fx, fy], + quad, + center_px: [ + (dst[0] + dst[2] * 0.5) * render[0], + (dst[1] + dst[3] * 0.5) * render[1], + ], + screen_px, + render_px: render, + }; + let a = cursor_sprite_cb(upright, sprite, hotspot, 1.0, [0.0; 4], render, None); + let b = cursor_sprite_cb(tilted, sprite, hotspot, 1.0, [0.0; 4], render, None); + assert_eq!((a.mode, b.mode), (7.0, 13.0)); + for k in 0..4 { + let px = (a.dst[k] - b.dst[k]).abs() * render[k % 2]; + assert!(px < 1e-3, "dst[{k}] : {} vs {} ({px} px)", a.dst[k], b.dst[k]); + } + // Les coins du quad sont ceux de la bbox : TL (0,0), TR (w,0), BR (w,h), BL (0,h). + let [w, h] = b.quad_px; + let expect = [0.0, 0.0, w, 0.0, w, h, 0.0, h]; + let got = [ + b.fx[0], + b.fx[1], + b.fx[2], + b.fx[3], + b.src_prev[0], + b.src_prev[1], + b.src_prev[2], + b.src_prev[3], + ]; + for k in 0..8 { + assert!((got[k] - expect[k]).abs() < 1e-3, "coin {k} : {got:?}"); + } + assert_eq!(b.mb, [0.0; 4], "sans volume, le shader garde le chemin plat"); + } + } + + /// Volume 0 : le `LayerCB` du sprite incliné est, octet pour octet, celui d'avant le volume. + /// La référence est le corps d'origine de `draw_cursor_sprite`, recopié tel quel. + #[test] + fn cursor_sprite_cb_without_volume_is_byte_identical_to_the_flat_sprite() { + fn before( + p: CursorPlacement, + pw: f32, + ph: f32, + hotspot: [f32; 2], + a: f32, + clip: [f32; 4], + ) -> LayerCB { + let CursorPlacement::Tilted { plane_pt, quad, center_px, screen_px, render_px } = p + else { + unreachable!() + }; + let (wf, hf) = (pw / screen_px[0], ph / screen_px[1]); + let x0 = plane_pt[0] - hotspot[0] * wf; + let y0 = plane_pt[1] - hotspot[1] * hf; + let corners = + [(x0, y0), (x0 + wf, y0), (x0 + wf, y0 + hf), (x0, y0 + hf)].map(|(fx, fy)| { + let (px, py) = quad.point_px(fx, fy); + (center_px[0] + px, center_px[1] + py) + }); + let (min_x, max_x) = corners + .iter() + .fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (min_y, max_y) = corners + .iter() + .fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + let (bw, bh) = ((max_x - min_x).max(1.0), (max_y - min_y).max(1.0)); + let local = |(x, y): (f32, f32)| [x - min_x, y - min_y]; + let [tl0, tl1] = local(corners[0]); + let [tr0, tr1] = local(corners[1]); + let [br0, br1] = local(corners[2]); + let [bl0, bl1] = local(corners[3]); + let (rw, rh) = (render_px[0], render_px[1]); + LayerCB { + dst: [min_x / rw, min_y / rh, bw / rw, bh / rh], + quad_px: [bw, bh], + mode: 13.0, + color: [1.0, 1.0, 1.0, a], + fx: [tl0, tl1, tr0, tr1], + src_prev: [br0, br1, bl0, bl1], + dst_prev: clip, + ..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() + }; + for rot in [ISO, LEFT, [-8.0, 16.0, 1.0]] { + let plan = plan_with(rot, 0.0); + assert!(plan.volume.is_none()); + let clip = [0.1, 0.2, 0.7, 0.6]; + let got = cursor_sprite_cb( + plan.placement, + [30.0, 34.0], + [0.2, 0.1], + 0.8, + clip, + [1920.0, 1080.0], + None, + ); + let want = before(plan.placement, 30.0, 34.0, [0.2, 0.1], 0.8, clip); + assert_eq!(bytes(&got), bytes(&want), "rotation {rot:?}"); + } + } + + /// Le rayon de l'ombre de contact reste sous le point où le mode 12 retourne son quad, et + /// la bbox du sprite en volume contient ses faces arrière. + #[test] + fn cursor_volume_shadow_and_bbox_stay_consistent() { + for rot in [[0.0; 3], ISO, LEFT] { + let plan = plan_with(rot, 1.0); + let v = plan.volume.expect("volume"); + let s = v.shadow; + let c = [ + (s.fx[0], s.fx[1]), + (s.fx[2], s.fx[3]), + (s.src_prev[0], s.src_prev[1]), + (s.src_prev[2], s.src_prev[3]), + ]; + // Demi-côté le plus court du quad d'ombre, mesuré naïvement sur ses arêtes. + let min_half = (0..4) + .map(|k| { + let (a, b) = (c[k], c[(k + 1) % 4]); + (b.0 - a.0).hypot(b.1 - a.1) * 0.5 + }) + .fold(f32::MAX, f32::min); + assert!( + s.radius_px > 0.0 && s.radius_px <= 0.8 * min_half + 1e-3, + "{rot:?} : r {} / {min_half}", + s.radius_px + ); + + let cb = cursor_sprite_cb( + plan.placement, + [plan.size_px; 2], + [0.0; 2], + 1.0, + [0.0; 4], + [1920.0, 1080.0], + Some(&v), + ); + assert_eq!(cb.mb, [v.extrude_px[0], v.extrude_px[1], v.taps as f32, 0.0]); + let e = v.extrude_px; + for (x, y) in [ + (cb.fx[0], cb.fx[1]), + (cb.fx[2], cb.fx[3]), + (cb.src_prev[0], cb.src_prev[1]), + (cb.src_prev[2], cb.src_prev[3]), + ] { + for (px, py) in [(x, y), (x + e[0], y + e[1])] { + assert!( + px >= -1e-3 && px <= cb.quad_px[0] + 1e-3, + "{rot:?} : x {px} hors bbox" + ); + assert!( + py >= -1e-3 && py <= cb.quad_px[1] + 1e-3, + "{rot:?} : y {py} hors bbox" + ); + } + } + } + } } diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 51c9765f2..f476a40b8 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -631,6 +631,8 @@ struct InspectorParams { cursor_smoothing: f32, /// 0..1 : force du flou de mouvement DU CURSEUR (indépendant du motion blur écran). cursor_motion_blur: f32, + /// 0..1 : volume du curseur (extrusion + ombre de contact). + cursor_volume: f32, cursor_auto_hide: bool, } @@ -651,6 +653,7 @@ impl Default for InspectorParams { cursor_bounce_scale: 1.0, cursor_smoothing: 0.0, cursor_motion_blur: 0.0, + cursor_volume: 0.0, cursor_auto_hide: false, } } @@ -929,6 +932,7 @@ impl LiveView { "cursorClickBounce" => p.cursor_bounce_scale = v.max(0.0), "cursorSmoothing" => p.cursor_smoothing = v.clamp(0.0, 1.0), "cursorMotionBlur" => p.cursor_motion_blur = v.clamp(0.0, 1.0), + "cursorVolume" => p.cursor_volume = v.clamp(0.0, 1.0), _ => {} } } @@ -1529,6 +1533,7 @@ unsafe fn render_thread( cursor_size_scale: ip.cursor_size_scale, cursor_bounce_scale: ip.cursor_bounce_scale, cursor_motion_blur: ip.cursor_motion_blur, + cursor_volume: ip.cursor_volume, cursor_auto_hide: ip.cursor_auto_hide, has_webcam: has_real_webcam, }); diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 0d226c0c8..4b606e4bc 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -398,6 +398,10 @@ pub struct SceneCursor { pub smoothing: f32, pub motion_blur: f32, pub click_bounce: f32, + /// 0..1 : volume du curseur (extrusion + ombre de contact). 0 = sprite plat, le rendu + /// d'avant. `#[serde(default)]` : absent des projets et des JSON écrits avant ce réglage. + #[serde(default)] + pub volume: f32, pub clip_to_bounds: bool, /// id du thème (jeu de sprites) — informatif ici : le natif consomme `cursor_sprites`. pub theme: String, diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index 245069d41..25a916706 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -223,6 +223,35 @@ float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 local_px) return sum / max(total, 1e-4); } +// Curseur EN VOLUME (mode 13, mb.z > 1) : extrusion par répétition, sans maillage. La silhouette +// alpha du sprite est rééchantillonnée `mb.z` fois (≤ 48), chaque copie translatée d'une fraction +// de `mb.xy` — le vecteur d'extrusion `e` en px, déjà projeté côté CPU +// (`frame_geometry::cursor_extrusion_px`). Une translation est la même dans tous les repères, +// donc `local - e·f` retombe sur la copie f sans autre calcul. La copie 0 est la face avant, +// intacte ; les suivantes sont les flancs, assombris avec la profondeur. Composition +// avant→arrière (« under ») : un flanc ne se voit que là où rien de plus proche ne couvre déjà +// le pixel, et la boucle s'arrête dès que le pixel est plein. +// `SampleLevel(…, 0)` et non `Sample` : un gradient implicite est interdit dans une boucle à +// sortie dynamique, et les textures d'image n'ont qu'un niveau — le résultat est le même. +// Alpha DROIT comme le mode 13 plat : on prémultiplie ici. +float4 cursor_extruded(float2 local) +{ + int taps = min((int) mb.z, 48); + float4 acc = 0.0; + [loop] for (int k = 0; k < 48; k++) + { + if (k >= taps || acc.a > 0.999) break; + float f = (float) k / (float) (taps - 1); + float3 r = quad_inverse_bilinear(local - mb.xy * f, fx.xy, fx.zw, src_prev.xy, src_prev.zw); + if (r.z < 0.5) continue; + float4 s = texImg.SampleLevel(samp, saturate(float2(r.x, r.y)), 0.0); + float shade = (k == 0) ? 1.0 : 0.72 - 0.3 * f; + float a = s.a * color.a; + acc += (1.0 - acc.a) * float4(s.rgb * shade * a, a); + } + return acc; +} + float4 ps_main(VSOut i) : SV_Target { // mode 13 : SPRITE DE CURSEUR posé sur l'écran incliné. Même warp que le mode 8, mais @@ -231,7 +260,9 @@ float4 ps_main(VSOut i) : SV_Target // doit donc subir la même inclinaison qu'elle, sinon il se lit comme un autocollant plat // collé par-dessus la scène. Corriger sa seule position ne suffisait pas. // fx.xy/fx.zw = coins TL/TR (px locaux) ; src_prev.xy/.zw = BR/BL ; dst_prev = rect de clip - // « Clip to canvas » en espace sortie. + // « Clip to canvas » en espace sortie. mb.xy = vecteur d'extrusion en px, mb.z = nombre de + // copies (volume, cf. `cursor_extruded`) ; mb.z ≤ 1 = sprite plat, le rendu d'avant. + // Un futur mode 14 doit être testé AVANT cette branche, qui n'a pas de borne haute. if (mode > 12.5) { if (i.pout.x < dst_prev.x || i.pout.x > dst_prev.x + dst_prev.z || @@ -239,6 +270,10 @@ float4 ps_main(VSOut i) : SV_Target { return float4(0.0, 0.0, 0.0, 0.0); } + if (mb.z > 1.5) + { + return cursor_extruded(i.local); + } float3 r = quad_inverse_bilinear(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw); if (r.z < 0.5) { diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index 1ab91d5f7..b5737e1f2 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -287,6 +287,29 @@ inline float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 loca return sum / max(total, 1e-4); } +// Curseur EN VOLUME (mode 13, mb.z > 1). Port ligne pour ligne de `cursor_extruded` (HLSL), +// dont les commentaires font foi ; seules différences : `layer` et `texImg` arrivent en +// paramètres, et `SampleLevel(…, 0)` s'écrit `sample(…, level(0.0))`. +static float4 cursor_extruded(float2 local, constant Layer &layer, + texture2d texImg) +{ + int taps = min((int) layer.mb.z, 48); + float4 acc = float4(0.0); + for (int k = 0; k < 48; k++) + { + if (k >= taps || acc.a > 0.999) break; + float f = (float) k / (float) (taps - 1); + float3 r = quad_inverse_bilinear(local - layer.mb.xy * f, layer.fx.xy, layer.fx.zw, + layer.src_prev.xy, layer.src_prev.zw); + if (r.z < 0.5) continue; + float4 s = texImg.sample(samp, clamp(float2(r.x, r.y), 0.0, 1.0), level(0.0)); + float shade = (k == 0) ? 1.0 : 0.72 - 0.3 * f; + float a = s.a * layer.color.a; + acc += (1.0 - acc.a) * float4(s.rgb * shade * a, a); + } + return acc; +} + fragment float4 ps_main(VSOut i [[stage_in]], constant Layer &layer [[buffer(0)]], texture2d texY [[texture(0)]], @@ -298,6 +321,8 @@ fragment float4 ps_main(VSOut i [[stage_in]], texture2d texMask [[texture(3)]]) { // mode 13 : SPRITE DE CURSEUR posé sur l'écran incliné. Cf. commentaires HLSL. + // mb.xy = vecteur d'extrusion en px, mb.z = nombre de copies (volume) ; mb.z ≤ 1 = plat. + // Un futur mode 14 doit être testé AVANT cette branche, qui n'a pas de borne haute. if (layer.mode > 12.5) { if (i.pout.x < layer.dst_prev.x || i.pout.x > layer.dst_prev.x + layer.dst_prev.z || @@ -305,6 +330,10 @@ fragment float4 ps_main(VSOut i [[stage_in]], { return float4(0.0, 0.0, 0.0, 0.0); } + if (layer.mb.z > 1.5) + { + return cursor_extruded(i.local, layer, texImg); + } float3 r = quad_inverse_bilinear(i.local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw); if (r.z < 0.5) diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index bc778f474..ec55fa4ee 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -27,7 +27,7 @@ struct Layer { fx: vec4, // mode 2 : spread ombre en px ; modes 8/12/13 : coins TL,TR du quad projeté ; mode 9 : hampe de la flèche ; mode 10 : (flou?, rayon/bloc px, ovale?, teinté?) src_prev: vec4, // modes 8/12/13 : coins BR,BL du quad projeté ; mode 9 : barbe 1 ; mode 10 incliné : coins BR,BL du masque dst_prev: vec4, // mode 8 : taille du plan en px AVANT projection (le rayon y vit) ; mode 13 : rect de clip ; mode 9 : barbe 2 ; mode 10 incliné : coins TL,TR du masque - mb: vec4, // 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: vec4, // 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é ; mode 13 : mb.xy = vecteur d'extrusion en px, mb.z = nombre de copies (volume, <= 1 = plat) } @group(0) @binding(0) var layer: Layer; @@ -267,6 +267,35 @@ fn blur_webcam_bg(uv: vec2, intensity: f32, qpx: vec2, local_px: vec2< return sum / max(total, 1e-4); } +// Curseur EN VOLUME (mode 13, mb.z > 1). Port ligne pour ligne de `cursor_extruded` +// (HLSL), dont les commentaires font foi : la silhouette est reechantillonnee `mb.z` +// fois (<= 48), translatee d'une fraction de `mb.xy` ; copie 0 = face avant intacte, +// les suivantes = flancs assombris, composes avant->arriere. `textureSampleLevel` a +// LOD 0 : pas de gradient implicite dans une boucle, et la texture n'a qu'un niveau. +fn cursor_extruded(local: vec2) -> vec4 { + let taps = min(i32(layer.mb.z), 48); + var acc = vec4(0.0); + for (var k: i32 = 0; k < 48; k = k + 1) { + if k >= taps || acc.a > 0.999 { + break; + } + let f = f32(k) / f32(taps - 1); + let r = quad_inverse_bilinear( + local - layer.mb.xy * f, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, + ); + if r.z < 0.5 { + continue; + } + let s = textureSampleLevel( + texY, samp, clamp(vec2(r.x, r.y), vec2(0.0), vec2(1.0)), 0.0, + ); + let shade = select(0.72 - 0.3 * f, 1.0, k == 0); + let a = s.a * layer.color.a; + acc += (1.0 - acc.a) * vec4(s.rgb * shade * a, a); + } + return acc; +} + @fragment fn fs_main(i: VsOut) -> @location(0) vec4 { var rgb: vec3; @@ -522,6 +551,10 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { || i.pout.y < layer.dst_prev.y || i.pout.y > layer.dst_prev.y + layer.dst_prev.w { return vec4(0.0, 0.0, 0.0, 0.0); } + // Volume (`mb.z` copies, `mb.xy` = extrusion en px) ; <= 1 = sprite plat, inchange. + if layer.mb.z > 1.5 { + return cursor_extruded(i.local); + } let r = quad_inverse_bilinear( i.local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, ); diff --git a/crates/compositor/tests/compose_linux.rs b/crates/compositor/tests/compose_linux.rs index 81ee2cfb8..e320ca8e8 100644 --- a/crates/compositor/tests/compose_linux.rs +++ b/crates/compositor/tests/compose_linux.rs @@ -372,6 +372,76 @@ fn compose_linux_curseur_sur_ecran_tilte() { ); } +/// Curseur en volume (`cursor.volume`, mode 13 avec `mb.z` copies) : parite avec +/// `cursor_volume_render.rs` (Windows). Sprite vert opaque sur fond magenta : a +/// volume 1 des flancs vert SOMBRE apparaissent autour du carre, sur ecran droit +/// (quad identite, repli bas-droite) comme incline ; la face avant, verte vive, +/// reste entiere. A volume 0 aucun pixel sombre : le chemin plat est intact. +#[test] +fn compose_linux_curseur_en_volume() { + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { + eprintln!("compose_linux curseur en volume: opt-in. Skip."); + return; + } + const SPRITE: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAABAAAAAQBPJcTWAAAAGElEQVR4nGNk+MdAEmAhTfmohlENQ0kDAGoRATwbkCdPAAAAAElFTkSuQmCC"; + + let gpu = Gpu::create(false).expect("Gpu::create"); + let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); + let mut dec = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); + + let track_path = std::env::temp_dir().join("os_cursor_track_volume.json"); + std::fs::write( + &track_path, + r#"{"samples":[{"timeMs":3000,"cx":0.3,"cy":0.3,"cursorType":"arrow"}]}"#, + ) + .expect("write track"); + let track = CursorTrack::load(track_path.to_str().unwrap(), 0.0, 6.0).expect("CursorTrack::load"); + comp.set_cursor(track); + comp.set_cursor_time(Some(3.0)); + + let scene_json = |rotation: &str, volume: f32| { + format!( + r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0.2,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},"background":{{"kind":"color","color":"#ff00ff"}},"zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":6,"scale":1.0,"focusX":0.5,"focusY":0.5,"rotation":{rotation}}}],"annotations":[],"cursor":{{"show":true,"size":4,"smoothing":0,"motionBlur":0,"clickBounce":0,"volume":{volume},"clipToBounds":false,"theme":"default","cursorSprites":{{"arrow":{{"path":"{SPRITE}","hotspotX":0.5,"hotspotY":0.5}}}}}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":30}}}}"## + ) + }; + let bright = |p: &[u8]| p[1] > 180 && p[0] < 120 && p[2] < 120; + let dark_green = |p: &[u8]| p[1] > 40 && p[1] < 170 && p[0] < 40 && p[2] < 40; + + for rotation in ["null", "\"iso\""] { + let (w, h, flat, deep) = unsafe { + let sf = dec.seek_to(1.0).expect("Decoder::seek_to"); + let render = |json: String| { + let scene = Scene::from_json(&json).expect("scene json"); + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene( + &scene, + )); + comp.set_scene(Some(scene)); + comp.compose_frame(sf, sf, 90.0, &Cfg::c8()).expect("compose_frame"); + comp.readback_direct().expect("readback_direct") + }; + let (w, h, flat) = render(scene_json(rotation, 0.0)); + let (_, _, deep) = render(scene_json(rotation, 1.0)); + (w, h, flat, deep) + }; + let name = if rotation == "null" { "flat" } else { "iso" }; + write_ppm(&format!("compose_linux_cursor_volume_{name}"), w, h, &deep); + let count = |rgba: &[u8], f: &dyn Fn(&[u8]) -> bool| rgba.chunks_exact(4).filter(|p| f(p)).count(); + let (front0, front1) = (count(&flat, &bright), count(&deep, &bright)); + let (sides0, sides1) = (count(&flat, &dark_green), count(&deep, &dark_green)); + println!( + "compose_linux curseur en volume ({name}) : face avant {front0} -> {front1}, \ + flancs {sides0} -> {sides1}" + ); + assert!(front0 > 50, "{name}: curseur absent a volume 0 (n={front0})"); + assert!( + front1 * 100 >= front0 * 97, + "{name}: la face avant a perdu des pixels ({front0} -> {front1})" + ); + assert!(sides0 < 20, "{name}: des flancs a volume 0 ({sides0})"); + assert!(sides1 > 30, "{name}: pas de flancs a volume 1 ({sides1})"); + } +} + /// Curseur : sprite thematise (mode 7) dessine au centre. Sprite VERT (data URI /// PNG) distinct du fond sombre et de l'ecran, pour l'affirmer sans ambiguite. #[test] diff --git a/crates/compositor/tests/cursor_volume_render.rs b/crates/compositor/tests/cursor_volume_render.rs new file mode 100644 index 000000000..cacd7fa9a --- /dev/null +++ b/crates/compositor/tests/cursor_volume_render.rs @@ -0,0 +1,158 @@ +//! Le curseur en volume (`cursor.volume`) : des flancs le long de la normale du plan et une +//! ombre de contact, sur écran droit comme incliné — sans toucher à sa face avant. +//! +//! Rend de vraies frames par le compositeur D3D11, avec le sprite `arrow` livré +//! (`public/cursors/default`) et une piste curseur synthétique immobile sur le pavé rouge de la +//! source de `privacy_blur_under_zoom.rs` (mêmes réglages ffmpeg). +//! +//! ```powershell +//! $env:OPENSCREEN_CURSOR_VOLUME_SOURCE = "...\secret.mp4" +//! $env:OPENSCREEN_CURSOR_VOLUME_OUT = "...\renders" # facultatif : un PPM par cas +//! cargo test -p openscreen-compositor --test cursor_volume_render -- --nocapture +//! ``` + +// Windows seulement : le readback et le décodage D3D11VA de ce harnais n'existent que là. +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config; +use openscreen_compositor::cursor::CursorTrack; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::frame_geometry::live_params_from_scene; +use openscreen_compositor::live::Player; +use openscreen_compositor::scene::Scene; + +const W: u32 = 1280; +const H: u32 = 720; +const AT_SEC: f64 = 3.0; + +fn write_ppm(path: &std::path::Path, rgba: &[u8], w: u32, h: u32) -> std::io::Result<()> { + let mut out = Vec::with_capacity(rgba.len() / 4 * 3 + 32); + out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes()); + for px in rgba.chunks_exact(4) { + out.extend_from_slice(&px[..3]); + } + std::fs::write(path, out) +} + +fn scene_json(source: &str, arrow: &str, volume: f32, rotation: &str) -> String { + let s = source.replace('\\', "/"); + let a = arrow.replace('\\', "/"); + format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":6,"webcamOffsetSec":0,"hasAudio":false}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, + "effects": {{"padding":0.1,"blur":false,"shadow":0.0,"roundnessFrac":0,"motionBlur":0.0}}, + "background": {{"kind":"color","color":"#d8d8d8"}}, + "zoomRegions": [{{"id":"z","startSec":0,"endSec":6,"scale":1.5,"focusX":0.6,"focusY":0.6,"focusMode":"manual","rotation":{rotation}}}], + "annotations": [], + "speedRegions": [], + "cursor": {{"show":true,"size":3,"smoothing":0,"motionBlur":0,"clickBounce":0,"volume":{volume},"clipToBounds":false,"theme":"default", + "cursorSprites": {{"arrow": {{"path":"{a}","hotspotX":0.119,"hotspotY":0.0874}}}}}}, + "cropByClip": [null], + "output": {{"width":{W},"height":{H},"fps":null}} + }}"## + ) +} + +fn render( + gpu: &Gpu, + source: &str, + track: &str, + arrow: &str, + volume: f32, + rotation: &str, +) -> Vec { + 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(&scene_json(source, arrow, volume, rotation)).expect("scene valide"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + comp.set_cursor(CursorTrack::load(track, 0.0, 6.0).expect("piste curseur")); + unsafe { + let mut player = Player::open(source, "", gpu).expect("ouvrir la source"); + player + .present_frame(&comp, &cfg, AT_SEC) + .expect("composer la frame"); + comp.readback_resized(W, H).expect("readback") + } +} + +fn luma(p: &[u8]) -> u32 { + (p[0] as u32 * 2126 + p[1] as u32 * 7152 + p[2] as u32 * 722) / 10000 +} + +#[test] +fn the_cursor_volume_adds_sides_and_keeps_the_front_face() { + let Ok(source) = std::env::var("OPENSCREEN_CURSOR_VOLUME_SOURCE") else { + println!("SKIP: definir OPENSCREEN_CURSOR_VOLUME_SOURCE (voir l'en-tete du fichier)."); + return; + }; + let out_dir = std::env::var("OPENSCREEN_CURSOR_VOLUME_OUT") + .ok() + .map(std::path::PathBuf::from); + if let Some(dir) = &out_dir { + std::fs::create_dir_all(dir).expect("creer le dossier de sortie"); + } + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let arrow = root.join("public/cursors/default/arrow.png"); + let arrow = arrow.to_str().expect("chemin utf-8"); + // Piste immobile au coin haut-gauche du pavé rouge : les flancs sombres s'y lisent. + let track = std::env::temp_dir().join("openscreen-cursor-volume-track.json"); + std::fs::write( + &track, + r#"{"samples":[{"timeMs":0,"cx":0.58,"cy":0.58},{"timeMs":6000,"cx":0.58,"cy":0.58}]}"#, + ) + .expect("ecrire la piste"); + let track = track.to_str().expect("chemin utf-8"); + let gpu = Gpu::create(false).expect("device d3d11"); + + for (name, rotation) in [("flat", "null"), ("iso", r#""iso""#), ("left", r#""left""#)] { + let flat = render(&gpu, &source, track, arrow, 0.0, rotation); + let deep = render(&gpu, &source, track, arrow, 1.0, rotation); + if let Some(dir) = &out_dir { + write_ppm(&dir.join(format!("{name}-volume0.ppm")), &flat, W, H).expect("ppm"); + write_ppm(&dir.join(format!("{name}-volume1.ppm")), &deep, W, H).expect("ppm"); + } + let changed = flat + .chunks_exact(4) + .zip(deep.chunks_exact(4)) + .filter(|(a, b)| a != b) + .count(); + // Écart franc (> 30 en luma, dans un sens ou l'autre) : les flancs gris du liseré blanc + // ressortent PLUS clairs que le rouge, l'ombre plus sombre. + let marked = flat + .chunks_exact(4) + .zip(deep.chunks_exact(4)) + .filter(|(a, b)| luma(a).abs_diff(luma(b)) > 30) + .count(); + // Face avant : les pixels blancs du sprite à volume 0 restent blancs. Le passage du mode 7 + // au mode 13 (quad identité) ne doit rien déplacer, et les flancs passent DERRIÈRE. + let (mut white, mut kept) = (0usize, 0usize); + for (a, b) in flat.chunks_exact(4).zip(deep.chunks_exact(4)) { + if a[0] > 235 && a[1] > 235 && a[2] > 235 { + white += 1; + kept += usize::from(b[0] > 225 && b[1] > 225 && b[2] > 225); + } + } + println!( + "{name:<5} pixels changes {changed:>6} marques {marked:>5} face avant {kept}/{white}" + ); + assert!( + white > 200, + "{name}: le sprite n'est pas visible ({white} px blancs)" + ); + assert!( + marked > 150, + "{name}: le volume ne se voit pas ({changed} changes, {marked} marques)" + ); + assert!( + kept * 100 >= white * 97, + "{name}: la face avant a bougé ({kept}/{white})" + ); + } +} diff --git a/electron/ai-edition/style-preset-service.test.ts b/electron/ai-edition/style-preset-service.test.ts index 4a7158dca..afd5600ae 100644 --- a/electron/ai-edition/style-preset-service.test.ts +++ b/electron/ai-edition/style-preset-service.test.ts @@ -21,7 +21,14 @@ const APPEARANCE: StylePresetAppearance = { webcamBackgroundMode: "none", webcamWallpaper: "/wallpapers/wallpaper1.jpg", webcamBlurIntensity: 0.5, - cursor: { size: 3, smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, clipToBounds: false }, + cursor: { + size: 3, + smoothing: 0.67, + motionBlur: 0.35, + clickBounce: 2.5, + volume: 0, + clipToBounds: false, + }, cursorShow: true, cursorAutoHide: false, cursorTheme: "default", diff --git a/electron/ipc/nativeBridge.presets.test.ts b/electron/ipc/nativeBridge.presets.test.ts index a04ec169c..93c6ba6df 100644 --- a/electron/ipc/nativeBridge.presets.test.ts +++ b/electron/ipc/nativeBridge.presets.test.ts @@ -41,7 +41,14 @@ const APPEARANCE: StylePresetAppearance = { webcamBackgroundMode: "none", webcamWallpaper: "#ffffff", webcamBlurIntensity: 0.5, - cursor: { size: 3, smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, clipToBounds: false }, + cursor: { + size: 3, + smoothing: 0.67, + motionBlur: 0.35, + clickBounce: 2.5, + volume: 0, + clipToBounds: false, + }, cursorShow: true, cursorAutoHide: false, cursorTheme: "default", diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index bfa10f273..b1ced5196 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -3436,6 +3436,24 @@ export function CursorPane() { onCommit={() => void commit()} /> ) : null} + {/* 0..1 in storage, percent on screen. With the cursor hidden there is + nothing to extrude, so the slider is disabled and its tooltip says why. */} + { + setLive({ cursor: { volume: v / 100 } }); + if (isNativeCompositorActive()) { + setNativeParam("cursorVolume", v / 100); + } + }} + onCommit={() => void commit()} + /> ); @@ -3488,6 +3506,7 @@ export function SliderCell({ onCommit, showValue = true, full = false, + title, }: { label: string; value: number; @@ -3503,10 +3522,12 @@ export function SliderCell({ * l'interpolent), sans quoi elle s'affiche deux fois. */ showValue?: boolean; full?: boolean; + /** Tooltip on the whole cell — how a disabled slider says why it is disabled. */ + title?: string; }) { const pct = Math.max(0, Math.min(100, max > min ? ((value - min) / (max - min)) * 100 : 0)); return ( -
+
{label} {showValue ? ( diff --git a/src/components/video-editor/editorDefaults.ts b/src/components/video-editor/editorDefaults.ts index 55bf565e5..59d95a5bb 100644 --- a/src/components/video-editor/editorDefaults.ts +++ b/src/components/video-editor/editorDefaults.ts @@ -11,6 +11,7 @@ import { DEFAULT_CURSOR_MOTION_BLUR, DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_SMOOTHING, + DEFAULT_CURSOR_VOLUME, DEFAULT_WEBCAM_LAYOUT_PRESET, DEFAULT_WEBCAM_MASK_SHAPE, DEFAULT_WEBCAM_POSITION, @@ -76,6 +77,7 @@ export const DEFAULT_CURSOR_SETTINGS: CursorVisualSettings & { show: boolean; th smoothing: DEFAULT_CURSOR_SMOOTHING, motionBlur: DEFAULT_CURSOR_MOTION_BLUR, clickBounce: DEFAULT_CURSOR_CLICK_BOUNCE, + volume: DEFAULT_CURSOR_VOLUME, clipToBounds: DEFAULT_CURSOR_CLIP_TO_BOUNDS, theme: DEFAULT_CURSOR_THEME_ID, }; diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 2a908c5b1..a9996bf4f 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -231,6 +231,8 @@ export interface CursorVisualSettings { smoothing: number; motionBlur: number; clickBounce: number; + /** 0..1 — extrudes the cursor along the screen's normal and adds a contact shadow. */ + volume: number; clipToBounds: boolean; autoHide?: boolean; } @@ -239,6 +241,8 @@ export const DEFAULT_CURSOR_SIZE = 3.0; export const DEFAULT_CURSOR_SMOOTHING = 0.67; export const DEFAULT_CURSOR_MOTION_BLUR = 0.35; export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5; +// Off: a flat sprite, exactly the cursor every existing project renders. +export const DEFAULT_CURSOR_VOLUME = 0; // false lets the cursor overflow into the background; true clips it to the canvas bounds. export const DEFAULT_CURSOR_CLIP_TO_BOUNDS = false; export const DEFAULT_CURSOR_AUTO_HIDE = false; diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index d8c252b74..fba6d9c8b 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -370,6 +370,8 @@ "cursor": { "smoothing": "التنعيم", "clickBounce": "ارتداد النقر", + "depth": "عمق ثلاثي الأبعاد", + "depthNeedsCursor": "فعّل «إظهار المؤشر» لإضافة عمق إليه.", "help": "عرض المؤشر اعتمادًا على بيانات التتبع المسجّلة: السمة، والحجم، والتنعيم، وضبابية الحركة، وارتداد النقر.", "clipToBoundsDescription": "يبقي المؤشر داخل إطار الفيديو. أوقف التشغيل للسماح للمؤشر بتجاوز الحواف - مفيد عند التكبير أو التحريك.", "size": "الحجم", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index e15a6e168..a96ebcbb0 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -370,6 +370,8 @@ "cursor": { "smoothing": "Vyhlazení", "clickBounce": "Odraz při kliknutí", + "depth": "3D hloubka", + "depthNeedsCursor": "Zapněte „Zobrazit kurzor“, aby kurzor získal hloubku.", "help": "Renderování kurzoru ze zaznamenané telemetrie: motiv, velikost, vyhlazení, pohybové rozmazání a zdůraznění odrazu při kliknutí.", "clipToBoundsDescription": "Udržuje kurzor uvnitř rámce videa. Vypněte, pokud chcete, aby kurzor přesahoval okraje, což se hodí při přiblížení nebo posunu.", "size": "Velikost", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 893a15f4a..8a120b5c1 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -370,6 +370,8 @@ "cursor": { "smoothing": "Smoothing", "clickBounce": "Click Bounce", + "depth": "3D Depth", + "depthNeedsCursor": "Turn on Show Cursor to give it depth.", "help": "Cursor rendering from the recorded telemetry: theme, size, smoothing, motion blur, and click-bounce emphasis.", "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned.", "size": "Size", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 796c4a003..e404b5cf3 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -370,6 +370,8 @@ "cursor": { "smoothing": "Suavizado", "clickBounce": "Rebote al clic", + "depth": "Profundidad 3D", + "depthNeedsCursor": "Activa «Mostrar cursor» para darle profundidad.", "help": "Representación del cursor a partir de la telemetría grabada: tema, tamaño, suavizado, desenfoque de movimiento y rebote al hacer clic.", "clipToBoundsDescription": "Mantiene el cursor dentro del cuadro del vídeo. Desactívalo para dejar que el cursor sobrepase los bordes; útil al hacer zoom o desplazar la imagen.", "size": "Tamaño", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index cb39bca7a..3001a70b4 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -370,6 +370,8 @@ "cursor": { "smoothing": "Lissage", "clickBounce": "Rebond au clic", + "depth": "Profondeur 3D", + "depthNeedsCursor": "Activez « Afficher le curseur » pour lui donner de la profondeur.", "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic.", "clipToBoundsDescription": "Garde le curseur à l'intérieur du cadre vidéo. Désactivez pour laisser le curseur dépasser les bords — utile en cas de zoom ou de panoramique.", "size": "Taille", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 914a94589..989725604 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -370,6 +370,8 @@ "cursor": { "smoothing": "Smussatura", "clickBounce": "Rimbalzo clic", + "depth": "Profondità 3D", + "depthNeedsCursor": "Attiva «Mostra cursore» per dargli profondità.", "help": "Resa del cursore dalla telemetria registrata: tema, dimensione, smussatura, sfocatura di movimento e rimbalzo al clic.", "clipToBoundsDescription": "Mantiene il cursore all'interno del fotogramma video. Disattiva per lasciare che il cursore superi i bordi — utile quando si esegue zoom o panoramica.", "size": "Dimensione", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index fa92b1a10..7c3058a34 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -370,6 +370,8 @@ "cursor": { "smoothing": "スムージング", "clickBounce": "クリックバウンス", + "depth": "3D の奥行き", + "depthNeedsCursor": "奥行きを付けるには「カーソルを表示」をオンにしてください。", "help": "記録されたテレメトリからのカーソル描画: テーマ、サイズ、スムージング、モーションブラー、クリック時のバウンス。", "clipToBoundsDescription": "カーソルを映像フレーム内に保ちます。オフにするとカーソルが端からはみ出せるようになります。ズームやパン時に便利です。", "size": "サイズ", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 7efc0f368..6a1cc8e6a 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -370,6 +370,8 @@ "cursor": { "smoothing": "부드러움", "clickBounce": "클릭 바운스", + "depth": "3D 깊이", + "depthNeedsCursor": "깊이를 주려면 '커서 표시'를 켜세요.", "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스.", "clipToBoundsDescription": "커서를 비디오 프레임 안에 유지합니다. 꺼두면 확대하거나 이동할 때 커서가 가장자리를 벗어날 수 있습니다.", "size": "크기", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index a2254e20e..e1e6faca9 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -370,6 +370,8 @@ "cursor": { "smoothing": "Suavização", "clickBounce": "Rebote ao clicar", + "depth": "Profundidade 3D", + "depthNeedsCursor": "Ative “Mostrar cursor” para dar profundidade a ele.", "help": "Renderização do cursor a partir da telemetria gravada: tema, tamanho, suavização, desfoque de movimento e salto ao clicar.", "clipToBoundsDescription": "Mantém o cursor dentro do quadro do vídeo. Desative para permitir que o cursor ultrapasse as bordas — útil ao aplicar zoom ou ao deslocar.", "size": "Tamanho", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 3552e62b3..895d97520 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -370,6 +370,8 @@ "cursor": { "smoothing": "Сглаживание", "clickBounce": "Отскок при клике", + "depth": "3D-глубина", + "depthNeedsCursor": "Включите «Показывать курсор», чтобы придать ему глубину.", "help": "Отрисовка курсора по записанной телеметрии: тема, размер, сглаживание, размытие движения и отскок при клике.", "clipToBoundsDescription": "Удерживает курсор внутри кадра видео. Отключите, чтобы курсор мог выходить за края — полезно при увеличении или панорамировании.", "size": "Размер", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 9ce904456..3a3f35e50 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -370,6 +370,8 @@ "cursor": { "smoothing": "Yumuşatma", "clickBounce": "Tıklama Sıçraması", + "depth": "3B Derinlik", + "depthNeedsCursor": "Derinlik vermek için «İmleci Göster»i açın.", "help": "Kaydedilen telemetriden imleç çizimi: tema, boyut, yumuşatma, hareket bulanıklığı ve tıklama sıçraması.", "clipToBoundsDescription": "İmleci video kare içinde tutar. İmlecin kenarları aşmasına izin vermek için kapatın — yakınlaştırma veya kaydırma yapılırken kullanışlıdır.", "size": "Boyut", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 8a2d7138a..af4ddc1ec 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -370,6 +370,8 @@ "cursor": { "smoothing": "Làm mượt", "clickBounce": "Nảy khi nhấp", + "depth": "Độ sâu 3D", + "depthNeedsCursor": "Bật “Hiện con trỏ” để tạo độ sâu cho con trỏ.", "help": "Cách vẽ con trỏ từ dữ liệu đã ghi: chủ đề, kích thước, làm mượt, mờ chuyển động và hiệu ứng nảy khi nhấp.", "clipToBoundsDescription": "Giữ con trỏ bên trong khung hình video. Tắt để cho phép con trỏ vượt ra ngoài mép — hữu ích khi phóng to hoặc lia máy.", "size": "Kích thước", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index bb59240f5..f081f7b73 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -370,6 +370,8 @@ "cursor": { "smoothing": "平滑", "clickBounce": "点击弹跳", + "depth": "3D 深度", + "depthNeedsCursor": "开启“显示光标”后才能为光标添加深度。", "help": "基于录制遥测数据的光标渲染:主题、大小、平滑、运动模糊和点击回弹。", "clipToBoundsDescription": "将光标保持在视频画面内。关闭后光标可以超出边缘——在放大或平移时很有用。", "size": "大小", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 59326e857..423835ac8 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -370,6 +370,8 @@ "cursor": { "smoothing": "平滑", "clickBounce": "點擊彈跳", + "depth": "3D 深度", + "depthNeedsCursor": "開啟「顯示游標」後才能為游標加上深度。", "help": "根據錄製的遙測資料繪製游標:主題、大小、平滑、動態模糊與點擊回彈。", "clipToBoundsDescription": "讓游標保持在影片畫面內。關閉後游標可超出邊緣——在縮放或平移時很有用。", "size": "大小", diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index 3501d0e8f..759266a2b 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -119,6 +119,10 @@ describe("patchEditorSettings", () => { const snap = getEditorSettings(next); expect(snap.cursor.size).toBe(4); expect(snap.cursor.smoothing).toBe(0.9); + const deep = getEditorSettings(patchEditorSettings(next, { cursor: { volume: 0.5 } })); + expect(deep.cursor.volume).toBe(0.5); + expect(deep.cursor.size).toBe(4); + expect(snap.cursor.volume).toBe(0); }); it("toggles cursorAutoHide on and off via patch", () => { diff --git a/src/lib/ai-edition/store/editorSettings.ts b/src/lib/ai-edition/store/editorSettings.ts index 5f2b28bca..46271dbe5 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -138,6 +138,7 @@ interface LegacyShape { cursorSmoothing?: number; cursorMotionBlur?: number; cursorClickBounce?: number; + cursorVolume?: number; cursorClipToBounds?: boolean; cursorShow?: boolean; cursorAutoHide?: boolean; @@ -169,6 +170,9 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor smoothing: num(legacy?.cursorSmoothing, DEFAULT_EDITOR_SETTINGS.cursor.smoothing), motionBlur: num(legacy?.cursorMotionBlur, DEFAULT_EDITOR_SETTINGS.cursor.motionBlur), clickBounce: num(legacy?.cursorClickBounce, DEFAULT_EDITOR_SETTINGS.cursor.clickBounce), + // Same 0-1 range as the slider and the native clamp; absent in every project saved + // before the setting existed, which is what keeps them flat. + volume: clamp01(num(legacy?.cursorVolume, DEFAULT_EDITOR_SETTINGS.cursor.volume)), clipToBounds: bool(legacy?.cursorClipToBounds, DEFAULT_EDITOR_SETTINGS.cursor.clipToBounds), autoHide: bool(legacy?.cursorAutoHide, DEFAULT_EDITOR_SETTINGS.cursorAutoHide), }; @@ -274,6 +278,7 @@ function nextLegacy(current: LegacyShape | null, patch: EditorSettingsPatch): Le if (c.smoothing !== undefined) next.cursorSmoothing = c.smoothing; if (c.motionBlur !== undefined) next.cursorMotionBlur = c.motionBlur; if (c.clickBounce !== undefined) next.cursorClickBounce = c.clickBounce; + if (c.volume !== undefined) next.cursorVolume = c.volume; if (c.clipToBounds !== undefined) next.cursorClipToBounds = c.clipToBounds; if (c.theme !== undefined) next.cursorTheme = c.theme; if (c.show !== undefined) next.cursorShow = c.show; diff --git a/src/lib/ai-edition/stylePresets.test.ts b/src/lib/ai-edition/stylePresets.test.ts index 7ba0a6492..5e3f31964 100644 --- a/src/lib/ai-edition/stylePresets.test.ts +++ b/src/lib/ai-edition/stylePresets.test.ts @@ -29,7 +29,14 @@ function appearance(overrides: Partial = {}): StylePreset webcamBackgroundMode: "blur", webcamWallpaper: "#112233", webcamBlurIntensity: 0.5, - cursor: { size: 3, smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, clipToBounds: false }, + cursor: { + size: 3, + smoothing: 0.67, + motionBlur: 0.35, + clickBounce: 2.5, + volume: 0, + clipToBounds: false, + }, cursorShow: true, cursorAutoHide: false, cursorTheme: "default", @@ -77,6 +84,25 @@ describe("parseStylePresetAppearance", () => { ).toThrow(/cursor\.size/); }); + it("reads a preset written before cursor.volume as a flat cursor, and bounds it", () => { + const { volume: _volume, ...flatCursor } = appearance().cursor; + expect(parseStylePresetAppearance({ ...appearance(), cursor: flatCursor }).cursor.volume).toBe( + 0, + ); + expect( + parseStylePresetAppearance({ + ...appearance(), + cursor: { ...appearance().cursor, volume: 0.4 }, + }).cursor.volume, + ).toBe(0.4); + expect(() => + parseStylePresetAppearance({ + ...appearance(), + cursor: { ...appearance().cursor, volume: 1.5 }, + }), + ).toThrow(/cursor\.volume/); + }); + it("falls back to the default cursor theme for an id this build does not ship", () => { expect( parseStylePresetAppearance(appearance({ cursorTheme: "theme-from-the-future" })).cursorTheme, diff --git a/src/lib/ai-edition/stylePresets.ts b/src/lib/ai-edition/stylePresets.ts index 1b46ce44d..5253a84e1 100644 --- a/src/lib/ai-edition/stylePresets.ts +++ b/src/lib/ai-edition/stylePresets.ts @@ -109,6 +109,7 @@ const CURSOR_NUMBER_RANGES = { smoothing: [0, 1], motionBlur: [0, 1], clickBounce: [0, 5], + volume: [0, 1], } as const; type Fields = Record; @@ -203,8 +204,9 @@ export function parseStylePresetWallpaper(value: unknown, key = "wallpaper"): st * guessing a factory value for it would apply something the author never chose. The one * lenient field is `cursorTheme`: themes come and go between builds, so an id this build * does not ship falls back to the default cursor instead of rejecting a preset that is - * otherwise sound (the editor does the same when it renders one). Unknown extra keys are - * dropped. + * otherwise sound (the editor does the same when it renders one). `cursor.volume` may be + * absent: it postdates format version 1, and a preset without it was authored flat. Unknown + * extra keys are dropped. */ export function parseStylePresetAppearance(value: unknown): StylePresetAppearance { if (!isRecord(value)) { @@ -245,6 +247,11 @@ export function parseStylePresetAppearance(value: unknown): StylePresetAppearanc smoothing: readNumber(cursor, "smoothing", CURSOR_NUMBER_RANGES.smoothing, "cursor."), motionBlur: readNumber(cursor, "motionBlur", CURSOR_NUMBER_RANGES.motionBlur, "cursor."), clickBounce: readNumber(cursor, "clickBounce", CURSOR_NUMBER_RANGES.clickBounce, "cursor."), + // Presets written before the setting existed have no volume: they meant a flat cursor. + volume: + cursor.volume === undefined + ? 0 + : readNumber(cursor, "volume", CURSOR_NUMBER_RANGES.volume, "cursor."), clipToBounds: readBoolean(cursor, "clipToBounds", "cursor."), }, cursorShow: readBoolean(value, "cursorShow"), diff --git a/src/lib/ai-edition/stylePresetsEditor.test.ts b/src/lib/ai-edition/stylePresetsEditor.test.ts index 338aaa339..6b3a96273 100644 --- a/src/lib/ai-edition/stylePresetsEditor.test.ts +++ b/src/lib/ai-edition/stylePresetsEditor.test.ts @@ -40,7 +40,14 @@ function styledSettings(): EditorSettingsSnapshot { webcamBackgroundMode: "custom", webcamWallpaper: "#123456", webcamBlurIntensity: 0.9, - cursor: { size: 5, smoothing: 0.1, motionBlur: 0.9, clickBounce: 4, clipToBounds: true }, + cursor: { + size: 5, + smoothing: 0.1, + motionBlur: 0.9, + clickBounce: 4, + volume: 0.6, + clipToBounds: true, + }, cursorShow: false, cursorAutoHide: true, cursorTheme: "default", diff --git a/src/lib/ai-edition/stylePresetsEditor.ts b/src/lib/ai-edition/stylePresetsEditor.ts index 898c63fab..e3dc79d58 100644 --- a/src/lib/ai-edition/stylePresetsEditor.ts +++ b/src/lib/ai-edition/stylePresetsEditor.ts @@ -34,6 +34,7 @@ export function stylePresetAppearanceFromSettings( smoothing: settings.cursor.smoothing, motionBlur: settings.cursor.motionBlur, clickBounce: settings.cursor.clickBounce, + volume: settings.cursor.volume, clipToBounds: settings.cursor.clipToBounds, }, cursorShow: settings.cursorShow, diff --git a/src/lib/projectDefaults.ts b/src/lib/projectDefaults.ts index 2deb8687d..b78b67dad 100644 --- a/src/lib/projectDefaults.ts +++ b/src/lib/projectDefaults.ts @@ -20,6 +20,7 @@ export interface ProjectAppearanceDefaults { smoothing: number; motionBlur: number; clickBounce: number; + volume: number; clipToBounds: boolean; autoHide: boolean; }; @@ -52,6 +53,7 @@ export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = { smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, + volume: 0, clipToBounds: false, autoHide: false, }, diff --git a/src/native/browserShim.presets.test.ts b/src/native/browserShim.presets.test.ts index 35172982f..5678db9ff 100644 --- a/src/native/browserShim.presets.test.ts +++ b/src/native/browserShim.presets.test.ts @@ -25,7 +25,14 @@ const APPEARANCE: StylePresetAppearance = { webcamBackgroundMode: "none", webcamWallpaper: "#ffffff", webcamBlurIntensity: 0.5, - cursor: { size: 3, smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, clipToBounds: false }, + cursor: { + size: 3, + smoothing: 0.67, + motionBlur: 0.35, + clickBounce: 2.5, + volume: 0, + clipToBounds: false, + }, cursorShow: true, cursorAutoHide: false, cursorTheme: "default", diff --git a/src/native/nativeCompositorStore.test.ts b/src/native/nativeCompositorStore.test.ts index e0a741da0..33b404973 100644 --- a/src/native/nativeCompositorStore.test.ts +++ b/src/native/nativeCompositorStore.test.ts @@ -38,6 +38,7 @@ const ADDON_KEYS = [ "webcamSize", "cursorSize", "cursorClickBounce", + "cursorVolume", "cursorSmoothing", "cursorMotionBlur", "backgroundColor", @@ -79,10 +80,11 @@ describe("pushAllNativeParams", () => { // here would scale the preview by 10 or 100 on load — a regression that // would look like "the cursor is enormous when I open a project". const pushed = await pushWith({ - cursor: { size: 3, clickBounce: 2.5, smoothing: 0.67, motionBlur: 0.35 }, + cursor: { size: 3, clickBounce: 2.5, volume: 0.4, smoothing: 0.67, motionBlur: 0.35 }, }); expect(pushed.get("cursorSize")).toBe(3); expect(pushed.get("cursorClickBounce")).toBe(2.5); + expect(pushed.get("cursorVolume")).toBeCloseTo(0.4, 5); expect(pushed.get("cursorSmoothing")).toBeCloseTo(0.67, 5); expect(pushed.get("cursorMotionBlur")).toBeCloseTo(0.35, 5); }); diff --git a/src/native/nativeCompositorStore.ts b/src/native/nativeCompositorStore.ts index 27b72f49e..01adc73b8 100644 --- a/src/native/nativeCompositorStore.ts +++ b/src/native/nativeCompositorStore.ts @@ -146,6 +146,7 @@ export function pushAllNativeParams(settings: EditorSettingsSnapshot): void { setNativeParam("cursorAutoHide", settings.cursorAutoHide); setNativeParam("cursorSize", settings.cursor.size); setNativeParam("cursorClickBounce", settings.cursor.clickBounce); + setNativeParam("cursorVolume", settings.cursor.volume); setNativeParam("cursorSmoothing", settings.cursor.smoothing); setNativeParam("cursorMotionBlur", settings.cursor.motionBlur); } diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index 26bc6e986..6f807b4e6 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -1012,6 +1012,12 @@ describe("buildSceneDescription.settings mapping", () => { expect(buildSceneDescription(high).layout.webcamSize).toBeCloseTo(0.5, 5); }); + it("keeps the cursor flat for a project saved before cursor volume, and clamps it", () => { + expect(buildSceneDescription(makeDoc({ legacyEditor: {} })).cursor.volume).toBe(0); + const tooDeep = makeDoc({ legacyEditor: { cursorVolume: 3 } }); + expect(buildSceneDescription(tooDeep).cursor.volume).toBe(1); + }); + it("maps the cursor sub-settings", () => { const doc = makeDoc({ legacyEditor: { @@ -1019,10 +1025,12 @@ describe("buildSceneDescription.settings mapping", () => { cursorSmoothing: 0.9, cursorMotionBlur: 0.5, cursorClickBounce: 1.5, + cursorVolume: 0.7, cursorClipToBounds: true, }, }); const cursor = buildSceneDescription(doc).cursor; + expect(cursor.volume).toBe(0.7); expect(cursor.size).toBe(4); expect(cursor.smoothing).toBe(0.9); expect(cursor.motionBlur).toBe(0.5); diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index 945d93e20..13d05d3ae 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -374,6 +374,8 @@ export interface SceneCursor { /** 0..1. */ motionBlur: number; clickBounce: number; + /** 0..1 cursor extrusion + contact shadow (`scene.rs` `SceneCursor::volume`). 0 = flat. */ + volume: number; clipToBounds: boolean; /** Cursor theme id (sprite set). */ theme: string; @@ -1036,6 +1038,7 @@ export function buildSceneDescription( smoothing: settings.cursor.smoothing, motionBlur: settings.cursor.motionBlur, clickBounce: settings.cursor.clickBounce, + volume: settings.cursor.volume, clipToBounds: settings.cursor.clipToBounds, theme: settings.cursorTheme, }, From 97846c25f704f7393f5e9fc1e94f106aaf8aab1e Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 17 Sep 2026 19:53:35 +0200 Subject: [PATCH 06/11] feat(compositor): expose programme time to the frame geometry Formerly #687. --- crates/compositor/src/compositor_linux.rs | 19 ++- crates/compositor/src/compositor_macos.rs | 15 ++ crates/compositor/src/compositor_windows.rs | 16 ++ crates/compositor/src/frame_geometry.rs | 43 ++++- crates/compositor/src/live.rs | 34 +++- crates/compositor/src/regions.rs | 153 ++++++++++++++++++ crates/compositor/src/timeline_walk.rs | 4 + .../compositor/tests/programme_time_seek.rs | 104 ++++++++++++ 8 files changed, 381 insertions(+), 7 deletions(-) create mode 100644 crates/compositor/tests/programme_time_seek.rs diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 6a6ae128a..aab2efffd 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -3,8 +3,8 @@ //! Equivalent Linux de `compositor_windows.rs` / `compositor_macos.rs` : meme //! surface publique (`Compositor::{new, new_sized, normalize_render_size, //! render_size, set_scene, set_live_params, set_cursor, set_cursor_time, -//! set_timeline_time, clear_cursor, scene_snapshot, clear_srv_cache, -//! compose_frame, readback_direct}`) pour que `live.rs` et `compositor-view-napi` +//! set_timeline_time, set_programme_time, clear_cursor, scene_snapshot, +//! clear_srv_cache, compose_frame, readback_direct}`) pour que `live.rs` et `compositor-view-napi` //! (cfg-re-exportes via `crate::compositor`) l'utilisent sans connaitre la //! plateforme. S'y ajoutent, specifiques a ce backend, les trois entrees de la //! ring de staging (`set_readback_depth`, `readback_submit`, `readback_take`) : @@ -298,6 +298,8 @@ pub struct Compositor { cursor: RefCell>, cursor_time: RefCell>, timeline_time: RefCell>, + /// Temps programme (secondes de sortie) -- cf. `FrameGeometryInput::programme_time`. + programme_time: RefCell>, /// Rasterizer de texte (annotations mode 11). `None` si l'init cosmic-text /// echoue -- le rendu continue sans texte plutot que de tout casser. @@ -670,6 +672,7 @@ impl Compositor { cursor: RefCell::new(None), cursor_time: RefCell::new(None), timeline_time: RefCell::new(None), + programme_time: RefCell::new(None), text_raster: crate::text::TextRasterizer::new().ok(), img_cache: RefCell::new(std::collections::HashMap::new()), img_tick: std::cell::Cell::new(0), @@ -980,6 +983,17 @@ impl Compositor { *self.timeline_time.borrow_mut() = t; } + pub fn set_programme_time(&self, t: Option) { + *self.programme_time.borrow_mut() = t; + } + + /// Dernier temps programme reçu : pour que les tests vérifient ce qui atteint vraiment + /// `FrameGeometryInput`, pas seulement ce que l'appelant croit envoyer. + #[doc(hidden)] + pub fn programme_time(&self) -> Option { + *self.programme_time.borrow() + } + pub fn clear_cursor(&self) { *self.cursor.borrow_mut() = None; } @@ -1908,6 +1922,7 @@ impl Compositor { scene: scene_ref.as_ref(), cursor: cursor_ref.as_ref(), timeline_t_override: *self.timeline_time.borrow(), + programme_time: *self.programme_time.borrow(), }); // (`wtw`/`wth` sont les dims de la TEXTURE webcam, consommees par le // cover-crop du calque PiP plus bas.) diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 2ec061a4c..a17a67342 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -262,6 +262,8 @@ pub struct Compositor { cursor: RefCell>, cursor_time: RefCell>, timeline_time: RefCell>, + /// Temps programme (secondes de sortie) — cf. `FrameGeometryInput::programme_time`. + programme_time: RefCell>, live_params: RefCell, metal_texture_cache: CVMetalTextureCache, /// Dernier command buffer soumis, gardé pour pouvoir l'attendre AU MOMENT où le CPU lit @@ -611,6 +613,7 @@ impl Compositor { cursor: RefCell::new(None), cursor_time: RefCell::new(None), timeline_time: RefCell::new(None), + programme_time: RefCell::new(None), live_params: RefCell::new(LiveParams::default()), metal_texture_cache: cache, last_cmd: RefCell::new(None), @@ -686,6 +689,17 @@ impl Compositor { *self.timeline_time.borrow_mut() = t; } + pub fn set_programme_time(&self, t: Option) { + *self.programme_time.borrow_mut() = t; + } + + /// Dernier temps programme reçu : pour que les tests vérifient ce qui atteint vraiment + /// `FrameGeometryInput`, pas seulement ce que l'appelant croit envoyer. + #[doc(hidden)] + pub fn programme_time(&self) -> Option { + *self.programme_time.borrow() + } + pub fn clear_cursor(&self) { *self.cursor.borrow_mut() = None; } @@ -1955,6 +1969,7 @@ impl Compositor { scene: scene_ref.as_ref(), cursor: cursor_ref.as_ref(), timeline_t_override: *self.timeline_time.borrow(), + programme_time: *self.programme_time.borrow(), }); let cmd_buf = self.gpu.context.new_command_buffer(); diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 99a64b8c6..f3196b41e 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -132,6 +132,8 @@ pub struct Compositor { /// entre clips : les régions projetées par l'app portent elles aussi des temps source. /// Séparé de l'override curseur pour préserver les chemins fixture sans télémétrie. timeline_t_override: RefCell>, + /// Temps programme (secondes de sortie) — cf. `FrameGeometryInput::programme_time`. + programme_time: RefCell>, // cache des SRV décodeur par (texture array, slice) : le pool réutilise ~32 textures, // donc après warmup plus aucune création de SRV par frame (overhead CPU supprimé). srv_cache: RefCell>, @@ -606,6 +608,7 @@ impl Compositor { cursor: RefCell::new(None), cursor_t_override: RefCell::new(None), timeline_t_override: RefCell::new(None), + programme_time: RefCell::new(None), srv_cache: RefCell::new(HashMap::new()), live_params: RefCell::new(LiveParams::default()), scene: RefCell::new(None), @@ -1391,6 +1394,18 @@ impl Compositor { *self.timeline_t_override.borrow_mut() = t; } + /// Voir `programme_time`. `None` restaure le comportement fixture (`frame / FPS`). + pub fn set_programme_time(&self, t: Option) { + *self.programme_time.borrow_mut() = t; + } + + /// Dernier temps programme reçu : pour que les tests vérifient ce qui atteint vraiment + /// `FrameGeometryInput`, pas seulement ce que l'appelant croit envoyer. + #[doc(hidden)] + pub fn programme_time(&self) -> Option { + *self.programme_time.borrow() + } + /// Copie de la scène courante (si présente) — utilisé par l'export multiclip pour lire les /// réglages curseur (thème/lissage/show) sans dupliquer le contrat de scène côté pipeline. pub fn scene_snapshot(&self) -> Option { @@ -1604,6 +1619,7 @@ impl Compositor { scene: scene_ref.as_ref(), cursor: cursor_ref.as_ref(), timeline_t_override: *self.timeline_t_override.borrow(), + programme_time: *self.programme_time.borrow(), }); let scene_preset = g.scene_preset.clone(); let mb_taps = g.mb_taps; diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 70cb33dbf..af6a47549 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -895,6 +895,10 @@ pub struct FrameGeometryInput<'a> { pub scene: Option<&'a Scene>, pub cursor: Option<&'a crate::cursor::CursorTrack>, pub timeline_t_override: Option, + /// Temps PROGRAMME (secondes de sortie), continu à travers les coupes et les clips : + /// `frames / out_fps` à l'export, `regions::ProgrammeClock` en preview. `None` = fixture + /// (`frame / FPS`). Voir `FrameGeometry::programme_t`. + pub programme_time: Option, } /// Les 15 valeurs que la moitié « dessin » consomme. Sur les 75 locaux que le calcul @@ -905,6 +909,11 @@ pub struct FrameGeometry { pub mb_taps: f32, pub mb_amount: f32, pub source_t: f32, + /// Horloge des effets qui vivent sur la SORTIE et non sur la source (fond animé) : + /// `source_t` saute à chaque coupe et à chaque clip, pas elle. Recopiée telle quelle de + /// l'entrée, jamais déduite de `frame` — en preview ce compteur n'est qu'un tick, qui + /// diffère entre lecture et seek pour la même image. + pub programme_t: f32, /// Rotation 3D de BASE (préset × force) : elle seule fixe l'échelle de containment et le /// choix mode 0 / mode 8. pub zoom_rotation: [f32; 3], @@ -1566,6 +1575,7 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { mb_taps, mb_amount, source_t, + programme_t: input.programme_time.unwrap_or(frame / FPS), zoom_rotation, zoom_rotation_dyn, padding_scale, @@ -2048,6 +2058,8 @@ mod tests { scene: Some(scene), cursor: None, timeline_t_override: Some(1.5), + // Distinct de `source_t` et de `frame / FPS` : un croisement des trois se verrait. + programme_time: Some(2.25), } } @@ -2551,18 +2563,20 @@ mod tests { g.cut[0], g.cut[1], g.cut[2], g.cut[3], g.s_radius, g.w_radius, g.w_px[0], g.w_px[1], g.frame_min_px, g.padding_scale, g.shape_fade, g.mb_taps, g.source_t, + g.programme_t, ]; - // Valeurs mesurées, pas devinées : toute dérive est une divergence à expliquer, - // pas un seuil à relâcher. // Mesuré sur ce code, pas deviné : toute dérive est une divergence à expliquer, // pas un seuil à relâcher. Ordre : s_dst[4], w_dst[4], cut[4], s_radius, w_radius, - // w_px[2], frame_min_px, padding_scale, shape_fade, mb_taps, source_t. - let want: [f32; 21] = [ + // w_px[2], frame_min_px, padding_scale, shape_fade, mb_taps, source_t, programme_t. + // `programme_t` ajouté avec l'horloge programme : c'est une recopie de l'entrée, les + // 21 valeurs d'avant n'ont pas bougé d'un bit. + let want: [f32; 22] = [ 0.10207555, 0.102, 0.7958489, 0.796, 0.9058473, 0.8325926, 0.0733194, 0.13037036, 0.0, 0.0, 0.61, 0.6055147, 16.779, 42.89185, 85.7837, 85.7837, 658.0, 0.796, 1.0, 6.25, 1.5, + 2.25, ]; for (i, (a, b)) in got.iter().zip(want.iter()).enumerate() { assert_eq!( @@ -2573,6 +2587,26 @@ mod tests { } } + /// En preview, la même image arrive avec deux compteurs `frame` différents selon qu'on y + /// vient en lecture (`idx` incrémenté) ou par un seek (`idx` dérivé du temps). Le temps + /// programme ne doit dépendre que de l'instant fourni, jamais de ce compteur. + #[test] + fn programme_time_ignores_the_frame_counter() { + let scene = golden_scene(); + let cfg = crate::config::all().pop().expect("au moins une config"); + let mut played = golden_input(&scene, &cfg); + played.frame = 7.0; + let mut sought = golden_input(&scene, &cfg); + sought.frame = 90.0; + assert_eq!( + plan_frame(&played).programme_t.to_bits(), + plan_frame(&sought).programme_t.to_bits() + ); + // Sans horloge programme (bench/fixture), repli sur le compteur comme `source_t`. + sought.programme_time = None; + assert_eq!(plan_frame(&sought).programme_t, 90.0 / FPS); + } + /// Le contrat cross-backend, verrouillé octet par octet. Un shader qui lit un champ /// décalé ne lève rien : il rend faux, en silence. #[test] @@ -2982,6 +3016,7 @@ mod tests { mb_taps: 1.0, mb_amount: 0.0, source_t: 0.0, + programme_t: 0.0, zoom_rotation: [0.0, 0.0, 0.0], zoom_rotation_dyn: [0.0, 0.0, 0.0], padding_scale: 1.0, diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index f476a40b8..d77a2227d 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -23,7 +23,7 @@ //! le napi — le `gen` est l'identité de la frame (cf. `LatestFrame`). use crate::compositor::{Compositor, LiveParams}; -use crate::regions::speed_at; +use crate::regions::{speed_at, ProgrammeClock}; use crate::scene::Scene; use crate::config::{self, Cfg}; use crate::cursor::CursorTrack; @@ -288,6 +288,9 @@ pub struct Player { has_current_frame: bool, use_current_on_next_step: bool, idx: u32, + /// Horloge programme du clip ACTIF, posée par `render_thread` à chaque changement de + /// clip ou de scène (`set_programme_clock`). `None` = pas de scène : fixture. + programme: Option, } impl Player { @@ -307,6 +310,7 @@ impl Player { has_current_frame: false, use_current_on_next_step: false, idx: 0, + programme: None, }) } @@ -425,6 +429,27 @@ impl Player { self.sdec.cur_time_sec() } + /// Recalcule l'horloge programme pour le clip `clip_index` de `scene` (la scène COMPLÈTE, + /// pas la fenêtre d'un clip : il faut la durée de sortie des clips précédents). + /// + /// Cadence : `scene.output.fps`, sinon celle de la source, arrondie — le repli de l'export. + /// ponytail: l'export prend la source du PREMIER clip et, depuis la modale, sa propre + /// cadence ; un écart ne décale le temps programme que d'au plus une frame par span de + /// vitesse (arrondi `ceil` des spans). À aligner si un effet devient sensible à la frame. + pub unsafe fn set_programme_clock(&mut self, scene: Option<&Scene>, clip_index: usize) { + self.programme = scene.and_then(|s| { + let fps = s.output.fps.unwrap_or_else(|| self.sdec.fps().round().max(1.0)); + ProgrammeClock::for_clip(s, clip_index, fps) + }); + } + + /// Temps programme de la frame écran courante : fonction de son seul pts, donc identique + /// qu'on y arrive en lecture (`step`) ou par un seek (`present_frame`). + pub unsafe fn programme_time(&self) -> Option { + let t = self.sdec.cur_time_sec(); + self.programme.as_ref().map(|c| c.at(t) as f32) + } + /// Compose la PROCHAINE frame due (→ `comp.rt`), au plus une, si `target_source_time` /// (temps écran) est atteint. Sémantique de "hold" : `false` sans rien composer quand la /// frame suivante n'est pas encore due — l'appelant garde alors l'image déjà affichée, @@ -548,6 +573,7 @@ impl Player { let t = self.sdec.cur_time_sec() as f32; comp.set_cursor_time(Some(t)); comp.set_timeline_time(Some(t)); + comp.set_programme_time(self.programme_time()); } /// Recompose la frame courante (déjà décodée) — rafraîchit après un changement de param. @@ -1274,6 +1300,7 @@ unsafe fn advance_to_next_scene_clip( *active_webcam_offset_sec = next_clip.webcam_offset_sec; *active_clip_index = next_index; comp.set_scene(Some(scene_for_clip(scene, *active_clip_index))); + player.set_programme_clock(Some(scene), *active_clip_index); // Réutilise le curseur préchargé s'il est disponible (voir plus haut) — sinon // (préchargement pas encore prêt / raté) on retombe sur la lecture synchrone // habituelle, comme avant cette optimisation. @@ -1573,6 +1600,11 @@ unsafe fn render_thread( }); comp.set_scene(scene); } + // Le temps programme dépend du clip actif ET de la scène entière (durées des clips + // précédents) : à recalculer dès que l'un des deux change. + if clip_changed || scene_changed { + player.set_programme_clock(full_scene.as_ref(), active_clip_index); + } // résolution cible du preview (le canvas Electron) → force le recadrage des // ressources GPU si elle change. BUG évité : sans ce suivi, redimensionner le diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs index 7ea8dee1f..a715a4cbd 100644 --- a/crates/compositor/src/regions.rs +++ b/crates/compositor/src/regions.rs @@ -93,6 +93,70 @@ pub fn speed_at(regions: &[SceneSpeedRegion], active_clip_index: usize, t: f64) 1.0 } +/// Le temps PROGRAMME (secondes de sortie) d'un clip, en fonction de son temps source. +/// +/// L'export le connaît sans calcul : c'est `frames / out_fps`, le compteur de +/// `walk_composited_timeline`. La preview n'a pas ce compteur — sa lecture libre avance au pts +/// du décodeur et le renderer ne pousse un seek qu'en pause — donc elle le RECONSTRUIT depuis +/// la scène : les frames des clips précédents, puis celles des spans de vitesse du clip actif, +/// avec les mêmes `speed_segments_for_window` que l'export. Pour un temps source que l'export +/// vise (`start + k·speed/fps`), `at` rend exactement `(frames avant + k) / fps`. +/// +/// Fonction pure du temps source : la même image donne le même temps programme, qu'on y +/// arrive en lecture ou par un seek. C'est tout ce que l'horloge garantit — pas un intégrateur. +/// +/// ponytail: fenêtres déclarées, pas bornées à la durée réelle du fichier comme le fait +/// l'export (`available_duration_sec`) ; une source plus courte que sa fenêtre décale les +/// clips suivants d'autant. Même limite que `outputFrameCount.ts`. +#[derive(Debug, Clone)] +pub struct ProgrammeClock { + /// Frames de sortie de tous les clips qui précèdent. + frames_before: u64, + segments: Vec, + fps: f64, +} + +impl ProgrammeClock { + /// `None` si l'index ne désigne aucun clip ou si `fps` n'est pas utilisable. + pub fn for_clip(scene: &crate::scene::Scene, clip_index: usize, fps: f64) -> Option { + if !fps.is_finite() || fps <= 0.0 || clip_index >= scene.clips.len() { + return None; + } + // Même appartenance que `Scene::for_clip_window` : l'index décide quand il est là, + // sinon le chevauchement de fenêtre (que `speed_segments_for_window` filtre déjà). + let segments_of = |i: usize| { + let clip = &scene.clips[i]; + let regions: Vec = scene + .speed_regions + .iter() + .filter(|r| r.clip_index.map(|c| c == i).unwrap_or(true)) + .copied() + .collect(); + speed_segments_for_window(®ions, clip.source_start_sec, clip.source_end_sec, fps) + }; + let frames_before = (0..clip_index) + .flat_map(&segments_of) + .map(|s| s.frame_count) + .sum(); + Some(Self { frames_before, segments: segments_of(clip_index), fps }) + } + + /// Temps programme au temps source `source_sec` du clip. Borné à la fenêtre du clip : + /// avant, le premier frame ; après (ou sous une coupe en fin), le premier du clip suivant. + pub fn at(&self, source_sec: f64) -> f64 { + let mut frames = self.frames_before as f64; + for s in &self.segments { + if source_sec < s.end_sec { + let k = ((source_sec - s.start_sec).max(0.0) / s.speed * self.fps) + .min(s.frame_count as f64); + return (frames + k) / self.fps; + } + frames += s.frame_count as f64; + } + frames / self.fps + } +} + fn push_speed_segment( spans: &mut Vec, start_sec: f64, @@ -1940,3 +2004,92 @@ mod exporter_frame_totals { assert_eq!(frames(0.0, 10.0, &[], 0.0), 0, "fps non positif"); } } + +#[cfg(test)] +mod programme_clock { + use super::*; + use crate::scene::Scene; + + const FPS: f64 = 30.0; + + /// Deux clips du même fichier, une région de vitesse chacun, et une région du clip 1 posée + /// dans la fenêtre du clip 0 : seul son `clipIndex` dit qu'elle n'est pas à lui. + fn scene() -> Scene { + Scene::from_json( + r##"{ + "clips":[{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":4,"webcamOffsetSec":0,"hasAudio":false}, + {"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":10,"sourceEndSec":13,"webcamOffsetSec":0,"hasAudio":false}], + "layout":{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}, + "effects":{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}, + "background":{"kind":"color","color":"#000000"}, + "zoomRegions":[], + "speedRegions":[{"clipIndex":0,"startSec":1.0,"endSec":2.0,"speed":2.0}, + {"clipIndex":1,"startSec":0.5,"endSec":3.5,"speed":4.0}, + {"clipIndex":1,"startSec":11.0,"endSec":12.0,"speed":0.5}], + "cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}, + "cropByClip":[null,null], + "output":{"width":1920,"height":1080,"fps":null} + }"##, + ) + .expect("scène") + } + + /// La preview et l'export tombent sur le même temps programme : pour chaque frame que la + /// marche d'export compose (mêmes spans, même cible source, même `frames / out_fps`), + /// l'horloge interrogée au seul temps source rend la même valeur. + #[test] + fn the_clock_matches_the_export_walk_at_every_frame() { + let scene = scene(); + let mut frames: u64 = 0; + for (i, clip) in scene.clips.iter().enumerate() { + let window = scene.for_clip_window(i, clip.source_start_sec, clip.source_end_sec); + let clock = ProgrammeClock::for_clip(&scene, i, FPS).expect("horloge"); + let segments = speed_segments_for_window( + &window.speed_regions, + clip.source_start_sec, + clip.source_end_sec, + FPS, + ); + for s in &segments { + for k in 0..s.frame_count { + let target = s.start_sec + k as f64 * s.speed / FPS; + let export = (frames as f64 / FPS) as f32; + let preview = clock.at(target) as f32; + assert!( + (export - preview).abs() <= 1e-6, + "clip {i} frame {frames} (source {target}) : export {export}, preview {preview}" + ); + frames += 1; + } + } + } + // 1 s à 1×, 1 s à 2×, 2 s à 1× ; puis 1 s à 1×, 1 s à 0,5×, 1 s à 1×. + assert_eq!( + frames, + 30 + 15 + 60 + 30 + 60 + 30, + "garde : la région du clip 1 posée dans la fenêtre du clip 0 doit être ignorée" + ); + } + + /// Le temps programme ne saute pas à la frontière de clip et ne recule jamais. + #[test] + fn the_clock_is_continuous_across_clips_and_clamped_to_the_window() { + let scene = scene(); + let first = ProgrammeClock::for_clip(&scene, 0, FPS).expect("clip 0"); + let second = ProgrammeClock::for_clip(&scene, 1, FPS).expect("clip 1"); + assert_eq!(first.at(0.0), 0.0); + assert_eq!(first.at(4.0), second.at(10.0)); + assert_eq!(second.at(10.0), 105.0 / FPS); + // Hors fenêtre (sous une coupe) : borné, jamais extrapolé. + assert_eq!(second.at(3.0), second.at(10.0)); + assert_eq!(second.at(99.0), 225.0 / FPS); + let mut last = -1.0; + for step in 0..=1300 { + let t = second.at(step as f64 / 100.0); + assert!(t >= last, "recul à {step}"); + last = t; + } + assert!(ProgrammeClock::for_clip(&scene, 2, FPS).is_none()); + assert!(ProgrammeClock::for_clip(&scene, 0, 0.0).is_none()); + } +} diff --git a/crates/compositor/src/timeline_walk.rs b/crates/compositor/src/timeline_walk.rs index fe3a2e795..67218ccf8 100644 --- a/crates/compositor/src/timeline_walk.rs +++ b/crates/compositor/src/timeline_walk.rs @@ -325,6 +325,9 @@ pub(crate) unsafe fn walk_composited_timeline( } comp.set_timeline_time(Some(target_source_time as f32)); + // Le temps de SORTIE, lui, ne saute ni aux coupes ni aux clips. Même + // arithmétique (f64 puis f32) que `ProgrammeClock::at` côté preview. + comp.set_programme_time(Some((frames as f64 / out_fps as f64) as f32)); if cursor_enabled && cursor_active_path.is_some() { comp.set_cursor_time(Some(target_source_time as f32)); } @@ -348,6 +351,7 @@ pub(crate) unsafe fn walk_composited_timeline( comp.set_cursor_time(None); comp.set_timeline_time(None); + comp.set_programme_time(None); // Le compositeur est réutilisé par la preview après un export : lui rendre sa cadence. comp.set_segmentation_deterministic(false); Ok(frames) diff --git a/crates/compositor/tests/programme_time_seek.rs b/crates/compositor/tests/programme_time_seek.rs new file mode 100644 index 000000000..7357dede9 --- /dev/null +++ b/crates/compositor/tests/programme_time_seek.rs @@ -0,0 +1,104 @@ +//! Le temps programme de la preview est une fonction de l'image affichée : y arriver en +//! lecture libre (`Player::step`) ou par un seek (`Player::present_frame`) donne le même. +//! +//! Rend de vraies frames par le compositeur D3D11, sur une vraie source (pts réels du +//! décodeur). Vérifie la valeur que le compositeur a REÇUE (celle que lit `plan_frame`), pas +//! celle du player : le lien Player -> Compositor n'a pas de test sans GPU. +//! +//! La CI ne le joue pas : il faut D3D11 et une source vidéo, sinon il se saute. Piloté par l'environnement, comme `privacy_blur_under_zoom.rs`, dont il réutilise +//! la source (n'importe quel MP4 d'au moins 6 s convient) : +//! +//! ```powershell +//! $env:OPENSCREEN_PRIVACY_SECRET = "...\secret.mp4" +//! cargo test -p openscreen-compositor --test programme_time_seek -- --nocapture +//! ``` + +// Windows seulement : le décodage D3D11VA de ce harnais n'existe que là. +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::frame_geometry::live_params_from_scene; +use openscreen_compositor::live::Player; +use openscreen_compositor::scene::Scene; + +/// Deux coupes du même fichier, la seconde jouée par le player, avec une région 2× dedans : +/// le temps programme doit compter la durée de sortie de la première et la vitesse. +fn scene_json(source: &str) -> String { + let s = source.replace('\\', "/"); + format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":1.5,"webcamOffsetSec":0,"hasAudio":false}}, + {{"screenPath":"{s}","webcamPath":"","sourceStartSec":2,"sourceEndSec":6,"webcamOffsetSec":0,"hasAudio":false}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}}, + "effects": {{"padding":0.1,"blur":false,"shadow":0.0,"roundnessFrac":0,"motionBlur":0.0}}, + "background": {{"kind":"color","color":"#303030"}}, + "zoomRegions": [], + "speedRegions": [{{"clipIndex":1,"startSec":3,"endSec":4,"speed":2}}], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null, null], + "output": {{"width":480,"height":270,"fps":30}} + }}"## + ) +} + +#[test] +fn playback_and_seek_give_the_same_programme_time() { + let Ok(source) = 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 mut cfg = config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + let comp = Compositor::new_sized(&gpu, 480, 270).expect("compositor"); + let scene = Scene::from_json(&scene_json(&source)).expect("scene valide"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene.clone())); + comp.clear_cursor(); + + unsafe { + let mut player = Player::open(&source, "", &gpu).expect("ouvrir la source"); + player.set_programme_clock(Some(&scene), 1); + + // Lecture libre depuis le début du clip 1, une image à la fois. + assert!(player.present_frame(&comp, &cfg, 2.0).expect("seek initial")); + // Horloge de lecture avancée à pas fixe, comme l'accumulateur de `render_thread`. + let mut played = Vec::new(); + let mut target = 2.0; + while target < 5.0 { + target += 1.0 / 60.0; + // Effacé avant chaque appel : c'est l'appel lui-même qui doit le poser. + comp.set_programme_time(None); + if player.step(&comp, &cfg, target).expect("step") { + let programme = comp.programme_time().expect("step n'a pas transmis le temps programme"); + assert_eq!(Some(programme), player.programme_time(), "garde : compositeur et player divergent"); + played.push((player.screen_time_sec(), programme)); + } + } + assert!(played.len() > 60, "garde : trop peu d'images jouées ({})", played.len()); + + // Les mêmes images, atteintes par un seek. + for &(pts, programme) in played.iter().step_by(7) { + comp.set_programme_time(None); + assert!(player.present_frame(&comp, &cfg, pts).expect("seek")); + assert_eq!(player.screen_time_sec(), pts, "garde : le seek doit retomber sur l'image"); + let sought = comp.programme_time().expect("le seek n'a pas transmis le temps programme"); + assert_eq!(sought.to_bits(), programme.to_bits(), "pts {pts} : lecture {programme}, seek {sought}"); + } + + // Le clip 0 dure 1,5 s en sortie ; la région 2× compresse 3..4 s en 0,5 s. + let at = |pts: f64| played.iter().find(|(t, _)| (*t - pts).abs() < 1e-3).map(|p| p.1); + let first = played[0]; + println!("premiere image : pts {} -> programme {}", first.0, first.1); + assert!((first.1 as f64 - (1.5 + (first.0 - 2.0))).abs() < 1e-4); + if let (Some(a), Some(b)) = (at(3.0), at(4.0)) { + assert!((b - a - 0.5).abs() < 1e-4, "2x : 3..4 s source -> {} s programme", b - a); + } + let last = played[played.len() - 1]; + println!("derniere image : pts {} -> programme {}", last.0, last.1); + assert!((last.1 as f64 - (1.5 + 1.0 + 0.5 + (last.0 - 4.0))).abs() < 1e-4); + } +} From 561c6f2577777b3e1f36e14934aa0ace233840bc Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 17 Sep 2026 19:53:35 +0200 Subject: [PATCH 07/11] feat(background): animate the gradient wallpapers Formerly #691. --- crates/compositor/src/compositor_linux.rs | 10 +- crates/compositor/src/compositor_macos.rs | 10 +- crates/compositor/src/compositor_windows.rs | 13 +- crates/compositor/src/frame_geometry.rs | 47 ++++++ crates/compositor/src/scene.rs | 37 ++++- crates/compositor/src/shaders.hlsl | 69 +++++++++ crates/compositor/src/shaders.metal | 65 +++++++++ crates/compositor/src/vk_shaders/layer.wgsl | 67 ++++++++- .../compositor/tests/animated_background.rs | 134 ++++++++++++++++++ crates/fixture/scene-gradient-aurora.json | 37 +++++ crates/fixture/scene-gradient-still.json | 32 +++++ crates/poc-d3d/src/bench.rs | 8 +- .../ai-edition/style-preset-service.test.ts | 1 + electron/ipc/nativeBridge.presets.test.ts | 1 + .../RightPanes.backgroundMotion.test.tsx | 66 +++++++++ src/components/ai-edition/RightPanes.tsx | 42 ++++++ .../video-editor/projectPersistence.test.ts | 11 ++ .../video-editor/projectPersistence.ts | 6 + src/components/video-editor/types.ts | 10 ++ src/i18n/locales/ar/settings.json | 6 + src/i18n/locales/cs/settings.json | 6 + src/i18n/locales/en/settings.json | 6 + src/i18n/locales/es/settings.json | 6 + src/i18n/locales/fr/settings.json | 6 + src/i18n/locales/it/settings.json | 6 + src/i18n/locales/ja-JP/settings.json | 6 + src/i18n/locales/ko-KR/settings.json | 6 + src/i18n/locales/pt-BR/settings.json | 6 + src/i18n/locales/ru/settings.json | 6 + src/i18n/locales/tr/settings.json | 6 + src/i18n/locales/vi/settings.json | 6 + src/i18n/locales/zh-CN/settings.json | 6 + src/i18n/locales/zh-TW/settings.json | 6 + src/lib/ai-edition/document/migrate.test.ts | 16 +++ src/lib/ai-edition/document/migrate.ts | 1 + .../ai-edition/store/editorSettings.test.ts | 8 ++ src/lib/ai-edition/store/editorSettings.ts | 9 ++ src/lib/ai-edition/stylePresets.test.ts | 10 ++ src/lib/ai-edition/stylePresets.ts | 17 ++- src/lib/ai-edition/stylePresetsEditor.test.ts | 1 + src/lib/ai-edition/stylePresetsEditor.ts | 2 + src/lib/projectDefaults.ts | 2 + src/native/browserShim.presets.test.ts | 1 + src/native/sceneDescription.test.ts | 48 ++++++- src/native/sceneDescription.ts | 29 +++- .../engineering/rendering-performance.md | 40 ++++++ 46 files changed, 913 insertions(+), 21 deletions(-) create mode 100644 crates/compositor/tests/animated_background.rs create mode 100644 crates/fixture/scene-gradient-aurora.json create mode 100644 crates/fixture/scene-gradient-still.json create mode 100644 src/components/ai-edition/RightPanes.backgroundMotion.test.tsx diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index aab2efffd..655864f48 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -1383,7 +1383,8 @@ impl Compositor { Some(SceneBackground::Color { color }) => { flat(solid(parse_hex(color).unwrap_or(BLACK))) } - Some(SceneBackground::Gradient { angle_deg, stops }) => { + // Le mouvement ne vaut que pour le fond d'ecran : la bulle garde son degrade immobile. + Some(SceneBackground::Gradient { angle_deg, stops, .. }) => { let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(BLACK); let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); // angle CSS -> direction unitaire, meme convention que le fond @@ -1938,17 +1939,20 @@ impl Compositor { Some(SceneBackground::Color { color }) => { (parse_hex(&color).unwrap_or(lp.bg_color), None) } - Some(SceneBackground::Gradient { angle_deg, stops }) => { + Some(SceneBackground::Gradient { angle_deg, stops, motion }) => { let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(lp.bg_color); let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); let a = angle_deg.to_radians(); + let (anim, mb) = + crate::frame_geometry::gradient_motion_slots(motion, g.programme_t, rw / rh); let cb = LayerCB { dst: [0.0, 0.0, 1.0, 1.0], src: [c1[0], c1[1], c1[2], c1[3]], quad_px: [rw, rh], mode: 5.0, color: c0, - fx: [a.sin(), -a.cos(), 0.0, 0.0], + fx: [a.sin(), -a.cos(), anim[0], anim[1]], + mb, ..Default::default() }; ([0.0, 0.0, 0.0, 1.0], Some(BgLayer::Gradient(cb))) diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index a17a67342..0b3c6c0ed 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -1016,7 +1016,8 @@ impl Compositor { Some(SceneBackground::Color { color }) => { self.draw_solid(enc, &solid(parse_hex(color).unwrap_or(BLACK))); } - Some(SceneBackground::Gradient { angle_deg, stops }) => { + // Le mouvement ne vaut que pour le fond d'écran : la bulle garde son dégradé immobile. + Some(SceneBackground::Gradient { angle_deg, stops, .. }) => { let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(BLACK); let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); // angle CSS → direction unitaire, même convention que le fond d'écran. @@ -1994,10 +1995,12 @@ impl Compositor { &LayerCB { dst: [0.0, 0.0, 1.0, 1.0], mode: 1.0, color: c, ..Default::default() }, ); } - Some(SceneBackground::Gradient { angle_deg, stops }) => { + Some(SceneBackground::Gradient { angle_deg, stops, motion }) => { let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(lp.bg_color); let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); let a = angle_deg.to_radians(); + let (anim, mb) = + crate::frame_geometry::gradient_motion_slots(motion, g.programme_t, rw / rh); self.draw_solid( enc, &LayerCB { @@ -2005,7 +2008,8 @@ impl Compositor { src: [c1[0], c1[1], c1[2], c1[3]], mode: 5.0, color: c0, - fx: [a.sin(), -a.cos(), 0.0, 0.0], + fx: [a.sin(), -a.cos(), anim[0], anim[1]], + mb, ..Default::default() }, ); diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index f3196b41e..bcc1c81d1 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -968,7 +968,8 @@ impl Compositor { Some(SceneBackground::Color { color }) => { self.draw_solid(&solid(parse_hex(color).unwrap_or(BLACK))); } - Some(SceneBackground::Gradient { angle_deg, stops }) => { + // Le mouvement ne vaut que pour le fond d'écran : la bulle garde son dégradé immobile. + Some(SceneBackground::Gradient { angle_deg, stops, .. }) => { let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(BLACK); let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); // angle CSS → direction unitaire, même convention que le fond d'écran. @@ -1662,19 +1663,25 @@ impl Compositor { ..Default::default() }); } - SceneBackground::Gradient { angle_deg, stops } => { + SceneBackground::Gradient { angle_deg, stops, motion } => { let c0 = stops.first().and_then(|s| parse_hex(s)).unwrap_or(lp.bg_color); let c1 = stops.last().and_then(|s| parse_hex(s)).unwrap_or(c0); // angle CSS → direction unitaire (espace sortie, y vers le bas) : // 0° = vers le haut, 90° = vers la droite. let a = angle_deg.to_radians(); let dir = [a.sin(), -a.cos()]; + let (anim, mb) = crate::frame_geometry::gradient_motion_slots( + motion, + g.programme_t, + self.rw() / self.rh(), + ); self.draw_solid(&LayerCB { dst: [0.0, 0.0, 1.0, 1.0], src: [c1[0], c1[1], c1[2], c1[3]], mode: 5.0, color: c0, - fx: [dir[0], dir[1], 0.0, 0.0], + fx: [dir[0], dir[1], anim[0], anim[1]], + mb, ..Default::default() }); } diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index af6a47549..bb102c92a 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -796,6 +796,36 @@ fn same_source_path(a: &str, b: &str) -> bool { a.eq_ignore_ascii_case(b) } +/// Période commune des mouvements de fond (s). Chaque période du shader (20, 24, 30, 40, 12, +/// 120 s) la divise, donc replier le temps dessus ne crée aucun raccord visible — et garde au +/// shader un temps borné, là où un `f32` de plusieurs heures perdrait la finesse du bruit. +pub const GRADIENT_MOTION_PERIOD_S: f32 = 120.0; + +/// Emplacements libres du mode 5 pour le fond animé : `fx.zw` = (temps programme replié, indice +/// du mouvement), `mb.x` = aspect w/h de la sortie (les nappes de l'aurore restent rondes). +/// +/// `GradientMotion::None` rend les zéros d'avant l'animation, emplacement pour emplacement : le +/// dégradé immobile reste celui d'aujourd'hui, octet pour octet. Fonction pure du temps +/// programme, jamais d'un état porté de frame en frame — preview et export, lecture et seek +/// tombent sur la même image. +pub fn gradient_motion_slots( + motion: crate::scene::GradientMotion, + programme_t: f32, + aspect: f32, +) -> ([f32; 2], [f32; 4]) { + use crate::scene::GradientMotion as M; + let index = match motion { + M::None => return ([0.0, 0.0], [0.0; 4]), + M::Drift => 1.0, + M::Aurora => 2.0, + M::Waves => 3.0, + }; + ( + [programme_t.rem_euclid(GRADIENT_MOTION_PERIOD_S), index], + [aspect, 0.0, 0.0, 0.0], + ) +} + /// True when this clip really has a camera to draw. /// /// TWO ways the app says "no camera", and both must be caught here, because the @@ -2607,6 +2637,23 @@ mod tests { assert_eq!(plan_frame(&sought).programme_t, 90.0 / FPS); } + /// Sans mouvement, les emplacements restent les zéros d'avant (dégradé inchangé) ; avec, + /// le temps est replié sur la période commune et l'indice suit l'ordre du shader. + #[test] + fn gradient_motion_slots_are_zero_when_still_and_wrap_the_clock() { + use crate::scene::GradientMotion as M; + assert_eq!(gradient_motion_slots(M::None, 37.5, 16.0 / 9.0), ([0.0, 0.0], [0.0; 4])); + let (fx, mb) = gradient_motion_slots(M::Drift, 125.0, 2.0); + assert_eq!(fx, [5.0, 1.0]); + assert_eq!(mb, [2.0, 0.0, 0.0, 0.0]); + assert_eq!(gradient_motion_slots(M::Aurora, 0.0, 1.0).0[1], 2.0); + assert_eq!(gradient_motion_slots(M::Waves, 240.0, 1.0).0, [0.0, 3.0]); + // Chaque période du shader divise la période de repli : pas de raccord au bouclage. + for p in [20.0, 24.0, 30.0, 40.0, 12.0, 120.0] { + assert_eq!(GRADIENT_MOTION_PERIOD_S % p, 0.0, "période {p}"); + } + } + /// Le contrat cross-backend, verrouillé octet par octet. Un shader qui lit un champ /// décalé ne lève rien : il rend faux, en silence. #[test] diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 4b606e4bc..3da7e1332 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -173,10 +173,30 @@ pub enum SceneBackground { #[serde(rename = "angleDeg")] angle_deg: f32, stops: Vec, + /// Absent pour un fond immobile : l'app n'émet la clé que si un mouvement est choisi, + /// donc la scène d'un projet sans animation ne bouge pas d'un octet. + #[serde(default)] + motion: GradientMotion, }, Image { path: String }, } +/// Mouvement lent d'un fond dégradé (`settings.wallpaperMotion`), lu par le mode 5 dans `fx.w`. +/// +/// Une valeur inconnue (projet ouvert par une version plus ancienne que celle qui l'a écrit) +/// retombe sur `None` au lieu de faire échouer le parse de toute la scène. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GradientMotion { + Drift, + Aurora, + Waves, + // Dernier : serde n'accepte `other` que sur la dernière variante. + #[default] + #[serde(other)] + None, +} + /// Une annotation de la timeline (temps en secondes, source du clip). /// /// Espace de coordonnées — à respecter au rendu : `x`/`y`/`w`/`h` sont des fractions du **rect @@ -658,9 +678,10 @@ mod tests { assert!(scene.layout.webcam_mirror); assert!((scene.effects.roundness_frac - 0.0222).abs() < 1e-6); match scene.background { - SceneBackground::Gradient { angle_deg, ref stops } => { + SceneBackground::Gradient { angle_deg, ref stops, motion } => { assert_eq!(angle_deg, 135.0); assert_eq!(stops.len(), 2); + assert_eq!(motion, GradientMotion::None); } _ => panic!("expected gradient"), } @@ -683,6 +704,20 @@ mod tests { assert_eq!(s.output.fps, Some(30.0)); } + #[test] + fn gradient_motion_parses_and_tolerates_an_unknown_value() { + let motion_of = |json: &str| match serde_json::from_str::(json).expect("parse") { + SceneBackground::Gradient { motion, .. } => motion, + _ => panic!("expected gradient"), + }; + let g = |m: &str| format!(r##"{{"kind":"gradient","angleDeg":90,"stops":["#000","#fff"]{m}}}"##); + assert_eq!(motion_of(&g("")), GradientMotion::None); + assert_eq!(motion_of(&g(r#","motion":"drift""#)), GradientMotion::Drift); + assert_eq!(motion_of(&g(r#","motion":"aurora""#)), GradientMotion::Aurora); + assert_eq!(motion_of(&g(r#","motion":"waves""#)), GradientMotion::Waves); + assert_eq!(motion_of(&g(r#","motion":"plasma""#)), GradientMotion::None); + } + #[test] fn parses_webcam_rect_payload() { // webcamRect est une fraction 0..1 du cadre de sortie ; sa présence doit désactiver diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index 25a916706..2f822a18d 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -174,6 +174,61 @@ float3 quad_inverse_bilinear(float2 P, float2 c00, float2 c10, float2 c11, float return (r0.z > 0.5) ? r0 : r1; } +// 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) +{ + float3 p3 = frac(float3(p.x, p.y, p.x) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return frac((p3.x + p3.y) * p3.z); +} + +// Bruit de valeur lissé (hermite), sans texture. +float value_noise(float2 q) +{ + float2 i = floor(q); + float2 f = frac(q); + float2 u = f * f * (3.0 - 2.0 * f); + float a = hash12(i); + float b = hash12(i + float2(1.0, 0.0)); + float c = hash12(i + float2(0.0, 1.0)); + float d = hash12(i + float2(1.0, 1.0)); + return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y); +} + +// Mouvements 2 (aurore) et 3 (vagues) du mode 5, dans les deux couleurs des stops seulement. +// `gp` 0..1 sur le quad, `dir`/`denom` ceux du dégradé, `time` = temps programme replié sur +// 120 s (toutes les périodes ci-dessous le divisent), `aspect` = w/h de la sortie. Périodes +// longues et contraste bas : le fond ne doit jamais prendre l'attention. +float3 gradient_motion(float2 gp, float2 dir, float denom, float3 c0, float3 c1, float time, + float motion, float aspect) +{ + const float TAU = 6.2831853; + float u = dot(gp - 0.5, dir) / denom; // position le long de l'axe, -0.5..0.5 + if (motion < 2.5) + { + // Aurore : rampe perturbée par un bruit lent (dont l'origine tourne en 120 s), puis + // trois nappes gaussiennes sur des Lissajous de 20 à 40 s. Coordonnées corrigées de + // l'aspect pour que les nappes restent rondes. + float2 p = float2((gp.x - 0.5) * aspect, gp.y - 0.5); + float ph = TAU * time / 120.0; + float n = value_noise(p * 2.5 + 1.5 * float2(cos(ph), sin(ph))); + float3 g = lerp(c0, c1, saturate(0.5 + u + 0.3 * (n - 0.5))); + float2 b0 = float2(0.35 * aspect * sin(TAU * time / 20.0), 0.25 * sin(TAU * time / 30.0 + 1.0)); + float2 b1 = float2(0.30 * aspect * sin(TAU * time / 24.0 + 2.0), 0.22 * cos(TAU * time / 40.0)); + float2 b2 = float2(0.25 * aspect * cos(TAU * time / 30.0 + 4.0), 0.28 * sin(TAU * time / 24.0 + 3.0)); + g = lerp(g, c1, 0.45 * exp(-dot(p - b0, p - b0) / 0.08)); + g = lerp(g, c0, 0.45 * exp(-dot(p - b1, p - b1) / 0.06)); + g = lerp(g, c1, 0.35 * exp(-dot(p - b2, p - b2) / 0.05)); + return g; + } + // Vagues : trois bandes sinus perpendiculaires à l'axe, qui avancent d'une bande en 12 s, + // légèrement ondulées le long des bandes (20 s). Elles décalent la rampe, rien d'autre. + float v = dot(gp - 0.5, float2(-dir.y, dir.x)) / denom; + float w = sin(TAU * (3.0 * u + 0.04 * sin(TAU * (1.5 * v + time / 20.0)) - time / 12.0)); + return lerp(c0, c1, saturate(0.5 + u + 0.07 * w)); +} + // Fond flouté pour le mode "blur" de la webcam. // Disque de Vogel (spirale à angle d'or) à 21 échantillons avec pondération gaussienne et // rotation par pixel via Interleaved Gradient Noise (IGN) pour un bokeh photographique doux, isotrope et rapide. @@ -478,9 +533,19 @@ float4 ps_main(VSOut i) : SV_Target // mode 5 : gradient linéaire 2 stops (parité web wallpaper dégradé). color = stop0, // src.xyz = stop1, fx.xy = direction unitaire (espace sortie, y vers le bas). t est // normalisé coin-à-coin (dénominateur = |dx|+|dy|) pour couvrir toute la diagonale. + // Fond animé : fx.z = temps programme (s, replié sur 120), fx.w = mouvement (0 immobile, + // 1 dérive, 2 aurore, 3 vagues), mb.x = aspect w/h. 0 rend le dégradé d'avant à l'octet. if (mode > 4.5) { float2 dir = fx.xy; + if (fx.w > 0.5 && fx.w < 1.5) + { + // Dérive : l'axe respire de ±15° (0.2617994 rad) en 20 s. + float da = 0.2617994 * sin(6.2831853 * fx.z / 20.0); + float sa = sin(da); + float ca = cos(da); + dir = float2(dir.x * ca - dir.y * sa, dir.x * sa + dir.y * ca); + } float denom = max(abs(dir.x) + abs(dir.y), 1e-4); // Paramétré sur le QUAD dès qu'il en a un (la bulle webcam), sinon sur la sortie. Pour le // fond plein cadre les deux coïncident ; pour une bulle dans un coin, `pout` ne montrerait @@ -489,6 +554,10 @@ float4 ps_main(VSOut i) : SV_Target float2 gp = (quad_px.x > 0.0 && quad_px.y > 0.0) ? (i.local / quad_px) : i.pout; float t = saturate(0.5 + dot(gp - 0.5, dir) / denom); float3 g = lerp(color.rgb, src.xyz, t); + if (fx.w > 1.5) + { + g = gradient_motion(gp, dir, denom, color.rgb, src.xyz, fx.z, fx.w, mb.x); + } float a = quad_round_alpha(i.local, quad_px, radius_px); return float4(g * a, a); // prémultiplié } diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index b5737e1f2..319647c33 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -310,6 +310,55 @@ static float4 cursor_extruded(float2 local, constant Layer &layer, return acc; } +// Hash 2D -> [0,1) sans sin(). Miroir de `hash12` côté HLSL. +inline float hash12(float2 p) +{ + float3 p3 = fract(float3(p.x, p.y, p.x) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +// Bruit de valeur lissé (hermite), sans texture. Miroir de `value_noise` côté HLSL. +inline float value_noise(float2 q) +{ + float2 i = floor(q); + float2 f = fract(q); + float2 u = f * f * (3.0 - 2.0 * f); + float a = hash12(i); + float b = hash12(i + float2(1.0, 0.0)); + float c = hash12(i + float2(0.0, 1.0)); + float d = hash12(i + float2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +// Mouvements 2 (aurore) et 3 (vagues) du mode 5. Miroir ligne pour ligne de +// `gradient_motion` côté HLSL (commentaires complets là-bas). +inline float3 gradient_motion(float2 gp, float2 dir, float denom, float3 c0, float3 c1, + float time, float motion, float aspect) +{ + const float TAU = 6.2831853; + float u = dot(gp - 0.5, dir) / denom; // position le long de l'axe, -0.5..0.5 + if (motion < 2.5) + { + // Aurore : rampe perturbée par un bruit lent, puis trois nappes gaussiennes. + float2 p = float2((gp.x - 0.5) * aspect, gp.y - 0.5); + float ph = TAU * time / 120.0; + float n = value_noise(p * 2.5 + 1.5 * float2(cos(ph), sin(ph))); + float3 g = mix(c0, c1, clamp(0.5 + u + 0.3 * (n - 0.5), 0.0, 1.0)); + float2 b0 = float2(0.35 * aspect * sin(TAU * time / 20.0), 0.25 * sin(TAU * time / 30.0 + 1.0)); + float2 b1 = float2(0.30 * aspect * sin(TAU * time / 24.0 + 2.0), 0.22 * cos(TAU * time / 40.0)); + float2 b2 = float2(0.25 * aspect * cos(TAU * time / 30.0 + 4.0), 0.28 * sin(TAU * time / 24.0 + 3.0)); + g = mix(g, c1, 0.45 * exp(-dot(p - b0, p - b0) / 0.08)); + g = mix(g, c0, 0.45 * exp(-dot(p - b1, p - b1) / 0.06)); + g = mix(g, c1, 0.35 * exp(-dot(p - b2, p - b2) / 0.05)); + return g; + } + // Vagues : trois bandes sinus perpendiculaires à l'axe (12 s), ondulées (20 s). + float v = dot(gp - 0.5, float2(-dir.y, dir.x)) / denom; + float w = sin(TAU * (3.0 * u + 0.04 * sin(TAU * (1.5 * v + time / 20.0)) - time / 12.0)); + return mix(c0, c1, clamp(0.5 + u + 0.07 * w, 0.0, 1.0)); +} + fragment float4 ps_main(VSOut i [[stage_in]], constant Layer &layer [[buffer(0)]], texture2d texY [[texture(0)]], @@ -455,9 +504,20 @@ fragment float4 ps_main(VSOut i [[stage_in]], // // Le port avait remplacé tout ce calcul par une couleur plate : un dégradé s'affichait // comme son premier stop, uniformément. + // + // Fond animé : fx.z = temps programme (s, replié sur 120), fx.w = mouvement (0 immobile, + // 1 dérive, 2 aurore, 3 vagues), mb.x = aspect w/h. 0 rend le dégradé d'avant à l'octet. if (layer.mode > 4.5 && layer.mode < 5.5) { float2 dir = layer.fx.xy; + if (layer.fx.w > 0.5 && layer.fx.w < 1.5) + { + // Dérive : l'axe respire de ±15° (0.2617994 rad) en 20 s. + float da = 0.2617994 * sin(6.2831853 * layer.fx.z / 20.0); + float sa = sin(da); + float ca = cos(da); + dir = float2(dir.x * ca - dir.y * sa, dir.x * sa + dir.y * ca); + } float denom = max(abs(dir.x) + abs(dir.y), 1e-4); // Paramétré sur le QUAD dès qu'il en a un (la bulle webcam), sinon sur la sortie. Pour le // fond plein cadre les deux coïncident ; pour une bulle dans un coin, `pout` ne montrerait @@ -468,6 +528,11 @@ fragment float4 ps_main(VSOut i [[stage_in]], : i.pout; float t = clamp(0.5 + dot(gp - 0.5, dir) / denom, 0.0, 1.0); float3 g = mix(layer.color.rgb, layer.src.xyz, t); + if (layer.fx.w > 1.5) + { + g = gradient_motion(gp, dir, denom, layer.color.rgb, layer.src.xyz, layer.fx.z, + layer.fx.w, layer.mb.x); + } float a = quad_round_alpha(i.local, layer.quad_px, layer.radius_px); return float4(g * a, a); // prémultiplié } diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index ec55fa4ee..78ad4760a 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -24,10 +24,10 @@ struct Layer { radius_px: f32, mode: f32, // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre, 8 = écran tilté, 9 = flèche, 10 = flou/mosaïque, 12 = ombre du quad tilté, 13 = curseur tilté color: vec4, - fx: vec4, // mode 2 : spread ombre en px ; modes 8/12/13 : coins TL,TR du quad projeté ; mode 9 : hampe de la flèche ; mode 10 : (flou?, rayon/bloc px, ovale?, teinté?) + fx: vec4, // mode 2 : spread ombre en px ; mode 5 : (direction xy, temps programme replie, mouvement 0..3) ; modes 8/12/13 : coins TL,TR du quad projeté ; mode 9 : hampe de la flèche ; mode 10 : (flou?, rayon/bloc px, ovale?, teinté?) src_prev: vec4, // modes 8/12/13 : coins BR,BL du quad projeté ; mode 9 : barbe 1 ; mode 10 incliné : coins BR,BL du masque dst_prev: vec4, // mode 8 : taille du plan en px AVANT projection (le rayon y vit) ; mode 13 : rect de clip ; mode 9 : barbe 2 ; mode 10 incliné : coins TL,TR du masque - mb: vec4, // 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é ; mode 13 : mb.xy = vecteur d'extrusion en px, mb.z = nombre de copies (volume, <= 1 = plat) + mb: vec4, // 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é ; mode 13 : mb.xy = vecteur d'extrusion en px, mb.z = nombre de copies (volume, <= 1 = plat) } @group(0) @binding(0) var layer: Layer; @@ -221,6 +221,51 @@ fn quad_inverse_bilinear(P: vec2, c00: vec2, c10: vec2, c11: vec2 return r1; } +// 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); + p3 = p3 + vec3(dot(p3, p3.yzx + vec3(33.33))); + return fract((p3.x + p3.y) * p3.z); +} + +// Bruit de valeur lisse (hermite), sans texture. Miroir de `value_noise` cote HLSL. +fn value_noise(q: vec2) -> f32 { + let i = floor(q); + let f = fract(q); + let u = f * f * (vec2(3.0) - 2.0 * f); + let a = hash12(i); + let b = hash12(i + vec2(1.0, 0.0)); + let c = hash12(i + vec2(0.0, 1.0)); + let d = hash12(i + vec2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +// Mouvements 2 (aurore) et 3 (vagues) du mode 5. Miroir ligne pour ligne de +// `gradient_motion` cote HLSL (commentaires complets la-bas). +fn gradient_motion(gp: vec2, dir: vec2, denom: f32, c0: vec3, c1: vec3, + time: f32, motion: f32, aspect: f32) -> vec3 { + let TAU = 6.2831853; + let u = dot(gp - vec2(0.5), dir) / denom; // position le long de l'axe, -0.5..0.5 + if motion < 2.5 { + // Aurore : rampe perturbee par un bruit lent, puis trois nappes gaussiennes. + let p = vec2((gp.x - 0.5) * aspect, gp.y - 0.5); + let ph = TAU * time / 120.0; + let n = value_noise(p * 2.5 + 1.5 * vec2(cos(ph), sin(ph))); + var g = mix(c0, c1, clamp(0.5 + u + 0.3 * (n - 0.5), 0.0, 1.0)); + let b0 = vec2(0.35 * aspect * sin(TAU * time / 20.0), 0.25 * sin(TAU * time / 30.0 + 1.0)); + let b1 = vec2(0.30 * aspect * sin(TAU * time / 24.0 + 2.0), 0.22 * cos(TAU * time / 40.0)); + let b2 = vec2(0.25 * aspect * cos(TAU * time / 30.0 + 4.0), 0.28 * sin(TAU * time / 24.0 + 3.0)); + g = mix(g, c1, 0.45 * exp(-dot(p - b0, p - b0) / 0.08)); + g = mix(g, c0, 0.45 * exp(-dot(p - b1, p - b1) / 0.06)); + g = mix(g, c1, 0.35 * exp(-dot(p - b2, p - b2) / 0.05)); + return g; + } + // Vagues : trois bandes sinus perpendiculaires a l'axe (12 s), ondulees (20 s). + let v = dot(gp - vec2(0.5), vec2(-dir.y, dir.x)) / denom; + let w = sin(TAU * (3.0 * u + 0.04 * sin(TAU * (1.5 * v + time / 20.0)) - time / 12.0)); + return mix(c0, c1, clamp(0.5 + u + 0.07 * w, 0.0, 1.0)); +} + // Fond flouté pour le mode "blur" de la webcam. // Disque de Vogel (spirale à angle d'or) à 21 échantillons avec pondération gaussienne et // rotation par pixel via Interleaved Gradient Noise (IGN) pour un bokeh photographique doux, isotrope et rapide. @@ -361,7 +406,17 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { // la direction fx.xy (sin, -cos de l'angle). Parite avec le HLSL/MSL. // `denom` : HLSL et MSL normalisent coin-a-coin (|dx|+|dy|) pour couvrir toute la // diagonale. Il manquait ici, donc le meme degrade ne rendait pas pareil sur Linux. - let denom = max(abs(layer.fx.x) + abs(layer.fx.y), 1e-4); + // Fond anime : fx.z = temps programme (s, replie sur 120), fx.w = mouvement (0 immobile, + // 1 derive, 2 aurore, 3 vagues), mb.x = aspect w/h. 0 rend le degrade d'avant a l'octet. + var dir = layer.fx.xy; + if layer.fx.w > 0.5 && layer.fx.w < 1.5 { + // Derive : l'axe respire de +-15 deg (0.2617994 rad) en 20 s. + let da = 0.2617994 * sin(6.2831853 * layer.fx.z / 20.0); + let sa = sin(da); + let ca = cos(da); + dir = vec2(dir.x * ca - dir.y * sa, dir.x * sa + dir.y * ca); + } + let denom = max(abs(dir.x) + abs(dir.y), 1e-4); // Parametre sur le QUAD des qu'il en a un (la bulle webcam), sinon sur la sortie. Pour // le fond plein cadre les deux coincident ; pour une bulle dans un coin, `pout` ne // montrerait que la tranche du degrade plein cadre qui passe dessous. @@ -369,8 +424,12 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { if layer.quad_px.x > 0.0 && layer.quad_px.y > 0.0 { gp = i.local / layer.quad_px; } - let t = clamp(0.5 + dot(gp - vec2(0.5), layer.fx.xy) / denom, 0.0, 1.0); + let t = clamp(0.5 + dot(gp - vec2(0.5), dir) / denom, 0.0, 1.0); rgb = mix(layer.color.rgb, layer.src.rgb, t); + if layer.fx.w > 1.5 { + rgb = gradient_motion(gp, dir, denom, layer.color.rgb, layer.src.rgb, layer.fx.z, + layer.fx.w, layer.mb.x); + } } else if layer.mode > 10.5 && layer.mode < 11.5 { // Mode 11 : texte. texY est l'atlas R8 (couverture alpha au canal .r, // produit par text_cosmic::TextRasterizer), teinte par layer.color. diff --git a/crates/compositor/tests/animated_background.rs b/crates/compositor/tests/animated_background.rs new file mode 100644 index 000000000..fb8dc3488 --- /dev/null +++ b/crates/compositor/tests/animated_background.rs @@ -0,0 +1,134 @@ +//! Un fond dégradé animé (mode 5, `fx.w`) est une fonction pure du temps programme : +//! immobile, il ne dépend pas de l'instant ; animé, chaque mouvement donne une image propre à +//! chacun des trois instants fixes, et différente du dégradé immobile. +//! +//! Rend de vraies frames par le compositeur D3D11. Piloté par l'environnement, comme +//! `privacy_blur_under_zoom.rs`, dont il réutilise la source (n'importe quel MP4 d'au moins +//! 6 s convient) : +//! +//! ```powershell +//! $env:OPENSCREEN_PRIVACY_SECRET = "...\secret.mp4" +//! cargo test -p openscreen-compositor --test animated_background -- --nocapture +//! ``` +//! +//! `OPENSCREEN_BACKGROUND_OUT` (facultatif) reçoit un PPM par cas, pour l'inspection à l'œil. +//! Les empreintes imprimées sont celles de CE GPU : le rendu « none » se compare à celui du +//! shader d'avant le fond animé en relançant le test sur l'ancien `shaders.hlsl`. + +// Windows seulement : le readback et le décodage D3D11VA de ce harnais n'existent que là. +#![cfg(windows)] + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::frame_geometry::live_params_from_scene; +use openscreen_compositor::live::Player; +use openscreen_compositor::scene::Scene; + +const W: u32 = 960; +const H: u32 = 540; +/// Trois clips de 6 s du même fichier : le clip `k` lu à 3 s source tombe à `6k + 3` s de +/// programme. La frame vidéo est la même, seul le temps programme change. +const PROGRAMME_TIMES: [f32; 3] = [3.0, 9.0, 15.0]; + +fn write_ppm(path: &std::path::Path, rgba: &[u8], w: u32, h: u32) -> std::io::Result<()> { + let mut out = Vec::with_capacity(rgba.len() / 4 * 3 + 32); + out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes()); + for px in rgba.chunks_exact(4) { + out.extend_from_slice(&px[..3]); + } + std::fs::write(path, out) +} + +/// `motion` : fragment JSON inséré tel quel dans le fond (`""` = clé absente). +fn scene_json(source: &str, motion: &str) -> String { + let s = source.replace('\\', "/"); + let clip = format!( + r#"{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":6,"webcamOffsetSec":0,"hasAudio":false}}"# + ); + format!( + r##"{{ + "clips": [{clip},{clip},{clip}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}}, + "effects": {{"padding":1.0,"blur":false,"shadow":0.0,"roundnessFrac":0.02,"motionBlur":0.0}}, + "background": {{"kind":"gradient","angleDeg":135,"stops":["#2b3a67","#b8577f"]{motion}}}, + "zoomRegions": [], + "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null, null, null], + "output": {{"width":{W},"height":{H},"fps":30}} + }}"## + ) +} + +fn render(gpu: &Gpu, source: &str, motion: &str, clip: usize) -> Vec { + 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(&scene_json(source, motion)).expect("scene valide"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene.clone())); + comp.clear_cursor(); + unsafe { + let mut player = Player::open(source, "", gpu).expect("ouvrir la source"); + player.set_programme_clock(Some(&scene), clip); + player.present_frame(&comp, &cfg, 3.0).expect("composer la frame"); + let t = player.programme_time().expect("horloge programme"); + assert!((t - PROGRAMME_TIMES[clip]).abs() < 0.05, "temps programme {t} pour le clip {clip}"); + comp.readback_resized(W, H).expect("readback") + } +} + +fn hash(rgba: &[u8]) -> u64 { + let mut h = DefaultHasher::new(); + rgba.hash(&mut h); + h.finish() +} + +#[test] +fn each_motion_is_a_distinct_pure_function_of_programme_time() { + let Ok(source) = std::env::var("OPENSCREEN_PRIVACY_SECRET") else { + println!("SKIP: definir OPENSCREEN_PRIVACY_SECRET (voir l'en-tete du fichier)."); + return; + }; + let out_dir = std::env::var("OPENSCREEN_BACKGROUND_OUT").ok().map(std::path::PathBuf::from); + if let Some(dir) = &out_dir { + std::fs::create_dir_all(dir).expect("creer le dossier de sortie"); + } + let gpu = Gpu::create(false).expect("device d3d11"); + let shoot = |name: &str, motion: &str, clip: usize| { + let rgba = render(&gpu, &source, motion, clip); + if let Some(dir) = &out_dir { + let file = format!("{name}-t{:02}.ppm", PROGRAMME_TIMES[clip] as u32); + write_ppm(&dir.join(file), &rgba, W, H).expect("ecrire le ppm"); + } + let h = hash(&rgba); + println!("{name:<8} t={:>4} {h:016x}", PROGRAMME_TIMES[clip]); + h + }; + + // Immobile : clé absente, "none" et une valeur inconnue rendent la même image, à tout instant. + let still = shoot("absent", "", 0); + for clip in 0..3 { + assert_eq!(shoot("none", r#","motion":"none""#, clip), still, "none au clip {clip}"); + } + assert_eq!(shoot("unknown", r#","motion":"plasma""#, 2), still); + + for name in ["drift", "aurora", "waves"] { + let motion = format!(r#","motion":"{name}""#); + let hashes: Vec = (0..3).map(|clip| shoot(name, &motion, clip)).collect(); + for (k, h) in hashes.iter().enumerate() { + assert_ne!(*h, still, "{name} a t={} rend le degrade immobile", PROGRAMME_TIMES[k]); + } + assert!( + hashes[0] != hashes[1] && hashes[1] != hashes[2] && hashes[0] != hashes[2], + "{name} : deux instants rendent la meme image ({hashes:x?})" + ); + // Fonction pure : le même instant recomposé par un autre compositeur donne le même octet. + assert_eq!(shoot(name, &motion, 1), hashes[1], "{name} n'est pas reproductible"); + } +} diff --git a/crates/fixture/scene-gradient-aurora.json b/crates/fixture/scene-gradient-aurora.json new file mode 100644 index 000000000..bb2e89a13 --- /dev/null +++ b/crates/fixture/scene-gradient-aurora.json @@ -0,0 +1,37 @@ +{ + "clips": [], + "layout": { + "preset": "picture-in-picture", + "webcamSize": 1, + "webcamShape": "rectangle", + "webcamMirror": false, + "webcamPosition": null, + "webcamReactiveZoom": false + }, + "effects": { + "padding": 0.5, + "blur": false, + "shadow": 0.8, + "roundnessFrac": 0.0222, + "motionBlur": 0 + }, + "background": { + "kind": "gradient", + "angleDeg": 135, + "stops": ["#2b3a67", "#b8577f"], + "motion": "aurora" + }, + "zoomRegions": [], + "speedRegions": [], + "cursor": { + "show": false, + "size": 1, + "smoothing": 0, + "motionBlur": 0, + "clickBounce": 0, + "clipToBounds": false, + "theme": "default" + }, + "cropByClip": [], + "output": { "width": 1920, "height": 1080, "fps": 60 } +} diff --git a/crates/fixture/scene-gradient-still.json b/crates/fixture/scene-gradient-still.json new file mode 100644 index 000000000..d25db4154 --- /dev/null +++ b/crates/fixture/scene-gradient-still.json @@ -0,0 +1,32 @@ +{ + "clips": [], + "layout": { + "preset": "picture-in-picture", + "webcamSize": 1, + "webcamShape": "rectangle", + "webcamMirror": false, + "webcamPosition": null, + "webcamReactiveZoom": false + }, + "effects": { + "padding": 0.5, + "blur": false, + "shadow": 0.8, + "roundnessFrac": 0.0222, + "motionBlur": 0 + }, + "background": { "kind": "gradient", "angleDeg": 135, "stops": ["#2b3a67", "#b8577f"] }, + "zoomRegions": [], + "speedRegions": [], + "cursor": { + "show": false, + "size": 1, + "smoothing": 0, + "motionBlur": 0, + "clickBounce": 0, + "clipToBounds": false, + "theme": "default" + }, + "cropByClip": [], + "output": { "width": 1920, "height": 1080, "fps": 60 } +} diff --git a/crates/poc-d3d/src/bench.rs b/crates/poc-d3d/src/bench.rs index aa2de7d60..4b7a6e0c7 100644 --- a/crates/poc-d3d/src/bench.rs +++ b/crates/poc-d3d/src/bench.rs @@ -7,7 +7,7 @@ use anyhow::{Context as _, Result}; use openscreen_compositor::compositor::Compositor; use openscreen_compositor::gif_export::{GifExportParams, GifStats}; use openscreen_compositor::pipeline::ClipSource; -use openscreen_compositor::{config, cursor, d3d, gif_export, live, pipeline, scene}; +use openscreen_compositor::{config, cursor, d3d, frame_geometry, gif_export, live, pipeline, scene}; use std::fmt::Write as _; use std::path::Path; @@ -134,7 +134,11 @@ fn run_bench(args: &[String]) -> Result<()> { if !scene_arg.is_empty() { let json = std::fs::read_to_string(&scene_arg) .with_context(|| format!("lecture de la scène {scene_arg}"))?; - comp.set_scene(Some(scene::Scene::from_json(&json)?)); + let s = scene::Scene::from_json(&json)?; + // Comme l'export de l'app : sans ces réglages, le padding de la scène est ignoré, la + // vidéo couvre tout le cadre et le fond n'apparaît pas dans la preuve visuelle. + comp.set_live_params(frame_geometry::live_params_from_scene(&s)); + comp.set_scene(Some(s)); println!("scène chargée depuis {scene_arg}"); } diff --git a/electron/ai-edition/style-preset-service.test.ts b/electron/ai-edition/style-preset-service.test.ts index afd5600ae..c50f80edc 100644 --- a/electron/ai-edition/style-preset-service.test.ts +++ b/electron/ai-edition/style-preset-service.test.ts @@ -7,6 +7,7 @@ import { StylePresetError, StylePresetService } from "./style-preset-service"; const APPEARANCE: StylePresetAppearance = { wallpaper: "#000000", + wallpaperMotion: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, diff --git a/electron/ipc/nativeBridge.presets.test.ts b/electron/ipc/nativeBridge.presets.test.ts index 93c6ba6df..ade58983c 100644 --- a/electron/ipc/nativeBridge.presets.test.ts +++ b/electron/ipc/nativeBridge.presets.test.ts @@ -27,6 +27,7 @@ vi.mock("../native-bridge/services/aiEditionService", () => ({ const APPEARANCE: StylePresetAppearance = { wallpaper: "#000000", + wallpaperMotion: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, diff --git a/src/components/ai-edition/RightPanes.backgroundMotion.test.tsx b/src/components/ai-edition/RightPanes.backgroundMotion.test.tsx new file mode 100644 index 000000000..aef460a58 --- /dev/null +++ b/src/components/ai-edition/RightPanes.backgroundMotion.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +// The background animation control moves only what the compositor draws as a gradient. On any +// other wallpaper it must be dead and say why, not hold a choice that changes nothing on screen. + +import "@testing-library/jest-dom"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import { LOCALE_STORAGE_KEY } from "@/i18n/config"; +import { createEmptyDocument } from "@/lib/ai-edition/schema"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { VideoEffectsPane } from "./RightPanes"; + +const REASON = /applies to gradient backgrounds only/i; + +function renderWith(legacyEditor: Record) { + const base = createEmptyDocument({ projectId: "project_motion", title: "Motion" }); + useProjectStore.setState({ + projectId: base.project.id, + document: { ...base, legacyEditor }, + revision: 1, + status: "ready", + }); + return render( + + + , + ); +} + +const control = () => screen.getByRole("combobox", { name: "Animation" }); + +beforeEach(() => { + localStorage.clear(); + localStorage.setItem(LOCALE_STORAGE_KEY, "en"); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + useProjectStore.getState().clear(); +}); + +describe("background animation control", () => { + it("animates a gradient wallpaper", () => { + renderWith({ wallpaper: "linear-gradient(135deg, #2b3a67, #b8577f)" }); + expect(control()).toBeEnabled(); + expect(control()).toHaveValue("none"); + expect(screen.queryByText(REASON)).not.toBeInTheDocument(); + + fireEvent.change(control(), { target: { value: "aurora" } }); + expect(useProjectStore.getState().document?.legacyEditor).toMatchObject({ + wallpaperMotion: "aurora", + }); + }); + + it("is disabled with its reason on an image, and keeps the stored choice", () => { + renderWith({ wallpaper: "/wallpapers/wallpaper1.jpg", wallpaperMotion: "waves" }); + expect(control()).toBeDisabled(); + expect(control()).toHaveValue("none"); + expect(screen.getByText(REASON)).toBeInTheDocument(); + expect(useProjectStore.getState().document?.legacyEditor).toMatchObject({ + wallpaperMotion: "waves", + }); + }); +}); diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index b1ced5196..ba32fc6da 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -43,6 +43,7 @@ import { toast } from "sonner"; import defaultCursorPreviewUrl from "@/assets/cursors/Cursor=Default.svg"; import GradientEditor, { type GradientEditorState } from "@/components/ui/gradient-editor"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { WALLPAPER_MOTIONS, type WallpaperMotion } from "@/components/video-editor/types"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { resolveCaptionLane } from "@/lib/ai-edition/captions/settings"; import { collapseTracksToPills, trackGroupId } from "@/lib/ai-edition/document/audioTracks"; @@ -100,6 +101,7 @@ import { WALLPAPER_THUMB_PATHS, } from "@/lib/wallpaper"; import { isNativeCompositorActive, setNativeParam } from "@/native"; +import { wallpaperAcceptsMotion } from "@/native/sceneDescription"; import { ASPECT_RATIO_PRESETS, type AspectRatio, @@ -323,6 +325,13 @@ function useWallpaperFileInput(onPicked: (dataUrl: string) => void): { }; } +const WALLPAPER_MOTION_LABEL_KEYS: Record = { + none: "background.motionNone", + drift: "background.motionDrift", + aurora: "background.motionAurora", + waves: "background.motionWaves", +}; + // Wallpaper picker — image / solid color / gradient tabs. // // Wallpapers round-trip through the legacyEditor envelope exactly as they did @@ -336,6 +345,7 @@ function BackgroundSection() { const { pick: handlePickFile, input: fileInput } = useWallpaperFileInput((dataUrl) => set({ wallpaper: dataUrl }), ); + const motionApplies = wallpaperAcceptsMotion(settings.wallpaper); return ( <> @@ -390,6 +400,38 @@ function BackgroundSection() { which closes the popover and would unmount the input mid-pick, dropping the file. It has no layout to cost us here. */} {fileInput} + {/* Beside the picker because it moves what the picker chose. Only the compositor's own + gradient can move; on anything else the control says so instead of holding a + choice that changes nothing on screen. The stored choice is kept and comes back + with the next gradient. */} +
+ + +
+ {motionApplies ? null : ( +

+ {ts("background.motionGradientOnly")} +

+ )} {/* Reads in the order it acts: pick a background, then blur it. Lived under "Effects" while that was a separate facet, which is how a control named "Blur BG" ended up in the tab that doesn't say background. */} diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 9c610eda3..a19b92568 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -32,6 +32,7 @@ describe("projectPersistence media compatibility", () => { }, { wallpaper: "/wallpapers/wallpaper1.jpg", + wallpaperMotion: "none", shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, @@ -69,6 +70,16 @@ describe("projectPersistence media compatibility", () => { expect(validateProjectData(project)).toBe(true); }); + // The CLI export reads projects through this function: a dropped key is a motion the + // preview shows and the export does not. + it("keeps a known wallpaper motion and drops an unknown one", () => { + expect(normalizeProjectEditor({ wallpaperMotion: "drift" }).wallpaperMotion).toBe("drift"); + expect(normalizeProjectEditor({}).wallpaperMotion).toBe("none"); + expect(normalizeProjectEditor({ wallpaperMotion: "plasma" as never }).wallpaperMotion).toBe( + "none", + ); + }); + it("normalizes webcam mask shape values safely", () => { expect(normalizeProjectEditor({ webcamMaskShape: "rounded" }).webcamMaskShape).toBe("rounded"); expect( diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index ccbb31567..a2c41398f 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -32,6 +32,7 @@ import { DEFAULT_WEBCAM_REACTIVE_ZOOM, DEFAULT_ZOOM_DEPTH, DEFAULT_ZOOM_MOTION_BLUR, + isWallpaperMotion, MAX_BLUR_BLOCK_SIZE, MAX_BLUR_INTENSITY, MAX_PLAYBACK_SPEED, @@ -40,6 +41,7 @@ import { MIN_PLAYBACK_SPEED, type SpeedRegion, type TrimRegion, + type WallpaperMotion, type WebcamLayoutPreset, type WebcamMaskShape, type WebcamPosition, @@ -68,6 +70,7 @@ export const PROJECT_VERSION = 2; export interface ProjectEditorState { wallpaper: string; + wallpaperMotion: WallpaperMotion; shadowIntensity: number; showBlur: boolean; motionBlurAmount: number; @@ -476,6 +479,9 @@ export function normalizeProjectEditor(editor: Partial): Pro typeof editor.wallpaper === "string" ? normalizeWallpaperValue(editor.wallpaper) : DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, + // Carried here too: the CLI export reads the project through this function, and a + // key it drops is a motion the preview shows and the export does not. + wallpaperMotion: isWallpaperMotion(editor.wallpaperMotion) ? editor.wallpaperMotion : "none", shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index a9996bf4f..e6bfdd795 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -36,6 +36,16 @@ export function isWebcamBackgroundMode(value: unknown): value is WebcamBackgroun export const DEFAULT_WEBCAM_BACKGROUND_MODE: WebcamBackgroundMode = "none"; +/** Slow motion of a gradient wallpaper, rendered by the native compositor from programme + * time. Only a `linear-gradient(...)` wallpaper moves; any other keeps its value and ignores it. */ +export const WALLPAPER_MOTIONS = ["none", "drift", "aurora", "waves"] as const; + +export type WallpaperMotion = (typeof WALLPAPER_MOTIONS)[number]; + +export function isWallpaperMotion(value: unknown): value is WallpaperMotion { + return (WALLPAPER_MOTIONS as readonly unknown[]).includes(value); +} + export const DEFAULT_WEBCAM_BLUR_INTENSITY = 0.5; /** When true, the picture-in-picture webcam scales inversely with zoom (shrinks as you zoom in). */ diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index fba6d9c8b..f7bf5c1f3 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "التحريك", + "motionNone": "بلا", + "motionDrift": "انجراف", + "motionAurora": "شفق", + "motionWaves": "أمواج", + "motionGradientOnly": "ينطبق التحريك على الخلفيات المتدرجة فقط. اختر تدرجًا لتحريكه.", "gradientLabel": "تدرج لوني {{index}}", "uploadCustom": "رفع صورة مخصصة", "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index a96ebcbb0..b02b6fd20 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "Animace", + "motionNone": "Žádný", + "motionDrift": "Posun", + "motionAurora": "Polární záře", + "motionWaves": "Vlny", + "motionGradientOnly": "Animace platí jen pro přechodová pozadí. Vyberte přechod, který chcete animovat.", "gradientLabel": "Přechod {{index}}", "uploadCustom": "Nahrát vlastní", "unsupportedImage": "Nepodporovaný obrázek. Použijte soubor JPG nebo PNG.", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 8a120b5c1..c1198cfd6 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "Animation", + "motionNone": "None", + "motionDrift": "Drift", + "motionAurora": "Aurora", + "motionWaves": "Waves", + "motionGradientOnly": "Animation applies to gradient backgrounds only. Pick a gradient to animate it.", "gradientLabel": "Gradient {{index}}", "uploadCustom": "Upload Custom", "unsupportedImage": "Unsupported image. Use a JPG or PNG file.", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index e404b5cf3..cfeafe913 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "Animación", + "motionNone": "Ninguno", + "motionDrift": "Deriva", + "motionAurora": "Aurora", + "motionWaves": "Ondas", + "motionGradientOnly": "La animación solo se aplica a fondos degradados. Elige un degradado para animarlo.", "gradientLabel": "Degradado {{index}}", "uploadCustom": "Subir personalizado", "unsupportedImage": "Imagen no compatible. Usa un archivo JPG o PNG.", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 3001a70b4..226f3b809 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "Animation", + "motionNone": "Aucun", + "motionDrift": "Dérive", + "motionAurora": "Aurore", + "motionWaves": "Vagues", + "motionGradientOnly": "L'animation ne s'applique qu'aux fonds dégradés. Choisissez un dégradé pour l'animer.", "gradientLabel": "Dégradé {{index}}", "uploadCustom": "Téléverser une image", "unsupportedImage": "Image non prise en charge. Utilisez un fichier JPG ou PNG.", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 989725604..9fd8454d7 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "Animazione", + "motionNone": "Nessuno", + "motionDrift": "Deriva", + "motionAurora": "Aurora", + "motionWaves": "Onde", + "motionGradientOnly": "L'animazione si applica solo agli sfondi sfumati. Scegli una sfumatura per animarla.", "gradientLabel": "Sfumatura {{index}}", "uploadCustom": "Carica personalizzato", "unsupportedImage": "Immagine non supportata. Usa un file JPG o PNG.", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 7c3058a34..fd0770886 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "アニメーション", + "motionNone": "なし", + "motionDrift": "ドリフト", + "motionAurora": "オーロラ", + "motionWaves": "ウェーブ", + "motionGradientOnly": "アニメーションはグラデーション背景にのみ適用されます。アニメーションするにはグラデーションを選択してください。", "gradientLabel": "グラデーション {{index}}", "uploadCustom": "カスタム画像を読み込む", "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 6a1cc8e6a..7521c1768 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "애니메이션", + "motionNone": "없음", + "motionDrift": "드리프트", + "motionAurora": "오로라", + "motionWaves": "물결", + "motionGradientOnly": "애니메이션은 그라데이션 배경에만 적용됩니다. 애니메이션을 적용하려면 그라데이션을 선택하세요.", "gradientLabel": "그라디언트 {{index}}", "uploadCustom": "직접 업로드", "unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요.", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index e1e6faca9..ce83a1aa5 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "Animação", + "motionNone": "Nenhum", + "motionDrift": "Deriva", + "motionAurora": "Aurora", + "motionWaves": "Ondas", + "motionGradientOnly": "A animação só se aplica a fundos em degradê. Escolha um degradê para animá-lo.", "gradientLabel": "Gradiente {{index}}", "uploadCustom": "Enviar Personalizada", "unsupportedImage": "Imagem não suportada. Use um arquivo JPG ou PNG.", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 895d97520..adfc828a0 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "Анимация", + "motionNone": "Нет", + "motionDrift": "Дрейф", + "motionAurora": "Сияние", + "motionWaves": "Волны", + "motionGradientOnly": "Анимация применяется только к градиентным фонам. Выберите градиент, чтобы анимировать его.", "gradientLabel": "Градиент {{index}}", "uploadCustom": "Загрузить свой", "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG.", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 3a3f35e50..b2602f925 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "Animasyon", + "motionNone": "Yok", + "motionDrift": "Kayma", + "motionAurora": "Kutup ışığı", + "motionWaves": "Dalgalar", + "motionGradientOnly": "Animasyon yalnızca gradyan arka planlara uygulanır. Canlandırmak için bir gradyan seçin.", "gradientLabel": "Gradyan {{index}}", "uploadCustom": "Özel Yükle", "unsupportedImage": "Desteklenmeyen görsel. JPG veya PNG dosyası kullanın.", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index af4ddc1ec..3eb117354 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "Hoạt ảnh", + "motionNone": "Không", + "motionDrift": "Trôi", + "motionAurora": "Cực quang", + "motionWaves": "Sóng", + "motionGradientOnly": "Hoạt ảnh chỉ áp dụng cho nền chuyển sắc. Hãy chọn một nền chuyển sắc để tạo hoạt ảnh.", "gradientLabel": "Dải màu {{index}}", "uploadCustom": "Tải lên tùy chỉnh", "unsupportedImage": "Ảnh không được hỗ trợ. Hãy dùng tệp JPG hoặc PNG.", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index f081f7b73..52d4ee9a6 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "动画", + "motionNone": "无", + "motionDrift": "漂移", + "motionAurora": "极光", + "motionWaves": "波浪", + "motionGradientOnly": "动画仅适用于渐变背景。请选择一个渐变来启用动画。", "gradientLabel": "渐变 {{index}}", "uploadCustom": "上传自定义", "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 423835ac8..4c0276e2e 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "動畫", + "motionNone": "無", + "motionDrift": "漂移", + "motionAurora": "極光", + "motionWaves": "波浪", + "motionGradientOnly": "動畫僅適用於漸層背景。請選擇一個漸層來啟用動畫。", "gradientLabel": "漸層 {{index}}", "uploadCustom": "上傳自訂", "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。", diff --git a/src/lib/ai-edition/document/migrate.test.ts b/src/lib/ai-edition/document/migrate.test.ts index efbc01379..2ff944006 100644 --- a/src/lib/ai-edition/document/migrate.test.ts +++ b/src/lib/ai-edition/document/migrate.test.ts @@ -14,6 +14,7 @@ function makeV2Project(overrides: Partial = {}): EditorProjec media: { screenVideoPath: "/recordings/screen.webm" }, editor: { wallpaper: "/wallpapers/wallpaper1.jpg", + wallpaperMotion: "none", shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, @@ -262,6 +263,21 @@ describe("migrateAxcutDocumentToProjectData", () => { expect(back.editor.webcamMaskShape).toBe("circle"); }); + it("defaults wallpaperMotion to none when legacyEditor lacks it", () => { + const doc = migrateProjectDataToAxcutDocument(makeV2Project()); + const { wallpaperMotion: _omitted, ...legacy } = doc.legacyEditor as Record; + const back = migrateAxcutDocumentToProjectData({ ...doc, legacyEditor: legacy }); + expect(back.editor.wallpaperMotion).toBe("none"); + }); + + it("keeps a legacy wallpaperMotion over the default", () => { + const v2 = makeV2Project({ + editor: { ...makeV2Project().editor, wallpaperMotion: "aurora" }, + }); + const back = migrateAxcutDocumentToProjectData(migrateProjectDataToAxcutDocument(v2)); + expect(back.editor.wallpaperMotion).toBe("aurora"); + }); + it("round-trips zoomRegions and annotationRegions back to ms", () => { const v2 = makeV2Project({ editor: { diff --git a/src/lib/ai-edition/document/migrate.ts b/src/lib/ai-edition/document/migrate.ts index ba6d7fda5..c3a2b74bc 100644 --- a/src/lib/ai-edition/document/migrate.ts +++ b/src/lib/ai-edition/document/migrate.ts @@ -287,6 +287,7 @@ export function migrateAxcutDocumentToProjectData(input: AxcutDocument): EditorP const editor: ProjectEditorState = { wallpaper: "", + wallpaperMotion: "none", shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index 759266a2b..23f82ee1c 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -283,6 +283,14 @@ describe("patchEditorSettings", () => { expect(getEditorSettings(doc).webcamBackgroundMode).toBe("none"); }); + it("round-trips the wallpaper motion and rejects an unknown one", () => { + expect(getEditorSettings(baseDoc).wallpaperMotion).toBe("none"); + const patched = patchEditorSettings(baseDoc, { wallpaperMotion: "aurora" }); + expect(getEditorSettings(patched).wallpaperMotion).toBe("aurora"); + const doc = { ...baseDoc, legacyEditor: { wallpaperMotion: "plasma" } } as typeof baseDoc; + expect(getEditorSettings(doc).wallpaperMotion).toBe("none"); + }); + it("clamps a stored webcam blur intensity into 0..1", () => { const tooHigh = { ...baseDoc, legacyEditor: { webcamBlurIntensity: 1000 } } as typeof baseDoc; expect(getEditorSettings(tooHigh).webcamBlurIntensity).toBe(1); diff --git a/src/lib/ai-edition/store/editorSettings.ts b/src/lib/ai-edition/store/editorSettings.ts index 46271dbe5..7b297b311 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -13,7 +13,9 @@ import { type CursorVisualSettings, DEFAULT_CROP_REGION, DEFAULT_WEBCAM_POSITION, + isWallpaperMotion, isWebcamBackgroundMode, + type WallpaperMotion, type WebcamBackgroundMode, type WebcamLayoutPreset, type WebcamMaskShape, @@ -72,6 +74,8 @@ const DEFAULT_CROP_PAN: CropPan = { x: 0.5, y: 0.5 }; export interface EditorSettingsSnapshot { wallpaper: string; + /** Only a gradient wallpaper moves; kept as chosen when the wallpaper changes kind. */ + wallpaperMotion: WallpaperMotion; aspectRatio: AspectRatio; shadowIntensity: number; showBlur: boolean; @@ -115,6 +119,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettingsSnapshot = { interface LegacyShape { wallpaper?: string; + wallpaperMotion?: WallpaperMotion; aspectRatio?: AspectRatio; shadowIntensity?: number; showBlur?: boolean; @@ -191,6 +196,9 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor return { wallpaper: str(legacy?.wallpaper, DEFAULT_EDITOR_SETTINGS.wallpaper), + wallpaperMotion: isWallpaperMotion(legacy?.wallpaperMotion) + ? legacy.wallpaperMotion + : DEFAULT_EDITOR_SETTINGS.wallpaperMotion, aspectRatio: legacy?.aspectRatio ?? DEFAULT_EDITOR_SETTINGS.aspectRatio, shadowIntensity: num(legacy?.shadowIntensity, DEFAULT_EDITOR_SETTINGS.shadowIntensity), showBlur: bool(legacy?.showBlur, DEFAULT_EDITOR_SETTINGS.showBlur), @@ -234,6 +242,7 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor } export interface EditorSettingsPatch { wallpaper?: string; + wallpaperMotion?: WallpaperMotion; aspectRatio?: AspectRatio; shadowIntensity?: number; showBlur?: boolean; diff --git a/src/lib/ai-edition/stylePresets.test.ts b/src/lib/ai-edition/stylePresets.test.ts index 5e3f31964..a2132f547 100644 --- a/src/lib/ai-edition/stylePresets.test.ts +++ b/src/lib/ai-edition/stylePresets.test.ts @@ -15,6 +15,7 @@ import { function appearance(overrides: Partial = {}): StylePresetAppearance { return { wallpaper: "/wallpapers/wallpaper3.jpg", + wallpaperMotion: "drift", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, @@ -56,6 +57,15 @@ describe("parseStylePresetAppearance", () => { expect(() => parseStylePresetAppearance(rest)).toThrow(/padding/); }); + // Added after format version 1 shipped: an older preset has a still wallpaper. + it("reads a preset without a wallpaper motion as still, and refuses an unknown one", () => { + const { wallpaperMotion: _motion, ...older } = appearance(); + expect(parseStylePresetAppearance(older).wallpaperMotion).toBe("none"); + expect(() => + parseStylePresetAppearance({ ...appearance(), wallpaperMotion: "plasma" }), + ).toThrow(/wallpaperMotion/); + }); + it("rejects out-of-range numbers, wrong types and unknown enum values", () => { expect(() => parseStylePresetAppearance(appearance({ padding: 101 }))).toThrow(TypeError); expect(() => parseStylePresetAppearance(appearance({ borderRadius: -1 }))).toThrow( diff --git a/src/lib/ai-edition/stylePresets.ts b/src/lib/ai-edition/stylePresets.ts index 5253a84e1..f4910b1b5 100644 --- a/src/lib/ai-edition/stylePresets.ts +++ b/src/lib/ai-edition/stylePresets.ts @@ -12,6 +12,7 @@ import type { CursorVisualSettings, + WallpaperMotion, WebcamBackgroundMode, WebcamLayoutPreset, WebcamMaskShape, @@ -34,6 +35,7 @@ const CSS_WALLPAPER_MAX_LENGTH = 10_000; /** The appearance fields of `EditorSettingsSnapshot`, in the snapshot's own shape. */ export interface StylePresetAppearance { wallpaper: string; + wallpaperMotion: WallpaperMotion; aspectRatio: AspectRatio; shadowIntensity: number; showBlur: boolean; @@ -92,6 +94,12 @@ const WEBCAM_BACKGROUND_MODES = [ "blur", "custom", ] as const satisfies readonly WebcamBackgroundMode[]; +const WALLPAPER_MOTIONS = [ + "none", + "drift", + "aurora", + "waves", +] as const satisfies readonly WallpaperMotion[]; // Bounds are the editor sliders' (RightPanes.tsx), in stored units. `getEditorSettings` // only clamps two of these, so a preset is the stricter gate: a value no slider can @@ -205,8 +213,9 @@ export function parseStylePresetWallpaper(value: unknown, key = "wallpaper"): st * lenient field is `cursorTheme`: themes come and go between builds, so an id this build * does not ship falls back to the default cursor instead of rejecting a preset that is * otherwise sound (the editor does the same when it renders one). `cursor.volume` may be - * absent: it postdates format version 1, and a preset without it was authored flat. Unknown - * extra keys are dropped. + * absent: it postdates format version 1, and a preset without it was authored flat. + * `wallpaperMotion` may be absent too: a preset saved before it has a still wallpaper, which + * is exactly what "none" means. Unknown extra keys are dropped. */ export function parseStylePresetAppearance(value: unknown): StylePresetAppearance { if (!isRecord(value)) { @@ -224,6 +233,10 @@ export function parseStylePresetAppearance(value: unknown): StylePresetAppearanc } return { wallpaper: parseStylePresetWallpaper(value.wallpaper), + wallpaperMotion: + value.wallpaperMotion === undefined + ? "none" + : readEnum(value, "wallpaperMotion", WALLPAPER_MOTIONS), aspectRatio: value.aspectRatio, shadowIntensity: readNumber(value, "shadowIntensity", NUMBER_RANGES.shadowIntensity), showBlur: readBoolean(value, "showBlur"), diff --git a/src/lib/ai-edition/stylePresetsEditor.test.ts b/src/lib/ai-edition/stylePresetsEditor.test.ts index 6b3a96273..bc588ab65 100644 --- a/src/lib/ai-edition/stylePresetsEditor.test.ts +++ b/src/lib/ai-edition/stylePresetsEditor.test.ts @@ -26,6 +26,7 @@ function styledSettings(): EditorSettingsSnapshot { return { ...DEFAULT_EDITOR_SETTINGS, wallpaper: "linear-gradient(90deg, #000, #fff)", + wallpaperMotion: "waves", aspectRatio: "9:16", shadowIntensity: 0.8, showBlur: true, diff --git a/src/lib/ai-edition/stylePresetsEditor.ts b/src/lib/ai-edition/stylePresetsEditor.ts index e3dc79d58..b24dcb7a7 100644 --- a/src/lib/ai-edition/stylePresetsEditor.ts +++ b/src/lib/ai-edition/stylePresetsEditor.ts @@ -15,6 +15,7 @@ export function stylePresetAppearanceFromSettings( ): StylePresetAppearance { return { wallpaper: settings.wallpaper, + wallpaperMotion: settings.wallpaperMotion, aspectRatio: settings.aspectRatio, shadowIntensity: settings.shadowIntensity, showBlur: settings.showBlur, @@ -58,6 +59,7 @@ export function factoryStylePresetAppearance(): StylePresetAppearance { export function stylePresetPatch(appearance: StylePresetAppearance): EditorSettingsPatch { return { wallpaper: appearance.wallpaper, + wallpaperMotion: appearance.wallpaperMotion, aspectRatio: appearance.aspectRatio, shadowIntensity: appearance.shadowIntensity, showBlur: appearance.showBlur, diff --git a/src/lib/projectDefaults.ts b/src/lib/projectDefaults.ts index b78b67dad..c508e76e1 100644 --- a/src/lib/projectDefaults.ts +++ b/src/lib/projectDefaults.ts @@ -1,5 +1,6 @@ export interface ProjectAppearanceDefaults { wallpaper: string; + wallpaperMotion: "none" | "drift" | "aurora" | "waves"; aspectRatio: `${number}:${number}` | "native"; shadowIntensity: number; showBlur: boolean; @@ -33,6 +34,7 @@ export interface ProjectAppearanceDefaults { /** The factory appearance every new project starts from. */ export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = { wallpaper: "/wallpapers/wallpaper1.jpg", + wallpaperMotion: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, diff --git a/src/native/browserShim.presets.test.ts b/src/native/browserShim.presets.test.ts index 5678db9ff..285b0ce7f 100644 --- a/src/native/browserShim.presets.test.ts +++ b/src/native/browserShim.presets.test.ts @@ -11,6 +11,7 @@ import { const APPEARANCE: StylePresetAppearance = { wallpaper: "#000000", + wallpaperMotion: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index 6f807b4e6..f9418be5b 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -19,7 +19,7 @@ import type { } from "@/lib/ai-edition/schema"; import { axcutSchemaVersion } from "@/lib/ai-edition/schema"; import { getFocusBoundsForScale } from "@/lib/zoomMath/focusUtils"; -import { buildSceneDescription } from "./sceneDescription"; +import { buildSceneDescription, wallpaperAcceptsMotion } from "./sceneDescription"; // --- Fixture helpers -------------------------------------------------------- // Keep fixtures minimal & deterministic — every field the serializer consults is filled in; @@ -178,6 +178,52 @@ describe("buildSceneDescription.background", () => { }); }); +describe("buildSceneDescription.background motion", () => { + const gradient = "linear-gradient(135deg, #eaebed, #bcc0c6)"; + + it("attaches the chosen motion to a gradient", () => { + const doc = makeDoc({ legacyEditor: { wallpaper: gradient, wallpaperMotion: "aurora" } }); + expect(buildSceneDescription(doc).background).toEqual({ + kind: "gradient", + angleDeg: 135, + stops: ["#eaebed", "#bcc0c6"], + motion: "aurora", + }); + }); + + // The Rust side defaults the key: a still gradient sends the payload it sent before. + it("omits the key when the gradient is still", () => { + for (const wallpaperMotion of ["none", undefined, "plasma"]) { + const doc = makeDoc({ legacyEditor: { wallpaper: gradient, wallpaperMotion } }); + expect(buildSceneDescription(doc).background).not.toHaveProperty("motion"); + } + }); + + it("never moves a colour, an image or the webcam's own gradient", () => { + const doc = makeDoc({ + legacyEditor: { + wallpaper: "/wallpapers/wallpaper1.jpg", + wallpaperMotion: "waves", + webcamBackgroundMode: "custom", + webcamWallpaper: gradient, + }, + }); + const scene = buildSceneDescription(doc); + expect(scene.background).toEqual({ kind: "image", path: "/wallpapers/wallpaper1.jpg" }); + expect(scene.webcamEffect?.background).not.toHaveProperty("motion"); + const colour = makeDoc({ legacyEditor: { wallpaper: "#123456", wallpaperMotion: "drift" } }); + expect(buildSceneDescription(colour).background).toEqual({ kind: "color", color: "#123456" }); + }); + + it("enables the control exactly for the wallpapers the compositor animates", () => { + expect(wallpaperAcceptsMotion(gradient)).toBe(true); + expect(wallpaperAcceptsMotion("#123456")).toBe(false); + expect(wallpaperAcceptsMotion("/wallpapers/wallpaper1.jpg")).toBe(false); + // Drawn by the web preview only; the compositor paints it as an image path. + expect(wallpaperAcceptsMotion("radial-gradient(#fff, #000)")).toBe(false); + }); +}); + describe("buildSceneDescription.webcamEffect", () => { // Omitted rather than sent as {mode:"none"}: the Rust side defaults the field, so every // project without an effect would otherwise carry it for nothing. diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index 13d05d3ae..0de4d36c8 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -18,6 +18,7 @@ import type { CameraFullscreenRegion, SpeedRegion, + WallpaperMotion, WebcamBackgroundMode, } from "@/components/video-editor/types"; import { DEFAULT_CROP_REGION, getZoomScale } from "@/components/video-editor/types"; @@ -57,7 +58,14 @@ import type { CompositorClipInput } from "./contracts"; /** Background behind the screen. Parsed from `settings.wallpaper`. */ export type SceneBackground = | { kind: "color"; color: string } // "#rrggbb" - | { kind: "gradient"; angleDeg: number; stops: string[] } // linear-gradient(deg, c1, c2, …) + // linear-gradient(deg, c1, c2, …). `motion` is omitted when still, so a project without + // animation sends the same payload as before the setting existed. + | { + kind: "gradient"; + angleDeg: number; + stops: string[]; + motion?: Exclude; + } | { kind: "image"; path: string }; // "/wallpapers/…" or a data: URL /** A timeline zoom region (from `document.zoomRanges`). Times in seconds. */ @@ -509,6 +517,23 @@ function parseWallpaper(wallpaper: string) { return { kind: "image", path: wallpaper } as const; } +/** + * True when `settings.wallpaperMotion` has something to move: the compositor animates only + * the gradient it draws itself. The motion control asks this, not its own guess at what a + * gradient is, so it is enabled exactly when the export would show the motion. + */ +export function wallpaperAcceptsMotion(wallpaper: string): boolean { + return parseWallpaper(wallpaper).kind === "gradient"; +} + +/** The screen background with the chosen motion attached, when there is one to attach. */ +function sceneBackground(wallpaper: string, motion: WallpaperMotion): SceneBackground { + const background = parseWallpaper(wallpaper); + return background.kind === "gradient" && motion !== "none" + ? { ...background, motion } + : background; +} + /** * The ONE clip list every native-facing consumer must build from — trim-narrowed * (`resolvePlaybackSegments`, so word-level cuts from the transcript editor actually reach @@ -1046,7 +1071,7 @@ export function buildSceneDescription( gainDb: settings.audioGainDb, }, audioTracks, - background: parseWallpaper(settings.wallpaper), + background: sceneBackground(settings.wallpaper, settings.wallpaperMotion), zoomRegions: projectedZoomRegions.map((region) => ({ id: region.id, startSec: region.startMs / 1000, diff --git a/technical-documentation/engineering/rendering-performance.md b/technical-documentation/engineering/rendering-performance.md index ea5b63cf1..9238c3ba5 100644 --- a/technical-documentation/engineering/rendering-performance.md +++ b/technical-documentation/engineering/rendering-performance.md @@ -172,6 +172,46 @@ The encoder is not the bottleneck either way — the two CPU rows differ by 5 %, gap to hardware is 18×. That gap is the blur and motion-blur shaders (see the table above), not the codec. +### Animated background (C9) — 2026-09-16 + +The animated gradient (`background.motion`, shader mode 5) is not a `Cfg` flag: it only +exists in a scene. So its row is an A/B of two scenes that differ by that one key, +[`scene-gradient-still.json`](../../crates/fixture/scene-gradient-still.json) and +[`scene-gradient-aurora.json`](../../crates/fixture/scene-gradient-aurora.json). Aurora is +the priced motion because it is the most expensive: value noise plus three gaussians on +every output pixel. + +```bash +x.bat run --release -- --cfg C3,C8 --preview --frames 300 --repeat 5 --scene fixture/scene-gradient-aurora.json +x.bat run --release -- --cfg C3,C8 --export --scene fixture/scene-gradient-aurora.json +x.bat run --release -- --cfg C3 --backend cpu --preview --frames 300 --repeat 3 --scene fixture/scene-gradient-aurora.json +``` + +One machine (RTX 4070 Ti, Ryzen 7 5800X, ~55 browser processes live), not the reference +iGPU. The frozen fixture media were absent, so the sources are two 6 s `-c copy` cuts of a +local 1080p60 recording. One warm-up pass discarded, then three rounds with still and +aurora interleaved. Each cell is the best of the three rounds, and each round is itself +best-of-5 (preview), best-of-3 (WARP) or a single run (export). + +| path | cfg | still ms/f | aurora ms/f | Δ ms/f | spread (per round) | +|---|---|---:|---:|---:|---| +| HW preview | C3 | 4.20 | 4.20 | 0.00 | 59–70 % | +| HW preview | C8 | 6.76 | 6.77 | +0.01 | 5.8–9.0 % | +| HW export | C3 | 2.22 | 2.24 | +0.02 | 2.22–2.36 over rounds | +| HW export | C8 | 2.17 | 2.19 | +0.02 | 2.17–2.26 over rounds | +| WARP preview | C3 | 12.54 | 19.69 | **+7.15** | 5.9/6.3 % still, 3.2/2.2 % aurora | + +**Free on a GPU, +57 % on WARP.** On the hardware backend the delta is below the noise in +every path. On WARP the shader costs ~7 ms per 1080p frame, the same shape as the blur +cliffs above: fine for a real GPU, heavy for a per-pixel loop on the CPU rasteriser. + +> **HW preview C3 is not admissible** under [§ spread thresholds](#spread-thresholds): its +> first repeat is a cold outlier in every round, so only its best-of is readable. One WARP +> round per arm blew the gate too (still 42.8 %, aurora 24.1 %) and is left out of the +> table. The export rows are single runs per round. The export does animate: a 60 px strip +> of the aurora export moves by up to 22/255 between frames 0 and 300, against 4/255 for the +> still one. + ## The macOS export path — 2026-09-03/04 Everything above is the Windows reference machine. This section is a **different machine and a different pipeline**: a Mac mini M1 (8 cores, 8 GiB, macOS 26.5), Metal compositor, VideoToolbox on both ends. Nothing here transfers to the Windows numbers, and the reverse held too — of the three levers that mattered on Windows and Linux, **none applied here**. From 38ebfb000327aea1e26d33593d69470ca171f132 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 17 Sep 2026 19:53:35 +0200 Subject: [PATCH 08/11] feat(frames): draw a window chrome around the screen plane Formerly #688. --- crates/compositor/src/compositor_linux.rs | 122 ++++- crates/compositor/src/compositor_macos.rs | 35 +- crates/compositor/src/compositor_windows.rs | 34 +- crates/compositor/src/frame_geometry.rs | 451 +++++++++++++++++- crates/compositor/src/scene.rs | 36 ++ crates/compositor/src/shaders.hlsl | 58 ++- crates/compositor/src/shaders.metal | 53 +- crates/compositor/src/vk_shaders/layer.wgsl | 62 ++- .../compositor/tests/window_frame_render.rs | 123 +++++ .../ai-edition/style-preset-service.test.ts | 1 + electron/ipc/nativeBridge.presets.test.ts | 1 + src/components/ai-edition/PreviewCanvas.tsx | 21 +- src/components/ai-edition/RightPanes.tsx | 59 +++ .../ai-edition/fitClipShapes.test.tsx | 20 + src/i18n/locales/ar/settings.json | 7 +- src/i18n/locales/cs/settings.json | 7 +- src/i18n/locales/en/settings.json | 7 +- src/i18n/locales/es/settings.json | 7 +- src/i18n/locales/fr/settings.json | 7 +- src/i18n/locales/it/settings.json | 7 +- src/i18n/locales/ja-JP/settings.json | 7 +- src/i18n/locales/ko-KR/settings.json | 7 +- src/i18n/locales/pt-BR/settings.json | 7 +- src/i18n/locales/ru/settings.json | 7 +- src/i18n/locales/tr/settings.json | 7 +- src/i18n/locales/vi/settings.json | 7 +- src/i18n/locales/zh-CN/settings.json | 7 +- src/i18n/locales/zh-TW/settings.json | 7 +- .../ai-edition/store/editorSettings.test.ts | 8 + src/lib/ai-edition/store/editorSettings.ts | 12 +- src/lib/ai-edition/stylePresets.test.ts | 9 + src/lib/ai-edition/stylePresets.ts | 21 +- src/lib/ai-edition/stylePresetsEditor.test.ts | 1 + src/lib/ai-edition/stylePresetsEditor.ts | 2 + src/lib/compositeLayout.test.ts | 20 + src/lib/compositeLayout.ts | 34 ++ src/lib/projectDefaults.ts | 18 + src/native/browserShim.presets.test.ts | 1 + src/native/sceneDescription.test.ts | 15 + src/native/sceneDescription.ts | 8 + 40 files changed, 1240 insertions(+), 83 deletions(-) create mode 100644 crates/compositor/tests/window_frame_render.rs diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 655864f48..149a9b826 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -43,8 +43,8 @@ pub use crate::frame_geometry::{ live_params_from_scene, webcam_shape_code, FIXTURE_FRAMES, LayerCB, LiveParams, OUT_H, OUT_W, }; use crate::frame_geometry::{ - cursor_sprite_cb, parse_hex, plan_cursor, plan_frame, CursorPlacement, CursorPlanInput, - FrameGeometryInput, + cursor_sprite_cb, cursor_sprite_dst, parse_hex, plan_cursor, plan_frame, CursorPlacement, + CursorPlanInput, FrameGeometryInput, ShadowCaster, }; use crate::scene::{Scene, SceneBackground}; @@ -1994,6 +1994,9 @@ impl Compositor { // `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. + // 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(); let screen_layer = match tilt.as_ref() { None => LayerCB { dst: g.s_dst, @@ -2004,10 +2007,14 @@ impl Compositor { color: [1.0, 1.0, 1.0, 1.0], src_prev: g.cut, dst_prev: g.s_dst_prev, - mb: [g.mb_taps, g.mb_amount, 1.0, 0.0], + mb: [g.mb_taps, g.mb_amount, 1.0, square_top], ..Default::default() }, - Some(quad) => self.tilted_screen_cb(quad, s_px, quad_center_px, g.cut, g.s_radius), + Some(quad) => { + let mut cb = self.tilted_screen_cb(quad, s_px, quad_center_px, g.cut, g.s_radius); + cb.dst_prev[2] = square_top; + cb + } }; // Bind group construit AVANT le pass (doit vivre pendant tout le pass) ; // `_screen_uniform` garde le buffer uniforme en vie (reference par le bind). @@ -2025,24 +2032,26 @@ impl Compositor { // plateformes quelle que soit la resolution de sortie. // // L'ombre suit la silhouette REELLEMENT affichee : rect arrondi (mode 2) - // quand l'ecran est droit, quadrilatere projete (mode 12) quand il penche. + // quand l'ecran est droit, quadrilatere projete (mode 12) quand il penche. Avec un + // cadre de fenetre, c'est le CADRE qui la porte (`shadow_caster`), sinon la barre de + // titre flotterait au-dessus de l'ombre. let screen_shadow = cfg.shadow.then(|| { let spread = crate::frame_geometry::SCREEN_SHADOW_SPREAD_FRAC * g.frame_min_px; let offset = [0.0, crate::frame_geometry::SCREEN_SHADOW_OFFSET_FRAC * g.frame_min_px]; let opacity = 0.45 * lp.shadow_scale; - let cb = match tilt.as_ref() { - None => self.shadow_cb(g.s_dst, s_px, g.s_radius, spread, offset, opacity), - Some(quad) => self.quad_shadow_cb( - &quad.corners, - quad_center_px, - g.s_radius * quad.scale, - spread, - offset, - opacity, - ), + let cb = match g.shadow_caster([rw, rh]) { + ShadowCaster::Upright { dst, size_px, radius } => { + self.shadow_cb(dst, size_px, radius, spread, offset, opacity) + } + ShadowCaster::Tilted { corners, center_px, radius } => { + self.quad_shadow_cb(&corners, center_px, radius, spread, offset, opacity) + } }; self.make_bind(&cb, None, &dummy) }); + // Le cadre (mode 14), dessine entre l'ombre et l'ecran : l'ecran le recouvre et ne + // laisse voir que la barre de titre et le filet. + let window_frame = g.window_frame_cb([rw, rh]).map(|cb| self.make_bind(&cb, None, &dummy)); // Fond (gradient mode 5 OU image mode 6), dessine dans la passe de fond. let bg_draw = bg_layer.and_then(|bl| match bl { @@ -2766,6 +2775,10 @@ impl Compositor { rpass.set_bind_group(0, bind, &[]); rpass.draw(0..4, 0..1); } + if let Some((_buf, bind)) = &window_frame { + rpass.set_bind_group(0, bind, &[]); + rpass.draw(0..4, 0..1); + } rpass.set_bind_group(0, &screen_bind, &[]); rpass.draw(0..4, 0..1); if let Some((_buf, bind)) = &webcam_shadow { @@ -4524,6 +4537,85 @@ mod tests { rgba } } + + /// Ecran gris sur fond gris moyen, avec ou sans cadre de fenetre (`frame` est insere tel + /// quel dans `effects`), droit ou incline (`rotation`, JSON). + fn compose_framed(comp: &Compositor, gpu: &Gpu, frame: &str, rotation: &str) -> Vec { + let json = format!( + r##"{{"clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded", + "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, + "effects":{{"padding":0.2,"blur":false,"shadow":0.6,"roundnessFrac":0.03,"motionBlur":0{frame}}}, + "background":{{"kind":"color","color":"#6070a0"}}, + "zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":10,"scale":1,"focusX":0.5,"focusY":0.5,"rotation":{rotation}}}], + "annotations":[], + "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0, + "clipToBounds":false,"theme":"default"}}, + "cropByClip":[null], + "output":{{"width":1280,"height":720,"fps":30}}}}"## + ); + let scene = Scene::from_json(&json).expect("scene json"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(false); + comp.set_scene(Some(scene)); + let screen = FakeFrame::new(gpu, 640, 360, |_, _| 60); + let webcam = FakeFrame::new(gpu, 64, 64, |_, _| 60); + let mut cfg = Cfg::c8(); + cfg.bg_blur = false; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = false; + cfg.mblur_n = 1; + cfg.shadow = true; + unsafe { + comp.set_timeline_time(Some(2.0)); + comp.compose_frame(screen.as_ptr(), webcam.as_ptr(), 0.0, &cfg) + .expect("compose_frame"); + let (_, _, rgba) = comp.readback_direct().expect("readback_direct"); + rgba + } + } + + /// Le cadre de fenetre (mode 14) se dessine sur Linux comme ailleurs : `"none"` rend + /// l'image d'avant a l'octet, le theme clair ajoute une barre claire, droite comme + /// inclinee, et les deux themes different. `OPENSCREEN_FRAME_OUT` recoit les PNG. + #[test] + fn the_window_frame_draws_its_title_bar_flat_and_tilted() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let out_dir = std::env::var("OPENSCREEN_FRAME_OUT").ok(); + let bright = |rgba: &[u8]| { + rgba.chunks_exact(4).filter(|p| p[0] > 215 && p[1] > 215 && p[2] > 215).count() + }; + let differing = |a: &[u8], b: &[u8]| { + a.chunks_exact(4) + .zip(b.chunks_exact(4)) + .filter(|(p, q)| p.iter().zip(q.iter()).take(3).any(|(x, y)| x.abs_diff(*y) > 8)) + .count() + }; + for (name, rotation) in [("flat", "null"), ("iso", r#""iso""#)] { + let absent = compose_framed(&comp, &gpu, "", rotation); + let none = compose_framed(&comp, &gpu, r#","frame":"none""#, rotation); + let light = compose_framed(&comp, &gpu, r#","frame":"window-light""#, rotation); + let dark = compose_framed(&comp, &gpu, r#","frame":"window-dark""#, rotation); + if let Some(dir) = &out_dir { + for (theme, rgba) in [("light", &light), ("dark", &dark)] { + let path = format!("{dir}/linux-{name}-{theme}.png"); + image::RgbaImage::from_raw(1280, 720, rgba.clone()) + .expect("dimensions du readback") + .save(&path) + .unwrap_or_else(|e| panic!("ecriture {path} : {e}")); + } + } + assert_eq!(absent, none, "{name}: frame none a change des pixels"); + assert_eq!(bright(&none), 0, "{name}: le temoin a deja des pixels clairs"); + // Barre de titre : ~0.04 x 576 px de haut sur ~1000 px de large, moins l'inclinaison. + assert!(bright(&light) > 10_000, "{name}: barre claire absente ({} px)", bright(&light)); + assert!(differing(&none, &dark) > 10_000, "{name}: cadre sombre absent"); + assert!(differing(&light, &dark) > 10_000, "{name}: les deux themes se confondent"); + } + } } // --------------------------------------------------------------------------- diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 0b3c6c0ed..1afb012f0 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -34,7 +34,7 @@ use crate::ffi::AVFrame; pub use crate::frame_geometry::{ live_params_from_scene, webcam_shape_code, FIXTURE_FRAMES, LayerCB, LiveParams, OUT_H, OUT_W, }; -use crate::frame_geometry::{parse_hex, FrameGeometryInput, SCREEN_SHADOW_OFFSET_FRAC, +use crate::frame_geometry::{parse_hex, FrameGeometryInput, ShadowCaster, SCREEN_SHADOW_OFFSET_FRAC, SCREEN_SHADOW_SPREAD_FRAC, WEBCAM_SHADOW_OFFSET_FRAC, WEBCAM_SHADOW_OPACITY, WEBCAM_SHADOW_SPREAD_FRAC}; use crate::scene::{Scene, SceneBackground}; @@ -1147,6 +1147,7 @@ impl Compositor { /// Écran incliné (mode 8) : warp bilinéaire inverse dans la bbox du quad projeté. /// Pas de motion blur sur ce chemin — le tilt est bref, la simplification ne se voit pas. + #[allow(clippy::too_many_arguments)] unsafe fn draw_tilted_screen( &self, enc: &metal::RenderCommandEncoderRef, @@ -1155,6 +1156,8 @@ impl Compositor { center_px: [f32; 2], cut: [f32; 4], radius: f32, + // 1 : coins hauts carrés, l'écran est sous la barre d'un cadre (`dst_prev.z`). + square_top: f32, y: &metal::Texture, uv: &metal::Texture, ) { @@ -1190,7 +1193,7 @@ impl Compositor { mode: 8.0, fx: [tl0, tl1, tr0, tr1], src_prev: [br0, br1, bl0, bl1], - dst_prev: [plane_px[0], plane_px[1], 0.0, 0.0], + dst_prev: [plane_px[0], plane_px[1], square_top, 0.0], ..Default::default() }, y, @@ -2069,20 +2072,22 @@ impl Compositor { let opacity = 0.45 * lp.shadow_scale; // L'ombre suit la silhouette réellement affichée : rect arrondi quand l'écran est // droit, quadrilatère projeté quand il est penché. Un rect droit derrière un écran - // incliné se lit comme une seconde surface, pas comme son ombre. - match tilt.as_ref() { - None => self.draw_shadow(enc, g.s_dst, s_px, g.s_radius, spread, offset, opacity), - Some(quad) => self.draw_quad_shadow( - enc, - &quad.corners, - quad_center_px, - g.s_radius * quad.scale, - spread, - offset, - opacity, + // incliné se lit comme une seconde surface, pas comme son ombre. Avec un cadre de + // fenêtre, c'est le CADRE qui la porte (`shadow_caster`). + match g.shadow_caster([rw, rh]) { + ShadowCaster::Upright { dst, size_px, radius } => { + self.draw_shadow(enc, dst, size_px, radius, spread, offset, opacity) + } + ShadowCaster::Tilted { corners, center_px, radius } => self.draw_quad_shadow( + enc, &corners, center_px, radius, spread, offset, opacity, ), } } + // Le cadre (mode 14) passe SOUS l'écran, qui ne laisse voir que la barre et le filet. + if let Some(cb) = g.window_frame_cb([rw, rh]) { + self.draw_solid(enc, &cb); + } + let square_top = g.screen_square_top(); let [su0, sv0, su1, sv1] = g.cut; match tilt.as_ref() { None => self.draw_video( @@ -2096,14 +2101,14 @@ impl Compositor { color: [0.0, 0.0, 0.0, 1.0], src_prev: [su0, sv0, su1, sv1], dst_prev: g.s_dst_prev, - mb: [g.mb_taps, g.mb_amount, 1.0, 0.0], + mb: [g.mb_taps, g.mb_amount, 1.0, square_top], ..Default::default() }, &sy, &suv, ), Some(quad) => self.draw_tilted_screen( - enc, quad, s_px, quad_center_px, g.cut, g.s_radius, &sy, &suv, + enc, quad, s_px, quad_center_px, g.cut, g.s_radius, square_top, &sy, &suv, ), } diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index bcc1c81d1..a4dd3d0d3 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -11,7 +11,7 @@ pub use crate::frame_geometry::{live_params_from_scene, webcam_shape_code, Layer use crate::frame_geometry::{ cover_crop_uv, cover_uv_rect, decode_data_uri, ease_in_out_cubic, lerp, lerp4, parse_hex, preset_placements, remap_box, screen_source_rect, timeline, CursorPlacement, - FrameParams, Placement, CURSOR_BASE_SIZE_FRAC, FPS, SCREEN_SHADOW_OFFSET_FRAC, + FrameParams, Placement, ShadowCaster, CURSOR_BASE_SIZE_FRAC, FPS, SCREEN_SHADOW_OFFSET_FRAC, SCREEN_SHADOW_SPREAD_FRAC, SHADOW_TUNING_REF_PX, WEBCAM_SHADOW_OFFSET_FRAC, WEBCAM_SHADOW_OPACITY, WEBCAM_SHADOW_SPREAD_FRAC, }; @@ -1761,23 +1761,29 @@ impl Compositor { // penché ne se lisait pas comme son ombre mais comme une seconde surface. Elle suit // aussi la croissance de la boîte pendant un zoom (issue #179) : quand la boîte sort // du cadre, l'ombre en sort avec elle, sans jamais se lire comme une bande noire. + // Avec un cadre de fenêtre, c'est le CADRE qui porte l'ombre (`shadow_caster`), sinon + // elle tomberait sous l'écran seul et la barre de titre flotterait au-dessus. + let render_px = [self.rw(), self.rh()]; if cfg.shadow { let spread = SCREEN_SHADOW_SPREAD_FRAC * frame_min_px; let offset = [0.0, SCREEN_SHADOW_OFFSET_FRAC * frame_min_px]; let opacity = 0.45 * lp.shadow_scale; - match tilt.as_ref() { - None => self.draw_shadow(s_dst, s_px, s_radius, spread, offset, opacity), - Some(quad) => self.draw_quad_shadow( - &quad.corners, - quad_center_px, - // Même rayon que le plan incliné lui-même (cf. le dessin du mode 8). - s_radius * quad.scale, - spread, - offset, - opacity, - ), + match g.shadow_caster(render_px) { + ShadowCaster::Upright { dst, size_px, radius } => { + self.draw_shadow(dst, size_px, radius, spread, offset, opacity) + } + // Même rayon que le plan incliné lui-même (cf. le dessin du mode 8). + ShadowCaster::Tilted { corners, center_px, radius } => { + self.draw_quad_shadow(&corners, center_px, radius, spread, offset, opacity) + } } } + // Le cadre (mode 14) passe SOUS l'écran : l'écran le recouvre, ne laissant voir que la + // barre de titre, le filet et les coins bas entre les deux arrondis. + if let Some(cb) = g.window_frame_cb(render_px) { + self.draw_solid(&cb); + } + let square_top = g.screen_square_top(); if crate::regions::is_identity_rotation(zoom_rotation) { self.draw_video( &LayerCB { @@ -1789,7 +1795,7 @@ impl Compositor { color: [0.0, 0.0, 0.0, 1.0], src_prev: [su0_p, sv0_p, su0_p + 2.0 * hu_p, sv0_p + 2.0 * hv_p], dst_prev: s_dst_prev, - mb: [mb_taps, mb_amount, 1.0, 0.0], + mb: [mb_taps, mb_amount, 1.0, square_top], ..Default::default() }, &sy, @@ -1847,7 +1853,7 @@ impl Compositor { mode: 8.0, fx: [tl0, tl1, tr0, tr1], src_prev: [br0, br1, bl0, bl1], - dst_prev: [plane_px[0], plane_px[1], 0.0, 0.0], + dst_prev: [plane_px[0], plane_px[1], square_top, 0.0], ..Default::default() }, &sy, diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index bb102c92a..87faaf7d1 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -362,6 +362,65 @@ pub(crate) const WEBCAM_SHADOW_OFFSET_FRAC: f32 = 12.0 / SHADOW_TUNING_REF_PX; pub(crate) const WEBCAM_SHADOW_OPACITY: f32 = 0.35; /// Taille de base du curseur, même convention (34 px réglés contre un cadre 1080). pub(crate) const CURSOR_BASE_SIZE_FRAC: f32 = 34.0 / SHADOW_TUNING_REF_PX; + +/// Hauteur de la barre de titre du cadre de fenêtre, en fraction du petit côté de la boîte où +/// l'écran tenait sans cadre. Environ 35 px sur une boîte de 864 px (1080p, padding 50 %) : la +/// proportion d'une barre de titre de bureau à cette échelle. +pub(crate) const WINDOW_FRAME_BAR_FRAC: f32 = 0.04; +/// Épaisseur du filet qui borde le cadre, même référence : environ un pixel sur la même boîte. +/// Pas de plancher en px — il rendrait le filet plus épais, en proportion, dans la petite +/// preview qu'à l'export ; le shader l'estompe plutôt que de le faire disparaître. +pub(crate) const WINDOW_FRAME_LINE_FRAC: f32 = 0.0012; + +/// Le cadre de fenêtre posé autour de l'écran (`effects.frame`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WindowFrame { + pub dark: bool, + /// Marges du cadre autour de `s_dst`, en FRACTION de cette boîte : gauche, haut, droite, bas + /// (le haut est la barre de titre, les trois autres le filet). Des fractions et non des px : + /// `remap_box` agrandit la boîte sous un zoom (#179) et le cadre doit grandir avec elle. Ce + /// sont aussi, prises en négatif ou au-delà de 1, les coordonnées du plan où + /// `TiltedQuad::point_px` extrapole les coins du cadre sous un préset 3D. + pub margins: [f32; 4], + /// Rayon extérieur du cadre, en px de la boîte droite : c'est lui que règle Roundness quand + /// il y a un cadre. L'écran n'arrondit plus que ses coins bas, de ce rayon moins le filet. + pub radius: f32, +} + +/// Rétrécit la boîte écran `a` (fractions de sortie) pour que l'écran ET son cadre tiennent là +/// où l'écran seul tenait. Rend la nouvelle boîte écran et les marges du cadre (cf. +/// `WindowFrame::margins`). +/// +/// Sans ça le cadre déborderait de la boîte, donc du canvas à petit padding. La boîte garde son +/// ratio (celui du crop, déjà cuit dans `a`) et se centre dans `a` ; sous `cover` (layouts en +/// bloc) l'écran prend au contraire tout ce que le cadre laisse, le cover rognant la source. +pub(crate) fn fit_in_window_frame(a: [f32; 4], render_px: [f32; 2], cover: bool) -> ([f32; 4], [f32; 4]) { + let [rw, rh] = render_px; + let (aw, ah) = (a[2] * rw, a[3] * rh); + let m = aw.min(ah); + let bar = WINDOW_FRAME_BAR_FRAC * m; + let line = WINDOW_FRAME_LINE_FRAC * m; + let (iw, ih) = ((aw - 2.0 * line).max(1.0), (ah - bar - line).max(1.0)); + let (sw, sh) = if cover { + (iw, ih) + } else { + let ar = aw / ah.max(1e-4); + if iw / ih > ar { (ih * ar, ih) } else { (iw, iw / ar) } + }; + let (fw, fh) = (sw + 2.0 * line, sh + bar + line); + let (cx, cy) = ((a[0] + a[2] * 0.5) * rw, (a[1] + a[3] * 0.5) * rh); + let (x0, y0) = (cx - fw * 0.5 + line, cy - fh * 0.5 + bar); + ([x0 / rw, y0 / rh, sw / rw, sh / rh], [line / sw, bar / sh, line / sw, line / sh]) +} + +/// Qui porte l'ombre portée de l'écran, et avec quelle silhouette. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ShadowCaster { + /// Rect droit (mode 2) : `dst` en fractions de sortie, `size_px` sa taille, `radius` en px. + Upright { dst: [f32; 4], size_px: [f32; 2], radius: f32 }, + /// Quad incliné (mode 12) : coins TL, TR, BR, BL relatifs à `center_px`, rayon du plan. + Tilted { corners: [(f32, f32); 4], center_px: [f32; 2], radius: f32 }, +} /// Rect [x,y,w,h] normalisé d'un sprite de curseur de taille `w`×`h` dont le pivot `hotspot` /// (fraction 0..1 de l'image) doit tomber exactement sur `center`. /// @@ -960,7 +1019,8 @@ pub struct FrameGeometry { /// (`privacy_mask`). /// /// C'est `s_dst` avant le `remap_box` du zoom, donc le rect que l'app a résolu - /// (`layout.screenRect`) et que l'overlay web reçoit comme conteneur. Le contrat de + /// (`layout.screenRect`, rétréci par `fitInWindowFrame` sous un cadre) et que l'overlay web + /// reçoit comme conteneur. Le contrat de /// `SceneAnnotation` est explicite : « deliberately NOT affected by the zoom crop — the /// overlay is a sibling of the element carrying the zoom transform, so annotations hold /// still while the content zooms underneath them ». Tant que le zoom vivait dans la @@ -975,6 +1035,10 @@ pub struct FrameGeometry { pub w_px: [f32; 2], pub w_radius: f32, pub shape_fade: f32, + /// Cadre de fenêtre autour de l'écran. `None` : aucun, et le rendu est celui d'avant le + /// cadre, à l'octet. `Some` : `s_dst` est déjà la boîte rétrécie, et `s_radius` le rayon des + /// seuls coins BAS de l'écran (les coins hauts sont carrés, sous la barre de titre). + pub window_frame: Option, } /// Rect de destination d'une annotation dans un rect d'ancrage, en fractions de la sortie. @@ -1030,6 +1094,130 @@ impl FrameGeometry { self.s_ann[3] * rh } + /// 1 quand l'écran doit garder ses coins HAUTS carrés (il est sous la barre de titre d'un + /// cadre), 0 sinon. Le mode 0 le lit dans `mb.w`, le mode 8 dans `dst_prev.z` — deux + /// emplacements que ces modes laissaient à zéro, d'où un rendu inchangé sans cadre. + pub fn screen_square_top(&self) -> f32 { + if self.window_frame.is_some() { 1.0 } else { 0.0 } + } + + /// Le plan incliné de l'écran, `None` quand il est droit. Même appel que les backends : un + /// calcul déterministe, donc le même quadrilatère au bit près. + fn screen_tilt(&self, render_px: [f32; 2]) -> Option { + let s_px = [self.s_dst[2] * render_px[0], self.s_dst[3] * render_px[1]]; + (!crate::regions::is_identity_rotation(self.zoom_rotation)) + .then(|| crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], self.zoom_rotation)) + } + + fn screen_center_px(&self, render_px: [f32; 2]) -> [f32; 2] { + [ + (self.s_dst[0] + self.s_dst[2] * 0.5) * render_px[0], + (self.s_dst[1] + self.s_dst[3] * 0.5) * render_px[1], + ] + } + + /// Les coins TL, TR, BR, BL du cadre, en px relatifs au centre de l'écran, plus l'échelle du + /// plan (1 à plat). Incliné, c'est le MÊME `TiltedQuad` que l'écran, prolongé au-delà de + /// 0..1 : un warp bilinéaire est entièrement fixé par ses quatre coins, donc le prolonger + /// donne exactement le plan que le mode 8 dessine, et le cadre penche avec l'écran sans + /// aucune trigonométrie de plus. + fn window_frame_corners(&self, frame: &WindowFrame, render_px: [f32; 2]) -> ([(f32, f32); 4], f32) { + let [ml, mt, mr, mb] = frame.margins; + let quad = self.screen_tilt(render_px).unwrap_or_else(|| { + let (hw, hh) = (self.s_dst[2] * render_px[0] * 0.5, self.s_dst[3] * render_px[1] * 0.5); + crate::regions::TiltedQuad { + corners: [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)], + scale: 1.0, + } + }); + let corners = [ + quad.point_px(-ml, -mt), + quad.point_px(1.0 + mr, -mt), + quad.point_px(1.0 + mr, 1.0 + mb), + quad.point_px(-ml, 1.0 + mb), + ]; + (corners, quad.scale) + } + + /// Ce qui porte l'ombre portée : le cadre quand il y en a un — sinon l'ombre tomberait sous + /// l'écran seul et la barre de titre flotterait au-dessus d'elle —, l'écran sinon, avec + /// exactement l'arithmétique que les backends faisaient avant le cadre. + pub fn shadow_caster(&self, render_px: [f32; 2]) -> ShadowCaster { + let center_px = self.screen_center_px(render_px); + match (&self.window_frame, self.screen_tilt(render_px)) { + (None, None) => ShadowCaster::Upright { + dst: self.s_dst, + size_px: [self.s_dst[2] * render_px[0], self.s_dst[3] * render_px[1]], + radius: self.s_radius, + }, + (None, Some(quad)) => ShadowCaster::Tilted { + corners: quad.corners, + center_px, + radius: self.s_radius * quad.scale, + }, + (Some(frame), None) => { + let [ml, mt, mr, mb] = frame.margins; + let d = self.s_dst; + let dst = [ + d[0] - ml * d[2], + d[1] - mt * d[3], + d[2] * (1.0 + ml + mr), + d[3] * (1.0 + mt + mb), + ]; + ShadowCaster::Upright { + dst, + size_px: [dst[2] * render_px[0], dst[3] * render_px[1]], + radius: frame.radius, + } + } + (Some(frame), Some(_)) => { + let (corners, scale) = self.window_frame_corners(frame, render_px); + ShadowCaster::Tilted { corners, center_px, radius: frame.radius * scale } + } + } + } + + /// Le calque du cadre (mode 14), à dessiner après l'ombre et AVANT l'écran. `None` sans cadre. + /// + /// Une seule forme pour le cas droit et le cas incliné : le mode 14 fait toujours le warp + /// inverse du mode 8, et sur un rect ce warp est l'identité exacte (le terme quadratique est + /// nul). Le calque se construit donc ici, une fois, pour les trois backends. + pub fn window_frame_cb(&self, render_px: [f32; 2]) -> Option { + let frame = self.window_frame.as_ref()?; + let [rw, rh] = render_px; + let (corners, scale) = self.window_frame_corners(frame, render_px); + let center = self.screen_center_px(render_px); + let (min_x, max_x) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (min_y, max_y) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + let bbox = [(max_x - min_x).max(1.0), (max_y - min_y).max(1.0)]; + let local = |(x, y): (f32, f32)| [x - min_x, y - min_y]; + let [tl, tr, br, bl] = corners.map(local); + let [ml, mt, mr, mb] = frame.margins; + let (s_w, s_h) = (self.s_dst[2] * rw, self.s_dst[3] * rh); + // Dimensions dans le repère du plan, avant projection, comme `plane_px` au mode 8. + let plane_px = [s_w * (1.0 + ml + mr) * scale, s_h * (1.0 + mt + mb) * scale]; + let (bar_px, line_px) = (mt * s_h * scale, ml * s_w * scale); + let (fill, line) = if frame.dark { + ([0.165, 0.165, 0.180, 1.0], [1.0, 1.0, 1.0, 0.14]) + } else { + ([0.925, 0.925, 0.935, 1.0], [0.0, 0.0, 0.0, 0.16]) + }; + Some(LayerCB { + dst: [(center[0] + min_x) / rw, (center[1] + min_y) / rh, bbox[0] / rw, bbox[1] / rh], + quad_px: bbox, + radius_px: frame.radius * scale, + mode: 14.0, + color: fill, + fx: [tl[0], tl[1], tr[0], tr[1]], + src_prev: [br[0], br[1], bl[0], bl[1]], + dst_prev: [plane_px[0], plane_px[1], bar_px, line_px], + mb: line, + ..Default::default() + }) + } + /// Où dessiner le masque d'une annotation « flou » pour qu'il couvre le CONTENU qu'il /// cachait au repos, et à quelle force. `None` si le rect est dégénéré. /// @@ -1433,8 +1621,24 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // d'atteindre les bords du cadre. On rend le zoom à la BOÎTE (cf. `remap_box`) : // la coupe dessinée redevient le crop nu, la boîte porte le grossissement et // déborde le padding — c'est la géométrie de `applyZoomTransform` (TS). - let s_base = fit_screen(p.screen.dst); - let s_base_prev = fit_screen(pp.screen.dst); + let s_box = fit_screen(p.screen.dst); + let s_box_prev = fit_screen(pp.screen.dst); + // Cadre de fenêtre : l'écran rétrécit pour que lui ET son cadre tiennent dans la boîte + // où il tenait seul — le padding garde donc son sens, et rien ne sort du canvas. Le reste + // du calcul (cover, zoom, rayon) part de cette boîte rétrécie. + let frame_kind = scene.map(|s| s.effects.frame).unwrap_or_default(); + let screen_cover = scene.map(|s| s.layout.screen_cover).unwrap_or(false); + let (s_base, frame_margins) = match frame_kind { + crate::scene::SceneFrame::None => (s_box, None), + _ => { + let (b, m) = fit_in_window_frame(s_box, [rw, rh], screen_cover); + (b, Some(m)) + } + }; + let s_base_prev = match frame_margins { + None => s_box_prev, + Some(_) => fit_in_window_frame(s_box_prev, [rw, rh], screen_cover).0, + }; // Layouts "bloc" (side-by-side / top-bottom) : la boîte écran est un SLOT au ratio // arbitraire, et le web y fait tenir l'image en `cover` (`computeCompositeLayout` // renvoie `screenCover: true`, honoré par `frameRenderer`). Le natif l'ignorait, donc @@ -1563,15 +1767,34 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // Le rayon suit la boîte : quand le zoom l'agrandit (issue #179), les coins grandissent // avec elle puis sortent du cadre — comme le masque de la référence, qui porte le même // `br: maskBorderRadius * camS` et quitte l'étage au même moment. - let s_radius = match (cfg.rounded, app_screen_radius_frac, scene_roundness_frac) { + // Avec un cadre, le rayon appartient au CADRE : c'est sa boîte extérieure qui sert de + // référence, et Roundness arrondit le cadre au lieu de doubler un arrondi d'écran. + let rounded_box_min_px = match frame_margins { + None => s_min_px, + Some([ml, mt, mr, mb]) => { + ((s_dst[2] * rw) * (1.0 + ml + mr)).min((s_dst[3] * rh) * (1.0 + mt + mb)) + } + }; + let outer_radius = match (cfg.rounded, app_screen_radius_frac, scene_roundness_frac) { (false, _, _) => 0.0, // Preset en bloc : le rayon appartient à la boîte écran (parité exacte avec la caméra). - (true, Some(f), _) => f * s_min_px, + (true, Some(f), _) => f * rounded_box_min_px, // Scène sans rayon imposé : slider Roundness, relatif au cadre. (true, None, Some(f)) => f * frame_min_px, // Fixture/bench (pas de scène) : chemin inspector historique, inchangé. (true, None, None) => p.screen.radius * lp.radius_scale, }; + // Sous un cadre, l'écran est rentré du filet : ses coins bas restent concentriques à + // ceux du cadre en perdant l'épaisseur du filet ; ses coins hauts sont carrés (shader). + let window_frame = frame_margins.map(|margins| WindowFrame { + dark: frame_kind == crate::scene::SceneFrame::WindowDark, + margins, + radius: outer_radius, + }); + let s_radius = match frame_margins { + None => outer_radius, + Some([ml, ..]) => (outer_radius - ml * s_dst[2] * rw).max(0.0), + }; let w_px = [w_dst[2] * rw, w_dst[3] * rh]; // Rayon caméra. Le slider Roundness ne s'y applique jamais (il ne vaut que pour l'ÉCRAN). // Quand l'app le résout (`computeCompositeLayout`, source unique), on le prend : c'est la @@ -1612,7 +1835,10 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { cut, s_dst, s_dst_prev, - // La boîte écran telle qu'elle serait sans zoom : `remap_box` n'est PAS appliqué. + // La boîte écran telle qu'elle serait sans zoom : `remap_box` n'est PAS appliqué. Le + // cadre, lui, l'est : c'est le rect du CONTENU, celui où l'overlay web pose ses poignées + // (`fitInWindowFrame` côté TS, même calcul), et sans quoi un flou tracé sur un secret + // tombait une barre de titre plus haut que ce qu'il devait couvrir. s_ann: s_base, s_radius, frame_min_px, @@ -1621,6 +1847,7 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { w_px, w_radius, shape_fade, + window_frame, } } @@ -2145,6 +2372,217 @@ mod tests { assert_eq!(a.s_ann, a.s_dst, "sans zoom, ancre et boîte écran coïncident"); } + /// Scène à boîte écran résolue par l'app, pour le cadre de fenêtre. `frame` est inséré tel + /// quel dans `effects` (chaîne vide = clé absente). + fn framed_scene(frame: &str, rotation: &str, zoom: f32, cover: bool) -> Scene { + Scene::from_json(&format!( + r##"{{ + "clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null, + "webcamReactiveZoom":false,"screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}},"screenCover":{cover}}}, + "effects":{{"padding":0.2,"blur":false,"shadow":0.5,"roundnessFrac":0.03,"motionBlur":0{frame}}}, + "background":{{"kind":"color","color":"#1e1e2e"}}, + "zoomRegions":[{{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":{zoom},"focusX":0.5,"focusY":0.5,"rotation":{rotation}}}], + "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":1,"clipToBounds":false,"theme":"default"}}, + "cropByClip":[null], + "output":{{"width":1920,"height":1080,"fps":60}} + }}"## + )) + .expect("framed scene") + } + + fn framed_plan(scene: &Scene) -> FrameGeometry { + let cfg = crate::config::all().pop().expect("au moins une config"); + let mut input = golden_input(scene, &cfg); + input.render_px = [1920.0, 1080.0]; + plan_frame(&input) + } + + const RENDER: [f32; 2] = [1920.0, 1080.0]; + + fn contains(outer: [f32; 4], inner: [f32; 4], eps: f32) -> bool { + inner[0] >= outer[0] - eps + && inner[1] >= outer[1] - eps + && inner[0] + inner[2] <= outer[0] + outer[2] + eps + && inner[1] + inner[3] <= outer[1] + outer[3] + eps + } + + /// Sans cadre — clé absente ou `"none"` —, la géométrie est celle d'avant le cadre, champ + /// pour champ, et l'ombre reprend exactement l'arithmétique des backends. + #[test] + fn no_frame_leaves_the_geometry_untouched() { + for rotation in ["null", r#""iso""#] { + let absent = framed_plan(&framed_scene("", rotation, 1.5, false)); + let none = framed_plan(&framed_scene(r#","frame":"none""#, rotation, 1.5, false)); + assert_eq!(absent.s_dst, none.s_dst); + assert_eq!(absent.s_ann, none.s_ann); + assert_eq!(absent.s_radius.to_bits(), none.s_radius.to_bits()); + assert!(none.window_frame.is_none()); + assert!(none.window_frame_cb(RENDER).is_none()); + assert_eq!(none.screen_square_top(), 0.0); + let s_px = [none.s_dst[2] * RENDER[0], none.s_dst[3] * RENDER[1]]; + let expected = match none.screen_tilt(RENDER) { + None => ShadowCaster::Upright { dst: none.s_dst, size_px: s_px, radius: none.s_radius }, + Some(q) => ShadowCaster::Tilted { + corners: q.corners, + center_px: none.screen_center_px(RENDER), + radius: none.s_radius * q.scale, + }, + }; + assert_eq!(none.shadow_caster(RENDER), expected); + // Sans zoom, l'ancre des annotations reste la boîte écran. + let rest = framed_plan(&framed_scene(r#","frame":"none""#, rotation, 1.0, false)); + assert_eq!(rest.s_ann, rest.s_dst); + // Et cette boîte est celle que la scène a résolue, au bit près : sans cadre, rien ne + // la rétrécit. Épinglé sur l'entrée, pas sur une mesure du code. + let want: [f32; 4] = [0.1, 0.1, 0.8, 0.8]; + assert_eq!(rest.s_dst.map(f32::to_bits), want.map(f32::to_bits)); + assert_eq!(rest.s_ann.map(f32::to_bits), want.map(f32::to_bits)); + assert_eq!(rest.s_radius.to_bits(), (0.03f32 * 1080.0).to_bits()); + } + } + + /// Sous un cadre, le masque de confidentialité couvre le rect que l'overlay web montre à + /// l'utilisateur : l'overlay pose ses poignées sur `fitInWindowFrame(screenRect)` (TS), le + /// portage de `fit_in_window_frame`. Ancré sur la boîte non rétrécie, le masque tombait une + /// barre de titre plus bas que le secret tracé, et en laissait une bande lisible. + #[test] + fn a_framed_privacy_mask_covers_what_the_overlay_shows() { + // A plat seulement : incliné, le contenu ne tombe plus dans un rect droit, et l'overlay + // web ne s'incline pas non plus (limite antérieure au cadre). + { + let rotation = "null"; + let g = framed_plan(&framed_scene(r#","frame":"window-light""#, rotation, 1.0, false)); + // Valeurs épinglées aussi dans `compositeLayout.test.ts` : les deux portages + // doivent rendre ce rect-là. + let overlay = fit_in_window_frame([0.1, 0.1, 0.8, 0.8], RENDER, false).0; + let want = [223.6416 / 1920.0, 142.56 / 1080.0, 1472.7168 / 1920.0, 828.4032 / 1080.0]; + for k in 0..4 { + assert!((overlay[k] - want[k]).abs() < 1e-5, "{overlay:?} au lieu de {want:?}"); + } + assert_eq!(g.s_ann, overlay, "{rotation}: l'ancre n'est pas le rect de l'overlay"); + for (x, y) in [(0.0, 0.0), (0.62, 0.18), (0.8, 0.9)] { + let mut a = blur_annotation(""); + (a.x, a.y) = (x, y); + let drawn = annotation_dst_in(overlay, a.x, a.y, a.w, a.h); + let m = g.privacy_mask(&a, RENDER).expect("masque"); + assert!(contains(m.dst, drawn, 1e-6), "{rotation}: masque {:?} / tracé {drawn:?}", m.dst); + // Et il ne déborde que de sa marge d'un pixel. + assert!((m.dst[1] - drawn[1]).abs() * RENDER[1] < 1.01); + } + } + } + + /// À plat : le cadre contient l'écran, porte l'ombre, et le tout tient dans la boîte où + /// l'écran tenait seul — au ratio près, l'écran garde celui de sa source. + #[test] + fn the_flat_frame_wraps_the_screen_inside_the_old_box() { + for (frame, dark) in [(r#","frame":"window-light""#, false), (r#","frame":"window-dark""#, true)] { + for cover in [false, true] { + let old = framed_plan(&framed_scene("", "null", 1.0, cover)); + let g = framed_plan(&framed_scene(frame, "null", 1.0, cover)); + let wf = g.window_frame.expect("un cadre"); + assert_eq!(wf.dark, dark); + assert_eq!(g.screen_square_top(), 1.0); + let ShadowCaster::Upright { dst: outer, size_px, radius } = g.shadow_caster(RENDER) else { + panic!("un cadre droit porte une ombre droite"); + }; + assert!(contains(outer, g.s_dst, 1e-6), "cadre {outer:?} / écran {:?}", g.s_dst); + assert!(contains(old.s_dst, outer, 1e-6), "boîte {:?} / cadre {outer:?}", old.s_dst); + // Il remplit la boîte sur au moins un axe : le padding garde son sens. + let fills = (outer[2] - old.s_dst[2]).abs() < 1e-5 || (outer[3] - old.s_dst[3]).abs() < 1e-5; + assert!(fills, "le cadre ne remplit pas la boîte : {outer:?} dans {:?}", old.s_dst); + // La barre de titre est en haut, le filet ailleurs. + assert!(wf.margins[1] * g.s_dst[3] > 10.0 * wf.margins[0] * g.s_dst[2]); + if !cover { + let ar = |r: [f32; 4]| (r[2] * RENDER[0]) / (r[3] * RENDER[1]); + assert!((ar(g.s_dst) - ar(old.s_dst)).abs() < 1e-3, "l'écran a changé de ratio"); + } + // Roundness arrondit le cadre ; l'écran perd l'épaisseur du filet. + assert!((radius - old.s_radius).abs() < old.s_radius * 0.05, "rayon {radius} vs {}", old.s_radius); + let line_px = wf.margins[0] * g.s_dst[2] * RENDER[0]; + assert!((g.s_radius - (radius - line_px)).abs() < 1e-3); + // Le calque du cadre : un rect exact, bbox comprise. + let cb = g.window_frame_cb(RENDER).expect("calque"); + assert_eq!(cb.mode, 14.0); + assert!((cb.quad_px[0] - size_px[0]).abs() < 1e-2 && (cb.quad_px[1] - size_px[1]).abs() < 1e-2); + assert_eq!([cb.fx[0], cb.fx[1]], [0.0, 0.0]); + assert!((cb.src_prev[0] - size_px[0]).abs() < 1e-2 && (cb.src_prev[1] - size_px[1]).abs() < 1e-2); + assert!((cb.dst_prev[0] - size_px[0]).abs() < 1e-2); + assert_eq!(cb.radius_px, radius); + } + } + } + + /// Incliné : le cadre est le même plan que l'écran, prolongé. Les coins de l'écran sont + /// l'image bilinéaire des coins du cadre aux fractions de ses marges — la propriété qui + /// garantit que le cadre penche exactement comme le contenu —, le cadre contient l'écran, + /// porte l'ombre (mode 12), et tient dans la boîte d'avant. + #[test] + fn the_tilted_frame_is_the_screen_plane_extended() { + for preset in [r#""iso""#, r#""left""#, r#""right""#] { + let old = framed_plan(&framed_scene("", preset, 1.0, false)); + let g = framed_plan(&framed_scene(r#","frame":"window-light""#, preset, 1.0, false)); + let wf = g.window_frame.expect("un cadre"); + let quad = g.screen_tilt(RENDER).expect("incliné"); + let ShadowCaster::Tilted { corners: frame, center_px, radius } = g.shadow_caster(RENDER) else { + panic!("un cadre incliné porte une ombre inclinée"); + }; + assert_eq!(center_px, g.screen_center_px(RENDER)); + assert!((radius - wf.radius * quad.scale).abs() < 1e-4); + + let [ml, mt, mr, mb] = wf.margins; + let frame_quad = crate::regions::TiltedQuad { corners: frame, scale: quad.scale }; + let (u0, v0) = (ml / (1.0 + ml + mr), mt / (1.0 + mt + mb)); + let (u1, v1) = ((1.0 + ml) / (1.0 + ml + mr), (1.0 + mt) / (1.0 + mt + mb)); + for (i, (u, v)) in [(u0, v0), (u1, v0), (u1, v1), (u0, v1)].into_iter().enumerate() { + let (x, y) = frame_quad.point_px(u, v); + let (sx, sy) = quad.corners[i]; + assert!((x - sx).abs() < 0.05 && (y - sy).abs() < 0.05, "{preset} coin {i}: {x},{y} vs {sx},{sy}"); + } + + // Le cadre contient l'écran : chaque coin de l'écran est du bon côté des 4 arêtes. + for &(px, py) in &quad.corners { + for k in 0..4 { + let (ax, ay) = frame[k]; + let (bx, by) = frame[(k + 1) % 4]; + let cross = (bx - ax) * (py - ay) - (by - ay) * (px - ax); + assert!(cross > 0.0, "{preset}: coin d'écran hors du cadre (arête {k})"); + } + } + + // Il tient dans la boîte où l'écran tenait seul. Le prolongement bilinéaire n'est pas + // une vraie projection et le containment ne porte que sur l'écran : on tolère 1 %. + let (min_x, max_x) = frame.iter().fold((f32::MAX, f32::MIN), |(a, b), &(x, _)| (a.min(x), b.max(x))); + let (min_y, max_y) = frame.iter().fold((f32::MAX, f32::MIN), |(a, b), &(_, y)| (a.min(y), b.max(y))); + let bbox = [ + (center_px[0] + min_x) / RENDER[0], + (center_px[1] + min_y) / RENDER[1], + (max_x - min_x) / RENDER[0], + (max_y - min_y) / RENDER[1], + ]; + assert!(contains(old.s_dst, bbox, 0.01), "{preset}: cadre {bbox:?} hors de {:?}", old.s_dst); + + let cb = g.window_frame_cb(RENDER).expect("calque"); + assert!((cb.dst_prev[2] - mt * g.s_dst[3] * RENDER[1] * quad.scale).abs() < 1e-3); + assert_eq!(g.screen_square_top(), 1.0); + } + } + + /// Sous un zoom (#179) le cadre grandit avec la boîte : ses marges sont des fractions de + /// `s_dst`, qui porte le grossissement. L'ancre des annotations, elle, ne bouge pas. + #[test] + fn the_frame_zooms_with_the_box() { + let frame = r#","frame":"window-dark""#; + let rest = framed_plan(&framed_scene(frame, "null", 1.0, false)); + let zoomed = framed_plan(&framed_scene(frame, "null", 2.0, false)); + assert_eq!(rest.window_frame.unwrap().margins, zoomed.window_frame.unwrap().margins); + assert!((zoomed.s_dst[2] / rest.s_dst[2] - 2.0).abs() < 1e-4); + assert_eq!(rest.s_ann, zoomed.s_ann); + let ShadowCaster::Upright { dst, .. } = zoomed.shadow_caster(RENDER) else { panic!() }; + assert!(contains(dst, zoomed.s_dst, 1e-6)); + } + /// La même scène, zoomée ET inclinée par un préset de rotation 3D. fn tilted_golden_scene() -> Scene { Scene::from_json( @@ -3078,6 +3516,7 @@ mod tests { w_px: [0.0, 0.0], w_radius: 0.0, shape_fade: 0.0, + window_frame: None, } } diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 3da7e1332..7d0ea7ba5 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -162,6 +162,26 @@ pub struct SceneEffects { pub roundness_frac: f32, /// 0..1 flou de mouvement. pub motion_blur: f32, + /// Cadre dessiné autour de l'enregistrement. `#[serde(default)]` : l'app omet la clé quand + /// il n'y en a pas, et tout payload d'avant le cadre se lit « sans cadre ». + #[serde(default)] + pub frame: SceneFrame, +} + +/// Le cadre autour de l'écran (`effects.frame` côté TS). Une valeur inconnue — un cadre ajouté +/// par une version plus récente de l'app — retombe sur `None` au lieu de faire échouer toute la +/// scène : l'enregistrement reste rendu, sans cadre. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SceneFrame { + /// Chrome de fenêtre : barre de titre, trois pastilles, filet. Thème clair. + WindowLight, + /// Le même chrome, thème sombre. + WindowDark, + /// Dernier : serde n'accepte `other` que sur la dernière variante. + #[default] + #[serde(other)] + None, } /// Fond derrière l'écran (parsé depuis `settings.wallpaper`). @@ -693,6 +713,22 @@ mod tests { assert_eq!(scene.output.width, 1920); } + #[test] + fn the_frame_defaults_to_none_and_tolerates_an_unknown_value() { + let effects = |frame: &str| -> SceneEffects { + serde_json::from_str(&format!( + r#"{{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0{frame}}}"# + )) + .expect("parse effects") + }; + assert_eq!(effects("").frame, SceneFrame::None); + assert_eq!(effects(r#","frame":"none""#).frame, SceneFrame::None); + assert_eq!(effects(r#","frame":"window-light""#).frame, SceneFrame::WindowLight); + assert_eq!(effects(r#","frame":"window-dark""#).frame, SceneFrame::WindowDark); + // Un cadre d'une version plus récente de l'app : pas de cadre, mais la scène se lit. + assert_eq!(effects(r#","frame":"browser""#).frame, SceneFrame::None); + } + #[test] fn parses_color_and_image_backgrounds() { let color = r##"{"clips":[],"layout":{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false},"effects":{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0},"background":{"kind":"color","color":"#123456"},"zoomRegions":[],"cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"},"cropByClip":[],"output":{"width":1280,"height":720,"fps":30}}"##; diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index 2f822a18d..d5395c345 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -229,6 +229,19 @@ float3 gradient_motion(float2 gp, float2 dir, float denom, float3 c0, float3 c1, return lerp(c0, c1, saturate(0.5 + u + 0.07 * w)); } +// Couverture d'une pastille (disque) adoucie sur ~1.5 px, pour la barre de titre du mode 14. +float disc_cov(float2 p, float2 c, float r) +{ + return 1.0 - smoothstep(r - 0.75, r + 0.75, length(p - c)); +} + +// Couverture d'un trait centré sur `x = 0`, de demi-épaisseur `half_w`, sur ~1 px. Un trait plus +// fin qu'un pixel s'estompe au lieu de disparaître (même forme que la flèche du mode 9). +float band_cov(float x, float half_w) +{ + return saturate(half_w + 0.5 - abs(x)); +} + // Fond flouté pour le mode "blur" de la webcam. // Disque de Vogel (spirale à angle d'or) à 21 échantillons avec pondération gaussienne et // rotation par pixel via Interleaved Gradient Noise (IGN) pour un bokeh photographique doux, isotrope et rapide. @@ -309,6 +322,45 @@ float4 cursor_extruded(float2 local) float4 ps_main(VSOut i) : SV_Target { + // mode 14 : CADRE DE FENÊTRE autour de l'écran (barre de titre, trois pastilles, filet), + // dessiné SOUS lui. Testé en premier : la branche du mode 13 n'a pas de borne haute. + // Même warp que le mode 8 — le cadre est le quad de l'écran prolongé, il penche donc avec + // lui ; à plat, le quad est un rect et le warp l'identité exacte. + // fx.xy/fx.zw = coins TL/TR, src_prev.xy/.zw = BR/BL (px locaux) ; dst_prev.xy = taille du + // cadre dans son plan, dst_prev.z = hauteur de la barre, dst_prev.w = épaisseur du filet + // (px du plan) ; radius_px = rayon extérieur (les coins hauts plafonnent à la barre) ; + // color = fond de la barre, mb = couleur du filet (alpha droit). + if (mode > 13.5) + { + float3 r = quad_inverse_bilinear(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw); + if (r.z < 0.5) + { + return float4(0.0, 0.0, 0.0, 0.0); // hors du cadre projeté + } + float2 plane_px = dst_prev.xy; + float bar = dst_prev.z; + float line_w = dst_prev.w; + float2 q = float2(r.x, r.y) * plane_px; // px du plan depuis le coin haut-gauche + float2 p = q - plane_px * 0.5; + // Coins hauts plafonnés à la barre : au-delà, l'arrondi descendrait sous la barre et + // les coins carrés de l'écran en dépasseraient. + float rad = max(radius_px, 0.0); + float d = sd_round_rect(p, plane_px * 0.5, (p.y < 0.0) ? min(rad, bar) : rad); + float cov = 1.0 - smoothstep(0.0, 1.5, d); + // Filet intérieur le long du contour, et séparation entre la barre et le contenu. + float stroke = max(band_cov(-d - line_w * 0.5, line_w * 0.5), + band_cov(q.y - (bar - line_w * 0.5), line_w * 0.5)); + float3 rgb = lerp(color.rgb, mb.rgb, stroke * mb.a); + // Pastilles : proportions d'une barre de 28 px (rayon 6, pas de 20). + float dr = bar * 0.214; + float dx = bar * 0.714; + rgb = lerp(rgb, float3(1.000, 0.373, 0.341), disc_cov(q, float2(dx, bar * 0.5), dr)); + rgb = lerp(rgb, float3(0.996, 0.737, 0.180), disc_cov(q, float2(2.0 * dx, bar * 0.5), dr)); + rgb = lerp(rgb, float3(0.157, 0.784, 0.251), disc_cov(q, float2(3.0 * dx, bar * 0.5), dr)); + float a = cov * color.a; + return float4(rgb * a, a); // prémultiplié + } + // mode 13 : SPRITE DE CURSEUR posé sur l'écran incliné. Même warp que le mode 8, mais // échantillonnant la texture du curseur en alpha DROIT (comme le mode 7) au lieu de la // vidéo NV12. Le curseur remplace un pointeur qui faisait partie de l'image capturée : il @@ -500,9 +552,10 @@ float4 ps_main(VSOut i) : SV_Target // en escalier franc, soit la troncature même que cette branche existe pour éviter (d'où le // symptôme « le tilt 3D est tronqué, mais pas au-dessus d'un certain arrondi »). L'ombre du // mode 12 applique déjà son `max(radius_px, 0.0)` sans garde, pour la même raison. + // dst_prev.z = 1 : écran sous un cadre de fenêtre, coins HAUTS carrés (sous la barre). float2 plane_px = dst_prev.xy; float2 p = float2(r.x, r.y) * plane_px - plane_px * 0.5; - float d = sd_round_rect(p, plane_px * 0.5, max(radius_px, 0.0)); + float d = sd_round_rect(p, plane_px * 0.5, (dst_prev.z > 0.5 && p.y < 0.0) ? 0.0 : max(radius_px, 0.0)); float tilt_a = 1.0 - smoothstep(0.0, 1.5, d); return float4(sample_yuv(uv) * tilt_a, tilt_a); // prémultiplié, comme les autres modes } @@ -676,9 +729,10 @@ float4 ps_main(VSOut i) : SV_Target // même espace, le coin est donc rond par construction. (Avant, le canvas figé en 16:9 // était étiré en fin de pipeline et il fallait pré-déformer par `mb.yz` pour que le // cercle ne ressorte pas elliptique.) + // mb.w = 1 : écran sous un cadre de fenêtre, coins HAUTS carrés (sous la barre de titre). float2 halfsz = quad_px * 0.5; float2 p = i.local - quad_px * 0.5; - float d = sd_round_rect(p, halfsz, radius_px); + float d = sd_round_rect(p, halfsz, (mb.w > 0.5 && p.y < 0.0) ? 0.0 : radius_px); alpha *= 1.0 - smoothstep(0.0, 1.5, d); // ~1.5px feather (§7 fwidth-like) } return float4(rgb * alpha, alpha); // prémultiplié diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index 319647c33..f6b2008a4 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -230,8 +230,20 @@ inline float3 quad_inverse_bilinear(float2 P, float2 c00, float2 c10, float2 c11 return (r0.z > 0.5) ? r0 : r1; } +// 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) +{ + return 1.0 - smoothstep(r - 0.75, r + 0.75, length(p - c)); +} + +// Couverture d'un trait centré sur `x = 0`, de demi-épaisseur `half_w`, sur ~1 px. +inline float band_cov(float x, float half_w) +{ + return clamp(half_w + 0.5 - abs(x), 0.0, 1.0); +} + // ================================================================================= -// Pixel shader principal : un seul `ps_main` qui gère 14 modes via `layer.mode`. +// Pixel shader principal : un seul `ps_main` qui gère 15 modes via `layer.mode`. // Identique à `ps_main` côté HLSL ligne pour ligne (à la syntaxe MSL près). // ================================================================================= @@ -369,6 +381,38 @@ fragment float4 ps_main(VSOut i [[stage_in]], // puisque la branche n'est prise que si layer.fx.z > 0.5. texture2d texMask [[texture(3)]]) { + // mode 14 : CADRE DE FENÊTRE autour de l'écran, dessiné SOUS lui. Cf. commentaires HLSL. + // Testé en premier : la branche du mode 13 n'a pas de borne haute. + // fx/src_prev = coins du cadre projeté ; dst_prev = (taille du plan, barre, filet) ; + // radius_px = rayon extérieur ; color = fond de la barre ; mb = couleur du filet. + if (layer.mode > 13.5) + { + float3 r = quad_inverse_bilinear(i.local, layer.fx.xy, layer.fx.zw, + layer.src_prev.xy, layer.src_prev.zw); + if (r.z < 0.5) + { + return float4(0.0, 0.0, 0.0, 0.0); // hors du cadre projeté + } + float2 plane_px = layer.dst_prev.xy; + float bar = layer.dst_prev.z; + float line_w = layer.dst_prev.w; + float2 q = float2(r.x, r.y) * plane_px; + float2 p = q - plane_px * 0.5; + float rad = max(layer.radius_px, 0.0); + float d = sd_round_rect(p, plane_px * 0.5, (p.y < 0.0) ? min(rad, bar) : rad); + float cov = 1.0 - smoothstep(0.0, 1.5, d); + float stroke = max(band_cov(-d - line_w * 0.5, line_w * 0.5), + band_cov(q.y - (bar - line_w * 0.5), line_w * 0.5)); + float3 rgb = mix(layer.color.rgb, layer.mb.rgb, stroke * layer.mb.a); + float dr = bar * 0.214; + float dx = bar * 0.714; + rgb = mix(rgb, float3(1.000, 0.373, 0.341), disc_cov(q, float2(dx, bar * 0.5), dr)); + rgb = mix(rgb, float3(0.996, 0.737, 0.180), disc_cov(q, float2(2.0 * dx, bar * 0.5), dr)); + rgb = mix(rgb, float3(0.157, 0.784, 0.251), disc_cov(q, float2(3.0 * dx, bar * 0.5), dr)); + float a = cov * layer.color.a; + return float4(rgb * a, a); + } + // mode 13 : SPRITE DE CURSEUR posé sur l'écran incliné. Cf. commentaires HLSL. // mb.xy = vecteur d'extrusion en px, mb.z = nombre de copies (volume) ; mb.z ≤ 1 = plat. // Un futur mode 14 doit être testé AVANT cette branche, qui n'a pas de borne haute. @@ -458,9 +502,11 @@ fragment float4 ps_main(VSOut i [[stage_in]], // Inconditionnel, rayon 0 compris — `sd_round_rect` dégénère en SDF de rectangle et // le feather de 1,5 px subsiste, ce qui fait lire une arête inclinée COMME une arête // plutôt que comme une troncature en marches d'escalier. + // dst_prev.z = 1 : écran sous un cadre de fenêtre, coins HAUTS carrés (sous la barre). float2 plane_px = layer.dst_prev.xy; float2 p = float2(r.x, r.y) * plane_px - plane_px * 0.5; - float d = sd_round_rect(p, plane_px * 0.5, max(layer.radius_px, 0.0)); + float d = sd_round_rect(p, plane_px * 0.5, + (layer.dst_prev.z > 0.5 && p.y < 0.0) ? 0.0 : max(layer.radius_px, 0.0)); float tilt_a = 1.0 - smoothstep(0.0, 1.5, d); // L'alpha est cette couverture, pas `color.a` : les draws du mode 8 laissent `color` // à zéro, donc le port rendait de toute façon un plan totalement transparent. @@ -713,9 +759,10 @@ fragment float4 ps_main(VSOut i [[stage_in]], float alpha = layer.color.a * alpha_mask; if (layer.radius_px > 0.0) { + // mb.w = 1 : écran sous un cadre de fenêtre, coins HAUTS carrés (sous la barre de titre). float2 halfsz = layer.quad_px * 0.5; float2 p = i.local - layer.quad_px * 0.5; - float d = sd_round_rect(p, halfsz, layer.radius_px); + float d = sd_round_rect(p, halfsz, (layer.mb.w > 0.5 && p.y < 0.0) ? 0.0 : layer.radius_px); alpha *= 1.0 - smoothstep(0.0, 1.5, d); } return float4(rgb * alpha, alpha); diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index 78ad4760a..d3d9d33b2 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -22,12 +22,12 @@ struct Layer { src: vec4, // u0,v0,u1,v1 source 0..1 quad_px: vec2, // taille du quad en px de sortie (pour la SDF isotrope) radius_px: f32, - mode: f32, // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre, 8 = écran tilté, 9 = flèche, 10 = flou/mosaïque, 12 = ombre du quad tilté, 13 = curseur tilté - color: vec4, - fx: vec4, // mode 2 : spread ombre en px ; mode 5 : (direction xy, temps programme replie, mouvement 0..3) ; modes 8/12/13 : coins TL,TR du quad projeté ; mode 9 : hampe de la flèche ; mode 10 : (flou?, rayon/bloc px, ovale?, teinté?) - src_prev: vec4, // modes 8/12/13 : coins BR,BL du quad projeté ; mode 9 : barbe 1 ; mode 10 incliné : coins BR,BL du masque - dst_prev: vec4, // mode 8 : taille du plan en px AVANT projection (le rayon y vit) ; mode 13 : rect de clip ; mode 9 : barbe 2 ; mode 10 incliné : coins TL,TR du masque - mb: vec4, // 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é ; mode 13 : mb.xy = vecteur d'extrusion en px, mb.z = nombre de copies (volume, <= 1 = plat) + mode: f32, // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre, 8 = écran tilté, 9 = flèche, 10 = flou/mosaïque, 12 = ombre du quad tilté, 13 = curseur tilté, 14 = cadre de fenetre + color: vec4, // mode 14 : fond de la barre de titre + fx: vec4, // mode 2 : spread ombre en px ; mode 5 : (direction xy, temps programme replie, mouvement 0..3) ; modes 8/12/13/14 : coins TL,TR du quad projeté ; mode 9 : hampe de la flèche ; mode 10 : (flou?, rayon/bloc px, ovale?, teinté?) + 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 + 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) ; mode 14 : .xy = taille du plan du cadre, .z = hauteur de la barre, .w = epaisseur du filet (px du plan) ; mode 13 : rect de clip ; mode 9 : barbe 2 ; mode 10 incliné : coins TL,TR du masque + mb: vec4, // 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é ; mode 13 : mb.xy = vecteur d'extrusion en px, mb.z = nombre de copies (volume, <= 1 = plat) ; mode 14 : couleur du filet (alpha droit) } @group(0) @binding(0) var layer: Layer; @@ -266,6 +266,16 @@ fn gradient_motion(gp: vec2, dir: vec2, denom: f32, c0: vec3, c1: return mix(c0, c1, clamp(0.5 + u + 0.07 * w, 0.0, 1.0)); } +// Couverture d'une pastille (disque) adoucie sur ~1.5 px, pour la barre de titre du mode 14. +fn disc_cov(p: vec2, c: vec2, r: f32) -> f32 { + return 1.0 - smoothstep(r - 0.75, r + 0.75, length(p - c)); +} + +// Couverture d'un trait centre sur `x = 0`, de demi-epaisseur `half_w`, sur ~1 px. +fn band_cov(x: f32, half_w: f32) -> f32 { + return clamp(half_w + 0.5 - abs(x), 0.0, 1.0); +} + // Fond flouté pour le mode "blur" de la webcam. // Disque de Vogel (spirale à angle d'or) à 21 échantillons avec pondération gaussienne et // rotation par pixel via Interleaved Gradient Noise (IGN) pour un bokeh photographique doux, isotrope et rapide. @@ -570,9 +580,11 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { // Inconditionnel, rayon 0 compris -- `sd_round_rect` degenere en SDF de // rectangle et le feather de 1,5 px subsiste, ce qui fait lire une arete // inclinee COMME une arete plutot que comme un escalier. + // dst_prev.z = 1 : ecran sous un cadre de fenetre, coins HAUTS carres (sous la barre). let plane_px = layer.dst_prev.xy; let p = vec2(r.x, r.y) * plane_px - plane_px * 0.5; - let d = sd_round_rect(p, plane_px * 0.5, max(layer.radius_px, 0.0)); + let d = sd_round_rect(p, plane_px * 0.5, + select(max(layer.radius_px, 0.0), 0.0, layer.dst_prev.z > 0.5 && p.y < 0.0)); let tilt_a = 1.0 - smoothstep(0.0, 1.5, d); // L'alpha est cette couverture, pas `color.a` : les draws du mode 8 laissent // `color` a zero, donc s'en servir rendrait un plan totalement transparent. @@ -625,6 +637,39 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { let s = textureSample(texY, samp, clamp(vec2(r.x, r.y), vec2(0.0), vec2(1.0))); let ca = s.a * layer.color.a; return vec4(s.rgb * ca, ca); + } else if layer.mode > 13.5 && layer.mode < 14.5 { + // Mode 14 -- CADRE DE FENETRE autour de l'ecran (barre de titre, trois pastilles, + // filet), dessine SOUS lui. Meme warp que le mode 8 : le cadre est le quad de l'ecran + // prolonge, il penche donc avec lui ; a plat le quad est un rect et le warp l'identite. + let r = quad_inverse_bilinear( + i.local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, + ); + if r.z < 0.5 { + return vec4(0.0, 0.0, 0.0, 0.0); // hors du cadre projete + } + let plane_px = layer.dst_prev.xy; + let bar = layer.dst_prev.z; + let line_w = layer.dst_prev.w; + let q = vec2(r.x, r.y) * plane_px; // px du plan depuis le coin haut-gauche + let p = q - plane_px * 0.5; + // Coins hauts plafonnes a la barre : au-dela, l'arrondi descendrait sous la barre et + // les coins carres de l'ecran en depasseraient. + let rad = max(layer.radius_px, 0.0); + let d = sd_round_rect(p, plane_px * 0.5, select(rad, min(rad, bar), p.y < 0.0)); + let cov = 1.0 - smoothstep(0.0, 1.5, d); + // Filet interieur le long du contour, et separation entre la barre et le contenu. + let stroke = max(band_cov(-d - line_w * 0.5, line_w * 0.5), + band_cov(q.y - (bar - line_w * 0.5), line_w * 0.5)); + var frame_rgb = mix(layer.color.rgb, layer.mb.rgb, stroke * layer.mb.a); + // Pastilles : proportions d'une barre de 28 px (rayon 6, pas de 20). Deroulees a la + // main, comme `sd_convex_quad` : pas de tableau local indexe. + let dr = bar * 0.214; + let dx = bar * 0.714; + frame_rgb = mix(frame_rgb, vec3(1.000, 0.373, 0.341), disc_cov(q, vec2(dx, bar * 0.5), dr)); + frame_rgb = mix(frame_rgb, vec3(0.996, 0.737, 0.180), disc_cov(q, vec2(2.0 * dx, bar * 0.5), dr)); + frame_rgb = mix(frame_rgb, vec3(0.157, 0.784, 0.251), disc_cov(q, vec2(3.0 * dx, bar * 0.5), dr)); + let fa = cov * layer.color.a; + return vec4(frame_rgb * fa, fa); // premultiplie } else { // Mode 2 — ombre portée (SDF d'un quad arrondi élargi de `fx.x`). let spread = layer.fx.x; @@ -641,9 +686,10 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { // Feather ~1.5 px sur le bord du quad — parité exacte avec le HLSL // (`smoothstep(0.0, 1.5, d)`). Le shader HLSL inclut `quad_px` en px de // SORTIE ; on reproduit la même chose ici. + // mb.w = 1 : ecran sous un cadre de fenetre, coins HAUTS carres (sous la barre de titre). let halfsz = layer.quad_px * 0.5; let p = i.local - layer.quad_px * 0.5; - let d = sd_round_rect(p, halfsz, layer.radius_px); + let d = sd_round_rect(p, halfsz, select(layer.radius_px, 0.0, layer.mb.w > 0.5 && p.y < 0.0)); alpha *= 1.0 - smoothstep(0.0, 1.5, d); } diff --git a/crates/compositor/tests/window_frame_render.rs b/crates/compositor/tests/window_frame_render.rs new file mode 100644 index 000000000..ea1a3b881 --- /dev/null +++ b/crates/compositor/tests/window_frame_render.rs @@ -0,0 +1,123 @@ +//! Le cadre de fenêtre (`effects.frame`) rendu par le vrai compositeur D3D11 : droit et incliné, +//! clair et sombre, plus le témoin sans cadre. +//! +//! Piloté par l'environnement, comme `privacy_blur_under_zoom.rs` : il faut une source vidéo +//! quelconque (celle de ce test-là convient). +//! +//! ```powershell +//! $env:OPENSCREEN_FRAME_SOURCE = "...\secret.mp4" +//! $env:OPENSCREEN_FRAME_OUT = "...\renders" # facultatif : un PPM par cas +//! cargo test -p openscreen-compositor --test window_frame_render -- --nocapture +//! ``` + +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::frame_geometry::live_params_from_scene; +use openscreen_compositor::live::Player; +use openscreen_compositor::scene::Scene; + +const W: u32 = 1280; +const H: u32 = 720; +const AT_SEC: f64 = 3.0; + +fn write_ppm(path: &std::path::Path, rgba: &[u8], w: u32, h: u32) -> std::io::Result<()> { + let mut out = Vec::with_capacity(rgba.len() / 4 * 3 + 32); + out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes()); + for px in rgba.chunks_exact(4) { + out.extend_from_slice(&px[..3]); + } + std::fs::write(path, out) +} + +/// `frame` : `None` = clé absente du JSON (payload d'avant le cadre), sinon sa valeur. +fn scene_json(source: &str, frame: Option<&str>, rotation: &str) -> String { + let s = source.replace('\\', "/"); + let frame = frame.map(|f| format!(r#","frame":"{f}""#)).unwrap_or_default(); + // Zoom 1 : la région ne sert qu'à porter le préset 3D, sans grossir la boîte. + format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":6,"webcamOffsetSec":0,"hasAudio":false}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, + "effects": {{"padding":0.5,"blur":false,"shadow":0.6,"roundnessFrac":0.03,"motionBlur":0.0{frame}}}, + "background": {{"kind":"gradient","angleDeg":135,"stops":["#5b6ee1","#e8a0bf"]}}, + "zoomRegions": [{{"id":"z","startSec":0,"endSec":6,"scale":1.0,"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":{rotation}}}], + "annotations": [], + "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":{W},"height":{H},"fps":null}} + }}"## + ) +} + +fn render(gpu: &Gpu, source: &str, frame: Option<&str>, rotation: &str) -> Vec { + 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(&scene_json(source, frame, rotation)).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(source, "", gpu).expect("ouvrir la source"); + player.present_frame(&comp, &cfg, AT_SEC).expect("composer la frame"); + comp.readback_resized(W, H).expect("readback") + } +} + +/// Pixels qui diffèrent de plus de `tol` sur un canal. +fn differing(a: &[u8], b: &[u8], tol: u8) -> usize { + a.chunks_exact(4) + .zip(b.chunks_exact(4)) + .filter(|(p, q)| p.iter().zip(q.iter()).take(3).any(|(x, y)| x.abs_diff(*y) > tol)) + .count() +} + +#[test] +fn the_window_frame_renders_flat_and_tilted_in_both_themes() { + let Ok(source) = std::env::var("OPENSCREEN_FRAME_SOURCE") else { + println!("SKIP: definir OPENSCREEN_FRAME_SOURCE (voir l'en-tete du fichier)."); + return; + }; + let out_dir = std::env::var("OPENSCREEN_FRAME_OUT").ok().map(std::path::PathBuf::from); + if let Some(dir) = &out_dir { + std::fs::create_dir_all(dir).expect("creer le dossier de sortie"); + } + let gpu = Gpu::create(false).expect("device d3d11"); + + let mut renders = Vec::new(); + for (tilt_name, rotation) in [("flat", "null"), ("iso", r#""iso""#), ("left", r#""left""#)] { + for (frame_name, frame) in + [("absent", None), ("none", Some("none")), ("light", Some("window-light")), ("dark", Some("window-dark"))] + { + let rgba = render(&gpu, &source, frame, rotation); + if let Some(dir) = &out_dir { + write_ppm(&dir.join(format!("{tilt_name}-{frame_name}.ppm")), &rgba, W, H) + .expect("ecrire le ppm"); + } + renders.push(((tilt_name, frame_name), rgba)); + } + } + let get = |t: &str, f: &str| &renders.iter().find(|((a, b), _)| *a == t && *b == f).unwrap().1; + + for tilt in ["flat", "iso", "left"] { + // `"none"` et la clé absente : le même rendu, à l'octet. Les deux se lisent + // `SceneFrame::None`, donc ce n'est qu'un garde-fou de désérialisation : la preuve que le + // chemin sans cadre n'a pas bougé est `no_frame_leaves_the_geometry_untouched`. + assert!(get(tilt, "none") == get(tilt, "absent"), "{tilt}: frame none != payload sans cadre"); + // Le cadre se voit, et ses deux thèmes aussi. + let light = differing(get(tilt, "none"), get(tilt, "light"), 8); + let dark = differing(get(tilt, "none"), get(tilt, "dark"), 8); + let themes = differing(get(tilt, "light"), get(tilt, "dark"), 8); + println!("{tilt:<5} clair {light:>7} sombre {dark:>7} clair/sombre {themes:>7}"); + let area = (W * H) as usize; + assert!(light > area / 50, "{tilt}: le cadre clair ne se voit pas ({light} px)"); + assert!(dark > area / 50, "{tilt}: le cadre sombre ne se voit pas ({dark} px)"); + assert!(themes > area / 200, "{tilt}: clair et sombre se confondent ({themes} px)"); + } +} diff --git a/electron/ai-edition/style-preset-service.test.ts b/electron/ai-edition/style-preset-service.test.ts index c50f80edc..70f1452b7 100644 --- a/electron/ai-edition/style-preset-service.test.ts +++ b/electron/ai-edition/style-preset-service.test.ts @@ -8,6 +8,7 @@ import { StylePresetError, StylePresetService } from "./style-preset-service"; const APPEARANCE: StylePresetAppearance = { wallpaper: "#000000", wallpaperMotion: "none", + frame: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, diff --git a/electron/ipc/nativeBridge.presets.test.ts b/electron/ipc/nativeBridge.presets.test.ts index ade58983c..b67cb45ab 100644 --- a/electron/ipc/nativeBridge.presets.test.ts +++ b/electron/ipc/nativeBridge.presets.test.ts @@ -28,6 +28,7 @@ vi.mock("../native-bridge/services/aiEditionService", () => ({ const APPEARANCE: StylePresetAppearance = { wallpaper: "#000000", wallpaperMotion: "none", + frame: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index 55e9ada15..cc1ebb756 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -49,6 +49,7 @@ import { locateVirtualPosition } from "@/lib/ai-edition/timeline/virtual-preview import { computeCameraFullscreenRect, computeCompositeLayout, + fitInWindowFrame, resolveWebcamLayoutPreset, type WebcamCompositeLayout, } from "@/lib/compositeLayout"; @@ -313,10 +314,22 @@ export function PreviewCanvas(props: PreviewCanvasProps) { }; }, [layout, cameraFullscreenProgress, frameSize]); + // The stage hosting the interactive overlays is the CONTENT rect: with a window frame the + // compositor shrinks the screen under the title bar, and every handle (annotations, privacy + // blur, zoom focus) must sit on the pixels it edits. `layout` itself stays unshrunk: the + // native scene receives it and applies the same fit. + const stageLayout = useMemo(() => { + if (!layout?.screenRect || settings.frame === "none") return layout; + return { + ...layout, + screenRect: fitInWindowFrame(layout.screenRect, layout.screenCover ?? false), + }; + }, [layout, settings.frame]); + const frameStyle = useMemo(() => buildFrameStyle(settings), [settings]); const screenStyle = useMemo( - () => buildScreenStyle(layout, settings, frameSize), - [layout, settings, frameSize], + () => buildScreenStyle(stageLayout, settings, frameSize), + [stageLayout, settings, frameSize], ); const webcamStyle = useMemo( () => buildWebcamStyle(effectiveLayout, settings, frameSize), @@ -454,8 +467,8 @@ export function PreviewCanvas(props: PreviewCanvasProps) { annotations={props.annotationRegions} selectedAnnotationId={props.selectedAnnotationId ?? null} currentTimeSec={props.currentTimeSec} - containerWidth={layout.screenRect.width} - containerHeight={layout.screenRect.height} + containerWidth={(stageLayout ?? layout).screenRect.width} + containerHeight={(stageLayout ?? layout).screenRect.height} onSelectAnnotation={props.onSelectAnnotation} onPositionChange={props.onAnnotationPositionChange} onSizeChange={props.onAnnotationSizeChange} diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index ba32fc6da..539e20ddc 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -94,6 +94,7 @@ import { themePickerPreviewAssets, } from "@/lib/cursor/cursorThemes"; import { buildGradientFromEditor } from "@/lib/gradientBuilder"; +import { RECORDING_FRAMES, type RecordingFrame } from "@/lib/projectDefaults"; import { classifyWallpaper, resolveImageWallpaperUrl, @@ -2375,6 +2376,12 @@ function pluralKey(locale: string, count: number): string { // ─── Video Effects ───────────────────────────────────────────────── +const RECORDING_FRAME_LABEL_KEYS: Record = { + none: "effects.windowNone", + "window-light": "effects.windowLight", + "window-dark": "effects.windowDark", +}; + /** * One pane for everything that shapes the composition. * @@ -2399,6 +2406,7 @@ export function VideoEffectsPane() { const nativeFormats = useMemo(() => (document ? collectNativeFormats(document) : []), [document]); const [fitMenuOpen, setFitMenuOpen] = useState(false); const [ratioMenuOpen, setRatioMenuOpen] = useState(false); + const [frameMenuOpen, setFrameMenuOpen] = useState(false); const { locale } = useI18n(); const clipCountLabel = (count: number) => ts(pluralKey(locale, count), { count }); @@ -2583,6 +2591,57 @@ export function VideoEffectsPane() {
+ {/* The window chrome drawn around the recording. A menu like Format above it, and + for the same reason: it picks one project-wide look among a few. With a frame + on, Roundness rounds the frame and Shadow falls under it — both still move what + they name. */} +
+ + {ts("effects.window")} + + + + + + +
+ {RECORDING_FRAMES.map((frame) => ( + + ))} +
+
+
+
{ + it("writes each frame to the document", async () => { + mount(documentWithShapes([[1920, 1080]])); + // Starts at "none", so each pick below moves the stored value. + for (const [label, frame] of [ + ["Light", "window-light"], + ["Dark", "window-dark"], + ["None", "none"], + ] as const) { + fireEvent.click(screen.getByRole("button", { name: "Window" })); + fireEvent.click(within(screen.getByRole("menu")).getByRole("menuitem", { name: label })); + await waitFor(() => + expect( + (useProjectStore.getState().document?.legacyEditor as Record)?.frame, + ).toBe(frame), + ); + } + }); +}); diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index f7bf5c1f3..483436bc7 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -114,7 +114,12 @@ "format": "التنسيق", "fitClip": "ملاءمة", "motionBlur": "ضبابية الحركة", - "roundness": "الاستدارة" + "roundness": "الاستدارة", + "window": "نافذة", + "windowNone": "بلا", + "windowLight": "فاتح", + "windowDark": "داكن", + "windowHelp": "يرسم شريط عنوان وإطار نافذة حول التسجيل." }, "stylePresets": { "button": "الإعدادات المسبقة", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index b02b6fd20..03ac7a991 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -114,7 +114,12 @@ "format": "Formát", "fitClip": "Přizpůsobit", "motionBlur": "Pohybové rozmazání", - "roundness": "Zaoblení" + "roundness": "Zaoblení", + "window": "Okno", + "windowNone": "Žádné", + "windowLight": "Světlé", + "windowDark": "Tmavé", + "windowHelp": "Kolem záznamu vykreslí záhlaví a okraj okna." }, "stylePresets": { "button": "Předvolby", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index c1198cfd6..1315d35ac 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -114,7 +114,12 @@ "format": "Format", "fitClip": "Fit", "motionBlur": "Motion Blur", - "roundness": "Roundness" + "roundness": "Roundness", + "window": "Window", + "windowNone": "None", + "windowLight": "Light", + "windowDark": "Dark", + "windowHelp": "Draws a window title bar and border around the recording." }, "stylePresets": { "button": "Presets", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index cfeafe913..9c15ab130 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -114,7 +114,12 @@ "format": "Formato", "fitClip": "Ajustar", "motionBlur": "Desenfoque de movimiento", - "roundness": "Redondez" + "roundness": "Redondez", + "window": "Ventana", + "windowNone": "Ninguna", + "windowLight": "Clara", + "windowDark": "Oscura", + "windowHelp": "Dibuja una barra de título y un borde de ventana alrededor de la grabación." }, "stylePresets": { "button": "Preajustes", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 226f3b809..f6f657920 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -114,7 +114,12 @@ "format": "Format", "fitClip": "Ajuster", "motionBlur": "Flou de mouvement", - "roundness": "Arrondi" + "roundness": "Arrondi", + "window": "Fenêtre", + "windowNone": "Aucune", + "windowLight": "Claire", + "windowDark": "Sombre", + "windowHelp": "Dessine une barre de titre et une bordure de fenêtre autour de l'enregistrement." }, "stylePresets": { "button": "Préréglages", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 9fd8454d7..7820a2f1b 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -114,7 +114,12 @@ "format": "Formato", "fitClip": "Adatta", "motionBlur": "Sfocatura movimento", - "roundness": "Arrotondamento" + "roundness": "Arrotondamento", + "window": "Finestra", + "windowNone": "Nessuna", + "windowLight": "Chiara", + "windowDark": "Scura", + "windowHelp": "Disegna una barra del titolo e un bordo di finestra attorno alla registrazione." }, "stylePresets": { "button": "Preset", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index fd0770886..2d64a223d 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -114,7 +114,12 @@ "format": "フォーマット", "fitClip": "合わせる", "motionBlur": "モーションブラー", - "roundness": "丸み" + "roundness": "丸み", + "window": "ウィンドウ", + "windowNone": "なし", + "windowLight": "ライト", + "windowDark": "ダーク", + "windowHelp": "録画の周りにウィンドウのタイトルバーと枠を描画します。" }, "stylePresets": { "button": "プリセット", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 7521c1768..e5cee071d 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -114,7 +114,12 @@ "format": "형식", "fitClip": "맞추기", "motionBlur": "모션 블러", - "roundness": "모서리 둥글기" + "roundness": "모서리 둥글기", + "window": "창", + "windowNone": "없음", + "windowLight": "밝게", + "windowDark": "어둡게", + "windowHelp": "녹화 화면 주위에 창 제목 표시줄과 테두리를 그립니다." }, "stylePresets": { "button": "프리셋", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index ce83a1aa5..7ea0e67e3 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -114,7 +114,12 @@ "format": "Formato", "fitClip": "Ajustar", "motionBlur": "Desfoque de Movimento", - "roundness": "Arredondamento" + "roundness": "Arredondamento", + "window": "Janela", + "windowNone": "Nenhuma", + "windowLight": "Clara", + "windowDark": "Escura", + "windowHelp": "Desenha uma barra de título e uma borda de janela ao redor da gravação." }, "stylePresets": { "button": "Predefinições", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index adfc828a0..4cef4baab 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -114,7 +114,12 @@ "format": "Формат", "fitClip": "Подогнать", "motionBlur": "Размытие движения", - "roundness": "Скругление" + "roundness": "Скругление", + "window": "Окно", + "windowNone": "Нет", + "windowLight": "Светлое", + "windowDark": "Тёмное", + "windowHelp": "Рисует заголовок и рамку окна вокруг записи." }, "stylePresets": { "button": "Пресеты", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index b2602f925..f27f87e78 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -114,7 +114,12 @@ "format": "Biçim", "fitClip": "Sığdır", "motionBlur": "Hareket Bulanıklığı", - "roundness": "Yuvarlaklık" + "roundness": "Yuvarlaklık", + "window": "Pencere", + "windowNone": "Yok", + "windowLight": "Açık", + "windowDark": "Koyu", + "windowHelp": "Kaydın çevresine bir pencere başlık çubuğu ve kenarlığı çizer." }, "stylePresets": { "button": "Ön ayarlar", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 3eb117354..6351be6eb 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -114,7 +114,12 @@ "format": "Định dạng", "fitClip": "Vừa khít", "motionBlur": "Làm mờ chuyển động", - "roundness": "Độ bo tròn" + "roundness": "Độ bo tròn", + "window": "Cửa sổ", + "windowNone": "Không", + "windowLight": "Sáng", + "windowDark": "Tối", + "windowHelp": "Vẽ thanh tiêu đề và viền cửa sổ quanh bản ghi." }, "stylePresets": { "button": "Cài đặt sẵn", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 52d4ee9a6..23a3be1f9 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -114,7 +114,12 @@ "format": "格式", "fitClip": "适配", "motionBlur": "运动模糊", - "roundness": "圆角" + "roundness": "圆角", + "window": "窗口", + "windowNone": "无", + "windowLight": "浅色", + "windowDark": "深色", + "windowHelp": "在录制内容周围绘制窗口标题栏和边框。" }, "stylePresets": { "button": "预设", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 4c0276e2e..21a2dd14e 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -114,7 +114,12 @@ "format": "格式", "fitClip": "符合", "motionBlur": "動態模糊", - "roundness": "圓角" + "roundness": "圓角", + "window": "視窗", + "windowNone": "無", + "windowLight": "淺色", + "windowDark": "深色", + "windowHelp": "在錄製內容周圍繪製視窗標題列和邊框。" }, "stylePresets": { "button": "預設", diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index 23f82ee1c..0d1fb7726 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -291,6 +291,14 @@ describe("patchEditorSettings", () => { expect(getEditorSettings(doc).wallpaperMotion).toBe("none"); }); + it("round-trips the recording frame and reads an unknown one as no frame", () => { + expect(getEditorSettings(baseDoc).frame).toBe("none"); + const patched = patchEditorSettings(baseDoc, { frame: "window-dark" }); + expect(getEditorSettings(patched).frame).toBe("window-dark"); + const unknown = { ...baseDoc, legacyEditor: { frame: "browser" } } as typeof baseDoc; + expect(getEditorSettings(unknown).frame).toBe("none"); + }); + it("clamps a stored webcam blur intensity into 0..1", () => { const tooHigh = { ...baseDoc, legacyEditor: { webcamBlurIntensity: 1000 } } as typeof baseDoc; expect(getEditorSettings(tooHigh).webcamBlurIntensity).toBe(1); diff --git a/src/lib/ai-edition/store/editorSettings.ts b/src/lib/ai-edition/store/editorSettings.ts index 7b297b311..c67f5ce97 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -22,7 +22,11 @@ import { type WebcamPosition, type WebcamSizePreset, } from "@/components/video-editor/types"; -import { DEFAULT_PROJECT_APPEARANCE } from "@/lib/projectDefaults"; +import { + DEFAULT_PROJECT_APPEARANCE, + isRecordingFrame, + type RecordingFrame, +} from "@/lib/projectDefaults"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; import { clamp01 } from "@/utils/math"; import type { AxcutDocument } from "../schema"; @@ -76,6 +80,8 @@ export interface EditorSettingsSnapshot { wallpaper: string; /** Only a gradient wallpaper moves; kept as chosen when the wallpaper changes kind. */ wallpaperMotion: WallpaperMotion; + /** The frame drawn around the recording (window chrome), or "none". */ + frame: RecordingFrame; aspectRatio: AspectRatio; shadowIntensity: number; showBlur: boolean; @@ -120,6 +126,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettingsSnapshot = { interface LegacyShape { wallpaper?: string; wallpaperMotion?: WallpaperMotion; + frame?: RecordingFrame; aspectRatio?: AspectRatio; shadowIntensity?: number; showBlur?: boolean; @@ -199,6 +206,8 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor wallpaperMotion: isWallpaperMotion(legacy?.wallpaperMotion) ? legacy.wallpaperMotion : DEFAULT_EDITOR_SETTINGS.wallpaperMotion, + // An unknown value (a frame a newer build added) reads as no frame, like the compositor. + frame: isRecordingFrame(legacy?.frame) ? legacy.frame : DEFAULT_EDITOR_SETTINGS.frame, aspectRatio: legacy?.aspectRatio ?? DEFAULT_EDITOR_SETTINGS.aspectRatio, shadowIntensity: num(legacy?.shadowIntensity, DEFAULT_EDITOR_SETTINGS.shadowIntensity), showBlur: bool(legacy?.showBlur, DEFAULT_EDITOR_SETTINGS.showBlur), @@ -243,6 +252,7 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor export interface EditorSettingsPatch { wallpaper?: string; wallpaperMotion?: WallpaperMotion; + frame?: RecordingFrame; aspectRatio?: AspectRatio; shadowIntensity?: number; showBlur?: boolean; diff --git a/src/lib/ai-edition/stylePresets.test.ts b/src/lib/ai-edition/stylePresets.test.ts index a2132f547..12c9bdc93 100644 --- a/src/lib/ai-edition/stylePresets.test.ts +++ b/src/lib/ai-edition/stylePresets.test.ts @@ -16,6 +16,7 @@ function appearance(overrides: Partial = {}): StylePreset return { wallpaper: "/wallpapers/wallpaper3.jpg", wallpaperMotion: "drift", + frame: "window-dark", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, @@ -113,6 +114,14 @@ describe("parseStylePresetAppearance", () => { ).toThrow(/cursor\.volume/); }); + it("reads a preset saved before the frame existed as frameless, and rejects an unknown frame", () => { + const { frame: _frame, ...older } = appearance(); + expect(parseStylePresetAppearance(older).frame).toBe("none"); + expect(() => parseStylePresetAppearance({ ...appearance(), frame: "window-sepia" })).toThrow( + /frame/, + ); + }); + it("falls back to the default cursor theme for an id this build does not ship", () => { expect( parseStylePresetAppearance(appearance({ cursorTheme: "theme-from-the-future" })).cursorTheme, diff --git a/src/lib/ai-edition/stylePresets.ts b/src/lib/ai-edition/stylePresets.ts index f4910b1b5..1dcccc8e8 100644 --- a/src/lib/ai-edition/stylePresets.ts +++ b/src/lib/ai-edition/stylePresets.ts @@ -20,6 +20,7 @@ import type { } from "../../components/video-editor/types"; import { type AspectRatio, isAspectRatio } from "../../utils/aspectRatioUtils"; import { CURSOR_THEME_IDS, DEFAULT_CURSOR_THEME_ID } from "../cursor/cursorThemes"; +import { isRecordingFrame, type RecordingFrame } from "../projectDefaults"; export const STYLE_PRESET_FILE_EXTENSION = ".openscreenpreset"; export const STYLE_PRESET_FORMAT = "openscreen-style-preset"; @@ -36,6 +37,7 @@ const CSS_WALLPAPER_MAX_LENGTH = 10_000; export interface StylePresetAppearance { wallpaper: string; wallpaperMotion: WallpaperMotion; + frame: RecordingFrame; aspectRatio: AspectRatio; shadowIntensity: number; showBlur: boolean; @@ -155,6 +157,20 @@ function readEnum(source: Fields, key: string, allowed: readon return value as T; } +/** + * The one field a version-1 preset may omit: every preset written before the frame existed + * lacks it, and "no frame" is exactly what those presets looked like. A value that IS there + * must be one this build knows. + */ +function readFrame(source: Fields): RecordingFrame { + const value = source.frame; + if (value === undefined) return "none"; + if (!isRecordingFrame(value)) { + throw new TypeError("Style preset frame must be one of: none, window-light, window-dark."); + } + return value; +} + const HEX_COLOR_RE = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; const COLOR_FUNCTION_RE = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(.*\)$/i; const GRADIENT_RE = /^(?:repeating-)?(?:linear|radial|conic)-gradient\(.*\)$/i; @@ -215,7 +231,9 @@ export function parseStylePresetWallpaper(value: unknown, key = "wallpaper"): st * otherwise sound (the editor does the same when it renders one). `cursor.volume` may be * absent: it postdates format version 1, and a preset without it was authored flat. * `wallpaperMotion` may be absent too: a preset saved before it has a still wallpaper, which - * is exactly what "none" means. Unknown extra keys are dropped. + * is exactly what "none" means. + * `frame` may be absent as well: a preset saved before it had no frame (see `readFrame`). + * Unknown extra keys are dropped. */ export function parseStylePresetAppearance(value: unknown): StylePresetAppearance { if (!isRecord(value)) { @@ -237,6 +255,7 @@ export function parseStylePresetAppearance(value: unknown): StylePresetAppearanc value.wallpaperMotion === undefined ? "none" : readEnum(value, "wallpaperMotion", WALLPAPER_MOTIONS), + frame: readFrame(value), aspectRatio: value.aspectRatio, shadowIntensity: readNumber(value, "shadowIntensity", NUMBER_RANGES.shadowIntensity), showBlur: readBoolean(value, "showBlur"), diff --git a/src/lib/ai-edition/stylePresetsEditor.test.ts b/src/lib/ai-edition/stylePresetsEditor.test.ts index bc588ab65..a15659e5f 100644 --- a/src/lib/ai-edition/stylePresetsEditor.test.ts +++ b/src/lib/ai-edition/stylePresetsEditor.test.ts @@ -27,6 +27,7 @@ function styledSettings(): EditorSettingsSnapshot { ...DEFAULT_EDITOR_SETTINGS, wallpaper: "linear-gradient(90deg, #000, #fff)", wallpaperMotion: "waves", + frame: "window-light", aspectRatio: "9:16", shadowIntensity: 0.8, showBlur: true, diff --git a/src/lib/ai-edition/stylePresetsEditor.ts b/src/lib/ai-edition/stylePresetsEditor.ts index b24dcb7a7..c4101138b 100644 --- a/src/lib/ai-edition/stylePresetsEditor.ts +++ b/src/lib/ai-edition/stylePresetsEditor.ts @@ -16,6 +16,7 @@ export function stylePresetAppearanceFromSettings( return { wallpaper: settings.wallpaper, wallpaperMotion: settings.wallpaperMotion, + frame: settings.frame, aspectRatio: settings.aspectRatio, shadowIntensity: settings.shadowIntensity, showBlur: settings.showBlur, @@ -60,6 +61,7 @@ export function stylePresetPatch(appearance: StylePresetAppearance): EditorSetti return { wallpaper: appearance.wallpaper, wallpaperMotion: appearance.wallpaperMotion, + frame: appearance.frame, aspectRatio: appearance.aspectRatio, shadowIntensity: appearance.shadowIntensity, showBlur: appearance.showBlur, diff --git a/src/lib/compositeLayout.test.ts b/src/lib/compositeLayout.test.ts index 0a100a679..29618f3df 100644 --- a/src/lib/compositeLayout.test.ts +++ b/src/lib/compositeLayout.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { computeCameraFullscreenRect, computeCompositeLayout, + fitInWindowFrame, isWebcamBlockLayout, resolveWebcamReactiveZoom, type StyledRenderRect, @@ -528,3 +529,22 @@ describe("computeCameraFullscreenRect", () => { ); }); }); + +describe("fitInWindowFrame", () => { + // Same box and numbers as `a_framed_privacy_mask_covers_what_the_overlay_shows` + // (frame_geometry.rs): the preview overlay and the compositor must agree on this rect. + it("matches the native content rect of a framed screen", () => { + const r = fitInWindowFrame({ x: 192, y: 108, width: 1536, height: 864 }, false); + expect(r.x).toBeCloseTo(223.6416, 3); + expect(r.y).toBeCloseTo(142.56, 3); + expect(r.width).toBeCloseTo(1472.7168, 3); + expect(r.height).toBeCloseTo(828.4032, 3); + }); + + it("fills what the frame leaves under cover", () => { + const r = fitInWindowFrame({ x: 0, y: 0, width: 1000, height: 500 }, true); + expect(r.width).toBeCloseTo(1000 - 2 * 0.6, 6); + expect(r.y).toBeCloseTo(20, 6); + expect(r.y + r.height).toBeCloseTo(500 - 0.6, 6); + }); +}); diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts index 11eb35a50..6959267a6 100644 --- a/src/lib/compositeLayout.ts +++ b/src/lib/compositeLayout.ts @@ -5,6 +5,40 @@ export interface RenderRect { height: number; } +/** + * Window-frame proportions, as fractions of the short side of the screen box the frame is + * fitted into. Must match `WINDOW_FRAME_BAR_FRAC` / `WINDOW_FRAME_LINE_FRAC` in + * crates/compositor/src/frame_geometry.rs. + */ +export const WINDOW_FRAME_BAR_FRAC = 0.04; +export const WINDOW_FRAME_LINE_FRAC = 0.0012; + +/** + * The content rect of the screen once a window frame is drawn around it: the screen shrinks + * so screen + frame fit in `box`. Port of `fit_in_window_frame` (frame_geometry.rs). The + * native compositor still receives the unshrunk `box` and shrinks it itself; the preview + * overlay uses this rect so annotation, blur and zoom-focus handles sit on the content the + * compositor draws, not on the title bar. + */ +export function fitInWindowFrame(box: RenderRect, cover: boolean): RenderRect { + const m = Math.min(box.width, box.height); + const bar = WINDOW_FRAME_BAR_FRAC * m; + const line = WINDOW_FRAME_LINE_FRAC * m; + const iw = Math.max(1, box.width - 2 * line); + const ih = Math.max(1, box.height - bar - line); + let sw = iw; + let sh = ih; + if (!cover) { + const ar = box.width / Math.max(1e-4, box.height); + if (iw / ih > ar) sw = ih * ar; + else sh = iw / ar; + } + const fh = sh + bar + line; + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + return { x: cx - sw / 2, y: cy - fh / 2 + bar, width: sw, height: sh }; +} + /** Floor for the reactive webcam multiplier so the camera never shrinks below ~35% at deep zoom. */ export const WEBCAM_REACTIVE_ZOOM_MIN_SCALE = 0.35; diff --git a/src/lib/projectDefaults.ts b/src/lib/projectDefaults.ts index c508e76e1..28c722abe 100644 --- a/src/lib/projectDefaults.ts +++ b/src/lib/projectDefaults.ts @@ -1,6 +1,23 @@ +/** + * The frame drawn around the recording, a project setting like the wallpaper. "none" draws + * nothing and renders exactly as before the setting existed. + */ +export type RecordingFrame = "none" | "window-light" | "window-dark"; + +export const RECORDING_FRAMES = [ + "none", + "window-light", + "window-dark", +] as const satisfies readonly RecordingFrame[]; + +export function isRecordingFrame(value: unknown): value is RecordingFrame { + return typeof value === "string" && (RECORDING_FRAMES as readonly string[]).includes(value); +} + export interface ProjectAppearanceDefaults { wallpaper: string; wallpaperMotion: "none" | "drift" | "aurora" | "waves"; + frame: RecordingFrame; aspectRatio: `${number}:${number}` | "native"; shadowIntensity: number; showBlur: boolean; @@ -35,6 +52,7 @@ export interface ProjectAppearanceDefaults { export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = { wallpaper: "/wallpapers/wallpaper1.jpg", wallpaperMotion: "none", + frame: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, diff --git a/src/native/browserShim.presets.test.ts b/src/native/browserShim.presets.test.ts index 285b0ce7f..5430f56f2 100644 --- a/src/native/browserShim.presets.test.ts +++ b/src/native/browserShim.presets.test.ts @@ -12,6 +12,7 @@ import { const APPEARANCE: StylePresetAppearance = { wallpaper: "#000000", wallpaperMotion: "none", + frame: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index f9418be5b..c1bb0e5d2 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -224,6 +224,21 @@ describe("buildSceneDescription.background motion", () => { }); }); +describe("buildSceneDescription.effects.frame", () => { + // Omitted for "none", like `webcamEffect`: `SceneFrame` defaults on the Rust side, so a + // frameless project serializes exactly as it did before the setting existed. + it("omits the frame when there is none", () => { + expect(buildSceneDescription(makeDoc()).effects).not.toHaveProperty("frame"); + const none = makeDoc({ legacyEditor: { frame: "none" } }); + expect(buildSceneDescription(none).effects).not.toHaveProperty("frame"); + }); + + it("carries the chosen window frame", () => { + const doc = makeDoc({ legacyEditor: { frame: "window-light" } }); + expect(buildSceneDescription(doc).effects.frame).toBe("window-light"); + }); +}); + describe("buildSceneDescription.webcamEffect", () => { // Omitted rather than sent as {mode:"none"}: the Rust side defaults the field, so every // project without an effect would otherwise carry it for nothing. diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index 0de4d36c8..d3c386b68 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -53,6 +53,7 @@ import { webcamSizeToFraction, } from "@/lib/compositeLayout"; import { parseCssGradient, resolveLinearGradientAngle } from "@/lib/exporter/gradientParser"; +import type { RecordingFrame } from "@/lib/projectDefaults"; import type { CompositorClipInput } from "./contracts"; /** Background behind the screen. Parsed from `settings.wallpaper`. */ @@ -370,6 +371,12 @@ export interface SceneEffects { roundnessFrac: number; /** 0..1 motion blur. */ motionBlur: number; + /** + * The frame drawn around the recording. Omitted for "none", like `webcamEffect`: the + * Rust side defaults the field (`SceneFrame::None`), and a scene without a frame then + * serializes exactly as it did before the field existed. + */ + frame?: Exclude; } /** Cursor rendering, from the editor settings. */ @@ -1055,6 +1062,7 @@ export function buildSceneDescription( roundnessFrac: settings.borderRadius / Math.max(1, Math.min(outputDims.width, outputDims.height)), motionBlur: settings.motionBlurAmount, + ...(settings.frame !== "none" ? { frame: settings.frame } : {}), }, cursor: { show: settings.cursorShow, From b1f843151dec3aa113426e73c91de6cfcf9ea7e2 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 17 Sep 2026 19:53:36 +0200 Subject: [PATCH 09/11] feat(effects): defocus the tilted screen by its depth Carries the tilted plane's depth gradient to mode 8 and blurs by it. Formerly #689 and #692. --- crates/compositor/src/compositor_linux.rs | 160 ++++++++++- crates/compositor/src/compositor_macos.rs | 210 +++++++++++++- crates/compositor/src/compositor_windows.rs | 108 +++++++ crates/compositor/src/frame_geometry.rs | 131 ++++++++- crates/compositor/src/regions.rs | 205 +++++++++++++- crates/compositor/src/scene.rs | 9 + crates/compositor/src/shaders.hlsl | 29 +- crates/compositor/src/shaders.metal | 24 +- crates/compositor/src/vk_shaders/layer.wgsl | 23 +- crates/compositor/tests/compose_linux.rs | 110 ++++++++ .../compositor/tests/tilted_depth_of_field.rs | 263 ++++++++++++++++++ .../tests/tilted_depth_slot_inert.rs | 126 +++++++++ .../ai-edition/style-preset-service.test.ts | 1 + electron/ipc/nativeBridge.presets.test.ts | 1 + src/components/ai-edition/RightPanes.tsx | 20 +- .../ai-edition/depthOfFieldToggle.test.tsx | 76 +++++ .../video-editor/projectPersistence.test.ts | 2 + .../video-editor/projectPersistence.ts | 6 + src/i18n/locales/ar/settings.json | 3 + src/i18n/locales/cs/settings.json | 3 + src/i18n/locales/en/settings.json | 3 + src/i18n/locales/es/settings.json | 3 + src/i18n/locales/fr/settings.json | 3 + src/i18n/locales/it/settings.json | 3 + src/i18n/locales/ja-JP/settings.json | 3 + src/i18n/locales/ko-KR/settings.json | 3 + src/i18n/locales/pt-BR/settings.json | 3 + src/i18n/locales/ru/settings.json | 3 + src/i18n/locales/tr/settings.json | 3 + src/i18n/locales/vi/settings.json | 3 + src/i18n/locales/zh-CN/settings.json | 3 + src/i18n/locales/zh-TW/settings.json | 3 + src/lib/ai-edition/document/migrate.test.ts | 12 + src/lib/ai-edition/document/migrate.ts | 1 + .../ai-edition/store/editorSettings.test.ts | 11 + src/lib/ai-edition/store/editorSettings.ts | 4 + src/lib/ai-edition/stylePresets.test.ts | 12 + src/lib/ai-edition/stylePresets.ts | 14 +- src/lib/ai-edition/stylePresetsEditor.ts | 2 + src/lib/projectDefaults.ts | 4 + src/native/browserShim.presets.test.ts | 1 + src/native/sceneDescription.test.ts | 6 + src/native/sceneDescription.ts | 6 + .../testing/manual-e2e-checklist.md | 1 + 44 files changed, 1573 insertions(+), 47 deletions(-) create mode 100644 crates/compositor/tests/tilted_depth_of_field.rs create mode 100644 crates/compositor/tests/tilted_depth_slot_inert.rs create mode 100644 src/components/ai-edition/depthOfFieldToggle.test.tsx diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 149a9b826..ebea86196 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -239,6 +239,17 @@ struct WebcamMask { height: u32, } +/// Pyramide de profondeur de champ (cf. `Compositor::dof_pyramid`). `_tex` garde la +/// texture en vie ; `view` porte tous les niveaux (binding 4 du mode 8), `mips` un +/// niveau chacune (cible du remplissage puis de `generate_mips`). +struct DofPyramid { + _tex: wgpu::Texture, + view: wgpu::TextureView, + mips: Vec, + width: u32, + height: u32, +} + pub struct Compositor { gpu: Gpu, render_w: u32, @@ -330,6 +341,12 @@ pub struct Compositor { ann_copy_view: wgpu::TextureView, ann_copy_mips: Vec, + /// Pyramide de profondeur de champ (mode 8) : la video en RGBA8 a demi-resolution + /// de la texture DECODEUR, mips compris (texture, vue tous niveaux, vue par niveau), + /// et sa taille de niveau 0. Allouee au premier ecran incline, recreee quand la + /// texture decodeur change de taille. Cf. `compositor_windows::DofPyramid`. + dof_pyramid: RefCell>, + /// Images d'annotation, indexees par ID d'annotation -- PAS par chemin comme /// `img_cache`. Une annotation image porte souvent une data-URI de plusieurs /// mega-octets ; s'en servir comme cle de HashMap la ferait hacher a chaque @@ -680,6 +697,7 @@ impl Compositor { ann_copy, ann_copy_view, ann_copy_mips, + dof_pyramid: RefCell::new(None), ann_img_cache: RefCell::new(std::collections::HashMap::new()), webcam_mask: RefCell::new(None), seg_capture: RefCell::new(None), @@ -749,8 +767,21 @@ impl Compositor { ) -> (wgpu::Texture, wgpu::TextureView, Vec) { // floor(log2(max)) + 1 : le dernier niveau mesure 1x1. let levels = 32 - w.max(h).max(1).leading_zeros(); + Self::make_mip_chain(gpu, "ann-copy", w, h, levels) + } + + /// Texture RGBA8 a `levels` niveaux : la texture, une vue sur tous les niveaux + /// (echantillonnage) et une vue PAR niveau (cibles de `generate_mips`). Partagee + /// par `ann_copy` et la pyramide de profondeur de champ. + fn make_mip_chain( + gpu: &Gpu, + label: &str, + w: u32, + h: u32, + levels: u32, + ) -> (wgpu::Texture, wgpu::TextureView, Vec) { let tex = gpu.device.create_texture(&wgpu::TextureDescriptor { - label: Some("ann-copy"), + label: Some(label), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, mip_level_count: levels, sample_count: 1, @@ -765,7 +796,7 @@ impl Compositor { let mips = (0..levels) .map(|level| { tex.create_view(&wgpu::TextureViewDescriptor { - label: Some("ann-copy-mip"), + label: Some("mip-level"), base_mip_level: level, mip_level_count: Some(1), ..Default::default() @@ -782,10 +813,6 @@ impl Compositor { /// recouvrent s'echantillonnent l'une l'autre selon l'ordre de dessin. Meme /// contrat que le `blit` + `generate_mipmaps` de `compositor_macos`. /// - /// wgpu n'a pas de `generate_mipmaps` : chaque niveau est une passe de rendu - /// plein ecran qui echantillonne le precedent. Le filtre lineaire sur une - /// source exactement deux fois plus grande EST la moyenne 2x2, donc cette - /// boucle produit la meme pyramide que le blit Metal. fn generate_ann_mips(&self, encoder: &mut wgpu::CommandEncoder) { encoder.copy_texture_to_texture( self.rt.as_image_copy(), @@ -796,9 +823,34 @@ impl Compositor { depth_or_array_layers: 1, }, ); + self.generate_mips(encoder, &self.ann_copy_mips); + } + + /// Alloue (ou realloue a la taille de la texture decodeur) la pyramide de profondeur + /// de champ : demi-resolution, `dof_pyramid_levels` niveaux. Ses UV sont ceux des + /// plans video, le calcul d'UV du mode 8 ne change donc pas. + fn ensure_dof_pyramid(&self, tex_w: u32, tex_h: u32) { + let (w, h) = (tex_w.div_ceil(2).max(1), tex_h.div_ceil(2).max(1)); + let stale = + self.dof_pyramid.borrow().as_ref().is_none_or(|p| (p.width, p.height) != (w, h)); + if stale { + let levels = crate::frame_geometry::dof_pyramid_levels(w, h); + let (tex, view, mips) = Self::make_mip_chain(&self.gpu, "dof-pyramid", w, h, levels); + *self.dof_pyramid.borrow_mut() = + Some(DofPyramid { _tex: tex, view, mips, width: w, height: h }); + } + } + + /// Remplit les niveaux 1.. de `mips` depuis le niveau 0 (une vue par niveau). + /// + /// wgpu n'a pas de `generate_mipmaps` : chaque niveau est une passe de rendu + /// plein ecran qui echantillonne le precedent. Le filtre lineaire sur une + /// source exactement deux fois plus grande EST la moyenne 2x2, donc cette + /// boucle produit la meme pyramide que le blit Metal. + fn generate_mips(&self, encoder: &mut wgpu::CommandEncoder, mips: &[wgpu::TextureView]) { // Les bind groups doivent survivre a leur passe : on les garde tous ici. let mut keep: Vec<(wgpu::Buffer, wgpu::BindGroup)> = Vec::new(); - for level in 1..self.ann_copy_mips.len() { + for level in 1..mips.len() { let uniform = self.gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("ann-mip-uniform"), // `fs_copy` ne lit pas l'uniforme, mais le layout du blur l'exige. @@ -812,9 +864,7 @@ impl Compositor { wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::TextureView( - &self.ann_copy_mips[level - 1], - ), + resource: wgpu::BindingResource::TextureView(&mips[level - 1]), }, wgpu::BindGroupEntry { binding: 2, @@ -826,7 +876,7 @@ impl Compositor { let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("ann-mip-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.ann_copy_mips[level], + view: &mips[level], resolve_target: None, ops: wgpu::Operations { // Clear plutot que Load : le niveau n'a jamais ete ecrit, @@ -1121,7 +1171,9 @@ impl Compositor { s_px: [f32; 2], center_px: [f32; 2], cut: [f32; 4], + focus_plane: [f32; 2], radius: f32, + dof: bool, ) -> LayerCB { let (rw, rh) = (self.render_w as f32, self.render_h as f32); let corners = quad.corners; @@ -1155,6 +1207,8 @@ impl Compositor { fx: [tl0, tl1, tr0, tr1], src_prev: [br0, br1, bl0, bl1], dst_prev: [plane_px[0], plane_px[1], 0.0, 0.0], + // Gradient de profondeur du plan, profondeur du focus et `k` (`depth_mb`). + mb: quad.depth_mb(s_px, focus_plane, dof), ..Default::default() } } @@ -1164,6 +1218,19 @@ impl Compositor { cb: &LayerCB, planes: Option<(&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView)>, dummy: &wgpu::TextureView, + ) -> (wgpu::Buffer, wgpu::BindGroup) { + self.make_bind_b4(cb, planes, dummy, None) + } + + /// `make_bind`, binding 4 impose. Seul le mode 8 s'en sert, pour y lier la pyramide + /// de profondeur de champ a la place du masque webcam qu'il ne lit pas -- jamais le + /// binding 1, qui porte la luma. + fn make_bind_b4( + &self, + cb: &LayerCB, + planes: Option<(&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView)>, + dummy: &wgpu::TextureView, + b4: Option<&wgpu::TextureView>, ) -> (wgpu::Buffer, wgpu::BindGroup) { let uniform = self.gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("layer-uniform"), @@ -1179,7 +1246,7 @@ impl Compositor { // calque webcam leve `fx.z`. `dummy` reste le repli tant qu'aucune frame // n'a ete segmentee. let mask = self.webcam_mask.borrow(); - let mask_view = mask.as_ref().map_or(dummy, |m| &m.view); + let mask_view = b4.unwrap_or_else(|| mask.as_ref().map_or(dummy, |m| &m.view)); let bind = self.gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("layer"), layout: &self.bind_group_layout, @@ -1974,6 +2041,14 @@ impl Compositor { (g.s_dst[0] + g.s_dst[2] * 0.5) * rw, (g.s_dst[1] + g.s_dst[3] * 0.5) * rh, ]; + // Profondeur de champ : pyramide allouee et remplie seulement sur une frame + // inclinee qui la lit. L'emprunt tient jusqu'a la soumission de l'encodeur. + let dof = g.depth_of_field_on(self.gpu.backend == crate::d3d::Backend::Cpu); + if dof { + self.ensure_dof_pyramid(stw, sth); + } + let dof_guard = self.dof_pyramid.borrow(); + let dof_pyramid = dof_guard.as_ref().filter(|_| dof); // Calque ecran : mode 0 (rect droit, NV12 -> RGB) quand la rotation est // neutre, mode 8 (warp bilineaire inverse dans la bbox du quad projete) @@ -2011,7 +2086,15 @@ impl Compositor { ..Default::default() }, Some(quad) => { - let mut cb = self.tilted_screen_cb(quad, s_px, quad_center_px, g.cut, g.s_radius); + let mut cb = self.tilted_screen_cb( + quad, + s_px, + quad_center_px, + g.cut, + g.focus_plane, + g.s_radius, + dof, + ); cb.dst_prev[2] = square_top; cb } @@ -2019,8 +2102,31 @@ impl Compositor { // Bind group construit AVANT le pass (doit vivre pendant tout le pass) ; // `_screen_uniform` garde le buffer uniforme en vie (reference par le bind). let dummy = self.dummy_view(); - let (_screen_uniform, screen_bind) = - self.make_bind(&screen_layer, Some((&sy, &su, &sv)), &dummy); + // Binding 4 EXPLICITE sur le mode 8 : la pyramide si l'effet tourne, sinon le + // repli habituel (`k = 0`, le shader n'y lit rien). + let (_screen_uniform, screen_bind) = self.make_bind_b4( + &screen_layer, + Some((&sy, &su, &sv)), + &dummy, + dof_pyramid.map(|p| &p.view), + ); + // Remplissage de la pyramide : UN draw du mode 0 en UV plein vers son niveau 0 + // (cf. `compositor_windows::fill_dof_pyramid`), `color.a = 1`, un seul tap. + let dof_fill = dof_pyramid.map(|p| { + let full = [0.0, 0.0, 1.0, 1.0]; + let cb = LayerCB { + dst: full, + src: full, + quad_px: [p.width as f32, p.height as f32], + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + src_prev: full, + dst_prev: full, + mb: [1.0, 0.0, 1.0, 0.0], + ..Default::default() + }; + self.make_bind(&cb, Some((&sy, &su, &sv)), &dummy) + }); // OMBRE PORTEE de l'ecran, dessinee JUSTE AVANT le calque ecran. Le shader // la connait depuis le debut ; ce qui manquait etait uniquement le draw @@ -2718,6 +2824,30 @@ impl Compositor { let mut encoder = self.gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("compose"), }); + // Passe 0 : pyramide de profondeur de champ, niveau 0 vide d'abord (le « over » + // rend alors la source telle quelle), puis ses mips. + if let (Some(p), Some((_buf, bind))) = (dof_pyramid, &dof_fill) { + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("dof-pyramid-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &p.mips[0], + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + rpass.set_pipeline(&self.pipeline); + rpass.set_bind_group(0, bind, &[]); + rpass.draw(0..4, 0..1); + } + self.generate_mips(&mut encoder, &p.mips); + } // Passe 1 : fond (clear a `bg_clear` + gradient mode 5 eventuel). { let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 1afb012f0..b16a66d80 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -327,6 +327,10 @@ pub struct Compositor { /// échantillonner la cible sur laquelle on dessine, et le mode 10 lit un niveau de mip /// pour flouter à coût constant. ann_copy: metal::Texture, + /// Pyramide de profondeur de champ (mode 8) : la vidéo en RGBA8 à demi-résolution de la + /// texture DÉCODEUR, mips compris. Allouée au premier écran incliné, recréée quand la + /// texture décodeur change de taille. Cf. `compositor_windows::DofPyramid`. + dof_pyramid: RefCell>, /// Images d'annotation, indexées par ID d'annotation (pas par data-URL : celle-ci pèse /// souvent des mégaoctets et la hacher à chaque frame coûterait plus que le décodage). /// La longueur sert de garde-fou quand l'utilisateur change l'image. @@ -639,6 +643,7 @@ impl Compositor { pipeline_kdown, pipeline_kup, ann_copy, + dof_pyramid: RefCell::new(None), ann_img_cache: RefCell::new(std::collections::HashMap::new()), text_cache: RefCell::new(std::collections::HashMap::new()), text_raster: crate::text::TextRasterizer::new().ok(), @@ -1098,6 +1103,66 @@ impl Compositor { } + /// Remplit la pyramide de profondeur de champ depuis la frame écran et la rend. Port de + /// `compositor_windows::fill_dof_pyramid` : UN draw du mode 0 en UV plein vers une cible + /// demi-résolution vidée d'abord (`color.a = 1`, un seul tap), puis `generate_mipmaps`, + /// l'appel qui sert déjà `ann_copy`. Le viewport découle de la taille de l'attachement. + unsafe fn fill_dof_pyramid( + &self, + cmd: &metal::CommandBufferRef, + y: &metal::Texture, + uv: &metal::Texture, + tex_w: u32, + tex_h: u32, + ) -> Result { + let (w, h) = (tex_w.div_ceil(2).max(1), tex_h.div_ceil(2).max(1)); + let stale = self + .dof_pyramid + .borrow() + .as_ref() + .is_none_or(|p| (p.width(), p.height()) != (w as u64, h as u64)); + if stale { + let d = metal::TextureDescriptor::new(); + d.set_texture_type(metal::MTLTextureType::D2); + d.set_pixel_format(metal::MTLPixelFormat::RGBA8Unorm); + d.set_width(w as u64); + d.set_height(h as u64); + d.set_storage_mode(metal::MTLStorageMode::Private); + d.set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead); + d.set_mipmap_level_count(crate::frame_geometry::dof_pyramid_levels(w, h) as u64); + *self.dof_pyramid.borrow_mut() = Some(self.gpu.device.new_texture(&d)); + } + let pyr = self.dof_pyramid.borrow().clone().expect("pyramide allouée ci-dessus"); + let enc = self.begin_pass( + cmd, + &pyr, + Some(metal::MTLClearColor::new(0.0, 0.0, 0.0, 0.0)), + &self.pipeline_main, + )?; + let full = [0.0, 0.0, 1.0, 1.0]; + self.draw_video( + enc, + &LayerCB { + dst: full, + src: full, + quad_px: [w as f32, h as f32], + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + src_prev: full, + dst_prev: full, + mb: [1.0, 0.0, 1.0, 0.0], + ..Default::default() + }, + y, + uv, + ); + enc.end_encoding(); + let blit = cmd.new_blit_command_encoder(); + blit.generate_mipmaps(&pyr); + blit.end_encoding(); + Ok(pyr) + } + /// Ombre d'un écran incliné en 3D : la pénombre suit le QUADRILATÈRE projeté (mode 12), /// pas son rect englobant. Port de `compositor_windows::draw_quad_shadow`. #[allow(clippy::too_many_arguments)] @@ -1155,11 +1220,13 @@ impl Compositor { s_px: [f32; 2], center_px: [f32; 2], cut: [f32; 4], + focus_plane: [f32; 2], radius: f32, // 1 : coins hauts carrés, l'écran est sous la barre d'un cadre (`dst_prev.z`). square_top: f32, y: &metal::Texture, uv: &metal::Texture, + dof_pyramid: Option<&metal::Texture>, ) { let (rw, rh) = (self.render_w as f32, self.render_h as f32); let corners = quad.corners; @@ -1178,6 +1245,9 @@ impl Compositor { let [tr0, tr1] = local(corners[1]); let [br0, br1] = local(corners[2]); let [bl0, bl1] = local(corners[3]); + // texture(2) EXPLICITE : `draw_video` ne lie que 0/1, et le slot 2 garde sinon ce que + // le draw précédent y a laissé. `None` quand l'effet est coupé : `k = 0`, rien n'y est lu. + enc.set_fragment_texture(2, dof_pyramid.map(|t| &**t)); self.draw_video( enc, &LayerCB { @@ -1194,6 +1264,9 @@ impl Compositor { fx: [tl0, tl1, tr0, tr1], src_prev: [br0, br1, bl0, bl1], dst_prev: [plane_px[0], plane_px[1], square_top, 0.0], + // Gradient de profondeur du plan, profondeur du focus et `k` (`depth_mb`) : la + // pyramide liée décide seule si l'effet tourne. + mb: quad.depth_mb(s_px, focus_plane, dof_pyramid.is_some()), ..Default::default() }, y, @@ -1977,6 +2050,13 @@ impl Compositor { }); let cmd_buf = self.gpu.context.new_command_buffer(); + // Profondeur de champ : pyramide remplie seulement sur une frame inclinée qui la lit, + // dans ses propres passes, avant celles de la composition. + let dof_pyramid = if g.depth_of_field_on(self.gpu.backend == crate::d3d::Backend::Cpu) { + Some(self.fill_dof_pyramid(cmd_buf, &sy, &suv, stw, sth)?) + } else { + None + }; let enc = self.begin_pass( cmd_buf, &self.rt, @@ -2108,7 +2188,17 @@ impl Compositor { &suv, ), Some(quad) => self.draw_tilted_screen( - enc, quad, s_px, quad_center_px, g.cut, g.s_radius, square_top, &sy, &suv, + enc, + quad, + s_px, + quad_center_px, + g.cut, + g.focus_plane, + g.s_radius, + square_top, + &sy, + &suv, + dof_pyramid.as_ref(), ), } @@ -3298,6 +3388,124 @@ mod tests { } } + // ----------------------------------------------------------------------- + // Profondeur de champ du mode 8 (pendant macOS de `tests/tilted_depth_of_field.rs`) + // + // Le seul golden de l'effet qui tourne en CI : celui de Windows est opt-in (source vidéo), + // celui de Linux aussi (`OPENSCREEN_LINUX_COMPOSE`). C'est aussi le premier vrai passage de + // `fill_dof_pyramid`, de `generate_mipmaps` et du `texture(2)` du mode 8. Le piège qu'il + // garde : un `level(lod)` qui retomberait au niveau 0 laisserait macOS net là où Windows + // floute. + // ----------------------------------------------------------------------- + + /// Écran seul sur fond magenta (couleur qu'aucune luma neutre ne produit), focus manuel sur + /// le coin proche d'`iso` (haut-droit), zoom 1 en plein palier sur 0..6 s. + fn dof_scene_json(rotation: &str, dof: bool) -> String { + format!( + r##"{{"clips":[], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle", + "webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}}, + "effects":{{"padding":0.2,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0, + "depthOfField":{dof}}}, + "background":{{"kind":"color","color":"#ff00ff"}}, + "zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":6,"scale":1.0, + "focusX":0.94,"focusY":0.06,"focusMode":"manual", + "rotation":{rotation}}}], + "annotations":[], + "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0, + "clipToBounds":false,"theme":"default"}}, + "cropByClip":[], + "output":{{"width":1280,"height":720,"fps":30}}}}"## + ) + } + + fn compose_dof(comp: &super::Compositor, screen: &FakeFrame, rotation: &str, dof: bool) -> Vec { + let scene = crate::scene::Scene::from_json(&dof_scene_json(rotation, dof)).expect("scene json"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(false); + comp.set_scene(Some(scene)); + let mut cfg = crate::config::Cfg::c8(); + cfg.shadow = false; + cfg.cursor = false; + cfg.mblur_n = 1; + unsafe { + // Frame 180 à `FPS` (60) = 3 s : en plein palier de la région. + comp.compose_frame(screen.as_ptr(), std::ptr::null(), 180.0, &cfg) + .expect("compose_frame"); + comp.readback_direct().expect("readback_direct").2 + } + } + + /// Coin du plan (hors magenta) extrême dans la direction `dir`, rentré de `inset` vers le + /// centroïde : une fenêtre posée là est entièrement sur le plan, loin du feather. + fn dof_corner(rgba: &[u8], w: u32, h: u32, dir: (i64, i64), inset: f64) -> (u32, u32) { + let (mut best, mut at) = (i64::MIN, (0i64, 0i64)); + let (mut sx, mut sy, mut n) = (0f64, 0f64, 0f64); + for y in 0..h as i64 { + for x in 0..w as i64 { + let p = &rgba[((y * w as i64 + x) * 4) as usize..]; + if p[0] > 200 && p[1] < 60 && p[2] > 200 { + continue; + } + sx += x as f64; + sy += y as f64; + n += 1.0; + if x * dir.0 + y * dir.1 > best { + best = x * dir.0 + y * dir.1; + at = (x, y); + } + } + } + let x = at.0 as f64 + (sx / n - at.0 as f64) * inset; + let y = at.1 as f64 + (sy / n - at.1 as f64) * inset; + (x as u32, y as u32) + } + + /// Octets et netteté (écart moyen entre voisins, vert) d'une fenêtre `r`×`r` centrée en `c`. + fn dof_window(rgba: &[u8], w: u32, c: (u32, u32), r: u32) -> (Vec, f64) { + let g = |x: u32, y: u32| rgba[((y * w + x) * 4 + 1) as usize] as f64; + let (mut bytes, mut acc) = (Vec::new(), 0.0); + for y in c.1 - r / 2..c.1 + r / 2 { + for x in c.0 - r / 2..c.0 + r / 2 { + let i = ((y * w + x) * 4) as usize; + bytes.extend_from_slice(&rgba[i..i + 4]); + acc += (g(x + 1, y) - g(x, y)).abs() + (g(x, y + 1) - g(x, y)).abs(); + } + } + (bytes, acc / (r * r) as f64) + } + + #[test] + fn depth_of_field_defocuses_the_far_corner_of_a_tilted_screen_only() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let (w, h) = (1280u32, 720u32); + let comp = super::Compositor::new_sized(&gpu, w, h).expect("Compositor::new_sized"); + // Damier de 2 texels : le détail le plus fin possible, que le moindre niveau de mip efface. + let screen = FakeFrame::new(640, 360, |col, row| if (col / 2 + row / 2) % 2 == 0 { 180 } else { 60 }); + + let on = compose_dof(&comp, &screen, "\"iso\"", true); + let off = compose_dof(&comp, &screen, "\"iso\"", false); + let r = 32; + let near = dof_corner(&off, w, h, (1, -1), 0.12); + let far = dof_corner(&off, w, h, (-1, 1), 0.12); + let ((near_on_px, near_on), (near_off_px, near_off)) = + (dof_window(&on, w, near, r), dof_window(&off, w, near, r)); + let ((_, far_on), (_, far_off)) = (dof_window(&on, w, far, r), dof_window(&off, w, far, r)); + println!("dof : proche {near_off:.2} -> {near_on:.2}, lointain {far_off:.2} -> {far_on:.2}"); + assert!(far_off > 2.0, "fenêtre lointaine sans détail ({far_off})"); + assert!(near_on_px == near_off_px, "coin proche modifié : {near_off} -> {near_on}"); + assert!(far_on < far_off * 0.7, "coin lointain pas flouté : {far_off} -> {far_on}"); + + // À plat, l'effet n'existe pas : allumé ou non, la frame est la même à l'octet. + assert!( + compose_dof(&comp, &screen, "null", true) == compose_dof(&comp, &screen, "null", false), + "rotation nulle : la profondeur de champ a changé la frame" + ); + } + /// Le pendant macOS de `compositor_windows`'s `every_shader_entry_point_compiles`. /// /// `shaders.metal` est compilé À L'EXÉCUTION par `new_library_with_source` : une diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index a4dd3d0d3..e3e9e4214 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -75,6 +75,16 @@ struct WebcamMask { height: u32, } +/// Pyramide de la profondeur de champ (mode 8) : la vidéo en RGBA8 à demi-résolution de la +/// texture DÉCODEUR, avec sa chaîne de mips. Dimensionnée sur la texture décodeur et remplie en +/// UV plein (0..1) : ses UV sont ceux de t0/t1, et le calcul d'UV du mode 8 ne change pas. +struct DofPyramid { + rtv: ID3D11RenderTargetView, + srv: ID3D11ShaderResourceView, + width: u32, + height: u32, +} + pub struct Compositor { dev: ID3D11Device, ctx: ID3D11DeviceContext, @@ -219,6 +229,11 @@ pub struct Compositor { /// que `live_readback_staging`, mais en NV12 et non en RGBA : l'encodeur logiciel veut /// les plans Y/UV, pas des pixels RGBA. Voir `read_nv12_scaled`. nv12_readback_staging: RefCell>, + /// Pyramide de profondeur de champ, allouée au premier écran incliné et recréée quand la + /// texture décodeur change de taille. Rien n'est alloué tant qu'aucun tilt n'est rendu. + dof_pyramid: RefCell>, + /// Device WARP : la profondeur de champ y obéit à `DOF_ON_CPU_BACKEND`. + cpu_backend: bool, } /// Ressources d'un resize export à une taille cible : RGBA intermédiaire (résultat du @@ -637,6 +652,8 @@ impl Compositor { seg_scratch: RefCell::new(Vec::new()), seg_failed: RefCell::new(false), nv12_readback_staging: RefCell::new(None), + dof_pyramid: RefCell::new(None), + cpu_backend: gpu.backend == crate::d3d::Backend::Cpu, }) } @@ -811,6 +828,86 @@ impl Compositor { [1.0 / hw, 1.0 / hh, off, 0.0]); } + /// Remplit la pyramide de profondeur de champ depuis la frame écran et rend sa SRV. + /// + /// UN draw du mode 0 existant, en UV plein, vers une cible demi-résolution : un échantillon + /// bilinéaire 2:1 est une moyenne 2x2, cette cible EST donc déjà le niveau 1 d'une pyramide + /// pleine résolution. `GenerateMips` fait le reste, comme pour `ann_copy`. Cible vidée + /// d'abord et `color.a = 1` : un `LayerCB` par défaut a `color.a = 0`, et le mode 0 sortirait + /// une pyramide transparente. `mb.x = 1` : un seul tap, pas de flou de mouvement. + /// + /// Laisse l'état de composition lié sur le RT principal ; l'appelant enchaîne sur `begin`. + unsafe fn fill_dof_pyramid( + &self, + sy: &ID3D11ShaderResourceView, + suv: &ID3D11ShaderResourceView, + tex_w: u32, + tex_h: u32, + ) -> Result { + let (w, h) = (tex_w.div_ceil(2).max(1), tex_h.div_ceil(2).max(1)); + let stale = self.dof_pyramid.borrow().as_ref().is_none_or(|p| (p.width, p.height) != (w, h)); + if stale { + let desc = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: crate::frame_geometry::dof_pyramid_levels(w, h), + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, + CPUAccessFlags: 0, + MiscFlags: D3D11_RESOURCE_MISC_GENERATE_MIPS.0 as u32, + }; + let mut tex: Option = None; + self.dev.CreateTexture2D(&desc, None, Some(&mut tex))?; + let tex = tex.unwrap(); + // RTV sur le niveau 0 seul (défaut), SRV sur toute la chaîne (défaut). + let mut rtv: Option = None; + self.dev.CreateRenderTargetView(&tex, None, Some(&mut rtv))?; + let mut srv: Option = None; + self.dev.CreateShaderResourceView(&tex, None, Some(&mut srv))?; + *self.dof_pyramid.borrow_mut() = + Some(DofPyramid { rtv: rtv.unwrap(), srv: srv.unwrap(), width: w, height: h }); + } + let pyr = self.dof_pyramid.borrow(); + let pyr = pyr.as_ref().expect("pyramide allouée ci-dessus"); + self.bind_compose_state(); + // Délie t2 : la pyramide y est peut-être encore liée depuis le mode 8 précédent, et une + // ressource liée en lecture ET en écriture est retirée d'office par le runtime. + self.ctx.PSSetShaderResources(2, Some(&[None])); + self.ctx.OMSetRenderTargets(Some(&[Some(pyr.rtv.clone())]), None); + self.ctx.ClearRenderTargetView(&pyr.rtv, &[0.0, 0.0, 0.0, 0.0]); + self.ctx.RSSetViewports(Some(&[D3D11_VIEWPORT { + TopLeftX: 0.0, + TopLeftY: 0.0, + Width: w as f32, + Height: h as f32, + MinDepth: 0.0, + MaxDepth: 1.0, + }])); + let full = [0.0, 0.0, 1.0, 1.0]; + self.draw_video( + &LayerCB { + dst: full, + src: full, + quad_px: [w as f32, h as f32], + mode: 0.0, + color: [1.0, 1.0, 1.0, 1.0], + src_prev: full, + dst_prev: full, + mb: [1.0, 0.0, 1.0, 0.0], + ..Default::default() + }, + sy, + suv, + ); + // La pyramide quitte la sortie avant de générer ses mips. + self.bind_compose_state(); + self.ctx.GenerateMips(&pyr.srv); + Ok(pyr.srv.clone()) + } + unsafe fn upload_cb(&self, cb: &LayerCB) { let mut m = D3D11_MAPPED_SUBRESOURCE::default(); self.ctx @@ -1641,6 +1738,10 @@ impl Compositor { let w_radius = g.w_radius; let shape_fade = g.shape_fade; + // Profondeur de champ : la pyramide n'est remplie que sur une frame inclinée qui la lit, + // AVANT `begin`, qui rebranche ensuite le RT principal et le vide. + let dof = g.depth_of_field_on(self.cpu_backend); + let dof_srv = if dof { Some(self.fill_dof_pyramid(&sy, &suv, stw, sth)?) } else { None }; self.begin([0.0, 0.0, 0.0, 1.0]); @@ -1842,6 +1943,10 @@ impl Compositor { let [tr0, tr1] = local(corners[1]); let [br0, br1] = local(corners[2]); let [bl0, bl1] = local(corners[3]); + // t2 EXPLICITE : `draw_video` ne lie que t0/t1, et t2 garde sinon ce que le draw + // précédent y a laissé (un wallpaper, un sprite). `None` quand l'effet est coupé : + // `k = 0`, le shader n'y lit rien. + self.ctx.PSSetShaderResources(2, Some(&[dof_srv.clone()])); self.draw_video( &LayerCB { dst: bbox_dst, @@ -1854,11 +1959,14 @@ impl Compositor { fx: [tl0, tl1, tr0, tr1], src_prev: [br0, br1, bl0, bl1], dst_prev: [plane_px[0], plane_px[1], square_top, 0.0], + // Gradient de profondeur du plan, profondeur du focus et `k` (`depth_mb`). + mb: quad.depth_mb(s_px, g.focus_plane, dof), ..Default::default() }, &sy, &suv, ); + self.ctx.PSSetShaderResources(2, Some(&[None])); } // --- curseur custom : suit le mapping src/dst (zoom+layout), click bounce, diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 87faaf7d1..2e447120a 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -259,6 +259,27 @@ pub(crate) fn remap_box(base: [f32; 4], cut_ref: [f32; 4], cut: [f32; 4]) -> [f3 base[3] * (cut[3] - cut[1]) / rh, ] } +/// Où tombe le point de focus du zoom dans la coupe DESSINÉE `cut`, en 0..1 de cette coupe. +/// +/// `focus` est exprimé dans le crop utilisateur (la convention de `screen_source_rect`), pas +/// dans `cut` : les deux diffèrent dès qu'un crop ou un cover s'en mêle. Le zoom, lui, ne compte +/// pas : la coupe dessinée est prise à zoom 1 (issue #179). Et le centre de la coupe zoomée +/// n'est pas le focus dès qu'elle bute sur un bord, d'où le report du point lui-même, sans +/// jamais supposer (0.5, 0.5). +pub(crate) fn focus_in_cut( + u_max: f32, + v_max: f32, + crop: Option, + focus: [f32; 2], + cut: [f32; 4], +) -> [f32; 2] { + // Zoom 1 : la coupe est le crop entier, quel que soit le focus. + let [cu0, cv0, cu1, cv1] = screen_source_rect(u_max, v_max, crop, 1.0, [0.5, 0.5]); + let f = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.5 }; + let (u, v) = (cu0 + f(focus[0]) * (cu1 - cu0), cv0 + f(focus[1]) * (cv1 - cv0)); + let local = |x: f32, a: f32, b: f32| if b - a > 1e-6 { ((x - a) / (b - a)).clamp(0.0, 1.0) } else { 0.5 }; + [local(u, cut[0], cut[2]), local(v, cut[1], cut[3])] +} /// Sous-rect SOURCE (en UV de texture) qui remplit une boîte de ratio `box_ar` **sans /// déformer** l'image : le plus grand rect centré ayant ce ratio, tiré de la frame /// visible — l'équivalent de `object-fit: cover` côté web. @@ -340,6 +361,20 @@ pub const HALF_W: u32 = OUT_W / 2; pub const HALF_H: u32 = OUT_H / 2; pub const FIXTURE_FRAMES: u32 = 360; pub(crate) const FPS: f32 = 60.0; +/// La profondeur de champ tourne-t-elle sur le backend CPU (WARP, lavapipe) ? Le seul drapeau +/// partagé qui la coupe là-bas si son coût y devient prohibitif (seuil fixé par la spec : +/// 10 ms/frame). Mesuré (`tests/tilted_depth_of_field.rs`, 1080p iso, trois passes) : WARP +/// +3.8 / +6.3 / +12.2 ms/frame, soit +35 à +45 % ; le matériel +0.04 à +0.08 ms/frame. Médiane +/// sous le seuil : l'effet reste allumé partout, ce drapeau est le levier si ça change. +pub const DOF_ON_CPU_BACKEND: bool = true; +/// Niveaux de la pyramide de profondeur de champ (demi-résolution de la texture décodeur, donc +/// son niveau 0 est le niveau 1 d'une pyramide pleine résolution). Le shader plafonne sa lecture +/// à `DOF_MAX_LOD` (1.5) ; les niveaux au-delà laissent la marge de relever ce plafond sans +/// toucher aux trois backends. Jamais plus que la chaîne complète d'une très petite source. +pub fn dof_pyramid_levels(w: u32, h: u32) -> u32 { + const DOF_PYRAMID_LEVELS: u32 = 5; + DOF_PYRAMID_LEVELS.min(32 - w.max(h).max(1).leading_zeros()) +} /// Longueurs de style exprimées en FRACTION du petit côté du cadre, et non en pixels. /// /// Elles étaient écrites en px bruts au point d'appel, ce qui voulait dire « px du render @@ -485,6 +520,10 @@ impl CursorPlacement { ) }), scale: lerp(quad.scale, quad_b.scale, f), + depth_k: ( + lerp(quad.depth_k.0, quad_b.depth_k.0, f), + lerp(quad.depth_k.1, quad_b.depth_k.1, f), + ), }, center_px: [lerp(center_px[0], center_b[0], f), lerp(center_px[1], center_b[1], f)], screen_px: [lerp(screen_px[0], screen_b[0], f), lerp(screen_px[1], screen_b[1], f)], @@ -1012,6 +1051,12 @@ pub struct FrameGeometry { pub padding_scale: f32, /// Coupe source de l'écran en UV texture (crop utilisateur + zoom). pub cut: [f32; 4], + /// Point de focus du zoom (résolu : suivi curseur et rampe compris) en 0..1 DANS la coupe + /// dessinée `cut`, c'est-à-dire dans le plan que le mode 8 incline. Borné au plan : un focus + /// que le cover a rogné retombe sur le bord. C'est lui qui fixe `z_focus` (`depth_mb`). + pub focus_plane: [f32; 2], + /// Réglage « Depth of field » du projet. Ne décide rien seul : voir `depth_of_field_on`. + pub depth_of_field: bool, pub s_dst: [f32; 4], pub s_dst_prev: [f32; 4], /// Boîte écran **sans le zoom** : le conteneur auquel les annotations et les @@ -1079,6 +1124,16 @@ impl FrameGeometry { }) } + /// La profondeur de champ tourne-t-elle sur CETTE frame ? Réglage allumé, écran réellement + /// incliné (à plat le mode 8 n'est pas dessiné), et backend qui en a les moyens (cf. + /// `DOF_ON_CPU_BACKEND`). Une seule réponse pour les trois backends : elle décide à la fois + /// du remplissage de la pyramide et du `k` du mode 8, qui ne doivent jamais diverger. + pub fn depth_of_field_on(&self, cpu_backend: bool) -> bool { + self.depth_of_field + && !crate::regions::is_identity_rotation(self.zoom_rotation) + && (DOF_ON_CPU_BACKEND || !cpu_backend) + } + /// `annotation_dst_in` appliqué à `s_ann`, pour les backends qui tiennent la géométrie /// entière — c'est-à-dire ceux qui n'ont aucune raison de choisir un rect. pub fn annotation_dst(&self, x: f32, y: f32, w: f32, h: f32) -> [f32; 4] { @@ -1103,10 +1158,8 @@ impl FrameGeometry { /// Le plan incliné de l'écran, `None` quand il est droit. Même appel que les backends : un /// calcul déterministe, donc le même quadrilatère au bit près. - fn screen_tilt(&self, render_px: [f32; 2]) -> Option { - let s_px = [self.s_dst[2] * render_px[0], self.s_dst[3] * render_px[1]]; - (!crate::regions::is_identity_rotation(self.zoom_rotation)) - .then(|| crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], self.zoom_rotation)) + fn screen_tilt_in(&self, render_px: [f32; 2]) -> Option { + self.screen_tilt([self.s_dst[2] * render_px[0], self.s_dst[3] * render_px[1]]) } fn screen_center_px(&self, render_px: [f32; 2]) -> [f32; 2] { @@ -1123,11 +1176,12 @@ impl FrameGeometry { /// aucune trigonométrie de plus. fn window_frame_corners(&self, frame: &WindowFrame, render_px: [f32; 2]) -> ([(f32, f32); 4], f32) { let [ml, mt, mr, mb] = frame.margins; - let quad = self.screen_tilt(render_px).unwrap_or_else(|| { + let quad = self.screen_tilt_in(render_px).unwrap_or_else(|| { let (hw, hh) = (self.s_dst[2] * render_px[0] * 0.5, self.s_dst[3] * render_px[1] * 0.5); crate::regions::TiltedQuad { corners: [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)], scale: 1.0, + depth_k: (0.0, 0.0), } }); let corners = [ @@ -1144,7 +1198,7 @@ impl FrameGeometry { /// exactement l'arithmétique que les backends faisaient avant le cadre. pub fn shadow_caster(&self, render_px: [f32; 2]) -> ShadowCaster { let center_px = self.screen_center_px(render_px); - match (&self.window_frame, self.screen_tilt(render_px)) { + match (&self.window_frame, self.screen_tilt_in(render_px)) { (None, None) => ShadowCaster::Upright { dst: self.s_dst, size_px: [self.s_dst[2] * render_px[0], self.s_dst[3] * render_px[1]], @@ -1668,6 +1722,7 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { let cut_ref = cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, p.focus)); let cut_ref_prev = cover(screen_source_rect(u_max, v_max, active_crop, pp.zoom, p.focus)); let cut = cover(screen_source_rect(u_max, v_max, active_crop, 1.0, p.focus)); + let focus_plane = focus_in_cut(u_max, v_max, active_crop, p.focus, cut); let s_dst = remap_box(s_base, cut_ref, cut); // Parallaxe : calculée ici, une fois la coupe connue — elle mesure la vitesse du curseur // en coupes VISIBLES par seconde. La coupe visible est `cut_ref`, zoom compris : `cut` @@ -1833,6 +1888,9 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { zoom_rotation_dyn, padding_scale, cut, + focus_plane, + // Sans scène (bench fixture), aucune zoom region, donc aucun tilt : rien à défocaliser. + depth_of_field: scene.is_some_and(|s| s.effects.depth_of_field), s_dst, s_dst_prev, // La boîte écran telle qu'elle serait sans zoom : `remap_box` n'est PAS appliqué. Le @@ -2065,6 +2123,7 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option ShadowCaster::Upright { dst: none.s_dst, size_px: s_px, radius: none.s_radius }, Some(q) => ShadowCaster::Tilted { corners: q.corners, @@ -2524,7 +2583,7 @@ mod tests { let old = framed_plan(&framed_scene("", preset, 1.0, false)); let g = framed_plan(&framed_scene(r#","frame":"window-light""#, preset, 1.0, false)); let wf = g.window_frame.expect("un cadre"); - let quad = g.screen_tilt(RENDER).expect("incliné"); + let quad = g.screen_tilt_in(RENDER).expect("incliné"); let ShadowCaster::Tilted { corners: frame, center_px, radius } = g.shadow_caster(RENDER) else { panic!("un cadre incliné porte une ombre inclinée"); }; @@ -2532,7 +2591,7 @@ mod tests { assert!((radius - wf.radius * quad.scale).abs() < 1e-4); let [ml, mt, mr, mb] = wf.margins; - let frame_quad = crate::regions::TiltedQuad { corners: frame, scale: quad.scale }; + let frame_quad = crate::regions::TiltedQuad { corners: frame, scale: quad.scale, depth_k: quad.depth_k }; let (u0, v0) = (ml / (1.0 + ml + mr), mt / (1.0 + mt + mb)); let (u1, v1) = ((1.0 + ml) / (1.0 + ml + mr), (1.0 + mt) / (1.0 + mt + mb)); for (i, (u, v)) in [(u0, v0), (u1, v0), (u1, v1), (u0, v1)].into_iter().enumerate() { @@ -2667,6 +2726,56 @@ mod tests { } } + /// Le focus de profondeur (`z_focus`, mode 8) passe par la coupe réellement dessinée. + /// + /// Crop décalé ET focus collé au bord droit sous un zoom x2 : la coupe zoomée bute sur le + /// bord, son centre tombe à 0.75 du crop alors que le point visé est à 0.95. C'est ce point-là + /// que le plan doit tenir net, pas le centre, et encore moins (0.5, 0.5). + #[test] + fn the_depth_focus_goes_through_the_drawn_cut() { + let cfg = crate::config::all().pop().expect("au moins une config"); + let json = zoomed_golden_scene_json() + .replace(r#""rotation":"none""#, r#""rotation":"iso""#) + .replace(r#""focusX":0.5"#, r#""focusX":0.95"#) + .replace( + r#""cropByClip":[{"x":0,"y":0,"#, + r#""cropByClip":[{"x":0.3,"y":0.1,"#, + ); + let scene = Scene::from_json(&json).expect("scène inclinée recadrée"); + let input = golden_input(&scene, &cfg); + let g = plan_frame(&input); + assert!(!crate::regions::is_identity_rotation(g.zoom_rotation), "garde : iso doit incliner"); + assert!((g.focus_plane[0] - 0.95).abs() < 1e-4, "focus x {:?}", g.focus_plane); + assert!((g.focus_plane[1] - 0.3).abs() < 1e-4, "focus y {:?}", g.focus_plane); + + // Garde : la coupe zoomée est bien clampée, son centre n'est PAS le focus. + let crop = scene.crop_by_clip[0]; + let cut_ref = screen_source_rect(input.u_max, input.v_max, crop, 2.0, [0.95, 0.3]); + let centre_local = ((cut_ref[0] + cut_ref[2]) * 0.5 - g.cut[0]) / (g.cut[2] - g.cut[0]); + assert!((centre_local - 0.75).abs() < 1e-3, "garde : centre de coupe {centre_local}"); + + // Et `z_focus` est la profondeur de CE point, pas celle du centre du plan. + let render = input.render_px; + let s_px = [g.s_dst[2] * render[0], g.s_dst[3] * render[1]]; + let quad = g.screen_tilt(s_px).expect("iso incline"); + let mb = quad.depth_mb(s_px, g.focus_plane, g.depth_of_field_on(false)); + assert!(mb[3] > 0.0, "réglage absent de la scène : la profondeur de champ est allumée"); + assert!(!g.depth_of_field_on(true) || DOF_ON_CPU_BACKEND); + let want = (0.95 - 0.5) * mb[0] + (0.3 - 0.5) * mb[1]; + assert!((mb[2] - want).abs() < 1e-3 && mb[2].abs() > 1.0, "z_focus {} au lieu de {want}", mb[2]); + } + + /// Un cover rogne la coupe dans le crop : le focus s'y reporte, et retombe sur le bord du + /// plan quand le cover l'a coupé. + #[test] + fn the_depth_focus_follows_a_cover_cut_and_clamps_to_it() { + let cut = [0.25, 0.0, 0.75, 1.0]; + for (focus, want) in [([0.6, 0.5], [0.7, 0.5]), ([0.95, 0.5], [1.0, 0.5]), ([f32::NAN, 0.0], [0.5, 0.0])] { + let got = focus_in_cut(1.0, 1.0, None, focus, cut); + assert!((got[0] - want[0]).abs() < 1e-5 && (got[1] - want[1]).abs() < 1e-5, "{got:?} != {want:?}"); + } + } + fn blur_annotation(json_extra: &str) -> crate::scene::SceneAnnotation { serde_json::from_str(&format!( r#"{{"id":"secret","startSec":0,"endSec":10,"kind":"blur", @@ -3506,6 +3615,8 @@ mod tests { zoom_rotation_dyn: [0.0, 0.0, 0.0], padding_scale: 1.0, cut: [0.0, 0.0, 1.0, 1.0], + focus_plane: [0.5, 0.5], + depth_of_field: false, s_dst: [0.0, 0.0, 1.0, 1.0], s_dst_prev: [0.0, 0.0, 1.0, 1.0], s_ann: [0.0, 0.0, 1.0, 1.0], @@ -3733,7 +3844,7 @@ mod tests { CursorPlacement::Upright { center: [dst[0] + fx * dst[2], dst[1] + fy * dst[3]] }; let screen_px = [dst[2] * render[0], dst[3] * render[1]]; let quad = - crate::regions::rotated_quad_corners_px(screen_px[0], screen_px[1], [0.0; 3]); + crate::regions::rotated_quad_corners_px(screen_px[0], screen_px[1], [0.0; 3], [0.0; 3]); assert_eq!(quad.scale, 1.0); let tilted = CursorPlacement::Tilted { plane_pt: [fx, fy], diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs index a715a4cbd..774381303 100644 --- a/crates/compositor/src/regions.rs +++ b/crates/compositor/src/regions.rs @@ -660,8 +660,9 @@ fn project_corner(x0: f32, y0: f32, rot: [f32; 3], perspective: f32) -> Option<( Some((px, py)) } -/// Le point local (x0,y0,0) tourné par `rot` (degrés X/Y/Z), avant perspective. `z` > 0 vient -/// vers la caméra, < 0 recule. +/// La rotation seule de `project_corner` : le point local (x0,y0,0) tourné par `rot` (degrés +/// X/Y/Z), AVANT perspective. `z > 0` = vers la caméra (la perspective divise par +/// `perspective - z`, donc agrandit), < 0 recule. fn rotate_corner(x0: f32, y0: f32, rot: [f32; 3]) -> (f32, f32, f32) { let (a, b, g) = (rot[0].to_radians(), rot[1].to_radians(), rot[2].to_radians()); let (ca, sa) = (a.cos(), a.sin()); @@ -683,6 +684,38 @@ fn rotate_corner(x0: f32, y0: f32, rot: [f32; 3]) -> (f32, f32, f32) { (px, py, pz) } +/// `(Kx, Ky)` tels que le `pz` de `rotate_corner(x0, y0, rot)` vaille `Kx·x0 + Ky·y0`. +/// +/// Forme close du `pz` ci-dessus : le point part de z = 0, donc rotateZ ne touche pas z, +/// rotateY en tire `-x1·sb`, rotateX en tire `y1·sa + z·ca`, où (x1, y1) est le point après +/// rotateZ. En développant x1 = x0·cg − y0·sg et y1 = x0·sg + y0·cg, `pz` est linéaire en +/// (x0, y0) — d'où ces deux coefficients, sans rien de plus à calculer par pixel. +fn depth_coefficients(rot: [f32; 3]) -> (f32, f32) { + let (a, b, g) = (rot[0].to_radians(), rot[1].to_radians(), rot[2].to_radians()); + let (ca, sa) = (a.cos(), a.sin()); + let sb = b.sin(); + let (cg, sg) = (g.cos(), g.sin()); + (sa * sg - sb * ca * cg, sa * cg + sb * ca * sg) +} + +/// ROTATION_3D_PERSPECTIVE_FACTOR (TS) — à garder synchronisé avec `types.ts`, que la passe 3D +/// de l'exporteur canvas lit encore. Distance de fuite = facteur × min(w,h) : plus le facteur +/// est grand, plus la caméra est loin et plus la convergence des arêtes s'aplatit. À 2.6 elle +/// était si faible que l'inclinaison ne se lisait plus (le bord haut d'iso ressortait à 0.08° de +/// l'horizontale). +const PERSPECTIVE_FACTOR: f32 = 1.6; + +/// Profondeur de champ du mode 8 : cercle de confusion, en texels SOURCE, par unité d'écart de +/// profondeur rapporté à la distance de fuite (`|z − z_focus| / P`). +/// +/// 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. +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 /// derrière le plan de fuite. fn project_scaled_corners( @@ -714,6 +747,10 @@ pub struct TiltedQuad { /// Facteur de containment. Le plan mesure donc `taille_du_rect × scale` dans son PROPRE repère, /// avant projection — ce qu'il faut connaître pour y poser un rayon de coin à la bonne échelle. pub scale: f32, + /// `(Kx, Ky)` : la profondeur d'un point du plan, en px, vaut `Kx·x + Ky·y` pour (x, y) sa + /// position en px dans le repère du plan, centre à l'origine, AVANT projection. Positive = + /// vers la caméra. Nulle quand le quad est rendu à plat. + pub depth_k: (f32, f32), } /// Les 4 coins (TL, TR, BR, BL) du quad tilté en 3D, en px relatifs au CENTRE du rect d'origine @@ -734,12 +771,6 @@ pub fn rotated_quad_corners_px( rot: [f32; 3], rot_dyn: [f32; 3], ) -> TiltedQuad { - // ROTATION_3D_PERSPECTIVE_FACTOR (TS) — à garder synchronisé avec `types.ts`, que la passe 3D - // de l'exporteur canvas lit encore. Distance de fuite = facteur × min(w,h) : plus le facteur - // est grand, plus la caméra est loin et plus la convergence des arêtes s'aplatit. À 2.6 elle - // était si faible que l'inclinaison ne se lisait plus (le bord haut d'iso ressortait à 0.08° de - // l'horizontale). - const PERSPECTIVE_FACTOR: f32 = 1.6; let perspective = width.min(height) * PERSPECTIVE_FACTOR; let (half_w, half_h) = (width * 0.5, height * 0.5); @@ -766,6 +797,7 @@ pub fn rotated_quad_corners_px( (-half_w, half_h), ], scale: 1.0, + depth_k: (0.0, 0.0), }; } }; @@ -786,14 +818,18 @@ pub fn rotated_quad_corners_px( None => break, } } + // La profondeur suit l'angle RÉELLEMENT dessiné (base + dynamique), sinon le flou se + // décollerait du plan pendant la parallaxe. + let mut drawn = rot; if rot_dyn != [0.0; 3] { let full = [rot[0] + rot_dyn[0], rot[1] + rot_dyn[1], rot[2] + rot_dyn[2]]; // Un coin qui passerait derrière le plan de fuite : on garde la pose de base. if let Some(c) = project_scaled_corners(width, height, scale, full, perspective) { corners = c; + drawn = full; } } - TiltedQuad { corners, scale } + TiltedQuad { corners, scale, depth_k: depth_coefficients(drawn) } } /// Amplitude maximale de la part dynamique du tilt, en degrés X/Y/Z. Partagée par tout ce qui @@ -974,6 +1010,28 @@ impl TiltedQuad { (top.0 + (bottom.0 - top.0) * fy, top.1 + (bottom.1 - top.1) * fy) } + /// Le `mb` du draw mode 8 : `[gx, gy, z_focus, k]`. + /// + /// La profondeur du point (s, t) du plan (0..1, ce que le warp inverse du shader retrouve) + /// vaut `(s − 0.5)·gx + (t − 0.5)·gy`, en px, positive vers la caméra : deux multiply-add par + /// pixel, aucune trigo. `z_focus` est cette profondeur au point de focus du zoom + /// (`focus_plane`, 0..1 dans le plan — `FrameGeometry::focus_plane`). + /// + /// `k` convertit l'écart de profondeur en cercle de confusion : `coc = k·|z − z_focus|`, en + /// texels source (`DOF_COC_PER_DEPTH / P`). `dof = false` le laisse à 0 : le shader ne + /// quitte alors jamais son échantillon net, et la sortie est celle d'avant l'effet à l'octet. + /// + /// `screen_px` est la taille du rect d'origine, celle passée à `rotated_quad_corners_px` : le + /// plan mesure `screen_px × scale` dans son propre repère, et la distance de fuite en dérive. + pub fn depth_mb(&self, screen_px: [f32; 2], focus_plane: [f32; 2], dof: bool) -> [f32; 4] { + let gx = screen_px[0] * self.scale * self.depth_k.0; + let gy = screen_px[1] * self.scale * self.depth_k.1; + let z_focus = (focus_plane[0] - 0.5) * gx + (focus_plane[1] - 0.5) * gy; + let perspective = screen_px[0].min(screen_px[1]) * PERSPECTIVE_FACTOR; + let k = if dof && perspective > 0.0 { DOF_COC_PER_DEPTH / perspective } else { 0.0 }; + [gx, gy, z_focus, k] + } + /// Demi-largeur / demi-hauteur de la bounding box des coins projetés, en px. pub fn half_extents_px(&self) -> (f32, f32) { projected_extents(&self.corners) @@ -1002,6 +1060,23 @@ mod zoom_focus_tests { } } + /// Le focus automatique ne « perd » jamais la piste : hors de ses bornes, il tient la + /// dernière (ou la première) position suivie au lieu de retomber sur le point statique de la + /// région. Sans ça, le plan net sauterait d'un bout de l'écran à l'autre à la fin de la + /// télémétrie — et la netteté avec lui, maintenant que `z_focus` en dépend. + #[test] + fn auto_focus_holds_the_last_tracked_point_past_the_track() { + let track = CursorTrack::new(vec![(3.0, 0.8, 0.2), (4.0, 0.9, 0.1)], Vec::new(), Vec::new()); + let mut r = region(1.5, 0.1); + r.focus_mode = Some("auto".into()); + let last = track.follow_at(4.0).unwrap(); + for t in [4.5f32, 6.0, 7.9] { + assert_eq!(resolve_focus(&r, t, Some(&track)), [last.0, last.1], "t = {t}"); + } + let first = track.follow_at(3.0).unwrap(); + assert_eq!(resolve_focus(&r, 2.1, Some(&track)), [first.0, first.1]); + } + #[test] fn zoom_cursor_alpha_hides_during_zoom() { let mut r = region(2.0, 0.5); @@ -1353,6 +1428,113 @@ mod tilt_tests { } } + fn presets() -> [(&'static str, [f32; 3]); 3] { + [ + ("iso", rotation3d_for(&Some("iso".into()))), + ("left", rotation3d_for(&Some("left".into()))), + ("right", rotation3d_for(&Some("right".into()))), + ] + } + + fn depth_at(mb: [f32; 4], s: f32, t: f32) -> f32 { + (s - 0.5) * mb[0] + (t - 0.5) * mb[1] + } + + /// La forme close de `depth_mb` doit redonner le `pz` que `project_corner` calcule — sur tout + /// le plan, pas seulement aux coins, et dans le repère exact où les coins ont été projetés. + #[test] + fn the_closed_form_depth_matches_the_rotation() { + for (name, rot) in presets() { + for (w, h) in [(1920.0f32, 1080.0f32), (1080.0, 1920.0), (800.0, 800.0)] { + 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 perspective = w.min(h) * PERSPECTIVE_FACTOR; + for (i, (s, t)) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].into_iter().enumerate() + { + let (x0, y0) = ((s - 0.5) * w * quad.scale, (t - 0.5) * h * quad.scale); + // Même repère que les coins : projeter ce point redonne le coin rendu. + let (px, py) = project_corner(x0, y0, rot, perspective).unwrap(); + let (cx, cy) = quad.corners[i]; + assert!((px - cx).abs() < 1e-2 && (py - cy).abs() < 1e-2, "{name} coin {i}"); + } + for i in 0..=8 { + for j in 0..=8 { + let (s, t) = (i as f32 / 8.0, j as f32 / 8.0); + let (x0, y0) = ((s - 0.5) * w * quad.scale, (t - 0.5) * h * quad.scale); + let (_, _, pz) = rotate_corner(x0, y0, rot); + let z = depth_at(mb, s, t); + assert!( + (z - pz).abs() <= 1e-3 * w.max(h), + "{name} {w}x{h} ({s},{t}) : forme close {z} != pz {pz}" + ); + } + } + } + } + } + + /// Le signe. Pour iso, le coin haut-droit est le PROCHE (dessiné plus grand), le bas-gauche le + /// lointain : z(BL) < z(TR). Un signe inversé flouterait le coin proche, et la règle des 2° + /// ne le verrait pas — d'où le recoupement avec la taille réellement dessinée. + #[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 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)); + assert!(z_bl < z_tr, "z(BL) = {z_bl} doit être < z(TR) = {z_tr}"); + // Grossissement perspective d'un coin = position projetée / position tournée. + let magnification = |i: usize, s: f32, t: f32| { + let (x0, y0) = ((s - 0.5) * w * quad.scale, (t - 0.5) * h * quad.scale); + let (rx, ry, _) = rotate_corner(x0, y0, rot); + quad.corners[i].0.hypot(quad.corners[i].1) / rx.hypot(ry) + }; + let (m_tr, m_bl) = (magnification(1, 1.0, 0.0), magnification(3, 0.0, 1.0)); + assert!(m_tr > m_bl * 1.2, "TR x{m_tr} devrait être nettement plus grossi que BL x{m_bl}"); + } + + /// Le focus au centre du plan est à la profondeur du centre (0), et `k` reste nul réglage + /// coupé : le shader ne quitte alors jamais son échantillon net. + #[test] + fn the_depth_slot_is_inert_when_off_and_centred() { + for (name, rot) in presets() { + let quad = rotated_quad_corners_px(1920.0, 1080.0, rot, [0.0; 3]); + let mb = quad.depth_mb([1920.0, 1080.0], [0.5, 0.5], false); + assert_eq!(mb[2], 0.0, "{name}"); + assert_eq!(mb[3], 0.0, "{name}"); + let off = quad.depth_mb([1920.0, 1080.0], [0.9, 0.2], false); + assert!((off[2] - depth_at(off, 0.9, 0.2)).abs() < 1e-4, "{name}"); + } + let flat = rotated_quad_corners_px(1920.0, 1080.0, [0.0, 0.0, 0.0], [0.0; 3]); + assert_eq!(flat.depth_mb([1920.0, 1080.0], [0.1, 0.9], false), [0.0; 4]); + // À plat, allumer ne floute rien : gx = gy = 0, donc |z − z_focus| = 0 partout. + let lit = flat.depth_mb([1920.0, 1080.0], [0.1, 0.9], true); + assert_eq!([lit[0], lit[1], lit[2]], [0.0; 3]); + } + + /// Le cercle de confusion que le shader calcule (`k·|z − z_focus|`, texels source) : nul au + /// focus, croissant vers le coin LOINTAIN, et le même en 1080p qu'en 4K — il est réglé en + /// 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 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); + mb[3] * (depth_at(mb, s, t) - mb[2]).abs() + }; + 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). + 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. + assert!(coc(1920.0, 1080.0, [1.0, 0.0], 0.0, 1.0) > far * 1.8); + let far_4k = coc(3840.0, 2160.0, centre, 0.0, 1.0); + assert!((far_4k - far).abs() < 1e-3 * far, "{far_4k} != {far}"); + } + #[test] fn a_flat_quad_is_left_alone() { // Sans rotation, aucun containment ne doit s'appliquer : les coins sont ceux du rect. @@ -1544,6 +1726,11 @@ mod tilt_tests { // Garde : la part dynamique bouge bien les coins. let moved = rotated_quad_corners_px(1920.0, 1080.0, base, [1.9, 3.0, 0.0]); assert!((moved.corners[1].0 - frozen.corners[1].0).abs() > 5.0); + // La profondeur de champ suit l'angle DESSINÉ, pas la base seule. + let d = [1.9, 3.0, 0.0]; + let full = [base[0] + d[0], base[1] + d[1], base[2] + d[2]]; + assert_eq!(moved.depth_k, depth_coefficients(full), "{name}"); + assert_ne!(moved.depth_k, frozen.depth_k, "{name}"); } } diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 7d0ea7ba5..f2e476c87 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -166,6 +166,15 @@ pub struct SceneEffects { /// il n'y en a pas, et tout payload d'avant le cadre se lit « sans cadre ». #[serde(default)] pub frame: SceneFrame, + /// Réglage « Depth of field » : défocalise l'écran incliné selon sa profondeur (mode 8), + /// net au focus du zoom. Sans effet hors tilt. Allumé par défaut, clé absente comprise : il + /// ne s'applique qu'aux zooms inclinés, où il suit l'angle réel. + #[serde(default = "default_true")] + pub depth_of_field: bool, +} + +fn default_true() -> bool { + true } /// Le cadre autour de l'écran (`effects.frame` côté TS). Une valeur inconnue — un cadre ajouté diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index d5395c345..bc02c3341 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -38,13 +38,17 @@ VSOut vs_main(uint vid : SV_VertexID) Texture2D texY : register(t0); Texture2D texUV : register(t1); -Texture2D texImg : register(t2); // wallpaper image RGBA (fond, mode 6) +Texture2D texImg : register(t2); // wallpaper image RGBA (fond, mode 6) ; mode 8 : pyramide de flou // Masque de segmentation du sujet, 0 = fond, 1 = sujet. Produit par `segmentation.rs` a la // resolution du modele (256x144) ; l'upscale vers la resolution webcam est fait par le sampler // lineaire, ce qui est exactement le filtrage qu'on veut sur un masque. Texture2D texMask : register(t3); SamplerState samp : register(s0); +// Plafond de la profondeur de champ du mode 8, en niveau de la pyramide demi-résolution (1.5 = +// ~5.7 texels source). Validé à l'œil sur du texte : cf. `tests/tilted_depth_of_field.rs`. +#define DOF_MAX_LOD 1.5 + // BT.709 limited -> RGB (§7 E1), matrice en dur, range mesuré en S1. float3 yuv709_limited(float y, float2 cbcr) { @@ -397,6 +401,11 @@ float4 ps_main(VSOut i) : SV_Target // de perspective-correct exact, mais indiscernable à l'œil pour un tilt de 10-22°) et // échantillonne la vidéo à l'UV correspondant, sinon transparent (hors du quad projeté). // fx.xy/fx.zw = coins TL/TR (px locaux, 0..quad_px) ; src_prev.xy/.zw = coins BR/BL. + // 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`) ; + // 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). // 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 @@ -557,7 +566,23 @@ float4 ps_main(VSOut i) : SV_Target float2 p = float2(r.x, r.y) * plane_px - plane_px * 0.5; float d = sd_round_rect(p, plane_px * 0.5, (dst_prev.z > 0.5 && p.y < 0.0) ? 0.0 : max(radius_px, 0.0)); float tilt_a = 1.0 - smoothstep(0.0, 1.5, d); - return float4(sample_yuv(uv) * tilt_a, tilt_a); // prémultiplié, comme les autres modes + // Profondeur de champ : cercle de confusion en texels source, nul au focus du zoom. + // Sous un demi-texel, l'échantillon net d'avant, à l'octet : le texte net ne passe + // 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); + 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) + { + 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 float4(rgb * tilt_a, tilt_a); // prémultiplié, comme les autres modes } // mode 7 : sprite curseur thème (PNG alpha droite, arrow.png etc.). Prémultiplie ici diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index f6b2008a4..162edd0eb 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -102,6 +102,10 @@ vertex VSOut vs_main(uint vid [[vertex_id]], constexpr sampler samp(filter::linear, mip_filter::linear, address::clamp_to_edge); constexpr sampler sampNV(filter::linear, address::clamp_to_edge); +// Plafond de la profondeur de champ du mode 8, en niveau de la pyramide demi-résolution. +// Même valeur que `DOF_MAX_LOD` du HLSL. +constant float DOF_MAX_LOD = 1.5; + // Slots de texture, tenus par les paramètres des entry points : // ps_main : 0 = texY (Y, R8), 1 = texUV (CbCr, RG8), 2 = texImg (RGBA) // ps_fs_* : 0 = rgbTex (RGBA) @@ -480,6 +484,11 @@ fragment float4 ps_main(VSOut i [[stage_in]], } // mode 8 : écran tilté (zoom regions "rotation"). Warp bilinéaire inverse. + // 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`) ; + // k = texels source de flou par px d'écart de profondeur (0 = profondeur de champ coupée). + // texture(2) (texImg) = pyramide demi-résolution de la vidéo, 5 niveaux, mêmes UV que + // texture(0/1) ; liée explicitement à chaque draw du mode 8. Cf. commentaires HLSL. if (layer.mode > 7.5 && layer.mode < 8.5) { // PAS de test de clip sur `dst_prev` ici — le port en avait copié un depuis le @@ -508,9 +517,22 @@ fragment float4 ps_main(VSOut i [[stage_in]], float d = sd_round_rect(p, plane_px * 0.5, (layer.dst_prev.z > 0.5 && p.y < 0.0) ? 0.0 : max(layer.radius_px, 0.0)); float tilt_a = 1.0 - smoothstep(0.0, 1.5, d); + // 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) + { + 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)); + } // L'alpha est cette couverture, pas `color.a` : les draws du mode 8 laissent `color` // à zéro, donc le port rendait de toute façon un plan totalement transparent. - return float4(sample_yuv(uv, texY, texUV) * tilt_a, tilt_a); + return float4(rgb * tilt_a, tilt_a); } // mode 7 : sprite curseur thème (PNG alpha droite). Prémultiplie ici, comme partout diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index d3d9d33b2..2bf92063c 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -27,7 +27,7 @@ struct Layer { fx: vec4, // mode 2 : spread ombre en px ; mode 5 : (direction xy, temps programme replie, mouvement 0..3) ; modes 8/12/13/14 : coins TL,TR du quad projeté ; mode 9 : hampe de la flèche ; mode 10 : (flou?, rayon/bloc px, ovale?, teinté?) 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 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) ; mode 14 : .xy = taille du plan du cadre, .z = hauteur de la barre, .w = epaisseur du filet (px du plan) ; mode 13 : rect de clip ; mode 9 : barbe 2 ; mode 10 incliné : coins TL,TR du masque - mb: vec4, // 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é ; mode 13 : mb.xy = vecteur d'extrusion en px, mb.z = nombre de copies (volume, <= 1 = plat) ; mode 14 : couleur du filet (alpha droit) + 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é ; mode 13 : mb.xy = vecteur d'extrusion en px, mb.z = nombre de copies (volume, <= 1 = plat) ; mode 14 : couleur du filet (alpha droit) } @group(0) @binding(0) var layer: Layer; @@ -36,7 +36,13 @@ struct Layer { @group(0) @binding(3) var samp: sampler; // Masque de segmentation du sujet webcam, R8. Une vue 1x1 est liee quand aucun masque // n'existe : la branche n'est de toute facon prise que si layer.fx.z > 0.5. +// Mode 8 : ce binding porte a la place la pyramide RGBA de profondeur de champ (jamais le +// binding 1, qui porte la luma). @group(0) @binding(4) var texMask: texture_2d; + +// Plafond de la profondeur de champ du mode 8, en niveau de la pyramide demi-resolution. +// Meme valeur que `DOF_MAX_LOD` du HLSL. +const DOF_MAX_LOD: f32 = 1.5; // V est en binding 5 et pas 3 : les bindings 0-4 etaient deja pris quand le plan // de chroma a ete dedouble, et renumeroter aurait touche tous les bind groups // pour un gain nul. @@ -586,9 +592,22 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { let d = sd_round_rect(p, plane_px * 0.5, select(max(layer.radius_px, 0.0), 0.0, layer.dst_prev.z > 0.5 && p.y < 0.0)); let tilt_a = 1.0 - smoothstep(0.0, 1.5, d); + // 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); + 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)); + } // L'alpha est cette couverture, pas `color.a` : les draws du mode 8 laissent // `color` a zero, donc s'en servir rendrait un plan totalement transparent. - return vec4(sample_yuv(uv) * tilt_a, tilt_a); + return vec4(tilt_rgb * tilt_a, tilt_a); } else if layer.mode > 11.5 && layer.mode < 12.5 { // Mode 12 -- ombre du quad projete. La penombre suit le QUADRILATERE, pas son // rect englobant : un rect droit derriere un ecran incline se lit comme une diff --git a/crates/compositor/tests/compose_linux.rs b/crates/compositor/tests/compose_linux.rs index e320ca8e8..da4b31388 100644 --- a/crates/compositor/tests/compose_linux.rs +++ b/crates/compositor/tests/compose_linux.rs @@ -1670,3 +1670,113 @@ fn compose_linux_annotation_ancree_hors_zoom() { ); } } + +// --------------------------------------------------------------------------- +// Profondeur de champ du mode 8 (pendant Linux de `tilted_depth_of_field.rs`) +// --------------------------------------------------------------------------- + +/// Nettete d'une fenetre `r`x`r` centree en `c` : moyenne des ecarts entre voisins (vert). +fn dof_sharpness(rgba: &[u8], w: u32, c: (u32, u32), r: u32) -> f64 { + let g = |x: u32, y: u32| rgba[((y * w + x) * 4 + 1) as usize] as f64; + let (mut acc, mut n) = (0.0, 0.0); + for y in c.1 - r / 2..c.1 + r / 2 { + for x in c.0 - r / 2..c.0 + r / 2 { + acc += (g(x + 1, y) - g(x, y)).abs() + (g(x, y + 1) - g(x, y)).abs(); + n += 1.0; + } + } + acc / n +} + +/// Octets RGBA de la fenetre `r`x`r` centree en `c`. +fn dof_window_bytes(rgba: &[u8], w: u32, c: (u32, u32), r: u32) -> Vec { + let mut out = Vec::new(); + for y in c.1 - r / 2..c.1 + r / 2 { + let i = ((y * w + c.0 - r / 2) * 4) as usize; + out.extend_from_slice(&rgba[i..i + (r * 4) as usize]); + } + out +} + +/// Coin du plan (hors fond magenta) extreme dans la direction `dir`, rentre de `inset` +/// vers le centroide. +fn dof_corner(rgba: &[u8], w: u32, h: u32, dir: (i64, i64), inset: f64) -> (u32, u32) { + let (mut best, mut at) = (i64::MIN, (0i64, 0i64)); + let (mut sx, mut sy, mut n) = (0f64, 0f64, 0f64); + for y in 0..h as i64 { + for x in 0..w as i64 { + let i = ((y * w as i64 + x) * 4) as usize; + if !not_bg(&rgba[i..i + 4]) { + continue; + } + sx += x as f64; + sy += y as f64; + n += 1.0; + if x * dir.0 + y * dir.1 > best { + best = x * dir.0 + y * dir.1; + at = (x, y); + } + } + } + let x = at.0 as f64 + (sx / n - at.0 as f64) * inset; + let y = at.1 as f64 + (sy / n - at.1 as f64) * inset; + (x as u32, y as u32) +} + +/// Golden par backend : iso, focus sur le coin proche. Le coin lointain perd son detail, le +/// proche ne bouge pas d'un octet, et a plat le reglage ne change rien. Un `level(lod)` qui +/// retomberait au niveau 0 (vue a un seul niveau, binding 4 mal lie) rendrait le lointain net. +/// Source : `OPENSCREEN_DOF_SOURCE` (du texte de preference), sinon la fixture. +#[test] +fn compose_linux_profondeur_de_champ() { + let source = std::env::var("OPENSCREEN_DOF_SOURCE").unwrap_or_else(|_| FIXTURE.into()); + if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(&source).is_file() { + eprintln!("compose_linux dof: opt-in (OPENSCREEN_LINUX_COMPOSE=1 + source). Skip."); + return; + } + let (w, h) = (1920u32, 1080u32); + // `create_auto` : lavapipe (backend CPU) compte, et `DOF_ON_CPU_BACKEND` l'y laisse tourner. + let gpu = Gpu::create_auto(false).expect("Gpu::create_auto"); + let comp = Compositor::new_sized(&gpu, w, h).expect("Compositor::new_sized"); + let mut dec = Decoder::open(&source, &gpu).expect("Decoder::open"); + let mut cfg = Cfg::c8(); + cfg.shadow = false; + let scene = |rotation: &str, dof: bool| { + tilt_scene_json(rotation, 0, 0.0) + .replace(r#""motionBlur":0}"#, &format!(r#""motionBlur":0,"depthOfField":{dof}}}"#)) + .replace(r#""focusX":0.5,"focusY":0.5"#, r#""focusX":0.94,"focusY":0.06"#) + }; + let (on, off, flat_on, flat_off) = unsafe { + let sf = dec.seek_to(1.0).expect("Decoder::seek_to"); + let render = |json: String| { + let scene = Scene::from_json(&json).expect("scene json"); + comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + comp.compose_frame(sf, sf, 90.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct").2 + }; + ( + render(scene("\"iso\"", true)), + render(scene("\"iso\"", false)), + render(scene("null", true)), + render(scene("null", false)), + ) + }; + write_ppm("compose_linux_dof_on", w, h, &on); + write_ppm("compose_linux_dof_off", w, h, &off); + let r = 48; + let near = dof_corner(&off, w, h, (1, -1), 0.12); + let far = dof_corner(&off, w, h, (-1, 1), 0.12); + let (near_on, near_off) = (dof_sharpness(&on, w, near, r), dof_sharpness(&off, w, near, r)); + let (far_on, far_off) = (dof_sharpness(&on, w, far, r), dof_sharpness(&off, w, far, r)); + println!( + "compose_linux dof : proche {near_off:.2} -> {near_on:.2}, lointain {far_off:.2} -> {far_on:.2}" + ); + assert!(far_off > 2.0, "fenetre lointaine sans detail ({far_off})"); + assert!( + dof_window_bytes(&on, w, near, r) == dof_window_bytes(&off, w, near, r), + "coin proche modifie : {near_off} -> {near_on}" + ); + assert!(far_on < far_off * 0.7, "coin lointain pas floute : {far_off} -> {far_on}"); + assert!(flat_on == flat_off, "rotation nulle : la profondeur de champ a change la frame"); +} diff --git a/crates/compositor/tests/tilted_depth_of_field.rs b/crates/compositor/tests/tilted_depth_of_field.rs new file mode 100644 index 000000000..94734294a --- /dev/null +++ b/crates/compositor/tests/tilted_depth_of_field.rs @@ -0,0 +1,263 @@ +//! La profondeur de champ du mode 8 floute le coin LOINTAIN d'un écran incliné et laisse le +//! coin proche, où tombe le focus, intact à l'octet. +//! +//! Rend de vraies frames par le compositeur D3D11, sur une source quelconque (de préférence du +//! texte : c'est là qu'un plafond de flou trop haut se voit). Le fond est magenta, une couleur +//! qu'aucune source réaliste ne produit, pour isoler le plan au pixel près. +//! +//! ```powershell +//! $env:OPENSCREEN_DOF_SOURCE = "...\text.mp4" # obligatoire, sinon le test se saute +//! $env:OPENSCREEN_DOF_OUT = "...\renders" # facultatif : PPM par cas +//! $env:OPENSCREEN_DOF_BENCH = "1" # facultatif : A/B de coût, matériel et WARP +//! cargo test -p openscreen-compositor --test tilted_depth_of_field -- --nocapture +//! ``` +//! +//! Le pendant « réglage coupé = rendu d'avant à l'octet » est `tilted_depth_slot_inert.rs`, qui +//! compare à une référence produite sans l'effet. + +// Windows seulement : le readback et le décodage D3D11VA de ce harnais n'existent que là. +#![cfg(windows)] + +use std::time::Instant; + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config; +use openscreen_compositor::d3d::{Backend, Gpu}; +use openscreen_compositor::frame_geometry::live_params_from_scene; +use openscreen_compositor::live::Player; +use openscreen_compositor::scene::Scene; + +/// En plein palier du zoom : la région couvre 0..6 s. +const AT_SEC: f64 = 3.0; + +fn scene_json(source: &str, rotation: &str, dof: bool, focus: [f32; 2], w: u32, h: u32) -> String { + let s = source.replace('\\', "/"); + let [fx, fy] = focus; + format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":6,"webcamOffsetSec":0,"hasAudio":false}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.05,"y":0.05,"width":0.9,"height":0.9}}}}, + "effects": {{"padding":0.1,"blur":false,"shadow":0.0,"roundnessFrac":0.0,"motionBlur":0.0,"depthOfField":{dof}}}, + "background": {{"kind":"color","color":"#ff00ff"}}, + "zoomRegions": [{{"id":"z","startSec":0,"endSec":6,"scale":1.0,"focusX":{fx},"focusY":{fy},"focusMode":"manual","rotation":{rotation}}}], + "annotations": [], + "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":{w},"height":{h},"fps":null}} + }}"## + ) +} + +fn compositor(gpu: &Gpu, source: &str, rotation: &str, dof: bool, focus: [f32; 2], w: u32, h: u32) -> Compositor { + let comp = Compositor::new_sized(gpu, w, h).expect("compositor"); + let scene = Scene::from_json(&scene_json(source, rotation, dof, focus, w, h)).expect("scene valide"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + comp.clear_cursor(); + comp +} + +fn cfg() -> config::Cfg { + let mut cfg = config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + cfg.shadow = false; + cfg +} + +fn render(gpu: &Gpu, source: &str, rotation: &str, dof: bool, focus: [f32; 2], w: u32, h: u32) -> Vec { + let comp = compositor(gpu, source, rotation, dof, focus, w, h); + unsafe { + let mut player = Player::open(source, "", gpu).expect("ouvrir la source"); + player.present_frame(&comp, &cfg(), AT_SEC).expect("composer la frame"); + comp.readback_resized(w, h).expect("readback") + } +} + +fn write_ppm(dir: &Option, name: &str, rgba: &[u8], w: u32, h: u32) { + let Some(dir) = dir else { return }; + std::fs::create_dir_all(dir).expect("creer le dossier de sortie"); + let mut out = Vec::with_capacity(rgba.len() / 4 * 3 + 32); + out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes()); + for px in rgba.chunks_exact(4) { + out.extend_from_slice(&px[..3]); + } + std::fs::write(dir.join(format!("{name}.ppm")), out).expect("ecrire le ppm"); +} + +/// Pixel du plan (pas le fond magenta). Seuil large : le feather des bords ne compte pas. +fn on_plane(p: &[u8]) -> bool { + !(p[0] > 200 && p[1] < 60 && p[2] > 200) +} + +/// Coin du plan extrême dans la direction `(dx, dy)`, rentré de `inset` vers le centroïde : +/// une fenêtre posée là est entièrement sur le plan, loin du feather. +fn corner_window(rgba: &[u8], w: u32, h: u32, dir: (i64, i64), inset: f64) -> (u32, u32) { + let (mut best, mut at) = (i64::MIN, (0i64, 0i64)); + let (mut sx, mut sy, mut n) = (0f64, 0f64, 0f64); + for y in 0..h as i64 { + for x in 0..w as i64 { + let i = ((y * w as i64 + x) * 4) as usize; + if !on_plane(&rgba[i..i + 4]) { + continue; + } + sx += x as f64; + sy += y as f64; + n += 1.0; + let score = x * dir.0 + y * dir.1; + if score > best { + best = score; + at = (x, y); + } + } + } + let (cx, cy) = (sx / n, sy / n); + let x = at.0 as f64 + (cx - at.0 as f64) * inset; + let y = at.1 as f64 + (cy - at.1 as f64) * inset; + (x as u32, y as u32) +} + +/// Netteté d'une fenêtre `r`×`r` centrée en `c` : moyenne des écarts absolus entre voisins +/// (luma approchée par le vert). Le texte net en a beaucoup, le texte flou peu. +fn sharpness(rgba: &[u8], w: u32, c: (u32, u32), r: u32) -> f64 { + let g = |x: u32, y: u32| rgba[((y * w + x) * 4 + 1) as usize] as f64; + let (mut acc, mut n) = (0.0, 0.0); + for y in c.1 - r / 2..c.1 + r / 2 { + for x in c.0 - r / 2..c.0 + r / 2 { + acc += (g(x + 1, y) - g(x, y)).abs() + (g(x, y + 1) - g(x, y)).abs(); + n += 1.0; + } + } + acc / n +} + +fn window_bytes(rgba: &[u8], w: u32, c: (u32, u32), r: u32) -> Vec { + let mut out = Vec::new(); + for y in c.1 - r / 2..c.1 + r / 2 { + let i = ((y * w + c.0 - r / 2) * 4) as usize; + out.extend_from_slice(&rgba[i..i + (r * 4) as usize]); + } + out +} + +#[test] +fn the_far_corner_of_a_tilted_screen_is_defocused_and_the_near_one_is_not() { + let Ok(source) = std::env::var("OPENSCREEN_DOF_SOURCE") else { + println!("SKIP: definir OPENSCREEN_DOF_SOURCE (voir l'en-tete du fichier)."); + return; + }; + let out = std::env::var("OPENSCREEN_DOF_OUT").ok().map(std::path::PathBuf::from); + let gpu = Gpu::create(false).expect("device d3d11"); + let (w, h) = (1920u32, 1080u32); + let rot = r#""iso""#; + + // Focus sur le coin PROCHE (haut-droit d'iso) : le lointain (bas-gauche) en est au plus loin. + let near_focus = [0.94, 0.06]; + let on = render(&gpu, &source, rot, true, near_focus, w, h); + let off = render(&gpu, &source, rot, false, near_focus, w, h); + write_ppm(&out, "iso-dof-on", &on, w, h); + write_ppm(&out, "iso-dof-off", &off, w, h); + + let r = 48; + let near = corner_window(&off, w, h, (1, -1), 0.12); + let far = corner_window(&off, w, h, (-1, 1), 0.12); + let (near_on, near_off) = (sharpness(&on, w, near, r), sharpness(&off, w, near, r)); + let (far_on, far_off) = (sharpness(&on, w, far, r), sharpness(&off, w, far, r)); + println!("iso : proche {near:?} nettete {near_off:.2} -> {near_on:.2}"); + println!("iso : lointain {far:?} nettete {far_off:.2} -> {far_on:.2}"); + // Garde : les deux fenêtres voient du contenu, pas un aplat. + assert!(near_off > 2.0 && far_off > 2.0, "fenetres sans detail ({near_off}, {far_off})"); + // Le coin proche, au focus, reste l'échantillon net d'avant : pas un octet ne bouge. + assert_eq!(window_bytes(&on, w, near, r), window_bytes(&off, w, near, r), "coin proche modifie"); + // Le coin lointain perd nettement de son détail, et plus que le proche : l'ordre des deux + // est ce qu'un signe de profondeur inversé casserait. + assert!(far_on < far_off * 0.7, "coin lointain pas floute : {far_off} -> {far_on}"); + assert!(far_on / far_off < near_on / near_off, "le lointain n'est pas le plus flou"); + + // Focus au centre : les deux coins sont à la même distance du plan net, ni l'un ni l'autre + // n'est épargné — la preuve que c'est bien le focus qui décide, pas la position. + let centre = render(&gpu, &source, rot, true, [0.5, 0.5], w, h); + write_ppm(&out, "iso-dof-on-centre-focus", ¢re, w, h); + let near_c = sharpness(¢re, w, near, r); + println!("iso focus centre : proche {near_off:.2} -> {near_c:.2}"); + assert!(near_c < near_off * 0.9, "focus au centre : le coin proche devrait se flouter aussi"); + + // À plat, l'effet n'existe pas : allumé ou non, la frame est la même à l'octet. + let flat_on = render(&gpu, &source, "null", true, near_focus, w, h); + let flat_off = render(&gpu, &source, "null", false, near_focus, w, h); + assert!(flat_on == flat_off, "rotation nulle : la profondeur de champ a change la frame"); +} + +/// Coût mesuré, A/B entrelacé : la même frame décodée, recomposée par deux compositeurs qui ne +/// diffèrent que par le réglage. Le readback synchronise le GPU à chaque tour ; il est payé des +/// deux côtés, donc l'écart est celui de l'effet. +/// +/// Trois formes, parce que la pyramide suit la texture SOURCE et non le rendu : +/// - export : rendu à la taille de sortie, `readback_resized` (ce que fait l'export) ; +/// - preview : rendu à la taille du panneau (1280×720 ici, cf. `preview_render_size`), lu par +/// `readback_direct` comme la boucle live — la pyramide y reste pleine taille, donc son coût +/// pèse relativement plus ; +/// - 4K : source et sortie 3840×2160, si `OPENSCREEN_DOF_SOURCE_4K` est posée. +#[test] +fn depth_of_field_cost_a_b() { + let (Ok(source), Ok(_)) = (std::env::var("OPENSCREEN_DOF_SOURCE"), std::env::var("OPENSCREEN_DOF_BENCH")) else { + println!("SKIP: definir OPENSCREEN_DOF_SOURCE et OPENSCREEN_DOF_BENCH."); + return; + }; + let source_4k = std::env::var("OPENSCREEN_DOF_SOURCE_4K").ok(); + let mut cases = vec![ + (Backend::Hardware, source.clone(), (1920u32, 1080u32), false, 300usize), + (Backend::Hardware, source.clone(), (1280, 720), true, 300), + (Backend::Cpu, source.clone(), (1920, 1080), false, 20), + (Backend::Cpu, source.clone(), (1280, 720), true, 20), + ]; + if let Some(s4) = source_4k { + cases.insert(2, (Backend::Hardware, s4, (3840, 2160), false, 150)); + } + for (backend, source, (w, h), preview, rounds) in cases { + let Ok(gpu) = Gpu::create_backend(backend, false) else { + println!("{backend:?} indisponible, saute"); + continue; + }; + let focus = [0.92, 0.08]; + let on = compositor(&gpu, &source, r#""iso""#, true, focus, w, h); + let off = compositor(&gpu, &source, r#""iso""#, false, focus, w, h); + let cfg = cfg(); + let read = |comp: &Compositor| unsafe { + if preview { + comp.readback_direct().expect("readback").2 + } else { + comp.readback_resized(w, h).expect("readback") + } + }; + let (mut t_on, mut t_off) = (0f64, 0f64); + unsafe { + let mut player = Player::open(&source, "", &gpu).expect("ouvrir la source"); + player.present_frame(&on, &cfg, AT_SEC).expect("composer la frame"); + // Chauffe : allocation de la pyramide, caches du pilote. + for comp in [&on, &off, &on, &off] { + player.recompose(comp, &cfg).expect("recomposer"); + read(comp); + } + for i in 0..rounds { + // Ordre alterné : aucun des deux ne profite toujours d'être second. + let pair = if i % 2 == 0 { [(&on, true), (&off, false)] } else { [(&off, false), (&on, true)] }; + for (comp, is_on) in pair { + let t0 = Instant::now(); + player.recompose(comp, &cfg).expect("recomposer"); + read(comp); + let dt = t0.elapsed().as_secs_f64() * 1e3; + if is_on { t_on += dt } else { t_off += dt } + } + } + } + let (a, b) = (t_on / rounds as f64, t_off / rounds as f64); + let shape = if preview { "preview (readback_direct)" } else { "export (readback_resized)" }; + println!( + "{backend:?} {w}x{h} {shape}, iso, {rounds} tours : DoF on {a:.2} ms/frame, off {b:.2} ms/frame, surcout {:.2} ms/frame", + a - b + ); + } +} diff --git a/crates/compositor/tests/tilted_depth_slot_inert.rs b/crates/compositor/tests/tilted_depth_slot_inert.rs new file mode 100644 index 000000000..ec1cd24b2 --- /dev/null +++ b/crates/compositor/tests/tilted_depth_slot_inert.rs @@ -0,0 +1,126 @@ +//! La profondeur de champ éteinte ne change pas un octet du rendu. +//! +//! `TiltedQuad::depth_mb` remplit le `mb` du mode 8. Réglage « Depth of field » coupé, `k` vaut 0 +//! et une frame inclinée doit sortir identique, octet pour octet, à celle d'avant ce slot. Même +//! chose, réglage ALLUMÉ, pour une frame sans rotation : le mode 8 n'y est pas dessiné et la +//! pyramide n'est pas remplie. La seule façon de le montrer est de comparer à un rendu de +//! référence produit SANS le slot (la clé `depthOfField` y est ignorée) : +//! +//! ```powershell +//! # 1) sur le code d'avant (sources de src/ de la base), écrire la référence +//! $env:OPENSCREEN_PRIVACY_SECRET = "...\secret.mp4" +//! $env:OPENSCREEN_DEPTH_OUT = "...\reference" +//! cargo test -p openscreen-compositor --test tilted_depth_slot_inert -- --nocapture +//! # 2) sur le code d'après, comparer +//! $env:OPENSCREEN_DEPTH_OUT = "...\after" +//! $env:OPENSCREEN_DEPTH_REFERENCE = "...\reference" +//! cargo test -p openscreen-compositor --test tilted_depth_slot_inert -- --nocapture +//! ``` +//! +//! Même source que `privacy_blur_under_zoom.rs` (pavé rouge sur fond gris). Sans +//! `OPENSCREEN_PRIVACY_SECRET`, le test se saute. + +// Windows seulement : le readback et le décodage D3D11VA de ce harnais n'existent que là. +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::frame_geometry::live_params_from_scene; +use openscreen_compositor::live::Player; +use openscreen_compositor::scene::Scene; + +const W: u32 = 960; +const H: u32 = 540; +/// En plein palier du zoom : la région couvre 0..6 s. +const AT_SEC: f64 = 3.0; + +fn scene_json(secret: &str, fx: f32, fy: f32, rotation: &str, dof: bool) -> String { + let s = secret.replace('\\', "/"); + format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":6,"webcamOffsetSec":0,"hasAudio":false}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, + "effects": {{"padding":0.1,"blur":false,"shadow":0.35,"roundnessFrac":0.03,"motionBlur":0.0,"depthOfField":{dof}}}, + "background": {{"kind":"color","color":"#303030"}}, + "zoomRegions": [{{"id":"z","startSec":0,"endSec":6,"scale":1.6,"focusX":{fx},"focusY":{fy},"focusMode":"manual","rotation":"{rotation}"}}], + "annotations": [], + "speedRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [{{"x":0.1,"y":0.05,"width":0.85,"height":0.9}}], + "output": {{"width":{W},"height":{H},"fps":null}} + }}"## + ) +} + +fn render(gpu: &Gpu, secret: &str, fx: f32, fy: f32, rotation: &str, dof: bool) -> Vec { + 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(&scene_json(secret, fx, fy, rotation, dof)).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, AT_SEC).expect("composer la frame"); + comp.readback_resized(W, H).expect("readback") + } +} + +fn write_ppm(path: &std::path::Path, rgba: &[u8]) -> std::io::Result<()> { + let mut out = Vec::with_capacity(rgba.len() / 4 * 3 + 32); + out.extend_from_slice(format!("P6\n{W} {H}\n255\n").as_bytes()); + for px in rgba.chunks_exact(4) { + out.extend_from_slice(&px[..3]); + } + std::fs::write(path, out) +} + +#[test] +fn a_tilted_frame_is_unchanged_by_the_depth_slot() { + let Ok(secret) = std::env::var("OPENSCREEN_PRIVACY_SECRET") else { + println!("SKIP: definir OPENSCREEN_PRIVACY_SECRET (voir l'en-tete du fichier)."); + return; + }; + let out_dir = std::env::var("OPENSCREEN_DEPTH_OUT").ok().map(std::path::PathBuf::from); + let reference = std::env::var("OPENSCREEN_DEPTH_REFERENCE").ok().map(std::path::PathBuf::from); + if let Some(dir) = &out_dir { + std::fs::create_dir_all(dir).expect("creer le dossier de sortie"); + } + let gpu = Gpu::create(false).expect("device d3d11"); + + // Focus décentrés : un `z_focus` nul (focus au centre) ne prouverait rien sur le 3e champ. + // Les trois présets inclinés réglage coupé ; la frame droite réglage allumé. + let cases = [ + ("iso", 0.8, 0.3, false), + ("left", 0.2, 0.7, false), + ("right", 0.9, 0.9, false), + ("none", 0.8, 0.3, true), + ]; + let mut diffs = Vec::new(); + for (rotation, fx, fy, dof) in cases { + let rgba = render(&gpu, &secret, fx, fy, rotation, dof); + // Garde : la frame montre bien l'écran, pas un fond uni. + let red = rgba.chunks_exact(4).filter(|p| p[0] > 150 && p[1] < 80 && p[2] < 80).count(); + assert!(red > 1_000, "{rotation}: pave rouge absent ({red} px)"); + if let Some(dir) = &out_dir { + std::fs::write(dir.join(format!("{rotation}.rgba")), &rgba).expect("ecrire le brut"); + write_ppm(&dir.join(format!("{rotation}.ppm")), &rgba).expect("ecrire le ppm"); + } + match &reference { + None => println!("{rotation:<6} rendu, {red} px rouges (pas de reference)"), + Some(dir) => { + let want = std::fs::read(dir.join(format!("{rotation}.rgba"))).expect("lire la reference"); + let differing = rgba.iter().zip(&want).filter(|(a, b)| a != b).count(); + println!("{rotation:<6} {differing} octets differents de la reference"); + if want.len() != rgba.len() || differing != 0 { + diffs.push(format!("{rotation}: {differing} octets differents")); + } + } + } + } + assert!(diffs.is_empty(), "la profondeur de champ eteinte a change le rendu :\n{}", diffs.join("\n")); +} diff --git a/electron/ai-edition/style-preset-service.test.ts b/electron/ai-edition/style-preset-service.test.ts index 70f1452b7..2523377d9 100644 --- a/electron/ai-edition/style-preset-service.test.ts +++ b/electron/ai-edition/style-preset-service.test.ts @@ -13,6 +13,7 @@ const APPEARANCE: StylePresetAppearance = { shadowIntensity: 0.2, showBlur: false, motionBlurAmount: 0.2, + depthOfField: true, borderRadius: 40, padding: 50, webcamLayoutPreset: "picture-in-picture", diff --git a/electron/ipc/nativeBridge.presets.test.ts b/electron/ipc/nativeBridge.presets.test.ts index b67cb45ab..06cc85e1e 100644 --- a/electron/ipc/nativeBridge.presets.test.ts +++ b/electron/ipc/nativeBridge.presets.test.ts @@ -33,6 +33,7 @@ const APPEARANCE: StylePresetAppearance = { shadowIntensity: 0.2, showBlur: false, motionBlurAmount: 0.2, + depthOfField: true, borderRadius: 40, padding: 50, webcamLayoutPreset: "picture-in-picture", diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 539e20ddc..e16fcfb4b 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -2404,6 +2404,7 @@ export function VideoEffectsPane() { // can never disagree about what shape the footage is. Already sorted by clip count then by // pixel area, so [0] is "the shape most of this timeline is in" with no heuristic of ours. const nativeFormats = useMemo(() => (document ? collectNativeFormats(document) : []), [document]); + const hasTiltedZoom = (document?.zoomRanges ?? []).some((z) => z.rotationPreset != null); const [fitMenuOpen, setFitMenuOpen] = useState(false); const [ratioMenuOpen, setRatioMenuOpen] = useState(false); const [frameMenuOpen, setFrameMenuOpen] = useState(false); @@ -2712,11 +2713,28 @@ export function VideoEffectsPane() { onCommit={() => void commit()} />
+ {/* Next to motion blur because it is the other blur of the RECORDING. It only ever + acts on a 3D-tilted zoom, so with none in the project the switch would move + nothing on screen: it is disabled then, and the row says why. */} +
+ + {ts("effects.depthOfField")} + + {hasTiltedZoom ? ts("effects.depthOfFieldHint") : ts("effects.depthOfFieldNoTilt")} + + + void set({ depthOfField: v })} + /> +
); } -// ─── Layout (webcam) ────────────────────────────────────────────── +// ─── Layout (webcam)────────────────────────────────────────────── const WEBCAM_PRESETS = [ { value: "picture-in-picture", labelKey: "layout.pictureInPicture" }, diff --git a/src/components/ai-edition/depthOfFieldToggle.test.tsx b/src/components/ai-edition/depthOfFieldToggle.test.tsx new file mode 100644 index 000000000..c3fd590c7 --- /dev/null +++ b/src/components/ai-edition/depthOfFieldToggle.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +// The depth-of-field switch only moves something on a 3D-tilted zoom. Without one it must be +// disabled and say why; with one it must write the project setting the compositor reads. + +import "@testing-library/jest-dom"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import type { AxcutDocument } from "@/lib/ai-edition/schema"; +import { createEmptyDocument } from "@/lib/ai-edition/schema"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { VideoEffectsPane } from "./RightPanes"; + +vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })); + +function documentWithZoom(rotationPreset?: "iso"): AxcutDocument { + const base = createEmptyDocument({ title: "T", projectId: "p1" }); + return { + ...base, + zoomRanges: [ + { + id: "z1", + startMs: 0, + endMs: 2000, + depth: 3, + focus: { cx: 0.5, cy: 0.5 }, + ...(rotationPreset ? { rotationPreset } : {}), + }, + ], + } as unknown as AxcutDocument; +} + +function mount(doc: AxcutDocument) { + useProjectStore.setState({ document: doc }); + render( + + + , + ); + return screen.getByRole("button", { name: "Depth of field" }); +} + +const stored = () => + (useProjectStore.getState().document?.legacyEditor as Record | undefined) + ?.depthOfField; + +beforeEach(() => { + localStorage.clear(); + useProjectStore.setState({ document: null }); +}); +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +describe("depth of field toggle", () => { + it("is disabled and says why when no zoom is tilted", () => { + const toggle = mount(documentWithZoom()); + expect(toggle).toBeDisabled(); + expect(screen.getByText("No 3D zoom in this project")).toBeInTheDocument(); + }); + + it("is on by default with a tilted zoom, and turns off then on again", async () => { + const toggle = mount(documentWithZoom("iso")); + expect(toggle).toBeEnabled(); + expect(toggle).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText("3D zooms")).toBeInTheDocument(); + + fireEvent.click(toggle); + await waitFor(() => expect(stored()).toBe(false)); + await waitFor(() => expect(toggle).toHaveAttribute("aria-pressed", "false")); + + fireEvent.click(toggle); + await waitFor(() => expect(stored()).toBe(true)); + }); +}); diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index a19b92568..ccd0404cf 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -36,6 +36,7 @@ describe("projectPersistence media compatibility", () => { shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, + depthOfField: true, borderRadius: 0, padding: 50, cropRegion: { x: 0, y: 0, width: 1, height: 1 }, @@ -211,6 +212,7 @@ it("creates stable snapshots for identical project state", () => { shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, + depthOfField: true, borderRadius: 0, padding: 50, cropRegion: { x: 0, y: 0, width: 1, height: 1 }, diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index a2c41398f..34f9cd602 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -2,6 +2,7 @@ import { normalizeTextAnimation } from "@/lib/annotationTextAnimation"; import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects"; import { normalizeCursorThemeId } from "@/lib/cursor/cursorThemes"; import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; +import { DEFAULT_PROJECT_APPEARANCE } from "@/lib/projectDefaults"; import type { ProjectMedia } from "@/lib/recordingSession"; import { normalizeProjectMedia } from "@/lib/recordingSession"; import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper"; @@ -74,6 +75,7 @@ export interface ProjectEditorState { shadowIntensity: number; showBlur: boolean; motionBlurAmount: number; + depthOfField: boolean; borderRadius: number; padding: number; cropRegion: CropRegion; @@ -497,6 +499,10 @@ export function normalizeProjectEditor(editor: Partial): Pro ? DEFAULT_ZOOM_MOTION_BLUR : DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount : DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount, + depthOfField: + typeof editor.depthOfField === "boolean" + ? editor.depthOfField + : DEFAULT_PROJECT_APPEARANCE.depthOfField, borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 483436bc7..45985b7b3 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -114,6 +114,9 @@ "format": "التنسيق", "fitClip": "ملاءمة", "motionBlur": "ضبابية الحركة", + "depthOfField": "عمق المجال", + "depthOfFieldHint": "تكبير ثلاثي الأبعاد", + "depthOfFieldNoTilt": "لا يوجد تكبير ثلاثي الأبعاد في هذا المشروع", "roundness": "الاستدارة", "window": "نافذة", "windowNone": "بلا", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index 03ac7a991..7dd2703e4 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -114,6 +114,9 @@ "format": "Formát", "fitClip": "Přizpůsobit", "motionBlur": "Pohybové rozmazání", + "depthOfField": "Hloubka ostrosti", + "depthOfFieldHint": "3D přiblížení", + "depthOfFieldNoTilt": "Projekt nemá žádné 3D přiblížení", "roundness": "Zaoblení", "window": "Okno", "windowNone": "Žádné", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 1315d35ac..849886391 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -114,6 +114,9 @@ "format": "Format", "fitClip": "Fit", "motionBlur": "Motion Blur", + "depthOfField": "Depth of field", + "depthOfFieldHint": "3D zooms", + "depthOfFieldNoTilt": "No 3D zoom in this project", "roundness": "Roundness", "window": "Window", "windowNone": "None", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 9c15ab130..91f1aa633 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -114,6 +114,9 @@ "format": "Formato", "fitClip": "Ajustar", "motionBlur": "Desenfoque de movimiento", + "depthOfField": "Profundidad de campo", + "depthOfFieldHint": "Zooms 3D", + "depthOfFieldNoTilt": "No hay zoom 3D en este proyecto", "roundness": "Redondez", "window": "Ventana", "windowNone": "Ninguna", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index f6f657920..025796431 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -114,6 +114,9 @@ "format": "Format", "fitClip": "Ajuster", "motionBlur": "Flou de mouvement", + "depthOfField": "Profondeur de champ", + "depthOfFieldHint": "Zooms 3D", + "depthOfFieldNoTilt": "Aucun zoom 3D dans ce projet", "roundness": "Arrondi", "window": "Fenêtre", "windowNone": "Aucune", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 7820a2f1b..682f630c4 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -114,6 +114,9 @@ "format": "Formato", "fitClip": "Adatta", "motionBlur": "Sfocatura movimento", + "depthOfField": "Profondità di campo", + "depthOfFieldHint": "Zoom 3D", + "depthOfFieldNoTilt": "Nessuno zoom 3D in questo progetto", "roundness": "Arrotondamento", "window": "Finestra", "windowNone": "Nessuna", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 2d64a223d..8124b1206 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -114,6 +114,9 @@ "format": "フォーマット", "fitClip": "合わせる", "motionBlur": "モーションブラー", + "depthOfField": "被写界深度", + "depthOfFieldHint": "3D ズーム", + "depthOfFieldNoTilt": "このプロジェクトに 3D ズームはありません", "roundness": "丸み", "window": "ウィンドウ", "windowNone": "なし", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index e5cee071d..e019ac748 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -114,6 +114,9 @@ "format": "형식", "fitClip": "맞추기", "motionBlur": "모션 블러", + "depthOfField": "피사계 심도", + "depthOfFieldHint": "3D 확대", + "depthOfFieldNoTilt": "이 프로젝트에 3D 확대가 없습니다", "roundness": "모서리 둥글기", "window": "창", "windowNone": "없음", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 7ea0e67e3..01c3f945e 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -114,6 +114,9 @@ "format": "Formato", "fitClip": "Ajustar", "motionBlur": "Desfoque de Movimento", + "depthOfField": "Profundidade de campo", + "depthOfFieldHint": "Zooms 3D", + "depthOfFieldNoTilt": "Nenhum zoom 3D neste projeto", "roundness": "Arredondamento", "window": "Janela", "windowNone": "Nenhuma", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 4cef4baab..f10a40d3f 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -114,6 +114,9 @@ "format": "Формат", "fitClip": "Подогнать", "motionBlur": "Размытие движения", + "depthOfField": "Глубина резкости", + "depthOfFieldHint": "3D-зум", + "depthOfFieldNoTilt": "В проекте нет 3D-зума", "roundness": "Скругление", "window": "Окно", "windowNone": "Нет", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index f27f87e78..18a6789a9 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -114,6 +114,9 @@ "format": "Biçim", "fitClip": "Sığdır", "motionBlur": "Hareket Bulanıklığı", + "depthOfField": "Alan derinliği", + "depthOfFieldHint": "3D yakınlaştırmalar", + "depthOfFieldNoTilt": "Bu projede 3D yakınlaştırma yok", "roundness": "Yuvarlaklık", "window": "Pencere", "windowNone": "Yok", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 6351be6eb..2a88156f1 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -114,6 +114,9 @@ "format": "Định dạng", "fitClip": "Vừa khít", "motionBlur": "Làm mờ chuyển động", + "depthOfField": "Độ sâu trường ảnh", + "depthOfFieldHint": "Thu phóng 3D", + "depthOfFieldNoTilt": "Dự án chưa có thu phóng 3D", "roundness": "Độ bo tròn", "window": "Cửa sổ", "windowNone": "Không", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 23a3be1f9..35eaec6d9 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -114,6 +114,9 @@ "format": "格式", "fitClip": "适配", "motionBlur": "运动模糊", + "depthOfField": "景深", + "depthOfFieldHint": "3D 缩放", + "depthOfFieldNoTilt": "此项目没有 3D 缩放", "roundness": "圆角", "window": "窗口", "windowNone": "无", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 21a2dd14e..258340174 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -114,6 +114,9 @@ "format": "格式", "fitClip": "符合", "motionBlur": "動態模糊", + "depthOfField": "景深", + "depthOfFieldHint": "3D 縮放", + "depthOfFieldNoTilt": "此專案沒有 3D 縮放", "roundness": "圓角", "window": "視窗", "windowNone": "無", diff --git a/src/lib/ai-edition/document/migrate.test.ts b/src/lib/ai-edition/document/migrate.test.ts index 2ff944006..0a365c568 100644 --- a/src/lib/ai-edition/document/migrate.test.ts +++ b/src/lib/ai-edition/document/migrate.test.ts @@ -18,6 +18,7 @@ function makeV2Project(overrides: Partial = {}): EditorProjec shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, + depthOfField: true, borderRadius: 0, padding: 50, cropRegion: { x: 0, y: 0, width: 1, height: 1 }, @@ -278,6 +279,17 @@ describe("migrateAxcutDocumentToProjectData", () => { expect(back.editor.wallpaperMotion).toBe("aurora"); }); + it("round-trips depthOfField: on by default, an explicit off stays off", () => { + const roundTrip = (depthOfField: boolean) => + migrateAxcutDocumentToProjectData( + migrateProjectDataToAxcutDocument( + makeV2Project({ editor: { ...makeV2Project().editor, depthOfField } }), + ), + ).editor.depthOfField; + expect(roundTrip(true)).toBe(true); + expect(roundTrip(false)).toBe(false); + }); + it("round-trips zoomRegions and annotationRegions back to ms", () => { const v2 = makeV2Project({ editor: { diff --git a/src/lib/ai-edition/document/migrate.ts b/src/lib/ai-edition/document/migrate.ts index c3a2b74bc..fdd967515 100644 --- a/src/lib/ai-edition/document/migrate.ts +++ b/src/lib/ai-edition/document/migrate.ts @@ -291,6 +291,7 @@ export function migrateAxcutDocumentToProjectData(input: AxcutDocument): EditorP shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, + depthOfField: true, borderRadius: 0, padding: 50, cropRegion: { x: 0, y: 0, width: 1, height: 1 } as CropRegion, diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index 0d1fb7726..f13f6762c 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -85,6 +85,17 @@ describe("getEditorSettings", () => { const snap = getEditorSettings(doc); expect(snap.showBlur).toBe(false); }); + + it("keeps depth of field on unless the project stored a boolean off", () => { + expect(getEditorSettings(baseDoc).depthOfField).toBe(true); + const junk: AxcutDocument = { + ...baseDoc, + legacyEditor: { depthOfField: "no" as unknown as boolean }, + }; + expect(getEditorSettings(junk).depthOfField).toBe(true); + const off = patchEditorSettings(baseDoc, { depthOfField: false }); + expect(getEditorSettings(off).depthOfField).toBe(false); + }); }); describe("patchEditorSettings", () => { diff --git a/src/lib/ai-edition/store/editorSettings.ts b/src/lib/ai-edition/store/editorSettings.ts index c67f5ce97..5da9138e7 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -86,6 +86,7 @@ export interface EditorSettingsSnapshot { shadowIntensity: number; showBlur: boolean; motionBlurAmount: number; + depthOfField: boolean; borderRadius: number; padding: number; cropRegion: CropRegion; @@ -131,6 +132,7 @@ interface LegacyShape { shadowIntensity?: number; showBlur?: boolean; motionBlurAmount?: number; + depthOfField?: boolean; borderRadius?: number; padding?: number; cropRegion?: CropRegion; @@ -212,6 +214,7 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor shadowIntensity: num(legacy?.shadowIntensity, DEFAULT_EDITOR_SETTINGS.shadowIntensity), showBlur: bool(legacy?.showBlur, DEFAULT_EDITOR_SETTINGS.showBlur), motionBlurAmount: num(legacy?.motionBlurAmount, DEFAULT_EDITOR_SETTINGS.motionBlurAmount), + depthOfField: bool(legacy?.depthOfField, DEFAULT_EDITOR_SETTINGS.depthOfField), borderRadius: num(legacy?.borderRadius, DEFAULT_EDITOR_SETTINGS.borderRadius), padding: num(legacy?.padding, DEFAULT_EDITOR_SETTINGS.padding), cropRegion: legacy?.cropRegion ?? DEFAULT_EDITOR_SETTINGS.cropRegion, @@ -257,6 +260,7 @@ export interface EditorSettingsPatch { shadowIntensity?: number; showBlur?: boolean; motionBlurAmount?: number; + depthOfField?: boolean; borderRadius?: number; padding?: number; cropRegion?: CropRegion; diff --git a/src/lib/ai-edition/stylePresets.test.ts b/src/lib/ai-edition/stylePresets.test.ts index 12c9bdc93..593110077 100644 --- a/src/lib/ai-edition/stylePresets.test.ts +++ b/src/lib/ai-edition/stylePresets.test.ts @@ -21,6 +21,7 @@ function appearance(overrides: Partial = {}): StylePreset shadowIntensity: 0.2, showBlur: false, motionBlurAmount: 0.2, + depthOfField: true, borderRadius: 40, padding: 50, webcamLayoutPreset: "picture-in-picture", @@ -67,6 +68,17 @@ describe("parseStylePresetAppearance", () => { ).toThrow(/wallpaperMotion/); }); + it("keeps depth of field on for a preset saved before the setting existed", () => { + const { depthOfField: _dof, ...older } = appearance({ depthOfField: false }); + expect(parseStylePresetAppearance(older).depthOfField).toBe(true); + expect(parseStylePresetAppearance(appearance({ depthOfField: false })).depthOfField).toBe( + false, + ); + expect(() => parseStylePresetAppearance({ ...appearance(), depthOfField: "on" })).toThrow( + /depthOfField/, + ); + }); + it("rejects out-of-range numbers, wrong types and unknown enum values", () => { expect(() => parseStylePresetAppearance(appearance({ padding: 101 }))).toThrow(TypeError); expect(() => parseStylePresetAppearance(appearance({ borderRadius: -1 }))).toThrow( diff --git a/src/lib/ai-edition/stylePresets.ts b/src/lib/ai-edition/stylePresets.ts index 1dcccc8e8..e4d828e04 100644 --- a/src/lib/ai-edition/stylePresets.ts +++ b/src/lib/ai-edition/stylePresets.ts @@ -42,6 +42,7 @@ export interface StylePresetAppearance { shadowIntensity: number; showBlur: boolean; motionBlurAmount: number; + depthOfField: boolean; borderRadius: number; padding: number; webcamLayoutPreset: WebcamLayoutPreset; @@ -228,12 +229,12 @@ export function parseStylePresetWallpaper(value: unknown, key = "wallpaper"): st * guessing a factory value for it would apply something the author never chose. The one * lenient field is `cursorTheme`: themes come and go between builds, so an id this build * does not ship falls back to the default cursor instead of rejecting a preset that is - * otherwise sound (the editor does the same when it renders one). `cursor.volume` may be - * absent: it postdates format version 1, and a preset without it was authored flat. - * `wallpaperMotion` may be absent too: a preset saved before it has a still wallpaper, which - * is exactly what "none" means. - * `frame` may be absent as well: a preset saved before it had no frame (see `readFrame`). - * Unknown extra keys are dropped. + * otherwise sound (the editor does the same when it renders one). The others postdate the + * first version-1 files, so a preset saved before one of them existed carries no choice about + * it and gets the value that means "unchanged": `cursor.volume` may be absent (the preset was + * authored flat), `wallpaperMotion` too (a still wallpaper, which is exactly what "none" + * means), `frame` as well (no frame, see `readFrame`), and `depthOfField` keeps the factory + * value (on). A present but ill-typed value is still refused. Unknown extra keys are dropped. */ export function parseStylePresetAppearance(value: unknown): StylePresetAppearance { if (!isRecord(value)) { @@ -260,6 +261,7 @@ export function parseStylePresetAppearance(value: unknown): StylePresetAppearanc shadowIntensity: readNumber(value, "shadowIntensity", NUMBER_RANGES.shadowIntensity), showBlur: readBoolean(value, "showBlur"), motionBlurAmount: readNumber(value, "motionBlurAmount", NUMBER_RANGES.motionBlurAmount), + depthOfField: value.depthOfField === undefined ? true : readBoolean(value, "depthOfField"), borderRadius: readNumber(value, "borderRadius", NUMBER_RANGES.borderRadius), padding: readNumber(value, "padding", NUMBER_RANGES.padding), webcamLayoutPreset: readEnum(value, "webcamLayoutPreset", WEBCAM_LAYOUT_PRESETS), diff --git a/src/lib/ai-edition/stylePresetsEditor.ts b/src/lib/ai-edition/stylePresetsEditor.ts index c4101138b..177afcd85 100644 --- a/src/lib/ai-edition/stylePresetsEditor.ts +++ b/src/lib/ai-edition/stylePresetsEditor.ts @@ -21,6 +21,7 @@ export function stylePresetAppearanceFromSettings( shadowIntensity: settings.shadowIntensity, showBlur: settings.showBlur, motionBlurAmount: settings.motionBlurAmount, + depthOfField: settings.depthOfField, borderRadius: settings.borderRadius, padding: settings.padding, webcamLayoutPreset: settings.webcamLayoutPreset, @@ -66,6 +67,7 @@ export function stylePresetPatch(appearance: StylePresetAppearance): EditorSetti shadowIntensity: appearance.shadowIntensity, showBlur: appearance.showBlur, motionBlurAmount: appearance.motionBlurAmount, + depthOfField: appearance.depthOfField, borderRadius: appearance.borderRadius, padding: appearance.padding, webcamLayoutPreset: appearance.webcamLayoutPreset, diff --git a/src/lib/projectDefaults.ts b/src/lib/projectDefaults.ts index 28c722abe..2173f12a9 100644 --- a/src/lib/projectDefaults.ts +++ b/src/lib/projectDefaults.ts @@ -22,6 +22,8 @@ export interface ProjectAppearanceDefaults { shadowIntensity: number; showBlur: boolean; motionBlurAmount: number; + /** Defocus a 3D-tilted screen by its depth; inert on flat zooms. */ + depthOfField: boolean; borderRadius: number; padding: number; webcamLayoutPreset: "picture-in-picture" | "vertical-stack" | "dual-frame" | "no-webcam"; @@ -57,6 +59,8 @@ export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = { shadowIntensity: 0.2, showBlur: false, motionBlurAmount: 0.2, + // On: it only acts on tilted zooms, where the blur already scales with the real angle. + depthOfField: true, borderRadius: 40, padding: 50, webcamLayoutPreset: "picture-in-picture", diff --git a/src/native/browserShim.presets.test.ts b/src/native/browserShim.presets.test.ts index 5430f56f2..1f725d00d 100644 --- a/src/native/browserShim.presets.test.ts +++ b/src/native/browserShim.presets.test.ts @@ -17,6 +17,7 @@ const APPEARANCE: StylePresetAppearance = { shadowIntensity: 0.2, showBlur: false, motionBlurAmount: 0.2, + depthOfField: true, borderRadius: 40, padding: 50, webcamLayoutPreset: "picture-in-picture", diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index c1bb0e5d2..93daea29c 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -1079,6 +1079,12 @@ describe("buildSceneDescription.settings mapping", () => { expect(buildSceneDescription(tooDeep).cursor.volume).toBe(1); }); + it("carries depth of field: on by default, off when the project turns it off", () => { + expect(buildSceneDescription(makeDoc({})).effects.depthOfField).toBe(true); + const off = makeDoc({ legacyEditor: { depthOfField: false } }); + expect(buildSceneDescription(off).effects.depthOfField).toBe(false); + }); + it("maps the cursor sub-settings", () => { const doc = makeDoc({ legacyEditor: { diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index d3c386b68..7c5325dc7 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -377,6 +377,11 @@ export interface SceneEffects { * serializes exactly as it did before the field existed. */ frame?: Exclude; + /** + * Defocus a 3D-tilted screen by its depth, sharp at the zoom focus. Inert on flat zooms: + * the native side only reads it where it draws a tilted plane. + */ + depthOfField: boolean; } /** Cursor rendering, from the editor settings. */ @@ -1063,6 +1068,7 @@ export function buildSceneDescription( settings.borderRadius / Math.max(1, Math.min(outputDims.width, outputDims.height)), motionBlur: settings.motionBlurAmount, ...(settings.frame !== "none" ? { frame: settings.frame } : {}), + depthOfField: settings.depthOfField, }, cursor: { show: settings.cursorShow, diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 38b4b312f..b942d924e 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -628,3 +628,4 @@ The mask comes from the native compositor (ONNX Runtime + the vendored selfie-se | 2026-09-15 | dev build (`npm run dev`), worktree `e2e-macos-pr-652-b93321` on `pr/509-hud-session-prefs`: main pass on `8fa0740a` (PR #652 head before the scope narrowing), rescope smoke on `ac36aab4` | macOS 26.5, M1 | Pass — 1 defect found, fixed on the branch before merge | **Settings-persistence slice for PR #652, driven with real OS mouse/keyboard.** Capture prefs persist across relaunches from both the HUD and the Rec stage — display source, system audio, mic toggle, cursor mode, auto-zoom — and `recording-settings.json` matched the UI after every restart (final smoke state: mic on, system audio on, `editable-overlay` cursor, autoZoom on, `screen:1:0`). A window source is restored after restart; when that window no longer exists, refocusing the HUD falls back cleanly to "Screen". Re-enumeration on HUD focus: no flicker, no permission prompt, no errors. New-project appearance defaults applied without touching existing projects (on `8fa0740a`, before the narrowing dropped them). **Defect: the HUD stop button did nothing** — select source → record → stop pill left the take recording with no log line. Root cause: in `startWhenDevicesReady`, when every device is ready on the first check the async body never reaches an `await`, so its `finally` clears `startWhenDevicesReadyInFlight` before `startWhenDevicesReadyInFlight.current = pending` runs; the ref keeps a settled promise forever and every later stop (or countdown cancel) returns it. The existing tests all make the gate wait once, so none caught it. Reported on PR #652; fixed on the branch in `e159ced0` (merged to main as `1df32fed`). **The fix was not re-verified in a dedicated run** — the post-narrowing smoke on `ac36aab4` ("drop the Settings dialog") confirmed prefs still persist and an existing project still opens, nothing more. Not covered: explicit mic/camera device restore and preview stop on reset (host has no camera, mic is default-only), the full capture-to-export flow, export, transcript/AI sections, Windows/Linux. | | 2026-09-15 | dev build (`npm run dev`), worktree `editor-presets` on `feat/editor-presets` (PR #675) | macOS 26.5, M1 | Pass — no defect | **Style-presets header slice, driven with real OS mouse input.** Confirmed the Presets trigger is in the editor header between the Media/Edit/Rec switcher and theme control, and is absent from the Composition pane header. Opened the menu from the new location and confirmed the built-in `OpenScreen` preset plus the empty user-preset state. Created `E2E Header Smoke`; the success toast appeared and the resulting `.openscreenpreset` file contained format/version metadata and appearance-only state. Applied the built-in preset and observed `Applied “OpenScreen”`; renamed the user preset to `E2E Header Renamed` and verified the file rename on disk; updated it from the current project and observed `Preset updated`; deleted it through the confirmation UI and verified the menu returned to `No saved presets yet` and the preset directory contained no files. Not covered: capture, media import, preview/export, transcript/AI sections, Windows/Linux, or preset portability between machines. | | 2026-09-16 | worktree `trim-modifiers-visibility-771405` on `fix/privacy-blur-follows-zoom` @ `cd927180`; `compositor_view.node` rebuilt from this tree by `npm run build:native:compositor` (2026-09-16 10:26, contains the new `privacy_mask` shader comment), ffmpeg DLLs hash-identical to the vendored SDK; renderer rebuilt with `npm run build-vite` | Windows 11 26200, NVENC export | Partial — no defect | **Privacy blur under zoom and 3D tilt, through the app's real export (`electron . export -o `).** Source: synthetic 1920×1080 clip with a red box at 55–70 % on both axes. Eight legacy projects (rest, 2.2× zoom centred, 2.2× zoom at 0.75/0.75, 2.2× zoom + iso), each exported without and with a white mosaic blur annotation over the box; frame at 3 s. Red pixels still visible: rest 0 (clear 42 777), centred 0 (207 933), off-centre 0 (207 932), iso 0 (140 398); the masked iso frame shows the mask following the tilted box. Before the fix the same geometry leaked 89–100 % of the box (D3D11 pixel test `privacy_blur_under_zoom.rs`). **Not covered:** the GUI editor preview (the HUD rendered as a black rectangle in computer-use screenshots, so the pass went through the CLI export, which shares `compose_frame` with the preview), oval and Gaussian styles, motion-blurred zoom ramps (unit-tested only), macOS and Linux rendering (Linux lib tests pass under WSL/lavapipe; Metal only compiles on macOS CI). | +| 2026-09-16 | worktree `wf_62afabe4-4fb-7` on `feat/3d-depth-of-field` @ `7587de8e` + the macOS golden and bench commits; `compositor_view.node` rebuilt from this tree by `npm run build:native:compositor` (string probe `DOF_MAX_LOD` HIT, control `OPENSCREEN_EXPORT_ENCODER` HIT), ffmpeg DLLs hash-identical to the vendored SDK; renderer rebuilt with `npm run build-vite` | Windows 11 26200, NVENC export | Partial — no defect | **Depth of field on a tilted zoom, through the app's real export (`electron . export -o `).** Source: synthetic 1920×1080 text-and-grid clip. Three legacy projects: `iso` zoom with focus at 0.92/0.08 and no `depthOfField` key (the default must apply), the same with `depthOfField: false`, and a flat zoom. Frame at 3 s: with the default the far (bottom-left) half of the tilted screen is visibly defocused and the near half stays sharp; with `false` the whole plane is sharp; the flat project renders a flat, sharp screen. Export wall time was the same with and without the effect (2.22–2.36 s over two warm runs each). **Not covered:** the GUI editor. Computer-use access to `electron.exe` was declined for this run, so the toggle on, the toggle off and the disabled state on a project without a tilt were checked only by the jsdom test `depthOfFieldToggle.test.tsx`. The preview cost was measured by the A/B harness instead (`tilted_depth_of_field.rs`, preview shape at 1280×720 + `readback_direct`: +0.03 ms/frame on hardware, +2.5 ms/frame on WARP). macOS and Linux rendering were not run here (the macOS golden runs on macOS CI; the Linux golden is opt-in under WSL). | From 29c83f59f348dfc73be73397ac6c7574085ee6fe Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 17 Sep 2026 19:53:36 +0200 Subject: [PATCH 10/11] feat(3d): add the follow-cursor orbit camera and a modelled 3D cursor Real look-at camera orbiting the still screen, projective warp, and every default cursor state extruded from its sprite with shadow, contact and impact. Formerly #695. --- crates/compositor/src/camera.rs | 685 ++++++ crates/compositor/src/compositor_linux.rs | 732 +++++- crates/compositor/src/compositor_macos.rs | 579 ++++- crates/compositor/src/compositor_windows.rs | 192 +- crates/compositor/src/cursor.rs | 121 +- crates/compositor/src/cursor_sdf.rs | 413 ++++ crates/compositor/src/frame_geometry.rs | 1959 +++++++++++++---- crates/compositor/src/lib.rs | 2 + crates/compositor/src/live.rs | 10 +- crates/compositor/src/regions.rs | 515 ++++- crates/compositor/src/scene.rs | 12 +- crates/compositor/src/shaders.hlsl | 441 +++- crates/compositor/src/shaders.metal | 406 +++- crates/compositor/src/vk_shaders/layer.wgsl | 349 ++- crates/compositor/tests/compose_linux.rs | 70 - .../compositor/tests/cursor_model_render.rs | 824 +++++++ crates/compositor/tests/cursor_tap_render.rs | 423 ++++ .../compositor/tests/cursor_volume_render.rs | 158 -- .../compositor/tests/follow_camera_render.rs | 485 ++++ docs/3d-effects-v2.md | 424 ++++ .../ai-edition/style-preset-service.test.ts | 2 +- electron/ipc/nativeBridge.presets.test.ts | 2 +- .../ai-edition/RightPanes.i18n.test.tsx | 1 + src/components/ai-edition/RightPanes.tsx | 47 +- .../ai-edition/v4/FloatingInspector.test.tsx | 78 +- .../ai-edition/v4/FloatingInspector.tsx | 90 +- src/components/video-editor/editorDefaults.ts | 4 +- .../video-editor/projectPersistence.ts | 10 +- src/components/video-editor/types.ts | 48 +- src/i18n/locales/ar/settings.json | 33 +- src/i18n/locales/cs/settings.json | 33 +- src/i18n/locales/en/settings.json | 33 +- src/i18n/locales/es/settings.json | 33 +- src/i18n/locales/fr/settings.json | 33 +- src/i18n/locales/it/settings.json | 33 +- src/i18n/locales/ja-JP/settings.json | 33 +- src/i18n/locales/ko-KR/settings.json | 33 +- src/i18n/locales/pt-BR/settings.json | 33 +- src/i18n/locales/ru/settings.json | 33 +- src/i18n/locales/tr/settings.json | 33 +- src/i18n/locales/vi/settings.json | 33 +- src/i18n/locales/zh-CN/settings.json | 33 +- src/i18n/locales/zh-TW/settings.json | 33 +- src/lib/ai-edition/schema/index.ts | 10 +- .../ai-edition/store/editorSettings.test.ts | 12 +- src/lib/ai-edition/store/editorSettings.ts | 9 +- src/lib/ai-edition/store/useTimeline.test.ts | 14 + src/lib/ai-edition/store/useTimeline.ts | 8 +- src/lib/ai-edition/stylePresets.test.ts | 28 +- src/lib/ai-edition/stylePresets.ts | 14 +- src/lib/ai-edition/stylePresetsEditor.test.ts | 2 +- src/lib/ai-edition/stylePresetsEditor.ts | 2 +- src/lib/projectDefaults.ts | 4 +- src/native/browserShim.presets.test.ts | 2 +- src/native/nativeCompositorStore.test.ts | 15 +- src/native/nativeCompositorStore.ts | 2 +- src/native/sceneDescription.test.ts | 10 +- src/native/sceneDescription.ts | 15 +- .../testing/manual-e2e-checklist.md | 4 +- 59 files changed, 8346 insertions(+), 1349 deletions(-) create mode 100644 crates/compositor/src/camera.rs create mode 100644 crates/compositor/src/cursor_sdf.rs create mode 100644 crates/compositor/tests/cursor_model_render.rs create mode 100644 crates/compositor/tests/cursor_tap_render.rs delete mode 100644 crates/compositor/tests/cursor_volume_render.rs create mode 100644 crates/compositor/tests/follow_camera_render.rs create mode 100644 docs/3d-effects-v2.md diff --git a/crates/compositor/src/camera.rs b/crates/compositor/src/camera.rs new file mode 100644 index 000000000..c2a14c989 --- /dev/null +++ b/crates/compositor/src/camera.rs @@ -0,0 +1,685 @@ +//! Caméra 3D réelle, en orbite, pour `follow-cursor`. +//! +//! L'ÉCRAN NE BOUGE PAS. L'œil tourne autour de lui sur une sphère : l'azimut suit la position +//! horizontale du pointeur (à droite, on voit l'écran par la droite et son côté droit vient vers +//! nous), l'élévation sa position verticale (en haut, la caméra monte et plonge sur le haut de +//! l'écran), autour d'une petite élévation de repos. L'œil regarde toujours son point visé, haut du +//! monde fixe (le `lookAt` classique) : le roulis est donc nul PAR CONSTRUCTION. L'axe horizontal +//! de l'image reste horizontal dans le monde, et une verticale qui passe par le point visé reste +//! verticale à l'image. Ce qui penche encore, c'est la perspective, et elle seule. +//! +//! Pourquoi une orbite et pas un pivot sur place : depuis un œil fixe, tourner la caméra n'est +//! presque qu'un pan à plat, l'angle sous lequel on voit l'écran change à peine. L'orientation ne +//! se lit que si l'œil se DÉPLACE autour de l'écran. +//! +//! Repère du monde : celui de l'écran, en px de sa boîte (`s_dst`, zoom compris), origine au +//! centre de l'écran, x à droite, y vers le bas, z vers l'œil ; l'écran est le plan z = 0. +//! +//! Le rendu passe par `TiltedQuad`, que les modes 8, 10, 12, 13, 14 et 15 savent déjà dessiner : la +//! rotation de la caméra y est une rotation X (tangage) puis Y (lacet) du plan, exactement celle de +//! `regions::rotate_point`, plus une translation de l'image du centre de l'écran (`offset`) et un +//! warp projectif (`projective`). Cette convention décrit n'importe quel œil sans roulis : l'orbite +//! n'ajoute rien aux shaders. + +use crate::regions::{CameraFrame, TiltedQuad}; + +/// Distance de l'œil au point visé, en `min(w, h)` de la boîte au zoom 1 : l'objectif des angles +/// fixes (`regions::PERSPECTIVE_FACTOR`), ~35° de champ sur le petit côté. Assez court pour que la +/// perspective se lise : c'est elle qui montre l'orientation. +pub const DISTANCE: f32 = crate::regions::PERSPECTIVE_FACTOR; +/// Azimut (degrés) quand le pointeur touche le bord droit de l'image (+), ou gauche (−). +pub const AZIMUTH_DEG: f32 = 22.0; +/// Élévation (degrés) : au repos, puis le débattement, pointeur au bord haut (+) ou bas (−). Le +/// repos regarde un peu d'en haut : les bords verticaux convergent, l'écran ne se tient jamais +/// parfaitement droit au milieu de son orbite. +pub const ELEVATION_DEG: [f32; 2] = [4.0, 12.0]; +/// Part du zoom prise en travelling plutôt qu'en focale : la distance est divisée par +/// `zoom^DOLLY`. Un zoom purement optique (0) aplatit d'autant la perspective de la vue ; en +/// s'approchant, la caméra garde assez de fuite pour que l'orbite se lise encore au zoom. Plus +/// (0,75), la vue zoomée tourne au grand-angle et tout le texte penche. +const DOLLY: f32 = 0.5; +/// Zoom à partir duquel le cadrage « écran entier » (containment de l'enveloppe, centrage) a +/// cédé la place au cadrage « point visé au centre ». Entre 1 et lui, fondu linéaire. +const FULL_VIEW_ZOOM: f32 = 2.0; +/// Recul de l'œil au contact d'un clic, en fraction de sa distance (impact du clic). +pub const PRESS: f32 = 0.04; +/// Marge du containment : l'enveloppe n'est échantillonnée qu'en 3×3, et le centrage déplace un +/// peu le pire cas entre ces points (mesuré : 0,3 % au plus). +const FIT_MARGIN: f32 = 0.995; + +/// Ce que la caméra reçoit d'une frame. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CameraPose { + /// Poids de la caméra (0..1) : la force de la région de zoom. À 0, la caméra regarde l'écran + /// de face et la projection est l'identité. + pub weight: f32, + /// Point visé dans l'écran (0..1 depuis son coin haut-gauche), déjà pondéré. + pub aim: [f32; 2], + /// Le pointeur lissé dans l'écran (0..1), déjà pondéré : il place l'œil sur l'orbite. + pub orbit: [f32; 2], + /// Zoom courant (≥ 1) : l'œil s'approche de `zoom^DOLLY`, la focale fait le reste. + pub zoom: f32, + /// Impact des clics (somme de `regions::tap`, −1 au contact), déjà passé par ses portes : + /// l'œil recule de `PRESS` au contact. 0 sans impact. + pub press: f32, +} + +impl CameraPose { + /// La caméra au repos, pointeur au centre : ce que rend une région sans piste. + pub const REST: CameraPose = + CameraPose { weight: 1.0, aim: [0.5; 2], orbit: [0.5; 2], zoom: 1.0, press: 0.0 }; +} + +/// Une caméra posée : œil, orientation et focale, dans le repère du monde. +#[derive(Clone, Copy, Debug)] +pub struct View { + eye: [f32; 3], + /// Lacet et tangage (rad) : positif = vers la droite, vers le haut. + yaw: f32, + pitch: f32, + /// Focale en px de sortie. + focal: f32, +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +/// Direction de visée pour un lacet et un tangage. +fn forward(yaw: f32, pitch: f32) -> [f32; 3] { + [yaw.sin() * pitch.cos(), -pitch.sin(), -yaw.cos() * pitch.cos()] +} + +/// Azimut et élévation (rad) d'un pointeur en `orbit` (0..1, déjà pondéré), au poids `w`. +fn orbit_angles(orbit: [f32; 2], w: f32) -> [f32; 2] { + let [rest, swing] = ELEVATION_DEG; + [ + (AZIMUTH_DEG * (2.0 * orbit[0] - 1.0)).to_radians(), + (rest * w + swing * (1.0 - 2.0 * orbit[1])).to_radians(), + ] +} + +/// L'œil à `dist` du point `target` de l'écran, dans la direction (azimut, élévation), qui le +/// regarde. +fn orbit_view(target: [f32; 2], dist: f32, [az, el]: [f32; 2], focal: f32) -> View { + let eye = [ + target[0] + dist * az.sin() * el.cos(), + target[1] - dist * el.sin(), + dist * az.cos() * el.cos(), + ]; + View { eye, yaw: -az, pitch: -el, focal } +} + +/// Boîte englobante `[x0, x1, y0, y1]` de l'écran `unit` (petit côté 1) vu à `DISTANCE` de +/// `target`, focale `DISTANCE` : en unités du petit côté, relative au point principal. +fn unit_bbox(unit: [f32; 2], angles: [f32; 2], target: [f32; 2]) -> [f32; 4] { + let v = orbit_view(target, DISTANCE, angles, DISTANCE); + let mut b = [f32::MAX, f32::MIN, f32::MAX, f32::MIN]; + for [x, y] in corners(unit[0], unit[1]) { + let [px, py] = v.project([x, y, 0.0]).unwrap_or([x, y]); + b = [b[0].min(px), b[1].max(px), b[2].min(py), b[3].max(py)]; + } + b +} + +/// Le point à viser pour que l'écran vu sous `angles` soit CENTRÉ dans sa boîte, en unités du +/// petit côté. Vu de biais, le côté proche grandit et le lointain rétrécit : viser le centre de +/// l'écran le décentrerait, et le containment paierait ce décalage (0,77 au lieu de 0,83 en +/// 16:9). Quatre passes : 0,2 px d'écart à 1080p. +fn centring(unit: [f32; 2], angles: [f32; 2]) -> [f32; 2] { + let mut l = [0.0f32; 2]; + for _ in 0..4 { + let b = unit_bbox(unit, angles, l); + l = [l[0] + 0.5 * (b[0] + b[1]), l[1] + 0.5 * (b[2] + b[3])]; + } + l +} + +/// Le containment d'une région : le facteur qui fait tenir l'écran centré dans sa boîte depuis +/// TOUTE l'enveloppe de l'orbite au poids `w`. Il ne dépend ni du pointeur ni du temps, donc +/// l'écran ne respire pas pendant que la caméra tourne. +fn envelope_fit(unit: [f32; 2], w: f32) -> f32 { + let mut fit = 1.0f32; + for ox in [0.0, 0.5, 1.0] { + for oy in [0.0, 0.5, 1.0] { + let orbit = [0.5 + (ox - 0.5) * w, 0.5 + (oy - 0.5) * w]; + let angles = orbit_angles(orbit, w); + let b = unit_bbox(unit, angles, centring(unit, angles)); + let (mx, my) = (b[0].abs().max(b[1].abs()), b[2].abs().max(b[3].abs())); + fit = fit.min(0.5 * unit[0] / mx).min(0.5 * unit[1] / my); + } + } + if fit < 1.0 { fit * FIT_MARGIN } else { 1.0 } +} + +impl View { + /// La caméra qui filme une boîte écran de `box_px` px (zoom compris) sous `pose`. + /// + /// - L'œil est sur l'orbite de `pose.orbit`, à `DISTANCE × min(boîte) × zoom^(−DOLLY)` du point + /// visé : la vue se resserre moitié par travelling, moitié par focale. + /// - Au zoom 1, le point visé est décalé pour centrer l'écran (`centring`) et la focale réduite + /// pour qu'il tienne dans sa boîte depuis toute l'orbite (`envelope_fit`). Dès le zoom + /// `FULL_VIEW_ZOOM`, le point visé tombe au centre de l'image, grossi du zoom. + pub fn new(box_px: [f32; 2], pose: CameraPose) -> View { + let w = pose.weight.clamp(0.0, 1.0); + let [bw, bh] = box_px; + let m = bw.min(bh).max(1e-3); + let zoom = pose.zoom.max(1.0); + let unit = [bw / m, bh / m]; + let fade = ((zoom - 1.0) / (FULL_VIEW_ZOOM - 1.0)).clamp(0.0, 1.0); + let fit = envelope_fit(unit, w); + let fit = fit + (1.0 - fit) * fade; + let angles = orbit_angles(pose.orbit, w); + let shift = centring(unit, angles).map(|c| c * m * (1.0 - fade)); + let dist = DISTANCE * m * zoom.powf(-DOLLY); + let target = [(pose.aim[0] - 0.5) * bw + shift[0], (pose.aim[1] - 0.5) * bh + shift[1]]; + orbit_view(target, dist * (1.0 - PRESS * pose.press * w), angles, dist * fit) + } + + /// Axes de la caméra dans le monde : droite, bas, visée. + fn basis(&self) -> ([f32; 3], [f32; 3], [f32; 3]) { + let (sy, cy, sp, cp) = (self.yaw.sin(), self.yaw.cos(), self.pitch.sin(), self.pitch.cos()); + ([cy, 0.0, sy], [sy * sp, cp, -cy * sp], forward(self.yaw, self.pitch)) + } + + /// Un point du monde en px relatifs au point principal (le centre de la boîte) ; `None` + /// derrière la caméra. + pub fn project(&self, p: [f32; 3]) -> Option<[f32; 2]> { + let (r, u, f) = self.basis(); + let q = sub(p, self.eye); + let z = dot(f, q); + (z > 1e-3).then(|| [self.focal * dot(r, q) / z, self.focal * dot(u, q) / z]) + } + + /// Azimut et élévation de l'œil, en degrés. + pub fn angles_deg(&self) -> [f32; 2] { + [-self.yaw.to_degrees(), -self.pitch.to_degrees()] + } + + /// L'écran `box_px` vu par cette caméra, dans la convention de `TiltedQuad`. + /// + /// Un point `p` du plan, en px du plan (le monde multiplié par `scale`), vaut + /// `w = R·p + (offset, 0)` dans le repère de la caméra et se projette en `P·w.xy / (P − w.z)`, + /// avec `R = rotate_point(·, rot)` et `P = focal` : c'est la projection de `project`, le monde + /// mis à l'échelle autour de l'œil pour que le centre de l'écran soit à la profondeur `P`. + pub fn quad(&self, box_px: [f32; 2]) -> TiltedQuad { + let (_, _, f) = self.basis(); + let project = |p: [f32; 3]| self.project(p).unwrap_or([p[0], p[1]]); + let c = corners(box_px[0], box_px[1]).map(|[x, y]| { + let [px, py] = project([x, y, 0.0]); + (px, py) + }); + let rot = [self.pitch.to_degrees(), self.yaw.to_degrees(), 0.0]; + TiltedQuad { + corners: c, + scale: self.focal / -dot(f, self.eye), + depth_k: crate::regions::depth_coefficients(rot), + rot, + perspective: self.focal, + offset: project([0.0; 3]), + projective: true, + } + } +} + +/// Coins TL, TR, BR, BL d'une boîte centrée. +fn corners(bw: f32, bh: f32) -> [[f32; 2]; 4] { + let (hw, hh) = (bw * 0.5, bh * 0.5); + [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]] +} + +// ---- Le cadreur ---------------------------------------------------------------------------- + +/// Pulsation du lissage (rad/s) : une réponse critique, 95 % d'un saut en 0,95 s, sans +/// dépassement. +const FOLLOW_OMEGA: f32 = 5.0; +/// Anticipation (s) : un enregistrement se monte après coup, la caméra part avant le geste. Le +/// retard moyen du lissage est `2/ω` = 0,4 s ; il en reste 0,15. +const FOLLOW_LOOKAHEAD_S: f32 = 0.25; +/// Pas et longueur du noyau : ω·τ va jusqu'à 10, la queue coupée pèse 5·10⁻⁴. +const FOLLOW_STEP_S: f32 = 1.0 / 60.0; +const FOLLOW_TAPS: usize = 120; +/// La vue comptée plus large que `1/zoom` de l'écran, pour la portée du point visé. +const VIEW_MARGIN: f32 = 1.1; + +/// Ce que le cadreur rend pour une frame, en fractions de l'écran recadré : le point visé et le +/// pointeur lissé. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Follow { + pub aim: [f32; 2], + pub orbit: [f32; 2], +} + +impl Follow { + pub const CENTRE: Follow = Follow { aim: [0.5; 2], orbit: [0.5; 2] }; +} + +/// Où `follow-cursor` vise à `t` et où il place l'œil, pour une région de zoom `zoom`. Sans +/// piste : le centre, caméra au repos. +/// +/// Le pointeur (lu dans l'image source recadrée, borné à l'écran et à la fenêtre du clip) passe +/// par la réponse impulsionnelle d'un ressort critique, `h(τ) = ω²·τ·e^(−ωτ)`, avancée de +/// `FOLLOW_LOOKAHEAD_S` : une convolution sur 2 s de piste. C'est une pure fonction de `t`, sans +/// mémoire d'une frame à l'autre, au coût borné (120 lectures) quelle que soit la longueur de la +/// région. Pas de zone morte : la caméra vit avec le pointeur, le lissage la garde calme. +/// +/// - `orbit` : le pointeur lissé, sur tout l'écran. +/// - `aim` : le même, borné avant lissage à `0,5 ± (0,5 − 0,5·VIEW_MARGIN/zoom)`, là où la vue +/// reste dans l'écran ; au zoom 1, le centre. +pub fn follow(frame: &CameraFrame, t: f32, zoom: f32) -> Follow { + let Some(track) = frame.track else { return Follow::CENTRE }; + let reach = (0.5 - 0.5 * VIEW_MARGIN / zoom.max(1.0)).max(0.0); + let [x0, y0, x1, y1] = frame.crop; + let size = [(x1 - x0).max(1e-6), (y1 - y0).max(1e-6)]; + let (mut aim, mut orbit, mut total) = ([0.0f32; 2], [0.0f32; 2], 0.0f32); + for k in 1..=FOLLOW_TAPS { + let tau = k as f32 * FOLLOW_STEP_S; + let weight = tau * (-FOLLOW_OMEGA * tau).exp(); + let at = (t + FOLLOW_LOOKAHEAD_S - tau).max(frame.window[0]).min(frame.window[1]); + let Some((px, py)) = track.at(at) else { return Follow::CENTRE }; + let p = [((px - x0) / size[0]).clamp(0.0, 1.0), ((py - y0) / size[1]).clamp(0.0, 1.0)]; + for i in 0..2 { + orbit[i] += weight * p[i]; + aim[i] += weight * p[i].clamp(0.5 - reach, 0.5 + reach); + } + total += weight; + } + Follow { aim: aim.map(|a| a / total), orbit: orbit.map(|o| o / total) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cursor::CursorTrack; + + const BOX: [f32; 2] = [1536.0, 864.0]; + + fn pose(orbit: [f32; 2], zoom: f32) -> CameraPose { + let reach = (0.5 - 0.5 * VIEW_MARGIN / zoom).max(0.0); + let aim = orbit.map(|o| o.clamp(0.5 - reach, 0.5 + reach)); + CameraPose { weight: 1.0, aim, orbit, zoom, press: 0.0 } + } + + fn view(orbit: [f32; 2], zoom: f32) -> (View, [f32; 2]) { + let bx = BOX.map(|b| b * zoom); + (View::new(bx, pose(orbit, zoom)), bx) + } + + /// L'enveloppe du chemin : pointeur sur une grille de l'écran, à chaque zoom de l'app et au + /// zoom 1, pendant l'entrée aussi (le zoom et le poids montent ensemble, comme dans + /// `zoom_state_in`), et au creux d'un clic. + fn envelope() -> Vec<(f32, f32, [f32; 2], View, [f32; 2])> { + let mut out = Vec::new(); + for zoom in [1.0f32, 1.25, 1.5, 1.8, 2.2, 3.5, 5.0] { + let reach = (0.5 - 0.5 * VIEW_MARGIN / zoom).max(0.0); + for k in [2, 5, 8, 10] { + let weight = k as f32 / 10.0; + let z = 1.0 + (zoom - 1.0) * weight; + let bx = BOX.map(|b| b * z); + for i in 0..=4 { + for j in 0..=4 { + let o = [i as f32 / 4.0, j as f32 / 4.0]; + let aim = o.map(|c| c.clamp(0.5 - reach, 0.5 + reach)); + for press in [0.0, -1.0] { + let p = CameraPose { + weight, + aim: aim.map(|a| 0.5 + (a - 0.5) * weight), + orbit: o.map(|a| 0.5 + (a - 0.5) * weight), + zoom: z, + press, + }; + out.push((zoom, weight, o, View::new(bx, p), bx)); + } + } + } + } + } + out + } + + /// L'axe horizontal de l'image reste horizontal dans le monde, et une verticale du monde qui + /// passe par le point regardé reste verticale à l'image : roulis nul sur toute l'enveloppe. + #[test] + fn the_roll_is_zero_on_the_whole_envelope() { + for (zoom, weight, o, v, _) in envelope() { + let (r, _, f) = v.basis(); + assert_eq!(r[1], 0.0, "l'axe horizontal de l'image quitte l'horizontale"); + let looked = [0, 1, 2].map(|k| v.eye[k] + 2500.0 * f[k]); + for dy in [-400.0f32, -150.0, 150.0, 400.0] { + let p = v.project([looked[0], looked[1] + dy, looked[2]]).unwrap(); + assert!(p[0].abs() < 2e-3, "zoom {zoom} poids {weight} {o:?} dy {dy} : {p:?}"); + } + } + } + + /// Pointeur à droite : l'œil passe à droite, le bord droit de l'écran est plus proche, donc + /// plus haut à l'image que le gauche. En haut : l'œil monte, le bord haut est plus large que le + /// bas. En bas : l'inverse. Plus le pointeur s'écarte, plus l'angle grandit. + #[test] + fn the_orbit_follows_the_pointer_side() { + let height = |q: &TiltedQuad, a: usize, b: usize| (q.corners[b].1 - q.corners[a].1).abs(); + let width = |q: &TiltedQuad, a: usize, b: usize| (q.corners[b].0 - q.corners[a].0).abs(); + for zoom in [1.0f32, 1.8, 3.5] { + let (right, bx) = view([0.9, 0.5], zoom); + let (left, _) = view([0.1, 0.5], zoom); + let (top, _) = view([0.5, 0.1], zoom); + let (bottom, _) = view([0.5, 0.9], zoom); + let (mid, _) = view([0.5, 0.5], zoom); + let (qr, ql, qt, qb) = (right.quad(bx), left.quad(bx), top.quad(bx), bottom.quad(bx)); + // Coins TL, TR, BR, BL : bord gauche 0→3, droit 1→2, haut 0→1, bas 3→2. + assert!(height(&qr, 1, 2) > 1.1 * height(&qr, 0, 3), "zoom {zoom} droite"); + assert!(height(&ql, 0, 3) > 1.1 * height(&ql, 1, 2), "zoom {zoom} gauche"); + assert!(width(&qt, 0, 1) > 1.05 * width(&qt, 3, 2), "zoom {zoom} haut"); + assert!(width(&qb, 3, 2) > 1.03 * width(&qb, 0, 1), "zoom {zoom} bas"); + let ([az_r, el_r], [az_l, _]) = (right.angles_deg(), left.angles_deg()); + let ([_, el_t], [_, el_b], [az_m, el_m]) = (top.angles_deg(), bottom.angles_deg(), mid.angles_deg()); + assert!((az_r - 0.8 * AZIMUTH_DEG).abs() < 1e-3 && (az_l + 0.8 * AZIMUTH_DEG).abs() < 1e-3); + assert!(az_m.abs() < 1e-4 && (el_m - ELEVATION_DEG[0]).abs() < 1e-3 && el_r == el_m); + assert!(el_t > el_m + 9.0 && el_b < el_m - 9.0, "{el_t} {el_m} {el_b}"); + // L'œil est bien du côté du pointeur. + assert!(right.eye[0] > 0.0 && left.eye[0] < 0.0 && top.eye[1] < bottom.eye[1]); + } + let [a, b, c] = [0.6f32, 0.75, 0.95].map(|x| view([x, 0.5], 1.0).0.angles_deg()[0]); + assert!(0.0 < a && a < b && b < c, "{a} {b} {c}"); + } + + /// Dès `FULL_VIEW_ZOOM`, le point visé tombe au point principal. + #[test] + fn the_camera_looks_at_its_aim() { + for zoom in [2.0f32, 3.5] { + let p = pose([0.62, 0.41], zoom); + let bx = BOX.map(|b| b * zoom); + let v = View::new(bx, p); + let aim = [(p.aim[0] - 0.5) * bx[0], (p.aim[1] - 0.5) * bx[1], 0.0]; + let q = v.project(aim).unwrap(); + assert!(q[0].abs() < 1e-2 && q[1].abs() < 1e-2, "{q:?}"); + } + } + + /// À poids nul, la caméra regarde l'écran de face et le rend à sa taille : le rendu plat, + /// quels que soient le zoom et les clics. + #[test] + fn a_zero_weight_is_the_flat_layout() { + for zoom in [1.0f32, 1.8, 5.0] { + for press in [0.0, -1.0, 0.4] { + let bx = BOX.map(|b| b * zoom); + let p = CameraPose { weight: 0.0, aim: [0.5; 2], orbit: [0.5; 2], zoom, press }; + let q = View::new(bx, p).quad(bx); + for (c, e) in q.corners.iter().zip(corners(bx[0], bx[1])) { + assert!((c.0 - e[0]).abs() < 0.05 && (c.1 - e[1]).abs() < 0.05, "{c:?} {e:?}"); + } + assert!((q.scale - 1.0).abs() < 1e-4 && q.rot == [0.0; 3], "{} {:?}", q.scale, q.rot); + } + } + } + + /// Au zoom 1, l'écran entier tient dans sa boîte depuis toute l'orbite, et la touche au pire + /// de l'enveloppe : le containment est juste, pas une marge au jugé. + #[test] + fn at_zoom_1_the_whole_screen_stays_in_its_box() { + for bx in [BOX, [864.0, 1536.0], [1000.0, 1000.0], [2560.0, 1080.0]] { + let mut tightest = f32::MAX; + for k in [1, 4, 7, 10] { + let weight = k as f32 / 10.0; + for i in 0..=20 { + for j in 0..=20 { + let o = [i as f32 / 20.0, j as f32 / 20.0].map(|c| 0.5 + (c - 0.5) * weight); + let p = CameraPose { weight, aim: [0.5; 2], orbit: o, zoom: 1.0, press: 0.0 }; + let (mx, my) = View::new(bx, p).quad(bx).half_extents_px(); + assert!( + mx <= bx[0] * 0.5 + 0.5 && my <= bx[1] * 0.5 + 0.5, + "{bx:?} poids {weight} {o:?} : {mx} {my}" + ); + tightest = tightest.min((bx[0] * 0.5 - mx).min(bx[1] * 0.5 - my)); + } + } + } + println!("{bx:?} : au plus près, {tightest:.1} px du bord de la boîte"); + assert!(tightest < 0.01 * bx[0].min(bx[1]), "{bx:?} {tightest}"); + } + let (mx, my) = View::new(BOX, CameraPose::REST).quad(BOX).half_extents_px(); + println!("repos 16:9 : {:.3} × {:.3} de la boîte", mx / (BOX[0] * 0.5), my / (BOX[1] * 0.5)); + assert!(mx > 0.8 * BOX[0] * 0.5 && my > 0.8 * BOX[1] * 0.5, "{mx} {my}"); + } + + /// Le containment est gelé par région : à zoom et poids donnés, la focale ne dépend ni du + /// pointeur ni du point visé. L'écran ne respire pas pendant que la caméra tourne. + #[test] + fn the_scale_is_frozen_per_region() { + for zoom in [1.0f32, 1.25, 1.8, 3.5] { + let focal = view([0.5, 0.5], zoom).0.focal; + for i in 0..=10 { + for j in 0..=10 { + let v = view([i as f32 / 10.0, j as f32 / 10.0], zoom).0; + assert_eq!(v.focal, focal, "zoom {zoom} ({i}, {j})"); + } + } + } + // Le clic recule l'œil sans toucher la focale : l'écran rapetisse, puis revient. + let (still, bx) = view([0.7, 0.4], 1.0); + let pressed = View::new(bx, CameraPose { press: -1.0, ..pose([0.7, 0.4], 1.0) }); + assert_eq!(still.focal, pressed.focal); + let (s, p) = (still.quad(bx).half_extents_px(), pressed.quad(bx).half_extents_px()); + assert!(p.0 < s.0 * 0.975 && p.1 < s.1 * 0.975, "{s:?} {p:?}"); + } + + /// Le quad, sa translation, sa focale et sa rotation reproduisent la caméra : ce que le mode 15 + /// reconstruit (rayon par pixel) tombe exactement sur ce que le mode 8 dessine. + #[test] + fn the_quad_convention_reproduces_the_camera() { + for (_, _, o, v, bx) in envelope().into_iter().step_by(37) { + let q = v.quad(bx); + let s = q.scale; + let tol = 2e-4 * bx[0]; + for (fx, fy) in [(0.0, 0.0), (0.3, 0.7), (1.0, 1.0), (0.9, 0.1), (-0.05, 1.05)] { + let p = [(fx - 0.5) * bx[0], (fy - 0.5) * bx[1], 0.0]; + let want = v.project(p).unwrap(); + let w = crate::regions::rotate_point(p.map(|c| c * s), q.rot); + let (wx, wy) = (w[0] + q.offset[0], w[1] + q.offset[1]); + let d = q.perspective - w[2]; + let got = [wx * q.perspective / d, wy * q.perspective / d]; + assert!( + (got[0] - want[0]).abs() < tol && (got[1] - want[1]).abs() < tol, + "{o:?} {got:?} {want:?}" + ); + // Et la correspondance directe du warp (homographie des coins) est la même. + let h = q.point_px(fx, fy); + assert!((h.0 - want[0]).abs() < tol && (h.1 - want[1]).abs() < tol, "{o:?} {h:?} {want:?}"); + } + // L'œil, dans le repère du plan, est celui de la caméra. + let eye = crate::regions::rotate_point_inv([-q.offset[0], -q.offset[1], q.perspective], q.rot); + for k in 0..3 { + assert!((eye[k] - v.eye[k] * s).abs() < tol, "{o:?} {eye:?} {:?}", v.eye); + } + } + } + + /// Le warp bilinéaire des angles fixes s'écarte de la projection exacte de plusieurs dizaines + /// de px sous cette caméra (partie visible d'un cadre 1920×1080) : le warp projectif est + /// nécessaire. + #[test] + fn the_bilinear_warp_is_too_far_from_the_camera() { + let bilinear = |c: &[(f32, f32); 4], u: f32, v: f32| { + let top = (c[0].0 + (c[1].0 - c[0].0) * u, c[0].1 + (c[1].1 - c[0].1) * u); + let bottom = (c[3].0 + (c[2].0 - c[3].0) * u, c[3].1 + (c[2].1 - c[3].1) * u); + (top.0 + (bottom.0 - top.0) * v, top.1 + (bottom.1 - top.1) * v) + }; + let mut worst = 0.0f32; + for (_, weight, _, v, bx) in envelope() { + if weight < 1.0 { + continue; + } + let q = v.quad(bx); + for i in 0..=20 { + for j in 0..=20 { + let (u, t) = (i as f32 / 20.0, j as f32 / 20.0); + let exact = q.point_px(u, t); + if exact.0.abs() > 960.0 || exact.1.abs() > 540.0 { + continue; + } + let b = bilinear(&q.corners, u, t); + worst = worst.max((b.0 - exact.0).hypot(b.1 - exact.1)); + } + } + } + println!("écart bilinéaire / projectif, pire px visible : {worst:.0}"); + assert!(worst > 20.0, "{worst}"); + } + + /// Ce qui penche, et où. Le roulis est nul : une arête ne penche que par la perspective. La + /// règle des 2° est relâchée pour cette caméra (une arête peut croiser un axe pendant que + /// l'œil passe), mais au repos, pointeur au milieu, aucun bord vertical visible n'est droit : + /// l'élévation de repos les fait converger. La pente du contenu au centre de la vue, + /// `atan(tan(azimut)·sin(élévation))`, reste bornée : c'est ce qui se lit comme « penché » + /// quand l'œil passe dans un coin. + #[test] + fn what_slopes_and_where() { + let (half_w, half_h) = (960.0f32, 540.0f32); + for zoom in [1.0f32, 1.25, 1.8, 2.2, 3.5, 5.0] { + let (v, bx) = view([0.5, 0.5], zoom); + let c = v.quad(bx).corners; + for k in [1usize, 3] { + let (a, b) = (c[k], c[(k + 1) % 4]); + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let visible = (0..=64) + .map(|i| (a.0 + dx * i as f32 / 64.0, a.1 + dy * i as f32 / 64.0)) + .any(|p| p.0.abs() <= half_w && p.1.abs() <= half_h); + let deg = dx.atan2(dy.abs()).to_degrees().abs(); + assert!(!visible || deg > 1.0, "zoom {zoom} arête {k} à {deg:.2}°"); + } + } + let mut worst = 0.0f32; + for (_, _, _, v, _) in envelope() { + let [az, el] = v.angles_deg().map(f32::to_radians); + worst = worst.max((az.tan() * el.sin()).atan().to_degrees().abs()); + } + println!("pente du contenu au centre de la vue, au pire de l'orbite : {worst:.2}°"); + assert!(worst < 7.0, "{worst}"); + } + + fn track(at: impl Fn(f32) -> (f32, f32)) -> CursorTrack { + CursorTrack::new( + (0..=1800) + .map(|i| { + let t = i as f32 / 30.0; + let (x, y) = at(t); + (t, x, y) + }) + .collect(), + vec![], + vec![], + ) + } + + fn whole(track: &CursorTrack) -> CameraFrame<'_> { + CameraFrame { track: Some(track), crop: [0.0, 0.0, 1.0, 1.0], window: [0.0, 100.0] } + } + + /// Lecture, seek arrière, sauts : même cadrage pour le même `t`, et aucune frame ne saute. + #[test] + fn the_follow_is_a_pure_continuous_function_of_time() { + let tr = track(|t| (0.5 + 0.4 * (t * 1.3).sin(), 0.5 + 0.3 * (t * 0.7).cos())); + let f = whole(&tr); + let at = |i: usize| follow(&f, 1.0 + i as f32 / 60.0, 2.0); + let forward: Vec<_> = (0..600).map(at).collect(); + for i in (0..600).rev().step_by(7) { + assert_eq!(at(i), forward[i], "frame {i}"); + } + // Le pointeur va jusqu'à 0,56 écran par seconde : le cadrage reste en dessous, sans à-coup. + let speed = |a: [f32; 2], b: [f32; 2]| (b[0] - a[0]).hypot(b[1] - a[1]) * 60.0; + for w in forward.windows(3) { + let (v0, v1) = (speed(w[0].orbit, w[1].orbit), speed(w[1].orbit, w[2].orbit)); + assert!(v1 < 0.56 && (v1 - v0).abs() < 0.02, "{w:?}"); + } + } + + /// Un saut du pointeur : la caméra part avant (anticipation), ne dépasse jamais, et s'est + /// posée à 95 % moins d'une seconde après. Le point visé fait le même chemin, borné à la + /// portée du zoom. + #[test] + fn a_jump_settles_in_about_a_second() { + let tr = track(|t| if t < 4.0 { (0.3, 0.6) } else { (0.8, 0.2) }); + let f = whole(&tr); + let at = |t: f32| follow(&f, t, 2.0); + assert!((at(3.5).orbit[0] - 0.3).abs() < 1e-4); + assert!(at(3.9).orbit[0] > 0.31, "l'anticipation doit faire partir la caméra avant le saut"); + let progress = |t: f32| (at(t).orbit[0] - 0.3) / 0.5; + let (mut last, mut settled) = (0.0, None); + for i in 0..=300 { + let t = 3.5 + i as f32 / 100.0; + let p = progress(t); + assert!(p >= last - 1e-5 && p <= 1.0 + 1e-4, "t {t} : {p} après {last}"); + if settled.is_none() && p >= 0.95 { + settled = Some(t - 4.0); + } + last = p; + } + let settled = settled.expect("jamais posée"); + println!("95 % du saut {settled:.2} s après lui"); + assert!((0.6..=1.0).contains(&settled), "{settled}"); + // Le point visé : borné à 0,5 ± 0,225 au zoom 2. + let posed = at(8.0); + assert!((posed.aim[0] - 0.725).abs() < 1e-3 && (posed.aim[1] - 0.275).abs() < 1e-3, "{posed:?}"); + assert!((posed.orbit[0] - 0.8).abs() < 1e-3); + } + + /// Le point visé reste là où la vue reste dans l'écran (au centre au zoom 1), l'orbite lit + /// tout l'écran. Recadrage compris ; sans piste, le centre. + #[test] + fn the_aim_keeps_the_view_on_the_screen() { + let corner = track(|_| (0.99, 0.01)); + for zoom in [1.0f32, 1.5, 2.0, 3.0] { + let got = follow(&whole(&corner), 5.0, zoom); + let reach = (0.5 - 0.5 * VIEW_MARGIN / zoom).max(0.0); + assert!( + (got.aim[0] - (0.5 + reach)).abs() < 1e-3 && (got.aim[1] - (0.5 - reach)).abs() < 1e-3, + "{zoom} {got:?}" + ); + assert!((got.orbit[0] - 0.99).abs() < 1e-3 && (got.orbit[1] - 0.01).abs() < 1e-3); + } + // Dans un recadrage [0,2 ; 0,4], x = 0,38 est tout à droite de ce qu'on voit ; hors du + // recadrage, le pointeur compte pour le bord. + let tr = track(|_| (0.38, 0.5)); + let cropped = CameraFrame { crop: [0.2, 0.0, 0.4, 1.0], ..whole(&tr) }; + assert!(follow(&cropped, 5.0, 2.0).orbit[0] > 0.89); + assert!(follow(&whole(&tr), 5.0, 2.0).orbit[0] < 0.4); + let outside = CameraFrame { crop: [0.0, 0.0, 0.3, 1.0], ..whole(&tr) }; + assert!((follow(&outside, 5.0, 2.0).orbit[0] - 1.0).abs() < 1e-5); + assert_eq!(follow(&CameraFrame::NONE, 5.0, 2.0), Follow::CENTRE); + } + + /// Le coût ne dépend pas de la longueur de la région : 120 lectures de piste par frame. + /// Chaque mesure garde le meilleur de 7 passes : une préemption ne gonfle qu'une passe. + #[test] + fn the_follow_cost_is_bounded() { + let tr = track(|t| (0.5 + 0.4 * (t * 0.9).sin(), 0.5 + 0.3 * (t * 0.4).cos())); + let f = whole(&tr); + let best = |run: &dyn Fn(usize)| { + (0..7) + .map(|_| { + let t0 = std::time::Instant::now(); + (0..200).for_each(run); + t0.elapsed() / 200 + }) + .min() + .unwrap() + }; + let time = |t: f32| { + best(&|i| { + std::hint::black_box(follow(&f, t - i as f32 * 0.01, 2.0)); + }) + }; + let (early, late) = (time(3.0), time(59.0)); + let camera = best(&|i| { + std::hint::black_box(View::new(BOX, pose([i as f32 / 200.0, 0.3], 1.5))); + }); + println!("cadreur : {early:?} à 3 s, {late:?} à 59 s ; caméra : {camera:?}"); + assert!(late < std::time::Duration::from_millis(1), "{late:?}"); + assert!(late < early * 3 + std::time::Duration::from_micros(50), "{early:?} {late:?}"); + assert!(camera < std::time::Duration::from_millis(1), "{camera:?}"); + } +} diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index ebea86196..9466d34d7 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -43,8 +43,9 @@ pub use crate::frame_geometry::{ live_params_from_scene, webcam_shape_code, FIXTURE_FRAMES, LayerCB, LiveParams, OUT_H, OUT_W, }; use crate::frame_geometry::{ - cursor_sprite_cb, cursor_sprite_dst, parse_hex, plan_cursor, plan_frame, CursorPlacement, - CursorPlanInput, FrameGeometryInput, ShadowCaster, + cursor_model_cb, cursor_sprite_cb, cursor_sprite_dst, parse_hex, plan_cursor, plan_frame, + tilted_screen_cb, CursorPlacement, CursorPlanInput, FrameGeometryInput, ShadowCaster, + SpriteShape, }; use crate::scene::{Scene, SceneBackground}; @@ -332,6 +333,9 @@ pub struct Compositor { /// touche depuis appartient au jeu actif et ne peut pas etre evince -- voir /// `cached_image`. img_frame_start: std::cell::Cell, + /// Champs de distance des sprites de curseur (mode 15), R16F, par chemin, avec leur forme. + /// Pas d'eviction : seuls les seize sprites du theme par defaut y passent (~2,6 Mo en tout). + sdf_cache: RefCell>, /// Copie mipmappee de la frame composee, lue par les annotations « flou » /// (mode 10). `ann_copy` garde la texture en vie, `ann_copy_view` porte tous @@ -692,6 +696,7 @@ impl Compositor { programme_time: RefCell::new(None), text_raster: crate::text::TextRasterizer::new().ok(), img_cache: RefCell::new(std::collections::HashMap::new()), + sdf_cache: RefCell::new(std::collections::HashMap::new()), img_tick: std::cell::Cell::new(0), img_frame_start: std::cell::Cell::new(0), ann_copy, @@ -1161,58 +1166,6 @@ impl Compositor { } } - /// `LayerCB` de l'ecran incline (mode 8) : le quad projete est dessine dans sa - /// BBOX et le fragment remonte au (s,t) du plan par warp bilineaire inverse. - /// Port de `compositor_macos::draw_tilted_screen`. Pas de motion blur sur ce - /// chemin -- le tilt est bref, la simplification ne se voit pas. - fn tilted_screen_cb( - &self, - quad: &crate::regions::TiltedQuad, - s_px: [f32; 2], - center_px: [f32; 2], - cut: [f32; 4], - focus_plane: [f32; 2], - radius: f32, - dof: bool, - ) -> LayerCB { - let (rw, rh) = (self.render_w as f32, self.render_h as f32); - let corners = quad.corners; - // Taille du plan dans son propre repere, AVANT projection : c'est la que vit - // le rayon, pour qu'il reste constant le long du bord au lieu de s'etirer - // avec la perspective. - let plane_px = [s_px[0] * quad.scale, s_px[1] * quad.scale]; - let (min_x, max_x) = - corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); - let (min_y, max_y) = - corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); - let bbox_w = (max_x - min_x).max(1.0); - let bbox_h = (max_y - min_y).max(1.0); - // Coins en px LOCAUX a la bbox, pour matcher `i.local` du shader. - let local = |(x, y): (f32, f32)| -> [f32; 2] { [x - min_x, y - min_y] }; - let [tl0, tl1] = local(corners[0]); - let [tr0, tr1] = local(corners[1]); - let [br0, br1] = local(corners[2]); - let [bl0, bl1] = local(corners[3]); - LayerCB { - dst: [ - (center_px[0] + min_x) / rw, - (center_px[1] + min_y) / rh, - bbox_w / rw, - bbox_h / rh, - ], - src: cut, - quad_px: [bbox_w, bbox_h], - radius_px: radius * quad.scale, - mode: 8.0, - fx: [tl0, tl1, tr0, tr1], - src_prev: [br0, br1, bl0, bl1], - dst_prev: [plane_px[0], plane_px[1], 0.0, 0.0], - // Gradient de profondeur du plan, profondeur du focus et `k` (`depth_mb`). - mb: quad.depth_mb(s_px, focus_plane, dof), - ..Default::default() - } - } - fn make_bind( &self, cb: &LayerCB, @@ -1323,6 +1276,44 @@ impl Compositor { Ok((tex, w, h)) } + /// Champ de distance du sprite `path` (binding 2 du mode 15) et sa forme, calcules au + /// premier appel. Parite `compositor_windows::cursor_sdf`. + fn cursor_sdf(&self, path: &str) -> Result<(wgpu::Texture, SpriteShape)> { + if let Some(hit) = self.sdf_cache.borrow().get(path) { + return Ok(hit.clone()); + } + let sdf = crate::cursor_sdf::CursorSdf::load(path)?; + let size = wgpu::Extent3d { width: sdf.width, height: sdf.height, depth_or_array_layers: 1 }; + let tex = self.gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("cursor-sdf"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.gpu.context.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &sdf.f16_bytes(), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(sdf.width * 2), + rows_per_image: Some(sdf.height), + }, + size, + ); + let entry = (tex, sdf.shape); + self.sdf_cache.borrow_mut().insert(path.to_string(), entry.clone()); + Ok(entry) + } + /// Ouvre une frame du point de vue d'`img_cache` : tout ce qui sera touche /// apres cet appel est le jeu actif, et devient inevincable jusqu'a la /// frame suivante. @@ -2085,19 +2076,18 @@ impl Compositor { mb: [g.mb_taps, g.mb_amount, 1.0, square_top], ..Default::default() }, - Some(quad) => { - let mut cb = self.tilted_screen_cb( - quad, - s_px, - quad_center_px, - g.cut, - g.focus_plane, - g.s_radius, - dof, - ); - cb.dst_prev[2] = square_top; - cb - } + // Mode 8, partage avec Windows et macOS (`tilted_screen_cb`). + Some(quad) => tilted_screen_cb( + quad, + s_px, + quad_center_px, + g.cut, + g.focus_plane, + g.s_radius, + square_top, + dof, + [rw, rh], + ), }; // Bind group construit AVANT le pass (doit vivre pendant tout le pass) ; // `_screen_uniform` garde le buffer uniforme en vie (reference par le bind). @@ -2143,7 +2133,7 @@ impl Compositor { // titre flotterait au-dessus de l'ombre. let screen_shadow = cfg.shadow.then(|| { let spread = crate::frame_geometry::SCREEN_SHADOW_SPREAD_FRAC * g.frame_min_px; - let offset = [0.0, crate::frame_geometry::SCREEN_SHADOW_OFFSET_FRAC * g.frame_min_px]; + let offset = g.screen_shadow_offset(); let opacity = 0.45 * lp.shadow_scale; let cb = match g.shadow_caster([rw, rh]) { ShadowCaster::Upright { dst, size_px, radius } => { @@ -2678,9 +2668,9 @@ impl Compositor { } } - // Curseur thematise : sprite RGBA droit (mode 7) ou pose sur le plan - // incline (mode 13) selon ce que `plan_cursor` a resolu. - // `_tex`/`_view`/`_bufs` gardent le sprite et les uniformes en vie + // Curseur thematise : sprite RGBA droit (mode 7), pose sur le plan + // incline (mode 13) ou curseur modelise (mode 15) selon ce que + // `plan_cursor` a resolu. `_tex`/`_bufs` gardent le sprite et les uniformes en vie // pendant le pass. Miroir de la branche curseur de `compositor_macos`. // // TRAINEE (`plan.taps > 1`) : `binds` porte une copie par echantillon, @@ -2689,12 +2679,11 @@ impl Compositor { // fois -- cf. le commentaire au point de dessin. struct CursorDraw { _bufs: Vec, - _tex: wgpu::Texture, - _view: wgpu::TextureView, + /// Le sprite, et pour le curseur modelise son champ de distance. + _tex: Vec<(wgpu::Texture, wgpu::TextureView)>, binds: Vec, - /// Ombre de contact du volume (mode 12), dessinee une fois SOUS le - /// curseur et sa trainee. `None` sans volume. - shadow: Option<(wgpu::Buffer, wgpu::BindGroup)>, + /// L'impact des clics (mode 16), dessine SOUS le curseur, sur le RT. + impacts: Vec, } let cursor_draw: Option = (|| { let track = cursor_ref.as_ref()?; @@ -2713,7 +2702,37 @@ impl Compositor { .borrow() .unwrap_or(frame / crate::frame_geometry::FPS), }, - )?; + )? + .for_backend(self.gpu.backend == crate::d3d::Backend::Cpu); + + // `taps == 1` : un seul placement, celui de l'instant rendu -- le + // chemin net d'avant, inchange. Au-dela, on echelonne les copies + // regulierement de `prev_placement` (inclus) au placement courant + // (inclus) : c'est ce que font les deux autres backends, et inclure + // les deux bornes est ce qui fait que la trainee touche a la fois + // l'endroit d'ou le curseur vient et celui ou il est. + // + // On interpole des PLACEMENTS et non des centres : `lerp` sait + // traiter le cas incline, si bien qu'une trainee sous zoom incline + // reste dans le plan au lieu de repasser par un centre 2D qui + // l'aplatirait. + let placements: Vec = if plan.taps <= 1 { + vec![plan.placement] + } else { + (0..plan.taps) + .map(|k| { + let f = k as f32 / (plan.taps - 1) as f32; + plan.prev_placement.lerp(plan.placement, f) + }) + .collect() + }; + let (mut bufs, mut binds) = (Vec::new(), Vec::new()); + let mut impacts = Vec::new(); + for cb in &plan.impacts { + let (buf, bind) = self.make_bind(cb, None, &dummy); + bufs.push(buf); + impacts.push(bind); + } let sprites = scene_ref .as_ref() @@ -2740,33 +2759,46 @@ impl Compositor { (plan.size_px * ar, plan.size_px) }; let hotspot = [sprite.hotspot_x, sprite.hotspot_y]; - // `taps == 1` : un seul placement, celui de l'instant rendu -- le - // chemin net d'avant, inchange. Au-dela, on echelonne les copies - // regulierement de `prev_placement` (inclus) au placement courant - // (inclus) : c'est ce que font les deux autres backends, et inclure - // les deux bornes est ce qui fait que la trainee touche a la fois - // l'endroit d'ou le curseur vient et celui ou il est. - // - // On interpole des PLACEMENTS et non des centres : `lerp` sait - // traiter le cas incline, si bien qu'une trainee sous zoom incline - // reste dans le plan au lieu de repasser par un centre 2D qui - // l'aplatirait. - let placements: Vec = if plan.taps <= 1 { - vec![plan.placement] - } else { - (0..plan.taps) - .map(|k| { - let f = k as f32 / (plan.taps - 1) as f32; - plan.prev_placement.lerp(plan.placement, f) - }) - .collect() - }; let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); - let (mut bufs, mut binds) = (Vec::new(), Vec::new()); + + // Curseur modelise (mode 15) : ce meme sprite extrude, `plan_cursor` en a tire la + // pose. Sprite au binding 1 (texY), champ au binding 2 (texU). Parite Windows/macOS. + if let Some(pose) = plan.model { + match self.cursor_sdf(&sprite.path) { + Ok((sdf, shape)) => { + let shape = SpriteShape { hotspot, ..shape }; + let sdf_view = sdf.create_view(&wgpu::TextureViewDescriptor::default()); + for placement in placements { + let Some(cb) = cursor_model_cb( + placement, + plan.size_px, + pose, + shape, + plan.alpha, + plan.clip, + ) else { + continue; + }; + let (buf, bind) = + self.make_bind(&cb, Some((&view, &sdf_view, &view)), &dummy); + bufs.push(buf); + binds.push(bind); + } + return (!binds.is_empty()).then_some(CursorDraw { + _bufs: bufs, + _tex: vec![(tex, view), (sdf, sdf_view)], + binds, + impacts, + }); + } + Err(e) => eprintln!("[curseur] champ de \"{}\" : {e:#}", sprite.path), + } + } + for placement in placements { // Geometrie partagee avec Windows et macOS (`cursor_sprite_cb`) : // mode 7 droit, ou mode 13 pose sur le plan -- le clip vit alors - // dans `dst_prev`, et le volume dans `mb`. + // dans `dst_prev`. let cb = cursor_sprite_cb( placement, [pw, ph], @@ -2774,17 +2806,13 @@ impl Compositor { plan.alpha, plan.clip, [rw, rh], - plan.volume.as_ref(), ); // Sprite RGBA au binding 1 (texY) que le mode 7 echantillonne. let (buf, bind) = self.make_bind(&cb, Some((&view, &view, &view)), &dummy); bufs.push(buf); binds.push(bind); } - // Le sprite existe (sinon on serait deja sorti) : l'ombre de contact - // peut suivre. Pas de sprite, pas de volume -- comme sur macOS. - let shadow = plan.volume.as_ref().map(|v| self.make_bind(&v.shadow, None, &dummy)); - Some(CursorDraw { _bufs: bufs, _tex: tex, _view: view, binds, shadow }) + Some(CursorDraw { _bufs: bufs, _tex: vec![(tex, view)], binds, impacts }) })(); // Bind group de la passe de composition d'`accum` (layout du blur : // uniform + texture + sampler). Construit hors de la pass, comme les @@ -2962,9 +2990,8 @@ impl Compositor { rpass.set_bind_group(0, &a.bind, &[]); rpass.draw(0..4, 0..1); } - // Ombre de contact du volume : sur la scene, sous le curseur ET sous - // sa trainee (composee apres cette pass). Parite Windows/macOS. - if let Some((_buf, bind)) = cursor_draw.as_ref().and_then(|c| c.shadow.as_ref()) { + // L'impact des clics, sous le curseur et sa trainee (dessinee plus bas). + for bind in cursor_draw.iter().flat_map(|c| &c.impacts) { rpass.set_bind_group(0, bind, &[]); rpass.draw(0..4, 0..1); } @@ -4746,6 +4773,495 @@ mod tests { assert!(differing(&light, &dark) > 10_000, "{name}: les deux themes se confondent"); } } + + // ----------------------------------------------------------------------- + // Curseur modélisé (mode 15) : pendant de `tests/cursor_model_render.rs` (Windows). + // ----------------------------------------------------------------------- + + /// Les états que le rendu passe en revue (hotspots de `DEFAULT_CURSOR_SPRITES`), et s'ils + /// sont centrés : ni tangage ni lacet. + const MODEL_STATES: [(&str, [f32; 2], bool); 6] = [ + ("arrow", [0.119, 0.0874], false), + ("pointer", [0.3893, 0.0032], false), + ("text", [0.4375, 0.5333], true), + ("open-hand", [0.4375, 0.1781], false), + ("resize-ew", [0.4881, 0.4706], true), + ("not-allowed", [0.5, 0.5], true), + ]; + + /// Écran seul (strié) sous un zoom 1 porteur de `rotation`, curseur par défaut à la taille + /// `size` avec les sprites livrés de `MODEL_STATES`. `model3d` : `None` = clé absente. + fn model_scene_json(rotation: &str, model3d: Option, theme: &str, show: bool, size: f32) -> String { + let model3d = model3d.map(|m| format!(r#","model3d":{m}"#)).unwrap_or_default(); + let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../public/cursors/default").replace('\\', "/"); + let sprites: Vec = MODEL_STATES + .iter() + .map(|(key, [hx, hy], _)| format!(r#""{key}":{{"path":"{dir}/{key}.png","hotspotX":{hx},"hotspotY":{hy}}}"#)) + .collect(); + let sprites = sprites.join(","); + format!( + r##"{{"clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, + "effects":{{"padding":0.2,"blur":false,"shadow":0,"roundnessFrac":0.03,"motionBlur":0}}, + "background":{{"kind":"gradient","angleDeg":135,"stops":["#5b6ee1","#e8a0bf"]}}, + "zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":10,"scale":1,"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":{rotation}}}], + "annotations":[], + "cursor":{{"show":{show},"size":{size},"smoothing":0,"motionBlur":0,"clickBounce":2.5{model3d},"clipToBounds":false,"theme":"{theme}", + "cursorSprites":{{{sprites}}}}}, + "cropByClip":[null], + "output":{{"width":1280,"height":720,"fps":30}}}}"## + ) + } + + /// Une frame striée de barres sombres, bleue ou orangée : rien de NEUTRE, donc un pixel gris + /// est le curseur et un pixel teinté assombri son ombre ; un pixel identique sur les deux + /// teintes est du curseur opaque. + fn model_screen_planes(orange: bool) -> (Vec, Vec) { + let (w, h) = (640u32, 360u32); + let (u, v) = if orange { (100, 170) } else { (150, 120) }; + let y = (0..w * h) + .map(|i| { + let (col, row) = (i % w, i / w); + if row % 24 >= 8 && row % 24 < 12 && (col / 40) % 3 != 2 { 90 } else { 150 } + }) + .collect(); + let uv = (0..w * (h / 2)).map(|i| if i % 2 == 0 { u } else { v }).collect(); + (y, uv) + } + + fn compose_model( + comp: &Compositor, + screen: &FakeFrame, + json: &str, + track: &crate::cursor::CursorTrack, + ) -> Vec { + let scene = crate::scene::Scene::from_json(json).expect("scene json"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(false); + comp.set_scene(Some(scene)); + comp.set_cursor(track.clone()); + comp.set_cursor_time(Some(2.0)); + comp.set_timeline_time(Some(2.0)); + let mut cfg = crate::config::Cfg::c8(); + cfg.bg_blur = false; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = true; + cfg.mblur_n = 1; + cfg.shadow = false; + unsafe { + comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct").2 + } + } + + /// Immobile en (`at`, `at`) dans l'état `kind`, avec un clic au creux de `tap` avant l'instant + /// rendu si `click` : le modèle est alors posé. + fn model_track(kind: &str, click: bool, at: f32) -> crate::cursor::CursorTrack { + crate::cursor::CursorTrack::new( + vec![(0.0, at, at), (9.0, at, at)], + if click { vec![2.0 - 0.0495] } else { vec![] }, + vec![(0.0, kind.to_string())], + ) + } + + fn model_save(name: &str, rgba: &[u8]) { + let Ok(dir) = std::env::var("OPENSCREEN_CURSOR3D_OUT") else { return }; + let path = format!("{dir}/linux-{name}.png"); + image::RgbaImage::from_raw(1280, 720, rgba.to_vec()) + .expect("dimensions du readback") + .save(&path) + .unwrap_or_else(|e| panic!("ecriture {path} : {e}")); + } + + fn model_neutral(p: &[u8]) -> bool { + p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]) < 12 + } + + fn model_luma(p: &[u8]) -> f32 { + 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32 + } + + /// Ce qui distingue la frame avec flèche de la même sans : pixels de la flèche (neutres), + /// d'ombre (le bleu assombri, hors frange antialiasée), leurs centroïdes, et la distance de + /// l'ombre la plus proche de l'apex (le pixel de flèche le plus haut). + struct ModelSplit { + white: usize, + black: usize, + shadow: usize, + body_c: [f32; 2], + shadow_c: [f32; 2], + near_apex: f32, + } + + fn model_split(with: &[u8], without: &[u8]) -> ModelSplit { + let (w, h) = (1280i32, 720i32); + let at = |buf: &[u8], x: i32, y: i32| { + let i = ((y * w + x) * 4) as usize; + [buf[i], buf[i + 1], buf[i + 2]] + }; + let body = |x: i32, y: i32| { + x >= 0 && y >= 0 && x < w && y < h && at(with, x, y) != at(without, x, y) && model_neutral(&at(with, x, y)) + }; + let mut s = ModelSplit { white: 0, black: 0, shadow: 0, body_c: [0.0; 2], shadow_c: [0.0; 2], near_apex: f32::MAX }; + let (mut nb, mut apex) = (0usize, [0i32, i32::MAX]); + let mut shadows = Vec::new(); + for y in 0..h { + for x in 0..w { + let (a, b) = (at(with, x, y), at(without, x, y)); + if a == b { + continue; + } + if model_neutral(&a) { + nb += 1; + s.white += a.iter().all(|&c| c > 200) as usize; + s.black += a.iter().all(|&c| c < 45) as usize; + s.body_c = [s.body_c[0] + x as f32, s.body_c[1] + y as f32]; + if y < apex[1] { + apex = [x, y]; + } + } else if model_luma(&a) < model_luma(&b) - 6.0 + && !(-2..=2).any(|dy| (-2..=2).any(|dx| body(x + dx, y + dy))) + { + shadows.push([x as f32, y as f32]); + } + } + } + s.shadow = shadows.len(); + s.body_c = [s.body_c[0] / nb.max(1) as f32, s.body_c[1] / nb.max(1) as f32]; + for p in &shadows { + s.shadow_c = [s.shadow_c[0] + p[0], s.shadow_c[1] + p[1]]; + s.near_apex = s.near_apex.min((p[0] - apex[0] as f32).hypot(p[1] - apex[1] as f32)); + } + s.shadow_c = [s.shadow_c[0] / s.shadow.max(1) as f32, s.shadow_c[1] / s.shadow.max(1) as f32]; + s + } + + /// Les pixels OPAQUES du curseur : identiques sur les deux teintes, différents du contenu nu. + fn model_opaque(on_blue: &[u8], on_orange: &[u8], bare: &[u8]) -> Vec { + (0..1280 * 720) + .map(|i| { + let px = |b: &[u8]| [b[i * 4], b[i * 4 + 1], b[i * 4 + 2]]; + px(on_blue) == px(on_orange) && px(on_blue) != px(bare) + }) + .collect() + } + + fn model_iou(a: &[bool], b: &[bool]) -> f32 { + let inter = a.iter().zip(b).filter(|(x, y)| **x && **y).count(); + let union = a.iter().zip(b).filter(|(x, y)| **x || **y).count(); + inter as f32 / union.max(1) as f32 + } + + /// Part des pixels de `a` qui ont un pixel de `b` à `r` px au plus (norme max). + fn model_within(a: &[bool], b: &[bool], r: i32) -> f32 { + let near = |i: usize| { + let (x, y) = ((i % 1280) as i32, (i / 1280) as i32); + (-r..=r).any(|dy| { + (-r..=r).any(|dx| { + let (x, y) = (x + dx, y + dy); + (0..1280).contains(&x) && (0..720).contains(&y) && b[(y * 1280 + x) as usize] + }) + }) + }; + let hits: Vec = (0..a.len()).filter(|&i| a[i]).collect(); + hits.iter().filter(|&&i| near(i)).count() as f32 / hits.len().max(1) as f32 + } + + /// Parts des pixels sombres, clairs et rouges parmi ceux du masque qui tombent dans l'une + /// des trois classes. + fn model_palette(rgba: &[u8], mask: &[bool]) -> [f32; 3] { + let mut c = [0usize; 3]; + for (i, _) in mask.iter().enumerate().filter(|(_, m)| **m) { + let p = [rgba[i * 4] as i32, rgba[i * 4 + 1] as i32, rgba[i * 4 + 2] as i32]; + c[0] += (p.iter().max().unwrap() < &70) as usize; + c[1] += (p.iter().min().unwrap() > &150) as usize; + c[2] += (p[0] > p[1] + 60 && p[0] > p[2] + 60) as usize; + } + let n = c.iter().sum::().max(1) as f32; + c.map(|k| k as f32 / n) + } + + #[test] + fn the_modelled_arrow_casts_its_shadow_and_touches_the_screen() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let (y, uv) = model_screen_planes(false); + let screen = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv); + let (still, clicked) = (model_track("arrow", false, 0.45), model_track("arrow", true, 0.45)); + for (name, rotation) in [("flat", "null"), ("iso", r#""iso""#)] { + let json = |m: Option, theme: &str, show: bool| model_scene_json(rotation, m, theme, show, 3.0); + let bare = compose_model(&comp, &screen, &json(Some(true), "default", false), &still); + let absent = compose_model(&comp, &screen, &json(None, "default", true), &still); + let off = compose_model(&comp, &screen, &json(Some(false), "default", true), &still); + let other = compose_model(&comp, &screen, &json(Some(true), "other", true), &still); + let hover = compose_model(&comp, &screen, &json(Some(true), "default", true), &still); + let touch = compose_model(&comp, &screen, &json(Some(true), "default", true), &clicked); + model_save(&format!("{name}-hover"), &hover); + model_save(&format!("{name}-touch"), &touch); + assert!(absent == off && absent == other, "{name}: le chemin plat a change"); + assert!(absent != hover, "{name}: le reglage allume ne change rien"); + let (a, b) = (model_split(&hover, &bare), model_split(&touch, &bare)); + println!( + "{name} : blanc {}, noir {}, ombre {} (apex a {:.1} px, posee {:.1} px), centroides {:?} / {:?}", + a.white, a.black, a.shadow, a.near_apex, b.near_apex, a.body_c, a.shadow_c + ); + // Taille 3 en 1280x720 : ~50 a 60 px de haut, ~1000 px de silhouette. + assert!(a.white > 150 && a.black > 250, "{name}: matieres absentes"); + assert!(a.shadow > 300, "{name}: pas d'ombre"); + assert!(a.shadow_c[0] > a.body_c[0] && a.shadow_c[1] > a.body_c[1], "{name}: ombre pas en bas a droite"); + assert!(b.near_apex < 8.0, "{name}: posee, l'ombre est a {:.1} px de l'apex", b.near_apex); + assert!(a.near_apex > 12.0, "{name}: en l'air, l'ombre touche l'apex ({:.1} px)", a.near_apex); + } + } + + /// Chaque état garde son art et sa silhouette : posé au centre de l'écran (vu de face), le + /// modèle couvre ce que couvre le sprite plat, avec ses couleurs ; il porte une ombre en + /// l'air, suit l'inclinaison du plan, et un autre thème (ou le réglage éteint) reste plat. + #[test] + fn every_modelled_state_keeps_its_art_footprint_and_shadow() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let (y, uv) = model_screen_planes(false); + let blue = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv); + let (y, uv) = model_screen_planes(true); + let orange = FakeFrame::from_planes(&gpu, 640, 360, &y, &uv); + let json = |rotation: &str, m: Option, theme: &str, show: bool| model_scene_json(rotation, m, theme, show, 5.0); + let bare = compose_model(&comp, &blue, &json("null", Some(true), "default", false), &model_track("arrow", false, 0.5)); + let mut failures = Vec::new(); + for (key, _, centred) in MODEL_STATES { + let (still, clicked) = (model_track(key, false, 0.5), model_track(key, true, 0.5)); + let on = json("null", Some(true), "default", true); + let flat = json("null", Some(false), "default", true); + let touch = compose_model(&comp, &blue, &on, &clicked); + let touch_b = compose_model(&comp, &orange, &on, &clicked); + let hover = compose_model(&comp, &blue, &on, &still); + let tilted = compose_model(&comp, &blue, &json(r#""iso""#, Some(true), "default", true), &still); + let sprite = compose_model(&comp, &blue, &flat, &still); + let sprite_b = compose_model(&comp, &orange, &flat, &still); + let absent = compose_model(&comp, &blue, &json("null", None, "default", true), &still); + let other = compose_model(&comp, &blue, &json("null", Some(true), "other", true), &still); + model_save(&format!("art-{key}-touch"), &touch); + model_save(&format!("art-{key}-iso"), &tilted); + if absent != sprite || other != sprite { + failures.push(format!("{key}: le sprite plat a change")); + } + + let (m3d, m2d) = (model_opaque(&touch, &touch_b, &bare), model_opaque(&sprite, &sprite_b, &bare)); + let overlap = model_iou(&m3d, &m2d); + let (near3d, near2d) = (model_within(&m3d, &m2d, 2), model_within(&m2d, &m3d, 2)); + let (pal3d, pal2d) = (model_palette(&touch, &m3d), model_palette(&sprite, &m2d)); + let area = m2d.iter().filter(|m| **m).count() as f32; + let differs = |a: &[u8], i: usize| a[i * 4..i * 4 + 3] != bare[i * 4..i * 4 + 3]; + let shadow = (0..1280 * 720) + .filter(|&i| { + let (a, b) = (&hover[i * 4..i * 4 + 3], &bare[i * 4..i * 4 + 3]); + differs(&hover, i) && !model_neutral(a) && model_luma(a) < model_luma(b) - 6.0 + }) + .count() as f32; + let body = |rgba: &[u8]| (0..1280 * 720).filter(|&i| model_neutral(&rgba[i * 4..i * 4 + 3]) && differs(rgba, i)).count() as f32; + let (flat_body, tilted_body) = (body(&hover), body(&tilted)); + println!( + "{key} : IoU {overlap:.3}, a 2 px pres {near3d:.3} / {near2d:.3}, sprite {area} px, palette 3D {pal3d:?} / sprite {pal2d:?}, \ + ombre {shadow} px, corps a plat {flat_body} / incline {tilted_body}" + ); + let (min_iou, min_near, palette_tol) = if centred { (0.75, 0.98, 0.07) } else { (0.6, 0.9, 0.2) }; + if overlap <= min_iou || near3d < min_near || near2d < min_near { + failures.push(format!("{key}: la silhouette s'ecarte du sprite (IoU {overlap}, {near3d} / {near2d})")); + } + for (c, (a, b)) in ["sombre", "clair", "rouge"].iter().zip(pal3d.iter().zip(pal2d.iter())) { + if (a - b).abs() >= palette_tol { + failures.push(format!("{key}: part {c} {a} contre {b} dans le sprite")); + } + } + if shadow <= 0.3 * area { + failures.push(format!("{key}: pas d'ombre en l'air ({shadow} px)")); + } + // Le préset iso réduit le plan (unité 51,5 contre 62,6 px) et incline le modèle. + if !(tilted_body < 0.9 * flat_body && tilted_body > 0.3 * flat_body) { + failures.push(format!("{key}: le modele ne suit pas le plan ({tilted_body} / {flat_body})")); + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + + /// Pendant de `tests/cursor_tap_render.rs` (Windows) : sous un lissage qui traine loin + /// derriere la souris, la pointe du modele se pose sur la pastille rouge du clic, et l'anneau + /// de l'impact (mode 16) l'entoure, a plat et incline. + #[test] + fn the_modelled_tip_taps_the_marked_click_target() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let (w, h) = (640u32, 360u32); + // Pastille de 5 texels au centre d'un bloc de chroma : symetrique, chroma comprise. + let (tx, ty) = (193.0f32, 129.0f32); + let red = |x: f32, y: f32| (x - tx).hypot(y - ty) <= 5.0; + let (mut yp, mut uvp) = model_screen_planes(false); + for row in 0..h { + for col in 0..w { + // Un carre uni autour de la cible : l'anneau s'y mesure sans les barres. + if (col as f32 - tx).abs() < 45.0 && (row as f32 - ty).abs() < 45.0 { + yp[(row * w + col) as usize] = 150; + } + if red(col as f32 + 0.5, row as f32 + 0.5) { + yp[(row * w + col) as usize] = 63; + } + } + } + for row in 0..h / 2 { + for col in 0..w / 2 { + if red(2.0 * col as f32 + 1.0, 2.0 * row as f32 + 1.0) { + let i = (row * w + 2 * col) as usize; + uvp[i] = 102; + uvp[i + 1] = 240; + } + } + } + let screen = FakeFrame::from_planes(&gpu, w, h, &yp, &uvp); + // Arrivee sur la cible a 1,7 s, depart a 2,1 s, clic a `tc` entre les deux ; lisse a 0,5. + let target = (tx / w as f32, ty / h as f32); + let gesture = |tc: f32| { + let samples = (0..=240) + .map(|k| { + let t = k as f32 / 60.0; + let (x, y) = if t < 1.7 { + let f = t / 1.7; + (0.1 + (target.0 - 0.1) * f, 0.8 + (target.1 - 0.8) * f) + } else if t < 2.1 { + target + } else { + let f = ((t - 2.1) / 0.5).min(1.0); + (target.0 + 0.3 * f, target.1 + 0.2 * f) + }; + (t, x, y) + }) + .collect(); + crate::cursor::CursorTrack::new(samples, vec![tc], vec![(0.0, "arrow".to_string())]).smoothed(0.5) + }; + let (contact, ring) = (gesture(2.0 - 0.0495), gesture(2.0 - 0.16)); + let px = |b: &[u8], x: i32, y: i32| { + let i = ((y * 1280 + x) * 4) as usize; + [b[i] as i32, b[i + 1] as i32, b[i + 2] as i32] + }; + let is_red = |p: [i32; 3]| p[0] > p[1] + 60 && p[0] > p[2] + 60; + for rotation in ["null", r#""iso""#] { + let json = model_scene_json(rotation, Some(true), "default", true, 3.0); + let hidden = json.replace(r#""rotation":"#, r#""hideCursor":true,"rotation":"#); + let quiet = json.replace(r#""clickBounce":2.5"#, r#""clickBounce":0"#); + let bare = compose_model(&comp, &screen, &hidden, &contact); + let (mut sx, mut sy, mut n) = (0.0f32, 0.0f32, 0.0f32); + for y in 0..720 { + for x in 0..1280 { + if is_red(px(&bare, x, y)) { + (sx, sy, n) = (sx + x as f32 + 0.5, sy + y as f32 + 0.5, n + 1.0); + } + } + } + assert!(n > 10.0, "{rotation}: pastille introuvable"); + let m = [sx / n, sy / n]; + let touch = compose_model(&comp, &screen, &json, &contact); + model_save(&format!("tap-{}", if rotation == "null" { "flat" } else { "iso" }), &touch); + assert!(!is_red(px(&touch, m[0] as i32, (m[1] + 2.0) as i32)), "{rotation}: la pointe manque la pastille"); + // L'ecart du a l'impact, le long de deux demi-droites (haut, gauche) : meme rayon, a la + // perspective pres sous `iso` (l'anneau y est une ellipse). + let (on, off) = (compose_model(&comp, &screen, &json, &ring), compose_model(&comp, &screen, &quiet, &ring)); + let peak = |dx: i32, dy: i32| { + (4..45) + .map(|r| { + let (a, b) = (px(&on, m[0] as i32 + dx * r, m[1] as i32 + dy * r), px(&off, m[0] as i32 + dx * r, m[1] as i32 + dy * r)); + ((0..3).map(|c| (a[c] - b[c]).abs()).sum::(), r) + }) + .max() + .unwrap() + }; + let (up, left) = (peak(0, -1), peak(-1, 0)); + println!("{rotation} : pastille en {m:?}, anneau haut {up:?}, gauche {left:?}"); + assert!(up.0 > 60 && left.0 > 60, "{rotation}: anneau absent ({up:?} {left:?})"); + let tol = if rotation == "null" { 1 } else { 2 }; + assert!((up.1 - left.1).abs() <= tol, "{rotation}: anneau decentre ({up:?} {left:?})"); + } + } + + // ----------------------------------------------------------------------- + // Camera reelle (`follow-cursor`) : pendant de `tests/follow_camera_render.rs` (Windows). + // ----------------------------------------------------------------------- + + /// Le warp projectif du WGSL pose chaque ligne verticale d'une grille la ou la camera la + /// projette (`TiltedQuad::point_px`), a un quart de pixel pres, camera tournee a fond. + #[test] + fn the_follow_camera_warp_lands_every_grid_line_where_projected() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let (w, h, grid) = (640u32, 360u32, 32u32); + let y: Vec = (0..w * h) + .map(|i| if (i % w) % grid < 2 || (i / w) % grid < 2 { 30 } else { 220 }) + .collect(); + let uv = vec![128u8; (w * (h / 2)) as usize]; + let screen = FakeFrame::from_planes(&gpu, w, h, &y, &uv); + // Profondeur de champ coupee (allumee par defaut) : l'orbite a une vraie profondeur, et un + // trait floute ne se mesure plus a +-8 px. + let json = model_scene_json(r#""follow-cursor""#, None, "none", true, 0.05) + .replace(r#""scale":1,"#, r#""scale":2.2,"#) + .replace(r#""roundnessFrac":0.03"#, r#""roundnessFrac":0,"depthOfField":false"#); + let scene = crate::scene::Scene::from_json(&json).expect("scene json"); + let track = crate::cursor::CursorTrack::new(vec![(0.0, 0.93, 0.08), (9.0, 0.93, 0.08)], vec![], vec![]); + let rgba = compose_model(&comp, &screen, &json, &track); + let cfg = { + let mut c = crate::config::Cfg::c8(); + c.cursor = true; + c + }; + let g = plan_frame(&FrameGeometryInput { + render_px: [1280.0, 720.0], + screen_tex_px: [w as f32, h as f32], + screen_visible_px: [w as f32, h as f32], + webcam_visible_px: [w as f32, h as f32], + u_max: 1.0, + v_max: 1.0, + frame: 0.0, + cfg: &cfg, + live: live_params_from_scene(&scene), + scene: Some(&scene), + cursor: Some(&track), + timeline_t_override: Some(2.0), + programme_time: None, + }); + assert!(g.camera.is_some(), "la camera reelle doit etre posee"); + let s_px = [g.s_dst[2] * 1280.0, g.s_dst[3] * 720.0]; + let center = [(g.s_dst[0] + g.s_dst[2] * 0.5) * 1280.0, (g.s_dst[1] + g.s_dst[3] * 0.5) * 720.0]; + let quad = g.screen_tilt(s_px).expect("quad de la camera"); + let luma = |x: i32, y: i32| { + let i = ((y * 1280 + x) * 4) as usize; + 0.2126 * rgba[i] as f32 + 0.7152 * rgba[i + 1] as f32 + 0.0722 * rgba[i + 2] as f32 + }; + let (mut worst, mut n) = (0.0f32, 0); + for k in 0..w / grid { + let u = (k * grid + 1) as f32 / w as f32; + for j in 0..h / grid { + let v = ((j * grid) as f32 + grid as f32 * 0.5 + 1.0) / h as f32; + let p = quad.point_px(u, v); + let (x, yy) = (center[0] + p.0, center[1] + p.1); + if !(40.0..1240.0).contains(&x) || !(40.0..680.0).contains(&yy) { + continue; + } + // Barycentre de l'assombrissement sur la rangee, a +-8 px de la prevision. + let (mut sum, mut wsum) = (0.0f32, 0.0f32); + for dx in -8..=8 { + let xi = x.round() as i32 + dx; + let wgt = (200.0 - luma(xi, yy.floor() as i32)).max(0.0); + (sum, wsum) = (sum + wgt * (xi as f32 + 0.5), wsum + wgt); + } + if wsum > 200.0 { + worst = worst.max((sum / wsum - x).abs()); + n += 1; + } + } + } + println!("camera reelle : {n} points de grille, ecart au rendu {worst:.2} px"); + assert!(n > 50, "trop peu de points visibles : {n}"); + assert!(worst <= 0.25, "le rendu s'ecarte de la projection de {worst:.2} px"); + } } // --------------------------------------------------------------------------- diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index b16a66d80..8970435ba 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -34,7 +34,7 @@ use crate::ffi::AVFrame; pub use crate::frame_geometry::{ live_params_from_scene, webcam_shape_code, FIXTURE_FRAMES, LayerCB, LiveParams, OUT_H, OUT_W, }; -use crate::frame_geometry::{parse_hex, FrameGeometryInput, ShadowCaster, SCREEN_SHADOW_OFFSET_FRAC, +use crate::frame_geometry::{parse_hex, FrameGeometryInput, ShadowCaster, SCREEN_SHADOW_SPREAD_FRAC, WEBCAM_SHADOW_OFFSET_FRAC, WEBCAM_SHADOW_OPACITY, WEBCAM_SHADOW_SPREAD_FRAC}; use crate::scene::{Scene, SceneBackground}; @@ -282,6 +282,11 @@ pub struct Compositor { /// Valeur de `img_tick` au début de la frame en cours. Tout ce qui a été touché depuis /// appartient au jeu actif et ne peut pas être évincé — voir `cached_image`. img_frame_start: std::cell::Cell, + /// Champs de distance des sprites de curseur (mode 15), R16F, par chemin, avec leur forme. + /// Pas d'éviction : seuls les seize sprites du thème par défaut y passent (~2,6 Mo en tout). + sdf_cache: RefCell< + std::collections::HashMap, + >, // --- Engine : render targets --- /// Render target principal RGBA8. Cible de `compose_frame`. `Private` : c'est une @@ -624,6 +629,7 @@ impl Compositor { img_cache: RefCell::new(std::collections::HashMap::new()), img_tick: std::cell::Cell::new(0), img_frame_start: std::cell::Cell::new(0), + sdf_cache: RefCell::new(std::collections::HashMap::new()), rt, rt_read, nv12_y, @@ -889,6 +895,40 @@ impl Compositor { Ok((tex, w, h)) } + /// Champ de distance du sprite `path` (texture(4) du mode 15) et sa forme, calculés au + /// premier appel. Parité `compositor_windows::cursor_sdf`. R16Float : filtrable sur tous + /// les GPU Apple, contrairement au R32Float. + fn cursor_sdf( + &self, + path: &str, + ) -> Result<(metal::Texture, crate::frame_geometry::SpriteShape)> { + if let Some(hit) = self.sdf_cache.borrow().get(path) { + return Ok(hit.clone()); + } + let sdf = crate::cursor_sdf::CursorSdf::load(path)?; + let texels = sdf.f16_bytes(); + let tex = make_texture( + &self.gpu.device, + metal::MTLPixelFormat::R16Float, + sdf.width, + sdf.height, + metal::MTLStorageMode::Shared, + metal::MTLTextureUsage::ShaderRead, + ); + tex.replace_region( + metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { width: sdf.width as u64, height: sdf.height as u64, depth: 1 }, + }, + 0, + texels.as_ptr() as *const std::ffi::c_void, + (sdf.width * 2) as u64, + ); + let entry = (tex, sdf.shape); + self.sdf_cache.borrow_mut().insert(path.to_string(), entry.clone()); + Ok(entry) + } + /// Ouvre une frame du point de vue de `img_cache` : tout ce qui sera touché après cet appel /// est le jeu actif, et devient inévinçable jusqu'à la frame suivante. fn begin_image_frame(&self) { @@ -1210,8 +1250,8 @@ impl Compositor { ); } - /// Écran incliné (mode 8) : warp bilinéaire inverse dans la bbox du quad projeté. - /// Pas de motion blur sur ce chemin — le tilt est bref, la simplification ne se voit pas. + /// Écran incliné (mode 8) : le calque partagé (`frame_geometry::tilted_screen_cb`), avec la + /// pyramide de profondeur de champ en texture(2). #[allow(clippy::too_many_arguments)] unsafe fn draw_tilted_screen( &self, @@ -1228,50 +1268,23 @@ impl Compositor { uv: &metal::Texture, dof_pyramid: Option<&metal::Texture>, ) { - let (rw, rh) = (self.render_w as f32, self.render_h as f32); - let corners = quad.corners; - // Taille du plan dans son propre repère, avant projection : c'est là que vit le rayon, - // pour qu'il reste constant le long du bord au lieu de s'étirer avec la perspective. - let plane_px = [s_px[0] * quad.scale, s_px[1] * quad.scale]; - let (min_x, max_x) = - corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); - let (min_y, max_y) = - corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); - let bbox_w = (max_x - min_x).max(1.0); - let bbox_h = (max_y - min_y).max(1.0); - // coins en px LOCAUX à la bbox, pour matcher `i.local` du shader. - let local = |(x, y): (f32, f32)| -> [f32; 2] { [x - min_x, y - min_y] }; - let [tl0, tl1] = local(corners[0]); - let [tr0, tr1] = local(corners[1]); - let [br0, br1] = local(corners[2]); - let [bl0, bl1] = local(corners[3]); + let render_px = [self.render_w as f32, self.render_h as f32]; // texture(2) EXPLICITE : `draw_video` ne lie que 0/1, et le slot 2 garde sinon ce que // le draw précédent y a laissé. `None` quand l'effet est coupé : `k = 0`, rien n'y est lu. enc.set_fragment_texture(2, dof_pyramid.map(|t| &**t)); - self.draw_video( - enc, - &LayerCB { - dst: [ - (center_px[0] + min_x) / rw, - (center_px[1] + min_y) / rh, - bbox_w / rw, - bbox_h / rh, - ], - src: cut, - quad_px: [bbox_w, bbox_h], - radius_px: radius * quad.scale, - mode: 8.0, - fx: [tl0, tl1, tr0, tr1], - src_prev: [br0, br1, bl0, bl1], - dst_prev: [plane_px[0], plane_px[1], square_top, 0.0], - // Gradient de profondeur du plan, profondeur du focus et `k` (`depth_mb`) : la - // pyramide liée décide seule si l'effet tourne. - mb: quad.depth_mb(s_px, focus_plane, dof_pyramid.is_some()), - ..Default::default() - }, - y, - uv, + // La pyramide liée décide seule si la profondeur de champ tourne. + let cb = crate::frame_geometry::tilted_screen_cb( + quad, + s_px, + center_px, + cut, + focus_plane, + radius, + square_top, + dof_pyramid.is_some(), + render_px, ); + self.draw_video(enc, &cb, y, uv); } @@ -1921,6 +1934,9 @@ impl Compositor { /// Sprite de curseur (mode 7, ou 13 posé sur le plan). Rend `Err` quand l'art n'est pas /// chargeable, pour que l'appelant retombe sur le curseur dessiné. La géométrie vient de /// `frame_geometry::cursor_sprite_cb`, partagée avec Windows et Linux. + /// + /// Avec `model`, le même sprite extrudé (mode 15, `cursor_model_cb`) : le sprite en + /// texture(2), son champ de distance en texture(4). #[allow(clippy::too_many_arguments)] unsafe fn draw_cursor_sprite( &self, @@ -1930,9 +1946,29 @@ impl Compositor { a: f32, sprite: &crate::scene::SceneCursorSprite, clip: [f32; 4], - volume: Option<&crate::frame_geometry::CursorVolume>, + model: Option, ) -> Result<()> { let (tex, iw, ih) = self.cached_image(sprite.path.as_str())?; + // Sans champ de distance, repli sur le sprite plat plutôt qu'aucun curseur. Parité Linux. + if let Some(pose) = model { + match self.cursor_sdf(sprite.path.as_str()) { + Ok((sdf, shape)) => { + let shape = crate::frame_geometry::SpriteShape { + hotspot: [sprite.hotspot_x, sprite.hotspot_y], + ..shape + }; + if let Some(cb) = crate::frame_geometry::cursor_model_cb( + placement, size_px, pose, shape, a, clip, + ) { + enc.set_fragment_texture(2, Some(&tex)); + enc.set_fragment_texture(4, Some(&sdf)); + self.draw_solid(enc, &cb); + } + return Ok(()); + } + Err(e) => eprintln!("[curseur] champ de \"{}\" : {e:#}", sprite.path), + } + } let (rw, rh) = (self.render_w as f32, self.render_h as f32); let ar = iw as f32 / ih.max(1) as f32; let (pw, ph) = if ar >= 1.0 { (size_px, size_px / ar) } else { (size_px * ar, size_px) }; @@ -1943,7 +1979,6 @@ impl Compositor { a, clip, [rw, rh], - volume, ); enc.set_fragment_texture(2, Some(&tex)); self.draw_solid(enc, &cb); @@ -1955,8 +1990,10 @@ impl Compositor { /// Le repli « dot + ring » mathématique (mode 4) du chemin Windows n'est pas porté : /// l'app résout toujours un jeu de sprites, et l'art intégré couvre les états qu'un /// thème ne fournit pas. S'il n'y a vraiment aucun sprite, ne rien dessiner est plus - /// honnête qu'un curseur qui ne ressemble à aucun réglage. Le volume disparaît avec lui : - /// sans sprite, pas de silhouette à extruder ni d'ombre de contact. + /// honnête qu'un curseur qui ne ressemble à aucun réglage. + /// + /// Avec `model`, ce sprite extrudé (mode 15) : même résolution que `plan_cursor`, qui en a + /// tiré la pose. Parité `compositor_windows.rs`. #[allow(clippy::too_many_arguments)] unsafe fn draw_cur_themed( &self, @@ -1967,12 +2004,11 @@ impl Compositor { size_px: f32, a: f32, clip: [f32; 4], - volume: Option<&crate::frame_geometry::CursorVolume>, + model: Option, ) { let sprite = cursor_type.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow")); if let Some(sprite) = sprite { - if let Err(e) = - self.draw_cursor_sprite(enc, placement, size_px, a, sprite, clip, volume) + if let Err(e) = self.draw_cursor_sprite(enc, placement, size_px, a, sprite, clip, model) { eprintln!("[compositor] sprite curseur \"{}\" : {e:#}", sprite.path); } @@ -2148,7 +2184,7 @@ impl Compositor { ]; if cfg.shadow { let spread = SCREEN_SHADOW_SPREAD_FRAC * g.frame_min_px; - let offset = [0.0, SCREEN_SHADOW_OFFSET_FRAC * g.frame_min_px]; + let offset = g.screen_shadow_offset(); let opacity = 0.45 * lp.shadow_scale; // L'ombre suit la silhouette réellement affichée : rect arrondi quand l'écran est // droit, quadrilatère projeté quand il est penché. Un rect droit derrière un écran @@ -2218,23 +2254,21 @@ impl Compositor { track, t: self.cursor_time.borrow().unwrap_or(frame / crate::frame_geometry::FPS), }, - ); + ) + .map(|p| p.for_backend(self.gpu.backend == crate::d3d::Backend::Cpu)); if let Some(plan) = plan { let sprites = scene_ref .as_ref() .map(|s| s.cursor.cursor_sprites.clone()) .unwrap_or_default(); let kind = plan.cursor_type.as_deref(); - let volume = plan.volume.as_ref(); - // Ombre de contact du volume : une fois, sur la scène, SOUS le curseur et sa - // traînée — et seulement si le sprite existe. Parité `compositor_windows.rs`. - if let Some(v) = volume { - let sprite = kind.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow")); - if sprite.is_some_and(|s| self.cached_image(s.path.as_str()).is_ok()) { - let e = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; - self.draw_solid(e, &v.shadow); - e.end_encoding(); + // L'impact des clics (mode 16, sans texture), posé sur l'écran SOUS le curseur. + if !plan.impacts.is_empty() { + let e = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; + for cb in &plan.impacts { + self.draw_solid(e, cb); } + e.end_encoding(); } if plan.taps <= 1 { let e = self.begin_pass(cmd_buf, &self.rt, None, &self.pipeline_main)?; @@ -2246,7 +2280,7 @@ impl Compositor { plan.size_px, plan.alpha, plan.clip, - volume, + plan.model, ); e.end_encoding(); } else { @@ -2272,7 +2306,7 @@ impl Compositor { plan.size_px, plan.alpha, plan.clip, - volume, + plan.model, ); } e.end_encoding(); @@ -3506,6 +3540,425 @@ mod tests { ); } + // ----------------------------------------------------------------------- + // Curseur modélisé (mode 15) : pendant de `tests/cursor_model_render.rs` (Windows). Il tourne sur la + // CI macOS : c'est la seule compilation et la seule exécution du mode 15 en MSL. + // ----------------------------------------------------------------------- + + /// Les états que le rendu passe en revue (hotspots de `DEFAULT_CURSOR_SPRITES`), et s'ils + /// sont centrés : ni tangage ni lacet. + const MODEL_STATES: [(&str, [f32; 2], bool); 6] = [ + ("arrow", [0.119, 0.0874], false), + ("pointer", [0.3893, 0.0032], false), + ("text", [0.4375, 0.5333], true), + ("open-hand", [0.4375, 0.1781], false), + ("resize-ew", [0.4881, 0.4706], true), + ("not-allowed", [0.5, 0.5], true), + ]; + + /// Écran seul (strié) sous un zoom 1 porteur de `rotation`, curseur par défaut à la taille + /// `size` avec les sprites livrés de `MODEL_STATES`. `model3d` : `None` = clé absente. + fn model_scene_json(rotation: &str, model3d: Option, theme: &str, show: bool, size: f32) -> String { + let model3d = model3d.map(|m| format!(r#","model3d":{m}"#)).unwrap_or_default(); + let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../public/cursors/default").replace('\\', "/"); + let sprites: Vec = MODEL_STATES + .iter() + .map(|(key, [hx, hy], _)| format!(r#""{key}":{{"path":"{dir}/{key}.png","hotspotX":{hx},"hotspotY":{hy}}}"#)) + .collect(); + let sprites = sprites.join(","); + format!( + r##"{{"clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, + "effects":{{"padding":0.2,"blur":false,"shadow":0,"roundnessFrac":0.03,"motionBlur":0}}, + "background":{{"kind":"gradient","angleDeg":135,"stops":["#5b6ee1","#e8a0bf"]}}, + "zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":10,"scale":1,"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":{rotation}}}], + "annotations":[], + "cursor":{{"show":{show},"size":{size},"smoothing":0,"motionBlur":0,"clickBounce":2.5{model3d},"clipToBounds":false,"theme":"{theme}", + "cursorSprites":{{{sprites}}}}}, + "cropByClip":[null], + "output":{{"width":1280,"height":720,"fps":30}}}}"## + ) + } + + /// Une frame striée de barres sombres, bleue ou orangée : rien de NEUTRE, donc un pixel gris + /// est le curseur et un pixel teinté assombri son ombre ; un pixel identique sur les deux + /// teintes est du curseur opaque. + fn model_screen_planes(orange: bool) -> (Vec, Vec) { + let (w, h) = (640u32, 360u32); + let (u, v) = if orange { (100, 170) } else { (150, 120) }; + let y = (0..w * h) + .map(|i| { + let (col, row) = (i % w, i / w); + if row % 24 >= 8 && row % 24 < 12 && (col / 40) % 3 != 2 { 90 } else { 150 } + }) + .collect(); + let uv = (0..w * (h / 2)).map(|i| if i % 2 == 0 { u } else { v }).collect(); + (y, uv) + } + + fn compose_model( + comp: &Compositor, + screen: &FakeFrame, + json: &str, + track: &crate::cursor::CursorTrack, + ) -> Vec { + let scene = crate::scene::Scene::from_json(json).expect("scene json"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(false); + comp.set_scene(Some(scene)); + comp.set_cursor(track.clone()); + comp.set_cursor_time(Some(2.0)); + comp.set_timeline_time(Some(2.0)); + let mut cfg = crate::config::Cfg::c8(); + cfg.bg_blur = false; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = true; + cfg.mblur_n = 1; + cfg.shadow = false; + unsafe { + comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback_direct").2 + } + } + + /// Immobile en (`at`, `at`) dans l'état `kind`, avec un clic au creux de `tap` avant l'instant + /// rendu si `click` : le modèle est alors posé. + fn model_track(kind: &str, click: bool, at: f32) -> crate::cursor::CursorTrack { + crate::cursor::CursorTrack::new( + vec![(0.0, at, at), (9.0, at, at)], + if click { vec![2.0 - 0.0495] } else { vec![] }, + vec![(0.0, kind.to_string())], + ) + } + + fn model_save(name: &str, rgba: &[u8]) { + let Ok(dir) = std::env::var("OPENSCREEN_CURSOR3D_OUT") else { return }; + let path = format!("{dir}/macos-{name}.png"); + image::RgbaImage::from_raw(1280, 720, rgba.to_vec()) + .expect("dimensions du readback") + .save(&path) + .unwrap_or_else(|e| panic!("ecriture {path} : {e}")); + } + + fn model_neutral(p: &[u8]) -> bool { + p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]) < 12 + } + + fn model_luma(p: &[u8]) -> f32 { + 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32 + } + + /// Ce qui distingue la frame avec flèche de la même sans : pixels de la flèche (neutres), + /// d'ombre (le bleu assombri, hors frange antialiasée), leurs centroïdes, et la distance de + /// l'ombre la plus proche de l'apex (le pixel de flèche le plus haut). + struct ModelSplit { + white: usize, + black: usize, + shadow: usize, + body_c: [f32; 2], + shadow_c: [f32; 2], + near_apex: f32, + } + + fn model_split(with: &[u8], without: &[u8]) -> ModelSplit { + let (w, h) = (1280i32, 720i32); + let at = |buf: &[u8], x: i32, y: i32| { + let i = ((y * w + x) * 4) as usize; + [buf[i], buf[i + 1], buf[i + 2]] + }; + let body = |x: i32, y: i32| { + x >= 0 && y >= 0 && x < w && y < h && at(with, x, y) != at(without, x, y) && model_neutral(&at(with, x, y)) + }; + let mut s = ModelSplit { white: 0, black: 0, shadow: 0, body_c: [0.0; 2], shadow_c: [0.0; 2], near_apex: f32::MAX }; + let (mut nb, mut apex) = (0usize, [0i32, i32::MAX]); + let mut shadows = Vec::new(); + for y in 0..h { + for x in 0..w { + let (a, b) = (at(with, x, y), at(without, x, y)); + if a == b { + continue; + } + if model_neutral(&a) { + nb += 1; + s.white += a.iter().all(|&c| c > 200) as usize; + s.black += a.iter().all(|&c| c < 45) as usize; + s.body_c = [s.body_c[0] + x as f32, s.body_c[1] + y as f32]; + if y < apex[1] { + apex = [x, y]; + } + } else if model_luma(&a) < model_luma(&b) - 6.0 + && !(-2..=2).any(|dy| (-2..=2).any(|dx| body(x + dx, y + dy))) + { + shadows.push([x as f32, y as f32]); + } + } + } + s.shadow = shadows.len(); + s.body_c = [s.body_c[0] / nb.max(1) as f32, s.body_c[1] / nb.max(1) as f32]; + for p in &shadows { + s.shadow_c = [s.shadow_c[0] + p[0], s.shadow_c[1] + p[1]]; + s.near_apex = s.near_apex.min((p[0] - apex[0] as f32).hypot(p[1] - apex[1] as f32)); + } + s.shadow_c = [s.shadow_c[0] / s.shadow.max(1) as f32, s.shadow_c[1] / s.shadow.max(1) as f32]; + s + } + + /// Les pixels OPAQUES du curseur : identiques sur les deux teintes, différents du contenu nu. + fn model_opaque(on_blue: &[u8], on_orange: &[u8], bare: &[u8]) -> Vec { + (0..1280 * 720) + .map(|i| { + let px = |b: &[u8]| [b[i * 4], b[i * 4 + 1], b[i * 4 + 2]]; + px(on_blue) == px(on_orange) && px(on_blue) != px(bare) + }) + .collect() + } + + fn model_iou(a: &[bool], b: &[bool]) -> f32 { + let inter = a.iter().zip(b).filter(|(x, y)| **x && **y).count(); + let union = a.iter().zip(b).filter(|(x, y)| **x || **y).count(); + inter as f32 / union.max(1) as f32 + } + + /// Part des pixels de `a` qui ont un pixel de `b` à `r` px au plus (norme max). + fn model_within(a: &[bool], b: &[bool], r: i32) -> f32 { + let near = |i: usize| { + let (x, y) = ((i % 1280) as i32, (i / 1280) as i32); + (-r..=r).any(|dy| { + (-r..=r).any(|dx| { + let (x, y) = (x + dx, y + dy); + (0..1280).contains(&x) && (0..720).contains(&y) && b[(y * 1280 + x) as usize] + }) + }) + }; + let hits: Vec = (0..a.len()).filter(|&i| a[i]).collect(); + hits.iter().filter(|&&i| near(i)).count() as f32 / hits.len().max(1) as f32 + } + + /// Parts des pixels sombres, clairs et rouges parmi ceux du masque qui tombent dans l'une + /// des trois classes. + fn model_palette(rgba: &[u8], mask: &[bool]) -> [f32; 3] { + let mut c = [0usize; 3]; + for (i, _) in mask.iter().enumerate().filter(|(_, m)| **m) { + let p = [rgba[i * 4] as i32, rgba[i * 4 + 1] as i32, rgba[i * 4 + 2] as i32]; + c[0] += (p.iter().max().unwrap() < &70) as usize; + c[1] += (p.iter().min().unwrap() > &150) as usize; + c[2] += (p[0] > p[1] + 60 && p[0] > p[2] + 60) as usize; + } + let n = c.iter().sum::().max(1) as f32; + c.map(|k| k as f32 / n) + } + + #[test] + fn the_modelled_arrow_casts_its_shadow_and_touches_the_screen() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let (y, uv) = model_screen_planes(false); + let screen = FakeFrame::from_planes(640, 360, &y, &uv); + let (still, clicked) = (model_track("arrow", false, 0.45), model_track("arrow", true, 0.45)); + for (name, rotation) in [("flat", "null"), ("iso", r#""iso""#)] { + let json = |m: Option, theme: &str, show: bool| model_scene_json(rotation, m, theme, show, 3.0); + let bare = compose_model(&comp, &screen, &json(Some(true), "default", false), &still); + let absent = compose_model(&comp, &screen, &json(None, "default", true), &still); + let off = compose_model(&comp, &screen, &json(Some(false), "default", true), &still); + let other = compose_model(&comp, &screen, &json(Some(true), "other", true), &still); + let hover = compose_model(&comp, &screen, &json(Some(true), "default", true), &still); + let touch = compose_model(&comp, &screen, &json(Some(true), "default", true), &clicked); + model_save(&format!("{name}-hover"), &hover); + model_save(&format!("{name}-touch"), &touch); + assert!(absent == off && absent == other, "{name}: le chemin plat a change"); + assert!(absent != hover, "{name}: le reglage allume ne change rien"); + let (a, b) = (model_split(&hover, &bare), model_split(&touch, &bare)); + println!( + "{name} : blanc {}, noir {}, ombre {} (apex a {:.1} px, posee {:.1} px), centroides {:?} / {:?}", + a.white, a.black, a.shadow, a.near_apex, b.near_apex, a.body_c, a.shadow_c + ); + // Taille 3 en 1280x720 : ~50 a 60 px de haut, ~1000 px de silhouette. + assert!(a.white > 150 && a.black > 250, "{name}: matieres absentes"); + assert!(a.shadow > 300, "{name}: pas d'ombre"); + assert!(a.shadow_c[0] > a.body_c[0] && a.shadow_c[1] > a.body_c[1], "{name}: ombre pas en bas a droite"); + assert!(b.near_apex < 8.0, "{name}: posee, l'ombre est a {:.1} px de l'apex", b.near_apex); + assert!(a.near_apex > 12.0, "{name}: en l'air, l'ombre touche l'apex ({:.1} px)", a.near_apex); + } + } + + /// Chaque état garde son art et sa silhouette : posé au centre de l'écran (vu de face), le + /// modèle couvre ce que couvre le sprite plat, avec ses couleurs ; il porte une ombre en + /// l'air, suit l'inclinaison du plan, et un autre thème (ou le réglage éteint) reste plat. + #[test] + fn every_modelled_state_keeps_its_art_footprint_and_shadow() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let (y, uv) = model_screen_planes(false); + let blue = FakeFrame::from_planes(640, 360, &y, &uv); + let (y, uv) = model_screen_planes(true); + let orange = FakeFrame::from_planes(640, 360, &y, &uv); + let json = |rotation: &str, m: Option, theme: &str, show: bool| model_scene_json(rotation, m, theme, show, 5.0); + let bare = compose_model(&comp, &blue, &json("null", Some(true), "default", false), &model_track("arrow", false, 0.5)); + let mut failures = Vec::new(); + for (key, _, centred) in MODEL_STATES { + let (still, clicked) = (model_track(key, false, 0.5), model_track(key, true, 0.5)); + let on = json("null", Some(true), "default", true); + let flat = json("null", Some(false), "default", true); + let touch = compose_model(&comp, &blue, &on, &clicked); + let touch_b = compose_model(&comp, &orange, &on, &clicked); + let hover = compose_model(&comp, &blue, &on, &still); + let tilted = compose_model(&comp, &blue, &json(r#""iso""#, Some(true), "default", true), &still); + let sprite = compose_model(&comp, &blue, &flat, &still); + let sprite_b = compose_model(&comp, &orange, &flat, &still); + let absent = compose_model(&comp, &blue, &json("null", None, "default", true), &still); + let other = compose_model(&comp, &blue, &json("null", Some(true), "other", true), &still); + model_save(&format!("art-{key}-touch"), &touch); + model_save(&format!("art-{key}-iso"), &tilted); + if absent != sprite || other != sprite { + failures.push(format!("{key}: le sprite plat a change")); + } + + let (m3d, m2d) = (model_opaque(&touch, &touch_b, &bare), model_opaque(&sprite, &sprite_b, &bare)); + let overlap = model_iou(&m3d, &m2d); + let (near3d, near2d) = (model_within(&m3d, &m2d, 2), model_within(&m2d, &m3d, 2)); + let (pal3d, pal2d) = (model_palette(&touch, &m3d), model_palette(&sprite, &m2d)); + let area = m2d.iter().filter(|m| **m).count() as f32; + let differs = |a: &[u8], i: usize| a[i * 4..i * 4 + 3] != bare[i * 4..i * 4 + 3]; + let shadow = (0..1280 * 720) + .filter(|&i| { + let (a, b) = (&hover[i * 4..i * 4 + 3], &bare[i * 4..i * 4 + 3]); + differs(&hover, i) && !model_neutral(a) && model_luma(a) < model_luma(b) - 6.0 + }) + .count() as f32; + let body = |rgba: &[u8]| (0..1280 * 720).filter(|&i| model_neutral(&rgba[i * 4..i * 4 + 3]) && differs(rgba, i)).count() as f32; + let (flat_body, tilted_body) = (body(&hover), body(&tilted)); + println!( + "{key} : IoU {overlap:.3}, a 2 px pres {near3d:.3} / {near2d:.3}, sprite {area} px, palette 3D {pal3d:?} / sprite {pal2d:?}, \ + ombre {shadow} px, corps a plat {flat_body} / incline {tilted_body}" + ); + let (min_iou, min_near, palette_tol) = if centred { (0.75, 0.98, 0.07) } else { (0.6, 0.9, 0.2) }; + if overlap <= min_iou || near3d < min_near || near2d < min_near { + failures.push(format!("{key}: la silhouette s'ecarte du sprite (IoU {overlap}, {near3d} / {near2d})")); + } + for (c, (a, b)) in ["sombre", "clair", "rouge"].iter().zip(pal3d.iter().zip(pal2d.iter())) { + if (a - b).abs() >= palette_tol { + failures.push(format!("{key}: part {c} {a} contre {b} dans le sprite")); + } + } + if shadow <= 0.3 * area { + failures.push(format!("{key}: pas d'ombre en l'air ({shadow} px)")); + } + // Le préset iso réduit le plan (unité 51,5 contre 62,6 px) et incline le modèle. + if !(tilted_body < 0.9 * flat_body && tilted_body > 0.3 * flat_body) { + failures.push(format!("{key}: le modele ne suit pas le plan ({tilted_body} / {flat_body})")); + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + + /// Pendant de `tests/cursor_tap_render.rs` (Windows) : sous un lissage qui traîne loin + /// derrière la souris, la pointe du modèle se pose sur la pastille rouge du clic, et l'anneau + /// de l'impact (mode 16) l'entoure, à plat et incliné. Seule exécution du mode 16 en MSL. + #[test] + fn the_modelled_tip_taps_the_marked_click_target() { + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("Compositor::new_sized"); + let (w, h) = (640u32, 360u32); + // Pastille de 5 texels au centre d'un bloc de chroma : symétrique, chroma comprise. + let (tx, ty) = (193.0f32, 129.0f32); + let red = |x: f32, y: f32| (x - tx).hypot(y - ty) <= 5.0; + let (mut yp, mut uvp) = model_screen_planes(false); + for row in 0..h { + for col in 0..w { + // Un carré uni autour de la cible : l'anneau s'y mesure sans les barres. + if (col as f32 - tx).abs() < 45.0 && (row as f32 - ty).abs() < 45.0 { + yp[(row * w + col) as usize] = 150; + } + if red(col as f32 + 0.5, row as f32 + 0.5) { + yp[(row * w + col) as usize] = 63; + } + } + } + for row in 0..h / 2 { + for col in 0..w / 2 { + if red(2.0 * col as f32 + 1.0, 2.0 * row as f32 + 1.0) { + let i = (row * w + 2 * col) as usize; + uvp[i] = 102; + uvp[i + 1] = 240; + } + } + } + let screen = FakeFrame::from_planes(w, h, &yp, &uvp); + // Arrivée sur la cible à 1,7 s, départ à 2,1 s, clic à `tc` entre les deux ; lissé à 0,5. + let target = (tx / w as f32, ty / h as f32); + let gesture = |tc: f32| { + let samples = (0..=240) + .map(|k| { + let t = k as f32 / 60.0; + let (x, y) = if t < 1.7 { + let f = t / 1.7; + (0.1 + (target.0 - 0.1) * f, 0.8 + (target.1 - 0.8) * f) + } else if t < 2.1 { + target + } else { + let f = ((t - 2.1) / 0.5).min(1.0); + (target.0 + 0.3 * f, target.1 + 0.2 * f) + }; + (t, x, y) + }) + .collect(); + crate::cursor::CursorTrack::new(samples, vec![tc], vec![(0.0, "arrow".to_string())]).smoothed(0.5) + }; + let (contact, ring) = (gesture(2.0 - 0.0495), gesture(2.0 - 0.16)); + let px = |b: &[u8], x: i32, y: i32| { + let i = ((y * 1280 + x) * 4) as usize; + [b[i] as i32, b[i + 1] as i32, b[i + 2] as i32] + }; + let is_red = |p: [i32; 3]| p[0] > p[1] + 60 && p[0] > p[2] + 60; + for rotation in ["null", r#""iso""#] { + let json = model_scene_json(rotation, Some(true), "default", true, 3.0); + let hidden = json.replace(r#""rotation":"#, r#""hideCursor":true,"rotation":"#); + let quiet = json.replace(r#""clickBounce":2.5"#, r#""clickBounce":0"#); + let bare = compose_model(&comp, &screen, &hidden, &contact); + let (mut sx, mut sy, mut n) = (0.0f32, 0.0f32, 0.0f32); + for y in 0..720 { + for x in 0..1280 { + if is_red(px(&bare, x, y)) { + (sx, sy, n) = (sx + x as f32 + 0.5, sy + y as f32 + 0.5, n + 1.0); + } + } + } + assert!(n > 10.0, "{rotation}: pastille introuvable"); + let m = [sx / n, sy / n]; + let touch = compose_model(&comp, &screen, &json, &contact); + model_save(&format!("tap-{}", if rotation == "null" { "flat" } else { "iso" }), &touch); + assert!(!is_red(px(&touch, m[0] as i32, (m[1] + 2.0) as i32)), "{rotation}: la pointe manque la pastille"); + // L'écart dû à l'impact, le long de deux demi-droites (haut, gauche) : même rayon, à la + // perspective près sous `iso` (l'anneau y est une ellipse). + let (on, off) = (compose_model(&comp, &screen, &json, &ring), compose_model(&comp, &screen, &quiet, &ring)); + let peak = |dx: i32, dy: i32| { + (4..45) + .map(|r| { + let (a, b) = (px(&on, m[0] as i32 + dx * r, m[1] as i32 + dy * r), px(&off, m[0] as i32 + dx * r, m[1] as i32 + dy * r)); + ((0..3).map(|c| (a[c] - b[c]).abs()).sum::(), r) + }) + .max() + .unwrap() + }; + let (up, left) = (peak(0, -1), peak(-1, 0)); + println!("{rotation} : pastille en {m:?}, anneau haut {up:?}, gauche {left:?}"); + assert!(up.0 > 60 && left.0 > 60, "{rotation}: anneau absent ({up:?} {left:?})"); + let tol = if rotation == "null" { 1 } else { 2 }; + assert!((up.1 - left.1).abs() <= tol, "{rotation}: anneau décentré ({up:?} {left:?})"); + } + } + /// Le pendant macOS de `compositor_windows`'s `every_shader_entry_point_compiles`. /// /// `shaders.metal` est compilé À L'EXÉCUTION par `new_library_with_source` : une diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index e3e9e4214..62efa1e71 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -11,7 +11,7 @@ pub use crate::frame_geometry::{live_params_from_scene, webcam_shape_code, Layer use crate::frame_geometry::{ cover_crop_uv, cover_uv_rect, decode_data_uri, ease_in_out_cubic, lerp, lerp4, parse_hex, preset_placements, remap_box, screen_source_rect, timeline, CursorPlacement, - FrameParams, Placement, ShadowCaster, CURSOR_BASE_SIZE_FRAC, FPS, SCREEN_SHADOW_OFFSET_FRAC, + FrameParams, Placement, ShadowCaster, SpriteShape, CURSOR_BASE_SIZE_FRAC, FPS, SCREEN_SHADOW_SPREAD_FRAC, SHADOW_TUNING_REF_PX, WEBCAM_SHADOW_OFFSET_FRAC, WEBCAM_SHADOW_OPACITY, WEBCAM_SHADOW_SPREAD_FRAC, }; @@ -175,6 +175,9 @@ pub struct Compositor { /// Valeur de `img_tick` au début de la frame en cours. Tout ce qui a été touché depuis /// appartient au jeu actif et ne peut pas être évincé — voir `cached_image`. img_frame_start: std::cell::Cell, + /// Champs de distance des sprites de curseur (mode 15), R16F, par chemin, avec leur forme. + /// Pas d'éviction : seuls les seize sprites du thème par défaut y passent (~2,6 Mo en tout). + sdf_cache: RefCell>, /// Masque de segmentation du sujet webcam, R8 à la résolution du modèle. Écrit par /// `set_webcam_mask` depuis le thread d'inférence, lu au moment de dessiner la webcam. /// `None` tant qu'aucune frame n'a été segmentée — l'effet reste alors éteint plutôt que @@ -639,6 +642,7 @@ impl Compositor { img_cache: RefCell::new(HashMap::new()), img_tick: std::cell::Cell::new(0), img_frame_start: std::cell::Cell::new(0), + sdf_cache: RefCell::new(HashMap::new()), webcam_mask: RefCell::new(None), render_size: Cell::new((out_w, out_h)), resize_target: RefCell::new(None), @@ -1139,6 +1143,39 @@ impl Compositor { Ok((srv.unwrap(), w, h)) } + /// Champ de distance du sprite `path` (t4 du mode 15) et sa forme, calculés au premier appel. + unsafe fn cursor_sdf(&self, path: &str) -> Result<(ID3D11ShaderResourceView, SpriteShape)> { + if let Some(hit) = self.sdf_cache.borrow().get(path) { + return Ok(hit.clone()); + } + let sdf = crate::cursor_sdf::CursorSdf::load(path)?; + let texels = sdf.f16_bytes(); + let td = D3D11_TEXTURE2D_DESC { + Width: sdf.width, + Height: sdf.height, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R16_FLOAT, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_IMMUTABLE, + BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let init = D3D11_SUBRESOURCE_DATA { + pSysMem: texels.as_ptr() as *const c_void, + SysMemPitch: sdf.width * 2, + SysMemSlicePitch: 0, + }; + let mut tex: Option = None; + self.dev.CreateTexture2D(&td, Some(&init), Some(&mut tex))?; + let mut srv: Option = None; + self.dev.CreateShaderResourceView(&tex.unwrap(), None, Some(&mut srv))?; + let entry = (srv.unwrap(), sdf.shape); + self.sdf_cache.borrow_mut().insert(path.to_string(), entry.clone()); + Ok(entry) + } + /// Extrait la frame webcam en RGB8 à la résolution du modèle, dans `out`. /// /// `src` est le rect source de la webcam en UV (le même que celui passé à `draw_video`), @@ -1531,6 +1568,9 @@ impl Compositor { /// l'image) tombe sur `center`, à la taille de référence `size_px`. `Err` → l'appelant /// retombe sur `draw_cursor` (math dot+ring). La géométrie vient de /// `frame_geometry::cursor_sprite_cb`, partagée avec macOS et Linux. + /// + /// Avec `model`, le même sprite extrudé (mode 15, `cursor_model_cb`) : le sprite en t2, son + /// champ de distance en t4. unsafe fn draw_cursor_sprite( &self, placement: CursorPlacement, @@ -1538,10 +1578,31 @@ impl Compositor { a: f32, sprite: &SceneCursorSprite, clip: [f32; 4], - volume: Option<&crate::frame_geometry::CursorVolume>, + model: Option, ) -> Result<()> { let path = sprite.path.as_str(); let (srv, iw, ih) = self.cached_image(path)?; + // Sans champ de distance, repli sur le sprite plat plutôt que sur le curseur math. + // Parité Linux. + if let Some(pose) = model { + match self.cursor_sdf(path) { + Ok((sdf, shape)) => { + let shape = + SpriteShape { hotspot: [sprite.hotspot_x, sprite.hotspot_y], ..shape }; + if let Some(cb) = crate::frame_geometry::cursor_model_cb( + placement, size_px, pose, shape, a, clip, + ) { + self.upload_cb(&cb); + self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); + self.ctx.PSSetShaderResources(4, Some(&[Some(sdf)])); + self.ctx.Draw(4, 0); + self.ctx.PSSetShaderResources(4, Some(&[None])); + } + return Ok(()); + } + Err(e) => eprintln!("[curseur] champ de \"{path}\" : {e:#}"), + } + } let ar = iw as f32 / ih as f32; let (pw, ph) = if ar >= 1.0 { (size_px, size_px / ar) } else { (size_px * ar, size_px) }; let cb = crate::frame_geometry::cursor_sprite_cb( @@ -1551,7 +1612,6 @@ impl Compositor { a, clip, [self.rw(), self.rh()], - volume, ); self.upload_cb(&cb); self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); @@ -1560,7 +1620,8 @@ impl Compositor { } /// Sprite de l'état courant (`cursor_type`, ex. `"text"`), à défaut celui de la flèche, - /// à défaut le curseur math (dot+ring). + /// à défaut le curseur math (dot+ring). Avec `model`, ce sprite extrudé (mode 15) : même + /// résolution que `plan_cursor`, qui en a tiré la pose. /// /// Le repli sur la flèche compte : un thème n'apporte que sa flèche et son pointeur, les /// autres états venant de l'art intégrée — mais si un état inconnu apparaît, mieux vaut @@ -1574,17 +1635,17 @@ impl Compositor { size_px: f32, a: f32, clip: [f32; 4], - volume: Option<&crate::frame_geometry::CursorVolume>, + model: Option, ) { let sprite = cursor_type.and_then(|t| sprites.get(t)).or_else(|| sprites.get("arrow")); if let Some(sprite) = sprite { - if self.draw_cursor_sprite(placement, size_px, a, sprite, clip, volume).is_ok() { + if self.draw_cursor_sprite(placement, size_px, a, sprite, clip, model).is_ok() { return; } } // Le repli math reste droit même sur un plan incliné : il ne devrait plus apparaître // maintenant que l'art par défaut existe, et lui donner sa propre passe de warp pour - // un cas de secours ne se justifie pas. Il n'a donc pas de volume non plus. + // un cas de secours ne se justifie pas. self.draw_cursor(placement.upright_center(), size_px, a, clip); } @@ -1723,8 +1784,6 @@ impl Compositor { let mb_taps = g.mb_taps; let mb_amount = g.mb_amount; let source_t = g.source_t; - let zoom_rotation = g.zoom_rotation; - let zoom_rotation_dyn = g.zoom_rotation_dyn; let _padding_scale = g.padding_scale; let cut = g.cut; let s_dst = g.s_dst; @@ -1867,7 +1926,7 @@ impl Compositor { let render_px = [self.rw(), self.rh()]; if cfg.shadow { let spread = SCREEN_SHADOW_SPREAD_FRAC * frame_min_px; - let offset = [0.0, SCREEN_SHADOW_OFFSET_FRAC * frame_min_px]; + let offset = g.screen_shadow_offset(); let opacity = 0.45 * lp.shadow_scale; match g.shadow_caster(render_px) { ShadowCaster::Upright { dst, size_px, radius } => { @@ -1885,7 +1944,33 @@ impl Compositor { self.draw_solid(&cb); } let square_top = g.screen_square_top(); - if crate::regions::is_identity_rotation(zoom_rotation) { + if let Some(quad) = tilt { + // Écran incliné (angle fixe) ou vu par la caméra réelle : warp inverse du mode 8 dans + // la bbox du quad projeté (`tilted_screen_cb`, partagé). Les coins arrondis y sont + // rendus dans le repère DU PLAN : sans eux le plan a des arêtes de couteau qui + // tranchent le contenu en pleine phrase, et l'œil lit une découpe là où il devrait + // lire une inclinaison. + // t2 EXPLICITE : `draw_video` ne lie que t0/t1, et t2 garde sinon ce que le draw + // précédent y a laissé (un wallpaper, un sprite). `None` quand l'effet est coupé : + // `k = 0`, le shader n'y lit rien. + self.ctx.PSSetShaderResources(2, Some(&[dof_srv.clone()])); + self.draw_video( + &crate::frame_geometry::tilted_screen_cb( + &quad, + s_px, + quad_center_px, + [su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv], + g.focus_plane, + s_radius, + square_top, + dof, + render_px, + ), + &sy, + &suv, + ); + self.ctx.PSSetShaderResources(2, Some(&[None])); + } else { self.draw_video( &LayerCB { dst: s_dst, @@ -1902,71 +1987,6 @@ impl Compositor { &sy, &suv, ); - } else { - // Tilt 3D (zoom "rotation" iso/left/right) : warp bilinéaire inverse (mode 8, voir - // shaders.hlsl). Pas de motion blur dans ce chemin — le tilt est un effet bref, la - // simplification ne se voit pas. Les coins arrondis, eux, se voyaient : sans eux le - // plan a des arêtes de couteau qui tranchent le contenu en pleine phrase, et l'œil lit - // une découpe (« un overflow hidden qui tronque l'enregistrement ») là où il devrait - // lire une inclinaison. Ils sont donc rendus, dans le repère DU PLAN. - let quad = tilt.unwrap_or_else(|| { - crate::regions::rotated_quad_corners_px( - s_px[0], - s_px[1], - zoom_rotation, - zoom_rotation_dyn, - ) - }); - let corners = quad.corners; - // Taille du plan dans son propre repère, avant projection : c'est là que vit le rayon, - // pour qu'il reste un rayon constant le long du bord et non un arrondi qui s'étire avec - // la perspective. - let plane_px = [s_px[0] * quad.scale, s_px[1] * quad.scale]; - let (cx_px, cy_px) = (quad_center_px[0], quad_center_px[1]); - let (min_x, max_x) = corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| { - (mn.min(x), mx.max(x)) - }); - let (min_y, max_y) = corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| { - (mn.min(y), mx.max(y)) - }); - let bbox_w = (max_x - min_x).max(1.0); - let bbox_h = (max_y - min_y).max(1.0); - let bbox_dst = [ - (cx_px + min_x) / self.rw(), - (cy_px + min_y) / self.rh(), - bbox_w / self.rw(), - bbox_h / self.rh(), - ]; - // coins en px LOCAUX à la bbox (0..bbox_w/h), pour matcher `i.local` du shader. - let local = |(x, y): (f32, f32)| -> [f32; 2] { [x - min_x, y - min_y] }; - let [tl0, tl1] = local(corners[0]); - let [tr0, tr1] = local(corners[1]); - let [br0, br1] = local(corners[2]); - let [bl0, bl1] = local(corners[3]); - // t2 EXPLICITE : `draw_video` ne lie que t0/t1, et t2 garde sinon ce que le draw - // précédent y a laissé (un wallpaper, un sprite). `None` quand l'effet est coupé : - // `k = 0`, le shader n'y lit rien. - self.ctx.PSSetShaderResources(2, Some(&[dof_srv.clone()])); - self.draw_video( - &LayerCB { - dst: bbox_dst, - src: [su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv], - quad_px: [bbox_w, bbox_h], - // Le rayon suit la réduction du plan : l'écran incliné est plus petit, ses - // coins le sont d'autant, exactement comme s'il s'éloignait. - radius_px: s_radius * quad.scale, - mode: 8.0, - fx: [tl0, tl1, tr0, tr1], - src_prev: [br0, br1, bl0, bl1], - dst_prev: [plane_px[0], plane_px[1], square_top, 0.0], - // Gradient de profondeur du plan, profondeur du focus et `k` (`depth_mb`). - mb: quad.depth_mb(s_px, g.focus_plane, dof), - ..Default::default() - }, - &sy, - &suv, - ); - self.ctx.PSSetShaderResources(2, Some(&[None])); } // --- curseur custom : suit le mapping src/dst (zoom+layout), click bounce, @@ -1984,23 +2004,17 @@ impl Compositor { track, t: self.cursor_t_override.borrow().unwrap_or(frame / FPS), }, - ); + ) + .map(|p| p.for_backend(self.cpu_backend)); if let Some(plan) = plan { let cursor_sprites: HashMap = scene_ref .as_ref() .map(|s| s.cursor.cursor_sprites.clone()) .unwrap_or_default(); let cursor_type = plan.cursor_type.as_deref(); - let volume = plan.volume.as_ref(); - // Ombre de contact du volume : une fois, sur la scène, SOUS le curseur et sa - // traînée — et seulement si le sprite existe (le repli math n'a pas de volume). - if let Some(v) = volume { - let sprite = cursor_type - .and_then(|t| cursor_sprites.get(t)) - .or_else(|| cursor_sprites.get("arrow")); - if sprite.is_some_and(|s| self.cached_image(&s.path).is_ok()) { - self.draw_solid(&v.shadow); - } + // L'impact des clics (mode 16, sans texture), posé sur l'écran SOUS le curseur. + for cb in &plan.impacts { + self.draw_solid(cb); } if plan.taps <= 1 { self.draw_cur_themed( @@ -2010,7 +2024,7 @@ impl Compositor { plan.size_px, plan.alpha, plan.clip, - volume, + plan.model, ); } else { // Flou RÉEL, pas des copies discrètes : accumule les N échantillons dans un @@ -2028,7 +2042,7 @@ impl Compositor { plan.size_px, plan.alpha, plan.clip, - volume, + plan.model, ); } // composite le buffer accumulé sur la scène (blend "over" normal, prémultiplié). diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index 02642a969..5ce803f38 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -12,6 +12,17 @@ const AUTO_FOLLOW_MAX_FACTOR: f32 = 0.25; const AUTO_FOLLOW_RAMP_DISTANCE: f32 = 0.15; const AUTO_FOLLOW_REFERENCE_MS: f32 = 1000.0 / 40.0; +// Convergence du curseur dessiné sur le point cliqué (`pinned_at`). La tenue couvre le contact du +// curseur modélisé (27 à 74 ms après le clic), plus une image à 24 i/s. +const PIN_APPROACH_S: f32 = 0.25; +const PIN_HOLD_S: f32 = 0.1; +const PIN_RELEASE_S: f32 = 0.25; + +fn smoothstep01(x: f32) -> f32 { + let u = x.clamp(0.0, 1.0); + u * u * (3.0 - 2.0 * u) +} + #[derive(Clone)] pub struct CursorTrack { /// (t_secondes, cx, cy) normalisés dans le cadre screen, triés. @@ -22,6 +33,10 @@ pub struct CursorTrack { follow_samples: Vec<(f32, f32, f32)>, /// instants de clic (secondes) dans la fenêtre. clicks: Vec, + /// Position BRUTE de chaque clic (même ordre que `clicks`) : le pixel où l'utilisateur a + /// réellement cliqué. `smoothed()` la garde telle quelle, alors que la piste lissée passe + /// ailleurs à cet instant (cf. `pinned_at`). + click_points: Vec<(f32, f32)>, /// CHANGEMENTS d'état du curseur : (instant, `"arrow"` / `"text"` / `"pointer"` / …), triés. /// Une fonction en escalier, pas une valeur par échantillon : l'état tient sur des secondes /// entières alors que la position est échantillonnée toutes les 33 ms (~30 Hz, cf. @@ -95,7 +110,9 @@ impl CursorTrack { /// pour que la caméra suive la trajectoire que l'utilisateur voit réellement. pub(crate) fn new(samples: Vec<(f32, f32, f32)>, clicks: Vec, types: Vec<(f32, String)>) -> CursorTrack { let follow_samples = smooth_follow_samples(&samples); - CursorTrack { samples, follow_samples, clicks, types } + let click_points = + clicks.iter().map(|&tc| sample_at(&samples, tc).unwrap_or((0.0, 0.0))).collect(); + CursorTrack { samples, follow_samples, clicks, click_points, types } } /// État du curseur au temps `t` : la dernière transition à `t` ou avant. `None` avant la @@ -158,11 +175,45 @@ impl CursorTrack { sample_at(&self.follow_samples, t) } - /// Position (cx, cy) BRUTE au temps `t` (interpolation linéaire), ou None si hors piste. + /// Position (cx, cy) au temps `t` (interpolation linéaire), ou None si hors piste. Brute, sauf + /// sur une piste `smoothed()`. pub fn at(&self, t: f32) -> Option<(f32, f32)> { sample_at(&self.samples, t) } + /// `at(t)`, ramenée sur la position brute de chaque clic autour de lui : la pointe du curseur + /// modélisé doit toucher le pixel cliqué. Sur une piste lissée, le ressort traîne derrière la + /// souris et le contact tombait à côté de la cible ; sans lissage, la piste brute dérive pendant + /// le contact dès que la souris repart. + /// + /// Pour chaque clic `tc`, un poids vers son point : il monte en smoothstep sur + /// `PIN_APPROACH_S`, vaut 1 sur `[tc, tc + PIN_HOLD_S]` (le contact), puis retombe en + /// smoothstep sur `PIN_RELEASE_S`. Les clics s'appliquent dans l'ordre, le plus récent + /// l'emporte. Continue et de pente continue en `t` ; hors de ces fenêtres, `at(t)` à l'identique. + pub fn pinned_at(&self, t: f32) -> Option<(f32, f32)> { + let (mut x, mut y) = self.at(t)?; + let lo = self.clicks.partition_point(|&tc| tc <= t - PIN_HOLD_S - PIN_RELEASE_S); + let hi = self.clicks.partition_point(|&tc| tc < t + PIN_APPROACH_S); + for i in lo..hi.max(lo) { + let d = t - self.clicks[i]; + let w = if d < 0.0 { + smoothstep01(1.0 + d / PIN_APPROACH_S) + } else { + 1.0 - smoothstep01((d - PIN_HOLD_S) / PIN_RELEASE_S) + }; + let (cx, cy) = self.click_points[i]; + (x, y) = if w >= 1.0 { (cx, cy) } else { (x + (cx - x) * w, y + (cy - y) * w) }; + } + Some((x, y)) + } + + /// Les clics de `(lo, hi]` avec leur position brute, triés. + pub fn clicks_with_points(&self, lo: f32, hi: f32) -> impl Iterator + '_ { + let a = self.clicks.partition_point(|&tc| tc <= lo); + let b = self.clicks.partition_point(|&tc| tc <= hi).max(a); + self.clicks[a..b].iter().copied().zip(self.click_points[a..b].iter().copied()) + } + /// Facteur d'échelle « click bounce ». Cette fonction est la SEULE référence de la courbe : /// son ancien jumeau TS (`nativeCursor.ts`) a été supprimé avec pixi.js, et /// `scripts/inspect-native-cursor-click-bounce.mjs` ne fait que la recopier. Le curseur @@ -174,15 +225,7 @@ impl CursorTrack { pub fn bounce(&self, t: f32) -> f32 { const ANIM_S: f32 = 0.26; // 260 ms const PRESS_FRAC: f32 = 0.38; - let mut last_tc: Option = None; - for &tc in &self.clicks { - if tc <= t { - last_tc = Some(tc); // clics triés croissant -> garde le plus récent <= t - } else { - break; - } - } - let Some(tc) = last_tc else { return 1.0 }; + let Some(tc) = self.last_click_at(t) else { return 1.0 }; let elapsed = (t - tc) / ANIM_S; if elapsed >= 1.0 { return 1.0; @@ -196,6 +239,18 @@ impl CursorTrack { } } + /// L'instant du dernier clic à `t` ou avant, `None` s'il n'y en a pas. C'est le point de + /// départ commun du rebond d'échelle (`bounce`) et de l'abaissement de la hauteur du curseur + /// modélisé — les deux doivent lire le MÊME contact, sinon le sprite s'écrase et se pose à + /// deux instants différents. Les clics sont triés croissant (voir `CursorTrack::new`). + pub(crate) fn last_click_at(&self, t: f32) -> Option { + let i = self.clicks.partition_point(|&tc| tc <= t); + if i == 0 { + return None; + } + Some(self.clicks[i - 1]) + } + /// Les instants de clic dans `(lo, hi]`, triés. L'impact du clic sur le plan /// (`regions::click_impact`) les somme tous, là où `bounce` ne garde que le dernier. pub fn clicks_between(&self, lo: f32, hi: f32) -> &[f32] { @@ -232,8 +287,12 @@ impl CursorTrack { let ys = spring_smooth(&raw_y, stiffness, damping, mass, STEP_S); let samples = times.into_iter().zip(xs).zip(ys).map(|((t, x), y)| (t, x, y)).collect(); // Comme les clics, les changements d'état gardent leurs instants bruts : le lissage - // déplace la trajectoire, pas la chronologie de ce que faisait l'utilisateur. - CursorTrack::new(samples, self.clicks.clone(), self.types.clone()) + // déplace la trajectoire, pas la chronologie de ce que faisait l'utilisateur. Les points + // cliqués restent ceux de la piste brute. + CursorTrack { + click_points: self.click_points.clone(), + ..CursorTrack::new(samples, self.clicks.clone(), self.types.clone()) + } } /// Opacité du curseur (0.0..1.0) selon l'inactivité (auto-hide). @@ -430,6 +489,42 @@ mod tests { ); } + /// `pinned_at` pose le curseur sur le point BRUT du clic pendant le contact, même quand la + /// piste lissée traîne loin derrière, reste continue, et rend `at()` loin des clics. + #[test] + fn the_pinned_position_holds_the_raw_click_point() { + // Un geste rapide de 0,1 à 0,7 qui s'arrête pile sur le clic, puis repart vers le bas. + let samples: Vec<(f32, f32, f32)> = (0..=60) + .map(|k| { + let t = k as f32 / 30.0; + let x = 0.1 + 0.6 * (t / 1.0).min(1.0); + let y = 0.5 + 0.3 * ((t - 1.0) / 0.3).clamp(0.0, 1.0); + (t, x, y) + }) + .collect(); + let raw = CursorTrack::new(samples, vec![1.0], vec![]); + let clicked = raw.at(1.0).unwrap(); + for track in [raw.smoothed(0.0), raw.smoothed(0.5), raw.smoothed(1.0)] { + let lag = track.at(1.0).unwrap(); + for ms in 0..=100 { + let t = 1.0 + ms as f32 / 1000.0; + assert_eq!(track.pinned_at(t), Some(clicked), "{ms} ms après le clic"); + } + // Loin des clics : la piste telle quelle. + for t in [0.5, 0.74, 1.36, 1.8] { + assert_eq!(track.pinned_at(t), track.at(t), "t = {t}"); + } + // Continue : pas de saut d'une milliseconde à l'autre. + for k in 0..2000 { + let (a, b) = (track.pinned_at(k as f32 * 0.001).unwrap(), track.pinned_at((k + 1) as f32 * 0.001).unwrap()); + assert!((a.0 - b.0).abs() < 4e-3 && (a.1 - b.1).abs() < 4e-3, "saut en {k} ms : {a:?} -> {b:?}"); + } + println!("au clic, piste en {lag:?}, point cliqué {clicked:?}"); + } + // Le lissage traîne bel et bien : sans la convergence, le contact tomberait à côté. + assert!(raw.smoothed(0.5).at(1.0).unwrap().0 < 0.69); + } + /// Enveloppe du click bounce : neutre hors fenêtre, continue aux jonctions, creux à 0.76 et /// pic à 1.16 aux bons instants, et un nouveau clic redémarre la courbe. #[test] diff --git a/crates/compositor/src/cursor_sdf.rs b/crates/compositor/src/cursor_sdf.rs new file mode 100644 index 000000000..9bd449ed8 --- /dev/null +++ b/crates/compositor/src/cursor_sdf.rs @@ -0,0 +1,413 @@ +//! Champ de distance signé d'un sprite de curseur, pour le curseur modélisé (mode 15). +//! +//! Le mode 15 extrude la silhouette du sprite de l'état courant : il lui faut sa distance signée +//! en tout point. On la tire UNE fois, au chargement, de l'alpha du PNG : +//! +//! 1. l'alpha antialiasé est suréchantillonné ×`SDF_UPSAMPLE` (bilinéaire, bord du sprite +//! prolongé comme le sampler du mode 7, transparent au-delà) puis seuillé à 0,5 ; +//! 2. transformée de distance euclidienne EXACTE (Felzenszwalb & Huttenlocher, colonnes puis +//! lignes), vers le dedans et vers le dehors ; +//! 3. distance signée entre centres de texels, moins un demi-texel (le bord passe entre deux +//! texels), convertie en unités du modèle : le plus grand côté du sprite vaut 1 ; +//! 4. flou de rayon `SDF_SMOOTH` texels fins. Le seuil laisse un bord en escalier d'un texel +//! fin, dont le gradient hésite entre l'horizontale et la diagonale : les normales du mode 15 +//! en tiraient des stries sur les flancs. Le flou efface l'escalier ; il laisse en place un bord +//! droit (le champ y est affine) et n'arrondit un coin que d'un demi-texel source. +//! +//! La texture (R16F, même rect que le sprite) garde les distances telles quelles : un demi-flottant +//! est exact au millième près autour de zéro, là où la silhouette se joue, et il est filtrable sur +//! les trois backends (D3D11, Metal, wgpu), contrairement au R32F. + +use anyhow::{anyhow, Result}; + +use crate::frame_geometry::SpriteShape; + +/// Suréchantillonnage de l'alpha avant le seuil : la silhouette tombe à un quart de texel près. +pub const SDF_UPSAMPLE: usize = 4; +/// Rayon du flou du champ (deux boîtes successives), en texels fins. +const SDF_SMOOTH: usize = 2; + +/// Le champ d'un sprite, prêt à téléverser. +pub struct CursorSdf { + /// Taille de la texture : celle du sprite × `SDF_UPSAMPLE`. + pub width: u32, + pub height: u32, + /// Distances signées (unités du modèle, négatives dedans), ligne par ligne, texels centrés + /// comme ceux du sprite : la texture couvre exactement son rect. + pub texels: Vec, + /// La forme, hotspot nul : l'appelant pose celui de la scène. + pub shape: SpriteShape, +} + +impl CursorSdf { + /// Décode `path` (chemin ou data URI, comme les sprites) et en tire le champ. + pub fn load(path: &str) -> Result { + let img = if let Some(bytes) = crate::frame_geometry::decode_data_uri(path) { + image::load_from_memory(&bytes) + } else { + image::open(path) + } + .map_err(|e| anyhow!("sprite {path} : {e}"))? + .to_rgba8(); + let (w, h) = img.dimensions(); + Ok(CursorSdf::from_rgba(img.as_raw(), w, h)) + } + + /// `rgba` : `w`×`h` texels RGBA8, alpha droit. + pub fn from_rgba(rgba: &[u8], w: u32, h: u32) -> CursorSdf { + const K: usize = SDF_UPSAMPLE; + // Une marge de texels transparents : un texel intérieur collé au bord du sprite trouve + // ainsi son voisin extérieur, qui n'existe pas dans le rect. + const PAD: usize = 2; + let (w, h) = (w.max(1) as usize, h.max(1) as usize); + let alpha = |x: isize, y: isize| { + let (x, y) = (x.clamp(0, w as isize - 1) as usize, y.clamp(0, h as isize - 1) as usize); + rgba[(y * w + x) * 4 + 3] as f32 / 255.0 + }; + let (fw, fh) = (w * K, h * K); + let (gw, gh) = (fw + 2 * PAD, fh + 2 * PAD); + let mut inside = vec![false; gw * gh]; + for fy in 0..fh { + // Centre du texel fin, en texels source (leurs centres sont aux entiers). + let sy = (fy as f32 + 0.5) / K as f32 - 0.5; + let (y0, ty) = (sy.floor(), sy - sy.floor()); + for fx in 0..fw { + let sx = (fx as f32 + 0.5) / K as f32 - 0.5; + let (x0, tx) = (sx.floor(), sx - sx.floor()); + let (x0, y0) = (x0 as isize, y0 as isize); + let top = alpha(x0, y0) * (1.0 - tx) + alpha(x0 + 1, y0) * tx; + let bottom = alpha(x0, y0 + 1) * (1.0 - tx) + alpha(x0 + 1, y0 + 1) * tx; + inside[(fy + PAD) * gw + fx + PAD] = top * (1.0 - ty) + bottom * ty >= 0.5; + } + } + let to_inside = squared_edt(&inside, gw, gh); + let outside: Vec = inside.iter().map(|&i| !i).collect(); + let to_outside = squared_edt(&outside, gw, gh); + + let scale = 1.0 / (K * w.max(h)) as f32; + let mut texels = Vec::with_capacity(fw * fh); + let mut top_row = fh; + for fy in 0..fh { + for fx in 0..fw { + let g = (fy + PAD) * gw + fx + PAD; + let d = if inside[g] { + top_row = top_row.min(fy); + 0.5 - to_outside[g].sqrt() + } else { + // Aucun texel intérieur (sprite vide) : loin de tout, sans infini. + to_inside[g].sqrt().min((gw + gh) as f64) - 0.5 + }; + texels.push(d as f32 * scale); + } + } + for _ in 0..2 { + box_blur(&mut texels, fw, fh, SDF_SMOOTH); + } + let long = w.max(h) as f32; + CursorSdf { + width: fw as u32, + height: fh as u32, + texels, + shape: SpriteShape { + size: [w as f32 / long, h as f32 / long], + hotspot: [0.0; 2], + top: top_row as f32 / fh as f32, + }, + } + } + + /// Les texels en demi-flottants petit-boutistes, pour une texture R16F. + pub fn f16_bytes(&self) -> Vec { + self.texels.iter().flat_map(|&d| f16_bits(d).to_le_bytes()).collect() + } + + /// Le champ en `uv` (0..1 sur le rect du sprite), filtré comme le sampler des shaders : + /// bilinéaire, bord prolongé. Miroir CPU de l'échantillonnage du mode 15. + #[cfg(test)] + pub(crate) fn sample(&self, uv: [f32; 2]) -> f32 { + let (w, h) = (self.width as usize, self.height as usize); + let at = |x: isize, y: isize| { + let (x, y) = (x.clamp(0, w as isize - 1) as usize, y.clamp(0, h as isize - 1) as usize); + self.texels[y * w + x] + }; + let (sx, sy) = (uv[0] * w as f32 - 0.5, uv[1] * h as f32 - 0.5); + let (x0, y0) = (sx.floor(), sy.floor()); + let (tx, ty) = (sx - x0, sy - y0); + let (x0, y0) = (x0 as isize, y0 as isize); + let top = at(x0, y0) * (1.0 - tx) + at(x0 + 1, y0) * tx; + let bottom = at(x0, y0 + 1) * (1.0 - tx) + at(x0 + 1, y0 + 1) * tx; + top * (1.0 - ty) + bottom * ty + } +} + +/// Demi-flottant IEEE 754 de `v`, tronqué. Les distances n'ont ni NaN ni infini ; sous 2⁻¹⁵ elles +/// valent zéro, et c'est sans effet à cette échelle. +fn f16_bits(v: f32) -> u16 { + let b = v.to_bits(); + let sign = ((b >> 16) & 0x8000) as u16; + let e = ((b >> 23) & 0xff) as i32 - 127 + 15; + if e <= 0 { + return sign; + } + if e >= 31 { + return sign | 0x7c00; + } + sign | ((e as u16) << 10) | ((b >> 13) & 0x3ff) as u16 +} + +/// Flou boîte séparable de rayon `r`, bord prolongé. +fn box_blur(v: &mut [f32], w: usize, h: usize, r: usize) { + let mut line = vec![0.0f32; w.max(h)]; + let n = (2 * r + 1) as f32; + let pass = |line: &mut [f32], get: &dyn Fn(usize) -> f32, len: usize| { + for (i, out) in line[..len].iter_mut().enumerate() { + let sum: f32 = (0..2 * r + 1).map(|k| get((i + k).saturating_sub(r).min(len - 1))).sum(); + *out = sum / n; + } + }; + for y in 0..h { + pass(&mut line, &|x| v[y * w + x], w); + v[y * w..(y + 1) * w].copy_from_slice(&line[..w]); + } + for x in 0..w { + pass(&mut line, &|y| v[y * w + x], h); + for y in 0..h { + v[y * w + x] = line[y]; + } + } +} + +/// Carré de la distance de chaque texel au texel `seed` le plus proche (entre centres), infini +/// s'il n'y en a aucun. +fn squared_edt(seed: &[bool], w: usize, h: usize) -> Vec { + let mut grid: Vec = seed.iter().map(|&s| if s { 0.0 } else { f64::INFINITY }).collect(); + let n = w.max(h); + let (mut f, mut d) = (vec![0.0; n], vec![0.0; n]); + let (mut v, mut z) = (vec![0usize; n], vec![0.0; n]); + for x in 0..w { + for y in 0..h { + f[y] = grid[y * w + x]; + } + edt_1d(&f[..h], &mut d[..h], &mut v, &mut z); + for y in 0..h { + grid[y * w + x] = d[y]; + } + } + for y in 0..h { + let row = &mut grid[y * w..(y + 1) * w]; + f[..w].copy_from_slice(row); + edt_1d(&f[..w], &mut d[..w], &mut v, &mut z); + row.copy_from_slice(&d[..w]); + } + grid +} + +/// Enveloppe inférieure des paraboles `(q - p)² + f[p]` (les `f[p]` infinis n'en font pas +/// partie) : `d[q]` = son minimum en `q`. `v` = sommets de l'enveloppe, `z[i]` = abscisse à partir +/// de laquelle la parabole `v[i]` domine. +fn edt_1d(f: &[f64], d: &mut [f64], v: &mut [usize], z: &mut [f64]) { + let mut k: Option = None; + for q in 0..f.len() { + if f[q].is_infinite() { + continue; + } + let Some(mut top) = k else { + v[0] = q; + z[0] = f64::NEG_INFINITY; + k = Some(0); + continue; + }; + let fq = f[q] + (q * q) as f64; + loop { + let p = v[top]; + let s = (fq - f[p] - (p * p) as f64) / (2 * (q - p)) as f64; + // z[0] vaut -inf : on ne dépile jamais le premier sommet. + if s <= z[top] { + top -= 1; + continue; + } + top += 1; + v[top] = q; + z[top] = s; + break; + } + k = Some(top); + } + let Some(k) = k else { + d.fill(f64::INFINITY); + return; + }; + let mut j = 0; + for (q, out) in d.iter_mut().enumerate() { + while j < k && z[j + 1] < q as f64 { + j += 1; + } + let p = v[j]; + *out = (q as f64 - p as f64).powi(2) + f[p]; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn half_floats_encode_the_usual_values() { + for (v, bits) in [(0.0, 0x0000), (1.0, 0x3c00), (-2.0, 0xc000), (0.5, 0x3800), (-0.25, 0xb400)] { + assert_eq!(f16_bits(v), bits, "{v}"); + } + // 0.1 : exposant -4, mantisse tronquée à 10 bits. + assert_eq!(f16_bits(0.1), 0x2e66); + assert_eq!(f16_bits(1e9), 0x7c00); + } + + #[test] + fn the_distance_transform_is_exact() { + // Un masque pseudo-aléatoire, contre la force brute. + let (w, h) = (23usize, 17usize); + let seed: Vec = (0..w * h).map(|i| (i * 7919 + 13) % 29 == 0).collect(); + let got = squared_edt(&seed, w, h); + for y in 0..h { + for x in 0..w { + let mut best = f64::INFINITY; + for sy in 0..h { + for sx in 0..w { + if seed[sy * w + sx] { + let (dx, dy) = (x as f64 - sx as f64, y as f64 - sy as f64); + best = best.min(dx * dx + dy * dy); + } + } + } + assert_eq!(got[y * w + x], best, "({x}, {y})"); + } + } + assert!(squared_edt(&[false; 6], 3, 2).iter().all(|d| d.is_infinite())); + } + + /// Un sprite `w`×`h` dont chaque texel a pour alpha sa couverture par `inside` (16×16 + /// sous-échantillons) ; son champ doit suivre `exact` (texels source) à un demi-texel près + /// dans la bande de 3 texels autour du bord, celle où se jouent la silhouette, le chanfrein, + /// la couleur du bord et l'ombre de contact. Plus loin, le flou arrondit les crêtes (l'axe + /// médian) : un texel près. + fn check_sprite(w: u32, h: u32, inside: impl Fn(f32, f32) -> bool, exact: impl Fn(f32, f32) -> f32) { + let mut rgba = vec![255u8; (w * h * 4) as usize]; + for y in 0..h { + for x in 0..w { + let n = (0..256) + .filter(|k| { + let (sx, sy) = ((k % 16) as f32 / 16.0 + 1.0 / 32.0, (k / 16) as f32 / 16.0 + 1.0 / 32.0); + inside(x as f32 + sx, y as f32 + sy) + }) + .count(); + rgba[((y * w + x) * 4 + 3) as usize] = ((n as f32 / 256.0) * 255.0).round() as u8; + } + } + let sdf = CursorSdf::from_rgba(&rgba, w, h); + assert_eq!((sdf.width, sdf.height), (w * 4, h * 4)); + let long = w.max(h) as f32; + let (mut worst, mut worst_far) = (0.0f32, 0.0f32); + for fy in 0..sdf.height { + for fx in 0..sdf.width { + let (x, y) = ((fx as f32 + 0.5) / 4.0, (fy as f32 + 0.5) / 4.0); + let want = exact(x, y); + let got = sdf.texels[(fy * sdf.width + fx) as usize] * long; + if want.abs() <= 3.0 { + worst = worst.max((got - want).abs()); + } else { + worst_far = worst_far.max((got - want).abs()); + } + if want.abs() > 0.5 { + assert_eq!(got < 0.0, want < 0.0, "signe faux en ({x}, {y}) : {got} pour {want}"); + } + } + } + println!("écart maximal {worst} texel source près du bord, {worst_far} au-delà"); + assert!(worst < 0.5, "écart maximal {worst} texel source près du bord"); + assert!(worst_far < 1.0, "écart maximal {worst_far} texel source loin du bord"); + // Au centre d'un texel fin, le filtrage rend le texel. + let (fx, fy) = (sdf.width / 3, sdf.height / 2); + let uv = [(fx as f32 + 0.5) / sdf.width as f32, (fy as f32 + 0.5) / sdf.height as f32]; + assert!((sdf.sample(uv) - sdf.texels[(fy * sdf.width + fx) as usize]).abs() < 1e-6); + } + + #[test] + fn a_disc_gets_its_exact_distance() { + let (cx, cy, r) = (21.3f32, 18.6f32, 12.4f32); + check_sprite(44, 38, |x, y| (x - cx).hypot(y - cy) < r, |x, y| (x - cx).hypot(y - cy) - r); + } + + #[test] + fn a_rectangle_gets_its_exact_distance() { + let (x0, y0, x1, y1) = (6.3f32, 9.2f32, 30.6f32, 25.7f32); + check_sprite( + 40, + 34, + |x, y| x > x0 && x < x1 && y > y0 && y < y1, + |x, y| { + let (dx, dy) = ((x0 - x).max(x - x1), (y0 - y).max(y - y1)); + dx.max(0.0).hypot(dy.max(0.0)) + dx.max(dy).min(0.0) + }, + ); + } + + #[test] + fn a_shape_touching_the_sprite_edge_is_closed_by_it() { + // Plein partout : le bord du sprite est la silhouette, à un demi-texel fin près. + let rgba = vec![255u8; 6 * 5 * 4]; + let sdf = CursorSdf::from_rgba(&rgba, 6, 5); + assert!(sdf.texels.iter().all(|&d| d < 0.0)); + // Au coin, sous le flou : entre un demi et trois texels fins de profondeur. + let corner = sdf.texels[0] * 6.0 * 4.0; + assert!((-3.0..-0.5).contains(&corner), "coin : {corner} texels fins"); + assert_eq!(sdf.shape.top, 0.0); + assert_eq!(sdf.shape.size, [1.0, 5.0 / 6.0]); + // Le texel du sprite que les shaders tirent de la taille du champ. + assert_eq!(SDF_UPSAMPLE as f32 / sdf.width.max(sdf.height) as f32, 1.0 / 6.0); + } + + /// Pour chaque sprite livré, le signe du champ EST la silhouette de l'alpha (seuil 0,5), à la + /// frange près : vu de face, le modèle a la forme du sprite. + #[test] + fn every_shipped_sprite_keeps_its_silhouette() { + let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../public/cursors/default"); + let mut n = 0; + for entry in std::fs::read_dir(dir).expect("sprites livrés") { + let path = entry.expect("entrée").path(); + let img = image::open(&path).expect("png").to_rgba8(); + let sdf = CursorSdf::load(path.to_str().expect("chemin")).expect("champ"); + let (w, h) = img.dimensions(); + let (mut inter, mut union) = (0, 0); + for y in 0..h { + for x in 0..w { + let a = img.get_pixel(x, y)[3]; + // La frange antialiasée n'a pas de côté. + if (64..192).contains(&a) { + continue; + } + let uv = [(x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32]; + let (alpha_in, field_in) = (a >= 128, sdf.sample(uv) < 0.0); + inter += (alpha_in && field_in) as u32; + union += (alpha_in || field_in) as u32; + } + } + let iou = inter as f32 / union.max(1) as f32; + println!("{}: IoU {iou:.4}", path.display()); + assert!(iou > 0.995, "{}: IoU {iou}", path.display()); + n += 1; + } + assert_eq!(n, 16, "les seize états du thème par défaut"); + } + + #[test] + fn the_shipped_arrow_loads() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../public/cursors/default/arrow.png"); + let sdf = CursorSdf::load(path).expect("arrow.png"); + assert_eq!((sdf.width, sdf.height), (42 * 4, 70 * 4)); + assert_eq!(sdf.shape.size, [0.6, 1.0]); + assert!(sdf.shape.top < 0.02, "haut de la silhouette : {}", sdf.shape.top); + // Le hotspot (0.119, 0.0874) est la pointe de l'incrustation, dans la silhouette. + assert!(sdf.sample([0.119, 0.0874]) < 0.0); + assert!(sdf.sample([0.9, 0.1]) > 0.0); + assert_eq!(sdf.f16_bytes().len(), sdf.texels.len() * 2); + } +} diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 2e447120a..bc6ebee95 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -188,17 +188,10 @@ pub(crate) fn parse_color_channel(raw: &str, max: f32) -> Option { } Some(n / max) } -/// Rect source après crop puis zoom, dans les UV de la texture D3D. `u_max`/`v_max` -/// excluent le padding NV12 ; le crop reste donc exprimé dans le frame visible (0..1), -/// comme `VirtualPreview.cropVideoStyle`, puis le focus du zoom est remappé dans ce crop. -pub(crate) fn screen_source_rect( - u_max: f32, - v_max: f32, - crop: Option, - zoom: f32, - focus: [f32; 2], -) -> [f32; 4] { - let normalized_crop = crop.and_then(|crop| { +/// Le recadrage `[x0, y0, x1, y1]` en fractions de la source (repère normalisé du curseur), +/// l'image entière quand il est absent ou invalide. +fn normalized_crop(crop: Option) -> [f32; 4] { + crop.and_then(|crop| { if !crop.x.is_finite() || !crop.y.is_finite() || !crop.width.is_finite() || !crop.height.is_finite() { @@ -209,8 +202,21 @@ pub(crate) fn screen_source_rect( let x1 = (crop.x + crop.width).clamp(x0, 1.0); let y1 = (crop.y + crop.height).clamp(y0, 1.0); (x1 > x0 && y1 > y0).then_some([x0, y0, x1, y1]) - }); - let [x0, y0, x1, y1] = normalized_crop.unwrap_or([0.0, 0.0, 1.0, 1.0]); + }) + .unwrap_or([0.0, 0.0, 1.0, 1.0]) +} + +/// Rect source après crop puis zoom, dans les UV de la texture D3D. `u_max`/`v_max` +/// excluent le padding NV12 ; le crop reste donc exprimé dans le frame visible (0..1), +/// comme `VirtualPreview.cropVideoStyle`, puis le focus du zoom est remappé dans ce crop. +pub(crate) fn screen_source_rect( + u_max: f32, + v_max: f32, + crop: Option, + zoom: f32, + focus: [f32; 2], +) -> [f32; 4] { + let [x0, y0, x1, y1] = normalized_crop(crop); let (cu0, cv0, cu1, cv1) = (x0 * u_max, y0 * v_max, x1 * u_max, y1 * v_max); let (cw, ch) = (cu1 - cu0, cv1 - cv0); let zoom = if zoom.is_finite() && zoom >= 1.0 { zoom } else { 1.0 }; @@ -496,7 +502,7 @@ impl CursorPlacement { /// droit à travers la perspective. /// /// Le plan lui-même est interpolé aussi. Sous un vrai tilt les deux bornes partagent le même - /// quad (`a + (a - a) * f` rend `a` au bit près, rien ne bouge) ; mais un curseur en volume + /// quad (`a + (a - a) * f` rend `a` au bit près, rien ne bouge) ; mais le curseur modélisé /// sur écran droit porte un quad identité taillé dans `s_dst` d'un côté et `s_dst_prev` de /// l'autre, et garder celui de la queue décalerait la tête quand le zoom bouge. pub(crate) fn lerp(self, other: CursorPlacement, f: f32) -> CursorPlacement { @@ -524,6 +530,10 @@ impl CursorPlacement { lerp(quad.depth_k.0, quad_b.depth_k.0, f), lerp(quad.depth_k.1, quad_b.depth_k.1, f), ), + rot: std::array::from_fn(|i| lerp(quad.rot[i], quad_b.rot[i], f)), + perspective: lerp(quad.perspective, quad_b.perspective, f), + offset: std::array::from_fn(|i| lerp(quad.offset[i], quad_b.offset[i], f)), + projective: quad.projective, }, center_px: [lerp(center_px[0], center_b[0], f), lerp(center_px[1], center_b[1], f)], screen_px: [lerp(screen_px[0], screen_b[0], f), lerp(screen_px[1], screen_b[1], f)], @@ -552,11 +562,9 @@ impl CursorPlacement { } /// `LayerCB` du sprite de curseur, pour les trois backends : mode 7 sur écran droit, mode 13 -/// posé sur le plan (incliné, ou identité quand le curseur a du volume). +/// posé sur le plan incliné. /// -/// `sprite_px` = taille du sprite en px de sortie (ratio de l'image déjà appliqué). `volume` -/// ajoute l'extrusion : la bbox s'étend jusqu'aux faces arrière et `mb` porte `e` et le nombre -/// de copies. Sans volume, `mb` reste à zéro et le shader prend le chemin plat d'avant. +/// `sprite_px` = taille du sprite en px de sortie (ratio de l'image déjà appliqué). pub fn cursor_sprite_cb( placement: CursorPlacement, sprite_px: [f32; 2], @@ -564,12 +572,10 @@ pub fn cursor_sprite_cb( alpha: f32, clip: [f32; 4], render_px: [f32; 2], - volume: Option<&CursorVolume>, ) -> LayerCB { let [pw, ph] = sprite_px; let [rw, rh] = render_px; match placement { - // Pas de volume ici : `plan_cursor` rend un placement `Tilted` dès qu'il y en a. CursorPlacement::Upright { center } => LayerCB { dst: cursor_sprite_dst(center, pw / rw, ph / rh, hotspot), src: [0.0, 0.0, 1.0, 1.0], @@ -591,23 +597,12 @@ pub fn cursor_sprite_cb( let (px, py) = quad.point_px(fx, fy); (center_px[0] + px, center_px[1] + py) }); - let (mut min_x, mut max_x) = corners + let (min_x, max_x) = corners .iter() .fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); - let (mut min_y, mut max_y) = corners + let (min_y, max_y) = corners .iter() .fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); - // Les faces arrière sont la même silhouette décalée de `e` : la bbox doit les - // contenir aussi, sinon l'extrusion se coupe net au bord du sprite. - let mb = match volume { - Some(v) => { - let [ex, ey] = v.extrude_px; - (min_x, max_x) = (min_x.min(min_x + ex), max_x.max(max_x + ex)); - (min_y, max_y) = (min_y.min(min_y + ey), max_y.max(max_y + ey)); - [ex, ey, v.taps as f32, 0.0] - } - None => [0.0; 4], - }; // Le quad projeté d'un sprite peut être très fin de biais : une bbox d'un pixel // de large ferait diverger le warp inverse, donc plancher à 1 px. let (bw, bh) = ((max_x - min_x).max(1.0), (max_y - min_y).max(1.0)); @@ -625,13 +620,92 @@ 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, + // `mb.x` : 1 = warp projectif (caméra réelle). + mb: [quad.warp_flag(), 0.0, 0.0, 0.0], ..Default::default() } } } } +/// Gain de l'éclairage de la caméra réelle : une lampe posée sur la caméra, dont la lumière +/// décroît avec le carré de la distance. 1 = la décroissance physique ; moins, pour que le +/// contenu reste lisible. L'œil en orbite passe loin de l'axe : à 22° d'azimut, l'écart d'un bord +/// à l'autre vaut `0,42·gain` en 16:9, soit ±4 % ici. +pub const CAMERA_LIGHT_GAIN: f32 = 0.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. +/// +/// - `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` ; +/// - `square_top` : 1 sous un cadre de fenêtre (coins hauts carrés) ; +/// - `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. +#[allow(clippy::too_many_arguments)] +pub fn tilted_screen_cb( + quad: &crate::regions::TiltedQuad, + s_px: [f32; 2], + center_px: [f32; 2], + src: [f32; 4], + focus_plane: [f32; 2], + radius: f32, + square_top: f32, + dof: bool, + render_px: [f32; 2], +) -> LayerCB { + let [rw, rh] = render_px; + let corners = quad.corners; + // Taille du plan dans son propre repère, avant projection : c'est là que vit le rayon, pour + // qu'il reste constant le long du bord au lieu de s'étirer avec la perspective. + let plane_px = [s_px[0] * quad.scale, s_px[1] * quad.scale]; + let (min_x, max_x) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (min_y, max_y) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + let bbox_w = (max_x - min_x).max(1.0); + let bbox_h = (max_y - min_y).max(1.0); + // Coins en px LOCAUX à la bbox, pour matcher `i.local` du shader. + let local = |(x, y): (f32, f32)| -> [f32; 2] { [x - min_x, y - min_y] }; + let [tl0, tl1] = local(corners[0]); + let [tr0, tr1] = local(corners[1]); + let [br0, br1] = local(corners[2]); + let [bl0, bl1] = local(corners[3]); + let light = if quad.projective { + // Œil dans le repère du plan, et la dérivée de (d_centre / d)² au centre du plan. + let eye = crate::regions::rotate_point_inv( + [-quad.offset[0], -quad.offset[1], quad.perspective], + quad.rot, + ); + let e2 = eye[0] * eye[0] + eye[1] * eye[1] + eye[2] * eye[2]; + let k = 2.0 * CAMERA_LIGHT_GAIN / e2.max(1e-6); + [k * plane_px[0] * eye[0], k * plane_px[1] * eye[1], 0.0, 0.0] + } else { + [0.0; 4] + }; + LayerCB { + dst: [(center_px[0] + min_x) / rw, (center_px[1] + min_y) / rh, bbox_w / rw, bbox_h / rh], + src, + quad_px: [bbox_w, bbox_h], + // Le rayon suit la réduction du plan : l'écran incliné est plus petit, ses coins le sont + // d'autant, exactement comme s'il s'éloignait. + radius_px: radius * quad.scale, + mode: 8.0, + color: light, + fx: [tl0, tl1, tr0, tr1], + src_prev: [br0, br1, bl0, bl1], + dst_prev: [plane_px[0], plane_px[1], square_top, quad.warp_flag()], + // Gradient de profondeur du plan, profondeur du focus et `k` (`depth_mb`). + mb: quad.depth_mb(s_px, focus_plane, dof), + ..Default::default() + } +} + /// `LayerCB` d'une ombre portée par un quadrilatère (mode 12). `corners` (TL, TR, BR, BL) en px /// de sortie ABSOLUS. Même boîte que `draw_quad_shadow` des backends : le quad élargi du /// `spread`, pour que la pénombre ne se coupe pas net. @@ -875,9 +949,8 @@ pub struct LiveParams { /// vélocité), pas par un flou gaussien variable comme le canvas web — plus simple à /// réutiliser côté GPU, effet de streak équivalent. pub cursor_motion_blur: f32, - /// 0..1 : volume du curseur (extrusion le long de la normale du plan + ombre de contact). - /// 0 = sprite plat, rendu d'avant au bit près. - pub cursor_volume: f32, + /// Flèche modélisée en 3D (mode 15, cf. `plan_cursor`). `false` = le sprite plat d'avant. + pub cursor_model3d: bool, /// Masquage auto du curseur en cas d'inactivité. pub cursor_auto_hide: bool, /// False when the "webcam" decoder is actually just the screen video again (the TS side @@ -957,7 +1030,7 @@ impl Default for LiveParams { cursor_size_scale: 1.0, cursor_bounce_scale: 1.0, cursor_motion_blur: 0.0, - cursor_volume: 0.0, + cursor_model3d: false, cursor_auto_hide: false, has_webcam: true, } @@ -996,7 +1069,7 @@ pub fn live_params_from_scene(s: &crate::scene::Scene) -> LiveParams { cursor_size_scale: s.cursor.size, cursor_bounce_scale: s.cursor.click_bounce, cursor_motion_blur: s.cursor.motion_blur, - cursor_volume: s.cursor.volume, + cursor_model3d: s.cursor.model3d, cursor_auto_hide: s.cursor.auto_hide, ..LiveParams::default() } @@ -1048,6 +1121,9 @@ pub struct FrameGeometry { /// Part dynamique du tilt (parallaxe, `regions::dynamic_tilt`), ajoutée à la base à la /// projection. Nulle quand la base est neutre. pub zoom_rotation_dyn: [f32; 3], + /// 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, pub padding_scale: f32, /// Coupe source de l'écran en UV texture (crop utilisateur + zoom). pub cut: [f32; 4], @@ -1113,8 +1189,13 @@ impl FrameGeometry { /// Le quad de l'écran incliné pour une boîte de `s_px` px, `None` quand l'écran est droit. /// LE point de passage de l'écran, de son ombre, du curseur et du masque de flou : tous /// doivent porter la même séparation base / dynamique, sinon ils se décollent. + /// + /// Sous la caméra réelle, c'est l'écran vu par elle (`camera::View::quad`), même boîte. pub fn screen_tilt(&self, s_px: [f32; 2]) -> Option { - (!crate::regions::is_identity_rotation(self.zoom_rotation)).then(|| { + if let Some(pose) = self.camera { + return Some(crate::camera::View::new(s_px, pose).quad(s_px)); + } + self.tilted().then(|| { crate::regions::rotated_quad_corners_px( s_px[0], s_px[1], @@ -1124,14 +1205,17 @@ impl FrameGeometry { }) } + /// 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) + } + /// La profondeur de champ tourne-t-elle sur CETTE frame ? Réglage allumé, écran réellement /// incliné (à plat le mode 8 n'est pas dessiné), et backend qui en a les moyens (cf. /// `DOF_ON_CPU_BACKEND`). Une seule réponse pour les trois backends : elle décide à la fois /// du remplissage de la pyramide et du `k` du mode 8, qui ne doivent jamais diverger. pub fn depth_of_field_on(&self, cpu_backend: bool) -> bool { - self.depth_of_field - && !crate::regions::is_identity_rotation(self.zoom_rotation) - && (DOF_ON_CPU_BACKEND || !cpu_backend) + self.depth_of_field && self.tilted() && (DOF_ON_CPU_BACKEND || !cpu_backend) } /// `annotation_dst_in` appliqué à `s_ann`, pour les backends qui tiennent la géométrie @@ -1173,8 +1257,13 @@ impl FrameGeometry { /// plan (1 à plat). Incliné, c'est le MÊME `TiltedQuad` que l'écran, prolongé au-delà de /// 0..1 : un warp bilinéaire est entièrement fixé par ses quatre coins, donc le prolonger /// donne exactement le plan que le mode 8 dessine, et le cadre penche avec l'écran sans - /// aucune trigonométrie de plus. - fn window_frame_corners(&self, frame: &WindowFrame, render_px: [f32; 2]) -> ([(f32, f32); 4], f32) { + /// aucune trigonométrie de plus. Sous la caméra réelle, le prolongement est celui de + /// l'homographie, et le drapeau rendu (`TiltedQuad::warp_flag`) le dit au mode 14. + fn window_frame_corners( + &self, + frame: &WindowFrame, + render_px: [f32; 2], + ) -> ([(f32, f32); 4], f32, f32) { let [ml, mt, mr, mb] = frame.margins; let quad = self.screen_tilt_in(render_px).unwrap_or_else(|| { let (hw, hh) = (self.s_dst[2] * render_px[0] * 0.5, self.s_dst[3] * render_px[1] * 0.5); @@ -1182,6 +1271,10 @@ impl FrameGeometry { corners: [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)], scale: 1.0, depth_k: (0.0, 0.0), + rot: [0.0; 3], + perspective: 0.0, + offset: [0.0; 2], + projective: false, } }); let corners = [ @@ -1190,7 +1283,7 @@ impl FrameGeometry { quad.point_px(1.0 + mr, 1.0 + mb), quad.point_px(-ml, 1.0 + mb), ]; - (corners, quad.scale) + (corners, quad.scale, quad.warp_flag()) } /// Ce qui porte l'ombre portée : le cadre quand il y en a un — sinon l'ombre tomberait sous @@ -1225,12 +1318,27 @@ impl FrameGeometry { } } (Some(frame), Some(_)) => { - let (corners, scale) = self.window_frame_corners(frame, render_px); + let (corners, scale, _) = self.window_frame_corners(frame, render_px); ShadowCaster::Tilted { corners, center_px, radius: frame.radius * scale } } } } + /// Décalage de l'ombre portée de l'écran, en px de sortie : vers le bas sous un écran droit + /// ou un angle fixe (inchangé) ; sous la caméra réelle, le long de la lumière qui éclaire aussi + /// la flèche modélisée (`MODEL_LIGHT`), pour que les deux ombres tombent du même côté. La + /// direction glisse avec le poids de la caméra : aucun saut à l'entrée du zoom. + pub fn screen_shadow_offset(&self) -> [f32; 2] { + let off = SCREEN_SHADOW_OFFSET_FRAC * self.frame_min_px; + let Some(pose) = self.camera else { return [0.0, off] }; + let (lx, ly) = (-MODEL_LIGHT[0], -MODEL_LIGHT[1]); + let n = lx.hypot(ly); + let w = pose.weight.clamp(0.0, 1.0); + let dir = [lx / n * w, ly / n * w + (1.0 - w)]; + let len = dir[0].hypot(dir[1]).max(1e-6); + [off * dir[0] / len, off * dir[1] / len] + } + /// Le calque du cadre (mode 14), à dessiner après l'ombre et AVANT l'écran. `None` sans cadre. /// /// Une seule forme pour le cas droit et le cas incliné : le mode 14 fait toujours le warp @@ -1239,7 +1347,7 @@ impl FrameGeometry { pub fn window_frame_cb(&self, render_px: [f32; 2]) -> Option { let frame = self.window_frame.as_ref()?; let [rw, rh] = render_px; - let (corners, scale) = self.window_frame_corners(frame, render_px); + let (corners, scale, warp) = self.window_frame_corners(frame, render_px); let center = self.screen_center_px(render_px); let (min_x, max_x) = corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); @@ -1260,6 +1368,8 @@ impl FrameGeometry { }; Some(LayerCB { dst: [(center[0] + min_x) / rw, (center[1] + min_y) / rh, bbox[0] / rw, bbox[1] / rh], + // `src.x` : 1 = warp projectif (caméra réelle). Le mode 14 ne lit pas d'UV. + src: [warp, 0.0, 0.0, 0.0], quad_px: bbox, radius_px: frame.radius * scale, mode: 14.0, @@ -1314,7 +1424,7 @@ impl FrameGeometry { let zoom_k = if self.s_ann[3] > 0.0 { (self.s_dst[3] / self.s_ann[3]).max(1.0) } else { 1.0 }; - if crate::regions::is_identity_rotation(self.zoom_rotation) { + if !self.tilted() { // Le mode 0 étale chaque pixel entre sa position courante et celle de la frame // précédente (`dst_prev = s_dst_prev`) : pendant une rampe de zoom, une copie traînée // du secret sort du rect courant. Chaque coordonnée étalée reste entre ses deux @@ -1380,6 +1490,7 @@ impl FrameGeometry { dst: [min_x / rw, min_y / rh, quad_px[0] / rw, quad_px[1] / rh], quad_px, warp: Some(local), + projective: quad.projective, strength: (zoom_k * tilt_k).max(1.0), oval_ok: true, }) @@ -1403,6 +1514,8 @@ pub struct PrivacyMask { /// Écran incliné : coins TL, TR, BR, BL du masque, en px locaux à `dst` (la convention de /// `i.local` dans le shader). `None` : le masque est le rect `dst` lui-même. pub warp: Option<[[f32; 2]; 4]>, + /// `warp` suit l'homographie de ses coins (caméra réelle) plutôt que leur warp bilinéaire. + pub projective: bool, /// Multiplicateur du rayon de flou et du pas de mosaïque, jamais sous 1. pub strength: f32, /// `false` quand le rect a été élargi à la trace du flou de mouvement : un ovale inscrit @@ -1416,21 +1529,23 @@ impl PrivacyMask { dst, quad_px: [dst[2] * render_px[0], dst[3] * render_px[1]], warp: None, + projective: false, strength, oval_ok: true, } } /// Les champs du mode 10 qui portent le masque incliné : `(dst_prev, src_prev, mb)`, avec - /// `dst_prev` = TL, TR, `src_prev` = BR, BL et `mb.z` = 1. Le mode 10 lit déjà `fx` pour - /// ses propres réglages, d'où ces trois champs-là, qu'il ne lisait pas. + /// `dst_prev` = TL, TR, `src_prev` = BR, BL, `mb.z` = 1 et `mb.w` = 1 pour le warp projectif. + /// Le mode 10 lit déjà `fx` pour ses propres réglages, d'où ces trois champs-là, qu'il ne + /// lisait pas. pub fn warp_fields(&self) -> ([f32; 4], [f32; 4], [f32; 4]) { match self.warp { None => ([0.0; 4], [0.0; 4], [0.0; 4]), Some([tl, tr, br, bl]) => ( [tl[0], tl[1], tr[0], tr[1]], [br[0], br[1], bl[0], bl[1]], - [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 1.0, if self.projective { 1.0 } else { 0.0 }], ), } } @@ -1533,13 +1648,37 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { let mut zoom_rotation = [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_aim = [0.5f32; 2]; + let mut zoom_orbit = [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 + // plan que l'export laisse immobile. + let parallax_track = cursor_for_zoom.filter(|_| scene.is_some_and(|s| s.cursor.show)); + let active_crop = scene.and_then(|scene| { + scene.crop_by_clip.get(scene.active_clip_index).copied().flatten() + }); if !zoom_regions.is_empty() { - let zs = crate::regions::zoom_state_at(zoom_regions, source_t, cursor_for_zoom); + // La caméra `follow-cursor` lit le curseur dans l'image SOURCE recadrée — pas dans la + // coupe zoomée, qu'un focus auto recentre sur lui — et ignore ce qu'une coupe retire. + let camera = crate::regions::CameraFrame { + track: parallax_track, + crop: normalized_crop(active_crop), + window: scene + .and_then(|s| s.clips.get(s.active_clip_index)) + .map(|c| [c.source_start_sec as f32, c.source_end_sec as f32]) + .unwrap_or([f32::NEG_INFINITY, f32::INFINITY]), + }; + let zs = crate::regions::zoom_state_in(zoom_regions, source_t, cursor_for_zoom, &camera); p.zoom = zs.scale; p.focus = zs.focus; zoom_rotation = zs.rotation; zoom_tilt = zs.tilt; zoom_click_impact = zs.click_impact; + zoom_camera = zs.camera; + zoom_aim = zs.aim; + zoom_orbit = zs.orbit; let zs_p = crate::regions::zoom_state_at(zoom_regions, source_t_prev, cursor_for_zoom); pp.zoom = zs_p.scale; pp.focus = zs_p.focus; @@ -1628,9 +1767,6 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // diffère de la boîte du preset) se retrouve étiré pour remplir cette boîte — parité web // cassée : `computeCompositeLayout`/`centerRectInBounds` (TS) contiennent déjà le crop // dans sa boîte en respectant son ratio, le natif ne le faisait pas (rapport utilisateur). - let active_crop = scene.and_then(|scene| { - scene.crop_by_clip.get(scene.active_clip_index).copied().flatten() - }); let crop_aspect = match active_crop { Some(c) if c.width > 0.0001 && c.height > 0.0001 => { (c.width * scw) / (c.height * sch).max(0.0001) @@ -1722,32 +1858,48 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { let cut_ref = cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, p.focus)); let cut_ref_prev = cover(screen_source_rect(u_max, v_max, active_crop, pp.zoom, p.focus)); let cut = cover(screen_source_rect(u_max, v_max, active_crop, 1.0, p.focus)); - let focus_plane = focus_in_cut(u_max, v_max, active_crop, p.focus, cut); + // Impact du clic : mêmes piste, coupe, porte et budget que la parallaxe sous un angle fixe ; + // sous la caméra réelle, les mêmes clics font reculer l'œil. + let (impact, press) = match (scene, parallax_track) { + (Some(s), Some(track)) if zoom_click_impact > 0.0 => { + let (tilt, press) = click_impact_at(s, cfg, &lp, track, source_t, cut, [u_max, v_max]); + (tilt.map(|d| d * zoom_click_impact), press * zoom_click_impact) + } + _ => ([0.0; 3], 0.0), + }; + // Sous la caméra réelle, la visée et l'orbite vivent dans le recadrage, comme le focus : + // même report dans la coupe (un cover la rogne). La mise au point suit le pointeur lissé + // (l'orbite), pas le point visé : celui-ci reste au centre au zoom 1 et bute sur sa portée + // au zoom, là où le spectateur regarde le pointeur. + let camera = (zoom_camera > 0.0).then(|| crate::camera::CameraPose { + weight: zoom_camera, + aim: focus_in_cut(u_max, v_max, active_crop, zoom_aim, cut), + orbit: focus_in_cut(u_max, v_max, active_crop, zoom_orbit, cut), + zoom: p.zoom, + press, + }); + let focus_plane = match camera { + Some(pose) => pose.orbit, + None => focus_in_cut(u_max, v_max, active_crop, p.focus, cut), + }; let s_dst = remap_box(s_base, cut_ref, cut); // Parallaxe : calculée ici, une fois la coupe connue — elle mesure la vitesse du curseur // en coupes VISIBLES par seconde. La coupe visible est `cut_ref`, zoom compris : `cut` // ne porte plus que le crop depuis #179, et sous un x2 le même geste traverse deux fois // plus d'écran. La coupe passe au repère normalisé du curseur. - // Curseur masqué → pas de parallaxe : l'export ne charge la piste que si le curseur est - // affiché (`timeline_walk`), la preview toujours. Sans cette porte, la preview pencherait - // un plan que l'export laisse immobile. - let parallax_track = cursor_for_zoom.filter(|_| scene.is_some_and(|s| s.cursor.show)); let cut_norm = [ cut_ref[0] / u_max.max(1e-6), cut_ref[1] / v_max.max(1e-6), cut_ref[2] / u_max.max(1e-6), cut_ref[3] / v_max.max(1e-6), ]; - // Impact du clic : mêmes piste, coupe, porte et budget que la parallaxe. - let impact = match (scene, parallax_track) { - (Some(s), Some(track)) if zoom_click_impact > 0.0 => { - click_impact_at(s, cfg, &lp, track, source_t, cut, [u_max, v_max]) - .map(|d| d * zoom_click_impact) - } - _ => [0.0; 3], - }; - let zoom_rotation_dyn = - crate::regions::dynamic_tilt(source_t, parallax_track, cut_norm, zoom_tilt, impact); + let zoom_rotation_dyn = crate::regions::dynamic_tilt( + source_t, + parallax_track, + cut_norm, + zoom_tilt, + impact, + ); let s_dst_prev = remap_box(s_base_prev, cut_ref_prev, cut); // le padding n'affecte QUE l'écran (la quantité de fond révélée). La webcam reste ancrée // en bas-droite à sa marge fixe, quelle que soit la valeur de padding (pas de scale_frame) @@ -1886,6 +2038,7 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { programme_t: input.programme_time.unwrap_or(frame / FPS), zoom_rotation, zoom_rotation_dyn, + camera, padding_scale, cut, focus_plane, @@ -1929,56 +2082,28 @@ pub struct CursorPlan { pub cursor_type: Option, /// Opacité effective (0..1) tenant compte de l'inactivité (auto-hide) et du zoom. pub alpha: f32, - /// Volume du curseur. `None` au réglage 0 : sprite plat, rendu d'avant. - pub volume: Option, + /// Curseur modélisé (mode 15) : `Some` quand le réglage est allumé, que le thème est celui par + /// défaut et que l'état résout un sprite (le sien, sinon la flèche) ; sa pose tient déjà compte + /// de la part « pointeur » de ce sprite. Le placement est alors toujours `Tilted` (quad + /// identité sur un écran droit), et le sprite est extrudé au lieu d'être posé à plat. + pub model: Option, + /// L'impact des clics récents sur l'écran (mode 16), à dessiner SOUS le curseur. Vide sans + /// curseur modélisé, sans clic récent ou à `clickBounce` nul. + pub impacts: Vec, } -/// Épaisseur du curseur à volume 1, en fraction de la taille du sprite. -const CURSOR_VOLUME_DEPTH_FRAC: f32 = 0.6; -/// Écran droit : la normale se projette sur un point, l'extrusion ne se verrait pas. On la -/// remplace par un décalage bas-droite de 3 px pour un sprite de 34 px (volume 1). -const CURSOR_VOLUME_FLAT_FRAC: f32 = 3.0 / 34.0; -/// Plafond de copies de la silhouette : la borne de boucle des trois shaders. À la taille par -/// défaut, `iso` extrude ~37 px en 4K : 48 garde l'écart entre copies sous 1 px. Seuls les -/// pixels de flanc paient la boucle — la face avant opaque sort dès la copie 0. -const CURSOR_VOLUME_MAX_TAPS: u32 = 48; - -/// L'extrusion du curseur et son ombre de contact, calculées une fois pour les trois backends. -/// -/// Limites connues, assumées puisque le réglage part de 0 : un pack dont le PNG peint déjà une -/// ombre la voit extrudée avec le reste (ombre doublée) ; le curseur math de secours (mode 4, -/// Windows) reste plat ; et sans sprite il n'y a plus rien à extruder — macOS, qui n'a pas de -/// repli, ne dessine alors ni curseur ni volume. -#[derive(Clone, Copy)] -pub struct CursorVolume { - /// Où tombent les faces arrière par rapport à la face avant, en px de sortie. Le shader le - /// reçoit tel quel dans `mb.xy` : une translation ne dépend pas du repère local. - pub extrude_px: [f32; 2], - /// Copies de la silhouette, face avant comprise (>= 2) — `mb.z`. - pub taps: u32, - /// Ombre de contact sous la pointe (mode 12), prête à dessiner AVANT le sprite. - pub shadow: LayerCB, -} - -/// Vecteur d'extrusion en px : la normale du plan tournée par `rot`, projetée, fois l'épaisseur. -/// -/// La normale arrière (0,0,-1) passe par la même composition que `rotated_quad_corners_px` -/// (Z, puis Y, puis X) ; Z la laisse fixe, il reste `(-sin b, cos b · sin a)`. C'est la -/// direction où l'on voit les flancs : un plan dont le bord droit s'éloigne (b > 0) fait face à -/// droite, on le regarde depuis sa gauche, et c'est à gauche que dépassent les côtés du curseur. -/// Projection orthographique : la perspective ne ferait varier `e` que de quelques pour cent -/// d'un bout à l'autre de l'écran. -/// -/// À plat, cette projection s'annule. Le repli bas-droite prend le relais en fondu (et non par -/// un seuil) pour que la mise en place d'un tilt ne fasse pas sauter l'extrusion. -pub(crate) fn cursor_extrusion_px(rot: [f32; 3], thickness_px: f32, flat_px: f32) -> [f32; 2] { - let (a, b) = (rot[0].to_radians(), rot[1].to_radians()); - let e = [-b.sin() * thickness_px, b.cos() * a.sin() * thickness_px]; - let flat = [flat_px * std::f32::consts::FRAC_1_SQRT_2; 2]; - let len = e[0].hypot(e[1]); - let k = if flat_px > 0.0 { (len / flat_px).clamp(0.0, 1.0) } else { 1.0 }; - let k = k * k * (3.0 - 2.0 * k); - [lerp(flat[0], e[0], k), lerp(flat[1], e[1], k)] +impl CursorPlan { + /// Le plan tel qu'un backend le dessine. La traînée du curseur modélisé coûte une marche + /// de rayons par copie : mesuré en 1080p (taille 10, 16 copies), +0,6 à +0,9 ms/frame sur GPU + /// mais +68 à +98 ms sur WARP. Le backend logiciel ne dessine donc que la tête ; le sprite + /// plat, lui, garde sa traînée partout. + pub fn for_backend(mut self, cpu_backend: bool) -> CursorPlan { + if cpu_backend && self.model.is_some() { + self.taps = 1; + self.prev_placement = self.placement; + } + self + } } /// Ce que `plan_cursor` doit savoir en plus de `FrameGeometry`. @@ -2045,23 +2170,26 @@ fn click_impact_at( t: f32, cut: [f32; 4], uv_max: [f32; 2], -) -> [f32; 3] { - let Some(clip) = scene.clips.get(scene.active_clip_index) else { return [0.0; 3] }; +) -> ([f32; 3], f32) { + let Some(clip) = scene.clips.get(scene.active_clip_index) else { return ([0.0; 3], 0.0) }; let masked = scene.annotations.iter().any(|a| { a.kind == "blur" && a.blur.is_some() && t >= a.start_sec as f32 && t < a.end_sec as f32 }); if masked { - return [0.0; 3]; + return ([0.0; 3], 0.0); } let speed = crate::regions::speed_at(&scene.speed_regions, scene.active_clip_index, t as f64); let speed_weight = (2.0 - speed as f32).clamp(0.0, 1.0); let weight = cursor_alpha(Some(scene), cfg, live, track, t) * speed_weight; if weight <= 0.0 { - return [0.0; 3]; + return ([0.0; 3], 0.0); } let window = [clip.source_start_sec as f32, clip.source_end_sec as f32]; - crate::regions::click_impact(t, track, window, |p| cursor_plane_point(cut, uv_max, p)) - .map(|d| d * weight) + let aim = |p| cursor_plane_point(cut, uv_max, p); + ( + crate::regions::click_impact(t, track, window, aim).map(|d| d * weight), + crate::regions::click_press(t, track, window, aim) * weight, + ) } /// `None` = rien à dessiner cette frame : curseur masqué, pointeur hors du rect source @@ -2081,25 +2209,10 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option g.s_dst, - Some(quad) => { - let (hx, hy) = quad.half_extents_px(); - [ - (quad_center_px[0] - hx) / rw, - (quad_center_px[1] - hy) / rh, - 2.0 * hx / rw, - 2.0 * hy / rh, - ] - } - }; - let clip = match input.scene { - Some(s) if s.cursor.clip_to_bounds => cursor_bounds, - _ => [-1.0, -1.0, 3.0, 3.0], - }; - - let volume01 = input.live.cursor_volume.clamp(0.0, 1.0); - let has_volume = volume01 > 0.001; + let cursor_type = input.track.type_at(input.t); + let model_sprite = + modelled_sprite(input.scene, cursor_type).filter(|_| input.live.cursor_model3d); + let model3d = model_sprite.is_some(); let place = |cxy: Option<(f32, f32)>, dst: [f32; 4]| -> Option { cxy.and_then(|p| { let [fx, fy] = cursor_plane_point(g.cut, [input.u_max, input.v_max], p)?; @@ -2111,11 +2224,10 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option { + // Le curseur modélisé a toujours besoin d'un plan : sur écran droit, un plan + // IDENTITÉ taillé dans `dst`. À rotation nulle `rotated_quad_corners_px` rend les + // coins exacts (±w/2, ±h/2, échelle 1) : même caméra, même mode 15. + None if model3d => { let screen_px = [dst[2] * rw, dst[3] * rh]; CursorPlacement::Tilted { plane_pt: [fx, fy], @@ -2136,13 +2248,22 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option Option Option Option g.s_dst, + Some(quad) => { + let (hx, hy) = quad.half_extents_px(); + [ + (quad_center_px[0] - hx) / rw, + (quad_center_px[1] - hy) / rh, + 2.0 * hx / rw, + 2.0 * hy / rh, + ] + } + }; + let clip = match input.scene { + Some(s) if s.cursor.clip_to_bounds => cursor_bounds, + _ => [-1.0, -1.0, 3.0, 3.0], + }; + + // L'impact : un anneau par clic récent, centré sur son point BRUT, là où la pointe se pose. + let strength = (lp.cursor_bounce_scale / MODEL_CLICK_BOUNCE_REF).clamp(0.0, 2.0); + let impacts = if model3d && strength > 0.0 { + let half_px = IMPACT_EXTENT * size_px * (0.75 + 0.25 * strength); + input + .track + .clicks_with_points(input.t - IMPACT_DELAY_S - IMPACT_S, input.t - IMPACT_DELAY_S) + .filter_map(|(tc, p)| { + let impact = impact_at(input.t - tc, strength)?; + cursor_impact_cb(place(Some(p), g.s_dst)?, half_px, impact, alpha) + }) + .collect() } else { - None + Vec::new() }; Some(CursorPlan { @@ -2195,52 +2345,505 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option [f32; 2] { + [-self.hotspot[0] * self.size[0], -self.hotspot[1] * self.size[1]] + } + + /// La boîte englobante du modèle écrasé à `squash` : le rect du sprite, sur toute l'épaisseur. + fn model_box(&self, squash: f32) -> ([f32; 3], [f32; 3]) { + let [x, y] = self.origin(); + ([x, y, -MODEL_THICK * squash], [x + self.size[0], y + self.size[1], 0.0]) + } + + /// Hauteur du hotspot du dessus quand le point le plus bas du modèle basculé de `pitch` (≥ 0) + /// et écrasé à `squash` affleure le plan : le bas du haut de la silhouette (y minimal, face du + /// dessous) ; à plat, toute la face du dessous. Le chanfrein arrondit ce coin et laisse, + /// basculé, un jour d'au plus ~1 % de l'unité, invisible sous l'ombre de contact. + fn contact_lift(&self, pitch: f32, squash: f32) -> f32 { + let y_top = (self.top - self.hotspot[1]) * self.size[1]; + MODEL_THICK * squash * pitch.cos() - y_top * pitch.sin() + } +} + +/// Part « pointeur » d'un sprite, de 0 à 1, d'après son seul hotspot : sa distance au centre du +/// sprite, rapportée au demi-côté (norme max). Près d'un bord (flèche, main qui pointe, aide, +/// flèche haute) le curseur désigne de sa pointe, et le modèle penche et tourne comme la flèche ; +/// au centre (I, croix, redimensionnements, déplacement, interdit, attente, poing fermé) il ne +/// fait ni l'un ni l'autre : tourner une flèche de redimensionnement en change le sens, et basculer +/// une forme autour de son centre en enfoncerait la moitié dans le plan. +pub fn pointing_factor(hotspot: [f32; 2]) -> f32 { + let r = 2.0 * (hotspot[0] - 0.5).abs().max((hotspot[1] - 0.5).abs()); + let u = ((r - MODEL_POINTING_LO) / (MODEL_POINTING_HI - MODEL_POINTING_LO)).clamp(0.0, 1.0); + u * u * (3.0 - 2.0 * u) +} + +/// La pose du curseur modélisé à un instant : une fonction pure de `t`, comme tout le reste de +/// la frame (preview == export, lecture == seek). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CursorPose { + /// Garde au sol du point le plus bas, en unités du modèle : 0 = il touche le plan. + pub clearance: f32, + /// Tangage (rad) autour de l'axe x passant par le hotspot : la queue monte, la pointe descend. + pub pitch: f32, + /// Lacet (rad) autour de la normale du plan, appliqué après le tangage : positif = sens + /// horaire à l'écran (y vers le bas), la pointe part vers la droite. + pub yaw: f32, + /// Épaisseur du modèle rapportée à `MODEL_THICK` : moins de 1 quand le clic l'écrase. + pub squash: f32, +} + +/// La pose à `t`. `click_bounce` est le réglage brut (0..5, 2,5 par défaut), `pointing` la part +/// « pointeur » du sprite (`pointing_factor`). +/// +/// - Hauteur : `MODEL_HOVER` au repos ; chaque clic le fait descendre TOUCHER le plan au creux de +/// `regions::tap`, la courbe de l'impact du clic, dont le creux (49,5 ms) est celui de la +/// pression de `CursorTrack::bounce` : le modèle, le plan et le rebond d'échelle lisent le même +/// contact à la même image. Dernier clic avant `t`, comme `bounce`. Tous les états. +/// - Tangage : 18° au repos, jusqu'à +10° au creux de la pression, fois `clickBounce`. +/// - Lacet : vers la vitesse horizontale lissée (`follow_at`, différence centrée), et vers la +/// cible d'un clic dans les 300 ms qui le précèdent ; borné à ±25° en douceur (`tanh`), nul au +/// repos. Les contributions des clics montent avant eux et retombent sur la fenêtre de l'impact, +/// donc la pose reste continue en `t`. +/// - Tangage et lacet sont multipliés par `pointing` : entiers pour la flèche, nuls pour un +/// curseur centré. +/// - Écrasement : sur la même courbe `tap`, l'épaisseur descend à 1 − `MODEL_SQUASH` au creux, +/// puis le rebond l'épaissit un instant. Fois `clickBounce`, comme le tangage ; tous les états. +/// L'empreinte ne change pas : pas de rebond d'échelle en 3D. +pub fn cursor_pose( + track: &crate::cursor::CursorTrack, + t: f32, + click_bounce: f32, + pointing: f32, +) -> CursorPose { + use crate::regions::{tap, CLICK_IMPACT_WINDOW_S}; + let k = track.last_click_at(t).map(|tc| tap((t - tc) / CLICK_IMPACT_WINDOW_S)).unwrap_or(0.0); + let clearance = MODEL_HOVER * (1.0 + MODEL_CONTACT_GAIN * k).max(0.0); + let strength = (click_bounce / MODEL_CLICK_BOUNCE_REF).clamp(0.0, 2.0); + let press = (-k).max(0.0) * strength; + let pitch = pointing * (MODEL_PITCH_IDLE_DEG + MODEL_PITCH_PRESS_DEG * press).to_radians(); + let squash = (1.0 + MODEL_SQUASH * k * strength).max(MODEL_SQUASH_MIN); + + let smooth = |x: f32| { + let u = x.clamp(0.0, 1.0); + u * u * (3.0 - 2.0 * u) + }; + let h = MODEL_YAW_HALF_WINDOW_S; + let mut v = match (track.follow_at(t - h), track.follow_at(t + h)) { + (Some(a), Some(b)) => (b.0 - a.0) / (2.0 * h), + _ => 0.0, + }; + if let Some(here) = track.follow_at(t) { + for (tc, target) in track.clicks_with_points(t - CLICK_IMPACT_WINDOW_S, t + MODEL_AIM_S) { + let w = if tc > t { + smooth(1.0 - (tc - t) / MODEL_AIM_S) + } else { + 1.0 - smooth((t - tc) / CLICK_IMPACT_WINDOW_S) + }; + v += w * (target.0 - here.0) / MODEL_AIM_S; + } + } + let yaw = pointing * MODEL_YAW_MAX_DEG.to_radians() * (v / MODEL_YAW_SPEED).tanh(); + CursorPose { clearance, pitch, yaw, squash } +} + +/// Le sprite du thème par défaut que les backends résoudraient pour `cursor_type`, s'il y en a +/// un : c'est lui que le mode 15 extrude. Même résolution qu'eux : l'état s'il a un sprite, sinon +/// la flèche. Les autres thèmes restent plats. +fn modelled_sprite<'a>( + scene: Option<&'a Scene>, + cursor_type: Option<&str>, +) -> Option<&'a crate::scene::SceneCursorSprite> { + let s = scene.filter(|s| s.cursor.theme == "default")?; + let sprites = &s.cursor.cursor_sprites; + cursor_type.and_then(|k| sprites.get(k)).or_else(|| sprites.get("arrow")) +} + +/// La caméra et la pose d'un curseur modélisé posé en `placement` : de quoi projeter n'importe +/// quel point du modèle exactement comme le mode 15 le rend. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ModelView { + /// Rotation dessinée du plan, degrés (`TiltedQuad::rot`). rot: [f32; 3], + perspective: f32, + /// Translation du plan dans le repère caméra (`TiltedQuad::offset`). + offset: [f32; 2], + /// L'unité du modèle (plus grand côté du sprite) en px du plan. + unit: f32, + /// Hotspot du dessus, repère du plan (px). + tip: [f32; 3], + /// Décalage écran qui pose la pointe sur le pixel du contenu (warp bilinéaire du mode 8). + anchor: [f32; 2], + center: [f32; 2], + half: [f32; 2], + pose: CursorPose, + shape: SpriteShape, +} + +impl ModelView { + /// `None` pour un placement droit (`plan_cursor` n'en produit pas pour le modèle) ou une + /// caméra dégénérée. + /// + /// Le hotspot est posé sur le rayon de vue qui passe par le point du contenu (`plane_pt`), + /// à sa hauteur : la pointe reste donc sur le même pixel quand la flèche monte ou descend, et + /// seule l'ombre dit la hauteur. Le contenu, lui, est dessiné par un warp BILINÉAIRE des coins + /// projetés, qui s'écarte de la perspective exacte de quelques px : tout le rendu est décalé + /// de l'écart au point visé (`anchor`), pour que la pointe tombe sur le pixel que montre + /// l'écran. + /// + /// Le hotspot est posé à `clearance + contact_lift` : le point le plus bas du modèle posé, + /// quel qu'il soit, est à `clearance` du plan, donc rien ne passe jamais dessous. + pub(crate) fn new( + placement: CursorPlacement, + size_px: f32, + pose: CursorPose, + shape: SpriteShape, + ) -> Option { + let CursorPlacement::Tilted { plane_pt, quad, center_px, screen_px, .. } = placement else { + return None; + }; + let s = quad.scale; + let half = [screen_px[0] * s * 0.5, screen_px[1] * s * 0.5]; + let perspective = quad.perspective; + let unit = size_px * s; + let eye = crate::regions::rotate_point_inv( + [-quad.offset[0], -quad.offset[1], perspective], + quad.rot, + ); + if !(unit > 0.0) || !(eye[2] > 1e-3) { + return None; + } + let ground = + [(plane_pt[0] - 0.5) * 2.0 * half[0], (plane_pt[1] - 0.5) * 2.0 * half[1], 0.0]; + let height = unit * (pose.clearance + shape.contact_lift(pose.pitch, pose.squash)); + let k = height / eye[2]; + let tip = + [ground[0] + (eye[0] - ground[0]) * k, ground[1] + (eye[1] - ground[1]) * k, height]; + let mut view = ModelView { + rot: quad.rot, + perspective, + offset: quad.offset, + unit, + tip, + anchor: [0.0; 2], + center: center_px, + half, + pose, + shape, + }; + let exact = view.project(ground)?; + let (bx, by) = quad.point_px(plane_pt[0], plane_pt[1]); + view.anchor = [center_px[0] + bx - exact[0], center_px[1] + by - exact[1]]; + Some(view) + } + + /// Point du modèle (unités) → repère du plan (px) : tangage, lacet, échelle, hotspot. + pub(crate) fn model_to_plane(&self, q: [f32; 3]) -> [f32; 3] { + let (cp, sp) = (self.pose.pitch.cos(), self.pose.pitch.sin()); + let (cy, sy) = (self.pose.yaw.cos(), self.pose.yaw.sin()); + let (x, y, z) = (q[0], q[1] * cp - q[2] * sp, q[1] * sp + q[2] * cp); + let (x, y) = (x * cy - y * sy, x * sy + y * cy); + [self.tip[0] + self.unit * x, self.tip[1] + self.unit * y, self.tip[2] + self.unit * z] + } + + /// Point du repère du plan → px de sortie : la perspective exacte, puis l'ancrage. + pub(crate) fn project(&self, p: [f32; 3]) -> Option<[f32; 2]> { + let w = crate::regions::rotate_point(p, self.rot); + let d = self.perspective - w[2]; + if !(d > 1e-3) { + return None; + } + let f = self.perspective / d; + let (x, y) = (w[0] + self.offset[0], w[1] + self.offset[1]); + Some([self.center[0] + self.anchor[0] + x * f, self.center[1] + self.anchor[1] + y * f]) + } + + /// La lumière dans le repère du plan (elle est fixée à la caméra, pas au plan). + pub(crate) fn light(&self) -> [f32; 3] { + crate::regions::rotate_point_inv(MODEL_LIGHT, self.rot) + } + + /// Boîte (px de sortie, `[x0, y0, x1, y1]`) qui contient le modèle ET son ombre : les huit + /// coins de la boîte du modèle, et leur projection au sol le long de la lumière, élargie de la + /// portée de la pénombre et du contact. Conservatrice : le shader ne dessine rien hors d'elle. + /// + /// Portée : un rayon qui passe à `d` d'un point du modèle part d'un point du sol décalé d'au + /// plus `d / lz` de son ombre géométrique (`lz` = élévation de la lumière au-dessus du plan), + /// et la pénombre s'arrête à `d = min(t / MODEL_SOFTNESS, MODEL_SHADOW_PAD)`. + pub(crate) fn footprint(&self) -> Option<[f32; 4]> { + let (lo, hi) = self.shape.model_box(self.pose.squash); + let light = self.light(); + let lz = light[2].max(0.2); + let corners: [[f32; 3]; 8] = std::array::from_fn(|i| { + self.model_to_plane([ + if i & 1 == 0 { lo[0] } else { hi[0] }, + if i & 2 == 0 { lo[1] } else { hi[1] }, + if i & 4 == 0 { lo[2] } else { hi[2] }, + ]) + }); + 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); + 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]| { + b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]; + }; + for p in corners { + add(self.project(p)?); + let h = p[2].max(0.0) / lz; + let g = [p[0] - light[0] * h, p[1] - light[1] * h]; + for (dx, dy) in [(-reach, -reach), (reach, -reach), (reach, reach), (-reach, reach)] { + add(self.project([g[0] + dx, g[1] + dy, 0.0])?); + } + } + const PAD_PX: f32 = 2.0; + Some([b[0] - PAD_PX, b[1] - PAD_PX, b[2] + PAD_PX, b[3] + PAD_PX]) + } +} + +/// `LayerCB` du curseur modélisé (mode 15) posé en `placement`, pour les trois backends : le +/// sprite `shape` extrudé. `None` quand il n'y a rien à dessiner (placement droit, caméra +/// dégénérée). Voir l'en-tête de cette section pour l'emploi des emplacements. +pub fn cursor_model_cb( placement: CursorPlacement, size_px: f32, - volume01: f32, + pose: CursorPose, + shape: SpriteShape, + alpha: f32, + clip: [f32; 4], +) -> Option { + let view = ModelView::new(placement, size_px, pose, shape)?; + let CursorPlacement::Tilted { render_px: [rw, rh], .. } = placement else { return None }; + let [x0, y0, x1, y1] = view.footprint()?; + // Bords entiers : `local` vaut alors k + 0,5 au centre des pixels, comme le rastériseur. + let (x0, y0, x1, y1) = (x0.floor(), y0.floor(), x1.ceil(), y1.ceil()); + let (bw, bh) = ((x1 - x0).max(1.0), (y1 - y0).max(1.0)); + let r = view.rot.map(f32::to_radians); + Some(LayerCB { + dst: [x0 / rw, y0 / rh, bw / rw, bh / rh], + src: [ + x0 - view.center[0] - view.anchor[0], + y0 - view.center[1] - view.anchor[1], + view.perspective, + view.unit, + ], + quad_px: [bw, bh], + mode: 15.0, + color: [shape.origin()[0], shape.origin()[1], pose.squash, alpha], + fx: [r[0], r[1], r[2], pose.pitch], + src_prev: [view.tip[0], view.tip[1], view.tip[2], pose.yaw], + dst_prev: clip, + mb: [view.half[0], view.half[1], view.offset[0], view.offset[1]], + radius_px: shape.size[0] / shape.size[1], + ..Default::default() + }) +} + +// ============ Impact du clic (mode 16) ============ +// +// Sous le curseur modélisé, chaque clic laisse sur l'écran une tache de pression sous la pointe et +// un anneau qui s'étend depuis le point cliqué. Les deux sont posés SUR le plan, comme le sprite du +// mode 13 : un carré du plan centré sur le point, dont les coins passent par la projection du +// contenu, puis le warp inverse du shader. Ils suivent donc l'inclinaison et la caméra, et leur +// centre est le pixel cliqué. Rust calcule la courbe dans le temps ; les shaders ne dessinent +// que la forme de l'instant. +// +// Emplacements du `LayerCB` au mode 16 (longueurs en fractions du demi-côté du carré) : +// dst, quad_px bbox du carré projeté (sortie 0..1, px) +// fx, src_prev coins TL, TR puis BR, BL, en px locaux à la bbox (comme au mode 13) +// mb.x 1 = warp projectif (caméra réelle) +// mb.y, mb.z rayon et opacité de la tache de pression +// src rayon de l'anneau, sa demi-épaisseur, son opacité, celle de son halo sombre +// dst_prev le carré en fractions du plan (x, y, l, h) : rien n'est dessiné hors de l'écran +// radius_px largeur de l'antialiasing +// color teinte de l'anneau (rgb), opacité du curseur (a) + +/// L'impact part du creux de `tap`, l'instant du contact : la pression y est au plus fort. +const IMPACT_DELAY_S: f32 = 0.0495; +/// Durée de l'anneau. +const IMPACT_S: f32 = 0.4; +/// Demi-côté du carré de l'impact, en tailles de curseur, à `clickBounce` par défaut. +const IMPACT_EXTENT: f32 = 0.7; + +/// L'impact d'un clic, `age` secondes après lui, à la force `strength` (`clickBounce` rapporté à +/// sa valeur par défaut). `None` hors de sa fenêtre. +/// +/// L'anneau part de 12 % du carré et s'arrête à 80 % en décélérant (cubique), s'amincit de moitié +/// et s'éteint en `(1 − u)²`. La tache de pression, elle, ne dure que le contact et son rebond. +fn impact_at(age: f32, strength: f32) -> Option { + let u = (age - IMPACT_DELAY_S) / IMPACT_S; + if !(0.0..1.0).contains(&u) { + return None; + } + let smooth = |a: f32, b: f32, x: f32| { + let v = ((x - a) / (b - a)).clamp(0.0, 1.0); + v * v * (3.0 - 2.0 * v) + }; + let grow = 1.0 - (1.0 - u).powi(3); + let fade = (1.0 - u).powi(2) * smooth(0.0, 0.05, u) * strength.min(1.0); + Some(Impact { + ring_r: 0.12 + 0.68 * grow, + ring_w: 0.05 * (1.0 - 0.5 * grow), + ring_a: 0.9 * fade, + halo_a: 0.3 * fade, + spot_r: 0.3, + spot_a: 0.3 * (1.0 - smooth(0.0, 0.45, u)) * smooth(0.0, 0.05, u) * strength.min(1.0), + }) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Impact { + ring_r: f32, + ring_w: f32, + ring_a: f32, + halo_a: f32, + spot_r: f32, + spot_a: f32, +} + +/// `LayerCB` de l'impact (mode 16) centré sur le point du plan de `placement`, carré de demi-côté +/// `half_px` (px du rect d'écran non incliné, l'unité de la taille du curseur). `None` pour un +/// placement droit : le curseur modélisé a toujours un plan. +fn cursor_impact_cb( + placement: CursorPlacement, + half_px: f32, + impact: Impact, alpha: f32, -) -> Option { - let CursorPlacement::Tilted { plane_pt, quad, center_px, screen_px, render_px } = placement +) -> Option { + let CursorPlacement::Tilted { plane_pt, quad, center_px, screen_px, render_px: [rw, rh] } = + placement else { return None; }; - let scale = quad.scale; - // Tout en fraction de la taille du sprite : l'épaisseur suit le réglage Size et le padding, - // et la réduction du plan incliné (`scale`) comme le sprite lui-même. - let unit = size_px * volume01 * scale; - let e = cursor_extrusion_px( - rot, - CURSOR_VOLUME_DEPTH_FRAC * unit, - CURSOR_VOLUME_FLAT_FRAC * unit, - ); - // Une copie par pixel de profondeur : au-delà de 1 px d'écart, les copies se lisent comme - // des marches sur le contour. - let taps = ((e[0].hypot(e[1]).ceil() as u32) + 1).clamp(2, CURSOR_VOLUME_MAX_TAPS); - - // Ombre de contact : un petit carré DANS le plan autour de la pointe, projeté comme le - // sprite, puis poussé de `e` jusqu'au pied de l'extrusion — là où le curseur touche l'écran. - let half = 0.08 * size_px; - let (hx, hy) = (half / screen_px[0], half / screen_px[1]); - let corners = [(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)].map(|(dx, dy)| { - let (px, py) = quad.point_px(plane_pt[0] + dx, plane_pt[1] + dy); - (center_px[0] + px + e[0], center_px[1] + py + e[1]) - }); - // Rayon au plafond : la tache est ronde, et `quad_shadow_max_radius` garde le mode 12 à - // l'endroit (au-delà de 0.8× le demi-côté, le quad rentré se retourne). - let radius = quad_shadow_max_radius(&corners); - let spread = 0.14 * size_px * scale; - let shadow = quad_shadow_cb(&corners, radius, spread, 0.35 * volume01 * alpha, render_px); - Some(CursorVolume { extrude_px: e, taps, shadow }) + let (hx, hy) = (half_px / screen_px[0], half_px / screen_px[1]); + let (x0, y0) = (plane_pt[0] - hx, plane_pt[1] - hy); + let corners = [(x0, y0), (x0 + 2.0 * hx, y0), (x0 + 2.0 * hx, y0 + 2.0 * hy), (x0, y0 + 2.0 * hy)] + .map(|(fx, fy)| { + let (px, py) = quad.point_px(fx, fy); + (center_px[0] + px, center_px[1] + py) + }); + let (min_x, max_x) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(x, _)| (mn.min(x), mx.max(x))); + let (min_y, max_y) = + corners.iter().fold((f32::MAX, f32::MIN), |(mn, mx), &(_, y)| (mn.min(y), mx.max(y))); + let (bw, bh) = ((max_x - min_x).max(1.0), (max_y - min_y).max(1.0)); + let local = |(x, y): (f32, f32)| [x - min_x, y - min_y]; + let [tl0, tl1] = local(corners[0]); + let [tr0, tr1] = local(corners[1]); + let [br0, br1] = local(corners[2]); + let [bl0, bl1] = local(corners[3]); + Some(LayerCB { + dst: [min_x / rw, min_y / rh, bw / rw, bh / rh], + src: [impact.ring_r, impact.ring_w, impact.ring_a, impact.halo_a], + quad_px: [bw, bh], + // Un px à l'écran, en fractions du demi-côté, pris sur le petit axe de la bbox. + radius_px: 2.0 / bw.min(bh), + mode: 16.0, + color: [1.0, 1.0, 1.0, alpha], + fx: [tl0, tl1, tr0, tr1], + 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], + }) } /// Poids d'un échantillon du flou de mouvement de curseur (0 = queue/passé, taps-1 = tête/courant). @@ -2591,7 +3194,7 @@ mod tests { assert!((radius - wf.radius * quad.scale).abs() < 1e-4); let [ml, mt, mr, mb] = wf.margins; - let frame_quad = crate::regions::TiltedQuad { corners: frame, scale: quad.scale, depth_k: quad.depth_k }; + let frame_quad = crate::regions::TiltedQuad { corners: frame, ..quad }; let (u0, v0) = (ml / (1.0 + ml + mr), mt / (1.0 + mt + mb)); let (u1, v1) = ((1.0 + ml) / (1.0 + ml + mr), (1.0 + mt) / (1.0 + mt + mb)); for (i, (u, v)) in [(u0, v0), (u1, v0), (u1, v1), (u0, v1)].into_iter().enumerate() { @@ -3027,6 +3630,76 @@ mod tests { } } + /// `follow-cursor` passe par `plan_frame` : l'écran n'est pas incliné, la caméra vise et tourne + /// avec le pointeur lu dans le RECADRAGE, la boîte zoome sur son centre sans glisser, la mise au + /// point suit la visée, un clic fait reculer l'œil sans presser l'écran, et curseur masqué + /// (l'export n'a alors pas de piste) la caméra vise le centre, au repos. + #[test] + fn the_follow_camera_aims_at_the_pointer_in_the_crop() { + let cfg = crate::config::all().pop().expect("au moins une config"); + let json = zoomed_golden_scene_json() + .replace(r#""rotation":"none""#, r#""rotation":"follow-cursor","clickImpact":true"#); + let scene = Scene::from_json(&json).expect("scène"); + let parked = |x: f32, clicks: Vec| -> &'static crate::cursor::CursorTrack { + Box::leak(Box::new(crate::cursor::CursorTrack::new( + (0..=90).map(|i| (i as f32 / 30.0, x, 0.3)).collect(), + clicks, + vec![], + ))) + }; + let plan = |scene: &Scene, track| { + plan_frame(&FrameGeometryInput { cursor: Some(track), ..golden_input(scene, &cfg) }) + }; + // Le crop du golden fait 0,61 de large : x = 0,55 est tout à droite de ce qu'on voit, alors + // que dans l'image entière ce serait presque le centre. + let right = plan(&scene, parked(0.55, vec![])); + let left = plan(&scene, parked(0.05, vec![])); + let (cr, cl) = (right.camera.expect("caméra"), left.camera.expect("caméra")); + assert_eq!(cr.weight, 1.0); + // Zoom 2 : la vue reste dans l'écran tant que la visée reste dans [0,275 ; 0,725]. + assert!(cr.aim[0] > 0.72 && cl.aim[0] < 0.28, "{:?} {:?}", cr.aim, cl.aim); + // L'orbite lit tout le recadrage, sans la borne du zoom : 0,55 y tombe à 0,9. + assert!(cr.orbit[0] > 0.89 && cl.orbit[0] < 0.1, "{:?} {:?}", cr.orbit, cl.orbit); + assert_eq!((cr.zoom, cr.press), (2.0, 0.0)); + assert_eq!((right.zoom_rotation, right.zoom_rotation_dyn), ([0.0; 3], [0.0; 3])); + assert_eq!(right.focus_plane, cr.orbit, "la mise au point suit le pointeur"); + assert_eq!(right.s_dst, left.s_dst, "la boîte ne glisse pas"); + assert!(right.tilted() && right.depth_of_field_on(false) == right.depth_of_field); + + let render = [1170.0, 658.0]; + let s_px = [right.s_dst[2] * render[0], right.s_dst[3] * render[1]]; + let (qr, ql) = (right.screen_tilt(s_px).expect("caméra"), left.screen_tilt(s_px).expect("caméra")); + assert!(qr.projective && ql.projective); + assert!((qr.scale - ql.scale).abs() < 0.03, "{} {}", qr.scale, ql.scale); + // Visant à droite, la caméra rejette le centre de l'écran à gauche de l'image ; vue de la + // droite, le bord droit de l'écran est le plus haut. + assert!(qr.offset[0] < -100.0 && ql.offset[0] > 100.0, "{:?} {:?}", qr.offset, ql.offset); + let edge = |q: &crate::regions::TiltedQuad, a: usize, b: usize| q.corners[b].1 - q.corners[a].1; + assert!(edge(&qr, 1, 2) > edge(&qr, 0, 3) && edge(&ql, 0, 3) > edge(&ql, 1, 2)); + + // L'ombre de l'écran tombe du côté de celle de la flèche (lumière en haut à gauche), sur la + // même longueur que l'ombre droite, qui reste verticale. + let flat = plan(&zoomed_golden_scene(), parked(0.55, vec![])); + let ([fx, fy], [cx, cy]) = (flat.screen_shadow_offset(), right.screen_shadow_offset()); + assert!(fx == 0.0 && fy > 0.0, "{fx} {fy}"); + assert!(cx > 0.0 && cy > 0.0 && (cx.hypot(cy) - fy).abs() < 1e-3, "{cx} {cy}"); + + // Un clic ne presse pas l'écran immobile : l'œil recule, au creux de `tap` 50 ms après. + let clicked = plan(&scene, parked(0.55, vec![1.45])); + assert_eq!((clicked.zoom_rotation, clicked.zoom_rotation_dyn), ([0.0; 3], [0.0; 3])); + let pose = clicked.camera.expect("caméra"); + assert!(pose.press < -0.9, "{pose:?}"); + let pushed = clicked.screen_tilt(s_px).expect("caméra"); + assert!(pushed.half_extents_px().0 < 0.98 * qr.half_extents_px().0); + let off = Scene::from_json(&json.replace(r#","clickImpact":true"#, "")).expect("scène"); + assert_eq!(plan(&off, parked(0.55, vec![1.45])).camera.expect("caméra").press, 0.0); + + let hidden = Scene::from_json(&json.replace(r#""show":true"#, r#""show":false"#)).expect("scène"); + let front = plan(&hidden, parked(0.55, vec![])).camera.expect("caméra au repos"); + assert!((front.aim[0] - 0.5).abs() < 1e-4 && (front.aim[1] - 0.5).abs() < 1e-4, "{:?}", front.aim); + assert!((front.orbit[0] - 0.5).abs() < 1e-4 && (front.orbit[1] - 0.5).abs() < 1e-4, "{:?}", front.orbit); + } + /// Sous un préset 3D, le masque est le quad du contenu, warpé comme le mode 8 le dessine. #[test] fn a_privacy_mask_follows_the_tilted_content() { @@ -3613,6 +4286,7 @@ mod tests { programme_t: 0.0, zoom_rotation: [0.0, 0.0, 0.0], zoom_rotation_dyn: [0.0, 0.0, 0.0], + camera: None, padding_scale: 1.0, cut: [0.0, 0.0, 1.0, 1.0], focus_plane: [0.5, 0.5], @@ -3738,7 +4412,8 @@ mod tests { assert!((peak / rest - 1.8).abs() < 1e-3, "pic = 1 + 0.16 * 5, got {}", peak / rest); } - fn plan_with(rot: [f32; 3], volume: f32) -> CursorPlan { + /// Un plan de curseur immobile sous la rotation `rot`, coupe fixe. + fn plan_with(rot: [f32; 3]) -> CursorPlan { let cfg = crate::config::all().pop().expect("cfg"); let scene = zoomed_golden_scene(); let fg = FrameGeometry { zoom_rotation: rot, ..full_frame_geometry() }; @@ -3751,7 +4426,7 @@ mod tests { u_max: 1.0, v_max: 1.0, cfg: &cfg, - live: LiveParams { cursor_volume: volume, ..LiveParams::default() }, + live: LiveParams::default(), scene: Some(&scene), track: &track, t: 0.5, @@ -3763,130 +4438,10 @@ mod tests { const ISO: [f32; 3] = [-12.0, -18.0, -2.0]; const LEFT: [f32; 3] = [-8.0, -16.0, -1.0]; - /// Le sens de `e` : à plat, le repli bas-droite ; sous `iso` et `left` (bord droit qui - /// s'éloigne, bord haut qui recule), les flancs dépassent vers la droite et vers le haut. - #[test] - fn cursor_extrusion_points_along_the_projected_plane_normal() { - let flat = cursor_extrusion_px([0.0; 3], 20.0, 3.0); - assert!((flat[0] - flat[1]).abs() < 1e-6 && flat[0] > 0.0, "bas-droite : {flat:?}"); - assert!((flat[0].hypot(flat[1]) - 3.0).abs() < 1e-4, "3 px à plat : {flat:?}"); - - let iso = cursor_extrusion_px(ISO, 20.0, 3.0); - let expect = [ - -(-18f32).to_radians().sin() * 20.0, - (-18f32).to_radians().cos() * (-12f32).to_radians().sin() * 20.0, - ]; - assert!((iso[0] - expect[0]).abs() < 1e-4 && (iso[1] - expect[1]).abs() < 1e-4, "{iso:?}"); - assert!(iso[0] > 5.0 && iso[1] < -3.0, "iso : droite et haut, {iso:?}"); - - let left = cursor_extrusion_px(LEFT, 20.0, 3.0); - assert!(left[0] > 5.0 && left[1] < -2.0, "left : droite et haut, {left:?}"); - // `right` est le miroir de `left` : les flancs passent à gauche. - let right = cursor_extrusion_px([-8.0, 16.0, 1.0], 20.0, 3.0); - assert!((right[0] + left[0]).abs() < 1e-5 && (right[1] - left[1]).abs() < 1e-5); - - // Entre les deux, pas de saut : une rotation de 0.1° reste à côté du repli. - let tiny = cursor_extrusion_px([0.0, -0.1, 0.0], 20.0, 3.0); - assert!((tiny[0] - flat[0]).abs() < 0.1 && (tiny[1] - flat[1]).abs() < 0.1, "{tiny:?}"); - } - - /// Le plan porte `e`, les copies et l'ombre ; à volume 0 il n'en porte aucun, et le sprite - /// reste sur le chemin droit (mode 7) quand l'écran est plat. - #[test] - fn cursor_plan_carries_the_volume_only_when_it_is_on() { - let off = plan_with([0.0; 3], 0.0); - assert!(off.volume.is_none()); - assert!(matches!(off.placement, CursorPlacement::Upright { .. })); - - let flat = plan_with([0.0; 3], 1.0); - assert!(matches!(flat.placement, CursorPlacement::Tilted { .. }), "volume ⇒ mode 13"); - let v = flat.volume.expect("volume"); - let e = v.extrude_px; - assert!(e[0] > 0.0 && (e[0] - e[1]).abs() < 1e-4, "repli bas-droite : {e:?}"); - let expect_len = CURSOR_VOLUME_FLAT_FRAC * flat.size_px; - assert!((e[0].hypot(e[1]) - expect_len).abs() < 1e-3, "{e:?} vs {expect_len}"); - assert!(v.taps >= 2 && v.taps <= CURSOR_VOLUME_MAX_TAPS); - assert_eq!(v.shadow.mode, 12.0); - - let iso = plan_with(ISO, 1.0).volume.expect("volume").extrude_px; - let left = plan_with(LEFT, 1.0).volume.expect("volume").extrude_px; - assert!(iso[0] > 0.0 && iso[1] < 0.0, "iso : {iso:?}"); - assert!(left[0] > 0.0 && left[1] < 0.0, "left : {left:?}"); - assert!(iso[1] < left[1], "iso est plus penché en X que left : {iso:?} / {left:?}"); - // Moitié de volume, moitié d'épaisseur. - let half = plan_with(ISO, 0.5).volume.expect("volume").extrude_px; - assert!((half[0] * 2.0 - iso[0]).abs() < 1e-3, "{half:?} / {iso:?}"); - } - - /// Une copie par pixel d'extrusion jusqu'en 4K : au-delà de 1 px d'écart, les flancs se - /// lisent en marches. `plan_with` rend en 1080p à la taille 1 ; l'éditeur part de la taille 3 - /// (`DEFAULT_CURSOR_SIZE`), et la 4K double encore `size_px` (et donc `e`). - #[test] - fn cursor_volume_copies_stay_under_a_pixel_apart_in_4k() { - let plan = plan_with(ISO, 1.0); - for size_px in [plan.size_px * 3.0, plan.size_px * 6.0] { - let v = cursor_volume(ISO, plan.placement, size_px, 1.0, 1.0).expect("volume"); - let len = v.extrude_px[0].hypot(v.extrude_px[1]); - let gap = len / (v.taps - 1) as f32; - assert!(gap <= 1.0, "|e| {len} px, {} copies : écart {gap} px", v.taps); - } - } - - /// À rotation nulle, le quad identité rend EXACTEMENT le rect du mode 7 : passer le curseur - /// en mode 13 pour lui donner du volume ne le déplace pas d'un pixel. - #[test] - fn an_identity_quad_reproduces_the_upright_sprite_rect() { - let render = [1920.0, 1080.0]; - let (sprite, hotspot) = ([34.0, 40.0], [0.1, 0.05]); - for dst in [[0.0, 0.0, 1.0, 1.0], [0.1, 0.07, 0.8, 0.8], [0.23, 0.11, 0.5, 0.61]] { - let (fx, fy) = (0.37, 0.58); - let upright = - CursorPlacement::Upright { center: [dst[0] + fx * dst[2], dst[1] + fy * dst[3]] }; - let screen_px = [dst[2] * render[0], dst[3] * render[1]]; - let quad = - crate::regions::rotated_quad_corners_px(screen_px[0], screen_px[1], [0.0; 3], [0.0; 3]); - assert_eq!(quad.scale, 1.0); - let tilted = CursorPlacement::Tilted { - plane_pt: [fx, fy], - quad, - center_px: [ - (dst[0] + dst[2] * 0.5) * render[0], - (dst[1] + dst[3] * 0.5) * render[1], - ], - screen_px, - render_px: render, - }; - let a = cursor_sprite_cb(upright, sprite, hotspot, 1.0, [0.0; 4], render, None); - let b = cursor_sprite_cb(tilted, sprite, hotspot, 1.0, [0.0; 4], render, None); - assert_eq!((a.mode, b.mode), (7.0, 13.0)); - for k in 0..4 { - let px = (a.dst[k] - b.dst[k]).abs() * render[k % 2]; - assert!(px < 1e-3, "dst[{k}] : {} vs {} ({px} px)", a.dst[k], b.dst[k]); - } - // Les coins du quad sont ceux de la bbox : TL (0,0), TR (w,0), BR (w,h), BL (0,h). - let [w, h] = b.quad_px; - let expect = [0.0, 0.0, w, 0.0, w, h, 0.0, h]; - let got = [ - b.fx[0], - b.fx[1], - b.fx[2], - b.fx[3], - b.src_prev[0], - b.src_prev[1], - b.src_prev[2], - b.src_prev[3], - ]; - for k in 0..8 { - assert!((got[k] - expect[k]).abs() < 1e-3, "coin {k} : {got:?}"); - } - assert_eq!(b.mb, [0.0; 4], "sans volume, le shader garde le chemin plat"); - } - } - - /// Volume 0 : le `LayerCB` du sprite incliné est, octet pour octet, celui d'avant le volume. - /// La référence est le corps d'origine de `draw_cursor_sprite`, recopié tel quel. + /// 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`. #[test] - fn cursor_sprite_cb_without_volume_is_byte_identical_to_the_flat_sprite() { + fn cursor_sprite_cb_is_byte_identical_to_the_flat_sprite() { fn before( p: CursorPlacement, pw: f32, @@ -3936,8 +4491,7 @@ mod tests { unsafe { std::slice::from_raw_parts(cb as *const LayerCB as *const u8, 128) }.to_vec() }; for rot in [ISO, LEFT, [-8.0, 16.0, 1.0]] { - let plan = plan_with(rot, 0.0); - assert!(plan.volume.is_none()); + let plan = plan_with(rot); let clip = [0.1, 0.2, 0.7, 0.6]; let got = cursor_sprite_cb( plan.placement, @@ -3946,69 +4500,734 @@ mod tests { 0.8, clip, [1920.0, 1080.0], - None, ); let want = before(plan.placement, 30.0, 34.0, [0.2, 0.1], 0.8, clip); assert_eq!(bytes(&got), bytes(&want), "rotation {rot:?}"); } } - /// Le rayon de l'ombre de contact reste sous le point où le mode 12 retourne son quad, et - /// la bbox du sprite en volume contient ses faces arrière. + // ---- Curseur modélisé (mode 15) ---- + + /// Les seize états du thème par défaut et leurs hotspots (`DEFAULT_CURSOR_SPRITES`, + /// `src/lib/cursor/cursorThemes.ts`). + const DEFAULT_SPRITES: [(&str, [f32; 2]); 16] = [ + ("arrow", [0.119, 0.0874]), + ("text", [0.4375, 0.5333]), + ("pointer", [0.3893, 0.0032]), + ("crosshair", [0.4667, 0.4667]), + ("open-hand", [0.4375, 0.1781]), + ("closed-hand", [0.3889, 0.451]), + ("resize-ew", [0.4881, 0.4706]), + ("resize-ns", [0.5, 0.5]), + ("resize-nesw", [0.5, 0.5]), + ("resize-nwse", [0.5, 0.5]), + ("move", [0.4444, 0.4444]), + ("not-allowed", [0.5, 0.5]), + ("wait", [0.5, 0.5]), + ("app-starting", [0.05, 0.0537]), + ("help", [0.0515, 0.0572]), + ("up-arrow", [0.5, 0.069]), + ]; + + /// Les états qu'on fait passer par les tests géométriques : pointeurs, centrés, entre deux. + const MODEL_STATES: [&str; 8] = + ["arrow", "pointer", "up-arrow", "open-hand", "text", "resize-ew", "not-allowed", "closed-hand"]; + + fn hotspot_of(key: &str) -> [f32; 2] { + DEFAULT_SPRITES.iter().find(|(k, _)| *k == key).expect("état connu").1 + } + + fn sprite_path(key: &str) -> String { + format!("{}/../../public/cursors/default/{key}.png", env!("CARGO_MANIFEST_DIR")).replace('\\', "/") + } + + /// Le champ du sprite livré et sa forme, hotspot de la scène posé. + fn sprite_model(key: &str) -> (crate::cursor_sdf::CursorSdf, SpriteShape) { + let sdf = crate::cursor_sdf::CursorSdf::load(&sprite_path(key)).expect("sprite livré"); + let shape = SpriteShape { hotspot: hotspot_of(key), ..sdf.shape }; + (sdf, shape) + } + + /// Miroir CPU de `sd_sprite2` des shaders : le champ dans le rect du sprite, la borne le long + /// de la normale hors de lui. + fn sd2(sdf: &crate::cursor_sdf::CursorSdf, shape: SpriteShape, p: [f32; 2]) -> f32 { + let [x0, y0] = shape.origin(); + let c = [p[0].clamp(x0, x0 + shape.size[0]), p[1].clamp(y0, y0 + shape.size[1])]; + let d = sdf.sample([(c[0] - x0) / shape.size[0], (c[1] - y0) / shape.size[1]]); + let o = (p[0] - c[0]).hypot(p[1] - c[1]); + if o > 0.0 { o.hypot(d.max(0.0)) } else { d } + } + + /// Miroir CPU de `sd_model`, écrasé à `squash`. + fn sd_model(sdf: &crate::cursor_sdf::CursorSdf, shape: SpriteShape, squash: f32, p: [f32; 3]) -> f32 { + let half_t = MODEL_THICK * squash * 0.5; + let w = [sd2(sdf, shape, [p[0], p[1]]) + MODEL_BEVEL, (p[2] + half_t).abs() - (half_t - MODEL_BEVEL)]; + w[0].max(w[1]).min(0.0) + w[0].max(0.0).hypot(w[1].max(0.0)) - MODEL_BEVEL + } + + /// Repère du plan → modèle (vecteurs) : l'inverse de `ModelView::model_to_plane`. + fn plane_to_model(v: [f32; 3], pose: CursorPose) -> [f32; 3] { + let (cp, sp) = (pose.pitch.cos(), pose.pitch.sin()); + let (cy, sy) = (pose.yaw.cos(), pose.yaw.sin()); + let (x, y) = (v[0] * cy + v[1] * sy, -v[0] * sy + v[1] * cy); + [x, y * cp + v[2] * sp, -y * sp + v[2] * cp] + } + + /// Miroir CPU de l'ombre du mode 15 en un point `g` du plan (px du plan) : opacité 0..1. + fn model_shadow_at(view: &ModelView, sdf: &crate::cursor_sdf::CursorSdf, g: [f32; 2]) -> f32 { + let (pose, shape) = (view.pose, view.shape); + let q = plane_to_model( + [(g[0] - view.tip[0]) / view.unit, (g[1] - view.tip[1]) / view.unit, -view.tip[2] / view.unit], + pose, + ); + let l = plane_to_model(view.light(), pose); + let (lo, hi) = shape.model_box(pose.squash); + let (mut t0, mut t1) = (f32::MIN, f32::MAX); + for k in 0..3 { + let inv = 1.0 / if l[k].abs() > 1e-6 { l[k] } else { 1e-6 }; + let (a, b) = ((lo[k] - MODEL_SHADOW_PAD - q[k]) * inv, (hi[k] + MODEL_SHADOW_PAD - q[k]) * inv); + t0 = t0.max(a.min(b)); + t1 = t1.min(a.max(b)); + } + let mut lit = 1.0f32; + if t0 < t1 && t1 > 0.0 { + let mut t = t0.max(0.004); + for _ in 0..32 { + let d = sd_model(sdf, shape, pose.squash, [q[0] + l[0] * t, q[1] + l[1] * t, q[2] + l[2] * t]); + lit = lit.min(MODEL_SOFTNESS * d / t); + if lit < 0.002 || t > t1 { + break; + } + t += d.clamp(0.01, 0.2); + } + let r = lit.clamp(0.0, 1.0); + lit = r * r * (3.0 - 2.0 * r); + } + let u = (sd_model(sdf, shape, pose.squash, q) / MODEL_CONTACT_RADIUS).clamp(0.0, 1.0); + let contact = 1.0 - u * u * (3.0 - 2.0 * u); + ((1.0 - lit) * 0.5).max(contact * 0.5) + } + + /// Le point le plus bas du modèle posé, en unités au-dessus du plan. Pour un tangage ≥ 0, le + /// plus bas d'une colonne (x, y) pleine est le bas de sa colonne : l'épaisseur sous le plat + /// du dessous, relevé sur le chanfrein (`sd_model`, résolu en z). + fn lowest_point(view: &ModelView, sdf: &crate::cursor_sdf::CursorSdf) -> f32 { + let shape = view.shape; + let [x0, y0] = shape.origin(); + let n = 400; + let mut lowest = f32::MAX; + for i in 0..=n { + for j in 0..=n { + let (x, y) = (x0 + shape.size[0] * i as f32 / n as f32, y0 + shape.size[1] * j as f32 / n as f32); + let s = sd2(sdf, shape, [x, y]); + if s >= 0.0 { + continue; + } + let wx = (s + MODEL_BEVEL).max(0.0); + let z = -MODEL_THICK * view.pose.squash + MODEL_BEVEL - (MODEL_BEVEL * MODEL_BEVEL - wx * wx).sqrt(); + lowest = lowest.min(view.model_to_plane([x, y, z])[2]); + } + } + lowest / view.unit + } + + /// La scène dorée, thème par défaut, avec les seize sprites livrés. + fn model_scene() -> Scene { + let mut scene = zoomed_golden_scene(); + scene.cursor.theme = "default".into(); + for (key, [hx, hy]) in DEFAULT_SPRITES { + scene.cursor.cursor_sprites.insert( + key.into(), + crate::scene::SceneCursorSprite { path: sprite_path(key), hotspot_x: hx, hotspot_y: hy }, + ); + } + scene + } + + fn model_plan( + rot: [f32; 3], + scene: &Scene, + track: &crate::cursor::CursorTrack, + t: f32, + model3d: bool, + ) -> Option { + let cfg = crate::config::all().pop().expect("cfg"); + let fg = FrameGeometry { zoom_rotation: rot, ..full_frame_geometry() }; + plan_cursor( + &fg, + &CursorPlanInput { + render_px: [1920.0, 1080.0], + u_max: 1.0, + v_max: 1.0, + cfg: &cfg, + live: LiveParams { + cursor_model3d: model3d, + cursor_bounce_scale: 2.5, + ..LiveParams::default() + }, + scene: Some(scene), + track, + t, + }, + ) + } + + /// Immobile en (0.4, 0.6), dans l'état `kind` (`None` = la flèche), avec ces clics. + fn still_track_as(kind: Option<&str>, clicks: Vec) -> crate::cursor::CursorTrack { + let types = kind.map(|k| vec![(0.0, k.to_string())]).unwrap_or_default(); + crate::cursor::CursorTrack::new(vec![(0.0, 0.4, 0.6), (4.0, 0.4, 0.6)], clicks, types) + } + + fn still_track(clicks: Vec) -> crate::cursor::CursorTrack { + still_track_as(None, clicks) + } + + /// Le creux de `tap` : le contact que le modèle partage avec le rebond et l'impact du clic. + const CONTACT_S: f32 = 0.0495; + #[test] - fn cursor_volume_shadow_and_bbox_stay_consistent() { - for rot in [[0.0; 3], ISO, LEFT] { - let plan = plan_with(rot, 1.0); - let v = plan.volume.expect("volume"); - let s = v.shadow; - let c = [ - (s.fx[0], s.fx[1]), - (s.fx[2], s.fx[3]), - (s.src_prev[0], s.src_prev[1]), - (s.src_prev[2], s.src_prev[3]), - ]; - // Demi-côté le plus court du quad d'ombre, mesuré naïvement sur ses arêtes. - let min_half = (0..4) - .map(|k| { - let (a, b) = (c[k], c[(k + 1) % 4]); - (b.0 - a.0).hypot(b.1 - a.1) * 0.5 - }) - .fold(f32::MAX, f32::min); - assert!( - s.radius_px > 0.0 && s.radius_px <= 0.8 * min_half + 1e-3, - "{rot:?} : r {} / {min_half}", - s.radius_px + fn the_model_pose_is_a_pure_function_of_time() { + let track = crate::cursor::CursorTrack::new( + vec![(0.0, 0.2, 0.5), (0.9, 0.3, 0.5), (1.0, 0.7, 0.4), (3.0, 0.7, 0.4)], + vec![1.05, 1.4], + vec![], + ); + let times: Vec = (0..400).map(|k| k as f32 * 0.00731).collect(); + let forward: Vec = times.iter().map(|&t| cursor_pose(&track, t, 2.5, 1.0)).collect(); + let backward: Vec = + times.iter().rev().map(|&t| cursor_pose(&track.clone(), t, 2.5, 1.0)).collect(); + assert!(forward.iter().eq(backward.iter().rev()), "l'ordre d'évaluation change la pose"); + // Continue : pas de saut d'une milliseconde à l'autre, même autour des clics. + for k in 0..2999 { + let (a, b) = (k as f32 * 0.001, (k + 1) as f32 * 0.001); + let (p, q) = (cursor_pose(&track, a, 2.5, 1.0), cursor_pose(&track, b, 2.5, 1.0)); + assert!((p.yaw - q.yaw).abs() < 0.02, "lacet discontinu en {a} : {} -> {}", p.yaw, q.yaw); + assert!((p.clearance - q.clearance).abs() < 0.03, "hauteur discontinue en {a}"); + } + } + + #[test] + fn the_model_touches_the_plane_on_the_click_contact() { + let track = still_track(vec![0.5]); + let rest = cursor_pose(&track, 0.45, 2.5, 1.0); + assert_eq!(rest.clearance, MODEL_HOVER); + assert!((rest.pitch - MODEL_PITCH_IDLE_DEG.to_radians()).abs() < 1e-6); + assert_eq!(rest.yaw, 0.0, "immobile : pas de lacet"); + // Au clic même, rien n'a encore bougé ; au creux, le modèle est posé, plus penché. + assert_eq!(cursor_pose(&track, 0.5, 2.5, 1.0).clearance, MODEL_HOVER); + let down = cursor_pose(&track, 0.5 + CONTACT_S, 2.5, 1.0); + assert_eq!(down.clearance, 0.0, "au creux du contact, le modèle touche le plan"); + assert!(down.pitch > rest.pitch + 5f32.to_radians(), "la pression penche le modèle"); + // Posé assez longtemps pour qu'au moins une image le montre, même à 24 i/s… + for ms in 30..=70 { + assert_eq!(cursor_pose(&track, 0.5 + ms as f32 / 1000.0, 2.5, 1.0).clearance, 0.0, "{ms} ms"); + } + // …au même instant que la pression du rebond d'échelle, et relevé à la fin de la fenêtre. + let press = (0..260).min_by(|&a, &b| { + track.bounce(0.5 + a as f32 / 1000.0).total_cmp(&track.bounce(0.5 + b as f32 / 1000.0)) + }); + let press_ms = press.expect("un creux") as f32; + assert_eq!(cursor_pose(&track, 0.5 + press_ms / 1000.0, 2.5, 1.0).clearance, 0.0); + assert_eq!(cursor_pose(&track, 0.5 + crate::regions::CLICK_IMPACT_WINDOW_S, 2.5, 1.0), rest); + // clickBounce règle la pression, pas le contact. + let soft = cursor_pose(&track, 0.5 + CONTACT_S, 0.0, 1.0); + assert_eq!(soft.clearance, 0.0); + assert!((soft.pitch - rest.pitch).abs() < 1e-6); + } + + /// « Posé » veut dire posé, pour chaque état : au repos le point le plus bas du modèle est à + /// la garde au sol (jamais sous le plan), au contact il affleure le plan à 2 % de l'unité. + #[test] + fn every_state_rests_above_the_plane_and_touches_it_on_click() { + 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]] { + 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"); + let pose = plan.model.expect("modèle"); + let view = ModelView::new(plan.placement, plan.size_px, pose, shape).expect("vue"); + let gap = lowest_point(&view, &sdf) - want; + println!("{key} {rot:?} t={t}: point le plus bas à {want} + {gap:.4} u"); + assert!((-0.002..0.02).contains(&gap), "{key} {rot:?} t={t}: écart {gap} u"); + } + } + } + } + + #[test] + fn the_model_leans_towards_its_motion_and_its_click_target() { + let still = still_track(vec![]); + assert_eq!(cursor_pose(&still, 1.0, 2.5, 1.0).yaw, 0.0); + let right = crate::cursor::CursorTrack::new(vec![(0.0, 0.1, 0.5), (2.0, 0.9, 0.5)], vec![], vec![]); + let left = crate::cursor::CursorTrack::new(vec![(0.0, 0.9, 0.5), (2.0, 0.1, 0.5)], vec![], vec![]); + let (r, l) = (cursor_pose(&right, 1.0, 2.5, 1.0).yaw, cursor_pose(&left, 1.0, 2.5, 1.0).yaw); + assert!(r > 5f32.to_radians() && (r + l).abs() < 1e-5, "droite {r}, gauche {l}"); + let fast = crate::cursor::CursorTrack::new(vec![(0.0, 0.0, 0.5), (0.2, 1.0, 0.5)], vec![], vec![]); + let y = cursor_pose(&fast, 0.1, 2.5, 1.0).yaw; + assert!(y <= MODEL_YAW_MAX_DEG.to_radians() + 1e-6 && y > 20f32.to_radians(), "{y}"); + // Arrivée sur une cible à droite puis clic : juste avant, le modèle se tourne vers elle + // plus que la même arrivée sans clic ; au repos, longtemps après, le lacet revient à 0. + // Échantillonnée à 30 Hz comme la télémétrie : la piste de suivi lissée n'interpole pas + // à travers des trous de plusieurs secondes. + let samples: Vec<(f32, f32, f32)> = (0..120) + .map(|k| { + let t = k as f32 / 30.0; + (t, if t < 0.9 { 0.3 } else { 0.3 + 0.4 * ((t - 0.9) / 0.1).min(1.0) }, 0.5) + }) + .collect(); + let clicked = crate::cursor::CursorTrack::new(samples.clone(), vec![1.1], vec![]); + let quiet = crate::cursor::CursorTrack::new(samples, vec![], vec![]); + assert!(cursor_pose(&clicked, 1.05, 2.5, 1.0).yaw > cursor_pose(&quiet, 1.05, 2.5, 1.0).yaw + 1e-3); + assert!(cursor_pose(&clicked, 3.5, 2.5, 1.0).yaw.abs() < 1e-3); + } + + /// Les pointeurs penchent et tournent comme la flèche ; les curseurs centrés restent à plat et + /// de face, mais montent et descendent pareil. + #[test] + fn pointing_states_lean_and_centred_states_stay_level() { + for (key, hotspot) in DEFAULT_SPRITES { + let f = pointing_factor(hotspot); + match key { + "arrow" | "pointer" | "help" | "app-starting" | "up-arrow" => assert_eq!(f, 1.0, "{key}"), + "open-hand" => assert!(f > 0.5 && f < 1.0, "{key}: {f}"), + _ => assert_eq!(f, 0.0, "{key}"), + } + } + let moving = crate::cursor::CursorTrack::new( + vec![(0.0, 0.1, 0.5), (2.0, 0.9, 0.5)], + vec![1.0 - CONTACT_S], + vec![], + ); + let (full, level) = (cursor_pose(&moving, 1.0, 2.5, 1.0), cursor_pose(&moving, 1.0, 2.5, 0.0)); + assert!(full.pitch > 0.3 && full.yaw > 0.05, "{full:?}"); + assert_eq!((level.pitch, level.yaw), (0.0, 0.0)); + assert_eq!(level.clearance, full.clearance); + let half = cursor_pose(&moving, 1.0, 2.5, 0.5); + assert!((half.pitch - full.pitch * 0.5).abs() < 1e-6 && (half.yaw - full.yaw * 0.5).abs() < 1e-6); + // À plat, le lift est l'épaisseur : toute la face du dessous est au sol. + let (_, text) = sprite_model("text"); + assert_eq!(text.contact_lift(0.0, 1.0), MODEL_THICK); + } + + #[test] + fn every_default_state_is_modelled() { + let scene = model_scene(); + let track = still_track(vec![0.5]); + let off = model_plan([0.0; 3], &scene, &track, 0.3, false).expect("plan"); + assert!(off.model.is_none()); + assert!(matches!(off.placement, CursorPlacement::Upright { .. }), "réglage éteint : mode 7"); + let on = model_plan([0.0; 3], &scene, &track, 0.3, true).expect("plan"); + assert_eq!(on.model, Some(cursor_pose(&track, 0.3, 2.5, 1.0))); + let CursorPlacement::Tilted { quad, .. } = on.placement else { panic!("écran droit : plan identité") }; + assert_eq!((quad.scale, quad.rot), (1.0, [0.0; 3])); + // Pas de rebond d'échelle en 3D : la taille au creux du clic est celle du repos. + let press = model_plan([0.0; 3], &scene, &track, 0.5 + CONTACT_S, true).expect("plan"); + assert_eq!(press.size_px, on.size_px); + let flat_press = model_plan([0.0; 3], &scene, &track, 0.5 + CONTACT_S, false).expect("plan"); + assert!(flat_press.size_px < off.size_px, "le sprite plat garde son rebond"); + + // Chaque état du thème, avec la pose de son hotspot. + for (key, hotspot) in DEFAULT_SPRITES { + let typed = still_track_as(Some(key), vec![]); + let plan = model_plan([0.0; 3], &scene, &typed, 0.3, true).expect("plan"); + assert_eq!(plan.model, Some(cursor_pose(&typed, 0.3, 2.5, pointing_factor(hotspot))), "{key}"); + assert_eq!(plan.cursor_type.as_deref(), Some(key)); + } + // Un état sans sprite retombe sur la flèche, donc sur sa pose. + let unknown = still_track_as(Some("zoom-in"), vec![]); + assert_eq!( + model_plan([0.0; 3], &scene, &unknown, 0.3, true).expect("plan").model, + Some(cursor_pose(&unknown, 0.3, 2.5, 1.0)) + ); + // Un autre thème, ou pas de sprite du tout : le sprite plat. + let mut themed = model_scene(); + themed.cursor.theme = "black-pixel".into(); + assert!(model_plan([0.0; 3], &themed, &track, 0.3, true).expect("plan").model.is_none()); + let bare = zoomed_golden_scene(); + 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]; + + /// 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)> { + let scene = model_scene(); + let mut out = Vec::new(); + for key in MODEL_STATES { + let clicked = still_track_as(Some(key), vec![0.5]); + let moving = crate::cursor::CursorTrack::new( + vec![(0.0, 0.1, 0.6), (2.0, 0.9, 0.6)], + 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 (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); + out.push((format!("{key}/{name}/{pose}"), plan, shape, sdf)); + } + } + } + out + } - let cb = cursor_sprite_cb( - plan.placement, - [plan.size_px; 2], - [0.0; 2], - 1.0, - [0.0; 4], - [1920.0, 1080.0], - Some(&v), + #[test] + fn the_modelled_hotspot_lands_on_the_content_pixel() { + for (name, plan, shape, _) in model_cases() { + let pose = plan.model.expect("modèle"); + let view = ModelView::new(plan.placement, plan.size_px, pose, shape).expect("vue"); + let CursorPlacement::Tilted { plane_pt, quad, center_px, .. } = plan.placement else { + unreachable!() + }; + let (bx, by) = quad.point_px(plane_pt[0], plane_pt[1]); + let want = [center_px[0] + bx, center_px[1] + by]; + let got = view.project(view.model_to_plane([0.0; 3])).expect("projection"); + assert!( + (got[0] - want[0]).abs() < 0.02 && (got[1] - want[1]).abs() < 0.02, + "{name}: hotspot en {got:?}, contenu en {want:?}" ); - assert_eq!(cb.mb, [v.extrude_px[0], v.extrude_px[1], v.taps as f32, 0.0]); - let e = v.extrude_px; - for (x, y) in [ - (cb.fx[0], cb.fx[1]), - (cb.fx[2], cb.fx[3]), - (cb.src_prev[0], cb.src_prev[1]), - (cb.src_prev[2], cb.src_prev[3]), - ] { - for (px, py) in [(x, y), (x + e[0], y + e[1])] { - assert!( - px >= -1e-3 && px <= cb.quad_px[0] + 1e-3, - "{rot:?} : x {px} hors bbox" - ); - assert!( - py >= -1e-3 && py <= cb.quad_px[1] + 1e-3, - "{rot:?} : y {py} hors bbox" - ); + // Le cbuffer rend au shader le même rayon : `local + src.xy` au pixel du hotspot est + // la projection EXACTE du hotspot, ancrage ôté. + let cb = cursor_model_cb(plan.placement, plan.size_px, pose, shape, 1.0, [0.0; 4]).expect("cb"); + assert_eq!(cb.mode, 15.0); + let local = [want[0] - cb.dst[0] * 1920.0, want[1] - cb.dst[1] * 1080.0]; + let w = crate::regions::rotate_point(view.tip, view.rot); + let f = view.perspective / (view.perspective - w[2]); + assert!( + (local[0] + cb.src[0] - w[0] * f).abs() < 0.05 && (local[1] + cb.src[1] - w[1] * f).abs() < 0.05, + "{name}: le rayon du shader ne passe pas par le hotspot" + ); + assert_eq!([cb.src[2], cb.src[3]], [view.perspective, view.unit]); + assert_eq!(view.unit, plan.size_px * quad.scale, "{name}: l'unité est le côté du curseur"); + assert_eq!(cb.src_prev, [view.tip[0], view.tip[1], view.tip[2], pose.yaw]); + assert_eq!(cb.fx[3], pose.pitch); + assert!((cb.dst[2] * 1920.0 - cb.quad_px[0]).abs() < 1e-2); + // Le rect du sprite : sa taille se déduit de `radius_px` (w/h, plus grand côté = 1), + // comme `sprite_size()` dans les shaders, et `(p - color.rg) / taille` vaut le hotspot + // à l'origine. `mb.zw` porte la translation du plan, nulle sous un angle fixe. + let a = cb.radius_px; + let size = [a.min(1.0), (1.0 / a).min(1.0)]; + let [x0, y0] = [cb.color[0], cb.color[1]]; + let hotspot = [-x0 / size[0], -y0 / size[1]]; + assert!((hotspot[0] - shape.hotspot[0]).abs() < 1e-6 && (hotspot[1] - shape.hotspot[1]).abs() < 1e-6); + assert!((size[0] - shape.size[0]).abs() < 1e-6 && (size[1] - shape.size[1]).abs() < 1e-6); + assert_eq!(cb.color[2], pose.squash, "{name}: écrasement"); + assert_eq!([cb.mb[2], cb.mb[3]], view.offset, "{name}: translation du plan"); + assert!(shape.size[0].max(shape.size[1]) == 1.0, "{name}: {:?}", shape.size); + } + } + + /// Le pointeur file de `from` vers `to` à `speed` (écrans par seconde), s'y arrête `dwell` s + /// avant et après le clic `tc`, puis repart vers `away`. Échantillonné à 30 Hz comme la + /// télémétrie, plus l'échantillon du clic. Sans arrêt, la piste brute dérive déjà pendant le + /// contact. + fn approach_track(from: [f32; 2], to: [f32; 2], away: [f32; 2], speed: f32, dwell: f32, tc: f32) -> crate::cursor::CursorTrack { + let mix = |a: [f32; 2], b: [f32; 2], f: f32| (a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f); + let dist = |a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).hypot(a[1] - b[1]); + let arrive = tc - dwell; + let start = arrive - dist(from, to) / speed; + let (leave, end) = (tc + dwell, tc + dwell + dist(to, away) / speed); + let pos = |t: f32| { + if t < start { + (from[0], from[1]) + } else if t < arrive { + mix(from, to, (t - start) / (arrive - start)) + } else if t < leave { + (to[0], to[1]) + } else { + mix(to, away, ((t - leave) / (end - leave)).min(1.0)) + } + }; + let mut samples: Vec<(f32, f32, f32)> = (0..=120) + .map(|k| k as f32 / 30.0) + .filter(|&t| (t - tc).abs() > 1e-4) + .map(|t| (t, pos(t).0, pos(t).1)) + .collect(); + samples.push((tc, to[0], to[1])); + samples.sort_by(|a, b| a.0.total_cmp(&b.0)); + crate::cursor::CursorTrack::new(samples, vec![tc], vec![]) + } + + /// Le curseur modélisé et son écran à `t`, par le chemin du rendu : `plan_frame` puis + /// `plan_cursor`, scène dorée (recadrée, zoomée) sous `rotation`. + fn model_frame( + rotation: &str, + blur: f32, + track: &'static crate::cursor::CursorTrack, + t: f32, + ) -> (FrameGeometry, Option) { + let cfg = crate::config::all().pop().expect("cfg"); + let json = zoomed_golden_scene_json().replace(r#""rotation":"none""#, rotation); + let mut scene = Scene::from_json(&json).expect("scène"); + scene.cursor.theme = "default".into(); + scene.cursor.cursor_sprites = model_scene().cursor.cursor_sprites; + let live = LiveParams { + cursor_model3d: true, + cursor_bounce_scale: 2.5, + cursor_motion_blur: blur, + ..live_params_from_scene(&scene) + }; + let g = plan_frame(&FrameGeometryInput { + cursor: Some(track), + timeline_t_override: Some(t), + frame: t * FPS, + live, + ..golden_input(&scene, &cfg) + }); + let plan = plan_cursor( + &g, + &CursorPlanInput { + render_px: [1170.0, 658.0], + u_max: 1.0, + v_max: 1080.0 / 1088.0, + cfg: &cfg, + live, + scene: Some(&scene), + track, + t, + }, + ); + (g, plan) + } + + /// Le pixel où l'écran dessine la position `p` du contenu, sans passer par le curseur : la + /// coupe, puis le rect de l'écran droit ou le quad incliné (bilinéaire ou projectif). + fn content_px(g: &FrameGeometry, p: (f32, f32)) -> [f32; 2] { + let render = [1170.0, 658.0]; + let [fx, fy] = cursor_plane_point(g.cut, [1.0, 1080.0 / 1088.0], p).expect("clic dans la coupe"); + let s_px = [g.s_dst[2] * render[0], g.s_dst[3] * render[1]]; + match g.screen_tilt(s_px) { + None => [(g.s_dst[0] + fx * g.s_dst[2]) * render[0], (g.s_dst[1] + fy * g.s_dst[3]) * render[1]], + Some(quad) => { + let (x, y) = quad.point_px(fx, fy); + [(g.s_dst[0] + g.s_dst[2] * 0.5) * render[0] + x, (g.s_dst[1] + g.s_dst[3] * 0.5) * render[1] + y] + } + } + } + + /// Au contact, la pointe du curseur modélisé tombe sur le pixel du clic BRUT, à 0,5 px près : + /// quel que soit le lissage (le ressort traîne derrière la souris), la vitesse d'arrivée, un + /// départ immédiat, l'angle fixe (impact du plan compris), la caméra réelle, et la traînée de + /// flou (repliée sur la tête pendant le contact). + #[test] + fn the_modelled_tip_touches_the_raw_click_pixel() { + const TC: f32 = 1.5; + let presets = [ + ("flat", r#""rotation":"none""#), + ("iso", r#""rotation":"iso","clickImpact":true"#), + ("left", r#""rotation":"left","clickImpact":true"#), + ("right", r#""rotation":"right","clickImpact":true"#), + ("follow", r#""rotation":"follow-cursor""#), + ]; + let (to, from, away) = ([0.3, 0.18], [0.08, 0.05], [0.45, 0.32]); + let mut worst = 0.0f32; + for (name, rotation) in presets { + for smoothing in [0.0, 0.25, 0.5, 1.0] { + for speed in [0.5, 2.0] { + for dwell in [0.0, 0.15] { + let raw = approach_track(from, to, away, speed, dwell, TC); + let track: &'static crate::cursor::CursorTrack = Box::leak(Box::new(raw.smoothed(smoothing))); + let mut contacts = 0; + let mut case_worst = 0.0f32; + for key in ["arrow", "pointer"] { + let (_, shape) = sprite_model(key); + let shape = SpriteShape { hotspot: hotspot_of(key), ..shape }; + for k in 0..=15 { + let t = TC + 0.02 + k as f32 * 0.004; + let (g, plan) = model_frame(rotation, 1.0, track, t); + let plan = plan.expect("curseur visible au contact"); + let pose = plan.model.expect("modèle"); + if pose.clearance > 0.0 { + continue; + } + contacts += 1; + let view = ModelView::new(plan.placement, plan.size_px, pose, shape).expect("vue"); + let tip = view.project(view.model_to_plane([0.0; 3])).expect("pointe"); + let want = content_px(&g, (to[0], to[1])); + let off = (tip[0] - want[0]).hypot(tip[1] - want[1]); + case_worst = case_worst.max(off); + assert_eq!(plan.taps, 1, "{name} lissage {smoothing} : traînée au contact"); + } + } + println!("{name} lissage {smoothing} vitesse {speed} arrêt {dwell} : {case_worst:.3} px"); + assert!(contacts >= 16, "{name}: {contacts} images au contact"); + assert!(case_worst < 0.5, "{name} lissage {smoothing} vitesse {speed} arrêt {dwell} : {case_worst} px"); + worst = worst.max(case_worst); + } } } } + println!("pire écart au contact : {worst:.4} px"); + } + + /// L'impact : seulement sous le curseur modélisé, après un clic et à `clickBounce` non nul ; + /// il démarre au contact, dure 0,4 s, et son carré est centré sur le pixel du clic brut, posé + /// sur le plan (il penche avec lui). + #[test] + fn the_click_impact_rings_around_the_raw_click_pixel() { + const TC: f32 = 1.5; + let raw = approach_track([0.08, 0.05], [0.3, 0.18], [0.45, 0.32], 2.0, 0.0, TC); + let track: &'static crate::cursor::CursorTrack = Box::leak(Box::new(raw.smoothed(0.5))); + let quiet: &'static crate::cursor::CursorTrack = + Box::leak(Box::new(crate::cursor::CursorTrack::new(vec![(0.0, 0.3, 0.18), (4.0, 0.3, 0.18)], vec![], vec![]))); + for rotation in [r#""rotation":"none""#, r#""rotation":"iso""#, r#""rotation":"follow-cursor""#] { + let impacts = |track, t| model_frame(rotation, 0.0, track, t).1.expect("curseur").impacts; + assert!(impacts(quiet, TC + 0.1).is_empty(), "{rotation}: pas de clic, pas d'impact"); + assert!(impacts(track, TC + 0.04).is_empty(), "{rotation}: avant le contact"); + assert!(impacts(track, TC + IMPACT_DELAY_S + IMPACT_S + 0.001).is_empty(), "{rotation}: après"); + for age in [0.06, 0.1, 0.2, 0.4] { + let (g, plan) = model_frame(rotation, 0.0, track, TC + age); + let cb = &plan.expect("curseur").impacts; + assert_eq!(cb.len(), 1, "{rotation} +{age}"); + let cb = cb[0]; + assert_eq!(cb.mode, 16.0); + // Le centre du carré (s = t = 0,5) : l'inverse du quad dans le shader. + let c = |i: usize| -> [f32; 2] { + let v = [cb.fx[0], cb.fx[1], cb.fx[2], cb.fx[3], cb.src_prev[0], cb.src_prev[1], cb.src_prev[2], cb.src_prev[3]]; + [v[2 * i] + cb.dst[0] * 1170.0, v[2 * i + 1] + cb.dst[1] * 658.0] + }; + let (tl, tr, br, bl) = (c(0), c(1), c(2), c(3)); + let center = if cb.mb[0] > 0.5 { + let q = crate::regions::square_to_quad(&[(tl[0], tl[1]), (tr[0], tr[1]), (br[0], br[1]), (bl[0], bl[1])], 0.5, 0.5); + [q.0, q.1] + } else { + [(tl[0] + tr[0] + br[0] + bl[0]) / 4.0, (tl[1] + tr[1] + br[1] + bl[1]) / 4.0] + }; + let want = content_px(&g, (0.3, 0.18)); + assert!( + (center[0] - want[0]).abs() < 0.05 && (center[1] - want[1]).abs() < 0.05, + "{rotation} +{age}: impact en {center:?}, clic en {want:?}" + ); + assert!(cb.src[0] > 0.0 && cb.src[0] <= 0.8 && cb.src[2] > 0.0, "{rotation} +{age}: {:?}", cb.src); + } + // L'anneau grandit et s'éteint. + let ring = |age: f32| impact_at(age, 1.0).expect("dans la fenêtre"); + assert!(ring(0.1).ring_r < ring(0.3).ring_r && ring(0.1).ring_a > ring(0.3).ring_a); + assert!(ring(0.08).spot_a > 0.2 && ring(0.3).spot_a == 0.0); + } + // Réglage éteint, ou `clickBounce` nul : rien. + let cfg = crate::config::all().pop().expect("cfg"); + let scene = model_scene(); + let fg = full_frame_geometry(); + let plan = |model3d: bool, bounce: f32| { + plan_cursor( + &fg, + &CursorPlanInput { + render_px: [1920.0, 1080.0], + u_max: 1.0, + v_max: 1.0, + cfg: &cfg, + live: LiveParams { cursor_model3d: model3d, cursor_bounce_scale: bounce, ..LiveParams::default() }, + scene: Some(&scene), + track, + t: TC + 0.1, + }, + ) + .expect("plan") + .impacts + .len() + }; + assert_eq!((plan(true, 2.5), plan(false, 2.5), plan(true, 0.0)), (1, 0, 0)); + } + + /// L'écrasement suit le contact : neutre au repos, l'épaisseur au plus bas au creux, un léger + /// rebond ensuite, et rien à `clickBounce` nul. + #[test] + fn the_model_squashes_on_the_click() { + let track = still_track(vec![0.5]); + assert_eq!(cursor_pose(&track, 0.45, 2.5, 1.0).squash, 1.0); + let down = cursor_pose(&track, 0.5 + CONTACT_S, 2.5, 1.0); + assert!((down.squash - (1.0 - MODEL_SQUASH)).abs() < 1e-3, "{down:?}"); + let rebound = cursor_pose(&track, 0.5 + 0.165, 2.5, 1.0); + assert!(rebound.squash > 1.0 && rebound.squash < 1.06, "{rebound:?}"); + assert_eq!(cursor_pose(&track, 0.5 + CONTACT_S, 0.0, 1.0).squash, 1.0); + assert!(cursor_pose(&track, 0.5 + CONTACT_S, 5.0, 1.0).squash >= MODEL_SQUASH_MIN); + } + + #[test] + fn the_model_box_holds_the_model_and_its_shadow() { + for (name, plan, shape, sdf) in model_cases() { + let pose = plan.model.expect("modèle"); + let view = ModelView::new(plan.placement, plan.size_px, pose, shape).expect("vue"); + let cb = cursor_model_cb(plan.placement, plan.size_px, pose, shape, 1.0, [0.0; 4]).expect("cb"); + let (x0, y0) = (cb.dst[0] * 1920.0, cb.dst[1] * 1080.0); + let (x1, y1) = (x0 + cb.quad_px[0], y0 + cb.quad_px[1]); + let inside = |p: [f32; 2]| p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1; + let (lo, hi) = shape.model_box(pose.squash); + let mut solid = 0; + for i in 0..=20 { + for j in 0..=30 { + for k in 0..=6 { + let q = [ + lo[0] + (hi[0] - lo[0]) * i as f32 / 20.0, + lo[1] + (hi[1] - lo[1]) * j as f32 / 30.0, + lo[2] + (hi[2] - lo[2]) * k as f32 / 6.0, + ]; + if sd_model(&sdf, shape, pose.squash, q) > 0.0 { + continue; + } + solid += 1; + let p = view.project(view.model_to_plane(q)).expect("projection"); + assert!(inside(p), "{name}: le point {q:?} du modèle tombe hors de la boîte"); + } + } + } + assert!(solid > 100, "{name}: le modèle échantillonné est vide"); + // L'ombre : tout point du plan qu'elle assombrit d'au moins 1/255 est dans la boîte. + let (step, reach) = (view.unit / 12.0, 3.0 * view.unit); + let mut shaded = 0; + for i in 0..=72 { + for j in 0..=72 { + let g = [view.tip[0] - reach + i as f32 * step, view.tip[1] - reach + j as f32 * step]; + if model_shadow_at(&view, &sdf, g) < 0.5 / 255.0 { + continue; + } + shaded += 1; + let p = view.project([g[0], g[1], 0.0]).expect("projection"); + assert!(inside(p), "{name}: l'ombre en {g:?} déborde de la boîte ({p:?})"); + } + } + assert!(shaded > 50, "{name}: pas d'ombre échantillonnée"); + } } -} + /// Le backend logiciel ne dessine que la tête du curseur modélisé ; le GPU et le sprite plat + /// gardent leur traînée. + #[test] + fn the_software_backend_draws_the_modelled_head_only() { + let scene = model_scene(); + let fast = crate::cursor::CursorTrack::new(vec![(0.0, 0.1, 0.6), (0.5, 0.9, 0.6)], vec![], vec![]); + let cfg = crate::config::all().pop().expect("cfg"); + let fg = full_frame_geometry(); + let plan = |model3d: bool| { + plan_cursor( + &fg, + &CursorPlanInput { + render_px: [1920.0, 1080.0], + u_max: 1.0, + v_max: 1.0, + cfg: &cfg, + live: LiveParams { + cursor_model3d: model3d, + cursor_motion_blur: 1.0, + ..LiveParams::default() + }, + scene: Some(&scene), + track: &fast, + t: 0.25, + }, + ) + .expect("plan") + }; + let taps = plan(true).taps; + assert!(taps > 1, "le geste rapide doit donner une traînée"); + assert_eq!(plan(true).for_backend(false).taps, taps, "GPU : traînée gardée"); + let cpu = plan(true).for_backend(true); + assert_eq!(cpu.taps, 1); + assert_eq!(cpu.prev_placement.upright_center(), cpu.placement.upright_center()); + assert_eq!(plan(false).for_backend(true).taps, plan(false).taps, "le sprite plat garde sa traînée"); + } +} diff --git a/crates/compositor/src/lib.rs b/crates/compositor/src/lib.rs index ac9c93bab..a37083c45 100644 --- a/crates/compositor/src/lib.rs +++ b/crates/compositor/src/lib.rs @@ -29,8 +29,10 @@ pub mod audio; pub mod audio_jobs; +pub mod camera; pub mod config; pub mod cursor; +pub mod cursor_sdf; pub mod export_probe; pub mod ffi; pub mod frame_geometry; diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index d77a2227d..4243a33d0 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -657,8 +657,8 @@ struct InspectorParams { cursor_smoothing: f32, /// 0..1 : force du flou de mouvement DU CURSEUR (indépendant du motion blur écran). cursor_motion_blur: f32, - /// 0..1 : volume du curseur (extrusion + ombre de contact). - cursor_volume: f32, + /// Flèche modélisée en 3D (mode 15). + cursor_model3d: bool, cursor_auto_hide: bool, } @@ -679,7 +679,7 @@ impl Default for InspectorParams { cursor_bounce_scale: 1.0, cursor_smoothing: 0.0, cursor_motion_blur: 0.0, - cursor_volume: 0.0, + cursor_model3d: false, cursor_auto_hide: false, } } @@ -937,6 +937,7 @@ impl LiveView { "webcamMirror" => p.webcam_mirror = value, "cursorShow" => p.cursor_show = value, "cursorAutoHide" => p.cursor_auto_hide = value, + "cursorModel3d" => p.cursor_model3d = value, _ => {} } } @@ -958,7 +959,6 @@ impl LiveView { "cursorClickBounce" => p.cursor_bounce_scale = v.max(0.0), "cursorSmoothing" => p.cursor_smoothing = v.clamp(0.0, 1.0), "cursorMotionBlur" => p.cursor_motion_blur = v.clamp(0.0, 1.0), - "cursorVolume" => p.cursor_volume = v.clamp(0.0, 1.0), _ => {} } } @@ -1560,7 +1560,7 @@ unsafe fn render_thread( cursor_size_scale: ip.cursor_size_scale, cursor_bounce_scale: ip.cursor_bounce_scale, cursor_motion_blur: ip.cursor_motion_blur, - cursor_volume: ip.cursor_volume, + cursor_model3d: ip.cursor_model3d, cursor_auto_hide: ip.cursor_auto_hide, has_webcam: has_real_webcam, }); diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs index 774381303..138d6d57d 100644 --- a/crates/compositor/src/regions.rs +++ b/crates/compositor/src/regions.rs @@ -2,9 +2,11 @@ //! web (`zoomRegionUtils.ts` / `cameraFullscreenUtils.ts`) vers le natif, pour que le timing //! des transitions soit identique en preview ET en export. Inclut le "connected zoom pan" //! (chaînage lissé entre deux régions rapprochées), le focus "auto" (suivi de la télémétrie -//! curseur) et la rotation 3D (présets iso/left/right, cf. `compositor.rs` pour le rendu du -//! tilt perspective — ce module ne fait que le calcul temporel, pas le rendu GPU). +//! curseur) et la 3D (angles fixes iso/left/right, et la caméra `follow-cursor`, dont le modèle +//! vit dans `camera.rs` ; le rendu est dans les backends — ce module ne fait que le calcul +//! temporel et la géométrie, pas le rendu GPU). +use crate::camera::Follow; use crate::cursor::CursorTrack; use crate::scene::{SceneCameraFullscreenRegion, SceneSpeedRegion, SceneZoomRegion}; @@ -244,6 +246,16 @@ fn lerp(a: f32, b: f32, t: f32) -> f32 { /// span et rien en dehors. Sans ça son ease-in (1,5 s AVANT `start_sec`) et son ease-out /// déborderaient sur les frames GARDÉES de part et d'autre du trim — un zoom que l'export ne /// rendra jamais, visible dans la preview juste à côté de la coupe. Cf. `SceneZoomRegion`. +/// L'instant où la région commence à entrer (sa force quitte 0). +fn lead_in_start(region: &SceneZoomRegion) -> f32 { + let start = region.start_sec as f32; + if region.under_trim { + return start; + } + let zoom_in_end = start + ZOOM_IN_OVERLAP_S; + zoom_in_end - ZOOM_IN_TRANSITION_WINDOW_S +} + fn zoom_region_strength(region: &SceneZoomRegion, t: f32) -> f32 { let start = region.start_sec as f32; let end = region.end_sec as f32; @@ -251,7 +263,7 @@ fn zoom_region_strength(region: &SceneZoomRegion, t: f32) -> f32 { return if t >= start && t < end { 1.0 } else { 0.0 }; } let zoom_in_end = start + ZOOM_IN_OVERLAP_S; - let lead_in_start = zoom_in_end - ZOOM_IN_TRANSITION_WINDOW_S; + let lead_in_start = lead_in_start(region); let lead_out_end = end + TRANSITION_WINDOW_S; if t < lead_in_start || t > lead_out_end { return 0.0; @@ -344,9 +356,17 @@ pub struct ZoomState { /// quand leurs présets diffèrent. C'est la porte de `dynamic_tilt`. pub tilt: f32, /// Poids de l'impact du clic (0..1) : 1 sur une région qui l'active (`click_impact`), - /// interpolé entre deux régions chaînées. Ne suffit pas seul : l'impact passe aussi par la - /// porte de `tilt` (`dynamic_tilt`), donc rien sans préset. + /// interpolé entre deux régions chaînées. Ne suffit pas seul : sous un angle fixe, l'impact + /// passe aussi par la porte de `tilt` (`dynamic_tilt`) ; sous la caméra réelle, par son poids + /// (l'œil recule, `camera::PRESS`). pub click_impact: f32, + /// Poids de la caméra réelle (`camera.rs`, 0..1) : la force de la région `follow-cursor`, 0 + /// sinon. Jamais non nul en même temps que `rotation` (cf. la transition chaînée). + pub camera: f32, + /// Où la caméra vise, 0..1 dans le recadrage, déjà pondéré par `camera` (le centre à 0). + pub aim: [f32; 2], + /// Le pointeur lissé qui place l'œil sur son orbite, 0..1 dans le recadrage, pondéré de même. + pub orbit: [f32; 2], } const IDENTITY_ZOOM: ZoomState = ZoomState { @@ -355,14 +375,85 @@ const IDENTITY_ZOOM: ZoomState = ZoomState { rotation: [0.0, 0.0, 0.0], tilt: 0.0, click_impact: 0.0, + camera: 0.0, + aim: [0.5, 0.5], + orbit: [0.5, 0.5], }; -/// 1 si la région porte un préset 3D, 0 sinon. -fn has_tilt(region: &SceneZoomRegion) -> f32 { - if is_identity_rotation(rotation3d_for(®ion.rotation)) { 0.0 } else { 1.0 } +/// Ce que le champ `rotation` d'une région demande. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Camera { + /// Pas de préset (ou un préset inconnu) : écran droit. + Flat, + /// Un angle fixe : `iso`, `left`, `right`. L'écran est incliné devant la caméra. + Fixed([f32; 3]), + /// `follow-cursor` : l'écran est immobile, une caméra réelle tourne autour de lui avec le + /// pointeur (`camera.rs`). + Follow, +} + +fn camera_for(rotation: &Option) -> Camera { + match rotation.as_deref() { + Some("follow-cursor") => Camera::Follow, + _ => match rotation3d_for(rotation) { + r if is_identity_rotation(r) => Camera::Flat, + r => Camera::Fixed(r), + }, + } +} + +/// Ce que la caméra `follow-cursor` lit de la scène : la piste curseur (celle de la parallaxe, +/// donc `None` curseur masqué, comme à l'export), le recadrage du clip actif dans le repère +/// normalisé du curseur (`[x0, y0, x1, y1]`) et sa fenêtre source `[début, fin)`. +#[derive(Clone, Copy)] +pub struct CameraFrame<'a> { + pub track: Option<&'a CursorTrack>, + pub crop: [f32; 4], + pub window: [f32; 2], +} + +impl CameraFrame<'static> { + /// Sans piste : la caméra vise le centre, à sa pose de repos. + pub const NONE: CameraFrame<'static> = CameraFrame { + track: None, + crop: [0.0, 0.0, 1.0, 1.0], + window: [f32::NEG_INFINITY, f32::INFINITY], + }; +} + +/// L'angle fixe d'une région, nul sans préset ou sous la caméra réelle. +fn fixed_rotation(region: &SceneZoomRegion) -> [f32; 3] { + match camera_for(®ion.rotation) { + Camera::Fixed(r) => r, + Camera::Flat | Camera::Follow => [0.0; 3], + } +} + +/// 1 si la région porte un angle fixe, 0 sinon : la porte de la parallaxe et de l'impact. +fn fixed_flag(region: &SceneZoomRegion) -> f32 { + if matches!(camera_for(®ion.rotation), Camera::Fixed(_)) { 1.0 } else { 0.0 } +} + +/// 1 si la région porte la caméra réelle, 0 sinon. +fn follow_flag(region: &SceneZoomRegion) -> f32 { + if camera_for(®ion.rotation) == Camera::Follow { 1.0 } else { 0.0 } +} + +/// Ce que la caméra d'une région lit à `t` (le centre hors `follow-cursor`), non pondéré. +fn follow_at(region: &SceneZoomRegion, t: f32, frame: &CameraFrame) -> Follow { + match camera_for(®ion.rotation) { + Camera::Follow => crate::camera::follow(frame, t, region.scale), + Camera::Flat | Camera::Fixed(_) => Follow::CENTRE, + } } -/// 1 si la région active l'impact du clic, 0 sinon. +/// Un point 0..1 pondéré par le poids de la caméra : le centre à 0. +fn weighted(p: [f32; 2], camera: f32) -> [f32; 2] { + p.map(|a| 0.5 + (a - 0.5) * camera) +} + +/// 1 si la région active l'impact du clic, 0 sinon. Sous un angle fixe, le clic presse l'écran ; +/// sous la caméra réelle, l'écran reste immobile et c'est l'œil qui recule. fn impact_flag(region: &SceneZoomRegion) -> f32 { if region.click_impact { 1.0 } else { 0.0 } } @@ -455,6 +546,12 @@ pub fn arrow_local_geometry( /// télémétrie curseur (port de `getResolvedFocus`, sans le clamp — le crop-window de /// `compositor.rs` clampe déjà après coup, cf. `su0.clamp(...)`, donc redondant ici). fn resolve_focus(region: &SceneZoomRegion, t: f32, cursor: Option<&CursorTrack>) -> [f32; 2] { + // Sous la caméra réelle, le cadrage vient de son pivot : la boîte zoome sur son centre, sans + // glissement 2D. Chaînée à une région plate, la transition glisse donc vers le focus de + // celle-ci sans saut. + if camera_for(®ion.rotation) == Camera::Follow { + return [0.5, 0.5]; + } if region.focus_mode.as_deref() == Some("auto") { if let Some(track) = cursor { // `follow_at`, pas `at` : la caméra suit la piste LISSÉE. Suivre la télémétrie brute @@ -497,7 +594,20 @@ fn connected_pairs(regions: &[SceneZoomRegion]) -> Vec<(usize, usize, f32, f32)> /// `findDominantRegion` (TS) : régions chaînées d'abord (transition puis /// hold), sinon la région "dominante" indépendante la plus forte (ties → la plus récente). /// Hors de toute région → identité (échelle 1, focus centre, tilt nul). +/// +/// Sans piste, `follow-cursor` vise le centre. Le rendu passe par `zoom_state_in`, qui lui donne +/// la piste, le recadrage et la fenêtre du clip. pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&CursorTrack>) -> ZoomState { + zoom_state_in(regions, t, cursor, &CameraFrame::NONE) +} + +/// `zoom_state_at`, avec ce que lit la caméra `follow-cursor` (`CameraFrame`). +pub fn zoom_state_in( + regions: &[SceneZoomRegion], + t: f32, + cursor: Option<&CursorTrack>, + frame: &CameraFrame, +) -> ZoomState { if regions.is_empty() { return IDENTITY_ZOOM; } @@ -517,14 +627,38 @@ pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&Cursor // plus. La porte se ferme donc en milieu de course et ne se rouvre qu'au ras des bouts — // 1 − 4p(1−p) vaut 1 en p = 0 et p = 1, donc aucun saut avec la région d'avant ni avec // le palier d'après. Cf. `the_chained_sweep_keeps_every_edge_off_axis_and_inside`. - let (cur_rot, next_rot) = (rotation3d_for(&cur.rotation), rotation3d_for(&next.rotation)); - let crossing = if cur_rot == next_rot { 1.0 } else { 1.0 - 4.0 * progress * (1.0 - progress) }; + let (cur_cam, next_cam) = (camera_for(&cur.rotation), camera_for(&next.rotation)); + let crossing = + if cur_cam == next_cam { 1.0 } else { 1.0 - 4.0 * progress * (1.0 - progress) }; + // Un angle fixe incline l'écran, la caméra réelle le laisse immobile : les deux modèles ne + // se mélangent pas. Entre eux, la transition passe par l'écran droit à mi-course, l'un + // s'éteignant sur la première moitié, l'autre s'allumant sur la seconde. + let split = matches!( + (cur_cam, next_cam), + (Camera::Follow, Camera::Fixed(_)) | (Camera::Fixed(_), Camera::Follow) + ); + let (rotation, camera) = if split { + let (out, into) = ((1.0 - 2.0 * progress).max(0.0), (2.0 * progress - 1.0).max(0.0)); + let r = fixed_rotation(cur).map(|a| a * out); + let r = if r == [0.0; 3] { fixed_rotation(next).map(|a| a * into) } else { r }; + (r, follow_flag(cur) * out + follow_flag(next) * into) + } else { + ( + lerp_rotation3d(fixed_rotation(cur), fixed_rotation(next), progress), + lerp(follow_flag(cur), follow_flag(next), progress), + ) + }; + let (a, b) = (follow_at(cur, t, frame), follow_at(next, t, frame)); + let mix = |p: [f32; 2], q: [f32; 2]| [lerp(p[0], q[0], progress), lerp(p[1], q[1], progress)]; return ZoomState { scale: lerp(cur.scale, next.scale, progress), focus: [lerp(cur_focus[0], next_focus[0], progress), lerp(cur_focus[1], next_focus[1], progress)], - rotation: lerp_rotation3d(cur_rot, next_rot, progress), - tilt: lerp(has_tilt(cur), has_tilt(next), progress) * crossing, + rotation, + tilt: lerp(fixed_flag(cur), fixed_flag(next), progress) * crossing, click_impact: lerp(impact_flag(cur), impact_flag(next), progress), + camera, + aim: weighted(mix(a.aim, b.aim), camera), + orbit: weighted(mix(a.orbit, b.orbit), camera), }; } @@ -533,12 +667,17 @@ pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&Cursor for &(_, ni, _, t_end) in &pairs { let next = ®ions[ni]; if t > t_end && t < next.start_sec as f32 { + let camera = follow_flag(next); + let seen = follow_at(next, t, frame); return ZoomState { scale: next.scale, focus: resolve_focus(next, t, cursor), - rotation: rotation3d_for(&next.rotation), - tilt: has_tilt(next), + rotation: fixed_rotation(next), + tilt: fixed_flag(next), click_impact: impact_flag(next), + camera, + aim: weighted(seen.aim, camera), + orbit: weighted(seen.orbit, camera), }; } } @@ -579,12 +718,17 @@ pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&Cursor // comme si une région manuelle suivait le curseur. On inverse donc le mapping pour // trouver le centre qui produit la trajectoire de référence. let ease = |f: f32| f - (f - 0.5) * (1.0 - strength) / scale.max(1e-3); + let camera = follow_flag(r) * strength; + let seen = follow_at(r, t, frame); ZoomState { scale, focus: [ease(focus[0]), ease(focus[1])], - rotation: lerp_rotation3d([0.0, 0.0, 0.0], rotation3d_for(&r.rotation), strength), - tilt: has_tilt(r) * strength, + rotation: lerp_rotation3d([0.0, 0.0, 0.0], fixed_rotation(r), strength), + tilt: fixed_flag(r) * strength, click_impact: impact_flag(r), + camera, + aim: weighted(seen.aim, camera), + orbit: weighted(seen.orbit, camera), } } None => IDENTITY_ZOOM, @@ -664,11 +808,19 @@ fn project_corner(x0: f32, y0: f32, rot: [f32; 3], perspective: f32) -> Option<( /// X/Y/Z), AVANT perspective. `z > 0` = vers la caméra (la perspective divise par /// `perspective - z`, donc agrandit), < 0 recule. fn rotate_corner(x0: f32, y0: f32, rot: [f32; 3]) -> (f32, f32, f32) { + let [px, py, pz] = rotate_point([x0, y0, 0.0], rot); + (px, py, pz) +} + +/// `rotate_corner` pour un point QUELCONQUE du repère du plan (x droite, y bas, z vers la +/// caméra) : Z, puis Y, puis X, en degrés. Le curseur modélisé (mode 15) a besoin de points +/// hors du plan, et doit tourner exactement comme lui. +pub(crate) fn rotate_point(p: [f32; 3], rot: [f32; 3]) -> [f32; 3] { let (a, b, g) = (rot[0].to_radians(), rot[1].to_radians(), rot[2].to_radians()); let (ca, sa) = (a.cos(), a.sin()); let (cb, sb) = (b.cos(), b.sin()); let (cg, sg) = (g.cos(), g.sin()); - let (mut px, mut py, mut pz) = (x0, y0, 0.0f32); + let [mut px, mut py, mut pz] = p; // rotateZ let (zx, zy) = (px * cg - py * sg, px * sg + py * cg); px = zx; @@ -681,7 +833,27 @@ fn rotate_corner(x0: f32, y0: f32, rot: [f32; 3]) -> (f32, f32, f32) { let (xy, xz) = (py * ca - pz * sa, py * sa + pz * ca); py = xy; pz = xz; - (px, py, pz) + [px, py, pz] +} + +/// L'inverse de `rotate_point` : du repère caméra vers celui du plan (X, puis Y, puis Z, chacun +/// transposé). Une rotation est orthonormée, sa transposée est son inverse. +pub(crate) fn rotate_point_inv(p: [f32; 3], rot: [f32; 3]) -> [f32; 3] { + let (a, b, g) = (rot[0].to_radians(), rot[1].to_radians(), rot[2].to_radians()); + let (ca, sa) = (a.cos(), a.sin()); + let (cb, sb) = (b.cos(), b.sin()); + let (cg, sg) = (g.cos(), g.sin()); + let [mut px, mut py, mut pz] = p; + let (xy, xz) = (py * ca + pz * sa, -py * sa + pz * ca); + py = xy; + pz = xz; + let (yx, yz) = (px * cb - pz * sb, px * sb + pz * cb); + px = yx; + pz = yz; + let (zx, zy) = (px * cg + py * sg, -px * sg + py * cg); + px = zx; + py = zy; + [px, py, pz] } /// `(Kx, Ky)` tels que le `pz` de `rotate_corner(x0, y0, rot)` vaille `Kx·x0 + Ky·y0`. @@ -690,7 +862,7 @@ fn rotate_corner(x0: f32, y0: f32, rot: [f32; 3]) -> (f32, f32, f32) { /// rotateY en tire `-x1·sb`, rotateX en tire `y1·sa + z·ca`, où (x1, y1) est le point après /// rotateZ. En développant x1 = x0·cg − y0·sg et y1 = x0·sg + y0·cg, `pz` est linéaire en /// (x0, y0) — d'où ces deux coefficients, sans rien de plus à calculer par pixel. -fn depth_coefficients(rot: [f32; 3]) -> (f32, f32) { +pub(crate) fn depth_coefficients(rot: [f32; 3]) -> (f32, f32) { let (a, b, g) = (rot[0].to_radians(), rot[1].to_radians(), rot[2].to_radians()); let (ca, sa) = (a.cos(), a.sin()); let sb = b.sin(); @@ -703,7 +875,7 @@ fn depth_coefficients(rot: [f32; 3]) -> (f32, f32) { /// est grand, plus la caméra est loin et plus la convergence des arêtes s'aplatit. À 2.6 elle /// était si faible que l'inclinaison ne se lisait plus (le bord haut d'iso ressortait à 0.08° de /// l'horizontale). -const PERSPECTIVE_FACTOR: f32 = 1.6; +pub(crate) const PERSPECTIVE_FACTOR: f32 = 1.6; /// Profondeur de champ du mode 8 : cercle de confusion, en texels SOURCE, par unité d'écart de /// profondeur rapporté à la distance de fuite (`|z − z_focus| / P`). @@ -740,6 +912,12 @@ fn projected_extents(corners: &[(f32, f32); 4]) -> (f32, f32) { } /// Un écran incliné : ses 4 coins projetés, et la réduction qu'il a fallu pour qu'ils tiennent. +/// +/// La caméra qu'il décrit : un point `p` du plan (px du plan, centre à l'origine, z vers la +/// caméra) est `w = R·p + (offset, 0)` dans le repère de la caméra, œil en `(0, 0, P)`, et se +/// projette en `P·w.xy / (P − w.z)`, relatif au CENTRE du rect d'origine, avec +/// `R = rotate_point(·, rot)` et `P = perspective`. Les angles fixes tournent l'écran autour de son +/// centre (`offset` nul) ; la caméra réelle (`camera.rs`) vise ailleurs. #[derive(Clone, Copy)] pub struct TiltedQuad { /// Coins TL, TR, BR, BL en px relatifs au CENTRE du rect d'origine. @@ -751,6 +929,17 @@ pub struct TiltedQuad { /// position en px dans le repère du plan, centre à l'origine, AVANT projection. Positive = /// vers la caméra. Nulle quand le quad est rendu à plat. pub depth_k: (f32, f32), + /// La rotation RÉELLEMENT dessinée (base + dynamique, ou la base seule quand la pose + /// complète passerait derrière le plan de fuite), en degrés X/Y/Z. Le curseur modélisé + /// reconstruit la caméra avec : un angle voisin le décollerait du contenu. + pub rot: [f32; 3], + /// Distance caméra–plan `P`, en px. + pub perspective: f32, + /// 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 (caméra réelle) ; + /// `false` : par le warp bilinéaire des angles fixes, inchangé à l'octet. + pub projective: bool, } /// Les 4 coins (TL, TR, BR, BL) du quad tilté en 3D, en px relatifs au CENTRE du rect d'origine @@ -773,6 +962,50 @@ pub fn rotated_quad_corners_px( ) -> TiltedQuad { let perspective = width.min(height) * PERSPECTIVE_FACTOR; let (half_w, half_h) = (width * 0.5, height * 0.5); + let Some((scale, mut corners)) = contain_scale(width, height, rot, perspective) else { + // Un coin derrière le plan de fuite : on rend le quad non tourné plutôt qu'une projection + // absurde (même repli qu'avant). + return TiltedQuad { + corners: [(-half_w, -half_h), (half_w, -half_h), (half_w, half_h), (-half_w, half_h)], + scale: 1.0, + depth_k: (0.0, 0.0), + rot: [0.0; 3], + perspective, + offset: [0.0; 2], + projective: false, + }; + }; + // La profondeur suit l'angle RÉELLEMENT dessiné (base + dynamique), sinon le flou se + // décollerait du plan pendant la parallaxe. + let mut drawn = rot; + if rot_dyn != [0.0; 3] { + let full = [rot[0] + rot_dyn[0], rot[1] + rot_dyn[1], rot[2] + rot_dyn[2]]; + // Un coin qui passerait derrière le plan de fuite : on garde la pose de base. + if let Some(c) = project_scaled_corners(width, height, scale, full, perspective) { + corners = c; + drawn = full; + } + } + TiltedQuad { + corners, + scale, + depth_k: depth_coefficients(drawn), + rot: drawn, + perspective, + offset: [0.0; 2], + projective: false, + } +} + +/// L'échelle de containment de la pose `rot` et ses coins à cette échelle ; `None` si un coin +/// part derrière le plan de fuite dès l'échelle 1. +fn contain_scale( + width: f32, + height: f32, + rot: [f32; 3], + perspective: f32, +) -> Option<(f32, [(f32, f32); 4])> { + let (half_w, half_h) = (width * 0.5, height * 0.5); // BUG corrigé : l'échelle de containment était calculée en projetant les coins PLEINE TAILLE, // puis on projetait les coins RÉDUITS. La division perspective n'étant pas linéaire en la @@ -784,23 +1017,7 @@ pub fn rotated_quad_corners_px( // l'échelle par le facteur de débordement observé. Ça converge en deux ou trois tours ; huit // est une borne large qui coûte quelques multiplications une fois par frame. let mut scale = 1.0f32; - let mut corners = match project_scaled_corners(width, height, scale, rot, perspective) { - Some(c) => c, - // Un coin derrière le plan de fuite : on rend le quad non tourné plutôt qu'une projection - // absurde (même repli qu'avant). - None => { - return TiltedQuad { - corners: [ - (-half_w, -half_h), - (half_w, -half_h), - (half_w, half_h), - (-half_w, half_h), - ], - scale: 1.0, - depth_k: (0.0, 0.0), - }; - } - }; + let mut corners = project_scaled_corners(width, height, scale, rot, perspective)?; for _ in 0..8 { let (max_x, max_y) = projected_extents(&corners); if max_x <= 0.0 || max_y <= 0.0 { @@ -818,18 +1035,7 @@ pub fn rotated_quad_corners_px( None => break, } } - // La profondeur suit l'angle RÉELLEMENT dessiné (base + dynamique), sinon le flou se - // décollerait du plan pendant la parallaxe. - let mut drawn = rot; - if rot_dyn != [0.0; 3] { - let full = [rot[0] + rot_dyn[0], rot[1] + rot_dyn[1], rot[2] + rot_dyn[2]]; - // Un coin qui passerait derrière le plan de fuite : on garde la pose de base. - if let Some(c) = project_scaled_corners(width, height, scale, full, perspective) { - corners = c; - drawn = full; - } - } - TiltedQuad { corners, scale, depth_k: depth_coefficients(drawn) } + Some((scale, corners)) } /// Amplitude maximale de la part dynamique du tilt, en degrés X/Y/Z. Partagée par tout ce qui @@ -875,8 +1081,7 @@ const PARALLAX_VELOCITY_HALF_WINDOW_S: f32 = 0.1; /// /// - `track` : la piste curseur ; `None` (pas de sidecar) → rotation nulle. /// - `cut` : la coupe visible, dans le repère NORMALISÉ du curseur (`[x0, y0, x1, y1]`). La -/// vitesse s'y mesure en coupes par seconde — ce que voit le spectateur. L'impact du clic -/// visera depuis ce même point. +/// vitesse s'y mesure en coupes par seconde — ce que voit le spectateur. /// - `strength` : à quel point le tilt est installé (0..1, cf. `ZoomState::tilt`). Rien sous /// `PARALLAX_GATE_START` : pendant l'ease-in la base est encore dans (ou près de) la bande /// des 2°, y ajouter du mouvement ferait longer un axe à une arête. @@ -886,11 +1091,11 @@ const PARALLAX_VELOCITY_HALF_WINDOW_S: f32 = 0.1; /// fonction de `t` (cf. `smooth_follow_samples`). /// /// Sens : le plan se penche vers le geste — curseur vers la droite → le bord droit recule -/// (+Y), vers le bas → le bord bas recule (−X). Même convention que l'impact du clic. +/// (+Y), vers le bas → le bord bas recule (−X). Même convention pour l'impact du clic. /// /// `impact` : l'impact du clic (`click_impact`), déjà pondéré par ses propres portes. Il -/// s'ADDITIONNE à la parallaxe, la somme est bornée au budget, puis la porte d'ease-in -/// s'applique au tout : les deux effets passent par la même porte et le même budget. +/// s'ADDITIONNE au mouvement, la somme est bornée au budget, puis la porte d'ease-in +/// s'applique au tout : tous les effets passent par la même porte et le même budget. pub fn dynamic_tilt( t: f32, track: Option<&CursorTrack>, @@ -988,6 +1193,43 @@ pub fn click_impact( ] } +/// Le recul de la caméra réelle au temps `t` : la somme des `tap` des mêmes clics que +/// `click_impact` (dans la fenêtre du clip, visibles), bornée à [−1, 1], −1 au contact. Sans leur +/// position : l'écran ne bascule pas, l'œil recule tout droit. +pub fn click_press( + t: f32, + track: &CursorTrack, + window: [f32; 2], + aim: impl Fn((f32, f32)) -> Option<[f32; 2]>, +) -> f32 { + track + .clicks_between(t - CLICK_IMPACT_WINDOW_S, t) + .iter() + .filter(|&&tc| tc >= window[0] && tc < window[1] && track.at(tc).and_then(&aim).is_some()) + .map(|&tc| tap((t - tc) / CLICK_IMPACT_WINDOW_S)) + .sum::() + .clamp(-1.0, 1.0) +} + +/// Le point `(u, v)` du carré unité par l'homographie qui l'envoie sur `c` (TL, TR, BR, BL) : +/// la forme de Heckbert, coordonnées relatives à TL. Miroir de `quad_inverse_projective` des +/// shaders, qui la résout à l'envers. En `f64` : l'appelant la prolonge hors du carré. +pub fn square_to_quad(c: &[(f32, f32); 4], u: f32, v: f32) -> (f32, f32) { + let p = |i: usize| (c[i].0 as f64 - c[0].0 as f64, c[i].1 as f64 - c[0].1 as f64); + let (p1, p2, p3) = (p(1), p(2), p(3)); + let d1 = (p1.0 - p2.0, p1.1 - p2.1); + let d2 = (p3.0 - p2.0, p3.1 - p2.1); + let d3 = (p2.0 - p1.0 - p3.0, p2.1 - p1.1 - p3.1); + let den = d1.0 * d2.1 - d2.0 * d1.1; + let g = (d3.0 * d2.1 - d2.0 * d3.1) / den; + let h = (d1.0 * d3.1 - d3.0 * d1.1) / den; + let (u, v) = (u as f64, v as f64); + let w = g * u + h * v + 1.0; + let x = (p1.0 * (1.0 + g) * u + p3.0 * (1.0 + h) * v) / w; + let y = (p1.1 * (1.0 + g) * u + p3.1 * (1.0 + h) * v) / w; + ((c[0].0 as f64 + x) as f32, (c[0].1 as f64 + y) as f32) +} + fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 { let u = clamp01((x - e0) / (e1 - e0)); u * u * (3.0 - 2.0 * u) @@ -1003,7 +1245,13 @@ impl TiltedQuad { /// le plan incliné par cette fonction retombe exactement sur le contenu que le shader y a /// dessiné. Sans elle, un recouvrement comme le curseur reste sur le rect droit d'origine /// pendant que l'image, elle, est penchée. + /// + /// Sous la caméra réelle (`projective`), la correspondance est l'homographie des coins, que + /// le shader inverse aussi : exacte, prolongements hors de 0..1 compris. pub fn point_px(&self, fx: f32, fy: f32) -> (f32, f32) { + if self.projective { + return square_to_quad(&self.corners, fx, fy); + } let [tl, tr, br, bl] = self.corners; let top = (tl.0 + (tr.0 - tl.0) * fx, tl.1 + (tr.1 - tl.1) * fx); let bottom = (bl.0 + (br.0 - bl.0) * fx, bl.1 + (br.1 - bl.1) * fx); @@ -1027,11 +1275,16 @@ impl TiltedQuad { let gx = screen_px[0] * self.scale * self.depth_k.0; let gy = screen_px[1] * self.scale * self.depth_k.1; let z_focus = (focus_plane[0] - 0.5) * gx + (focus_plane[1] - 0.5) * gy; - let perspective = screen_px[0].min(screen_px[1]) * PERSPECTIVE_FACTOR; + let perspective = self.perspective; let k = if dof && perspective > 0.0 { DOF_COC_PER_DEPTH / perspective } else { 0.0 }; [gx, gy, z_focus, k] } + /// Le drapeau « warp projectif » des shaders (1) ou bilinéaire (0). + pub fn warp_flag(&self) -> f32 { + if self.projective { 1.0 } else { 0.0 } + } + /// Demi-largeur / demi-hauteur de la bounding box des coins projetés, en px. pub fn half_extents_px(&self) -> (f32, f32) { projected_extents(&self.corners) @@ -1557,7 +1810,7 @@ mod tilt_tests { } /// Plus petit écart d'une arête du quad à son axe, en degrés. - fn min_edge_angle(c: &[(f32, f32); 4]) -> f32 { + pub(super) fn min_edge_angle(c: &[(f32, f32); 4]) -> f32 { let h = |p: (f32, f32), q: (f32, f32)| (q.1 - p.1).atan2(q.0 - p.0).to_degrees().abs(); let v = |p: (f32, f32), q: (f32, f32)| (q.0 - p.0).atan2(q.1 - p.1).to_degrees().abs(); h(c[0], c[1]).min(h(c[3], c[2])).min(v(c[0], c[3])).min(v(c[1], c[2])) @@ -1668,7 +1921,9 @@ mod tilt_tests { hide_cursor: false, click_impact: false, }; - let presets = [None, Some("iso"), Some("left"), Some("right")]; + // `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")]; for a in presets { for b in presets { // Transition chaînée sur [4, 5] s. @@ -1677,6 +1932,8 @@ mod tilt_tests { let t = 4.0 + k as f32 / 200.0; let state = zoom_state_at(®ions, t, None); let base = state.rotation; + // Jamais les deux modèles à la fois. + assert!(state.camera == 0.0 || is_identity_rotation(base), "{a:?}→{b:?} t {t}"); let gate = smoothstep(PARALLAX_GATE_START, 1.0, state.tilt); for (w, h) in [(1920.0f32, 1080.0f32), (1080.0, 1920.0), (800.0, 800.0)] { let still = rotated_quad_corners_px(w, h, base, [0.0; 3]).corners; @@ -1704,9 +1961,12 @@ mod tilt_tests { } } } - // Garde : entre deux présets identiques, la porte reste ouverte tout du long. + // Garde : entre deux angles fixes identiques, la porte reste ouverte tout du long ; + // entre deux caméras réelles, la caméra aussi. if a.is_some() && a == b { - assert_eq!(zoom_state_at(®ions, 4.5, None).tilt, 1.0, "{a:?}"); + let mid = zoom_state_at(®ions, 4.5, None); + let open = if a == Some("follow-cursor") { mid.camera } else { mid.tilt }; + assert_eq!(open, 1.0, "{a:?}"); } } } @@ -2280,3 +2540,138 @@ mod programme_clock { assert!(ProgrammeClock::for_clip(&scene, 0, 0.0).is_none()); } } + +#[cfg(test)] +mod follow_camera_tests { + use super::*; + use crate::cursor::CursorTrack; + use crate::scene::SceneZoomRegion; + + fn region(rotation: &str, start: f64, end: f64) -> SceneZoomRegion { + SceneZoomRegion { + id: "z".into(), + clip_index: None, + start_sec: start, + end_sec: end, + scale: 2.0, + focus_x: 0.2, + focus_y: 0.7, + focus_mode: Some("manual".into()), + rotation: Some(rotation.into()), + under_trim: false, + hide_cursor: false, + click_impact: true, + } + } + + /// Une piste de 20 s échantillonnée à 30 Hz, position donnée par `at(t)`. + fn track(at: impl Fn(f32) -> (f32, f32)) -> CursorTrack { + CursorTrack::new( + (0..=600) + .map(|i| { + let t = i as f32 / 30.0; + let (x, y) = at(t); + (t, x, y) + }) + .collect(), + vec![], + vec![], + ) + } + + fn whole(track: &CursorTrack) -> CameraFrame<'_> { + CameraFrame { track: Some(track), crop: [0.0, 0.0, 1.0, 1.0], window: [0.0, 100.0] } + } + + /// Sous `follow-cursor`, l'écran n'est pas incliné et ne glisse pas (focus au centre, quel que + /// soit le réglage de la région) : seule la caméra, de poids la force de la région, vise et + /// tourne avec le pointeur. L'impact du clic passe (l'œil recule). Hors région, l'état plat + /// exact. + #[test] + fn follow_cursor_rides_the_zoom_envelope() { + let tr = track(|_| (0.85, 0.5)); + let f = whole(&tr); + let r = [region("follow-cursor", 2.0, 8.0)]; + let full = zoom_state_in(&r, 5.0, Some(&tr), &f); + assert_eq!((full.rotation, full.tilt, full.click_impact, full.camera), ([0.0; 3], 0.0, 1.0, 1.0)); + assert_eq!((full.scale, full.focus), (2.0, [0.5, 0.5])); + assert!(full.aim[0] > 0.7 && (full.aim[1] - 0.5).abs() < 1e-3, "{:?}", full.aim); + assert!((full.orbit[0] - 0.85).abs() < 1e-3 && (full.orbit[1] - 0.5).abs() < 1e-3, "{:?}", full.orbit); + let easing = zoom_state_in(&r, 1.5, Some(&tr), &f); + assert!(easing.camera > 0.0 && easing.camera < 1.0, "{}", easing.camera); + let raw = follow_at(&r[0], 1.5, &f); + assert_eq!((easing.aim, easing.orbit), (weighted(raw.aim, easing.camera), weighted(raw.orbit, easing.camera))); + assert_eq!(easing.focus, [0.5, 0.5]); + let out = zoom_state_in(&r, 0.0, Some(&tr), &f); + assert_eq!((out.scale, out.rotation, out.camera, out.aim, out.orbit), (1.0, [0.0; 3], 0.0, [0.5; 2], [0.5; 2])); + // Sans piste, la caméra vise le centre, au repos. + 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); + assert_eq!((iso.rotation, iso.camera, iso.click_impact), ([-12.0, -18.0, -2.0], 0.0, 1.0)); + let unknown = zoom_state_in(&[region("orbit", 2.0, 8.0)], 5.0, Some(&tr), &f); + assert_eq!((unknown.rotation, unknown.tilt, unknown.camera), ([0.0; 3], 0.0, 0.0)); + } + + /// Focus auto ou manuel, même visée : elle se lit dans l'image source, pas dans la coupe. + #[test] + fn follow_cursor_aims_the_same_under_auto_focus() { + let tr = track(|_| (0.85, 0.3)); + let mut auto = region("follow-cursor", 2.0, 8.0); + auto.focus_mode = Some("auto".into()); + let manual = region("follow-cursor", 2.0, 8.0); + let a = zoom_state_in(&[auto], 5.0, Some(&tr), &whole(&tr)); + let m = zoom_state_in(&[manual], 5.0, Some(&tr), &whole(&tr)); + assert_eq!((a.aim, a.focus), (m.aim, m.focus)); + assert!(a.aim[0] > 0.7 && a.aim[1] < 0.35, "{:?}", a.aim); + } + + /// Chaînée à un angle fixe, la transition passe par l'écran droit sans jamais mélanger les + /// deux modèles, et sans saut ; entre deux caméras réelles, la caméra reste pleine. + #[test] + fn chained_regions_never_mix_the_two_models() { + 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("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)], + ] { + let both = regions[0].rotation == regions[1].rotation; + let mut last: Option = None; + for k in 0..=400 { + // La transition chaînée couvre [4, 5] s, le palier la suit jusqu'à 4,5 s. + let t = 3.9 + k as f32 * 1.3 / 400.0; + let s = zoom_state_in(®ions, t, Some(&tr), &f); + assert!(s.camera == 0.0 || is_identity_rotation(s.rotation), "t {t} : {s:?}", s = (s.camera, s.rotation)); + if both { + assert_eq!(s.camera, 1.0, "t {t}"); + } + if let Some(p) = &last { + let jump = (s.camera - p.camera).abs() + + (0..3).map(|i| (s.rotation[i] - p.rotation[i]).abs() / 20.0).sum::() + + (0..2).map(|i| (s.aim[i] - p.aim[i]).abs() + (s.orbit[i] - p.orbit[i]).abs()).sum::(); + assert!(jump < 0.05, "saut à t {t} : {jump}"); + } + last = Some(s); + } + } + } + + /// Seek arrière ou lecture : même état. + #[test] + fn the_follow_state_is_a_pure_function_of_time() { + let tr = track(|t| (0.5 + 0.4 * (t * 1.3).sin(), 0.5 + 0.3 * (t * 0.7).cos())); + let f = whole(&tr); + let r = [region("follow-cursor", 2.0, 8.0)]; + let at = |i: usize| { + let s = zoom_state_in(&r, 1.0 + i as f32 / 30.0, Some(&tr), &f); + (s.camera, s.aim, s.orbit, s.scale) + }; + let forward: Vec<_> = (0..240).map(at).collect(); + for i in (0..240).rev() { + assert_eq!(at(i), forward[i], "frame {i}"); + } + } +} diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index f2e476c87..7025b927b 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -385,7 +385,10 @@ pub struct SceneZoomRegion { /// "manual" | "auto" (suit la télémétrie curseur) | null (= manual). #[serde(default)] pub focus_mode: Option, - /// "iso" | "left" | "right" | null. + /// La caméra 3D du zoom : un angle fixe ("iso" | "left" | "right"), la caméra réelle qui + /// tourne autour de l'écran avec le pointeur ("follow-cursor", cf. `camera.rs`), ou null + /// (écran droit). Une valeur + /// inconnue rend l'écran droit. pub rotation: Option, /// La région entière tombe sur une portion qu'un trim retire. Ses temps sont donc HORS de /// la fenêtre source de `clip_index`, qui n'est là que pour l'adresser (le segment que la @@ -447,10 +450,11 @@ pub struct SceneCursor { pub smoothing: f32, pub motion_blur: f32, pub click_bounce: f32, - /// 0..1 : volume du curseur (extrusion + ombre de contact). 0 = sprite plat, le rendu - /// d'avant. `#[serde(default)]` : absent des projets et des JSON écrits avant ce réglage. + /// Curseur MODÉLISÉ en 3D (mode 15) : le sprite de chaque état du thème par défaut, extrudé, + /// à la place du sprite plat. Les autres thèmes restent plats. `#[serde(default)]` : absent + /// des projets et des JSON écrits avant le réglage, qui gardent donc le curseur plat. #[serde(default)] - pub volume: f32, + pub model3d: bool, pub clip_to_bounds: bool, /// id du thème (jeu de sprites) — informatif ici : le natif consomme `cursor_sprites`. pub theme: String, diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index bc02c3341..52af2ca2d 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -4,16 +4,18 @@ cbuffer Layer : register(b0) { float4 dst; // x,y,w,h dans l'espace sortie 0..1 (origine haut-gauche) - float4 src; // u0,v0,u1,v1 dans l'espace source 0..1 + float4 src; // u0,v0,u1,v1 dans l'espace source 0..1 ; mode 15 : décalage px du rayon, P, U float2 quad_px; // taille du quad en pixels (pour les SDF) float radius_px; // rayon des coins arrondis en px (0 = aucun) - float mode; // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre portée, 4 = curseur - float4 color; // couleur pleine / teinte (ombre : rgb + opacité dans a) - float4 fx; // fx.x = spread ombre (px), fx.y,fx.z libres - float4 src_prev; // src à la frame précédente (flou de mouvement par vélocité) - float4 dst_prev; // dst à la frame précédente - float4 mb; // mb.x = nombre de taps de motion blur (1 = désactivé) + float mode; // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre portée, 4 = curseur, 15 = curseur 3D, 16 = impact du clic + float4 color; // couleur pleine / teinte (ombre : rgb + opacité dans a) ; mode 15 : coin du sprite, écrasement, opacité + float4 fx; // fx.x = spread ombre (px), fx.y,fx.z libres ; mode 15 : rotation du plan (rad), tangage + float4 src_prev; // src à la frame précédente (flou de mouvement par vélocité) ; mode 15 : hotspot, lacet + float4 dst_prev; // dst à la frame précédente ; modes 13 et 15 : rect de clip + float4 mb; // mb.x = nombre de taps de motion blur (1 = désactivé) ; mode 15 : demi-taille du plan, translation }; +// 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`). struct VSOut { @@ -43,6 +45,9 @@ Texture2D texImg : register(t2); // wallpaper image RGBA (fond, mode 6) // resolution du modele (256x144) ; l'upscale vers la resolution webcam est fait par le sampler // lineaire, ce qui est exactement le filtrage qu'on veut sur un masque. Texture2D texMask : register(t3); +// Champ de distance signé du sprite de curseur (mode 15 seulement), R16F, cf. `cursor_sdf.rs`. +// Le sprite lui-même est en t2 (texImg), comme aux modes 7 et 13. +Texture2D texSdf : register(t4); SamplerState samp : register(s0); // Plafond de la profondeur de champ du mode 8, en niveau de la pyramide demi-résolution (1.5 = @@ -178,6 +183,45 @@ float3 quad_inverse_bilinear(float2 P, float2 c00, float2 c10, float2 c11, float return (r0.z > 0.5) ? r0 : r1; } +// (s, t, ok) du point `P` dans le quad c00->c10->c11->c01 par l'homographie EXACTE du carré unité +// sur le quad (forme de Heckbert, coordonnées relatives à c00), résolue à l'envers par Cramer. +// C'est la projection d'un plan par une vraie caméra (`camera.rs`) : le warp bilinéaire s'en écarte +// de plusieurs dizaines de px au centre d'un écran vu de biais. Miroir de `regions::square_to_quad`. +float3 quad_inverse_projective(float2 P, float2 c00, float2 c10, float2 c11, float2 c01) +{ + float2 p1 = c10 - c00; + float2 p2 = c11 - c00; + float2 p3 = c01 - c00; + float2 d1 = p1 - p2; + float2 d2 = p3 - p2; + float2 d3 = p2 - p1 - p3; + float den = d1.x * d2.y - d2.x * d1.y; + float g = (d3.x * d2.y - d2.x * d3.y) / den; + float h = (d1.x * d3.y - d3.x * d1.y) / den; + float2 q = P - c00; + // x·(g s + h t + 1) = a s + b t, idem en y : un système 2×2 linéaire en (s, t). + float m00 = p1.x * (1.0 + g) - g * q.x; + float m01 = p3.x * (1.0 + h) - h * q.x; + float m10 = p1.y * (1.0 + g) - g * q.y; + float m11 = p3.y * (1.0 + h) - h * q.y; + float det = m00 * m11 - m01 * m10; + float s = (q.x * m11 - m01 * q.y) / det; + float t = (m00 * q.y - q.x * m10) / det; + float ok = (s >= -0.02 && s <= 1.02 && t >= -0.02 && t <= 1.02) ? 1.0 : 0.0; + 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é. +float3 quad_inverse(float2 P, float2 c00, float2 c10, float2 c11, float2 c01, float projective) +{ + if (projective > 0.5) + { + return quad_inverse_projective(P, c00, c10, c11, c01); + } + return quad_inverse_bilinear(P, c00, c10, c11, c01); +} + // 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) @@ -295,48 +339,341 @@ float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 local_px) return sum / max(total, 1e-4); } -// Curseur EN VOLUME (mode 13, mb.z > 1) : extrusion par répétition, sans maillage. La silhouette -// alpha du sprite est rééchantillonnée `mb.z` fois (≤ 48), chaque copie translatée d'une fraction -// de `mb.xy` — le vecteur d'extrusion `e` en px, déjà projeté côté CPU -// (`frame_geometry::cursor_extrusion_px`). Une translation est la même dans tous les repères, -// donc `local - e·f` retombe sur la copie f sans autre calcul. La copie 0 est la face avant, -// intacte ; les suivantes sont les flancs, assombris avec la profondeur. Composition -// avant→arrière (« under ») : un flanc ne se voit que là où rien de plus proche ne couvre déjà -// le pixel, et la boucle s'arrête dès que le pixel est plein. -// `SampleLevel(…, 0)` et non `Sample` : un gradient implicite est interdit dans une boucle à -// sortie dynamique, et les textures d'image n'ont qu'un niveau — le résultat est le même. -// Alpha DROIT comme le mode 13 plat : on prémultiplie ici. -float4 cursor_extruded(float2 local) -{ - int taps = min((int) mb.z, 48); - float4 acc = 0.0; - [loop] for (int k = 0; k < 48; k++) +// ============ Curseur MODÉLISÉ (mode 15) ============ +// Le sprite de l'état courant en objet 3D : sa silhouette (champ de distance signé tiré de son +// alpha par `cursor_sdf.rs`), extrudée avec un chanfrein, lancé de rayons par pixel. Le dessus +// porte l'art du sprite ; le chanfrein et les flancs, la couleur de son bord. Ce qui touche le +// modèle est éclairé ; ce qui le rate tombe sur le plan de l'écran, où l'on mesure l'ombre portée +// (marche vers la lumière, pénombre douce) et l'ombre de contact. La caméra est reconstruite à +// l'identique de `regions::rotate_point` + perspective P / (P - z) ; la pose, la caméra et la +// boîte de dessin viennent de `frame_geometry::cursor_model_cb`, qui documente les emplacements +// du cbuffer. +// +// Repère du MODÈLE : unité = plus grand côté du sprite, origine au hotspot de la face du dessus, +// x à droite, y vers le bas, z vers la caméra ; le modèle occupe z de -MODEL_THICK à 0. Le rect +// du sprite y commence en `color.rg` et mesure `sprite_size()` (rapport w/h dans `radius_px`). +// Textures : t2 (texImg) = le sprite, RGBA en alpha droit ; t4 (texSdf) = son champ, R16F, en +// unités du modèle, négatif dedans, sur le même rect. +// Constantes : miroir exact de `frame_geometry.rs` (MODEL_*). +static const float MODEL_THICK = 0.19; + +// Taille du sprite, repère du modèle : son plus grand côté vaut 1, `radius_px` porte w/h. +float2 sprite_size() +{ + return float2(min(radius_px, 1.0), min(1.0 / radius_px, 1.0)); +} + +// Un texel du sprite, en unités du modèle : le champ est le sprite suréchantillonné ×4 +// (`cursor_sdf::SDF_UPSAMPLE`) et le plus grand côté du sprite vaut 1. +static const float CURSOR_SDF_UPSAMPLE = 4.0; +float sprite_texel() +{ + uint w, h; + texSdf.GetDimensions(w, h); + return CURSOR_SDF_UPSAMPLE / (float)max(w, h); +} + +// Épaisseur du modèle, écrasé au clic de `color.b` (`CursorPose::squash`). +float model_thick() +{ + return MODEL_THICK * color.b; +} +static const float MODEL_BEVEL = 0.045; +// Direction VERS la lumière, repère caméra : haut-gauche, devant. +static const float3 MODEL_LIGHT = float3(-0.4194, -0.5792, 0.6990); +static const float MODEL_AMBIENT = 0.36; +static const float MODEL_DIFFUSE = 0.75; +static const float MODEL_SPECULAR = 0.45; +// Profondeur (texels du sprite) à laquelle on lit la couleur du bord : assez loin de la frange +// antialiasée, assez près pour rester dans le filet de l'art (~4 texels). +static const float MODEL_RIM_INSET = 1.5; +// Ombre portée : dureté de la pénombre (plus grand = plus net), portée maximale (la marche vers +// la lumière s'arrête à la boîte du modèle élargie d'autant) et opacité ; ombre de contact : +// portée (unités) et opacité. La boîte de dessin en dépend : miroir des constantes +// `MODEL_SOFTNESS`, `MODEL_SHADOW_PAD` et `MODEL_CONTACT_RADIUS` de `frame_geometry.rs`. +static const float MODEL_SOFTNESS = 6.0; +static const float MODEL_SHADOW_PAD = 0.45; +static const float MODEL_SHADOW_ALPHA = 0.5; +static const float MODEL_CONTACT_RADIUS = 0.12; +static const float MODEL_CONTACT_ALPHA = 0.5; + +// Distance signée à la silhouette (plan xy du modèle). Dans le rect du sprite, le champ ; hors de +// lui, une borne inférieure exacte le long de la normale au rect : le sprite tient dans son rect, +// qui est convexe, donc |p - s|² >= |p - c|² + |c - s|² pour tout point s du sprite (c = p ramené +// dans le rect). Échantillonné au niveau 0 : la marche est une boucle à sortie anticipée. +float sd_sprite2(float2 p) +{ + float2 c = clamp(p, color.rg, color.rg + sprite_size()); + float d = texSdf.SampleLevel(samp, (c - color.rg) / sprite_size(), 0.0); + float2 o = p - c; + float out2 = dot(o, o); + float e = max(d, 0.0); + return out2 > 0.0 ? sqrt(out2 + e * e) : d; +} + +// Distance signée au modèle : le contour rentré du chanfrein, épaisseur rentrée du chanfrein, puis +// regonflé : les arêtes du dessus et du dessous sont arrondies de MODEL_BEVEL. +float sd_model(float3 p) +{ + float half_t = model_thick() * 0.5; + float2 w = float2(sd_sprite2(p.xy) + MODEL_BEVEL, abs(p.z + half_t) - (half_t - MODEL_BEVEL)); + return min(max(w.x, w.y), 0.0) + length(max(w, 0.0)) - MODEL_BEVEL; +} + +// Normale par le gradient du champ (tétraèdre, quatre évaluations). +float3 model_normal(float3 p) +{ + const float e = 0.002; + return normalize(float3(1, -1, -1) * sd_model(p + float3(1, -1, -1) * e) + + float3(-1, -1, 1) * sd_model(p + float3(-1, -1, 1) * e) + + float3(-1, 1, -1) * sd_model(p + float3(-1, 1, -1) * e) + + float3(1, 1, 1) * sd_model(p + float3(1, 1, 1) * e)); +} + +// Entrée/sortie d'un rayon dans une boîte alignée (x = entrée, y = sortie ; x >= y : raté). +float2 ray_box(float3 o, float3 d, float3 lo, float3 hi) +{ + float3 inv = 1.0 / (abs(d) > 1e-6 ? d : 1e-6); + float3 t0 = (lo - o) * inv; + float3 t1 = (hi - o) * inv; + float3 tn = min(t0, t1); + float3 tf = max(t0, t1); + return float2(max(max(tn.x, tn.y), tn.z), min(min(tf.x, tf.y), tf.z)); +} + +// Cosinus/sinus de la pose : rotation du plan (X, Y, Z), tangage et lacet du modèle. +struct ModelFrame +{ + float3 c; + float3 s; + float cp; + float sp; + float cy; + float sy; +}; + +// Repère caméra -> repère du plan : la transposée de `regions::rotate_point` (X, Y, puis Z). +float3 world_to_plane(float3 v, ModelFrame f) +{ + float y = v.y * f.c.x + v.z * f.s.x; + float z = -v.y * f.s.x + v.z * f.c.x; + float x = v.x * f.c.y - z * f.s.y; + z = v.x * f.s.y + z * f.c.y; + return float3(x * f.c.z + y * f.s.z, -x * f.s.z + y * f.c.z, z); +} + +// Repère du modèle -> repère du plan (sans échelle ni translation) : tangage autour de x, puis +// lacet autour de z (`ModelView::model_to_plane`). +float3 model_to_plane(float3 v, ModelFrame f) +{ + float y = v.y * f.cp - v.z * f.sp; + float z = v.y * f.sp + v.z * f.cp; + return float3(v.x * f.cy - y * f.sy, v.x * f.sy + y * f.cy, z); +} + +// L'inverse du précédent. +float3 plane_to_model(float3 v, ModelFrame f) +{ + float x = v.x * f.cy + v.y * f.sy; + float y = -v.x * f.sy + v.y * f.cy; + return float3(x, y * f.cp + v.z * f.sp, -y * f.sp + v.z * f.cp); +} + +// Pénombre vers la lumière depuis `o` (1 = éclairé, 0 = dans l'ombre). Marche bornée à la boîte +// du modèle élargie de la portée de la pénombre, pas bornés, sortie dès que l'ombre est pleine. +float model_soft_shadow(float3 o, float3 l, float3 lo, float3 hi) +{ + float2 tb = ray_box(o, l, lo - MODEL_SHADOW_PAD, hi + MODEL_SHADOW_PAD); + if (tb.x >= tb.y || tb.y <= 0.0) { - if (k >= taps || acc.a > 0.999) break; - float f = (float) k / (float) (taps - 1); - float3 r = quad_inverse_bilinear(local - mb.xy * f, fx.xy, fx.zw, src_prev.xy, src_prev.zw); - if (r.z < 0.5) continue; - float4 s = texImg.SampleLevel(samp, saturate(float2(r.x, r.y)), 0.0); - float shade = (k == 0) ? 1.0 : 0.72 - 0.3 * f; - float a = s.a * color.a; - acc += (1.0 - acc.a) * float4(s.rgb * shade * a, a); + return 1.0; } - return acc; + float res = 1.0; + float t = max(tb.x, 0.004); + [loop] for (int k = 0; k < 32; k++) + { + float d = sd_model(o + l * t); + res = min(res, MODEL_SOFTNESS * d / t); + if (res < 0.002 || t > tb.y) + { + break; + } + t += clamp(d, 0.01, 0.2); + } + res = saturate(res); + return res * res * (3.0 - 2.0 * res); +} + +// Couleur de la matière au point `p` du plan xy (alpha droit) : l'art du sprite, lu au plus à +// MODEL_RIM_INSET texels du bord vers l'intérieur. Le dessus garde donc son art, et le chanfrein, +// les flancs et le dessous prennent la couleur du bord de CE sprite (le filet blanc de la flèche, +// le trait noir des mains), jamais la frange mêlée au transparent. +float3 model_albedo(float2 p) +{ + float texel = sprite_texel(); + float e = 0.25 * texel; + float2 g = float2(sd_sprite2(p + float2(e, 0.0)) - sd_sprite2(p - float2(e, 0.0)), + sd_sprite2(p + float2(0.0, e)) - sd_sprite2(p - float2(0.0, e))); + float2 q = p - g / max(length(g), 1e-6) * max(sd_sprite2(p) + MODEL_RIM_INSET * texel, 0.0); + return texImg.SampleLevel(samp, (q - color.rg) / sprite_size(), 0.0).rgb; +} + +// Couleur (alpha droit) d'un point de la surface vu le long de `rd`. +float3 model_shade(float3 q, float3 rd, float3 l) +{ + float3 n = model_normal(q); + float3 albedo = model_albedo(q.xy); + float diffuse = saturate(dot(n, l)); + // Reflet sur les arrondis seulement : une face plane l'allumerait d'un bloc (la lumière est + // directionnelle), et le dessus sombre d'un sprite virerait au gris à chaque clic. + float gloss = 1.0 - smoothstep(0.97, 0.995, abs(n.z)); + float spec = gloss * pow(saturate(dot(n, normalize(l - rd))), 110.0); + return albedo * (MODEL_AMBIENT + MODEL_DIFFUSE * diffuse) + MODEL_SPECULAR * spec; +} + +float4 cursor_model(float2 local) +{ + ModelFrame f; + f.c = cos(fx.xyz); + f.s = sin(fx.xyz); + f.cp = cos(fx.w); + f.sp = sin(fx.w); + f.cy = cos(src_prev.w); + f.sy = sin(src_prev.w); + float persp = src.z; + float unit = src.w; + float3 tip = src_prev.xyz; + // La boîte du modèle : le rect du sprite, sur toute l'épaisseur. + float3 lo = float3(color.rg, -model_thick()); + float3 hi = float3(color.rg + sprite_size(), 0.0); + + // Le rayon de ce pixel : de la caméra (0, 0, P) à travers le pixel sur le plan image z = 0. + // Le plan est translaté de mb.zw dans le repère caméra (caméra réelle, 0 sous un angle fixe). + float3 dw = float3(local + src.xy, -persp); + float dlen = length(dw); + float3 ro = plane_to_model((world_to_plane(float3(-mb.z, -mb.w, persp), f) - tip) / unit, f); + float3 rd = plane_to_model(world_to_plane(dw / dlen, f), f); + float3 l = plane_to_model(world_to_plane(MODEL_LIGHT, f), f); + // Le plan de l'écran dans le repère du modèle : dot(p, nz) = hz. + float3 nz = plane_to_model(float3(0.0, 0.0, 1.0), f); + float hz = -tip.z / unit; + + // Le modèle. Silhouette antialiasée : un rayon qui la frôle à moins d'un pixel la couvre en + // partie (`best`, la plus petite distance rencontrée, en pixels). + float cov = 0.0; + float3 rgb = 0.0; + float2 tb = ray_box(ro, rd, lo - 0.02, hi + 0.02); + if (tb.x < tb.y && tb.y > 0.0) + { + float t = max(tb.x, 0.0); + float best = 1e9; + float t_best = t; + bool hit = false; + [loop] for (int k = 0; k < 64; k++) + { + float d = sd_model(ro + rd * t); + float fp = t / dlen; + if (d < 0.1 * fp) + { + hit = true; + t_best = t; + break; + } + if (d / fp < best) + { + best = d / fp; + t_best = t; + } + t += d; + if (t > tb.y) + { + break; + } + } + cov = hit ? 1.0 : saturate(1.0 - best); + if (cov > 0.0) + { + rgb = model_shade(ro + rd * t_best, rd, l); + } + } + + // Le plan, là où le modèle ne couvre pas tout le pixel : ombre portée et ombre de contact, + // seulement à l'intérieur de l'écran (`mb.xy` = sa demi-taille, px du plan). + float shadow = 0.0; + float denom = dot(rd, nz); + if (cov < 1.0 && denom < -1e-4) + { + float3 g = ro + rd * ((hz - dot(ro, nz)) / denom); + float3 gp = tip + unit * model_to_plane(g, f); + float inside = saturate(min(mb.x - abs(gp.x), mb.y - abs(gp.y)) + 0.5); + if (inside > 0.0) + { + float dropped = 1.0 - model_soft_shadow(g, l, lo, hi); + float contact = 1.0 - smoothstep(0.0, MODEL_CONTACT_RADIUS, sd_model(g)); + shadow = inside * max(dropped * MODEL_SHADOW_ALPHA, contact * MODEL_CONTACT_ALPHA); + } + } + + float a = cov * color.a; + return float4(rgb * a, a + (1.0 - a) * shadow * color.a); // prémultiplié, ombre noire +} + +// ============ Impact du clic (mode 16) ============ +// Sous le curseur modélisé : une tache de pression et un anneau posés SUR l'écran, centrés sur le +// point cliqué. Même warp que le mode 13 (fx/src_prev = coins, mb.x = 1 : projectif) ; le carré +// (s, t) porte un disque, d = 0 au point cliqué et 1 sur le cercle inscrit. src = anneau (rayon, +// demi-épaisseur, opacité, opacité du halo sombre) ; mb.yz = tache (rayon, opacité) ; dst_prev = +// le carré en fractions du plan, pour ne rien dessiner hors de l'écran ; radius_px = largeur de +// l'antialiasing ; color = teinte de l'anneau et opacité. Emplacements : `cursor_impact_cb`. +float4 cursor_impact(float2 local) +{ + float3 r = quad_inverse(local, fx.xy, fx.zw, src_prev.xy, src_prev.zw, mb.x); + float2 pf = dst_prev.xy + r.xy * dst_prev.zw; + if (r.z < 0.5 || any(pf < 0.0) || any(pf > 1.0)) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + float d = length(r.xy * 2.0 - 1.0); + float x = abs(d - src.x); + float ring = src.z * (1.0 - smoothstep(src.y - radius_px, src.y + radius_px, x)); + float halo = src.w * exp(-x * x / (6.0 * src.y * src.y + radius_px * radius_px)); + float spot = mb.z * exp(-d * d / max(mb.y * mb.y, 1e-6)); + float shade = saturate(halo + spot); + float a = ring + (1.0 - ring) * shade; + return float4(color.rgb * ring, a) * color.a; // prémultiplié, ombre noire } float4 ps_main(VSOut i) : SV_Target { + // mode 16 : IMPACT DU CLIC (cf. `cursor_impact`). Testé en premier, comme le mode 15. + if (mode > 15.5) + { + return cursor_impact(i.local); + } + + // mode 15 : CURSEUR MODÉLISÉ (cf. `cursor_model`). Testé en premier : les branches suivantes + // n'ont pas de borne haute. dst_prev = rect de clip « Clip to canvas », comme au mode 13. + if (mode > 14.5) + { + if (i.pout.x < dst_prev.x || i.pout.x > dst_prev.x + dst_prev.z || + i.pout.y < dst_prev.y || i.pout.y > dst_prev.y + dst_prev.w) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + return cursor_model(i.local); + } + // mode 14 : CADRE DE FENÊTRE autour de l'écran (barre de titre, trois pastilles, filet), - // dessiné SOUS lui. Testé en premier : la branche du mode 13 n'a pas de borne haute. + // dessiné SOUS lui. Testé avant le mode 13, dont la branche n'a pas de borne haute. // Même warp que le mode 8 — le cadre est le quad de l'écran prolongé, il penche donc avec // lui ; à plat, le quad est un rect et le warp l'identité exacte. // fx.xy/fx.zw = coins TL/TR, src_prev.xy/.zw = BR/BL (px locaux) ; dst_prev.xy = taille du // cadre dans son plan, dst_prev.z = hauteur de la barre, dst_prev.w = épaisseur du filet // (px du plan) ; radius_px = rayon extérieur (les coins hauts plafonnent à la barre) ; - // color = fond de la barre, mb = couleur du filet (alpha droit). + // color = fond de la barre, mb = couleur du filet (alpha droit) ; src.x = 1 : warp projectif. if (mode > 13.5) { - float3 r = quad_inverse_bilinear(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw); + float3 r = quad_inverse(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw, src.x); if (r.z < 0.5) { return float4(0.0, 0.0, 0.0, 0.0); // hors du cadre projeté @@ -371,9 +708,8 @@ float4 ps_main(VSOut i) : SV_Target // doit donc subir la même inclinaison qu'elle, sinon il se lit comme un autocollant plat // collé par-dessus la scène. Corriger sa seule position ne suffisait pas. // fx.xy/fx.zw = coins TL/TR (px locaux) ; src_prev.xy/.zw = BR/BL ; dst_prev = rect de clip - // « Clip to canvas » en espace sortie. mb.xy = vecteur d'extrusion en px, mb.z = nombre de - // copies (volume, cf. `cursor_extruded`) ; mb.z ≤ 1 = sprite plat, le rendu d'avant. - // Un futur mode 14 doit être testé AVANT cette branche, qui n'a pas de borne haute. + // « Clip to canvas » en espace sortie ; mb.x = 1 : warp projectif. + // Un mode supérieur doit être testé AVANT cette branche, qui n'a pas de borne haute. if (mode > 12.5) { if (i.pout.x < dst_prev.x || i.pout.x > dst_prev.x + dst_prev.z || @@ -381,11 +717,7 @@ float4 ps_main(VSOut i) : SV_Target { return float4(0.0, 0.0, 0.0, 0.0); } - if (mb.z > 1.5) - { - return cursor_extruded(i.local); - } - float3 r = quad_inverse_bilinear(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw); + float3 r = quad_inverse(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw, mb.x); if (r.z < 0.5) { return float4(0.0, 0.0, 0.0, 0.0); // hors du sprite projeté @@ -395,12 +727,13 @@ float4 ps_main(VSOut i) : SV_Target return float4(s.rgb * a, a); } - // mode 8 : écran tilté en 3D (zoom regions "rotation" : iso/left/right). `dst`/`quad_px` - // couvrent la BOUNDING BOX des 4 coins projetés (calculée côté CPU, `regions.rs`) ; ce - // shader retrouve où tombe chaque pixel DANS le quad tilté (warp bilinéaire inverse — pas - // de perspective-correct exact, mais indiscernable à l'œil pour un tilt de 10-22°) et - // échantillonne la vidéo à l'UV correspondant, sinon transparent (hors du quad projeté). + // 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. // 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 // en px, positive vers la caméra ; z_focus = celle du focus du zoom (`TiltedQuad::depth_mb`) ; // k = texels source de flou par px d'écart de profondeur (0 = profondeur de champ coupée). @@ -455,7 +788,8 @@ float4 ps_main(VSOut i) : SV_Target // laquelle on dessine). `i.pout` donne directement l'UV de sortie, donc aucun mapping à // refaire. fx.x = 0 mosaïque / 1 flou ; fx.y = taille de bloc px (mosaïque) ou rayon px // (flou) ; fx.z = 0 rectangle / 1 ovale ; fx.w = 1 si le masque doit être teinté ; - // mb.z = 1 si le masque est un quad incliné (coins TL, TR dans dst_prev, BR, BL dans src_prev). + // mb.z = 1 si le masque est un quad incliné (coins TL, TR dans dst_prev, BR, BL dans src_prev), + // mb.w = 1 si son warp est projectif. if (mode > 9.5) { // Masque de forme, en coords locales normalisées du quad. @@ -466,7 +800,7 @@ float4 ps_main(VSOut i) : SV_Target // transparent SUR la zone à cacher. if (mb.z > 0.5) { - float3 w = quad_inverse_bilinear(i.local, dst_prev.xy, dst_prev.zw, src_prev.xy, src_prev.zw); + float3 w = quad_inverse(i.local, dst_prev.xy, dst_prev.zw, src_prev.xy, src_prev.zw, mb.w); if (w.z < 0.5) { return float4(0.0, 0.0, 0.0, 0.0); @@ -542,7 +876,7 @@ float4 ps_main(VSOut i) : SV_Target if (mode > 7.5) { - float3 r = quad_inverse_bilinear(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw); + float3 r = quad_inverse(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw, dst_prev.w); if (r.z < 0.5) { return float4(0.0, 0.0, 0.0, 0.0); // hors du quad projeté @@ -582,6 +916,11 @@ float4 ps_main(VSOut i) : SV_Target float3 far_rgb = texImg.SampleLevel(samp, uv, lod).rgb; rgb = lerp(rgb, far_rgb, saturate((coc - 0.5) / 1.5)); } + if (dst_prev.w > 0.5) + { + // La lampe de la caméra réelle : le côté proche un peu plus clair. + rgb = saturate(rgb * (1.0 + color.x * (rs.x - 0.5) + color.y * (rs.y - 0.5))); + } return float4(rgb * tilt_a, tilt_a); // prémultiplié, comme les autres modes } diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index 162edd0eb..31c80711c 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -48,16 +48,18 @@ using namespace metal; struct Layer { float4 dst; // x,y,w,h dans l'espace sortie 0..1 (origine haut-gauche) - float4 src; // u0,v0,u1,v1 dans l'espace source 0..1 + float4 src; // u0,v0,u1,v1 dans l'espace source 0..1 ; mode 15 : décalage px du rayon, P, U float2 quad_px; // taille du quad en pixels (pour les SDF) float radius_px; // rayon des coins arrondis en px (0 = aucun) - float mode; // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre portée, ... - float4 color; // couleur pleine / teinte (ombre : rgb + opacité dans a) - float4 fx; // fx.x = spread ombre (px), fx.y,fx.z libres - float4 src_prev; // src à la frame précédente (flou de mouvement par vélocité) - float4 dst_prev; // dst à la frame précédente - float4 mb; // mb.x = nombre de taps de motion blur (1 = désactivé) + float mode; // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre portée, ..., 15 = curseur 3D + float4 color; // couleur pleine / teinte (ombre : rgb + opacité dans a) ; mode 15 : coin du sprite, texel, opacité + float4 fx; // fx.x = spread ombre (px), fx.y,fx.z libres ; mode 15 : rotation du plan (rad), tangage + float4 src_prev; // src à la frame précédente (flou de mouvement par vélocité) ; mode 15 : hotspot, lacet + float4 dst_prev; // dst à la frame précédente ; modes 13 et 15 : rect de clip + float4 mb; // mb.x = nombre de taps de motion blur (1 = désactivé) ; mode 15 : demi-taille du plan, translation }; +// 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`). // `layer` est passé en `constant Layer& [[buffer(0)]]` à chaque entry point qui le lit // (cf. la note « DIFFÉRENCE STRUCTURELLE » en tête de fichier). Côté Rust, il est lié par @@ -107,7 +109,8 @@ constexpr sampler sampNV(filter::linear, address::clamp_to_edge); constant float DOF_MAX_LOD = 1.5; // Slots de texture, tenus par les paramètres des entry points : -// ps_main : 0 = texY (Y, R8), 1 = texUV (CbCr, RG8), 2 = texImg (RGBA) +// ps_main : 0 = texY (Y, R8), 1 = texUV (CbCr, RG8), 2 = texImg (RGBA), 3 = texMask (R8), +// 4 = texSdf (champ du sprite de curseur, R16F, mode 15) // ps_fs_* : 0 = rgbTex (RGBA) // ================================================================================= @@ -234,6 +237,43 @@ inline float3 quad_inverse_bilinear(float2 P, float2 c00, float2 c10, float2 c11 return (r0.z > 0.5) ? r0 : r1; } +// (s, t, ok) du point `P` par l'homographie EXACTE du carré unité sur le quad (forme de Heckbert, +// relative à c00), résolue à l'envers par Cramer : la projection d'un plan par la caméra réelle. +// Cf. commentaires HLSL. +inline float3 quad_inverse_projective(float2 P, float2 c00, float2 c10, float2 c11, float2 c01) +{ + float2 p1 = c10 - c00; + float2 p2 = c11 - c00; + float2 p3 = c01 - c00; + float2 d1 = p1 - p2; + float2 d2 = p3 - p2; + float2 d3 = p2 - p1 - p3; + float den = d1.x * d2.y - d2.x * d1.y; + float g = (d3.x * d2.y - d2.x * d3.y) / den; + float h = (d1.x * d3.y - d3.x * d1.y) / den; + float2 q = P - c00; + float m00 = p1.x * (1.0 + g) - g * q.x; + float m01 = p3.x * (1.0 + h) - h * q.x; + float m10 = p1.y * (1.0 + g) - g * q.y; + float m11 = p3.y * (1.0 + h) - h * q.y; + float det = m00 * m11 - m01 * m10; + float s = (q.x * m11 - m01 * q.y) / det; + float t = (m00 * q.y - q.x * m10) / det; + float ok = (s >= -0.02 && s <= 1.02 && t >= -0.02 && t <= 1.02) ? 1.0 : 0.0; + 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é. +inline float3 quad_inverse(float2 P, float2 c00, float2 c10, float2 c11, float2 c01, float projective) +{ + if (projective > 0.5) + { + return quad_inverse_projective(P, c00, c10, c11, c01); + } + return quad_inverse_bilinear(P, c00, c10, c11, c01); +} + // 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) { @@ -303,29 +343,6 @@ inline float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 loca return sum / max(total, 1e-4); } -// Curseur EN VOLUME (mode 13, mb.z > 1). Port ligne pour ligne de `cursor_extruded` (HLSL), -// dont les commentaires font foi ; seules différences : `layer` et `texImg` arrivent en -// paramètres, et `SampleLevel(…, 0)` s'écrit `sample(…, level(0.0))`. -static float4 cursor_extruded(float2 local, constant Layer &layer, - texture2d texImg) -{ - int taps = min((int) layer.mb.z, 48); - float4 acc = float4(0.0); - for (int k = 0; k < 48; k++) - { - if (k >= taps || acc.a > 0.999) break; - float f = (float) k / (float) (taps - 1); - float3 r = quad_inverse_bilinear(local - layer.mb.xy * f, layer.fx.xy, layer.fx.zw, - layer.src_prev.xy, layer.src_prev.zw); - if (r.z < 0.5) continue; - float4 s = texImg.sample(samp, clamp(float2(r.x, r.y), 0.0, 1.0), level(0.0)); - float shade = (k == 0) ? 1.0 : 0.72 - 0.3 * f; - float a = s.a * layer.color.a; - acc += (1.0 - acc.a) * float4(s.rgb * shade * a, a); - } - return acc; -} - // Hash 2D -> [0,1) sans sin(). Miroir de `hash12` côté HLSL. inline float hash12(float2 p) { @@ -375,6 +392,268 @@ inline float3 gradient_motion(float2 gp, float2 dir, float denom, float3 c0, flo return mix(c0, c1, clamp(0.5 + u + 0.07 * w, 0.0, 1.0)); } +// ============ Curseur MODÉLISÉ (mode 15) ============ +// Port ligne pour ligne de `cursor_model` (HLSL), dont les commentaires font foi ; seules +// différences : `layer` et les textures arrivent en paramètres (le sprite en texture(2), son +// champ R16F en texture(4)), `saturate` s'écrit `clamp`, `lerp` s'écrit `mix`, `SampleLevel` +// s'écrit `sample(…, level(0.0))`. +// Constantes : miroir exact de `frame_geometry.rs` (MODEL_*). +constant float MODEL_THICK = 0.19; +constant float MODEL_BEVEL = 0.045; +constant float3 MODEL_LIGHT = float3(-0.4194, -0.5792, 0.6990); +constant float MODEL_AMBIENT = 0.36; +constant float MODEL_DIFFUSE = 0.75; +constant float MODEL_SPECULAR = 0.45; +constant float MODEL_RIM_INSET = 1.5; +constant float MODEL_SOFTNESS = 6.0; +constant float MODEL_SHADOW_PAD = 0.45; +constant float MODEL_SHADOW_ALPHA = 0.5; +constant float MODEL_CONTACT_RADIUS = 0.12; +constant float MODEL_CONTACT_ALPHA = 0.5; + +// Taille du sprite, repère du modèle : son plus grand côté vaut 1, `radius_px` porte w/h. +inline float2 sprite_size(constant Layer &layer) +{ + return float2(min(layer.radius_px, 1.0), min(1.0 / layer.radius_px, 1.0)); +} + +// Un texel du sprite, en unités du modèle : le champ est le sprite suréchantillonné ×4. +constant float CURSOR_SDF_UPSAMPLE = 4.0; +inline float sprite_texel(texture2d texSdf) +{ + return CURSOR_SDF_UPSAMPLE / float(max(texSdf.get_width(), texSdf.get_height())); +} + +// Épaisseur du modèle, écrasé au clic de `color.b`. +inline float model_thick(constant Layer &layer) +{ + return MODEL_THICK * layer.color.b; +} + +static float sd_sprite2(float2 p, constant Layer &layer, texture2d texSdf) +{ + float2 lo = layer.color.rg; + float2 c = clamp(p, lo, lo + sprite_size(layer)); + float d = texSdf.sample(samp, (c - lo) / sprite_size(layer), level(0.0)).r; + float2 o = p - c; + float out2 = dot(o, o); + float e = max(d, 0.0); + return out2 > 0.0 ? sqrt(out2 + e * e) : d; +} + +static float sd_model(float3 p, constant Layer &layer, texture2d texSdf) +{ + float half_t = model_thick(layer) * 0.5; + float2 w = float2(sd_sprite2(p.xy, layer, texSdf) + MODEL_BEVEL, + abs(p.z + half_t) - (half_t - MODEL_BEVEL)); + return min(max(w.x, w.y), 0.0) + length(max(w, 0.0)) - MODEL_BEVEL; +} + +static float3 model_normal(float3 p, constant Layer &layer, texture2d texSdf) +{ + const float e = 0.002; + const float3 ka = float3(1.0, -1.0, -1.0); + const float3 kb = float3(-1.0, -1.0, 1.0); + const float3 kc = float3(-1.0, 1.0, -1.0); + const float3 kd = float3(1.0, 1.0, 1.0); + return normalize(ka * sd_model(p + ka * e, layer, texSdf) + kb * sd_model(p + kb * e, layer, texSdf) + + kc * sd_model(p + kc * e, layer, texSdf) + kd * sd_model(p + kd * e, layer, texSdf)); +} + +static float2 ray_box(float3 o, float3 d, float3 lo, float3 hi) +{ + float3 inv = 1.0 / select(float3(1e-6), d, abs(d) > 1e-6); + float3 t0 = (lo - o) * inv; + float3 t1 = (hi - o) * inv; + float3 tn = min(t0, t1); + float3 tf = max(t0, t1); + return float2(max(max(tn.x, tn.y), tn.z), min(min(tf.x, tf.y), tf.z)); +} + +struct ModelFrame +{ + float3 c; + float3 s; + float cp; + float sp; + float cy; + float sy; +}; + +static float3 world_to_plane(float3 v, ModelFrame f) +{ + float y = v.y * f.c.x + v.z * f.s.x; + float z = -v.y * f.s.x + v.z * f.c.x; + float x = v.x * f.c.y - z * f.s.y; + z = v.x * f.s.y + z * f.c.y; + return float3(x * f.c.z + y * f.s.z, -x * f.s.z + y * f.c.z, z); +} + +static float3 model_to_plane(float3 v, ModelFrame f) +{ + float y = v.y * f.cp - v.z * f.sp; + float z = v.y * f.sp + v.z * f.cp; + return float3(v.x * f.cy - y * f.sy, v.x * f.sy + y * f.cy, z); +} + +static float3 plane_to_model(float3 v, ModelFrame f) +{ + float x = v.x * f.cy + v.y * f.sy; + float y = -v.x * f.sy + v.y * f.cy; + return float3(x, y * f.cp + v.z * f.sp, -y * f.sp + v.z * f.cp); +} + +static float model_soft_shadow(float3 o, float3 l, float3 lo, float3 hi, constant Layer &layer, + texture2d texSdf) +{ + float2 tb = ray_box(o, l, lo - MODEL_SHADOW_PAD, hi + MODEL_SHADOW_PAD); + if (tb.x >= tb.y || tb.y <= 0.0) + { + return 1.0; + } + float res = 1.0; + float t = max(tb.x, 0.004); + for (int k = 0; k < 32; k++) + { + float d = sd_model(o + l * t, layer, texSdf); + res = min(res, MODEL_SOFTNESS * d / t); + if (res < 0.002 || t > tb.y) + { + break; + } + t += clamp(d, 0.01, 0.2); + } + res = clamp(res, 0.0, 1.0); + return res * res * (3.0 - 2.0 * res); +} + +static float3 model_albedo(float2 p, constant Layer &layer, texture2d texSdf, + texture2d texImg) +{ + float texel = sprite_texel(texSdf); + float e = 0.25 * texel; + float2 g = float2(sd_sprite2(p + float2(e, 0.0), layer, texSdf) - sd_sprite2(p - float2(e, 0.0), layer, texSdf), + sd_sprite2(p + float2(0.0, e), layer, texSdf) - sd_sprite2(p - float2(0.0, e), layer, texSdf)); + float2 q = p - g / max(length(g), 1e-6) * + max(sd_sprite2(p, layer, texSdf) + MODEL_RIM_INSET * texel, 0.0); + return texImg.sample(samp, (q - layer.color.rg) / sprite_size(layer), level(0.0)).rgb; +} + +static float3 model_shade(float3 q, float3 rd, float3 l, constant Layer &layer, + texture2d texSdf, + texture2d texImg) +{ + float3 n = model_normal(q, layer, texSdf); + float3 albedo = model_albedo(q.xy, layer, texSdf, texImg); + float diffuse = clamp(dot(n, l), 0.0, 1.0); + float gloss = 1.0 - smoothstep(0.97, 0.995, abs(n.z)); + float spec = gloss * pow(clamp(dot(n, normalize(l - rd)), 0.0, 1.0), 110.0); + return albedo * (MODEL_AMBIENT + MODEL_DIFFUSE * diffuse) + MODEL_SPECULAR * spec; +} + +static float4 cursor_model(float2 local, constant Layer &layer, + texture2d texSdf, + texture2d texImg) +{ + ModelFrame f; + f.c = cos(layer.fx.xyz); + f.s = sin(layer.fx.xyz); + f.cp = cos(layer.fx.w); + f.sp = sin(layer.fx.w); + f.cy = cos(layer.src_prev.w); + f.sy = sin(layer.src_prev.w); + float persp = layer.src.z; + float unit = layer.src.w; + float3 tip = layer.src_prev.xyz; + float3 lo = float3(layer.color.rg, -model_thick(layer)); + float3 hi = float3(layer.color.rg + sprite_size(layer), 0.0); + + float3 dw = float3(local + layer.src.xy, -persp); + float dlen = length(dw); + // Plan translaté de mb.zw dans le repère caméra (caméra réelle ; 0 sous un angle fixe). + float3 ro = plane_to_model((world_to_plane(float3(-layer.mb.z, -layer.mb.w, persp), f) - tip) / unit, f); + float3 rd = plane_to_model(world_to_plane(dw / dlen, f), f); + float3 l = plane_to_model(world_to_plane(MODEL_LIGHT, f), f); + float3 nz = plane_to_model(float3(0.0, 0.0, 1.0), f); + float hz = -tip.z / unit; + + float cov = 0.0; + float3 rgb = float3(0.0); + float2 tb = ray_box(ro, rd, lo - 0.02, hi + 0.02); + if (tb.x < tb.y && tb.y > 0.0) + { + float t = max(tb.x, 0.0); + float best = 1e9; + float t_best = t; + bool hit = false; + for (int k = 0; k < 64; k++) + { + float d = sd_model(ro + rd * t, layer, texSdf); + float fp = t / dlen; + if (d < 0.1 * fp) + { + hit = true; + t_best = t; + break; + } + if (d / fp < best) + { + best = d / fp; + t_best = t; + } + t += d; + if (t > tb.y) + { + break; + } + } + cov = hit ? 1.0 : clamp(1.0 - best, 0.0, 1.0); + if (cov > 0.0) + { + rgb = model_shade(ro + rd * t_best, rd, l, layer, texSdf, texImg); + } + } + + float shadow = 0.0; + float denom = dot(rd, nz); + if (cov < 1.0 && denom < -1e-4) + { + float3 g = ro + rd * ((hz - dot(ro, nz)) / denom); + float3 gp = tip + unit * model_to_plane(g, f); + float inside = clamp(min(layer.mb.x - abs(gp.x), layer.mb.y - abs(gp.y)) + 0.5, 0.0, 1.0); + if (inside > 0.0) + { + float dropped = 1.0 - model_soft_shadow(g, l, lo, hi, layer, texSdf); + float contact = 1.0 - smoothstep(0.0, MODEL_CONTACT_RADIUS, sd_model(g, layer, texSdf)); + shadow = inside * max(dropped * MODEL_SHADOW_ALPHA, contact * MODEL_CONTACT_ALPHA); + } + } + + float a = cov * layer.color.a; + return float4(rgb * a, a + (1.0 - a) * shadow * layer.color.a); // prémultiplié, ombre noire +} + +// ============ Impact du clic (mode 16) ============ +// Port ligne pour ligne de `cursor_impact` (HLSL), dont les commentaires font foi. +static float4 cursor_impact(float2 local, constant Layer &layer) +{ + float3 r = quad_inverse(local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, layer.mb.x); + float2 pf = layer.dst_prev.xy + r.xy * layer.dst_prev.zw; + if (r.z < 0.5 || any(pf < 0.0) || any(pf > 1.0)) + { + return float4(0.0); + } + float d = length(r.xy * 2.0 - 1.0); + float x = abs(d - layer.src.x); + float aa = layer.radius_px; + float ring = layer.src.z * (1.0 - smoothstep(layer.src.y - aa, layer.src.y + aa, x)); + float halo = layer.src.w * exp(-x * x / (6.0 * layer.src.y * layer.src.y + aa * aa)); + float spot = layer.mb.z * exp(-d * d / max(layer.mb.y * layer.mb.y, 1e-6)); + float shade = clamp(halo + spot, 0.0, 1.0); + float a = ring + (1.0 - ring) * shade; + return float4(layer.color.rgb * ring, a) * layer.color.a; // prémultiplié, ombre noire +} + fragment float4 ps_main(VSOut i [[stage_in]], constant Layer &layer [[buffer(0)]], texture2d texY [[texture(0)]], @@ -383,16 +662,39 @@ fragment float4 ps_main(VSOut i [[stage_in]], // Masque de segmentation du sujet webcam. Non lie tant qu'aucun // masque n'existe : Metal rend alors 0, ce qui est sans effet // puisque la branche n'est prise que si layer.fx.z > 0.5. - texture2d texMask [[texture(3)]]) + texture2d texMask [[texture(3)]], + // Champ de distance du sprite de curseur (mode 15 seulement), R16F, cf. + // `cursor_sdf.rs`. Le sprite lui-même est en texture(2), comme aux + // modes 7 et 13. + texture2d texSdf [[texture(4)]]) { + // mode 16 : IMPACT DU CLIC (`cursor_impact`). Testé en premier, comme le mode 15. + if (layer.mode > 15.5) + { + return cursor_impact(i.local, layer); + } + + // mode 15 : CURSEUR MODÉLISÉ (`cursor_model`). Testé en premier : les branches suivantes + // n'ont pas de borne haute. dst_prev = rect de clip « Clip to canvas », comme au mode 13. + if (layer.mode > 14.5) + { + if (i.pout.x < layer.dst_prev.x || i.pout.x > layer.dst_prev.x + layer.dst_prev.z || + i.pout.y < layer.dst_prev.y || i.pout.y > layer.dst_prev.y + layer.dst_prev.w) + { + return float4(0.0, 0.0, 0.0, 0.0); + } + return cursor_model(i.local, layer, texSdf, texImg); + } + // mode 14 : CADRE DE FENÊTRE autour de l'écran, dessiné SOUS lui. Cf. commentaires HLSL. - // Testé en premier : la branche du mode 13 n'a pas de borne haute. + // Testé avant le mode 13, dont la branche n'a pas de borne haute. // fx/src_prev = coins du cadre projeté ; dst_prev = (taille du plan, barre, filet) ; - // radius_px = rayon extérieur ; color = fond de la barre ; mb = couleur du filet. + // radius_px = rayon extérieur ; color = fond de la barre ; mb = couleur du filet ; + // src.x = 1 : warp projectif. if (layer.mode > 13.5) { - float3 r = quad_inverse_bilinear(i.local, layer.fx.xy, layer.fx.zw, - layer.src_prev.xy, layer.src_prev.zw); + float3 r = quad_inverse(i.local, layer.fx.xy, layer.fx.zw, + layer.src_prev.xy, layer.src_prev.zw, layer.src.x); if (r.z < 0.5) { return float4(0.0, 0.0, 0.0, 0.0); // hors du cadre projeté @@ -418,8 +720,8 @@ fragment float4 ps_main(VSOut i [[stage_in]], } // mode 13 : SPRITE DE CURSEUR posé sur l'écran incliné. Cf. commentaires HLSL. - // mb.xy = vecteur d'extrusion en px, mb.z = nombre de copies (volume) ; mb.z ≤ 1 = plat. - // Un futur mode 14 doit être testé AVANT cette branche, qui n'a pas de borne haute. + // Un mode supérieur doit être testé AVANT cette branche, qui n'a pas de borne haute. + // mb.x = 1 : warp projectif. if (layer.mode > 12.5) { if (i.pout.x < layer.dst_prev.x || i.pout.x > layer.dst_prev.x + layer.dst_prev.z || @@ -427,12 +729,8 @@ fragment float4 ps_main(VSOut i [[stage_in]], { return float4(0.0, 0.0, 0.0, 0.0); } - if (layer.mb.z > 1.5) - { - return cursor_extruded(i.local, layer, texImg); - } - float3 r = quad_inverse_bilinear(i.local, layer.fx.xy, layer.fx.zw, - layer.src_prev.xy, layer.src_prev.zw); + float3 r = quad_inverse(i.local, layer.fx.xy, layer.fx.zw, + layer.src_prev.xy, layer.src_prev.zw, layer.mb.x); if (r.z < 0.5) { return float4(0.0, 0.0, 0.0, 0.0); @@ -483,7 +781,9 @@ fragment float4 ps_main(VSOut i [[stage_in]], return float4(layer.color.rgb * a, a); } - // mode 8 : écran tilté (zoom regions "rotation"). Warp bilinéaire inverse. + // 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 + // é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`) ; // k = texels source de flou par px d'écart de profondeur (0 = profondeur de champ coupée). @@ -495,8 +795,8 @@ fragment float4 ps_main(VSOut i [[stage_in]], // mode 13. En mode 8 `dst_prev.xy` porte `plane_px`, la taille du plan en PIXELS // (~1600), comparée à `i.pout` qui vit dans [0,1] : la condition était vraie pour // tout pixel et la branche rendait du transparent partout. Le tilt ne dessinait rien. - float3 r = quad_inverse_bilinear(i.local, layer.fx.xy, layer.fx.zw, - layer.src_prev.xy, layer.src_prev.zw); + float3 r = quad_inverse(i.local, layer.fx.xy, layer.fx.zw, + layer.src_prev.xy, layer.src_prev.zw, layer.dst_prev.w); if (r.z < 0.5) { return float4(0.0, 0.0, 0.0, 0.0); // hors du quad projeté @@ -530,6 +830,11 @@ fragment float4 ps_main(VSOut i [[stage_in]], 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)); } + if (layer.dst_prev.w > 0.5) + { + // La lampe de la caméra réelle : le côté proche un peu plus clair. + rgb = clamp(rgb * (1.0 + layer.color.x * (rs.x - 0.5) + layer.color.y * (rs.y - 0.5)), 0.0, 1.0); + } // L'alpha est cette couverture, pas `color.a` : les draws du mode 8 laissent `color` // à zéro, donc le port rendait de toute façon un plan totalement transparent. return float4(rgb * tilt_a, tilt_a); @@ -650,7 +955,8 @@ fragment float4 ps_main(VSOut i [[stage_in]], // échantillonner la cible sur laquelle on dessine). `i.pout` donne directement l'UV de // sortie. fx.x = 0 mosaïque / 1 flou ; fx.y = taille de bloc px ou rayon px ; // fx.z = 0 rectangle / 1 ovale ; fx.w = 1 si le masque doit être teinté ; - // mb.z = 1 si le masque est un quad incliné (coins TL, TR dans dst_prev, BR, BL dans src_prev). + // mb.z = 1 si le masque est un quad incliné (coins TL, TR dans dst_prev, BR, BL dans src_prev), + // mb.w = 1 si son warp est projectif. // // Le port se contentait de recopier `texImg` : ni forme, ni flou, ni mosaïque, ni teinte. if (layer.mode > 9.5 && layer.mode < 10.5) @@ -659,8 +965,8 @@ fragment float4 ps_main(VSOut i [[stage_in]], // Écran incliné : masque warpé comme le contenu qu'il cache. Cf. commentaires HLSL. if (layer.mb.z > 0.5) { - float3 w = quad_inverse_bilinear(i.local, layer.dst_prev.xy, layer.dst_prev.zw, - layer.src_prev.xy, layer.src_prev.zw); + float3 w = quad_inverse(i.local, layer.dst_prev.xy, layer.dst_prev.zw, + layer.src_prev.xy, layer.src_prev.zw, layer.mb.w); if (w.z < 0.5) { return float4(0.0, 0.0, 0.0, 0.0); diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index 2bf92063c..7b9b77449 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -19,20 +19,20 @@ struct Layer { dst: vec4, // x,y,w,h sortie 0..1 (origine haut-gauche) - src: vec4, // u0,v0,u1,v1 source 0..1 + src: vec4, // u0,v0,u1,v1 source 0..1 ; mode 14 : .x = 1 si warp projectif ; mode 15 : (decalage px du rayon, P, unite du modele px) quad_px: vec2, // taille du quad en px de sortie (pour la SDF isotrope) - radius_px: f32, - mode: f32, // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre, 8 = écran tilté, 9 = flèche, 10 = flou/mosaïque, 12 = ombre du quad tilté, 13 = curseur tilté, 14 = cadre de fenetre - color: vec4, // mode 14 : fond de la barre de titre - fx: vec4, // mode 2 : spread ombre en px ; mode 5 : (direction xy, temps programme replie, mouvement 0..3) ; modes 8/12/13/14 : coins TL,TR du quad projeté ; mode 9 : hampe de la flèche ; mode 10 : (flou?, rayon/bloc px, ovale?, teinté?) - 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 - 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) ; mode 14 : .xy = taille du plan du cadre, .z = hauteur de la barre, .w = epaisseur du filet (px du plan) ; mode 13 : rect de clip ; mode 9 : barbe 2 ; mode 10 incliné : coins TL,TR du masque - 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é ; mode 13 : mb.xy = vecteur d'extrusion en px, mb.z = nombre de copies (volume, <= 1 = plat) ; mode 14 : couleur du filet (alpha droit) + radius_px: f32, // mode 15 : rapport w/h du sprite (son plus grand cote vaut 1 unite) + mode: f32, // 0 = vidéo NV12, 1 = couleur pleine, 2 = ombre, 8 = écran tilté, 9 = flèche, 10 = flou/mosaïque, 12 = ombre du quad tilté, 13 = curseur tilté, 14 = cadre de fenetre, 15 = curseur modelise, 16 = impact du clic (emplacements : `cursor_impact_cb`) + color: vec4, // mode 8 (camera reelle) : .xy = gradient d'eclairage ; mode 14 : fond de la barre de titre ; mode 15 : .rg = coin du sprite (unites du modele), .b = ecrasement de l'epaisseur, .a = opacite + fx: vec4, // mode 2 : spread ombre en px ; mode 5 : (direction xy, temps programme replie, mouvement 0..3) ; modes 8/12/13/14 : coins TL,TR du quad projeté ; mode 9 : hampe de la flèche ; mode 10 : (flou?, rayon/bloc px, ovale?, teinté?) ; mode 15 : (rotation du plan X, Y, Z en rad, tangage) + 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) + 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 + 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) ; mode 15 : .xy = demi-taille du plan dans son repere (px), .zw = translation du plan (repere camera, px) } @group(0) @binding(0) var layer: Layer; -@group(0) @binding(1) var texY: texture_2d; // R8Unorm, sample .r -@group(0) @binding(2) var texU: texture_2d; // R8Unorm, sample .r +@group(0) @binding(1) var texY: texture_2d; // R8Unorm, sample .r ; modes 7, 13 et 15 : le sprite RGBA +@group(0) @binding(2) var texU: texture_2d; // R8Unorm, sample .r ; mode 15 : le champ R16F du sprite @group(0) @binding(3) var samp: sampler; // Masque de segmentation du sujet webcam, R8. Une vue 1x1 est liee quand aucun masque // n'existe : la branche n'est de toute facon prise que si layer.fx.z > 0.5. @@ -227,6 +227,43 @@ fn quad_inverse_bilinear(P: vec2, c00: vec2, c10: vec2, c11: vec2 return r1; } +// (s, t, ok) du point `P` par l'homographie EXACTE du carre unite sur le quad (forme de Heckbert, +// relative a c00), resolue a l'envers par Cramer : la projection d'un plan par la camera reelle. +// Miroir du HLSL et de `regions::square_to_quad`. +fn quad_inverse_projective(P: vec2, c00: vec2, c10: vec2, c11: vec2, c01: vec2) -> vec3 { + let p1 = c10 - c00; + let p2 = c11 - c00; + let p3 = c01 - c00; + let d1 = p1 - p2; + let d2 = p3 - p2; + let d3 = p2 - p1 - p3; + let den = d1.x * d2.y - d2.x * d1.y; + let g = (d3.x * d2.y - d2.x * d3.y) / den; + let h = (d1.x * d3.y - d3.x * d1.y) / den; + let q = P - c00; + let m00 = p1.x * (1.0 + g) - g * q.x; + let m01 = p3.x * (1.0 + h) - h * q.x; + let m10 = p1.y * (1.0 + g) - g * q.y; + let m11 = p3.y * (1.0 + h) - h * q.y; + let det = m00 * m11 - m01 * m10; + let s = (q.x * m11 - m01 * q.y) / det; + let t = (m00 * q.y - q.x * m10) / det; + var ok = 0.0; + if s >= -0.02 && s <= 1.02 && t >= -0.02 && t <= 1.02 { + ok = 1.0; + } + 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. +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); + } + return quad_inverse_bilinear(P, c00, c10, c11, c01); +} + // 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); @@ -328,33 +365,236 @@ fn blur_webcam_bg(uv: vec2, intensity: f32, qpx: vec2, local_px: vec2< return sum / max(total, 1e-4); } -// Curseur EN VOLUME (mode 13, mb.z > 1). Port ligne pour ligne de `cursor_extruded` -// (HLSL), dont les commentaires font foi : la silhouette est reechantillonnee `mb.z` -// fois (<= 48), translatee d'une fraction de `mb.xy` ; copie 0 = face avant intacte, -// les suivantes = flancs assombris, composes avant->arriere. `textureSampleLevel` a -// LOD 0 : pas de gradient implicite dans une boucle, et la texture n'a qu'un niveau. -fn cursor_extruded(local: vec2) -> vec4 { - let taps = min(i32(layer.mb.z), 48); - var acc = vec4(0.0); - for (var k: i32 = 0; k < 48; k = k + 1) { - if k >= taps || acc.a > 0.999 { +// ---- Curseur MODELISE (mode 15) ---- +// Port ligne pour ligne de `cursor_model` (HLSL), dont les commentaires font foi : le sprite de +// l'etat courant en objet 3D, sa silhouette (champ de distance signe tire de son alpha) extrudee, +// lancee de rayons par pixel, eclairee, et qui porte une ombre douce et une ombre de contact sur +// le plan de l'ecran. Constantes : miroir exact de `frame_geometry.rs` (MODEL_*), emplacements du +// cbuffer : `cursor_model_cb`. +// Textures : le sprite RGBA (alpha droit) au binding 1 (`texY`, comme aux modes 7 et 13), son +// champ R16F au binding 2 (`texU`), sur le meme rect ; `color.rg` = coin du sprite dans le repere +// du modele, `sprite_size()` = sa taille (w/h dans `radius_px`), `color.b` = un texel du sprite (unites +// du modele). +const MODEL_THICK: f32 = 0.19; +const MODEL_BEVEL: f32 = 0.045; +const MODEL_LIGHT = vec3(-0.4194, -0.5792, 0.6990); +const MODEL_AMBIENT: f32 = 0.36; +const MODEL_DIFFUSE: f32 = 0.75; +const MODEL_SPECULAR: f32 = 0.45; +const MODEL_RIM_INSET: f32 = 1.5; +const MODEL_SOFTNESS: f32 = 6.0; +const MODEL_SHADOW_PAD: f32 = 0.45; +const MODEL_SHADOW_ALPHA: f32 = 0.5; +const MODEL_CONTACT_RADIUS: f32 = 0.12; +const MODEL_CONTACT_ALPHA: f32 = 0.5; + +// Taille du sprite, repere du modele : son plus grand cote vaut 1, `radius_px` porte w/h. +fn sprite_size() -> vec2 { + return vec2(min(layer.radius_px, 1.0), min(1.0 / layer.radius_px, 1.0)); +} + +// Un texel du sprite, en unites du modele : le champ est le sprite surechantillonne x4. +const CURSOR_SDF_UPSAMPLE: f32 = 4.0; +fn sprite_texel() -> f32 { + let d = vec2(textureDimensions(texU)); + return CURSOR_SDF_UPSAMPLE / max(d.x, d.y); +} + +// Epaisseur du modele, ecrase au clic de `color.b`. +fn model_thick() -> f32 { + return MODEL_THICK * layer.color.b; +} + +fn sd_sprite2(p: vec2) -> f32 { + let lo = layer.color.rg; + let c = clamp(p, lo, lo + sprite_size()); + // Niveau 0 explicite : la marche est une boucle a sortie anticipee (pas de derivees). + let d = textureSampleLevel(texU, samp, (c - lo) / sprite_size(), 0.0).r; + let o = p - c; + let out2 = dot(o, o); + let e = max(d, 0.0); + return select(d, sqrt(out2 + e * e), out2 > 0.0); +} + +fn sd_model(p: vec3) -> f32 { + let half_t = model_thick() * 0.5; + let w = vec2(sd_sprite2(p.xy) + MODEL_BEVEL, abs(p.z + half_t) - (half_t - MODEL_BEVEL)); + return min(max(w.x, w.y), 0.0) + length(max(w, vec2(0.0))) - MODEL_BEVEL; +} + +fn model_normal(p: vec3) -> vec3 { + let e = 0.002; + let ka = vec3(1.0, -1.0, -1.0); + let kb = vec3(-1.0, -1.0, 1.0); + let kc = vec3(-1.0, 1.0, -1.0); + let kd = vec3(1.0, 1.0, 1.0); + return normalize(ka * sd_model(p + ka * e) + kb * sd_model(p + kb * e) + + kc * sd_model(p + kc * e) + kd * sd_model(p + kd * e)); +} + +fn ray_box(o: vec3, d: vec3, lo: vec3, hi: vec3) -> vec2 { + let inv = vec3(1.0) / select(vec3(1e-6), d, abs(d) > vec3(1e-6)); + let t0 = (lo - o) * inv; + let t1 = (hi - o) * inv; + let tn = min(t0, t1); + let tf = max(t0, t1); + return vec2(max(max(tn.x, tn.y), tn.z), min(min(tf.x, tf.y), tf.z)); +} + +struct ModelFrame { + c: vec3, + s: vec3, + cp: f32, + sp: f32, + cy: f32, + sy: f32, +} + +fn world_to_plane(v: vec3, f: ModelFrame) -> vec3 { + let y = v.y * f.c.x + v.z * f.s.x; + var z = -v.y * f.s.x + v.z * f.c.x; + let x = v.x * f.c.y - z * f.s.y; + z = v.x * f.s.y + z * f.c.y; + return vec3(x * f.c.z + y * f.s.z, -x * f.s.z + y * f.c.z, z); +} + +fn model_to_plane(v: vec3, f: ModelFrame) -> vec3 { + let y = v.y * f.cp - v.z * f.sp; + let z = v.y * f.sp + v.z * f.cp; + return vec3(v.x * f.cy - y * f.sy, v.x * f.sy + y * f.cy, z); +} + +fn plane_to_model(v: vec3, f: ModelFrame) -> vec3 { + let x = v.x * f.cy + v.y * f.sy; + let y = -v.x * f.sy + v.y * f.cy; + return vec3(x, y * f.cp + v.z * f.sp, -y * f.sp + v.z * f.cp); +} + +fn model_soft_shadow(o: vec3, l: vec3, lo: vec3, hi: vec3) -> f32 { + let tb = ray_box(o, l, lo - vec3(MODEL_SHADOW_PAD), hi + vec3(MODEL_SHADOW_PAD)); + if tb.x >= tb.y || tb.y <= 0.0 { + return 1.0; + } + var res = 1.0; + var t = max(tb.x, 0.004); + for (var k = 0; k < 32; k = k + 1) { + let d = sd_model(o + l * t); + res = min(res, MODEL_SOFTNESS * d / t); + if res < 0.002 || t > tb.y { break; } - let f = f32(k) / f32(taps - 1); - let r = quad_inverse_bilinear( - local - layer.mb.xy * f, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, - ); - if r.z < 0.5 { - continue; + t = t + clamp(d, 0.01, 0.2); + } + res = clamp(res, 0.0, 1.0); + return res * res * (3.0 - 2.0 * res); +} + +fn model_albedo(p: vec2) -> vec3 { + let texel = sprite_texel(); + let e = 0.25 * texel; + let g = vec2(sd_sprite2(p + vec2(e, 0.0)) - sd_sprite2(p - vec2(e, 0.0)), + sd_sprite2(p + vec2(0.0, e)) - sd_sprite2(p - vec2(0.0, e))); + let q = p - g / max(length(g), 1e-6) * max(sd_sprite2(p) + MODEL_RIM_INSET * texel, 0.0); + return textureSampleLevel(texY, samp, (q - layer.color.rg) / sprite_size(), 0.0).rgb; +} + +fn model_shade(q: vec3, rd: vec3, l: vec3) -> vec3 { + let n = model_normal(q); + let albedo = model_albedo(q.xy); + let diffuse = clamp(dot(n, l), 0.0, 1.0); + let gloss = 1.0 - smoothstep(0.97, 0.995, abs(n.z)); + let spec = gloss * pow(clamp(dot(n, normalize(l - rd)), 0.0, 1.0), 110.0); + return albedo * (MODEL_AMBIENT + MODEL_DIFFUSE * diffuse) + MODEL_SPECULAR * spec; +} + +fn cursor_model(local: vec2) -> vec4 { + var f: ModelFrame; + f.c = cos(layer.fx.xyz); + f.s = sin(layer.fx.xyz); + f.cp = cos(layer.fx.w); + f.sp = sin(layer.fx.w); + f.cy = cos(layer.src_prev.w); + f.sy = sin(layer.src_prev.w); + let persp = layer.src.z; + let unit = layer.src.w; + let tip = layer.src_prev.xyz; + let lo = vec3(layer.color.rg, -model_thick()); + let hi = vec3(layer.color.rg + sprite_size(), 0.0); + + let dw = vec3(local + layer.src.xy, -persp); + let dlen = length(dw); + // Plan translate de mb.zw dans le repere camera (camera reelle ; 0 sous un angle fixe). + let ro = plane_to_model((world_to_plane(vec3(-layer.mb.z, -layer.mb.w, persp), f) - tip) / unit, f); + let rd = plane_to_model(world_to_plane(dw / dlen, f), f); + let l = plane_to_model(world_to_plane(MODEL_LIGHT, f), f); + let nz = plane_to_model(vec3(0.0, 0.0, 1.0), f); + let hz = -tip.z / unit; + + var cov = 0.0; + var rgb = vec3(0.0); + let tb = ray_box(ro, rd, lo - vec3(0.02), hi + vec3(0.02)); + if tb.x < tb.y && tb.y > 0.0 { + var t = max(tb.x, 0.0); + var best = 1e9; + var t_best = t; + var hit = false; + for (var k = 0; k < 64; k = k + 1) { + let d = sd_model(ro + rd * t); + let fp = t / dlen; + if d < 0.1 * fp { + hit = true; + t_best = t; + break; + } + if d / fp < best { + best = d / fp; + t_best = t; + } + t = t + d; + if t > tb.y { + break; + } } - let s = textureSampleLevel( - texY, samp, clamp(vec2(r.x, r.y), vec2(0.0), vec2(1.0)), 0.0, - ); - let shade = select(0.72 - 0.3 * f, 1.0, k == 0); - let a = s.a * layer.color.a; - acc += (1.0 - acc.a) * vec4(s.rgb * shade * a, a); + cov = select(clamp(1.0 - best, 0.0, 1.0), 1.0, hit); + if cov > 0.0 { + rgb = model_shade(ro + rd * t_best, rd, l); + } + } + + var shadow = 0.0; + let denom = dot(rd, nz); + if cov < 1.0 && denom < -1e-4 { + let g = ro + rd * ((hz - dot(ro, nz)) / denom); + let gp = tip + unit * model_to_plane(g, f); + let inside = clamp(min(layer.mb.x - abs(gp.x), layer.mb.y - abs(gp.y)) + 0.5, 0.0, 1.0); + if inside > 0.0 { + let dropped = 1.0 - model_soft_shadow(g, l, lo, hi); + let contact = 1.0 - smoothstep(0.0, MODEL_CONTACT_RADIUS, sd_model(g)); + shadow = inside * max(dropped * MODEL_SHADOW_ALPHA, contact * MODEL_CONTACT_ALPHA); + } + } + + let a = cov * layer.color.a; + return vec4(rgb * a, a + (1.0 - a) * shadow * layer.color.a); // premultiplie, ombre noire +} + +// ---- Impact du clic (mode 16) ---- +// Port ligne pour ligne de `cursor_impact` (HLSL), dont les commentaires font foi. +fn cursor_impact(local: vec2) -> vec4 { + let r = quad_inverse(local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, layer.mb.x); + let pf = layer.dst_prev.xy + r.xy * layer.dst_prev.zw; + if r.z < 0.5 || any(pf < vec2(0.0)) || any(pf > vec2(1.0)) { + return vec4(0.0); } - return acc; + let d = length(r.xy * 2.0 - vec2(1.0)); + let x = abs(d - layer.src.x); + let aa = layer.radius_px; + let ring = layer.src.z * (1.0 - smoothstep(layer.src.y - aa, layer.src.y + aa, x)); + let halo = layer.src.w * exp(-x * x / (6.0 * layer.src.y * layer.src.y + aa * aa)); + let spot = layer.mb.z * exp(-d * d / max(layer.mb.y * layer.mb.y, 1e-6)); + let shade = clamp(halo + spot, 0.0, 1.0); + let a = ring + (1.0 - ring) * shade; + return vec4(layer.color.rgb * ring, a) * layer.color.a; // premultiplie, ombre noire } @fragment @@ -481,15 +721,15 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { // fx.x = 0 mosaique / 1 flou ; fx.y = taille de bloc px (mosaique) ou // rayon px (flou) ; fx.z = 0 rectangle / 1 ovale ; fx.w = 1 si teinte ; // mb.z = 1 si le masque est un quad incline (coins TL, TR dans dst_prev, - // BR, BL dans src_prev). + // BR, BL dans src_prev) ; mb.w = 1 si son warp est projectif. var n = i.local / max(layer.quad_px, vec2(1e-6)); // Ecran incline : le masque est warpe comme le contenu qu'il cache // (`FrameGeometry::privacy_mask`), par le meme inverse que le mode 8. // Bord net, marge de 2 % comprise : un fondu rendrait le masque en // partie transparent SUR la zone a cacher. if layer.mb.z > 0.5 { - let wq = quad_inverse_bilinear(i.local, layer.dst_prev.xy, layer.dst_prev.zw, - layer.src_prev.xy, layer.src_prev.zw); + let wq = quad_inverse(i.local, layer.dst_prev.xy, layer.dst_prev.zw, + layer.src_prev.xy, layer.src_prev.zw, layer.mb.w); if wq.z < 0.5 { return vec4(0.0, 0.0, 0.0, 0.0); } @@ -563,13 +803,14 @@ 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 bilineaire inverse. + // 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). // // 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 // [0,1]. Un clip la-dessus serait vrai partout et n'afficherait rien. - let r = quad_inverse_bilinear( - i.local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, + let r = quad_inverse( + i.local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, layer.dst_prev.w, ); if r.z < 0.5 { return vec4(0.0, 0.0, 0.0, 0.0); // hors du quad projete @@ -605,6 +846,11 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { 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)); } + if layer.dst_prev.w > 0.5 { + // La lampe de la camera reelle : le cote proche un peu plus clair. + let shade = 1.0 + layer.color.x * (rs.x - 0.5) + layer.color.y * (rs.y - 0.5); + tilt_rgb = clamp(tilt_rgb * shade, vec3(0.0), vec3(1.0)); + } // L'alpha est cette couverture, pas `color.a` : les draws du mode 8 laissent // `color` a zero, donc s'en servir rendrait un plan totalement transparent. return vec4(tilt_rgb * tilt_a, tilt_a); @@ -641,12 +887,9 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { || i.pout.y < layer.dst_prev.y || i.pout.y > layer.dst_prev.y + layer.dst_prev.w { return vec4(0.0, 0.0, 0.0, 0.0); } - // Volume (`mb.z` copies, `mb.xy` = extrusion en px) ; <= 1 = sprite plat, inchange. - if layer.mb.z > 1.5 { - return cursor_extruded(i.local); - } - let r = quad_inverse_bilinear( - i.local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, + // mb.x = 1 : warp projectif (camera reelle). + let r = quad_inverse( + i.local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, layer.mb.x, ); if r.z < 0.5 { return vec4(0.0, 0.0, 0.0, 0.0); @@ -660,8 +903,9 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { // Mode 14 -- CADRE DE FENETRE autour de l'ecran (barre de titre, trois pastilles, // filet), dessine SOUS lui. Meme warp que le mode 8 : le cadre est le quad de l'ecran // prolonge, il penche donc avec lui ; a plat le quad est un rect et le warp l'identite. - let r = quad_inverse_bilinear( - i.local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, + // src.x = 1 : warp projectif (camera reelle). + let r = quad_inverse( + i.local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, layer.src.x, ); if r.z < 0.5 { return vec4(0.0, 0.0, 0.0, 0.0); // hors du cadre projete @@ -689,6 +933,17 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { frame_rgb = mix(frame_rgb, vec3(0.157, 0.784, 0.251), disc_cov(q, vec2(3.0 * dx, bar * 0.5), dr)); let fa = cov * layer.color.a; return vec4(frame_rgb * fa, fa); // premultiplie + } else if layer.mode > 14.5 && layer.mode < 15.5 { + // Mode 15 -- curseur modelise (`cursor_model`). Clip « Clip to canvas » dans + // `dst_prev`, comme au mode 13. + if i.pout.x < layer.dst_prev.x || i.pout.x > layer.dst_prev.x + layer.dst_prev.z + || i.pout.y < layer.dst_prev.y || i.pout.y > layer.dst_prev.y + layer.dst_prev.w { + return vec4(0.0, 0.0, 0.0, 0.0); + } + return cursor_model(i.local); + } else if layer.mode > 15.5 && layer.mode < 16.5 { + // Mode 16 -- impact du clic sous le curseur modelise (`cursor_impact`). + return cursor_impact(i.local); } else { // Mode 2 — ombre portée (SDF d'un quad arrondi élargi de `fx.x`). let spread = layer.fx.x; diff --git a/crates/compositor/tests/compose_linux.rs b/crates/compositor/tests/compose_linux.rs index da4b31388..f7a93b593 100644 --- a/crates/compositor/tests/compose_linux.rs +++ b/crates/compositor/tests/compose_linux.rs @@ -372,76 +372,6 @@ fn compose_linux_curseur_sur_ecran_tilte() { ); } -/// Curseur en volume (`cursor.volume`, mode 13 avec `mb.z` copies) : parite avec -/// `cursor_volume_render.rs` (Windows). Sprite vert opaque sur fond magenta : a -/// volume 1 des flancs vert SOMBRE apparaissent autour du carre, sur ecran droit -/// (quad identite, repli bas-droite) comme incline ; la face avant, verte vive, -/// reste entiere. A volume 0 aucun pixel sombre : le chemin plat est intact. -#[test] -fn compose_linux_curseur_en_volume() { - if std::env::var("OPENSCREEN_LINUX_COMPOSE").is_err() || !Path::new(FIXTURE).is_file() { - eprintln!("compose_linux curseur en volume: opt-in. Skip."); - return; - } - const SPRITE: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAABAAAAAQBPJcTWAAAAGElEQVR4nGNk+MdAEmAhTfmohlENQ0kDAGoRATwbkCdPAAAAAElFTkSuQmCC"; - - let gpu = Gpu::create(false).expect("Gpu::create"); - let comp = Compositor::new_sized(&gpu, W, H).expect("Compositor::new_sized"); - let mut dec = Decoder::open(FIXTURE, &gpu).expect("Decoder::open"); - - let track_path = std::env::temp_dir().join("os_cursor_track_volume.json"); - std::fs::write( - &track_path, - r#"{"samples":[{"timeMs":3000,"cx":0.3,"cy":0.3,"cursorType":"arrow"}]}"#, - ) - .expect("write track"); - let track = CursorTrack::load(track_path.to_str().unwrap(), 0.0, 6.0).expect("CursorTrack::load"); - comp.set_cursor(track); - comp.set_cursor_time(Some(3.0)); - - let scene_json = |rotation: &str, volume: f32| { - format!( - r##"{{"clips":[],"layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rectangle","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}},"effects":{{"padding":0.2,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}},"background":{{"kind":"color","color":"#ff00ff"}},"zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":6,"scale":1.0,"focusX":0.5,"focusY":0.5,"rotation":{rotation}}}],"annotations":[],"cursor":{{"show":true,"size":4,"smoothing":0,"motionBlur":0,"clickBounce":0,"volume":{volume},"clipToBounds":false,"theme":"default","cursorSprites":{{"arrow":{{"path":"{SPRITE}","hotspotX":0.5,"hotspotY":0.5}}}}}},"cropByClip":[],"output":{{"width":1920,"height":1080,"fps":30}}}}"## - ) - }; - let bright = |p: &[u8]| p[1] > 180 && p[0] < 120 && p[2] < 120; - let dark_green = |p: &[u8]| p[1] > 40 && p[1] < 170 && p[0] < 40 && p[2] < 40; - - for rotation in ["null", "\"iso\""] { - let (w, h, flat, deep) = unsafe { - let sf = dec.seek_to(1.0).expect("Decoder::seek_to"); - let render = |json: String| { - let scene = Scene::from_json(&json).expect("scene json"); - comp.set_live_params(openscreen_compositor::compositor::live_params_from_scene( - &scene, - )); - comp.set_scene(Some(scene)); - comp.compose_frame(sf, sf, 90.0, &Cfg::c8()).expect("compose_frame"); - comp.readback_direct().expect("readback_direct") - }; - let (w, h, flat) = render(scene_json(rotation, 0.0)); - let (_, _, deep) = render(scene_json(rotation, 1.0)); - (w, h, flat, deep) - }; - let name = if rotation == "null" { "flat" } else { "iso" }; - write_ppm(&format!("compose_linux_cursor_volume_{name}"), w, h, &deep); - let count = |rgba: &[u8], f: &dyn Fn(&[u8]) -> bool| rgba.chunks_exact(4).filter(|p| f(p)).count(); - let (front0, front1) = (count(&flat, &bright), count(&deep, &bright)); - let (sides0, sides1) = (count(&flat, &dark_green), count(&deep, &dark_green)); - println!( - "compose_linux curseur en volume ({name}) : face avant {front0} -> {front1}, \ - flancs {sides0} -> {sides1}" - ); - assert!(front0 > 50, "{name}: curseur absent a volume 0 (n={front0})"); - assert!( - front1 * 100 >= front0 * 97, - "{name}: la face avant a perdu des pixels ({front0} -> {front1})" - ); - assert!(sides0 < 20, "{name}: des flancs a volume 0 ({sides0})"); - assert!(sides1 > 30, "{name}: pas de flancs a volume 1 ({sides1})"); - } -} - /// Curseur : sprite thematise (mode 7) dessine au centre. Sprite VERT (data URI /// PNG) distinct du fond sombre et de l'ecran, pour l'affirmer sans ambiguite. #[test] diff --git a/crates/compositor/tests/cursor_model_render.rs b/crates/compositor/tests/cursor_model_render.rs new file mode 100644 index 000000000..2d7eb9e32 --- /dev/null +++ b/crates/compositor/tests/cursor_model_render.rs @@ -0,0 +1,824 @@ +//! Le curseur modélisé (`cursor.model3d`, mode 15) rendu par le vrai compositeur D3D11 : chaque +//! état du thème par défaut, extrudé depuis son sprite. +//! +//! La source est une frame NV12 SYNTHÉTIQUE (une texture remplie ici, présentée comme une frame +//! D3D11VA) : rien à décoder, donc le test tourne sur toute machine qui a un GPU, sans variable +//! d'environnement. Sans adaptateur matériel, il se saute (« test saute »). +//! +//! ```powershell +//! $env:OPENSCREEN_CURSOR3D_OUT = "...\renders" # facultatif : un PNG par cas, et la planche +//! cargo test -p openscreen-compositor --test cursor_model_render -- --nocapture +//! # coût d'une frame 1080p, curseur 3D allumé contre éteint (opt-in) : +//! $env:OPENSCREEN_CURSOR3D_BENCH = "1" +//! cargo test -p openscreen-compositor --release --test cursor_model_render bench -- --nocapture +//! ``` + +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config::Cfg; +use openscreen_compositor::cursor::CursorTrack; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::ffi::AVFrame; +use openscreen_compositor::frame_geometry::{ + live_params_from_scene, plan_cursor, plan_frame, CursorPlacement, CursorPlanInput, + FrameGeometryInput, +}; +use openscreen_compositor::scene::Scene; +use windows::core::Interface; +use windows::Win32::Graphics::Direct3D11::{ + ID3D11Texture2D, D3D11_BIND_SHADER_RESOURCE, D3D11_CPU_ACCESS_WRITE, + D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_WRITE_DISCARD, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DYNAMIC, +}; +use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_NV12, DXGI_SAMPLE_DESC}; + +const SRC: (u32, u32) = (640, 360); +/// Instant rendu : en plein palier de la région de zoom (0..10 s). +const T: f32 = 2.0; +/// Creux de `regions::tap` : un clic à `T - CONTACT_S` montre le modèle posé à `T`. +const CONTACT_S: f32 = 0.0495; + +/// Les seize états du thème par défaut et leurs hotspots (`DEFAULT_CURSOR_SPRITES`, +/// `src/lib/cursor/cursorThemes.ts`). +const STATES: [(&str, [f32; 2]); 16] = [ + ("arrow", [0.119, 0.0874]), + ("text", [0.4375, 0.5333]), + ("pointer", [0.3893, 0.0032]), + ("crosshair", [0.4667, 0.4667]), + ("open-hand", [0.4375, 0.1781]), + ("closed-hand", [0.3889, 0.451]), + ("resize-ew", [0.4881, 0.4706]), + ("resize-ns", [0.5, 0.5]), + ("resize-nesw", [0.5, 0.5]), + ("resize-nwse", [0.5, 0.5]), + ("move", [0.4444, 0.4444]), + ("not-allowed", [0.5, 0.5]), + ("wait", [0.5, 0.5]), + ("app-starting", [0.05, 0.0537]), + ("help", [0.0515, 0.0572]), + ("up-arrow", [0.5, 0.069]), +]; + +/// Les états que les tests de rendu passent en revue : pointeurs, centrés, entre deux. +const TESTED: [&str; 6] = ["arrow", "pointer", "text", "open-hand", "resize-ew", "not-allowed"]; + +/// Les états dont le hotspot est au centre : ni tangage ni lacet. +fn is_centred(key: &str) -> bool { + matches!(key, "text" | "resize-ew" | "not-allowed") +} + +fn gpu() -> Option { + match Gpu::create(false) { + Ok(g) => Some(g), + Err(e) => { + eprintln!("pas de device D3D11 matériel ({e:#}) — test saute"); + None + } + } +} + +/// Deux teintes de contenu : un pixel identique sur les deux est du modèle opaque, un pixel qui +/// change est le contenu, son ombre ou une frange antialiasée. +#[derive(Clone, Copy)] +enum Tint { + Blue, + Orange, +} + +/// Une frame NV12 synthétique : `data[0]` = la texture, `data[1]` = tranche 0, comme une frame +/// D3D11VA (le seul contrat que `Compositor::nv12_srvs` lit). Une teinte moyenne striée de barres +/// plus sombres : rien d'aussi clair ni d'aussi sombre que le curseur, et surtout rien de NEUTRE, +/// si bien qu'un pixel gris est le curseur et un pixel teinté assombri, son ombre. +struct FakeFrame { + frame: Box, + _tex: ID3D11Texture2D, +} + +impl FakeFrame { + fn new(gpu: &Gpu, tint: Tint) -> FakeFrame { + let (w, h) = SRC; + let (u, v) = match tint { + Tint::Blue => (150, 120), + Tint::Orange => (100, 170), + }; + let desc = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_NV12, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DYNAMIC, + BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, + CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, + MiscFlags: 0, + }; + unsafe { + let mut tex: Option = None; + gpu.device.CreateTexture2D(&desc, None, Some(&mut tex)).expect("texture NV12"); + let tex = tex.expect("texture NV12"); + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + gpu.context.Map(&tex, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut m)).expect("Map"); + let pitch = m.RowPitch as usize; + let dst = m.pData as *mut u8; + for row in 0..h as usize { + for col in 0..w as usize { + let bar = row % 24 >= 8 && row % 24 < 12 && (col / 40) % 3 != 2; + *dst.add(row * pitch + col) = if bar { 90 } else { 150 }; + } + } + for row in 0..(h / 2) as usize { + for col in 0..w as usize { + let uv = (h as usize + row) * pitch + col; + *dst.add(uv) = if col % 2 == 0 { u } else { v }; + } + } + gpu.context.Unmap(&tex, 0); + let mut frame: Box = Box::new(std::mem::zeroed()); + frame.data[0] = tex.as_raw() as *mut u8; + frame.data[1] = std::ptr::null_mut(); + frame.width = w as i32; + frame.height = h as i32; + FakeFrame { frame, _tex: tex } + } + } + + fn as_ptr(&self) -> *const AVFrame { + &*self.frame as *const AVFrame + } +} + +/// Les seize sprites livrés, au format `cursorSprites` de la scène. +fn sprites_json() -> String { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../public/cursors/default") + .to_string_lossy() + .replace('\\', "/"); + let entries: Vec = STATES + .iter() + .map(|(key, [hx, hy])| format!(r#""{key}":{{"path":"{dir}/{key}.png","hotspotX":{hx},"hotspotY":{hy}}}"#)) + .collect(); + format!("{{{}}}", entries.join(",")) +} + +/// `model3d` : `None` = clé absente (payload d'avant le réglage). +fn scene_json(rotation: &str, model3d: Option, theme: &str, motion_blur: f32, size: f32) -> String { + let model3d = model3d.map(|m| format!(r#","model3d":{m}"#)).unwrap_or_default(); + let sprites = sprites_json(); + format!( + r##"{{"clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, + "effects":{{"padding":0.2,"blur":false,"shadow":0,"roundnessFrac":0.03,"motionBlur":0}}, + "background":{{"kind":"gradient","angleDeg":135,"stops":["#5b6ee1","#e8a0bf"]}}, + "zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":10,"scale":1,"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":{rotation}}}], + "annotations":[], + "cursor":{{"show":true,"size":{size},"smoothing":0,"motionBlur":{motion_blur},"clickBounce":2.5{model3d},"clipToBounds":false,"theme":"{theme}", + "cursorSprites":{sprites}}}, + "cropByClip":[null], + "output":{{"width":1280,"height":720,"fps":30}}}}"## + ) +} + +/// La même scène, curseur masqué : la référence « sans curseur ». +fn hidden_json(rotation: &str) -> String { + scene_json(rotation, Some(true), "default", 0.0, 3.0).replace(r#""show":true"#, r#""show":false"#) +} + +/// Piste curseur écrite dans un sidecar temporaire : `(t, x, y, clic)`, dans l'état `kind` +/// (`None` = pas de clé, la flèche). +fn track_as(name: &str, kind: Option<&str>, samples: &[(f32, f32, f32, bool)]) -> CursorTrack { + let kind = kind.map(|k| format!(r#","cursorType":"{k}""#)).unwrap_or_default(); + let body: Vec = samples + .iter() + .map(|&(t, x, y, click)| { + let click = if click { r#","interactionType":"click""# } else { "" }; + format!(r#"{{"timeMs":{},"cx":{x},"cy":{y}{click}{kind}}}"#, t * 1000.0) + }) + .collect(); + let path = std::env::temp_dir().join(format!("os_cursor_model_{name}.json")); + std::fs::write(&path, format!(r#"{{"samples":[{}]}}"#, body.join(","))).expect("sidecar"); + CursorTrack::load(path.to_str().unwrap(), 0.0, 10.0).expect("piste curseur") +} + +fn track(name: &str, samples: &[(f32, f32, f32, bool)]) -> CursorTrack { + track_as(name, None, samples) +} + +/// Une piste immobile en (0.45, 0.45) dans l'état `kind`, avec ou sans clic au creux du contact +/// à `T`. +fn resting_as(name: &str, kind: Option<&str>, click: bool) -> CursorTrack { + resting_at(name, kind, click, 0.45) +} + +/// `resting_as`, en (`at`, `at`) : 0,5 est le centre de l'écran, sur l'axe de la caméra. +fn resting_at(name: &str, kind: Option<&str>, click: bool, at: f32) -> CursorTrack { + let mut s = vec![(0.0, at, at, false), (T - CONTACT_S - 0.2, at, at, false)]; + if click { + s.push((T - CONTACT_S, at, at, true)); + } + s.push((9.0, at, at, false)); + track_as(name, kind, &s) +} + +fn resting(name: &str, click: bool) -> CursorTrack { + resting_as(name, None, click) +} + +fn cfg() -> Cfg { + let mut cfg = Cfg::c8(); + cfg.bg_blur = false; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = true; + cfg.mblur_n = 1; + cfg.shadow = false; + cfg +} + +/// Ce que la frame dit du curseur : le pixel du contenu qu'il vise et son unité en px. +#[derive(Clone, Copy)] +struct Probe { + tip: [f32; 2], + unit: f32, +} + +fn render(comp: &Compositor, screen: &FakeFrame, json: &str, track: &CursorTrack) -> (Vec, Probe) { + let (rgba, probe) = render_any(comp, screen, json, track); + (rgba, probe.expect("un curseur à dessiner")) +} + +/// `render`, sans exiger de curseur (scène au curseur masqué). +fn render_any( + comp: &Compositor, + screen: &FakeFrame, + json: &str, + track: &CursorTrack, +) -> (Vec, Option) { + let scene = Scene::from_json(json).expect("scène valide"); + let mut live = live_params_from_scene(&scene); + live.has_webcam = false; + comp.set_live_params(live); + comp.set_has_webcam(false); + comp.set_scene(Some(scene.clone())); + comp.set_cursor(track.clone()); + comp.set_cursor_time(Some(T)); + comp.set_timeline_time(Some(T)); + let cfg = cfg(); + let (w, h, rgba) = unsafe { + comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback") + }; + let render_px = [w as f32, h as f32]; + let src = [SRC.0 as f32, SRC.1 as f32]; + let g = plan_frame(&FrameGeometryInput { + render_px, + screen_tex_px: src, + screen_visible_px: src, + webcam_visible_px: src, + u_max: 1.0, + v_max: 1.0, + frame: 0.0, + cfg: &cfg, + live, + scene: Some(&scene), + cursor: Some(track), + timeline_t_override: Some(T), + programme_time: None, + }); + let Some(plan) = plan_cursor( + &g, + &CursorPlanInput { + render_px, + u_max: 1.0, + v_max: 1.0, + cfg: &cfg, + live, + scene: Some(&scene), + track, + t: T, + }, + ) else { + return (rgba, None); + }; + let (tip, scale) = match plan.placement { + CursorPlacement::Tilted { plane_pt, quad, center_px, .. } => { + let (x, y) = quad.point_px(plane_pt[0], plane_pt[1]); + ([center_px[0] + x, center_px[1] + y], quad.scale) + } + CursorPlacement::Upright { center } => ([center[0] * render_px[0], center[1] * render_px[1]], 1.0), + }; + (rgba, Some(Probe { tip, unit: plan.size_px * scale })) +} + +fn px(rgba: &[u8], x: i32, y: i32) -> [u8; 4] { + let i = ((y * 1280 + x) * 4) as usize; + [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]] +} + +fn luma(p: [u8; 4]) -> f32 { + 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32 +} + +fn is_neutral(p: [u8; 4]) -> bool { + p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]) < 12 +} + +fn is_white(p: [u8; 4]) -> bool { + p[0] > 200 && p[1] > 200 && p[2] > 200 +} + +fn is_black(p: [u8; 4]) -> bool { + p[0] < 45 && p[1] < 45 && p[2] < 45 +} + +/// Ce qui distingue une frame avec flèche de la même sans : les pixels de la flèche (neutres, +/// quand le contenu est bleu) et ceux d'ombre (le bleu du contenu, assombri), avec leurs +/// centroïdes et la distance de l'ombre la plus proche de la pointe. +struct Split { + body: usize, + white: usize, + black: usize, + shadow: usize, + body_c: [f32; 2], + shadow_c: [f32; 2], + /// Distance (px) du pixel d'ombre le plus proche de l'apex de la silhouette. + shadow_near_apex: f32, +} + +fn split(with: &[u8], without: &[u8], apex: [f32; 2]) -> Split { + let mut s = Split { + body: 0, + white: 0, + black: 0, + shadow: 0, + body_c: [0.0; 2], + shadow_c: [0.0; 2], + shadow_near_apex: f32::MAX, + }; + let body = |x: i32, y: i32| { + (0..1280).contains(&x) + && (0..720).contains(&y) + && px(with, x, y) != px(without, x, y) + && is_neutral(px(with, x, y)) + }; + for y in 0..720 { + for x in 0..1280 { + let (a, b) = (px(with, x, y), px(without, x, y)); + if a == b { + continue; + } + // La frange antialiasée de la silhouette mêle la flèche au contenu : ni corps ni + // ombre, on l'écarte (deux pixels autour du corps). + let fringe = (-2..=2).any(|dy| (-2..=2).any(|dx| body(x + dx, y + dy))); + if is_neutral(a) { + s.body += 1; + s.white += is_white(a) as usize; + s.black += is_black(a) as usize; + s.body_c[0] += x as f32; + s.body_c[1] += y as f32; + } else if !fringe && luma(a) < luma(b) - 6.0 { + s.shadow += 1; + s.shadow_c[0] += x as f32; + s.shadow_c[1] += y as f32; + let d = (x as f32 - apex[0]).hypot(y as f32 - apex[1]); + s.shadow_near_apex = s.shadow_near_apex.min(d); + } + } + } + for (c, n) in [(&mut s.body_c, s.body), (&mut s.shadow_c, s.shadow)] { + *c = [c[0] / n.max(1) as f32, c[1] / n.max(1) as f32]; + } + s +} + +/// Les pixels OPAQUES du curseur : identiques sur les deux teintes de contenu, différents du +/// contenu nu. +fn opaque_mask(on_blue: &[u8], on_orange: &[u8], bare_blue: &[u8]) -> Vec { + (0..1280 * 720) + .map(|i| { + let (a, b, c) = (&on_blue[i * 4..i * 4 + 3], &on_orange[i * 4..i * 4 + 3], &bare_blue[i * 4..i * 4 + 3]); + a == b && a != c + }) + .collect() +} + +fn iou(a: &[bool], b: &[bool]) -> f32 { + let inter = a.iter().zip(b).filter(|(x, y)| **x && **y).count(); + let union = a.iter().zip(b).filter(|(x, y)| **x || **y).count(); + inter as f32 / union.max(1) as f32 +} + +/// Part des pixels de `a` qui ont un pixel de `b` à `r` px au plus (norme max) : l'accord de deux +/// silhouettes à la perspective près. +fn within(a: &[bool], b: &[bool], r: i32) -> f32 { + let near = |i: usize| { + let (x, y) = ((i % 1280) as i32, (i / 1280) as i32); + (-r..=r).any(|dy| { + (-r..=r).any(|dx| { + let (x, y) = (x + dx, y + dy); + (0..1280).contains(&x) && (0..720).contains(&y) && b[(y * 1280 + x) as usize] + }) + }) + }; + let hits: Vec = (0..a.len()).filter(|&i| a[i]).collect(); + hits.iter().filter(|&&i| near(i)).count() as f32 / hits.len().max(1) as f32 +} + +/// Parts des pixels sombres, clairs et rouges parmi ceux du masque qui tombent dans l'une des +/// trois classes : les demi-teintes du chanfrein n'y comptent pas. +fn palette(rgba: &[u8], mask: &[bool]) -> [f32; 3] { + let mut c = [0usize; 3]; + for (i, _) in mask.iter().enumerate().filter(|(_, m)| **m) { + let p = [rgba[i * 4] as i32, rgba[i * 4 + 1] as i32, rgba[i * 4 + 2] as i32]; + c[0] += (p.iter().max().unwrap() < &70) as usize; + c[1] += (p.iter().min().unwrap() > &150) as usize; + c[2] += (p[0] > p[1] + 60 && p[0] > p[2] + 60) as usize; + } + let n = c.iter().sum::().max(1) as f32; + c.map(|k| k as f32 / n) +} + +fn out_dir() -> Option { + let dir = std::env::var("OPENSCREEN_CURSOR3D_OUT").ok()?; + std::fs::create_dir_all(&dir).expect("dossier de sortie"); + Some(dir) +} + +fn save(name: &str, rgba: &[u8]) { + let Some(dir) = out_dir() else { return }; + let path = format!("{dir}/{name}.png"); + image::RgbaImage::from_raw(1280, 720, rgba.to_vec()) + .expect("dimensions du readback") + .save(&path) + .unwrap_or_else(|e| panic!("écriture {path} : {e}")); + println!("écrit {path}"); +} + +#[test] +fn the_modelled_arrow_stands_on_the_screen_and_casts_its_shadow() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("compositor"); + let screen = FakeFrame::new(&gpu, Tint::Blue); + let still = resting("still", false); + let clicked = resting("clicked", true); + + for (name, rotation) in [("flat", "null"), ("iso", r#""iso""#)] { + let bare = render_any(&comp, &screen, &hidden_json(rotation), &still).0; + let (hover, p) = render(&comp, &screen, &scene_json(rotation, Some(true), "default", 0.0, 3.0), &still); + let (touch, q) = + render(&comp, &screen, &scene_json(rotation, Some(true), "default", 0.0, 3.0), &clicked); + save(&format!("{name}-hover"), &hover); + save(&format!("{name}-touch"), &touch); + // L'apex de la silhouette : le filet blanc dépasse le hotspot vers le haut-gauche. + let apex = |p: &Probe| [p.tip[0] - 0.03 * p.unit, p.tip[1] - 0.07 * p.unit]; + let (a, b) = (split(&hover, &bare, apex(&p)), split(&touch, &bare, apex(&q))); + println!( + "{name} : unité {:.1} px, pointe {:?} | en l'air : corps {} (blanc {}, noir {}), ombre {} \ + à {:.1} px de l'apex, centroïdes {:?} / {:?} | posée : ombre à {:.1} px, centroïdes {:?} / {:?}", + p.unit, p.tip, a.body, a.white, a.black, a.shadow, a.shadow_near_apex, a.body_c, a.shadow_c, + b.shadow_near_apex, b.body_c, b.shadow_c + ); + + // La pointe : le hotspot est la pointe de l'incrustation noire. Juste sous elle, dans le + // corps, c'est noir ; juste au-dessus-gauche, le filet blanc ; loin au-dessus-gauche, le + // contenu, intact (l'ombre part vers le bas-droite). + let u = p.unit; + let at = |dx: f32, dy: f32| px(&hover, (p.tip[0] + dx * u) as i32, (p.tip[1] + dy * u) as i32); + assert!(is_black(at(0.07, 0.3)), "{name}: pas d'incrustation noire sous la pointe : {:?}", at(0.07, 0.3)); + assert!(is_white(at(-0.035, -0.02)) || is_white(at(-0.04, 0.05)), "{name}: pas de filet blanc à la pointe"); + let far = [(p.tip[0] - 0.3 * u) as i32, (p.tip[1] - 0.3 * u) as i32]; + assert_eq!(px(&hover, far[0], far[1]), px(&bare, far[0], far[1]), "{name}: le haut-gauche a bougé"); + + // Corps blanc ET noir, de la taille d'une flèche : 0,35 u² de silhouette dans le PNG. + let area = 0.35 * u * u; + assert!(a.white as f32 > 0.15 * area && a.black as f32 > 0.25 * area, "{name}: matières absentes"); + assert!((a.body as f32) < 1.5 * area && (a.body as f32) > 0.7 * area, "{name}: taille {} pour {area}", a.body); + + // L'ombre : présente, du côté opposé à la lumière (bas-droite de la flèche). + assert!(a.shadow as f32 > 0.1 * area, "{name}: pas d'ombre en l'air ({})", a.shadow); + assert!( + a.shadow_c[0] > a.body_c[0] && a.shadow_c[1] > a.body_c[1], + "{name}: l'ombre n'est pas en bas à droite ({:?} / {:?})", + a.shadow_c, + a.body_c + ); + // Posée, l'ombre rejoint la pointe et se resserre sous la flèche. + assert!(b.shadow_near_apex < 0.1 * u, "{name}: posée, l'ombre est à {:.1} px de l'apex", b.shadow_near_apex); + assert!(a.shadow_near_apex > 0.2 * u, "{name}: en l'air, l'ombre touche l'apex ({:.1} px)", a.shadow_near_apex); + let gap = |s: &Split| (s.shadow_c[0] - s.body_c[0]).hypot(s.shadow_c[1] - s.body_c[1]); + // (La queue, relevée par la pression, garde son ombre au loin : le centroïde ne se + // rapproche que d'une fraction de la hauteur.) + assert!(gap(&b) < gap(&a) - 0.1 * u, "{name}: l'ombre ne se rapproche pas au contact ({} / {})", gap(&b), gap(&a)); + // La pointe ne bouge pas quand la flèche descend (elle est sur le rayon de vue). + assert!((p.tip[0] - q.tip[0]).abs() < 0.01 && (p.tip[1] - q.tip[1]).abs() < 0.01); + } +} + +/// Chaque état garde son art et sa silhouette : posé au centre de l'écran (sur l'axe de la +/// caméra, donc vu de face), le modèle couvre ce que couvre le sprite plat, avec ses couleurs, et +/// il porte une ombre en l'air. +#[test] +fn every_state_keeps_its_art_and_its_footprint() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("compositor"); + let (blue, orange) = (FakeFrame::new(&gpu, Tint::Blue), FakeFrame::new(&gpu, Tint::Orange)); + const SIZE: f32 = 5.0; + let bare = render_any(&comp, &blue, &hidden_json("null"), &resting("bare", false)).0; + let mut failures = Vec::new(); + for key in TESTED { + let still = resting_at(&format!("art-still-{key}"), Some(key), false, 0.5); + let clicked = resting_at(&format!("art-clicked-{key}"), Some(key), true, 0.5); + let (on3d, flat) = + (scene_json("null", Some(true), "default", 0.0, SIZE), scene_json("null", Some(false), "default", 0.0, SIZE)); + let (touch, p) = render(&comp, &blue, &on3d, &clicked); + let touch_b = render(&comp, &orange, &on3d, &clicked).0; + let (hover, _) = render(&comp, &blue, &on3d, &still); + let sprite = render(&comp, &blue, &flat, &still).0; + let sprite_b = render(&comp, &orange, &flat, &still).0; + save(&format!("art-{key}-touch"), &touch); + save(&format!("art-{key}-sprite"), &sprite); + + let m3d = opaque_mask(&touch, &touch_b, &bare); + let m2d = opaque_mask(&sprite, &sprite_b, &bare); + let overlap = iou(&m3d, &m2d); + let (near3d, near2d) = (within(&m3d, &m2d, 2), within(&m2d, &m3d, 2)); + let (pal3d, pal2d) = (palette(&touch, &m3d), palette(&sprite, &m2d)); + let area = m2d.iter().filter(|m| **m).count() as f32; + // L'ombre en l'air : ce qui a changé, n'est pas du modèle, et s'est assombri. + let shadow = (0..1280 * 720) + .filter(|&i| { + let (a, b) = (&hover[i * 4..i * 4 + 4], &bare[i * 4..i * 4 + 4]); + a != b + && !is_neutral([a[0], a[1], a[2], 255]) + && luma([a[0], a[1], a[2], 255]) < luma([b[0], b[1], b[2], 255]) - 6.0 + }) + .count() as f32; + println!( + "{key} : unité {:.1} px, IoU {overlap:.3}, à 2 px près {near3d:.3} / {near2d:.3}, sprite {area} px, \ + palette 3D {pal3d:?} / sprite {pal2d:?}, ombre {shadow} px", + p.unit + ); + // De face et à plat, un curseur centré est son sprite, à la perspective près : le dessus + // est à une épaisseur du plan, donc agrandi de ~3 % (1 à 2 px, ce qui suffit à baisser + // l'IoU d'un trait fin comme le I ; à 2 px près, les silhouettes coïncident). Un pointeur est basculé de 28° + // (tangage de la pression) : raccourci, et le flanc de sa queue relevée se voit, de la + // couleur de son bord. + let (min_iou, min_near, palette_tol) = + if is_centred(key) { (0.75, 0.98, 0.07) } else { (0.6, 0.9, 0.2) }; + if overlap <= min_iou || near3d < min_near || near2d < min_near { + failures.push(format!("{key}: la silhouette s'écarte du sprite (IoU {overlap}, {near3d} / {near2d})")); + } + for (c, (a, b)) in ["sombre", "clair", "rouge"].iter().zip(pal3d.iter().zip(pal2d.iter())) { + if (a - b).abs() >= palette_tol { + failures.push(format!("{key}: part {c} {a} contre {b} dans le sprite")); + } + } + if shadow <= 0.3 * area { + failures.push(format!("{key}: pas d'ombre en l'air ({shadow} px)")); + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + +/// Incliné et à plat, le modèle n'a pas la même silhouette : le plan l'emporte avec lui. +#[test] +fn the_tilt_turns_every_state_with_the_screen() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("compositor"); + let screen = FakeFrame::new(&gpu, Tint::Blue); + for key in TESTED { + let still = resting_as(&format!("tilt-{key}"), Some(key), false); + let mask = |rotation: &str| -> (Vec, Probe) { + let (rgba, p) = render(&comp, &screen, &scene_json(rotation, Some(true), "default", 0.0, 3.0), &still); + // Silhouette relative au hotspot, sur une fenêtre de ±1,1 unité. + let r = (1.1 * p.unit) as i32; + let mut m = Vec::new(); + for dy in -r..r { + for dx in -r..r { + let (x, y) = (p.tip[0] as i32 + dx, p.tip[1] as i32 + dy); + m.push((0..1280).contains(&x) && (0..720).contains(&y) && is_neutral(px(&rgba, x, y))); + } + } + (m, p) + }; + let (flat, pf) = mask("null"); + let (iso, pi) = mask(r#""iso""#); + let n = flat.len().min(iso.len()); + let differ = (0..n).filter(|&k| flat[k] != iso[k]).count(); + println!("{key} : {differ} px diffèrent (unités {:.1} / {:.1})", pf.unit, pi.unit); + assert!(differ as f32 > 0.05 * pf.unit * pf.unit, "{key}: le modèle ne suit pas l'inclinaison ({differ})"); + } +} + +/// Réglage éteint : la frame est celle du sprite plat, quelle que soit la façon de le dire (clé +/// absente, `false`, ou allumé sur un autre thème), et quel que soit l'état. +#[test] +fn without_the_model_the_cursor_renders_the_flat_sprite() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("compositor"); + let screen = FakeFrame::new(&gpu, Tint::Blue); + let still = resting("flat-sprite", false); + let clicked = resting("flat-sprite-clicked", true); + let text = resting_as("flat-sprite-text", Some("text"), false); + for rotation in ["null", r#""iso""#] { + let absent = render(&comp, &screen, &scene_json(rotation, None, "default", 0.0, 3.0), &still).0; + let off = render(&comp, &screen, &scene_json(rotation, Some(false), "default", 0.0, 3.0), &still).0; + let other = render(&comp, &screen, &scene_json(rotation, Some(true), "other", 0.0, 3.0), &still).0; + let on = render(&comp, &screen, &scene_json(rotation, Some(true), "default", 0.0, 3.0), &still).0; + let off_click = render(&comp, &screen, &scene_json(rotation, Some(false), "default", 0.0, 3.0), &clicked).0; + if let Ok(dir) = std::env::var("OPENSCREEN_CURSOR3D_FLAT_REF") { + // Comparaison à des frames rendues par un commit de base : le même test, lancé + // là-bas, écrit les références ; lancé ici, il les relit. La frame plate, et celle du + // modèle au repos (sans clic), et le sprite plat au creux d'un clic. + let preset = if rotation == "null" { "flat" } else { "iso" }; + for (kind, frame) in [("flat", &absent), ("model", &on), ("flat-click", &off_click)] { + let name = format!("{dir}/{kind}-{preset}.rgba"); + match std::fs::read(&name) { + Ok(before) => assert!(&before == frame, "{rotation}: la frame {kind} diffère de la référence"), + Err(_) => std::fs::write(&name, frame).expect("écriture de la référence"), + } + } + } + assert!(absent == off, "{rotation}: model3d=false a changé la frame"); + assert!(absent == other, "{rotation}: un thème sans modèle a changé la frame"); + assert!(absent != on, "{rotation}: le réglage allumé ne change rien"); + let text_off = render(&comp, &screen, &scene_json(rotation, Some(false), "default", 0.0, 3.0), &text).0; + let text_other = render(&comp, &screen, &scene_json(rotation, Some(true), "other", 0.0, 3.0), &text).0; + let text_on = render(&comp, &screen, &scene_json(rotation, Some(true), "default", 0.0, 3.0), &text).0; + assert!(text_off == text_other, "{rotation}: le I d'un autre thème n'est plus plat"); + assert!(text_off != text_on, "{rotation}: le I du thème par défaut reste plat"); + assert!(text_off != off, "{rotation}: l'état n'a pas changé le sprite"); + } +} + +/// Le lacet : en mouvement vers la droite, la flèche tourne sa pointe vers la droite ; un +/// curseur centré, lui, ne tourne pas. +#[test] +fn a_moving_pointer_turns_towards_its_motion_and_a_centred_cursor_does_not() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("compositor"); + let screen = FakeFrame::new(&gpu, Tint::Blue); + let json = scene_json(r#""iso""#, Some(true), "default", 0.0, 3.0); + // En T, la piste mobile passe en x = 0,2 + 0,6 × T / (T + 1) = 0,6 : la piste immobile y est. + let moving_as = |key: Option<&str>| { + track_as(&format!("moving-{}", key.unwrap_or("arrow")), key, &[(0.0, 0.2, 0.45, false), (T + 1.0, 0.8, 0.45, false)]) + }; + let still_as = + |key: Option<&str>| track_as(&format!("still-yaw-{}", key.unwrap_or("arrow")), key, &[(0.0, 0.6, 0.45, false), (9.0, 0.6, 0.45, false)]); + + let (rgba, p) = render(&comp, &screen, &json, &moving_as(None)); + save("iso-moving", &rgba); + // Au repos la queue descend à droite de la pointe ; tournée vers la droite, elle part vers + // la gauche : l'extrémité de la queue (bas du corps) est donc moins à droite qu'au repos. + let (rest, r) = render(&comp, &screen, &json, &still_as(None)); + let tail_x = |rgba: &[u8], p: &Probe| { + let y = (p.tip[1] + 0.8 * p.unit) as i32; + let xs: Vec = (0..1280).filter(|&x| is_neutral(px(rgba, x, y))).collect(); + xs.iter().sum::() as f32 / xs.len().max(1) as f32 - p.tip[0] + }; + let (moving_x, rest_x) = (tail_x(&rgba, &p), tail_x(&rest, &r)); + println!("queue : {moving_x:.1} px en mouvement, {rest_x:.1} px au repos"); + assert!(moving_x < rest_x - 0.05 * p.unit, "la flèche ne tourne pas vers son mouvement"); + + // Le redimensionnement horizontal, même mouvement : même silhouette qu'immobile. Sur un écran + // à plat, que le mouvement du curseur n'incline pas : les deux pistes s'y croisent au pixel. + let flat = scene_json("null", Some(true), "default", 0.0, 3.0); + let silhouette = |rgba: &[u8]| -> Vec { (0..1280 * 720).map(|i| is_neutral(px(rgba, i % 1280, i / 1280))).collect() }; + let pair = |key: Option<&str>| { + let (moved, pm) = render(&comp, &screen, &flat, &moving_as(key)); + let (held, ph) = render(&comp, &screen, &flat, &still_as(key)); + assert!((pm.tip[0] - ph.tip[0]).abs() < 0.01, "les deux pistes ne se croisent pas en T : {:?} / {:?}", pm.tip, ph.tip); + iou(&silhouette(&moved), &silhouette(&held)) + }; + let (same, arrow_same) = (pair(Some("resize-ew")), pair(None)); + println!("silhouettes mobile/immobile : redimensionnement {same:.3}, flèche {arrow_same:.3}"); + assert!(same > 0.97, "le redimensionnement tourne avec son mouvement ({same})"); + assert!(arrow_same < same - 0.05, "la flèche devrait, elle, tourner ({arrow_same})"); +} + +/// La traînée de flou de mouvement : des copies du modèle, pas un sprite plat. +#[test] +fn the_motion_blur_trail_draws_modelled_copies() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("compositor"); + let screen = FakeFrame::new(&gpu, Tint::Blue); + let moving = track("trail", &[(0.0, 0.2, 0.45, false), (T + 1.0, 0.8, 0.45, false)]); + let (rgba, _) = render(&comp, &screen, &scene_json("null", Some(true), "default", 1.0, 3.0), &moving); + let (flat, _) = render(&comp, &screen, &scene_json("null", Some(false), "default", 1.0, 3.0), &moving); + save("flat-trail", &rgba); + assert!(rgba != flat, "la traînée 3D est celle du sprite plat"); +} + +/// Planches à regarder (opt-in, `OPENSCREEN_CURSOR3D_OUT`) : les seize états en l'air sur un +/// écran à plat, chacun à côté de son sprite plat ; la flèche, la main et le I posés sur un écran +/// incliné ; et les grandes flèches (taille 8) qui servent à la comparaison avec le modèle +/// analytique d'avant. +#[test] +fn contact_sheets() { + let Some(dir) = out_dir() else { + eprintln!("OPENSCREEN_CURSOR3D_OUT absent — saute"); + return; + }; + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("compositor"); + let screen = FakeFrame::new(&gpu, Tint::Blue); + const CELL: u32 = 300; + let crop = |rgba: &[u8], p: Probe| { + let img = image::RgbaImage::from_raw(1280, 720, rgba.to_vec()).expect("readback"); + let (x, y) = ((p.tip[0] - 0.7 * p.unit).max(0.0) as u32, (p.tip[1] - 0.7 * p.unit).max(0.0) as u32); + image::imageops::crop_imm(&img, x, y, CELL, CELL).to_image() + }; + + let mut sheet = image::RgbaImage::new(8 * CELL, 4 * CELL); + for (i, (key, _)) in STATES.iter().enumerate() { + let still = resting_as(&format!("sheet-{key}"), Some(key), false); + let (flat, p) = render(&comp, &screen, &scene_json("null", Some(false), "default", 0.0, 5.0), &still); + let (hover, q) = render(&comp, &screen, &scene_json("null", Some(true), "default", 0.0, 5.0), &still); + let (col, row) = ((i % 4) as u32 * 2, (i / 4) as u32); + image::imageops::overlay(&mut sheet, &crop(&flat, p), (col * CELL) as i64, (row * CELL) as i64); + image::imageops::overlay(&mut sheet, &crop(&hover, q), ((col + 1) * CELL) as i64, (row * CELL) as i64); + } + sheet.save(format!("{dir}/states-hover-flat.png")).expect("planche"); + + let mut tilted = image::RgbaImage::new(3 * CELL, 2 * CELL); + for (i, key) in ["arrow", "pointer", "text"].iter().enumerate() { + let clicked = resting_as(&format!("sheet-touch-{key}"), Some(key), true); + for (row, rotation) in ["null", r#""iso""#].iter().enumerate() { + let (touch, p) = render(&comp, &screen, &scene_json(rotation, Some(true), "default", 0.0, 5.0), &clicked); + image::imageops::overlay(&mut tilted, &crop(&touch, p), i as i64 * CELL as i64, row as i64 * CELL as i64); + } + } + tilted.save(format!("{dir}/states-touch-flat-and-iso.png")).expect("planche"); + + let (still, clicked) = (resting("big-still", false), resting("big-clicked", true)); + for (name, rotation) in [("flat", "null"), ("iso", r#""iso""#)] { + for (case, tr) in [("hover", &still), ("touch", &clicked)] { + let (rgba, _) = render(&comp, &screen, &scene_json(rotation, Some(true), "default", 0.0, 8.0), tr); + save(&format!("big-{name}-{case}"), &rgba); + } + } +} + +/// Coût d'une frame 1080p, curseur 3D allumé contre éteint (opt-in, à lancer en release). Le +/// readback synchronise chaque frame (sans lui on ne mesurerait que la mise en file) et pèse +/// autant des deux côtés : on alterne les deux réglages et on garde le meilleur de cinq passes. +/// `OPENSCREEN_CURSOR3D_BENCH=warp` mesure le backend logiciel (WARP) au lieu du GPU. +#[test] +fn bench_the_modelled_cursor_at_1080p() { + let Ok(which) = std::env::var("OPENSCREEN_CURSOR3D_BENCH") else { + eprintln!("OPENSCREEN_CURSOR3D_BENCH absent — saute"); + return; + }; + let gpu = if which == "warp" { + Gpu::create_backend(openscreen_compositor::d3d::Backend::Cpu, false).expect("WARP") + } else { + let Some(gpu) = gpu() else { return }; + gpu + }; + let comp = Compositor::new_sized(&gpu, 1920, 1080).expect("compositor"); + let screen = FakeFrame::new(&gpu, Tint::Blue); + let still = resting("bench", false); + let text = resting_as("bench-text", Some("text"), false); + // Un geste rapide : la traînée prend ses 16 copies. + let fast = track("bench-fast", &[(0.0, 0.2, 0.45, false), (T - 0.15, 0.2, 0.45, false), (T + 0.15, 0.8, 0.45, false), (9.0, 0.8, 0.45, false)]); + let time = |json: &str, track: &CursorTrack| { + let scene = Scene::from_json(json).expect("scène"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_has_webcam(false); + comp.set_scene(Some(scene)); + comp.set_cursor(track.clone()); + comp.set_cursor_time(Some(T)); + comp.set_timeline_time(Some(T)); + let cfg = cfg(); + let t0 = std::time::Instant::now(); + for _ in 0..100 { + unsafe { + comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &cfg).expect("compose"); + comp.readback_direct().expect("readback"); + } + } + t0.elapsed().as_secs_f64() * 10.0 + }; + for rotation in ["null", r#""iso""#] { + for (label, blur, size, tr) in [ + ("taille 3, net", 0.0, 3.0, &still), + ("taille 10, net", 0.0, 10.0, &still), + ("taille 10, I", 0.0, 10.0, &text), + ("taille 10, traînée 16", 1.0, 10.0, &fast), + ] { + let (off_json, on_json) = ( + scene_json(rotation, Some(false), "default", blur, size), + scene_json(rotation, Some(true), "default", blur, size), + ); + time(&off_json, tr); + time(&on_json, tr); + let (mut off, mut on) = (f64::MAX, f64::MAX); + for _ in 0..5 { + off = off.min(time(&off_json, tr)); + on = on.min(time(&on_json, tr)); + } + println!("{which} 1080p {rotation} {label} : sprite {off:.2} ms, curseur 3D {on:.2} ms ({:+.2} ms)", on - off); + } + } +} diff --git a/crates/compositor/tests/cursor_tap_render.rs b/crates/compositor/tests/cursor_tap_render.rs new file mode 100644 index 000000000..b29116c7a --- /dev/null +++ b/crates/compositor/tests/cursor_tap_render.rs @@ -0,0 +1,423 @@ +//! Le contact du curseur modélisé et l'impact du clic (mode 16), rendus par le vrai compositeur +//! D3D11 sur une frame NV12 SYNTHÉTIQUE : une maquette d'interface dont les cibles portent une +//! pastille rouge au pixel exact du clic. Sans adaptateur matériel, les tests se sautent +//! (« test saute »). +//! +//! - au contact, la pointe couvre la pastille, même sous un fort lissage (la piste lissée traîne +//! loin derrière la souris), à plat, sous un angle fixe et sous la caméra réelle ; +//! - l'anneau de l'impact est centré sur la pastille. +//! +//! Et, en opt-in, une vidéo de 4 s à 30 i/s (la flèche puis la main qui cliquent, à plat et en +//! `iso`, côte à côte) et une planche des images autour du contact : +//! +//! ```powershell +//! $env:OPENSCREEN_CURSOR_TAP_OUT = "...\cursor-tap" +//! cargo test -p openscreen-compositor --test cursor_tap_render -- --nocapture +//! ``` + +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config::Cfg; +use openscreen_compositor::cursor::CursorTrack; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::ffi::AVFrame; +use openscreen_compositor::frame_geometry::{ + live_params_from_scene, plan_cursor, plan_frame, CursorPlacement, CursorPlanInput, + FrameGeometryInput, +}; +use openscreen_compositor::scene::Scene; +use windows::core::Interface; +use windows::Win32::Graphics::Direct3D11::{ + ID3D11Texture2D, D3D11_BIND_SHADER_RESOURCE, D3D11_CPU_ACCESS_WRITE, + D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_WRITE_DISCARD, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DYNAMIC, +}; +use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_NV12, DXGI_SAMPLE_DESC}; + +const SRC: (u32, u32) = (1280, 720); +const OUT: (u32, u32) = (1280, 720); +/// Les deux cibles (repère normalisé de l'écran) et l'instant de leur clic : la flèche clique la +/// première, la main (état `pointer`) la seconde. Chacune tombe au centre d'un bloc de chroma +/// NV12 (texels impairs) : la pastille est alors symétrique autour d'elle, chroma comprise. +const TARGETS: [([f32; 2], f32); 2] = + [([385.0 / 1280.0, 259.0 / 720.0], 1.0), ([897.0 / 1280.0, 447.0 / 720.0], 2.4)]; +/// Au creux du contact (`regions::tap`). +const CONTACT_S: f32 = 0.0495; + +fn gpu() -> Option { + match Gpu::create(false) { + Ok(g) => Some(g), + Err(e) => { + eprintln!("pas de device D3D11 matériel ({e:#}) — test saute"); + None + } + } +} + +/// Une maquette d'interface en NV12 (barre de titre, carte, lignes de texte, deux boutons), avec +/// une pastille rouge de 5 texels de rayon au centre exact de chaque cible. Présentée comme une +/// frame D3D11VA (cf. `cursor_model_render.rs`). La luma est évaluée au centre de chaque texel, la +/// chroma au centre de chaque bloc 2×2. +struct MockFrame { + frame: Box, + _tex: ID3D11Texture2D, +} + +impl MockFrame { + fn new(gpu: &Gpu) -> MockFrame { + let (w, h) = SRC; + // (Y, Cb, Cr) de chaque pixel, en BT.709 limité. + let pixel = |fx: f32, fy: f32| -> (u8, u8, u8) { + let (x, y) = (fx as u32, fy as u32); + for ([tx, ty], _) in TARGETS { + let (cx, cy) = (tx * w as f32, ty * h as f32); + if (fx - cx).hypot(fy - cy) <= 5.0 { + return (63, 102, 240); // rouge + } + } + let button = |c: [f32; 2], hw: f32, hh: f32| { + let (dx, dy) = ((fx - c[0] * w as f32).abs(), (fy - c[1] * h as f32).abs()); + (dx - hw + 10.0).max(0.0).hypot((dy - hh + 10.0).max(0.0)) <= 10.0 + }; + if button(TARGETS[0].0, 130.0, 70.0) { + return (92, 180, 110); // bouton bleu + } + if button(TARGETS[1].0, 90.0, 26.0) { + return (150, 100, 90); // bouton vert + } + if y < 56 { + return (48, 132, 126); // barre de titre sombre + } + if x < 220 { + let item = (y - 56) % 44; + return if (14..22).contains(&item) && (24..150).contains(&x) { (150, 128, 128) } else { (222, 129, 127) }; + } + let card = x > 250 && x < 1240 && y > 90 && y < 690; + if card && (x == 251 || x == 1239 || y == 91 || y == 689) { + return (200, 128, 128); + } + // Lignes de « texte » dans la carte, à l'écart des boutons. + let line = (y % 36) >= 14 && (y % 36) < 20 && x > 280 && x < 1200 && (x / 97) % 5 != 4; + if card && line { + return (135, 128, 128); + } + (if card { 245 } else { 232 }, 128, 128) + }; + let desc = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_NV12, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DYNAMIC, + BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, + CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, + MiscFlags: 0, + }; + unsafe { + let mut tex: Option = None; + gpu.device.CreateTexture2D(&desc, None, Some(&mut tex)).expect("texture NV12"); + let tex = tex.expect("texture NV12"); + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + gpu.context.Map(&tex, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut m)).expect("Map"); + let pitch = m.RowPitch as usize; + let dst = m.pData as *mut u8; + for y in 0..h { + for x in 0..w { + *dst.add(y as usize * pitch + x as usize) = pixel(x as f32 + 0.5, y as f32 + 0.5).0; + } + } + for y in 0..h / 2 { + for x in 0..w / 2 { + let (_, cb, cr) = pixel(2.0 * x as f32 + 1.0, 2.0 * y as f32 + 1.0); + let uv = (h as usize + y as usize) * pitch + 2 * x as usize; + *dst.add(uv) = cb; + *dst.add(uv + 1) = cr; + } + } + gpu.context.Unmap(&tex, 0); + let mut frame: Box = Box::new(std::mem::zeroed()); + frame.data[0] = tex.as_raw() as *mut u8; + frame.data[1] = std::ptr::null_mut(); + frame.width = w as i32; + frame.height = h as i32; + MockFrame { frame, _tex: tex } + } + } + + fn as_ptr(&self) -> *const AVFrame { + &*self.frame as *const AVFrame + } +} + +/// `rotation` : valeur JSON du préset ; `hide` : le curseur masqué par la région (la caméra le +/// suit toujours), pour lire la pastille sans lui. +fn scene_json(rotation: &str, hide: bool, click_bounce: f32) -> String { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../public/cursors/default") + .to_string_lossy() + .replace('\\', "/"); + format!( + r##"{{"clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, + "effects":{{"padding":0.2,"blur":false,"shadow":0.5,"roundnessFrac":0.03,"motionBlur":0}}, + "background":{{"kind":"gradient","angleDeg":135,"stops":["#5b6ee1","#e8a0bf"]}}, + "zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":10,"scale":1,"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":{rotation},"hideCursor":{hide}}}], + "annotations":[], + "cursor":{{"show":true,"size":4,"smoothing":0.5,"motionBlur":0.5,"clickBounce":{click_bounce},"model3d":true,"clipToBounds":false,"theme":"default", + "cursorSprites":{{"arrow":{{"path":"{dir}/arrow.png","hotspotX":0.119,"hotspotY":0.0874}}, + "pointer":{{"path":"{dir}/pointer.png","hotspotX":0.3893,"hotspotY":0.0032}}}}}}, + "cropByClip":[null], + "output":{{"width":1280,"height":720,"fps":30}}}}"## + ) +} + +/// Le geste : repos, arc jusqu'à la première cible (arrivée 80 ms avant le clic), tenue 120 ms, +/// arc jusqu'à la seconde, qui se survole en main, puis départ. 60 Hz, clics compris, lissé à +/// 0,5 comme le ferait l'application. +fn gesture(name: &str) -> CursorTrack { + let ([ax, ay], t1) = TARGETS[0]; + let ([bx, by], t2) = TARGETS[1]; + let ease = |u: f32| { + let u = u.clamp(0.0, 1.0); + u * u * (3.0 - 2.0 * u) + }; + let arc = |p: [f32; 2], q: [f32; 2], u: f32, bow: f32| { + let e = ease(u); + let (dx, dy) = (q[0] - p[0], q[1] - p[1]); + let s = (std::f32::consts::PI * e).sin() * bow; + (p[0] + dx * e - dy * s, p[1] + dy * e + dx * s) + }; + let pos = |t: f32| -> (f32, f32) { + let start = [0.12, 0.8]; + let end = [0.9, 0.3]; + if t < t1 - 0.08 { + arc(start, [ax, ay], (t - 0.1) / (t1 - 0.18), 0.15) + } else if t < t1 + 0.12 { + (ax, ay) + } else if t < t2 - 0.1 { + arc([ax, ay], [bx, by], (t - t1 - 0.12) / (t2 - t1 - 0.22), -0.2) + } else if t < t2 + 0.15 { + (bx, by) + } else { + arc([bx, by], end, (t - t2 - 0.15) / 0.9, 0.1) + } + }; + let clicks = [t1, t2]; + let body: Vec = (0..=300) + .map(|i| { + let t = i as f32 / 60.0; + let (x, y) = pos(t); + let click = if clicks.iter().any(|c| (c - t).abs() < 1e-3) { r#","interactionType":"click""# } else { "" }; + let kind = if t > (t1 + t2) * 0.5 { "pointer" } else { "arrow" }; + format!(r#"{{"timeMs":{},"cx":{x},"cy":{y}{click},"cursorType":"{kind}"}}"#, (t * 1000.0).round()) + }) + .collect(); + let path = std::env::temp_dir().join(format!("os_cursor_tap_{name}.json")); + std::fs::write(&path, format!(r#"{{"samples":[{}]}}"#, body.join(","))).expect("sidecar"); + CursorTrack::load(path.to_str().unwrap(), 0.0, 10.0).expect("piste curseur").smoothed(0.5) +} + +fn cfg() -> Cfg { + let mut cfg = Cfg::c8(); + cfg.bg_blur = false; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = true; + cfg.mblur_n = 1; + cfg.shadow = true; + cfg +} + +/// La frame à `t`, et le pixel du contenu où le plan pose le curseur (la pointe), s'il y en a un. +fn render(comp: &Compositor, screen: &MockFrame, json: &str, track: &CursorTrack, t: f32) -> (Vec, Option<[f32; 2]>) { + let scene = Scene::from_json(json).expect("scène valide"); + let mut live = live_params_from_scene(&scene); + live.has_webcam = false; + comp.set_live_params(live); + comp.set_has_webcam(false); + comp.set_scene(Some(scene.clone())); + comp.set_cursor(track.clone()); + comp.set_cursor_time(Some(t)); + comp.set_timeline_time(Some(t)); + let cfg = cfg(); + let (w, h, rgba) = unsafe { + comp.compose_frame(screen.as_ptr(), screen.as_ptr(), t * 30.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback") + }; + assert_eq!((w, h), OUT); + let render_px = [w as f32, h as f32]; + let src = [SRC.0 as f32, SRC.1 as f32]; + let g = plan_frame(&FrameGeometryInput { + render_px, + screen_tex_px: src, + screen_visible_px: src, + webcam_visible_px: src, + u_max: 1.0, + v_max: 1.0, + frame: t * 30.0, + cfg: &cfg, + live, + scene: Some(&scene), + cursor: Some(track), + timeline_t_override: Some(t), + programme_time: None, + }); + let input = CursorPlanInput { render_px, u_max: 1.0, v_max: 1.0, cfg: &cfg, live, scene: Some(&scene), track, t }; + let tip = plan_cursor(&g, &input).map(|plan| match plan.placement { + CursorPlacement::Tilted { plane_pt, quad, center_px, .. } => { + let (x, y) = quad.point_px(plane_pt[0], plane_pt[1]); + [center_px[0] + x, center_px[1] + y] + } + CursorPlacement::Upright { center } => [center[0] * render_px[0], center[1] * render_px[1]], + }); + (rgba, tip) +} + +fn px(rgba: &[u8], x: i32, y: i32) -> [i32; 3] { + let i = ((y * OUT.0 as i32 + x) * 4) as usize; + [rgba[i] as i32, rgba[i + 1] as i32, rgba[i + 2] as i32] +} + +fn is_red(p: [i32; 3]) -> bool { + p[0] > p[1] + 60 && p[0] > p[2] + 60 +} + +/// Centre (px) de la pastille rouge la plus proche de `near`, lu dans l'image. +fn marker(rgba: &[u8], near: [f32; 2]) -> [f32; 2] { + let (mut sx, mut sy, mut n) = (0.0, 0.0, 0.0); + for y in (near[1] as i32 - 40).max(0)..(near[1] as i32 + 40).min(OUT.1 as i32) { + for x in (near[0] as i32 - 40).max(0)..(near[0] as i32 + 40).min(OUT.0 as i32) { + if is_red(px(rgba, x, y)) { + sx += x as f32 + 0.5; + sy += y as f32 + 0.5; + n += 1.0; + } + } + } + assert!(n > 10.0, "pas de pastille près de {near:?}"); + [sx / n, sy / n] +} + +/// Au contact, la pointe couvre la pastille : le pixel du clic n'est plus rouge, et le plan pose +/// la pointe sur le centre mesuré de la pastille à 0,5 px près. À plat, en `iso` et sous la +/// caméra réelle, avec une piste lissée à 0,5 qui passe à des dizaines de px de la cible. +#[test] +fn the_tip_lands_on_the_marked_click_target() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, OUT.0, OUT.1).expect("compositor"); + let screen = MockFrame::new(&gpu); + let track = gesture("contact"); + for (name, rotation) in [("flat", "null"), ("iso", r#""iso""#), ("follow", r#""follow-cursor""#)] { + for (target, tc) in TARGETS { + let t = tc + CONTACT_S; + // La pastille, lue sans curseur au même instant (la caméra suit la même piste). + let (bare, hidden) = render(&comp, &screen, &scene_json(rotation, true, 2.5), &track, t); + assert!(hidden.is_none(), "{name}: la région devait masquer le curseur"); + let (touch, tip) = render(&comp, &screen, &scene_json(rotation, false, 2.5), &track, t); + let tip = tip.expect("curseur au contact"); + let m = marker(&bare, tip); + let off = (tip[0] - m[0]).hypot(tip[1] - m[1]); + // Où serait la pointe sur la piste lissée : l'écart que la convergence rattrape, en + // largeurs d'écran (~1000 px ici). + let lag = track.at(t).map(|(x, y)| (x - target[0]).hypot(y - target[1])).unwrap() * 1000.0; + println!("{name} cible {target:?} : pointe à {off:.3} px de la pastille (piste lissée à ~{lag:.0} px)"); + assert!(off < 0.5, "{name} {target:?}: pointe à {off} px de la pastille"); + assert!(lag > 5.0, "{name}: le lissage devrait traîner, sinon le test ne prouve rien"); + // Juste sous le point cliqué, la pointe (l'incrustation noire de la flèche, le trait du + // doigt, dont le hotspot est le haut) couvre la pastille ; sans la convergence, la + // pointe serait à des dizaines de px. + let (x, y) = (m[0] as i32, (m[1] + 2.0) as i32); + assert!(!is_red(px(&touch, x, y)), "{name} {target:?}: la pastille reste visible sous la pointe"); + } + } +} + +/// L'anneau de l'impact est centré sur la pastille : à plat, il passe à la même distance au-dessus +/// et à gauche d'elle (le curseur couvre le bas et la droite ; les deux côtés sont sur le même +/// bouton), et il n'existe pas sans clic. +#[test] +fn the_impact_ring_is_centred_on_the_click() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, OUT.0, OUT.1).expect("compositor"); + let screen = MockFrame::new(&gpu); + let track = gesture("ring"); + let (_, tc) = TARGETS[0]; + let t = tc + 0.16; + let (bare, _) = render(&comp, &screen, &scene_json("null", true, 2.5), &track, t); + let (ring, tip) = render(&comp, &screen, &scene_json("null", false, 2.5), &track, t); + let (quiet, _) = render(&comp, &screen, &scene_json("null", false, 0.0), &track, t); + let m = marker(&bare, tip.expect("curseur")); + // Le profil de l'écart dû à l'impact le long d'une demi-droite partant du clic. + let peak = |dx: i32, dy: i32| -> (i32, i32) { + (4..45) + .map(|r| { + let (x, y) = (m[0] as i32 + dx * r, m[1] as i32 + dy * r); + let (a, b) = (px(&ring, x, y), px(&quiet, x, y)); + ((0..3).map(|c| (a[c] - b[c]).abs()).sum::(), r) + }) + .max() + .unwrap() + }; + let (up, left) = (peak(0, -1), peak(-1, 0)); + println!("anneau : haut {up:?}, gauche {left:?} (gain, rayon px)"); + assert!(up.0 > 60 && left.0 > 60, "anneau absent : {up:?} {left:?}"); + assert!((up.1 - left.1).abs() <= 1, "anneau décentré : {up:?} {left:?}"); + let (late, _) = render(&comp, &screen, &scene_json("null", false, 2.5), &track, tc + 0.5); + let (late_quiet, _) = render(&comp, &screen, &scene_json("null", false, 0.0), &track, tc + 0.5); + assert!(late == late_quiet, "l'anneau survit à sa fenêtre"); +} + +/// Vidéo et planche à regarder (opt-in, `OPENSCREEN_CURSOR_TAP_OUT`). +#[test] +fn cursor_tap_video() { + let Ok(dir) = std::env::var("OPENSCREEN_CURSOR_TAP_OUT") else { + eprintln!("OPENSCREEN_CURSOR_TAP_OUT absent — saute"); + return; + }; + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, OUT.0, OUT.1).expect("compositor"); + let screen = MockFrame::new(&gpu); + let track = gesture("video"); + let frames = format!("{dir}/frames"); + std::fs::create_dir_all(&frames).expect("dossier des frames"); + let presets = [("flat", "null"), ("iso", r#""iso""#)]; + let img = |rgba: Vec| image::RgbaImage::from_raw(OUT.0, OUT.1, rgba).expect("readback"); + for k in 0..120 { + let t = k as f32 / 30.0; + let mut both = image::RgbaImage::new(2 * OUT.0, OUT.1); + for (i, (_, rotation)) in presets.iter().enumerate() { + let (rgba, _) = render(&comp, &screen, &scene_json(rotation, false, 2.5), &track, t); + image::imageops::overlay(&mut both, &img(rgba), (i as u32 * OUT.0) as i64, 0); + } + both.save(format!("{frames}/{k:03}.png")).expect("frame"); + } + // La planche : autour de chaque clic, sept instants, agrandis ×2 autour de la pointe. + const CELL: u32 = 200; + let offsets = [-0.2, -0.067, 0.033, 0.067, 0.133, 0.233, 0.4]; + let mut sheet = image::RgbaImage::new(offsets.len() as u32 * 2 * CELL, 4 * 2 * CELL); + for (row, ((name, rotation), (_, tc))) in presets.iter().flat_map(|p| TARGETS.iter().map(move |t| (p, t))).enumerate() { + let (_, center) = render(&comp, &screen, &scene_json(rotation, false, 2.5), &track, tc + CONTACT_S); + let center = center.expect("curseur"); + for (col, dt) in offsets.iter().enumerate() { + let (rgba, _) = render(&comp, &screen, &scene_json(rotation, false, 2.5), &track, tc + dt); + let (x, y) = ((center[0] - CELL as f32 * 0.5) as u32, (center[1] - CELL as f32 * 0.5) as u32); + let crop = image::imageops::crop_imm(&img(rgba), x, y, CELL, CELL).to_image(); + let big = image::imageops::resize(&crop, 2 * CELL, 2 * CELL, image::imageops::FilterType::Nearest); + image::imageops::overlay(&mut sheet, &big, (col as u32 * 2 * CELL) as i64, (row as u32 * 2 * CELL) as i64); + } + println!("planche, ligne {row} : {name}, clic à {tc} s, colonnes {offsets:?} s"); + } + sheet.save(format!("{dir}/contact-sheet.png")).expect("planche"); + let ffmpeg = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../thirdparty/ffmpeg-n8.1.2-win64-lgpl-shared/bin/ffmpeg.exe"); + let status = std::process::Command::new(ffmpeg) + .args(["-y", "-loglevel", "error", "-framerate", "30", "-i"]) + .arg(format!("{frames}/%03d.png")) + .args(["-c:v", "h264_mf", "-b:v", "16M", "-pix_fmt", "nv12"]) + .arg(format!("{dir}/cursor-tap.mp4")) + .status(); + println!("ffmpeg : {status:?} -> {dir}/cursor-tap.mp4"); +} diff --git a/crates/compositor/tests/cursor_volume_render.rs b/crates/compositor/tests/cursor_volume_render.rs deleted file mode 100644 index cacd7fa9a..000000000 --- a/crates/compositor/tests/cursor_volume_render.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Le curseur en volume (`cursor.volume`) : des flancs le long de la normale du plan et une -//! ombre de contact, sur écran droit comme incliné — sans toucher à sa face avant. -//! -//! Rend de vraies frames par le compositeur D3D11, avec le sprite `arrow` livré -//! (`public/cursors/default`) et une piste curseur synthétique immobile sur le pavé rouge de la -//! source de `privacy_blur_under_zoom.rs` (mêmes réglages ffmpeg). -//! -//! ```powershell -//! $env:OPENSCREEN_CURSOR_VOLUME_SOURCE = "...\secret.mp4" -//! $env:OPENSCREEN_CURSOR_VOLUME_OUT = "...\renders" # facultatif : un PPM par cas -//! cargo test -p openscreen-compositor --test cursor_volume_render -- --nocapture -//! ``` - -// Windows seulement : le readback et le décodage D3D11VA de ce harnais n'existent que là. -#![cfg(windows)] - -use openscreen_compositor::compositor::Compositor; -use openscreen_compositor::config; -use openscreen_compositor::cursor::CursorTrack; -use openscreen_compositor::d3d::Gpu; -use openscreen_compositor::frame_geometry::live_params_from_scene; -use openscreen_compositor::live::Player; -use openscreen_compositor::scene::Scene; - -const W: u32 = 1280; -const H: u32 = 720; -const AT_SEC: f64 = 3.0; - -fn write_ppm(path: &std::path::Path, rgba: &[u8], w: u32, h: u32) -> std::io::Result<()> { - let mut out = Vec::with_capacity(rgba.len() / 4 * 3 + 32); - out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes()); - for px in rgba.chunks_exact(4) { - out.extend_from_slice(&px[..3]); - } - std::fs::write(path, out) -} - -fn scene_json(source: &str, arrow: &str, volume: f32, rotation: &str) -> String { - let s = source.replace('\\', "/"); - let a = arrow.replace('\\', "/"); - format!( - r##"{{ - "clips": [{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":6,"webcamOffsetSec":0,"hasAudio":false}}], - "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, - "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, - "effects": {{"padding":0.1,"blur":false,"shadow":0.0,"roundnessFrac":0,"motionBlur":0.0}}, - "background": {{"kind":"color","color":"#d8d8d8"}}, - "zoomRegions": [{{"id":"z","startSec":0,"endSec":6,"scale":1.5,"focusX":0.6,"focusY":0.6,"focusMode":"manual","rotation":{rotation}}}], - "annotations": [], - "speedRegions": [], - "cursor": {{"show":true,"size":3,"smoothing":0,"motionBlur":0,"clickBounce":0,"volume":{volume},"clipToBounds":false,"theme":"default", - "cursorSprites": {{"arrow": {{"path":"{a}","hotspotX":0.119,"hotspotY":0.0874}}}}}}, - "cropByClip": [null], - "output": {{"width":{W},"height":{H},"fps":null}} - }}"## - ) -} - -fn render( - gpu: &Gpu, - source: &str, - track: &str, - arrow: &str, - volume: f32, - rotation: &str, -) -> Vec { - 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(&scene_json(source, arrow, volume, rotation)).expect("scene valide"); - comp.set_live_params(live_params_from_scene(&scene)); - comp.set_scene(Some(scene)); - comp.set_cursor(CursorTrack::load(track, 0.0, 6.0).expect("piste curseur")); - unsafe { - let mut player = Player::open(source, "", gpu).expect("ouvrir la source"); - player - .present_frame(&comp, &cfg, AT_SEC) - .expect("composer la frame"); - comp.readback_resized(W, H).expect("readback") - } -} - -fn luma(p: &[u8]) -> u32 { - (p[0] as u32 * 2126 + p[1] as u32 * 7152 + p[2] as u32 * 722) / 10000 -} - -#[test] -fn the_cursor_volume_adds_sides_and_keeps_the_front_face() { - let Ok(source) = std::env::var("OPENSCREEN_CURSOR_VOLUME_SOURCE") else { - println!("SKIP: definir OPENSCREEN_CURSOR_VOLUME_SOURCE (voir l'en-tete du fichier)."); - return; - }; - let out_dir = std::env::var("OPENSCREEN_CURSOR_VOLUME_OUT") - .ok() - .map(std::path::PathBuf::from); - if let Some(dir) = &out_dir { - std::fs::create_dir_all(dir).expect("creer le dossier de sortie"); - } - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let arrow = root.join("public/cursors/default/arrow.png"); - let arrow = arrow.to_str().expect("chemin utf-8"); - // Piste immobile au coin haut-gauche du pavé rouge : les flancs sombres s'y lisent. - let track = std::env::temp_dir().join("openscreen-cursor-volume-track.json"); - std::fs::write( - &track, - r#"{"samples":[{"timeMs":0,"cx":0.58,"cy":0.58},{"timeMs":6000,"cx":0.58,"cy":0.58}]}"#, - ) - .expect("ecrire la piste"); - let track = track.to_str().expect("chemin utf-8"); - let gpu = Gpu::create(false).expect("device d3d11"); - - for (name, rotation) in [("flat", "null"), ("iso", r#""iso""#), ("left", r#""left""#)] { - let flat = render(&gpu, &source, track, arrow, 0.0, rotation); - let deep = render(&gpu, &source, track, arrow, 1.0, rotation); - if let Some(dir) = &out_dir { - write_ppm(&dir.join(format!("{name}-volume0.ppm")), &flat, W, H).expect("ppm"); - write_ppm(&dir.join(format!("{name}-volume1.ppm")), &deep, W, H).expect("ppm"); - } - let changed = flat - .chunks_exact(4) - .zip(deep.chunks_exact(4)) - .filter(|(a, b)| a != b) - .count(); - // Écart franc (> 30 en luma, dans un sens ou l'autre) : les flancs gris du liseré blanc - // ressortent PLUS clairs que le rouge, l'ombre plus sombre. - let marked = flat - .chunks_exact(4) - .zip(deep.chunks_exact(4)) - .filter(|(a, b)| luma(a).abs_diff(luma(b)) > 30) - .count(); - // Face avant : les pixels blancs du sprite à volume 0 restent blancs. Le passage du mode 7 - // au mode 13 (quad identité) ne doit rien déplacer, et les flancs passent DERRIÈRE. - let (mut white, mut kept) = (0usize, 0usize); - for (a, b) in flat.chunks_exact(4).zip(deep.chunks_exact(4)) { - if a[0] > 235 && a[1] > 235 && a[2] > 235 { - white += 1; - kept += usize::from(b[0] > 225 && b[1] > 225 && b[2] > 225); - } - } - println!( - "{name:<5} pixels changes {changed:>6} marques {marked:>5} face avant {kept}/{white}" - ); - assert!( - white > 200, - "{name}: le sprite n'est pas visible ({white} px blancs)" - ); - assert!( - marked > 150, - "{name}: le volume ne se voit pas ({changed} changes, {marked} marques)" - ); - assert!( - kept * 100 >= white * 97, - "{name}: la face avant a bougé ({kept}/{white})" - ); - } -} diff --git a/crates/compositor/tests/follow_camera_render.rs b/crates/compositor/tests/follow_camera_render.rs new file mode 100644 index 000000000..e1aaa9b06 --- /dev/null +++ b/crates/compositor/tests/follow_camera_render.rs @@ -0,0 +1,485 @@ +//! La caméra réelle de `follow-cursor` (l'orbite) rendue par le vrai compositeur D3D11. +//! +//! Deux tests sans variable d'environnement, sur une frame NV12 SYNTHÉTIQUE (une grille, donc une +//! géométrie connue au texel près ; sans adaptateur matériel, ils se sautent : « test saute ») : +//! +//! - le warp projectif du mode 8 pose chaque ligne de la grille là où la caméra la projette +//! (`TiltedQuad::point_px`), là où le warp bilinéaire des angles fixes la manquerait ; +//! - le roulis est nul au pixel près : la verticale qui passe par l'axe de la caméra reste +//! verticale à l'image, les autres convergent comme la projection le dit. +//! +//! Et, opt-in, une vidéo de 8 s à 30 i/s sur une vraie source décodée (une interface), avec un +//! pointeur qui visite la gauche, la droite, le haut et le bas et clique, au zoom 1 puis 1,8, +//! flèche 3D, cadre, ombre et profondeur de champ ; plus la planche gauche / droite / haut / bas +//! aux deux zooms et la trace de la caméra. +//! +//! ```powershell +//! $env:OPENSCREEN_FOLLOW_VIDEO_SOURCE = "...\screen.mp4" # 1920×1080, 10 s au moins +//! $env:OPENSCREEN_FOLLOW_VIDEO_OUT = "...\orbit" # frames\NNN.png, sheet-*.png, trace +//! cargo test -p openscreen-compositor --test follow_camera_render -- --nocapture +//! ffmpeg -framerate 30 -i orbit\frames\%03d.png -c:v h264_mf -b:v 12M -pix_fmt nv12 orbit.mp4 +//! ``` + +// Windows seulement : le readback et le décodage D3D11VA de ce harnais n'existent que là. +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config::Cfg; +use openscreen_compositor::cursor::CursorTrack; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::ffi::AVFrame; +use openscreen_compositor::frame_geometry::{live_params_from_scene, plan_frame, FrameGeometryInput}; +use openscreen_compositor::scene::Scene; +use windows::core::Interface; +use windows::Win32::Graphics::Direct3D11::{ + ID3D11Texture2D, D3D11_BIND_SHADER_RESOURCE, D3D11_CPU_ACCESS_WRITE, + D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_WRITE_DISCARD, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DYNAMIC, +}; +use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_NV12, DXGI_SAMPLE_DESC}; + +const SRC: (u32, u32) = (640, 360); +/// Pas de la grille, en texels : des lignes sombres de 2 texels sur fond clair. +const GRID: u32 = 32; +/// En plein palier de la région (0..10 s), le cadreur posé. +const T: f32 = 3.0; + +fn gpu() -> Option { + match Gpu::create(false) { + Ok(g) => Some(g), + Err(e) => { + eprintln!("pas de device D3D11 matériel ({e:#}) — test saute"); + None + } + } +} + +/// Une frame NV12 synthétique présentée comme une frame D3D11VA (cf. `cursor_model_render.rs`) : +/// une grille sombre sur fond clair, chroma neutre. +struct GridFrame { + frame: Box, + _tex: ID3D11Texture2D, +} + +impl GridFrame { + fn new(gpu: &Gpu) -> GridFrame { + let (w, h) = SRC; + let desc = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_NV12, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_DYNAMIC, + BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, + CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, + MiscFlags: 0, + }; + unsafe { + let mut tex: Option = None; + gpu.device.CreateTexture2D(&desc, None, Some(&mut tex)).expect("texture NV12"); + let tex = tex.expect("texture NV12"); + let mut m = D3D11_MAPPED_SUBRESOURCE::default(); + gpu.context.Map(&tex, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut m)).expect("Map"); + let pitch = m.RowPitch as usize; + let dst = m.pData as *mut u8; + for row in 0..h { + for col in 0..w { + let line = col % GRID < 2 || row % GRID < 2; + *dst.add(row as usize * pitch + col as usize) = if line { 30 } else { 220 }; + } + } + for row in 0..(h / 2) as usize { + for col in 0..w as usize { + *dst.add((h as usize + row) * pitch + col) = 128; + } + } + gpu.context.Unmap(&tex, 0); + let mut frame: Box = Box::new(std::mem::zeroed()); + frame.data[0] = tex.as_raw() as *mut u8; + frame.data[1] = std::ptr::null_mut(); + frame.width = w as i32; + frame.height = h as i32; + GridFrame { frame, _tex: tex } + } + } + + fn as_ptr(&self) -> *const AVFrame { + &*self.frame as *const AVFrame + } +} + +/// Piste curseur écrite dans un sidecar temporaire, échantillonnée à 60 Hz sur `[0, dur]`. +fn track(name: &str, dur: f32, at: &dyn Fn(f32) -> (f32, f32), clicks: &[f32]) -> CursorTrack { + let body: Vec = (0..=(dur * 60.0) as u32) + .map(|i| { + let t = i as f32 / 60.0; + let (x, y) = at(t); + let click = clicks.iter().any(|c| (c - t).abs() < 1e-3); + let kind = if click { r#","interactionType":"click""# } else { "" }; + format!(r#"{{"timeMs":{},"cx":{x},"cy":{y}{kind}}}"#, (t * 1000.0).round()) + }) + .collect(); + let path = std::env::temp_dir().join(format!("os_follow_{name}_{}.json", std::process::id())); + std::fs::write(&path, format!(r#"{{"samples":[{}]}}"#, body.join(","))).expect("sidecar"); + let track = CursorTrack::load(path.to_str().unwrap(), 0.0, dur as f64).expect("piste curseur"); + let _ = std::fs::remove_file(&path); + track +} + +fn grid_scene_dof(scale: f32, dof: bool) -> Scene { + Scene::from_json(&format!( + r##"{{"clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, + "effects":{{"padding":0.2,"blur":false,"shadow":0,"roundnessFrac":0.0,"motionBlur":0,"depthOfField":{dof}}}, + "background":{{"kind":"color","color":"#000000"}}, + "zoomRegions":[{{"clipIndex":0,"startSec":0,"endSec":10,"scale":{scale},"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":"follow-cursor"}}], + "annotations":[], + "cursor":{{"show":true,"size":0.05,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"none"}}, + "cropByClip":[null], + "output":{{"width":1280,"height":720,"fps":30}}}}"## + )) + .expect("scène valide") +} + +/// La grille nette : la profondeur de champ (allumée par défaut) floute le côté lointain, et un +/// trait flou ne se mesure plus au pixel. +fn grid_scene(scale: f32) -> Scene { + grid_scene_dof(scale, false) +} + +fn cfg() -> Cfg { + let mut cfg = Cfg::c8(); + cfg.bg_blur = false; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = true; + cfg.mblur_n = 1; + cfg.shadow = false; + cfg +} + +/// La frame, et le quad que la géométrie partagée prévoit pour elle (centre compris). +fn render( + comp: &Compositor, + screen: &GridFrame, + scene: &Scene, + track: &CursorTrack, +) -> (Vec, openscreen_compositor::regions::TiltedQuad, [f32; 2]) { + let mut live = live_params_from_scene(scene); + live.has_webcam = false; + comp.set_live_params(live); + comp.set_has_webcam(false); + comp.set_scene(Some(scene.clone())); + comp.set_cursor(track.clone()); + comp.set_cursor_time(Some(T)); + comp.set_timeline_time(Some(T)); + let cfg = cfg(); + let (w, h, rgba) = unsafe { + comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback") + }; + assert_eq!((w, h), (1280, 720)); + let render_px = [w as f32, h as f32]; + let src = [SRC.0 as f32, SRC.1 as f32]; + let g = plan_frame(&FrameGeometryInput { + render_px, + screen_tex_px: src, + screen_visible_px: src, + webcam_visible_px: src, + u_max: 1.0, + v_max: 1.0, + frame: 0.0, + cfg: &cfg, + live, + scene: Some(scene), + cursor: Some(track), + timeline_t_override: Some(T), + programme_time: None, + }); + assert!(g.camera.is_some(), "la caméra réelle doit être posée"); + let s_px = [g.s_dst[2] * render_px[0], g.s_dst[3] * render_px[1]]; + let center = [(g.s_dst[0] + g.s_dst[2] * 0.5) * render_px[0], (g.s_dst[1] + g.s_dst[3] * 0.5) * render_px[1]]; + (rgba, g.screen_tilt(s_px).expect("quad de la caméra"), center) +} + +fn luma(rgba: &[u8], x: i32, y: i32) -> f32 { + let i = ((y * 1280 + x) * 4) as usize; + 0.2126 * rgba[i] as f32 + 0.7152 * rgba[i + 1] as f32 + 0.0722 * rgba[i + 2] as f32 +} + +/// Le centre (sous-pixel) de la ligne sombre la plus proche de `x0` sur la rangée `y`, cherchée +/// à ±`reach` px : barycentre de l'assombrissement. `None` sans ligne. +fn line_center(rgba: &[u8], x0: f32, y: i32, reach: i32) -> Option { + let (mut sum, mut wsum) = (0.0f32, 0.0f32); + for dx in -reach..=reach { + let x = x0.round() as i32 + dx; + if !(0..1280).contains(&x) || !(0..720).contains(&y) { + return None; + } + let w = (200.0 - luma(rgba, x, y)).max(0.0); + (sum, wsum) = (sum + w * (x as f32 + 0.5), wsum + w); + } + (wsum > 200.0).then(|| sum / wsum) +} + +fn bilinear(c: &[(f32, f32); 4], u: f32, v: f32) -> (f32, f32) { + let top = (c[0].0 + (c[1].0 - c[0].0) * u, c[0].1 + (c[1].1 - c[0].1) * u); + let bottom = (c[3].0 + (c[2].0 - c[3].0) * u, c[3].1 + (c[2].1 - c[3].1) * u); + (top.0 + (bottom.0 - top.0) * v, top.1 + (bottom.1 - top.1) * v) +} + +/// Les lignes verticales de la grille tombent là où la caméra les projette, à un pixel près, +/// partout dans le cadre ; le warp bilinéaire les poserait à plusieurs pixels de là. +#[test] +fn the_projective_warp_lands_every_line_where_the_camera_projects_it() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("compositor"); + let screen = GridFrame::new(&gpu); + // Pointeur en haut à droite : caméra tournée à fond, le cas le plus projectif. + let tr = track("corner", 10.0, &|_| (0.93, 0.08), &[]); + let (rgba, quad, center) = render(&comp, &screen, &grid_scene(2.2), &tr); + if let Ok(dir) = std::env::var("OPENSCREEN_FOLLOW_VIDEO_OUT") { + image::RgbaImage::from_raw(1280, 720, rgba.clone()).unwrap().save(format!("{dir}/grid-corner.png")).unwrap(); + } + let (mut worst, mut worst_bilinear, mut n) = (0.0f32, 0.0f32, 0); + // `SRC.0 / GRID` exclu : ce serait le bord droit de l'écran, pas une ligne de la grille. + for k in 0..SRC.0 / GRID { + let u = (k * GRID) as f32 / SRC.0 as f32 + 1.0 / SRC.0 as f32; + for j in 0..SRC.1 / GRID { + // Au milieu d'une cellule, loin des lignes horizontales. + let v = ((j * GRID) as f32 + GRID as f32 * 0.5 + 1.0) / SRC.1 as f32; + let p = quad.point_px(u, v); + let (x, y) = (center[0] + p.0, center[1] + p.1); + if !(40.0..1240.0).contains(&x) || !(40.0..680.0).contains(&y) { + continue; + } + let Some(found) = line_center(&rgba, x, y.floor() as i32, 8) else { continue }; + // La ligne penche de moins de 5° : mesurée sur la rangée de `y`, à moins d'un demi-px + // de lui, elle ne se décale que de quelques centièmes. + let err = (found - (center[0] + p.0)).abs(); + worst = worst.max(err); + let b = bilinear(&quad.corners, u, v); + worst_bilinear = worst_bilinear.max((center[0] + b.0 - (center[0] + p.0)).abs()); + n += 1; + } + } + println!("{n} points de grille : écart au rendu {worst:.2} px (le bilinéaire en serait à {worst_bilinear:.1} px)"); + assert!(n > 50, "trop peu de points visibles : {n}"); + assert!(worst <= 0.25, "le rendu s'écarte de la projection de {worst:.2} px"); + assert!(worst_bilinear > 5.0, "{worst_bilinear}"); +} + +/// Une ligne verticale de la grille, mesurée au pixel : `x = a + b·(y − cy)`, relative au point +/// principal. La prévision vient de la projection (`TiltedQuad::point_px`), la mesure du rendu. +fn measure_line(rgba: &[u8], quad: &openscreen_compositor::regions::TiltedQuad, center: [f32; 2], u: f32) -> Option<((f32, f32), (f32, f32))> { + let (p0, p1) = (quad.point_px(u, 0.0), quad.point_px(u, 1.0)); + let predicted = |y: f32| p0.0 + (p1.0 - p0.0) * (y - p0.1) / (p1.1 - p0.1); + let pts: Vec<(f32, f32)> = (60..660) + .step_by(4) + .filter_map(|y| { + let dy = y as f32 + 0.5 - center[1]; + let x = center[0] + predicted(dy); + // Hors des croisements de lignes horizontales, qui tirent le barycentre. + (luma(rgba, (x + 14.0) as i32, y) > 150.0).then_some(())?; + line_center(rgba, x, y, 10).map(|found| (dy, found - center[0])) + }) + .collect(); + if pts.len() < 60 { + return None; + } + let fit = |pts: &[(f32, f32)]| { + let n = pts.len() as f32; + let (my, mx) = (pts.iter().map(|p| p.0).sum::() / n, pts.iter().map(|p| p.1).sum::() / n); + let b = pts.iter().map(|p| (p.0 - my) * (p.1 - mx)).sum::() + / pts.iter().map(|p| (p.0 - my).powi(2)).sum::(); + (mx - b * my, b) + }; + let want: Vec<(f32, f32)> = pts.iter().map(|&(y, _)| (y, predicted(y))).collect(); + Some((fit(&pts), fit(&want))) +} + +/// Roulis nul au pixel près. Sous l'élévation de l'orbite, les verticales convergent vers un +/// point de fuite À LA VERTICALE du point principal : la pente d'une ligne verticale de la grille +/// est proportionnelle à sa distance à l'axe, et nulle SUR l'axe. On mesure les deux lignes qui +/// l'encadrent, on interpole leur pente à l'axe : elle doit être nulle, où que soit le pointeur. +/// Chaque pente mesurée tombe aussi sur la projection. +#[test] +fn the_vertical_through_the_view_axis_stays_vertical() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, 1280, 720).expect("compositor"); + let screen = GridFrame::new(&gpu); + for (name, at, scale) in [ + ("tl", (0.1, 0.1), 1.8), + ("br", (0.9, 0.92), 1.8), + ("r", (0.9, 0.5), 1.0), + ("t", (0.5, 0.05), 1.0), + ("b", (0.3, 0.95), 2.2), + ] { + let tr = track(name, 10.0, &|_| at, &[]); + let (rgba, quad, center) = render(&comp, &screen, &grid_scene(scale), &tr); + let x_at_axis = |k: u32| quad.point_px((k * GRID + 1) as f32 / SRC.0 as f32, 0.5).0; + let right = (0..=SRC.0 / GRID).find(|&k| x_at_axis(k) > 0.0).expect("une ligne à droite de l'axe"); + let lines: Vec<_> = [right - 1, right] + .iter() + .map(|&k| measure_line(&rgba, &quad, center, (k * GRID + 1) as f32 / SRC.0 as f32)) + .collect(); + let [Some(((a1, b1), (_, w1))), Some(((a2, b2), (_, w2)))] = lines[..] else { + panic!("{name}: lignes introuvables autour de l'axe"); + }; + let axis = b1 + (b2 - b1) * (0.0 - a1) / (a2 - a1); + let deg = |b: f32| b.atan().to_degrees(); + println!( + "{name}: lignes à {a1:.0} et {a2:.0} px de l'axe, pentes {:.3}° et {:.3}° (projection {:.3}° et {:.3}°), à l'axe {:.3}°", + deg(b1), deg(b2), deg(w1), deg(w2), deg(axis) + ); + assert!(deg(axis).abs() < 0.1, "{name}: la verticale de l'axe penche de {:.3}°", deg(axis)); + assert!((deg(b1) - deg(w1)).abs() < 0.1 && (deg(b2) - deg(w2)).abs() < 0.1, "{name}"); + } +} + +/// Le chemin du pointeur de la vidéo, en fractions de l'image source (une interface : menu à +/// gauche, cartes, boutons à droite). Zoom 1 : gauche, droite, haut, bas ; puis zoom 1,8 : un +/// bouton à droite, le menu en haut à gauche, un bouton en bas à droite. Chaque trajet dure 0,5 s +/// et finit à l'heure du point ; un clic tombe 0,2 s après chaque arrivée. +const TOUR: [(f32, f32, f32); 9] = [ + (0.0, 0.55, 0.45), + (1.4, 0.10, 0.44), + (2.4, 0.90, 0.40), + (3.3, 0.64, 0.04), + (4.2, 0.45, 0.93), + (5.3, 0.82, 0.70), + (6.4, 0.10, 0.20), + (7.4, 0.80, 0.885), + (9.0, 0.80, 0.885), +]; + +fn tour(t: f32) -> (f32, f32) { + let i = TOUR.iter().rposition(|w| w.0 <= t).unwrap_or(0).min(TOUR.len() - 2); + let (a, b) = (TOUR[i], TOUR[i + 1]); + let e = ((t - (b.0 - 0.5)) / 0.5).clamp(0.0, 1.0); + let s = e * e * (3.0 - 2.0 * e); + let wobble = 0.003 * (t * 5.0).sin(); + (a.1 + (b.1 - a.1) * s + wobble, a.2 + (b.2 - a.2) * s) +} + +/// La scène de la vidéo et de la planche : interface, cadre sombre, ombre, profondeur de champ, +/// flèche 3D, impact du clic. +fn orbit_scene(source: &str, regions: &str, (w, h): (u32, u32)) -> Scene { + let arrow = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../public/cursors/default/arrow.png") + .to_string_lossy() + .replace('\\', "/"); + let src = source.replace('\\', "/"); + Scene::from_json(&format!( + r##"{{"clips":[{{"screenPath":"{src}","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.1,"y":0.1,"width":0.8,"height":0.8}}}}, + "effects":{{"padding":0.2,"blur":false,"shadow":0.6,"roundnessFrac":0.015,"motionBlur":0,"depthOfField":true,"frame":"window-dark"}}, + "background":{{"kind":"gradient","angleDeg":135,"stops":["#3b4fd1","#c86fa6"]}}, + "zoomRegions":[{regions}], + "annotations":[], + "cursor":{{"show":true,"size":2.2,"smoothing":0,"motionBlur":0,"clickBounce":2.5,"model3d":true,"clipToBounds":false,"theme":"default", + "cursorSprites":{{"arrow":{{"path":"{arrow}","hotspotX":0.119,"hotspotY":0.0874}}}}}}, + "cropByClip":[null], + "output":{{"width":{w},"height":{h},"fps":30}}}}"## + )) + .expect("scène valide") +} + +fn orbit_region(start: f32, end: f32, scale: f32) -> String { + format!( + r#"{{"clipIndex":0,"startSec":{start},"endSec":{end},"scale":{scale},"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":"follow-cursor","clickImpact":true}}"# + ) +} + +fn video_cfg() -> Cfg { + let mut cfg = openscreen_compositor::config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + cfg.shadow = true; + cfg.cursor = true; + cfg +} + +/// Opt-in : les frames d'une vidéo de 8 s à 30 i/s, zoom 1 puis zoom 1,8, et la planche +/// gauche / droite / haut / bas aux deux zooms. Cf. l'en-tête du fichier. +#[test] +fn an_orbit_camera_video() { + let (Ok(source), Ok(out)) = ( + std::env::var("OPENSCREEN_FOLLOW_VIDEO_SOURCE"), + std::env::var("OPENSCREEN_FOLLOW_VIDEO_OUT"), + ) else { + println!("SKIP: definir OPENSCREEN_FOLLOW_VIDEO_SOURCE et OPENSCREEN_FOLLOW_VIDEO_OUT."); + return; + }; + let size = (1920u32, 1080u32); + let (w, h) = size; + let frames = format!("{out}/frames"); + std::fs::create_dir_all(&frames).expect("dossier de sortie"); + let gpu = Gpu::create(false).expect("device d3d11"); + let cfg = video_cfg(); + let comp = Compositor::new_sized(&gpu, w, h).expect("compositor"); + let mut player = unsafe { openscreen_compositor::live::Player::open(&source, "", &gpu) }.expect("source"); + let mut present = |scene: &Scene, pointer: &CursorTrack, t: f32| -> Vec { + let live = live_params_from_scene(scene); + comp.set_live_params(live); + comp.set_scene(Some(scene.clone())); + comp.set_cursor(pointer.clone()); + unsafe { + player.present_frame(&comp, &cfg, t as f64).expect("composer la frame"); + comp.readback_resized(w, h).expect("readback") + } + }; + + // La planche : pointeur posé à gauche, à droite, en haut, en bas, au zoom 1 puis 1,8. + for (zoom, tag) in [(1.0f32, "z1"), (1.8, "z18")] { + let scene = orbit_scene(&source, &orbit_region(0.0, 10.0, zoom), size); + for (side, at) in [("left", (0.06, 0.5)), ("right", (0.94, 0.5)), ("top", (0.5, 0.05)), ("bottom", (0.5, 0.95))] { + let pointer = track(&format!("sheet_{tag}_{side}"), 10.0, &|_| at, &[]); + let rgba = present(&scene, &pointer, 3.0); + image::save_buffer(format!("{out}/sheet-{tag}-{side}.png"), &rgba, w, h, image::ColorType::Rgba8).expect("png"); + } + } + + // La vidéo : une région au zoom 1, chaînée à une région au zoom 1,8. + let regions = format!("{},{}", orbit_region(0.8, 4.6, 1.0), orbit_region(5.1, 8.5, 1.8)); + let scene = orbit_scene(&source, ®ions, size); + let clicks: Vec = TOUR[1..8].iter().map(|w| w.0 + 0.2).collect(); + let pointer = track("video", 10.0, &tour, &clicks).smoothed(0.0); + let live = live_params_from_scene(&scene); + // La trace de la caméra, frame par frame, par la géométrie partagée : de quoi juger la douceur + // du mouvement sans regarder la vidéo. + let mut trace = String::from("t,poids,zoom,orbite_x,orbite_y,visee_x,visee_y,azimut,elevation,recul,centre_x,centre_y\n"); + for i in 0..240u32 { + let t = i as f32 / 30.0; + let rgba = present(&scene, &pointer, t); + image::save_buffer(format!("{frames}/{i:03}.png"), &rgba, w, h, image::ColorType::Rgba8).expect("png"); + let g = plan_frame(&FrameGeometryInput { + render_px: [w as f32, h as f32], + screen_tex_px: [1920.0, 1080.0], + screen_visible_px: [1920.0, 1080.0], + webcam_visible_px: [1920.0, 1080.0], + u_max: 1.0, + v_max: 1.0, + frame: i as f32, + cfg: &cfg, + live, + scene: Some(&scene), + cursor: Some(&pointer), + timeline_t_override: Some(t), + programme_time: None, + }); + let s_px = [g.s_dst[2] * w as f32, g.s_dst[3] * h as f32]; + let pose = g.camera.unwrap_or(openscreen_compositor::camera::CameraPose { weight: 0.0, ..openscreen_compositor::camera::CameraPose::REST }); + let [az, el] = openscreen_compositor::camera::View::new(s_px, pose).angles_deg(); + let offset = g.screen_tilt(s_px).map(|q| q.offset).unwrap_or([0.0; 2]); + trace += &format!( + "{t:.4},{:.4},{:.4},{:.4},{:.4},{:.4},{:.4},{az:.3},{el:.3},{:.3},{:.2},{:.2}\n", + pose.weight, pose.zoom, pose.orbit[0], pose.orbit[1], pose.aim[0], pose.aim[1], pose.press, offset[0], offset[1] + ); + } + std::fs::write(format!("{out}/camera-trace.csv"), trace).expect("trace"); + println!("240 frames et 8 planches dans {out}"); +} diff --git a/docs/3d-effects-v2.md b/docs/3d-effects-v2.md new file mode 100644 index 000000000..9fa601628 --- /dev/null +++ b/docs/3d-effects-v2.md @@ -0,0 +1,424 @@ +# 3D effects — v2 : mouvement de caméra et curseur modélisé + +Suite de `spec-3d.md` (PR 1 → 5b, toutes ouvertes). Deux demandes : + +1. **plusieurs caméras 3D**, dont une où l'orientation suit la **position** du curseur ; +2. un **curseur modélisé** en vraie 3D — hauteur au-dessus du plan, ombre portée, contact au + clic, orientation vers le point cliqué — d'abord la flèche du thème par défaut, puis tous + ses états (main, I, redimensionnements…). + +Ce document tranche les décisions que la v1 laissait ouvertes et découpe les PR. + +--- + +## A. Le mouvement de caméra + +### A.1 Ce qui existe + +Un zoom porte **une attitude figée** (`rotationPreset` : `iso`, `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`) : + +- **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`. + +`rotated_quad_corners_px(w, h, base, dyn)` projette les coins ; l'échelle de containment est +calculée sur la **base seule** (« échelle gelée »), la part dynamique ne fait que reprojeter. + +### A.2 Le modèle : une seule liste « caméra 3D » + +**Révisé trois fois après test produit.** La première version séparait l'attitude +(`rotationPreset`) du mouvement (`cameraMotion` : `still`, `sway`, `follow`, `flip`) ; la +deuxième fusionnait tout en un sélecteur avec trois caméras mobiles (`follow-cursor`, +`swing-clicks`, `orbit`) qui faisaient tourner **l'écran** sur un chemin de poses à roulis +permanent (5,5 à 8,5°). Rejetées : « pour chacune, le métrage est de travers ». La troisième, +une caméra pan-tilt-zoom sur un œil fixe, paraissait figée. Il reste **un** champ, +`rotationPreset`, et **un** sélecteur « 3D camera » : les trois angles fixes et **une** caméra +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 | +| 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. + +« 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. + +### A.3 `follow-cursor` : une caméra en orbite + +`crates/compositor/src/camera.rs`. **L'écran ne bouge pas** : c'est le plan z = 0 du monde, en +px de sa boîte. **L'œil tourne autour de lui** sur une sphère et regarde toujours son point visé. + +**Pourquoi une orbite.** La version précédente (pan-tilt-zoom : œil fixe, objectif de 12°, la +caméra pivotait sur place) a été jugée « encore plus figée ». Depuis un œil fixe, tourner la caméra +n'est presque qu'un pan à plat : l'angle sous lequel on voit l'écran change à peine. L'orientation +ne se lit que si l'œil se déplace autour de l'écran. + +- **Orientation** : `lookAt` à haut fixe, lacet = −azimut, tangage = −élévation. **Roulis nul par + construction** : l'axe horizontal de l'image reste horizontal dans le monde, et une verticale qui + passe par le point visé reste verticale. Mesuré au pixel sur le rendu D3D11 : pente interpolée à + l'axe ≤ 0,013° sur cinq poses, les autres verticales convergeant comme la projection le prévoit + (écart ≤ 0,03°). +- **Azimut** : ±22° quand le pointeur touche le bord gauche ou droit de l'image source recadrée. + Pointeur à droite, l'œil passe à droite : le côté droit de l'écran vient vers nous. +- **Élévation** : 4° au repos, ±12° quand le pointeur touche le haut ou le bas. Pointeur en haut, + la caméra monte et plonge sur le haut de l'écran. L'élévation de repos fait converger les bords + verticaux : l'écran ne se tient jamais parfaitement droit au milieu de son orbite. +- **Objectif** : celui des angles fixes, distance `P = 1,6 × min(w, h)` (~35° sur le petit côté). +- **Zoom** : moitié travelling, moitié focale (distance divisée par `√zoom`). Un zoom purement + optique aplatissait la perspective de la vue ; un travelling complet tournait au grand-angle. +- **Cadrage au zoom 1** : le point visé est décalé pour que l'écran soit centré dans sa boîte vu de + biais (le côté proche grandit, le lointain rétrécit), et la focale est réduite pour qu'il tienne + dans sa boîte depuis **toute l'enveloppe** de l'orbite. Ce containment ne dépend que du ratio de + la boîte et du poids : un par région, l'écran ne respire pas. 16:9 : 0,83 (centré) au lieu de 0,77 + (sans centrage) ; au plus près, l'écran passe à 0,7 px du bord de sa boîte. Au repos, il en occupe + 85 % × 83 %. +- **Cadrage au zoom** : le centrage et le containment s'effacent linéairement jusqu'au zoom 2. Au + delà, le point visé tombe au centre de l'image, grossi exactement du zoom. Le point visé reste dans + `0,5 ± max(0, 0,5 − 0,55/max(zoom, 1))`, comme avant : au centre au zoom 1. +- **Force 0** = le rendu plat, par le même chemin (mode 0). + +**Le cadreur** (`camera::follow`), pure fonction de `t`, sans zone morte : le pointeur, lu dans +l'image source recadrée et borné à l'écran et à la fenêtre du clip, est convolué avec la réponse +impulsionnelle d'un ressort critique, `h(τ) = ω²·τ·e^(−ωτ)`, ω = 5 rad/s, sur 2 s de piste +(120 lectures), avancée de 0,25 s. + +- Deux sorties : le pointeur lissé (**orbite**, sur tout l'écran) et le même borné avant lissage à + la portée du zoom (**point visé**). +- Mesuré : un saut du pointeur est posé à 95 % 0,69 s après lui, sans dépassement, et la caméra + part avant lui. Coût : ~27 µs par frame en debug, identique à 3 s et à 59 s de région (la caméra elle-même : + ~22 µs, containment compris) (le cadreur + d'avant rejouait toute la région : ~5 ms pour une minute). +- Sur la vidéo de revue (8 s, zoom 1 puis 1,8) : azimut de −16° à +15°, élévation de −5° à +14°, + au plus 1,8° d'azimut et 68 px de déplacement du centre par image à 30 i/s. + +Sans piste (curseur masqué : l'export ne la charge pas), la caméra vise le centre, au repos. + +**Ce qui suit la caméra** : l'écran (mode 8), son ombre (mode 12), le cadre de fenêtre (mode 14), +le curseur plat (mode 13) et modélisé (mode 15), le flou de confidentialité (mode 10) et la +profondeur de champ. La mise au point suit le **pointeur lissé**, pas le point visé : celui-ci reste +au centre au zoom 1 et bute sur sa portée au zoom, alors que le spectateur regarde le pointeur. +L'ombre de l'écran tombe le long de la lumière de la flèche modélisée (haut-gauche). Une lampe posée +sur la caméra éclaire un peu plus le côté proche : gain 0,2, soit ±4 % d'un bord à l'autre à 22° +d'azimut (`CAMERA_LIGHT_GAIN`, 0,5 donnait ±10 % avec l'œil en orbite). + +**Impact du clic** : l'écran reste immobile, c'est **l'œil qui recule** de 4 % de sa distance au +contact, sur la courbe `tap()` de l'impact des angles fixes (mêmes clics, mêmes portes : fenêtre du +clip, clic visible, masque, vitesse, opacité du curseur), puis revient avec un léger rebond. Sur la +vidéo, l'écran perd 2 à 3 % de taille pendant deux images : un tapotement, pas un saut. Le panneau +l'active sous cette caméra, avec sa propre description. + +Chaînée à un angle fixe, une région `follow-cursor` ne mélange jamais les deux modèles : la +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 +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 à +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 : +rotation (tangage X, lacet Y), distance `P` et translation de l'image du centre de l'écran +(`offset`). Cette convention décrit n'importe quel œil sans roulis : l'orbite n'a rien changé aux +shaders ni aux emplacements du mode 15. + +**Ce qui penche.** La règle des 2° est relâchée pour cette caméra : une arête peut croiser un axe +pendant que l'œil passe, jamais par roulis. Au repos, pointeur au milieu, aucun bord vertical visible +n'est droit (élévation de repos). La pente du contenu au centre de la vue, +`atan(tan(azimut)·sin(élévation))`, atteint 6,35° quand l'œil est dans un coin de l'orbite (pointeur +en haut à droite) : c'est la perspective d'un vrai plan vu de trois quarts, et le prix des grands +angles demandés. Au zoom, la vue garde la fuite du zoom 1 : le texte converge nettement vers le côté +lointain. + +### A.3.1 Ce qui clochait avant + +- **Les caméras mobiles sur l'écran tournant** (`camera_pose`) : le roulis que la règle des 2° + imposait pour relier `left` à `right` rendait tout le métrage de travers. +- **La caméra pan-tilt-zoom** : œil fixe et objectif long, elle pivotait sans que l'angle de vue sur + l'écran change ; avec sa zone morte de 45 %, elle paraissait figée. +- **`flip` clignotait en focus auto** : le seuil était le centre de la coupe zoomée, que le focus + auto place sur le curseur lissé. Le côté se jouait au bruit flottant : 44 bascules miroir en + 6 s sur une dérive lente (mesuré avant suppression). +- **`follow` ne faisait rien en focus auto** (même cause), et plafonnait au budget dynamique + (±1,9° / ±3°) en manuel : à peine visible. +- **`still` et `sway` indiscernables** : la parallaxe d'une dérive lente culmine à ~1,2°. +- **Réglage mort sans curseur** : curseur masqué, aucune piste à l'export. Le sélecteur l'écrit + sous la liste. + +### A.4 Ce qui n'est pas livré ici + +- **`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. +- **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. + +### A.5 Portée TS + +Le natif porte la preview **et** l'export (`sceneDescription` → `scene.rs`). Le seul autre +consommateur de l'attitude est `getRotation3D` (`types.ts`), lu par +`computeRotation3DContainScale` via `zoomRegionUtils` — la preview CSS (`VirtualPreview.tsx`) ne +porte **aucun** tilt. Pour `follow-cursor`, `getRotation3D` rend la pose de repos de l'orbite +(X = −4° : caméra 4° au-dessus, le bord haut vient vers nous) : le chemin canvas n'a ni la piste ni +la caméra. + +--- + +## B. Le curseur modélisé + +### B.1 Ce qui a été retiré + +Deux essais précédents ne modélisaient rien, et ont été retirés sans compatibilité (ils n'ont +jamais atteint `main`) : + +- `cursor.volume` (« 3D Depth ») empilait des copies du sprite 2D le long de la normale du plan ; +- `cursor.hover` (« Float Height ») décalait le sprite et posait une tache (mode 12) dessous. + +Une carte découpée avec de l'épaisseur n'est pas un objet : pas de face éclairée, pas d'ombre +de sa forme, pas de contact. Un préréglage qui porte encore ces clés se lit toujours (les clés +inconnues sont ignorées). + +### B.2 Le réglage + +**Un seul interrupteur**, `cursor.model3d` (« 3D cursor », **éteint par défaut**) : il remplace +**chaque état** du thème par défaut (les seize de `DEFAULT_CURSOR_SPRITES` : flèche, I, main, +croix, mains ouverte et fermée, redimensionnements, déplacement, interdit, attente…) par son +sprite extrudé. Les autres thèmes gardent leur sprite plat ; l'indice du panneau le dit +(« Default style: every cursor shape turns 3D »). Curseur masqué, l'interrupteur est grisé et +son info-bulle dit pourquoi. Éteint, la frame est celle d'avant **à l'octet** (vérifié à plat et +incliné contre le commit de base). + +Tuyauterie : `CursorVisualSettings.model3d`, clé legacy `cursorModel3d`, préréglages (absent → +éteint), `SceneCursor.model3d` (`serde(default)`), `LiveParams.cursor_model3d`, paramètre live +`cursorModel3d`. Le contrat de scène ne gagne que ce champ optionnel, sans donnée de sprite +neuve : le modèle se tire du sprite que la scène transporte déjà. + +### B.3 Le modèle (mode 15) + +Un seul mode de shader, identique en HLSL, MSL et WGSL (`cursor_model`), lancé de rayons par +pixel dans la boîte de dessin. **Aucune forme n'est modélisée à la main** : le modèle est la +silhouette du sprite de l'état courant. + +- **Forme** : un champ de distance signé tiré de l'alpha du PNG, une fois au chargement + (`cursor_sdf.rs`) : alpha suréchantillonné ×4 (bilinéaire), seuil 0,5, transformée de + distance euclidienne exacte (Felzenszwalb), signée, puis floutée sur deux texels fins (le seuil + laisse un escalier dont les normales striaient les flancs). Texture **R16F** de la taille du + sprite ×4, distances en unités du modèle : un demi-flottant est exact au millième près autour + de zéro et filtrable sur les trois backends, là où le R32F ne l'est pas partout. Hors du rect du + sprite, le shader prend la borne exacte `√(|p − c|² + max(d(c), 0)²)`, `c` = `p` ramené dans le + rect. Unité = plus grand côté du sprite (= `size_px`), origine au hotspot de la face du dessus. + Extrudé de 0,19 unité, chanfrein arrondi de 0,045. Normales par gradient du champ. +- **Liaison** : sprite en t2 / `texture(2)` / binding 1 (`texY`), champ en t4 / `texture(4)` / + binding 2 (`texU`) sur Windows / macOS / Linux. Le cbuffer porte le coin du sprite + (`color.rg`), son rapport w/h (`radius_px`) et l'écrasement du clic (`color.b`, cf. B.5). Le + texel du sprite se tire de la taille du champ (`SDF_UPSAMPLE` / plus grand côté) : même valeur + au bit près que l'ancien emplacement. +- **Matières** : celles du sprite. Le dessus porte son art (alpha droit, comme aux modes 7 et 13) ; + le chanfrein, les flancs et le dessous lisent l'art à 1,5 texel à l'intérieur de la silhouette, + le long du gradient du champ : la couleur du bord de CE sprite (filet blanc de la flèche, trait + noir des mains), jamais la frange mêlée au transparent. Lumière fixée à la **caméra** + (haut-gauche, devant), ambiante 0,36, diffuse 0,75, reflet sur les arrondis seulement. +- **Ombre** : un rayon qui rate le modèle tombe sur le plan de l'écran. De là, marche vers la + lumière (pénombre `k·d/t`, k = 6, bornée à 0,45 unité) et ombre de contact (0,12 unité autour + du modèle). Opacité 0,5 chacune, et seulement à l'intérieur de l'écran. +- **Silhouette antialiasée** sur un pixel ; sortie prémultipliée, ombre noire. + +**Pourquoi pas des SDF hors ligne** (tirés des SVG par le générateur de sprites) : l'option +n'était à prendre que si le champ tiré du PNG arrondissait visiblement la flèche par rapport au +polygone analytique d'avant. Comparés côte à côte (flèche taille 8, à plat et iso, en l'air et +posée), les deux ont le même contour à l'œil ; le champ suit même mieux l'art (la queue du +polygone à 10 sommets était trop courte et trop droite). Sur les seize sprites, le signe du champ +coïncide avec l'alpha seuillé (IoU ≥ 0,9997 hors frange) ; sur un disque et un rectangle +synthétiques, l'écart au champ exact reste sous 0,5 texel source dans la bande de 3 texels autour +du bord (0,48 au pire), sous 1 texel au-delà (le flou arrondit les crêtes). + +### B.4 La caméra et l'ancrage + +La caméra est reconstruite par pixel exactement comme `regions.rs` projette le plan : rotation +dessinée (`TiltedQuad::rot`, base + dynamique), Z puis Y puis X, perspective `P/(P − z)` avec +`P = TiltedQuad::perspective` (`min(w, h) × 1,6` sous un angle fixe), translation du plan dans le +repère caméra (`TiltedQuad::offset`, en `mb.zw`, nulle sous un angle fixe, cf. A.3), échelle de +containment. Un écran droit prend un plan identité : même caméra, même mode. + +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. + +### B.5 La pose (fonction pure de `t`) + +`cursor_pose`, puis la part « pointeur » du sprite : + +- **Hauteur** : 0,35 unité de garde au repos. Chaque clic le pose **au contact** avec la courbe + `tap()`, celle de l'impact du clic, dont le creux (49,5 ms) est celui de la pression de + `bounce()`. Gain 1,25 : posé de 27 à 74 ms, donc au moins une image au contact jusqu'à + 21 i/s. Tous les états. +- **Tangage** : queue relevée, pointe vers le bas, 18° au repos, jusqu'à +10° au creux de la + pression, fois `clickBounce / 2,5`. +- **Lacet** : vers la vitesse horizontale lissée (`follow_at`, différence centrée sur ±100 ms), + et vers la cible d'un clic dans les 300 ms qui le précèdent. Borné en douceur à ±25° + (`tanh`), nul au repos, continu en `t`. +- **Part « pointeur »** (`pointing_factor`), tirée du seul hotspot, sans table par état : + distance du hotspot au centre du sprite rapportée au demi-côté (norme max), `smoothstep` de + 0,3 à 0,75. Tangage et lacet en sont multipliés. Flèche (0,83), main qui pointe, aide, + démarrage, flèche haute : 1, la pose de la flèche. I, croix, redimensionnements, déplacement, + interdit, attente, poing fermé : 0, ni tangage ni lacet (tourner une flèche de + redimensionnement en change le sens ; basculer une forme autour de son centre en enfoncerait + la moitié dans le plan). Main ouverte (hotspot au haut de la paume) : 0,86. +- **Point le plus bas** : le hotspot est posé à `garde + lift`, où + `lift = épaisseur·cos(tangage) − y_haut·sin(tangage)` et `y_haut` = haut de la silhouette (tiré + du champ) relatif au hotspot. Le point le plus bas du modèle posé est donc à la garde au + repos, au plan au contact, jamais dessous. Mesuré sur huit états, à plat et iso : 0 pour les + états centrés (face du dessous au sol), +1,6 à +1,8 % d'unité pour les pointeurs (le chanfrein + arrondit le coin qui touche), sous le seuil testé de 2 %. +- **Pas de rebond d'échelle** en 3D : le contact le remplace. +- **Écrasement** : sur la même courbe `tap()`, l'épaisseur descend à 70 % au creux, puis le + rebond l'épaissit un instant (104,8 % à 165 ms). Fois `clickBounce / 2,5`, jamais sous 55 % + (deux chanfreins et un peu de flanc). Le dessus descend, le point le plus bas reste posé, le + hotspot reste sur son pixel. L'empreinte, elle, ne change pas : un étalement de 5 % défaisait + l'égalité « curseur centré posé = son sprite » de B.3. + +### B.5.1 Le contact tombe sur le pixel cliqué + +**Ce qui clochait.** L'application passe au compositeur la piste **lissée** (`smoothed`, le +ressort du réglage « smoothing ») ; les clics gardent leurs instants bruts, mais la piste lissée +traîne derrière la souris. Le modèle se posait donc à côté de la cible. Mesuré par +`the_modelled_tip_touches_the_raw_click_pixel` (scène dorée 1170×658, recadrée, zoom 2), écart +entre la pointe au contact et le pixel du clic brut : + +| geste | lissage 0 | 0,25 | 0,5 | 1 | +|---|---|---|---|---| +| arrêt de 150 ms autour du clic, arrivée à 0,5 écran/s | 0 px | 6 à 8 px | 58 à 71 px | 105 à 127 px | +| idem, arrivée à 2 écrans/s | 0 px | 22 à 27 px | 137 à 166 px | 208 à 251 px | +| clic au vol (la souris repart aussitôt), 0,5 écran/s | 75 à 91 px | 59 à 72 px | 153 à 185 px | 209 à 252 px | + +S'y ajoutait la traînée de flou, étalée de 2 à 16 copies pendant le contact. + +**Correction** (`CursorTrack::pinned_at`, fonction pure de `t`). La piste garde la position +brute de chaque clic (`click_points`, conservée par `smoothed`). Le curseur modélisé est posé sur +`at(t)` tiré vers ce point : poids en smoothstep sur les 250 ms qui précèdent le clic, 1 pendant +les 100 ms qui suivent (le contact, 27 à 74 ms, plus une image à 24 i/s), retour en smoothstep +sur 250 ms. Continue et de pente continue ; hors de ces fenêtres, `at(t)` à l'identique. La +traînée lit la même position, donc se replie sur la tête pendant le contact. Le lacet vise aussi +le point brut. + +Après : 0,0001 px au pire, sur les cinq caméras (plat, `iso`, `left`, `right` avec impact du +plan, `follow-cursor`), quatre lissages, deux vitesses, avec et sans arrêt, flèche et main. Sur +le rendu D3D11 d'une maquette dont les cibles portent une pastille rouge +(`tests/cursor_tap_render.rs`, lissage 0,5) : la pointe est à 0,13 à 0,35 px du centre mesuré de +la pastille, à plat, en `iso` et sous la caméra réelle, et la couvre. + +Le sprite plat garde `at(t)` : son rendu reste celui d'avant à l'octet. + +### B.5.2 L'impact du clic (mode 16) + +Sous le curseur modélisé, chaque clic laisse une trace **sur** l'écran, centrée sur le point +cliqué brut : + +- **tache de pression** : un disque sombre gaussien (rayon 0,3 du carré, opacité 0,3), qui + apparaît au creux et s'éteint en ~180 ms ; +- **anneau** : un trait blanc net (demi-épaisseur 5 % du carré, qui s'amincit de moitié) bordé + d'un halo sombre doux (opacité 0,3), qui part de 12 % du carré et s'arrête à 80 % en + décélérant (cubique), et s'éteint en `(1 − u)²`. Le blanc se lit sur un contenu coloré ou + sombre, le halo sur un fond blanc. + +**Temps** : il part du creux de la pression (49,5 ms après le clic, l'instant du contact de +`tap()`, `bounce()` et de l'impact du plan) et dure 400 ms. À 30 i/s, la première image du +contact (33 ms) montre le curseur posé, la suivante (67 ms) l'anneau naissant. +**Taille** : le carré a un demi-côté de 0,7 taille de curseur (l'anneau finit donc à 0,56 +taille, ~40 px pour un curseur de taille 4 en 720p). **Réglage** : `clickBounce / 2,5` +multiplie les opacités (plafond 1) et règle la taille (`0,75 + 0,25 × force`) ; 0 = rien. +Seulement quand `model3d` est allumé (un curseur sans modèle n'a pas de contact). + +**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 +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`). +Dessiné sous le curseur et sa traînée ; le backend logiciel le garde. Emplacements du cbuffer : +en tête de la section « Impact du clic » de `frame_geometry.rs`. + +Vérifié : centre du carré sur le pixel du clic à 0,05 px près (plat, `iso`, `follow-cursor`) ; +sur le rendu D3D11 et lavapipe, l'écart dû à l'anneau culmine au même rayon au-dessus et à gauche +de la pastille ; plus rien après sa fenêtre. + +### B.6 Boîte, traînée, coût + +- **Boîte de dessin** : les huit coins de la boîte du modèle (le rect du sprite sur toute + l'épaisseur), et leur projection au sol le long de la lumière, élargie de la pénombre + (`min(t/k, 0,45) / lz`) et du contact. Un miroir CPU du shader, champ compris, vérifie + qu'aucun pixel du modèle ni d'ombre n'en sort, pour huit états et quatre plans. +- **Traînée** : des copies du modèle sur GPU. Le backend logiciel ne dessine que la tête + (`CursorPlan::for_backend`) : sur WARP la traînée de la flèche analytique coûtait déjà +68 à + +98 ms. +- **Coût** (1080p, RTX 4070 Ti, meilleur de cinq passes de 100 frames, readback compris) : + curseur net (taille 3 ou 10, flèche ou I) dans le bruit, à ±0,15 ms du sprite ; taille 10 avec + 16 copies, +0,22 à +0,38 ms/frame, contre +0,66 à +1,1 ms pour la flèche analytique mesurée + 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é + 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 + copie pendant le contact. + +### B.7 Limites + +- Seul le thème par défaut est modélisé ; les thèmes sweezy (art de 128 px, bords non + détourés) restent plats. +- Un pointeur basculé montre le flanc de sa queue, de la couleur de son bord : la main qui + pointe gagne un liseré noir au bas de la paume. C'est la 3D, pas un défaut. +- Un dessus plat (curseur centré) reçoit 0,88 de la lumière : son blanc sort gris clair (226), + là où le dessus penché de la flèche sort blanc. Mêmes constantes d'éclairage qu'avant. +- Le repli math de Windows (mode 4, sans sprite) reste plat. +- L'ombre s'arrête au bord du rect de l'écran, pas à ses coins arrondis. +- Le MSL n'est compilé et exécuté que par la CI macOS. +- Le sprite plat et l'impact du plan (`regions::click_impact`, qui lit `at(t_c)` sur la piste + lissée) ne convergent pas sur le point brut : leur rendu reste celui d'avant à l'octet. Sous un + lissage fort, le rebond du sprite plat tombe donc à côté de la cible. +- L'impact du mode 16 ne passe pas par les portes de celui du plan (vitesse ≥ 2×, flou de + confidentialité visible, fenêtre du clip). + +--- + +## C. Découpage en PR + +| PR | Titre | Contenu | Dépend de | +|---|---|---|---| +| **6** | `feat(zoom): add the follow-cursor 3D camera` | `rotationPreset` étendu (`follow-cursor`), caméra réelle dans `camera.rs`, warp projectif (modes 8, 10, 13, 14), un seul sélecteur, i18n ×14 | chantier 3D (PR 1, 2b) | +| **7** | `feat(cursor): model the default arrow in 3D` | `cursor.model3d`, mode 15, pose, ombre, retrait de `volume`/`hover` | PR 6 | +| **7b** | `feat(cursor): model every default cursor state in 3D` | champ de distance tiré de chaque sprite (`cursor_sdf.rs`), mode 15 générique, pose selon le hotspot, i18n ×14 | 7 | +| **7c** | `feat(cursor): tap the screen where the click happened` | convergence sur le point cliqué brut, écrasement, impact (mode 16), vidéo de revue | 7b | +| **8** | `feat(zoom): dolly the camera` | distance de fuite par région, dolly-zoom | PR 6 | +| **9** | `feat(cursor): the cursor picks things up` *(plus tard)* | long press : le plan reste pressé pendant un glisser (v1 §PR 2b « Later ») | 7 | + +La PR 6 touche surtout la géométrie partagée par les trois backends, testable sans GPU ; ses +shaders n'ajoutent qu'un warp projectif et une lampe, derrière un drapeau que les angles fixes +laissent à 0 (rendu inchangé à l'octet). Le passage à l'orbite n'a touché que `camera.rs`, le +cadreur et l'impact du clic : ni shader ni emplacement du mode 15. + +La PR 7 ajoute un mode de shader, donc elle se vérifie sur les trois backends : tests unitaires +Rust de la pose, du contact, de l'ancrage et de la boîte ; rendus D3D11 sur une frame NV12 +synthétique ; le même test de rendu dans les modules Linux (lavapipe) et macOS (CI). + +**État** : PR 6, 7, 7b et 7c écrites et testées. 8 et 9 restent à faire. diff --git a/electron/ai-edition/style-preset-service.test.ts b/electron/ai-edition/style-preset-service.test.ts index 2523377d9..43bfa9218 100644 --- a/electron/ai-edition/style-preset-service.test.ts +++ b/electron/ai-edition/style-preset-service.test.ts @@ -29,7 +29,7 @@ const APPEARANCE: StylePresetAppearance = { smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, - volume: 0, + model3d: false, clipToBounds: false, }, cursorShow: true, diff --git a/electron/ipc/nativeBridge.presets.test.ts b/electron/ipc/nativeBridge.presets.test.ts index 06cc85e1e..efdda0feb 100644 --- a/electron/ipc/nativeBridge.presets.test.ts +++ b/electron/ipc/nativeBridge.presets.test.ts @@ -49,7 +49,7 @@ const APPEARANCE: StylePresetAppearance = { smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, - volume: 0, + model3d: false, clipToBounds: false, }, cursorShow: true, diff --git a/src/components/ai-edition/RightPanes.i18n.test.tsx b/src/components/ai-edition/RightPanes.i18n.test.tsx index 42a364e39..f0522fb5c 100644 --- a/src/components/ai-edition/RightPanes.i18n.test.tsx +++ b/src/components/ai-edition/RightPanes.i18n.test.tsx @@ -71,6 +71,7 @@ describe("right-rail panes are localized", () => { expect(screen.getByText("Lissage")).toBeInTheDocument(); expect(screen.getByText("Rebond au clic")).toBeInTheDocument(); expect(screen.getByText("Masquer si inactif")).toBeInTheDocument(); + expect(screen.getByText("Curseur 3D")).toBeInTheDocument(); }); it("renders the cursor pane in German", () => { diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index e16fcfb4b..2971fec33 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -3453,6 +3453,30 @@ export function CursorPane() { onChange={(v) => void set({ cursor: { clipToBounds: v } })} />
+ {/* One switch for the modelled cursor. A hidden cursor has nothing to model, so the + row is disabled then and both its hint and its tooltip say why. */} +
+ + {ts("cursor.model3d")} + + {settings.cursorShow ? ts("cursor.model3dHint") : ts("cursor.model3dNeedsCursor")} + + + { + void set({ cursor: { model3d: v } }); + if (isNativeCompositorActive()) { + setNativeParam("cursorModel3d", v); + } + }} + /> +
{ts("cursor.theme")}
{cursorThemeOptions.map((option) => { @@ -3555,24 +3579,6 @@ export function CursorPane() { onCommit={() => void commit()} /> ) : null} - {/* 0..1 in storage, percent on screen. With the cursor hidden there is - nothing to extrude, so the slider is disabled and its tooltip says why. */} - { - setLive({ cursor: { volume: v / 100 } }); - if (isNativeCompositorActive()) { - setNativeParam("cursorVolume", v / 100); - } - }} - onCommit={() => void commit()} - />
); @@ -3625,7 +3631,6 @@ export function SliderCell({ onCommit, showValue = true, full = false, - title, }: { label: string; value: number; @@ -3641,12 +3646,10 @@ export function SliderCell({ * l'interpolent), sans quoi elle s'affiche deux fois. */ showValue?: boolean; full?: boolean; - /** Tooltip on the whole cell — how a disabled slider says why it is disabled. */ - title?: string; }) { const pct = Math.max(0, Math.min(100, max > min ? ((value - min) / (max - min)) * 100 : 0)); return ( -
+
{label} {showValue ? ( diff --git a/src/components/ai-edition/v4/FloatingInspector.test.tsx b/src/components/ai-edition/v4/FloatingInspector.test.tsx index fdf8cd5d7..093e3449d 100644 --- a/src/components/ai-edition/v4/FloatingInspector.test.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; vi.mock("@/contexts/I18nContext", () => ({ useScopedT: (scope: string) => (key: string) => `${scope}.${key}`, @@ -133,6 +134,16 @@ describe("FloatingInspector", () => { expect(screen.getByText("settings.zoom.clickImpact.needsCursor")).toBeInTheDocument(); }); + it("says the orbiting camera recoils on a click, since its screen stays still", () => { + const { tl, updateZoomClickImpact } = zoomTl({ rotationPreset: "follow-cursor" }); + render(); + const box = screen.getByRole("checkbox", { name: "settings.zoom.clickImpact.title" }); + expect(box).toBeEnabled(); + expect(screen.getByText("settings.zoom.clickImpact.descriptionCamera")).toBeInTheDocument(); + fireEvent.click(box); + expect(updateZoomClickImpact).toHaveBeenCalledWith("z", true); + }); + it("is disabled with its reason when the cursor is hidden globally", () => { editorSettings.cursorShow = false; try { @@ -157,4 +168,69 @@ describe("FloatingInspector", () => { expect(updateZoomClickImpact).toHaveBeenCalledWith("z", true); }); }); + + describe("3D camera select", () => { + const zoomTl = (region: Record) => { + const updateZoomRotation = vi.fn(); + const tl = { + ...defaultProps.tl, + selection: { kind: "zoom", id: "z" }, + zoomRegions: [ + { id: "z", startMs: 0, endMs: 1000, depth: 3, focus: { cx: 0.5, cy: 0.5 }, ...region }, + ], + updateZoomRotation, + } as unknown as React.ComponentProps["tl"]; + return { tl, updateZoomRotation }; + }; + + afterEach(() => { + useProjectStore.setState({ document: null }); + }); + + it("is the only 3D select, off by default, and says what off does", () => { + const { tl } = zoomTl({}); + render(); + const select = screen.getByRole("combobox", { name: "settings.zoom.camera.title" }); + expect(select).toHaveValue("off"); + expect(screen.getByText("settings.zoom.camera.description.off")).toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: /cameraMotion|threeD/ })).toBeNull(); + }); + + it("groups the fixed angles apart from the moving cameras", () => { + const { tl } = zoomTl({}); + render(); + const select = screen.getByRole("combobox", { name: "settings.zoom.camera.title" }); + const groups = [...select.querySelectorAll("optgroup")].map((g) => [ + g.label, + [...g.querySelectorAll("option")].map((o) => o.value), + ]); + expect(groups).toEqual([ + ["settings.zoom.camera.fixed", ["iso", "left", "right"]], + ["settings.zoom.camera.moving", ["follow-cursor"]], + ]); + }); + + it("writes the camera into rotationPreset, and off by absence", () => { + const { tl, updateZoomRotation } = zoomTl({ rotationPreset: "follow-cursor" }); + render(); + 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: "off" } }); + expect(updateZoomRotation).toHaveBeenLastCalledWith("z", undefined); + }); + + it("says a cursor-driven camera has nothing to follow while the cursor is hidden", () => { + editorSettings.cursorShow = false; + try { + const { tl } = zoomTl({ rotationPreset: "follow-cursor" }); + render(); + expect(screen.getByText("settings.zoom.camera.needsCursor")).toBeInTheDocument(); + } finally { + editorSettings.cursorShow = true; + } + }); + }); }); diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx index 008d36ba5..42ae170cd 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -17,7 +17,11 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { parseCustomPlaybackSpeedInput } from "@/components/video-editor/customPlaybackSpeed"; import { + FIXED_ROTATION_3D_PRESETS, + isRotation3DPreset, MAX_PLAYBACK_SPEED, + MOVING_ROTATION_3D_PRESETS, + type Rotation3DPreset, SPEED_OPTIONS, ZOOM_DEPTH_SCALES, } from "@/components/video-editor/types"; @@ -303,6 +307,14 @@ function paneRow(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", +}; + /** « Click impact » : une case à cocher, et dessous ce qu'elle fait — ou pourquoi elle ne peut * rien faire ici. */ function ClickImpactToggle({ @@ -580,32 +592,58 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void } ))} , )} - {paneRow( - ts("zoom.threeD.title"), - + void tl.updateZoomRotation( + region.id, + // "off" is the absence of a preset — the schema field is optional and + // `migrate.ts` drops it when falsy. + isRotation3DPreset(e.target.value) ? e.target.value : undefined, + ) + } + style={selectStyle} + > + + + {FIXED_ROTATION_3D_PRESETS.map((preset) => ( + + ))} + + + {MOVING_ROTATION_3D_PRESETS.map((preset) => ( + + ))} + + , + )} +

+ { + // A moving camera reads the cursor track, which the export only loads while the + // cursor is shown: say so rather than offer a camera that silently holds still. + !settings.cursorShow && region.rotationPreset === "follow-cursor" + ? ts("zoom.camera.needsCursor") + : ts( + `zoom.camera.description.${region.rotationPreset ? CAMERA_KEYS[region.rotationPreset] : "off"}`, + ) } - style={selectStyle} - > - - - - - , - )} +

+
void } : null } label={ts("zoom.clickImpact.title")} - description={ts("zoom.clickImpact.description")} + description={ts( + region.rotationPreset === "follow-cursor" + ? "zoom.clickImpact.descriptionCamera" + : "zoom.clickImpact.description", + )} onChange={(on) => void tl.updateZoomClickImpact(region.id, on)} /> {paneRow( diff --git a/src/components/video-editor/editorDefaults.ts b/src/components/video-editor/editorDefaults.ts index 59d95a5bb..3a4b37369 100644 --- a/src/components/video-editor/editorDefaults.ts +++ b/src/components/video-editor/editorDefaults.ts @@ -8,10 +8,10 @@ import { DEFAULT_CURSOR_AUTO_HIDE, DEFAULT_CURSOR_CLICK_BOUNCE, DEFAULT_CURSOR_CLIP_TO_BOUNDS, + DEFAULT_CURSOR_MODEL3D, DEFAULT_CURSOR_MOTION_BLUR, DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_SMOOTHING, - DEFAULT_CURSOR_VOLUME, DEFAULT_WEBCAM_LAYOUT_PRESET, DEFAULT_WEBCAM_MASK_SHAPE, DEFAULT_WEBCAM_POSITION, @@ -77,7 +77,7 @@ export const DEFAULT_CURSOR_SETTINGS: CursorVisualSettings & { show: boolean; th smoothing: DEFAULT_CURSOR_SMOOTHING, motionBlur: DEFAULT_CURSOR_MOTION_BLUR, clickBounce: DEFAULT_CURSOR_CLICK_BOUNCE, - volume: DEFAULT_CURSOR_VOLUME, + model3d: DEFAULT_CURSOR_MODEL3D, clipToBounds: DEFAULT_CURSOR_CLIP_TO_BOUNDS, theme: DEFAULT_CURSOR_THEME_ID, }; diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 34f9cd602..8aa7f9992 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -33,6 +33,7 @@ import { DEFAULT_WEBCAM_REACTIVE_ZOOM, DEFAULT_ZOOM_DEPTH, DEFAULT_ZOOM_MOTION_BLUR, + isRotation3DPreset, isWallpaperMotion, MAX_BLUR_BLOCK_SIZE, MAX_BLUR_INTENSITY, @@ -258,12 +259,9 @@ export function normalizeProjectEditor(editor: Partial): Pro const startMs = Math.max(0, Math.min(rawStart, rawEnd)); const endMs = Math.max(startMs + 1, rawEnd); - const validPreset = - region.rotationPreset === "iso" || - region.rotationPreset === "left" || - region.rotationPreset === "right" - ? region.rotationPreset - : undefined; + const validPreset = isRotation3DPreset(region.rotationPreset) + ? region.rotationPreset + : undefined; return { id: region.id, startMs, diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index e6bfdd795..426d531d1 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -75,7 +75,30 @@ export const DEFAULT_ROTATION_3D: Rotation3D = { rotationZ: 0, }; -export type Rotation3DPreset = "iso" | "left" | "right"; +/** 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 type FixedRotation3DPreset = (typeof FIXED_ROTATION_3D_PRESETS)[number]; + +/** + * A moving 3D camera. `follow-cursor` keeps the screen still and moves a real camera around it: + * the camera orbits to the side the cursor is on and rises or dips with it, always level. The + * native compositor renders it (`crates/compositor/src/camera.rs`). It needs the cursor track, + * which the export only loads while the cursor is shown. + */ +export const MOVING_ROTATION_3D_PRESETS = ["follow-cursor"] as const; +export type MovingRotation3DPreset = (typeof MOVING_ROTATION_3D_PRESETS)[number]; + +/** The zoom's "3D camera": absent means a flat screen. */ +export type Rotation3DPreset = FixedRotation3DPreset | MovingRotation3DPreset; + +export const ROTATION_3D_PRESET_ORDER: Rotation3DPreset[] = [ + ...FIXED_ROTATION_3D_PRESETS, + ...MOVING_ROTATION_3D_PRESETS, +]; + +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, @@ -83,14 +106,19 @@ export type Rotation3DPreset = "iso" | "left" | "right"; // 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 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 +// the cursor track draws. A camera above tips the top edge toward the viewer, which is a negative X +// 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 }, + "follow-cursor": { rotationX: -4, rotationY: 0, rotationZ: 0 }, }; -export const ROTATION_3D_PRESET_ORDER: Rotation3DPreset[] = ["iso", "left", "right"]; - /** Perspective distance in CSS px is this factor times min(viewport w, h). Same * factor in preview and export so the look matches at any canvas resolution. * Lower = camera closer = edges converge more visibly. At 2.6 the convergence was so flat that @@ -121,7 +149,7 @@ export interface ZoomRegion { source?: ZoomRegionSource; /** When true, cursor is hidden during this zoom region. */ hideCursor?: boolean; - /** When true, each click presses the tilted plane (needs `rotationPreset`). Omitted when off. */ + /** When true, each click presses the tilted plane, or recoils the `follow-cursor` camera (needs a `rotationPreset`). Omitted when off. */ clickImpact?: true; } @@ -241,8 +269,12 @@ export interface CursorVisualSettings { smoothing: number; motionBlur: number; clickBounce: number; - /** 0..1 — extrudes the cursor along the screen's normal and adds a contact shadow. */ - volume: number; + /** + * Draws every default-theme cursor shape as a modelled 3D object (compositor mode 15, its + * sprite extruded): it hovers over the screen, casts a real shadow on it and touches down on + * each click; pointing shapes also lean towards their motion. Other themes stay flat sprites. + */ + model3d: boolean; clipToBounds: boolean; autoHide?: boolean; } @@ -251,8 +283,8 @@ export const DEFAULT_CURSOR_SIZE = 3.0; export const DEFAULT_CURSOR_SMOOTHING = 0.67; export const DEFAULT_CURSOR_MOTION_BLUR = 0.35; export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5; -// Off: a flat sprite, exactly the cursor every existing project renders. -export const DEFAULT_CURSOR_VOLUME = 0; +// Off: the flat sprite every existing project renders. +export const DEFAULT_CURSOR_MODEL3D = false; // false lets the cursor overflow into the background; true clips it to the canvas bounds. export const DEFAULT_CURSOR_CLIP_TO_BOUNDS = false; export const DEFAULT_CURSOR_AUTO_HIDE = false; diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 45985b7b3..496c1693e 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -337,20 +337,32 @@ "autoDescription": "الكاميرا تتبع موضع المؤشر المسجل", "title": "وضع التركيز" }, - "threeD": { + "camera": { + "title": "كاميرا ثلاثية الأبعاد", + "off": "إيقاف", + "fixed": "زاوية ثابتة", + "moving": "كاميرا متحركة", "preset": { - "left": "يسار", - "right": "يمين", - "iso": "متساوي القياس" + "iso": "مائلة من الأعلى", + "left": "الشاشة مستديرة لليسار", + "right": "الشاشة مستديرة لليمين", + "followCursor": "يدور مع المؤشر" }, - "none": "بلا", - "title": "دوران ثلاثي الأبعاد" + "description": { + "off": "تبقى الشاشة مسطحة.", + "iso": "وضعية واحدة ثابتة: مستديرة لليسار ومرئية من الأعلى.", + "left": "وضعية واحدة ثابتة: الشاشة تتجه إلى اليسار وحافتها اليمنى تتقدم.", + "right": "وضعية واحدة ثابتة: الشاشة تتجه إلى اليمين وحافتها اليسرى تتقدم.", + "followCursor": "تبقى الشاشة ثابتة، وتدور الكاميرا حولها نحو جهة المؤشر وترتفع أو تنخفض معه." + }, + "needsCursor": "المؤشر مخفي: لا شيء تتبعه هذه الكاميرا، فتحافظ على زاويتها الابتدائية." }, "clickImpact": { "title": "أثر النقر", "description": "كل نقرة تضغط الشاشة المائلة: ينخفض الجانب المنقور ثم يعود.", - "needsRotation": "اختر دورانًا ثلاثي الأبعاد أولًا: لا يمكن ضغط إلا شاشة مائلة.", - "needsCursor": "المؤشر مخفي: لا تتفاعل الشاشة إلا مع النقرات المرئية." + "needsRotation": "اختر كاميرا ثلاثية الأبعاد أولًا: لا يمكن ضغط إلا شاشة مائلة.", + "needsCursor": "المؤشر مخفي: لا تتفاعل الشاشة إلا مع النقرات المرئية.", + "descriptionCamera": "كل نقرة تُبعد الكاميرا إلى الخلف قليلًا ثم تعود." }, "level": "مستوى التكبير", "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير", @@ -384,8 +396,9 @@ "cursor": { "smoothing": "التنعيم", "clickBounce": "ارتداد النقر", - "depth": "عمق ثلاثي الأبعاد", - "depthNeedsCursor": "فعّل «إظهار المؤشر» لإضافة عمق إليه.", + "model3d": "مؤشر ثلاثي الأبعاد", + "model3dHint": "النمط الافتراضي: تصبح كل أشكال المؤشر ثلاثية الأبعاد. تبقى الأنماط الأخرى مسطحة.", + "model3dNeedsCursor": "فعّل «إظهار المؤشر» لاستخدام المؤشر ثلاثي الأبعاد.", "help": "عرض المؤشر اعتمادًا على بيانات التتبع المسجّلة: السمة، والحجم، والتنعيم، وضبابية الحركة، وارتداد النقر.", "clipToBoundsDescription": "يبقي المؤشر داخل إطار الفيديو. أوقف التشغيل للسماح للمؤشر بتجاوز الحواف - مفيد عند التكبير أو التحريك.", "size": "الحجم", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index 7dd2703e4..6d44dee1d 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -337,20 +337,32 @@ "autoDescription": "Kamera sleduje zaznamenanou pozici kurzoru", "title": "Režim zaostření" }, - "threeD": { + "camera": { + "title": "3D kamera", + "off": "Vypnuto", + "fixed": "Pevný úhel", + "moving": "Pohyblivá kamera", "preset": { - "left": "Vlevo", - "right": "Vpravo", - "iso": "Iso" + "iso": "Šikmo shora", + "left": "Obrazovka natočená doleva", + "right": "Obrazovka natočená doprava", + "followCursor": "Obíhá s kurzorem" }, - "none": "Žádné", - "title": "3D rotace" + "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.", + "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." }, "clickImpact": { "title": "Odezva kliknutí", "description": "Každé kliknutí zatlačí nakloněnou obrazovku: kliknutá strana se prohne a vrátí zpět.", - "needsRotation": "Nejprve zvolte 3D rotaci: zatlačit lze jen nakloněnou obrazovku.", - "needsCursor": "Kurzor je skrytý: obrazovka reaguje jen na viditelná kliknutí." + "needsRotation": "Nejprve zvolte 3D kameru: zatlačit lze jen nakloněnou obrazovku.", + "needsCursor": "Kurzor je skrytý: obrazovka reaguje jen na viditelná kliknutí.", + "descriptionCamera": "Každé kliknutí kameru trochu oddálí, pak se vrátí." }, "level": "Úroveň přiblížení", "previewHold": "Podržte pro náhled efektu přiblížení", @@ -384,8 +396,9 @@ "cursor": { "smoothing": "Vyhlazení", "clickBounce": "Odraz při kliknutí", - "depth": "3D hloubka", - "depthNeedsCursor": "Zapněte „Zobrazit kurzor“, aby kurzor získal hloubku.", + "model3d": "3D kurzor", + "model3dHint": "Výchozí styl: všechny tvary kurzoru budou 3D. Ostatní styly zůstanou ploché.", + "model3dNeedsCursor": "Zapněte „Zobrazit kurzor“, abyste mohli použít 3D kurzor.", "help": "Renderování kurzoru ze zaznamenané telemetrie: motiv, velikost, vyhlazení, pohybové rozmazání a zdůraznění odrazu při kliknutí.", "clipToBoundsDescription": "Udržuje kurzor uvnitř rámce videa. Vypněte, pokud chcete, aby kurzor přesahoval okraje, což se hodí při přiblížení nebo posunu.", "size": "Velikost", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 849886391..b5695016c 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -337,20 +337,32 @@ "autoDescription": "Camera follows the recorded cursor position", "title": "Focus Mode" }, - "threeD": { + "camera": { + "title": "3D camera", + "off": "Off", + "fixed": "Fixed angle", + "moving": "Moving camera", "preset": { - "left": "Left", - "right": "Right", - "iso": "Iso" + "iso": "Angled from above", + "left": "Screen turned left", + "right": "Screen turned right", + "followCursor": "Orbits with the cursor" }, - "none": "None", - "title": "3D Rotation" + "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.", + "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." }, "clickImpact": { "title": "Click impact", "description": "Each click presses the tilted screen: the clicked side dips, then springs back.", - "needsRotation": "Pick a 3D rotation first: only a tilted screen can be pressed.", - "needsCursor": "The cursor is hidden: the screen only reacts to clicks you can see." + "needsRotation": "Pick a 3D camera first: only a tilted screen can be pressed.", + "needsCursor": "The cursor is hidden: the screen only reacts to clicks you can see.", + "descriptionCamera": "Each click pushes the camera back a little, then it springs forward." }, "level": "Zoom Level", "previewHold": "Hold to preview zoom effect", @@ -384,8 +396,9 @@ "cursor": { "smoothing": "Smoothing", "clickBounce": "Click Bounce", - "depth": "3D Depth", - "depthNeedsCursor": "Turn on Show Cursor to give it depth.", + "model3d": "3D cursor", + "model3dHint": "Default style: every cursor shape turns 3D. Other styles stay flat.", + "model3dNeedsCursor": "Turn on Show Cursor to use the 3D cursor.", "help": "Cursor rendering from the recorded telemetry: theme, size, smoothing, motion blur, and click-bounce emphasis.", "clipToBoundsDescription": "Keeps the cursor inside the video frame. Turn off to let the cursor extend past the edges - useful when zoomed in or panned.", "size": "Size", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 91f1aa633..29ac8fd5f 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -337,20 +337,32 @@ "autoDescription": "La cámara sigue la posición del cursor grabado", "title": "Modo de enfoque" }, - "threeD": { + "camera": { + "title": "Cámara 3D", + "off": "Desactivada", + "fixed": "Ángulo fijo", + "moving": "Cámara en movimiento", "preset": { - "left": "Izquierda", - "right": "Derecha", - "iso": "Iso" + "iso": "Picado en diagonal", + "left": "Pantalla girada a la izquierda", + "right": "Pantalla girada a la derecha", + "followCursor": "Gira con el cursor" }, - "none": "Ninguna", - "title": "Rotación 3D" + "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.", + "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." }, "clickImpact": { "title": "Impacto del clic", "description": "Cada clic presiona la pantalla inclinada: el lado pulsado se hunde y luego vuelve.", - "needsRotation": "Elige primero una rotación 3D: solo se puede presionar una pantalla inclinada.", - "needsCursor": "El cursor está oculto: la pantalla solo reacciona a los clics visibles." + "needsRotation": "Elige primero una cámara 3D: solo se puede presionar una pantalla inclinada.", + "needsCursor": "El cursor está oculto: la pantalla solo reacciona a los clics visibles.", + "descriptionCamera": "Cada clic hace retroceder un poco la cámara, que luego vuelve." }, "level": "Nivel de zoom", "previewHold": "Mantener para previsualizar el efecto de zoom", @@ -384,8 +396,9 @@ "cursor": { "smoothing": "Suavizado", "clickBounce": "Rebote al clic", - "depth": "Profundidad 3D", - "depthNeedsCursor": "Activa «Mostrar cursor» para darle profundidad.", + "model3d": "Cursor 3D", + "model3dHint": "Estilo predeterminado: todas las formas del cursor pasan a 3D. Los demás estilos siguen planos.", + "model3dNeedsCursor": "Activa «Mostrar cursor» para usar el cursor 3D.", "help": "Representación del cursor a partir de la telemetría grabada: tema, tamaño, suavizado, desenfoque de movimiento y rebote al hacer clic.", "clipToBoundsDescription": "Mantiene el cursor dentro del cuadro del vídeo. Desactívalo para dejar que el cursor sobrepase los bordes; útil al hacer zoom o desplazar la imagen.", "size": "Tamaño", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 025796431..f06bf6107 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -337,20 +337,32 @@ "autoDescription": "La caméra suit la position du curseur enregistré", "title": "Mode focus" }, - "threeD": { + "camera": { + "title": "Caméra 3D", + "off": "Désactivée", + "fixed": "Angle fixe", + "moving": "Caméra mobile", "preset": { - "left": "Gauche", - "right": "Droite", - "iso": "Iso" + "iso": "Plongée de biais", + "left": "Écran tourné à gauche", + "right": "Écran tourné à droite", + "followCursor": "Tourne avec le curseur" }, - "none": "Aucune", - "title": "Rotation 3D" + "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.", + "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." }, "clickImpact": { "title": "Impact du clic", "description": "Chaque clic enfonce l'écran incliné : le côté cliqué s'abaisse, puis revient.", - "needsRotation": "Choisissez d'abord une rotation 3D : seul un écran incliné peut être enfoncé.", - "needsCursor": "Le curseur est masqué : l'écran ne réagit qu'aux clics visibles." + "needsRotation": "Choisissez d'abord une caméra 3D : seul un écran incliné peut être enfoncé.", + "needsCursor": "Le curseur est masqué : l'écran ne réagit qu'aux clics visibles.", + "descriptionCamera": "Chaque clic fait reculer un peu la caméra, puis elle revient." }, "level": "Niveau de zoom", "previewHold": "Maintenir pour prévisualiser l'effet de zoom", @@ -384,8 +396,9 @@ "cursor": { "smoothing": "Lissage", "clickBounce": "Rebond au clic", - "depth": "Profondeur 3D", - "depthNeedsCursor": "Activez « Afficher le curseur » pour lui donner de la profondeur.", + "model3d": "Curseur 3D", + "model3dHint": "Style par défaut : toutes les formes du curseur passent en 3D. Les autres styles restent plats.", + "model3dNeedsCursor": "Activez « Afficher le curseur » pour utiliser le curseur 3D.", "help": "Rendu du curseur à partir de la télémétrie enregistrée : thème, taille, lissage, flou de mouvement et rebond au clic.", "clipToBoundsDescription": "Garde le curseur à l'intérieur du cadre vidéo. Désactivez pour laisser le curseur dépasser les bords — utile en cas de zoom ou de panoramique.", "size": "Taille", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 682f630c4..4554f537c 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -337,20 +337,32 @@ "autoDescription": "La fotocamera segue la posizione del cursore registrato", "title": "Modalità messa a fuoco" }, - "threeD": { + "camera": { + "title": "Camera 3D", + "off": "Disattivata", + "fixed": "Angolo fisso", + "moving": "Camera in movimento", "preset": { - "left": "Sinistra", - "right": "Destra", - "iso": "Iso" + "iso": "Dall'alto di sbieco", + "left": "Schermo ruotato a sinistra", + "right": "Schermo ruotato a destra", + "followCursor": "Ruota con il cursore" }, - "none": "Nessuna", - "title": "Rotazione 3D" + "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.", + "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." }, "clickImpact": { "title": "Impatto del clic", "description": "Ogni clic preme lo schermo inclinato: il lato cliccato si abbassa, poi torna.", - "needsRotation": "Scegli prima una rotazione 3D: si può premere solo uno schermo inclinato.", - "needsCursor": "Il cursore è nascosto: lo schermo reagisce solo ai clic visibili." + "needsRotation": "Scegli prima una camera 3D: si può premere solo uno schermo inclinato.", + "needsCursor": "Il cursore è nascosto: lo schermo reagisce solo ai clic visibili.", + "descriptionCamera": "Ogni clic fa arretrare un po' la telecamera, che poi torna avanti." }, "level": "Livello zoom", "previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom", @@ -384,8 +396,9 @@ "cursor": { "smoothing": "Smussatura", "clickBounce": "Rimbalzo clic", - "depth": "Profondità 3D", - "depthNeedsCursor": "Attiva «Mostra cursore» per dargli profondità.", + "model3d": "Cursore 3D", + "model3dHint": "Stile predefinito: tutte le forme del cursore diventano 3D. Gli altri stili restano piatti.", + "model3dNeedsCursor": "Attiva «Mostra cursore» per usare il cursore 3D.", "help": "Resa del cursore dalla telemetria registrata: tema, dimensione, smussatura, sfocatura di movimento e rimbalzo al clic.", "clipToBoundsDescription": "Mantiene il cursore all'interno del fotogramma video. Disattiva per lasciare che il cursore superi i bordi — utile quando si esegue zoom o panoramica.", "size": "Dimensione", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 8124b1206..399b34c77 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -337,20 +337,32 @@ "autoDescription": "表示範囲が録画中のカーソル位置に追従します", "title": "フォーカスモード" }, - "threeD": { + "camera": { + "title": "3Dカメラ", + "off": "オフ", + "fixed": "固定アングル", + "moving": "動くカメラ", "preset": { - "left": "左", - "right": "右", - "iso": "Iso" + "iso": "斜め上から", + "left": "画面を左向きに", + "right": "画面を右向きに", + "followCursor": "カーソルに合わせて回り込む" }, - "none": "なし", - "title": "3D回転" + "description": { + "off": "画面は平らなままです。", + "iso": "1つの姿勢を保ちます:左向きで、上から見下ろします。", + "left": "1つの姿勢を保ちます:画面が左を向き、右端が手前に出ます。", + "right": "1つの姿勢を保ちます:画面が右を向き、左端が手前に出ます。", + "followCursor": "画面は動かず、カメラが画面の周りを回ってカーソルのある側へ移り、カーソルに合わせて上下します。" + }, + "needsCursor": "カーソルが非表示です:このカメラは追う対象がなく、最初のアングルのままです。" }, "clickImpact": { "title": "クリックの押し込み", "description": "クリックするたびに傾いた画面が押され、クリックした側が沈んでから戻ります。", - "needsRotation": "先に3D回転を選んでください。押し込めるのは傾いた画面だけです。", - "needsCursor": "カーソルが非表示です。画面は見えているクリックにだけ反応します。" + "needsRotation": "先に3Dカメラを選んでください。押し込めるのは傾いた画面だけです。", + "needsCursor": "カーソルが非表示です。画面は見えているクリックにだけ反応します。", + "descriptionCamera": "クリックのたびにカメラが少し後ろへ下がり、また戻ります。" }, "level": "ズーム倍率", "previewHold": "押している間ズーム効果をプレビュー", @@ -384,8 +396,9 @@ "cursor": { "smoothing": "スムージング", "clickBounce": "クリックバウンス", - "depth": "3D の奥行き", - "depthNeedsCursor": "奥行きを付けるには「カーソルを表示」をオンにしてください。", + "model3d": "3D カーソル", + "model3dHint": "デフォルトのスタイル:すべてのカーソル形状が 3D になります。その他のスタイルは平面のままです。", + "model3dNeedsCursor": "3D カーソルを使うには「カーソルを表示」をオンにしてください。", "help": "記録されたテレメトリからのカーソル描画: テーマ、サイズ、スムージング、モーションブラー、クリック時のバウンス。", "clipToBoundsDescription": "カーソルを映像フレーム内に保ちます。オフにするとカーソルが端からはみ出せるようになります。ズームやパン時に便利です。", "size": "サイズ", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index e019ac748..97a53c12c 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -337,20 +337,32 @@ "autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다", "title": "포커스 모드" }, - "threeD": { + "camera": { + "title": "3D 카메라", + "off": "끔", + "fixed": "고정 각도", + "moving": "움직이는 카메라", "preset": { - "left": "왼쪽", - "right": "오른쪽", - "iso": "Iso" + "iso": "위에서 비스듬히", + "left": "화면 왼쪽으로 돌림", + "right": "화면 오른쪽으로 돌림", + "followCursor": "커서를 따라 회전" }, - "none": "없음", - "title": "3D 회전" + "description": { + "off": "화면이 평평하게 유지됩니다.", + "iso": "한 자세를 유지합니다: 왼쪽으로 돌아가 있고 위에서 내려다봅니다.", + "left": "한 자세를 유지합니다: 화면이 왼쪽을 향하고 오른쪽 가장자리가 앞으로 나옵니다.", + "right": "한 자세를 유지합니다: 화면이 오른쪽을 향하고 왼쪽 가장자리가 앞으로 나옵니다.", + "followCursor": "화면은 고정된 채, 카메라가 화면 주위를 돌아 커서가 있는 쪽으로 가고 커서를 따라 오르내립니다." + }, + "needsCursor": "커서가 숨겨져 있습니다. 이 카메라는 따라갈 대상이 없어 시작 각도를 유지합니다." }, "clickImpact": { "title": "클릭 눌림", "description": "클릭할 때마다 기울어진 화면이 눌려 클릭한 쪽이 내려갔다가 돌아옵니다.", - "needsRotation": "먼저 3D 회전을 선택하세요. 기울어진 화면만 누를 수 있습니다.", - "needsCursor": "커서가 숨겨져 있습니다. 화면은 보이는 클릭에만 반응합니다." + "needsRotation": "먼저 3D 카메라를 선택하세요. 기울어진 화면만 누를 수 있습니다.", + "needsCursor": "커서가 숨겨져 있습니다. 화면은 보이는 클릭에만 반응합니다.", + "descriptionCamera": "클릭할 때마다 카메라가 살짝 뒤로 물러났다가 돌아옵니다." }, "level": "줌 레벨", "previewHold": "누르고 있으면 줌 효과 미리보기", @@ -384,8 +396,9 @@ "cursor": { "smoothing": "부드러움", "clickBounce": "클릭 바운스", - "depth": "3D 깊이", - "depthNeedsCursor": "깊이를 주려면 '커서 표시'를 켜세요.", + "model3d": "3D 커서", + "model3dHint": "기본 스타일: 모든 커서 모양이 3D로 바뀝니다. 다른 스타일은 평면으로 유지됩니다.", + "model3dNeedsCursor": "3D 커서를 사용하려면 '커서 표시'를 켜세요.", "help": "기록된 텔레메트리를 기반으로 한 커서 렌더링: 테마, 크기, 스무딩, 모션 블러, 클릭 바운스.", "clipToBoundsDescription": "커서를 비디오 프레임 안에 유지합니다. 꺼두면 확대하거나 이동할 때 커서가 가장자리를 벗어날 수 있습니다.", "size": "크기", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 01c3f945e..37755ccca 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -337,20 +337,32 @@ "autoDescription": "A câmera segue a posição do cursor gravado", "title": "Modo de Foco" }, - "threeD": { + "camera": { + "title": "Câmera 3D", + "off": "Desativada", + "fixed": "Ângulo fixo", + "moving": "Câmera em movimento", "preset": { - "left": "Esquerda", - "right": "Direita", - "iso": "Iso" + "iso": "De cima, na diagonal", + "left": "Tela virada para a esquerda", + "right": "Tela virada para a direita", + "followCursor": "Gira com o cursor" }, - "none": "Nenhuma", - "title": "Rotação 3D" + "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.", + "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." }, "clickImpact": { "title": "Impacto do clique", "description": "Cada clique pressiona a tela inclinada: o lado clicado afunda e depois volta.", - "needsRotation": "Escolha primeiro uma rotação 3D: só uma tela inclinada pode ser pressionada.", - "needsCursor": "O cursor está oculto: a tela só reage a cliques visíveis." + "needsRotation": "Escolha primeiro uma câmera 3D: só uma tela inclinada pode ser pressionada.", + "needsCursor": "O cursor está oculto: a tela só reage a cliques visíveis.", + "descriptionCamera": "Cada clique faz a câmera recuar um pouco e depois voltar." }, "level": "Nível de Zoom", "previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom", @@ -384,8 +396,9 @@ "cursor": { "smoothing": "Suavização", "clickBounce": "Rebote ao clicar", - "depth": "Profundidade 3D", - "depthNeedsCursor": "Ative “Mostrar cursor” para dar profundidade a ele.", + "model3d": "Cursor 3D", + "model3dHint": "Estilo padrão: todas as formas do cursor ficam em 3D. Os outros estilos continuam planos.", + "model3dNeedsCursor": "Ative “Mostrar cursor” para usar o cursor 3D.", "help": "Renderização do cursor a partir da telemetria gravada: tema, tamanho, suavização, desfoque de movimento e salto ao clicar.", "clipToBoundsDescription": "Mantém o cursor dentro do quadro do vídeo. Desative para permitir que o cursor ultrapasse as bordas — útil ao aplicar zoom ou ao deslocar.", "size": "Tamanho", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index f10a40d3f..f02832acb 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -337,20 +337,32 @@ "autoDescription": "Камера следует за записанной позицией курсора", "title": "Режим фокуса" }, - "threeD": { + "camera": { + "title": "3D-камера", + "off": "Выкл.", + "fixed": "Фиксированный ракурс", + "moving": "Подвижная камера", "preset": { - "left": "Слева", - "right": "Справа", - "iso": "Изометрия" + "iso": "Сверху под углом", + "left": "Экран повёрнут влево", + "right": "Экран повёрнут вправо", + "followCursor": "Облетает за курсором" }, - "none": "Нет", - "title": "3D вращение" + "description": { + "off": "Экран остаётся плоским.", + "iso": "Одна поза: экран повёрнут влево и виден сверху.", + "left": "Одна поза: экран смотрит влево, его правый край выдвинут вперёд.", + "right": "Одна поза: экран смотрит вправо, его левый край выдвинут вперёд.", + "followCursor": "Экран неподвижен; камера облетает его, уходя в сторону курсора и поднимаясь или опускаясь вместе с ним." + }, + "needsCursor": "Курсор скрыт: этой камере нечего отслеживать, и она сохраняет начальный угол." }, "clickImpact": { "title": "Отклик на клик", "description": "Каждый клик вдавливает наклонённый экран: сторона клика прогибается и возвращается.", - "needsRotation": "Сначала выберите 3D-поворот: вдавить можно только наклонённый экран.", - "needsCursor": "Курсор скрыт: экран реагирует только на видимые клики." + "needsRotation": "Сначала выберите 3D-камеру: вдавить можно только наклонённый экран.", + "needsCursor": "Курсор скрыт: экран реагирует только на видимые клики.", + "descriptionCamera": "Каждый клик слегка отодвигает камеру назад, затем она возвращается." }, "level": "Уровень масштабирования", "previewHold": "Удерживайте для предпросмотра эффекта зума", @@ -384,8 +396,9 @@ "cursor": { "smoothing": "Сглаживание", "clickBounce": "Отскок при клике", - "depth": "3D-глубина", - "depthNeedsCursor": "Включите «Показывать курсор», чтобы придать ему глубину.", + "model3d": "3D-курсор", + "model3dHint": "Стиль по умолчанию: все формы курсора становятся объёмными. Остальные стили остаются плоскими.", + "model3dNeedsCursor": "Включите «Показывать курсор», чтобы использовать 3D-курсор.", "help": "Отрисовка курсора по записанной телеметрии: тема, размер, сглаживание, размытие движения и отскок при клике.", "clipToBoundsDescription": "Удерживает курсор внутри кадра видео. Отключите, чтобы курсор мог выходить за края — полезно при увеличении или панорамировании.", "size": "Размер", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 18a6789a9..428db34e5 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -337,20 +337,32 @@ "autoDescription": "Kamera kaydedilen imleç konumunu takip eder", "title": "Odak Modu" }, - "threeD": { + "camera": { + "title": "3D kamera", + "off": "Kapalı", + "fixed": "Sabit açı", + "moving": "Hareketli kamera", "preset": { - "left": "Sol", - "right": "Sağ", - "iso": "Iso" + "iso": "Yukarıdan çapraz", + "left": "Ekran sola dönük", + "right": "Ekran sağa dönük", + "followCursor": "İmleçle birlikte döner" }, - "none": "Yok", - "title": "3D Döndürme" + "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.", + "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." }, "clickImpact": { "title": "Tıklama etkisi", "description": "Her tıklama eğik ekranı bastırır: tıklanan taraf çöker, sonra geri döner.", - "needsRotation": "Önce bir 3D döndürme seçin: yalnızca eğik bir ekran bastırılabilir.", - "needsCursor": "İmleç gizli: ekran yalnızca görünen tıklamalara tepki verir." + "needsRotation": "Önce bir 3D kamera seçin: yalnızca eğik bir ekran bastırılabilir.", + "needsCursor": "İmleç gizli: ekran yalnızca görünen tıklamalara tepki verir.", + "descriptionCamera": "Her tıklama kamerayı biraz geri iter, ardından kamera geri gelir." }, "level": "Yakınlaştırma Seviyesi", "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun", @@ -384,8 +396,9 @@ "cursor": { "smoothing": "Yumuşatma", "clickBounce": "Tıklama Sıçraması", - "depth": "3B Derinlik", - "depthNeedsCursor": "Derinlik vermek için «İmleci Göster»i açın.", + "model3d": "3B imleç", + "model3dHint": "Varsayılan stil: tüm imleç şekilleri 3B olur. Diğer stiller düz kalır.", + "model3dNeedsCursor": "3B imleci kullanmak için «İmleci Göster»i açın.", "help": "Kaydedilen telemetriden imleç çizimi: tema, boyut, yumuşatma, hareket bulanıklığı ve tıklama sıçraması.", "clipToBoundsDescription": "İmleci video kare içinde tutar. İmlecin kenarları aşmasına izin vermek için kapatın — yakınlaştırma veya kaydırma yapılırken kullanışlıdır.", "size": "Boyut", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 2a88156f1..10f951b99 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -337,20 +337,32 @@ "autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi", "title": "Chế độ lấy nét" }, - "threeD": { + "camera": { + "title": "Camera 3D", + "off": "Tắt", + "fixed": "Góc cố định", + "moving": "Camera chuyển động", "preset": { - "left": "Trái", - "right": "Phải", - "iso": "Đẳng phối" + "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ỏ" }, - "none": "Không", - "title": "Xoay 3D" + "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.", + "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." }, "clickImpact": { "title": "Hiệu ứng nhấp", "description": "Mỗi lần nhấp sẽ ấn màn hình nghiêng: phía được nhấp lún xuống rồi bật lại.", - "needsRotation": "Hãy chọn xoay 3D trước: chỉ màn hình nghiêng mới ấn được.", - "needsCursor": "Con trỏ đang ẩn: màn hình chỉ phản ứng với các lần nhấp nhìn thấy được." + "needsRotation": "Hãy chọn camera 3D trước: chỉ màn hình nghiêng mới ấn được.", + "needsCursor": "Con trỏ đang ẩn: màn hình chỉ phản ứng với các lần nhấp nhìn thấy được.", + "descriptionCamera": "Mỗi cú nhấp đẩy máy quay lùi lại một chút rồi trở về." }, "level": "Mức độ thu phóng", "previewHold": "Giữ để xem trước hiệu ứng phóng to", @@ -384,8 +396,9 @@ "cursor": { "smoothing": "Làm mượt", "clickBounce": "Nảy khi nhấp", - "depth": "Độ sâu 3D", - "depthNeedsCursor": "Bật “Hiện con trỏ” để tạo độ sâu cho con trỏ.", + "model3d": "Con trỏ 3D", + "model3dHint": "Kiểu mặc định: mọi hình dạng con trỏ đều thành 3D. Các kiểu khác vẫn phẳng.", + "model3dNeedsCursor": "Bật “Hiện con trỏ” để dùng con trỏ 3D.", "help": "Cách vẽ con trỏ từ dữ liệu đã ghi: chủ đề, kích thước, làm mượt, mờ chuyển động và hiệu ứng nảy khi nhấp.", "clipToBoundsDescription": "Giữ con trỏ bên trong khung hình video. Tắt để cho phép con trỏ vượt ra ngoài mép — hữu ích khi phóng to hoặc lia máy.", "size": "Kích thước", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 35eaec6d9..dd66cbe08 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -337,20 +337,32 @@ "autoDescription": "摄像头跟随录制时的光标位置", "title": "对焦模式" }, - "threeD": { + "camera": { + "title": "3D 镜头", + "off": "关闭", + "fixed": "固定角度", + "moving": "运动镜头", "preset": { - "left": "左", - "right": "右", - "iso": "Iso" + "iso": "斜上方俯视", + "left": "画面向左转", + "right": "画面向右转", + "followCursor": "随光标环绕" }, - "none": "无", - "title": "3D 旋转" + "description": { + "off": "画面保持平整。", + "iso": "保持单一姿态:向左转,并从上方俯视。", + "left": "保持单一姿态:画面朝向左侧,右边缘向前。", + "right": "保持单一姿态:画面朝向右侧,左边缘向前。", + "followCursor": "屏幕保持不动,镜头绕着屏幕转到光标所在的一侧,并随光标升降。" + }, + "needsCursor": "光标已隐藏:此镜头没有可跟随的对象,保持初始角度。" }, "clickImpact": { "title": "点击按压", "description": "每次点击都会按压倾斜的画面:被点击的一侧下沉,然后弹回。", - "needsRotation": "请先选择 3D 旋转:只有倾斜的画面才能被按压。", - "needsCursor": "光标已隐藏:画面只对可见的点击做出反应。" + "needsRotation": "请先选择 3D 镜头:只有倾斜的画面才能被按压。", + "needsCursor": "光标已隐藏:画面只对可见的点击做出反应。", + "descriptionCamera": "每次点击,镜头都会稍稍后退,然后回位。" }, "level": "缩放级别", "previewHold": "按住预览放大效果", @@ -384,8 +396,9 @@ "cursor": { "smoothing": "平滑", "clickBounce": "点击弹跳", - "depth": "3D 深度", - "depthNeedsCursor": "开启“显示光标”后才能为光标添加深度。", + "model3d": "3D 光标", + "model3dHint": "默认样式:所有光标形状都变为 3D。其他样式保持平面。", + "model3dNeedsCursor": "开启“显示光标”后才能使用 3D 光标。", "help": "基于录制遥测数据的光标渲染:主题、大小、平滑、运动模糊和点击回弹。", "clipToBoundsDescription": "将光标保持在视频画面内。关闭后光标可以超出边缘——在放大或平移时很有用。", "size": "大小", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 258340174..007b8f07c 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -337,20 +337,32 @@ "autoDescription": "攝影機跟隨錄製時的游標位置", "title": "對焦模式" }, - "threeD": { + "camera": { + "title": "3D 鏡頭", + "off": "關閉", + "fixed": "固定角度", + "moving": "運動鏡頭", "preset": { - "left": "左", - "right": "右", - "iso": "Iso" + "iso": "斜上方俯視", + "left": "畫面向左轉", + "right": "畫面向右轉", + "followCursor": "隨游標環繞" }, - "none": "無", - "title": "3D 旋轉" + "description": { + "off": "畫面保持平整。", + "iso": "保持單一姿態:向左轉,並從上方俯視。", + "left": "保持單一姿態:畫面朝向左側,右邊緣向前。", + "right": "保持單一姿態:畫面朝向右側,左邊緣向前。", + "followCursor": "螢幕保持不動,鏡頭繞著螢幕轉到游標所在的一側,並隨游標升降。" + }, + "needsCursor": "游標已隱藏:此鏡頭沒有可跟隨的對象,維持初始角度。" }, "clickImpact": { "title": "點擊按壓", "description": "每次點擊都會按壓傾斜的畫面:被點擊的一側下沉,然後彈回。", - "needsRotation": "請先選擇 3D 旋轉:只有傾斜的畫面才能被按壓。", - "needsCursor": "游標已隱藏:畫面只對看得見的點擊做出反應。" + "needsRotation": "請先選擇 3D 鏡頭:只有傾斜的畫面才能被按壓。", + "needsCursor": "游標已隱藏:畫面只對看得見的點擊做出反應。", + "descriptionCamera": "每次點擊,鏡頭都會稍微後退,然後回位。" }, "level": "縮放級別", "previewHold": "按住預覽放大效果", @@ -384,8 +396,9 @@ "cursor": { "smoothing": "平滑", "clickBounce": "點擊彈跳", - "depth": "3D 深度", - "depthNeedsCursor": "開啟「顯示游標」後才能為游標加上深度。", + "model3d": "3D 游標", + "model3dHint": "預設樣式:所有游標形狀都會變成 3D。其他樣式維持平面。", + "model3dNeedsCursor": "開啟「顯示游標」後才能使用 3D 游標。", "help": "根據錄製的遙測資料繪製游標:主題、大小、平滑、動態模糊與點擊回彈。", "clipToBoundsDescription": "讓游標保持在影片畫面內。關閉後游標可超出邊緣——在縮放或平移時很有用。", "size": "大小", diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts index f1b5cfbde..06b147d6b 100644 --- a/src/lib/ai-edition/schema/index.ts +++ b/src/lib/ai-edition/schema/index.ts @@ -488,12 +488,16 @@ export const zoomRegionSchema = endGteStart( cy: z.number().min(0).max(1), }), focusMode: z.enum(["manual", "auto"]).optional(), - rotationPreset: z.enum(["iso", "left", "right"]).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(), customScale: z.number().positive().optional(), source: z.enum(["auto", "manual"]).optional(), hideCursor: z.boolean().optional(), - /** Each click presses the tilted plane toward the clicked side. Only meaningful with a - * `rotationPreset`; omitted (never `false`) when off. */ + /** Each click gives an impact: a fixed-angle `rotationPreset` presses the tilted plane + * toward the clicked side, `follow-cursor` recoils the camera. Needs a `rotationPreset`; + * omitted (never `false`) when off. */ clickImpact: z.literal(true).optional(), }), "endMs", diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index f13f6762c..1ec2e046a 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -130,10 +130,14 @@ describe("patchEditorSettings", () => { const snap = getEditorSettings(next); expect(snap.cursor.size).toBe(4); expect(snap.cursor.smoothing).toBe(0.9); - const deep = getEditorSettings(patchEditorSettings(next, { cursor: { volume: 0.5 } })); - expect(deep.cursor.volume).toBe(0.5); - expect(deep.cursor.size).toBe(4); - expect(snap.cursor.volume).toBe(0); + }); + + it("switches the 3D cursor without clobbering its siblings, off by default", () => { + expect(getEditorSettings(baseDoc).cursor.model3d).toBe(false); + const seed = patchEditorSettings(baseDoc, { cursor: { size: 4 } }); + const on = getEditorSettings(patchEditorSettings(seed, { cursor: { model3d: true } })); + expect(on.cursor.model3d).toBe(true); + expect(on.cursor.size).toBe(4); }); it("toggles cursorAutoHide on and off via patch", () => { diff --git a/src/lib/ai-edition/store/editorSettings.ts b/src/lib/ai-edition/store/editorSettings.ts index 5da9138e7..cc1a5089a 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -152,7 +152,7 @@ interface LegacyShape { cursorSmoothing?: number; cursorMotionBlur?: number; cursorClickBounce?: number; - cursorVolume?: number; + cursorModel3d?: boolean; cursorClipToBounds?: boolean; cursorShow?: boolean; cursorAutoHide?: boolean; @@ -184,9 +184,8 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor smoothing: num(legacy?.cursorSmoothing, DEFAULT_EDITOR_SETTINGS.cursor.smoothing), motionBlur: num(legacy?.cursorMotionBlur, DEFAULT_EDITOR_SETTINGS.cursor.motionBlur), clickBounce: num(legacy?.cursorClickBounce, DEFAULT_EDITOR_SETTINGS.cursor.clickBounce), - // Same 0-1 range as the slider and the native clamp; absent in every project saved - // before the setting existed, which is what keeps them flat. - volume: clamp01(num(legacy?.cursorVolume, DEFAULT_EDITOR_SETTINGS.cursor.volume)), + // Absent in every project saved before the setting existed: those keep the flat cursor. + model3d: bool(legacy?.cursorModel3d, DEFAULT_EDITOR_SETTINGS.cursor.model3d), clipToBounds: bool(legacy?.cursorClipToBounds, DEFAULT_EDITOR_SETTINGS.cursor.clipToBounds), autoHide: bool(legacy?.cursorAutoHide, DEFAULT_EDITOR_SETTINGS.cursorAutoHide), }; @@ -301,7 +300,7 @@ function nextLegacy(current: LegacyShape | null, patch: EditorSettingsPatch): Le if (c.smoothing !== undefined) next.cursorSmoothing = c.smoothing; if (c.motionBlur !== undefined) next.cursorMotionBlur = c.motionBlur; if (c.clickBounce !== undefined) next.cursorClickBounce = c.clickBounce; - if (c.volume !== undefined) next.cursorVolume = c.volume; + if (c.model3d !== undefined) next.cursorModel3d = c.model3d; if (c.clipToBounds !== undefined) next.cursorClipToBounds = c.clipToBounds; if (c.theme !== undefined) next.cursorTheme = c.theme; if (c.show !== undefined) next.cursorShow = c.show; diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 430c2693b..797f39eda 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -799,6 +799,20 @@ describe("useTimeline zoom modifiers (rotation + focus mode)", () => { }); }); + it("stores a moving camera in the same field as the fixed angles", async () => { + // 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 act(async () => { + await result.current.updateZoomRotation("zoom_a", "follow-cursor"); + }); + const zoom = useProjectStore.getState().document?.zoomRanges[0]; + expect(zoom?.rotationPreset).toBe("follow-cursor"); + expect(zoom).not.toHaveProperty("cameraMotion"); + }); + it("updates hideCursor on a zoom region", async () => { const { result } = renderTimeline(); await act(async () => { diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index ef927e618..72a074f53 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -6,7 +6,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { toFileUrl } from "@/components/video-editor/projectPersistence"; -import type { AnnotationRegion, AnnotationType } from "@/components/video-editor/types"; +import type { + AnnotationRegion, + AnnotationType, + Rotation3DPreset, +} from "@/components/video-editor/types"; import { useScopedT } from "@/contexts/I18nContext"; import { collapseTracksToPills, @@ -724,7 +728,7 @@ export function useTimeline() { // `undefined` clears the preset back to a flat frame; `migrate.ts` already drops the field // when it is falsy, so absent and "no rotation" are the same state. const updateZoomRotation = useCallback( - async (id: string, rotationPreset: "iso" | "left" | "right" | undefined) => { + async (id: string, rotationPreset: Rotation3DPreset | undefined) => { if (!document) return; const next: AxcutDocument = { ...document, diff --git a/src/lib/ai-edition/stylePresets.test.ts b/src/lib/ai-edition/stylePresets.test.ts index 593110077..f6a161f40 100644 --- a/src/lib/ai-edition/stylePresets.test.ts +++ b/src/lib/ai-edition/stylePresets.test.ts @@ -37,7 +37,7 @@ function appearance(overrides: Partial = {}): StylePreset smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, - volume: 0, + model3d: false, clipToBounds: false, }, cursorShow: true, @@ -107,23 +107,31 @@ describe("parseStylePresetAppearance", () => { ).toThrow(/cursor\.size/); }); - it("reads a preset written before cursor.volume as a flat cursor, and bounds it", () => { - const { volume: _volume, ...flatCursor } = appearance().cursor; - expect(parseStylePresetAppearance({ ...appearance(), cursor: flatCursor }).cursor.volume).toBe( - 0, + it("drops the retired cursor.volume and cursor.hover keys of an older preset", () => { + const older = { + ...appearance(), + cursor: { ...appearance().cursor, volume: 0.6, hover: 0.4 }, + }; + expect(parseStylePresetAppearance(older).cursor).toEqual(appearance().cursor); + }); + + it("reads a preset written before the 3D cursor as a flat cursor, and type-checks it", () => { + const { model3d: _model3d, ...flat } = appearance().cursor; + expect(parseStylePresetAppearance({ ...appearance(), cursor: flat }).cursor.model3d).toBe( + false, ); expect( parseStylePresetAppearance({ ...appearance(), - cursor: { ...appearance().cursor, volume: 0.4 }, - }).cursor.volume, - ).toBe(0.4); + cursor: { ...appearance().cursor, model3d: true }, + }).cursor.model3d, + ).toBe(true); expect(() => parseStylePresetAppearance({ ...appearance(), - cursor: { ...appearance().cursor, volume: 1.5 }, + cursor: { ...appearance().cursor, model3d: 1 }, }), - ).toThrow(/cursor\.volume/); + ).toThrow(/cursor\.model3d/); }); it("reads a preset saved before the frame existed as frameless, and rejects an unknown frame", () => { diff --git a/src/lib/ai-edition/stylePresets.ts b/src/lib/ai-edition/stylePresets.ts index e4d828e04..06734dde0 100644 --- a/src/lib/ai-edition/stylePresets.ts +++ b/src/lib/ai-edition/stylePresets.ts @@ -120,7 +120,6 @@ const CURSOR_NUMBER_RANGES = { smoothing: [0, 1], motionBlur: [0, 1], clickBounce: [0, 5], - volume: [0, 1], } as const; type Fields = Record; @@ -231,9 +230,9 @@ export function parseStylePresetWallpaper(value: unknown, key = "wallpaper"): st * does not ship falls back to the default cursor instead of rejecting a preset that is * otherwise sound (the editor does the same when it renders one). The others postdate the * first version-1 files, so a preset saved before one of them existed carries no choice about - * it and gets the value that means "unchanged": `cursor.volume` may be absent (the preset was - * authored flat), `wallpaperMotion` too (a still wallpaper, which is exactly what "none" - * means), `frame` as well (no frame, see `readFrame`), and `depthOfField` keeps the factory + * it and gets the value that means "unchanged": `cursor.model3d` may be absent (the flat + * cursor), `wallpaperMotion` too (a still wallpaper, which is exactly what "none" means), + * `frame` as well (no frame, see `readFrame`), and `depthOfField` keeps the factory * value (on). A present but ill-typed value is still refused. Unknown extra keys are dropped. */ export function parseStylePresetAppearance(value: unknown): StylePresetAppearance { @@ -281,11 +280,8 @@ export function parseStylePresetAppearance(value: unknown): StylePresetAppearanc smoothing: readNumber(cursor, "smoothing", CURSOR_NUMBER_RANGES.smoothing, "cursor."), motionBlur: readNumber(cursor, "motionBlur", CURSOR_NUMBER_RANGES.motionBlur, "cursor."), clickBounce: readNumber(cursor, "clickBounce", CURSOR_NUMBER_RANGES.clickBounce, "cursor."), - // Presets written before the setting existed have no volume: they meant a flat cursor. - volume: - cursor.volume === undefined - ? 0 - : readNumber(cursor, "volume", CURSOR_NUMBER_RANGES.volume, "cursor."), + // Presets written before the 3D cursor existed meant the flat one. + model3d: cursor.model3d === undefined ? false : readBoolean(cursor, "model3d", "cursor."), clipToBounds: readBoolean(cursor, "clipToBounds", "cursor."), }, cursorShow: readBoolean(value, "cursorShow"), diff --git a/src/lib/ai-edition/stylePresetsEditor.test.ts b/src/lib/ai-edition/stylePresetsEditor.test.ts index a15659e5f..318f0b4d3 100644 --- a/src/lib/ai-edition/stylePresetsEditor.test.ts +++ b/src/lib/ai-edition/stylePresetsEditor.test.ts @@ -47,7 +47,7 @@ function styledSettings(): EditorSettingsSnapshot { smoothing: 0.1, motionBlur: 0.9, clickBounce: 4, - volume: 0.6, + model3d: true, clipToBounds: true, }, cursorShow: false, diff --git a/src/lib/ai-edition/stylePresetsEditor.ts b/src/lib/ai-edition/stylePresetsEditor.ts index 177afcd85..fea3bbb27 100644 --- a/src/lib/ai-edition/stylePresetsEditor.ts +++ b/src/lib/ai-edition/stylePresetsEditor.ts @@ -37,7 +37,7 @@ export function stylePresetAppearanceFromSettings( smoothing: settings.cursor.smoothing, motionBlur: settings.cursor.motionBlur, clickBounce: settings.cursor.clickBounce, - volume: settings.cursor.volume, + model3d: settings.cursor.model3d, clipToBounds: settings.cursor.clipToBounds, }, cursorShow: settings.cursorShow, diff --git a/src/lib/projectDefaults.ts b/src/lib/projectDefaults.ts index 2173f12a9..2f606a9e7 100644 --- a/src/lib/projectDefaults.ts +++ b/src/lib/projectDefaults.ts @@ -40,7 +40,7 @@ export interface ProjectAppearanceDefaults { smoothing: number; motionBlur: number; clickBounce: number; - volume: number; + model3d: boolean; clipToBounds: boolean; autoHide: boolean; }; @@ -77,7 +77,7 @@ export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = { smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, - volume: 0, + model3d: false, clipToBounds: false, autoHide: false, }, diff --git a/src/native/browserShim.presets.test.ts b/src/native/browserShim.presets.test.ts index 1f725d00d..9c8261141 100644 --- a/src/native/browserShim.presets.test.ts +++ b/src/native/browserShim.presets.test.ts @@ -33,7 +33,7 @@ const APPEARANCE: StylePresetAppearance = { smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, - volume: 0, + model3d: false, clipToBounds: false, }, cursorShow: true, diff --git a/src/native/nativeCompositorStore.test.ts b/src/native/nativeCompositorStore.test.ts index 33b404973..23052e2c9 100644 --- a/src/native/nativeCompositorStore.test.ts +++ b/src/native/nativeCompositorStore.test.ts @@ -20,7 +20,7 @@ vi.mock("./compositorViewClient", () => ({ /** * The full key set the addon understands, transcribed from - * `crates/compositor/src/live.rs` — `set_param_bool` (3), `set_param_num` (9) + * `crates/compositor/src/live.rs` — `set_param_bool` (5), `set_param_num` (9) * and `set_param_str` (2). Anything the addon accepts and the app never pushes * silently keeps its compiled-in default, which is exactly the bug this file * guards: `cursorSize` was only ever pushed by the cursor panel's mount effect, @@ -31,6 +31,8 @@ const ADDON_KEYS = [ "backgroundBlur", "webcamMirror", "cursorShow", + "cursorAutoHide", + "cursorModel3d", "shadow", "roundness", "motionBlur", @@ -38,7 +40,6 @@ const ADDON_KEYS = [ "webcamSize", "cursorSize", "cursorClickBounce", - "cursorVolume", "cursorSmoothing", "cursorMotionBlur", "backgroundColor", @@ -80,11 +81,17 @@ describe("pushAllNativeParams", () => { // here would scale the preview by 10 or 100 on load — a regression that // would look like "the cursor is enormous when I open a project". const pushed = await pushWith({ - cursor: { size: 3, clickBounce: 2.5, volume: 0.4, smoothing: 0.67, motionBlur: 0.35 }, + cursor: { + size: 3, + clickBounce: 2.5, + model3d: true, + smoothing: 0.67, + motionBlur: 0.35, + }, }); expect(pushed.get("cursorSize")).toBe(3); expect(pushed.get("cursorClickBounce")).toBe(2.5); - expect(pushed.get("cursorVolume")).toBeCloseTo(0.4, 5); + expect(pushed.get("cursorModel3d")).toBe(true); expect(pushed.get("cursorSmoothing")).toBeCloseTo(0.67, 5); expect(pushed.get("cursorMotionBlur")).toBeCloseTo(0.35, 5); }); diff --git a/src/native/nativeCompositorStore.ts b/src/native/nativeCompositorStore.ts index 01adc73b8..d66390637 100644 --- a/src/native/nativeCompositorStore.ts +++ b/src/native/nativeCompositorStore.ts @@ -146,7 +146,7 @@ export function pushAllNativeParams(settings: EditorSettingsSnapshot): void { setNativeParam("cursorAutoHide", settings.cursorAutoHide); setNativeParam("cursorSize", settings.cursor.size); setNativeParam("cursorClickBounce", settings.cursor.clickBounce); - setNativeParam("cursorVolume", settings.cursor.volume); + setNativeParam("cursorModel3d", settings.cursor.model3d); setNativeParam("cursorSmoothing", settings.cursor.smoothing); setNativeParam("cursorMotionBlur", settings.cursor.motionBlur); } diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index 93daea29c..f155af7c2 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -1073,10 +1073,10 @@ describe("buildSceneDescription.settings mapping", () => { expect(buildSceneDescription(high).layout.webcamSize).toBeCloseTo(0.5, 5); }); - it("keeps the cursor flat for a project saved before cursor volume, and clamps it", () => { - expect(buildSceneDescription(makeDoc({ legacyEditor: {} })).cursor.volume).toBe(0); - const tooDeep = makeDoc({ legacyEditor: { cursorVolume: 3 } }); - expect(buildSceneDescription(tooDeep).cursor.volume).toBe(1); + it("keeps the flat cursor for an older project and carries the 3D cursor switch", () => { + expect(buildSceneDescription(makeDoc({ legacyEditor: {} })).cursor.model3d).toBe(false); + const on = makeDoc({ legacyEditor: { cursorModel3d: true } }); + expect(buildSceneDescription(on).cursor.model3d).toBe(true); }); it("carries depth of field: on by default, off when the project turns it off", () => { @@ -1092,12 +1092,10 @@ describe("buildSceneDescription.settings mapping", () => { cursorSmoothing: 0.9, cursorMotionBlur: 0.5, cursorClickBounce: 1.5, - cursorVolume: 0.7, cursorClipToBounds: true, }, }); const cursor = buildSceneDescription(doc).cursor; - expect(cursor.volume).toBe(0.7); expect(cursor.size).toBe(4); expect(cursor.smoothing).toBe(0.9); expect(cursor.motionBlur).toBe(0.5); diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index 7c5325dc7..dc0120af4 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -17,6 +17,7 @@ import type { CameraFullscreenRegion, + Rotation3DPreset, SpeedRegion, WallpaperMotion, WebcamBackgroundMode, @@ -82,8 +83,9 @@ export interface SceneZoomRegion { focusY: number; /** "auto" follows cursor telemetry instead of the fixed focus point. */ focusMode: "manual" | "auto" | null; - /** Optional rotation preset for the zoom. */ - rotation: "iso" | "left" | "right" | null; + /** The zoom's 3D camera: a fixed angle or a moving camera (`regions.rs::camera_for`). + * `null` = flat; the native side also renders an unknown value flat. */ + rotation: Rotation3DPreset | null; /** Index of the clip (within `SceneDescription.clips`) whose source time this region's * `startSec`/`endSec` are expressed in — disambiguates clips whose source windows * numerically overlap (same or different asset). Unset only for a region that @@ -394,8 +396,11 @@ export interface SceneCursor { /** 0..1. */ motionBlur: number; clickBounce: number; - /** 0..1 cursor extrusion + contact shadow (`scene.rs` `SceneCursor::volume`). 0 = flat. */ - volume: number; + /** + * Modelled 3D arrow (`scene.rs` `SceneCursor::model3d`, compositor mode 15). Only drawn for + * the default theme's arrow; any other cursor keeps its flat sprite. + */ + model3d: boolean; clipToBounds: boolean; /** Cursor theme id (sprite set). */ theme: string; @@ -1077,7 +1082,7 @@ export function buildSceneDescription( smoothing: settings.cursor.smoothing, motionBlur: settings.cursor.motionBlur, clickBounce: settings.cursor.clickBounce, - volume: settings.cursor.volume, + model3d: settings.cursor.model3d, clipToBounds: settings.cursor.clipToBounds, theme: settings.cursorTheme, }, diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index b942d924e..cd4267764 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -139,7 +139,7 @@ Sections marked **post-1.10.0** cover what has landed on `main` since the v1.10. - [ ] Activate the timeline tool with the visible zoom label and confirm a zoom region appears. - [ ] Select the zoom region and cycle its level through multiple available depths; confirm the preview scale changes. - [ ] Drag the zoom focus point in the preview and confirm the zoom follows the new focus. -- [ ] Change the zoom rotation preset among none, iso, left, and right and confirm the preview orientation changes. +- [ ] Change the zoom's 3D camera among Off, the three fixed angles and the follow-cursor camera, and confirm the preview orientation changes; with the follow-cursor camera, move the cursor or click from one side of the recording to the other and confirm the screen turns to that side. - [ ] Set a zoom region to automatic focus and confirm its focus follows cursor telemetry across the whole region. - [ ] Use the automatic-zooms menu and confirm it adds suggested zoom regions when cursor telemetry supports suggestions. - [ ] Select a zoom region and delete it from the selection inspector; confirm it disappears from the lane. @@ -377,7 +377,7 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a - [ ] On macOS, export a frame containing a text annotation and confirm the text is upright, centred in its box, and that its background plate fits the text. - [ ] On macOS, export a frame containing a blur annotation and confirm the area is actually obscured. - [ ] On macOS, export a range with the cursor visible and confirm the cursor and its trail are rendered. -- [ ] On macOS, export frames with each 3D rotation preset and confirm the tilt matches the Windows render. +- [ ] On macOS, export frames with each 3D camera preset and confirm the tilt matches the Windows render. - [ ] On macOS, export a frame with background blur enabled and confirm it matches the Windows render. - [ ] Export a range containing a zoom with an annotation and captions on screen, and confirm neither follows the zoom in the exported frames. - [ ] Confirm the packaged macOS app refuses to start or reports clearly when the compositor addon is missing, rather than failing at first render. From 60c60c76c715bc73a1b76df5384109f102e0cf98 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 17 Sep 2026 19:53:36 +0200 Subject: [PATCH 11/11] fix(i18n): add the German strings for the 3D effects --- src/i18n/locales/de/settings.json | 49 ++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index fb496f293..a64253686 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -1,5 +1,11 @@ { "background": { + "motion": "Animation", + "motionNone": "Keine", + "motionDrift": "Treiben", + "motionAurora": "Polarlicht", + "motionWaves": "Wellen", + "motionGradientOnly": "Animation gilt nur für Verlaufshintergründe. Wähle einen Verlauf, um ihn zu animieren.", "gradientLabel": "Verlauf {{index}}", "uploadCustom": "Eigenes hochladen", "unsupportedImage": "Nicht unterstütztes Bild. Verwende eine JPG- oder PNG-Datei.", @@ -108,7 +114,15 @@ "format": "Format", "fitClip": "Einpassen", "motionBlur": "Bewegungsunschärfe", - "roundness": "Rundung" + "depthOfField": "Tiefenschärfe", + "depthOfFieldHint": "3D-Zooms", + "depthOfFieldNoTilt": "Kein 3D-Zoom in diesem Projekt", + "roundness": "Rundung", + "window": "Fenster", + "windowNone": "Keins", + "windowLight": "Hell", + "windowDark": "Dunkel", + "windowHelp": "Zeichnet eine Fenstertitelleiste und einen Rahmen um die Aufnahme." }, "stylePresets": { "button": "Vorgaben", @@ -323,14 +337,32 @@ "autoDescription": "Die Kamera folgt der aufgezeichneten Cursorposition", "title": "Fokusmodus" }, - "threeD": { + "camera": { + "title": "3D-Kamera", + "off": "Aus", + "fixed": "Fester Winkel", + "moving": "Bewegte Kamera", "preset": { - "left": "Links", - "right": "Rechts", - "iso": "Iso" + "iso": "Schräg von oben", + "left": "Bildschirm nach links gedreht", + "right": "Bildschirm nach rechts gedreht", + "followCursor": "Kreist mit dem Cursor" }, - "none": "Keine", - "title": "3D-Rotation" + "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.", + "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." + }, + "clickImpact": { + "title": "Klick-Impuls", + "description": "Jeder Klick drückt den geneigten Bildschirm: Die geklickte Seite sinkt ein und federt zurück.", + "needsRotation": "Wähle zuerst eine 3D-Kamera: Nur ein geneigter Bildschirm kann gedrückt werden.", + "needsCursor": "Der Cursor ist ausgeblendet: Der Bildschirm reagiert nur auf sichtbare Klicks.", + "descriptionCamera": "Jeder Klick schiebt die Kamera ein wenig zurück, dann federt sie nach vorn." }, "level": "Zoomstufe", "previewHold": "Gedrückt halten für die Zoom-Vorschau", @@ -364,6 +396,9 @@ "cursor": { "smoothing": "Glättung", "clickBounce": "Klick-Federung", + "model3d": "3D-Cursor", + "model3dHint": "Standardstil: Jede Cursorform wird 3D. Andere Stile bleiben flach.", + "model3dNeedsCursor": "Aktiviere „Cursor anzeigen“, um den 3D-Cursor zu verwenden.", "help": "Cursor-Darstellung aus der aufgezeichneten Telemetrie: Stil, Größe, Glättung, Bewegungsunschärfe und Klick-Federung.", "clipToBoundsDescription": "Hält den Cursor innerhalb des Videobilds. Schalte es aus, damit der Cursor über die Ränder hinausragen kann — nützlich beim Hineinzoomen oder Verschieben.", "size": "Größe",