refactor(canvas): integrate graph-based handling for image source resolution and pipeline steps

This commit is contained in:
2026-04-04 10:28:20 +02:00
parent 90d6fe55b1
commit 12cd75c836
11 changed files with 477 additions and 218 deletions

View File

@@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest";
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { withResolvedCompareData } from "../canvas-helpers"; import { withResolvedCompareData } from "../canvas-helpers";
import {
buildGraphSnapshot,
resolveRenderPreviewInputFromGraph,
} from "@/lib/canvas-render-preview";
function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode { function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode {
return { 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 },
},
]);
});
});

View File

@@ -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<CanvasGraphContextValue | null>(null);
export function CanvasGraphProvider({
nodes,
edges,
children,
}: {
nodes: readonly CanvasGraphNodeLike[];
edges: readonly CanvasGraphEdgeLike[];
children: ReactNode;
}) {
const value = useMemo(() => buildGraphSnapshot(nodes, edges), [edges, nodes]);
return <CanvasGraphContext.Provider value={value}>{children}</CanvasGraphContext.Provider>;
}
export function useCanvasGraph(): CanvasGraphContextValue {
const context = useContext(CanvasGraphContext);
if (!context) {
throw new Error("useCanvasGraph must be used within CanvasGraphProvider");
}
return context;
}

View File

