From e3d4429ec94509bc7c7867d08a44112aa65982e6 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:01:48 +0200 Subject: [PATCH 01/11] =?UTF-8?q?feat(editor):=20store=20a=20per-clip=2018?= =?UTF-8?q?0=C2=B0=20rotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/ai-edition/clipRotation.test.ts | 26 ++++++++++++++++++++ src/lib/ai-edition/clipRotation.ts | 15 +++++++++++ src/lib/ai-edition/document/timeline.test.ts | 18 ++++++++++++++ src/lib/ai-edition/document/timeline.ts | 11 +++++---- src/lib/ai-edition/schema/index.test.ts | 15 +++++++++++ src/lib/ai-edition/schema/index.ts | 3 +++ src/lib/ai-edition/store/useTimeline.test.ts | 17 +++++++++++++ src/lib/ai-edition/store/useTimeline.ts | 21 +++++++++++++++- 8 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 src/lib/ai-edition/clipRotation.test.ts create mode 100644 src/lib/ai-edition/clipRotation.ts diff --git a/src/lib/ai-edition/clipRotation.test.ts b/src/lib/ai-edition/clipRotation.test.ts new file mode 100644 index 000000000..82873bd1c --- /dev/null +++ b/src/lib/ai-edition/clipRotation.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { clipRotation, turnCropRegion } from "./clipRotation"; + +describe("clipRotation", () => { + it("reads an absent field as 0", () => { + expect(clipRotation({})).toBe(0); + expect(clipRotation({ rotation: 180 })).toBe(180); + }); +}); + +describe("turnCropRegion", () => { + it("mirrors the rectangle inside the frame", () => { + expect(turnCropRegion({ x: 0.1, y: 0.2, width: 0.5, height: 0.3 })).toEqual({ + x: 0.4, + y: 0.5, + width: 0.5, + height: 0.3, + }); + }); + it("is its own inverse", () => { + const r = { x: 0.13, y: 0.07, width: 0.61, height: 0.42 }; + const back = turnCropRegion(turnCropRegion(r)); + expect(back.x).toBeCloseTo(r.x, 12); + expect(back.y).toBeCloseTo(r.y, 12); + }); +}); diff --git a/src/lib/ai-edition/clipRotation.ts b/src/lib/ai-edition/clipRotation.ts new file mode 100644 index 000000000..7a8cd3554 --- /dev/null +++ b/src/lib/ai-edition/clipRotation.ts @@ -0,0 +1,15 @@ +// A clip's main picture can be turned by 180° (a desk camera mounted upside down, an +// imported video filmed the wrong way up). Absent means upright; 0 is never stored. +export type ClipRotation = 0 | 180; + +export function clipRotation(clip: { rotation?: 180 }): ClipRotation { + return clip.rotation === 180 ? 180 : 0; +} + +// The same rectangle seen on the turned picture. Crops are stored in source coordinates; +// anything that shows or edits them on the turned picture goes through this, both ways. +export function turnCropRegion( + r: T, +): T { + return { ...r, x: 1 - r.x - r.width, y: 1 - r.y - r.height }; +} diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts index a33f1c6e7..50ebc3914 100644 --- a/src/lib/ai-edition/document/timeline.test.ts +++ b/src/lib/ai-edition/document/timeline.test.ts @@ -1914,6 +1914,24 @@ describe("removeRegion — the one shared region-delete mutator", () => { }); }); +describe("withClipsChanged — rotation", () => { + it("does not join two media-contiguous clips that differ in rotation", () => { + const left = makeClip({ id: "clip_l", sourceStartSec: 0, sourceEndSec: 5 }); + const right = makeClip({ + id: "clip_r", + sourceStartSec: 5, + sourceEndSec: 10, + timelineStartSec: 5, + timelineEndSec: 10, + }); + const doc = makeDoc(); + const next = withClipsChanged(doc, [left, { ...right, rotation: 180 }]); + expect(next.timeline.clips).toHaveLength(2); + // Control: without the rotation they DO join. + expect(withClipsChanged(doc, [left, right]).timeline.clips).toHaveLength(1); + }); +}); + describe("removeClip — delete a clip, close the gap, drop its pills", () => { const doc = () => makeDoc({ diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts index ba256fd3b..90ccec0c8 100644 --- a/src/lib/ai-edition/document/timeline.ts +++ b/src/lib/ai-edition/document/timeline.ts @@ -1231,7 +1231,7 @@ export function withClipsChanged(document: AxcutDocument, clips: AxcutClip[]): A return laid.length === 0 ? next : rederiveRegionMs(next, laid); } -/** Fold every run of same-media, same-crop, media-contiguous clips into one, reporting the +/** Fold every run of same-media, same-crop-and-rotation, media-contiguous clips into one, reporting the * ids that went away and the clip that now carries their content. */ function joinContiguous(clips: AxcutClip[]): { clips: AxcutClip[]; @@ -1259,15 +1259,16 @@ function joinContiguous(clips: AxcutClip[]): { return { clips: out, absorbed }; } -/** Same media, media timecodes that meet, same framing. Crop is the only property a clip - * carries that two otherwise-identical neighbours could legitimately disagree on, so it is - * the whole of the guard. */ +/** Same media, media timecodes that meet, same framing. Crop and rotation are the only + * properties a clip carries that two otherwise-identical neighbours could legitimately + * disagree on, so they are the whole of the guard. */ function joinable(left: AxcutClip, right: AxcutClip): boolean { return ( left.assetId === right.assetId && left.sourceEndSec !== undefined && Math.abs(left.sourceEndSec - right.sourceStartSec) < 1e-6 && - JSON.stringify(left.cropRegion ?? null) === JSON.stringify(right.cropRegion ?? null) + JSON.stringify(left.cropRegion ?? null) === JSON.stringify(right.cropRegion ?? null) && + (left.rotation ?? 0) === (right.rotation ?? 0) ); } diff --git a/src/lib/ai-edition/schema/index.test.ts b/src/lib/ai-edition/schema/index.test.ts index 38aabd870..88764a1ba 100644 --- a/src/lib/ai-edition/schema/index.test.ts +++ b/src/lib/ai-edition/schema/index.test.ts @@ -360,6 +360,21 @@ describe("axcut-schema v8", () => { ).toThrow(); }); + it("clipSchema accepts rotation 180 and an absent rotation, rejects 90", () => { + const base = { + id: "c1", + assetId: "a1", + sourceStartSec: 0, + sourceEndSec: 5, + timelineStartSec: 0, + timelineEndSec: 5, + origin: "user" as const, + }; + expect(clipSchema.safeParse({ ...base, rotation: 180 }).success).toBe(true); + expect(clipSchema.safeParse(base).success).toBe(true); + expect(clipSchema.safeParse({ ...base, rotation: 90 }).success).toBe(false); + }); + it("clipSchema rejects timelineEndSec < timelineStartSec or sourceEndSec < sourceStartSec", () => { const validBase = { id: "c1", diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts index 4d8caf600..22297b167 100644 --- a/src/lib/ai-edition/schema/index.ts +++ b/src/lib/ai-edition/schema/index.ts @@ -224,6 +224,9 @@ export const clipSchema = z // that as the identity region {x:0,y:0,width:1,height:1} rather than // storing the identity explicitly, so untouched clips stay lean. cropRegion: clipCropRegionSchema.optional(), + // Absent means upright. Only a half turn exists; 0 is never stored, so untouched + // clips stay lean (same rule as cropRegion). + rotation: z.literal(180).optional(), }) .refine((data) => data.timelineEndSec >= data.timelineStartSec, { message: "timelineEndSec must be greater than or equal to timelineStartSec", diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 917188529..b07357afa 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -612,6 +612,23 @@ describe("useTimeline.applyClipEdit (Edit-clip modal)", () => { }); expect(useProjectStore.getState().document?.timeline.clips[0].cropRegion).toBeUndefined(); }); + + it("sets, keeps and removes the rotation in one save each", async () => { + const { result } = renderTimeline(); + await act(async () => { + await result.current.applyClipEdit("clip_a", 0, 6, undefined, 180); + }); + expect(useProjectStore.getState().document?.timeline.clips[0].rotation).toBe(180); + expect(bridgeMocks.save).toHaveBeenCalledTimes(1); + await act(async () => { + await result.current.applyClipEdit("clip_a", 0, 6); + }); + expect(useProjectStore.getState().document?.timeline.clips[0].rotation).toBe(180); + await act(async () => { + await result.current.applyClipEdit("clip_a", 0, 6, undefined, 0); + }); + expect(useProjectStore.getState().document?.timeline.clips[0]).not.toHaveProperty("rotation"); + }); }); // #353. The toolbar button and the `C` shortcut both used to write a region on a diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index b8a867f77..d02dbd7f4 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -14,6 +14,7 @@ import type { import { useScopedT } from "@/contexts/I18nContext"; import { DEFAULT_TEXT_PLATE } from "../annotations/background"; import { fitTextBox } from "../annotations/placement"; +import type { ClipRotation } from "../clipRotation"; import { collapseTracksToPills, patchAudioTrack, @@ -1210,6 +1211,9 @@ export function useTimeline() { // section was never touched (leave the stored value alone); `null` clears it back to // "no crop" (full frame) rather than storing the identity region explicitly. // + // Rotation follows the same rule: `undefined` leaves the stored value, `0` removes the + // field, `180` sets it. + // // The document is read from the store, not off the render closure, so this composes // with `useSequentialTimelineOps`: queued behind another timeline write, it still sees // what that write committed. Same reason as `setTrimEntries` / `insertClipAt`. @@ -1219,11 +1223,12 @@ export function useTimeline() { sourceStartSec: number, sourceEndSec: number, cropRegion?: AxcutClipCropRegion | null, + rotation?: ClipRotation, ) => { const doc = useProjectStore.getState().document; if (!doc) return; const ranged = setClipSourceRange(doc, clipId, sourceStartSec, sourceEndSec); - const next: AxcutDocument = + const cropped: AxcutDocument = cropRegion === undefined ? ranged : { @@ -1235,6 +1240,20 @@ export function useTimeline() { ), }, }; + const next: AxcutDocument = + rotation === undefined + ? cropped + : { + ...cropped, + timeline: { + ...cropped.timeline, + clips: cropped.timeline.clips.map((c) => { + if (c.id !== clipId) return c; + const { rotation: _old, ...rest } = c; + return rotation === 180 ? { ...rest, rotation: 180 as const } : rest; + }), + }, + }; await saveDocument(next, { history: true }); }, [saveDocument], From 6610816dcb42d9c64ff894face79de2612c0f786 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:05:31 +0200 Subject: [PATCH 02/11] feat(scene): send each clip's rotation to the compositor --- src/native/sceneDescription.test.ts | 42 +++++++++++++++++++++++++++++ src/native/sceneDescription.ts | 8 ++++++ 2 files changed, 50 insertions(+) diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index 0d84461b5..82d6ccbba 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -2949,3 +2949,45 @@ describe("buildSceneDescription.audioTracks", () => { expect(buildSceneDescription(doc).audioTracks).toEqual([]); }); }); + +// --- rotationByClip ---------------------------------------------------------- + +describe("buildSceneDescription.rotationByClip", () => { + it("emits rotationByClip parallel to cropByClip", () => { + const asset = makeAsset({ id: "a", originalPath: "/a.mp4" }); + const upright = makeClip({ + id: "c1", + assetId: "a", + sourceStartSec: 0, + sourceEndSec: 4, + timelineStartSec: 0, + timelineEndSec: 4, + }); + const turned = makeClip({ + id: "c2", + assetId: "a", + sourceStartSec: 4, + sourceEndSec: 8, + timelineStartSec: 4, + timelineEndSec: 8, + rotation: 180, + }); + const scene = buildSceneDescription(makeDoc({ assets: [asset], clips: [upright, turned] })); + expect(scene.rotationByClip).toEqual([0, 180]); + expect(scene.rotationByClip).toHaveLength(scene.cropByClip.length); + }); + + it("emits all zeros when no clip is turned", () => { + const asset = makeAsset({ id: "a", originalPath: "/a.mp4" }); + const clip = makeClip({ + id: "c1", + assetId: "a", + sourceStartSec: 0, + sourceEndSec: 4, + timelineStartSec: 0, + timelineEndSec: 4, + }); + const scene = buildSceneDescription(makeDoc({ assets: [asset], clips: [clip] })); + expect(scene.rotationByClip).toEqual([0]); + }); +}); diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index 5cf2a8e5d..b963c8ad9 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -31,6 +31,7 @@ import { getCaptionTranslations, } from "@/lib/ai-edition/captions"; import { clickSoundForDocument } from "@/lib/ai-edition/clickSound"; +import { clipRotation } from "@/lib/ai-edition/clipRotation"; import { collapseTracksToPills, trackGroupId } from "@/lib/ai-edition/document/audioTracks"; import { createId } from "@/lib/ai-edition/document/ids"; import { isFormatFillActive, pickOutputDims } from "@/lib/ai-edition/document/outputFormat"; @@ -563,6 +564,11 @@ export interface SceneDescription { * `crop` field, which lost per-clip crops on multi-clip documents. */ cropByClip: Array<{ x: number; y: number; width: number; height: number } | null>; + /** + * Half turn of each clip's main picture, same order as `clips`. 0 = upright. The + * compositor turns the screen layer; crop, cursor and zoom stay in their own spaces. + */ + rotationByClip: Array<0 | 180>; /** Output frame. `fps` null = use the first clip's source fps. */ output: { width: number; height: number; fps: number | null }; /** Webcam background effect. Omitted when the mode is "none". */ @@ -1004,6 +1010,7 @@ export function buildSceneDescription( }; }, ); + const rotationByClip = visibleClips.map((clip) => clipRotation(clip)); // Zoom + Full Camera + speed regions are authored in RAW virtual (timeline) ms // in the document — the ruler where trims still occupy their space — but the @@ -1470,6 +1477,7 @@ export function buildSceneDescription( clipIndex: region.clipIndex, })), cropByClip, + rotationByClip, output: { ...pickOutputDims(document, settings.aspectRatio), fps: null }, // Omitted rather than sent as `{mode:"none"}`: the Rust side defaults the field, and // every project without a webcam effect would otherwise carry it for nothing. From 53abec95a75edcab870ca80bf73401d7831bd935 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:14:40 +0200 Subject: [PATCH 03/11] feat(compositor): plan a turned clip in display space --- crates/compositor/src/cursor.rs | 37 ++++++ crates/compositor/src/frame_geometry.rs | 147 +++++++++++++++++++++++- crates/compositor/src/scene.rs | 28 +++++ 3 files changed, 209 insertions(+), 3 deletions(-) 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..bd6c7aa2e 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. @@ -3068,9 +3085,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 +3589,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 +6531,116 @@ 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 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 +7932,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/scene.rs b/crates/compositor/src/scene.rs index 895fa6b2a..a296203a5 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -795,6 +795,10 @@ 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). Anything but 180 is 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 +814,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 +1221,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 From 7eb7fbfeeac342fc190d816a1fa36f6022793b62 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:27:10 +0200 Subject: [PATCH 04/11] feat(compositor): draw a turned clip on all three backends --- crates/compositor/src/compositor_linux.rs | 8 +- crates/compositor/src/compositor_macos.rs | 5 +- crates/compositor/src/compositor_windows.rs | 5 +- crates/compositor/src/frame_geometry.rs | 11 +- crates/compositor/src/live.rs | 126 +++++++++++++++----- crates/compositor/src/timeline_walk.rs | 16 ++- 6 files changed, 126 insertions(+), 45 deletions(-) diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index e3176ad8c..36100fdd6 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -2263,11 +2263,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 +2277,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 +2288,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/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index bd6c7aa2e..0281e64bf 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -2239,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, @@ -6601,6 +6602,14 @@ mod tests { 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"); diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index 16c75bd9e..aa9bf0965 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -810,6 +810,59 @@ 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. 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, +) -> 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(0.0)), + 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 +1481,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 +1554,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 +1681,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 +1697,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,31 +1707,14 @@ 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(), - } + // 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, + ) { last_smoothing = -1.0; } clip_changed = true; @@ -1741,7 +1785,19 @@ 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, + ) { + last_smoothing = -1.0; + } } // 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 +2311,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/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); From ad5e3128f9a9f5f2dcd4cd8f2a8294e1dac34d72 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:44:17 +0200 Subject: [PATCH 05/11] =?UTF-8?q?feat(editor):=20turn=20a=20clip=20by=2018?= =?UTF-8?q?0=C2=B0=20from=20the=20Edit-clip=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai-edition/EditClipModal.test.tsx | 116 +++++++++++++++++- src/components/ai-edition/Modals.tsx | 69 +++++++++-- src/components/ai-edition/NewEditorShell.tsx | 6 +- src/components/ai-edition/PreviewCanvas.tsx | 3 + .../VirtualPreview.rotation.test.tsx | 66 ++++++++++ src/components/ai-edition/VirtualPreview.tsx | 13 +- src/i18n/__tests__/tooltipCopy.test.ts | 2 +- src/i18n/locales/ar/editor.json | 4 + src/i18n/locales/cs/editor.json | 4 + src/i18n/locales/de/editor.json | 4 + src/i18n/locales/en/editor.json | 4 + src/i18n/locales/es/editor.json | 4 + src/i18n/locales/fr/editor.json | 4 + src/i18n/locales/it/editor.json | 4 + src/i18n/locales/ja-JP/editor.json | 4 + src/i18n/locales/ko-KR/editor.json | 4 + src/i18n/locales/pt-BR/editor.json | 4 + src/i18n/locales/ru/editor.json | 4 + src/i18n/locales/tr/editor.json | 4 + src/i18n/locales/vi/editor.json | 4 + src/i18n/locales/zh-CN/editor.json | 4 + src/i18n/locales/zh-TW/editor.json | 4 + .../timeline/apply-auto-zooms.test.ts | 19 +++ .../ai-edition/timeline/apply-auto-zooms.ts | 10 +- 24 files changed, 349 insertions(+), 15 deletions(-) create mode 100644 src/components/ai-edition/VirtualPreview.rotation.test.tsx diff --git a/src/components/ai-edition/EditClipModal.test.tsx b/src/components/ai-edition/EditClipModal.test.tsx index 9d5c5ad07..87025bd3f 100644 --- a/src/components/ai-edition/EditClipModal.test.tsx +++ b/src/components/ai-edition/EditClipModal.test.tsx @@ -1,6 +1,6 @@ // @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"; @@ -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,116 @@ 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("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..fe623633c 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,22 @@ export function EditClipModal({ setCropTouched(false); }; const handleApply = () => { - const nextCrop: CropRegion = { + const shownCrop: 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)), }; + // 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 +1066,7 @@ export function EditClipModal({ height: "100%", objectFit: "contain", background: "#000", + transform: draftRotation === 180 ? "rotate(180deg)" : undefined, }} /> ) : null} @@ -1195,6 +1228,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