"use client"; import { useCallback, useMemo, useRef, useState } from "react"; import { Handle, Position, type NodeProps } from "@xyflow/react"; import { ImageIcon } from "lucide-react"; import BaseNodeWrapper from "./base-node-wrapper"; import CompareSurface from "./compare-surface"; import { useCanvasGraph } from "@/components/canvas/canvas-graph-context"; import { resolveRenderPipelineHash, resolveRenderPreviewInputFromGraph, type RenderPreviewInput, } from "@/lib/canvas-render-preview"; import { resolveMixerPreviewFromGraph, type MixerPreviewState, } from "@/lib/canvas-mixer-preview"; interface CompareNodeData { leftUrl?: string; rightUrl?: string; leftLabel?: string; rightLabel?: string; } type CompareSide = "left" | "right"; type CompareSideState = { finalUrl?: string; label?: string; previewInput?: RenderPreviewInput; mixerPreviewState?: MixerPreviewState; isStaleRenderOutput: boolean; }; type CompareDisplayMode = "render" | "preview"; export default function CompareNode({ id, data, selected, width }: NodeProps) { const nodeData = data as CompareNodeData; const graph = useCanvasGraph(); const [sliderX, setSliderX] = useState(50); const [manualDisplayMode, setManualDisplayMode] = useState(null); const containerRef = useRef(null); const incomingEdges = useMemo( () => graph.incomingEdgesByTarget.get(id) ?? [], [graph, id], ); const resolvedSides = useMemo(() => { const resolveSide = ( side: CompareSide, finalUrl: string | undefined, finalLabel: string | undefined, defaultLabel: string, ): CompareSideState => { const incomingEdge = incomingEdges.find((edge) => edge.targetHandle === side); const sourceNode = incomingEdge ? graph.nodesById.get(incomingEdge.source) : undefined; const sourceData = (sourceNode?.data ?? {}) as Record; const sourceLabel = typeof sourceData.label === "string" && sourceData.label.length > 0 ? sourceData.label : sourceNode?.type; const label = finalLabel ?? sourceLabel ?? defaultLabel; let previewInput: RenderPreviewInput | undefined; let mixerPreviewState: MixerPreviewState | undefined; let isStaleRenderOutput = false; if (sourceNode && sourceNode.type === "render") { const preview = resolveRenderPreviewInputFromGraph({ nodeId: sourceNode.id, graph, }); if (preview.sourceUrl) { previewInput = { sourceUrl: preview.sourceUrl, steps: preview.steps, }; const sourceLastUploadedHash = typeof sourceData.lastUploadedHash === "string" ? sourceData.lastUploadedHash : undefined; const sourceLastRenderedHash = typeof sourceData.lastRenderedHash === "string" ? sourceData.lastRenderedHash : undefined; const sourcePersistedOutputHash = sourceLastUploadedHash ?? sourceLastRenderedHash; const sourceCurrentHash = resolveRenderPipelineHash({ sourceUrl: preview.sourceUrl, steps: preview.steps, data: sourceData, }); isStaleRenderOutput = Boolean(finalUrl) && Boolean(sourceCurrentHash) && Boolean(sourcePersistedOutputHash) && sourceCurrentHash !== sourcePersistedOutputHash; } } if (sourceNode && sourceNode.type === "mixer") { const mixerPreview = resolveMixerPreviewFromGraph({ nodeId: sourceNode.id, graph, }); if (mixerPreview.status === "ready") { mixerPreviewState = mixerPreview; } } const visibleFinalUrl = sourceNode?.type === "mixer" && mixerPreviewState ? undefined : finalUrl; if (visibleFinalUrl) { return { finalUrl: visibleFinalUrl, label, previewInput, mixerPreviewState, isStaleRenderOutput, }; } return { label, previewInput, mixerPreviewState, isStaleRenderOutput, }; }; return { left: resolveSide("left", nodeData.leftUrl, nodeData.leftLabel, "Before"), right: resolveSide("right", nodeData.rightUrl, nodeData.rightLabel, "After"), }; }, [ incomingEdges, nodeData.leftLabel, nodeData.leftUrl, nodeData.rightLabel, nodeData.rightUrl, graph, ]); const hasLeft = Boolean( resolvedSides.left.finalUrl || resolvedSides.left.previewInput || resolvedSides.left.mixerPreviewState, ); const hasRight = Boolean( resolvedSides.right.finalUrl || resolvedSides.right.previewInput || resolvedSides.right.mixerPreviewState, ); const hasConnectedRenderInput = useMemo( () => incomingEdges.some((edge) => { const sourceNode = graph.nodesById.get(edge.source); return sourceNode?.type === "render"; }), [graph, incomingEdges], ); const shouldDefaultToPreview = hasConnectedRenderInput || resolvedSides.left.isStaleRenderOutput || resolvedSides.right.isStaleRenderOutput; const effectiveDisplayMode = manualDisplayMode ?? (shouldDefaultToPreview ? "preview" : "render"); const previewNodeWidth = Math.max(240, Math.min(640, Math.round(width ?? 500))); const setSliderPercent = useCallback((value: number) => { setSliderX(Math.max(0, Math.min(100, value))); }, []); const handleSliderKeyDown = useCallback((event: React.KeyboardEvent) => { let nextValue: number | null = null; const step = event.shiftKey ? 10 : 2; if (event.key === "ArrowLeft" || event.key === "ArrowDown") { nextValue = sliderX - step; } else if (event.key === "ArrowRight" || event.key === "ArrowUp") { nextValue = sliderX + step; } else if (event.key === "Home") { nextValue = 0; } else if (event.key === "End") { nextValue = 100; } if (nextValue === null) { return; } event.preventDefault(); event.stopPropagation(); setSliderPercent(nextValue); }, [setSliderPercent, sliderX]); const handleMouseDown = useCallback((event: React.MouseEvent) => { event.stopPropagation(); const move = (moveEvent: MouseEvent) => { if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (moveEvent.clientX - rect.left) / rect.width)); setSliderPercent(x * 100); }; const up = () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); }, [setSliderPercent]); const handleTouchStart = useCallback((event: React.TouchEvent) => { event.stopPropagation(); const move = (moveEvent: TouchEvent) => { if (!containerRef.current || moveEvent.touches.length === 0) return; const rect = containerRef.current.getBoundingClientRect(); const touch = moveEvent.touches[0]; const x = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); setSliderPercent(x * 100); }; const end = () => { window.removeEventListener("touchmove", move); window.removeEventListener("touchend", end); }; window.addEventListener("touchmove", move); window.addEventListener("touchend", end); }, [setSliderPercent]); return (
⚖️ Compare
{hasConnectedRenderInput && (
)}
{!hasLeft && !hasRight && (

Connect two image nodes - left handle (blue) and right handle (green)

)} {hasRight && ( )} {hasLeft && ( )} {hasLeft && hasRight && ( <>
)} {hasLeft && (
{resolvedSides.left.label ?? "Before"}
)} {hasRight && (
{resolvedSides.right.label ?? "After"}
)}
); }