diff --git a/.gitignore b/.gitignore index 23e3156..131c698 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ next-env.d.ts .env.sentry-build-plugin .cursor .cursor/* +.kilo diff --git a/components/canvas/canvas-helpers.ts b/components/canvas/canvas-helpers.ts index 1870572..0c40b45 100644 --- a/components/canvas/canvas-helpers.ts +++ b/components/canvas/canvas-helpers.ts @@ -80,6 +80,19 @@ export type PendingEdgeSplit = { 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[] { const persistedEdges = edges.filter((edge) => edge.className !== "temp"); const pipelineNodes = nodes.map((node) => ({ @@ -142,7 +155,22 @@ export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNod if (!source) continue; const srcData = source.data as { url?: string; label?: string }; - const resolvedUrl = resolvePipelineImageUrl(source); + const sourceDataRecord = source.data as Record; + 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") { leftUrl = resolvedUrl; diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 8c72fd3..95caf1c 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -162,6 +162,47 @@ function isLikelyTransientSyncError(error: unknown): boolean { ); } +function summarizeUpdateDataPayload(payload: unknown): Record { + 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) + : 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 { + 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 { const data = node.data as Record | undefined; return typeof data?.storageId === "string" && data.storageId.length > 0; @@ -1005,9 +1046,35 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { } else if (op.type === "moveNode") { await moveNode(op.payload); } 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); + 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") { + 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); + if (process.env.NODE_ENV !== "production") { + console.info("[Canvas sync debug] updateData flush success", { + opId: op.id, + ...summarizeUpdateDataPayload(op.payload), + }); + } } await ackCanvasSyncOp(op.id); @@ -1015,6 +1082,24 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { } catch (error: unknown) { const transient = !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) { const backoffMs = Math.min(30_000, 1000 * 2 ** Math.min(op.attemptCount, 5)); await markCanvasSyncOpFailed(op.id, { diff --git a/components/canvas/nodes/compare-node.tsx b/components/canvas/nodes/compare-node.tsx index 92ab64c..30c5132 100644 --- a/components/canvas/nodes/compare-node.tsx +++ b/components/canvas/nodes/compare-node.tsx @@ -1,9 +1,15 @@ "use client"; -import { useCallback, useRef, useState } from "react"; -import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { Handle, Position, useStore, type NodeProps } from "@xyflow/react"; import { ImageIcon } from "lucide-react"; import BaseNodeWrapper from "./base-node-wrapper"; +import CompareSurface from "./compare-surface"; +import { + resolveRenderPipelineHash, + resolveRenderPreviewInput, + type RenderPreviewInput, +} from "@/lib/canvas-render-preview"; interface CompareNodeData { leftUrl?: string; @@ -12,13 +18,140 @@ interface CompareNodeData { 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 nodes = useStore((state) => state.nodes); + const edges = useStore((state) => state.edges); const [sliderX, setSliderX] = useState(50); + const [manualDisplayMode, setManualDisplayMode] = useState(null); const containerRef = useRef(null); - const hasLeft = !!nodeData.leftUrl; - const hasRight = !!nodeData.rightUrl; + const pipelineNodes = useMemo( + () => nodes.map((node) => ({ id: node.id, type: node.type ?? "", data: node.data })), + [nodes], + ); + const pipelineEdges = useMemo( + () => edges.map((edge) => ({ source: edge.source, target: edge.target })), + [edges], + ); + + const nodesById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]); + const incomingEdges = useMemo( + () => + edges.filter( + (edge) => + edge.target === id && + edge.className !== "temp" && + (edge.targetHandle === "left" || edge.targetHandle === "right"), + ), + [edges, id], + ); + + 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; + 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) => { event.stopPropagation(); @@ -86,7 +219,33 @@ export default function CompareNode({ data, selected }: NodeProps) { />
-
⚖️ Compare
+
+
⚖️ Compare
+ {hasConnectedRenderInput && ( +
+ + +
+ )} +
- {!hasLeft && !hasRight && ( -
- -

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

-
- )} - - {hasRight && ( - // eslint-disable-next-line @next/next/no-img-element - {nodeData.rightLabel - )} - - {hasLeft && ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {nodeData.leftLabel -
- )} - - {hasLeft && hasRight && ( - <> -
-
-
- - - -
+ {!hasLeft && !hasRight && ( +
+ +

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

- - )} + )} - {hasLeft && ( -
- - {nodeData.leftLabel ?? "Before"} - -
- )} + {hasRight && ( + + )} - {hasRight && ( -
- - {nodeData.rightLabel ?? "After"} - -
- )} + {hasLeft && ( + + )} + + {hasLeft && hasRight && ( + <> +
+
+
+ + + +
+
+ + )} + + {hasLeft && ( +
+ + {resolvedSides.left.label ?? "Before"} + +
+ )} + + {hasRight && ( +
+ + {resolvedSides.right.label ?? "After"} + +
+ )}
diff --git a/components/canvas/nodes/compare-surface.tsx b/components/canvas/nodes/compare-surface.tsx new file mode 100644 index 0000000..0581bd3 --- /dev/null +++ b/components/canvas/nodes/compare-surface.tsx @@ -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 ( +
+ {visibleFinalUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {label + ) : hasPreview ? ( + + ) : null} + + {hasPreview ? ( +
+ {isRendering ? "Live Preview..." : "Live Preview"} +
+ ) : null} + + {hasPreview && error ? ( +
+ Preview error +
+ ) : null} +
+ ); +} diff --git a/components/canvas/nodes/render-node.tsx b/components/canvas/nodes/render-node.tsx index 11ceb36..092c918 100644 --- a/components/canvas/nodes/render-node.tsx +++ b/components/canvas/nodes/render-node.tsx @@ -12,14 +12,10 @@ import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { api } from "@/convex/_generated/api"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { usePipelinePreview } from "@/hooks/use-pipeline-preview"; +import { resolveRenderPreviewInput } from "@/lib/canvas-render-preview"; import { resolveMediaAspectRatio } from "@/lib/canvas-utils"; import { parseAspectRatioString } from "@/lib/image-formats"; -import { - collectPipeline, - getSourceImage, - hashPipeline, - type PipelineStep, -} from "@/lib/image-pipeline/contracts"; +import { getSourceImage, hashPipeline } from "@/lib/image-pipeline/contracts"; import { bridge } from "@/lib/image-pipeline/bridge"; import type { Id } from "@/convex/_generated/dataModel"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; @@ -112,7 +108,13 @@ const RENDER_MIN_HEIGHT = 300; const ASPECT_RATIO_TOLERANCE = 0.015; const SIZE_TOLERANCE_PX = 1; -const RENDER_PIPELINE_TYPES = new Set(["curves", "color-adjust", "light-adjust", "detail-adjust"]); +function logRenderDebug(event: string, payload: Record): void { + if (process.env.NODE_ENV === "production") { + return; + } + + console.info("[RenderNode debug]", event, payload); +} function readPositiveNumber(value: unknown): number | null { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { @@ -217,21 +219,6 @@ function toRatioConstrainedSize(args: { return widthDistance <= heightDistance ? widthCandidate : heightCandidate; } -function resolveNodeImageUrl(node: Node): string | null { - const nodeData = (node.data ?? {}) as Record; - 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 { if (typeof value !== "number" || !Number.isFinite(value)) { return undefined; @@ -497,22 +484,40 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr [edges], ); - const sourceUrl = useMemo( + const renderPreviewInput = useMemo( () => - getSourceImage({ + resolveRenderPreviewInput({ nodeId: id, nodes: pipelineNodes, edges: pipelineEdges, - isSourceNode: (node) => - node.type === "image" || node.type === "ai-image" || node.type === "asset", - getSourceImageFromNode: (node) => { - const sourceNode = nodes.find((candidate) => candidate.id === node.id); - return sourceNode ? resolveNodeImageUrl(sourceNode) : null; - }, }), - [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( () => getSourceImage({ @@ -526,16 +531,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr [id, pipelineEdges, pipelineNodes], ); - const steps = useMemo( - () => - collectPipeline({ - nodeId: id, - nodes: pipelineNodes, - edges: pipelineEdges, - isPipelineNode: (node) => RENDER_PIPELINE_TYPES.has(node.type ?? ""), - }) as PipelineStep[], - [id, pipelineEdges, pipelineNodes], - ); + const steps = renderPreviewInput.steps; const renderFingerprint = useMemo( () => ({ @@ -741,7 +737,16 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr }; 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 ( localData.outputResolution === "custom" && @@ -762,6 +767,17 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr try { 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({ sourceUrl, steps, @@ -784,6 +800,20 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr 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)}`; if (mode === "download") { @@ -816,7 +846,9 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr lastRenderErrorHash: undefined, }; - if (mode === "download") { + const shouldUploadAfterRender = mode === "upload" || !status.isOffline; + + if (!shouldUploadAfterRender) { await persistImmediately(renderNext); return; } @@ -825,6 +857,15 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr setIsUploading(true); try { + logRenderDebug("upload-start", { + nodeId: id, + pipelineHash: currentPipelineHash, + triggerMode: mode, + filename, + mimeType: renderResult.mimeType, + sizeBytes: renderResult.sizeBytes, + }); + const uploadUrl = await generateUploadUrl(); if (runId !== renderRunIdRef.current) return; @@ -836,6 +877,14 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr if (runId !== renderRunIdRef.current) return; + logRenderDebug("upload-success", { + nodeId: id, + pipelineHash: currentPipelineHash, + triggerMode: mode, + storageId, + filename, + }); + const uploadNext: PersistedRenderData = { ...renderNext, storageId, @@ -861,6 +910,12 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr if (runId !== renderRunIdRef.current) return; const message = uploadError instanceof Error ? uploadError.message : "Upload failed"; + logRenderDebug("upload-error", { + nodeId: id, + pipelineHash: currentPipelineHash, + triggerMode: mode, + error: message, + }); await persistImmediately({ ...renderNext, lastUploadError: message, @@ -875,6 +930,12 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr if (runId !== renderRunIdRef.current) return; const message = error instanceof Error ? error.message : "Render failed"; + logRenderDebug("render-error", { + nodeId: id, + mode, + pipelineHash: currentPipelineHash, + error: message, + }); const next: PersistedRenderData = { ...localDataRef.current, lastRenderError: message, diff --git a/lib/canvas-render-preview.ts b/lib/canvas-render-preview.ts new file mode 100644 index 0000000..000f490 --- /dev/null +++ b/lib/canvas-render-preview.ts @@ -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; + 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; + 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, + }; +}