diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index 667c5d4f3..02642a969 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -196,6 +196,14 @@ impl CursorTrack { } } + /// Les instants de clic dans `(lo, hi]`, triés. L'impact du clic sur le plan + /// (`regions::click_impact`) les somme tous, là où `bounce` ne garde que le dernier. + pub fn clicks_between(&self, lo: f32, hi: f32) -> &[f32] { + let a = self.clicks.partition_point(|&tc| tc <= lo); + let b = self.clicks.partition_point(|&tc| tc <= hi); + &self.clicks[a..b.max(a)] + } + /// Piste repositionnée par un ressort-amortisseur (parité `cursorPathSmoothing.ts` : /// resample à 240 Hz + intégration semi-implicite d'Euler). `factor` 0..1 = valeur brute /// du slider (0 = passthrough, retourne un clone). Les clics restent sur leurs instants diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index a92ff06ec..febf6cc3a 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -1084,12 +1084,14 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // écran normal (vélocité pour le motion blur du chemin non-tilté). let mut zoom_rotation = [0.0f32; 3]; let mut zoom_tilt = 0.0f32; + let mut zoom_click_impact = 0.0f32; if !zoom_regions.is_empty() { let zs = crate::regions::zoom_state_at(zoom_regions, source_t, cursor_for_zoom); p.zoom = zs.scale; p.focus = zs.focus; zoom_rotation = zs.rotation; zoom_tilt = zs.tilt; + zoom_click_impact = zs.click_impact; let zs_p = crate::regions::zoom_state_at(zoom_regions, source_t_prev, cursor_for_zoom); pp.zoom = zs_p.scale; pp.focus = zs_p.focus; @@ -1271,8 +1273,16 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { cut_ref[2] / u_max.max(1e-6), cut_ref[3] / v_max.max(1e-6), ]; + // Impact du clic : mêmes piste, coupe, porte et budget que la parallaxe. + let impact = match (scene, parallax_track) { + (Some(s), Some(track)) if zoom_click_impact > 0.0 => { + click_impact_at(s, cfg, &lp, track, source_t, cut, [u_max, v_max]) + .map(|d| d * zoom_click_impact) + } + _ => [0.0; 3], + }; let zoom_rotation_dyn = - crate::regions::dynamic_tilt(source_t, parallax_track, cut_norm, zoom_tilt); + crate::regions::dynamic_tilt(source_t, parallax_track, cut_norm, zoom_tilt, impact); let s_dst_prev = remap_box(s_base_prev, cut_ref_prev, cut); // le padding n'affecte QUE l'écran (la quantité de fond révélée). La webcam reste ancrée // en bas-droite à sa marge fixe, quelle que soit la valeur de padding (pas de scale_frame) @@ -1442,22 +1452,83 @@ pub struct CursorPlanInput<'a> { pub t: f32, } +/// Opacité du curseur à `t`, avant placement : 0 quand il est masqué (`cursor.show`), sinon +/// l'auto-hide × le `hideCursor` des régions de zoom. Partagée par `plan_cursor` et l'impact du +/// clic : le plan ne bascule que sous un pointeur qu'on voit. +pub fn cursor_alpha( + scene: Option<&Scene>, + cfg: &Cfg, + live: &LiveParams, + track: &crate::cursor::CursorTrack, + t: f32, +) -> f32 { + if !scene.map(|s| s.cursor.show).unwrap_or(cfg.cursor) { + return 0.0; + } + let idle_alpha = track.opacity_at(t, live.cursor_auto_hide); + let zoom_alpha = match scene { + Some(s) => crate::regions::zoom_cursor_alpha(&s.zoom_regions, t), + None => 1.0, + }; + idle_alpha * zoom_alpha +} + +/// Où tombe la position curseur `p` (repère normalisé de l'écran) dans la coupe `cut` (UV +/// texture), en fraction 0..1 par axe ; `None` hors coupe. Le test « pointeur dans la coupe » +/// de `plan_cursor`, repris tel quel par l'impact du clic. +pub fn cursor_plane_point(cut: [f32; 4], uv_max: [f32; 2], p: (f32, f32)) -> Option<[f32; 2]> { + let [su0, sv0, su1, sv1] = cut; + let (hu, hv) = ((su1 - su0) * 0.5, (sv1 - sv0) * 0.5); + let fx = (p.0 * uv_max[0] - su0) / (2.0 * hu); + let fy = (p.1 * uv_max[1] - sv0) / (2.0 * hv); + ((0.0..=1.0).contains(&fx) && (0.0..=1.0).contains(&fy)).then_some([fx, fy]) +} + +/// L'impact des clics (`regions::click_impact`) avec les portes qui dépendent de la scène, à +/// multiplier encore par le poids des régions (`ZoomState::click_impact`) ; la porte du préset +/// vient ensuite, dans `dynamic_tilt`. +/// +/// - curseur visible (`cursor_alpha`) : `cursor.show` explicite, parce que l'export ne charge la +/// piste que si le curseur est affiché alors que la preview la charge toujours ; +/// - clics dans la fenêtre source du clip actif seulement (cf. `click_impact`) ; +/// - vitesse : poids `clamp(2 − vitesse, 0, 1)`. À 100× une frame couvre 3,3 s de source, la +/// courbe serait échantillonnée une fois, au hasard : une secousse d'une frame ; +/// - masques de confidentialité : rien tant qu'un flou/mosaïque est visible. Le masque suit +/// déjà le quad dynamique (`privacy_mask`), c'est une marge de sûreté que la spec demande. +fn click_impact_at( + scene: &Scene, + cfg: &Cfg, + live: &LiveParams, + track: &crate::cursor::CursorTrack, + t: f32, + cut: [f32; 4], + uv_max: [f32; 2], +) -> [f32; 3] { + let Some(clip) = scene.clips.get(scene.active_clip_index) else { return [0.0; 3] }; + let masked = scene.annotations.iter().any(|a| { + a.kind == "blur" && a.blur.is_some() && t >= a.start_sec as f32 && t < a.end_sec as f32 + }); + if masked { + return [0.0; 3]; + } + let speed = crate::regions::speed_at(&scene.speed_regions, scene.active_clip_index, t as f64); + let speed_weight = (2.0 - speed as f32).clamp(0.0, 1.0); + let weight = cursor_alpha(Some(scene), cfg, live, track, t) * speed_weight; + if weight <= 0.0 { + return [0.0; 3]; + } + let window = [clip.source_start_sec as f32, clip.source_end_sec as f32]; + crate::regions::click_impact(t, track, window, |p| cursor_plane_point(cut, uv_max, p)) + .map(|d| d * weight) +} + /// `None` = rien à dessiner cette frame : curseur masqué, pointeur hors du rect source /// courant (zoom serré, hors écran), ou sprite réduit à rien au creux d'un click bounce /// extrême — un état normal en lecture, pas une erreur. pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option { let (rw, rh) = (input.render_px[0], input.render_px[1]); - let show = input.scene.map(|s| s.cursor.show).unwrap_or(input.cfg.cursor); - if !show { - return None; - } - let idle_alpha = input.track.opacity_at(input.t, input.live.cursor_auto_hide); - let zoom_alpha = match input.scene { - Some(s) => crate::regions::zoom_cursor_alpha(&s.zoom_regions, input.t), - None => 1.0, - }; - let alpha = idle_alpha * zoom_alpha; + let alpha = cursor_alpha(input.scene, input.cfg, &input.live, input.track, input.t); if alpha <= 0.001 { return None; } @@ -1485,15 +1556,9 @@ pub fn plan_cursor(g: &FrameGeometry, input: &CursorPlanInput) -> Option [-1.0, -1.0, 3.0, 3.0], }; - let [su0, sv0, su1, sv1] = g.cut; - let (hu, hv) = ((su1 - su0) * 0.5, (sv1 - sv0) * 0.5); let place = |cxy: Option<(f32, f32)>, dst: [f32; 4]| -> Option { - cxy.and_then(|(cx2, cy2)| { - let fx = (cx2 * input.u_max - su0) / (2.0 * hu); - let fy = (cy2 * input.v_max - sv0) / (2.0 * hv); - if !(0.0..=1.0).contains(&fx) || !(0.0..=1.0).contains(&fy) { - return None; - } + cxy.and_then(|p| { + let [fx, fy] = cursor_plane_point(g.cut, [input.u_max, input.v_max], p)?; Some(match tilt.as_ref() { Some(&quad) => CursorPlacement::Tilted { plane_pt: [fx, fy], @@ -2003,6 +2068,90 @@ mod tests { assert!(zoomed > flat * 1.4, "zoom x2 : {zoomed} devrait dépasser {flat} nettement"); } + /// L'impact du clic passe par `plan_frame` et chacune de ses portes l'éteint : région sans + /// l'option ou sans préset, curseur masqué (réglage ou région), clic hors du clip actif ou + /// de la coupe, vitesse ≥ 2×, masque de flou visible. + #[test] + fn every_gate_cancels_the_click_impact() { + let cfg = crate::config::all().pop().expect("au moins une config"); + // Pointeur immobile près du bord droit du crop (0,61 de large), clic 50 ms avant + // l'instant du golden (1,5 s) : le creux de `tap`. + let track_at = |x: f32| -> &'static crate::cursor::CursorTrack { + Box::leak(Box::new(crate::cursor::CursorTrack::new( + (0..=90).map(|i| (i as f32 / 30.0, x, 0.3)).collect(), + vec![1.45], + vec![], + ))) + }; + let on_edge = track_at(0.6); + let impact_json = zoomed_golden_scene_json() + .replace(r#""rotation":"none""#, r#""rotation":"iso","clickImpact":true"#); + let dyn_of = |edit: &dyn Fn(String) -> String, track| { + let scene = Scene::from_json(&edit(impact_json.clone())).expect("scène"); + plan_frame(&FrameGeometryInput { cursor: Some(track), ..golden_input(&scene, &cfg) }) + .zoom_rotation_dyn + }; + let same = |s: String| s; + let on = dyn_of(&same, on_edge); + assert!(on[1] > 1.5, "clic à droite → +Y : {on:?}"); + + let insert = |field: &'static str| { + move |s: String| s.replace(r#""cursor":"#, &format!(r#"{field},"cursor":"#)) + }; + let cases: [(&str, Box String>); 7] = [ + ("option absente", Box::new(|s: String| s.replace(r#","clickImpact":true"#, ""))), + ("sans préset", Box::new(|s: String| s.replace(r#""rotation":"iso""#, r#""rotation":"none""#))), + ("curseur masqué", Box::new(|s: String| s.replace(r#""show":true"#, r#""show":false"#))), + ("région hideCursor", Box::new(|s: String| s.replace(r#""clickImpact":true"#, r#""clickImpact":true,"hideCursor":true"#))), + ("clic avant le clip", Box::new(|s: String| s.replace(r#""sourceStartSec":0"#, r#""sourceStartSec":1.46"#))), + ("vitesse 2x", Box::new(insert(r#""speedRegions":[{"clipIndex":0,"startSec":1.0,"endSec":2.0,"speed":2.0}]"#))), + ("flou visible", Box::new(insert(r#""annotations":[{"id":"b","startSec":1.0,"endSec":2.0,"kind":"blur","x":0.1,"y":0.1,"w":0.2,"h":0.2,"blur":{"style":"mosaic","shape":"rectangle","color":"black","intensity":8,"blockSize":16}}]"#))), + ]; + for (name, edit) in &cases { + assert_eq!(dyn_of(edit.as_ref(), on_edge), [0.0; 3], "{name}"); + } + assert_eq!(dyn_of(&same, track_at(0.9)), [0.0; 3], "clic hors du crop"); + + // Un flou qui n'est plus visible ne retient rien ; une vitesse 1,5× pèse moitié. + let later_blur = insert(r#""annotations":[{"id":"b","startSec":3.0,"endSec":4.0,"kind":"blur","x":0.1,"y":0.1,"w":0.2,"h":0.2,"blur":{"style":"blur","shape":"rectangle","color":"black","intensity":8,"blockSize":16}}]"#); + assert_eq!(dyn_of(&later_blur, on_edge), on); + let slow = dyn_of( + &insert(r#""speedRegions":[{"clipIndex":0,"startSec":1.0,"endSec":2.0,"speed":1.5}]"#), + on_edge, + ); + assert!((slow[1] - on[1] * 0.5).abs() < 1e-4, "{slow:?} vs {on:?}"); + + // Le curseur monte sur le même plan basculé que l'écran. + let g = plan_frame(&FrameGeometryInput { + cursor: Some(on_edge), + ..golden_input(&Scene::from_json(&impact_json).expect("scène"), &cfg) + }); + let render = [1170.0, 658.0]; + let s_px = [g.s_dst[2] * render[0], g.s_dst[3] * render[1]]; + let quad = g.screen_tilt(s_px).expect("iso incline"); + let scene = Scene::from_json(&impact_json).expect("scène"); + let plan = plan_cursor( + &g, + &CursorPlanInput { + render_px: render, + u_max: 1.0, + v_max: 1080.0 / 1088.0, + cfg: &cfg, + live: live_params_from_scene(&scene), + scene: Some(&scene), + track: on_edge, + t: 1.5, + }, + ) + .expect("curseur visible"); + match plan.placement { + CursorPlacement::Tilted { quad: cursor_quad, .. } => { + assert_eq!(cursor_quad.corners, quad.corners) + } + _ => panic!("le curseur doit suivre le plan incliné"), + } + } + /// Sous un préset 3D, le masque est le quad du contenu, warpé comme le mode 8 le dessine. #[test] fn a_privacy_mask_follows_the_tilted_content() { diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs index b1bd00719..7ea8dee1f 100644 --- a/crates/compositor/src/regions.rs +++ b/crates/compositor/src/regions.rs @@ -279,16 +279,30 @@ pub struct ZoomState { /// un, 0 sinon ; interpolé entre deux régions chaînées, et refermé en milieu de course /// quand leurs présets diffèrent. C'est la porte de `dynamic_tilt`. pub tilt: f32, + /// Poids de l'impact du clic (0..1) : 1 sur une région qui l'active (`click_impact`), + /// interpolé entre deux régions chaînées. Ne suffit pas seul : l'impact passe aussi par la + /// porte de `tilt` (`dynamic_tilt`), donc rien sans préset. + pub click_impact: f32, } -const IDENTITY_ZOOM: ZoomState = - ZoomState { scale: 1.0, focus: [0.5, 0.5], rotation: [0.0, 0.0, 0.0], tilt: 0.0 }; +const IDENTITY_ZOOM: ZoomState = ZoomState { + scale: 1.0, + focus: [0.5, 0.5], + rotation: [0.0, 0.0, 0.0], + tilt: 0.0, + click_impact: 0.0, +}; /// 1 si la région porte un préset 3D, 0 sinon. fn has_tilt(region: &SceneZoomRegion) -> f32 { if is_identity_rotation(rotation3d_for(®ion.rotation)) { 0.0 } else { 1.0 } } +/// 1 si la région active l'impact du clic, 0 sinon. +fn impact_flag(region: &SceneZoomRegion) -> f32 { + if region.click_impact { 1.0 } else { 0.0 } +} + /// Port de `easeConnectedPan` (TS) : cubic-bezier(0.1, 0, 0.2, 1). fn ease_connected_pan(t: f32) -> f32 { cubic_bezier(0.1, 0.0, 0.2, 1.0, t) @@ -446,6 +460,7 @@ pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&Cursor focus: [lerp(cur_focus[0], next_focus[0], progress), lerp(cur_focus[1], next_focus[1], progress)], rotation: lerp_rotation3d(cur_rot, next_rot, progress), tilt: lerp(has_tilt(cur), has_tilt(next), progress) * crossing, + click_impact: lerp(impact_flag(cur), impact_flag(next), progress), }; } @@ -459,6 +474,7 @@ pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&Cursor focus: resolve_focus(next, t, cursor), rotation: rotation3d_for(&next.rotation), tilt: has_tilt(next), + click_impact: impact_flag(next), }; } } @@ -504,6 +520,7 @@ pub fn zoom_state_at(regions: &[SceneZoomRegion], t: f32, cursor: Option<&Cursor focus: [ease(focus[0]), ease(focus[1])], rotation: lerp_rotation3d([0.0, 0.0, 0.0], rotation3d_for(&r.rotation), strength), tilt: has_tilt(r) * strength, + click_impact: impact_flag(r), } } None => IDENTITY_ZOOM, @@ -566,6 +583,22 @@ pub fn is_identity_rotation(r: [f32; 3]) -> bool { /// perspective `perspective` (distance en px ; <=0 = orthographique). `None` si le point /// passe derrière le plan de projection (cas pathologique, comme le `return 1` du TS). fn project_corner(x0: f32, y0: f32, rot: [f32; 3], perspective: f32) -> Option<(f32, f32)> { + let (mut px, mut py, pz) = rotate_corner(x0, y0, rot); + if perspective > 0.0 { + let denom = perspective - pz; + if denom <= 0.0 { + return None; + } + let f = perspective / denom; + px *= f; + py *= f; + } + Some((px, py)) +} + +/// Le point local (x0,y0,0) tourné par `rot` (degrés X/Y/Z), avant perspective. `z` > 0 vient +/// vers la caméra, < 0 recule. +fn rotate_corner(x0: f32, y0: f32, rot: [f32; 3]) -> (f32, f32, f32) { let (a, b, g) = (rot[0].to_radians(), rot[1].to_radians(), rot[2].to_radians()); let (ca, sa) = (a.cos(), a.sin()); let (cb, sb) = (b.cos(), b.sin()); @@ -583,16 +616,7 @@ fn project_corner(x0: f32, y0: f32, rot: [f32; 3], perspective: f32) -> Option<( let (xy, xz) = (py * ca - pz * sa, py * sa + pz * ca); py = xy; pz = xz; - if perspective > 0.0 { - let denom = perspective - pz; - if denom <= 0.0 { - return None; - } - let f = perspective / denom; - px *= f; - py *= f; - } - Some((px, py)) + (px, py, pz) } /// Les 4 coins d'un quad `width`×`height` réduit de `scale`, projetés. `None` si un coin part @@ -763,23 +787,105 @@ const PARALLAX_VELOCITY_HALF_WINDOW_S: f32 = 0.1; /// /// Sens : le plan se penche vers le geste — curseur vers la droite → le bord droit recule /// (+Y), vers le bas → le bord bas recule (−X). Même convention que l'impact du clic. -pub fn dynamic_tilt(t: f32, track: Option<&CursorTrack>, cut: [f32; 4], strength: f32) -> [f32; 3] { +/// +/// `impact` : l'impact du clic (`click_impact`), déjà pondéré par ses propres portes. Il +/// s'ADDITIONNE à la parallaxe, la somme est bornée au budget, puis la porte d'ease-in +/// s'applique au tout : les deux effets passent par la même porte et le même budget. +pub fn dynamic_tilt( + t: f32, + track: Option<&CursorTrack>, + cut: [f32; 4], + strength: f32, + impact: [f32; 3], +) -> [f32; 3] { let gate = smoothstep(PARALLAX_GATE_START, 1.0, strength); if gate <= 0.0 { return [0.0; 3]; } - let Some(track) = track else { return [0.0; 3] }; let h = PARALLAX_VELOCITY_HALF_WINDOW_S; - let (Some(a), Some(b)) = (track.follow_at(t - h), track.follow_at(t + h)) else { - return [0.0; 3]; + let parallax = match track.map(|tr| (tr.follow_at(t - h), tr.follow_at(t + h))) { + Some((Some(a), Some(b))) => { + let (cw, ch) = ((cut[2] - cut[0]).max(1e-3), (cut[3] - cut[1]).max(1e-3)); + let (vx, vy) = ((b.0 - a.0) / (2.0 * h * cw), (b.1 - a.1) / (2.0 * h * ch)); + // Saturation douce : jamais au-delà du budget, sans plateau sec pendant un geste + // rapide. + let soft = |v: f32, budget: f32| budget * (PARALLAX_DEG_PER_SPEED * v / budget).tanh(); + let b = DYNAMIC_TILT_BUDGET; + [soft(-vy, b[0]), soft(vx, b[1]), 0.0] + } + _ => [0.0; 3], }; - let (cw, ch) = ((cut[2] - cut[0]).max(1e-3), (cut[3] - cut[1]).max(1e-3)); - let (vx, vy) = ((b.0 - a.0) / (2.0 * h * cw), (b.1 - a.1) / (2.0 * h * ch)); - // Saturation douce : jamais au-delà du budget, sans plateau sec pendant un geste rapide. - let soft = |v: f32, budget: f32| budget * (PARALLAX_DEG_PER_SPEED * v / budget).tanh(); - let b = DYNAMIC_TILT_BUDGET; - let parallax = [soft(-vy, b[0]), soft(vx, b[1]), 0.0]; - clamp_dynamic_tilt(parallax).map(|d| d * gate) + let sum = [parallax[0] + impact[0], parallax[1] + impact[1], parallax[2] + impact[2]]; + clamp_dynamic_tilt(sum).map(|d| d * gate) +} + +/// Durée de l'impact du clic : la fenêtre de `CursorTrack::bounce`, pour que le plan et le +/// pointeur lisent comme un seul contact. +pub const CLICK_IMPACT_WINDOW_S: f32 = 0.26; +/// Amplitude `A` de l'impact, en degrés, au creux de `tap` pour un clic au bord de la coupe. +/// Calée sur le budget X (`DYNAMIC_TILT_BUDGET`, le plus serré) : un clic dans un coin pèse +/// autant sur les deux axes sans que X sature seul et ne tourne l'axe du pivot. Ce n'est PAS +/// le réglage `clickBounce` du curseur (brut sur [0, 5], 2,5 par défaut) : il rendrait le plan +/// 2,5 fois trop fort. Cf. `a_corner_click_moves_the_plane_visibly_at_a_frozen_scale`. +pub const CLICK_IMPACT_DEG: f32 = 1.9; +/// Valeur du pic de `sin(2πe)·(1−e)²` (en e ≈ 0,1904), pour que le creux de `tap` vaille −1. +const TAP_NORM: f32 = 0.610; + +/// L'enveloppe de l'impact, `e` = temps depuis le clic / `CLICK_IMPACT_WINDOW_S`. +/// +/// Courbe sœur de `CursorTrack::bounce`, qui reste intouchée : même instant de contact (creux +/// −1 à 49,5 ms) et même fenêtre (260 ms), mais sans la cassure de pente de `bounce` à 98,8 ms +/// (×2,45) — invisible sur un sprite de 30 px, brutale sur un plan entier. Rebond plus mou +/// (+0,164 à 165 ms contre 0,67) : un écran pèse plus qu'un pointeur. Retour à zéro avec une +/// pente nulle à 260 ms. Nulle hors de `[0, 1)`. +pub fn tap(e: f32) -> f32 { + if !(0.0..1.0).contains(&e) { + return 0.0; + } + let o = 1.0 - e; + -(std::f32::consts::TAU * e).sin() * o * o / TAP_NORM +} + +/// L'impact des clics au temps `t`, en degrés X/Y/Z, SANS ses portes (préset installé, +/// curseur visible, vitesse, masques : cf. `plan_frame`). À passer à `dynamic_tilt`. +/// +/// Un pivot RIGIDE autour du centre : le côté cliqué recule, le plan ne se déforme pas +/// localement — la géométrie ne sait pas dessiner une bosse (`project_corner` part d'un point +/// z = 0, le warp est bilinéaire). +/// +/// - `window` : la fenêtre source `[début, fin)` du clip actif. Un clic hors fenêtre est sur +/// une portion coupée : ses 260 ms déborderaient sur les frames gardées. +/// - `aim` : où tombe une position curseur dans la coupe VISIBLE (0..1 par axe), `None` hors +/// coupe — `frame_geometry::cursor_plane_point`, le test même de `plan_cursor`. Un clic hors +/// de ce qu'on voit ne bascule rien. +/// +/// Loi : `A · Σ tap((t − t_c) / 0,26) · [+dy, −dx, 0]`, chaque composante de la somme bornée à +/// [−1, 1] avant `A` — un double clic à 33 ms d'écart atteindrait 1,81. `dx`, `dy` ∈ [−1, 1] : +/// le décalage du clic au centre de la coupe, y vers le bas, visé à `at(t_c)` sur la piste +/// brute (pas `at(t)` : un glisser ferait vaciller l'axe en plein impact). `tap` vaut −1 au +/// contact : clic à droite → +Y → le bord droit recule ; clic en bas → −X → le bord bas recule. +pub fn click_impact( + t: f32, + track: &CursorTrack, + window: [f32; 2], + aim: impl Fn((f32, f32)) -> Option<[f32; 2]>, +) -> [f32; 3] { + let mut sum = [0.0f32; 2]; + for &tc in track.clicks_between(t - CLICK_IMPACT_WINDOW_S, t) { + if tc < window[0] || tc >= window[1] { + continue; + } + let Some([fx, fy]) = track.at(tc).and_then(&aim) else { continue }; + let (dx, dy) = ((2.0 * fx - 1.0).clamp(-1.0, 1.0), (2.0 * fy - 1.0).clamp(-1.0, 1.0)); + let k = tap((t - tc) / CLICK_IMPACT_WINDOW_S); + sum[0] += k * dy; + sum[1] -= k * dx; + } + [ + CLICK_IMPACT_DEG * sum[0].clamp(-1.0, 1.0), + CLICK_IMPACT_DEG * sum[1].clamp(-1.0, 1.0), + 0.0, + ] } fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 { @@ -828,6 +934,7 @@ mod zoom_focus_tests { rotation: None, under_trim: false, hide_cursor: false, + click_impact: false, } } @@ -1313,6 +1420,7 @@ mod tilt_tests { rotation: rotation.map(Into::into), under_trim: false, hide_cursor: false, + click_impact: false, }; let presets = [None, Some("iso"), Some("left"), Some("right")]; for a in presets { @@ -1390,7 +1498,7 @@ mod tilt_tests { #[test] fn no_track_means_no_dynamic_tilt() { - assert_eq!(dynamic_tilt(1.0, None, FULL_CUT, 1.0), [0.0; 3]); + assert_eq!(dynamic_tilt(1.0, None, FULL_CUT, 1.0, [0.0; 3]), [0.0; 3]); } #[test] @@ -1399,23 +1507,23 @@ mod tilt_tests { for k in 0..=85 { let strength = k as f32 / 100.0; assert_eq!( - dynamic_tilt(1.0, Some(&track), FULL_CUT, strength), + dynamic_tilt(1.0, Some(&track), FULL_CUT, strength, [0.0; 3]), [0.0; 3], "force {strength}" ); } - assert_ne!(dynamic_tilt(1.0, Some(&track), FULL_CUT, 0.95), [0.0; 3]); + assert_ne!(dynamic_tilt(1.0, Some(&track), FULL_CUT, 0.95, [0.0; 3]), [0.0; 3]); } /// Le geste penche le plan dans son sens, puis le plan revient à la pose du préset au repos. #[test] fn the_plane_leans_into_the_gesture_and_settles_at_rest() { let track = swipe(0.5, 1.5); - let moving = dynamic_tilt(1.0, Some(&track), FULL_CUT, 1.0); + let moving = dynamic_tilt(1.0, Some(&track), FULL_CUT, 1.0, [0.0; 3]); assert!(moving[1] > 0.5, "vers la droite → +Y : {moving:?}"); assert!(moving[0].abs() < 1e-3 && moving[2] == 0.0, "{moving:?}"); // La piste de suivi rattrape en quelques centaines de ms. - let rest = dynamic_tilt(4.0, Some(&track), FULL_CUT, 1.0); + let rest = dynamic_tilt(4.0, Some(&track), FULL_CUT, 1.0, [0.0; 3]); assert!(rest[1].abs() < 0.05, "au repos : {rest:?}"); // Vers le bas → le bord bas recule (−X). let down = CursorTrack::new( @@ -1423,7 +1531,7 @@ mod tilt_tests { vec![], vec![], ); - assert!(dynamic_tilt(1.0, Some(&down), FULL_CUT, 1.0)[0] < -0.5); + assert!(dynamic_tilt(1.0, Some(&down), FULL_CUT, 1.0, [0.0; 3])[0] < -0.5); } /// Quelle que soit la vitesse, la parallaxe reste dans le budget. Une coupe serrée (crop @@ -1434,11 +1542,11 @@ mod tilt_tests { for speed in [0.1f32, 1.0, 10.0, 1000.0, -1000.0] { let track = swipe(speed, 9.0); for cut in [FULL_CUT, [0.4, 0.4, 0.45, 0.45]] { - let d = dynamic_tilt(1.0, Some(&track), cut, 1.0); + let d = dynamic_tilt(1.0, Some(&track), cut, 1.0, [0.0; 3]); assert!(d[0].abs() <= b[0] && d[1].abs() <= b[1] && d[2] == 0.0, "{speed} {d:?}"); } } - let fast = dynamic_tilt(1.0, Some(&swipe(1000.0, 9.0)), FULL_CUT, 1.0); + let fast = dynamic_tilt(1.0, Some(&swipe(1000.0, 9.0)), FULL_CUT, 1.0, [0.0; 3]); assert!(fast[1] > 2.9, "saturation au budget : {fast:?}"); } @@ -1446,13 +1554,195 @@ mod tilt_tests { #[test] fn the_dynamic_tilt_is_a_pure_function_of_time() { let track = swipe(0.7, 2.0); - let at = |i: usize| dynamic_tilt(i as f32 / 30.0, Some(&track), FULL_CUT, 1.0); + let at = |i: usize| dynamic_tilt(i as f32 / 30.0, Some(&track), FULL_CUT, 1.0, [0.0; 3]); let forward: Vec<_> = (0..90).map(at).collect(); for i in (0..90).rev() { assert_eq!(at(i), forward[i]); } } + // ---- Impact du clic ---------------------------------------------------------------- + + const MS: f32 = 1.0 / 260.0; + + /// Creux −1 à 49,5 ms, rebond +0,164 à 165 ms, zéro à pente nulle à 260 ms, rien hors + /// fenêtre. + #[test] + fn the_tap_envelope_has_its_contact_rebound_and_flat_landing() { + let (mut lo, mut lo_ms) = (f32::MAX, 0.0); + let (mut hi, mut hi_ms) = (f32::MIN, 0.0); + for i in 0..2600 { + let ms = i as f32 * 0.1; + let v = tap(ms * MS); + if v < lo { + (lo, lo_ms) = (v, ms); + } + if v > hi { + (hi, hi_ms) = (v, ms); + } + } + assert!((lo + 1.0).abs() < 1e-3 && (lo_ms - 49.5).abs() < 0.3, "creux {lo} à {lo_ms} ms"); + assert!((hi - 0.164).abs() < 1e-3 && (hi_ms - 165.3).abs() < 0.5, "rebond {hi} à {hi_ms} ms"); + assert_eq!(tap(1.0), 0.0); + assert_eq!(tap(-1e-4), 0.0); + assert_eq!(tap(0.0), 0.0); + // Pente nulle à l'atterrissage : à 1 ms de la fin, la valeur est déjà sous 1e-4. + assert!(tap(259.0 * MS).abs() < 1e-4, "{}", tap(259.0 * MS)); + // Pas de cassure de pente (celle de `bounce` à 98,8 ms) : dérivée seconde bornée partout. + let d2 = |e: f32| (tap(e + 1e-3) - 2.0 * tap(e) + tap(e - 1e-3)) / 1e-6; + for i in 2..998 { + assert!(d2(i as f32 / 1000.0).abs() < 60.0, "cassure à e = {}", i as f32 / 1000.0); + } + } + + /// Une piste immobile au point `(x, y)`, avec des clics. + fn still(x: f32, y: f32, clicks: Vec) -> CursorTrack { + CursorTrack::new((0..=90).map(|i| (i as f32 / 30.0, x, y)).collect(), clicks, vec![]) + } + + /// La coupe entière, sans restriction : le point de la piste EST le point du plan. + fn full_aim(p: (f32, f32)) -> Option<[f32; 2]> { + ((0.0..=1.0).contains(&p.0) && (0.0..=1.0).contains(&p.1)).then_some([p.0, p.1]) + } + + const WHOLE: [f32; 2] = [0.0, 100.0]; + + /// Clic à droite → +Y ; en bas → −X ; au contact, le côté cliqué recule (z diminue). + #[test] + fn the_clicked_side_recedes() { + let t = 1.0 + 49.5 / 1000.0; + let right = click_impact(t, &still(1.0, 0.5, vec![1.0]), WHOLE, full_aim); + assert!((right[1] - CLICK_IMPACT_DEG).abs() < 1e-2 && right[0].abs() < 1e-6, "{right:?}"); + let bottom = click_impact(t, &still(0.5, 1.0, vec![1.0]), WHOLE, full_aim); + assert!((bottom[0] + CLICK_IMPACT_DEG).abs() < 1e-2 && bottom[1].abs() < 1e-6, "{bottom:?}"); + + let z = |x: f32, y: f32, rot: [f32; 3]| rotate_corner(x, y, rot).2; + for name in ["iso", "left", "right"] { + let base = preset(name); + let with = |d: [f32; 3]| [base[0] + d[0], base[1] + d[1], base[2] + d[2]]; + // Milieu du bord droit / du bord bas, et le bord opposé qui avance. + assert!(z(960.0, 0.0, with(right)) < z(960.0, 0.0, base), "{name} droite"); + assert!(z(-960.0, 0.0, with(right)) > z(-960.0, 0.0, base), "{name} gauche"); + assert!(z(0.0, 540.0, with(bottom)) < z(0.0, 540.0, base), "{name} bas"); + assert!(z(0.0, -540.0, with(bottom)) > z(0.0, -540.0, base), "{name} haut"); + } + // Au centre : pas d'axe, rien ne bouge. + assert_eq!(click_impact(t, &still(0.5, 0.5, vec![1.0]), WHOLE, full_aim), [0.0; 3]); + } + + /// Double clic : la somme est bornée — à 33 ms d'écart elle atteindrait 1,81. + #[test] + fn a_double_click_stays_bounded() { + let track = still(1.0, 1.0, vec![1.0, 1.033]); + let mut peak = 0.0f32; + for i in 0..400 { + let d = click_impact(1.0 + i as f32 / 1000.0, &track, WHOLE, full_aim); + assert!(d[0].abs() <= CLICK_IMPACT_DEG && d[1].abs() <= CLICK_IMPACT_DEG, "{d:?}"); + peak = peak.max(d[1]); + } + assert_eq!(peak, CLICK_IMPACT_DEG, "la somme brute dépasse 1 et se fait borner"); + } + + /// Rien hors de la fenêtre du clic, du clip actif ou de la coupe visible. + #[test] + fn clicks_outside_the_window_the_clip_or_the_crop_do_nothing() { + let track = still(1.0, 0.5, vec![1.0]); + assert_eq!(click_impact(0.99, &track, WHOLE, full_aim), [0.0; 3], "avant le clic"); + assert_eq!(click_impact(1.26, &track, WHOLE, full_aim), [0.0; 3], "après 260 ms"); + let t = 1.05; + assert_eq!(click_impact(t, &track, [1.01, 9.0], full_aim), [0.0; 3], "clic coupé avant"); + assert_eq!(click_impact(t, &track, [0.0, 1.0], full_aim), [0.0; 3], "fin exclusive"); + assert_ne!(click_impact(t, &track, [1.0, 9.0], full_aim), [0.0; 3], "début inclus"); + assert_eq!(click_impact(t, &track, WHOLE, |_| None), [0.0; 3], "hors coupe"); + assert_eq!(dynamic_tilt(t, None, FULL_CUT, 1.0, [0.0; 3]), [0.0; 3]); + } + + /// L'axe est visé à l'instant du clic : un glisser qui suit ne le fait pas vaciller. + #[test] + fn the_aim_is_frozen_at_the_click() { + let drag = CursorTrack::new( + (0..=90) + .map(|i| { + let t = i as f32 / 30.0; + (t, if t <= 1.0 { 1.0 } else { (1.0 - 3.0 * (t - 1.0)).max(0.0) }, 0.5) + }) + .collect(), + vec![1.0], + vec![], + ); + let rest = still(1.0, 0.5, vec![1.0]); + for i in 0..26 { + let t = 1.0 + i as f32 / 100.0; + assert_eq!( + click_impact(t, &drag, WHOLE, full_aim), + click_impact(t, &rest, WHOLE, full_aim), + "{t}" + ); + } + } + + /// L'impact passe par la porte de la parallaxe : rien pendant l'ease-in, et la somme des + /// deux reste dans le budget — donc dans le balayage des tests de la règle des 2°. + #[test] + fn the_impact_shares_the_parallax_gate_and_budget() { + let impact = [-CLICK_IMPACT_DEG, CLICK_IMPACT_DEG, 0.0]; + for k in 0..=85 { + let strength = k as f32 / 100.0; + assert_eq!(dynamic_tilt(1.0, None, FULL_CUT, strength, impact), [0.0; 3]); + } + assert_eq!(dynamic_tilt(1.0, None, FULL_CUT, 1.0, impact), impact); + let b = DYNAMIC_TILT_BUDGET; + assert!(CLICK_IMPACT_DEG <= b[0] && CLICK_IMPACT_DEG <= b[1]); + // Parallaxe saturée dans le même sens : la SOMME est bornée. + let fast = swipe(1000.0, 9.0); + let d = dynamic_tilt(1.0, Some(&fast), FULL_CUT, 1.0, impact); + assert!(d[1] <= b[1] && d[1] > b[1] - 1e-3, "{d:?}"); + let d = dynamic_tilt(1.0, Some(&fast), FULL_CUT, 0.95, impact); + let gate = smoothstep(PARALLAX_GATE_START, 1.0, 0.95); + assert!(d[1] <= b[1] * gate + 1e-5 && d[0].abs() <= b[0] * gate + 1e-5, "{d:?}"); + } + + /// Échelle gelée pendant l'impact : seuls les coins bougent, et assez pour se voir. + #[test] + fn a_corner_click_moves_the_plane_visibly_at_a_frozen_scale() { + let track = still(1.0, 1.0, vec![1.0]); + for name in ["iso", "left", "right"] { + let base = rotated_quad_corners_px(1920.0, 1080.0, preset(name), [0.0; 3]); + let mut worst = 0.0f32; + for i in 0..=26 { + let d = click_impact(1.0 + i as f32 / 100.0, &track, WHOLE, full_aim); + let q = rotated_quad_corners_px(1920.0, 1080.0, preset(name), d); + assert_eq!(q.scale, base.scale, "{name}"); + for (a, b) in q.corners.iter().zip(base.corners) { + worst = worst.max((a.0 - b.0).hypot(a.1 - b.1)); + } + } + // Mesuré : 24 px (iso), 25 (left), 27 (right) à 1080p pour un clic dans un coin. + assert!(worst > 15.0, "{name} : {worst:.1} px, invisible"); + } + } + + #[test] + fn the_click_impact_weight_follows_the_region_flag() { + let region = |click_impact: bool| SceneZoomRegion { + id: "z".into(), + clip_index: None, + start_sec: 2.0, + end_sec: 8.0, + scale: 2.0, + focus_x: 0.5, + focus_y: 0.5, + focus_mode: None, + rotation: Some("iso".into()), + under_trim: false, + hide_cursor: false, + click_impact, + }; + assert_eq!(zoom_state_at(&[region(true)], 5.0, None).click_impact, 1.0); + assert_eq!(zoom_state_at(&[region(false)], 5.0, None).click_impact, 0.0); + assert_eq!(zoom_state_at(&[region(true)], 0.0, None).click_impact, 0.0, "hors région"); + } + /// `ZoomState::tilt` : la force pour une région à préset, 0 sans préset. #[test] fn the_tilt_gate_follows_the_region_strength_only_with_a_preset() { @@ -1468,6 +1758,7 @@ mod tilt_tests { rotation: rotation.map(Into::into), under_trim: false, hide_cursor: false, + click_impact: false, }; assert_eq!(zoom_state_at(&[region(Some("iso"))], 5.0, None).tilt, 1.0); assert_eq!(zoom_state_at(&[region(None)], 5.0, None).tilt, 0.0); diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 3371aa8cd..0d226c0c8 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -353,6 +353,11 @@ pub struct SceneZoomRegion { /// Masque le curseur pendant cette région de zoom. #[serde(default)] pub hide_cursor: bool, + /// Chaque clic enfonce le plan incliné (`regions::click_impact`). Sans effet hors préset + /// 3D : c'est le préset qui installe le plan que le clic fait basculer. + /// `#[serde(default)]` : l'app omet la clé quand elle est fausse. + #[serde(default)] + pub click_impact: bool, } /// Une zone de vitesse portée par le temps source d'un clip. diff --git a/crates/compositor/tests/click_impact_render.rs b/crates/compositor/tests/click_impact_render.rs new file mode 100644 index 000000000..751e9b005 --- /dev/null +++ b/crates/compositor/tests/click_impact_render.rs @@ -0,0 +1,174 @@ +//! Sous un préset 3D avec « Click impact », un clic fait basculer le plan du côté cliqué puis le +//! laisse revenir (`regions::click_impact`). Rend de vraies frames par le compositeur D3D11. +//! +//! Même harnais que `tilt_parallax_render.rs`, mêmes variables d'environnement : une source +//! 1920×1080 de 6 s, de préférence quadrillée. La piste curseur (immobile, un clic) est écrite +//! par le test lui-même, au format du sidecar `.cursor.json`. +//! +//! ```powershell +//! ffmpeg -f lavfi -i "color=c=0x707070:s=1920x1080:r=30:d=6,drawgrid=w=96:h=96:t=3:c=white" -c:v h264_mf -b:v 6M -pix_fmt nv12 grid.mp4 +//! $env:OPENSCREEN_TILT_SOURCE = "...\grid.mp4" +//! cargo test -p openscreen-compositor --test click_impact_render -- --nocapture +//! ``` +//! +//! `OPENSCREEN_TILT_OUT` (facultatif) reçoit un PPM par instant, pour l'inspection à l'œil. + +// Windows seulement : le readback et le décodage D3D11VA de ce harnais n'existent que là. +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config; +use openscreen_compositor::cursor::CursorTrack; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::frame_geometry::live_params_from_scene; +use openscreen_compositor::live::Player; +use openscreen_compositor::scene::Scene; + +const W: u32 = 1920; +const H: u32 = 1080; +const CLICK_S: f64 = 2.0; + +/// Pointeur immobile près du bord droit, à mi-hauteur ; un clic à `CLICK_S`. +fn write_sidecar(path: &std::path::Path) { + let samples: Vec = (0..=180) + .map(|i| { + let ms = (i as f64 * 1000.0 / 30.0).round(); + let click = if (ms - CLICK_S * 1000.0).abs() < 1.0 { r#","interactionType":"click""# } else { "" }; + format!(r#"{{"timeMs":{ms},"cx":0.9,"cy":0.5{click}}}"#) + }) + .collect(); + std::fs::write(path, format!(r#"{{"samples":[{}]}}"#, samples.join(","))).expect("sidecar"); +} + +fn scene_json(source: &str, click_impact: bool) -> String { + let s = source.replace('\\', "/"); + let flag = if click_impact { r#","clickImpact":true"# } else { "" }; + format!( + r##"{{ + "clips": [{{"screenPath":"{s}","webcamPath":"","sourceStartSec":0,"sourceEndSec":6,"webcamOffsetSec":0,"hasAudio":false}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{{"x":0.15,"y":0.15,"width":0.7,"height":0.7}}}}, + "effects": {{"padding":0.1,"blur":false,"shadow":0.5,"roundnessFrac":0.02,"motionBlur":0.0}}, + "background": {{"kind":"color","color":"#2060d0"}}, + "zoomRegions": [{{"id":"z","startSec":0,"endSec":6,"scale":1.15,"focusX":0.5,"focusY":0.5,"focusMode":"manual","rotation":"iso"{flag}}}], + "annotations": [], + "speedRegions": [], + "cursor": {{"show":true,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":{W},"height":{H},"fps":null}} + }}"## + ) +} + +fn write_ppm(path: &std::path::Path, rgba: &[u8]) { + let mut out = format!("P6\n{W} {H}\n255\n").into_bytes(); + for px in rgba.chunks_exact(4) { + out.extend_from_slice(&px[..3]); + } + std::fs::write(path, out).expect("ecrire le ppm"); +} + +/// Un pixel du plan : tout ce qui n'est pas le fond bleu (ombre comprise, qui reste bleue). +fn is_plane(rgba: &[u8], x: u32, y: u32) -> bool { + let p = &rgba[((y * W + x) * 4) as usize..]; + !(p[2] as i32 > p[0] as i32 + 60) +} + +/// Pixels dont l'appartenance au plan diffère entre deux frames : la silhouette a bougé. Pas +/// d'égalité au pixel : deux frames de la source ne sont pas identiques après l'encodeur. +fn silhouette_diff(a: &[u8], b: &[u8]) -> usize { + (0..H) + .flat_map(|y| (0..W).map(move |x| (x, y))) + .filter(|&(x, y)| is_plane(a, x, y) != is_plane(b, x, y)) + .count() +} + +/// Hauteur du plan sur une colonne : le bord qui recule rétrécit. +fn plane_height_at(rgba: &[u8], x: u32) -> u32 { + (0..H).filter(|&y| is_plane(rgba, x, y)).count() as u32 +} + +/// Bord droit et bord gauche du plan sur la rangée du milieu. +fn left_and_right_edges(rgba: &[u8]) -> (u32, u32) { + let left = (0..W).find(|&x| is_plane(rgba, x, H / 2)).unwrap_or(0); + let right = (0..W).rev().find(|&x| is_plane(rgba, x, H / 2)).unwrap_or(0); + (left, right) +} + +#[test] +fn a_click_presses_the_clicked_side_of_the_tilted_plane() { + let Ok(source) = std::env::var("OPENSCREEN_TILT_SOURCE") else { + println!("SKIP: definir OPENSCREEN_TILT_SOURCE (voir l'en-tete du fichier)."); + return; + }; + let out_dir = std::env::var("OPENSCREEN_TILT_OUT").ok().map(std::path::PathBuf::from); + let sidecar_dir = out_dir.clone().unwrap_or_else(std::env::temp_dir); + std::fs::create_dir_all(&sidecar_dir).expect("creer le dossier de sortie"); + let sidecar = sidecar_dir.join(format!("click-impact-{}.cursor.json", std::process::id())); + write_sidecar(&sidecar); + let track = CursorTrack::load(sidecar.to_str().expect("chemin utf-8"), 0.0, 6.0).expect("piste"); + let _ = std::fs::remove_file(&sidecar); + + let gpu = Gpu::create(false).expect("device d3d11"); + let mut cfg = config::all().pop().expect("au moins une config"); + cfg.zoom = false; + cfg.layout_anim = false; + let comp = Compositor::new_sized(&gpu, W, H).expect("compositor"); + comp.set_cursor(track.smoothed(0.0)); + + let render = |click_impact: bool, t: f64| -> Vec { + let scene = Scene::from_json(&scene_json(&source, click_impact)).expect("scene valide"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + unsafe { + let mut player = Player::open(&source, "", &gpu).expect("ouvrir la source"); + player.present_frame(&comp, &cfg, t).expect("composer la frame"); + comp.readback_resized(W, H).expect("readback") + } + }; + + let instants = [ + ("rest-before", CLICK_S - 0.5), + ("contact", CLICK_S + 0.0495), + ("rebound", CLICK_S + 0.165), + ("rest-after", CLICK_S + 1.0), + ]; + let probe = |rgba: &[u8]| { + let (l, r) = left_and_right_edges(rgba); + // Colonnes fixes, à l'intérieur du plan à tous les instants (bords mesurés : ~430 et + // ~1660) : on compare la même tranche du plan d'une frame à l'autre. + (l, r, plane_height_at(rgba, 1600), plane_height_at(rgba, 480)) + }; + let mut frames = Vec::new(); + for (name, t) in instants { + let rgba = render(true, t); + if let Some(dir) = &out_dir { + write_ppm(&dir.join(format!("click-{name}.ppm")), &rgba); + } + let (l, r, hr, hl) = probe(&rgba); + println!("{name:<12} t={t:<6} bords {l:>4}..{r:>4} hauteur droite {hr:>4} gauche {hl:>4}"); + frames.push((rgba, (l, r, hr, hl))); + } + let off = render(false, CLICK_S + 0.0495); + if let Some(dir) = &out_dir { + write_ppm(&dir.join("click-off-at-contact.ppm"), &off); + } + + let rest = frames[0].1; + let contact = frames[1].1; + let (unchanged, settled, pressed) = ( + silhouette_diff(&off, &frames[0].0), + silhouette_diff(&frames[3].0, &frames[0].0), + silhouette_diff(&frames[1].0, &frames[0].0), + ); + println!("silhouette : option éteinte {unchanged} px, repos/repos {settled} px, contact {pressed} px"); + // Option éteinte : le clic ne change rien. Au repos après l'impact : la pose du préset. + assert_eq!(unchanged, 0, "sans l'option, le clic ne doit rien changer"); + assert_eq!(settled, 0, "le plan n'est pas revenu à sa pose"); + assert!(pressed > 1_000, "{pressed} px : le contact ne se voit pas"); + // Au contact, le bord droit (cliqué) recule : il se rapproche du centre et rétrécit, le + // bord gauche avance et grandit. + assert!(contact.1 < rest.1, "bord droit {} au repos {}", contact.1, rest.1); + assert!(contact.2 < rest.2, "hauteur droite {} au repos {}", contact.2, rest.2); + assert!(contact.3 > rest.3, "hauteur gauche {} au repos {}", contact.3, rest.3); +} diff --git a/src/components/ai-edition/v4/FloatingInspector.test.tsx b/src/components/ai-edition/v4/FloatingInspector.test.tsx index 39f644435..fdf8cd5d7 100644 --- a/src/components/ai-edition/v4/FloatingInspector.test.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.test.tsx @@ -27,6 +27,20 @@ vi.mock("../RightPanes", () => ({ VideoEffectsPane: () =>
VideoEffectsPane
, })); +const editorSettings = vi.hoisted(() => ({ cursorShow: true })); +vi.mock("@/lib/ai-edition/store/useEditorSettings", async (importOriginal) => { + const actual = await importOriginal(); + return { + useEditorSettings: () => { + const result = actual.useEditorSettings(); + return { + ...result, + settings: { ...result.settings, cursorShow: editorSettings.cursorShow }, + }; + }, + }; +}); + vi.mock("../CaptionsPane", () => ({ CaptionsPane: () =>
CaptionsPane
, })); @@ -86,4 +100,61 @@ describe("FloatingInspector", () => { fireEvent.click(closeBtn); expect(clearSelection).toHaveBeenCalledTimes(1); }); + + describe("click impact checkbox", () => { + const zoomTl = (region: Record) => { + const updateZoomClickImpact = vi.fn(); + const tl = { + ...defaultProps.tl, + selection: { kind: "zoom", id: "z" }, + zoomRegions: [ + { id: "z", startMs: 0, endMs: 1000, depth: 3, focus: { cx: 0.5, cy: 0.5 }, ...region }, + ], + updateZoomClickImpact, + } as unknown as React.ComponentProps["tl"]; + return { tl, updateZoomClickImpact }; + }; + + it("is off by default and disabled with its reason when there is no 3D preset", () => { + const { tl } = zoomTl({}); + render(); + const box = screen.getByRole("checkbox", { name: "settings.zoom.clickImpact.title" }); + expect(box).not.toBeChecked(); + expect(box).toBeDisabled(); + expect(screen.getByText("settings.zoom.clickImpact.needsRotation")).toBeInTheDocument(); + }); + + it("is disabled with its reason when the region hides the cursor", () => { + const { tl } = zoomTl({ rotationPreset: "iso", hideCursor: true }); + render(); + expect( + screen.getByRole("checkbox", { name: "settings.zoom.clickImpact.title" }), + ).toBeDisabled(); + expect(screen.getByText("settings.zoom.clickImpact.needsCursor")).toBeInTheDocument(); + }); + + it("is disabled with its reason when the cursor is hidden globally", () => { + editorSettings.cursorShow = false; + try { + const { tl } = zoomTl({ rotationPreset: "iso" }); + render(); + expect( + screen.getByRole("checkbox", { name: "settings.zoom.clickImpact.title" }), + ).toBeDisabled(); + expect(screen.getByText("settings.zoom.clickImpact.needsCursor")).toBeInTheDocument(); + } finally { + editorSettings.cursorShow = true; + } + }); + + it("toggles the region's clickImpact under a 3D preset", () => { + const { tl, updateZoomClickImpact } = zoomTl({ rotationPreset: "iso" }); + render(); + const box = screen.getByRole("checkbox", { name: "settings.zoom.clickImpact.title" }); + expect(box).toBeEnabled(); + expect(screen.getByText("settings.zoom.clickImpact.description")).toBeInTheDocument(); + fireEvent.click(box); + expect(updateZoomClickImpact).toHaveBeenCalledWith("z", true); + }); + }); }); diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx index 2a4a4904c..008d36ba5 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -303,6 +303,49 @@ function paneRow(label: string, control: React.ReactNode) { ); } +/** « Click impact » : une case à cocher, et dessous ce qu'elle fait — ou pourquoi elle ne peut + * rien faire ici. */ +function ClickImpactToggle({ + checked, + blocker, + label, + description, + onChange, +}: { + checked: boolean; + blocker: string | null; + label: string; + description: string; + onChange: (on: boolean) => void; +}) { + const disabled = blocker !== null; + return ( +
+ +

+ {blocker ?? description} +

+
+ ); +} + type AnnotationKind = AxcutAnnotationRegion["type"]; type ArrowDirectionKind = NonNullable["arrowDirection"]; @@ -559,6 +602,21 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void } , )} + void tl.updateZoomClickImpact(region.id, on)} + /> {paneRow( ts("zoom.focusMode.title"), // While the global toggle is on it OVERRIDES every region, so the control shows diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 1ba5192c7..9c610eda3 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -151,6 +151,20 @@ describe("projectPersistence media compatibility", () => { expect(editor.annotationRegions[1].blurData?.blockSize).toBe(4); }); + it("keeps clickImpact only when it is exactly true", () => { + const zoom = { startMs: 0, endMs: 1000, depth: 3 as const, focus: { cx: 0.5, cy: 0.5 } }; + const [on, off, junk] = normalizeProjectEditor({ + zoomRegions: [ + { ...zoom, id: "on", rotationPreset: "iso", clickImpact: true }, + { ...zoom, id: "off" }, + { ...zoom, id: "junk", clickImpact: "yes" as never }, + ], + }).zoomRegions; + expect(on.clickImpact).toBe(true); + expect("clickImpact" in off).toBe(false); + expect("clickImpact" in junk).toBe(false); + }); + it("accepts the dual frame webcam layout preset", () => { expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe( "dual-frame", diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 627402844..ccbb31567 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -271,6 +271,7 @@ export function normalizeProjectEditor(editor: Partial): Pro focusMode: region.focusMode === "auto" ? "auto" : "manual", source: region.source === "auto" ? "auto" : "manual", ...(validPreset ? { rotationPreset: validPreset } : {}), + ...(region.clickImpact === true ? { clickImpact: true as const } : {}), }; }) : []; diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 9922231a2..2a908c5b1 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -111,6 +111,8 @@ export interface ZoomRegion { source?: ZoomRegionSource; /** When true, cursor is hidden during this zoom region. */ hideCursor?: boolean; + /** When true, each click presses the tilted plane (needs `rotationPreset`). Omitted when off. */ + clickImpact?: true; } export function getRotation3D(region: Pick): Rotation3D { diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 26c9dcd3d..d8c252b74 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -332,6 +332,12 @@ "none": "بلا", "title": "دوران ثلاثي الأبعاد" }, + "clickImpact": { + "title": "أثر النقر", + "description": "كل نقرة تضغط الشاشة المائلة: ينخفض الجانب المنقور ثم يعود.", + "needsRotation": "اختر دورانًا ثلاثي الأبعاد أولًا: لا يمكن ضغط إلا شاشة مائلة.", + "needsCursor": "المؤشر مخفي: لا تتفاعل الشاشة إلا مع النقرات المرئية." + }, "level": "مستوى التكبير", "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير", "customScale": "تكبير مخصص", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index 447186de1..e15a6e168 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -332,6 +332,12 @@ "none": "Žádné", "title": "3D rotace" }, + "clickImpact": { + "title": "Odezva kliknutí", + "description": "Každé kliknutí zatlačí nakloněnou obrazovku: kliknutá strana se prohne a vrátí zpět.", + "needsRotation": "Nejprve zvolte 3D rotaci: zatlačit lze jen nakloněnou obrazovku.", + "needsCursor": "Kurzor je skrytý: obrazovka reaguje jen na viditelná kliknutí." + }, "level": "Úroveň přiblížení", "previewHold": "Podržte pro náhled efektu přiblížení", "customScale": "Vlastní přiblížení", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 8f2021ed5..893a15f4a 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -332,6 +332,12 @@ "none": "None", "title": "3D Rotation" }, + "clickImpact": { + "title": "Click impact", + "description": "Each click presses the tilted screen: the clicked side dips, then springs back.", + "needsRotation": "Pick a 3D rotation first: only a tilted screen can be pressed.", + "needsCursor": "The cursor is hidden: the screen only reacts to clicks you can see." + }, "level": "Zoom Level", "previewHold": "Hold to preview zoom effect", "customScale": "Custom Zoom", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index b416a152e..796c4a003 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -332,6 +332,12 @@ "none": "Ninguna", "title": "Rotación 3D" }, + "clickImpact": { + "title": "Impacto del clic", + "description": "Cada clic presiona la pantalla inclinada: el lado pulsado se hunde y luego vuelve.", + "needsRotation": "Elige primero una rotación 3D: solo se puede presionar una pantalla inclinada.", + "needsCursor": "El cursor está oculto: la pantalla solo reacciona a los clics visibles." + }, "level": "Nivel de zoom", "previewHold": "Mantener para previsualizar el efecto de zoom", "customScale": "Zoom personalizado", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index cca7f87df..cb39bca7a 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -332,6 +332,12 @@ "none": "Aucune", "title": "Rotation 3D" }, + "clickImpact": { + "title": "Impact du clic", + "description": "Chaque clic enfonce l'écran incliné : le côté cliqué s'abaisse, puis revient.", + "needsRotation": "Choisissez d'abord une rotation 3D : seul un écran incliné peut être enfoncé.", + "needsCursor": "Le curseur est masqué : l'écran ne réagit qu'aux clics visibles." + }, "level": "Niveau de zoom", "previewHold": "Maintenir pour prévisualiser l'effet de zoom", "customScale": "Zoom personnalisé", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index e17ac4cd9..914a94589 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -332,6 +332,12 @@ "none": "Nessuna", "title": "Rotazione 3D" }, + "clickImpact": { + "title": "Impatto del clic", + "description": "Ogni clic preme lo schermo inclinato: il lato cliccato si abbassa, poi torna.", + "needsRotation": "Scegli prima una rotazione 3D: si può premere solo uno schermo inclinato.", + "needsCursor": "Il cursore è nascosto: lo schermo reagisce solo ai clic visibili." + }, "level": "Livello zoom", "previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom", "customScale": "Zoom personalizzato", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 87ebd99bd..fa92b1a10 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -332,6 +332,12 @@ "none": "なし", "title": "3D回転" }, + "clickImpact": { + "title": "クリックの押し込み", + "description": "クリックするたびに傾いた画面が押され、クリックした側が沈んでから戻ります。", + "needsRotation": "先に3D回転を選んでください。押し込めるのは傾いた画面だけです。", + "needsCursor": "カーソルが非表示です。画面は見えているクリックにだけ反応します。" + }, "level": "ズーム倍率", "previewHold": "押している間ズーム効果をプレビュー", "customScale": "カスタムズーム", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index fbab36004..7efc0f368 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -332,6 +332,12 @@ "none": "없음", "title": "3D 회전" }, + "clickImpact": { + "title": "클릭 눌림", + "description": "클릭할 때마다 기울어진 화면이 눌려 클릭한 쪽이 내려갔다가 돌아옵니다.", + "needsRotation": "먼저 3D 회전을 선택하세요. 기울어진 화면만 누를 수 있습니다.", + "needsCursor": "커서가 숨겨져 있습니다. 화면은 보이는 클릭에만 반응합니다." + }, "level": "줌 레벨", "previewHold": "누르고 있으면 줌 효과 미리보기", "customScale": "커스텀 줌", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 32daeea43..a2254e20e 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -332,6 +332,12 @@ "none": "Nenhuma", "title": "Rotação 3D" }, + "clickImpact": { + "title": "Impacto do clique", + "description": "Cada clique pressiona a tela inclinada: o lado clicado afunda e depois volta.", + "needsRotation": "Escolha primeiro uma rotação 3D: só uma tela inclinada pode ser pressionada.", + "needsCursor": "O cursor está oculto: a tela só reage a cliques visíveis." + }, "level": "Nível de Zoom", "previewHold": "Mantenha pressionado para pré-visualizar o efeito de zoom", "customScale": "Zoom Personalizado", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 35cc24dfa..3552e62b3 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -332,6 +332,12 @@ "none": "Нет", "title": "3D вращение" }, + "clickImpact": { + "title": "Отклик на клик", + "description": "Каждый клик вдавливает наклонённый экран: сторона клика прогибается и возвращается.", + "needsRotation": "Сначала выберите 3D-поворот: вдавить можно только наклонённый экран.", + "needsCursor": "Курсор скрыт: экран реагирует только на видимые клики." + }, "level": "Уровень масштабирования", "previewHold": "Удерживайте для предпросмотра эффекта зума", "customScale": "Пользовательский масштаб", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index b6bf0f521..9ce904456 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -332,6 +332,12 @@ "none": "Yok", "title": "3D Döndürme" }, + "clickImpact": { + "title": "Tıklama etkisi", + "description": "Her tıklama eğik ekranı bastırır: tıklanan taraf çöker, sonra geri döner.", + "needsRotation": "Önce bir 3D döndürme seçin: yalnızca eğik bir ekran bastırılabilir.", + "needsCursor": "İmleç gizli: ekran yalnızca görünen tıklamalara tepki verir." + }, "level": "Yakınlaştırma Seviyesi", "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun", "customScale": "Özel Yakınlaştırma", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 43be7d8fd..8a2d7138a 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -332,6 +332,12 @@ "none": "Không", "title": "Xoay 3D" }, + "clickImpact": { + "title": "Hiệu ứng nhấp", + "description": "Mỗi lần nhấp sẽ ấn màn hình nghiêng: phía được nhấp lún xuống rồi bật lại.", + "needsRotation": "Hãy chọn xoay 3D trước: chỉ màn hình nghiêng mới ấn được.", + "needsCursor": "Con trỏ đang ẩn: màn hình chỉ phản ứng với các lần nhấp nhìn thấy được." + }, "level": "Mức độ thu phóng", "previewHold": "Giữ để xem trước hiệu ứng phóng to", "customScale": "Thu phóng tùy chỉnh", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 2c17ad141..bb59240f5 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -332,6 +332,12 @@ "none": "无", "title": "3D 旋转" }, + "clickImpact": { + "title": "点击按压", + "description": "每次点击都会按压倾斜的画面:被点击的一侧下沉,然后弹回。", + "needsRotation": "请先选择 3D 旋转:只有倾斜的画面才能被按压。", + "needsCursor": "光标已隐藏:画面只对可见的点击做出反应。" + }, "level": "缩放级别", "previewHold": "按住预览放大效果", "customScale": "自定义缩放", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index d4a2c5c12..59326e857 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -332,6 +332,12 @@ "none": "無", "title": "3D 旋轉" }, + "clickImpact": { + "title": "點擊按壓", + "description": "每次點擊都會按壓傾斜的畫面:被點擊的一側下沉,然後彈回。", + "needsRotation": "請先選擇 3D 旋轉:只有傾斜的畫面才能被按壓。", + "needsCursor": "游標已隱藏:畫面只對看得見的點擊做出反應。" + }, "level": "縮放級別", "previewHold": "按住預覽放大效果", "customScale": "自訂縮放", diff --git a/src/lib/ai-edition/document/migrate.test.ts b/src/lib/ai-edition/document/migrate.test.ts index da2f74c2b..efbc01379 100644 --- a/src/lib/ai-edition/document/migrate.test.ts +++ b/src/lib/ai-edition/document/migrate.test.ts @@ -112,6 +112,7 @@ describe("migrateProjectDataToAxcutDocument", () => { customScale: 2.5, source: "manual", hideCursor: true, + clickImpact: true, }, ], }, @@ -127,6 +128,7 @@ describe("migrateProjectDataToAxcutDocument", () => { expect(z.customScale).toBe(2.5); expect(z.rotationPreset).toBe("iso"); expect(z.hideCursor).toBe(true); + expect(z.clickImpact).toBe(true); }); it("converts annotationRegions to seconds with type and content preserved", () => { @@ -272,7 +274,9 @@ describe("migrateAxcutDocumentToProjectData", () => { depth: 4, focus: { cx: 0.5, cy: 0.5 }, hideCursor: true, + clickImpact: true, }, + { id: "z_2", startMs: 3000, endMs: 4000, depth: 2, focus: { cx: 0.5, cy: 0.5 } }, ], annotationRegions: [ { @@ -303,6 +307,9 @@ describe("migrateAxcutDocumentToProjectData", () => { expect(back.editor.zoomRegions[0].startMs).toBe(0); expect(back.editor.zoomRegions[0].endMs).toBe(2000); expect(back.editor.zoomRegions[0].hideCursor).toBe(true); + expect(back.editor.zoomRegions[0].clickImpact).toBe(true); + expect("clickImpact" in back.editor.zoomRegions[1]).toBe(false); + expect("clickImpact" in doc.zoomRanges[1]).toBe(false); expect(back.editor.annotationRegions[0].startMs).toBe(1000); expect(back.editor.annotationRegions[0].endMs).toBe(3000); }); diff --git a/src/lib/ai-edition/document/migrate.ts b/src/lib/ai-edition/document/migrate.ts index 1f7dcc088..ba6d7fda5 100644 --- a/src/lib/ai-edition/document/migrate.ts +++ b/src/lib/ai-edition/document/migrate.ts @@ -194,6 +194,7 @@ export function migrateProjectDataToAxcutDocument( ...(typeof region.customScale === "number" ? { customScale: region.customScale } : {}), ...(region.source === "auto" || region.source === "manual" ? { source: region.source } : {}), ...(region.hideCursor ? { hideCursor: true } : {}), + ...(region.clickImpact === true ? { clickImpact: true as const } : {}), })); const migratedAnnotations: AxcutAnnotationRegion[] = annotationRegions @@ -330,6 +331,7 @@ export function migrateAxcutDocumentToProjectData(input: AxcutDocument): EditorP ...(typeof region.customScale === "number" ? { customScale: region.customScale } : {}), ...(region.source ? { source: region.source } : {}), ...(region.hideCursor ? { hideCursor: true } : {}), + ...(region.clickImpact ? { clickImpact: true as const } : {}), })); editor.zoomRegions = reverseZoomRegions; diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts index 5f6afb7b2..f1b5cfbde 100644 --- a/src/lib/ai-edition/schema/index.ts +++ b/src/lib/ai-edition/schema/index.ts @@ -492,6 +492,9 @@ export const zoomRegionSchema = endGteStart( customScale: z.number().positive().optional(), source: z.enum(["auto", "manual"]).optional(), hideCursor: z.boolean().optional(), + /** Each click presses the tilted plane toward the clicked side. Only meaningful with a + * `rotationPreset`; omitted (never `false`) when off. */ + clickImpact: z.literal(true).optional(), }), "endMs", "startMs", diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts index eaf367edd..0d054eb83 100644 --- a/src/lib/ai-edition/store/documentWriteAudit.test.ts +++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts @@ -293,6 +293,7 @@ const DECLARED: WritePath[] = [ w("src/lib/ai-edition/store/useTimeline.ts", "updateSpeedSpan", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateSpeedValue", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateTrim", "save", "gesture"), + w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomClickImpact", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomDepth", "save", "gesture"), w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomFocusLive", "set", "automatic"), w("src/lib/ai-edition/store/useTimeline.ts", "updateZoomFocusMode", "save", "gesture"), diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 080cd83e6..430c2693b 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -812,6 +812,19 @@ describe("useTimeline zoom modifiers (rotation + focus mode)", () => { expect(useProjectStore.getState().document?.zoomRanges[0].hideCursor).toBeUndefined(); }); + it("updates clickImpact on a zoom region and drops the key when off", async () => { + const { result } = renderTimeline(); + await act(async () => { + await result.current.updateZoomClickImpact("zoom_a", true); + }); + expect(useProjectStore.getState().document?.zoomRanges[0].clickImpact).toBe(true); + + await act(async () => { + await result.current.updateZoomClickImpact("zoom_a", false); + }); + expect(useProjectStore.getState().document?.zoomRanges[0].clickImpact).toBeUndefined(); + }); + it("rolls a live focus edit back when its commit cannot be saved", async () => { bridgeMocks.save.mockResolvedValueOnce({ success: false, error: "project file locked" }); const { result } = renderTimeline(); diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index e262d81db..ef927e618 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -773,6 +773,22 @@ export function useTimeline() { [document, saveDocument], ); + // Per-region, like the preset it animates. `undefined` rather than `false` so the document + // keeps omitting the key when the option is off. + const updateZoomClickImpact = useCallback( + async (id: string, clickImpact: boolean) => { + if (!document) return; + const next: AxcutDocument = { + ...document, + zoomRanges: patchPillById(document.zoomRanges, id, { + clickImpact: clickImpact ? true : undefined, + }) as AxcutDocument["zoomRanges"], + }; + await saveDocument(next, { history: true }); + }, + [document, saveDocument], + ); + const updateAnnotationSpan = useCallback( async (id: string, startMs: number, endMs: number) => { if (!document) return; @@ -1522,6 +1538,7 @@ export function useTimeline() { updateZoomRotation, updateZoomFocusMode, updateZoomHideCursor, + updateZoomClickImpact, updateAnnotationSpan, updateAnnotationLive, commitAnnotationChange, diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index a581b5439..26bc6e986 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -529,6 +529,26 @@ describe("buildSceneDescription.zoomRegions", () => { expect(zoomRegions[0].hideCursor).toBe(true); }); + it("emits clickImpact only when it is on", () => { + const base = { + startMs: 0, + endMs: 1000, + depth: 3 as const, + focus: { cx: 0.5, cy: 0.5 }, + rotationPreset: "iso" as const, + }; + const doc = makeDoc({ + zoomRanges: [ + makeZoom({ ...base, id: "on", clickImpact: true }), + makeZoom({ ...base, id: "off" }), + ], + }); + const { zoomRegions } = buildSceneDescription(doc); + expect(zoomRegions[0].clickImpact).toBe(true); + // Omitted, not `false`: scene payloads without the option stay byte-identical. + expect("clickImpact" in zoomRegions[1]).toBe(false); + }); + it("converts ms→sec for start/end", () => { const z = makeZoom({ id: "z", diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index 4fdbeae76..945d93e20 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -92,6 +92,9 @@ export interface SceneZoomRegion { underTrim?: boolean; /** When true, cursor is hidden during this zoom region. */ hideCursor?: boolean; + /** Each click presses the tilted plane toward the clicked side (`regions::click_impact`). + * Native ignores it without a `rotation`. Omitted (not `false`) when off. */ + clickImpact?: true; } /** A "Full Camera" timeline region (from `legacyEditor.cameraFullscreenRegions`). Times in seconds. */ @@ -1071,6 +1074,7 @@ export function buildSceneDescription( clipIndex: region.clipIndex, ...(region.underTrim ? { underTrim: true } : {}), ...(region.hideCursor ? { hideCursor: true } : {}), + ...(region.clickImpact ? { clickImpact: true as const } : {}), })), annotations: projectedAnnotations .map((region) => {