diff --git a/src/components/ai-edition/AnnotationLayer.test.tsx b/src/components/ai-edition/AnnotationLayer.test.tsx new file mode 100644 index 000000000..9f2ea890c --- /dev/null +++ b/src/components/ai-edition/AnnotationLayer.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { render } from "@testing-library/react"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { AxcutAnnotationRegion } from "@/lib/ai-edition/schema"; +import { AnnotationLayer } from "./AnnotationLayer"; + +// react-rnd reads its offset in the parent off `getBoundingClientRect` on mount, and jsdom lays +// nothing out: every rect is at 0, the offset comes out as minus the position, and the box is +// drawn at twice its place. Placing each element where its translate puts it is all the layout +// these boxes need. +beforeAll(() => { + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function ( + this: HTMLElement, + ) { + const shift = /translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)/.exec(this.style.transform); + const [x, y] = shift ? [Number(shift[1]), Number(shift[2])] : [0, 0]; + return DOMRect.fromRect({ x, y, width: 0, height: 0 }); + }); +}); +afterAll(() => vi.restoreAllMocks()); + +function region(overrides: Partial): AxcutAnnotationRegion { + return { + id: "a", + startMs: 0, + endMs: 1000, + type: "text", + content: "Hi", + position: { x: 10, y: 20 }, + size: { width: 30, height: 40 }, + style: { + color: "#ffffff", + backgroundColor: "transparent", + fontSize: 32, + fontFamily: "Inter", + fontWeight: "bold", + fontStyle: "normal", + textDecoration: "none", + textAlign: "center", + }, + zIndex: 1, + ...overrides, + }; +} + +// A 1000×500 frame, the footage padded in to 800×400 at (100, 50). +const FOOTAGE = { x: 100, y: 50, width: 800, height: 400 }; + +function layer(annotations: AxcutAnnotationRegion[]) { + const view = render( + , + ); + const frame = view.container.firstElementChild as HTMLElement; + const boxes = [...view.container.querySelectorAll(".react-draggable")]; + return { frame, boxes }; +} + +describe("AnnotationLayer", () => { + it("places an annotation on the frame, free of the footage", () => { + const { frame, boxes } = layer([region({ space: "frame" })]); + // Its box moves in the frame itself, so it can go over the padding. + expect(boxes[0].parentElement).toBe(frame); + expect(boxes[0].style.transform).toBe("translate(100px,100px)"); + expect(boxes[0].style.width).toBe("300px"); + }); + + it("reads an annotation saved on the footage from the footage, but lets it roam the frame", () => { + const { frame, boxes } = layer([region({})]); + expect(boxes[0].parentElement).toBe(frame); + // 100 + 10 % of 800, 50 + 20 % of 400: where it has always been drawn. + expect(boxes[0].style.transform).toBe("translate(180px,130px)"); + expect(boxes[0].style.width).toBe("240px"); + }); + + it("keeps a blur inside the footage it hides", () => { + const { frame, boxes } = layer([region({ type: "blur", content: "" })]); + const container = boxes[0].parentElement as HTMLElement; + expect(container).not.toBe(frame); + expect(container.style.left).toBe("100px"); + expect(container.style.width).toBe("800px"); + expect(boxes[0].style.transform).toBe("translate(80px,80px)"); + }); +}); diff --git a/src/components/ai-edition/AnnotationLayer.tsx b/src/components/ai-edition/AnnotationLayer.tsx index c79ed460b..7020d7967 100644 --- a/src/components/ai-edition/AnnotationLayer.tsx +++ b/src/components/ai-edition/AnnotationLayer.tsx @@ -4,19 +4,27 @@ // editor's schema keeps all annotation types (including blur) in one // `document.annotations[]` array, so this uses a single filtered+sorted // list and a single selection id instead. +// +// The layer covers the whole preview frame: text, images and arrows are placed on the frame +// and move anywhere in it, padding included. A privacy blur stays on the footage, so its boxes +// live in a child sized to the footage rect and cannot leave it (see +// `lib/ai-edition/annotations/placement.ts`). +import { belongsInFrame, fitTextBox, toFrameSpace } from "@/lib/ai-edition/annotations/placement"; import type { AxcutAnnotationRegion } from "@/lib/ai-edition/schema"; -import { AnnotationOverlay } from "./AnnotationOverlay"; +import { AnnotationOverlay, type PxRect } from "./AnnotationOverlay"; interface AnnotationLayerProps { annotations: AxcutAnnotationRegion[]; selectedAnnotationId: string | null; currentTimeSec: number; - containerWidth: number; - containerHeight: number; + /** The preview frame, px. */ + frameWidth: number; + frameHeight: number; + /** The footage rect inside the frame, px (`layout.screenRect`). */ + footage: PxRect; onSelectAnnotation: (id: string) => void; - onPositionChange: (id: string, position: { x: number; y: number }) => void; - onSizeChange: (id: string, size: { width: number; height: number }) => void; + onChange: (id: string, patch: Partial) => void; onCommit: () => void; } @@ -24,11 +32,11 @@ export function AnnotationLayer({ annotations, selectedAnnotationId, currentTimeSec, - containerWidth, - containerHeight, + frameWidth, + frameHeight, + footage, onSelectAnnotation, - onPositionChange, - onSizeChange, + onChange, onCommit, }: AnnotationLayerProps) { const currentTimeMs = Math.round(currentTimeSec * 1000); @@ -53,28 +61,63 @@ export function AnnotationLayer({ } }; - if (containerWidth <= 0 || containerHeight <= 0) return null; + if (frameWidth <= 0 || frameHeight <= 0 || footage.width <= 0 || footage.height <= 0) { + return null; + } + + const frameBox: PxRect = { x: 0, y: 0, width: frameWidth, height: frameHeight }; + const footageBox: PxRect = { x: 0, y: 0, width: footage.width, height: footage.height }; + // The footage as fractions of the frame: the form the conversions in `placement.ts` take. + const footageInFrame = { + x: footage.x / frameWidth, + y: footage.y / frameHeight, + width: footage.width / frameWidth, + height: footage.height / frameHeight, + }; + const fitText = (region: AxcutAnnotationRegion) => fitTextBox(region, frameWidth / frameHeight); + + const overlay = (annotation: AxcutAnnotationRegion, inFrame: boolean) => { + const width = inFrame ? frameWidth : footage.width; + const height = inFrame ? frameHeight : footage.height; + return ( + + ); + }; return (
- {visible.map((annotation) => ( - - ))} + {visible.filter((a) => belongsInFrame(a.type)).map((a) => overlay(a, true))} +
+ {visible.filter((a) => !belongsInFrame(a.type)).map((a) => overlay(a, false))} +
); } diff --git a/src/components/ai-edition/AnnotationOverlay.tsx b/src/components/ai-edition/AnnotationOverlay.tsx index 81f8c2592..356a740fe 100644 --- a/src/components/ai-edition/AnnotationOverlay.tsx +++ b/src/components/ai-edition/AnnotationOverlay.tsx @@ -12,15 +12,34 @@ import { useEffect, useRef, useState } from "react"; import { Rnd } from "react-rnd"; import type { AxcutAnnotationRegion } from "@/lib/ai-edition/schema"; +import { clampToBound } from "@/lib/projectDefaults"; import { cn } from "@/lib/utils"; +type Region = AxcutAnnotationRegion; + +export interface PxRect { + x: number; + y: number; + width: number; + height: number; +} + interface AnnotationOverlayProps { - annotation: AxcutAnnotationRegion; + annotation: Region; isSelected: boolean; + /** Taille, en px, de l'élément où la boîte se déplace : le cadre, ou le footage pour un flou. + * Chaque écriture est un pourcentage de cet élément. */ containerWidth: number; containerHeight: number; - onPositionChange: (id: string, position: { x: number; y: number }) => void; - onSizeChange: (id: string, size: { width: number; height: number }) => void; + /** D'où se lit le rect enregistré, en px dans cet élément : l'élément lui-même, ou le rect du + * footage pour une annotation encore rangée sur lui mais qui se déplace dans le cadre. */ + box: PxRect; + /** Le patch qui range l'annotation dans le cadre, appliqué avant son premier geste ; `null` + * quand elle y est déjà (ou qu'elle n'en sort jamais, comme un flou). */ + toFrame: Partial | null; + /** Texte seulement : la boîte taillée sur le texte, centre conservé. */ + fitText?: (region: Region) => Pick; + onChange: (id: string, patch: Partial) => void; /** Écriture disque, appelée une fois en fin de geste — le drag/resize ne fait que du live. */ onCommit?: () => void; onClick: (id: string) => void; @@ -28,24 +47,44 @@ interface AnnotationOverlayProps { isSelectedBoost: boolean; } +const CORNERS_ONLY = { + top: false, + right: false, + bottom: false, + left: false, + topLeft: true, + topRight: true, + bottomLeft: true, + bottomRight: true, +} as const; + export function AnnotationOverlay({ annotation, isSelected, containerWidth, containerHeight, - onPositionChange, - onSizeChange, + box, + toFrame, + fitText, + onChange, onCommit, onClick, zIndex, isSelectedBoost, }: AnnotationOverlayProps) { - const committedX = (annotation.position.x / 100) * containerWidth; - const committedY = (annotation.position.y / 100) * containerHeight; - const committedWidth = (annotation.size.width / 100) * containerWidth; - const committedHeight = (annotation.size.height / 100) * containerHeight; + const committedX = box.x + (annotation.position.x / 100) * box.width; + const committedY = box.y + (annotation.position.y / 100) * box.height; + const committedWidth = (annotation.size.width / 100) * box.width; + const committedHeight = (annotation.size.height / 100) * box.height; const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null; + const isText = annotation.type === "text"; const isDraggingRef = useRef(false); + // Le geste en cours a-t-il bougé quelque chose ? Un clic sur une annotation sélectionnée passe + // aussi par le début et la fin d'un glissement : sans mouvement, il n'écrit rien. + const movedRef = useRef(false); + // Taille de police et hauteur de boîte au début d'un redimensionnement de texte : les poignées + // d'un texte agrandissent ses lettres, dans le rapport de la hauteur tirée à la hauteur de départ. + const resizeStartRef = useRef<{ fontSize: number; height: number } | null>(null); const [liveRect, setLiveRect] = useState({ x: committedX, y: committedY, @@ -64,70 +103,107 @@ export function AnnotationOverlay({ const { x, y, width, height } = liveRect; + /** Un rect en px de l'élément, en pourcentages de lui : ce que le document range. */ + const toPct = (rect: PxRect): Pick => ({ + position: { x: (rect.x / containerWidth) * 100, y: (rect.y / containerHeight) * 100 }, + size: { + width: (rect.width / containerWidth) * 100, + height: (rect.height / containerHeight) * 100, + }, + }); + + // Une annotation encore sur le footage passe dans le cadre à son premier mouvement, au pixel + // près : les écritures qui suivent sont des pourcentages du cadre. + const firstMove = () => { + if (movedRef.current) return; + movedRef.current = true; + if (toFrame) onChange(annotation.id, toFrame); + }; + + /** La taille de police qu'une hauteur de boîte tirée donne à un texte ; rien pour les autres. */ + const scaledText = (nextHeight: number): Partial => { + const start = resizeStartRef.current; + if (!isText || !start) return {}; + const fontSize = Math.round( + clampToBound((start.fontSize * nextHeight) / start.height, "annotationFontSize"), + ); + return { style: { ...(toFrame?.style ?? annotation.style), fontSize } }; + }; + + // Un texte finit chaque geste taillé sur ses mots, au cas où sa boîte ne l'était pas encore + // (une annotation d'avant, ou une poignée lâchée entre deux tailles). + const settle = (patch: Partial) => { + const next = { ...annotation, ...toFrame, ...patch } as Region; + onChange(annotation.id, isText && fitText ? { ...patch, ...fitText(next) } : patch); + onCommit?.(); + }; + return ( { isDraggingRef.current = true; + movedRef.current = false; }} onDrag={(_e, d) => { + firstMove(); setLiveRect((prev) => ({ ...prev, x: d.x, y: d.y })); // Pousse la position PENDANT le geste : c'est le natif qui peint, il doit donc suivre - // le curseur. `onPositionChange` ne met à jour qu'en mémoire ; l'écriture disque se - // fait une seule fois, au relâchement (`onCommit`). - onPositionChange(annotation.id, { - x: (d.x / containerWidth) * 100, - y: (d.y / containerHeight) * 100, + // le curseur. `onChange` ne met à jour qu'en mémoire ; l'écriture disque se fait une + // seule fois, au relâchement (`onCommit`). + onChange(annotation.id, { + position: toPct({ x: d.x, y: d.y, width, height }).position, }); }} onDragStop={(_e, d) => { - setLiveRect((prev) => ({ ...prev, x: d.x, y: d.y })); - const xPercent = (d.x / containerWidth) * 100; - const yPercent = (d.y / containerHeight) * 100; - onPositionChange(annotation.id, { x: xPercent, y: yPercent }); - onCommit?.(); + if (movedRef.current) { + setLiveRect((prev) => ({ ...prev, x: d.x, y: d.y })); + settle({ position: toPct({ x: d.x, y: d.y, width, height }).position }); + } + movedRef.current = false; setTimeout(() => { isDraggingRef.current = false; }, 100); }} + onResizeStart={() => { + movedRef.current = false; + const fontSize = (toFrame?.style ?? annotation.style).fontSize; + resizeStartRef.current = { fontSize, height: Math.max(1, height) }; + }} onResize={(_e, _direction, ref, _delta, position) => { - setLiveRect({ + const rect = { x: position.x, y: position.y, width: ref.offsetWidth, height: ref.offsetHeight, - }); + }; + firstMove(); + setLiveRect(rect); // Même raison que le drag : le natif doit suivre la poignée en direct. - onPositionChange(annotation.id, { - x: (position.x / containerWidth) * 100, - y: (position.y / containerHeight) * 100, - }); - onSizeChange(annotation.id, { - width: (ref.offsetWidth / containerWidth) * 100, - height: (ref.offsetHeight / containerHeight) * 100, - }); + onChange(annotation.id, { ...toPct(rect), ...scaledText(rect.height) }); }} onResizeStop={(_e, _direction, ref, _delta, position) => { - setLiveRect({ + const rect = { x: position.x, y: position.y, width: ref.offsetWidth, height: ref.offsetHeight, - }); - const xPercent = (position.x / containerWidth) * 100; - const yPercent = (position.y / containerHeight) * 100; - const widthPercent = (ref.offsetWidth / containerWidth) * 100; - const heightPercent = (ref.offsetHeight / containerHeight) * 100; - onPositionChange(annotation.id, { x: xPercent, y: yPercent }); - onSizeChange(annotation.id, { width: widthPercent, height: heightPercent }); - onCommit?.(); + }; + if (movedRef.current) { + setLiveRect(rect); + settle({ ...toPct(rect), ...scaledText(rect.height) }); + } + movedRef.current = false; + resizeStartRef.current = null; }} onClick={() => { if (isDraggingRef.current) return; onClick(annotation.id); }} bounds="parent" + // Un texte garde sa forme : sa boîte est celle de ses mots, seule la taille change. + lockAspectRatio={isText} className={cn( "cursor-move", isSelected && @@ -146,8 +222,8 @@ export function AnnotationOverlay({ }} // Un flou en tracé libre se déplace et se redimensionne comme les autres : ce qui le // bloquait, c'était la zone de saisie du tracé qui capturait le pointeur — et elle est - // partie avec l'outil. - enableResizing={isSelected} + // partie avec l'outil. Un texte ne se tire que par ses coins, qui agrandissent ses lettres. + enableResizing={isSelected ? (isText ? CORNERS_ONLY : true) : false} disableDragging={!isSelected} resizeHandleStyles={{ topLeft: { diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 7caf59975..d9273e77c 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -1625,15 +1625,10 @@ export function NewEditorShell() { tl.selection?.kind === "annotation" ? tl.selection.id : null } onSelectAnnotation={(id) => tl.selectRegion("annotation", id)} - onAnnotationPositionChange={(id, position) => { - // Live seulement : appelé à chaque mouvement de souris pour que le - // compositeur natif suive le geste. L'écriture disque se fait une fois, - // au relâchement, via `onAnnotationCommit`. - tl.updateAnnotationLive(id, { position }); - }} - onAnnotationSizeChange={(id, size) => { - tl.updateAnnotationLive(id, { size }); - }} + // Live seulement : appelé à chaque mouvement de souris pour que le + // compositeur natif suive le geste. L'écriture disque se fait une fois, + // au relâchement, via `onAnnotationCommit`. + onAnnotationChange={tl.updateAnnotationLive} onAnnotationBlurDataChange={(id, blurData) => tl.updateAnnotationLive(id, { blurData }) } diff --git a/src/components/ai-edition/Preview.tsx b/src/components/ai-edition/Preview.tsx index a55efe4dd..f4ad0a5c5 100644 --- a/src/components/ai-edition/Preview.tsx +++ b/src/components/ai-edition/Preview.tsx @@ -46,8 +46,7 @@ interface PreviewProps { annotationRegions?: AxcutAnnotationRegion[]; selectedAnnotationId?: string | null; onSelectAnnotation?: (id: string) => void; - onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void; - onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void; + onAnnotationChange?: (id: string, patch: Partial) => void; onAnnotationBlurDataChange?: (id: string, blurData: BlurData) => void; onAnnotationCommit?: () => void; seekTarget: { timeSec: number; requestId: number } | null; @@ -82,8 +81,7 @@ export function Preview({ annotationRegions, selectedAnnotationId, onSelectAnnotation, - onAnnotationPositionChange, - onAnnotationSizeChange, + onAnnotationChange, onAnnotationBlurDataChange, onAnnotationCommit, seekTarget, @@ -226,8 +224,7 @@ export function Preview({ annotationRegions={annotationRegions} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={onSelectAnnotation} - onAnnotationPositionChange={onAnnotationPositionChange} - onAnnotationSizeChange={onAnnotationSizeChange} + onAnnotationChange={onAnnotationChange} onAnnotationBlurDataChange={onAnnotationBlurDataChange} onAnnotationCommit={onAnnotationCommit} seekTarget={seekTarget} diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index f9ca4e1e8..04e150991 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -9,12 +9,14 @@ // the actual pixels come from the native canvas, not from this layout): // .previewFrame → canvas (wallpaper bg; never receives padding // from the slider directly). -// .screenStage → sizes/positions the zoom/annotation overlays by -// the composite-layout math (PiP/dual/stack/no-cam); +// .screenStage → sizes/positions the zoom overlay by the +// composite-layout math (PiP/dual/stack/no-cam); // its own