Merge branch 'feat/mixer-overlay-resize-render-bake'

This commit is contained in:
2026-04-15 08:46:42 +02:00
30 changed files with 6361 additions and 194 deletions

View File

@@ -144,16 +144,23 @@ 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), `offsetX`, `offsetY`.
- **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:** nur Inline-Formcontrols im Node; keine Drag-Manipulation im Preview, keine Rotation/Skalierung/Masks.
- **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)
- `compare` versteht `mixer`-Outputs ueber `lib/canvas-mixer-preview.ts`.
- Die Vorschau wird als DOM/CSS-Layering im Client gerendert (inkl. Blend/Opacity/Offset).
- Die Vorschau wird als DOM/CSS-Layering im Client gerendert (inkl. Blend/Opacity/Overlay-Rect).
- Scope bleibt eng: keine pauschale pseudo-image-Unterstuetzung fuer alle Consumer in V1.
### Render-Bake-Pfad (V1)
- Offizieller Bake-Flow: `mixer -> render`.
- `render` konsumiert die Mixer-Komposition (`sourceComposition.kind = "mixer"`) und nutzt sie fuer Preview + finalen Render/Upload.
- `mixer -> adjustments -> render` ist bewusst verschoben (deferred) und aktuell nicht offizieller Scope.
---
## Node-Status-Modell
@@ -325,7 +332,8 @@ useCanvasData (use-canvas-data.ts)
- **Node-Taxonomie:** Alle Node-Typen sind in `lib/canvas-node-catalog.ts` definiert. Phase-2/3 Nodes haben `implemented: false` und `disabledHint`.
- **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. Downstream-Nodes muessen den pseudo-image-Resolver nutzen (aktuell gezielt fuer `compare`).
- **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`.

View File

@@ -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,
@@ -53,6 +65,14 @@ vi.mock("@/components/canvas/canvas-handle", () => ({
),
}));
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>,
}));
@@ -66,6 +86,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
@@ -78,10 +100,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", () => {
@@ -192,6 +251,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 = [
{
@@ -275,14 +436,22 @@ 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",
overlayUrl: "https://cdn.example.com/overlay.png",
blendMode: "multiply",
opacity: 62,
offsetX: 12,
offsetY: -4,
overlayX: 0,
overlayY: 0,
overlayWidth: 1,
overlayHeight: 1,
cropLeft: 0,
cropTop: 0,
cropRight: 0,
cropBottom: 0,
},
});
});
@@ -317,4 +486,183 @@ describe("CompareNode render preview inputs", () => {
expect(markup).toContain('data-top="35%"');
expect(markup).toContain('data-top="55%"');
});
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

View File

@@ -218,8 +218,10 @@ describe("useCanvasConnections", () => {
defaultData: {
blendMode: "normal",
opacity: 100,
offsetX: 0,
offsetY: 0,
overlayX: 0,
overlayY: 0,
overlayWidth: 1,
overlayHeight: 1,
},
}),
);
@@ -232,8 +234,10 @@ describe("useCanvasConnections", () => {
data: {
blendMode: "normal",
opacity: 100,
offsetX: 0,
offsetY: 0,
overlayX: 0,
overlayY: 0,
overlayWidth: 1,
overlayHeight: 1,
},
}),
);

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Position, type NodeProps } from "@xyflow/react";
import { ImageIcon } from "lucide-react";
import BaseNodeWrapper from "./base-node-wrapper";
@@ -36,12 +36,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],
@@ -74,11 +80,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"
@@ -92,6 +104,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,
});
@@ -173,7 +186,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)));
@@ -321,6 +387,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"}
/>
)}
@@ -332,6 +399,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"}
/>

View File

@@ -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,12 +41,22 @@ 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;
const previewSteps = usePreview ? previewInput?.steps ?? EMPTY_STEPS : EMPTY_STEPS;
const visibleFinalUrl = usePreview ? undefined : finalUrl;
const previewDebounceMs = shouldFastPathPreviewPipeline(
@@ -43,6 +68,7 @@ export default function CompareSurface({
const { canvasRef, isRendering, error } = usePipelinePreview({
sourceUrl: previewSourceUrl,
sourceComposition: previewSourceComposition,
steps: previewSteps,
nodeWidth,
includeHistogram: false,
@@ -64,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 ? (
@@ -87,19 +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 inset-0 h-full w-full object-contain"
draggable={false}
style={{
mixBlendMode: mixerPreviewState.blendMode,
opacity: mixerPreviewState.opacity / 100,
transform: `translate(${mixerPreviewState.offsetX}px, ${mixerPreviewState.offsetY}px)`,
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

View File

@@ -464,11 +464,13 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
);
const sourceUrl = renderPreviewInput.sourceUrl;
const sourceComposition = renderPreviewInput.sourceComposition;
useEffect(() => {
logRenderDebug("node-data-updated", {
nodeId: id,
hasSourceUrl: typeof sourceUrl === "string" && sourceUrl.length > 0,
hasSourceComposition: Boolean(sourceComposition),
storageId: data.storageId ?? null,
lastUploadStorageId: data.lastUploadStorageId ?? null,
hasResolvedUrl: typeof data.url === "string" && data.url.length > 0,
@@ -485,6 +487,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
data.url,
id,
sourceUrl,
sourceComposition,
]);
const sourceNode = useMemo<SourceNodeDescriptor | null>(
@@ -526,9 +529,12 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
);
const currentPipelineHash = useMemo(() => {
if (!sourceUrl) return null;
return hashPipeline({ sourceUrl, render: renderFingerprint }, steps);
}, [renderFingerprint, sourceUrl, steps]);
if (!sourceUrl && !sourceComposition) return null;
return hashPipeline(
{ source: sourceComposition ?? sourceUrl, render: renderFingerprint },
steps,
);
}, [renderFingerprint, sourceComposition, sourceUrl, steps]);
const isRenderCurrent =
Boolean(currentPipelineHash) && localData.lastRenderedHash === currentPipelineHash;
@@ -558,7 +564,8 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
error: "Error",
};
const hasSource = typeof sourceUrl === "string" && sourceUrl.length > 0;
const hasSource =
(typeof sourceUrl === "string" && sourceUrl.length > 0) || Boolean(sourceComposition);
const previewNodeWidth = Math.max(260, Math.round(width ?? 320));
const {
@@ -569,6 +576,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
error: previewError,
} = usePipelinePreview({
sourceUrl,
sourceComposition,
steps,
nodeWidth: previewNodeWidth,
debounceMs: previewDebounceMs,
@@ -586,6 +594,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
error: fullscreenPreviewError,
} = usePipelinePreview({
sourceUrl: isFullscreenOpen && sourceUrl ? sourceUrl : null,
sourceComposition: isFullscreenOpen ? sourceComposition : undefined,
steps,
nodeWidth: fullscreenPreviewWidth,
includeHistogram: false,
@@ -720,11 +729,12 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
};
const handleRender = async (mode: "download" | "upload") => {
if (!sourceUrl || !currentPipelineHash) {
if ((!sourceUrl && !sourceComposition) || !currentPipelineHash) {
logRenderDebug("render-aborted-prerequisites", {
nodeId: id,
mode,
hasSourceUrl: Boolean(sourceUrl),
hasSourceComposition: Boolean(sourceComposition),
hasPipelineHash: Boolean(currentPipelineHash),
isOffline: status.isOffline,
});
@@ -769,7 +779,8 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
});
const renderResult = await renderFullWithWorkerFallback({
sourceUrl,
sourceUrl: sourceUrl ?? undefined,
sourceComposition,
steps,
render: {
resolution: activeData.outputResolution,

View File

@@ -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 {