diff --git a/components/canvas/__tests__/canvas-helpers.test.ts b/components/canvas/__tests__/canvas-helpers.test.ts index bdb735b..4596282 100644 --- a/components/canvas/__tests__/canvas-helpers.test.ts +++ b/components/canvas/__tests__/canvas-helpers.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest"; import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; import { withResolvedCompareData } from "../canvas-helpers"; +import { + buildGraphSnapshot, + resolveRenderPreviewInputFromGraph, +} from "@/lib/canvas-render-preview"; function createNode(overrides: Partial & Pick): RFNode { return { @@ -94,3 +98,45 @@ describe("withResolvedCompareData", () => { ); }); }); + +describe("canvas preview graph helpers", () => { + it("resolves the upstream source and pipeline steps from a graph snapshot", () => { + const graph = buildGraphSnapshot( + [ + { + id: "image-1", + type: "image", + data: { url: "https://cdn.example.com/source.png" }, + }, + { + id: "curves-1", + type: "curves", + data: { exposure: 0.2 }, + }, + { + id: "render-1", + type: "render", + data: {}, + }, + ], + [ + { source: "image-1", target: "curves-1" }, + { source: "curves-1", target: "render-1" }, + ], + ); + + const preview = resolveRenderPreviewInputFromGraph({ + nodeId: "render-1", + graph, + }); + + expect(preview.sourceUrl).toBe("https://cdn.example.com/source.png"); + expect(preview.steps).toEqual([ + { + nodeId: "curves-1", + type: "curves", + params: { exposure: 0.2 }, + }, + ]); + }); +}); diff --git a/components/canvas/canvas-graph-context.tsx b/components/canvas/canvas-graph-context.tsx new file mode 100644 index 0000000..a7f46b0 --- /dev/null +++ b/components/canvas/canvas-graph-context.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { createContext, useContext, useMemo, type ReactNode } from "react"; + +import { + buildGraphSnapshot, + type CanvasGraphEdgeLike, + type CanvasGraphNodeLike, + type CanvasGraphSnapshot, +} from "@/lib/canvas-render-preview"; + +type CanvasGraphContextValue = CanvasGraphSnapshot; + +const CanvasGraphContext = createContext(null); + +export function CanvasGraphProvider({ + nodes, + edges, + children, +}: { + nodes: readonly CanvasGraphNodeLike[]; + edges: readonly CanvasGraphEdgeLike[]; + children: ReactNode; +}) { + const value = useMemo(() => buildGraphSnapshot(nodes, edges), [edges, nodes]); + + return {children}; +} + +export function useCanvasGraph(): CanvasGraphContextValue { + const context = useContext(CanvasGraphContext); + if (!context) { + throw new Error("useCanvasGraph must be used within CanvasGraphProvider"); + } + + return context; +} diff --git a/components/canvas/canvas-helpers.ts b/components/canvas/canvas-helpers.ts index 5a10868..2b5f7e5 100644 --- a/components/canvas/canvas-helpers.ts +++ b/components/canvas/canvas-helpers.ts @@ -3,7 +3,10 @@ import type { DefaultEdgeOptions, Edge as RFEdge, Node as RFNode } from "@xyflow import { readCanvasOps } from "@/lib/canvas-local-persistence"; import type { Id } from "@/convex/_generated/dataModel"; import type { CanvasNodeDeleteBlockReason } from "@/lib/toast"; -import { getSourceImage } from "@/lib/image-pipeline/contracts"; +import { + buildGraphSnapshot, + getSourceImageFromGraph, +} from "@/lib/canvas-render-preview"; import { NODE_HANDLE_MAP } from "@/lib/canvas-utils"; export const OPTIMISTIC_NODE_PREFIX = "optimistic_"; @@ -200,15 +203,20 @@ function resolveStorageFallbackUrl(storageId: string): string | undefined { export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] { const persistedEdges = edges.filter((edge) => edge.className !== "temp"); - const pipelineNodes = nodes.map((node) => ({ - id: node.id, - type: node.type ?? "", - data: node.data, - })); - const pipelineEdges = persistedEdges.map((edge) => ({ - source: edge.source, - target: edge.target, - })); + const graph = buildGraphSnapshot( + nodes.map((node) => ({ + id: node.id, + type: node.type ?? "", + data: node.data, + })), + persistedEdges.map((edge) => ({ + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle ?? undefined, + targetHandle: edge.targetHandle ?? undefined, + className: edge.className ?? undefined, + })), + ); const resolveImageFromNode = (node: RFNode): string | undefined => { const nodeData = node.data as { url?: string; previewUrl?: string }; @@ -257,21 +265,21 @@ export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNod return direct; } - return getSourceImage({ - nodeId: sourceNode.id, - nodes: pipelineNodes, - edges: pipelineEdges, - isSourceNode: (node) => - node.type === "image" || - node.type === "ai-image" || - node.type === "asset" || - node.type === "render", - getSourceImageFromNode: (node) => { - const candidate = nodes.find((entry) => entry.id === node.id); - if (!candidate) return null; - return resolveImageFromNode(candidate) ?? null; - }, - }) ?? undefined; + return ( + getSourceImageFromGraph(graph, { + nodeId: sourceNode.id, + isSourceNode: (node) => + node.type === "image" || + node.type === "ai-image" || + node.type === "asset" || + node.type === "render", + getSourceImageFromNode: (node) => { + const candidate = graph.nodesById.get(node.id); + if (!candidate) return null; + return resolveImageFromNode(candidate as RFNode) ?? null; + }, + }) ?? undefined + ); }; let hasNodeUpdates = false; @@ -279,14 +287,14 @@ export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNod const nextNodes = nodes.map((node) => { if (node.type !== "compare") return node; - const incoming = persistedEdges.filter((edge) => edge.target === node.id); + const incoming = graph.incomingEdgesByTarget.get(node.id) ?? []; let leftUrl: string | undefined; let rightUrl: string | undefined; let leftLabel: string | undefined; let rightLabel: string | undefined; for (const edge of incoming) { - const source = nodes.find((candidate) => candidate.id === edge.source); + const source = graph.nodesById.get(edge.source); if (!source) continue; const srcData = source.data as { url?: string; label?: string }; @@ -300,8 +308,8 @@ export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNod const hasSourceUrl = typeof srcData.url === "string" && srcData.url.length > 0; let resolvedUrl = source.type === "render" - ? resolveRenderOutputUrl(source) - : resolvePipelineImageUrl(source); + ? resolveRenderOutputUrl(source as RFNode) + : resolvePipelineImageUrl(source as RFNode); if ( resolvedUrl === undefined && !hasSourceUrl && diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 8f755ce..84ad127 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -45,6 +45,7 @@ import { CanvasConnectionDropMenu, } from "@/components/canvas/canvas-connection-drop-menu"; import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context"; +import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-context"; import { CanvasPresetsProvider } from "@/components/canvas/canvas-presets-context"; import { AssetBrowserTargetContext, @@ -171,6 +172,28 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { [assetBrowserTargetNodeId], ); + const canvasGraphNodes = useMemo( + () => + nodes.map((node) => ({ + id: node.id, + type: node.type ?? "", + data: node.data, + })), + [nodes], + ); + + const canvasGraphEdges = useMemo( + () => + edges.map((edge) => ({ + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle ?? undefined, + targetHandle: edge.targetHandle ?? undefined, + className: edge.className ?? undefined, + })), + [edges], + ); + const pendingRemovedEdgeIds = useMemo( () => { void convexEdges; @@ -483,52 +506,54 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { scissorsMode ? onScissorsFlowPointerDownCapture : undefined } > - - - - - + + + + + + + diff --git a/components/canvas/nodes/adjustment-preview.tsx b/components/canvas/nodes/adjustment-preview.tsx index c760daa..0b10523 100644 --- a/components/canvas/nodes/adjustment-preview.tsx +++ b/components/canvas/nodes/adjustment-preview.tsx @@ -1,10 +1,14 @@ "use client"; import { useMemo } from "react"; -import { useStore, type Node } from "@xyflow/react"; +import { useCanvasGraph } from "@/components/canvas/canvas-graph-context"; import { usePipelinePreview } from "@/hooks/use-pipeline-preview"; -import { collectPipeline, getSourceImage, type PipelineStep } from "@/lib/image-pipeline/contracts"; +import { + collectPipelineFromGraph, + getSourceImageFromGraph, + type PipelineStep, +} from "@/lib/canvas-render-preview"; const PREVIEW_PIPELINE_TYPES = new Set([ "curves", @@ -13,19 +17,6 @@ const PREVIEW_PIPELINE_TYPES = new Set([ "detail-adjust", ]); -function resolveNodeImageUrl(node: Node): string | null { - const data = (node.data ?? {}) as Record; - const directUrl = typeof data.url === "string" ? data.url : null; - if (directUrl && directUrl.length > 0) { - return directUrl; - } - const previewUrl = typeof data.previewUrl === "string" ? data.previewUrl : null; - if (previewUrl && previewUrl.length > 0) { - return previewUrl; - } - return null; -} - function compactHistogram(values: readonly number[], points = 64): number[] { if (points <= 0) { return []; @@ -76,39 +67,31 @@ export default function AdjustmentPreview({ currentType: string; currentParams: unknown; }) { - const nodes = useStore((state) => state.nodes); - const edges = useStore((state) => state.edges); - - const pipelineNodes = useMemo( - () => nodes.map((node) => ({ id: node.id, type: node.type ?? "", data: node.data })), - [nodes], - ); - const pipelineEdges = useMemo( - () => edges.map((edge) => ({ source: edge.source, target: edge.target })), - [edges], - ); + const graph = useCanvasGraph(); const sourceUrl = useMemo( () => - getSourceImage({ + getSourceImageFromGraph(graph, { nodeId, - nodes: pipelineNodes, - edges: pipelineEdges, isSourceNode: (node) => node.type === "image" || node.type === "ai-image" || node.type === "asset", getSourceImageFromNode: (node) => { - const sourceNode = nodes.find((candidate) => candidate.id === node.id); - return sourceNode ? resolveNodeImageUrl(sourceNode) : null; + const sourceData = (node.data ?? {}) as Record; + const directUrl = typeof sourceData.url === "string" ? sourceData.url : null; + if (directUrl && directUrl.length > 0) { + return directUrl; + } + const previewUrl = + typeof sourceData.previewUrl === "string" ? sourceData.previewUrl : null; + return previewUrl && previewUrl.length > 0 ? previewUrl : null; }, }), - [nodeId, nodes, pipelineEdges, pipelineNodes], + [graph, nodeId], ); const steps = useMemo(() => { - const collected = collectPipeline({ + const collected = collectPipelineFromGraph(graph, { nodeId, - nodes: pipelineNodes, - edges: pipelineEdges, isPipelineNode: (node) => PREVIEW_PIPELINE_TYPES.has(node.type ?? ""), }); @@ -121,13 +104,18 @@ export default function AdjustmentPreview({ } return step as PipelineStep; }); - }, [currentParams, currentType, nodeId, pipelineEdges, pipelineNodes]); + }, [currentParams, currentType, graph, nodeId]); const { canvasRef, histogram, isRendering, hasSource, previewAspectRatio, error } = usePipelinePreview({ sourceUrl, steps, nodeWidth, + // Die Vorschau muss in-Node gut lesbar bleiben, aber nicht in voller + // Display-Auflösung rechnen. + previewScale: 0.5, + maxPreviewWidth: 720, + maxDevicePixelRatio: 1.25, }); const histogramSeries = useMemo(() => { diff --git a/components/canvas/nodes/compare-node.tsx b/components/canvas/nodes/compare-node.tsx index c69c918..6c8fa98 100644 --- a/components/canvas/nodes/compare-node.tsx +++ b/components/canvas/nodes/compare-node.tsx @@ -1,13 +1,14 @@ "use client"; import { useCallback, useMemo, useRef, useState } from "react"; -import { Handle, Position, useStore, type NodeProps } from "@xyflow/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, - resolveRenderPreviewInput, + resolveRenderPreviewInputFromGraph, type RenderPreviewInput, } from "@/lib/canvas-render-preview"; @@ -31,31 +32,13 @@ type CompareDisplayMode = "render" | "preview"; export default function CompareNode({ id, data, selected, width }: NodeProps) { const nodeData = data as CompareNodeData; - const nodes = useStore((state) => state.nodes); - const edges = useStore((state) => state.edges); + const graph = useCanvasGraph(); const [sliderX, setSliderX] = useState(50); const [manualDisplayMode, setManualDisplayMode] = useState(null); const containerRef = useRef(null); - - const pipelineNodes = useMemo( - () => nodes.map((node) => ({ id: node.id, type: node.type ?? "", data: node.data })), - [nodes], - ); - const pipelineEdges = useMemo( - () => edges.map((edge) => ({ source: edge.source, target: edge.target })), - [edges], - ); - - const nodesById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]); const incomingEdges = useMemo( - () => - edges.filter( - (edge) => - edge.target === id && - edge.className !== "temp" && - (edge.targetHandle === "left" || edge.targetHandle === "right"), - ), - [edges, id], + () => graph.incomingEdgesByTarget.get(id) ?? [], + [graph, id], ); const resolvedSides = useMemo(() => { @@ -66,7 +49,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) { defaultLabel: string, ): CompareSideState => { const incomingEdge = incomingEdges.find((edge) => edge.targetHandle === side); - const sourceNode = incomingEdge ? nodesById.get(incomingEdge.source) : undefined; + 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 @@ -79,10 +62,9 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) { let isStaleRenderOutput = false; if (sourceNode && sourceNode.type === "render") { - const preview = resolveRenderPreviewInput({ + const preview = resolveRenderPreviewInputFromGraph({ nodeId: sourceNode.id, - nodes: pipelineNodes, - edges: pipelineEdges, + graph, }); if (preview.sourceUrl) { @@ -132,9 +114,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) { nodeData.leftUrl, nodeData.rightLabel, nodeData.rightUrl, - nodesById, - pipelineEdges, - pipelineNodes, + graph, ]); const hasLeft = Boolean(resolvedSides.left.finalUrl || resolvedSides.left.previewInput); @@ -142,10 +122,10 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) { const hasConnectedRenderInput = useMemo( () => incomingEdges.some((edge) => { - const sourceNode = nodesById.get(edge.source); + const sourceNode = graph.nodesById.get(edge.source); return sourceNode?.type === "render"; }), - [incomingEdges, nodesById], + [graph, incomingEdges], ); const shouldDefaultToPreview = hasConnectedRenderInput || diff --git a/components/canvas/nodes/compare-surface.tsx b/components/canvas/nodes/compare-surface.tsx index 0581bd3..94f25ad 100644 --- a/components/canvas/nodes/compare-surface.tsx +++ b/components/canvas/nodes/compare-surface.tsx @@ -31,8 +31,11 @@ export default function CompareSurface({ sourceUrl: previewSourceUrl, steps: previewSteps, nodeWidth, - previewScale: 0.7, - maxPreviewWidth: 960, + // Compare-Nodes zeigen nur eine kompakte Live-Ansicht; kleinere Kacheln + // halten lange Workflows spürbar reaktionsfreudiger. + previewScale: 0.5, + maxPreviewWidth: 720, + maxDevicePixelRatio: 1.25, }); const hasPreview = Boolean(usePreview && previewInput); diff --git a/components/canvas/nodes/render-node.tsx b/components/canvas/nodes/render-node.tsx index 4e1fcae..9bd9171 100644 --- a/components/canvas/nodes/render-node.tsx +++ b/components/canvas/nodes/render-node.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Handle, Position, useStore, type Node, type NodeProps } from "@xyflow/react"; +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { AlertCircle, ArrowDown, CheckCircle2, CloudUpload, Loader2, Maximize2, X } from "lucide-react"; import { useMutation } from "convex/react"; @@ -12,10 +12,14 @@ import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { api } from "@/convex/_generated/api"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { usePipelinePreview } from "@/hooks/use-pipeline-preview"; -import { resolveRenderPreviewInput } from "@/lib/canvas-render-preview"; +import { useCanvasGraph } from "@/components/canvas/canvas-graph-context"; +import { + findSourceNodeFromGraph, + resolveRenderPreviewInputFromGraph, +} from "@/lib/canvas-render-preview"; import { resolveMediaAspectRatio } from "@/lib/canvas-utils"; import { parseAspectRatioString } from "@/lib/image-formats"; -import { getSourceImage, hashPipeline } from "@/lib/image-pipeline/contracts"; +import { hashPipeline } from "@/lib/image-pipeline/contracts"; import { isPipelineAbortError, renderFullWithWorkerFallback, @@ -431,8 +435,7 @@ async function uploadBlobToConvex(args: { export default function RenderNode({ id, data, selected, width, height }: NodeProps) { const generateUploadUrl = useMutation(api.storage.generateUploadUrl); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); - const nodes = useStore((state) => state.nodes); - const edges = useStore((state) => state.edges); + const graph = useCanvasGraph(); const [localData, setLocalData] = useState(() => sanitizeRenderData(data), @@ -485,24 +488,13 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr }); }; - const pipelineNodes = useMemo( - () => nodes.map((node) => ({ id: node.id, type: node.type ?? "", data: node.data })), - [nodes], - ); - - const pipelineEdges = useMemo( - () => edges.map((edge) => ({ source: edge.source, target: edge.target })), - [edges], - ); - const renderPreviewInput = useMemo( () => - resolveRenderPreviewInput({ + resolveRenderPreviewInputFromGraph({ nodeId: id, - nodes: pipelineNodes, - edges: pipelineEdges, + graph, }), - [id, pipelineEdges, pipelineNodes], + [graph, id], ); const sourceUrl = renderPreviewInput.sourceUrl; @@ -531,15 +523,13 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr const sourceNode = useMemo( () => - getSourceImage({ + findSourceNodeFromGraph(graph, { nodeId: id, - nodes: pipelineNodes, - edges: pipelineEdges, isSourceNode: (node) => node.type === "image" || node.type === "ai-image" || node.type === "asset", - getSourceImageFromNode: (node) => node as SourceNodeDescriptor, + getSourceImageFromNode: () => true, }), - [id, pipelineEdges, pipelineNodes], + [graph, id], ); const steps = renderPreviewInput.steps; @@ -608,8 +598,11 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr sourceUrl, steps, nodeWidth: previewNodeWidth, - previewScale: 0.7, - maxPreviewWidth: 960, + // Inline-Preview: bewusst kompakt halten, damit Änderungen schneller + // sichtbar werden, besonders in langen Graphen. + previewScale: 0.5, + maxPreviewWidth: 720, + maxDevicePixelRatio: 1.25, }); const fullscreenPreviewWidth = Math.max(960, Math.round((width ?? 320) * 3)); @@ -621,8 +614,9 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr sourceUrl: isFullscreenOpen && sourceUrl ? sourceUrl : null, steps, nodeWidth: fullscreenPreviewWidth, - previewScale: 1, - maxPreviewWidth: 3072, + previewScale: 0.85, + maxPreviewWidth: 1920, + maxDevicePixelRatio: 1.5, }); const targetAspectRatio = useMemo(() => { diff --git a/convex/nodes.ts b/convex/nodes.ts index 0c1f5ba..5bc457d 100644 --- a/convex/nodes.ts +++ b/convex/nodes.ts @@ -1232,12 +1232,6 @@ export const move = mutation({ await getCanvasOrThrow(ctx, node.canvasId, user.userId); await ctx.db.patch(nodeId, { positionX, positionY }); - console.info("[canvas.updatedAt] touch", { - canvasId: node.canvasId, - source: "nodes.move", - nodeId, - }); - await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); }, }); @@ -1261,13 +1255,6 @@ export const resize = mutation({ ? ADJUSTMENT_MIN_WIDTH : width; await ctx.db.patch(nodeId, { width: clampedWidth, height }); - console.info("[canvas.updatedAt] touch", { - canvasId: node.canvasId, - source: "nodes.resize", - nodeId, - nodeType: node.type, - }); - await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); }, }); @@ -1289,7 +1276,7 @@ export const batchMove = mutation({ if (moves.length === 0) return; const nodeIds = moves.map((move) => move.nodeId); - const { canvasId } = await getValidatedBatchNodesOrThrow( + await getValidatedBatchNodesOrThrow( ctx, user.userId, nodeIds, @@ -1298,13 +1285,6 @@ export const batchMove = mutation({ for (const { nodeId, positionX, positionY } of moves) { await ctx.db.patch(nodeId, { positionX, positionY }); } - - console.info("[canvas.updatedAt] touch", { - canvasId, - source: "nodes.batchMove", - moveCount: moves.length, - }); - await ctx.db.patch(canvasId, { updatedAt: Date.now() }); }, }); @@ -1324,14 +1304,6 @@ export const updateData = mutation({ await getCanvasOrThrow(ctx, node.canvasId, user.userId); const normalizedData = normalizeNodeDataForWrite(node.type, data); await ctx.db.patch(nodeId, { data: normalizedData }); - console.info("[canvas.updatedAt] touch", { - canvasId: node.canvasId, - source: "nodes.updateData", - nodeId, - nodeType: node.type, - approxDataBytes: estimateSerializedBytes(normalizedData), - }); - await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); }, }); diff --git a/hooks/use-pipeline-preview.ts b/hooks/use-pipeline-preview.ts index b1e9381..d536104 100644 --- a/hooks/use-pipeline-preview.ts +++ b/hooks/use-pipeline-preview.ts @@ -16,11 +16,21 @@ type UsePipelinePreviewOptions = { nodeWidth: number; previewScale?: number; maxPreviewWidth?: number; + maxDevicePixelRatio?: number; }; -function computePreviewWidth(nodeWidth: number, previewScale: number, maxPreviewWidth: number): number { +function computePreviewWidth( + nodeWidth: number, + previewScale: number, + maxPreviewWidth: number, + maxDevicePixelRatio: number, +): number { const dpr = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1; - return Math.max(1, Math.round(Math.min(nodeWidth * dpr * previewScale, maxPreviewWidth))); + const effectiveDpr = Math.max(1, Math.min(dpr, maxDevicePixelRatio)); + return Math.max( + 1, + Math.round(Math.min(nodeWidth * effectiveDpr * previewScale, maxPreviewWidth)), + ); } export function usePipelinePreview(options: UsePipelinePreviewOptions): { @@ -57,9 +67,19 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { return Math.max(128, Math.round(options.maxPreviewWidth)); }, [options.maxPreviewWidth]); + const maxDevicePixelRatio = useMemo(() => { + if ( + typeof options.maxDevicePixelRatio !== "number" || + !Number.isFinite(options.maxDevicePixelRatio) + ) { + return 1.5; + } + return Math.max(1, options.maxDevicePixelRatio); + }, [options.maxDevicePixelRatio]); + const previewWidth = useMemo( - () => computePreviewWidth(options.nodeWidth, previewScale, maxPreviewWidth), - [maxPreviewWidth, options.nodeWidth, previewScale], + () => computePreviewWidth(options.nodeWidth, previewScale, maxPreviewWidth, maxDevicePixelRatio), + [maxDevicePixelRatio, maxPreviewWidth, options.nodeWidth, previewScale], ); const pipelineHash = useMemo(() => { diff --git a/lib/canvas-render-preview.ts b/lib/canvas-render-preview.ts index 000f490..699eb9c 100644 --- a/lib/canvas-render-preview.ts +++ b/lib/canvas-render-preview.ts @@ -1,6 +1,4 @@ import { - collectPipeline, - getSourceImage, hashPipeline, type PipelineStep, } from "@/lib/image-pipeline/contracts"; @@ -21,6 +19,25 @@ export type RenderPreviewInput = { steps: PipelineStep[]; }; +export type CanvasGraphNodeLike = { + id: string; + type: string; + data?: unknown; +}; + +export type CanvasGraphEdgeLike = { + source: string; + target: string; + sourceHandle?: string; + targetHandle?: string; + className?: string | null; +}; + +export type CanvasGraphSnapshot = { + nodesById: ReadonlyMap; + incomingEdgesByTarget: ReadonlyMap; +}; + type RenderResolutionOption = "original" | "2x" | "custom"; type RenderFormatOption = "png" | "jpeg" | "webp"; @@ -115,28 +132,197 @@ export function resolveNodeImageUrl(data: unknown): string | null { return null; } -export function resolveRenderPreviewInput(args: { +export function buildGraphSnapshot( + nodes: readonly CanvasGraphNodeLike[], + edges: readonly CanvasGraphEdgeLike[], + includeTempEdges = false, +): CanvasGraphSnapshot { + const nodesById = new Map(); + for (const node of nodes) { + nodesById.set(node.id, node); + } + + const incomingEdgesByTarget = new Map(); + for (const edge of edges) { + if (!includeTempEdges && edge.className === "temp") { + continue; + } + + const bucket = incomingEdgesByTarget.get(edge.target); + if (bucket) { + bucket.push(edge); + } else { + incomingEdgesByTarget.set(edge.target, [edge]); + } + } + + for (const edgesForTarget of incomingEdgesByTarget.values()) { + edgesForTarget.sort((left, right) => { + const sourceCompare = left.source.localeCompare(right.source); + if (sourceCompare !== 0) return sourceCompare; + const leftHandle = left.sourceHandle ?? ""; + const rightHandle = right.sourceHandle ?? ""; + const handleCompare = leftHandle.localeCompare(rightHandle); + if (handleCompare !== 0) return handleCompare; + return (left.targetHandle ?? "").localeCompare(right.targetHandle ?? ""); + }); + } + + return { + nodesById, + incomingEdgesByTarget, + }; +} + +function getSortedIncomingEdge( + incomingEdges: readonly CanvasGraphEdgeLike[] | undefined, +): CanvasGraphEdgeLike | null { + if (!incomingEdges || incomingEdges.length === 0) { + return null; + } + + return incomingEdges[0] ?? null; +} + +function walkUpstreamFromGraph( + graph: CanvasGraphSnapshot, + nodeId: string, +): { path: CanvasGraphNodeLike[]; selectedEdges: CanvasGraphEdgeLike[] } { + const path: CanvasGraphNodeLike[] = []; + const selectedEdges: CanvasGraphEdgeLike[] = []; + const visiting = new Set(); + + const visit = (currentId: string): void => { + if (visiting.has(currentId)) { + throw new Error(`Cycle detected in pipeline graph at node '${currentId}'.`); + } + + visiting.add(currentId); + + const incoming = getSortedIncomingEdge(graph.incomingEdgesByTarget.get(currentId)); + if (incoming) { + selectedEdges.push(incoming); + visit(incoming.source); + } + + visiting.delete(currentId); + + const current = graph.nodesById.get(currentId); + if (current) { + path.push(current); + } + }; + + visit(nodeId); + + return { + path, + selectedEdges, + }; +} + +export function collectPipelineFromGraph( + graph: CanvasGraphSnapshot, + options: { + nodeId: string; + isPipelineNode: (node: CanvasGraphNodeLike) => boolean; + }, +): PipelineStep[] { + const traversal = walkUpstreamFromGraph(graph, options.nodeId); + + const steps: PipelineStep[] = []; + for (const node of traversal.path) { + if (!options.isPipelineNode(node)) { + continue; + } + + steps.push({ + nodeId: node.id, + type: node.type, + params: node.data, + }); + } + + return steps; +} + +export function getSourceImageFromGraph( + graph: CanvasGraphSnapshot, + options: { + nodeId: string; + isSourceNode: (node: CanvasGraphNodeLike) => boolean; + getSourceImageFromNode: (node: CanvasGraphNodeLike) => TSourceImage | null | undefined; + }, +): TSourceImage | null { + const traversal = walkUpstreamFromGraph(graph, options.nodeId); + + for (let index = traversal.path.length - 1; index >= 0; index -= 1) { + const node = traversal.path[index]; + if (!options.isSourceNode(node)) { + continue; + } + + const sourceImage = options.getSourceImageFromNode(node); + if (sourceImage != null) { + return sourceImage; + } + } + + return null; +} + +export function findSourceNodeFromGraph( + graph: CanvasGraphSnapshot, + options: { + nodeId: string; + isSourceNode: (node: CanvasGraphNodeLike) => boolean; + getSourceImageFromNode: (node: CanvasGraphNodeLike) => unknown; + }, +): CanvasGraphNodeLike | null { + const traversal = walkUpstreamFromGraph(graph, options.nodeId); + + for (let index = traversal.path.length - 1; index >= 0; index -= 1) { + const node = traversal.path[index]; + if (!options.isSourceNode(node)) { + continue; + } + + if (options.getSourceImageFromNode(node) != null) { + return node; + } + } + + return null; +} + +export function resolveRenderPreviewInputFromGraph(args: { nodeId: string; - nodes: readonly RenderPreviewGraphNode[]; - edges: readonly RenderPreviewGraphEdge[]; + graph: CanvasGraphSnapshot; }): { sourceUrl: string | null; steps: PipelineStep[] } { - const sourceUrl = getSourceImage({ + const sourceUrl = getSourceImageFromGraph(args.graph, { nodeId: args.nodeId, - nodes: args.nodes, - edges: args.edges, isSourceNode: (node) => SOURCE_NODE_TYPES.has(node.type ?? ""), getSourceImageFromNode: (node) => resolveNodeImageUrl(node.data), }); - const steps = collectPipeline({ + const steps = collectPipelineFromGraph(args.graph, { nodeId: args.nodeId, - nodes: args.nodes, - edges: args.edges, isPipelineNode: (node) => RENDER_PREVIEW_PIPELINE_TYPES.has(node.type ?? ""), - }) as PipelineStep[]; + }); return { sourceUrl, steps, }; } + +export function resolveRenderPreviewInput(args: { + nodeId: string; + nodes: readonly RenderPreviewGraphNode[]; + edges: readonly RenderPreviewGraphEdge[]; +}): { sourceUrl: string | null; steps: PipelineStep[] } { + return resolveRenderPreviewInputFromGraph({ + nodeId: args.nodeId, + graph: buildGraphSnapshot(args.nodes, args.edges), + }); +}