diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index e3176ad8c..17f736d0e 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -2241,12 +2241,13 @@ impl Compositor { // Calque ecran : mode 0 (rect droit, NV12 -> RGB) quand la rotation est // neutre, mode 8 (warp bilineaire inverse dans la bbox du quad projete) // sinon. Place par plan_frame (cover-fit + coins arrondis) ; - // `src = g.cut` (crop utilisateur + zoom en UV texture) dans les deux cas. + // `src = g.screen_src(g.cut)` (crop utilisateur + zoom en UV texture, bornes + // inversees pour un clip tourne) dans les deux cas. // // FLOU DE VELOCITE. Au mode 0, `src_prev`/`dst_prev` decrivent le MEME // calque a la frame precedente ; le shader remappe chaque pixel de sortie // par ce couple pour retrouver l'UV qu'il occupait alors, et floute le long - // du segment. `src_prev = g.cut` et non un `cut` d'avant : la coupe est + // du segment. `src_prev = g.screen_src(g.cut)` et non un `cut` d'avant : la coupe est // identique aux deux frames (`plan_frame` ne fait varier que le rect de // DESTINATION entre `s_dst` et `s_dst_prev`), ce que Windows documente // aussi. Le mouvement vient donc entierement de `dst_prev`. @@ -2263,11 +2264,13 @@ impl Compositor { // Et la remontee du contour interieur du chrome au-dessus de l'ecran (`mb.z` au mode 0, // `color.z` au mode 8) : l'arrondi du haut se fait par le cadre. 0 sans fenetre. let top_lift = g.screen_top_lift_px([rw, rh]); + // The screen texture's source rect: a turned clip samples it with reversed bounds. + let src_uv = g.screen_src(g.cut); let screen_layer = match tilt.as_ref() { None => { // Sous le masque d'un layout en bloc, rogne au slot (`mask_flat_screen`). let (dst, src, quad_px, radius_px) = - g.mask_flat_screen(g.s_dst, g.cut, s_px, g.s_radius, [rw, rh]); + g.mask_flat_screen(g.s_dst, src_uv, s_px, g.s_radius, [rw, rh]); LayerCB { dst, src, @@ -2275,7 +2278,7 @@ impl Compositor { radius_px, mode: 0.0, color: [1.0, 1.0, 1.0, 1.0], - src_prev: g.cut, + src_prev: src_uv, dst_prev: g.s_dst_prev, mb: [g.screen_pixel_taps([rw, rh]), g.mb_amount, top_lift, square_top], ..Default::default() @@ -2286,7 +2289,7 @@ impl Compositor { quad, s_px, quad_center_px, - g.cut, + src_uv, g.focus_plane, g.s_radius, top_lift, diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 07ea2dd7c..4280ef26b 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -2489,7 +2489,8 @@ impl Compositor { } let square_top = g.screen_square_top(); let top_lift = g.screen_top_lift_px([rw, rh]); - let [su0, sv0, su1, sv1] = g.cut; + // The screen texture's source rect: a turned clip samples it with reversed bounds. + let [su0, sv0, su1, sv1] = g.screen_src(g.cut); // Sous le masque d'un layout en bloc, rogné au slot (`FrameGeometry::mask_flat_screen`). let (dst, src, quad_px, radius_px) = g.mask_flat_screen(g.s_dst, [su0, sv0, su1, sv1], s_px, g.s_radius, [rw, rh]); @@ -2516,7 +2517,7 @@ impl Compositor { quad, s_px, quad_center_px, - g.cut, + g.screen_src(g.cut), g.focus_plane, g.s_radius, top_lift, diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 26dc8f67b..17fb18055 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -2038,7 +2038,8 @@ impl Compositor { let mb_amount = g.mb_amount; let source_t = g.source_t; let _padding_scale = g.padding_scale; - let cut = g.cut; + // The screen texture's source rect: a turned clip samples it with reversed bounds. + let cut = g.screen_src(g.cut); let s_dst = g.s_dst; let s_dst_prev = g.s_dst_prev; let s_ann = g.s_ann; @@ -2140,7 +2141,7 @@ impl Compositor { self.draw_video( &LayerCB { dst: [-over, -over, 1.0 + 2.0 * over, 1.0 + 2.0 * over], - src: [0.0, 0.0, u_max, v_max], + src: g.screen_src([0.0, 0.0, u_max, v_max]), quad_px: [self.rw(), self.rh()], mode: 0.0, color: [1.0, 1.0, 1.0, 1.0], diff --git a/crates/compositor/src/cursor.rs b/crates/compositor/src/cursor.rs index d9c104a82..b1bac4fb2 100644 --- a/crates/compositor/src/cursor.rs +++ b/crates/compositor/src/cursor.rs @@ -164,6 +164,23 @@ impl CursorTrack { } } + /// The same track seen on a picture turned by 180°: every position mirrored, times, + /// clicks, visibility and cursor types unchanged. A turned clip is planned entirely in + /// what the viewer sees, so its telemetry is turned once here rather than at every reader. + pub fn turned(&self) -> CursorTrack { + let flip = + |v: &[(f32, f32, f32)]| v.iter().map(|&(t, x, y)| (t, 1.0 - x, 1.0 - y)).collect(); + CursorTrack { + samples: flip(&self.samples), + follow_samples: flip(&self.follow_samples), + clicks: self.clicks.clone(), + click_points: self.click_points.iter().map(|&(x, y)| (1.0 - x, 1.0 - y)).collect(), + visibility: self.visibility.clone(), + types: self.types.clone(), + view_cache: Default::default(), + } + } + /// Visibilité enregistrée au temps `t`. Les anciens sidecars qui n'ont pas ce champ sont /// visibles par défaut, comme les lecteurs TypeScript. pub fn visible_at(&self, t: f32) -> bool { @@ -661,6 +678,26 @@ fn cursor_spring_config(smoothing_factor: f32) -> (f32, f32, f32) { mod tests { use super::*; + #[test] + fn turned_track_mirrors_positions_and_keeps_times() { + let t = CursorTrack::new( + vec![(0.0, 0.2, 0.3), (1.0, 0.6, 0.9)], + vec![0.5], + vec![(0.0, "arrow".to_string())], + ); + let r = t.turned(); + let (x, y) = r.at(0.0).unwrap(); + assert!((x - 0.8).abs() < 1e-6 && (y - 0.7).abs() < 1e-6, "({x}, {y})"); + assert_eq!(r.clicks_between(0.0, 1.0), t.clicks_between(0.0, 1.0)); + assert_eq!(r.type_at(0.0), t.type_at(0.0)); + let (fx, fy) = r.follow_at(1.0).unwrap(); + let (gx, gy) = t.follow_at(1.0).unwrap(); + assert!((fx - (1.0 - gx)).abs() < 1e-5 && (fy - (1.0 - gy)).abs() < 1e-5); + // The click point turns with the picture: the click at 0.5 s was at (0.4, 0.6). + let (px, py) = r.click_points[0]; + assert!((px - 0.6).abs() < 1e-6 && (py - 0.4).abs() < 1e-6, "({px}, {py})"); + } + /// Zone morte du suivi auto : un pointeur qui tremble au centre de la vue ne la bouge pas ; /// un pointeur qui part loin est rattrapé, jusqu'au bord de la zone et pas au-delà. /// Les vues d'une région sont rejouées une fois puis relues : même réponse dans n'importe quel diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 0decdb21f..0281e64bf 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -1972,6 +1972,11 @@ pub struct FrameGeometry { /// Layouts en bloc : le slot de l'écran, un conteneur qui le MASQUE (cf. `ScreenMask`). /// `None` ailleurs, et le rendu est celui d'avant, à l'octet. pub screen_mask: Option, + /// The clip being composed is turned by 180°. Everything above is planned in display + /// space; only the screen layer's texture sampling turns, through `screen_src`. + pub screen_turn: bool, + /// `[u_max, v_max]` of the screen texture: the valid area `screen_src` mirrors within. + pub screen_uv_max: [f32; 2], } /// Le conteneur d'un layout en bloc (côte à côte, haut/bas) : un masque fixe, overflow hidden. @@ -2053,6 +2058,18 @@ pub fn annotation_dst_in(anchor: [f32; 4], x: f32, y: f32, w: f32, h: f32) -> [f } impl FrameGeometry { + /// The texture rect to sample for a display-space screen rect. Upright: unchanged. + /// Turned: mirrored inside the valid area, which reverses both bounds — the shaders + /// interpolate linearly from `src.xy` to `src.zw`, so that IS the half turn. `cut` and + /// everything planned from it stay in display space. + pub fn screen_src(&self, uv: [f32; 4]) -> [f32; 4] { + if !self.screen_turn { + return uv; + } + let [um, vm] = self.screen_uv_max; + [um - uv[0], vm - uv[1], um - uv[2], vm - uv[3]] + } + /// Le quad de l'écran incliné pour une boîte de `s_px` px, `None` quand l'écran est droit. /// LE point de passage de l'écran, de son ombre, du curseur et du masque de flou : tous /// doivent porter la même séparation base / dynamique, sinon ils se décollent. @@ -2222,7 +2239,8 @@ impl FrameGeometry { let s_px = [self.s_dst[2] * render_px[0], self.s_dst[3] * render_px[1]]; let flat = LayerCB { dst: [0.0, 0.0, 1.0, 1.0], - src: self.cut, + // Reread from the screen texture: a turned clip samples it with reversed bounds. + src: self.screen_src(self.cut), quad_px: s_px, radius_px: self.s_radius, mode: 18.0, @@ -3068,9 +3086,19 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // focus auto) : un plan ne bouge que sous un pointeur qu'on voit. Preview et export // chargent la piste dans tous les cas (le focus auto la suit), cette porte fait le reste. let parallax_track = cursor_for_zoom.filter(|_| scene.is_some_and(|s| s.cursor.show)); - let active_crop = scene.and_then(|scene| { - scene.crop_by_clip.get(scene.active_clip_index).copied().flatten() - }); + // A turned clip is planned in what the viewer sees: its crop (stored in source + // coordinates) is mirrored here, its cursor track is mirrored where it is loaded, and + // only the screen layer's sampling maps back to the texture (`screen_src`). + let screen_turn = scene.is_some_and(|s| s.screen_turned()); + let active_crop = scene + .and_then(|scene| scene.crop_by_clip.get(scene.active_clip_index).copied().flatten()) + .map(|c| { + if screen_turn { + SceneCrop { x: 1.0 - c.x - c.width, y: 1.0 - c.y - c.height, ..c } + } else { + c + } + }); if !zoom_regions.is_empty() { // L'orbite en focus auto lit le curseur dans l'image SOURCE recadrée — pas dans la // coupe zoomée, qu'un focus auto recentre sur lui — et ignore ce qu'une coupe retire. @@ -3562,6 +3590,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { shape_fade, window_frame, screen_mask, + screen_turn, + screen_uv_max: [u_max, v_max], } } @@ -6502,6 +6532,124 @@ mod tests { } } + /// A single-clip scene at zoom 1 with the cursor shown, its clip `rotation` (0 or 180) + /// and its crop (`null` or a crop object, verbatim JSON). + fn turn_scene(rotation: u16, crop: &str) -> Scene { + let mut scene = Scene::from_json(&format!( + r##"{{ + "clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], + "layout":{{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null, + "webcamReactiveZoom":false}}, + "effects":{{"padding":0.5,"blur":false,"shadow":0.5,"roundnessFrac":0.02,"motionBlur":0}}, + "background":{{"kind":"color","color":"#1e1e2e"}}, + "zoomRegions":[], + "cursor":{{"show":true,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":1,"clipToBounds":false,"theme":"default"}}, + "cropByClip":[{crop}], + "output":{{"width":1920,"height":1080,"fps":60}} + }}"## + )) + .expect("turn scene"); + scene.rotation_by_clip = vec![rotation]; + scene + } + + #[test] + fn upright_clip_keeps_screen_src_identity() { + let g = framed_plan(&turn_scene(0, "null")); + assert!(!g.screen_turn); + assert_eq!(g.screen_src(g.cut), g.cut); + // An absent field is upright too. + let mut absent = turn_scene(0, "null"); + absent.rotation_by_clip.clear(); + let g = framed_plan(&absent); + assert!(!g.screen_turn); + assert_eq!(g.screen_src(g.cut), g.cut); + } + + #[test] + fn turned_clip_reverses_the_source_bounds_inside_the_valid_area() { + let g = framed_plan(&turn_scene(180, "null")); + assert!(g.screen_turn); + assert_eq!(g.screen_uv_max, [1920.0 / 1920.0, 1080.0 / 1088.0]); + // The display-space cut is the upright one: only the sampling turns. + assert_eq!(g.cut, framed_plan(&turn_scene(0, "null")).cut); + let [a0, b0, a1, b1] = g.screen_src(g.cut); + assert!(a0 > a1 && b0 > b1, "bounds reversed: {:?}", [a0, b0, a1, b1]); + for v in [a0, a1] { + assert!((0.0..=g.screen_uv_max[0]).contains(&v), "u {v}"); + } + for v in [b0, b1] { + assert!((0.0..=g.screen_uv_max[1]).contains(&v), "v {v}"); + } + // Full frame: the whole valid area, corner to opposite corner. + assert!((a0 - 1.0).abs() < 1e-6 && a1.abs() < 1e-6, "u {a0}..{a1}"); + assert!((b0 - 1080.0 / 1088.0).abs() < 1e-6 && b1.abs() < 1e-6, "v {b0}..{b1}"); + } + + #[test] + fn turned_crop_selects_the_same_source_pixels() { + let crop = r#"{"x":0.1,"y":0.2,"width":0.5,"height":0.3}"#; + let g = framed_plan(&turn_scene(180, crop)); + // Planned in display space: the crop is mirrored, x 0.4..0.9, y 0.5..0.8. + let (um, vm) = (g.screen_uv_max[0], g.screen_uv_max[1]); + assert!((g.cut[0] / um - 0.4).abs() < 1e-5 && (g.cut[2] / um - 0.9).abs() < 1e-5, "cut {:?}", g.cut); + assert!((g.cut[1] / vm - 0.5).abs() < 1e-5 && (g.cut[3] / vm - 0.8).abs() < 1e-5, "cut {:?}", g.cut); + // ...and the texture rect sampled is exactly the stored crop, reversed. + let [a0, b0, a1, b1] = g.screen_src(g.cut); + assert!((a1 / um - 0.1).abs() < 1e-5 && (a0 / um - 0.6).abs() < 1e-5, "u {a0}..{a1}"); + assert!((b1 / vm - 0.2).abs() < 1e-5 && (b0 / vm - 0.5).abs() < 1e-5, "v {b0}..{b1}"); + // The screen box keeps the crop's shape either way. + assert_eq!(g.s_dst, framed_plan(&turn_scene(0, crop)).s_dst); + } + + #[test] + fn screen_trail_rereads_a_turned_clip_turned() { + let g = framed_plan(&turn_scene(180, "null")); + assert_eq!(g.screen_trail_cb(RENDER, false).src, g.screen_src(g.cut)); + let upright = framed_plan(&turn_scene(0, "null")); + assert_eq!(upright.screen_trail_cb(RENDER, false).src, upright.cut); + } + + #[test] + fn cursor_of_a_turned_clip_lands_on_the_turned_content() { + let cfg = crate::config::all().pop().expect("au moins une config"); + let center = |rotation: u16, track: &'static crate::cursor::CursorTrack| -> ([f32; 2], [f32; 4]) { + let scene = turn_scene(rotation, "null"); + let g = framed_plan(&scene); + let mut live = live_params_from_scene(&scene); + live.cursor_model3d = false; + let plan = plan_cursor( + &g, + &CursorPlanInput { + render_px: RENDER, + u_max: 1.0, + v_max: 1080.0 / 1088.0, + cfg: &cfg, + live, + scene: Some(&scene), + track, + t: 1.5, + }, + ) + .expect("cursor in view"); + match plan.placement { + CursorPlacement::Upright { center } => (center, g.s_dst), + _ => panic!("upright screen, flat sprite"), + } + }; + let (x, y) = (0.2f32, 0.3f32); + // Upright: the cursor sits at (x, y) of the screen box. + let (c, d) = center(0, parked_track(x, y)); + let want = [d[0] + x * d[2], d[1] + y * d[3]]; + assert!((c[0] - want[0]).abs() < 1e-5 && (c[1] - want[1]).abs() < 1e-5, "{c:?} vs {want:?}"); + // Turned: the track is turned where it is loaded, so the cursor sits where the turned + // picture shows that source point, (1 - x, 1 - y) of the screen box. + let turned: &'static crate::cursor::CursorTrack = Box::leak(Box::new(parked_track(x, y).turned())); + let (c, d) = center(180, turned); + let want = [d[0] + (1.0 - x) * d[2], d[1] + (1.0 - y) * d[3]]; + assert!((c[0] - want[0]).abs() < 1e-5 && (c[1] - want[1]).abs() < 1e-5, "{c:?} vs {want:?}"); + } + /// Le curseur est sur le métrage : le slot le rogne aussi. #[test] fn the_cursor_is_clipped_to_the_slot() { @@ -7793,6 +7941,8 @@ mod tests { shape_fade: 0.0, window_frame: None, screen_mask: None, + screen_turn: false, + screen_uv_max: [1.0, 1.0], } } diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 16c75bd9e..3229ff498 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -810,6 +810,61 @@ fn resolve_scene_clip_index( } } +/// The cache key of a clip's cursor track. A turned clip uses the same telemetry file +/// mirrored (`CursorTrack::turned`), so the two must never share a cache entry. +pub(crate) fn cursor_key(screen_path: &str, turned: bool) -> String { + let path = format!("{screen_path}.cursor.json"); + if turned { format!("{path}#turned") } else { path } +} + +/// A clip's cursor track as the compositor reads it: mirrored for a turned clip, whose frame is +/// planned in what the viewer sees. +fn oriented_cursor(track: CursorTrack, turned: bool) -> CursorTrack { + if turned { track.turned() } else { track } +} + +/// Rereads the active clip's cursor telemetry when its key changed (another file, or the same +/// file turned the other way) and hands it to the compositor already smoothed, so a paused +/// preview never shows one unsmoothed frame. Returns whether it reread. +fn reload_cursor_if_changed( + comp: &Compositor, + raw_cursor: &mut Option, + loaded_cursor_path: &mut String, + screen_path: &str, + turned: bool, + smoothing: f32, +) -> bool { + let key = cursor_key(screen_path, turned); + if key == *loaded_cursor_path { + return false; + } + let cursor_path = format!("{screen_path}.cursor.json"); + *raw_cursor = CursorTrack::load(&cursor_path, 0.0, 24.0 * 3600.0) + .ok() + .map(|t| oriented_cursor(t, turned)); + // Journalisé ICI seulement : sinon la ligne annonce « loaded=ok » à + // chaque changement de segment alors que rien n'a été relu, et le log + // laisse croire à un travail qui n'a plus lieu. + match raw_cursor { + Some(track) => eprintln!( + "[live] cursor: path={} turned={} loaded=ok samples={}", + cursor_path, + turned, + track.sample_count(), + ), + None => eprintln!("[live] cursor: path={} loaded=FAIL — clear_cursor()", cursor_path), + } + *loaded_cursor_path = key; + // Sans relecture, rien à reposer : un changement de taille garde le + // curseur (`Compositor::resized`), et le relisser parcourt tout + // l'enregistrement à chaque bascule d'un scrub. + match raw_cursor { + Some(track) => comp.set_cursor(track.smoothed(smoothing)), + None => comp.clear_cursor(), + } + true +} + fn scene_for_clip(scene: &Scene, clip_index: usize) -> Scene { match scene.clips.get(clip_index) { Some(clip) => scene.for_clip_window( @@ -1428,20 +1483,24 @@ unsafe fn advance_to_next_scene_clip( *active_webcam_path = next_clip.webcam_path.clone(); *active_webcam_offset_sec = next_clip.webcam_offset_sec; *active_clip_index = next_index; - comp.set_scene(Some(scene_for_clip(scene, *active_clip_index))); + let clip_scene = scene_for_clip(scene, *active_clip_index); + let turned = clip_scene.screen_turned(); + comp.set_scene(Some(clip_scene)); player.set_programme_clock(Some(scene), *active_clip_index); // Réutilise le curseur préchargé s'il est disponible (voir plus haut) — sinon // (préchargement pas encore prêt / raté) on retombe sur la lecture synchrone - // habituelle, comme avant cette optimisation. + // habituelle, comme avant cette optimisation. The prefetched track is the raw + // file: it is turned here, where the clip it belongs to is known. let cursor_path = format!("{}.cursor.json", active_screen_path); *raw_cursor = match prefetched_cursor { Some(track) => track, None => CursorTrack::load(&cursor_path, 0.0, 24.0 * 3600.0).ok(), - }; + } + .map(|t| oriented_cursor(t, turned)); // Le fichier est noté avec la piste. Sinon une requête de clip qui revient sur le // fichier d'avant le croit encore chargé, ne relit rien, et dessine sur ce clip la // télémétrie de celui qu'on vient de quitter. - *loaded_cursor_path = cursor_path; + *loaded_cursor_path = cursor_key(active_screen_path, turned); match raw_cursor { Some(track) => comp.set_cursor(track.smoothed(0.0)), None => comp.clear_cursor(), @@ -1497,7 +1556,8 @@ unsafe fn render_thread( // fichier à chaque changement du slider "smoothing" (voir la boucle plus bas). let mut raw_cursor = CursorTrack::load(cursor_json, 0.0, 24.0 * 3600.0).ok(); /// Chemin de la télémétrie curseur actuellement chargée dans `raw_cursor` — évite de - /// relire le même fichier à chaque changement de segment (voir plus bas). + /// relire le même fichier à chaque changement de segment (voir plus bas). Compared with + /// `cursor_key`: no scene has arrived yet, so this first track is upright. let mut loaded_cursor_path = cursor_json.to_string(); if let Some(track) = &raw_cursor { comp.set_cursor(track.smoothed(0.0)); @@ -1623,6 +1683,7 @@ unsafe fn render_thread( active_webcam_offset_sec = request.webcam_offset_sec; let scene = shared.scene.lock().unwrap().clone(); full_scene = scene.clone(); + let mut turned = false; if let Some(base_scene) = scene { if let Some(index) = resolve_scene_clip_index( &base_scene, @@ -1638,7 +1699,9 @@ unsafe fn render_thread( active_screen_path, active_webcam_path ); } - comp.set_scene(Some(scene_for_clip(&base_scene, active_clip_index))); + let clip_scene = scene_for_clip(&base_scene, active_clip_index); + turned = clip_scene.screen_turned(); + comp.set_scene(Some(clip_scene)); scene_applied = true; } // Relire la télémétrie curseur seulement si le FICHIER change. Elle était @@ -1646,32 +1709,16 @@ unsafe fn render_thread( // compris quand seul le segment changeait, où le chemin est par // construction identique. Mesuré : 66 bascules sur un scrub de deux clips, // donc 66 relectures du même fichier. - let cursor_path = format!("{}.cursor.json", active_screen_path); - if cursor_path != loaded_cursor_path { - loaded_cursor_path = cursor_path.clone(); - raw_cursor = CursorTrack::load(&cursor_path, 0.0, 24.0 * 3600.0).ok(); - // Journalisé ICI seulement : sinon la ligne annonce « loaded=ok » à - // chaque changement de segment alors que rien n'a été relu, et le log - // laisse croire à un travail qui n'a plus lieu. - match &raw_cursor { - Some(track) => eprintln!( - "[live] cursor: path={} loaded=ok samples={}", - cursor_path, - track.sample_count(), - ), - None => eprintln!( - "[live] cursor: path={} loaded=FAIL — clear_cursor()", - cursor_path, - ), - } - // Sans relecture, rien à reposer : un changement de taille garde le - // curseur (`Compositor::resized`), et le relisser parcourt tout - // l'enregistrement à chaque bascule d'un scrub. - match &raw_cursor { - Some(track) => comp.set_cursor(track.smoothed(0.0)), - None => comp.clear_cursor(), - } - last_smoothing = -1.0; + // The key also carries the clip's turn: the same file turned is another track. + if reload_cursor_if_changed( + &comp, + &mut raw_cursor, + &mut loaded_cursor_path, + &active_screen_path, + turned, + ip.cursor_smoothing, + ) { + last_smoothing = ip.cursor_smoothing; } clip_changed = true; } @@ -1741,7 +1788,20 @@ unsafe fn render_thread( } scene_for_clip(&base_scene, active_clip_index) }); + // Turning the active clip changes no file, only the scene: its track is reread + // here, mirrored the new way. + let turned = scene.as_ref().is_some_and(Scene::screen_turned); comp.set_scene(scene); + if reload_cursor_if_changed( + &comp, + &mut raw_cursor, + &mut loaded_cursor_path, + &active_screen_path, + turned, + ip.cursor_smoothing, + ) { + last_smoothing = ip.cursor_smoothing; + } } // Le temps programme dépend du clip actif ET de la scène entière (durées des clips // précédents) : à recalculer dès que l'un des deux change. @@ -2255,9 +2315,15 @@ pub fn run_standalone(_screen: &str, _webcam: &str, _cursor_json: &str) -> anyho #[cfg(test)] mod tests { - use super::{open_settle_window, should_settle, SETTLE_STEP, SETTLE_WINDOW}; + use super::{cursor_key, open_settle_window, should_settle, SETTLE_STEP, SETTLE_WINDOW}; use std::time::Instant; + #[test] + fn cursor_key_tells_a_turned_clip_from_an_upright_one() { + assert_eq!(cursor_key("a.mp4", false), "a.mp4.cursor.json"); + assert_ne!(cursor_key("a.mp4", false), cursor_key("a.mp4", true)); + } + /// Le bug d'origine : en pause, un seul recompose par changement, donc le masque de /// segmentation (asynchrone, 3 composes de latence) n'arrivait jamais avant un scrub. #[test] diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index 895fa6b2a..a8237c978 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -795,6 +795,11 @@ pub struct Scene { /// Crop écran par clip, dans le même ordre que `clips` (`cropByClip` côté TS). #[serde(default)] pub crop_by_clip: Vec>, + /// Half turn of each clip's main picture, same order as `clips` (`rotationByClip` on + /// the TS side). Values are 0 or 180; any other u16 is treated as upright. Absent in every + /// scene written before it. + #[serde(default)] + pub rotation_by_clip: Vec, /// Effet d'arrière-plan de la webcam. Absent = aucun effet. #[serde(default)] pub webcam_effect: Option, @@ -810,6 +815,11 @@ impl Scene { Ok(serde_json::from_str(json)?) } + /// Whether the clip being composed is turned by 180°. + pub fn screen_turned(&self) -> bool { + self.rotation_by_clip.get(self.active_clip_index) == Some(&180) + } + /// Copie de scène limitée aux régions du clip actif. `clipIndex` est l'identité fiable /// lorsque plusieurs clips réutilisent les mêmes temps source ; son absence retombe sur le /// chevauchement avec la fenêtre source pour accepter les anciens payloads. @@ -1212,6 +1222,25 @@ mod annotation_tests { ); } + #[test] + fn rotation_by_clip_defaults_empty_and_reads_the_active_clip() { + let scene = Scene::from_json(&scene_json("[]")).expect("parse"); + assert!(scene.rotation_by_clip.is_empty()); + assert!(!scene.screen_turned()); + let mut turned = scene.clone(); + turned.rotation_by_clip = vec![0, 180]; + assert!(!turned.for_clip_window(0, 0.0, 1.0).screen_turned()); + assert!(turned.for_clip_window(1, 0.0, 1.0).screen_turned()); + // Only a half turn counts; anything else stays upright. + turned.rotation_by_clip = vec![90]; + assert!(!turned.for_clip_window(0, 0.0, 1.0).screen_turned()); + // The field arrives from the app as `rotationByClip`. + let json = scene_json("[]").replace(r#""cropByClip":[]"#, r#""cropByClip":[],"rotationByClip":[180]"#); + let parsed = Scene::from_json(&json).expect("parse rotationByClip"); + assert_eq!(parsed.rotation_by_clip, vec![180]); + assert!(parsed.screen_turned()); + } + #[test] fn for_clip_window_still_falls_back_to_window_overlap_without_a_clip_index() { // Vieux payload : rien ne dit à quel clip la région appartient, le chevauchement de diff --git a/crates/compositor/src/timeline_walk.rs b/crates/compositor/src/timeline_walk.rs index a16271c8b..d5dab6617 100644 --- a/crates/compositor/src/timeline_walk.rs +++ b/crates/compositor/src/timeline_walk.rs @@ -19,6 +19,7 @@ use crate::cursor::CursorTrack; use crate::d3d::Gpu; use crate::ffi::AVFrame; use crate::frame_geometry::webcam_is_real; +use crate::live::cursor_key; use crate::pipeline::{ClipSource, Decoder}; use crate::regions::{speed_segments_for_window, SpeedSegment}; use crate::scene::Scene; @@ -268,6 +269,8 @@ pub(crate) unsafe fn walk_composited_timeline( source_end_sec, out_fps as f64, ); + // A turned clip is planned in what the viewer sees: its cursor track is the mirrored one. + let turned = clip_scene.as_ref().is_some_and(|s| s.screen_turned()); if clip_scene.is_some() { comp.set_scene(clip_scene); } @@ -285,18 +288,21 @@ pub(crate) unsafe fn walk_composited_timeline( } if cursor_enabled { - if !cursor_tracks.contains_key(&clip.screen) { + // Keyed by file AND turn: the same file turned is another track. + let key = cursor_key(&clip.screen, turned); + if !cursor_tracks.contains_key(&key) { let path = format!("{}.cursor.json", clip.screen); if let Ok(raw) = CursorTrack::load(&path, 0.0, 24.0 * 3600.0) { - cursor_tracks.insert(clip.screen.clone(), raw.smoothed(cursor_smoothing)); + let raw = if turned { raw.turned() } else { raw }; + cursor_tracks.insert(key.clone(), raw.smoothed(cursor_smoothing)); } // absente/illisible → pas d'entrée : ce clip s'exporte sans curseur (visible, // pas masqué en un curseur fantôme d'un autre clip). } - if cursor_active_path.as_deref() != Some(clip.screen.as_str()) { - if let Some(track) = cursor_tracks.get(&clip.screen) { + if cursor_active_path.as_deref() != Some(key.as_str()) { + if let Some(track) = cursor_tracks.get(&key) { comp.set_cursor(track.clone()); - cursor_active_path = Some(clip.screen.clone()); + cursor_active_path = Some(key); } else { comp.clear_cursor(); comp.set_cursor_time(None); diff --git a/src/components/ai-edition/EditClipModal.test.tsx b/src/components/ai-edition/EditClipModal.test.tsx index 9d5c5ad07..b687d9ba2 100644 --- a/src/components/ai-edition/EditClipModal.test.tsx +++ b/src/components/ai-edition/EditClipModal.test.tsx @@ -1,10 +1,10 @@ // @vitest-environment jsdom import "@testing-library/jest-dom"; -import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import type { ReactElement } from "react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/contexts/I18nContext"; -import type { AxcutClip } from "@/lib/ai-edition/schema"; +import { type AxcutClip, clipCropRegionSchema } from "@/lib/ai-edition/schema"; import { EditClipModal } from "./Modals"; function renderWithI18n(ui: ReactElement) { @@ -232,6 +232,7 @@ describe("EditClipModal crop from the keyboard", () => { 20, 105, expect.objectContaining({ x: 0.01, y: 0, width: 0.99 }), + undefined, ); }); }); @@ -357,3 +358,135 @@ describe("EditClipModal playhead for framing the crop", () => { expect(onClose).toHaveBeenCalledTimes(1); }); }); + +describe("EditClipModal rotation", () => { + // Stored in source coordinates; on the turned picture this crop sits at 40%, 50%. + const TURNED: AxcutClip = { + ...CLIP, + cropRegion: { x: 0.1, y: 0.2, width: 0.5, height: 0.3 }, + rotation: 180, + }; + + function renderTurned(clip: AxcutClip = TURNED) { + const onApply = vi.fn(); + renderWithI18n( + , + ); + return { onApply }; + } + const cropText = () => + screen.getByRole("slider", { name: "Crop" }).getAttribute("aria-valuetext"); + const rotationButton = (name: string) => + within(screen.getByRole("group", { name: "Rotation" })).getByRole("button", { name }); + + it("shows a turned clip's crop mirrored, and opens with nothing to apply", () => { + renderTurned(); + + expect(cropText()).toBe("40%, 50%, 50% × 30%"); + expect(rotationButton("180°")).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + }); + + it("keeps the stored crop when the rotation is toggled", () => { + const { onApply } = renderTurned(); + + fireEvent.click(rotationButton("0°")); + // The frame jumps with the picture: upright, the stored crop shows as stored. + expect(cropText()).toBe("10%, 20%, 50% × 30%"); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(onApply).toHaveBeenCalledWith(20, 105, undefined, 0); + }); + + it("turns an upright clip, keeping its crop", () => { + const { onApply } = renderTurned({ ...TURNED, rotation: undefined }); + + expect(cropText()).toBe("10%, 20%, 50% × 30%"); + fireEvent.click(rotationButton("180°")); + expect(cropText()).toBe("40%, 50%, 50% × 30%"); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(onApply).toHaveBeenCalledWith(20, 105, undefined, 180); + }); + + it("Reset restores the stored rotation", () => { + renderTurned(); + + fireEvent.click(rotationButton("0°")); + expect(screen.getByRole("button", { name: "Reset" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + + expect(rotationButton("180°")).toHaveAttribute("aria-pressed", "true"); + expect(cropText()).toBe("40%, 50%, 50% × 30%"); + expect(screen.getByRole("button", { name: "Reset" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + }); + + it("Apply converts a crop moved on the turned picture back to source coordinates", () => { + const { onApply } = renderTurned(); + + // One step right and one down on the turned picture is one left and one up in the source. + const crop = screen.getByRole("slider", { name: "Crop" }); + fireEvent.keyDown(crop, { key: "ArrowRight" }); + fireEvent.keyDown(crop, { key: "ArrowDown" }); + expect(cropText()).toBe("41%, 51%, 50% × 30%"); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(onApply).toHaveBeenCalledWith( + 20, + 105, + { + x: expect.closeTo(0.09, 9), + y: expect.closeTo(0.19, 9), + width: expect.closeTo(0.5, 9), + height: expect.closeTo(0.3, 9), + }, + undefined, + ); + }); + + it("saves a crop moved flush against the turned picture's edge", () => { + // 1 - 0.9 - 0.1 is -2.8e-17 in floating point, which the schema rejects as below 0. + const { onApply } = renderTurned({ + ...TURNED, + cropRegion: { x: 0.01, y: 0.01, width: 0.1, height: 0.1 }, + }); + + const crop = screen.getByRole("slider", { name: "Crop" }); + fireEvent.keyDown(crop, { key: "ArrowRight" }); + fireEvent.keyDown(crop, { key: "ArrowDown" }); + expect(cropText()).toBe("90%, 90%, 10% × 10%"); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + const saved = onApply.mock.calls[0][2]; + expect(saved.x).toBeGreaterThanOrEqual(0); + expect(saved.y).toBeGreaterThanOrEqual(0); + expect(clipCropRegionSchema.safeParse(saved).success).toBe(true); + }); + + it("turns the crop preview's picture, not the frame drawn over it", () => { + renderWithI18n( + , + ); + const video = document.querySelector("video"); + if (!video) throw new Error("no preview video rendered"); + expect(video.style.transform).toBe("rotate(180deg)"); + + fireEvent.click(rotationButton("0°")); + expect(video.style.transform).toBe(""); + }); +}); diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index f2b7bb7a8..6cef1f098 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -9,6 +9,7 @@ import { } from "react"; import type { CropRegion } from "@/components/video-editor/types"; import { useScopedT } from "@/contexts/I18nContext"; +import { type ClipRotation, clipRotation, turnCropRegion } from "@/lib/ai-edition/clipRotation"; import type { AxcutClip } from "@/lib/ai-edition/schema"; import { formatSeconds } from "@/lib/ai-edition/timeline/format"; import { cropDraftFromRegion, cropDraftToPct, previewBoxStyle } from "./cropDraft"; @@ -644,14 +645,26 @@ export interface AssetMeta { const IDENTITY_CROP: CropRegion = { x: 0, y: 0, width: 1, height: 1 }; +// The clip's stored crop as the dialog shows it: on the turned picture for a turned clip. +function shownCropRegion(clip: AxcutClip): CropRegion { + const region = clip.cropRegion ?? IDENTITY_CROP; + return clipRotation(clip) === 180 ? turnCropRegion(region) : region; +} + interface EditClipModalProps extends BaseModalProps { clip: AxcutClip | null; assetMeta: AssetMeta | null; videoSources: VideoSource[]; /** `cropRegion` is `undefined` when the crop section wasn't touched (Reset * back to the clip's stored value) — the caller can skip the write in that - * case — and `null` when the user explicitly reset it to "no crop". */ - onApply: (sourceStartSec: number, sourceEndSec: number, cropRegion?: CropRegion | null) => void; + * case — and `null` when the user explicitly reset it to "no crop". `rotation` is + * `undefined` when it was left as stored. */ + onApply: ( + sourceStartSec: number, + sourceEndSec: number, + cropRegion?: CropRegion | null, + rotation?: ClipRotation, + ) => void; } // Mirrors Axcut's ClipEditDialog: an embedded preview of just this clip plus @@ -685,6 +698,8 @@ export function EditClipModal({ const [cropHPct, setCropHPct] = useState(100); const [cropRatio, setCropRatio] = useState("free"); const [cropTouched, setCropTouched] = useState(false); + // The crop draft is kept as the dialog shows it: on the turned picture when this is 180. + const [draftRotation, setDraftRotation] = useState(0); // Source video's real pixel aspect ratio (width/height) — needed to convert // between a preset's visual ratio (e.g. 16/9) and the crop region's // fraction-of-frame width/height. 16/9 is just a placeholder until the @@ -704,8 +719,8 @@ export function EditClipModal({ setDraftEnd(clip.sourceEndSec ?? clip.sourceStartSec); setActiveEdge(null); setPlayheadSec(clip.sourceStartSec); - const region = clip.cropRegion ?? IDENTITY_CROP; - const pct = cropDraftToPct(cropDraftFromRegion(region)); + setDraftRotation(clipRotation(clip)); + const pct = cropDraftToPct(cropDraftFromRegion(shownCropRegion(clip))); setCropXPct(pct.x); setCropYPct(pct.y); setCropWPct(pct.w); @@ -785,7 +800,7 @@ export function EditClipModal({ const hasTrimChanges = Math.abs(draftStart - clip.sourceStartSec) > 0.001 || Math.abs(draftEnd - (clip.sourceEndSec ?? 0)) > 0.001; - const hasChanges = hasTrimChanges || cropTouched; + const hasChanges = hasTrimChanges || cropTouched || draftRotation !== clipRotation(clip); const clipSources = videoSources.filter((s) => s.id === clip.assetId); const cropPreviewSource = clipSources[0] ?? null; @@ -861,6 +876,15 @@ export function EditClipModal({ setCropHPct(fit.h); }; + // Turning the picture keeps the stored crop, so the frame jumps with the picture. That is + // not a crop edit: `cropTouched` stays as it was. + const handleRotationChange = (next: ClipRotation) => { + if (next === draftRotation) return; + setDraftRotation(next); + setCropXPct(100 - cropXPct - cropWPct); + setCropYPct(100 - cropYPct - cropHPct); + }; + // Fraction-space width/height ratio the crop is locked to while a preset is // active (null for "Free"). The resize handles honor it, so the user can // move/scale the crop but never change its aspect ratio. @@ -951,8 +975,9 @@ export function EditClipModal({ setDraftStart(clip.sourceStartSec); setDraftEnd(end); setPlayheadSec((p) => Math.min(Math.max(p, clip.sourceStartSec), end)); + setDraftRotation(clipRotation(clip)); const region = clip.cropRegion ?? IDENTITY_CROP; - const pct = cropDraftToPct(cropDraftFromRegion(region)); + const pct = cropDraftToPct(cropDraftFromRegion(shownCropRegion(clip))); setCropXPct(pct.x); setCropYPct(pct.y); setCropWPct(pct.w); @@ -961,15 +986,25 @@ export function EditClipModal({ setCropTouched(false); }; const handleApply = () => { - const nextCrop: CropRegion = { - x: Math.max(0, Math.min(1, cropXPct / 100)), - y: Math.max(0, Math.min(1, cropYPct / 100)), - width: Math.max(0.01, Math.min(1, cropWPct / 100)), - height: Math.max(0.01, Math.min(1, cropHPct / 100)), + const width = Math.max(0.01, Math.min(1, cropWPct / 100)); + const height = Math.max(0.01, Math.min(1, cropHPct / 100)); + // Kept inside the frame, so turning it never yields a negative x/y. + const shownCrop: CropRegion = { + x: Math.max(0, Math.min(1 - width, cropXPct / 100)), + y: Math.max(0, Math.min(1 - height, cropYPct / 100)), + width, + height, }; + // Stored in source coordinates, whatever the dialog showed it on. + const nextCrop = draftRotation === 180 ? turnCropRegion(shownCrop) : shownCrop; const isIdentity = nextCrop.x === 0 && nextCrop.y === 0 && nextCrop.width === 1 && nextCrop.height === 1; - onApply(draftStart, draftEnd, cropTouched ? (isIdentity ? null : nextCrop) : undefined); + onApply( + draftStart, + draftEnd, + cropTouched ? (isIdentity ? null : nextCrop) : undefined, + draftRotation !== clipRotation(clip) ? draftRotation : undefined, + ); onClose(); }; @@ -1034,6 +1069,7 @@ export function EditClipModal({ height: "100%", objectFit: "contain", background: "#000", + transform: draftRotation === 180 ? "rotate(180deg)" : undefined, }} /> ) : null} @@ -1195,6 +1231,26 @@ export function EditClipModal({ onChange={handleCropRatioChange} /> + {t("editClipDialog.rotation")} +
+ + label={t("editClipDialog.rotation")} + options={[ + { + value: 0, + label: t("editClipDialog.rotationUpright"), + title: t("editClipDialog.rotationTip"), + }, + { + value: 180, + label: t("editClipDialog.rotationTurned"), + title: t("editClipDialog.rotationTip"), + }, + ]} + value={draftRotation} + onChange={handleRotationChange} + /> +
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 08b52f21e..5fafc485c 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -1709,7 +1709,7 @@ export function NewEditorShell() { : null } videoSources={videoSources} - onApply={(sStart, sEnd, cropRegion) => { + onApply={(sStart, sEnd, cropRegion, rotation) => { if (!editClipTarget) return; const clipId = editClipTarget.id; // One user action, one document, one save. This used to be two calls — @@ -1718,7 +1718,9 @@ export function NewEditorShell() { // first and one of the two edits vanished silently (#355). It goes on the // shared write queue for the same reason every other timeline edit does: // so it can't clobber, or be clobbered by, a save already in flight. - void enqueueTimelineWrite(() => tl.applyClipEdit(clipId, sStart, sEnd, cropRegion)); + void enqueueTimelineWrite(() => + tl.applyClipEdit(clipId, sStart, sEnd, cropRegion, rotation), + ); setEditClipTarget(null); }} /> diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index c817fde3c..59bf4030b 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -33,6 +33,7 @@ import { type ZoomFocus, } from "@/components/video-editor/types"; import { useScopedT } from "@/contexts/I18nContext"; +import { clipRotation } from "@/lib/ai-edition/clipRotation"; import { isFormatFillActive, resolveAspectRatioValue, @@ -228,6 +229,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) { [props.clips, props.currentTimeSec], ); const cropRegion: CropRegion = activeClip?.cropRegion ?? DEFAULT_CROP_REGION; + const rotation = activeClip ? clipRotation(activeClip) : 0; // P4 — the layout preset is global (one panel for the whole timeline) but the camera // is per clip, so the layout has to be resolved against the clip under the playhead. @@ -378,6 +380,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) { onVideoElement: relayIsPlaying, onLoadedMetadata: relayLoadedMetadata, cropRegion, + rotation, }; const handleWebcamPointerDown = (event: ReactPointerEvent) => { diff --git a/src/components/ai-edition/VirtualPreview.rotation.test.tsx b/src/components/ai-edition/VirtualPreview.rotation.test.tsx new file mode 100644 index 000000000..7a0a38e1d --- /dev/null +++ b/src/components/ai-edition/VirtualPreview.rotation.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import { cleanup, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CropRegion } from "@/components/video-editor/types"; +import type { ClipRotation } from "@/lib/ai-edition/clipRotation"; +import { VirtualPreview } from "./VirtualPreview"; + +beforeEach(() => { + // No playback here: the frames are never run. + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => undefined); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function videoStyle(cropRegion: CropRegion | null, rotation?: ClipRotation) { + const view = render( + , + ); + const video = view.container.querySelector("video"); + if (!video) throw new Error("no