From 4933903c56befdd896826b4f4f200008379b02ec Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 17 Sep 2026 20:16:00 +0200 Subject: [PATCH] revert: back out the 3D effects until they run on macOS and Linux Reverts 2c2b14f4..d8190181 (#682), merged before anyone ran the app on macOS or on a real Linux GPU. The two unrelated fixes of that series stay: the Linux cursor-trail test (2a7ffb3f) and the dev HTTP cache switch (53004e0b). --- crates/compositor/src/camera.rs | 685 ---- crates/compositor/src/compositor_linux.rs | 1050 +----- crates/compositor/src/compositor_macos.rs | 900 +----- crates/compositor/src/compositor_windows.rs | 391 +-- crates/compositor/src/cursor.rs | 129 +- crates/compositor/src/cursor_sdf.rs | 413 --- crates/compositor/src/frame_geometry.rs | 2871 +---------------- crates/compositor/src/lib.rs | 2 - crates/compositor/src/live.rs | 39 +- crates/compositor/src/regions.rs | 1523 +-------- crates/compositor/src/scene.rs | 97 +- crates/compositor/src/shaders.hlsl | 564 +--- crates/compositor/src/shaders.metal | 511 +-- crates/compositor/src/timeline_walk.rs | 4 - crates/compositor/src/vk_shaders/layer.wgsl | 458 +-- .../compositor/tests/animated_background.rs | 134 - .../compositor/tests/click_impact_render.rs | 174 - crates/compositor/tests/compose_linux.rs | 110 - .../compositor/tests/cursor_model_render.rs | 824 ----- crates/compositor/tests/cursor_tap_render.rs | 423 --- .../compositor/tests/follow_camera_render.rs | 485 --- .../compositor/tests/programme_time_seek.rs | 104 - .../compositor/tests/tilt_parallax_render.rs | 155 - .../compositor/tests/tilted_depth_of_field.rs | 263 -- .../tests/tilted_depth_slot_inert.rs | 126 - .../compositor/tests/window_frame_render.rs | 123 - crates/fixture/scene-gradient-aurora.json | 37 - crates/fixture/scene-gradient-still.json | 32 - crates/fixture/scene-tilt.json | 74 - crates/poc-d3d/src/bench.rs | 10 +- docs/3d-effects-v2.md | 424 --- .../ai-edition/style-preset-service.test.ts | 12 +- electron/ipc/nativeBridge.presets.test.ts | 12 +- src/components/ai-edition/PreviewCanvas.tsx | 21 +- .../RightPanes.backgroundMotion.test.tsx | 66 - .../ai-edition/RightPanes.i18n.test.tsx | 1 - src/components/ai-edition/RightPanes.tsx | 145 +- .../ai-edition/depthOfFieldToggle.test.tsx | 76 - .../ai-edition/fitClipShapes.test.tsx | 20 - .../ai-edition/v4/FloatingInspector.test.tsx | 149 +- .../ai-edition/v4/FloatingInspector.tsx | 142 +- src/components/video-editor/editorDefaults.ts | 2 - .../video-editor/projectPersistence.test.ts | 27 - .../video-editor/projectPersistence.ts | 23 +- src/components/video-editor/types.ts | 54 +- src/i18n/locales/ar/settings.json | 49 +- src/i18n/locales/cs/settings.json | 49 +- src/i18n/locales/de/settings.json | 49 +- src/i18n/locales/en/settings.json | 49 +- src/i18n/locales/es/settings.json | 49 +- src/i18n/locales/fr/settings.json | 49 +- src/i18n/locales/it/settings.json | 49 +- src/i18n/locales/ja-JP/settings.json | 49 +- src/i18n/locales/ko-KR/settings.json | 49 +- src/i18n/locales/pt-BR/settings.json | 49 +- src/i18n/locales/ru/settings.json | 49 +- src/i18n/locales/tr/settings.json | 49 +- src/i18n/locales/vi/settings.json | 49 +- src/i18n/locales/zh-CN/settings.json | 49 +- src/i18n/locales/zh-TW/settings.json | 49 +- src/lib/ai-edition/document/migrate.test.ts | 35 - src/lib/ai-edition/document/migrate.ts | 4 - src/lib/ai-edition/schema/index.ts | 9 +- .../store/documentWriteAudit.test.ts | 1 - .../ai-edition/store/editorSettings.test.ts | 35 - src/lib/ai-edition/store/editorSettings.ts | 29 +- src/lib/ai-edition/store/useTimeline.test.ts | 27 - src/lib/ai-edition/store/useTimeline.ts | 25 +- src/lib/ai-edition/stylePresets.test.ts | 67 +- src/lib/ai-edition/stylePresets.ts | 41 +- src/lib/ai-edition/stylePresetsEditor.test.ts | 11 +- src/lib/ai-edition/stylePresetsEditor.ts | 7 - src/lib/compositeLayout.test.ts | 20 - src/lib/compositeLayout.ts | 34 - src/lib/projectDefaults.ts | 26 - src/native/browserShim.presets.test.ts | 12 +- src/native/nativeCompositorStore.test.ts | 13 +- src/native/nativeCompositorStore.ts | 1 - src/native/sceneDescription.test.ts | 95 +- src/native/sceneDescription.ts | 59 +- .../engineering/rendering-performance.md | 40 - .../testing/manual-e2e-checklist.md | 5 +- 82 files changed, 792 insertions(+), 14424 deletions(-) delete mode 100644 crates/compositor/src/camera.rs delete mode 100644 crates/compositor/src/cursor_sdf.rs delete mode 100644 crates/compositor/tests/animated_background.rs delete mode 100644 crates/compositor/tests/click_impact_render.rs delete mode 100644 crates/compositor/tests/cursor_model_render.rs delete mode 100644 crates/compositor/tests/cursor_tap_render.rs delete mode 100644 crates/compositor/tests/follow_camera_render.rs delete mode 100644 crates/compositor/tests/programme_time_seek.rs delete mode 100644 crates/compositor/tests/tilt_parallax_render.rs delete mode 100644 crates/compositor/tests/tilted_depth_of_field.rs delete mode 100644 crates/compositor/tests/tilted_depth_slot_inert.rs delete mode 100644 crates/compositor/tests/window_frame_render.rs delete mode 100644 crates/fixture/scene-gradient-aurora.json delete mode 100644 crates/fixture/scene-gradient-still.json delete mode 100644 crates/fixture/scene-tilt.json delete mode 100644 docs/3d-effects-v2.md delete mode 100644 src/components/ai-edition/RightPanes.backgroundMotion.test.tsx delete mode 100644 src/components/ai-edition/depthOfFieldToggle.test.tsx diff --git a/crates/compositor/src/camera.rs b/crates/compositor/src/camera.rs deleted file mode 100644 index c2a14c989..000000000 --- a/crates/compositor/src/camera.rs +++ /dev/null @@ -1,685 +0,0 @@ -//! 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 9466d34d7..7e08ebcad 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, set_programme_time, clear_cursor, scene_snapshot, -//! clear_srv_cache, compose_frame, readback_direct}`) pour que `live.rs` et `compositor-view-napi` +//! set_timeline_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`) : @@ -43,9 +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_model_cb, cursor_sprite_cb, cursor_sprite_dst, parse_hex, plan_cursor, plan_frame, - tilted_screen_cb, CursorPlacement, CursorPlanInput, FrameGeometryInput, ShadowCaster, - SpriteShape, + cursor_sprite_dst, parse_hex, plan_cursor, plan_frame, CursorPlacement, CursorPlanInput, + FrameGeometryInput, }; use crate::scene::{Scene, SceneBackground}; @@ -240,17 +239,6 @@ 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, @@ -310,8 +298,6 @@ 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. @@ -333,9 +319,6 @@ 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 @@ -345,12 +328,6 @@ 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 @@ -693,16 +670,13 @@ 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()), - 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, 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), @@ -772,21 +746,8 @@ 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(label), + label: Some("ann-copy"), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, mip_level_count: levels, sample_count: 1, @@ -801,7 +762,7 @@ impl Compositor { let mips = (0..levels) .map(|level| { tex.create_view(&wgpu::TextureViewDescriptor { - label: Some("mip-level"), + label: Some("ann-copy-mip"), base_mip_level: level, mip_level_count: Some(1), ..Default::default() @@ -818,6 +779,10 @@ 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(), @@ -828,34 +793,9 @@ 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..mips.len() { + for level in 1..self.ann_copy_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. @@ -869,7 +809,9 @@ impl Compositor { wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::TextureView(&mips[level - 1]), + resource: wgpu::BindingResource::TextureView( + &self.ann_copy_mips[level - 1], + ), }, wgpu::BindGroupEntry { binding: 2, @@ -881,7 +823,7 @@ impl Compositor { let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("ann-mip-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &mips[level], + view: &self.ann_copy_mips[level], resolve_target: None, ops: wgpu::Operations { // Clear plutot que Load : le niveau n'a jamais ete ecrit, @@ -1038,17 +980,6 @@ 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; } @@ -1166,24 +1097,59 @@ impl Compositor { } } - fn make_bind( + /// `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, - 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) + quad: &crate::regions::TiltedQuad, + s_px: [f32; 2], + center_px: [f32; 2], + cut: [f32; 4], + radius: f32, + ) -> 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], + ..Default::default() + } } - /// `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( + fn make_bind( &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"), @@ -1199,7 +1165,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 = b4.unwrap_or_else(|| mask.as_ref().map_or(dummy, |m| &m.view)); + let mask_view = 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, @@ -1276,44 +1242,6 @@ 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. @@ -1441,8 +1369,7 @@ impl Compositor { Some(SceneBackground::Color { color }) => { flat(solid(parse_hex(color).unwrap_or(BLACK))) } - // Le mouvement ne vaut que pour le fond d'ecran : la bulle garde son degrade immobile. - Some(SceneBackground::Gradient { angle_deg, stops, .. }) => { + 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 @@ -1981,7 +1908,6 @@ 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.) @@ -1997,20 +1923,17 @@ impl Compositor { Some(SceneBackground::Color { color }) => { (parse_hex(&color).unwrap_or(lp.bg_color), None) } - Some(SceneBackground::Gradient { angle_deg, stops, motion }) => { + Some(SceneBackground::Gradient { angle_deg, stops }) => { 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(), anim[0], anim[1]], - mb, + fx: [a.sin(), -a.cos(), 0.0, 0.0], ..Default::default() }; ([0.0, 0.0, 0.0, 1.0], Some(BgLayer::Gradient(cb))) @@ -2027,19 +1950,12 @@ 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 = g.screen_tilt(s_px); + 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 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, ]; - // 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) @@ -2060,9 +1976,6 @@ 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, @@ -2073,50 +1986,16 @@ 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, square_top], + mb: [g.mb_taps, g.mb_amount, 1.0, 0.0], ..Default::default() }, - // 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], - ), + Some(quad) => self.tilted_screen_cb(quad, s_px, quad_center_px, g.cut, g.s_radius), }; // 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(); - // 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) - }); + let (_screen_uniform, screen_bind) = + self.make_bind(&screen_layer, 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 @@ -2128,26 +2007,24 @@ 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. 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. + // quand l'ecran est droit, quadrilatere projete (mode 12) quand il penche. let screen_shadow = cfg.shadow.then(|| { let spread = crate::frame_geometry::SCREEN_SHADOW_SPREAD_FRAC * g.frame_min_px; - let offset = g.screen_shadow_offset(); + 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 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) - } + 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, + ), }; 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 { @@ -2668,9 +2545,9 @@ impl Compositor { } } - // 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 + // 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 // pendant le pass. Miroir de la branche curseur de `compositor_macos`. // // TRAINEE (`plan.taps > 1`) : `binds` porte une copie par echantillon, @@ -2679,11 +2556,9 @@ impl Compositor { // fois -- cf. le commentaire au point de dessin. struct CursorDraw { _bufs: Vec, - /// Le sprite, et pour le curseur modelise son champ de distance. - _tex: Vec<(wgpu::Texture, wgpu::TextureView)>, + _tex: wgpu::Texture, + _view: wgpu::TextureView, binds: Vec, - /// 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()?; @@ -2702,37 +2577,7 @@ 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() @@ -2759,60 +2604,86 @@ 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()); - - // 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, + 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() } - 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`. - let cb = cursor_sprite_cb( - placement, - [pw, ph], - hotspot, - plan.alpha, - plan.clip, - [rw, rh], - ); + }; // 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: vec![(tex, view)], binds, impacts }) + Some(CursorDraw { _bufs: bufs, _tex: tex, _view: view, binds }) })(); // Bind group de la passe de composition d'`accum` (layout du blur : // uniform + texture + sampler). Construit hors de la pass, comme les @@ -2852,30 +2723,6 @@ 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 { @@ -2933,10 +2780,6 @@ 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 { @@ -2990,11 +2833,6 @@ impl Compositor { rpass.set_bind_group(0, &a.bind, &[]); rpass.draw(0..4, 0..1); } - // 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); - } // 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). @@ -4694,574 +4532,6 @@ 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"); - } - } - - // ----------------------------------------------------------------------- - // 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 8970435ba..b4acfba47 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, +use crate::frame_geometry::{parse_hex, FrameGeometryInput, SCREEN_SHADOW_OFFSET_FRAC, SCREEN_SHADOW_SPREAD_FRAC, WEBCAM_SHADOW_OFFSET_FRAC, WEBCAM_SHADOW_OPACITY, WEBCAM_SHADOW_SPREAD_FRAC}; use crate::scene::{Scene, SceneBackground}; @@ -262,8 +262,6 @@ 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 @@ -282,11 +280,6 @@ 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 @@ -332,10 +325,6 @@ 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. @@ -622,14 +611,12 @@ 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), 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, @@ -649,7 +636,6 @@ 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(), @@ -700,17 +686,6 @@ 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; } @@ -895,40 +870,6 @@ 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) { @@ -1061,8 +1002,7 @@ impl Compositor { Some(SceneBackground::Color { color }) => { self.draw_solid(enc, &solid(parse_hex(color).unwrap_or(BLACK))); } - // Le mouvement ne vaut que pour le fond d'écran : la bulle garde son dégradé immobile. - Some(SceneBackground::Gradient { angle_deg, stops, .. }) => { + 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. @@ -1143,66 +1083,6 @@ 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)] @@ -1250,9 +1130,8 @@ impl Compositor { ); } - /// É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)] + /// É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. unsafe fn draw_tilted_screen( &self, enc: &metal::RenderCommandEncoderRef, @@ -1260,31 +1139,48 @@ 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 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)); - // 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, + 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]); + 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], 0.0, 0.0], + ..Default::default() + }, + y, + uv, ); - self.draw_video(enc, &cb, y, uv); } @@ -1931,13 +1827,8 @@ impl Compositor { Ok(enc) } - /// 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)] + /// Sprite de curseur (mode 7). Rend `Err` quand l'art n'est pas chargeable, pour que + /// l'appelant retombe sur le curseur dessiné. unsafe fn draw_cursor_sprite( &self, enc: &metal::RenderCommandEncoderRef, @@ -1946,40 +1837,61 @@ impl Compositor { a: f32, sprite: &crate::scene::SceneCursorSprite, clip: [f32; 4], - 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) }; - let cb = crate::frame_geometry::cursor_sprite_cb( - placement, - [pw, ph], - [sprite.hotspot_x, sprite.hotspot_y], - a, - clip, - [rw, rh], - ); + 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() + } + } + }; enc.set_fragment_texture(2, Some(&tex)); self.draw_solid(enc, &cb); Ok(()) @@ -1991,10 +1903,6 @@ impl Compositor { /// 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. - /// - /// 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, enc: &metal::RenderCommandEncoderRef, @@ -2004,12 +1912,10 @@ impl Compositor { size_px: f32, a: f32, clip: [f32; 4], - 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, model) - { + if let Err(e) = self.draw_cursor_sprite(enc, placement, size_px, a, sprite, clip) { eprintln!("[compositor] sprite curseur \"{}\" : {e:#}", sprite.path); } } @@ -2082,17 +1988,9 @@ 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(); - // 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, @@ -2114,12 +2012,10 @@ 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, motion }) => { + Some(SceneBackground::Gradient { angle_deg, stops }) => { 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 { @@ -2127,8 +2023,7 @@ impl Compositor { src: [c1[0], c1[1], c1[2], c1[3]], mode: 5.0, color: c0, - fx: [a.sin(), -a.cos(), anim[0], anim[1]], - mb, + fx: [a.sin(), -a.cos(), 0.0, 0.0], ..Default::default() }, ); @@ -2177,33 +2072,32 @@ 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 = g.screen_tilt(s_px); + 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 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, ]; if cfg.shadow { let spread = SCREEN_SHADOW_SPREAD_FRAC * g.frame_min_px; - let offset = g.screen_shadow_offset(); + let offset = [0.0, SCREEN_SHADOW_OFFSET_FRAC * g.frame_min_px]; 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. 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, + // 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, ), } } - // 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( @@ -2217,24 +2111,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, square_top], + mb: [g.mb_taps, g.mb_amount, 1.0, 0.0], ..Default::default() }, &sy, &suv, ), Some(quad) => self.draw_tilted_screen( - enc, - quad, - s_px, - quad_center_px, - g.cut, - g.focus_plane, - g.s_radius, - square_top, - &sy, - &suv, - dof_pyramid.as_ref(), + enc, quad, s_px, quad_center_px, g.cut, g.s_radius, &sy, &suv, ), } @@ -2254,34 +2138,16 @@ 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(); - // 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)?; - self.draw_cur_themed( - e, - &sprites, - kind, - plan.placement, - plan.size_px, - plan.alpha, - plan.clip, - plan.model, - ); + self.draw_cur_themed(e, &sprites, kind, plan.placement, plan.size_px, plan.alpha, plan.clip); e.end_encoding(); } else { // Flou RÉEL, pas des copies discrètes : les N échantillons s'accumulent dans @@ -2306,7 +2172,6 @@ impl Compositor { plan.size_px, plan.alpha, plan.clip, - plan.model, ); } e.end_encoding(); @@ -3422,543 +3287,6 @@ 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" - ); - } - - // ----------------------------------------------------------------------- - // 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 62efa1e71..da8d12021 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -9,9 +9,9 @@ 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, decode_data_uri, ease_in_out_cubic, lerp, + cover_crop_uv, cover_uv_rect, cursor_sprite_dst, decode_data_uri, ease_in_out_cubic, lerp, lerp4, parse_hex, preset_placements, remap_box, screen_source_rect, timeline, CursorPlacement, - FrameParams, Placement, ShadowCaster, SpriteShape, CURSOR_BASE_SIZE_FRAC, FPS, + FrameParams, Placement, 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, }; @@ -75,16 +75,6 @@ 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, @@ -142,8 +132,6 @@ 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>, @@ -175,9 +163,6 @@ 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 @@ -232,11 +217,6 @@ 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 @@ -626,7 +606,6 @@ 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), @@ -642,7 +621,6 @@ 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), @@ -656,8 +634,6 @@ 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, }) } @@ -832,86 +808,6 @@ 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 @@ -1069,8 +965,7 @@ impl Compositor { Some(SceneBackground::Color { color }) => { self.draw_solid(&solid(parse_hex(color).unwrap_or(BLACK))); } - // Le mouvement ne vaut que pour le fond d'écran : la bulle garde son dégradé immobile. - Some(SceneBackground::Gradient { angle_deg, stops, .. }) => { + 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. @@ -1143,39 +1038,6 @@ 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`), @@ -1529,18 +1391,6 @@ 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 { @@ -1566,11 +1416,7 @@ 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). 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. + /// retombe sur `draw_cursor` (math dot+ring). unsafe fn draw_cursor_sprite( &self, placement: CursorPlacement, @@ -1578,41 +1424,62 @@ impl Compositor { a: f32, sprite: &SceneCursorSprite, clip: [f32; 4], - 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( - placement, - [pw, ph], - [sprite.hotspot_x, sprite.hotspot_y], - a, - clip, - [self.rw(), self.rh()], - ); + 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() + } + } + }; + self.upload_cb(&cb); self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); self.ctx.Draw(4, 0); @@ -1620,13 +1487,11 @@ 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). Avec `model`, ce sprite extrudé (mode 15) : même - /// résolution que `plan_cursor`, qui en a tiré la pose. + /// à défaut le curseur math (dot+ring). /// /// 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, @@ -1635,11 +1500,10 @@ impl Compositor { size_px: f32, a: f32, clip: [f32; 4], - 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, model).is_ok() { + if self.draw_cursor_sprite(placement, size_px, a, sprite, clip).is_ok() { return; } } @@ -1778,12 +1642,12 @@ 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; let mb_amount = g.mb_amount; let source_t = g.source_t; + let zoom_rotation = g.zoom_rotation; let _padding_scale = g.padding_scale; let cut = g.cut; let s_dst = g.s_dst; @@ -1797,10 +1661,6 @@ 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]); @@ -1823,25 +1683,19 @@ impl Compositor { ..Default::default() }); } - SceneBackground::Gradient { angle_deg, stops, motion } => { + SceneBackground::Gradient { angle_deg, stops } => { 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], anim[0], anim[1]], - mb, + fx: [dir[0], dir[1], 0.0, 0.0], ..Default::default() }); } @@ -1913,7 +1767,8 @@ 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 = g.screen_tilt(s_px); + 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 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 @@ -1921,56 +1776,24 @@ 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 = g.screen_shadow_offset(); + let offset = [0.0, SCREEN_SHADOW_OFFSET_FRAC * frame_min_px]; let opacity = 0.45 * lp.shadow_scale; - 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 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, + 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, - [su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv], - g.focus_plane, - s_radius, - square_top, - dof, - render_px, + // Même rayon que le plan incliné lui-même (cf. le dessin du mode 8). + s_radius * quad.scale, + spread, + offset, + opacity, ), - &sy, - &suv, - ); - self.ctx.PSSetShaderResources(2, Some(&[None])); - } else { + } + } + if crate::regions::is_identity_rotation(zoom_rotation) { self.draw_video( &LayerCB { dst: s_dst, @@ -1981,7 +1804,60 @@ 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, square_top], + mb: [mb_taps, mb_amount, 1.0, 0.0], + ..Default::default() + }, + &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) + }); + 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]); + 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], 0.0, 0.0], ..Default::default() }, &sy, @@ -2004,18 +1880,13 @@ 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(); - // 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( &cursor_sprites, @@ -2024,7 +1895,6 @@ impl Compositor { plan.size_px, plan.alpha, plan.clip, - plan.model, ); } else { // Flou RÉEL, pas des copies discrètes : accumule les N échantillons dans un @@ -2042,7 +1912,6 @@ impl Compositor { plan.size_px, plan.alpha, plan.clip, - 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 5ce803f38..667c5d4f3 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -12,17 +12,6 @@ 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. @@ -33,10 +22,6 @@ 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. @@ -110,9 +95,7 @@ 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); - 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 } + CursorTrack { samples, follow_samples, clicks, types } } /// État du curseur au temps `t` : la dernière transition à `t` ou avant. `None` avant la @@ -175,45 +158,11 @@ impl CursorTrack { sample_at(&self.follow_samples, t) } - /// Position (cx, cy) au temps `t` (interpolation linéaire), ou None si hors piste. Brute, sauf - /// sur une piste `smoothed()`. + /// Position (cx, cy) BRUTE au temps `t` (interpolation linéaire), ou None si hors piste. 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 @@ -225,7 +174,15 @@ impl CursorTrack { pub fn bounce(&self, t: f32) -> f32 { const ANIM_S: f32 = 0.26; // 260 ms const PRESS_FRAC: f32 = 0.38; - let Some(tc) = self.last_click_at(t) else { return 1.0 }; + 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 elapsed = (t - tc) / ANIM_S; if elapsed >= 1.0 { return 1.0; @@ -239,26 +196,6 @@ 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] { - 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 @@ -287,12 +224,8 @@ 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. 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()) - } + // déplace la trajectoire, pas la chronologie de ce que faisait l'utilisateur. + CursorTrack::new(samples, self.clicks.clone(), self.types.clone()) } /// Opacité du curseur (0.0..1.0) selon l'inactivité (auto-hide). @@ -489,42 +422,6 @@ 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 deleted file mode 100644 index 9bd449ed8..000000000 --- a/crates/compositor/src/cursor_sdf.rs +++ /dev/null @@ -1,413 +0,0 @@ -//! 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 bc6ebee95..8973425ee 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -188,24 +188,6 @@ pub(crate) fn parse_color_channel(raw: &str, max: f32) -> Option { } Some(n / max) } -/// 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() - { - return None; - } - let x0 = crop.x.clamp(0.0, 1.0); - let y0 = crop.y.clamp(0.0, 1.0); - 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]) - }) - .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. @@ -216,7 +198,19 @@ pub(crate) fn screen_source_rect( zoom: f32, focus: [f32; 2], ) -> [f32; 4] { - let [x0, y0, x1, y1] = normalized_crop(crop); + let normalized_crop = crop.and_then(|crop| { + if !crop.x.is_finite() || !crop.y.is_finite() + || !crop.width.is_finite() || !crop.height.is_finite() + { + return None; + } + let x0 = crop.x.clamp(0.0, 1.0); + let y0 = crop.y.clamp(0.0, 1.0); + 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]); 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 }; @@ -265,27 +259,6 @@ 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. @@ -367,20 +340,6 @@ 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 @@ -403,65 +362,6 @@ 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`. /// @@ -500,43 +400,16 @@ 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 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 { match (self, other) { ( CursorPlacement::Tilted { plane_pt: a, quad, center_px, screen_px, render_px }, - CursorPlacement::Tilted { - plane_pt: b, - quad: quad_b, - center_px: center_b, - screen_px: screen_b, - .. - }, + CursorPlacement::Tilted { plane_pt: b, .. }, ) => CursorPlacement::Tilted { - 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), - depth_k: ( - 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)], + plane_pt: [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f], + quad, + center_px, + screen_px, render_px, }, (a, b) => { @@ -560,213 +433,6 @@ impl CursorPlacement { } } } - -/// `LayerCB` du sprite de curseur, pour les trois backends : mode 7 sur écran droit, mode 13 -/// posé sur le plan incliné. -/// -/// `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], - hotspot: [f32; 2], - alpha: f32, - clip: [f32; 4], - render_px: [f32; 2], -) -> LayerCB { - let [pw, ph] = sprite_px; - let [rw, rh] = render_px; - 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, 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 (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, 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.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. -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 { @@ -949,8 +615,6 @@ 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, - /// 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 @@ -967,36 +631,6 @@ 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 @@ -1030,7 +664,6 @@ impl Default for LiveParams { cursor_size_scale: 1.0, cursor_bounce_scale: 1.0, cursor_motion_blur: 0.0, - cursor_model3d: false, cursor_auto_hide: false, has_webcam: true, } @@ -1069,7 +702,6 @@ 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_model3d: s.cursor.model3d, cursor_auto_hide: s.cursor.auto_hide, ..LiveParams::default() } @@ -1096,10 +728,6 @@ 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 @@ -1110,29 +738,10 @@ 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], - /// 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], - /// 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 @@ -1140,8 +749,7 @@ 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`, rétréci par `fitInWindowFrame` sous un cadre) et que l'overlay web - /// reçoit comme conteneur. Le contrat de + /// (`layout.screenRect`) 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 @@ -1156,10 +764,6 @@ 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. @@ -1186,38 +790,6 @@ 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. - /// - /// 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 { - 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], - self.zoom_rotation, - self.zoom_rotation_dyn, - ) - }) - } - - /// 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 && self.tilted() && (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] { @@ -1233,155 +805,6 @@ 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_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] { - [ - (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. 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); - crate::regions::TiltedQuad { - 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 = [ - 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, quad.warp_flag()) - } - - /// 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_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]], - 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 } - } - } - } - - /// 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 - /// 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, 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))); - 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], - // `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, - 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é. /// @@ -1424,7 +847,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 !self.tilted() { + if crate::regions::is_identity_rotation(self.zoom_rotation) { // 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 @@ -1448,7 +871,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 = self.screen_tilt(s_px)?; + let quad = crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], self.zoom_rotation); let centre = [ (self.s_dst[0] + self.s_dst[2] * 0.5) * rw, (self.s_dst[1] + self.s_dst[3] * 0.5) * rh, @@ -1490,7 +913,6 @@ 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, }) @@ -1514,8 +936,6 @@ 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 @@ -1529,23 +949,21 @@ 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, `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. + /// `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. 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, if self.projective { 1.0 } else { 0.0 }], + [0.0, 0.0, 1.0, 0.0], ), } } @@ -1646,39 +1064,11 @@ 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; - 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() { - // 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); + 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; - 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; @@ -1767,6 +1157,9 @@ 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) @@ -1811,24 +1204,8 @@ 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_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, - }; + let s_base = fit_screen(p.screen.dst); + let s_base_prev = fit_screen(pp.screen.dst); // 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 @@ -1858,48 +1235,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)); - // 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. - 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, - 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) @@ -1974,34 +1310,15 @@ 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. - // 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) { + let s_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 * rounded_box_min_px, + (true, Some(f), _) => f * s_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 @@ -2035,21 +1352,12 @@ 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, - camera, 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 - // 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. + // La boîte écran telle qu'elle serait sans zoom : `remap_box` n'est PAS appliqué. s_ann: s_base, s_radius, frame_min_px, @@ -2058,7 +1366,6 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { w_px, w_radius, shape_fade, - window_frame, } } @@ -2082,28 +1389,6 @@ pub struct CursorPlan { pub cursor_type: Option, /// Opacité effective (0..1) tenant compte de l'inactivité (auto-hide) et du zoom. pub alpha: f32, - /// 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, -} - -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`. @@ -2119,103 +1404,59 @@ 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], 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], 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], 0.0); - } - let window = [clip.source_start_sec as f32, clip.source_end_sec as f32]; - 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 /// 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 alpha = cursor_alpha(input.scene, input.cfg, &input.live, input.track, input.t); + 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; if alpha <= 0.001 { return None; } let s_px = [g.s_dst[2] * rw, g.s_dst[3] * rh]; - let tilt = g.screen_tilt(s_px); + 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 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, ]; - 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 cursor_bounds: [f32; 4] = match tilt.as_ref() { + None => 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 [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(|p| { - let [fx, fy] = cursor_plane_point(g.cut, [input.u_max, input.v_max], p)?; + 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; + } Some(match tilt.as_ref() { Some(&quad) => CursorPlacement::Tilted { plane_pt: [fx, fy], @@ -2224,46 +1465,19 @@ 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], - [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]], }, }) }) }; - // Le curseur modélisé touche le plan à chaque clic : il se pose sur le point cliqué brut - // (`pinned_at`), pas sur la piste lissée qui traîne derrière la souris. Le sprite plat garde - // la piste telle quelle, son rendu est celui d'avant. - let at = |t: f32| if model3d { input.track.pinned_at(t) } else { input.track.at(t) }; - let placement = place(at(input.t), g.s_dst)?; + let placement = place(input.track.at(input.t), g.s_dst)?; let lp = input.live; // `cursor_bounce_scale` est le clickBounce brut (0..5) : au-delà de 1/0.24 ≈ 4.17, le creux // de la pression (0.76) passe sous zéro. Une taille négative retournerait le sprite, donc // plancher à 0 — le curseur disparaît le temps du creux, c'est ce qu'une telle amplitude dit. - // Le curseur modélisé n'a pas de rebond d'échelle : le clic le fait toucher le plan à la place. - let bounce = if model3d { - 1.0 - } else { - (1.0 + (input.track.bounce(input.t) - 1.0) * lp.cursor_bounce_scale).max(0.0) - }; + let bounce = (1.0 + (input.track.bounce(input.t) - 1.0) * lp.cursor_bounce_scale).max(0.0); let size_px = CURSOR_BASE_SIZE_FRAC * g.frame_min_px * lp.cursor_size_scale * bounce * g.padding_scale; // Taille nulle = rien à dessiner, et surtout rien à projeter : sur un plan incliné les quatre @@ -2281,7 +1495,7 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> 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 { - Vec::new() - }; - Some(CursorPlan { placement, prev_placement, size_px, taps, clip, - cursor_type: cursor_type.map(str::to_string), + cursor_type: input.track.type_at(input.t).map(str::to_string), alpha, - model: model_sprite.map(|s| { - let pointing = pointing_factor([s.hotspot_x, s.hotspot_y]); - cursor_pose(input.track, input.t, lp.cursor_bounce_scale, pointing) - }), - impacts, }) } -// ============ Curseur modélisé (mode 15) ============ -// -// Le sprite de l'état courant (thème par défaut) devient un OBJET : sa silhouette extrudée, lancée -// de rayons par pixel dans le shader, éclairée, et qui porte une vraie ombre sur l'écran. La -// silhouette est le champ de distance signé que `cursor_sdf` tire de l'alpha du sprite ; le dessus -// porte l'art du sprite, les flancs et le chanfrein la couleur de son bord. Rust ne fait ici que la -// pose (fonction pure de `t`), la caméra et la boîte de dessin ; les trois shaders font le reste -// avec les MÊMES constantes. -// -// Repère du MODÈLE : unité = plus grand côté du sprite (`size_px`, la taille du curseur), 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. -// -// Emplacements du `LayerCB` au mode 15 (128 octets, inchangés) : -// dst rect de dessin (sortie 0..1) : boîte du modèle ET de son ombre -// quad_px taille de ce rect en px (le VS en tire `local`) -// src.xy décalage px : `local + src.xy` = pixel relatif à l'axe de la caméra, ancrage ôté -// src.z P, distance caméra–plan (px) ; src.w = U, l'unité du modèle (px du plan) -// color.rg coin haut-gauche du sprite, repère du modèle (unités) -// color.b écrasement : l'épaisseur vaut MODEL_THICK × color.b (`CursorPose::squash`). -// Un texel du sprite se tire de la taille du champ (`SDF_UPSAMPLE` / plus grand côté) -// color.a opacité (auto-hide, zoom) -// fx.xyz rotation dessinée du plan (rad, X/Y/Z, ordre de `regions::rotate_point`) -// fx.w tangage du modèle (rad) -// src_prev.xyz hotspot de la face du dessus, repère du plan (px, centre du plan à l'origine) -// src_prev.w lacet du modèle (rad) -// dst_prev rect de clip « Clip to canvas » (sortie 0..1), comme au mode 13 -// mb.xy demi-taille du plan dans son repère (px) : l'ombre s'arrête à ses bords -// mb.zw translation du plan dans le repère caméra (px, `TiltedQuad::offset`) : le -// plan vaut `R·p + (mb.zw, 0)`, œil en (0, 0, P). Nulle sous un angle fixe. -// radius_px rapport w/h du sprite : sa taille (unités) en découle, plus grand côté = 1 -// Textures : le sprite RGBA (alpha droit) et son champ R16F (`cursor_sdf`), sur le même rect. -// Windows et macOS : sprite en t2/texture(2) (`texImg`), champ en t4/texture(4) (`texSdf`). -// Linux : sprite au binding 1 (`texY`), champ au binding 2 (`texU`). - -/// Épaisseur du modèle : la face du dessus est en z = 0, celle du dessous en z = -épaisseur. -pub const MODEL_THICK: f32 = 0.19; -/// Rayon du chanfrein arrondi des arêtes du dessus et du dessous. -pub const MODEL_BEVEL: f32 = 0.045; -/// Direction VERS la lumière, repère caméra (x droite, y bas, z vers le spectateur) : en haut à -/// gauche, devant. Unitaire. Miroir de `MODEL_LIGHT` dans les trois shaders. -pub const MODEL_LIGHT: [f32; 3] = [-0.4194, -0.5792, 0.6990]; - -/// Garde au sol au repos, en unités du modèle. -const MODEL_HOVER: f32 = 0.35; -/// Gain sur `tap` pour la descente : à 1,25 le modèle reste posé de 27 à 74 ms après le clic, -/// assez pour qu'au moins une image le montre au contact jusqu'à 21 i/s. -const MODEL_CONTACT_GAIN: f32 = 1.25; -/// Tangage au repos (queue relevée, pointe vers le bas) et supplément au creux de la pression. -const MODEL_PITCH_IDLE_DEG: f32 = 18.0; -const MODEL_PITCH_PRESS_DEG: f32 = 10.0; -/// Le supplément de tangage suit `clickBounce` rapporté à sa valeur par défaut, borné à 2×. -const MODEL_CLICK_BOUNCE_REF: f32 = 2.5; -/// Lacet maximal, et la vitesse (largeurs de l'écran par seconde) qui en donne 76 % (`tanh 1`). -const MODEL_YAW_MAX_DEG: f32 = 25.0; -const MODEL_YAW_SPEED: f32 = 0.8; -/// Demi-fenêtre de la différence centrée qui mesure la vitesse (comme la parallaxe du plan). -const MODEL_YAW_HALF_WINDOW_S: f32 = 0.1; -/// Combien de temps avant un clic le modèle se tourne vers sa cible. -const MODEL_AIM_S: f32 = 0.3; -/// Écrasement au creux du clic (part de l'épaisseur perdue), à `clickBounce` par défaut. -/// L'épaisseur ne descend jamais sous `MODEL_SQUASH_MIN` (deux chanfreins, plus un peu de flanc). -const MODEL_SQUASH: f32 = 0.3; -const MODEL_SQUASH_MIN: f32 = 0.55; -/// Hotspot → part « pointeur » (cf. `pointing_factor`) : sous `LO` (distance au centre rapportée -/// au demi-côté), un curseur centré ; au-delà de `HI`, un pointeur. La flèche est à 0,83. -const MODEL_POINTING_LO: f32 = 0.3; -const MODEL_POINTING_HI: f32 = 0.75; -/// Pénombre (miroir des shaders) : un point du plan est dans l'ombre douce quand son rayon vers -/// la lumière passe à moins de `t / MODEL_SOFTNESS` du modèle, `t` la distance parcourue, et -/// jamais au-delà de `MODEL_SHADOW_PAD` (la marche s'arrête à la boîte du modèle élargie d'autant). -const MODEL_SOFTNESS: f32 = 6.0; -const MODEL_SHADOW_PAD: f32 = 0.45; -/// Portée de l'ombre de contact (miroir des shaders), en unités du modèle. -const MODEL_CONTACT_RADIUS: f32 = 0.12; - -/// Ce que le mode 15 sait du sprite qu'il extrude : son rect dans le repère du modèle et le haut -/// de sa silhouette. Tiré du PNG par `cursor_sdf::CursorSdf`, le hotspot venant de la scène. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct SpriteShape { - /// Taille du sprite en unités du modèle : son plus grand côté vaut 1. - pub size: [f32; 2], - /// Hotspot, fraction du sprite (`SceneCursorSprite`). - pub hotspot: [f32; 2], - /// Haut de la silhouette (alpha 0,5), fraction de la hauteur du sprite. - pub top: f32, -} - -impl SpriteShape { - /// Coin haut-gauche du sprite, repère du modèle. - pub(crate) fn origin(&self) -> [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`). +/// Poids d'un échantillon du flou de mouvement de curseur (0 = queue/passé, taps-1 = tête/courant). /// -/// - 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; - } +/// Poids croissant de 0.25 (queue) à 1.0 (tête), normalisé pour que la somme valle 1.0. +/// La somme des (0.25 + 0.75 * k / (taps - 1)) pour k de 0 à taps-1 vaut taps * (0.25 + 1.0) / 2 = taps * 0.625. +#[inline] +pub fn cursor_tap_weight(k: u32, taps: u32) -> f32 { + if taps <= 1 { + return 1.0; } - 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, - 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: [rw, rh] } = - placement - else { - return None; - }; - 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). -/// -/// Poids croissant de 0.25 (queue) à 1.0 (tête), normalisé pour que la somme valle 1.0. -/// La somme des (0.25 + 0.75 * k / (taps - 1)) pour k de 0 à taps-1 vaut taps * (0.25 + 1.0) / 2 = taps * 0.625. -#[inline] -pub fn cursor_tap_weight(k: u32, taps: u32) -> f32 { - if taps <= 1 { - return 1.0; - } - let t = k as f32 / (taps - 1) as f32; - let ramp = 0.25 + 0.75 * t; - let sum = taps as f32 * 0.625; - ramp / sum + let t = k as f32 / (taps - 1) as f32; + let ramp = 0.25 + 0.75 * t; + let sum = taps as f32 * 0.625; + ramp / sum } /// Les clés à évincer d'un cache de textures pour repasser sous `budget`, la moins récemment @@ -2977,8 +1660,6 @@ 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), } } @@ -3034,217 +1715,6 @@ 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_in(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_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"); - }; - 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, ..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() { - 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( @@ -3329,56 +1799,6 @@ 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", @@ -3456,250 +1876,6 @@ mod tests { assert!((m.strength - 2.0).abs() < 1e-3, "force {} au lieu de 2", m.strength); } - /// La parallaxe (`regions::dynamic_tilt`) passe par `plan_frame` une seule fois, et le - /// curseur la porte comme l'écran : même quad, même échelle gelée. Curseur masqué ou - /// région sans préset → rien ne bouge. - #[test] - fn the_cursor_rides_the_same_dynamic_tilt_as_the_screen() { - let cfg = crate::config::all().pop().expect("au moins une config"); - // Le curseur file vers la droite autour de t = 1,5 s (l'instant de `golden_input`). - 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 with_track = |scene: &Scene| FrameGeometryInput { - cursor: Some(track), - ..golden_input(scene, &cfg) - }; - - let still = plan_frame(&golden_input(&tilted_golden_scene(), &cfg)); - let g = plan_frame(&with_track(&tilted_golden_scene())); - assert_eq!(still.zoom_rotation_dyn, [0.0; 3], "sans piste"); - assert_eq!(g.zoom_rotation, still.zoom_rotation, "la base ne dépend pas du curseur"); - assert!(g.zoom_rotation_dyn[1] > 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"); - } - - /// 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é"), - } - } - - /// `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() { @@ -3713,7 +1889,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 = g.screen_tilt(s_px).expect("iso incline"); + let quad = crate::regions::rotated_quad_corners_px(s_px[0], s_px[1], g.zoom_rotation); 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], @@ -3813,20 +1989,18 @@ 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, 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] = [ + // w_px[2], frame_min_px, padding_scale, shape_fade, mb_taps, source_t. + let want: [f32; 21] = [ 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!( @@ -3837,43 +2011,6 @@ 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); - } - - /// 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] @@ -4283,14 +2420,9 @@ 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], - camera: None, 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], @@ -4301,7 +2433,6 @@ mod tests { w_px: [0.0, 0.0], w_radius: 0.0, shape_fade: 0.0, - window_frame: None, } } @@ -4411,823 +2542,5 @@ 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); } - - /// 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() }; - 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::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 `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_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); - 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], - ); - let want = before(plan.placement, 30.0, 34.0, [0.2, 0.1], 0.8, clip); - assert_eq!(bytes(&got), bytes(&want), "rotation {rot:?}"); - } - } - - // ---- 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 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 - } - - #[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:?}" - ); - // 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 a37083c45..ac9c93bab 100644 --- a/crates/compositor/src/lib.rs +++ b/crates/compositor/src/lib.rs @@ -29,10 +29,8 @@ 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 4243a33d0..51c9765f2 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, ProgrammeClock}; +use crate::regions::speed_at; use crate::scene::Scene; use crate::config::{self, Cfg}; use crate::cursor::CursorTrack; @@ -288,9 +288,6 @@ 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 { @@ -310,7 +307,6 @@ impl Player { has_current_frame: false, use_current_on_next_step: false, idx: 0, - programme: None, }) } @@ -429,27 +425,6 @@ 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, @@ -573,7 +548,6 @@ 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. @@ -657,8 +631,6 @@ struct InspectorParams { cursor_smoothing: f32, /// 0..1 : force du flou de mouvement DU CURSEUR (indépendant du motion blur écran). cursor_motion_blur: f32, - /// Flèche modélisée en 3D (mode 15). - cursor_model3d: bool, cursor_auto_hide: bool, } @@ -679,7 +651,6 @@ impl Default for InspectorParams { cursor_bounce_scale: 1.0, cursor_smoothing: 0.0, cursor_motion_blur: 0.0, - cursor_model3d: false, cursor_auto_hide: false, } } @@ -937,7 +908,6 @@ impl LiveView { "webcamMirror" => p.webcam_mirror = value, "cursorShow" => p.cursor_show = value, "cursorAutoHide" => p.cursor_auto_hide = value, - "cursorModel3d" => p.cursor_model3d = value, _ => {} } } @@ -1300,7 +1270,6 @@ 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. @@ -1560,7 +1529,6 @@ 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_model3d: ip.cursor_model3d, cursor_auto_hide: ip.cursor_auto_hide, has_webcam: has_real_webcam, }); @@ -1600,11 +1568,6 @@ 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 138d6d57d..e6f645b89 100644 --- a/crates/compositor/src/regions.rs +++ b/crates/compositor/src/regions.rs @@ -2,11 +2,9 @@ //! 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 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). +//! 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). -use crate::camera::Follow; use crate::cursor::CursorTrack; use crate::scene::{SceneCameraFullscreenRegion, SceneSpeedRegion, SceneZoomRegion}; @@ -95,70 +93,6 @@ 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, @@ -246,16 +180,6 @@ 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; @@ -263,7 +187,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 = lead_in_start(region); + let lead_in_start = zoom_in_end - ZOOM_IN_TRANSITION_WINDOW_S; let lead_out_end = end + TRANSITION_WINDOW_S; if t < lead_in_start || t > lead_out_end { return 0.0; @@ -351,112 +275,9 @@ 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, - /// 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 : 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 { - scale: 1.0, - focus: [0.5, 0.5], - 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], -}; - -/// 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, - } -} - -/// 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 } -} +const IDENTITY_ZOOM: ZoomState = ZoomState { scale: 1.0, focus: [0.5, 0.5], rotation: [0.0, 0.0, 0.0] }; /// Port de `easeConnectedPan` (TS) : cubic-bezier(0.1, 0, 0.2, 1). fn ease_connected_pan(t: f32) -> f32 { @@ -546,12 +367,6 @@ 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 @@ -594,20 +409,7 @@ 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; } @@ -622,43 +424,10 @@ pub fn zoom_state_in( 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_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, - 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), + rotation: lerp_rotation3d(rotation3d_for(&cur.rotation), rotation3d_for(&next.rotation), progress), }; } @@ -667,17 +436,10 @@ pub fn zoom_state_in( 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: fixed_rotation(next), - tilt: fixed_flag(next), - click_impact: impact_flag(next), - camera, - aim: weighted(seen.aim, camera), - orbit: weighted(seen.orbit, camera), + rotation: rotation3d_for(&next.rotation), }; } } @@ -718,17 +480,10 @@ pub fn zoom_state_in( // 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], 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), + rotation: lerp_rotation3d([0.0, 0.0, 0.0], rotation3d_for(&r.rotation), strength), } } None => IDENTITY_ZOOM, @@ -791,36 +546,11 @@ 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)) -} - -/// 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 [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] = p; + let (mut px, mut py, mut pz) = (x0, y0, 0.0f32); // rotateZ let (zx, zy) = (px * cg - py * sg, px * sg + py * cg); px = zx; @@ -833,61 +563,18 @@ pub(crate) fn rotate_point(p: [f32; 3], rot: [f32; 3]) -> [f32; 3] { let (xy, xz) = (py * ca - pz * sa, py * sa + pz * ca); py = xy; pz = xz; - [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`. -/// -/// 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. -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(); - let (cg, sg) = (g.cos(), g.sin()); - (sa * sg - sb * ca * cg, sa * cg + sb * ca * sg) + 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)) } -/// 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). -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`). -/// -/// 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( @@ -912,12 +599,6 @@ 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. @@ -925,87 +606,21 @@ 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), - /// 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 /// (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). -/// -/// `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 { +pub fn rotated_quad_corners_px(width: f32, height: f32, rot: [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); - 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 @@ -1017,7 +632,22 @@ fn contain_scale( // 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 = project_scaled_corners(width, height, scale, rot, perspective)?; + 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, + }; + } + }; for _ in 0..8 { let (max_x, max_y) = projected_extents(&corners); if max_x <= 0.0 || max_y <= 0.0 { @@ -1035,204 +665,7 @@ fn contain_scale( None => break, } } - Some((scale, corners)) -} - -/// 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. -/// - `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 pour l'impact du clic. -/// -/// `impact` : l'impact du clic (`click_impact`), déjà pondéré par ses propres portes. Il -/// 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>, - 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 h = PARALLAX_VELOCITY_HALF_WINDOW_S; - 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 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, - ] -} - -/// 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) + TiltedQuad { corners, scale } } impl TiltedQuad { @@ -1245,46 +678,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); (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 = 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) @@ -1309,25 +709,7 @@ mod zoom_focus_tests { rotation: None, under_trim: false, hide_cursor: false, - click_impact: false, - } - } - - /// 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] @@ -1585,13 +967,12 @@ 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, preset("iso"), [0.0; 3]); + let quad = rotated_quad_corners_px(1920.0, 1080.0, rotation3d_for(&Some("iso".into()))); 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); @@ -1604,7 +985,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, preset("left"), [0.0; 3]); + let quad = rotated_quad_corners_px(1920.0, 1080.0, rotation3d_for(&Some("left".into()))); 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; @@ -1617,7 +998,7 @@ mod tilt_tests { #[test] fn a_tilted_plane_moves_the_cursor_off_the_upright_rect() { let (w, h) = (1920.0f32, 1080.0f32); - let quad = rotated_quad_corners_px(w, h, rotation3d_for(&Some("iso".into())), [0.0; 3]); + let quad = rotated_quad_corners_px(w, h, rotation3d_for(&Some("iso".into()))); 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); @@ -1639,7 +1020,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, [0.0; 3]).corners; + let corners = rotated_quad_corners_px(w, h, rot).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!( @@ -1662,7 +1043,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, [0.0; 3]).corners; + let c = rotated_quad_corners_px(1920.0, 1080.0, rot).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(); @@ -1681,117 +1062,10 @@ 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. - let corners = rotated_quad_corners_px(1000.0, 600.0, [0.0, 0.0, 0.0], [0.0; 3]).corners; + let corners = rotated_quad_corners_px(1000.0, 600.0, [0.0, 0.0, 0.0]).corners; let (max_x, max_y) = projected_extents(&corners); assert!((max_x - 500.0).abs() < 0.5 && (max_y - 300.0).abs() < 0.5); } @@ -1800,484 +1074,11 @@ mod tilt_tests { fn the_tilt_actually_tilts() { // Garde-fou contre un containment trop zélé qui aplatirait l'effet : les quatre coins // d'un quad incliné ne peuvent pas rester alignés deux à deux comme un rectangle droit. - let corners = rotated_quad_corners_px(1920.0, 1080.0, preset("iso"), [0.0; 3]).corners; + let corners = rotated_quad_corners_px(1920.0, 1080.0, rotation3d_for(&Some("iso".into()))).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. - 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])) - } - - /// 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, - click_impact: false, - }; - // `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. - 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; - // 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; - 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 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 { - 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:?}"); - } - } - } - } - - /// `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); - // 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}"); - } - } - - /// 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]), [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]), - [0.0; 3], - "force {strength}" - ); - } - 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, [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, [0.0; 3]); - 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; 3])[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, [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, [0.0; 3]); - 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, [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() { - 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, - 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); - 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 @@ -2360,7 +1161,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, [0.0; 3]).corners; + let corners = rotated_quad_corners_px(1920.0, 1080.0, rot).corners; let mut dropped = Vec::new(); let n = 64; for i in 0..=n { @@ -2451,227 +1252,3 @@ 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()); - } -} - -#[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 7025b927b..3371aa8cd 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -162,35 +162,6 @@ 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, - /// 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é -/// 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`). @@ -202,30 +173,10 @@ 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 @@ -385,10 +336,7 @@ pub struct SceneZoomRegion { /// "manual" | "auto" (suit la télémétrie curseur) | null (= manual). #[serde(default)] pub focus_mode: Option, - /// 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. + /// "iso" | "left" | "right" | null. 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 @@ -405,11 +353,6 @@ 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. @@ -450,11 +393,6 @@ pub struct SceneCursor { pub smoothing: f32, pub motion_blur: f32, pub click_bounce: f32, - /// 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 model3d: bool, pub clip_to_bounds: bool, /// id du thème (jeu de sprites) — informatif ici : le natif consomme `cursor_sprites`. pub theme: String, @@ -711,10 +649,9 @@ 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, motion } => { + SceneBackground::Gradient { angle_deg, ref stops } => { assert_eq!(angle_deg, 135.0); assert_eq!(stops.len(), 2); - assert_eq!(motion, GradientMotion::None); } _ => panic!("expected gradient"), } @@ -726,22 +663,6 @@ 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}}"##; @@ -753,20 +674,6 @@ 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 52af2ca2d..245069d41 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -4,18 +4,16 @@ 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 ; mode 15 : décalage px du rayon, P, U + float4 src; // u0,v0,u1,v1 dans l'espace source 0..1 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, 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 + 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é) }; -// 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 { @@ -40,20 +38,13 @@ 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) ; mode 8 : pyramide de flou +Texture2D texImg : register(t2); // wallpaper image RGBA (fond, mode 6) // 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); -// 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 = -// ~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) { @@ -183,113 +174,6 @@ 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) -{ - 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)); -} - -// 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. @@ -339,377 +223,15 @@ float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 local_px) return sum / max(total, 1e-4); } -// ============ 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) - { - return 1.0; - } - 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é 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) ; src.x = 1 : warp projectif. - if (mode > 13.5) - { - 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é - } - 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 // 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.x = 1 : warp projectif. - // Un mode supérieur doit être testé AVANT cette branche, qui n'a pas de borne haute. + // « Clip to canvas » en espace sortie. if (mode > 12.5) { if (i.pout.x < dst_prev.x || i.pout.x > dst_prev.x + dst_prev.z || @@ -717,7 +239,7 @@ float4 ps_main(VSOut i) : SV_Target { return float4(0.0, 0.0, 0.0, 0.0); } - float3 r = quad_inverse(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw, mb.x); + 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 sprite projeté @@ -727,18 +249,12 @@ 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) 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. + // 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é). // 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). - // 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 @@ -788,8 +304,7 @@ 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.w = 1 si son warp est projectif. + // mb.z = 1 si le masque est un quad incliné (coins TL, TR dans dst_prev, BR, BL dans src_prev). if (mode > 9.5) { // Masque de forme, en coords locales normalisées du quad. @@ -800,7 +315,7 @@ float4 ps_main(VSOut i) : SV_Target // transparent SUR la zone à cacher. if (mb.z > 0.5) { - float3 w = quad_inverse(i.local, dst_prev.xy, dst_prev.zw, src_prev.xy, src_prev.zw, mb.w); + float3 w = quad_inverse_bilinear(i.local, dst_prev.xy, dst_prev.zw, src_prev.xy, src_prev.zw); if (w.z < 0.5) { return float4(0.0, 0.0, 0.0, 0.0); @@ -876,7 +391,7 @@ float4 ps_main(VSOut i) : SV_Target if (mode > 7.5) { - float3 r = quad_inverse(i.local, fx.xy, fx.zw, src_prev.xy, src_prev.zw, dst_prev.w); + 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 quad projeté @@ -895,33 +410,11 @@ 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, (dst_prev.z > 0.5 && p.y < 0.0) ? 0.0 : max(radius_px, 0.0)); + float d = sd_round_rect(p, plane_px * 0.5, max(radius_px, 0.0)); float tilt_a = 1.0 - smoothstep(0.0, 1.5, d); - // 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)); - } - 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 + return float4(sample_yuv(uv) * 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 @@ -950,19 +443,9 @@ 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 @@ -971,10 +454,6 @@ 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é } @@ -1093,10 +572,9 @@ 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, (mb.w > 0.5 && p.y < 0.0) ? 0.0 : radius_px); + float d = sd_round_rect(p, halfsz, 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 31c80711c..1ab91d5f7 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -48,18 +48,16 @@ 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 ; mode 15 : décalage px du rayon, P, U + float4 src; // u0,v0,u1,v1 dans l'espace source 0..1 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, ..., 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 + 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é) }; -// 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 @@ -104,13 +102,8 @@ 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), 3 = texMask (R8), -// 4 = texSdf (champ du sprite de curseur, R16F, mode 15) +// ps_main : 0 = texY (Y, R8), 1 = texUV (CbCr, RG8), 2 = texImg (RGBA) // ps_fs_* : 0 = rgbTex (RGBA) // ================================================================================= @@ -237,57 +230,8 @@ 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) -{ - 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 15 modes via `layer.mode`. +// Pixel shader principal : un seul `ps_main` qui gère 14 modes via `layer.mode`. // Identique à `ps_main` côté HLSL ligne pour ligne (à la syntaxe MSL près). // ================================================================================= @@ -343,317 +287,6 @@ inline float3 blur_webcam_bg(float2 uv, float intensity, float2 qpx, float2 loca return sum / max(total, 1e-4); } -// 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)); -} - -// ============ 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)]], @@ -662,66 +295,9 @@ 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)]], - // 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)]]) + texture2d texMask [[texture(3)]]) { - // 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é 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 ; - // src.x = 1 : warp projectif. - if (layer.mode > 13.5) - { - 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é - } - 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. - // 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 || @@ -729,8 +305,8 @@ fragment float4 ps_main(VSOut i [[stage_in]], { return float4(0.0, 0.0, 0.0, 0.0); } - float3 r = quad_inverse(i.local, layer.fx.xy, layer.fx.zw, - layer.src_prev.xy, layer.src_prev.zw, layer.mb.x); + 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); @@ -781,22 +357,15 @@ fragment float4 ps_main(VSOut i [[stage_in]], return float4(layer.color.rgb * a, a); } - // 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). - // 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. + // mode 8 : écran tilté (zoom regions "rotation"). Warp bilinéaire inverse. 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 // 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(i.local, layer.fx.xy, layer.fx.zw, - layer.src_prev.xy, layer.src_prev.zw, layer.dst_prev.w); + 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 quad projeté @@ -811,33 +380,13 @@ 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, - (layer.dst_prev.z > 0.5 && p.y < 0.0) ? 0.0 : max(layer.radius_px, 0.0)); + float d = sd_round_rect(p, plane_px * 0.5, 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)); - } - 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); + return float4(sample_yuv(uv, texY, texUV) * tilt_a, tilt_a); } // mode 7 : sprite curseur thème (PNG alpha droite). Prémultiplie ici, comme partout @@ -877,20 +426,9 @@ 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 @@ -901,11 +439,6 @@ 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é } @@ -955,8 +488,7 @@ 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.w = 1 si son warp est projectif. + // mb.z = 1 si le masque est un quad incliné (coins TL, TR dans dst_prev, BR, BL dans src_prev). // // 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) @@ -965,8 +497,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(i.local, layer.dst_prev.xy, layer.dst_prev.zw, - layer.src_prev.xy, layer.src_prev.zw, layer.mb.w); + float3 w = quad_inverse_bilinear(i.local, layer.dst_prev.xy, layer.dst_prev.zw, + layer.src_prev.xy, layer.src_prev.zw); if (w.z < 0.5) { return float4(0.0, 0.0, 0.0, 0.0); @@ -1087,10 +619,9 @@ 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.mb.w > 0.5 && p.y < 0.0) ? 0.0 : layer.radius_px); + float d = sd_round_rect(p, halfsz, layer.radius_px); alpha *= 1.0 - smoothstep(0.0, 1.5, d); } return float4(rgb * alpha, alpha); diff --git a/crates/compositor/src/timeline_walk.rs b/crates/compositor/src/timeline_walk.rs index 67218ccf8..fe3a2e795 100644 --- a/crates/compositor/src/timeline_walk.rs +++ b/crates/compositor/src/timeline_walk.rs @@ -325,9 +325,6 @@ 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)); } @@ -351,7 +348,6 @@ 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/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index 7b9b77449..bc778f474 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -19,30 +19,24 @@ struct Layer { dst: vec4, // x,y,w,h sortie 0..1 (origine haut-gauche) - 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) + 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 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) + 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é?) + 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é } @group(0) @binding(0) var layer: Layer; -@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(1) var texY: texture_2d; // R8Unorm, sample .r +@group(0) @binding(2) var texU: texture_2d; // R8Unorm, sample .r @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. @@ -227,98 +221,6 @@ 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); - 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)); -} - -// 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. @@ -365,238 +267,6 @@ fn blur_webcam_bg(uv: vec2, intensity: f32, qpx: vec2, local_px: vec2< return sum / max(total, 1e-4); } -// ---- 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; - } - 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; - } - } - 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); - } - 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 fn fs_main(i: VsOut) -> @location(0) vec4 { var rgb: vec3; @@ -662,17 +332,7 @@ 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. - // 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); + let denom = max(abs(layer.fx.x) + abs(layer.fx.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. @@ -680,12 +340,8 @@ 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), dir) / denom, 0.0, 1.0); + let t = clamp(0.5 + dot(gp - vec2(0.5), layer.fx.xy) / 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. @@ -721,15 +377,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) ; mb.w = 1 si son warp est projectif. + // BR, BL dans src_prev). 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(i.local, layer.dst_prev.xy, layer.dst_prev.zw, - layer.src_prev.xy, layer.src_prev.zw, layer.mb.w); + let wq = quad_inverse_bilinear(i.local, layer.dst_prev.xy, layer.dst_prev.zw, + layer.src_prev.xy, layer.src_prev.zw); if wq.z < 0.5 { return vec4(0.0, 0.0, 0.0, 0.0); } @@ -803,14 +459,13 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { } else if layer.mode > 7.5 && layer.mode < 8.5 { // Mode 8 -- ecran tilte (rotation 3D des zoom regions). Le quad projete est // dessine dans sa BBOX (le VS ne sait tracer qu'un rect) et chaque fragment - // remonte au (s,t) du plan par warp inverse : bilineaire sous un angle fixe, projectif exact - // sous la camera reelle (dst_prev.w = 1), qui eclaire aussi le plan (color.xy). + // remonte au (s,t) du plan par warp bilineaire inverse. // // 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( - i.local, layer.fx.xy, layer.fx.zw, layer.src_prev.xy, layer.src_prev.zw, layer.dst_prev.w, + 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 quad projete @@ -827,33 +482,13 @@ 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, - select(max(layer.radius_px, 0.0), 0.0, layer.dst_prev.z > 0.5 && p.y < 0.0)); + let d = sd_round_rect(p, plane_px * 0.5, max(layer.radius_px, 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)); - } - 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); + return vec4(sample_yuv(uv) * 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 @@ -887,9 +522,8 @@ 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); } - // 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, + 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); @@ -899,51 +533,6 @@ 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. - // 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 - } - 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 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; @@ -960,10 +549,9 @@ 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, select(layer.radius_px, 0.0, layer.mb.w > 0.5 && p.y < 0.0)); + let d = sd_round_rect(p, halfsz, layer.radius_px); alpha *= 1.0 - smoothstep(0.0, 1.5, d); } diff --git a/crates/compositor/tests/animated_background.rs b/crates/compositor/tests/animated_background.rs deleted file mode 100644 index fb8dc3488..000000000 --- a/crates/compositor/tests/animated_background.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! 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/compositor/tests/click_impact_render.rs b/crates/compositor/tests/click_impact_render.rs deleted file mode 100644 index 751e9b005..000000000 --- a/crates/compositor/tests/click_impact_render.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! 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/crates/compositor/tests/compose_linux.rs b/crates/compositor/tests/compose_linux.rs index f7a93b593..81ee2cfb8 100644 --- a/crates/compositor/tests/compose_linux.rs +++ b/crates/compositor/tests/compose_linux.rs @@ -1600,113 +1600,3 @@ 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/cursor_model_render.rs b/crates/compositor/tests/cursor_model_render.rs deleted file mode 100644 index 2d7eb9e32..000000000 --- a/crates/compositor/tests/cursor_model_render.rs +++ /dev/null @@ -1,824 +0,0 @@ -//! 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 deleted file mode 100644 index b29116c7a..000000000 --- a/crates/compositor/tests/cursor_tap_render.rs +++ /dev/null @@ -1,423 +0,0 @@ -//! 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/follow_camera_render.rs b/crates/compositor/tests/follow_camera_render.rs deleted file mode 100644 index e1aaa9b06..000000000 --- a/crates/compositor/tests/follow_camera_render.rs +++ /dev/null @@ -1,485 +0,0 @@ -//! 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/crates/compositor/tests/programme_time_seek.rs b/crates/compositor/tests/programme_time_seek.rs deleted file mode 100644 index 7357dede9..000000000 --- a/crates/compositor/tests/programme_time_seek.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! 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); - } -} diff --git a/crates/compositor/tests/tilt_parallax_render.rs b/crates/compositor/tests/tilt_parallax_render.rs deleted file mode 100644 index 56f83ed46..000000000 --- a/crates/compositor/tests/tilt_parallax_render.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! 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/compositor/tests/tilted_depth_of_field.rs b/crates/compositor/tests/tilted_depth_of_field.rs deleted file mode 100644 index 94734294a..000000000 --- a/crates/compositor/tests/tilted_depth_of_field.rs +++ /dev/null @@ -1,263 +0,0 @@ -//! 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 deleted file mode 100644 index ec1cd24b2..000000000 --- a/crates/compositor/tests/tilted_depth_slot_inert.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! 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/crates/compositor/tests/window_frame_render.rs b/crates/compositor/tests/window_frame_render.rs deleted file mode 100644 index ea1a3b881..000000000 --- a/crates/compositor/tests/window_frame_render.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! 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/crates/fixture/scene-gradient-aurora.json b/crates/fixture/scene-gradient-aurora.json deleted file mode 100644 index bb2e89a13..000000000 --- a/crates/fixture/scene-gradient-aurora.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "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 deleted file mode 100644 index d25db4154..000000000 --- a/crates/fixture/scene-gradient-still.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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/fixture/scene-tilt.json b/crates/fixture/scene-tilt.json deleted file mode 100644 index 77d43fc7b..000000000 --- a/crates/fixture/scene-tilt.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "_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 4b7a6e0c7..34460d9aa 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, frame_geometry, gif_export, live, pipeline, scene}; +use openscreen_compositor::{config, cursor, d3d, gif_export, live, pipeline, scene}; use std::fmt::Write as _; use std::path::Path; @@ -134,11 +134,7 @@ 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}"))?; - 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)); + comp.set_scene(Some(scene::Scene::from_json(&json)?)); println!("scène chargée depuis {scene_arg}"); } @@ -154,6 +150,7 @@ 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( @@ -296,6 +293,7 @@ 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 diff --git a/docs/3d-effects-v2.md b/docs/3d-effects-v2.md deleted file mode 100644 index 9fa601628..000000000 --- a/docs/3d-effects-v2.md +++ /dev/null @@ -1,424 +0,0 @@ -# 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 43bfa9218..4a7158dca 100644 --- a/electron/ai-edition/style-preset-service.test.ts +++ b/electron/ai-edition/style-preset-service.test.ts @@ -7,13 +7,10 @@ import { StylePresetError, StylePresetService } from "./style-preset-service"; const APPEARANCE: StylePresetAppearance = { wallpaper: "#000000", - wallpaperMotion: "none", - frame: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, motionBlurAmount: 0.2, - depthOfField: true, borderRadius: 40, padding: 50, webcamLayoutPreset: "picture-in-picture", @@ -24,14 +21,7 @@ const APPEARANCE: StylePresetAppearance = { webcamBackgroundMode: "none", webcamWallpaper: "/wallpapers/wallpaper1.jpg", webcamBlurIntensity: 0.5, - cursor: { - size: 3, - smoothing: 0.67, - motionBlur: 0.35, - clickBounce: 2.5, - model3d: false, - clipToBounds: false, - }, + cursor: { size: 3, smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, 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 efdda0feb..a04ec169c 100644 --- a/electron/ipc/nativeBridge.presets.test.ts +++ b/electron/ipc/nativeBridge.presets.test.ts @@ -27,13 +27,10 @@ vi.mock("../native-bridge/services/aiEditionService", () => ({ const APPEARANCE: StylePresetAppearance = { wallpaper: "#000000", - wallpaperMotion: "none", - frame: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, motionBlurAmount: 0.2, - depthOfField: true, borderRadius: 40, padding: 50, webcamLayoutPreset: "picture-in-picture", @@ -44,14 +41,7 @@ const APPEARANCE: StylePresetAppearance = { webcamBackgroundMode: "none", webcamWallpaper: "#ffffff", webcamBlurIntensity: 0.5, - cursor: { - size: 3, - smoothing: 0.67, - motionBlur: 0.35, - clickBounce: 2.5, - model3d: false, - clipToBounds: false, - }, + cursor: { size: 3, smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, clipToBounds: false }, cursorShow: true, cursorAutoHide: false, cursorTheme: "default", diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index cc1ebb756..55e9ada15 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -49,7 +49,6 @@ import { locateVirtualPosition } from "@/lib/ai-edition/timeline/virtual-preview import { computeCameraFullscreenRect, computeCompositeLayout, - fitInWindowFrame, resolveWebcamLayoutPreset, type WebcamCompositeLayout, } from "@/lib/compositeLayout"; @@ -314,22 +313,10 @@ 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(stageLayout, settings, frameSize), - [stageLayout, settings, frameSize], + () => buildScreenStyle(layout, settings, frameSize), + [layout, settings, frameSize], ); const webcamStyle = useMemo( () => buildWebcamStyle(effectiveLayout, settings, frameSize), @@ -467,8 +454,8 @@ export function PreviewCanvas(props: PreviewCanvasProps) { annotations={props.annotationRegions} selectedAnnotationId={props.selectedAnnotationId ?? null} currentTimeSec={props.currentTimeSec} - containerWidth={(stageLayout ?? layout).screenRect.width} - containerHeight={(stageLayout ?? layout).screenRect.height} + containerWidth={layout.screenRect.width} + containerHeight={layout.screenRect.height} onSelectAnnotation={props.onSelectAnnotation} onPositionChange={props.onAnnotationPositionChange} onSizeChange={props.onAnnotationSizeChange} diff --git a/src/components/ai-edition/RightPanes.backgroundMotion.test.tsx b/src/components/ai-edition/RightPanes.backgroundMotion.test.tsx deleted file mode 100644 index aef460a58..000000000 --- a/src/components/ai-edition/RightPanes.backgroundMotion.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -// @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.i18n.test.tsx b/src/components/ai-edition/RightPanes.i18n.test.tsx index f0522fb5c..42a364e39 100644 --- a/src/components/ai-edition/RightPanes.i18n.test.tsx +++ b/src/components/ai-edition/RightPanes.i18n.test.tsx @@ -71,7 +71,6 @@ 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 2971fec33..bfa10f273 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -43,7 +43,6 @@ 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"; @@ -94,7 +93,6 @@ import { themePickerPreviewAssets, } from "@/lib/cursor/cursorThemes"; import { buildGradientFromEditor } from "@/lib/gradientBuilder"; -import { RECORDING_FRAMES, type RecordingFrame } from "@/lib/projectDefaults"; import { classifyWallpaper, resolveImageWallpaperUrl, @@ -102,7 +100,6 @@ import { WALLPAPER_THUMB_PATHS, } from "@/lib/wallpaper"; import { isNativeCompositorActive, setNativeParam } from "@/native"; -import { wallpaperAcceptsMotion } from "@/native/sceneDescription"; import { ASPECT_RATIO_PRESETS, type AspectRatio, @@ -326,13 +323,6 @@ 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 @@ -346,7 +336,6 @@ function BackgroundSection() { const { pick: handlePickFile, input: fileInput } = useWallpaperFileInput((dataUrl) => set({ wallpaper: dataUrl }), ); - const motionApplies = wallpaperAcceptsMotion(settings.wallpaper); return ( <> @@ -401,38 +390,6 @@ 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. */} @@ -2376,12 +2333,6 @@ 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. * @@ -2404,10 +2355,8 @@ 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); const { locale } = useI18n(); const clipCountLabel = (count: number) => ts(pluralKey(locale, count), { count }); @@ -2592,57 +2541,6 @@ 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) => ( - - ))} -
-
-
-
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" }, @@ -3453,30 +3334,6 @@ 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) => { diff --git a/src/components/ai-edition/depthOfFieldToggle.test.tsx b/src/components/ai-edition/depthOfFieldToggle.test.tsx deleted file mode 100644 index c3fd590c7..000000000 --- a/src/components/ai-edition/depthOfFieldToggle.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -// @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/ai-edition/fitClipShapes.test.tsx b/src/components/ai-edition/fitClipShapes.test.tsx index d25245ffd..f1317b023 100644 --- a/src/components/ai-edition/fitClipShapes.test.tsx +++ b/src/components/ai-edition/fitClipShapes.test.tsx @@ -216,23 +216,3 @@ describe("fitting a clip is an action, and a choice only when there is one", () expect(screen.queryByRole("note")).not.toBeInTheDocument(); }); }); - -describe("the window frame menu persists the pick", () => { - 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/components/ai-edition/v4/FloatingInspector.test.tsx b/src/components/ai-edition/v4/FloatingInspector.test.tsx index 093e3449d..39f644435 100644 --- a/src/components/ai-edition/v4/FloatingInspector.test.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.test.tsx @@ -1,8 +1,7 @@ // @vitest-environment jsdom import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { describe, expect, it, vi } from "vitest"; vi.mock("@/contexts/I18nContext", () => ({ useScopedT: (scope: string) => (key: string) => `${scope}.${key}`, @@ -28,20 +27,6 @@ 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
, })); @@ -101,136 +86,4 @@ 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("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 { - 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); - }); - }); - - 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 42ae170cd..2a4a4904c 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -17,11 +17,7 @@ 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"; @@ -307,57 +303,6 @@ 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({ - 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"]; @@ -592,73 +537,28 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void } ))} , )} -
- {paneRow( - ts("zoom.camera.title"), - // ONE control for the whole 3D camera: a fixed angle and a moving camera are - // alternatives, not two settings to combine. - , - )} -

- { - // 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"}`, - ) + {paneRow( + ts("zoom.threeD.title"), + , + )} {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/editorDefaults.ts b/src/components/video-editor/editorDefaults.ts index 3a4b37369..55bf565e5 100644 --- a/src/components/video-editor/editorDefaults.ts +++ b/src/components/video-editor/editorDefaults.ts @@ -8,7 +8,6 @@ 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, @@ -77,7 +76,6 @@ export const DEFAULT_CURSOR_SETTINGS: CursorVisualSettings & { show: boolean; th smoothing: DEFAULT_CURSOR_SMOOTHING, motionBlur: DEFAULT_CURSOR_MOTION_BLUR, clickBounce: DEFAULT_CURSOR_CLICK_BOUNCE, - model3d: DEFAULT_CURSOR_MODEL3D, clipToBounds: DEFAULT_CURSOR_CLIP_TO_BOUNDS, theme: DEFAULT_CURSOR_THEME_ID, }; diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index ccd0404cf..1ba5192c7 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -32,11 +32,9 @@ describe("projectPersistence media compatibility", () => { }, { wallpaper: "/wallpapers/wallpaper1.jpg", - wallpaperMotion: "none", shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, - depthOfField: true, borderRadius: 0, padding: 50, cropRegion: { x: 0, y: 0, width: 1, height: 1 }, @@ -71,16 +69,6 @@ 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( @@ -163,20 +151,6 @@ 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", @@ -212,7 +186,6 @@ 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 8aa7f9992..627402844 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -2,7 +2,6 @@ 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"; @@ -33,8 +32,6 @@ import { DEFAULT_WEBCAM_REACTIVE_ZOOM, DEFAULT_ZOOM_DEPTH, DEFAULT_ZOOM_MOTION_BLUR, - isRotation3DPreset, - isWallpaperMotion, MAX_BLUR_BLOCK_SIZE, MAX_BLUR_INTENSITY, MAX_PLAYBACK_SPEED, @@ -43,7 +40,6 @@ import { MIN_PLAYBACK_SPEED, type SpeedRegion, type TrimRegion, - type WallpaperMotion, type WebcamLayoutPreset, type WebcamMaskShape, type WebcamPosition, @@ -72,11 +68,9 @@ export const PROJECT_VERSION = 2; export interface ProjectEditorState { wallpaper: string; - wallpaperMotion: WallpaperMotion; shadowIntensity: number; showBlur: boolean; motionBlurAmount: number; - depthOfField: boolean; borderRadius: number; padding: number; cropRegion: CropRegion; @@ -259,9 +253,12 @@ export function normalizeProjectEditor(editor: Partial): Pro const startMs = Math.max(0, Math.min(rawStart, rawEnd)); const endMs = Math.max(startMs + 1, rawEnd); - const validPreset = isRotation3DPreset(region.rotationPreset) - ? region.rotationPreset - : undefined; + const validPreset = + region.rotationPreset === "iso" || + region.rotationPreset === "left" || + region.rotationPreset === "right" + ? region.rotationPreset + : undefined; return { id: region.id, startMs, @@ -274,7 +271,6 @@ 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 } : {}), }; }) : []; @@ -479,9 +475,6 @@ 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 @@ -497,10 +490,6 @@ 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/components/video-editor/types.ts b/src/components/video-editor/types.ts index 426d531d1..9922231a2 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -36,16 +36,6 @@ 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). */ @@ -75,30 +65,7 @@ export const DEFAULT_ROTATION_3D: Rotation3D = { rotationZ: 0, }; -/** 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); -} +export type Rotation3DPreset = "iso" | "left" | "right"; // 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, @@ -106,19 +73,14 @@ export function isRotation3DPreset(value: unknown): value is Rotation3DPreset { // 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 @@ -149,8 +111,6 @@ export interface ZoomRegion { source?: ZoomRegionSource; /** When true, cursor is hidden during this zoom region. */ hideCursor?: boolean; - /** When true, each click presses the tilted plane, or recoils the `follow-cursor` camera (needs a `rotationPreset`). Omitted when off. */ - clickImpact?: true; } export function getRotation3D(region: Pick): Rotation3D { @@ -269,12 +229,6 @@ export interface CursorVisualSettings { smoothing: number; motionBlur: number; clickBounce: 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; } @@ -283,8 +237,6 @@ 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: 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 496c1693e..26c9dcd3d 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -1,11 +1,5 @@ { "background": { - "motion": "التحريك", - "motionNone": "بلا", - "motionDrift": "انجراف", - "motionAurora": "شفق", - "motionWaves": "أمواج", - "motionGradientOnly": "ينطبق التحريك على الخلفيات المتدرجة فقط. اختر تدرجًا لتحريكه.", "gradientLabel": "تدرج لوني {{index}}", "uploadCustom": "رفع صورة مخصصة", "unsupportedImage": "صورة غير مدعومة. استخدم ملف JPG أو PNG.", @@ -114,15 +108,7 @@ "format": "التنسيق", "fitClip": "ملاءمة", "motionBlur": "ضبابية الحركة", - "depthOfField": "عمق المجال", - "depthOfFieldHint": "تكبير ثلاثي الأبعاد", - "depthOfFieldNoTilt": "لا يوجد تكبير ثلاثي الأبعاد في هذا المشروع", - "roundness": "الاستدارة", - "window": "نافذة", - "windowNone": "بلا", - "windowLight": "فاتح", - "windowDark": "داكن", - "windowHelp": "يرسم شريط عنوان وإطار نافذة حول التسجيل." + "roundness": "الاستدارة" }, "stylePresets": { "button": "الإعدادات المسبقة", @@ -337,32 +323,14 @@ "autoDescription": "الكاميرا تتبع موضع المؤشر المسجل", "title": "وضع التركيز" }, - "camera": { - "title": "كاميرا ثلاثية الأبعاد", - "off": "إيقاف", - "fixed": "زاوية ثابتة", - "moving": "كاميرا متحركة", + "threeD": { "preset": { - "iso": "مائلة من الأعلى", - "left": "الشاشة مستديرة لليسار", - "right": "الشاشة مستديرة لليمين", - "followCursor": "يدور مع المؤشر" + "left": "يسار", + "right": "يمين", + "iso": "متساوي القياس" }, - "description": { - "off": "تبقى الشاشة مسطحة.", - "iso": "وضعية واحدة ثابتة: مستديرة لليسار ومرئية من الأعلى.", - "left": "وضعية واحدة ثابتة: الشاشة تتجه إلى اليسار وحافتها اليمنى تتقدم.", - "right": "وضعية واحدة ثابتة: الشاشة تتجه إلى اليمين وحافتها اليسرى تتقدم.", - "followCursor": "تبقى الشاشة ثابتة، وتدور الكاميرا حولها نحو جهة المؤشر وترتفع أو تنخفض معه." - }, - "needsCursor": "المؤشر مخفي: لا شيء تتبعه هذه الكاميرا، فتحافظ على زاويتها الابتدائية." - }, - "clickImpact": { - "title": "أثر النقر", - "description": "كل نقرة تضغط الشاشة المائلة: ينخفض الجانب المنقور ثم يعود.", - "needsRotation": "اختر كاميرا ثلاثية الأبعاد أولًا: لا يمكن ضغط إلا شاشة مائلة.", - "needsCursor": "المؤشر مخفي: لا تتفاعل الشاشة إلا مع النقرات المرئية.", - "descriptionCamera": "كل نقرة تُبعد الكاميرا إلى الخلف قليلًا ثم تعود." + "none": "بلا", + "title": "دوران ثلاثي الأبعاد" }, "level": "مستوى التكبير", "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير", @@ -396,9 +364,6 @@ "cursor": { "smoothing": "التنعيم", "clickBounce": "ارتداد النقر", - "model3d": "مؤشر ثلاثي الأبعاد", - "model3dHint": "النمط الافتراضي: تصبح كل أشكال المؤشر ثلاثية الأبعاد. تبقى الأنماط الأخرى مسطحة.", - "model3dNeedsCursor": "فعّل «إظهار المؤشر» لاستخدام المؤشر ثلاثي الأبعاد.", "help": "عرض المؤشر اعتمادًا على بيانات التتبع المسجّلة: السمة، والحجم، والتنعيم، وضبابية الحركة، وارتداد النقر.", "clipToBoundsDescription": "يبقي المؤشر داخل إطار الفيديو. أوقف التشغيل للسماح للمؤشر بتجاوز الحواف - مفيد عند التكبير أو التحريك.", "size": "الحجم", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index 6d44dee1d..447186de1 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -1,11 +1,5 @@ { "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.", @@ -114,15 +108,7 @@ "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é", - "windowLight": "Světlé", - "windowDark": "Tmavé", - "windowHelp": "Kolem záznamu vykreslí záhlaví a okraj okna." + "roundness": "Zaoblení" }, "stylePresets": { "button": "Předvolby", @@ -337,32 +323,14 @@ "autoDescription": "Kamera sleduje zaznamenanou pozici kurzoru", "title": "Režim zaostření" }, - "camera": { - "title": "3D kamera", - "off": "Vypnuto", - "fixed": "Pevný úhel", - "moving": "Pohyblivá kamera", + "threeD": { "preset": { - "iso": "Šikmo shora", - "left": "Obrazovka natočená doleva", - "right": "Obrazovka natočená doprava", - "followCursor": "Obíhá s kurzorem" + "left": "Vlevo", + "right": "Vpravo", + "iso": "Iso" }, - "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 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í." + "none": "Žádné", + "title": "3D rotace" }, "level": "Úroveň přiblížení", "previewHold": "Podržte pro náhled efektu přiblížení", @@ -396,9 +364,6 @@ "cursor": { "smoothing": "Vyhlazení", "clickBounce": "Odraz při kliknutí", - "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/de/settings.json b/src/i18n/locales/de/settings.json index a64253686..fb496f293 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -1,11 +1,5 @@ { "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.", @@ -114,15 +108,7 @@ "format": "Format", "fitClip": "Einpassen", "motionBlur": "Bewegungsunschärfe", - "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." + "roundness": "Rundung" }, "stylePresets": { "button": "Vorgaben", @@ -337,32 +323,14 @@ "autoDescription": "Die Kamera folgt der aufgezeichneten Cursorposition", "title": "Fokusmodus" }, - "camera": { - "title": "3D-Kamera", - "off": "Aus", - "fixed": "Fester Winkel", - "moving": "Bewegte Kamera", + "threeD": { "preset": { - "iso": "Schräg von oben", - "left": "Bildschirm nach links gedreht", - "right": "Bildschirm nach rechts gedreht", - "followCursor": "Kreist mit dem Cursor" + "left": "Links", + "right": "Rechts", + "iso": "Iso" }, - "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." + "none": "Keine", + "title": "3D-Rotation" }, "level": "Zoomstufe", "previewHold": "Gedrückt halten für die Zoom-Vorschau", @@ -396,9 +364,6 @@ "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", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index b5695016c..8f2021ed5 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -1,11 +1,5 @@ { "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.", @@ -114,15 +108,7 @@ "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", - "windowLight": "Light", - "windowDark": "Dark", - "windowHelp": "Draws a window title bar and border around the recording." + "roundness": "Roundness" }, "stylePresets": { "button": "Presets", @@ -337,32 +323,14 @@ "autoDescription": "Camera follows the recorded cursor position", "title": "Focus Mode" }, - "camera": { - "title": "3D camera", - "off": "Off", - "fixed": "Fixed angle", - "moving": "Moving camera", + "threeD": { "preset": { - "iso": "Angled from above", - "left": "Screen turned left", - "right": "Screen turned right", - "followCursor": "Orbits with the cursor" + "left": "Left", + "right": "Right", + "iso": "Iso" }, - "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 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." + "none": "None", + "title": "3D Rotation" }, "level": "Zoom Level", "previewHold": "Hold to preview zoom effect", @@ -396,9 +364,6 @@ "cursor": { "smoothing": "Smoothing", "clickBounce": "Click Bounce", - "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 29ac8fd5f..b416a152e 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -1,11 +1,5 @@ { "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.", @@ -114,15 +108,7 @@ "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", - "windowLight": "Clara", - "windowDark": "Oscura", - "windowHelp": "Dibuja una barra de título y un borde de ventana alrededor de la grabación." + "roundness": "Redondez" }, "stylePresets": { "button": "Preajustes", @@ -337,32 +323,14 @@ "autoDescription": "La cámara sigue la posición del cursor grabado", "title": "Modo de enfoque" }, - "camera": { - "title": "Cámara 3D", - "off": "Desactivada", - "fixed": "Ángulo fijo", - "moving": "Cámara en movimiento", + "threeD": { "preset": { - "iso": "Picado en diagonal", - "left": "Pantalla girada a la izquierda", - "right": "Pantalla girada a la derecha", - "followCursor": "Gira con el cursor" + "left": "Izquierda", + "right": "Derecha", + "iso": "Iso" }, - "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 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." + "none": "Ninguna", + "title": "Rotación 3D" }, "level": "Nivel de zoom", "previewHold": "Mantener para previsualizar el efecto de zoom", @@ -396,9 +364,6 @@ "cursor": { "smoothing": "Suavizado", "clickBounce": "Rebote al clic", - "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 f06bf6107..cca7f87df 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -1,11 +1,5 @@ { "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.", @@ -114,15 +108,7 @@ "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", - "windowLight": "Claire", - "windowDark": "Sombre", - "windowHelp": "Dessine une barre de titre et une bordure de fenêtre autour de l'enregistrement." + "roundness": "Arrondi" }, "stylePresets": { "button": "Préréglages", @@ -337,32 +323,14 @@ "autoDescription": "La caméra suit la position du curseur enregistré", "title": "Mode focus" }, - "camera": { - "title": "Caméra 3D", - "off": "Désactivée", - "fixed": "Angle fixe", - "moving": "Caméra mobile", + "threeD": { "preset": { - "iso": "Plongée de biais", - "left": "Écran tourné à gauche", - "right": "Écran tourné à droite", - "followCursor": "Tourne avec le curseur" + "left": "Gauche", + "right": "Droite", + "iso": "Iso" }, - "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 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." + "none": "Aucune", + "title": "Rotation 3D" }, "level": "Niveau de zoom", "previewHold": "Maintenir pour prévisualiser l'effet de zoom", @@ -396,9 +364,6 @@ "cursor": { "smoothing": "Lissage", "clickBounce": "Rebond au clic", - "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 4554f537c..e17ac4cd9 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -1,11 +1,5 @@ { "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.", @@ -114,15 +108,7 @@ "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", - "windowLight": "Chiara", - "windowDark": "Scura", - "windowHelp": "Disegna una barra del titolo e un bordo di finestra attorno alla registrazione." + "roundness": "Arrotondamento" }, "stylePresets": { "button": "Preset", @@ -337,32 +323,14 @@ "autoDescription": "La fotocamera segue la posizione del cursore registrato", "title": "Modalità messa a fuoco" }, - "camera": { - "title": "Camera 3D", - "off": "Disattivata", - "fixed": "Angolo fisso", - "moving": "Camera in movimento", + "threeD": { "preset": { - "iso": "Dall'alto di sbieco", - "left": "Schermo ruotato a sinistra", - "right": "Schermo ruotato a destra", - "followCursor": "Ruota con il cursore" + "left": "Sinistra", + "right": "Destra", + "iso": "Iso" }, - "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 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." + "none": "Nessuna", + "title": "Rotazione 3D" }, "level": "Livello zoom", "previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom", @@ -396,9 +364,6 @@ "cursor": { "smoothing": "Smussatura", "clickBounce": "Rimbalzo clic", - "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 399b34c77..87ebd99bd 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -1,11 +1,5 @@ { "background": { - "motion": "アニメーション", - "motionNone": "なし", - "motionDrift": "ドリフト", - "motionAurora": "オーロラ", - "motionWaves": "ウェーブ", - "motionGradientOnly": "アニメーションはグラデーション背景にのみ適用されます。アニメーションするにはグラデーションを選択してください。", "gradientLabel": "グラデーション {{index}}", "uploadCustom": "カスタム画像を読み込む", "unsupportedImage": "対応していない画像です。JPG または PNG ファイルを使用してください。", @@ -114,15 +108,7 @@ "format": "フォーマット", "fitClip": "合わせる", "motionBlur": "モーションブラー", - "depthOfField": "被写界深度", - "depthOfFieldHint": "3D ズーム", - "depthOfFieldNoTilt": "このプロジェクトに 3D ズームはありません", - "roundness": "丸み", - "window": "ウィンドウ", - "windowNone": "なし", - "windowLight": "ライト", - "windowDark": "ダーク", - "windowHelp": "録画の周りにウィンドウのタイトルバーと枠を描画します。" + "roundness": "丸み" }, "stylePresets": { "button": "プリセット", @@ -337,32 +323,14 @@ "autoDescription": "表示範囲が録画中のカーソル位置に追従します", "title": "フォーカスモード" }, - "camera": { - "title": "3Dカメラ", - "off": "オフ", - "fixed": "固定アングル", - "moving": "動くカメラ", + "threeD": { "preset": { - "iso": "斜め上から", - "left": "画面を左向きに", - "right": "画面を右向きに", - "followCursor": "カーソルに合わせて回り込む" + "left": "左", + "right": "右", + "iso": "Iso" }, - "description": { - "off": "画面は平らなままです。", - "iso": "1つの姿勢を保ちます:左向きで、上から見下ろします。", - "left": "1つの姿勢を保ちます:画面が左を向き、右端が手前に出ます。", - "right": "1つの姿勢を保ちます:画面が右を向き、左端が手前に出ます。", - "followCursor": "画面は動かず、カメラが画面の周りを回ってカーソルのある側へ移り、カーソルに合わせて上下します。" - }, - "needsCursor": "カーソルが非表示です:このカメラは追う対象がなく、最初のアングルのままです。" - }, - "clickImpact": { - "title": "クリックの押し込み", - "description": "クリックするたびに傾いた画面が押され、クリックした側が沈んでから戻ります。", - "needsRotation": "先に3Dカメラを選んでください。押し込めるのは傾いた画面だけです。", - "needsCursor": "カーソルが非表示です。画面は見えているクリックにだけ反応します。", - "descriptionCamera": "クリックのたびにカメラが少し後ろへ下がり、また戻ります。" + "none": "なし", + "title": "3D回転" }, "level": "ズーム倍率", "previewHold": "押している間ズーム効果をプレビュー", @@ -396,9 +364,6 @@ "cursor": { "smoothing": "スムージング", "clickBounce": "クリックバウンス", - "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 97a53c12c..fbab36004 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -1,11 +1,5 @@ { "background": { - "motion": "애니메이션", - "motionNone": "없음", - "motionDrift": "드리프트", - "motionAurora": "오로라", - "motionWaves": "물결", - "motionGradientOnly": "애니메이션은 그라데이션 배경에만 적용됩니다. 애니메이션을 적용하려면 그라데이션을 선택하세요.", "gradientLabel": "그라디언트 {{index}}", "uploadCustom": "직접 업로드", "unsupportedImage": "지원하지 않는 이미지입니다. JPG 또는 PNG 파일을 사용하세요.", @@ -114,15 +108,7 @@ "format": "형식", "fitClip": "맞추기", "motionBlur": "모션 블러", - "depthOfField": "피사계 심도", - "depthOfFieldHint": "3D 확대", - "depthOfFieldNoTilt": "이 프로젝트에 3D 확대가 없습니다", - "roundness": "모서리 둥글기", - "window": "창", - "windowNone": "없음", - "windowLight": "밝게", - "windowDark": "어둡게", - "windowHelp": "녹화 화면 주위에 창 제목 표시줄과 테두리를 그립니다." + "roundness": "모서리 둥글기" }, "stylePresets": { "button": "프리셋", @@ -337,32 +323,14 @@ "autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다", "title": "포커스 모드" }, - "camera": { - "title": "3D 카메라", - "off": "끔", - "fixed": "고정 각도", - "moving": "움직이는 카메라", + "threeD": { "preset": { - "iso": "위에서 비스듬히", - "left": "화면 왼쪽으로 돌림", - "right": "화면 오른쪽으로 돌림", - "followCursor": "커서를 따라 회전" + "left": "왼쪽", + "right": "오른쪽", + "iso": "Iso" }, - "description": { - "off": "화면이 평평하게 유지됩니다.", - "iso": "한 자세를 유지합니다: 왼쪽으로 돌아가 있고 위에서 내려다봅니다.", - "left": "한 자세를 유지합니다: 화면이 왼쪽을 향하고 오른쪽 가장자리가 앞으로 나옵니다.", - "right": "한 자세를 유지합니다: 화면이 오른쪽을 향하고 왼쪽 가장자리가 앞으로 나옵니다.", - "followCursor": "화면은 고정된 채, 카메라가 화면 주위를 돌아 커서가 있는 쪽으로 가고 커서를 따라 오르내립니다." - }, - "needsCursor": "커서가 숨겨져 있습니다. 이 카메라는 따라갈 대상이 없어 시작 각도를 유지합니다." - }, - "clickImpact": { - "title": "클릭 눌림", - "description": "클릭할 때마다 기울어진 화면이 눌려 클릭한 쪽이 내려갔다가 돌아옵니다.", - "needsRotation": "먼저 3D 카메라를 선택하세요. 기울어진 화면만 누를 수 있습니다.", - "needsCursor": "커서가 숨겨져 있습니다. 화면은 보이는 클릭에만 반응합니다.", - "descriptionCamera": "클릭할 때마다 카메라가 살짝 뒤로 물러났다가 돌아옵니다." + "none": "없음", + "title": "3D 회전" }, "level": "줌 레벨", "previewHold": "누르고 있으면 줌 효과 미리보기", @@ -396,9 +364,6 @@ "cursor": { "smoothing": "부드러움", "clickBounce": "클릭 바운스", - "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 37755ccca..32daeea43 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -1,11 +1,5 @@ { "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.", @@ -114,15 +108,7 @@ "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", - "windowLight": "Clara", - "windowDark": "Escura", - "windowHelp": "Desenha uma barra de título e uma borda de janela ao redor da gravação." + "roundness": "Arredondamento" }, "stylePresets": { "button": "Predefinições", @@ -337,32 +323,14 @@ "autoDescription": "A câmera segue a posição do cursor gravado", "title": "Modo de Foco" }, - "camera": { - "title": "Câmera 3D", - "off": "Desativada", - "fixed": "Ângulo fixo", - "moving": "Câmera em movimento", + "threeD": { "preset": { - "iso": "De cima, na diagonal", - "left": "Tela virada para a esquerda", - "right": "Tela virada para a direita", - "followCursor": "Gira com o cursor" + "left": "Esquerda", + "right": "Direita", + "iso": "Iso" }, - "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 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." + "none": "Nenhuma", + "title": "Rotação 3D" }, "level": "Nível de Zoom", "previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom", @@ -396,9 +364,6 @@ "cursor": { "smoothing": "Suavização", "clickBounce": "Rebote ao clicar", - "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 f02832acb..35cc24dfa 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -1,11 +1,5 @@ { "background": { - "motion": "Анимация", - "motionNone": "Нет", - "motionDrift": "Дрейф", - "motionAurora": "Сияние", - "motionWaves": "Волны", - "motionGradientOnly": "Анимация применяется только к градиентным фонам. Выберите градиент, чтобы анимировать его.", "gradientLabel": "Градиент {{index}}", "uploadCustom": "Загрузить свой", "unsupportedImage": "Неподдерживаемое изображение. Используйте файл JPG или PNG.", @@ -114,15 +108,7 @@ "format": "Формат", "fitClip": "Подогнать", "motionBlur": "Размытие движения", - "depthOfField": "Глубина резкости", - "depthOfFieldHint": "3D-зум", - "depthOfFieldNoTilt": "В проекте нет 3D-зума", - "roundness": "Скругление", - "window": "Окно", - "windowNone": "Нет", - "windowLight": "Светлое", - "windowDark": "Тёмное", - "windowHelp": "Рисует заголовок и рамку окна вокруг записи." + "roundness": "Скругление" }, "stylePresets": { "button": "Пресеты", @@ -337,32 +323,14 @@ "autoDescription": "Камера следует за записанной позицией курсора", "title": "Режим фокуса" }, - "camera": { - "title": "3D-камера", - "off": "Выкл.", - "fixed": "Фиксированный ракурс", - "moving": "Подвижная камера", + "threeD": { "preset": { - "iso": "Сверху под углом", - "left": "Экран повёрнут влево", - "right": "Экран повёрнут вправо", - "followCursor": "Облетает за курсором" + "left": "Слева", + "right": "Справа", + "iso": "Изометрия" }, - "description": { - "off": "Экран остаётся плоским.", - "iso": "Одна поза: экран повёрнут влево и виден сверху.", - "left": "Одна поза: экран смотрит влево, его правый край выдвинут вперёд.", - "right": "Одна поза: экран смотрит вправо, его левый край выдвинут вперёд.", - "followCursor": "Экран неподвижен; камера облетает его, уходя в сторону курсора и поднимаясь или опускаясь вместе с ним." - }, - "needsCursor": "Курсор скрыт: этой камере нечего отслеживать, и она сохраняет начальный угол." - }, - "clickImpact": { - "title": "Отклик на клик", - "description": "Каждый клик вдавливает наклонённый экран: сторона клика прогибается и возвращается.", - "needsRotation": "Сначала выберите 3D-камеру: вдавить можно только наклонённый экран.", - "needsCursor": "Курсор скрыт: экран реагирует только на видимые клики.", - "descriptionCamera": "Каждый клик слегка отодвигает камеру назад, затем она возвращается." + "none": "Нет", + "title": "3D вращение" }, "level": "Уровень масштабирования", "previewHold": "Удерживайте для предпросмотра эффекта зума", @@ -396,9 +364,6 @@ "cursor": { "smoothing": "Сглаживание", "clickBounce": "Отскок при клике", - "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 428db34e5..b6bf0f521 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -1,11 +1,5 @@ { "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.", @@ -114,15 +108,7 @@ "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", - "windowLight": "Açık", - "windowDark": "Koyu", - "windowHelp": "Kaydın çevresine bir pencere başlık çubuğu ve kenarlığı çizer." + "roundness": "Yuvarlaklık" }, "stylePresets": { "button": "Ön ayarlar", @@ -337,32 +323,14 @@ "autoDescription": "Kamera kaydedilen imleç konumunu takip eder", "title": "Odak Modu" }, - "camera": { - "title": "3D kamera", - "off": "Kapalı", - "fixed": "Sabit açı", - "moving": "Hareketli kamera", + "threeD": { "preset": { - "iso": "Yukarıdan çapraz", - "left": "Ekran sola dönük", - "right": "Ekran sağa dönük", - "followCursor": "İmleçle birlikte döner" + "left": "Sol", + "right": "Sağ", + "iso": "Iso" }, - "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 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." + "none": "Yok", + "title": "3D Döndürme" }, "level": "Yakınlaştırma Seviyesi", "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun", @@ -396,9 +364,6 @@ "cursor": { "smoothing": "Yumuşatma", "clickBounce": "Tıklama Sıçraması", - "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 10f951b99..43be7d8fd 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -1,11 +1,5 @@ { "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.", @@ -114,15 +108,7 @@ "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", - "windowLight": "Sáng", - "windowDark": "Tối", - "windowHelp": "Vẽ thanh tiêu đề và viền cửa sổ quanh bản ghi." + "roundness": "Độ bo tròn" }, "stylePresets": { "button": "Cài đặt sẵn", @@ -337,32 +323,14 @@ "autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi", "title": "Chế độ lấy nét" }, - "camera": { - "title": "Camera 3D", - "off": "Tắt", - "fixed": "Góc cố định", - "moving": "Camera chuyển động", + "threeD": { "preset": { - "iso": "Chéo từ trên xuống", - "left": "Màn hình xoay sang trái", - "right": "Màn hình xoay sang phải", - "followCursor": "Xoay quanh theo con trỏ" + "left": "Trái", + "right": "Phải", + "iso": "Đẳng phối" }, - "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 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ề." + "none": "Không", + "title": "Xoay 3D" }, "level": "Mức độ thu phóng", "previewHold": "Giữ để xem trước hiệu ứng phóng to", @@ -396,9 +364,6 @@ "cursor": { "smoothing": "Làm mượt", "clickBounce": "Nảy khi nhấp", - "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 dd66cbe08..2c17ad141 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -1,11 +1,5 @@ { "background": { - "motion": "动画", - "motionNone": "无", - "motionDrift": "漂移", - "motionAurora": "极光", - "motionWaves": "波浪", - "motionGradientOnly": "动画仅适用于渐变背景。请选择一个渐变来启用动画。", "gradientLabel": "渐变 {{index}}", "uploadCustom": "上传自定义", "unsupportedImage": "不支持的图片格式。请使用 JPG 或 PNG 文件。", @@ -114,15 +108,7 @@ "format": "格式", "fitClip": "适配", "motionBlur": "运动模糊", - "depthOfField": "景深", - "depthOfFieldHint": "3D 缩放", - "depthOfFieldNoTilt": "此项目没有 3D 缩放", - "roundness": "圆角", - "window": "窗口", - "windowNone": "无", - "windowLight": "浅色", - "windowDark": "深色", - "windowHelp": "在录制内容周围绘制窗口标题栏和边框。" + "roundness": "圆角" }, "stylePresets": { "button": "预设", @@ -337,32 +323,14 @@ "autoDescription": "摄像头跟随录制时的光标位置", "title": "对焦模式" }, - "camera": { - "title": "3D 镜头", - "off": "关闭", - "fixed": "固定角度", - "moving": "运动镜头", + "threeD": { "preset": { - "iso": "斜上方俯视", - "left": "画面向左转", - "right": "画面向右转", - "followCursor": "随光标环绕" + "left": "左", + "right": "右", + "iso": "Iso" }, - "description": { - "off": "画面保持平整。", - "iso": "保持单一姿态:向左转,并从上方俯视。", - "left": "保持单一姿态:画面朝向左侧,右边缘向前。", - "right": "保持单一姿态:画面朝向右侧,左边缘向前。", - "followCursor": "屏幕保持不动,镜头绕着屏幕转到光标所在的一侧,并随光标升降。" - }, - "needsCursor": "光标已隐藏:此镜头没有可跟随的对象,保持初始角度。" - }, - "clickImpact": { - "title": "点击按压", - "description": "每次点击都会按压倾斜的画面:被点击的一侧下沉,然后弹回。", - "needsRotation": "请先选择 3D 镜头:只有倾斜的画面才能被按压。", - "needsCursor": "光标已隐藏:画面只对可见的点击做出反应。", - "descriptionCamera": "每次点击,镜头都会稍稍后退,然后回位。" + "none": "无", + "title": "3D 旋转" }, "level": "缩放级别", "previewHold": "按住预览放大效果", @@ -396,9 +364,6 @@ "cursor": { "smoothing": "平滑", "clickBounce": "点击弹跳", - "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 007b8f07c..d4a2c5c12 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -1,11 +1,5 @@ { "background": { - "motion": "動畫", - "motionNone": "無", - "motionDrift": "漂移", - "motionAurora": "極光", - "motionWaves": "波浪", - "motionGradientOnly": "動畫僅適用於漸層背景。請選擇一個漸層來啟用動畫。", "gradientLabel": "漸層 {{index}}", "uploadCustom": "上傳自訂", "unsupportedImage": "不支援的圖片格式。請使用 JPG 或 PNG 檔案。", @@ -114,15 +108,7 @@ "format": "格式", "fitClip": "符合", "motionBlur": "動態模糊", - "depthOfField": "景深", - "depthOfFieldHint": "3D 縮放", - "depthOfFieldNoTilt": "此專案沒有 3D 縮放", - "roundness": "圓角", - "window": "視窗", - "windowNone": "無", - "windowLight": "淺色", - "windowDark": "深色", - "windowHelp": "在錄製內容周圍繪製視窗標題列和邊框。" + "roundness": "圓角" }, "stylePresets": { "button": "預設", @@ -337,32 +323,14 @@ "autoDescription": "攝影機跟隨錄製時的游標位置", "title": "對焦模式" }, - "camera": { - "title": "3D 鏡頭", - "off": "關閉", - "fixed": "固定角度", - "moving": "運動鏡頭", + "threeD": { "preset": { - "iso": "斜上方俯視", - "left": "畫面向左轉", - "right": "畫面向右轉", - "followCursor": "隨游標環繞" + "left": "左", + "right": "右", + "iso": "Iso" }, - "description": { - "off": "畫面保持平整。", - "iso": "保持單一姿態:向左轉,並從上方俯視。", - "left": "保持單一姿態:畫面朝向左側,右邊緣向前。", - "right": "保持單一姿態:畫面朝向右側,左邊緣向前。", - "followCursor": "螢幕保持不動,鏡頭繞著螢幕轉到游標所在的一側,並隨游標升降。" - }, - "needsCursor": "游標已隱藏:此鏡頭沒有可跟隨的對象,維持初始角度。" - }, - "clickImpact": { - "title": "點擊按壓", - "description": "每次點擊都會按壓傾斜的畫面:被點擊的一側下沉,然後彈回。", - "needsRotation": "請先選擇 3D 鏡頭:只有傾斜的畫面才能被按壓。", - "needsCursor": "游標已隱藏:畫面只對看得見的點擊做出反應。", - "descriptionCamera": "每次點擊,鏡頭都會稍微後退,然後回位。" + "none": "無", + "title": "3D 旋轉" }, "level": "縮放級別", "previewHold": "按住預覽放大效果", @@ -396,9 +364,6 @@ "cursor": { "smoothing": "平滑", "clickBounce": "點擊彈跳", - "model3d": "3D 游標", - "model3dHint": "預設樣式:所有游標形狀都會變成 3D。其他樣式維持平面。", - "model3dNeedsCursor": "開啟「顯示游標」後才能使用 3D 游標。", "help": "根據錄製的遙測資料繪製游標:主題、大小、平滑、動態模糊與點擊回彈。", "clipToBoundsDescription": "讓游標保持在影片畫面內。關閉後游標可超出邊緣——在縮放或平移時很有用。", "size": "大小", diff --git a/src/lib/ai-edition/document/migrate.test.ts b/src/lib/ai-edition/document/migrate.test.ts index 0a365c568..da2f74c2b 100644 --- a/src/lib/ai-edition/document/migrate.test.ts +++ b/src/lib/ai-edition/document/migrate.test.ts @@ -14,11 +14,9 @@ function makeV2Project(overrides: Partial = {}): EditorProjec media: { screenVideoPath: "/recordings/screen.webm" }, editor: { wallpaper: "/wallpapers/wallpaper1.jpg", - wallpaperMotion: "none", shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, - depthOfField: true, borderRadius: 0, padding: 50, cropRegion: { x: 0, y: 0, width: 1, height: 1 }, @@ -114,7 +112,6 @@ describe("migrateProjectDataToAxcutDocument", () => { customScale: 2.5, source: "manual", hideCursor: true, - clickImpact: true, }, ], }, @@ -130,7 +127,6 @@ 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", () => { @@ -264,32 +260,6 @@ 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 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: { @@ -302,9 +272,7 @@ 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: [ { @@ -335,9 +303,6 @@ 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 fdd967515..1f7dcc088 100644 --- a/src/lib/ai-edition/document/migrate.ts +++ b/src/lib/ai-edition/document/migrate.ts @@ -194,7 +194,6 @@ 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 @@ -287,11 +286,9 @@ export function migrateAxcutDocumentToProjectData(input: AxcutDocument): EditorP const editor: ProjectEditorState = { wallpaper: "", - wallpaperMotion: "none", shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, - depthOfField: true, borderRadius: 0, padding: 50, cropRegion: { x: 0, y: 0, width: 1, height: 1 } as CropRegion, @@ -333,7 +330,6 @@ 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 06b147d6b..5f6afb7b2 100644 --- a/src/lib/ai-edition/schema/index.ts +++ b/src/lib/ai-edition/schema/index.ts @@ -488,17 +488,10 @@ export const zoomRegionSchema = endGteStart( cy: z.number().min(0).max(1), }), focusMode: z.enum(["manual", "auto"]).optional(), - /** The zoom's 3D camera (`Rotation3DPreset` in `components/video-editor/types.ts` — same - * literal list, duplicated here): a fixed angle, or the camera that turns to the cursor - * (`crates/compositor/src/camera.rs`). Absent means a flat screen. */ - rotationPreset: z.enum(["iso", "left", "right", "follow-cursor"]).optional(), + rotationPreset: z.enum(["iso", "left", "right"]).optional(), customScale: z.number().positive().optional(), source: z.enum(["auto", "manual"]).optional(), hideCursor: z.boolean().optional(), - /** 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", "startMs", diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts index 0d054eb83..eaf367edd 100644 --- a/src/lib/ai-edition/store/documentWriteAudit.test.ts +++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts @@ -293,7 +293,6 @@ 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/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index 1ec2e046a..3501d0e8f 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -85,17 +85,6 @@ 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", () => { @@ -132,14 +121,6 @@ describe("patchEditorSettings", () => { expect(snap.cursor.smoothing).toBe(0.9); }); - 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", () => { const enabled = patchEditorSettings(baseDoc, { cursorAutoHide: true }); expect(getEditorSettings(enabled).cursorAutoHide).toBe(true); @@ -298,22 +279,6 @@ 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("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 cc1a5089a..5f2b28bca 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -13,20 +13,14 @@ import { type CursorVisualSettings, DEFAULT_CROP_REGION, DEFAULT_WEBCAM_POSITION, - isWallpaperMotion, isWebcamBackgroundMode, - type WallpaperMotion, type WebcamBackgroundMode, type WebcamLayoutPreset, type WebcamMaskShape, type WebcamPosition, type WebcamSizePreset, } from "@/components/video-editor/types"; -import { - DEFAULT_PROJECT_APPEARANCE, - isRecordingFrame, - type RecordingFrame, -} from "@/lib/projectDefaults"; +import { DEFAULT_PROJECT_APPEARANCE } from "@/lib/projectDefaults"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; import { clamp01 } from "@/utils/math"; import type { AxcutDocument } from "../schema"; @@ -78,15 +72,10 @@ 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; - /** The frame drawn around the recording (window chrome), or "none". */ - frame: RecordingFrame; aspectRatio: AspectRatio; shadowIntensity: number; showBlur: boolean; motionBlurAmount: number; - depthOfField: boolean; borderRadius: number; padding: number; cropRegion: CropRegion; @@ -126,13 +115,10 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettingsSnapshot = { interface LegacyShape { wallpaper?: string; - wallpaperMotion?: WallpaperMotion; - frame?: RecordingFrame; aspectRatio?: AspectRatio; shadowIntensity?: number; showBlur?: boolean; motionBlurAmount?: number; - depthOfField?: boolean; borderRadius?: number; padding?: number; cropRegion?: CropRegion; @@ -152,7 +138,6 @@ interface LegacyShape { cursorSmoothing?: number; cursorMotionBlur?: number; cursorClickBounce?: number; - cursorModel3d?: boolean; cursorClipToBounds?: boolean; cursorShow?: boolean; cursorAutoHide?: boolean; @@ -184,8 +169,6 @@ 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), - // 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), }; @@ -204,16 +187,10 @@ 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, - // 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), 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, @@ -253,13 +230,10 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor } export interface EditorSettingsPatch { wallpaper?: string; - wallpaperMotion?: WallpaperMotion; - frame?: RecordingFrame; aspectRatio?: AspectRatio; shadowIntensity?: number; showBlur?: boolean; motionBlurAmount?: number; - depthOfField?: boolean; borderRadius?: number; padding?: number; cropRegion?: CropRegion; @@ -300,7 +274,6 @@ 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.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 797f39eda..080cd83e6 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -799,20 +799,6 @@ 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 () => { @@ -826,19 +812,6 @@ 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 72a074f53..e262d81db 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -6,11 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { toFileUrl } from "@/components/video-editor/projectPersistence"; -import type { - AnnotationRegion, - AnnotationType, - Rotation3DPreset, -} from "@/components/video-editor/types"; +import type { AnnotationRegion, AnnotationType } from "@/components/video-editor/types"; import { useScopedT } from "@/contexts/I18nContext"; import { collapseTracksToPills, @@ -728,7 +724,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: Rotation3DPreset | undefined) => { + async (id: string, rotationPreset: "iso" | "left" | "right" | undefined) => { if (!document) return; const next: AxcutDocument = { ...document, @@ -777,22 +773,6 @@ 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; @@ -1542,7 +1522,6 @@ export function useTimeline() { updateZoomRotation, updateZoomFocusMode, updateZoomHideCursor, - updateZoomClickImpact, updateAnnotationSpan, updateAnnotationLive, commitAnnotationChange, diff --git a/src/lib/ai-edition/stylePresets.test.ts b/src/lib/ai-edition/stylePresets.test.ts index f6a161f40..7ba0a6492 100644 --- a/src/lib/ai-edition/stylePresets.test.ts +++ b/src/lib/ai-edition/stylePresets.test.ts @@ -15,13 +15,10 @@ import { function appearance(overrides: Partial = {}): StylePresetAppearance { return { wallpaper: "/wallpapers/wallpaper3.jpg", - wallpaperMotion: "drift", - frame: "window-dark", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, motionBlurAmount: 0.2, - depthOfField: true, borderRadius: 40, padding: 50, webcamLayoutPreset: "picture-in-picture", @@ -32,14 +29,7 @@ function appearance(overrides: Partial = {}): StylePreset webcamBackgroundMode: "blur", webcamWallpaper: "#112233", webcamBlurIntensity: 0.5, - cursor: { - size: 3, - smoothing: 0.67, - motionBlur: 0.35, - clickBounce: 2.5, - model3d: false, - clipToBounds: false, - }, + cursor: { size: 3, smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, clipToBounds: false }, cursorShow: true, cursorAutoHide: false, cursorTheme: "default", @@ -59,26 +49,6 @@ 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("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( @@ -107,41 +77,6 @@ describe("parseStylePresetAppearance", () => { ).toThrow(/cursor\.size/); }); - 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, model3d: true }, - }).cursor.model3d, - ).toBe(true); - expect(() => - parseStylePresetAppearance({ - ...appearance(), - cursor: { ...appearance().cursor, model3d: 1 }, - }), - ).toThrow(/cursor\.model3d/); - }); - - 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 06734dde0..1b46ce44d 100644 --- a/src/lib/ai-edition/stylePresets.ts +++ b/src/lib/ai-edition/stylePresets.ts @@ -12,7 +12,6 @@ import type { CursorVisualSettings, - WallpaperMotion, WebcamBackgroundMode, WebcamLayoutPreset, WebcamMaskShape, @@ -20,7 +19,6 @@ 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,13 +34,10 @@ 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; - frame: RecordingFrame; aspectRatio: AspectRatio; shadowIntensity: number; showBlur: boolean; motionBlurAmount: number; - depthOfField: boolean; borderRadius: number; padding: number; webcamLayoutPreset: WebcamLayoutPreset; @@ -97,12 +92,6 @@ 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 @@ -157,20 +146,6 @@ 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; @@ -228,12 +203,8 @@ 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). 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.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. + * otherwise sound (the editor does the same when it renders one). Unknown extra keys are + * dropped. */ export function parseStylePresetAppearance(value: unknown): StylePresetAppearance { if (!isRecord(value)) { @@ -251,16 +222,10 @@ export function parseStylePresetAppearance(value: unknown): StylePresetAppearanc } return { wallpaper: parseStylePresetWallpaper(value.wallpaper), - wallpaperMotion: - 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"), 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), @@ -280,8 +245,6 @@ 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 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 318f0b4d3..338aaa339 100644 --- a/src/lib/ai-edition/stylePresetsEditor.test.ts +++ b/src/lib/ai-edition/stylePresetsEditor.test.ts @@ -26,8 +26,6 @@ function styledSettings(): EditorSettingsSnapshot { return { ...DEFAULT_EDITOR_SETTINGS, wallpaper: "linear-gradient(90deg, #000, #fff)", - wallpaperMotion: "waves", - frame: "window-light", aspectRatio: "9:16", shadowIntensity: 0.8, showBlur: true, @@ -42,14 +40,7 @@ function styledSettings(): EditorSettingsSnapshot { webcamBackgroundMode: "custom", webcamWallpaper: "#123456", webcamBlurIntensity: 0.9, - cursor: { - size: 5, - smoothing: 0.1, - motionBlur: 0.9, - clickBounce: 4, - model3d: true, - clipToBounds: true, - }, + cursor: { size: 5, smoothing: 0.1, motionBlur: 0.9, clickBounce: 4, 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 fea3bbb27..898c63fab 100644 --- a/src/lib/ai-edition/stylePresetsEditor.ts +++ b/src/lib/ai-edition/stylePresetsEditor.ts @@ -15,13 +15,10 @@ export function stylePresetAppearanceFromSettings( ): StylePresetAppearance { return { wallpaper: settings.wallpaper, - wallpaperMotion: settings.wallpaperMotion, - frame: settings.frame, aspectRatio: settings.aspectRatio, shadowIntensity: settings.shadowIntensity, showBlur: settings.showBlur, motionBlurAmount: settings.motionBlurAmount, - depthOfField: settings.depthOfField, borderRadius: settings.borderRadius, padding: settings.padding, webcamLayoutPreset: settings.webcamLayoutPreset, @@ -37,7 +34,6 @@ export function stylePresetAppearanceFromSettings( smoothing: settings.cursor.smoothing, motionBlur: settings.cursor.motionBlur, clickBounce: settings.cursor.clickBounce, - model3d: settings.cursor.model3d, clipToBounds: settings.cursor.clipToBounds, }, cursorShow: settings.cursorShow, @@ -61,13 +57,10 @@ export function factoryStylePresetAppearance(): StylePresetAppearance { export function stylePresetPatch(appearance: StylePresetAppearance): EditorSettingsPatch { return { wallpaper: appearance.wallpaper, - wallpaperMotion: appearance.wallpaperMotion, - frame: appearance.frame, aspectRatio: appearance.aspectRatio, 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/compositeLayout.test.ts b/src/lib/compositeLayout.test.ts index 29618f3df..0a100a679 100644 --- a/src/lib/compositeLayout.test.ts +++ b/src/lib/compositeLayout.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { computeCameraFullscreenRect, computeCompositeLayout, - fitInWindowFrame, isWebcamBlockLayout, resolveWebcamReactiveZoom, type StyledRenderRect, @@ -529,22 +528,3 @@ 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 6959267a6..11eb35a50 100644 --- a/src/lib/compositeLayout.ts +++ b/src/lib/compositeLayout.ts @@ -5,40 +5,6 @@ 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 2f606a9e7..2deb8687d 100644 --- a/src/lib/projectDefaults.ts +++ b/src/lib/projectDefaults.ts @@ -1,29 +1,9 @@ -/** - * 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; 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"; @@ -40,7 +20,6 @@ export interface ProjectAppearanceDefaults { smoothing: number; motionBlur: number; clickBounce: number; - model3d: boolean; clipToBounds: boolean; autoHide: boolean; }; @@ -53,14 +32,10 @@ export interface ProjectAppearanceDefaults { /** The factory appearance every new project starts from. */ export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = { wallpaper: "/wallpapers/wallpaper1.jpg", - wallpaperMotion: "none", - frame: "none", aspectRatio: "16:9", 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", @@ -77,7 +52,6 @@ export const DEFAULT_PROJECT_APPEARANCE: ProjectAppearanceDefaults = { smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, - model3d: false, clipToBounds: false, autoHide: false, }, diff --git a/src/native/browserShim.presets.test.ts b/src/native/browserShim.presets.test.ts index 9c8261141..35172982f 100644 --- a/src/native/browserShim.presets.test.ts +++ b/src/native/browserShim.presets.test.ts @@ -11,13 +11,10 @@ import { const APPEARANCE: StylePresetAppearance = { wallpaper: "#000000", - wallpaperMotion: "none", - frame: "none", aspectRatio: "16:9", shadowIntensity: 0.2, showBlur: false, motionBlurAmount: 0.2, - depthOfField: true, borderRadius: 40, padding: 50, webcamLayoutPreset: "picture-in-picture", @@ -28,14 +25,7 @@ const APPEARANCE: StylePresetAppearance = { webcamBackgroundMode: "none", webcamWallpaper: "#ffffff", webcamBlurIntensity: 0.5, - cursor: { - size: 3, - smoothing: 0.67, - motionBlur: 0.35, - clickBounce: 2.5, - model3d: false, - clipToBounds: false, - }, + cursor: { size: 3, smoothing: 0.67, motionBlur: 0.35, clickBounce: 2.5, clipToBounds: false }, cursorShow: true, cursorAutoHide: false, cursorTheme: "default", diff --git a/src/native/nativeCompositorStore.test.ts b/src/native/nativeCompositorStore.test.ts index 23052e2c9..e0a741da0 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` (5), `set_param_num` (9) + * `crates/compositor/src/live.rs` — `set_param_bool` (3), `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,8 +31,6 @@ const ADDON_KEYS = [ "backgroundBlur", "webcamMirror", "cursorShow", - "cursorAutoHide", - "cursorModel3d", "shadow", "roundness", "motionBlur", @@ -81,17 +79,10 @@ 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, - model3d: true, - smoothing: 0.67, - motionBlur: 0.35, - }, + cursor: { size: 3, clickBounce: 2.5, smoothing: 0.67, motionBlur: 0.35 }, }); expect(pushed.get("cursorSize")).toBe(3); expect(pushed.get("cursorClickBounce")).toBe(2.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 d66390637..27b72f49e 100644 --- a/src/native/nativeCompositorStore.ts +++ b/src/native/nativeCompositorStore.ts @@ -146,7 +146,6 @@ export function pushAllNativeParams(settings: EditorSettingsSnapshot): void { setNativeParam("cursorAutoHide", settings.cursorAutoHide); setNativeParam("cursorSize", settings.cursor.size); setNativeParam("cursorClickBounce", settings.cursor.clickBounce); - 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 f155af7c2..a581b5439 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, wallpaperAcceptsMotion } from "./sceneDescription"; +import { buildSceneDescription } from "./sceneDescription"; // --- Fixture helpers -------------------------------------------------------- // Keep fixtures minimal & deterministic — every field the serializer consults is filled in; @@ -178,67 +178,6 @@ 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.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. @@ -590,26 +529,6 @@ 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", @@ -1073,18 +992,6 @@ describe("buildSceneDescription.settings mapping", () => { expect(buildSceneDescription(high).layout.webcamSize).toBeCloseTo(0.5, 5); }); - 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", () => { - 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 dc0120af4..4fdbeae76 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -17,9 +17,7 @@ import type { CameraFullscreenRegion, - Rotation3DPreset, SpeedRegion, - WallpaperMotion, WebcamBackgroundMode, } from "@/components/video-editor/types"; import { DEFAULT_CROP_REGION, getZoomScale } from "@/components/video-editor/types"; @@ -54,20 +52,12 @@ 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`. */ export type SceneBackground = | { kind: "color"; color: string } // "#rrggbb" - // 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: "gradient"; angleDeg: number; stops: string[] } // linear-gradient(deg, c1, c2, …) | { kind: "image"; path: string }; // "/wallpapers/…" or a data: URL /** A timeline zoom region (from `document.zoomRanges`). Times in seconds. */ @@ -83,9 +73,8 @@ export interface SceneZoomRegion { focusY: number; /** "auto" follows cursor telemetry instead of the fixed focus point. */ focusMode: "manual" | "auto" | 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; + /** Optional rotation preset for the zoom. */ + rotation: "iso" | "left" | "right" | 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 @@ -103,9 +92,6 @@ 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. */ @@ -373,17 +359,6 @@ 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; - /** - * 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. */ @@ -396,11 +371,6 @@ export interface SceneCursor { /** 0..1. */ motionBlur: number; clickBounce: 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; @@ -534,23 +504,6 @@ 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 @@ -1072,8 +1025,6 @@ export function buildSceneDescription( roundnessFrac: 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, @@ -1082,7 +1033,6 @@ export function buildSceneDescription( smoothing: settings.cursor.smoothing, motionBlur: settings.cursor.motionBlur, clickBounce: settings.cursor.clickBounce, - model3d: settings.cursor.model3d, clipToBounds: settings.cursor.clipToBounds, theme: settings.cursorTheme, }, @@ -1090,7 +1040,7 @@ export function buildSceneDescription( gainDb: settings.audioGainDb, }, audioTracks, - background: sceneBackground(settings.wallpaper, settings.wallpaperMotion), + background: parseWallpaper(settings.wallpaper), zoomRegions: projectedZoomRegions.map((region) => ({ id: region.id, startSec: region.startMs / 1000, @@ -1121,7 +1071,6 @@ export function buildSceneDescription( clipIndex: region.clipIndex, ...(region.underTrim ? { underTrim: true } : {}), ...(region.hideCursor ? { hideCursor: true } : {}), - ...(region.clickImpact ? { clickImpact: true as const } : {}), })), annotations: projectedAnnotations .map((region) => { diff --git a/technical-documentation/engineering/rendering-performance.md b/technical-documentation/engineering/rendering-performance.md index 9238c3ba5..ea5b63cf1 100644 --- a/technical-documentation/engineering/rendering-performance.md +++ b/technical-documentation/engineering/rendering-performance.md @@ -172,46 +172,6 @@ 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**. diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index cd4267764..38b4b312f 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'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. +- [ ] Change the zoom rotation preset among none, iso, left, and right and confirm the preview orientation changes. - [ ] 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 camera preset and confirm the tilt matches the Windows render. +- [ ] On macOS, export frames with each 3D rotation 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. @@ -628,4 +628,3 @@ 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). |