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