feat(canvas): separate mixer resize and crop semantics

This commit is contained in:
2026-04-15 08:31:53 +02:00
parent 61728f9e52
commit f1c61fd14e
18 changed files with 4783 additions and 228 deletions

View File

@@ -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"}
/>

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,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

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 {