Merge origin/master: combine compare URL pipeline with storage fallback

Made-with: Cursor
This commit is contained in:
Matthias
2026-04-02 22:29:11 +02:00
7 changed files with 670 additions and 122 deletions

1
.gitignore vendored
View File

@@ -44,3 +44,4 @@ next-env.d.ts
.env.sentry-build-plugin .env.sentry-build-plugin
.cursor .cursor
.cursor/* .cursor/*
.kilo

View File

@@ -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;

View File

@@ -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, {

View File

@@ -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}
@@ -104,29 +263,24 @@ export default function CompareNode({ data, selected }: NodeProps) {
)} )}
{hasRight && ( {hasRight && (
// eslint-disable-next-line @next/next/no-img-element <CompareSurface
<img finalUrl={resolvedSides.right.finalUrl}
src={nodeData.rightUrl} label={resolvedSides.right.label}
alt={nodeData.rightLabel ?? "Right"} previewInput={resolvedSides.right.previewInput}
className="pointer-events-none absolute inset-0 h-full w-full object-contain" nodeWidth={previewNodeWidth}
draggable={false} preferPreview={effectiveDisplayMode === "preview"}
/> />
)} )}
{hasLeft && ( {hasLeft && (
<div <CompareSurface
className="pointer-events-none absolute inset-0 overflow-hidden" finalUrl={resolvedSides.left.finalUrl}
style={{ width: `${sliderX}%` }} label={resolvedSides.left.label}
> previewInput={resolvedSides.left.previewInput}
{/* eslint-disable-next-line @next/next/no-img-element */} nodeWidth={previewNodeWidth}
<img clipWidthPercent={sliderX}
src={nodeData.leftUrl} preferPreview={effectiveDisplayMode === "preview"}
alt={nodeData.leftLabel ?? "Left"}
className="absolute inset-0 h-full w-full object-contain"
style={{ width: "100%", maxWidth: "none" }}
draggable={false}
/> />
</div>
)} )}
{hasLeft && hasRight && ( {hasLeft && hasRight && (
@@ -157,7 +311,7 @@ export default function CompareNode({ data, selected }: NodeProps) {
{hasLeft && ( {hasLeft && (
<div className="pointer-events-none absolute left-2 top-2 z-10"> <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"> <span className="rounded bg-blue-500/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
{nodeData.leftLabel ?? "Before"} {resolvedSides.left.label ?? "Before"}
</span> </span>
</div> </div>
)} )}
@@ -165,7 +319,7 @@ export default function CompareNode({ data, selected }: NodeProps) {
{hasRight && ( {hasRight && (
<div className="pointer-events-none absolute right-2 top-2 z-10"> <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"> <span className="rounded bg-emerald-500/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
{nodeData.rightLabel ?? "After"} {resolvedSides.right.label ?? "After"}
</span> </span>
</div> </div>
)} )}

View 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>
);
}

View File

@@ -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,

View 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,
};
}