diff --git a/components/canvas/CLAUDE.md b/components/canvas/CLAUDE.md index 6df9213..ccc1909 100644 --- a/components/canvas/CLAUDE.md +++ b/components/canvas/CLAUDE.md @@ -133,9 +133,10 @@ render: 300 × 420 mixer: 360 × 320 - **Handles:** genau zwei Inputs links (`base`, `overlay`) und ein Output rechts (`mixer-out`). - **Erlaubte Inputs:** `image`, `asset`, `ai-image`, `render`. - **Connection-Limits:** maximal 2 eingehende Kanten insgesamt, davon pro Handle genau 1. -- **Node-Data (V1):** `blendMode` (`normal|multiply|screen|overlay`), `opacity` (0..100), `overlayX`, `overlayY`, `overlayWidth`, `overlayHeight` (normierte 0..1-Rect-Werte). +- **Node-Data (V1):** `blendMode` (`normal|multiply|screen|overlay`), `opacity` (0..100), `overlayX`, `overlayY`, `overlayWidth`, `overlayHeight` (Frame-Rect, normiert 0..1) plus `contentX`, `contentY`, `contentWidth`, `contentHeight` (Content-Framing innerhalb des Overlay-Frames, ebenfalls normiert 0..1). - **Output-Semantik:** pseudo-image (clientseitig aus Graph + Controls aufgeloest), kein persistiertes Asset, kein Storage-Write. -- **UI/Interaction:** Overlay ist im Preview direkt per Drag verschiebbar und ueber Corner-Handles frei resizable; numerische Inline-Controls bleiben als Feineinstellung erhalten. +- **UI/Interaction:** Zwei Modi im Preview: `Frame resize` (Overlay-Frame verschieben + ueber Corner-Handles resizen) und `Content framing` (Overlay-Inhalt innerhalb des Frames verschieben). Numerische Inline-Controls bleiben als Feineinstellung erhalten. +- **Sizing/Crop-Verhalten:** Der Overlay-Inhalt wird `object-cover`-aehnlich in den Content-Rect eingepasst; bei abweichenden Seitenverhaeltnissen wird zentriert gecroppt. ### Compare-Integration (V1) @@ -321,6 +322,7 @@ useCanvasData (use-canvas-data.ts) - **Video-Connection-Policy:** `video-prompt` darf **nur** mit `ai-video` verbunden werden (und umgekehrt). `text → video-prompt` ist erlaubt (Prompt-Quelle). `ai-video → compare` ist erlaubt. - **Mixer-Connection-Policy:** `mixer` akzeptiert nur `image|asset|ai-image|render`; Ziel-Handles sind nur `base` und `overlay`, pro Handle maximal eine eingehende Kante, insgesamt maximal zwei. - **Mixer-Pseudo-Output:** `mixer` liefert in V1 kein persistiertes Bild. Offizielle Consumer sind `compare` und der direkte Bake-Pfad `mixer -> render`; `mixer -> adjustments -> render` bleibt vorerst deferred. +- **Mixer Legacy-Daten:** Alte `offsetX`/`offsetY`-Mixer-Daten werden beim Lesen auf den Full-Frame-Fallback (`overlay* = 0/0/1/1`) normalisiert; Content-Framing defaults auf `content* = 0/0/1/1`. - **Agent-Flow:** `agent` akzeptiert nur Content-/Kontext-Quellen (z. B. `render`, `compare`, `text`, `image`) als Input; ausgehende Kanten sind fuer `agent -> agent-output` vorgesehen. - **Convex Generated Types:** `api.ai.generateVideo` wird u. U. nicht in `convex/_generated/api.d.ts` exportiert. Der Code verwendet `api as unknown as {...}` als Workaround. Ein `npx convex dev`-Zyklus würde die Typen korrekt generieren. - **Canvas Graph Query:** Der Canvas nutzt `canvasGraph.get` (aus `convex/canvasGraph.ts`) statt separater `nodes.list`/`edges.list` Queries. Optimistic Updates laufen über `canvas-graph-query-cache.ts`. diff --git a/components/canvas/__tests__/compare-node.test.tsx b/components/canvas/__tests__/compare-node.test.tsx index 9155884..225057a 100644 --- a/components/canvas/__tests__/compare-node.test.tsx +++ b/components/canvas/__tests__/compare-node.test.tsx @@ -1,5 +1,9 @@ +// @vitest-environment jsdom + import React from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-context"; @@ -15,12 +19,20 @@ type StoreState = { }>; }; +type ResizeObserverEntryLike = { + target: Element; + contentRect: { width: number; height: number }; +}; + const storeState: StoreState = { nodes: [], edges: [], }; const compareSurfaceSpy = vi.fn(); +let resizeObserverCallback: + | ((entries: ResizeObserverEntryLike[]) => void) + | null = null; vi.mock("@xyflow/react", () => ({ Handle: () => null, @@ -28,6 +40,14 @@ vi.mock("@xyflow/react", () => ({ useStore: (selector: (state: StoreState) => unknown) => selector(storeState), })); +vi.mock("@/hooks/use-pipeline-preview", () => ({ + usePipelinePreview: () => ({ + canvasRef: { current: null }, + isRendering: false, + error: null, + }), +})); + vi.mock("../nodes/base-node-wrapper", () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, })); @@ -41,6 +61,8 @@ vi.mock("../nodes/compare-surface", () => ({ import CompareNode from "../nodes/compare-node"; +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + function renderCompareNode(props: Record) { return renderToStaticMarkup( ) { } describe("CompareNode render preview inputs", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + beforeEach(() => { storeState.nodes = []; storeState.edges = []; compareSurfaceSpy.mockReset(); + resizeObserverCallback = null; + globalThis.ResizeObserver = class ResizeObserver { + constructor(callback: (entries: ResizeObserverEntryLike[]) => void) { + resizeObserverCallback = callback; + } + + observe(target: Element) { + resizeObserverCallback?.([ + { + target, + contentRect: { width: 500, height: 380 }, + }, + ]); + } + + unobserve() {} + + disconnect() {} + } as unknown as typeof ResizeObserver; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount(); + }); + } + + container?.remove(); + root = null; + container = null; }); it("passes previewInput to CompareSurface for a connected render node without final output", () => { @@ -167,6 +226,108 @@ describe("CompareNode render preview inputs", () => { }); }); + it("defaults mixer-backed render compare inputs to preview mode when only sourceComposition exists", () => { + storeState.nodes = [ + { + id: "base-image", + type: "image", + data: { url: "https://cdn.example.com/base.png" }, + }, + { + id: "overlay-image", + type: "asset", + data: { url: "https://cdn.example.com/overlay.png" }, + }, + { + id: "mixer-1", + type: "mixer", + data: { + blendMode: "multiply", + opacity: 62, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.4, + overlayHeight: 0.5, + cropLeft: 0.1, + cropTop: 0, + cropRight: 0.2, + cropBottom: 0.1, + }, + }, + { + id: "render-1", + type: "render", + data: { + lastUploadUrl: "https://cdn.example.com/stale-render-output.png", + }, + }, + ]; + storeState.edges = [ + { + id: "edge-base-mixer", + source: "base-image", + target: "mixer-1", + targetHandle: "base", + }, + { + id: "edge-overlay-mixer", + source: "overlay-image", + target: "mixer-1", + targetHandle: "overlay", + }, + { id: "edge-mixer-render", source: "mixer-1", target: "render-1" }, + { + id: "edge-render-compare", + source: "render-1", + target: "compare-1", + targetHandle: "left", + }, + ]; + + renderCompareNode({ + id: "compare-1", + data: { leftUrl: "https://cdn.example.com/stale-render-output.png" }, + selected: false, + dragging: false, + zIndex: 0, + isConnectable: true, + type: "compare", + xPos: 0, + yPos: 0, + width: 500, + height: 380, + sourcePosition: undefined, + targetPosition: undefined, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + }); + + expect(compareSurfaceSpy).toHaveBeenCalledTimes(1); + expect(compareSurfaceSpy.mock.calls[0]?.[0]).toMatchObject({ + finalUrl: "https://cdn.example.com/stale-render-output.png", + preferPreview: true, + previewInput: { + sourceUrl: null, + sourceComposition: { + kind: "mixer", + baseUrl: "https://cdn.example.com/base.png", + overlayUrl: "https://cdn.example.com/overlay.png", + blendMode: "multiply", + opacity: 62, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.4, + overlayHeight: 0.5, + cropLeft: 0.1, + cropTop: 0, + cropRight: 0.2, + cropBottom: 0.1, + }, + steps: [], + }, + }); + }); + it("prefers mixer composite preview over persisted compare finalUrl when mixer is connected", () => { storeState.nodes = [ { @@ -250,6 +411,8 @@ describe("CompareNode render preview inputs", () => { ); expect(mixerCall?.[0]).toMatchObject({ finalUrl: undefined, + nodeWidth: 500, + nodeHeight: 380, mixerPreviewState: { status: "ready", baseUrl: "https://cdn.example.com/base.png", @@ -260,7 +423,190 @@ describe("CompareNode render preview inputs", () => { overlayY: 0, overlayWidth: 1, overlayHeight: 1, + cropLeft: 0, + cropTop: 0, + cropRight: 0, + cropBottom: 0, }, }); }); + + it("passes the measured compare surface size to mixer previews instead of the full node box", async () => { + storeState.nodes = [ + { + id: "base-image", + type: "image", + data: { url: "https://cdn.example.com/base.png" }, + }, + { + id: "overlay-image", + type: "asset", + data: { url: "https://cdn.example.com/overlay.png" }, + }, + { + id: "mixer-1", + type: "mixer", + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.6, + overlayHeight: 0.5, + }, + }, + ]; + storeState.edges = [ + { + id: "edge-base-mixer", + source: "base-image", + target: "mixer-1", + targetHandle: "base", + }, + { + id: "edge-overlay-mixer", + source: "overlay-image", + target: "mixer-1", + targetHandle: "overlay", + }, + { + id: "edge-mixer-compare", + source: "mixer-1", + target: "compare-1", + targetHandle: "left", + }, + ]; + + await act(async () => { + root?.render( + } + edges={storeState.edges} + > + )} + /> + , + ); + }); + + await vi.waitFor(() => { + const latestCompareSurfaceCall = compareSurfaceSpy.mock.calls.findLast( + ([props]) => + Boolean((props as { mixerPreviewState?: { status?: string } }).mixerPreviewState), + ); + + expect(latestCompareSurfaceCall?.[0]).toMatchObject({ + nodeWidth: 500, + nodeHeight: 380, + }); + }); + + const surfaceElement = container?.querySelector(".nodrag.relative.min-h-0.w-full"); + expect(surfaceElement).toBeInstanceOf(HTMLDivElement); + + await act(async () => { + resizeObserverCallback?.([ + { + target: surfaceElement as HTMLDivElement, + contentRect: { width: 468, height: 312 }, + }, + ]); + }); + + const latestCompareSurfaceCall = compareSurfaceSpy.mock.calls.findLast( + ([props]) => + Boolean((props as { mixerPreviewState?: { status?: string } }).mixerPreviewState), + ); + + expect(latestCompareSurfaceCall?.[0]).toMatchObject({ + nodeWidth: 468, + nodeHeight: 312, + }); + expect(latestCompareSurfaceCall?.[0]).not.toMatchObject({ + nodeWidth: 640, + nodeHeight: 480, + }); + }); + + it("anchors direct mixer previews to the actual compare surface rect", async () => { + const compareSurfaceModule = await vi.importActual( + "../nodes/compare-surface", + ); + const ActualCompareSurface = compareSurfaceModule.default; + + await act(async () => { + root?.render( + + + , + ); + }); + + const images = container?.querySelectorAll("img"); + const baseImage = images?.[0]; + + if (!(baseImage instanceof HTMLImageElement)) { + throw new Error("base image not found"); + } + + Object.defineProperty(baseImage, "naturalWidth", { configurable: true, value: 200 }); + Object.defineProperty(baseImage, "naturalHeight", { configurable: true, value: 100 }); + + await act(async () => { + baseImage.dispatchEvent(new Event("load")); + }); + + const overlayImage = container?.querySelectorAll("img")?.[1]; + if (!(overlayImage instanceof HTMLImageElement)) { + throw new Error("overlay image not found"); + } + + Object.defineProperty(overlayImage, "naturalWidth", { configurable: true, value: 200 }); + Object.defineProperty(overlayImage, "naturalHeight", { configurable: true, value: 100 }); + + await act(async () => { + overlayImage.dispatchEvent(new Event("load")); + }); + + const overlayFrame = overlayImage.parentElement; + expect(overlayFrame?.style.left).toBe("0%"); + expect(overlayFrame?.style.top).toBe("17.105263157894736%"); + expect(overlayFrame?.style.width).toBe("100%"); + expect(overlayFrame?.style.height).toBe("65.78947368421053%"); + }); }); diff --git a/components/canvas/__tests__/mixer-node.test.tsx b/components/canvas/__tests__/mixer-node.test.tsx index 2f3874a..4a4a824 100644 --- a/components/canvas/__tests__/mixer-node.test.tsx +++ b/components/canvas/__tests__/mixer-node.test.tsx @@ -46,6 +46,15 @@ type TestEdge = { targetHandle?: string; }; +function cropRectData(x: number, y: number, width: number, height: number) { + return { + cropLeft: x, + cropTop: y, + cropRight: 1 - (x + width), + cropBottom: 1 - (y + height), + }; +} + function buildMixerNodeProps(overrides?: Partial>) { return { id: "mixer-1", @@ -56,6 +65,7 @@ function buildMixerNodeProps(overrides?: Partial { let container: HTMLDivElement | null = null; let root: Root | null = null; + let resizeObserverCallback: + | ((entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) + | null = null; const readyNodes: TestNode[] = [ { id: "image-base", type: "image", data: { url: "https://cdn.example.com/base.png" } }, @@ -91,6 +104,7 @@ describe("MixerNode", () => { overlayY: 0, overlayWidth: 0.5, overlayHeight: 0.5, + ...cropRectData(0, 0, 1, 1), }, }, ]; @@ -103,6 +117,29 @@ describe("MixerNode", () => { beforeEach(() => { vi.useFakeTimers(); mocks.queueNodeDataUpdate.mockClear(); + resizeObserverCallback = null; + globalThis.ResizeObserver = class ResizeObserver { + constructor( + callback: ( + entries: Array<{ target: Element; contentRect: { width: number; height: number } }>, + ) => void, + ) { + resizeObserverCallback = callback; + } + + observe(target: Element) { + resizeObserverCallback?.([ + { + target, + contentRect: { width: 200, height: 200 }, + }, + ]); + } + + unobserve() {} + + disconnect() {} + } as unknown as typeof ResizeObserver; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -137,6 +174,29 @@ describe("MixerNode", () => { }); } + function mockPreviewRect(preview: HTMLDivElement) { + return vi.spyOn(preview, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 200, + bottom: 200, + width: 200, + height: 200, + toJSON: () => ({}), + }); + } + + async function setNaturalImageSize(image: HTMLImageElement, width: number, height: number) { + Object.defineProperty(image, "naturalWidth", { configurable: true, value: width }); + Object.defineProperty(image, "naturalHeight", { configurable: true, value: height }); + + await act(async () => { + image.dispatchEvent(new Event("load")); + }); + } + it("renders empty state copy when no inputs are connected", async () => { await renderNode(); @@ -165,6 +225,519 @@ describe("MixerNode", () => { expect(overlayImage).toBeTruthy(); }); + it("anchors the preview overlay frame to the visible base cover rect", async () => { + await renderNode({ + nodes: [ + { + id: "image-base", + type: "image", + data: { + url: "https://cdn.example.com/base.png", + intrinsicWidth: 200, + intrinsicHeight: 100, + }, + }, + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0, + overlayY: 0, + overlayWidth: 1, + overlayHeight: 1, + ...cropRectData(0, 0, 1, 1), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0, + overlayY: 0, + overlayWidth: 1, + overlayHeight: 1, + ...cropRectData(0, 0, 1, 1), + }, + }, + }); + + const overlayFrame = container?.querySelector('[data-testid="mixer-overlay"]'); + if (!(overlayFrame instanceof HTMLDivElement)) { + throw new Error("overlay frame not found"); + } + + expect(overlayFrame.style.left).toBe("-50%"); + expect(overlayFrame.style.top).toBe("0%"); + expect(overlayFrame.style.width).toBe("200%"); + expect(overlayFrame.style.height).toBe("100%"); + }); + + it("anchors resize handles to the displayed overlay frame rect on wide bases", async () => { + await renderNode({ + nodes: [ + { + id: "image-base", + type: "image", + data: { + url: "https://cdn.example.com/base.png", + intrinsicWidth: 200, + intrinsicHeight: 100, + }, + }, + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.25, + overlayHeight: 0.4, + ...cropRectData(0, 0, 1, 1), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.25, + overlayHeight: 0.4, + ...cropRectData(0, 0, 1, 1), + }, + }, + }); + + const northWestHandle = container?.querySelector('[data-testid="mixer-resize-nw"]'); + const southEastHandle = container?.querySelector('[data-testid="mixer-resize-se"]'); + + if (!(northWestHandle instanceof HTMLDivElement)) { + throw new Error("north west handle not found"); + } + if (!(southEastHandle instanceof HTMLDivElement)) { + throw new Error("south east handle not found"); + } + + expect(Number.parseFloat(northWestHandle.style.left)).toBeCloseTo(-30, 6); + expect(Number.parseFloat(northWestHandle.style.top)).toBeCloseTo(20, 6); + expect(Number.parseFloat(southEastHandle.style.left)).toBeCloseTo(20, 6); + expect(Number.parseFloat(southEastHandle.style.top)).toBeCloseTo(60, 6); + }); + + it("anchors crop box handles to the displayed overlay frame rect on wide bases", async () => { + await renderNode({ + nodes: [ + { + id: "image-base", + type: "image", + data: { + url: "https://cdn.example.com/base.png", + intrinsicWidth: 200, + intrinsicHeight: 100, + }, + }, + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.25, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.25, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + }); + + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + const overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + + await setNaturalImageSize(overlayContent, 100, 100); + + await act(async () => { + contentModeToggle.click(); + }); + + const northWestHandle = container?.querySelector('[data-testid="mixer-resize-nw"]'); + const eastHandle = container?.querySelector('[data-testid="mixer-resize-e"]'); + + if (!(northWestHandle instanceof HTMLDivElement)) { + throw new Error("north west crop handle not found"); + } + if (!(eastHandle instanceof HTMLDivElement)) { + throw new Error("east crop handle not found"); + } + + expect(Number.parseFloat(northWestHandle.style.left)).toBeCloseTo(-25, 6); + expect(Number.parseFloat(northWestHandle.style.top)).toBeCloseTo(20, 6); + expect(Number.parseFloat(eastHandle.style.left)).toBeCloseTo(15, 6); + expect(Number.parseFloat(eastHandle.style.top)).toBeCloseTo(40, 6); + }); + + it("uses displayed base rect scaling for frame move deltas on wide bases", async () => { + await renderNode({ + nodes: [ + { + id: "image-base", + type: "image", + data: { + url: "https://cdn.example.com/base.png", + intrinsicWidth: 200, + intrinsicHeight: 100, + }, + }, + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.25, + overlayHeight: 0.4, + ...cropRectData(0, 0, 1, 1), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.25, + overlayHeight: 0.4, + ...cropRectData(0, 0, 1, 1), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const overlayFrame = container?.querySelector('[data-testid="mixer-overlay"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(overlayFrame instanceof HTMLDivElement)) { + throw new Error("overlay frame not found"); + } + + mockPreviewRect(preview); + + await act(async () => { + overlayFrame.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 10, clientY: 40 })); + }); + + await act(async () => { + window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 50, clientY: 60 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + const rawLastCall = mocks.queueNodeDataUpdate.mock.calls.at(-1) as unknown[] | undefined; + const lastCall = rawLastCall?.[0] as + | { nodeId: string; data: Record } + | undefined; + + expect(lastCall?.nodeId).toBe("mixer-1"); + expect(lastCall?.data.overlayX as number).toBeCloseTo(0.2, 6); + expect(lastCall?.data.overlayY as number).toBeCloseTo(0.3, 6); + }); + + it("uses displayed base rect scaling for frame resize deltas on wide bases", async () => { + await renderNode({ + nodes: [ + { + id: "image-base", + type: "image", + data: { + url: "https://cdn.example.com/base.png", + intrinsicWidth: 200, + intrinsicHeight: 100, + }, + }, + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.25, + overlayHeight: 0.4, + ...cropRectData(0, 0, 1, 1), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.25, + overlayHeight: 0.4, + ...cropRectData(0, 0, 1, 1), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const resizeHandle = container?.querySelector('[data-testid="mixer-resize-se"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(resizeHandle instanceof HTMLDivElement)) { + throw new Error("resize handle not found"); + } + + mockPreviewRect(preview); + + await act(async () => { + resizeHandle.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 40, clientY: 120 })); + }); + + await act(async () => { + window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 80, clientY: 120 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + const rawLastCall = mocks.queueNodeDataUpdate.mock.calls.at(-1) as unknown[] | undefined; + const lastCall = rawLastCall?.[0] as + | { nodeId: string; data: Record } + | undefined; + + expect(lastCall?.nodeId).toBe("mixer-1"); + expect(lastCall?.data.overlayX as number).toBeCloseTo(0.1, 6); + expect(lastCall?.data.overlayY as number).toBeCloseTo(0.2, 6); + expect(lastCall?.data.overlayWidth as number).toBeCloseTo(0.35, 6); + expect(lastCall?.data.overlayHeight as number).toBeCloseTo(0.56, 6); + }); + + it("uses displayed overlay frame scaling for crop move deltas on wide bases", async () => { + await renderNode({ + nodes: [ + { + id: "image-base", + type: "image", + data: { + url: "https://cdn.example.com/base.png", + intrinsicWidth: 200, + intrinsicHeight: 100, + }, + }, + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.25, + overlayHeight: 0.4, + ...cropRectData(0, 0, 0.6, 0.6), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.25, + overlayHeight: 0.4, + ...cropRectData(0, 0, 0.6, 0.6), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + const overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + + mockPreviewRect(preview); + await setNaturalImageSize(overlayContent, 100, 80); + + await act(async () => { + contentModeToggle.click(); + }); + + const cropBox = container?.querySelector('[data-testid="mixer-crop-box"]'); + if (!(cropBox instanceof HTMLDivElement)) { + throw new Error("crop box not found"); + } + + await act(async () => { + cropBox.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: -10, clientY: 40 })); + }); + + await act(async () => { + window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 10, clientY: 52 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + const rawLastCall = mocks.queueNodeDataUpdate.mock.calls.at(-1) as unknown[] | undefined; + const lastCall = rawLastCall?.[0] as + | { nodeId: string; data: Record } + | undefined; + + expect(lastCall?.nodeId).toBe("mixer-1"); + expect(lastCall?.data.cropLeft as number).toBeCloseTo(0.12, 6); + expect(lastCall?.data.cropTop as number).toBeCloseTo(0.09, 6); + expect(lastCall?.data.cropRight as number).toBeCloseTo(0.28, 6); + expect(lastCall?.data.cropBottom as number).toBeCloseTo(0.31, 6); + }); + + it("uses displayed overlay frame scaling for crop resize deltas on wide bases", async () => { + await renderNode({ + nodes: [ + { + id: "image-base", + type: "image", + data: { + url: "https://cdn.example.com/base.png", + intrinsicWidth: 200, + intrinsicHeight: 100, + }, + }, + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.25, + overlayHeight: 0.4, + ...cropRectData(0, 0, 0.6, 0.6), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.25, + overlayHeight: 0.4, + ...cropRectData(0, 0, 0.6, 0.6), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + const overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + + mockPreviewRect(preview); + await setNaturalImageSize(overlayContent, 100, 80); + + await act(async () => { + contentModeToggle.click(); + }); + + const resizeHandle = container?.querySelector('[data-testid="mixer-resize-e"]'); + if (!(resizeHandle instanceof HTMLDivElement)) { + throw new Error("east resize handle not found"); + } + + await act(async () => { + resizeHandle.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 20, clientY: 40 })); + }); + + await act(async () => { + window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 40, clientY: 40 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + expect(mocks.queueNodeDataUpdate).toHaveBeenLastCalledWith({ + nodeId: "mixer-1", + data: expect.objectContaining({ + cropLeft: 0, + cropTop: 0, + cropRight: 0.28, + cropBottom: 0.4, + }), + }); + }); + + it("maps overlay content through crop/source-region styles instead of contain-fit", async () => { + await renderNode({ nodes: readyNodes, edges: readyEdges }); + + const overlayImage = container?.querySelector('img[alt="Mixer overlay"]'); + if (!(overlayImage instanceof HTMLImageElement)) { + throw new Error("overlay image not found"); + } + + expect(overlayImage.className).not.toContain("object-contain"); + expect(overlayImage.style.width).toBe("100%"); + expect(overlayImage.style.height).toBe("100%"); + }); + it("drag updates persisted overlay geometry", async () => { await renderNode({ nodes: readyNodes, edges: readyEdges }); @@ -174,8 +747,8 @@ describe("MixerNode", () => { if (!(preview instanceof HTMLDivElement)) { throw new Error("preview not found"); } - if (!(overlay instanceof HTMLImageElement)) { - throw new Error("overlay image not found"); + if (!(overlay instanceof HTMLDivElement)) { + throw new Error("overlay frame not found"); } vi.spyOn(preview, "getBoundingClientRect").mockReturnValue({ @@ -207,6 +780,7 @@ describe("MixerNode", () => { overlayY: 0.1, overlayWidth: 0.5, overlayHeight: 0.5, + ...cropRectData(0, 0, 1, 1), }), }); }); @@ -220,8 +794,8 @@ describe("MixerNode", () => { if (!(preview instanceof HTMLDivElement)) { throw new Error("preview not found"); } - if (!(overlay instanceof HTMLImageElement)) { - throw new Error("overlay image not found"); + if (!(overlay instanceof HTMLDivElement)) { + throw new Error("overlay frame not found"); } vi.spyOn(preview, "getBoundingClientRect").mockReturnValue({ @@ -253,12 +827,42 @@ describe("MixerNode", () => { overlayY: 0.5, overlayWidth: 0.5, overlayHeight: 0.5, + ...cropRectData(0, 0, 1, 1), }), }); }); - it("resize updates persisted overlay width and height", async () => { - await renderNode({ nodes: readyNodes, edges: readyEdges }); + it("frame resize keeps the displayed overlay aspect ratio locked", async () => { + await renderNode({ + nodes: [ + readyNodes[0], + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.2, 0.1, 0.4, 0.6), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.2, 0.1, 0.4, 0.6), + }, + }, + }); const preview = container?.querySelector('[data-testid="mixer-preview"]'); const resizeHandle = container?.querySelector('[data-testid="mixer-resize-se"]'); @@ -294,11 +898,89 @@ describe("MixerNode", () => { await vi.advanceTimersByTimeAsync(250); }); + const rawLastCall = mocks.queueNodeDataUpdate.mock.calls.at(-1) as unknown[] | undefined; + const lastCall = rawLastCall?.[0] as + | { nodeId: string; data: Record } + | undefined; + + expect(lastCall?.nodeId).toBe("mixer-1"); + expect(lastCall?.data).toEqual( + expect.objectContaining({ + overlayX: 0.1, + overlayY: 0.2, + ...cropRectData(0.2, 0.1, 0.4, 0.6), + }), + ); + expect( + (lastCall?.data.overlayWidth as number) / (lastCall?.data.overlayHeight as number), + ).toBeCloseTo(1.25, 6); + }); + + it("frame resize preserves crop fields while scaling the displayed overlay proportionally", async () => { + await renderNode({ + nodes: [ + readyNodes[0], + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.2, 0.1, 0.4, 0.6), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.2, 0.1, 0.4, 0.6), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const resizeHandle = container?.querySelector('[data-testid="mixer-resize-se"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(resizeHandle instanceof HTMLDivElement)) { + throw new Error("resize handle not found"); + } + + mockPreviewRect(preview); + + expect(resizeHandle.style.left).toBe("60%"); + expect(resizeHandle.style.top).toBe("60.00000000000001%"); + + await act(async () => { + resizeHandle.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 120, clientY: 120 })); + }); + + await act(async () => { + window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 140, clientY: 140 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + expect(mocks.queueNodeDataUpdate).toHaveBeenLastCalledWith({ nodeId: "mixer-1", data: expect.objectContaining({ - overlayWidth: 0.7, - overlayHeight: 0.6, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.625, + overlayHeight: 0.5, + ...cropRectData(0.2, 0.1, 0.4, 0.6), }), }); }); @@ -345,10 +1027,1078 @@ describe("MixerNode", () => { data: expect.objectContaining({ overlayWidth: 0.1, overlayHeight: 0.1, + ...cropRectData(0, 0, 1, 1), }), }); }); + it("renders explicit content framing mode toggle", async () => { + await renderNode({ nodes: readyNodes, edges: readyEdges }); + + expect(container?.querySelector('[data-testid="mixer-content-mode-toggle"]')).toBeTruthy(); + }); + + it("crop drag inside the crop box repositions the crop region only", async () => { + await renderNode({ + nodes: [ + readyNodes[0], + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0, + overlayY: 0, + overlayWidth: 0.5, + overlayHeight: 0.5, + ...cropRectData(0, 0, 0.6, 0.6), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0, + overlayY: 0, + overlayWidth: 0.5, + overlayHeight: 0.5, + ...cropRectData(0, 0, 0.6, 0.6), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + const overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + vi.spyOn(preview, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 200, + bottom: 200, + width: 200, + height: 200, + toJSON: () => ({}), + }); + await setNaturalImageSize(overlayContent, 100, 100); + + await act(async () => { + contentModeToggle.click(); + }); + + const cropBox = container?.querySelector('[data-testid="mixer-crop-box"]'); + if (!(cropBox instanceof HTMLDivElement)) { + throw new Error("crop box not found"); + } + + await act(async () => { + cropBox.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, clientX: 50, clientY: 50 }), + ); + }); + + await act(async () => { + window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 90, clientY: 70 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + const rawLastCall = mocks.queueNodeDataUpdate.mock.calls.at(-1) as unknown[] | undefined; + const lastCall = rawLastCall?.[0] as + | { nodeId: string; data: Record } + | undefined; + expect(lastCall?.nodeId).toBe("mixer-1"); + expect(lastCall?.data).toEqual( + expect.objectContaining({ + overlayX: 0, + overlayY: 0, + overlayWidth: 0.5, + overlayHeight: 0.5, + ...cropRectData(0.24, 0.12, 0.6, 0.6), + }), + ); + }); + + it("content framing supports zooming content before drag from defaults", async () => { + await renderNode({ nodes: readyNodes, edges: readyEdges }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + const cropRight = container?.querySelector('input[name="cropRight"]'); + const cropBottom = container?.querySelector('input[name="cropBottom"]'); + const overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + if (!(cropRight instanceof HTMLInputElement)) { + throw new Error("cropRight input not found"); + } + if (!(cropBottom instanceof HTMLInputElement)) { + throw new Error("cropBottom input not found"); + } + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + vi.spyOn(preview, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 200, + bottom: 200, + width: 200, + height: 200, + toJSON: () => ({}), + }); + await setNaturalImageSize(overlayContent, 100, 100); + + await act(async () => { + contentModeToggle.click(); + }); + + const cropBox = container?.querySelector('[data-testid="mixer-crop-box"]'); + if (!(cropBox instanceof HTMLDivElement)) { + throw new Error("crop box not found"); + } + + await act(async () => { + cropRight.value = "0.4"; + cropRight.dispatchEvent(new Event("input", { bubbles: true })); + cropRight.dispatchEvent(new Event("change", { bubbles: true })); + cropBottom.value = "0.3"; + cropBottom.dispatchEvent(new Event("input", { bubbles: true })); + cropBottom.dispatchEvent(new Event("change", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + await act(async () => { + cropBox.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, clientX: 50, clientY: 50 }), + ); + }); + + await act(async () => { + window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 90, clientY: 70 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + const rawLastCall = mocks.queueNodeDataUpdate.mock.calls.at(-1) as unknown[] | undefined; + const lastCall = rawLastCall?.[0] as + | { nodeId: string; data: Record } + | undefined; + expect(lastCall?.nodeId).toBe("mixer-1"); + expect(lastCall?.data).toEqual( + expect.objectContaining({ + overlayX: 0, + overlayY: 0, + overlayWidth: 0.5, + overlayHeight: 0.5, + }), + ); + expect(lastCall?.data.cropLeft as number).toBeCloseTo(0.28, 6); + expect(lastCall?.data.cropTop as number).toBeCloseTo(0.14, 6); + expect(lastCall?.data.cropRight as number).toBeCloseTo(0.12, 6); + expect(lastCall?.data.cropBottom as number).toBeCloseTo(0.16, 6); + }); + + it("crop drag uses the crop box as the movement frame instead of the full overlay", async () => { + await renderNode({ + nodes: [ + readyNodes[0], + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.1, 0.5, 0.5), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.1, 0.5, 0.5), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + const overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + vi.spyOn(preview, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 200, + bottom: 200, + width: 200, + height: 200, + toJSON: () => ({}), + }); + await setNaturalImageSize(overlayContent, 100, 100); + + await act(async () => { + contentModeToggle.click(); + }); + + const cropBox = container?.querySelector('[data-testid="mixer-crop-box"]'); + if (!(cropBox instanceof HTMLDivElement)) { + throw new Error("crop box not found"); + } + + await act(async () => { + cropBox.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, clientX: 50, clientY: 50 }), + ); + }); + + await act(async () => { + window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 70, clientY: 66 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + const rawLastCall = mocks.queueNodeDataUpdate.mock.calls.at(-1) as unknown[] | undefined; + const lastCall = rawLastCall?.[0] as + | { nodeId: string; data: Record } + | undefined; + expect(lastCall?.nodeId).toBe("mixer-1"); + expect(lastCall?.data).toEqual( + expect.objectContaining({ + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.225, 0.2, 0.5, 0.5), + }), + ); + }); + + it("crop handles render on the crop box while the displayed overlay frame stays fixed", async () => { + await renderNode({ + nodes: [ + readyNodes[0], + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + const overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + mockPreviewRect(preview); + await setNaturalImageSize(overlayContent, 50, 100); + + await act(async () => { + contentModeToggle.click(); + }); + + const cropBox = container?.querySelector('[data-testid="mixer-crop-box"]'); + if (!(cropBox instanceof HTMLDivElement)) { + throw new Error("crop box not found"); + } + + expect(Number.parseFloat(cropBox.style.left)).toBeCloseTo(30, 6); + expect(Number.parseFloat(cropBox.style.top)).toBeCloseTo(0, 6); + expect(Number.parseFloat(cropBox.style.width)).toBeCloseTo(40, 6); + expect(Number.parseFloat(cropBox.style.height)).toBeCloseTo(100, 6); + const northWestHandle = container?.querySelector('[data-testid="mixer-resize-nw"]'); + const northHandle = container?.querySelector('[data-testid="mixer-resize-n"]'); + const southEastHandle = container?.querySelector('[data-testid="mixer-resize-se"]'); + if (!(northWestHandle instanceof HTMLDivElement)) { + throw new Error("north west handle not found"); + } + if (!(northHandle instanceof HTMLDivElement)) { + throw new Error("north handle not found"); + } + if (!(southEastHandle instanceof HTMLDivElement)) { + throw new Error("south east handle not found"); + } + + expect(Number.parseFloat(northWestHandle.style.left)).toBeCloseTo(25, 6); + expect(Number.parseFloat(northWestHandle.style.top)).toBeCloseTo(20, 6); + expect(Number.parseFloat(northHandle.style.left)).toBeCloseTo(35, 6); + expect(Number.parseFloat(northHandle.style.top)).toBeCloseTo(20, 6); + expect(Number.parseFloat(southEastHandle.style.left)).toBeCloseTo(45, 6); + expect(Number.parseFloat(southEastHandle.style.top)).toBeCloseTo(60, 6); + }); + + it("crop move uses the visible aspect-aware rect for non-square overlays", async () => { + await renderNode({ + nodes: [ + readyNodes[0], + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + const overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + + mockPreviewRect(preview); + await setNaturalImageSize(overlayContent, 50, 100); + + await act(async () => { + contentModeToggle.click(); + }); + + const cropBox = container?.querySelector('[data-testid="mixer-crop-box"]'); + if (!(cropBox instanceof HTMLDivElement)) { + throw new Error("crop box not found"); + } + + await act(async () => { + cropBox.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 55, clientY: 66 })); + }); + + await act(async () => { + window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 65, clientY: 76 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + const rawLastCall = mocks.queueNodeDataUpdate.mock.calls.at(-1) as unknown[] | undefined; + const lastCall = rawLastCall?.[0] as + | { nodeId: string; data: Record } + | undefined; + + expect(lastCall?.nodeId).toBe("mixer-1"); + expect(lastCall?.data).toEqual( + expect.objectContaining({ + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + }), + ); + expect(lastCall?.data.cropLeft as number).toBeCloseTo(0.225, 6); + expect(lastCall?.data.cropTop as number).toBeCloseTo(0.2625, 6); + expect(lastCall?.data.cropRight as number).toBeCloseTo(0.275, 6); + expect(lastCall?.data.cropBottom as number).toBeCloseTo(0.2375, 6); + }); + + it("crop resize uses the visible aspect-aware rect for non-square overlays", async () => { + await renderNode({ + nodes: [ + readyNodes[0], + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + const overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + + mockPreviewRect(preview); + await setNaturalImageSize(overlayContent, 50, 100); + + await act(async () => { + contentModeToggle.click(); + }); + + const resizeHandle = container?.querySelector('[data-testid="mixer-resize-e"]'); + if (!(resizeHandle instanceof HTMLDivElement)) { + throw new Error("east resize handle not found"); + } + + await act(async () => { + resizeHandle.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 70, clientY: 76 })); + }); + + await act(async () => { + window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 80, clientY: 76 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + const rawLastCall = mocks.queueNodeDataUpdate.mock.calls.at(-1) as unknown[] | undefined; + const lastCall = rawLastCall?.[0] as + | { nodeId: string; data: Record } + | undefined; + + expect(lastCall?.nodeId).toBe("mixer-1"); + expect(lastCall?.data).toEqual( + expect.objectContaining({ + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + cropLeft: 0.1, + cropTop: 0.2, + }), + ); + expect(lastCall?.data.cropBottom as number).toBeCloseTo(0.3, 6); + expect(lastCall?.data.cropRight as number).toBeCloseTo(0.275, 6); + }); + + it("ignores crop interactions until overlay natural size is known", async () => { + await renderNode({ + nodes: [ + readyNodes[0], + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + + mockPreviewRect(preview); + + await act(async () => { + contentModeToggle.click(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + expect(container?.querySelector('[data-testid="mixer-crop-box"]')).toBeNull(); + expect(container?.querySelector('[data-testid="mixer-resize-e"]')).toBeNull(); + expect(mocks.queueNodeDataUpdate).not.toHaveBeenCalled(); + }); + + it("does not render crop affordances until overlay natural size is known", async () => { + await renderNode({ + nodes: readyNodes, + edges: readyEdges, + }); + + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + + await act(async () => { + contentModeToggle.click(); + }); + + expect(container?.querySelector('[data-testid="mixer-crop-box"]')).toBeNull(); + expect(container?.querySelector('[data-testid="mixer-resize-e"]')).toBeNull(); + }); + + it("ignores crop interactions after overlay source swap until new natural size loads", async () => { + await renderNode({ + nodes: [ + readyNodes[0], + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + let overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + + mockPreviewRect(preview); + await setNaturalImageSize(overlayContent, 100, 100); + + await act(async () => { + contentModeToggle.click(); + }); + + await renderNode({ + nodes: [ + readyNodes[0], + { id: "image-overlay", type: "asset", data: { url: "https://cdn.example.com/overlay-2.png" } }, + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + }); + + const swappedPreview = container?.querySelector('[data-testid="mixer-preview"]'); + if (!(swappedPreview instanceof HTMLDivElement)) { + throw new Error("preview not found after source swap"); + } + mockPreviewRect(swappedPreview); + + mocks.queueNodeDataUpdate.mockClear(); + + overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found after source swap"); + } + expect(overlayContent.getAttribute("src")).toBe("https://cdn.example.com/overlay-2.png"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + expect(container?.querySelector('[data-testid="mixer-crop-box"]')).toBeNull(); + expect(container?.querySelector('[data-testid="mixer-resize-e"]')).toBeNull(); + expect(mocks.queueNodeDataUpdate).not.toHaveBeenCalled(); + }); + + it("hides crop affordances after overlay source swap until the new image loads", async () => { + await renderNode({ + nodes: readyNodes, + edges: readyEdges, + }); + + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + let overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + + await setNaturalImageSize(overlayContent, 100, 100); + + await act(async () => { + contentModeToggle.click(); + }); + + await renderNode({ + nodes: [ + readyNodes[0], + { id: "image-overlay", type: "asset", data: { url: "https://cdn.example.com/overlay-2.png" } }, + readyNodes[2], + ], + edges: readyEdges, + }); + + overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found after source swap"); + } + expect(overlayContent.getAttribute("src")).toBe("https://cdn.example.com/overlay-2.png"); + expect(container?.querySelector('[data-testid="mixer-crop-box"]')).toBeNull(); + expect(container?.querySelector('[data-testid="mixer-resize-e"]')).toBeNull(); + }); + + it("crop handle drag trims edges without changing displayed overlay frame size", async () => { + await renderNode({ + nodes: [ + readyNodes[0], + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + ...cropRectData(0.1, 0.2, 0.5, 0.5), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + const overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + const resizeHandle = container?.querySelector('[data-testid="mixer-resize-se"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + if (!(resizeHandle instanceof HTMLDivElement)) { + throw new Error("resize handle not found"); + } + + mockPreviewRect(preview); + await setNaturalImageSize(overlayContent, 100, 100); + + await act(async () => { + contentModeToggle.click(); + }); + + await act(async () => { + resizeHandle.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 80, clientY: 96 })); + }); + + await act(async () => { + window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 100, clientY: 116 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + const rawLastCall = mocks.queueNodeDataUpdate.mock.calls.at(-1) as unknown[] | undefined; + const lastCall = rawLastCall?.[0] as + | { nodeId: string; data: Record } + | undefined; + + expect(lastCall?.nodeId).toBe("mixer-1"); + expect(lastCall?.data).toEqual( + expect.objectContaining({ + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.4, + cropLeft: 0.1, + cropTop: 0.2, + }), + ); + expect(lastCall?.data.cropRight as number).toBeLessThan(0.4); + expect(lastCall?.data.cropBottom as number).toBeLessThan(0.3); + }); + + it("crop edge handles trim a single side only", async () => { + await renderNode({ + nodes: [ + readyNodes[0], + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.15, + overlayY: 0.1, + overlayWidth: 0.55, + overlayHeight: 0.45, + ...cropRectData(0.2, 0.1, 0.5, 0.5), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.15, + overlayY: 0.1, + overlayWidth: 0.55, + overlayHeight: 0.45, + ...cropRectData(0.2, 0.1, 0.5, 0.5), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + const overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + mockPreviewRect(preview); + await setNaturalImageSize(overlayContent, 100, 100); + + await act(async () => { + contentModeToggle.click(); + }); + + const resizeHandle = container?.querySelector('[data-testid="mixer-resize-e"]'); + if (!(resizeHandle instanceof HTMLDivElement)) { + throw new Error("east resize handle not found"); + } + + await act(async () => { + resizeHandle.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 92, clientY: 65 })); + }); + + await act(async () => { + window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 112, clientY: 65 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + const rawLastCall = mocks.queueNodeDataUpdate.mock.calls.at(-1) as unknown[] | undefined; + const lastCall = rawLastCall?.[0] as + | { nodeId: string; data: Record } + | undefined; + + expect(lastCall?.nodeId).toBe("mixer-1"); + expect(lastCall?.data).toEqual( + expect.objectContaining({ + overlayX: 0.15, + overlayY: 0.1, + overlayWidth: 0.55, + overlayHeight: 0.45, + cropLeft: 0.2, + cropTop: 0.1, + cropBottom: 0.4, + }), + ); + expect(lastCall?.data.cropRight as number).toBeLessThan(0.3); + }); + + it("crop handle drag does not mutate overlayWidth or overlayHeight", async () => { + await renderNode({ + nodes: [ + readyNodes[0], + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.15, + overlayY: 0.1, + overlayWidth: 0.55, + overlayHeight: 0.45, + ...cropRectData(0.2, 0.1, 0.5, 0.5), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.15, + overlayY: 0.1, + overlayWidth: 0.55, + overlayHeight: 0.45, + ...cropRectData(0.2, 0.1, 0.5, 0.5), + }, + }, + }); + + const preview = container?.querySelector('[data-testid="mixer-preview"]'); + const contentModeToggle = container?.querySelector('[data-testid="mixer-content-mode-toggle"]'); + const overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + const resizeHandle = container?.querySelector('[data-testid="mixer-resize-se"]'); + + if (!(preview instanceof HTMLDivElement)) { + throw new Error("preview not found"); + } + if (!(contentModeToggle instanceof HTMLButtonElement)) { + throw new Error("content mode toggle not found"); + } + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + if (!(resizeHandle instanceof HTMLDivElement)) { + throw new Error("resize handle not found"); + } + + mockPreviewRect(preview); + await setNaturalImageSize(overlayContent, 100, 100); + + await act(async () => { + contentModeToggle.click(); + }); + + await act(async () => { + resizeHandle.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 110, clientY: 110 })); + }); + + await act(async () => { + window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 125, clientY: 120 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + await vi.advanceTimersByTimeAsync(250); + }); + + const rawLastCall = mocks.queueNodeDataUpdate.mock.calls.at(-1) as unknown[] | undefined; + const lastCall = rawLastCall?.[0] as + | { nodeId: string; data: Record } + | undefined; + + expect(lastCall?.nodeId).toBe("mixer-1"); + expect(lastCall?.data).toEqual( + expect.objectContaining({ + overlayX: 0.15, + overlayY: 0.1, + overlayWidth: 0.55, + overlayHeight: 0.45, + }), + ); + expect(lastCall?.data.cropRight as number).not.toBe(0.3); + expect(lastCall?.data.cropBottom as number).not.toBe(0.4); + }); + + it("maps crop/source-region fields into a fixed displayed frame size", async () => { + await renderNode({ + nodes: [ + readyNodes[0], + readyNodes[1], + { + ...readyNodes[2], + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.5, + ...cropRectData(0.1, 0.2, 0.5, 0.25), + }, + }, + ], + edges: readyEdges, + props: { + data: { + blendMode: "normal", + opacity: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.5, + overlayHeight: 0.5, + ...cropRectData(0.1, 0.2, 0.5, 0.25), + }, + }, + }); + + const overlayContent = container?.querySelector('[data-testid="mixer-overlay-content"]'); + if (!(overlayContent instanceof HTMLImageElement)) { + throw new Error("overlay content image not found"); + } + + expect(overlayContent.style.left).toBe("-20%"); + expect(overlayContent.style.top).toBe("-80%"); + expect(overlayContent.style.width).toBe("200%"); + expect(overlayContent.style.height).toBe("400%"); + }); + it("numeric controls still update overlay rect fields", async () => { await renderNode(); diff --git a/components/canvas/nodes/compare-node.tsx b/components/canvas/nodes/compare-node.tsx index 715945a..823a2ce 100644 --- a/components/canvas/nodes/compare-node.tsx +++ b/components/canvas/nodes/compare-node.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Handle, Position, type NodeProps } from "@xyflow/react"; import { ImageIcon } from "lucide-react"; import BaseNodeWrapper from "./base-node-wrapper"; @@ -35,12 +35,18 @@ type CompareSideState = { type CompareDisplayMode = "render" | "preview"; -export default function CompareNode({ id, data, selected, width }: NodeProps) { +type CompareSurfaceSize = { + width: number; + height: number; +}; + +export default function CompareNode({ id, data, selected, width, height }: NodeProps) { const nodeData = data as CompareNodeData; const graph = useCanvasGraph(); const [sliderX, setSliderX] = useState(50); const [manualDisplayMode, setManualDisplayMode] = useState(null); const containerRef = useRef(null); + const [surfaceSize, setSurfaceSize] = useState(null); const incomingEdges = useMemo( () => graph.incomingEdgesByTarget.get(id) ?? [], [graph, id], @@ -73,11 +79,17 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) { graph, }); - if (preview.sourceUrl) { - previewInput = { - sourceUrl: preview.sourceUrl, - steps: preview.steps, - }; + if (preview.sourceUrl || preview.sourceComposition) { + previewInput = preview.sourceComposition + ? { + sourceUrl: null, + sourceComposition: preview.sourceComposition, + steps: preview.steps, + } + : { + sourceUrl: preview.sourceUrl, + steps: preview.steps, + }; const sourceLastUploadedHash = typeof sourceData.lastUploadedHash === "string" @@ -91,6 +103,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) { sourceLastUploadedHash ?? sourceLastRenderedHash; const sourceCurrentHash = resolveRenderPipelineHash({ sourceUrl: preview.sourceUrl, + sourceComposition: preview.sourceComposition, steps: preview.steps, data: sourceData, }); @@ -172,7 +185,60 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) { resolvedSides.right.isStaleRenderOutput; const effectiveDisplayMode = manualDisplayMode ?? (shouldDefaultToPreview ? "preview" : "render"); - const previewNodeWidth = Math.max(240, Math.min(640, Math.round(width ?? 500))); + const fallbackSurfaceWidth = Math.max(240, Math.min(640, Math.round(width ?? 500))); + const fallbackSurfaceHeight = Math.max(180, Math.min(720, Math.round(height ?? 380))); + const previewNodeWidth = Math.max( + 1, + Math.round(surfaceSize?.width ?? fallbackSurfaceWidth), + ); + const previewNodeHeight = Math.max( + 1, + Math.round(surfaceSize?.height ?? fallbackSurfaceHeight), + ); + + useEffect(() => { + const surfaceElement = containerRef.current; + if (!surfaceElement) { + return; + } + + const updateSurfaceSize = (nextWidth: number, nextHeight: number) => { + const roundedWidth = Math.max(1, Math.round(nextWidth)); + const roundedHeight = Math.max(1, Math.round(nextHeight)); + + setSurfaceSize((current) => + current?.width === roundedWidth && current?.height === roundedHeight + ? current + : { + width: roundedWidth, + height: roundedHeight, + }, + ); + }; + + const measureSurface = () => { + const rect = surfaceElement.getBoundingClientRect(); + updateSurfaceSize(rect.width, rect.height); + }; + + measureSurface(); + + if (typeof ResizeObserver === "undefined") { + return undefined; + } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) { + return; + } + + updateSurfaceSize(entry.contentRect.width, entry.contentRect.height); + }); + + observer.observe(surfaceElement); + return () => observer.disconnect(); + }, []); const setSliderPercent = useCallback((value: number) => { setSliderX(Math.max(0, Math.min(100, value))); @@ -314,6 +380,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) { previewInput={resolvedSides.right.previewInput} mixerPreviewState={resolvedSides.right.mixerPreviewState} nodeWidth={previewNodeWidth} + nodeHeight={previewNodeHeight} preferPreview={effectiveDisplayMode === "preview"} /> )} @@ -325,6 +392,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) { previewInput={resolvedSides.left.previewInput} mixerPreviewState={resolvedSides.left.mixerPreviewState} nodeWidth={previewNodeWidth} + nodeHeight={previewNodeHeight} clipWidthPercent={sliderX} preferPreview={effectiveDisplayMode === "preview"} /> diff --git a/components/canvas/nodes/compare-surface.tsx b/components/canvas/nodes/compare-surface.tsx index 92e7e72..0cb48c0 100644 --- a/components/canvas/nodes/compare-surface.tsx +++ b/components/canvas/nodes/compare-surface.tsx @@ -1,5 +1,7 @@ "use client"; +import { useState } from "react"; + import { useCanvasGraph } from "@/components/canvas/canvas-graph-context"; import { usePipelinePreview } from "@/hooks/use-pipeline-preview"; import { @@ -7,8 +9,20 @@ import { type RenderPreviewInput, } from "@/lib/canvas-render-preview"; import type { MixerPreviewState } from "@/lib/canvas-mixer-preview"; +import { + computeMixerCompareOverlayImageStyle, + computeMixerFrameRectInSurface, + isMixerCropImageReady, +} from "@/lib/mixer-crop-layout"; const EMPTY_STEPS: RenderPreviewInput["steps"] = []; +const ZERO_SIZE = { width: 0, height: 0 }; + +type LoadedImageState = { + url: string | null; + width: number; + height: number; +}; type CompareSurfaceProps = { finalUrl?: string; @@ -16,6 +30,7 @@ type CompareSurfaceProps = { previewInput?: RenderPreviewInput; mixerPreviewState?: MixerPreviewState; nodeWidth: number; + nodeHeight: number; clipWidthPercent?: number; preferPreview?: boolean; }; @@ -26,10 +41,19 @@ export default function CompareSurface({ previewInput, mixerPreviewState, nodeWidth, + nodeHeight, clipWidthPercent, preferPreview, }: CompareSurfaceProps) { const graph = useCanvasGraph(); + const [baseImageState, setBaseImageState] = useState({ + url: null, + ...ZERO_SIZE, + }); + const [overlayImageState, setOverlayImageState] = useState({ + url: null, + ...ZERO_SIZE, + }); const usePreview = Boolean(previewInput && (preferPreview || !finalUrl)); const previewSourceUrl = usePreview ? previewInput?.sourceUrl ?? null : null; const previewSourceComposition = usePreview ? previewInput?.sourceComposition : undefined; @@ -66,6 +90,35 @@ export default function CompareSurface({ } : undefined; + const baseNaturalSize = + mixerPreviewState?.baseUrl && mixerPreviewState.baseUrl === baseImageState.url + ? { width: baseImageState.width, height: baseImageState.height } + : ZERO_SIZE; + const overlayNaturalSize = + mixerPreviewState?.overlayUrl && mixerPreviewState.overlayUrl === overlayImageState.url + ? { width: overlayImageState.width, height: overlayImageState.height } + : ZERO_SIZE; + + const mixerCropReady = isMixerCropImageReady({ + currentOverlayUrl: mixerPreviewState?.overlayUrl, + loadedOverlayUrl: overlayImageState.url, + sourceWidth: overlayNaturalSize.width, + sourceHeight: overlayNaturalSize.height, + }); + const mixerFrameRect = hasMixerPreview + ? computeMixerFrameRectInSurface({ + surfaceWidth: nodeWidth, + surfaceHeight: nodeHeight, + baseWidth: baseNaturalSize.width, + baseHeight: baseNaturalSize.height, + overlayX: mixerPreviewState.overlayX, + overlayY: mixerPreviewState.overlayY, + overlayWidth: mixerPreviewState.overlayWidth, + overlayHeight: mixerPreviewState.overlayHeight, + fit: "contain", + }) + : null; + return (
{visibleFinalUrl ? ( @@ -89,22 +142,62 @@ export default function CompareSurface({ alt={label ?? "Comparison image"} className="absolute inset-0 h-full w-full object-contain" draggable={false} - /> - {/* eslint-disable-next-line @next/next/no-img-element */} - {label { + setBaseImageState({ + url: event.currentTarget.currentSrc || event.currentTarget.src, + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + }); }} /> + {mixerFrameRect ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {label { + setOverlayImageState({ + url: event.currentTarget.currentSrc || event.currentTarget.src, + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + }); + }} + style={ + mixerCropReady + ? computeMixerCompareOverlayImageStyle({ + surfaceWidth: nodeWidth, + surfaceHeight: nodeHeight, + baseWidth: baseNaturalSize.width, + baseHeight: baseNaturalSize.height, + overlayX: mixerPreviewState.overlayX, + overlayY: mixerPreviewState.overlayY, + overlayWidth: mixerPreviewState.overlayWidth, + overlayHeight: mixerPreviewState.overlayHeight, + sourceWidth: overlayNaturalSize.width, + sourceHeight: overlayNaturalSize.height, + cropLeft: mixerPreviewState.cropLeft, + cropTop: mixerPreviewState.cropTop, + cropRight: mixerPreviewState.cropRight, + cropBottom: mixerPreviewState.cropBottom, + }) + : { visibility: "hidden" } + } + /> +
+ ) : null} ) : null} diff --git a/components/canvas/nodes/mixer-node.tsx b/components/canvas/nodes/mixer-node.tsx index d5918c2..60f4bfb 100644 --- a/components/canvas/nodes/mixer-node.tsx +++ b/components/canvas/nodes/mixer-node.tsx @@ -21,18 +21,23 @@ import { type MixerBlendMode, } from "@/lib/canvas-mixer-preview"; import type { Id } from "@/convex/_generated/dataModel"; +import { computeMixerFrameRectInSurface } from "@/lib/mixer-crop-layout"; const BLEND_MODE_OPTIONS: MixerBlendMode[] = ["normal", "multiply", "screen", "overlay"]; const MIN_OVERLAY_SIZE = 0.1; +const MIN_CROP_REMAINING_SIZE = 0.1; const MAX_OVERLAY_POSITION = 1; const SAVE_DELAY_MS = 160; +const MIXER_DIAGNOSTICS_ENABLED = + process.env.NODE_ENV !== "test" && process.env.NEXT_PUBLIC_MIXER_DIAGNOSTICS === "1"; type MixerLocalData = ReturnType; type ResizeCorner = "nw" | "ne" | "sw" | "se"; +type CropHandle = ResizeCorner | "n" | "e" | "s" | "w"; type InteractionState = | { - kind: "move"; + kind: "frame-move"; startClientX: number; startClientY: number; startData: MixerLocalData; @@ -40,24 +45,277 @@ type InteractionState = previewHeight: number; } | { - kind: "resize"; + kind: "frame-resize"; corner: ResizeCorner; startClientX: number; startClientY: number; startData: MixerLocalData; previewWidth: number; previewHeight: number; + } + | { + kind: "content-resize"; + corner: CropHandle; + startClientX: number; + startClientY: number; + startData: MixerLocalData; + previewWidth: number; + previewHeight: number; + } + | { + kind: "content-move"; + startClientX: number; + startClientY: number; + startData: MixerLocalData; + previewWidth: number; + previewHeight: number; }; +type LoadedImageSize = { + url: string | null; + width: number; + height: number; +}; + +type PreviewSurfaceSize = { + width: number; + height: number; +}; + +const ZERO_SURFACE_SIZE: PreviewSurfaceSize = { width: 0, height: 0 }; + function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } +function computeAspectRatio(width: number, height: number): number | null { + if (width <= 0 || height <= 0) { + return null; + } + + const ratio = width / height; + return Number.isFinite(ratio) ? ratio : null; +} + +function resolveDisplayedRectAspectRatio(args: { + rect: { width: number; height: number } | null; + surfaceWidth: number; + surfaceHeight: number; + fallback: number; +}): number { + if (args.rect && args.rect.width > 0 && args.rect.height > 0) { + const ratio = computeAspectRatio( + args.rect.width * args.surfaceWidth, + args.rect.height * args.surfaceHeight, + ); + if (ratio) { + return ratio; + } + } + + return args.fallback; +} + +function readPositiveNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; +} + +function resolveSourceImageSize(data: unknown): PreviewSurfaceSize { + const record = (data ?? {}) as Record; + const width = + readPositiveNumber(record.intrinsicWidth) ?? + readPositiveNumber(record.outputWidth) ?? + readPositiveNumber(record.width); + const height = + readPositiveNumber(record.intrinsicHeight) ?? + readPositiveNumber(record.outputHeight) ?? + readPositiveNumber(record.height); + + if (!width || !height) { + return ZERO_SURFACE_SIZE; + } + + return { width, height }; +} + +function roundDiagnosticNumber(value: number | null): number | null { + if (value === null || !Number.isFinite(value)) { + return null; + } + + return Math.round(value * 1000) / 1000; +} + +function diffMixerData(before: MixerLocalData, after: MixerLocalData) { + const keys: Array = [ + "blendMode", + "opacity", + "overlayX", + "overlayY", + "overlayWidth", + "overlayHeight", + "cropLeft", + "cropTop", + "cropRight", + "cropBottom", + ]; + + return keys.reduce>((acc, key) => { + if (before[key] !== after[key]) { + acc[key] = { + before: before[key], + after: after[key], + }; + } + return acc; + }, {}); +} + +function computeContainRect(args: { + sourceWidth: number; + sourceHeight: number; + boundsX: number; + boundsY: number; + boundsWidth: number; + boundsHeight: number; +}): { x: number; y: number; width: number; height: number } { + const { sourceWidth, sourceHeight, boundsX, boundsY, boundsWidth, boundsHeight } = args; + + if (sourceWidth <= 0 || sourceHeight <= 0 || boundsWidth <= 0 || boundsHeight <= 0) { + return { + x: boundsX, + y: boundsY, + width: boundsWidth, + height: boundsHeight, + }; + } + + const scale = Math.min(boundsWidth / sourceWidth, boundsHeight / sourceHeight); + if (!Number.isFinite(scale) || scale <= 0) { + return { + x: boundsX, + y: boundsY, + width: boundsWidth, + height: boundsHeight, + }; + } + + const width = sourceWidth * scale; + const height = sourceHeight * scale; + + return { + x: boundsX + (boundsWidth - width) / 2, + y: boundsY + (boundsHeight - height) / 2, + width, + height, + }; +} + +function computeCropImageStyle(args: { + frameAspectRatio: number; + sourceWidth: number; + sourceHeight: number; + cropLeft: number; + cropTop: number; + cropRight: number; + cropBottom: number; +}) { + const safeWidth = Math.max(1 - args.cropLeft - args.cropRight, MIN_CROP_REMAINING_SIZE); + const safeHeight = Math.max(1 - args.cropTop - args.cropBottom, MIN_CROP_REMAINING_SIZE); + const visibleRect = computeVisibleContentRect({ + frameAspectRatio: args.frameAspectRatio, + sourceWidth: args.sourceWidth, + sourceHeight: args.sourceHeight, + cropLeft: args.cropLeft, + cropTop: args.cropTop, + cropRight: args.cropRight, + cropBottom: args.cropBottom, + }); + + if (!visibleRect) { + return { + left: `${(-args.cropLeft / safeWidth) * 100}%`, + top: `${(-args.cropTop / safeHeight) * 100}%`, + width: `${(1 / safeWidth) * 100}%`, + height: `${(1 / safeHeight) * 100}%`, + } as const; + } + + const imageWidth = visibleRect.width / safeWidth; + const imageHeight = visibleRect.height / safeHeight; + + return { + left: `${(visibleRect.x - (args.cropLeft / safeWidth) * visibleRect.width) * 100}%`, + top: `${(visibleRect.y - (args.cropTop / safeHeight) * visibleRect.height) * 100}%`, + width: `${imageWidth * 100}%`, + height: `${imageHeight * 100}%`, + } as const; +} + +function computeVisibleContentRect(args: { + frameAspectRatio: number; + sourceWidth: number; + sourceHeight: number; + cropLeft: number; + cropTop: number; + cropRight: number; + cropBottom: number; +}) { + if (args.sourceWidth <= 0 || args.sourceHeight <= 0) { + return null; + } + + const cropWidth = Math.max(1 - args.cropLeft - args.cropRight, MIN_CROP_REMAINING_SIZE); + const cropHeight = Math.max(1 - args.cropTop - args.cropBottom, MIN_CROP_REMAINING_SIZE); + const frameAspectRatio = args.frameAspectRatio > 0 ? args.frameAspectRatio : 1; + + const rect = computeContainRect({ + sourceWidth: args.sourceWidth * cropWidth, + sourceHeight: args.sourceHeight * cropHeight, + boundsX: 0, + boundsY: 0, + boundsWidth: frameAspectRatio, + boundsHeight: 1, + }); + + return { + x: rect.x / frameAspectRatio, + y: rect.y, + width: rect.width / frameAspectRatio, + height: rect.height, + }; +} + +function cropRectFromData(data: Pick< + MixerLocalData, + "cropLeft" | "cropTop" | "cropRight" | "cropBottom" +>) { + return { + x: data.cropLeft, + y: data.cropTop, + width: 1 - data.cropLeft - data.cropRight, + height: 1 - data.cropTop - data.cropBottom, + }; +} + +function cropEdgesFromRect(rect: { x: number; y: number; width: number; height: number }) { + return { + cropLeft: rect.x, + cropTop: rect.y, + cropRight: 1 - (rect.x + rect.width), + cropBottom: 1 - (rect.y + rect.height), + }; +} + function normalizeLocalMixerData(data: MixerLocalData): MixerLocalData { const overlayX = clamp(data.overlayX, 0, MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE); const overlayY = clamp(data.overlayY, 0, MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE); const overlayWidth = clamp(data.overlayWidth, MIN_OVERLAY_SIZE, MAX_OVERLAY_POSITION - overlayX); const overlayHeight = clamp(data.overlayHeight, MIN_OVERLAY_SIZE, MAX_OVERLAY_POSITION - overlayY); + const cropLeft = clamp(data.cropLeft, 0, MAX_OVERLAY_POSITION - MIN_CROP_REMAINING_SIZE); + const cropTop = clamp(data.cropTop, 0, MAX_OVERLAY_POSITION - MIN_CROP_REMAINING_SIZE); + const cropRight = clamp(data.cropRight, 0, MAX_OVERLAY_POSITION - cropLeft - MIN_CROP_REMAINING_SIZE); + const cropBottom = clamp(data.cropBottom, 0, MAX_OVERLAY_POSITION - cropTop - MIN_CROP_REMAINING_SIZE); return { ...data, @@ -65,6 +323,49 @@ function normalizeLocalMixerData(data: MixerLocalData): MixerLocalData { overlayY, overlayWidth, overlayHeight, + cropLeft, + cropTop, + cropRight, + cropBottom, + }; +} + +function computeLockedAspectRect(args: { + x: number; + y: number; + width: number; + height: number; + minSize: number; + corner: ResizeCorner; + deltaX: number; + deltaY: number; + aspectRatio?: number; +}) { + const { x, y, width, height, minSize, corner, deltaX, deltaY, aspectRatio } = args; + const lockedAspectRatio = aspectRatio && aspectRatio > 0 ? aspectRatio : width / height; + const lockedHeight = width / lockedAspectRatio; + const anchorX = corner.includes("w") ? x + width : x; + const anchorY = corner.includes("n") ? y + height : y; + const requestedScaleX = (width + (corner.includes("w") ? -deltaX : deltaX)) / width; + const requestedScaleY = + (lockedHeight + (corner.includes("n") ? -deltaY : deltaY)) / lockedHeight; + const dominantScale = + Math.abs(requestedScaleX - 1) >= Math.abs(requestedScaleY - 1) + ? requestedScaleX + : requestedScaleY; + const minScale = Math.max(minSize / width, minSize / lockedHeight); + const maxWidth = corner.includes("w") ? anchorX : MAX_OVERLAY_POSITION - x; + const maxHeight = corner.includes("n") ? anchorY : MAX_OVERLAY_POSITION - y; + const maxScale = Math.min(maxWidth / width, maxHeight / lockedHeight); + const scale = clamp(dominantScale, minScale, maxScale); + const nextWidth = width * scale; + const nextHeight = nextWidth / lockedAspectRatio; + + return { + x: corner.includes("w") ? anchorX - nextWidth : x, + y: corner.includes("n") ? anchorY - nextHeight : y, + width: nextWidth, + height: nextHeight, }; } @@ -73,66 +374,95 @@ function computeResizeRect(args: { corner: ResizeCorner; deltaX: number; deltaY: number; + aspectRatio?: number; }): Pick { - const { startData, corner, deltaX, deltaY } = args; - const startRight = startData.overlayX + startData.overlayWidth; - const startBottom = startData.overlayY + startData.overlayHeight; + const { startData, corner, deltaX, deltaY, aspectRatio } = args; + const nextRect = computeLockedAspectRect({ + x: startData.overlayX, + y: startData.overlayY, + width: startData.overlayWidth, + height: startData.overlayHeight, + minSize: MIN_OVERLAY_SIZE, + corner, + deltaX, + deltaY, + aspectRatio, + }); - let overlayX = startData.overlayX; - let overlayY = startData.overlayY; - let overlayWidth = startData.overlayWidth; - let overlayHeight = startData.overlayHeight; + return normalizeLocalMixerData({ + ...startData, + overlayX: nextRect.x, + overlayY: nextRect.y, + overlayWidth: nextRect.width, + overlayHeight: nextRect.height, + }); +} + +function computeContentResizeRect(args: { + startData: MixerLocalData; + corner: CropHandle; + deltaX: number; + deltaY: number; +}): Pick { + const { startData, corner, deltaX, deltaY } = args; + const startRect = cropRectFromData(startData); + const startRight = startRect.x + startRect.width; + const startBottom = startRect.y + startRect.height; + + let nextX = startRect.x; + let nextY = startRect.y; + let nextWidth = startRect.width; + let nextHeight = startRect.height; if (corner.includes("w")) { - overlayX = clamp( - startData.overlayX + deltaX, - 0, - startData.overlayX + startData.overlayWidth - MIN_OVERLAY_SIZE, - ); - overlayWidth = startRight - overlayX; + nextX = clamp(startRect.x + deltaX, 0, startRight - MIN_CROP_REMAINING_SIZE); + nextWidth = startRight - nextX; } if (corner.includes("e")) { - overlayWidth = clamp( - startData.overlayWidth + deltaX, - MIN_OVERLAY_SIZE, - MAX_OVERLAY_POSITION - startData.overlayX, - ); + nextWidth = clamp(startRect.width + deltaX, MIN_CROP_REMAINING_SIZE, 1 - startRect.x); } if (corner.includes("n")) { - overlayY = clamp( - startData.overlayY + deltaY, - 0, - startData.overlayY + startData.overlayHeight - MIN_OVERLAY_SIZE, - ); - overlayHeight = startBottom - overlayY; + nextY = clamp(startRect.y + deltaY, 0, startBottom - MIN_CROP_REMAINING_SIZE); + nextHeight = startBottom - nextY; } if (corner.includes("s")) { - overlayHeight = clamp( - startData.overlayHeight + deltaY, - MIN_OVERLAY_SIZE, - MAX_OVERLAY_POSITION - startData.overlayY, - ); + nextHeight = clamp(startRect.height + deltaY, MIN_CROP_REMAINING_SIZE, 1 - startRect.y); } return normalizeLocalMixerData({ ...startData, - overlayX, - overlayY, - overlayWidth, - overlayHeight, + ...cropEdgesFromRect({ + x: nextX, + y: nextY, + width: nextWidth, + height: nextHeight, + }), }); } -export default function MixerNode({ id, data, selected }: NodeProps) { +export default function MixerNode({ id, data, selected, width, height }: NodeProps) { const graph = useCanvasGraph(); const { queueNodeDataUpdate } = useCanvasSync(); const previewRef = useRef(null); + const overlayImageRef = useRef(null); const latestNodeDataRef = useRef((data ?? {}) as Record); const [hasImageLoadError, setHasImageLoadError] = useState(false); const [interaction, setInteraction] = useState(null); + const [isContentFramingMode, setIsContentFramingMode] = useState(false); + const [baseImageSize, setBaseImageSize] = useState({ + url: null, + width: 0, + height: 0, + }); + const [overlayImageSize, setOverlayImageSize] = useState({ + url: null, + width: 0, + height: 0, + }); + const [previewSurfaceSize, setPreviewSurfaceSize] = useState(ZERO_SURFACE_SIZE); useEffect(() => { latestNodeDataRef.current = (data ?? {}) as Record; @@ -158,6 +488,186 @@ export default function MixerNode({ id, data, selected }: NodeProps) { () => resolveMixerPreviewFromGraph({ nodeId: id, graph }), [graph, id], ); + const baseSourceNode = useMemo(() => { + const incomingEdges = graph.incomingEdgesByTarget.get(id) ?? []; + const baseEdge = incomingEdges.find( + (edge) => edge.targetHandle === "base" || edge.targetHandle == null || edge.targetHandle === "", + ); + + return baseEdge ? graph.nodesById.get(baseEdge.source) : undefined; + }, [graph, id]); + const baseSourceSize = useMemo( + () => resolveSourceImageSize(baseSourceNode?.data), + [baseSourceNode?.data], + ); + const overlayImageUrl = previewState.status === "ready" ? previewState.overlayUrl : null; + const baseImageUrl = previewState.status === "ready" ? previewState.baseUrl : null; + + useEffect(() => { + const previewElement = previewRef.current; + if (!previewElement) { + return; + } + + const updatePreviewSurfaceSize = (nextWidth: number, nextHeight: number) => { + setPreviewSurfaceSize((current) => + current.width === nextWidth && current.height === nextHeight + ? current + : { width: nextWidth, height: nextHeight }, + ); + }; + + const measurePreview = () => { + const rect = previewElement.getBoundingClientRect(); + updatePreviewSurfaceSize(rect.width, rect.height); + }; + + measurePreview(); + + if (typeof ResizeObserver === "undefined") { + return undefined; + } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) { + return; + } + + updatePreviewSurfaceSize(entry.contentRect.width, entry.contentRect.height); + }); + + observer.observe(previewElement); + return () => observer.disconnect(); + }, []); + + const overlayNaturalSize = + overlayImageUrl && overlayImageUrl === overlayImageSize.url + ? { + width: overlayImageSize.width, + height: overlayImageSize.height, + } + : { width: 0, height: 0 }; + const baseNaturalSize = + baseImageUrl && baseImageUrl === baseImageSize.url + ? { + width: baseImageSize.width, + height: baseImageSize.height, + } + : baseSourceSize; + + const emitMixerDiagnostics = (reason: string, extra?: Record) => { + if (!MIXER_DIAGNOSTICS_ENABLED) { + return; + } + + const previewRect = previewRef.current?.getBoundingClientRect(); + const overlayImage = overlayImageRef.current; + + const frameRect = previewRect + ? { + x: localData.overlayX * previewRect.width, + y: localData.overlayY * previewRect.height, + width: localData.overlayWidth * previewRect.width, + height: localData.overlayHeight * previewRect.height, + } + : null; + + const cropRect = cropRectFromData(localData); + const contentBoundsRect = frameRect + ? { + x: frameRect.x + cropRect.x * frameRect.width, + y: frameRect.y + cropRect.y * frameRect.height, + width: cropRect.width * frameRect.width, + height: cropRect.height * frameRect.height, + } + : null; + + const visibleContentRect = + contentBoundsRect && overlayImage + ? computeContainRect({ + sourceWidth: overlayImage.naturalWidth, + sourceHeight: overlayImage.naturalHeight, + boundsX: contentBoundsRect.x, + boundsY: contentBoundsRect.y, + boundsWidth: contentBoundsRect.width, + boundsHeight: contentBoundsRect.height, + }) + : null; + + const frameAspectRatio = frameRect + ? computeAspectRatio(frameRect.width, frameRect.height) + : null; + const contentBoundsAspectRatio = contentBoundsRect + ? computeAspectRatio(contentBoundsRect.width, contentBoundsRect.height) + : null; + const visibleContentAspectRatio = visibleContentRect + ? computeAspectRatio(visibleContentRect.width, visibleContentRect.height) + : null; + + const currentHandleRect = + isContentFramingMode && visibleContentRect + ? { + x: visibleContentRect.x, + y: visibleContentRect.y, + width: visibleContentRect.width, + height: visibleContentRect.height, + } + : frameRect; + + const handleOffsetFromVisibleContent = + currentHandleRect && visibleContentRect + ? { + x: roundDiagnosticNumber(currentHandleRect.x - visibleContentRect.x), + y: roundDiagnosticNumber(currentHandleRect.y - visibleContentRect.y), + width: roundDiagnosticNumber(currentHandleRect.width - visibleContentRect.width), + height: roundDiagnosticNumber(currentHandleRect.height - visibleContentRect.height), + } + : null; + + console.debug("[mixer-diagnostics]", { + nodeId: id, + reason, + mode: isContentFramingMode ? "content-framing" : "frame-resize", + intent: isContentFramingMode + ? "crop should change visible area without changing displayed image size" + : "resize should change displayed image size without changing aspect ratio", + currentHandleAnchorSource: "frame", + expectedHandleAnchorSource: "frame", + interactionKind: interaction?.kind ?? null, + previewRect, + frameRect, + frameAspectRatio: roundDiagnosticNumber(frameAspectRatio), + contentBoundsRect, + contentBoundsAspectRatio: roundDiagnosticNumber(contentBoundsAspectRatio), + visibleContentRect, + visibleContentAspectRatio: roundDiagnosticNumber(visibleContentAspectRatio), + currentHandleRect, + handleOffsetFromVisibleContent, + overlayNaturalSize: overlayImage + ? { + width: overlayImage.naturalWidth, + height: overlayImage.naturalHeight, + } + : null, + localData, + ...extra, + }); + }; + + useEffect(() => { + emitMixerDiagnostics("mode-or-geometry-changed"); + }, [ + isContentFramingMode, + localData.overlayX, + localData.overlayY, + localData.overlayWidth, + localData.overlayHeight, + localData.cropLeft, + localData.cropTop, + localData.cropRight, + localData.cropBottom, + ]); const onBlendModeChange = (event: ChangeEvent) => { setHasImageLoadError(false); @@ -168,7 +678,16 @@ export default function MixerNode({ id, data, selected }: NodeProps) { }; const onNumberChange = ( - field: "opacity" | "overlayX" | "overlayY" | "overlayWidth" | "overlayHeight", + field: + | "opacity" + | "overlayX" + | "overlayY" + | "overlayWidth" + | "overlayHeight" + | "cropLeft" + | "cropTop" + | "cropRight" + | "cropBottom", ) => (event: FormEvent) => { setHasImageLoadError(false); @@ -196,7 +715,7 @@ export default function MixerNode({ id, data, selected }: NodeProps) { const startInteraction = ( event: ReactMouseEvent, kind: InteractionState["kind"], - corner?: ResizeCorner, + corner?: CropHandle, ) => { event.preventDefault(); event.stopPropagation(); @@ -206,14 +725,51 @@ export default function MixerNode({ id, data, selected }: NodeProps) { return; } + if ( + (kind === "content-move" || kind === "content-resize") && + (overlayNaturalSize.width <= 0 || overlayNaturalSize.height <= 0) + ) { + return; + } + + emitMixerDiagnostics("interaction-start", { + requestedInteractionKind: kind, + resizeCorner: corner ?? null, + target: event.target instanceof HTMLElement ? event.target.dataset : null, + currentTarget: event.currentTarget.dataset, + currentTargetClassName: + event.currentTarget instanceof HTMLElement ? event.currentTarget.className : null, + pointer: { + clientX: event.clientX, + clientY: event.clientY, + }, + }); + + const activeGeometryRect = + kind === "content-move" || kind === "content-resize" + ? displayedOverlayFrameRect ?? { + x: localData.overlayX, + y: localData.overlayY, + width: localData.overlayWidth, + height: localData.overlayHeight, + } + : displayedBaseRect; + const activeGeometryWidth = + (activeGeometryRect?.width ?? 1) * previewRect.width || previewRect.width; + const activeGeometryHeight = + (activeGeometryRect?.height ?? 1) * previewRect.height || previewRect.height; + setInteraction({ kind, - corner: kind === "resize" ? (corner as ResizeCorner) : undefined, + corner: + kind === "frame-resize" || kind === "content-resize" + ? (corner as ResizeCorner) + : undefined, startClientX: event.clientX, startClientY: event.clientY, startData: localData, - previewWidth: previewRect.width, - previewHeight: previewRect.height, + previewWidth: activeGeometryWidth, + previewHeight: activeGeometryHeight, } as InteractionState); }; @@ -223,10 +779,76 @@ export default function MixerNode({ id, data, selected }: NodeProps) { } const handleMouseMove = (event: MouseEvent) => { - const deltaX = (event.clientX - interaction.startClientX) / interaction.previewWidth; - const deltaY = (event.clientY - interaction.startClientY) / interaction.previewHeight; + const pointerDeltaX = event.clientX - interaction.startClientX; + const pointerDeltaY = event.clientY - interaction.startClientY; + const deltaX = pointerDeltaX / interaction.previewWidth; + const deltaY = pointerDeltaY / interaction.previewHeight; - if (interaction.kind === "move") { + const emitInteractionMoveDiagnostics = ( + nextData: MixerLocalData, + extra?: Record, + ) => { + const changedFields = diffMixerData(interaction.startData, nextData); + const beforeCropRect = cropRectFromData(interaction.startData); + const afterCropRect = cropRectFromData(nextData); + emitMixerDiagnostics("interaction-move", { + requestedInteractionKind: interaction.kind, + resizeCorner: interaction.kind === "frame-resize" || interaction.kind === "content-resize" + ? interaction.corner + : null, + pointer: { + clientX: event.clientX, + clientY: event.clientY, + }, + pointerDeltaPx: { + x: roundDiagnosticNumber(pointerDeltaX), + y: roundDiagnosticNumber(pointerDeltaY), + }, + deltaInPreviewSpace: { + x: roundDiagnosticNumber(deltaX), + y: roundDiagnosticNumber(deltaY), + }, + changedFields, + beforeAspectRatio: { + overlay: roundDiagnosticNumber( + computeAspectRatio( + interaction.startData.overlayWidth, + interaction.startData.overlayHeight, + ), + ), + content: roundDiagnosticNumber( + computeAspectRatio( + beforeCropRect.width, + beforeCropRect.height, + ), + ), + }, + afterAspectRatio: { + overlay: roundDiagnosticNumber(computeAspectRatio(nextData.overlayWidth, nextData.overlayHeight)), + content: roundDiagnosticNumber(computeAspectRatio(afterCropRect.width, afterCropRect.height)), + }, + semanticChecks: { + resizeChangedOverlayAspectRatio: + interaction.kind === "frame-resize" + ? interaction.startData.overlayWidth / interaction.startData.overlayHeight !== + nextData.overlayWidth / nextData.overlayHeight + : null, + cropChangedOverlaySize: + interaction.kind === "content-move" || interaction.kind === "content-resize" + ? interaction.startData.overlayWidth !== nextData.overlayWidth || + interaction.startData.overlayHeight !== nextData.overlayHeight + : null, + cropChangedContentSize: + interaction.kind === "content-resize" + ? beforeCropRect.width !== afterCropRect.width || + beforeCropRect.height !== afterCropRect.height + : null, + }, + ...extra, + }); + }; + + if (interaction.kind === "frame-move") { const nextX = clamp( interaction.startData.overlayX + deltaX, 0, @@ -238,6 +860,19 @@ export default function MixerNode({ id, data, selected }: NodeProps) { MAX_OVERLAY_POSITION - interaction.startData.overlayHeight, ); + const nextData = { + ...interaction.startData, + overlayX: nextX, + overlayY: nextY, + }; + + emitInteractionMoveDiagnostics(nextData, { + deltaInFrameSpace: { + x: roundDiagnosticNumber(deltaX), + y: roundDiagnosticNumber(deltaY), + }, + }); + updateLocalData((current) => ({ ...current, overlayX: nextX, @@ -246,13 +881,142 @@ export default function MixerNode({ id, data, selected }: NodeProps) { return; } + if (interaction.kind === "content-move") { + const startCropRect = cropRectFromData(interaction.startData); + const visibleRect = computeVisibleContentRect({ + frameAspectRatio: + interaction.previewWidth > 0 && interaction.previewHeight > 0 + ? interaction.previewWidth / interaction.previewHeight + : 1, + sourceWidth: overlayNaturalSize.width, + sourceHeight: overlayNaturalSize.height, + cropLeft: interaction.startData.cropLeft, + cropTop: interaction.startData.cropTop, + cropRight: interaction.startData.cropRight, + cropBottom: interaction.startData.cropBottom, + }); + const contentDeltaX = + (pointerDeltaX / + (interaction.previewWidth * (visibleRect?.width ?? 1))) * + startCropRect.width; + const contentDeltaY = + (pointerDeltaY / + (interaction.previewHeight * (visibleRect?.height ?? 1))) * + startCropRect.height; + + const nextX = clamp( + startCropRect.x + contentDeltaX, + 0, + MAX_OVERLAY_POSITION - startCropRect.width, + ); + const nextY = clamp( + startCropRect.y + contentDeltaY, + 0, + MAX_OVERLAY_POSITION - startCropRect.height, + ); + + const nextData = { + ...interaction.startData, + ...cropEdgesFromRect({ + x: nextX, + y: nextY, + width: startCropRect.width, + height: startCropRect.height, + }), + }; + + emitInteractionMoveDiagnostics(nextData, { + deltaInFrameSpace: { + x: roundDiagnosticNumber(contentDeltaX), + y: roundDiagnosticNumber(contentDeltaY), + }, + }); + + updateLocalData((current) => ({ + ...current, + ...cropEdgesFromRect({ + x: nextX, + y: nextY, + width: startCropRect.width, + height: startCropRect.height, + }), + })); + return; + } + + if (interaction.kind === "content-resize") { + const startCropRect = cropRectFromData(interaction.startData); + const visibleRect = computeVisibleContentRect({ + frameAspectRatio: + interaction.previewWidth > 0 && interaction.previewHeight > 0 + ? interaction.previewWidth / interaction.previewHeight + : 1, + sourceWidth: overlayNaturalSize.width, + sourceHeight: overlayNaturalSize.height, + cropLeft: interaction.startData.cropLeft, + cropTop: interaction.startData.cropTop, + cropRight: interaction.startData.cropRight, + cropBottom: interaction.startData.cropBottom, + }); + const contentDeltaX = + (pointerDeltaX / + (interaction.previewWidth * (visibleRect?.width ?? 1))) * + startCropRect.width; + const contentDeltaY = + (pointerDeltaY / + (interaction.previewHeight * (visibleRect?.height ?? 1))) * + startCropRect.height; + + const nextRect = computeContentResizeRect({ + startData: interaction.startData, + corner: interaction.corner, + deltaX: contentDeltaX, + deltaY: contentDeltaY, + }); + + const nextData = { + ...interaction.startData, + ...nextRect, + }; + + emitInteractionMoveDiagnostics(nextData, { + deltaInFrameSpace: { + x: roundDiagnosticNumber(contentDeltaX), + y: roundDiagnosticNumber(contentDeltaY), + }, + }); + + updateLocalData((current) => ({ + ...current, + ...nextRect, + })); + return; + } + const nextRect = computeResizeRect({ startData: interaction.startData, corner: interaction.corner, deltaX, deltaY, + aspectRatio: + interaction.startData.overlayWidth > 0 && interaction.startData.overlayHeight > 0 + ? interaction.startData.overlayWidth / interaction.startData.overlayHeight + : undefined, }); + emitInteractionMoveDiagnostics( + { + ...interaction.startData, + ...nextRect, + }, + { + deltaInFrameSpace: { + x: roundDiagnosticNumber(deltaX), + y: roundDiagnosticNumber(deltaY), + }, + }, + ); + updateLocalData((current) => ({ ...current, ...nextRect, @@ -260,6 +1024,7 @@ export default function MixerNode({ id, data, selected }: NodeProps) { }; const handleMouseUp = () => { + emitMixerDiagnostics("interaction-end"); setInteraction(null); }; @@ -274,16 +1039,113 @@ export default function MixerNode({ id, data, selected }: NodeProps) { const showReadyPreview = previewState.status === "ready" && !hasImageLoadError; const showPreviewError = hasImageLoadError || previewState.status === "error"; + const hasOverlayNaturalSize = overlayNaturalSize.width > 0 && overlayNaturalSize.height > 0; + const effectivePreviewSurfaceWidth = previewSurfaceSize.width || width || 0; + const effectivePreviewSurfaceHeight = previewSurfaceSize.height || height || 0; + const displayedBaseRect = computeMixerFrameRectInSurface({ + surfaceWidth: effectivePreviewSurfaceWidth, + surfaceHeight: effectivePreviewSurfaceHeight, + baseWidth: baseNaturalSize.width, + baseHeight: baseNaturalSize.height, + overlayX: 0, + overlayY: 0, + overlayWidth: 1, + overlayHeight: 1, + fit: "cover", + }); + const displayedOverlayFrameRect = computeMixerFrameRectInSurface({ + surfaceWidth: effectivePreviewSurfaceWidth, + surfaceHeight: effectivePreviewSurfaceHeight, + baseWidth: baseNaturalSize.width, + baseHeight: baseNaturalSize.height, + overlayX: localData.overlayX, + overlayY: localData.overlayY, + overlayWidth: localData.overlayWidth, + overlayHeight: localData.overlayHeight, + fit: "cover", + }); + const displayedOverlayFrameAspectRatio = resolveDisplayedRectAspectRatio({ + rect: displayedOverlayFrameRect, + surfaceWidth: effectivePreviewSurfaceWidth, + surfaceHeight: effectivePreviewSurfaceHeight, + fallback: + localData.overlayWidth > 0 && localData.overlayHeight > 0 + ? localData.overlayWidth / localData.overlayHeight + : 1, + }); - const overlayStyle = { + const resizeHandleRect = displayedOverlayFrameRect + ? { + left: displayedOverlayFrameRect.x, + top: displayedOverlayFrameRect.y, + width: displayedOverlayFrameRect.width, + height: displayedOverlayFrameRect.height, + } + : { + left: localData.overlayX, + top: localData.overlayY, + width: localData.overlayWidth, + height: localData.overlayHeight, + }; + const visibleContentRect = + computeVisibleContentRect({ + frameAspectRatio: displayedOverlayFrameAspectRatio, + sourceWidth: overlayNaturalSize.width, + sourceHeight: overlayNaturalSize.height, + cropLeft: localData.cropLeft, + cropTop: localData.cropTop, + cropRight: localData.cropRight, + cropBottom: localData.cropBottom, + }) ?? { x: 0, y: 0, width: 1, height: 1 }; + const cropHandleRect = { + left: resizeHandleRect.left + resizeHandleRect.width * visibleContentRect.x, + top: resizeHandleRect.top + resizeHandleRect.height * visibleContentRect.y, + width: resizeHandleRect.width * visibleContentRect.width, + height: resizeHandleRect.height * visibleContentRect.height, + }; + + const overlayFrameStyle = { mixBlendMode: localData.blendMode, opacity: localData.opacity / 100, - left: `${localData.overlayX * 100}%`, - top: `${localData.overlayY * 100}%`, - width: `${localData.overlayWidth * 100}%`, - height: `${localData.overlayHeight * 100}%`, + left: `${(displayedOverlayFrameRect?.x ?? localData.overlayX) * 100}%`, + top: `${(displayedOverlayFrameRect?.y ?? localData.overlayY) * 100}%`, + width: `${(displayedOverlayFrameRect?.width ?? localData.overlayWidth) * 100}%`, + height: `${(displayedOverlayFrameRect?.height ?? localData.overlayHeight) * 100}%`, } as const; + const overlayContentStyle = computeCropImageStyle({ + frameAspectRatio: displayedOverlayFrameAspectRatio, + sourceWidth: overlayNaturalSize.width, + sourceHeight: overlayNaturalSize.height, + cropLeft: localData.cropLeft, + cropTop: localData.cropTop, + cropRight: localData.cropRight, + cropBottom: localData.cropBottom, + }); + const cropBoxStyle = { + left: `${visibleContentRect.x * 100}%`, + top: `${visibleContentRect.y * 100}%`, + width: `${visibleContentRect.width * 100}%`, + height: `${visibleContentRect.height * 100}%`, + } as const; + + const frameResizeHandles = [ + { corner: "nw", cursor: "nwse-resize" }, + { corner: "ne", cursor: "nesw-resize" }, + { corner: "sw", cursor: "nesw-resize" }, + { corner: "se", cursor: "nwse-resize" }, + ] as const; + const cropHandles = [ + { corner: "nw", cursor: "nwse-resize" }, + { corner: "n", cursor: "ns-resize" }, + { corner: "ne", cursor: "nesw-resize" }, + { corner: "e", cursor: "ew-resize" }, + { corner: "se", cursor: "nwse-resize" }, + { corner: "s", cursor: "ns-resize" }, + { corner: "sw", cursor: "nesw-resize" }, + { corner: "w", cursor: "ew-resize" }, + ] as const; + return ( -
+
{showReadyPreview ? ( <> {/* eslint-disable-next-line @next/next/no-img-element */} Mixer base { + setBaseImageSize({ + url: event.currentTarget.currentSrc || event.currentTarget.src, + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + }); + }} onError={() => setHasImageLoadError(true)} + style={ + displayedBaseRect + ? { + left: `${displayedBaseRect.x * 100}%`, + top: `${displayedBaseRect.y * 100}%`, + width: `${displayedBaseRect.width * 100}%`, + height: `${displayedBaseRect.height * 100}%`, + } + : undefined + } /> - {/* eslint-disable-next-line @next/next/no-img-element */} - Mixer overlay startInteraction(event, "move")} - onError={() => setHasImageLoadError(true)} - style={overlayStyle} - /> + data-interaction-role="frame" + data-anchor-source="frame" + className={`absolute overflow-hidden border border-white/70 nodrag nopan ${ + isContentFramingMode ? "cursor-default" : "cursor-move" + }`} + onMouseDown={(event) => { + if (isContentFramingMode) { + return; + } + startInteraction(event, "frame-move"); + }} + style={overlayFrameStyle} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + Mixer overlay { + setOverlayImageSize({ + url: event.currentTarget.currentSrc || event.currentTarget.src, + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + }); + emitMixerDiagnostics("overlay-image-loaded"); + }} + onError={() => setHasImageLoadError(true)} + style={overlayContentStyle} + /> - {([ - { corner: "nw", cursor: "nwse-resize" }, - { corner: "ne", cursor: "nesw-resize" }, - { corner: "sw", cursor: "nesw-resize" }, - { corner: "se", cursor: "nwse-resize" }, - ] as const).map(({ corner, cursor }) => ( + {isContentFramingMode && hasOverlayNaturalSize ? ( +
startInteraction(event, "content-move")} + style={cropBoxStyle} + /> + ) : null} +
+ + {((isContentFramingMode && hasOverlayNaturalSize) ? cropHandles : frameResizeHandles).map(({ corner, cursor }) => (
startInteraction(event, "resize", corner)} + data-interaction-role={(isContentFramingMode && hasOverlayNaturalSize) ? "content-resize-handle" : "frame-resize-handle"} + data-anchor-source={(isContentFramingMode && hasOverlayNaturalSize) ? "crop-box" : "frame"} + data-resize-corner={corner} + className="absolute z-10 h-3 w-3 rounded-full border border-white/80 bg-foreground/80 nodrag nopan" + onMouseDown={(event) => { + emitMixerDiagnostics("resize-handle-mousedown", { + resizeCorner: corner, + requestedInteractionKind: + (isContentFramingMode && hasOverlayNaturalSize) ? "content-resize" : "frame-resize", + }); + startInteraction( + event, + (isContentFramingMode && hasOverlayNaturalSize) ? "content-resize" : "frame-resize", + corner, + ); + }} style={{ - left: `${(corner.includes("w") ? localData.overlayX : localData.overlayX + localData.overlayWidth) * 100}%`, - top: `${(corner.includes("n") ? localData.overlayY : localData.overlayY + localData.overlayHeight) * 100}%`, + left: `${( + corner.includes("w") + ? ((isContentFramingMode && hasOverlayNaturalSize) ? cropHandleRect.left : resizeHandleRect.left) + : corner.includes("e") + ? ((isContentFramingMode && hasOverlayNaturalSize) + ? cropHandleRect.left + cropHandleRect.width + : resizeHandleRect.left + resizeHandleRect.width) + : ((isContentFramingMode && hasOverlayNaturalSize) + ? cropHandleRect.left + cropHandleRect.width / 2 + : resizeHandleRect.left + resizeHandleRect.width / 2) + ) * 100}%`, + top: `${( + corner.includes("n") + ? ((isContentFramingMode && hasOverlayNaturalSize) ? cropHandleRect.top : resizeHandleRect.top) + : corner.includes("s") + ? ((isContentFramingMode && hasOverlayNaturalSize) + ? cropHandleRect.top + cropHandleRect.height + : resizeHandleRect.top + resizeHandleRect.height) + : ((isContentFramingMode && hasOverlayNaturalSize) + ? cropHandleRect.top + cropHandleRect.height / 2 + : resizeHandleRect.top + resizeHandleRect.height / 2) + ) * 100}%`, transform: "translate(-50%, -50%)", cursor, }} @@ -379,13 +1327,31 @@ export default function MixerNode({ id, data, selected }: NodeProps) {
+ + + + + + + + + +
diff --git a/components/canvas/nodes/use-node-local-data.ts b/components/canvas/nodes/use-node-local-data.ts index b714fc3..0948312 100644 --- a/components/canvas/nodes/use-node-local-data.ts +++ b/components/canvas/nodes/use-node-local-data.ts @@ -22,6 +22,25 @@ function logNodeDataDebug(event: string, payload: Record): void console.info("[Canvas node debug]", event, payload); } +function diffNodeData( + before: Record, + after: Record, +): Record { + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + const diff: Record = {}; + + for (const key of keys) { + if (before[key] !== after[key]) { + diff[key] = { + before: before[key], + after: after[key], + }; + } + } + + return diff; +} + export function useNodeLocalData({ nodeId, data, @@ -55,6 +74,16 @@ export function useNodeLocalData({ const savedValue = localDataRef.current; const savedVersion = localChangeVersionRef.current; + logNodeDataDebug("queue-save-flush", { + nodeId, + nodeType: debugLabel, + savedVersion, + changedFields: diffNodeData( + acceptedPersistedDataRef.current as Record, + savedValue as Record, + ), + }); + Promise.resolve(onSave(savedValue)) .then(() => { if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) { @@ -144,7 +173,17 @@ export function useNodeLocalData({ const updateLocalData = useCallback( (updater: (current: T) => T) => { - const next = updater(localDataRef.current); + const previous = localDataRef.current; + const next = updater(previous); + + logNodeDataDebug("local-update", { + nodeId, + nodeType: debugLabel, + changedFields: diffNodeData( + previous as Record, + next as Record, + ), + }); localChangeVersionRef.current += 1; hasPendingLocalChangesRef.current = true; @@ -153,7 +192,7 @@ export function useNodeLocalData({ setPreviewNodeDataOverride(nodeId, next); queueSave(); }, - [nodeId, queueSave, setPreviewNodeDataOverride], + [debugLabel, nodeId, queueSave, setPreviewNodeDataOverride], ); return { diff --git a/lib/canvas-mixer-preview.ts b/lib/canvas-mixer-preview.ts index d04b47f..c641277 100644 --- a/lib/canvas-mixer-preview.ts +++ b/lib/canvas-mixer-preview.ts @@ -23,6 +23,10 @@ export type MixerPreviewState = { overlayY: number; overlayWidth: number; overlayHeight: number; + cropLeft: number; + cropTop: number; + cropRight: number; + cropBottom: number; error?: MixerPreviewError; }; @@ -41,6 +45,10 @@ const DEFAULT_OVERLAY_X = 0; const DEFAULT_OVERLAY_Y = 0; const DEFAULT_OVERLAY_WIDTH = 1; const DEFAULT_OVERLAY_HEIGHT = 1; +const DEFAULT_CROP_LEFT = 0; +const DEFAULT_CROP_TOP = 0; +const DEFAULT_CROP_RIGHT = 0; +const DEFAULT_CROP_BOTTOM = 0; const MIN_OVERLAY_POSITION = 0; const MAX_OVERLAY_POSITION = 1; const MIN_OVERLAY_SIZE = 0.1; @@ -81,6 +89,37 @@ function normalizeOverlayNumber(value: unknown, fallback: number): number { return parsed; } +function normalizeUnitRect(args: { + x: unknown; + y: unknown; + width: unknown; + height: unknown; + defaults: { x: number; y: number; width: number; height: number }; +}): { x: number; y: number; width: number; height: number } { + const x = clamp( + normalizeOverlayNumber(args.x, args.defaults.x), + MIN_OVERLAY_POSITION, + MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE, + ); + const y = clamp( + normalizeOverlayNumber(args.y, args.defaults.y), + MIN_OVERLAY_POSITION, + MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE, + ); + const width = clamp( + normalizeOverlayNumber(args.width, args.defaults.width), + MIN_OVERLAY_SIZE, + Math.min(MAX_OVERLAY_SIZE, MAX_OVERLAY_POSITION - x), + ); + const height = clamp( + normalizeOverlayNumber(args.height, args.defaults.height), + MIN_OVERLAY_SIZE, + Math.min(MAX_OVERLAY_SIZE, MAX_OVERLAY_POSITION - y), + ); + + return { x, y, width, height }; +} + function normalizeOverlayRect(record: Record): Pick< MixerPreviewState, "overlayX" | "overlayY" | "overlayWidth" | "overlayHeight" @@ -101,38 +140,105 @@ function normalizeOverlayRect(record: Record): Pick< }; } - const overlayX = clamp( - normalizeOverlayNumber(record.overlayX, DEFAULT_OVERLAY_X), - MIN_OVERLAY_POSITION, - MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE, + const normalized = normalizeUnitRect({ + x: record.overlayX, + y: record.overlayY, + width: record.overlayWidth, + height: record.overlayHeight, + defaults: { + x: DEFAULT_OVERLAY_X, + y: DEFAULT_OVERLAY_Y, + width: DEFAULT_OVERLAY_WIDTH, + height: DEFAULT_OVERLAY_HEIGHT, + }, + }); + + return { + overlayX: normalized.x, + overlayY: normalized.y, + overlayWidth: normalized.width, + overlayHeight: normalized.height, + }; +} + +function normalizeCropEdges(record: Record): Pick< + MixerPreviewState, + "cropLeft" | "cropTop" | "cropRight" | "cropBottom" +> { + const hasCropField = + record.cropLeft !== undefined || + record.cropTop !== undefined || + record.cropRight !== undefined || + record.cropBottom !== undefined; + const hasLegacyContentRectField = + record.contentX !== undefined || + record.contentY !== undefined || + record.contentWidth !== undefined || + record.contentHeight !== undefined; + + if (!hasCropField && hasLegacyContentRectField) { + const legacyRect = normalizeUnitRect({ + x: record.contentX, + y: record.contentY, + width: record.contentWidth, + height: record.contentHeight, + defaults: { + x: 0, + y: 0, + width: 1, + height: 1, + }, + }); + + return { + cropLeft: legacyRect.x, + cropTop: legacyRect.y, + cropRight: 1 - (legacyRect.x + legacyRect.width), + cropBottom: 1 - (legacyRect.y + legacyRect.height), + }; + } + + const cropLeft = clamp( + normalizeOverlayNumber(record.cropLeft, DEFAULT_CROP_LEFT), + 0, + 1 - MIN_OVERLAY_SIZE, ); - const overlayY = clamp( - normalizeOverlayNumber(record.overlayY, DEFAULT_OVERLAY_Y), - MIN_OVERLAY_POSITION, - MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE, + const cropTop = clamp( + normalizeOverlayNumber(record.cropTop, DEFAULT_CROP_TOP), + 0, + 1 - MIN_OVERLAY_SIZE, ); - const overlayWidth = clamp( - normalizeOverlayNumber(record.overlayWidth, DEFAULT_OVERLAY_WIDTH), - MIN_OVERLAY_SIZE, - Math.min(MAX_OVERLAY_SIZE, MAX_OVERLAY_POSITION - overlayX), + const cropRight = clamp( + normalizeOverlayNumber(record.cropRight, DEFAULT_CROP_RIGHT), + 0, + 1 - cropLeft - MIN_OVERLAY_SIZE, ); - const overlayHeight = clamp( - normalizeOverlayNumber(record.overlayHeight, DEFAULT_OVERLAY_HEIGHT), - MIN_OVERLAY_SIZE, - Math.min(MAX_OVERLAY_SIZE, MAX_OVERLAY_POSITION - overlayY), + const cropBottom = clamp( + normalizeOverlayNumber(record.cropBottom, DEFAULT_CROP_BOTTOM), + 0, + 1 - cropTop - MIN_OVERLAY_SIZE, ); return { - overlayX, - overlayY, - overlayWidth, - overlayHeight, + cropLeft, + cropTop, + cropRight, + cropBottom, }; } export function normalizeMixerPreviewData(data: unknown): Pick< MixerPreviewState, - "blendMode" | "opacity" | "overlayX" | "overlayY" | "overlayWidth" | "overlayHeight" + | "blendMode" + | "opacity" + | "overlayX" + | "overlayY" + | "overlayWidth" + | "overlayHeight" + | "cropLeft" + | "cropTop" + | "cropRight" + | "cropBottom" > { const record = (data ?? {}) as Record; const blendMode = MIXER_BLEND_MODES.has(record.blendMode as MixerBlendMode) @@ -143,6 +249,7 @@ export function normalizeMixerPreviewData(data: unknown): Pick< blendMode, opacity: normalizeOpacity(record.opacity), ...normalizeOverlayRect(record), + ...normalizeCropEdges(record), }; } @@ -174,6 +281,17 @@ function resolveSourceUrlFromNode(args: { } if (args.sourceNode.type === "render") { + const preview = resolveRenderPreviewInputFromGraph({ + nodeId: args.sourceNode.id, + graph: args.graph, + }); + if (preview.sourceComposition) { + return undefined; + } + if (preview.sourceUrl) { + return preview.sourceUrl; + } + const renderData = (args.sourceNode.data ?? {}) as Record; const renderOutputUrl = typeof renderData.lastUploadUrl === "string" && renderData.lastUploadUrl.length > 0 @@ -188,11 +306,7 @@ function resolveSourceUrlFromNode(args: { return directRenderUrl; } - const preview = resolveRenderPreviewInputFromGraph({ - nodeId: args.sourceNode.id, - graph: args.graph, - }); - return preview.sourceUrl ?? undefined; + return undefined; } return resolveNodeImageUrl(args.sourceNode.data) ?? undefined; diff --git a/lib/canvas-node-templates.ts b/lib/canvas-node-templates.ts index e5c4aa6..961a022 100644 --- a/lib/canvas-node-templates.ts +++ b/lib/canvas-node-templates.ts @@ -55,6 +55,10 @@ export const CANVAS_NODE_TEMPLATES = [ overlayY: 0, overlayWidth: 1, overlayHeight: 1, + cropLeft: 0, + cropTop: 0, + cropRight: 0, + cropBottom: 0, }, }, { diff --git a/lib/canvas-render-preview.ts b/lib/canvas-render-preview.ts index 6d2b15e..6fcbf05 100644 --- a/lib/canvas-render-preview.ts +++ b/lib/canvas-render-preview.ts @@ -32,6 +32,10 @@ export type RenderPreviewSourceComposition = { overlayY: number; overlayWidth: number; overlayHeight: number; + cropLeft: number; + cropTop: number; + cropRight: number; + cropBottom: number; }; export type CanvasGraphNodeLike = { @@ -161,6 +165,10 @@ const DEFAULT_OVERLAY_X = 0; const DEFAULT_OVERLAY_Y = 0; const DEFAULT_OVERLAY_WIDTH = 1; const DEFAULT_OVERLAY_HEIGHT = 1; +const DEFAULT_CROP_LEFT = 0; +const DEFAULT_CROP_TOP = 0; +const DEFAULT_CROP_RIGHT = 0; +const DEFAULT_CROP_BOTTOM = 0; const MIN_OVERLAY_POSITION = 0; const MAX_OVERLAY_POSITION = 1; const MIN_OVERLAY_SIZE = 0.1; @@ -250,6 +258,80 @@ function normalizeMixerCompositionRect(data: Record): Pick< }; } +function normalizeMixerCompositionCropEdges(data: Record): Pick< + RenderPreviewSourceComposition, + "cropLeft" | "cropTop" | "cropRight" | "cropBottom" +> { + const hasCropField = + data.cropLeft !== undefined || + data.cropTop !== undefined || + data.cropRight !== undefined || + data.cropBottom !== undefined; + const hasLegacyContentRectField = + data.contentX !== undefined || + data.contentY !== undefined || + data.contentWidth !== undefined || + data.contentHeight !== undefined; + + if (!hasCropField && hasLegacyContentRectField) { + const contentX = clamp( + normalizeOverlayNumber(data.contentX, 0), + MIN_OVERLAY_POSITION, + MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE, + ); + const contentY = clamp( + normalizeOverlayNumber(data.contentY, 0), + MIN_OVERLAY_POSITION, + MAX_OVERLAY_POSITION - MIN_OVERLAY_SIZE, + ); + const contentWidth = clamp( + normalizeOverlayNumber(data.contentWidth, 1), + MIN_OVERLAY_SIZE, + Math.min(MAX_OVERLAY_SIZE, MAX_OVERLAY_POSITION - contentX), + ); + const contentHeight = clamp( + normalizeOverlayNumber(data.contentHeight, 1), + MIN_OVERLAY_SIZE, + Math.min(MAX_OVERLAY_SIZE, MAX_OVERLAY_POSITION - contentY), + ); + + return { + cropLeft: contentX, + cropTop: contentY, + cropRight: 1 - (contentX + contentWidth), + cropBottom: 1 - (contentY + contentHeight), + }; + } + + const cropLeft = clamp( + normalizeOverlayNumber(data.cropLeft, DEFAULT_CROP_LEFT), + 0, + 1 - MIN_OVERLAY_SIZE, + ); + const cropTop = clamp( + normalizeOverlayNumber(data.cropTop, DEFAULT_CROP_TOP), + 0, + 1 - MIN_OVERLAY_SIZE, + ); + const cropRight = clamp( + normalizeOverlayNumber(data.cropRight, DEFAULT_CROP_RIGHT), + 0, + 1 - cropLeft - MIN_OVERLAY_SIZE, + ); + const cropBottom = clamp( + normalizeOverlayNumber(data.cropBottom, DEFAULT_CROP_BOTTOM), + 0, + 1 - cropTop - MIN_OVERLAY_SIZE, + ); + + return { + cropLeft, + cropTop, + cropRight, + cropBottom, + }; +} + export function resolveRenderFingerprint(data: unknown): { resolution: RenderResolutionOption; customWidth?: number; @@ -379,11 +461,6 @@ function resolveMixerSourceUrlFromNode(args: { } if (args.node.type === "render") { - const directRenderUrl = resolveRenderOutputUrl(args.node); - if (directRenderUrl) { - return directRenderUrl; - } - const preview = resolveRenderPreviewInputFromGraph({ nodeId: args.node.id, graph: args.graph, @@ -391,8 +468,16 @@ function resolveMixerSourceUrlFromNode(args: { if (preview.sourceComposition) { return null; } + if (preview.sourceUrl) { + return preview.sourceUrl; + } - return preview.sourceUrl; + const directRenderUrl = resolveRenderOutputUrl(args.node); + if (directRenderUrl) { + return directRenderUrl; + } + + return null; } return resolveNodeImageUrl(args.node.data); @@ -443,6 +528,7 @@ function resolveRenderMixerCompositionFromGraph(args: { blendMode, opacity: normalizeOpacity(data.opacity), ...normalizeMixerCompositionRect(data), + ...normalizeMixerCompositionCropEdges(data), }; } diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts index fd23d42..34a1dd3 100644 --- a/lib/canvas-utils.ts +++ b/lib/canvas-utils.ts @@ -303,6 +303,10 @@ export const NODE_DEFAULTS: Record< overlayY: 0, overlayWidth: 1, overlayHeight: 1, + cropLeft: 0, + cropTop: 0, + cropRight: 0, + cropBottom: 0, }, }, "agent-output": { diff --git a/lib/image-pipeline/render-types.ts b/lib/image-pipeline/render-types.ts index 4fff91d..5cf3368 100644 --- a/lib/image-pipeline/render-types.ts +++ b/lib/image-pipeline/render-types.ts @@ -34,6 +34,10 @@ export type RenderSourceComposition = { overlayY: number; overlayWidth: number; overlayHeight: number; + cropLeft: number; + cropTop: number; + cropRight: number; + cropBottom: number; }; export type ResolvedRenderSize = { diff --git a/lib/image-pipeline/source-loader.ts b/lib/image-pipeline/source-loader.ts index d7da26d..174e14e 100644 --- a/lib/image-pipeline/source-loader.ts +++ b/lib/image-pipeline/source-loader.ts @@ -1,3 +1,6 @@ +import type { RenderSourceComposition } from "@/lib/image-pipeline/render-types"; +import { computeVisibleMixerContentRect } from "@/lib/mixer-crop-layout"; + export const SOURCE_BITMAP_CACHE_MAX_ENTRIES = 32; type CacheEntry = { @@ -12,18 +15,6 @@ type LoadSourceBitmapOptions = { signal?: AbortSignal; }; -type RenderSourceComposition = { - kind: "mixer"; - baseUrl: string; - overlayUrl: string; - blendMode: "normal" | "multiply" | "screen" | "overlay"; - opacity: number; - overlayX: number; - overlayY: number; - overlayWidth: number; - overlayHeight: number; -}; - type LoadRenderSourceBitmapOptions = { sourceUrl?: string; sourceComposition?: RenderSourceComposition; @@ -302,61 +293,63 @@ function normalizeMixerRect(source: RenderSourceComposition): { }; } -function computeObjectCoverSourceRect(args: { - sourceWidth: number; - sourceHeight: number; - destinationWidth: number; - destinationHeight: number; -}): { - sourceX: number; - sourceY: number; - sourceWidth: number; - sourceHeight: number; +function normalizeMixerCropEdges(source: RenderSourceComposition): { + left: number; + top: number; + right: number; + bottom: number; } { - const { sourceWidth, sourceHeight, destinationWidth, destinationHeight } = args; + const legacySource = source as RenderSourceComposition & { + contentX?: number; + contentY?: number; + contentWidth?: number; + contentHeight?: number; + }; + const hasLegacyContentRect = + legacySource.contentX !== undefined || + legacySource.contentY !== undefined || + legacySource.contentWidth !== undefined || + legacySource.contentHeight !== undefined; + + if (hasLegacyContentRect) { + const contentX = Math.max( + 0, + Math.min(0.9, normalizeRatio(legacySource.contentX ?? Number.NaN, 0)), + ); + const contentY = Math.max( + 0, + Math.min(0.9, normalizeRatio(legacySource.contentY ?? Number.NaN, 0)), + ); + const contentWidth = Math.max( + 0.1, + Math.min(1, normalizeRatio(legacySource.contentWidth ?? Number.NaN, 1), 1 - contentX), + ); + const contentHeight = Math.max( + 0.1, + Math.min(1, normalizeRatio(legacySource.contentHeight ?? Number.NaN, 1), 1 - contentY), + ); - if ( - sourceWidth <= 0 || - sourceHeight <= 0 || - destinationWidth <= 0 || - destinationHeight <= 0 - ) { return { - sourceX: 0, - sourceY: 0, - sourceWidth, - sourceHeight, + left: contentX, + top: contentY, + right: 1 - (contentX + contentWidth), + bottom: 1 - (contentY + contentHeight), }; } - const sourceAspectRatio = sourceWidth / sourceHeight; - const destinationAspectRatio = destinationWidth / destinationHeight; + const cropLeft = Math.max(0, Math.min(0.9, normalizeRatio(source.cropLeft, 0))); + const cropTop = Math.max(0, Math.min(0.9, normalizeRatio(source.cropTop, 0))); + const cropRight = Math.max(0, Math.min(1 - cropLeft - 0.1, normalizeRatio(source.cropRight, 0))); + const cropBottom = Math.max( + 0, + Math.min(1 - cropTop - 0.1, normalizeRatio(source.cropBottom, 0)), + ); - if (!Number.isFinite(sourceAspectRatio) || !Number.isFinite(destinationAspectRatio)) { - return { - sourceX: 0, - sourceY: 0, - sourceWidth, - sourceHeight, - }; - } - - if (sourceAspectRatio > destinationAspectRatio) { - const croppedWidth = sourceHeight * destinationAspectRatio; - return { - sourceX: (sourceWidth - croppedWidth) / 2, - sourceY: 0, - sourceWidth: croppedWidth, - sourceHeight, - }; - } - - const croppedHeight = sourceWidth / destinationAspectRatio; return { - sourceX: 0, - sourceY: (sourceHeight - croppedHeight) / 2, - sourceWidth, - sourceHeight: croppedHeight, + left: cropLeft, + top: cropTop, + right: cropRight, + bottom: cropBottom, }; } @@ -381,32 +374,49 @@ async function loadMixerCompositionBitmap( context.drawImage(baseBitmap, 0, 0, baseBitmap.width, baseBitmap.height); const rect = normalizeMixerRect(sourceComposition); - const destinationX = rect.x * baseBitmap.width; - const destinationY = rect.y * baseBitmap.height; - const destinationWidth = rect.width * baseBitmap.width; - const destinationHeight = rect.height * baseBitmap.height; - const sourceRect = computeObjectCoverSourceRect({ + const frameX = rect.x * baseBitmap.width; + const frameY = rect.y * baseBitmap.height; + const frameWidth = rect.width * baseBitmap.width; + const frameHeight = rect.height * baseBitmap.height; + const cropEdges = normalizeMixerCropEdges(sourceComposition); + const sourceX = cropEdges.left * overlayBitmap.width; + const sourceY = cropEdges.top * overlayBitmap.height; + const sourceWidth = (1 - cropEdges.left - cropEdges.right) * overlayBitmap.width; + const sourceHeight = (1 - cropEdges.top - cropEdges.bottom) * overlayBitmap.height; + const visibleRect = computeVisibleMixerContentRect({ + frameAspectRatio: frameHeight > 0 ? frameWidth / frameHeight : 1, sourceWidth: overlayBitmap.width, sourceHeight: overlayBitmap.height, - destinationWidth, - destinationHeight, + cropLeft: cropEdges.left, + cropTop: cropEdges.top, + cropRight: cropEdges.right, + cropBottom: cropEdges.bottom, }); + const destX = frameX + (visibleRect?.x ?? 0) * frameWidth; + const destY = frameY + (visibleRect?.y ?? 0) * frameHeight; + const destWidth = (visibleRect?.width ?? 1) * frameWidth; + const destHeight = (visibleRect?.height ?? 1) * frameHeight; context.globalCompositeOperation = mixerBlendModeToCompositeOperation( sourceComposition.blendMode, ); context.globalAlpha = normalizeCompositionOpacity(sourceComposition.opacity); + context.save(); + context.beginPath(); + context.rect(frameX, frameY, frameWidth, frameHeight); + context.clip(); context.drawImage( overlayBitmap, - sourceRect.sourceX, - sourceRect.sourceY, - sourceRect.sourceWidth, - sourceRect.sourceHeight, - destinationX, - destinationY, - destinationWidth, - destinationHeight, + sourceX, + sourceY, + sourceWidth, + sourceHeight, + destX, + destY, + destWidth, + destHeight, ); + context.restore(); context.globalCompositeOperation = "source-over"; context.globalAlpha = 1; diff --git a/lib/mixer-crop-layout.ts b/lib/mixer-crop-layout.ts new file mode 100644 index 0000000..17ba548 --- /dev/null +++ b/lib/mixer-crop-layout.ts @@ -0,0 +1,219 @@ +const MIN_CROP_REMAINING_SIZE = 0.1; + +type MixerSurfaceFit = "contain" | "cover"; + +function formatPercent(value: number): string { + const normalized = Math.abs(value) < 1e-10 ? 0 : value; + return `${normalized}%`; +} + +function computeFittedRect(args: { + sourceWidth: number; + sourceHeight: number; + boundsX: number; + boundsY: number; + boundsWidth: number; + boundsHeight: number; + fit?: MixerSurfaceFit; +}): { x: number; y: number; width: number; height: number } { + const { + sourceWidth, + sourceHeight, + boundsX, + boundsY, + boundsWidth, + boundsHeight, + fit = "contain", + } = args; + + if (sourceWidth <= 0 || sourceHeight <= 0 || boundsWidth <= 0 || boundsHeight <= 0) { + return { + x: boundsX, + y: boundsY, + width: boundsWidth, + height: boundsHeight, + }; + } + + const scale = + fit === "cover" + ? Math.max(boundsWidth / sourceWidth, boundsHeight / sourceHeight) + : Math.min(boundsWidth / sourceWidth, boundsHeight / sourceHeight); + if (!Number.isFinite(scale) || scale <= 0) { + return { + x: boundsX, + y: boundsY, + width: boundsWidth, + height: boundsHeight, + }; + } + + const width = sourceWidth * scale; + const height = sourceHeight * scale; + + return { + x: boundsX + (boundsWidth - width) / 2, + y: boundsY + (boundsHeight - height) / 2, + width, + height, + }; +} + +export function computeMixerFrameRectInSurface(args: { + surfaceWidth: number; + surfaceHeight: number; + baseWidth: number; + baseHeight: number; + overlayX: number; + overlayY: number; + overlayWidth: number; + overlayHeight: number; + fit?: MixerSurfaceFit; +}): { x: number; y: number; width: number; height: number } | null { + if (args.baseWidth <= 0 || args.baseHeight <= 0 || args.surfaceWidth <= 0 || args.surfaceHeight <= 0) { + return null; + } + + const baseRect = computeFittedRect({ + sourceWidth: args.baseWidth, + sourceHeight: args.baseHeight, + boundsX: 0, + boundsY: 0, + boundsWidth: args.surfaceWidth, + boundsHeight: args.surfaceHeight, + fit: args.fit, + }); + + return { + x: (baseRect.x + args.overlayX * baseRect.width) / args.surfaceWidth, + y: (baseRect.y + args.overlayY * baseRect.height) / args.surfaceHeight, + width: (args.overlayWidth * baseRect.width) / args.surfaceWidth, + height: (args.overlayHeight * baseRect.height) / args.surfaceHeight, + }; +} + +export function computeVisibleMixerContentRect(args: { + frameAspectRatio: number; + sourceWidth: number; + sourceHeight: number; + cropLeft: number; + cropTop: number; + cropRight: number; + cropBottom: number; +}): { x: number; y: number; width: number; height: number } | null { + if (args.sourceWidth <= 0 || args.sourceHeight <= 0) { + return null; + } + + const cropWidth = Math.max(1 - args.cropLeft - args.cropRight, MIN_CROP_REMAINING_SIZE); + const cropHeight = Math.max(1 - args.cropTop - args.cropBottom, MIN_CROP_REMAINING_SIZE); + const frameAspectRatio = args.frameAspectRatio > 0 ? args.frameAspectRatio : 1; + + const rect = computeFittedRect({ + sourceWidth: args.sourceWidth * cropWidth, + sourceHeight: args.sourceHeight * cropHeight, + boundsX: 0, + boundsY: 0, + boundsWidth: frameAspectRatio, + boundsHeight: 1, + }); + + return { + x: rect.x / frameAspectRatio, + y: rect.y, + width: rect.width / frameAspectRatio, + height: rect.height, + }; +} + +export function computeMixerCropImageStyle(args: { + frameAspectRatio: number; + sourceWidth: number; + sourceHeight: number; + cropLeft: number; + cropTop: number; + cropRight: number; + cropBottom: number; +}) { + const safeWidth = Math.max(1 - args.cropLeft - args.cropRight, MIN_CROP_REMAINING_SIZE); + const safeHeight = Math.max(1 - args.cropTop - args.cropBottom, MIN_CROP_REMAINING_SIZE); + const visibleRect = computeVisibleMixerContentRect(args); + + if (!visibleRect) { + return { + left: formatPercent((-args.cropLeft / safeWidth) * 100), + top: formatPercent((-args.cropTop / safeHeight) * 100), + width: formatPercent((1 / safeWidth) * 100), + height: formatPercent((1 / safeHeight) * 100), + } as const; + } + + const imageWidth = visibleRect.width / safeWidth; + const imageHeight = visibleRect.height / safeHeight; + + return { + left: formatPercent((visibleRect.x - (args.cropLeft / safeWidth) * visibleRect.width) * 100), + top: formatPercent((visibleRect.y - (args.cropTop / safeHeight) * visibleRect.height) * 100), + width: formatPercent(imageWidth * 100), + height: formatPercent(imageHeight * 100), + } as const; +} + +export function computeMixerCompareOverlayImageStyle(args: { + surfaceWidth: number; + surfaceHeight: number; + baseWidth: number; + baseHeight: number; + overlayX: number; + overlayY: number; + overlayWidth: number; + overlayHeight: number; + sourceWidth: number; + sourceHeight: number; + cropLeft: number; + cropTop: number; + cropRight: number; + cropBottom: number; +}) { + const frameRect = computeMixerFrameRectInSurface({ + surfaceWidth: args.surfaceWidth, + surfaceHeight: args.surfaceHeight, + baseWidth: args.baseWidth, + baseHeight: args.baseHeight, + overlayX: args.overlayX, + overlayY: args.overlayY, + overlayWidth: args.overlayWidth, + overlayHeight: args.overlayHeight, + }); + + const frameAspectRatio = + frameRect && frameRect.width > 0 && frameRect.height > 0 + ? (frameRect.width * args.surfaceWidth) / (frameRect.height * args.surfaceHeight) + : args.overlayWidth > 0 && args.overlayHeight > 0 + ? args.overlayWidth / args.overlayHeight + : 1; + + return computeMixerCropImageStyle({ + frameAspectRatio, + sourceWidth: args.sourceWidth, + sourceHeight: args.sourceHeight, + cropLeft: args.cropLeft, + cropTop: args.cropTop, + cropRight: args.cropRight, + cropBottom: args.cropBottom, + }); +} + +export function isMixerCropImageReady(args: { + currentOverlayUrl: string | null | undefined; + loadedOverlayUrl: string | null; + sourceWidth: number; + sourceHeight: number; +}): boolean { + return Boolean( + args.currentOverlayUrl && + args.loadedOverlayUrl === args.currentOverlayUrl && + args.sourceWidth > 0 && + args.sourceHeight > 0, + ); +} diff --git a/tests/image-pipeline/image-pipeline.worker.test.ts b/tests/image-pipeline/image-pipeline.worker.test.ts index 93190f7..9969793 100644 --- a/tests/image-pipeline/image-pipeline.worker.test.ts +++ b/tests/image-pipeline/image-pipeline.worker.test.ts @@ -83,6 +83,10 @@ describe("image-pipeline.worker full render", () => { overlayY: 16, overlayWidth: 128, overlayHeight: 64, + cropLeft: 0, + cropTop: 0, + cropRight: 0, + cropBottom: 0, }; workerScope.onmessage?.({ diff --git a/tests/image-pipeline/source-loader.test.ts b/tests/image-pipeline/source-loader.test.ts index 5ec43f2..15bbbb8 100644 --- a/tests/image-pipeline/source-loader.test.ts +++ b/tests/image-pipeline/source-loader.test.ts @@ -356,7 +356,7 @@ describe("loadSourceBitmap", () => { expect(revokeObjectUrl).toHaveBeenCalledWith("blob:video-source"); }); - it("renders mixer overlays with object-cover semantics instead of stretching", async () => { + it("renders non-square mixer overlays with contain-fit parity instead of stretching", async () => { const baseBlob = new Blob(["base"]); const overlayBlob = new Blob(["overlay"]); const baseBitmap = { width: 100, height: 100 } as ImageBitmap; @@ -367,6 +367,11 @@ describe("loadSourceBitmap", () => { const context = { clearRect: vi.fn(), drawImage, + save: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + rect: vi.fn(), + clip: vi.fn(), globalCompositeOperation: "source-over" as GlobalCompositeOperation, globalAlpha: 1, }; @@ -420,7 +425,7 @@ describe("loadSourceBitmap", () => { return composedBitmap; } - throw new Error("Unexpected createImageBitmap input in mixer cover-fit test."); + throw new Error("Unexpected createImageBitmap input in mixer contain-fit test."); }), ); @@ -438,22 +443,358 @@ describe("loadSourceBitmap", () => { overlayY: 0.2, overlayWidth: 0.25, overlayHeight: 0.5, + cropLeft: 0, + cropTop: 0, + cropRight: 0, + cropBottom: 0, }, }), ).resolves.toBe(composedBitmap); expect(drawImage).toHaveBeenNthCalledWith(1, baseBitmap, 0, 0, 100, 100); + const overlayDrawArgs = drawImage.mock.calls[1]; + expect(overlayDrawArgs?.[0]).toBe(overlayBitmap); + expect(overlayDrawArgs?.[1]).toBe(0); + expect(overlayDrawArgs?.[2]).toBe(0); + expect(overlayDrawArgs?.[3]).toBe(200); + expect(overlayDrawArgs?.[4]).toBe(100); + expect(overlayDrawArgs?.[5]).toBe(10); + expect(overlayDrawArgs?.[6]).toBeCloseTo(38.75, 10); + expect(overlayDrawArgs?.[7]).toBe(25); + expect(overlayDrawArgs?.[8]).toBeCloseTo(12.5, 10); + }); + + it("applies mixer crop framing by trimming source edges while leaving the displayed frame size untouched", async () => { + const baseBlob = new Blob(["base"]); + const overlayBlob = new Blob(["overlay"]); + const baseBitmap = { width: 100, height: 100 } as ImageBitmap; + const overlayBitmap = { width: 200, height: 100 } as ImageBitmap; + const composedBitmap = { width: 100, height: 100 } as ImageBitmap; + + const drawImage = vi.fn(); + const save = vi.fn(); + const restore = vi.fn(); + const beginPath = vi.fn(); + const rect = vi.fn(); + const clip = vi.fn(); + const context = { + clearRect: vi.fn(), + drawImage, + save, + restore, + beginPath, + rect, + clip, + globalCompositeOperation: "source-over" as GlobalCompositeOperation, + globalAlpha: 1, + }; + const canvas = { + width: 0, + height: 0, + getContext: vi.fn().mockReturnValue(context), + } as unknown as HTMLCanvasElement; + + const nativeCreateElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tagName: string) => { + if (tagName.toLowerCase() === "canvas") { + return canvas; + } + + return nativeCreateElement(tagName); + }); + + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("base.png")) { + return { + ok: true, + status: 200, + headers: { get: vi.fn().mockReturnValue("image/png") }, + blob: vi.fn().mockResolvedValue(baseBlob), + }; + } + + return { + ok: true, + status: 200, + headers: { get: vi.fn().mockReturnValue("image/png") }, + blob: vi.fn().mockResolvedValue(overlayBlob), + }; + }), + ); + + vi.stubGlobal( + "createImageBitmap", + vi.fn().mockImplementation(async (input: unknown) => { + if (input === baseBlob) { + return baseBitmap; + } + if (input === overlayBlob) { + return overlayBitmap; + } + if (input === canvas) { + return composedBitmap; + } + + throw new Error("Unexpected createImageBitmap input in mixer content framing test."); + }), + ); + + const { loadRenderSourceBitmap } = await importSubject(); + + await expect( + loadRenderSourceBitmap({ + sourceComposition: { + kind: "mixer", + baseUrl: "https://cdn.example.com/base.png", + overlayUrl: "https://cdn.example.com/overlay.png", + blendMode: "overlay", + opacity: 80, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.4, + overlayHeight: 0.4, + cropLeft: 0.5, + cropTop: 0, + cropRight: 0, + cropBottom: 0, + }, + }), + ).resolves.toBe(composedBitmap); + + expect(drawImage).toHaveBeenNthCalledWith(1, baseBitmap, 0, 0, 100, 100); + expect(save).toHaveBeenCalledTimes(1); + expect(beginPath).toHaveBeenCalledTimes(1); + expect(rect).toHaveBeenCalledWith(10, 20, 40, 40); + expect(clip).toHaveBeenCalledTimes(1); expect(drawImage).toHaveBeenNthCalledWith( 2, overlayBitmap, - 75, + 100, 0, - 50, + 100, 100, 10, 20, - 25, - 50, + 40, + 40, ); + expect(restore).toHaveBeenCalledTimes(1); + }); + + it("keeps overlayWidth and overlayHeight fixed while crop framing trims the sampled source region", async () => { + const baseBlob = new Blob(["base"]); + const overlayBlob = new Blob(["overlay"]); + const baseBitmap = { width: 100, height: 100 } as ImageBitmap; + const overlayBitmap = { width: 200, height: 100 } as ImageBitmap; + const composedBitmap = { width: 100, height: 100 } as ImageBitmap; + + const drawImage = vi.fn(); + const context = { + clearRect: vi.fn(), + drawImage, + save: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + rect: vi.fn(), + clip: vi.fn(), + globalCompositeOperation: "source-over" as GlobalCompositeOperation, + globalAlpha: 1, + }; + const canvas = { + width: 0, + height: 0, + getContext: vi.fn().mockReturnValue(context), + } as unknown as HTMLCanvasElement; + + const nativeCreateElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tagName: string) => { + if (tagName.toLowerCase() === "canvas") { + return canvas; + } + + return nativeCreateElement(tagName); + }); + + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("base.png")) { + return { + ok: true, + status: 200, + headers: { get: vi.fn().mockReturnValue("image/png") }, + blob: vi.fn().mockResolvedValue(baseBlob), + }; + } + + return { + ok: true, + status: 200, + headers: { get: vi.fn().mockReturnValue("image/png") }, + blob: vi.fn().mockResolvedValue(overlayBlob), + }; + }), + ); + + vi.stubGlobal( + "createImageBitmap", + vi.fn().mockImplementation(async (input: unknown) => { + if (input === baseBlob) { + return baseBitmap; + } + if (input === overlayBlob) { + return overlayBitmap; + } + if (input === canvas) { + return composedBitmap; + } + + throw new Error("Unexpected createImageBitmap input in overlay size preservation test."); + }), + ); + + const { loadRenderSourceBitmap } = await importSubject(); + + await expect( + loadRenderSourceBitmap({ + sourceComposition: { + kind: "mixer", + baseUrl: "https://cdn.example.com/base.png", + overlayUrl: "https://cdn.example.com/overlay.png", + blendMode: "overlay", + opacity: 80, + overlayX: 0.15, + overlayY: 0.25, + overlayWidth: 0.5, + overlayHeight: 0.3, + cropLeft: 0.25, + cropTop: 0.1, + cropRight: 0.25, + cropBottom: 0.3, + }, + }), + ).resolves.toBe(composedBitmap); + + const overlayDrawArgs = drawImage.mock.calls[1]; + expect(overlayDrawArgs?.[0]).toBe(overlayBitmap); + expect(overlayDrawArgs?.[1]).toBe(50); + expect(overlayDrawArgs?.[2]).toBe(10); + expect(overlayDrawArgs?.[3]).toBe(100); + expect(overlayDrawArgs?.[4]).toBeCloseTo(60, 10); + expect(overlayDrawArgs?.[5]).toBeCloseTo(15, 10); + expect(overlayDrawArgs?.[6]).toBeCloseTo(25, 10); + expect(overlayDrawArgs?.[7]).toBeCloseTo(50, 10); + expect(overlayDrawArgs?.[8]).toBeCloseTo(30, 10); + }); + + it("contains a cropped wide source within the overlay frame during bake", async () => { + const baseBlob = new Blob(["base"]); + const overlayBlob = new Blob(["overlay"]); + const baseBitmap = { width: 100, height: 100 } as ImageBitmap; + const overlayBitmap = { width: 200, height: 100 } as ImageBitmap; + const composedBitmap = { width: 100, height: 100 } as ImageBitmap; + + const drawImage = vi.fn(); + const context = { + clearRect: vi.fn(), + drawImage, + save: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + rect: vi.fn(), + clip: vi.fn(), + globalCompositeOperation: "source-over" as GlobalCompositeOperation, + globalAlpha: 1, + }; + const canvas = { + width: 0, + height: 0, + getContext: vi.fn().mockReturnValue(context), + } as unknown as HTMLCanvasElement; + + const nativeCreateElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tagName: string) => { + if (tagName.toLowerCase() === "canvas") { + return canvas; + } + + return nativeCreateElement(tagName); + }); + + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("base.png")) { + return { + ok: true, + status: 200, + headers: { get: vi.fn().mockReturnValue("image/png") }, + blob: vi.fn().mockResolvedValue(baseBlob), + }; + } + + return { + ok: true, + status: 200, + headers: { get: vi.fn().mockReturnValue("image/png") }, + blob: vi.fn().mockResolvedValue(overlayBlob), + }; + }), + ); + + vi.stubGlobal( + "createImageBitmap", + vi.fn().mockImplementation(async (input: unknown) => { + if (input === baseBlob) { + return baseBitmap; + } + if (input === overlayBlob) { + return overlayBitmap; + } + if (input === canvas) { + return composedBitmap; + } + + throw new Error("Unexpected createImageBitmap input in aspect-aware crop bake test."); + }), + ); + + const { loadRenderSourceBitmap } = await importSubject(); + + await expect( + loadRenderSourceBitmap({ + sourceComposition: { + kind: "mixer", + baseUrl: "https://cdn.example.com/base.png", + overlayUrl: "https://cdn.example.com/overlay.png", + blendMode: "overlay", + opacity: 80, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.4, + overlayHeight: 0.4, + cropLeft: 0, + cropTop: 0.25, + cropRight: 0, + cropBottom: 0.25, + }, + }), + ).resolves.toBe(composedBitmap); + + const overlayDrawArgs = drawImage.mock.calls[1]; + expect(overlayDrawArgs?.[0]).toBe(overlayBitmap); + expect(overlayDrawArgs?.[1]).toBe(0); + expect(overlayDrawArgs?.[2]).toBe(25); + expect(overlayDrawArgs?.[3]).toBe(200); + expect(overlayDrawArgs?.[4]).toBe(50); + expect(overlayDrawArgs?.[5]).toBe(10); + expect(overlayDrawArgs?.[6]).toBeCloseTo(35, 10); + expect(overlayDrawArgs?.[7]).toBe(40); + expect(overlayDrawArgs?.[8]).toBeCloseTo(10, 10); }); }); diff --git a/tests/lib/canvas-mixer-preview.test.ts b/tests/lib/canvas-mixer-preview.test.ts index a3f2b57..91231dd 100644 --- a/tests/lib/canvas-mixer-preview.test.ts +++ b/tests/lib/canvas-mixer-preview.test.ts @@ -4,7 +4,7 @@ import { buildGraphSnapshot } from "@/lib/canvas-render-preview"; import { resolveMixerPreviewFromGraph } from "@/lib/canvas-mixer-preview"; describe("resolveMixerPreviewFromGraph", () => { - it("resolves base and overlay URLs by target handle", () => { + it("resolves base and overlay URLs by target handle while keeping frame and crop trims independent", () => { const graph = buildGraphSnapshot( [ { @@ -32,6 +32,10 @@ describe("resolveMixerPreviewFromGraph", () => { overlayY: 0.2, overlayWidth: 0.6, overlayHeight: 0.5, + cropLeft: 0.08, + cropTop: 0.15, + cropRight: 0.22, + cropBottom: 0.1, }, }, ], @@ -52,10 +56,110 @@ describe("resolveMixerPreviewFromGraph", () => { overlayY: 0.2, overlayWidth: 0.6, overlayHeight: 0.5, + cropLeft: 0.08, + cropTop: 0.15, + cropRight: 0.22, + cropBottom: 0.1, }); }); - it("prefers render output URL over upstream preview source when available", () => { + it("preserves crop trims when frame resize data changes", () => { + const graph = buildGraphSnapshot( + [ + { + id: "image-base", + type: "image", + data: { url: "https://cdn.example.com/base.png" }, + }, + { + id: "overlay-asset", + type: "asset", + data: { url: "https://cdn.example.com/overlay.png" }, + }, + { + id: "mixer-1", + type: "mixer", + data: { + overlayX: 0.2, + overlayY: 0.1, + overlayWidth: 0.6, + overlayHeight: 0.3, + cropLeft: 0.15, + cropTop: 0.05, + cropRight: 0.4, + cropBottom: 0.25, + }, + }, + ], + [ + { source: "image-base", target: "mixer-1", targetHandle: "base" }, + { source: "overlay-asset", target: "mixer-1", targetHandle: "overlay" }, + ], + ); + + expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual( + expect.objectContaining({ + overlayX: 0.2, + overlayY: 0.1, + overlayWidth: 0.6, + overlayHeight: 0.3, + cropLeft: 0.15, + cropTop: 0.05, + cropRight: 0.4, + cropBottom: 0.25, + }), + ); + }); + + it("preserves overlayWidth and overlayHeight when crop trims change", () => { + const graph = buildGraphSnapshot( + [ + { + id: "image-base", + type: "image", + data: { url: "https://cdn.example.com/base.png" }, + }, + { + id: "overlay-asset", + type: "asset", + data: { url: "https://cdn.example.com/overlay.png" }, + }, + { + id: "mixer-1", + type: "mixer", + data: { + overlayX: 0.05, + overlayY: 0.25, + overlayWidth: 0.55, + overlayHeight: 0.35, + cropLeft: 0.4, + cropTop: 0.1, + cropRight: 0.3, + cropBottom: 0.1, + }, + }, + ], + [ + { source: "image-base", target: "mixer-1", targetHandle: "base" }, + { source: "overlay-asset", target: "mixer-1", targetHandle: "overlay" }, + ], + ); + + expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual( + expect.objectContaining({ + overlayX: 0.05, + overlayY: 0.25, + overlayWidth: 0.55, + overlayHeight: 0.35, + cropLeft: 0.4, + cropTop: 0.1, + cropRight: 0.3, + cropBottom: 0.1, + }), + ); + }); + + it("prefers live render preview URL over stale baked render output", () => { const graph = buildGraphSnapshot( [ { @@ -91,13 +195,79 @@ describe("resolveMixerPreviewFromGraph", () => { expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({ status: "ready", baseUrl: "https://cdn.example.com/base.png", - overlayUrl: "https://cdn.example.com/render-output.png", + overlayUrl: "https://cdn.example.com/upstream.png", blendMode: "normal", opacity: 100, overlayX: 0, overlayY: 0, overlayWidth: 1, overlayHeight: 1, + cropLeft: 0, + cropTop: 0, + cropRight: 0, + cropBottom: 0, + }); + }); + + it("does not reuse stale baked render output when only live sourceComposition exists", () => { + const graph = buildGraphSnapshot( + [ + { + id: "base-image", + type: "image", + data: { url: "https://cdn.example.com/base.png" }, + }, + { + id: "overlay-base", + type: "image", + data: { url: "https://cdn.example.com/overlay-base.png" }, + }, + { + id: "overlay-asset", + type: "asset", + data: { url: "https://cdn.example.com/overlay-asset.png" }, + }, + { + id: "upstream-mixer", + type: "mixer", + data: {}, + }, + { + id: "render-overlay", + type: "render", + data: { + lastUploadUrl: "https://cdn.example.com/stale-render-output.png", + }, + }, + { + id: "mixer-1", + type: "mixer", + data: {}, + }, + ], + [ + { source: "overlay-base", target: "upstream-mixer", targetHandle: "base" }, + { source: "overlay-asset", target: "upstream-mixer", targetHandle: "overlay" }, + { source: "upstream-mixer", target: "render-overlay" }, + { source: "base-image", target: "mixer-1", targetHandle: "base" }, + { source: "render-overlay", target: "mixer-1", targetHandle: "overlay" }, + ], + ); + + expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({ + status: "partial", + baseUrl: "https://cdn.example.com/base.png", + overlayUrl: undefined, + blendMode: "normal", + opacity: 100, + overlayX: 0, + overlayY: 0, + overlayWidth: 1, + overlayHeight: 1, + cropLeft: 0, + cropTop: 0, + cropRight: 0, + cropBottom: 0, }); }); @@ -128,10 +298,14 @@ describe("resolveMixerPreviewFromGraph", () => { overlayY: 0, overlayWidth: 1, overlayHeight: 1, + cropLeft: 0, + cropTop: 0, + cropRight: 0, + cropBottom: 0, }); }); - it("normalizes rect values and clamps", () => { + it("normalizes crop trims and clamps", () => { const graph = buildGraphSnapshot( [ { @@ -154,6 +328,10 @@ describe("resolveMixerPreviewFromGraph", () => { overlayY: "1.4", overlayWidth: 2, overlayHeight: 0, + cropLeft: "0.95", + cropTop: -2, + cropRight: "4", + cropBottom: "0", }, }, ], @@ -173,6 +351,10 @@ describe("resolveMixerPreviewFromGraph", () => { overlayY: 0.9, overlayWidth: 1, overlayHeight: 0.1, + cropLeft: 0.9, + cropTop: 0, + cropRight: 0, + cropBottom: 0, }); }); @@ -214,6 +396,57 @@ describe("resolveMixerPreviewFromGraph", () => { overlayY: 0, overlayWidth: 1, overlayHeight: 1, + cropLeft: 0, + cropTop: 0, + cropRight: 0, + cropBottom: 0, + }); + }); + + it("maps legacy content rect fields into crop trims during normalization", () => { + const graph = buildGraphSnapshot( + [ + { + id: "base-ai", + type: "ai-image", + data: { url: "https://cdn.example.com/base-ai.png" }, + }, + { + id: "overlay-asset", + type: "asset", + data: { url: "https://cdn.example.com/overlay-asset.png" }, + }, + { + id: "mixer-1", + type: "mixer", + data: { + contentX: 0.2, + contentY: 0.1, + contentWidth: 0.5, + contentHeight: 0.6, + }, + }, + ], + [ + { source: "base-ai", target: "mixer-1", targetHandle: "base" }, + { source: "overlay-asset", target: "mixer-1", targetHandle: "overlay" }, + ], + ); + + expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({ + status: "ready", + baseUrl: "https://cdn.example.com/base-ai.png", + overlayUrl: "https://cdn.example.com/overlay-asset.png", + blendMode: "normal", + opacity: 100, + overlayX: 0, + overlayY: 0, + overlayWidth: 1, + overlayHeight: 1, + cropLeft: 0.2, + cropTop: 0.1, + cropRight: 0.30000000000000004, + cropBottom: 0.30000000000000004, }); }); @@ -255,6 +488,10 @@ describe("resolveMixerPreviewFromGraph", () => { overlayY: 0, overlayWidth: 1, overlayHeight: 1, + cropLeft: 0, + cropTop: 0, + cropRight: 0, + cropBottom: 0, }); }); @@ -293,6 +530,10 @@ describe("resolveMixerPreviewFromGraph", () => { overlayY: 0, overlayWidth: 1, overlayHeight: 1, + cropLeft: 0, + cropTop: 0, + cropRight: 0, + cropBottom: 0, error: "duplicate-handle-edge", }); }); diff --git a/tests/lib/canvas-render-preview.test.ts b/tests/lib/canvas-render-preview.test.ts index 0e9e274..4ca9dd2 100644 --- a/tests/lib/canvas-render-preview.test.ts +++ b/tests/lib/canvas-render-preview.test.ts @@ -4,6 +4,13 @@ import { buildGraphSnapshot, resolveRenderPreviewInputFromGraph, } from "@/lib/canvas-render-preview"; +import { + computeMixerCompareOverlayImageStyle, + computeMixerFrameRectInSurface, + computeVisibleMixerContentRect, + computeMixerCropImageStyle, + isMixerCropImageReady, +} from "@/lib/mixer-crop-layout"; describe("resolveRenderPreviewInputFromGraph", () => { it("resolves mixer input as renderable mixer composition", () => { @@ -29,6 +36,10 @@ describe("resolveRenderPreviewInputFromGraph", () => { overlayY: 0.1, overlayWidth: 0.55, overlayHeight: 0.44, + cropLeft: 0.08, + cropTop: 0.15, + cropRight: 0.22, + cropBottom: 0.1, }, }, { @@ -61,6 +72,10 @@ describe("resolveRenderPreviewInputFromGraph", () => { overlayY: 0.1, overlayWidth: 0.55, overlayHeight: 0.44, + cropLeft: 0.08, + cropTop: 0.15, + cropRight: 0.22, + cropBottom: 0.1, }, steps: [], }); @@ -89,6 +104,10 @@ describe("resolveRenderPreviewInputFromGraph", () => { overlayY: "1.4", overlayWidth: 2, overlayHeight: 0, + cropLeft: "0.95", + cropTop: -2, + cropRight: "4", + cropBottom: "0", }, }, { @@ -119,6 +138,10 @@ describe("resolveRenderPreviewInputFromGraph", () => { overlayY: 0.9, overlayWidth: 1, overlayHeight: 0.1, + cropLeft: 0.9, + cropTop: 0, + cropRight: 0, + cropBottom: 0, }); }); @@ -206,4 +229,189 @@ describe("resolveRenderPreviewInputFromGraph", () => { expect(preview.sourceUrl).toBe("https://cdn.example.com/generated-video.mp4"); expect(preview.sourceComposition).toBeUndefined(); }); + + it("prefers live render preview URLs over stale baked render URLs inside downstream mixer compositions", () => { + const graph = buildGraphSnapshot( + [ + { + id: "base-image", + type: "image", + data: { url: "https://cdn.example.com/base.png" }, + }, + { + id: "overlay-upstream", + type: "image", + data: { url: "https://cdn.example.com/upstream.png" }, + }, + { + id: "render-overlay", + type: "render", + data: { + lastUploadUrl: "https://cdn.example.com/stale-render-output.png", + }, + }, + { + id: "mixer-1", + type: "mixer", + data: {}, + }, + { + id: "render-2", + type: "render", + data: {}, + }, + ], + [ + { source: "overlay-upstream", target: "render-overlay" }, + { source: "base-image", target: "mixer-1", targetHandle: "base" }, + { source: "render-overlay", target: "mixer-1", targetHandle: "overlay" }, + { source: "mixer-1", target: "render-2" }, + ], + ); + + const preview = resolveRenderPreviewInputFromGraph({ nodeId: "render-2", graph }); + + expect(preview).toEqual({ + sourceUrl: null, + sourceComposition: { + kind: "mixer", + baseUrl: "https://cdn.example.com/base.png", + overlayUrl: "https://cdn.example.com/upstream.png", + blendMode: "normal", + opacity: 100, + overlayX: 0, + overlayY: 0, + overlayWidth: 1, + overlayHeight: 1, + cropLeft: 0, + cropTop: 0, + cropRight: 0, + cropBottom: 0, + }, + steps: [], + }); + }); +}); + +describe("mixer crop layout parity", () => { + it("contains a wide cropped source inside a square overlay frame", () => { + expect( + computeVisibleMixerContentRect({ + frameAspectRatio: 1, + sourceWidth: 200, + sourceHeight: 100, + cropLeft: 0, + cropTop: 0.25, + cropRight: 0, + cropBottom: 0.25, + }), + ).toEqual({ + x: 0, + y: 0.375, + width: 1, + height: 0.25, + }); + }); + + it("returns compare image styles that letterbox instead of stretching", () => { + expect( + computeMixerCropImageStyle({ + frameAspectRatio: 1, + sourceWidth: 200, + sourceHeight: 100, + cropLeft: 0, + cropTop: 0, + cropRight: 0, + cropBottom: 0, + }), + ).toEqual({ + left: "0%", + top: "25%", + width: "100%", + height: "50%", + }); + }); + + it("uses the actual base-aware frame pixel ratio for compare crop math", () => { + expect( + computeMixerCompareOverlayImageStyle({ + surfaceWidth: 500, + surfaceHeight: 380, + baseWidth: 200, + baseHeight: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.4, + overlayHeight: 0.4, + sourceWidth: 200, + sourceHeight: 100, + cropLeft: 0.1, + cropTop: 0, + cropRight: 0.1, + cropBottom: 0, + }), + ).toEqual({ + left: "0%", + top: "0%", + width: "100%", + height: "100%", + }); + }); + + it("does not mark compare crop overlay ready before natural size is known", () => { + expect( + isMixerCropImageReady({ + currentOverlayUrl: "https://cdn.example.com/overlay-a.png", + loadedOverlayUrl: null, + sourceWidth: 0, + sourceHeight: 0, + }), + ).toBe(false); + }); + + it("invalidates compare crop overlay readiness on source swap until the new image loads", () => { + expect( + isMixerCropImageReady({ + currentOverlayUrl: "https://cdn.example.com/overlay-b.png", + loadedOverlayUrl: "https://cdn.example.com/overlay-a.png", + sourceWidth: 200, + sourceHeight: 100, + }), + ).toBe(false); + }); + + it("positions mixer overlay frame relative to the displayed base-image rect", () => { + expect( + computeMixerFrameRectInSurface({ + surfaceWidth: 1, + surfaceHeight: 1, + baseWidth: 200, + baseHeight: 100, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.4, + overlayHeight: 0.4, + }), + ).toEqual({ + x: 0.1, + y: 0.35, + width: 0.4, + height: 0.2, + }); + }); + + it("returns null frame placement until base image natural size is known", () => { + expect( + computeMixerFrameRectInSurface({ + surfaceWidth: 1, + surfaceHeight: 1, + baseWidth: 0, + baseHeight: 0, + overlayX: 0.1, + overlayY: 0.2, + overlayWidth: 0.4, + overlayHeight: 0.4, + }), + ).toBeNull(); + }); });