feat(canvas): separate mixer resize and crop semantics
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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 }) => <div>{children}</div>,
|
||||
}));
|
||||
@@ -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<string, unknown>) {
|
||||
return renderToStaticMarkup(
|
||||
<CanvasGraphProvider
|
||||
@@ -53,10 +75,47 @@ function renderCompareNode(props: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
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(
|
||||
<CanvasGraphProvider
|
||||
nodes={storeState.nodes as Array<{ id: string; type: string; data?: unknown }>}
|
||||
edges={storeState.edges}
|
||||
>
|
||||
<CompareNode
|
||||
{...({
|
||||
id: "compare-1",
|
||||
data: {},
|
||||
selected: false,
|
||||
dragging: false,
|
||||
zIndex: 0,
|
||||
isConnectable: true,
|
||||
type: "compare",
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
width: 640,
|
||||
height: 480,
|
||||
sourcePosition: undefined,
|
||||
targetPosition: undefined,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
} as unknown as React.ComponentProps<typeof CompareNode>)}
|
||||
/>
|
||||
</CanvasGraphProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
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<typeof import("../nodes/compare-surface")>(
|
||||
"../nodes/compare-surface",
|
||||
);
|
||||
const ActualCompareSurface = compareSurfaceModule.default;
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<CanvasGraphProvider nodes={[]} edges={[]}>
|
||||
<ActualCompareSurface
|
||||
mixerPreviewState={{
|
||||
status: "ready",
|
||||
baseUrl: "https://cdn.example.com/base.png",
|
||||
overlayUrl: "https://cdn.example.com/overlay.png",
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
overlayX: 0,
|
||||
overlayY: 0,
|
||||
overlayWidth: 1,
|
||||
overlayHeight: 1,
|
||||
cropLeft: 0,
|
||||
cropTop: 0,
|
||||
cropRight: 0,
|
||||
cropBottom: 0,
|
||||
}}
|
||||
nodeWidth={500}
|
||||
nodeHeight={380}
|
||||
/>
|
||||
</CanvasGraphProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
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%");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<CompareDisplayMode | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [surfaceSize, setSurfaceSize] = useState<CompareSurfaceSize | null>(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"}
|
||||
/>
|
||||
|
||||
@@ -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<LoadedImageState>({
|
||||
url: null,
|
||||
...ZERO_SIZE,
|
||||
});
|
||||
const [overlayImageState, setOverlayImageState] = useState<LoadedImageState>({
|
||||
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 (
|
||||
<div className="pointer-events-none absolute inset-0" style={clipStyle}>
|
||||
{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 */}
|
||||
<img
|
||||
src={mixerPreviewState.overlayUrl}
|
||||
alt={label ?? "Comparison image"}
|
||||
className="absolute object-contain"
|
||||
draggable={false}
|
||||
style={{
|
||||
mixBlendMode: mixerPreviewState.blendMode,
|
||||
opacity: mixerPreviewState.opacity / 100,
|
||||
left: `${mixerPreviewState.overlayX * 100}%`,
|
||||
top: `${mixerPreviewState.overlayY * 100}%`,
|
||||
width: `${mixerPreviewState.overlayWidth * 100}%`,
|
||||
height: `${mixerPreviewState.overlayHeight * 100}%`,
|
||||
onLoad={(event) => {
|
||||
setBaseImageState({
|
||||
url: event.currentTarget.currentSrc || event.currentTarget.src,
|
||||
width: event.currentTarget.naturalWidth,
|
||||
height: event.currentTarget.naturalHeight,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{mixerFrameRect ? (
|
||||
<div
|
||||
className="absolute overflow-hidden"
|
||||
style={{
|
||||
mixBlendMode: mixerPreviewState.blendMode,
|
||||
opacity: mixerPreviewState.opacity / 100,
|
||||
left: `${mixerFrameRect.x * 100}%`,
|
||||
top: `${mixerFrameRect.y * 100}%`,
|
||||
width: `${mixerFrameRect.width * 100}%`,
|
||||
height: `${mixerFrameRect.height * 100}%`,
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={mixerPreviewState.overlayUrl}
|
||||
alt={label ?? "Comparison image"}
|
||||
className="absolute max-w-none"
|
||||
draggable={false}
|
||||
onLoad={(event) => {
|
||||
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" }
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,25 @@ function logNodeDataDebug(event: string, payload: Record<string, unknown>): void
|
||||
console.info("[Canvas node debug]", event, payload);
|
||||
}
|
||||
|
||||
function diffNodeData(
|
||||
before: Record<string, unknown>,
|
||||
after: Record<string, unknown>,
|
||||
): Record<string, { before: unknown; after: unknown }> {
|
||||
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
const diff: Record<string, { before: unknown; after: unknown }> = {};
|
||||
|
||||
for (const key of keys) {
|
||||
if (before[key] !== after[key]) {
|
||||
diff[key] = {
|
||||
before: before[key],
|
||||
after: after[key],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
export function useNodeLocalData<T>({
|
||||
nodeId,
|
||||
data,
|
||||
@@ -55,6 +74,16 @@ export function useNodeLocalData<T>({
|
||||
const savedValue = localDataRef.current;
|
||||
const savedVersion = localChangeVersionRef.current;
|
||||
|
||||
logNodeDataDebug("queue-save-flush", {
|
||||
nodeId,
|
||||
nodeType: debugLabel,
|
||||
savedVersion,
|
||||
changedFields: diffNodeData(
|
||||
acceptedPersistedDataRef.current as Record<string, unknown>,
|
||||
savedValue as Record<string, unknown>,
|
||||
),
|
||||
});
|
||||
|
||||
Promise.resolve(onSave(savedValue))
|
||||
.then(() => {
|
||||
if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) {
|
||||
@@ -144,7 +173,17 @@ export function useNodeLocalData<T>({
|
||||
|
||||
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<string, unknown>,
|
||||
next as Record<string, unknown>,
|
||||
),
|
||||
});
|
||||
|
||||
localChangeVersionRef.current += 1;
|
||||
hasPendingLocalChangesRef.current = true;
|
||||
@@ -153,7 +192,7 @@ export function useNodeLocalData<T>({
|
||||
setPreviewNodeDataOverride(nodeId, next);
|
||||
queueSave();
|
||||
},
|
||||
[nodeId, queueSave, setPreviewNodeDataOverride],
|
||||
[debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user