@@ -3,7 +3,10 @@ import type { DefaultEdgeOptions, Edge as RFEdge, Node as RFNode } from "@xyflow
import { readCanvasOps } from "@/lib/canvas-local-persistence"; import { readCanvasOps } from "@/lib/canvas-local-persistence";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast"; 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"; import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
export const OPTIMISTIC_NODE_PREFIX = "optimistic_"; export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
@@ -200,15 +203,20 @@ function resolveStorageFallbackUrl(storageId: string): string | undefined {
export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] { export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] {
const persistedEdges = edges.filter((edge) => edge.className !== "temp"); const persistedEdges = edges.filter((edge) => edge.className !== "temp");
const pipelineNodes = nodes.map((node) => ({ const graph = buildGraphSnapshot(
id: node.id, nodes.map((node) => ({
type: node.type ?? "", id: node.id,
data: node.data, type: node.type ?? "",
})); data: node.data,
const pipelineEdges = persistedEdges.map((edge) => ({ })),
source: edge.source, persistedEdges.map((edge) => ({
target: edge.target, 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 resolveImageFromNode = (node: RFNode): string | undefined => {
const nodeData = node.data as { url?: string; previewUrl?: string }; const nodeData = node.data as { url?: string; previewUrl?: string };
@@ -257,21 +265,21 @@ export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNod
return direct; return direct;
} }
return getSourceImage({ return (
nodeId: sourceNode.id, getSourceImageFromGraph(graph, {
nodes: pipelineNodes, nodeId: sourceNode.id,
edges: pipelineEdges, isSourceNode: (node) =>
isSourceNode: (node) => node.type === "image" ||
node.type === "image" || node.type === "ai-image" ||
node.type === "ai-image" || node.type === "asset" ||
node.type === "asset" || node.type === "render",
node.type === "render", getSourceImageFromNode: (node) => {
getSourceImageFromNode: (node) => { const candidate = graph.nodesById.get(node.id);
const candidate = nodes.find((entry) => entry.id === node.id); if (!candidate) return null;
if (!candidate) return null; return resolveImageFromNode(candidate as RFNode) ?? null;
return resolveImageFromNode(candidate) ?? null; },
}, }) ?? undefined
}) ?? undefined; );
}; };
let hasNodeUpdates = false; let hasNodeUpdates = false;
@@ -279,14 +287,14 @@ export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNod
const nextNodes = nodes.map((node) => { const nextNodes = nodes.map((node) => {
if (node.type !== "compare") return 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 leftUrl: string | undefined;
let rightUrl: string | undefined; let rightUrl: string | undefined;
let leftLabel: string | undefined; let leftLabel: string | undefined;
let rightLabel: string | undefined; let rightLabel: string | undefined;
for (const edge of incoming) { for (const edge of incoming) {
const source = nodes.find((candidate) => candidate.id === edge.source); const source = graph.nodesById.get(edge.source);
if (!source) continue; if (!source) continue;
const srcData = source.data as { url?: string; label?: string }; 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; const hasSourceUrl = typeof srcData.url === "string" && srcData.url.length > 0;
let resolvedUrl = let resolvedUrl =
source.type === "render" source.type === "render"
? resolveRenderOutputUrl(source) ? resolveRenderOutputUrl(source as RFNode)
: resolvePipelineImageUrl(source); : resolvePipelineImageUrl(source as RFNode);
if ( if (
resolvedUrl === undefined && resolvedUrl === undefined &&
!hasSourceUrl && !hasSourceUrl &&

View File

@@ -45,6 +45,7 @@ import {
CanvasConnectionDropMenu, CanvasConnectionDropMenu,
} from "@/components/canvas/canvas-connection-drop-menu"; } from "@/components/canvas/canvas-connection-drop-menu";
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context"; 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 { CanvasPresetsProvider } from "@/components/canvas/canvas-presets-context";
import { import {
AssetBrowserTargetContext, AssetBrowserTargetContext,
@@ -171,6 +172,28 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
[assetBrowserTargetNodeId], [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( const pendingRemovedEdgeIds = useMemo(
() => { () => {
void convexEdges; void convexEdges;
@@ -483,52 +506,54 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
scissorsMode ? onScissorsFlowPointerDownCapture : undefined scissorsMode ? onScissorsFlowPointerDownCapture : undefined
} }
> >
<ReactFlow <CanvasGraphProvider nodes={canvasGraphNodes} edges={canvasGraphEdges}>
nodes={nodes} <ReactFlow
edges={edges} nodes={nodes}
onlyRenderVisibleElements edges={edges}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS} onlyRenderVisibleElements
connectionLineComponent={CustomConnectionLine} defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
nodeTypes={nodeTypes} connectionLineComponent={CustomConnectionLine}
onNodesChange={onNodesChange} nodeTypes={nodeTypes}
onEdgesChange={onEdgesChange} onNodesChange={onNodesChange}
onNodeDragStart={onNodeDragStart} onEdgesChange={onEdgesChange}
onNodeDrag={onNodeDrag} onNodeDragStart={onNodeDragStart}
onNodeDragStop={onNodeDragStop} onNodeDrag={onNodeDrag}
onConnect={onConnect} onNodeDragStop={onNodeDragStop}
onConnectEnd={onConnectEnd} onConnect={onConnect}
onReconnect={onReconnect} onConnectEnd={onConnectEnd}
onReconnectStart={onReconnectStart} onReconnect={onReconnect}
onReconnectEnd={onReconnectEnd} onReconnectStart={onReconnectStart}
onBeforeDelete={onBeforeDelete} onReconnectEnd={onReconnectEnd}
onNodesDelete={onNodesDelete} onBeforeDelete={onBeforeDelete}
onEdgesDelete={onEdgesDelete} onNodesDelete={onNodesDelete}
onEdgeClick={scissorsMode ? onEdgeClickScissors : undefined} onEdgesDelete={onEdgesDelete}
onError={onFlowError} onEdgeClick={scissorsMode ? onEdgeClickScissors : undefined}
onDragOver={onDragOver} onError={onFlowError}
onDrop={onDrop} onDragOver={onDragOver}
fitView onDrop={onDrop}
minZoom={CANVAS_MIN_ZOOM} fitView
snapToGrid={false} minZoom={CANVAS_MIN_ZOOM}
deleteKeyCode={["Backspace", "Delete"]} snapToGrid={false}
multiSelectionKeyCode="Shift" deleteKeyCode={["Backspace", "Delete"]}
nodesConnectable={!scissorsMode} multiSelectionKeyCode="Shift"
panOnDrag={flowPanOnDrag} nodesConnectable={!scissorsMode}
selectionOnDrag={flowSelectionOnDrag} panOnDrag={flowPanOnDrag}
panActivationKeyCode="Space" selectionOnDrag={flowSelectionOnDrag}
proOptions={{ hideAttribution: true }} panActivationKeyCode="Space"
colorMode={resolvedTheme === "dark" ? "dark" : "light"} proOptions={{ hideAttribution: true }}
className={cn("bg-background", scissorsMode && "canvas-scissors-mode")} colorMode={resolvedTheme === "dark" ? "dark" : "light"}
> className={cn("bg-background", scissorsMode && "canvas-scissors-mode")}
<Background variant={BackgroundVariant.Dots} gap={16} size={1} /> >
<Controls className="bg-card! border! shadow-sm! rounded-lg!" /> <Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<MiniMap <Controls className="bg-card! border! shadow-sm! rounded-lg!" />
className="bg-card! border! shadow-sm! rounded-lg!" <MiniMap
nodeColor={getMiniMapNodeColor} className="bg-card! border! shadow-sm! rounded-lg!"
nodeStrokeColor={getMiniMapNodeStrokeColor} nodeColor={getMiniMapNodeColor}
maskColor="rgba(0, 0, 0, 0.1)" nodeStrokeColor={getMiniMapNodeStrokeColor}
/> maskColor="rgba(0, 0, 0, 0.1)"
</ReactFlow> />
</ReactFlow>
</CanvasGraphProvider>
</div> </div>
</div> </div>
</AssetBrowserTargetContext.Provider> </AssetBrowserTargetContext.Provider>

View File

@@ -1,10 +1,14 @@
"use client"; "use client";
import { useMemo } from "react"; 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 { 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([ const PREVIEW_PIPELINE_TYPES = new Set([
"curves", "curves",
@@ -13,19 +17,6 @@ const PREVIEW_PIPELINE_TYPES = new Set([
"detail-adjust", "detail-adjust",
]); ]);
function resolveNodeImageUrl(node: Node): string | null {
const data = (node.data ?? {}) as Record<string, unknown>;
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[] { function compactHistogram(values: readonly number[], points = 64): number[] {
if (points <= 0) { if (points <= 0) {
return []; return [];
@@ -76,39 +67,31 @@ export default function AdjustmentPreview({
currentType: string; currentType: string;
currentParams: unknown; currentParams: unknown;
}) { }) {
const nodes = useStore((state) => state.nodes); const graph = useCanvasGraph();
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 sourceUrl = useMemo( const sourceUrl = useMemo(
() => () =>
getSourceImage({ getSourceImageFromGraph(graph, {
nodeId, nodeId,
nodes: pipelineNodes,
edges: pipelineEdges,
isSourceNode: (node) => isSourceNode: (node) =>
node.type === "image" || node.type === "ai-image" || node.type === "asset", node.type === "image" || node.type === "ai-image" || node.type === "asset",
getSourceImageFromNode: (node) => { getSourceImageFromNode: (node) => {
const sourceNode = nodes.find((candidate) => candidate.id === node.id); const sourceData = (node.data ?? {}) as Record<string, unknown>;
return sourceNode ? resolveNodeImageUrl(sourceNode) : null; 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 steps = useMemo(() => {
const collected = collectPipeline({ const collected = collectPipelineFromGraph(graph, {
nodeId, nodeId,
nodes: pipelineNodes,
edges: pipelineEdges,
isPipelineNode: (node) => PREVIEW_PIPELINE_TYPES.has(node.type ?? ""), isPipelineNode: (node) => PREVIEW_PIPELINE_TYPES.has(node.type ?? ""),
}); });
@@ -121,13 +104,18 @@ export default function AdjustmentPreview({
} }
return step as PipelineStep; return step as PipelineStep;
}); });
}, [currentParams, currentType, nodeId, pipelineEdges, pipelineNodes]); }, [currentParams, currentType, graph, nodeId]);
const { canvasRef, histogram, isRendering, hasSource, previewAspectRatio, error } = const { canvasRef, histogram, isRendering, hasSource, previewAspectRatio, error } =
usePipelinePreview({ usePipelinePreview({
sourceUrl, sourceUrl,
steps, steps,
nodeWidth, 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(() => { const histogramSeries = useMemo(() => {

View File

@@ -1,13 +1,14 @@
"use client"; "use client";
import { useCallback, useMemo, useRef, useState } from "react"; 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 { ImageIcon } from "lucide-react";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import CompareSurface from "./compare-surface"; import CompareSurface from "./compare-surface";
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
import { import {
resolveRenderPipelineHash, resolveRenderPipelineHash,
resolveRenderPreviewInput, resolveRenderPreviewInputFromGraph,
type RenderPreviewInput, type RenderPreviewInput,
} from "@/lib/canvas-render-preview"; } from "@/lib/canvas-render-preview";
@@ -31,31 +32,13 @@ type CompareDisplayMode = "render" | "preview";
export default function CompareNode({ id, data, selected, width }: NodeProps) { export default function CompareNode({ id, data, selected, width }: NodeProps) {
const nodeData = data as CompareNodeData; const nodeData = data as CompareNodeData;
const nodes = useStore((state) => state.nodes); const graph = useCanvasGraph();
const edges = useStore((state) => state.edges);
const [sliderX, setSliderX] = useState(50); const [sliderX, setSliderX] = useState(50);
const [manualDisplayMode, setManualDisplayMode] = useState<CompareDisplayMode | null>(null); const [manualDisplayMode, setManualDisplayMode] = useState<CompareDisplayMode | null>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(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( const incomingEdges = useMemo(
() => () => graph.incomingEdgesByTarget.get(id) ?? [],
edges.filter( [graph, id],
(edge) =>
edge.target === id &&
edge.className !== "temp" &&
(edge.targetHandle === "left" || edge.targetHandle === "right"),
),
[edges, id],
); );
const resolvedSides = useMemo(() => { const resolvedSides = useMemo(() => {
@@ -66,7 +49,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
defaultLabel: string, defaultLabel: string,
): CompareSideState => { ): CompareSideState => {
const incomingEdge = incomingEdges.find((edge) => edge.targetHandle === side); 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<string, unknown>; const sourceData = (sourceNode?.data ?? {}) as Record<string, unknown>;
const sourceLabel = const sourceLabel =
typeof sourceData.label === "string" && sourceData.label.length > 0 typeof sourceData.label === "string" && sourceData.label.length > 0
@@ -79,10 +62,9 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
let isStaleRenderOutput = false; let isStaleRenderOutput = false;
if (sourceNode && sourceNode.type === "render") { if (sourceNode && sourceNode.type === "render") {
const preview = resolveRenderPreviewInput({ const preview = resolveRenderPreviewInputFromGraph({
nodeId: sourceNode.id, nodeId: sourceNode.id,
nodes: pipelineNodes, graph,
edges: pipelineEdges,
}); });
if (preview.sourceUrl) { if (preview.sourceUrl) {
@@ -132,9 +114,7 @@ export default function CompareNode({ id, data, selected, width }: NodeProps) {
nodeData.leftUrl, nodeData.leftUrl,
nodeData.rightLabel, nodeData.rightLabel,
nodeData.rightUrl, nodeData.rightUrl,
nodesById, graph,
pipelineEdges,
pipelineNodes,
]); ]);
const hasLeft = Boolean(resolvedSides.left.finalUrl || resolvedSides.left.previewInput); 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( const hasConnectedRenderInput = useMemo(
() => () =>
incomingEdges.some((edge) => { incomingEdges.some((edge) => {
const sourceNode = nodesById.get(edge.source); const sourceNode = graph.nodesById.get(edge.source);
return sourceNode?.type === "render"; return sourceNode?.type === "render";
}), }),
[incomingEdges, nodesById], [graph, incomingEdges],
); );
const shouldDefaultToPreview = const shouldDefaultToPreview =
hasConnectedRenderInput || hasConnectedRenderInput ||

View File

@@ -31,8 +31,11 @@ export default function CompareSurface({
sourceUrl: previewSourceUrl, sourceUrl: previewSourceUrl,
steps: previewSteps, steps: previewSteps,
nodeWidth, nodeWidth,
previewScale: 0.7, // Compare-Nodes zeigen nur eine kompakte Live-Ansicht; kleinere Kacheln
maxPreviewWidth: 960, // halten lange Workflows spürbar reaktionsfreudiger.
previewScale: 0.5,
maxPreviewWidth: 720,
maxDevicePixelRatio: 1.25,
}); });
const hasPreview = Boolean(usePreview && previewInput); const hasPreview = Boolean(usePreview && previewInput);

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; 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 { AlertCircle, ArrowDown, CheckCircle2, CloudUpload, Loader2, Maximize2, X } from "lucide-react";
import { useMutation } from "convex/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 { api } from "@/convex/_generated/api";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { usePipelinePreview } from "@/hooks/use-pipeline-preview"; 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 { resolveMediaAspectRatio } from "@/lib/canvas-utils";
import { parseAspectRatioString } from "@/lib/image-formats"; import { parseAspectRatioString } from "@/lib/image-formats";
import { getSourceImage, hashPipeline } from "@/lib/image-pipeline/contracts"; import { hashPipeline } from "@/lib/image-pipeline/contracts";
import { import {
isPipelineAbortError, isPipelineAbortError,
renderFullWithWorkerFallback, renderFullWithWorkerFallback,
@@ -431,8 +435,7 @@ async function uploadBlobToConvex(args: {
export default function RenderNode({ id, data, selected, width, height }: NodeProps<RenderNodeType>) { export default function RenderNode({ id, data, selected, width, height }: NodeProps<RenderNodeType>) {
const generateUploadUrl = useMutation(api.storage.generateUploadUrl); const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
const nodes = useStore((state) => state.nodes); const graph = useCanvasGraph();
const edges = useStore((state) => state.edges);
const [localData, setLocalData] = useState<PersistedRenderData>(() => const [localData, setLocalData] = useState<PersistedRenderData>(() =>
sanitizeRenderData(data), 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( const renderPreviewInput = useMemo(
() => () =>
resolveRenderPreviewInput({ resolveRenderPreviewInputFromGraph({
nodeId: id, nodeId: id,
nodes: pipelineNodes, graph,
edges: pipelineEdges,
}), }),
[id, pipelineEdges, pipelineNodes], [graph, id],
); );
const sourceUrl = renderPreviewInput.sourceUrl; const sourceUrl = renderPreviewInput.sourceUrl;
@@ -531,15 +523,13 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
const sourceNode = useMemo<SourceNodeDescriptor | null>( const sourceNode = useMemo<SourceNodeDescriptor | null>(
() => () =>
getSourceImage({ findSourceNodeFromGraph(graph, {
nodeId: id, nodeId: id,
nodes: pipelineNodes,
edges: pipelineEdges,
isSourceNode: (node) => isSourceNode: (node) =>
node.type === "image" || node.type === "ai-image" || node.type === "asset", 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; const steps = renderPreviewInput.steps;
@@ -608,8 +598,11 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
sourceUrl, sourceUrl,
steps, steps,
nodeWidth: previewNodeWidth, nodeWidth: previewNodeWidth,
previewScale: 0.7, // Inline-Preview: bewusst kompakt halten, damit Änderungen schneller
maxPreviewWidth: 960, // sichtbar werden, besonders in langen Graphen.
previewScale: 0.5,
maxPreviewWidth: 720,
maxDevicePixelRatio: 1.25,
}); });
const fullscreenPreviewWidth = Math.max(960, Math.round((width ?? 320) * 3)); 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, sourceUrl: isFullscreenOpen && sourceUrl ? sourceUrl : null,
steps, steps,
nodeWidth: fullscreenPreviewWidth, nodeWidth: fullscreenPreviewWidth,
previewScale: 1, previewScale: 0.85,
maxPreviewWidth: 3072, maxPreviewWidth: 1920,
maxDevicePixelRatio: 1.5,
}); });
const targetAspectRatio = useMemo(() => { const targetAspectRatio = useMemo(() => {

View File

@@ -1232,12 +1232,6 @@ export const move = mutation({
await getCanvasOrThrow(ctx, node.canvasId, user.userId); await getCanvasOrThrow(ctx, node.canvasId, user.userId);
await ctx.db.patch(nodeId, { positionX, positionY }); 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 ? ADJUSTMENT_MIN_WIDTH
: width; : width;
await ctx.db.patch(nodeId, { width: clampedWidth, height }); 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; if (moves.length === 0) return;
const nodeIds = moves.map((move) => move.nodeId); const nodeIds = moves.map((move) => move.nodeId);
const { canvasId } = await getValidatedBatchNodesOrThrow( await getValidatedBatchNodesOrThrow(
ctx, ctx,
user.userId, user.userId,
nodeIds, nodeIds,
@@ -1298,13 +1285,6 @@ export const batchMove = mutation({
for (const { nodeId, positionX, positionY } of moves) { for (const { nodeId, positionX, positionY } of moves) {
await ctx.db.patch(nodeId, { positionX, positionY }); 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); await getCanvasOrThrow(ctx, node.canvasId, user.userId);
const normalizedData = normalizeNodeDataForWrite(node.type, data); const normalizedData = normalizeNodeDataForWrite(node.type, data);
await ctx.db.patch(nodeId, { data: normalizedData }); 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() });
}, },
}); });

View File

@@ -16,11 +16,21 @@ type UsePipelinePreviewOptions = {
nodeWidth: number; nodeWidth: number;
previewScale?: number; previewScale?: number;
maxPreviewWidth?: 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; 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): { export function usePipelinePreview(options: UsePipelinePreviewOptions): {
@@ -57,9 +67,19 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
return Math.max(128, Math.round(options.maxPreviewWidth)); return Math.max(128, Math.round(options.maxPreviewWidth));
}, [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( const previewWidth = useMemo(
() => computePreviewWidth(options.nodeWidth, previewScale, maxPreviewWidth), () => computePreviewWidth(options.nodeWidth, previewScale, maxPreviewWidth, maxDevicePixelRatio),
[maxPreviewWidth, options.nodeWidth, previewScale], [maxDevicePixelRatio, maxPreviewWidth, options.nodeWidth, previewScale],
); );
const pipelineHash = useMemo(() => { const pipelineHash = useMemo(() => {

View File

@@ -1,6 +1,4 @@
import { import {
collectPipeline,
getSourceImage,
hashPipeline, hashPipeline,
type PipelineStep, type PipelineStep,
} from "@/lib/image-pipeline/contracts"; } from "@/lib/image-pipeline/contracts";
@@ -21,6 +19,25 @@ export type RenderPreviewInput = {
steps: PipelineStep[]; 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<string, CanvasGraphNodeLike>;
incomingEdgesByTarget: ReadonlyMap<string, readonly CanvasGraphEdgeLike[]>;
};
type RenderResolutionOption = "original" | "2x" | "custom"; type RenderResolutionOption = "original" | "2x" | "custom";
type RenderFormatOption = "png" | "jpeg" | "webp"; type RenderFormatOption = "png" | "jpeg" | "webp";
@@ -115,28 +132,197 @@ export function resolveNodeImageUrl(data: unknown): string | null {
return null; return null;
} }
export function resolveRenderPreviewInput(args: { export function buildGraphSnapshot(
nodes: readonly CanvasGraphNodeLike[],
edges: readonly CanvasGraphEdgeLike[],
includeTempEdges = false,
): CanvasGraphSnapshot {
const nodesById = new Map<string, CanvasGraphNodeLike>();
for (const node of nodes) {
nodesById.set(node.id, node);
}
const incomingEdgesByTarget = new Map<string, CanvasGraphEdgeLike[]>();
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<string>();
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<TSourceImage>(
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; nodeId: string;
nodes: readonly RenderPreviewGraphNode[]; graph: CanvasGraphSnapshot;
edges: readonly RenderPreviewGraphEdge[];
}): { sourceUrl: string | null; steps: PipelineStep[] } { }): { sourceUrl: string | null; steps: PipelineStep[] } {
const sourceUrl = getSourceImage({ const sourceUrl = getSourceImageFromGraph(args.graph, {
nodeId: args.nodeId, nodeId: args.nodeId,
nodes: args.nodes,
edges: args.edges,
isSourceNode: (node) => SOURCE_NODE_TYPES.has(node.type ?? ""), isSourceNode: (node) => SOURCE_NODE_TYPES.has(node.type ?? ""),
getSourceImageFromNode: (node) => resolveNodeImageUrl(node.data), getSourceImageFromNode: (node) => resolveNodeImageUrl(node.data),
}); });
const steps = collectPipeline({ const steps = collectPipelineFromGraph(args.graph, {
nodeId: args.nodeId, nodeId: args.nodeId,
nodes: args.nodes,
edges: args.edges,
isPipelineNode: (node) => RENDER_PREVIEW_PIPELINE_TYPES.has(node.type ?? ""), isPipelineNode: (node) => RENDER_PREVIEW_PIPELINE_TYPES.has(node.type ?? ""),
}) as PipelineStep[]; });
return { return {
sourceUrl, sourceUrl,
steps, 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),
});
}