Merge origin/master: combine compare URL pipeline with storage fallback
Made-with: Cursor
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ next-env.d.ts
|
|||||||
.env.sentry-build-plugin
|
.env.sentry-build-plugin
|
||||||
.cursor
|
.cursor
|
||||||
.cursor/*
|
.cursor/*
|
||||||
|
.kilo
|
||||||
|
|||||||
@@ -80,6 +80,19 @@ export type PendingEdgeSplit = {
|
|||||||
positionY: number;
|
positionY: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function resolveStorageFallbackUrl(storageId: string): string | undefined {
|
||||||
|
const convexBaseUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||||
|
if (!convexBaseUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(`/api/storage/${storageId}`, convexBaseUrl).toString();
|
||||||
|
} catch {
|
||||||
|
return 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 pipelineNodes = nodes.map((node) => ({
|
||||||
@@ -142,7 +155,22 @@ export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNod
|
|||||||
if (!source) continue;
|
if (!source) continue;
|
||||||
|
|
||||||
const srcData = source.data as { url?: string; label?: string };
|
const srcData = source.data as { url?: string; label?: string };
|
||||||
const resolvedUrl = resolvePipelineImageUrl(source);
|
const sourceDataRecord = source.data as Record<string, unknown>;
|
||||||
|
const storageIdCandidate =
|
||||||
|
typeof sourceDataRecord.storageId === "string"
|
||||||
|
? sourceDataRecord.storageId
|
||||||
|
: typeof sourceDataRecord.lastUploadStorageId === "string"
|
||||||
|
? sourceDataRecord.lastUploadStorageId
|
||||||
|
: undefined;
|
||||||
|
const hasSourceUrl = typeof srcData.url === "string" && srcData.url.length > 0;
|
||||||
|
let resolvedUrl = resolvePipelineImageUrl(source);
|
||||||
|
if (
|
||||||
|
resolvedUrl === undefined &&
|
||||||
|
!hasSourceUrl &&
|
||||||
|
storageIdCandidate !== undefined
|
||||||
|
) {
|
||||||
|
resolvedUrl = resolveStorageFallbackUrl(storageIdCandidate);
|
||||||
|
}
|
||||||
|
|
||||||
if (edge.targetHandle === "left") {
|
if (edge.targetHandle === "left") {
|
||||||
leftUrl = resolvedUrl;
|
leftUrl = resolvedUrl;
|
||||||
|
|||||||
@@ -162,6 +162,47 @@ function isLikelyTransientSyncError(error: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summarizeUpdateDataPayload(payload: unknown): Record<string, unknown> {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return { payloadShape: "invalid" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = payload as { nodeId?: unknown; data?: unknown };
|
||||||
|
const data =
|
||||||
|
typeof p.data === "object" && p.data !== null
|
||||||
|
? (p.data as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeId: typeof p.nodeId === "string" ? p.nodeId : null,
|
||||||
|
hasData: Boolean(data),
|
||||||
|
hasStorageId: typeof data?.storageId === "string" && data.storageId.length > 0,
|
||||||
|
hasLastUploadStorageId:
|
||||||
|
typeof data?.lastUploadStorageId === "string" &&
|
||||||
|
data.lastUploadStorageId.length > 0,
|
||||||
|
hasUrl: typeof data?.url === "string" && data.url.length > 0,
|
||||||
|
hasLastUploadUrl:
|
||||||
|
typeof data?.lastUploadUrl === "string" && data.lastUploadUrl.length > 0,
|
||||||
|
lastUploadedAt:
|
||||||
|
typeof data?.lastUploadedAt === "number" && Number.isFinite(data.lastUploadedAt)
|
||||||
|
? data.lastUploadedAt
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeResizePayload(payload: unknown): Record<string, unknown> {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return { payloadShape: "invalid" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = payload as { nodeId?: unknown; width?: unknown; height?: unknown };
|
||||||
|
return {
|
||||||
|
nodeId: typeof p.nodeId === "string" ? p.nodeId : null,
|
||||||
|
width: typeof p.width === "number" && Number.isFinite(p.width) ? p.width : null,
|
||||||
|
height: typeof p.height === "number" && Number.isFinite(p.height) ? p.height : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function hasStorageId(node: Doc<"nodes">): boolean {
|
function hasStorageId(node: Doc<"nodes">): boolean {
|
||||||
const data = node.data as Record<string, unknown> | undefined;
|
const data = node.data as Record<string, unknown> | undefined;
|
||||||
return typeof data?.storageId === "string" && data.storageId.length > 0;
|
return typeof data?.storageId === "string" && data.storageId.length > 0;
|
||||||
@@ -1005,9 +1046,35 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
} else if (op.type === "moveNode") {
|
} else if (op.type === "moveNode") {
|
||||||
await moveNode(op.payload);
|
await moveNode(op.payload);
|
||||||
} else if (op.type === "resizeNode") {
|
} else if (op.type === "resizeNode") {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.info("[Canvas sync debug] resizeNode enqueue->flush", {
|
||||||
|
opId: op.id,
|
||||||
|
attemptCount: op.attemptCount,
|
||||||
|
...summarizeResizePayload(op.payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
await resizeNode(op.payload);
|
await resizeNode(op.payload);
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.info("[Canvas sync debug] resizeNode flush success", {
|
||||||
|
opId: op.id,
|
||||||
|
...summarizeResizePayload(op.payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (op.type === "updateData") {
|
} else if (op.type === "updateData") {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.info("[Canvas sync debug] updateData enqueue->flush", {
|
||||||
|
opId: op.id,
|
||||||
|
attemptCount: op.attemptCount,
|
||||||
|
...summarizeUpdateDataPayload(op.payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
await updateNodeData(op.payload);
|
await updateNodeData(op.payload);
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.info("[Canvas sync debug] updateData flush success", {
|
||||||
|
opId: op.id,
|
||||||
|
...summarizeUpdateDataPayload(op.payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await ackCanvasSyncOp(op.id);
|
await ackCanvasSyncOp(op.id);
|
||||||
@@ -1015,6 +1082,24 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const transient =
|
const transient =
|
||||||
!isSyncOnline || isLikelyTransientSyncError(error);
|
!isSyncOnline || isLikelyTransientSyncError(error);
|
||||||
|
if (op.type === "updateData" && process.env.NODE_ENV !== "production") {
|
||||||
|
console.warn("[Canvas sync debug] updateData flush failed", {
|
||||||
|
opId: op.id,
|
||||||
|
attemptCount: op.attemptCount,
|
||||||
|
transient,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
...summarizeUpdateDataPayload(op.payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (op.type === "resizeNode" && process.env.NODE_ENV !== "production") {
|
||||||
|
console.warn("[Canvas sync debug] resizeNode flush failed", {
|
||||||
|
opId: op.id,
|
||||||
|
attemptCount: op.attemptCount,
|
||||||
|
transient,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
...summarizeResizePayload(op.payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (transient) {
|
if (transient) {
|
||||||
const backoffMs = Math.min(30_000, 1000 * 2 ** Math.min(op.attemptCount, 5));
|
const backoffMs = Math.min(30_000, 1000 * 2 ** Math.min(op.attemptCount, 5));
|
||||||
await markCanvasSyncOpFailed(op.id, {
|
await markCanvasSyncOpFailed(op.id, {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
import { Handle, Position, useStore, 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 {
|
||||||
|
resolveRenderPipelineHash,
|
||||||
|
resolveRenderPreviewInput,
|
||||||
|
type RenderPreviewInput,
|
||||||
|
} from "@/lib/canvas-render-preview";
|
||||||
|
|
||||||
interface CompareNodeData {
|
interface CompareNodeData {
|
||||||
leftUrl?: string;
|
leftUrl?: string;
|
||||||
@@ -12,13 +18,140 @@ interface CompareNodeData {
|
|||||||
rightLabel?: string;
|
rightLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CompareNode({ data, selected }: NodeProps) {
|
type CompareSide = "left" | "right";
|
||||||
|
|
||||||
|
type CompareSideState = {
|
||||||
|
finalUrl?: string;
|
||||||
|
label?: string;
|
||||||
|
previewInput?: RenderPreviewInput;
|
||||||
|
isStaleRenderOutput: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CompareDisplayMode = "render" | "preview";
|
||||||
|
|
||||||
|
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 edges = useStore((state) => state.edges);
|
||||||
const [sliderX, setSliderX] = useState(50);
|
const [sliderX, setSliderX] = useState(50);
|
||||||
|
const [manualDisplayMode, setManualDisplayMode] = useState<CompareDisplayMode | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const hasLeft = !!nodeData.leftUrl;
|
const pipelineNodes = useMemo(
|
||||||
const hasRight = !!nodeData.rightUrl;
|
() => 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],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedSides = useMemo(() => {
|
||||||
|
const resolveSide = (
|
||||||
|
side: CompareSide,
|
||||||
|
finalUrl: string | undefined,
|
||||||
|
finalLabel: string | undefined,
|
||||||
|
defaultLabel: string,
|
||||||
|
): CompareSideState => {
|
||||||
|
const incomingEdge = incomingEdges.find((edge) => edge.targetHandle === side);
|
||||||
|
const sourceNode = incomingEdge ? nodesById.get(incomingEdge.source) : undefined;
|
||||||
|
const sourceData = (sourceNode?.data ?? {}) as Record<string, unknown>;
|
||||||
|
const sourceLabel =
|
||||||
|
typeof sourceData.label === "string" && sourceData.label.length > 0
|
||||||
|
? sourceData.label
|
||||||
|
: sourceNode?.type;
|
||||||
|
|
||||||
|
const label = finalLabel ?? sourceLabel ?? defaultLabel;
|
||||||
|
|
||||||
|
let previewInput: RenderPreviewInput | undefined;
|
||||||
|
let isStaleRenderOutput = false;
|
||||||
|
|
||||||
|
if (sourceNode && sourceNode.type === "render") {
|
||||||
|
const preview = resolveRenderPreviewInput({
|
||||||
|
nodeId: sourceNode.id,
|
||||||
|
nodes: pipelineNodes,
|
||||||
|
edges: pipelineEdges,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (preview.sourceUrl) {
|
||||||
|
previewInput = {
|
||||||
|
sourceUrl: preview.sourceUrl,
|
||||||
|
steps: preview.steps,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceLastUploadedHash =
|
||||||
|
typeof sourceData.lastUploadedHash === "string"
|
||||||
|
? sourceData.lastUploadedHash
|
||||||
|
: undefined;
|
||||||
|
const sourceLastRenderedHash =
|
||||||
|
typeof sourceData.lastRenderedHash === "string"
|
||||||
|
? sourceData.lastRenderedHash
|
||||||
|
: undefined;
|
||||||
|
const sourcePersistedOutputHash =
|
||||||
|
sourceLastUploadedHash ?? sourceLastRenderedHash;
|
||||||
|
const sourceCurrentHash = resolveRenderPipelineHash({
|
||||||
|
sourceUrl: preview.sourceUrl,
|
||||||
|
steps: preview.steps,
|
||||||
|
data: sourceData,
|
||||||
|
});
|
||||||
|
|
||||||
|
isStaleRenderOutput =
|
||||||
|
Boolean(finalUrl) &&
|
||||||
|
Boolean(sourceCurrentHash) &&
|
||||||
|
Boolean(sourcePersistedOutputHash) &&
|
||||||
|
sourceCurrentHash !== sourcePersistedOutputHash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalUrl) {
|
||||||
|
return { finalUrl, label, previewInput, isStaleRenderOutput };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { label, previewInput, isStaleRenderOutput };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: resolveSide("left", nodeData.leftUrl, nodeData.leftLabel, "Before"),
|
||||||
|
right: resolveSide("right", nodeData.rightUrl, nodeData.rightLabel, "After"),
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
incomingEdges,
|
||||||
|
nodeData.leftLabel,
|
||||||
|
nodeData.leftUrl,
|
||||||
|
nodeData.rightLabel,
|
||||||
|
nodeData.rightUrl,
|
||||||
|
nodesById,
|
||||||
|
pipelineEdges,
|
||||||
|
pipelineNodes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasLeft = Boolean(resolvedSides.left.finalUrl || resolvedSides.left.previewInput);
|
||||||
|
const hasRight = Boolean(resolvedSides.right.finalUrl || resolvedSides.right.previewInput);
|
||||||
|
const hasConnectedRenderInput = useMemo(
|
||||||
|
() =>
|
||||||
|
incomingEdges.some((edge) => {
|
||||||
|
const sourceNode = nodesById.get(edge.source);
|
||||||
|
return sourceNode?.type === "render";
|
||||||
|
}),
|
||||||
|
[incomingEdges, nodesById],
|
||||||
|
);
|
||||||
|
const shouldDefaultToPreview =
|
||||||
|
resolvedSides.left.isStaleRenderOutput || resolvedSides.right.isStaleRenderOutput;
|
||||||
|
const effectiveDisplayMode =
|
||||||
|
manualDisplayMode ?? (shouldDefaultToPreview ? "preview" : "render");
|
||||||
|
const previewNodeWidth = Math.max(240, Math.min(640, Math.round(width ?? 500)));
|
||||||
|
|
||||||
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -86,7 +219,33 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid h-full min-h-0 w-full grid-cols-1 grid-rows-[auto_minmax(0,1fr)]">
|
<div className="grid h-full min-h-0 w-full grid-cols-1 grid-rows-[auto_minmax(0,1fr)]">
|
||||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">⚖️ Compare</div>
|
<div className="flex items-center justify-between gap-2 px-3 py-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">⚖️ Compare</div>
|
||||||
|
{hasConnectedRenderInput && (
|
||||||
|
<div className="nodrag inline-flex rounded-md border border-border bg-background/80 p-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded px-2 py-0.5 text-[10px] font-medium ${effectiveDisplayMode === "render" ? "bg-muted text-foreground" : "text-muted-foreground"}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setManualDisplayMode("render");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Render
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded px-2 py-0.5 text-[10px] font-medium ${effectiveDisplayMode === "preview" ? "bg-muted text-foreground" : "text-muted-foreground"}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setManualDisplayMode("preview");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -94,81 +253,76 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
|||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
>
|
>
|
||||||
{!hasLeft && !hasRight && (
|
{!hasLeft && !hasRight && (
|
||||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||||
<ImageIcon className="h-10 w-10 opacity-30" />
|
<ImageIcon className="h-10 w-10 opacity-30" />
|
||||||
<p className="px-8 text-center text-xs opacity-60">
|
<p className="px-8 text-center text-xs opacity-60">
|
||||||
Connect two image nodes - left handle (blue) and right handle (green)
|
Connect two image nodes - left handle (blue) and right handle (green)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasRight && (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={nodeData.rightUrl}
|
|
||||||
alt={nodeData.rightLabel ?? "Right"}
|
|
||||||
className="pointer-events-none absolute inset-0 h-full w-full object-contain"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasLeft && (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
|
||||||
style={{ width: `${sliderX}%` }}
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={nodeData.leftUrl}
|
|
||||||
alt={nodeData.leftLabel ?? "Left"}
|
|
||||||
className="absolute inset-0 h-full w-full object-contain"
|
|
||||||
style={{ width: "100%", maxWidth: "none" }}
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasLeft && hasRight && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute bottom-0 top-0 z-10 w-0.5 bg-white shadow-md"
|
|
||||||
style={{ left: `${sliderX}%` }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute top-1/2 z-20 -translate-x-1/2 -translate-y-1/2"
|
|
||||||
style={{ left: `${sliderX}%` }}
|
|
||||||
>
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-white shadow-lg">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
||||||
<path
|
|
||||||
d="M5 8H1M11 8H15M5 5L2 8L5 11M11 5L14 8L11 11"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{hasLeft && (
|
{hasRight && (
|
||||||
<div className="pointer-events-none absolute left-2 top-2 z-10">
|
<CompareSurface
|
||||||
<span className="rounded bg-blue-500/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
|
finalUrl={resolvedSides.right.finalUrl}
|
||||||
{nodeData.leftLabel ?? "Before"}
|
label={resolvedSides.right.label}
|
||||||
</span>
|
previewInput={resolvedSides.right.previewInput}
|
||||||
</div>
|
nodeWidth={previewNodeWidth}
|
||||||
)}
|
preferPreview={effectiveDisplayMode === "preview"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasRight && (
|
{hasLeft && (
|
||||||
<div className="pointer-events-none absolute right-2 top-2 z-10">
|
<CompareSurface
|
||||||
<span className="rounded bg-emerald-500/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
|
finalUrl={resolvedSides.left.finalUrl}
|
||||||
{nodeData.rightLabel ?? "After"}
|
label={resolvedSides.left.label}
|
||||||
</span>
|
previewInput={resolvedSides.left.previewInput}
|
||||||
</div>
|
nodeWidth={previewNodeWidth}
|
||||||
)}
|
clipWidthPercent={sliderX}
|
||||||
|
preferPreview={effectiveDisplayMode === "preview"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasLeft && hasRight && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute bottom-0 top-0 z-10 w-0.5 bg-white shadow-md"
|
||||||
|
style={{ left: `${sliderX}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-1/2 z-20 -translate-x-1/2 -translate-y-1/2"
|
||||||
|
style={{ left: `${sliderX}%` }}
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-white shadow-lg">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path
|
||||||
|
d="M5 8H1M11 8H15M5 5L2 8L5 11M11 5L14 8L11 11"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasLeft && (
|
||||||
|
<div className="pointer-events-none absolute left-2 top-2 z-10">
|
||||||
|
<span className="rounded bg-blue-500/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
|
||||||
|
{resolvedSides.left.label ?? "Before"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasRight && (
|
||||||
|
<div className="pointer-events-none absolute right-2 top-2 z-10">
|
||||||
|
<span className="rounded bg-emerald-500/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
|
||||||
|
{resolvedSides.right.label ?? "After"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseNodeWrapper>
|
</BaseNodeWrapper>
|
||||||
|
|||||||
77
components/canvas/nodes/compare-surface.tsx
Normal file
77
components/canvas/nodes/compare-surface.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePipelinePreview } from "@/hooks/use-pipeline-preview";
|
||||||
|
import type { RenderPreviewInput } from "@/lib/canvas-render-preview";
|
||||||
|
|
||||||
|
const EMPTY_STEPS: RenderPreviewInput["steps"] = [];
|
||||||
|
|
||||||
|
type CompareSurfaceProps = {
|
||||||
|
finalUrl?: string;
|
||||||
|
label?: string;
|
||||||
|
previewInput?: RenderPreviewInput;
|
||||||
|
nodeWidth: number;
|
||||||
|
clipWidthPercent?: number;
|
||||||
|
preferPreview?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CompareSurface({
|
||||||
|
finalUrl,
|
||||||
|
label,
|
||||||
|
previewInput,
|
||||||
|
nodeWidth,
|
||||||
|
clipWidthPercent,
|
||||||
|
preferPreview,
|
||||||
|
}: CompareSurfaceProps) {
|
||||||
|
const usePreview = Boolean(previewInput && (preferPreview || !finalUrl));
|
||||||
|
const previewSourceUrl = usePreview ? previewInput?.sourceUrl ?? null : null;
|
||||||
|
const previewSteps = usePreview ? previewInput?.steps ?? EMPTY_STEPS : EMPTY_STEPS;
|
||||||
|
const visibleFinalUrl = usePreview ? undefined : finalUrl;
|
||||||
|
|
||||||
|
const { canvasRef, isRendering, error } = usePipelinePreview({
|
||||||
|
sourceUrl: previewSourceUrl,
|
||||||
|
steps: previewSteps,
|
||||||
|
nodeWidth,
|
||||||
|
previewScale: 0.7,
|
||||||
|
maxPreviewWidth: 960,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasPreview = Boolean(usePreview && previewInput);
|
||||||
|
const clipStyle =
|
||||||
|
typeof clipWidthPercent === "number"
|
||||||
|
? {
|
||||||
|
clipPath: `inset(0 ${100 - clipWidthPercent}% 0 0)`,
|
||||||
|
WebkitClipPath: `inset(0 ${100 - clipWidthPercent}% 0 0)`,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0" style={clipStyle}>
|
||||||
|
{visibleFinalUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={visibleFinalUrl}
|
||||||
|
alt={label ?? "Comparison image"}
|
||||||
|
className="absolute inset-0 h-full w-full object-contain"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : hasPreview ? (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="absolute inset-0 h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasPreview ? (
|
||||||
|
<div className="absolute bottom-2 left-2 rounded bg-amber-500/85 px-1.5 py-0.5 text-[10px] font-medium text-black/90 backdrop-blur-sm">
|
||||||
|
{isRendering ? "Live Preview..." : "Live Preview"}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasPreview && error ? (
|
||||||
|
<div className="absolute bottom-2 right-2 rounded bg-destructive/85 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
|
||||||
|
Preview error
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,14 +12,10 @@ 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 { resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
||||||
import { parseAspectRatioString } from "@/lib/image-formats";
|
import { parseAspectRatioString } from "@/lib/image-formats";
|
||||||
import {
|
import { getSourceImage, hashPipeline } from "@/lib/image-pipeline/contracts";
|
||||||
collectPipeline,
|
|
||||||
getSourceImage,
|
|
||||||
hashPipeline,
|
|
||||||
type PipelineStep,
|
|
||||||
} from "@/lib/image-pipeline/contracts";
|
|
||||||
import { bridge } from "@/lib/image-pipeline/bridge";
|
import { bridge } from "@/lib/image-pipeline/bridge";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
@@ -112,7 +108,13 @@ const RENDER_MIN_HEIGHT = 300;
|
|||||||
const ASPECT_RATIO_TOLERANCE = 0.015;
|
const ASPECT_RATIO_TOLERANCE = 0.015;
|
||||||
const SIZE_TOLERANCE_PX = 1;
|
const SIZE_TOLERANCE_PX = 1;
|
||||||
|
|
||||||
const RENDER_PIPELINE_TYPES = new Set(["curves", "color-adjust", "light-adjust", "detail-adjust"]);
|
function logRenderDebug(event: string, payload: Record<string, unknown>): void {
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("[RenderNode debug]", event, payload);
|
||||||
|
}
|
||||||
|
|
||||||
function readPositiveNumber(value: unknown): number | null {
|
function readPositiveNumber(value: unknown): number | null {
|
||||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||||
@@ -217,21 +219,6 @@ function toRatioConstrainedSize(args: {
|
|||||||
return widthDistance <= heightDistance ? widthCandidate : heightCandidate;
|
return widthDistance <= heightDistance ? widthCandidate : heightCandidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveNodeImageUrl(node: Node): string | null {
|
|
||||||
const nodeData = (node.data ?? {}) as Record<string, unknown>;
|
|
||||||
const directUrl = typeof nodeData.url === "string" ? nodeData.url : null;
|
|
||||||
if (directUrl && directUrl.length > 0) {
|
|
||||||
return directUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewUrl = typeof nodeData.previewUrl === "string" ? nodeData.previewUrl : null;
|
|
||||||
if (previewUrl && previewUrl.length > 0) {
|
|
||||||
return previewUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeDimension(value: unknown): number | undefined {
|
function sanitizeDimension(value: unknown): number | undefined {
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -497,22 +484,40 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
[edges],
|
[edges],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sourceUrl = useMemo(
|
const renderPreviewInput = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getSourceImage({
|
resolveRenderPreviewInput({
|
||||||
nodeId: id,
|
nodeId: id,
|
||||||
nodes: pipelineNodes,
|
nodes: pipelineNodes,
|
||||||
edges: pipelineEdges,
|
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;
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
[id, nodes, pipelineEdges, pipelineNodes],
|
[id, pipelineEdges, pipelineNodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sourceUrl = renderPreviewInput.sourceUrl;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
logRenderDebug("node-data-updated", {
|
||||||
|
nodeId: id,
|
||||||
|
hasSourceUrl: typeof sourceUrl === "string" && sourceUrl.length > 0,
|
||||||
|
storageId: data.storageId ?? null,
|
||||||
|
lastUploadStorageId: data.lastUploadStorageId ?? null,
|
||||||
|
hasResolvedUrl: typeof data.url === "string" && data.url.length > 0,
|
||||||
|
lastUploadedAt: data.lastUploadedAt ?? null,
|
||||||
|
lastUploadedHash: data.lastUploadedHash ?? null,
|
||||||
|
lastRenderedHash: data.lastRenderedHash ?? null,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
data.lastRenderedHash,
|
||||||
|
data.lastUploadStorageId,
|
||||||
|
data.lastUploadedAt,
|
||||||
|
data.lastUploadedHash,
|
||||||
|
data.storageId,
|
||||||
|
data.url,
|
||||||
|
id,
|
||||||
|
sourceUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
const sourceNode = useMemo<SourceNodeDescriptor | null>(
|
const sourceNode = useMemo<SourceNodeDescriptor | null>(
|
||||||
() =>
|
() =>
|
||||||
getSourceImage({
|
getSourceImage({
|
||||||
@@ -526,16 +531,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
[id, pipelineEdges, pipelineNodes],
|
[id, pipelineEdges, pipelineNodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
const steps = useMemo(
|
const steps = renderPreviewInput.steps;
|
||||||
() =>
|
|
||||||
collectPipeline({
|
|
||||||
nodeId: id,
|
|
||||||
nodes: pipelineNodes,
|
|
||||||
edges: pipelineEdges,
|
|
||||||
isPipelineNode: (node) => RENDER_PIPELINE_TYPES.has(node.type ?? ""),
|
|
||||||
}) as PipelineStep[],
|
|
||||||
[id, pipelineEdges, pipelineNodes],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderFingerprint = useMemo(
|
const renderFingerprint = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -741,7 +737,16 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRender = async (mode: "download" | "upload") => {
|
const handleRender = async (mode: "download" | "upload") => {
|
||||||
if (!sourceUrl || !currentPipelineHash) return;
|
if (!sourceUrl || !currentPipelineHash) {
|
||||||
|
logRenderDebug("render-aborted-prerequisites", {
|
||||||
|
nodeId: id,
|
||||||
|
mode,
|
||||||
|
hasSourceUrl: Boolean(sourceUrl),
|
||||||
|
hasPipelineHash: Boolean(currentPipelineHash),
|
||||||
|
isOffline: status.isOffline,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
localData.outputResolution === "custom" &&
|
localData.outputResolution === "custom" &&
|
||||||
@@ -762,6 +767,17 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const activeData = localDataRef.current;
|
const activeData = localDataRef.current;
|
||||||
|
logRenderDebug("render-start", {
|
||||||
|
nodeId: id,
|
||||||
|
mode,
|
||||||
|
pipelineHash: currentPipelineHash,
|
||||||
|
resolution: activeData.outputResolution,
|
||||||
|
customWidth: activeData.customWidth ?? null,
|
||||||
|
customHeight: activeData.customHeight ?? null,
|
||||||
|
format: activeData.format,
|
||||||
|
jpegQuality: activeData.format === "jpeg" ? activeData.jpegQuality : null,
|
||||||
|
});
|
||||||
|
|
||||||
const renderResult = await bridge.renderFull({
|
const renderResult = await bridge.renderFull({
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
steps,
|
steps,
|
||||||
@@ -784,6 +800,20 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
|
|
||||||
if (runId !== renderRunIdRef.current) return;
|
if (runId !== renderRunIdRef.current) return;
|
||||||
|
|
||||||
|
logRenderDebug("render-success", {
|
||||||
|
nodeId: id,
|
||||||
|
mode,
|
||||||
|
pipelineHash: currentPipelineHash,
|
||||||
|
width: renderResult.width,
|
||||||
|
height: renderResult.height,
|
||||||
|
sourceWidth: renderResult.sourceWidth,
|
||||||
|
sourceHeight: renderResult.sourceHeight,
|
||||||
|
format: renderResult.format,
|
||||||
|
mimeType: renderResult.mimeType,
|
||||||
|
sizeBytes: renderResult.sizeBytes,
|
||||||
|
wasSizeClamped: renderResult.wasSizeClamped,
|
||||||
|
});
|
||||||
|
|
||||||
const filename = `lemonspace-render-${Date.now()}.${extensionForFormat(renderResult.format)}`;
|
const filename = `lemonspace-render-${Date.now()}.${extensionForFormat(renderResult.format)}`;
|
||||||
|
|
||||||
if (mode === "download") {
|
if (mode === "download") {
|
||||||
@@ -816,7 +846,9 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
lastRenderErrorHash: undefined,
|
lastRenderErrorHash: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === "download") {
|
const shouldUploadAfterRender = mode === "upload" || !status.isOffline;
|
||||||
|
|
||||||
|
if (!shouldUploadAfterRender) {
|
||||||
await persistImmediately(renderNext);
|
await persistImmediately(renderNext);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -825,6 +857,15 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
logRenderDebug("upload-start", {
|
||||||
|
nodeId: id,
|
||||||
|
pipelineHash: currentPipelineHash,
|
||||||
|
triggerMode: mode,
|
||||||
|
filename,
|
||||||
|
mimeType: renderResult.mimeType,
|
||||||
|
sizeBytes: renderResult.sizeBytes,
|
||||||
|
});
|
||||||
|
|
||||||
const uploadUrl = await generateUploadUrl();
|
const uploadUrl = await generateUploadUrl();
|
||||||
if (runId !== renderRunIdRef.current) return;
|
if (runId !== renderRunIdRef.current) return;
|
||||||
|
|
||||||
@@ -836,6 +877,14 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
|
|
||||||
if (runId !== renderRunIdRef.current) return;
|
if (runId !== renderRunIdRef.current) return;
|
||||||
|
|
||||||
|
logRenderDebug("upload-success", {
|
||||||
|
nodeId: id,
|
||||||
|
pipelineHash: currentPipelineHash,
|
||||||
|
triggerMode: mode,
|
||||||
|
storageId,
|
||||||
|
filename,
|
||||||
|
});
|
||||||
|
|
||||||
const uploadNext: PersistedRenderData = {
|
const uploadNext: PersistedRenderData = {
|
||||||
...renderNext,
|
...renderNext,
|
||||||
storageId,
|
storageId,
|
||||||
@@ -861,6 +910,12 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
if (runId !== renderRunIdRef.current) return;
|
if (runId !== renderRunIdRef.current) return;
|
||||||
|
|
||||||
const message = uploadError instanceof Error ? uploadError.message : "Upload failed";
|
const message = uploadError instanceof Error ? uploadError.message : "Upload failed";
|
||||||
|
logRenderDebug("upload-error", {
|
||||||
|
nodeId: id,
|
||||||
|
pipelineHash: currentPipelineHash,
|
||||||
|
triggerMode: mode,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
await persistImmediately({
|
await persistImmediately({
|
||||||
...renderNext,
|
...renderNext,
|
||||||
lastUploadError: message,
|
lastUploadError: message,
|
||||||
@@ -875,6 +930,12 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
if (runId !== renderRunIdRef.current) return;
|
if (runId !== renderRunIdRef.current) return;
|
||||||
|
|
||||||
const message = error instanceof Error ? error.message : "Render failed";
|
const message = error instanceof Error ? error.message : "Render failed";
|
||||||
|
logRenderDebug("render-error", {
|
||||||
|
nodeId: id,
|
||||||
|
mode,
|
||||||
|
pipelineHash: currentPipelineHash,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
const next: PersistedRenderData = {
|
const next: PersistedRenderData = {
|
||||||
...localDataRef.current,
|
...localDataRef.current,
|
||||||
lastRenderError: message,
|
lastRenderError: message,
|
||||||
|
|||||||
142
lib/canvas-render-preview.ts
Normal file
142
lib/canvas-render-preview.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import {
|
||||||
|
collectPipeline,
|
||||||
|
getSourceImage,
|
||||||
|
hashPipeline,
|
||||||
|
type PipelineStep,
|
||||||
|
} from "@/lib/image-pipeline/contracts";
|
||||||
|
|
||||||
|
export type RenderPreviewGraphNode = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
data?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RenderPreviewGraphEdge = {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RenderPreviewInput = {
|
||||||
|
sourceUrl: string;
|
||||||
|
steps: PipelineStep[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RenderResolutionOption = "original" | "2x" | "custom";
|
||||||
|
type RenderFormatOption = "png" | "jpeg" | "webp";
|
||||||
|
|
||||||
|
const DEFAULT_OUTPUT_RESOLUTION: RenderResolutionOption = "original";
|
||||||
|
const DEFAULT_FORMAT: RenderFormatOption = "png";
|
||||||
|
const DEFAULT_JPEG_QUALITY = 90;
|
||||||
|
const MIN_CUSTOM_DIMENSION = 1;
|
||||||
|
const MAX_CUSTOM_DIMENSION = 16_384;
|
||||||
|
|
||||||
|
function sanitizeDimension(value: unknown): number | undefined {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rounded = Math.round(value);
|
||||||
|
if (rounded < MIN_CUSTOM_DIMENSION || rounded > MAX_CUSTOM_DIMENSION) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_NODE_TYPES = new Set(["image", "ai-image", "asset"]);
|
||||||
|
|
||||||
|
export const RENDER_PREVIEW_PIPELINE_TYPES = new Set([
|
||||||
|
"curves",
|
||||||
|
"color-adjust",
|
||||||
|
"light-adjust",
|
||||||
|
"detail-adjust",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function resolveRenderFingerprint(data: unknown): {
|
||||||
|
resolution: RenderResolutionOption;
|
||||||
|
customWidth?: number;
|
||||||
|
customHeight?: number;
|
||||||
|
format: RenderFormatOption;
|
||||||
|
jpegQuality?: number;
|
||||||
|
} {
|
||||||
|
const record = (data ?? {}) as Record<string, unknown>;
|
||||||
|
const resolution: RenderResolutionOption =
|
||||||
|
record.outputResolution === "2x" || record.outputResolution === "custom"
|
||||||
|
? record.outputResolution
|
||||||
|
: DEFAULT_OUTPUT_RESOLUTION;
|
||||||
|
|
||||||
|
const format: RenderFormatOption =
|
||||||
|
record.format === "jpeg" || record.format === "webp"
|
||||||
|
? record.format
|
||||||
|
: DEFAULT_FORMAT;
|
||||||
|
|
||||||
|
const jpegQuality =
|
||||||
|
typeof record.jpegQuality === "number" && Number.isFinite(record.jpegQuality)
|
||||||
|
? Math.max(1, Math.min(100, Math.round(record.jpegQuality)))
|
||||||
|
: DEFAULT_JPEG_QUALITY;
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolution,
|
||||||
|
customWidth: resolution === "custom" ? sanitizeDimension(record.customWidth) : undefined,
|
||||||
|
customHeight: resolution === "custom" ? sanitizeDimension(record.customHeight) : undefined,
|
||||||
|
format,
|
||||||
|
jpegQuality: format === "jpeg" ? jpegQuality : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRenderPipelineHash(args: {
|
||||||
|
sourceUrl: string | null;
|
||||||
|
steps: PipelineStep[];
|
||||||
|
data: unknown;
|
||||||
|
}): string | null {
|
||||||
|
if (!args.sourceUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashPipeline(
|
||||||
|
{ sourceUrl: args.sourceUrl, render: resolveRenderFingerprint(args.data) },
|
||||||
|
args.steps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNodeImageUrl(data: unknown): string | null {
|
||||||
|
const record = (data ?? {}) as Record<string, unknown>;
|
||||||
|
const directUrl = typeof record.url === "string" ? record.url : null;
|
||||||
|
if (directUrl && directUrl.length > 0) {
|
||||||
|
return directUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewUrl =
|
||||||
|
typeof record.previewUrl === "string" ? record.previewUrl : null;
|
||||||
|
if (previewUrl && previewUrl.length > 0) {
|
||||||
|
return previewUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRenderPreviewInput(args: {
|
||||||
|
nodeId: string;
|
||||||
|
nodes: readonly RenderPreviewGraphNode[];
|
||||||
|
edges: readonly RenderPreviewGraphEdge[];
|
||||||
|
}): { sourceUrl: string | null; steps: PipelineStep[] } {
|
||||||
|
const sourceUrl = getSourceImage({
|
||||||
|
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({
|
||||||
|
nodeId: args.nodeId,
|
||||||
|
nodes: args.nodes,
|
||||||
|
edges: args.edges,
|
||||||
|
isPipelineNode: (node) => RENDER_PREVIEW_PIPELINE_TYPES.has(node.type ?? ""),
|
||||||
|
}) as PipelineStep[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceUrl,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user