"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { Position, type Node, type NodeProps } from "@xyflow/react"; import { AlertCircle, ArrowDown, CheckCircle2, CloudUpload, Loader2, Maximize2, X } from "lucide-react"; import { useMutation } from "convex/react"; import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper"; import { SliderRow } from "@/components/canvas/nodes/adjustment-controls"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 { useCanvasGraph } from "@/components/canvas/canvas-graph-context"; import { findSourceNodeFromGraph, resolveRenderPreviewInputFromGraph, shouldFastPathPreviewPipeline, } from "@/lib/canvas-render-preview"; import { resolveMediaAspectRatio } from "@/lib/canvas-utils"; import { parseAspectRatioString } from "@/lib/image-formats"; import { hashPipeline } from "@/lib/image-pipeline/contracts"; import { buildHistogramPlot } from "@/lib/image-pipeline/histogram-plot"; import { isPipelineAbortError, renderFullWithWorkerFallback, } from "@/lib/image-pipeline/worker-client"; import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; import type { Id } from "@/convex/_generated/dataModel"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import CanvasHandle from "@/components/canvas/canvas-handle"; type RenderResolutionOption = "original" | "2x" | "custom"; type RenderFormatOption = "png" | "jpeg" | "webp"; type SourceNodeDescriptor = { id: string; type: string; data?: unknown; }; type RenderNodeData = { outputResolution?: RenderResolutionOption; customWidth?: number; customHeight?: number; format?: RenderFormatOption; jpegQuality?: number; lastRenderedAt?: number; lastRenderedHash?: string; lastRenderWidth?: number; lastRenderHeight?: number; lastRenderFormat?: RenderFormatOption; lastRenderMimeType?: string; lastRenderSizeBytes?: number; lastRenderQuality?: number | null; lastRenderSourceWidth?: number; lastRenderSourceHeight?: number; lastRenderWasSizeClamped?: boolean; lastRenderError?: string; lastRenderErrorHash?: string; storageId?: string; url?: string; lastUploadedAt?: number; lastUploadedHash?: string; lastUploadStorageId?: string; lastUploadUrl?: string; lastUploadMimeType?: string; lastUploadSizeBytes?: number; lastUploadFilename?: string; lastUploadError?: string; lastUploadErrorHash?: string; _status?: string; _statusMessage?: string; }; export type RenderNodeType = Node; type RenderState = "idle" | "rendering" | "done" | "error"; type PersistedRenderData = { outputResolution: RenderResolutionOption; customWidth?: number; customHeight?: number; format: RenderFormatOption; jpegQuality: number; lastRenderedAt?: number; lastRenderedHash?: string; lastRenderWidth?: number; lastRenderHeight?: number; lastRenderFormat?: RenderFormatOption; lastRenderMimeType?: string; lastRenderSizeBytes?: number; lastRenderQuality?: number | null; lastRenderSourceWidth?: number; lastRenderSourceHeight?: number; lastRenderWasSizeClamped?: boolean; lastRenderError?: string; lastRenderErrorHash?: string; storageId?: string; url?: string; lastUploadedAt?: number; lastUploadedHash?: string; lastUploadStorageId?: string; lastUploadUrl?: string; lastUploadMimeType?: string; lastUploadSizeBytes?: number; lastUploadFilename?: string; lastUploadError?: string; lastUploadErrorHash?: string; isFavorite?: true; }; 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; const RENDER_MIN_WIDTH = 260; const RENDER_MIN_HEIGHT = 300; const ASPECT_RATIO_TOLERANCE = 0.015; const SIZE_TOLERANCE_PX = 1; 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) { return null; } return value; } function resolveSourceAspectRatio(sourceNode: SourceNodeDescriptor | null): number | null { if (!sourceNode) { return null; } const sourceData = (sourceNode.data ?? {}) as Record; if (sourceNode.type === "image") { const sourceWidth = readPositiveNumber(sourceData.width); const sourceHeight = readPositiveNumber(sourceData.height); if (sourceWidth && sourceHeight) { return sourceWidth / sourceHeight; } return null; } if (sourceNode.type === "asset") { return resolveMediaAspectRatio( readPositiveNumber(sourceData.intrinsicWidth) ?? undefined, readPositiveNumber(sourceData.intrinsicHeight) ?? undefined, typeof sourceData.orientation === "string" ? sourceData.orientation : undefined, ); } if (sourceNode.type === "ai-image") { const outputWidth = readPositiveNumber(sourceData.outputWidth); const outputHeight = readPositiveNumber(sourceData.outputHeight); if (outputWidth && outputHeight) { return outputWidth / outputHeight; } const aspectRatioLabel = typeof sourceData.aspectRatio === "string" ? sourceData.aspectRatio : null; if (!aspectRatioLabel) { return null; } try { const parsed = parseAspectRatioString(aspectRatioLabel); return parsed.w / parsed.h; } catch { return null; } } return null; } function toRatioConstrainedSize(args: { currentWidth: number; currentHeight: number; aspectRatio: number; minWidth: number; minHeight: number; }): { width: number; height: number } { const { currentWidth, currentHeight, aspectRatio, minWidth, minHeight } = args; const fromWidth = () => { let width = Math.max(minWidth, currentWidth); let height = width / aspectRatio; if (height < minHeight) { height = minHeight; width = height * aspectRatio; } return { width: Math.round(width), height: Math.round(height), }; }; const fromHeight = () => { let height = Math.max(minHeight, currentHeight); let width = height * aspectRatio; if (width < minWidth) { width = minWidth; height = width / aspectRatio; } return { width: Math.round(width), height: Math.round(height), }; }; const widthCandidate = fromWidth(); const heightCandidate = fromHeight(); const widthDistance = Math.abs(widthCandidate.width - currentWidth) + Math.abs(widthCandidate.height - currentHeight); const heightDistance = Math.abs(heightCandidate.width - currentWidth) + Math.abs(heightCandidate.height - currentHeight); return widthDistance <= heightDistance ? widthCandidate : heightCandidate; } 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; } function sanitizeRenderData(data: RenderNodeData): PersistedRenderData { const outputResolution: RenderResolutionOption = data.outputResolution === "2x" || data.outputResolution === "custom" ? data.outputResolution : DEFAULT_OUTPUT_RESOLUTION; const format: RenderFormatOption = data.format === "jpeg" || data.format === "webp" ? data.format : DEFAULT_FORMAT; const jpegQuality = typeof data.jpegQuality === "number" && Number.isFinite(data.jpegQuality) ? Math.max(1, Math.min(100, Math.round(data.jpegQuality))) : DEFAULT_JPEG_QUALITY; const next: PersistedRenderData = { outputResolution, format, jpegQuality, }; if (outputResolution === "custom") { const width = sanitizeDimension(data.customWidth); const height = sanitizeDimension(data.customHeight); if (width !== undefined) next.customWidth = width; if (height !== undefined) next.customHeight = height; } if (typeof data.lastRenderedAt === "number" && Number.isFinite(data.lastRenderedAt)) { next.lastRenderedAt = data.lastRenderedAt; } if (typeof data.lastRenderedHash === "string" && data.lastRenderedHash.length > 0) { next.lastRenderedHash = data.lastRenderedHash; } if (typeof data.lastRenderWidth === "number" && Number.isFinite(data.lastRenderWidth)) { next.lastRenderWidth = Math.max(1, Math.round(data.lastRenderWidth)); } if (typeof data.lastRenderHeight === "number" && Number.isFinite(data.lastRenderHeight)) { next.lastRenderHeight = Math.max(1, Math.round(data.lastRenderHeight)); } if (data.lastRenderFormat === "png" || data.lastRenderFormat === "jpeg" || data.lastRenderFormat === "webp") { next.lastRenderFormat = data.lastRenderFormat; } if (typeof data.lastRenderMimeType === "string" && data.lastRenderMimeType.length > 0) { next.lastRenderMimeType = data.lastRenderMimeType; } if (typeof data.lastRenderSizeBytes === "number" && Number.isFinite(data.lastRenderSizeBytes)) { next.lastRenderSizeBytes = Math.max(0, Math.round(data.lastRenderSizeBytes)); } if ( data.lastRenderQuality === null || (typeof data.lastRenderQuality === "number" && Number.isFinite(data.lastRenderQuality)) ) { next.lastRenderQuality = data.lastRenderQuality; } if ( typeof data.lastRenderSourceWidth === "number" && Number.isFinite(data.lastRenderSourceWidth) ) { next.lastRenderSourceWidth = Math.max(1, Math.round(data.lastRenderSourceWidth)); } if ( typeof data.lastRenderSourceHeight === "number" && Number.isFinite(data.lastRenderSourceHeight) ) { next.lastRenderSourceHeight = Math.max(1, Math.round(data.lastRenderSourceHeight)); } if (typeof data.lastRenderWasSizeClamped === "boolean") { next.lastRenderWasSizeClamped = data.lastRenderWasSizeClamped; } if (typeof data.lastRenderError === "string" && data.lastRenderError.length > 0) { next.lastRenderError = data.lastRenderError; } if (typeof data.lastRenderErrorHash === "string" && data.lastRenderErrorHash.length > 0) { next.lastRenderErrorHash = data.lastRenderErrorHash; } if (typeof data.storageId === "string" && data.storageId.length > 0) { next.storageId = data.storageId; } if (typeof data.url === "string" && data.url.length > 0) { next.url = data.url; } if (typeof data.lastUploadedAt === "number" && Number.isFinite(data.lastUploadedAt)) { next.lastUploadedAt = data.lastUploadedAt; } if (typeof data.lastUploadedHash === "string" && data.lastUploadedHash.length > 0) { next.lastUploadedHash = data.lastUploadedHash; } if (typeof data.lastUploadStorageId === "string" && data.lastUploadStorageId.length > 0) { next.lastUploadStorageId = data.lastUploadStorageId; } if (typeof data.lastUploadUrl === "string" && data.lastUploadUrl.length > 0) { next.lastUploadUrl = data.lastUploadUrl; } if (typeof data.lastUploadMimeType === "string" && data.lastUploadMimeType.length > 0) { next.lastUploadMimeType = data.lastUploadMimeType; } if (typeof data.lastUploadSizeBytes === "number" && Number.isFinite(data.lastUploadSizeBytes)) { next.lastUploadSizeBytes = Math.max(0, Math.round(data.lastUploadSizeBytes)); } if (typeof data.lastUploadFilename === "string" && data.lastUploadFilename.length > 0) { next.lastUploadFilename = data.lastUploadFilename; } if (typeof data.lastUploadError === "string" && data.lastUploadError.length > 0) { next.lastUploadError = data.lastUploadError; } if (typeof data.lastUploadErrorHash === "string" && data.lastUploadErrorHash.length > 0) { next.lastUploadErrorHash = data.lastUploadErrorHash; } return preserveNodeFavorite(next, data) as PersistedRenderData; } function formatBytes(bytes: number | undefined): string { if (bytes === undefined) return "-"; if (bytes < 1024) return `${bytes} B`; const kb = bytes / 1024; if (kb < 1024) return `${kb.toFixed(1)} KB`; return `${(kb / 1024).toFixed(2)} MB`; } function extensionForFormat(format: RenderFormatOption): string { return format === "jpeg" ? "jpg" : format; } async function uploadBlobToConvex(args: { uploadUrl: string; blob: Blob; mimeType: string; }): Promise<{ storageId: string }> { const response = await fetch(args.uploadUrl, { method: "POST", headers: { "Content-Type": args.mimeType, }, body: args.blob, }); if (!response.ok) { throw new Error(`Upload failed: ${response.status}`); } let payload: unknown; try { payload = await response.json(); } catch { throw new Error("Upload failed: invalid response"); } const storageId = (payload as { storageId?: unknown }).storageId; if (typeof storageId !== "string" || storageId.length === 0) { throw new Error("Upload failed: missing storageId"); } return { storageId }; } export default function RenderNode({ id, data, selected, width, height }: NodeProps) { const generateUploadUrl = useMutation(api.storage.generateUploadUrl); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const graph = useCanvasGraph(); const [localData, setLocalData] = useState(() => sanitizeRenderData(data), ); const [isRendering, setIsRendering] = useState(false); const [isUploading, setIsUploading] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); const localDataRef = useRef(localData); const renderRunIdRef = useRef(0); const renderAbortControllerRef = useRef(null); const menuButtonRef = useRef(null); const menuPanelRef = useRef(null); const lastAppliedAspectRatioRef = useRef(null); useEffect(() => { return () => { renderAbortControllerRef.current?.abort(); renderAbortControllerRef.current = null; }; }, []); useEffect(() => { localDataRef.current = localData; }, [localData]); useEffect(() => { const timer = window.setTimeout(() => { setLocalData(sanitizeRenderData(data)); }, 0); return () => { window.clearTimeout(timer); }; }, [data]); const queueSave = useDebouncedCallback(() => { void queueNodeDataUpdate({ nodeId: id as Id<"nodes">, data: localDataRef.current, }); }, 120); const updateLocalData = (updater: (current: PersistedRenderData) => PersistedRenderData) => { setLocalData((current) => { const next = updater(current); localDataRef.current = next; queueSave(); return next; }); }; const renderPreviewInput = useMemo( () => resolveRenderPreviewInputFromGraph({ nodeId: id, graph, }), [graph, id], ); const sourceUrl = renderPreviewInput.sourceUrl; const sourceComposition = renderPreviewInput.sourceComposition; useEffect(() => { logRenderDebug("node-data-updated", { nodeId: id, hasSourceUrl: typeof sourceUrl === "string" && sourceUrl.length > 0, hasSourceComposition: Boolean(sourceComposition), 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, sourceComposition, ]); const sourceNode = useMemo( () => findSourceNodeFromGraph(graph, { nodeId: id, isSourceNode: (node) => node.type === "image" || node.type === "ai-image" || node.type === "asset", getSourceImageFromNode: () => true, }), [graph, id], ); const steps = renderPreviewInput.steps; const hasCropStep = useMemo(() => steps.some((step) => step.type === "crop"), [steps]); const previewDebounceMs = shouldFastPathPreviewPipeline( steps, graph.previewNodeDataOverrides, ) ? 16 : undefined; const renderFingerprint = useMemo( () => ({ resolution: localData.outputResolution, customWidth: localData.outputResolution === "custom" ? localData.customWidth : undefined, customHeight: localData.outputResolution === "custom" ? localData.customHeight : undefined, format: localData.format, jpegQuality: localData.format === "jpeg" ? localData.jpegQuality : undefined, }), [ localData.customHeight, localData.customWidth, localData.format, localData.jpegQuality, localData.outputResolution, ], ); const currentPipelineHash = useMemo(() => { if (!sourceUrl && !sourceComposition) return null; return hashPipeline( { source: sourceComposition ?? sourceUrl, render: renderFingerprint }, steps, ); }, [renderFingerprint, sourceComposition, sourceUrl, steps]); const isRenderCurrent = Boolean(currentPipelineHash) && localData.lastRenderedHash === currentPipelineHash; const currentError = currentPipelineHash && localData.lastRenderErrorHash === currentPipelineHash ? localData.lastRenderError : undefined; const currentUploadError = currentPipelineHash && localData.lastUploadErrorHash === currentPipelineHash ? localData.lastUploadError : undefined; const isUploadCurrent = Boolean(currentPipelineHash) && localData.lastUploadedHash === currentPipelineHash; const renderState: RenderState = isRendering ? "rendering" : currentError ? "error" : isRenderCurrent && typeof localData.lastRenderedAt === "number" ? "done" : "idle"; const renderStateLabel: Record = { idle: "Idle", rendering: "Rendering", done: "Done", error: "Error", }; const hasSource = (typeof sourceUrl === "string" && sourceUrl.length > 0) || Boolean(sourceComposition); const previewNodeWidth = Math.max(260, Math.round(width ?? 320)); const { canvasRef, histogram, isRendering: isPreviewRendering, previewAspectRatio, error: previewError, } = usePipelinePreview({ sourceUrl, sourceComposition, steps, nodeWidth: previewNodeWidth, debounceMs: previewDebounceMs, // Inline-Preview: bewusst kompakt halten, damit Änderungen schneller // sichtbar werden, besonders in langen Graphen. previewScale: 0.5, maxPreviewWidth: 720, maxDevicePixelRatio: 1.25, }); const fullscreenPreviewWidth = Math.max(960, Math.round((width ?? 320) * 3)); const { canvasRef: fullscreenCanvasRef, isRendering: isFullscreenPreviewRendering, error: fullscreenPreviewError, } = usePipelinePreview({ sourceUrl: isFullscreenOpen && sourceUrl ? sourceUrl : null, sourceComposition: isFullscreenOpen ? sourceComposition : undefined, steps, nodeWidth: fullscreenPreviewWidth, includeHistogram: false, debounceMs: previewDebounceMs, previewScale: 0.85, maxPreviewWidth: 1920, maxDevicePixelRatio: 1.5, }); const targetAspectRatio = useMemo(() => { if ( hasCropStep && typeof previewAspectRatio === "number" && Number.isFinite(previewAspectRatio) && previewAspectRatio > 0 ) { return previewAspectRatio; } const sourceAspectRatio = resolveSourceAspectRatio(sourceNode); if (sourceAspectRatio && Number.isFinite(sourceAspectRatio) && sourceAspectRatio > 0) { return sourceAspectRatio; } if ( typeof previewAspectRatio === "number" && Number.isFinite(previewAspectRatio) && previewAspectRatio > 0 ) { return previewAspectRatio; } return null; }, [hasCropStep, previewAspectRatio, sourceNode]); useEffect(() => { if (!hasSource || targetAspectRatio === null) { return; } const measuredWidth = typeof width === "number" ? width : 0; const measuredHeight = typeof height === "number" ? height : 0; if (measuredWidth <= 0 || measuredHeight <= 0) { return; } const currentAspectRatio = measuredWidth / measuredHeight; const aspectDelta = Math.abs(currentAspectRatio - targetAspectRatio); const lastAppliedAspectRatio = lastAppliedAspectRatioRef.current; const hasAspectRatioChanged = lastAppliedAspectRatio === null || Math.abs(lastAppliedAspectRatio - targetAspectRatio) > ASPECT_RATIO_TOLERANCE; if (aspectDelta <= ASPECT_RATIO_TOLERANCE && !hasAspectRatioChanged) { return; } const targetSize = toRatioConstrainedSize({ currentWidth: measuredWidth, currentHeight: measuredHeight, aspectRatio: targetAspectRatio, minWidth: RENDER_MIN_WIDTH, minHeight: RENDER_MIN_HEIGHT, }); const widthDelta = Math.abs(targetSize.width - measuredWidth); const heightDelta = Math.abs(targetSize.height - measuredHeight); if (widthDelta <= SIZE_TOLERANCE_PX && heightDelta <= SIZE_TOLERANCE_PX) { lastAppliedAspectRatioRef.current = targetAspectRatio; return; } lastAppliedAspectRatioRef.current = targetAspectRatio; void queueNodeResize({ nodeId: id as Id<"nodes">, width: targetSize.width, height: targetSize.height, }); }, [hasSource, height, id, queueNodeResize, targetAspectRatio, width]); const histogramPlot = useMemo(() => { return buildHistogramPlot(histogram, { points: 64, width: 96, height: 44, }); }, [histogram]); const canRender = hasSource && !isRendering && !isUploading && (localData.outputResolution !== "custom" || (typeof localData.customWidth === "number" && typeof localData.customHeight === "number")); const canUpload = canRender && !status.isOffline; const canOpenFullscreen = hasSource || Boolean(localData.url); useEffect(() => { if (!isMenuOpen) { return; } const onPointerDown = (event: PointerEvent) => { const target = event.target as globalThis.Node | null; if ( target && (menuButtonRef.current?.contains(target) || menuPanelRef.current?.contains(target)) ) { return; } setIsMenuOpen(false); }; window.addEventListener("pointerdown", onPointerDown); return () => { window.removeEventListener("pointerdown", onPointerDown); }; }, [isMenuOpen]); const persistImmediately = async (next: PersistedRenderData) => { localDataRef.current = next; setLocalData(next); await queueNodeDataUpdate({ nodeId: id as Id<"nodes">, data: next, }); }; const applyLocalDataImmediately = (next: PersistedRenderData) => { localDataRef.current = next; setLocalData(next); }; const handleRender = async (mode: "download" | "upload") => { if ((!sourceUrl && !sourceComposition) || !currentPipelineHash) { logRenderDebug("render-aborted-prerequisites", { nodeId: id, mode, hasSourceUrl: Boolean(sourceUrl), hasSourceComposition: Boolean(sourceComposition), hasPipelineHash: Boolean(currentPipelineHash), isOffline: status.isOffline, }); return; } if ( localData.outputResolution === "custom" && (localData.customWidth === undefined || localData.customHeight === undefined) ) { const next = { ...localDataRef.current, lastRenderError: "Custom width and height are required.", lastRenderErrorHash: currentPipelineHash, }; if (mode === "upload") { await persistImmediately(next); } else { applyLocalDataImmediately(next); } return; } renderRunIdRef.current += 1; const runId = renderRunIdRef.current; renderAbortControllerRef.current?.abort(); const abortController = new AbortController(); renderAbortControllerRef.current = abortController; setIsRendering(true); 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 renderFullWithWorkerFallback({ sourceUrl: sourceUrl ?? undefined, sourceComposition, steps, render: { resolution: activeData.outputResolution, customSize: activeData.outputResolution === "custom" && activeData.customWidth !== undefined && activeData.customHeight !== undefined ? { width: activeData.customWidth, height: activeData.customHeight, } : undefined, format: activeData.format, jpegQuality: activeData.format === "jpeg" ? activeData.jpegQuality / 100 : undefined, }, signal: abortController.signal, }); 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") { const objectUrl = window.URL.createObjectURL(renderResult.blob); const anchor = document.createElement("a"); anchor.href = objectUrl; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => { window.URL.revokeObjectURL(objectUrl); }, 30_000); } const renderNext: PersistedRenderData = { ...activeData, lastRenderedAt: Date.now(), lastRenderedHash: currentPipelineHash, lastRenderWidth: renderResult.width, lastRenderHeight: renderResult.height, lastRenderFormat: renderResult.format, lastRenderMimeType: renderResult.mimeType, lastRenderSizeBytes: renderResult.sizeBytes, lastRenderQuality: renderResult.quality, lastRenderSourceWidth: renderResult.sourceWidth, lastRenderSourceHeight: renderResult.sourceHeight, lastRenderWasSizeClamped: renderResult.wasSizeClamped, lastRenderError: undefined, lastRenderErrorHash: undefined, }; const shouldUploadAfterRender = mode === "upload"; if (!shouldUploadAfterRender) { applyLocalDataImmediately(renderNext); return; } if (runId !== renderRunIdRef.current) return; 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; const { storageId } = await uploadBlobToConvex({ uploadUrl, blob: renderResult.blob, mimeType: renderResult.mimeType, }); if (runId !== renderRunIdRef.current) return; logRenderDebug("upload-success", { nodeId: id, pipelineHash: currentPipelineHash, triggerMode: mode, storageId, filename, }); const uploadNext: PersistedRenderData = { ...renderNext, storageId, url: undefined, lastUploadedAt: Date.now(), lastUploadedHash: currentPipelineHash, lastUploadStorageId: storageId, lastUploadUrl: undefined, lastUploadMimeType: renderResult.mimeType, lastUploadSizeBytes: renderResult.sizeBytes, lastUploadFilename: filename, lastUploadError: undefined, lastUploadErrorHash: undefined, }; await persistImmediately(uploadNext); if (runId !== renderRunIdRef.current) return; // URL-Aufloesung findet ueber den Canvas-Subscription-Cache statt. // Optionaler Nachlade-Lookup ist hier nicht erforderlich. } catch (uploadError: unknown) { 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, lastUploadErrorHash: currentPipelineHash, }); } finally { if (runId === renderRunIdRef.current) { setIsUploading(false); } } } catch (error: unknown) { if (runId !== renderRunIdRef.current) return; if (isPipelineAbortError(error)) { 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, lastRenderErrorHash: currentPipelineHash, }; if (mode === "upload") { await persistImmediately(next); } else { applyLocalDataImmediately(next); } } finally { if (runId === renderRunIdRef.current) { if (renderAbortControllerRef.current === abortController) { renderAbortControllerRef.current = null; } setIsRendering(false); } } }; const statusToneClass = renderState === "done" ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : renderState === "rendering" ? "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300" : renderState === "error" ? "border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300" : "border-border bg-muted/40 text-muted-foreground"; const wrapperStatus = renderState === "rendering" ? "executing" : renderState; return ( <> , onClick: () => setIsFullscreenOpen(true), disabled: !canOpenFullscreen, }, ]} className="flex h-full min-w-[280px] flex-col overflow-hidden border-sky-500/30" >
Bildausgabe
{hasSource ? ( ) : (
Verbinde eine Bild-, Asset- oder KI-Bild-Node als Quelle.
)}
{renderStateLabel[renderState]}
{(isPreviewRendering || previewError) && hasSource ? (
{isPreviewRendering ? "Preview..." : "Preview error"}
) : null}
{isMenuOpen ? (
event.stopPropagation()} className="nodrag absolute right-0 top-11 w-64 space-y-2 rounded-xl border border-border/80 bg-popover/95 p-3 shadow-lg backdrop-blur" >
Resolution
{localData.outputResolution === "custom" ? (
) : null}
Format
{localData.format === "jpeg" ? ( { updateLocalData((current) => ({ ...current, jpegQuality: Math.max(1, Math.min(100, Math.round(value))), })); }} /> ) : null}
{status.isOffline ? (

Upload ist nur online verfuegbar.

) : null}
) : null}
{renderState === "idle" && !isRenderCurrent && localData.lastRenderedAt ? (
Pipeline geaendert. Bitte erneut rendern.
) : null} {renderState === "done" ? (
Export abgeschlossen
{localData.lastRenderWidth}x{localData.lastRenderHeight} px - {String(localData.lastRenderFormat ?? localData.format).toUpperCase()} - {formatBytes(localData.lastRenderSizeBytes)}
{localData.lastRenderWasSizeClamped ?
Ausgabe wurde an Groessenlimits angepasst.
: null}
) : null} {renderState === "error" && currentError ? (
{currentError}
) : null} {isUploadCurrent && localData.lastUploadStorageId ? (
Upload gespeichert
Storage: {localData.lastUploadStorageId}
{localData.lastUploadUrl ? "URL aufgeloest" : "URL-Aufloesung ausstehend"}
) : null} {currentUploadError ? (
Upload fehlgeschlagen: {currentUploadError}
) : null} {previewError ? (
Preview: {previewError}
) : null}
Gesamt-Histogramm
Render-Ausgabe
{hasSource ? (
{isFullscreenPreviewRendering ? (
Rendering preview...
) : null} {fullscreenPreviewError ? (
Preview: {fullscreenPreviewError}
) : null}
) : localData.url ? ( // eslint-disable-next-line @next/next/no-img-element Render output ) : (
Keine Render-Ausgabe verfuegbar
)}
); }