refactor(canvas): integrate graph-based handling for image source resolution and pipeline steps
This commit is contained in:
@@ -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 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
37
components/canvas/canvas-graph-context.tsx
Normal file
37
components/canvas/canvas-graph-context.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
nodes.map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: node.type ?? "",
|
type: node.type ?? "",
|
||||||
data: node.data,
|
data: node.data,
|
||||||
}));
|
})),
|
||||||
const pipelineEdges = persistedEdges.map((edge) => ({
|
persistedEdges.map((edge) => ({
|
||||||
source: edge.source,
|
source: edge.source,
|
||||||
target: edge.target,
|
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 (
|
||||||
|
getSourceImageFromGraph(graph, {
|
||||||
nodeId: sourceNode.id,
|
nodeId: sourceNode.id,
|
||||||
nodes: pipelineNodes,
|
|
||||||
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 = nodes.find((entry) => entry.id === node.id);
|
const candidate = graph.nodesById.get(node.id);
|
||||||
if (!candidate) return null;
|
if (!candidate) return null;
|
||||||
return resolveImageFromNode(candidate) ?? null;
|
return resolveImageFromNode(candidate as RFNode) ?? 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 &&
|
||||||
|
|||||||
@@ -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,6 +506,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
scissorsMode ? onScissorsFlowPointerDownCapture : undefined
|
scissorsMode ? onScissorsFlowPointerDownCapture : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<CanvasGraphProvider nodes={canvasGraphNodes} edges={canvasGraphEdges}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@@ -529,6 +553,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
maskColor="rgba(0, 0, 0, 0.1)"
|
maskColor="rgba(0, 0, 0, 0.1)"
|
||||||
/>
|
/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
</CanvasGraphProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AssetBrowserTargetContext.Provider>
|
</AssetBrowserTargetContext.Provider>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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() });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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;
|
nodeId: string;
|
||||||
nodes: readonly RenderPreviewGraphNode[];
|
isPipelineNode: (node: CanvasGraphNodeLike) => boolean;
|
||||||
edges: readonly RenderPreviewGraphEdge[];
|
},
|
||||||
|
): 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;
|
||||||
|
graph: CanvasGraphSnapshot;
|
||||||
}): { 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user