diff --git a/components.json b/components.json index 42e7bc3..a4dbfe4 100644 --- a/components.json +++ b/components.json @@ -12,6 +12,8 @@ }, "iconLibrary": "lucide", "rtl": false, + "menuColor": "inverted", + "menuAccent": "subtle", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -19,7 +21,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "menuColor": "inverted", - "menuAccent": "subtle", - "registries": {} + "registries": { + "@tool-ui": "https://www.tool-ui.com/r/{name}.json" + } } diff --git a/components/canvas/canvas-node-template-picker.tsx b/components/canvas/canvas-node-template-picker.tsx index 1dba897..a222d96 100644 --- a/components/canvas/canvas-node-template-picker.tsx +++ b/components/canvas/canvas-node-template-picker.tsx @@ -3,11 +3,15 @@ import { FolderOpen, Frame, + Focus, GitCompare, + ImageDown, Image, Package, + Palette, Sparkles, StickyNote, + Sun, Type, Video, type LucideIcon, @@ -29,6 +33,11 @@ const NODE_ICONS: Record = { group: FolderOpen, asset: Package, video: Video, + curves: Sparkles, + "color-adjust": Palette, + "light-adjust": Sun, + "detail-adjust": Focus, + render: ImageDown, }; const NODE_SEARCH_KEYWORDS: Partial< @@ -43,6 +52,11 @@ const NODE_SEARCH_KEYWORDS: Partial< group: ["group", "gruppe", "folder"], asset: ["asset", "freepik", "stock"], video: ["video", "pexels", "clip"], + curves: ["curves", "tone", "contrast"], + "color-adjust": ["color", "hue", "saturation"], + "light-adjust": ["light", "exposure", "brightness"], + "detail-adjust": ["detail", "sharp", "grain"], + render: ["render", "export", "download"], }; export type CanvasNodeTemplatePickerProps = { diff --git a/components/canvas/canvas-placement-context.tsx b/components/canvas/canvas-placement-context.tsx index 27ddd8e..12316a8 100644 --- a/components/canvas/canvas-placement-context.tsx +++ b/components/canvas/canvas-placement-context.tsx @@ -11,11 +11,12 @@ import { useStore, type Edge as RFEdge } from "@xyflow/react"; import type { Id } from "@/convex/_generated/dataModel"; import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils"; +import type { CanvasNodeType } from "@/lib/canvas-node-types"; import { isOptimisticEdgeId } from "./canvas-helpers"; type CreateNodeArgs = { canvasId: Id<"canvases">; - type: string; + type: CanvasNodeType; positionX: number; positionY: number; width: number; @@ -28,7 +29,7 @@ type CreateNodeArgs = { type CreateNodeWithEdgeSplitArgs = { canvasId: Id<"canvases">; - type: string; + type: CanvasNodeType; positionX: number; positionY: number; width: number; @@ -70,7 +71,7 @@ type CreateNodeWithEdgeToTargetMutation = ( type FlowPoint = { x: number; y: number }; type CreateNodeWithIntersectionInput = { - type: string; + type: CanvasNodeType; position: FlowPoint; width?: number; height?: number; diff --git a/components/canvas/canvas-reconnect.ts b/components/canvas/canvas-reconnect.ts index 6c3a8ee..a728575 100644 --- a/components/canvas/canvas-reconnect.ts +++ b/components/canvas/canvas-reconnect.ts @@ -18,6 +18,11 @@ type UseCanvasReconnectHandlersParams = { targetHandle?: string; }) => Promise; runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise; + validateConnection?: ( + oldEdge: RFEdge, + newConnection: Connection, + ) => string | null; + onInvalidConnection?: (message: string) => void; }; export function useCanvasReconnectHandlers({ @@ -27,6 +32,8 @@ export function useCanvasReconnectHandlers({ setEdges, runCreateEdgeMutation, runRemoveEdgeMutation, + validateConnection, + onInvalidConnection, }: UseCanvasReconnectHandlersParams): { onReconnectStart: () => void; onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void; @@ -45,11 +52,19 @@ export function useCanvasReconnectHandlers({ const onReconnect = useCallback( (oldEdge: RFEdge, newConnection: Connection) => { + const validationError = validateConnection?.(oldEdge, newConnection) ?? null; + if (validationError) { + edgeReconnectSuccessful.current = true; + pendingReconnectRef.current = null; + onInvalidConnection?.(validationError); + return; + } + edgeReconnectSuccessful.current = true; pendingReconnectRef.current = { oldEdge, newConnection }; setEdges((currentEdges) => reconnectEdge(oldEdge, newConnection, currentEdges)); }, - [edgeReconnectSuccessful, setEdges], + [edgeReconnectSuccessful, onInvalidConnection, setEdges, validateConnection], ); const onReconnectEnd = useCallback( diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 513c888..0d034de 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -65,6 +65,11 @@ import { import { api } from "@/convex/_generated/api"; import type { Doc, Id } from "@/convex/_generated/dataModel"; import { authClient } from "@/lib/auth-client"; +import { + isAdjustmentNodeType, + isCanvasNodeType, + type CanvasNodeType, +} from "@/lib/canvas-node-types"; import { nodeTypes } from "./node-types"; import { @@ -157,6 +162,54 @@ function hasStorageId(node: Doc<"nodes">): boolean { return typeof data?.storageId === "string" && data.storageId.length > 0; } +const ADJUSTMENT_ALLOWED_SOURCE_TYPES = new Set([ + "image", + "asset", + "ai-image", + "curves", + "color-adjust", + "light-adjust", + "detail-adjust", +]); + +const ADJUSTMENT_DISALLOWED_TARGET_TYPES = new Set(["prompt", "ai-image"]); + +function validateCanvasConnection( + connection: Connection, + nodes: RFNode[], + edges: RFEdge[], + edgeToReplaceId?: string, +): string | null { + if (!connection.source || !connection.target) return "Unvollstaendige Verbindung."; + if (connection.source === connection.target) return "Node kann nicht mit sich selbst verbunden werden."; + + const sourceNode = nodes.find((node) => node.id === connection.source); + const targetNode = nodes.find((node) => node.id === connection.target); + if (!sourceNode || !targetNode) return "Verbindung enthaelt unbekannte Nodes."; + + const sourceType = sourceNode.type ?? ""; + const targetType = targetNode.type ?? ""; + + if (isAdjustmentNodeType(targetType)) { + if (!ADJUSTMENT_ALLOWED_SOURCE_TYPES.has(sourceType)) { + return "Adjustment-Nodes akzeptieren nur Bild-, Asset-, KI-Bild- oder Adjustment-Input."; + } + + const incomingCount = edges.filter( + (edge) => edge.target === connection.target && edge.id !== edgeToReplaceId, + ).length; + if (incomingCount >= 1) { + return "Adjustment-Nodes erlauben genau eine eingehende Verbindung."; + } + } + + if (isAdjustmentNodeType(sourceType) && ADJUSTMENT_DISALLOWED_TARGET_TYPES.has(targetType)) { + return "Adjustment-Ausgaben koennen nicht an Prompt- oder KI-Bild-Nodes angeschlossen werden."; + } + + return null; +} + function CanvasInner({ canvasId }: CanvasInnerProps) { const t = useTranslations('toasts'); const { screenToFlowPosition } = useReactFlow(); @@ -899,7 +952,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { try { if (op.type === "createNode") { - const realId = await createNodeRaw(op.payload); + const realId = await createNodeRaw( + op.payload as Parameters[0], + ); await remapOptimisticNodeLocally(op.payload.clientRequestId, realId); await syncPendingMoveForClientRequestRef.current( op.payload.clientRequestId, @@ -907,7 +962,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ); setEdgeSyncNonce((value) => value + 1); } else if (op.type === "createNodeWithEdgeFromSource") { - const realId = await createNodeWithEdgeFromSourceRaw(op.payload); + const realId = await createNodeWithEdgeFromSourceRaw( + op.payload as Parameters[0], + ); await remapOptimisticNodeLocally(op.payload.clientRequestId, realId); await syncPendingMoveForClientRequestRef.current( op.payload.clientRequestId, @@ -915,7 +972,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ); setEdgeSyncNonce((value) => value + 1); } else if (op.type === "createNodeWithEdgeToTarget") { - const realId = await createNodeWithEdgeToTargetRaw(op.payload); + const realId = await createNodeWithEdgeToTargetRaw( + op.payload as Parameters[0], + ); await remapOptimisticNodeLocally(op.payload.clientRequestId, realId); await syncPendingMoveForClientRequestRef.current( op.payload.clientRequestId, @@ -923,7 +982,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ); setEdgeSyncNonce((value) => value + 1); } else if (op.type === "createNodeWithEdgeSplit") { - const realId = await createNodeWithEdgeSplitRaw(op.payload); + const realId = await createNodeWithEdgeSplitRaw( + op.payload as Parameters[0], + ); await remapOptimisticNodeLocally(op.payload.clientRequestId, realId); await syncPendingMoveForClientRequestRef.current( op.payload.clientRequestId, @@ -1625,6 +1686,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { setEdges, runCreateEdgeMutation, runRemoveEdgeMutation, + validateConnection: (oldEdge, nextConnection) => + validateCanvasConnection(nextConnection, nodes, edges, oldEdge.id), + onInvalidConnection: (message) => { + toast.warning("Verbindung abgelehnt", message); + }, }); // ─── Convex → Lokaler State Sync ────────────────────────────── @@ -2262,17 +2328,23 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // ─── Neue Verbindung → Convex Edge ──────────────────────────── const onConnect = useCallback( (connection: Connection) => { - if (connection.source && connection.target) { - void runCreateEdgeMutation({ - canvasId, - sourceNodeId: connection.source as Id<"nodes">, - targetNodeId: connection.target as Id<"nodes">, - sourceHandle: connection.sourceHandle ?? undefined, - targetHandle: connection.targetHandle ?? undefined, - }); + const validationError = validateCanvasConnection(connection, nodes, edges); + if (validationError) { + toast.warning("Verbindung abgelehnt", validationError); + return; } + + if (!connection.source || !connection.target) return; + + void runCreateEdgeMutation({ + canvasId, + sourceNodeId: connection.source as Id<"nodes">, + targetNodeId: connection.target as Id<"nodes">, + sourceHandle: connection.sourceHandle ?? undefined, + targetHandle: connection.targetHandle ?? undefined, + }); }, - [canvasId, runCreateEdgeMutation], + [canvasId, edges, nodes, runCreateEdgeMutation], ); const onConnectEnd = useCallback( @@ -2477,19 +2549,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { } // Support both plain type string (sidebar) and JSON payload (browser panels) - let nodeType: string; + let nodeType: CanvasNodeType | null = null; let payloadData: Record | undefined; try { const parsed = JSON.parse(rawData); - if (typeof parsed === "object" && parsed.type) { - nodeType = parsed.type; + if ( + typeof parsed === "object" && + parsed !== null && + typeof (parsed as { type?: unknown }).type === "string" && + isCanvasNodeType((parsed as { type: string }).type) + ) { + nodeType = (parsed as { type: CanvasNodeType }).type; payloadData = parsed.data; - } else { - nodeType = rawData; } } catch { - nodeType = rawData; + if (isCanvasNodeType(rawData)) { + nodeType = rawData; + } + } + + if (!nodeType) { + toast.warning("Node-Typ nicht verfuegbar", "Unbekannter Node konnte nicht erstellt werden."); + return; } const position = screenToFlowPosition({ @@ -2526,6 +2608,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }, [ screenToFlowPosition, + t, canvasId, generateUploadUrl, isSyncOnline, diff --git a/components/canvas/node-types.ts b/components/canvas/node-types.ts index 93cd25e..3812b53 100644 --- a/components/canvas/node-types.ts +++ b/components/canvas/node-types.ts @@ -8,6 +8,11 @@ import NoteNode from "./nodes/note-node"; import CompareNode from "./nodes/compare-node"; import AssetNode from "./nodes/asset-node"; import VideoNode from "./nodes/video-node"; +import CurvesNode from "./nodes/curves-node"; +import ColorAdjustNode from "./nodes/color-adjust-node"; +import LightAdjustNode from "./nodes/light-adjust-node"; +import DetailAdjustNode from "./nodes/detail-adjust-node"; +import RenderNode from "./nodes/render-node"; /** * Node-Type-Map für React Flow. @@ -27,4 +32,9 @@ export const nodeTypes = { compare: CompareNode, asset: AssetNode, video: VideoNode, + curves: CurvesNode, + "color-adjust": ColorAdjustNode, + "light-adjust": LightAdjustNode, + "detail-adjust": DetailAdjustNode, + render: RenderNode, } as const; diff --git a/components/canvas/nodes/adjustment-controls.tsx b/components/canvas/nodes/adjustment-controls.tsx new file mode 100644 index 0000000..f355e9b --- /dev/null +++ b/components/canvas/nodes/adjustment-controls.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Slider } from "@/components/ui/slider"; + +export function SliderRow({ + label, + value, + min, + max, + step = 1, + onChange, +}: { + label: string; + value: number; + min: number; + max: number; + step?: number; + onChange: (nextValue: number) => void; +}) { + return ( +
+
+ {label} + {value.toFixed(step < 1 ? 2 : 0)} +
+ { + onChange(values[0] ?? value); + }} + className="nodrag nowheel" + /> +
+ ); +} diff --git a/components/canvas/nodes/adjustment-preview.tsx b/components/canvas/nodes/adjustment-preview.tsx new file mode 100644 index 0000000..c760daa --- /dev/null +++ b/components/canvas/nodes/adjustment-preview.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useMemo } from "react"; +import { useStore, type Node } from "@xyflow/react"; + +import { usePipelinePreview } from "@/hooks/use-pipeline-preview"; +import { collectPipeline, getSourceImage, type PipelineStep } from "@/lib/image-pipeline/contracts"; + +const PREVIEW_PIPELINE_TYPES = new Set([ + "curves", + "color-adjust", + "light-adjust", + "detail-adjust", +]); + +function resolveNodeImageUrl(node: Node): string | null { + const data = (node.data ?? {}) as Record; + const directUrl = typeof data.url === "string" ? data.url : null; + if (directUrl && directUrl.length > 0) { + return directUrl; + } + const previewUrl = typeof data.previewUrl === "string" ? data.previewUrl : null; + if (previewUrl && previewUrl.length > 0) { + return previewUrl; + } + return null; +} + +function compactHistogram(values: readonly number[], points = 64): number[] { + if (points <= 0) { + return []; + } + + if (values.length === 0) { + return Array.from({ length: points }, () => 0); + } + + const bucket = values.length / points; + const compacted: number[] = []; + for (let pointIndex = 0; pointIndex < points; pointIndex += 1) { + let sum = 0; + const start = Math.floor(pointIndex * bucket); + const end = Math.min(values.length, Math.floor((pointIndex + 1) * bucket) || start + 1); + for (let index = start; index < end; index += 1) { + sum += values[index] ?? 0; + } + compacted.push(sum); + } + return compacted; +} + +function histogramPolyline(values: readonly number[], maxValue: number, width: number, height: number): string { + if (values.length === 0) { + return ""; + } + + const divisor = Math.max(1, values.length - 1); + return values + .map((value, index) => { + const x = (index / divisor) * width; + const normalized = maxValue > 0 ? value / maxValue : 0; + const y = height - normalized * height; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(" "); +} + +export default function AdjustmentPreview({ + nodeId, + nodeWidth, + currentType, + currentParams, +}: { + nodeId: string; + nodeWidth: number; + currentType: string; + currentParams: unknown; +}) { + const nodes = useStore((state) => state.nodes); + const edges = useStore((state) => state.edges); + + const pipelineNodes = useMemo( + () => nodes.map((node) => ({ id: node.id, type: node.type ?? "", data: node.data })), + [nodes], + ); + const pipelineEdges = useMemo( + () => edges.map((edge) => ({ source: edge.source, target: edge.target })), + [edges], + ); + + const sourceUrl = useMemo( + () => + getSourceImage({ + nodeId, + 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; + }, + }), + [nodeId, nodes, pipelineEdges, pipelineNodes], + ); + + const steps = useMemo(() => { + const collected = collectPipeline({ + nodeId, + nodes: pipelineNodes, + edges: pipelineEdges, + isPipelineNode: (node) => PREVIEW_PIPELINE_TYPES.has(node.type ?? ""), + }); + + return collected.map((step) => { + if (step.nodeId === nodeId && step.type === currentType) { + return { + ...step, + params: currentParams, + } as PipelineStep; + } + return step as PipelineStep; + }); + }, [currentParams, currentType, nodeId, pipelineEdges, pipelineNodes]); + + const { canvasRef, histogram, isRendering, hasSource, previewAspectRatio, error } = + usePipelinePreview({ + sourceUrl, + steps, + nodeWidth, + }); + + const histogramSeries = useMemo(() => { + const red = compactHistogram(histogram.red, 64); + const green = compactHistogram(histogram.green, 64); + const blue = compactHistogram(histogram.blue, 64); + const rgb = compactHistogram(histogram.rgb, 64); + const max = Math.max(1, ...red, ...green, ...blue, ...rgb); + return { red, green, blue, rgb, max }; + }, [histogram.blue, histogram.green, histogram.red, histogram.rgb]); + + const histogramPolylines = useMemo(() => { + const width = 96; + const height = 44; + return { + red: histogramPolyline(histogramSeries.red, histogramSeries.max, width, height), + green: histogramPolyline(histogramSeries.green, histogramSeries.max, width, height), + blue: histogramPolyline(histogramSeries.blue, histogramSeries.max, width, height), + rgb: histogramPolyline(histogramSeries.rgb, histogramSeries.max, width, height), + }; + }, [histogramSeries.blue, histogramSeries.green, histogramSeries.max, histogramSeries.red, histogramSeries.rgb]); + + return ( +
+
+ {!hasSource ? ( +
+ Verbinde eine Bild-, Asset- oder KI-Bild-Node fuer Live-Preview. +
+ ) : null} + {hasSource ? ( + + ) : null} + {isRendering ? ( +
+ Rendering... +
+ ) : null} +
+ + + + + + +
+
+ {error ?

{error}

: null} +
+ ); +} diff --git a/components/canvas/nodes/base-node-wrapper.tsx b/components/canvas/nodes/base-node-wrapper.tsx index 066f666..17fe358 100644 --- a/components/canvas/nodes/base-node-wrapper.tsx +++ b/components/canvas/nodes/base-node-wrapper.tsx @@ -11,6 +11,7 @@ import { } from "@xyflow/react"; import { Trash2, Copy } from "lucide-react"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; +import { isCanvasNodeType } from "@/lib/canvas-node-types"; import { NodeErrorBoundary } from "./node-error-boundary"; interface ResizeConfig { @@ -38,6 +39,11 @@ const RESIZE_CONFIGS: Record = { "ai-image": { minWidth: 200, minHeight: 208, keepAspectRatio: false }, compare: { minWidth: 300, minHeight: 200 }, prompt: { minWidth: 260, minHeight: 220 }, + curves: { minWidth: 240, minHeight: 320 }, + "color-adjust": { minWidth: 240, minHeight: 360 }, + "light-adjust": { minWidth: 240, minHeight: 360 }, + "detail-adjust": { minWidth: 240, minHeight: 360 }, + render: { minWidth: 260, minHeight: 300, keepAspectRatio: true }, text: { minWidth: 220, minHeight: 90 }, note: { minWidth: 200, minHeight: 90 }, }; @@ -58,6 +64,19 @@ const INTERNAL_FIELDS = new Set([ "retryCount", "url", "canvasId", + "lastRenderedAt", + "lastRenderedHash", + "lastRenderWidth", + "lastRenderHeight", + "lastRenderFormat", + "lastRenderMimeType", + "lastRenderSizeBytes", + "lastRenderQuality", + "lastRenderSourceWidth", + "lastRenderSourceHeight", + "lastRenderWasSizeClamped", + "lastRenderError", + "lastRenderErrorHash", ]); function NodeToolbarActions({ @@ -128,7 +147,10 @@ function NodeToolbarActions({ // Fire-and-forget: optimistic update makes the duplicate appear instantly void createNodeWithIntersection({ - type: node.type ?? "text", + type: + typeof node.type === "string" && isCanvasNodeType(node.type) + ? node.type + : "text", position: { x: originalPosition.x + 50, y: originalPosition.y + 50, @@ -213,6 +235,7 @@ export default function BaseNodeWrapper({ analyzing: "border-yellow-400 animate-pulse", clarifying: "border-amber-400", executing: "border-yellow-400 animate-pulse", + rendering: "border-yellow-400 animate-pulse", done: "border-green-500", error: "border-red-500", }; diff --git a/components/canvas/nodes/color-adjust-node.tsx b/components/canvas/nodes/color-adjust-node.tsx new file mode 100644 index 0000000..c28770e --- /dev/null +++ b/components/canvas/nodes/color-adjust-node.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; +import { useMutation } from "convex/react"; +import { Palette } from "lucide-react"; + +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; +import { useAuthQuery } from "@/hooks/use-auth-query"; +import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; +import { SliderRow } from "@/components/canvas/nodes/adjustment-controls"; +import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper"; +import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview"; +import { + cloneAdjustmentData, + DEFAULT_COLOR_ADJUST_DATA, + normalizeColorAdjustData, + type ColorAdjustData, +} from "@/lib/image-pipeline/adjustment-types"; +import { COLOR_PRESETS } from "@/lib/image-pipeline/presets"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { toast } from "@/lib/toast"; + +type ColorAdjustNodeData = ColorAdjustData & { + _status?: string; + _statusMessage?: string; +}; + +export type ColorAdjustNodeType = Node; + +type PresetDoc = { + _id: Id<"adjustmentPresets">; + name: string; + params: unknown; +}; + +export default function ColorAdjustNode({ id, data, selected, width }: NodeProps) { + const { queueNodeDataUpdate } = useCanvasSync(); + const savePreset = useMutation(api.presets.save); + const userPresets = (useAuthQuery(api.presets.list, { nodeType: "color-adjust" }) ?? []) as PresetDoc[]; + + const [localData, setLocalData] = useState(() => + normalizeColorAdjustData({ ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), ...data }), + ); + const [presetSelection, setPresetSelection] = useState("custom"); + const localDataRef = useRef(localData); + + useEffect(() => { + localDataRef.current = localData; + }, [localData]); + + useEffect(() => { + const timer = window.setTimeout(() => { + setLocalData( + normalizeColorAdjustData({ ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), ...data }), + ); + }, 0); + return () => { + window.clearTimeout(timer); + }; + }, [data]); + + const queueSave = useDebouncedCallback(() => { + void queueNodeDataUpdate({ + nodeId: id as Id<"nodes">, + data: localDataRef.current, + }); + }, 16); + + const updateData = (updater: (draft: ColorAdjustData) => ColorAdjustData) => { + setPresetSelection("custom"); + setLocalData((current) => { + const next = updater(current); + localDataRef.current = next; + queueSave(); + return next; + }); + }; + + const builtinOptions = useMemo(() => Object.entries(COLOR_PRESETS), []); + + const applyPresetValue = (value: string) => { + if (value === "custom") { + setPresetSelection("custom"); + return; + } + if (value.startsWith("builtin:")) { + const key = value.replace("builtin:", ""); + const preset = COLOR_PRESETS[key]; + if (!preset) return; + const next = cloneAdjustmentData(preset); + setPresetSelection(value); + setLocalData(next); + localDataRef.current = next; + queueSave(); + return; + } + if (value.startsWith("user:")) { + const presetId = value.replace("user:", "") as Id<"adjustmentPresets">; + const preset = userPresets.find((entry) => entry._id === presetId); + if (!preset) return; + const next = normalizeColorAdjustData(preset.params); + setPresetSelection(value); + setLocalData(next); + localDataRef.current = next; + queueSave(); + } + }; + + const handleSavePreset = async () => { + const name = window.prompt("Preset-Name"); + if (!name) return; + await savePreset({ + name, + nodeType: "color-adjust", + params: localData, + }); + toast.success("Preset gespeichert"); + }; + + return ( + + + +
+
+ + Farbe +
+ +
+ + +
+ + + +
+ + updateData((current) => ({ + ...current, + hsl: { ...current.hsl, hue: value }, + preset: null, + })) + } + /> + + updateData((current) => ({ + ...current, + hsl: { ...current.hsl, saturation: value }, + preset: null, + })) + } + /> + + updateData((current) => ({ + ...current, + hsl: { ...current.hsl, luminance: value }, + preset: null, + })) + } + /> + + updateData((current) => ({ ...current, temperature: value, preset: null })) + } + /> + + updateData((current) => ({ ...current, tint: value, preset: null })) + } + /> + + updateData((current) => ({ ...current, vibrance: value, preset: null })) + } + /> +
+
+ + +
+ ); +} diff --git a/components/canvas/nodes/curves-node.tsx b/components/canvas/nodes/curves-node.tsx new file mode 100644 index 0000000..963ea14 --- /dev/null +++ b/components/canvas/nodes/curves-node.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; +import { useMutation } from "convex/react"; +import { TrendingUp } from "lucide-react"; + +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; +import { useAuthQuery } from "@/hooks/use-auth-query"; +import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; +import { SliderRow } from "@/components/canvas/nodes/adjustment-controls"; +import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper"; +import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview"; +import { + cloneAdjustmentData, + DEFAULT_CURVES_DATA, + normalizeCurvesData, + type CurvesData, +} from "@/lib/image-pipeline/adjustment-types"; +import { CURVE_PRESETS } from "@/lib/image-pipeline/presets"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { toast } from "@/lib/toast"; + +type CurvesNodeData = CurvesData & { + _status?: string; + _statusMessage?: string; +}; + +export type CurvesNodeType = Node; + +type PresetDoc = { + _id: Id<"adjustmentPresets">; + name: string; + params: unknown; +}; + +export default function CurvesNode({ id, data, selected, width }: NodeProps) { + const { queueNodeDataUpdate } = useCanvasSync(); + const savePreset = useMutation(api.presets.save); + const userPresets = (useAuthQuery(api.presets.list, { nodeType: "curves" }) ?? []) as PresetDoc[]; + + const [localData, setLocalData] = useState(() => + normalizeCurvesData({ ...cloneAdjustmentData(DEFAULT_CURVES_DATA), ...data }), + ); + const [presetSelection, setPresetSelection] = useState("custom"); + const localDataRef = useRef(localData); + + useEffect(() => { + localDataRef.current = localData; + }, [localData]); + + useEffect(() => { + const timer = window.setTimeout(() => { + setLocalData( + normalizeCurvesData({ ...cloneAdjustmentData(DEFAULT_CURVES_DATA), ...data }), + ); + }, 0); + return () => { + window.clearTimeout(timer); + }; + }, [data]); + + const queueSave = useDebouncedCallback(() => { + void queueNodeDataUpdate({ + nodeId: id as Id<"nodes">, + data: localDataRef.current, + }); + }, 16); + + const updateData = (updater: (draft: CurvesData) => CurvesData) => { + setPresetSelection("custom"); + setLocalData((current) => { + const next = updater(current); + localDataRef.current = next; + queueSave(); + return next; + }); + }; + + const builtinOptions = useMemo(() => Object.entries(CURVE_PRESETS), []); + + const applyPresetValue = (value: string) => { + if (value === "custom") { + setPresetSelection("custom"); + return; + } + + if (value.startsWith("builtin:")) { + const key = value.replace("builtin:", ""); + const preset = CURVE_PRESETS[key]; + if (!preset) return; + setPresetSelection(value); + setLocalData(cloneAdjustmentData(preset)); + localDataRef.current = cloneAdjustmentData(preset); + queueSave(); + return; + } + + if (value.startsWith("user:")) { + const presetId = value.replace("user:", "") as Id<"adjustmentPresets">; + const preset = userPresets.find((entry) => entry._id === presetId); + if (!preset) return; + const next = normalizeCurvesData(preset.params); + setPresetSelection(value); + setLocalData(next); + localDataRef.current = next; + queueSave(); + } + }; + + const handleSavePreset = async () => { + const name = window.prompt("Preset-Name"); + if (!name) return; + await savePreset({ + name, + nodeType: "curves", + params: localData, + }); + toast.success("Preset gespeichert"); + }; + + return ( + + + +
+
+ + Kurven +
+ +
+ + +
+ + + +
+ + updateData((current) => ({ + ...current, + levels: { ...current.levels, blackPoint: value }, + preset: null, + })) + } + /> + + updateData((current) => ({ + ...current, + levels: { ...current.levels, whitePoint: value }, + preset: null, + })) + } + /> + + updateData((current) => ({ + ...current, + levels: { ...current.levels, gamma: value }, + preset: null, + })) + } + /> +
+
+ + +
+ ); +} diff --git a/components/canvas/nodes/detail-adjust-node.tsx b/components/canvas/nodes/detail-adjust-node.tsx new file mode 100644 index 0000000..6fcca39 --- /dev/null +++ b/components/canvas/nodes/detail-adjust-node.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; +import { useMutation } from "convex/react"; +import { Focus } from "lucide-react"; + +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; +import { useAuthQuery } from "@/hooks/use-auth-query"; +import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; +import { SliderRow } from "@/components/canvas/nodes/adjustment-controls"; +import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper"; +import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview"; +import { + cloneAdjustmentData, + DEFAULT_DETAIL_ADJUST_DATA, + normalizeDetailAdjustData, + type DetailAdjustData, +} from "@/lib/image-pipeline/adjustment-types"; +import { DETAIL_PRESETS } from "@/lib/image-pipeline/presets"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { toast } from "@/lib/toast"; + +type DetailAdjustNodeData = DetailAdjustData & { + _status?: string; + _statusMessage?: string; +}; + +export type DetailAdjustNodeType = Node; + +type PresetDoc = { + _id: Id<"adjustmentPresets">; + name: string; + params: unknown; +}; + +export default function DetailAdjustNode({ id, data, selected, width }: NodeProps) { + const { queueNodeDataUpdate } = useCanvasSync(); + const savePreset = useMutation(api.presets.save); + const userPresets = (useAuthQuery(api.presets.list, { nodeType: "detail-adjust" }) ?? []) as PresetDoc[]; + + const [localData, setLocalData] = useState(() => + normalizeDetailAdjustData({ ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), ...data }), + ); + const [presetSelection, setPresetSelection] = useState("custom"); + const localDataRef = useRef(localData); + + useEffect(() => { + localDataRef.current = localData; + }, [localData]); + + useEffect(() => { + const timer = window.setTimeout(() => { + setLocalData( + normalizeDetailAdjustData({ ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), ...data }), + ); + }, 0); + return () => { + window.clearTimeout(timer); + }; + }, [data]); + + const queueSave = useDebouncedCallback(() => { + void queueNodeDataUpdate({ + nodeId: id as Id<"nodes">, + data: localDataRef.current, + }); + }, 16); + + const updateData = (updater: (draft: DetailAdjustData) => DetailAdjustData) => { + setPresetSelection("custom"); + setLocalData((current) => { + const next = updater(current); + localDataRef.current = next; + queueSave(); + return next; + }); + }; + + const builtinOptions = useMemo(() => Object.entries(DETAIL_PRESETS), []); + + const applyPresetValue = (value: string) => { + if (value === "custom") { + setPresetSelection("custom"); + return; + } + if (value.startsWith("builtin:")) { + const key = value.replace("builtin:", ""); + const preset = DETAIL_PRESETS[key]; + if (!preset) return; + const next = cloneAdjustmentData(preset); + setPresetSelection(value); + setLocalData(next); + localDataRef.current = next; + queueSave(); + return; + } + if (value.startsWith("user:")) { + const presetId = value.replace("user:", "") as Id<"adjustmentPresets">; + const preset = userPresets.find((entry) => entry._id === presetId); + if (!preset) return; + const next = normalizeDetailAdjustData(preset.params); + setPresetSelection(value); + setLocalData(next); + localDataRef.current = next; + queueSave(); + } + }; + + const handleSavePreset = async () => { + const name = window.prompt("Preset-Name"); + if (!name) return; + await savePreset({ + name, + nodeType: "detail-adjust", + params: localData, + }); + toast.success("Preset gespeichert"); + }; + + return ( + + + +
+
+ + Detail +
+ +
+ + +
+ + + +
+ updateData((current) => ({ ...current, sharpen: { ...current.sharpen, amount: value }, preset: null }))} /> + updateData((current) => ({ ...current, sharpen: { ...current.sharpen, radius: value }, preset: null }))} /> + updateData((current) => ({ ...current, sharpen: { ...current.sharpen, threshold: value }, preset: null }))} /> + updateData((current) => ({ ...current, clarity: value, preset: null }))} /> + updateData((current) => ({ ...current, denoise: { ...current.denoise, luminance: value }, preset: null }))} /> + updateData((current) => ({ ...current, denoise: { ...current.denoise, color: value }, preset: null }))} /> + updateData((current) => ({ ...current, grain: { ...current.grain, amount: value }, preset: null }))} /> +
+
+ + +
+ ); +} diff --git a/components/canvas/nodes/light-adjust-node.tsx b/components/canvas/nodes/light-adjust-node.tsx new file mode 100644 index 0000000..1cd28de --- /dev/null +++ b/components/canvas/nodes/light-adjust-node.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; +import { useMutation } from "convex/react"; +import { Sun } from "lucide-react"; + +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; +import { useAuthQuery } from "@/hooks/use-auth-query"; +import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; +import { SliderRow } from "@/components/canvas/nodes/adjustment-controls"; +import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper"; +import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview"; +import { + cloneAdjustmentData, + DEFAULT_LIGHT_ADJUST_DATA, + normalizeLightAdjustData, + type LightAdjustData, +} from "@/lib/image-pipeline/adjustment-types"; +import { LIGHT_PRESETS } from "@/lib/image-pipeline/presets"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { toast } from "@/lib/toast"; + +type LightAdjustNodeData = LightAdjustData & { + _status?: string; + _statusMessage?: string; +}; + +export type LightAdjustNodeType = Node; + +type PresetDoc = { + _id: Id<"adjustmentPresets">; + name: string; + params: unknown; +}; + +export default function LightAdjustNode({ id, data, selected, width }: NodeProps) { + const { queueNodeDataUpdate } = useCanvasSync(); + const savePreset = useMutation(api.presets.save); + const userPresets = (useAuthQuery(api.presets.list, { nodeType: "light-adjust" }) ?? []) as PresetDoc[]; + + const [localData, setLocalData] = useState(() => + normalizeLightAdjustData({ ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), ...data }), + ); + const [presetSelection, setPresetSelection] = useState("custom"); + const localDataRef = useRef(localData); + + useEffect(() => { + localDataRef.current = localData; + }, [localData]); + + useEffect(() => { + const timer = window.setTimeout(() => { + setLocalData( + normalizeLightAdjustData({ ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), ...data }), + ); + }, 0); + return () => { + window.clearTimeout(timer); + }; + }, [data]); + + const queueSave = useDebouncedCallback(() => { + void queueNodeDataUpdate({ + nodeId: id as Id<"nodes">, + data: localDataRef.current, + }); + }, 16); + + const updateData = (updater: (draft: LightAdjustData) => LightAdjustData) => { + setPresetSelection("custom"); + setLocalData((current) => { + const next = updater(current); + localDataRef.current = next; + queueSave(); + return next; + }); + }; + + const builtinOptions = useMemo(() => Object.entries(LIGHT_PRESETS), []); + + const applyPresetValue = (value: string) => { + if (value === "custom") { + setPresetSelection("custom"); + return; + } + if (value.startsWith("builtin:")) { + const key = value.replace("builtin:", ""); + const preset = LIGHT_PRESETS[key]; + if (!preset) return; + const next = cloneAdjustmentData(preset); + setPresetSelection(value); + setLocalData(next); + localDataRef.current = next; + queueSave(); + return; + } + if (value.startsWith("user:")) { + const presetId = value.replace("user:", "") as Id<"adjustmentPresets">; + const preset = userPresets.find((entry) => entry._id === presetId); + if (!preset) return; + const next = normalizeLightAdjustData(preset.params); + setPresetSelection(value); + setLocalData(next); + localDataRef.current = next; + queueSave(); + } + }; + + const handleSavePreset = async () => { + const name = window.prompt("Preset-Name"); + if (!name) return; + await savePreset({ + name, + nodeType: "light-adjust", + params: localData, + }); + toast.success("Preset gespeichert"); + }; + + return ( + + + +
+
+ + Licht +
+ +
+ + +
+ + + +
+ updateData((current) => ({ ...current, brightness: value, preset: null }))} /> + updateData((current) => ({ ...current, contrast: value, preset: null }))} /> + updateData((current) => ({ ...current, exposure: value, preset: null }))} /> + updateData((current) => ({ ...current, highlights: value, preset: null }))} /> + updateData((current) => ({ ...current, shadows: value, preset: null }))} /> + updateData((current) => ({ ...current, whites: value, preset: null }))} /> + updateData((current) => ({ ...current, blacks: value, preset: null }))} /> + updateData((current) => ({ ...current, vignette: { ...current.vignette, amount: value }, preset: null }))} /> +
+
+ + +
+ ); +} diff --git a/components/canvas/nodes/render-node.tsx b/components/canvas/nodes/render-node.tsx new file mode 100644 index 0000000..0ec6c98 --- /dev/null +++ b/components/canvas/nodes/render-node.tsx @@ -0,0 +1,1213 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Handle, Position, useStore, type Node, type NodeProps } from "@xyflow/react"; +import { AlertCircle, ArrowDown, CheckCircle2, CloudUpload, Loader2 } from "lucide-react"; +import { useConvex, 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 { resolveMediaAspectRatio } from "@/lib/canvas-utils"; +import { parseAspectRatioString } from "@/lib/image-formats"; +import { + collectPipeline, + getSourceImage, + hashPipeline, + type PipelineStep, +} from "@/lib/image-pipeline/contracts"; +import { bridge } from "@/lib/image-pipeline/bridge"; +import type { Id } from "@/convex/_generated/dataModel"; + +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; +}; + +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; + +const RENDER_PIPELINE_TYPES = new Set(["curves", "color-adjust", "light-adjust", "detail-adjust"]); + +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 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; + } + 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 next; +} + +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; +} + +function compactHistogram(values: readonly number[], points = 64): number[] { + if (points <= 0) { + return []; + } + + if (values.length === 0) { + return Array.from({ length: points }, () => 0); + } + + const bucket = values.length / points; + const compacted: number[] = []; + for (let pointIndex = 0; pointIndex < points; pointIndex += 1) { + let sum = 0; + const start = Math.floor(pointIndex * bucket); + const end = Math.min(values.length, Math.floor((pointIndex + 1) * bucket) || start + 1); + for (let index = start; index < end; index += 1) { + sum += values[index] ?? 0; + } + compacted.push(sum); + } + return compacted; +} + +function histogramPolyline(values: readonly number[], maxValue: number, width: number, height: number): string { + if (values.length === 0) { + return ""; + } + + const divisor = Math.max(1, values.length - 1); + return values + .map((value, index) => { + const x = (index / divisor) * width; + const normalized = maxValue > 0 ? value / maxValue : 0; + const y = height - normalized * height; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(" "); +} + +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 convex = useConvex(); + const generateUploadUrl = useMutation(api.storage.generateUploadUrl); + const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); + const nodes = useStore((state) => state.nodes); + const edges = useStore((state) => state.edges); + + const [localData, setLocalData] = useState(() => + sanitizeRenderData(data), + ); + const [isRendering, setIsRendering] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const localDataRef = useRef(localData); + const renderRunIdRef = useRef(0); + const menuButtonRef = useRef(null); + const menuPanelRef = useRef(null); + const lastAppliedAspectRatioRef = useRef(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 pipelineNodes = useMemo( + () => nodes.map((node) => ({ id: node.id, type: node.type ?? "", data: node.data })), + [nodes], + ); + + const pipelineEdges = useMemo( + () => edges.map((edge) => ({ source: edge.source, target: edge.target })), + [edges], + ); + + const sourceUrl = useMemo( + () => + getSourceImage({ + 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], + ); + + const sourceNode = useMemo( + () => + getSourceImage({ + nodeId: id, + nodes: pipelineNodes, + edges: pipelineEdges, + isSourceNode: (node) => + node.type === "image" || node.type === "ai-image" || node.type === "asset", + getSourceImageFromNode: (node) => node as SourceNodeDescriptor, + }), + [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 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) return null; + return hashPipeline({ sourceUrl, render: renderFingerprint }, steps); + }, [renderFingerprint, 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; + const previewNodeWidth = Math.max(260, Math.round(width ?? 320)); + + const { + canvasRef, + histogram, + isRendering: isPreviewRendering, + previewAspectRatio, + error: previewError, + } = usePipelinePreview({ + sourceUrl, + steps, + nodeWidth: previewNodeWidth, + previewScale: 0.7, + maxPreviewWidth: 960, + }); + + const targetAspectRatio = useMemo(() => { + 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; + }, [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 histogramSeries = useMemo(() => { + const red = compactHistogram(histogram.red, 64); + const green = compactHistogram(histogram.green, 64); + const blue = compactHistogram(histogram.blue, 64); + const rgb = compactHistogram(histogram.rgb, 64); + const max = Math.max(1, ...red, ...green, ...blue, ...rgb); + return { red, green, blue, rgb, max }; + }, [histogram.blue, histogram.green, histogram.red, histogram.rgb]); + + const histogramPolylines = useMemo(() => { + const width = 96; + const height = 44; + return { + red: histogramPolyline(histogramSeries.red, histogramSeries.max, width, height), + green: histogramPolyline(histogramSeries.green, histogramSeries.max, width, height), + blue: histogramPolyline(histogramSeries.blue, histogramSeries.max, width, height), + rgb: histogramPolyline(histogramSeries.rgb, histogramSeries.max, width, height), + }; + }, [histogramSeries.blue, histogramSeries.green, histogramSeries.max, histogramSeries.red, histogramSeries.rgb]); + + const canRender = + hasSource && + !isRendering && + !isUploading && + (localData.outputResolution !== "custom" || + (typeof localData.customWidth === "number" && typeof localData.customHeight === "number")); + const canUpload = canRender && !status.isOffline; + + 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 handleRender = async (mode: "download" | "upload") => { + if (!sourceUrl || !currentPipelineHash) return; + + if ( + localData.outputResolution === "custom" && + (localData.customWidth === undefined || localData.customHeight === undefined) + ) { + const next = { + ...localDataRef.current, + lastRenderError: "Custom width and height are required.", + lastRenderErrorHash: currentPipelineHash, + }; + await persistImmediately(next); + return; + } + + renderRunIdRef.current += 1; + const runId = renderRunIdRef.current; + setIsRendering(true); + + try { + const activeData = localDataRef.current; + const renderResult = await bridge.renderFull({ + sourceUrl, + 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, + }, + }); + + if (runId !== renderRunIdRef.current) return; + + 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, + }; + + if (mode === "download") { + await persistImmediately(renderNext); + return; + } + + if (runId !== renderRunIdRef.current) return; + setIsUploading(true); + + try { + 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; + + 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; + + try { + const refreshed = await convex.query(api.nodes.get, { nodeId: id as Id<"nodes"> }); + const refreshedData = refreshed?.data as Record | undefined; + const resolvedUrl = + typeof refreshedData?.url === "string" && refreshedData.url.length > 0 + ? refreshedData.url + : undefined; + + if (resolvedUrl && runId === renderRunIdRef.current) { + await persistImmediately({ + ...localDataRef.current, + url: resolvedUrl, + lastUploadUrl: resolvedUrl, + }); + } + } catch { + // URL-Aufloesung ist optional; storageId bleibt die persistente Referenz. + } + } catch (uploadError: unknown) { + if (runId !== renderRunIdRef.current) return; + + const message = uploadError instanceof Error ? uploadError.message : "Upload failed"; + await persistImmediately({ + ...renderNext, + lastUploadError: message, + lastUploadErrorHash: currentPipelineHash, + }); + } finally { + if (runId === renderRunIdRef.current) { + setIsUploading(false); + } + } + } catch (error: unknown) { + if (runId !== renderRunIdRef.current) return; + + const message = error instanceof Error ? error.message : "Render failed"; + const next: PersistedRenderData = { + ...localDataRef.current, + lastRenderError: message, + lastRenderErrorHash: currentPipelineHash, + }; + await persistImmediately(next); + } finally { + if (runId === renderRunIdRef.current) { + 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 ( + + + +
+
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 +
+ + + + + + +
+
+ + + + ); +} diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 248ed5e..aec5304 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -17,10 +17,12 @@ import type * as export_ from "../export.js"; import type * as freepik from "../freepik.js"; import type * as helpers from "../helpers.js"; import type * as http from "../http.js"; +import type * as node_type_validator from "../node_type_validator.js"; import type * as nodes from "../nodes.js"; import type * as openrouter from "../openrouter.js"; import type * as pexels from "../pexels.js"; import type * as polar from "../polar.js"; +import type * as presets from "../presets.js"; import type * as storage from "../storage.js"; import type * as users from "../users.js"; @@ -40,10 +42,12 @@ declare const fullApi: ApiFromModules<{ freepik: typeof freepik; helpers: typeof helpers; http: typeof http; + node_type_validator: typeof node_type_validator; nodes: typeof nodes; openrouter: typeof openrouter; pexels: typeof pexels; polar: typeof polar; + presets: typeof presets; storage: typeof storage; users: typeof users; }>; diff --git a/convex/edges.ts b/convex/edges.ts index ad6e97d..a7de8da 100644 --- a/convex/edges.ts +++ b/convex/edges.ts @@ -1,7 +1,36 @@ -import { query, mutation } from "./_generated/server"; +import { query, mutation, type MutationCtx } from "./_generated/server"; import { v } from "convex/values"; import { requireAuth } from "./helpers"; -import type { Id } from "./_generated/dataModel"; +import type { Doc, Id } from "./_generated/dataModel"; +import { isAdjustmentNodeType } from "../lib/canvas-node-types"; + +async function assertTargetAllowsIncomingEdge( + ctx: MutationCtx, + args: { + targetNodeId: Id<"nodes">; + edgeIdToIgnore?: Id<"edges">; + }, +): Promise { + const targetNode = await ctx.db.get(args.targetNodeId); + if (!targetNode) { + throw new Error("Target node not found"); + } + if (!isAdjustmentNodeType(targetNode.type)) { + return; + } + + const incomingEdges = await ctx.db + .query("edges") + .withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId)) + .collect(); + + const existingIncoming = incomingEdges.filter( + (edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore, + ); + if (existingIncoming.length >= 1) { + throw new Error("Adjustment nodes allow only one incoming edge."); + } +} // ============================================================================ // Queries @@ -89,6 +118,10 @@ export const create = mutation({ throw new Error("Cannot connect a node to itself"); } + await assertTargetAllowsIncomingEdge(ctx, { + targetNodeId: args.targetNodeId, + }); + const edgeId = await ctx.db.insert("edges", { canvasId: args.canvasId, sourceNodeId: args.sourceNodeId, diff --git a/convex/node-type-validator.ts b/convex/node_type_validator.ts similarity index 100% rename from convex/node-type-validator.ts rename to convex/node_type_validator.ts diff --git a/convex/nodes.ts b/convex/nodes.ts index 9da9aa3..0541f2e 100644 --- a/convex/nodes.ts +++ b/convex/nodes.ts @@ -3,7 +3,7 @@ import { v } from "convex/values"; import { requireAuth } from "./helpers"; import type { Doc, Id } from "./_generated/dataModel"; import { isAdjustmentNodeType } from "../lib/canvas-node-types"; -import { nodeTypeValidator } from "./node-type-validator"; +import { nodeTypeValidator } from "./node_type_validator"; // ============================================================================ // Interne Helpers @@ -60,6 +60,7 @@ const CUSTOM_RENDER_DIMENSION_MAX = 16384; const DEFAULT_RENDER_OUTPUT_RESOLUTION = "original" as const; const DEFAULT_RENDER_FORMAT = "png" as const; const DEFAULT_RENDER_JPEG_QUALITY = 90; +const ADJUSTMENT_MIN_WIDTH = 240; type RenderOutputResolution = (typeof RENDER_OUTPUT_RESOLUTIONS)[number]; type RenderFormat = (typeof RENDER_FORMATS)[number]; @@ -143,6 +144,20 @@ function parseRenderJpegQuality(value: unknown): number { return value as number; } +function parseOptionalPositiveInteger(fieldName: string, value: unknown): number { + if (!Number.isInteger(value) || (value as number) < 1) { + throw new Error(`Render data '${fieldName}' must be a positive integer.`); + } + return value as number; +} + +function parseOptionalNonNegativeInteger(fieldName: string, value: unknown): number { + if (!Number.isInteger(value) || (value as number) < 0) { + throw new Error(`Render data '${fieldName}' must be a non-negative integer.`); + } + return value as number; +} + function normalizeRenderData(data: unknown): Record { if (!isRecord(data)) { throw new Error("Render node data must be an object."); @@ -174,6 +189,151 @@ function normalizeRenderData(data: unknown): Record { normalized.lastRenderedAt = data.lastRenderedAt; } + if (data.lastRenderedHash !== undefined) { + if (typeof data.lastRenderedHash !== "string" || data.lastRenderedHash.length === 0) { + throw new Error("Render data 'lastRenderedHash' must be a non-empty string when provided."); + } + normalized.lastRenderedHash = data.lastRenderedHash; + } + + if (data.lastRenderWidth !== undefined) { + normalized.lastRenderWidth = parseOptionalPositiveInteger("lastRenderWidth", data.lastRenderWidth); + } + + if (data.lastRenderHeight !== undefined) { + normalized.lastRenderHeight = parseOptionalPositiveInteger("lastRenderHeight", data.lastRenderHeight); + } + + if (data.lastRenderFormat !== undefined) { + normalized.lastRenderFormat = parseRenderFormat(data.lastRenderFormat); + } + + if (data.lastRenderMimeType !== undefined) { + if (typeof data.lastRenderMimeType !== "string" || data.lastRenderMimeType.length === 0) { + throw new Error("Render data 'lastRenderMimeType' must be a non-empty string when provided."); + } + normalized.lastRenderMimeType = data.lastRenderMimeType; + } + + if (data.lastRenderSizeBytes !== undefined) { + normalized.lastRenderSizeBytes = parseOptionalNonNegativeInteger( + "lastRenderSizeBytes", + data.lastRenderSizeBytes, + ); + } + + if (data.lastRenderQuality !== undefined) { + if (data.lastRenderQuality !== null) { + if ( + typeof data.lastRenderQuality !== "number" || + !Number.isFinite(data.lastRenderQuality) || + data.lastRenderQuality < 0 || + data.lastRenderQuality > 1 + ) { + throw new Error("Render data 'lastRenderQuality' must be null or a number between 0 and 1."); + } + } + normalized.lastRenderQuality = data.lastRenderQuality; + } + + if (data.lastRenderSourceWidth !== undefined) { + normalized.lastRenderSourceWidth = parseOptionalPositiveInteger( + "lastRenderSourceWidth", + data.lastRenderSourceWidth, + ); + } + + if (data.lastRenderSourceHeight !== undefined) { + normalized.lastRenderSourceHeight = parseOptionalPositiveInteger( + "lastRenderSourceHeight", + data.lastRenderSourceHeight, + ); + } + + if (data.lastRenderWasSizeClamped !== undefined) { + if (typeof data.lastRenderWasSizeClamped !== "boolean") { + throw new Error("Render data 'lastRenderWasSizeClamped' must be a boolean when provided."); + } + normalized.lastRenderWasSizeClamped = data.lastRenderWasSizeClamped; + } + + if (data.lastRenderError !== undefined) { + if (typeof data.lastRenderError !== "string" || data.lastRenderError.length === 0) { + throw new Error("Render data 'lastRenderError' must be a non-empty string when provided."); + } + normalized.lastRenderError = data.lastRenderError; + } + + if (data.lastRenderErrorHash !== undefined) { + if (typeof data.lastRenderErrorHash !== "string" || data.lastRenderErrorHash.length === 0) { + throw new Error("Render data 'lastRenderErrorHash' must be a non-empty string when provided."); + } + normalized.lastRenderErrorHash = data.lastRenderErrorHash; + } + + if (data.lastUploadedAt !== undefined) { + if (typeof data.lastUploadedAt !== "number" || !Number.isFinite(data.lastUploadedAt)) { + throw new Error("Render data 'lastUploadedAt' must be a finite number."); + } + normalized.lastUploadedAt = data.lastUploadedAt; + } + + if (data.lastUploadedHash !== undefined) { + if (typeof data.lastUploadedHash !== "string" || data.lastUploadedHash.length === 0) { + throw new Error("Render data 'lastUploadedHash' must be a non-empty string when provided."); + } + normalized.lastUploadedHash = data.lastUploadedHash; + } + + if (data.lastUploadStorageId !== undefined) { + if (typeof data.lastUploadStorageId !== "string" || data.lastUploadStorageId.length === 0) { + throw new Error("Render data 'lastUploadStorageId' must be a non-empty string when provided."); + } + normalized.lastUploadStorageId = data.lastUploadStorageId; + } + + if (data.lastUploadUrl !== undefined) { + if (typeof data.lastUploadUrl !== "string" || data.lastUploadUrl.length === 0) { + throw new Error("Render data 'lastUploadUrl' must be a non-empty string when provided."); + } + normalized.lastUploadUrl = data.lastUploadUrl; + } + + if (data.lastUploadMimeType !== undefined) { + if (typeof data.lastUploadMimeType !== "string" || data.lastUploadMimeType.length === 0) { + throw new Error("Render data 'lastUploadMimeType' must be a non-empty string when provided."); + } + normalized.lastUploadMimeType = data.lastUploadMimeType; + } + + if (data.lastUploadSizeBytes !== undefined) { + normalized.lastUploadSizeBytes = parseOptionalNonNegativeInteger( + "lastUploadSizeBytes", + data.lastUploadSizeBytes, + ); + } + + if (data.lastUploadFilename !== undefined) { + if (typeof data.lastUploadFilename !== "string" || data.lastUploadFilename.length === 0) { + throw new Error("Render data 'lastUploadFilename' must be a non-empty string when provided."); + } + normalized.lastUploadFilename = data.lastUploadFilename; + } + + if (data.lastUploadError !== undefined) { + if (typeof data.lastUploadError !== "string" || data.lastUploadError.length === 0) { + throw new Error("Render data 'lastUploadError' must be a non-empty string when provided."); + } + normalized.lastUploadError = data.lastUploadError; + } + + if (data.lastUploadErrorHash !== undefined) { + if (typeof data.lastUploadErrorHash !== "string" || data.lastUploadErrorHash.length === 0) { + throw new Error("Render data 'lastUploadErrorHash' must be a non-empty string when provided."); + } + normalized.lastUploadErrorHash = data.lastUploadErrorHash; + } + if (data.storageId !== undefined) { if (typeof data.storageId !== "string" || data.storageId.length === 0) { throw new Error("Render data 'storageId' must be a non-empty string when provided."); @@ -211,6 +371,32 @@ function normalizeNodeDataForWrite( return data; } +async function assertTargetAllowsIncomingEdge( + ctx: MutationCtx, + args: { + targetNodeId: Id<"nodes">; + edgeIdToIgnore?: Id<"edges">; + }, +): Promise { + const targetNode = await ctx.db.get(args.targetNodeId); + if (!targetNode) { + throw new Error("Target node not found"); + } + + if (!isAdjustmentNodeType(targetNode.type)) { + return; + } + + const incomingEdges = await ctx.db + .query("edges") + .withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId)) + .collect(); + const existingIncoming = incomingEdges.filter((edge) => edge._id !== args.edgeIdToIgnore); + if (existingIncoming.length >= 1) { + throw new Error("Adjustment nodes allow only one incoming edge."); + } +} + async function getIdempotentNodeCreateResult( ctx: MutationCtx, args: { @@ -577,6 +763,11 @@ export const splitEdgeAtExistingNode = mutation({ targetHandle: args.newNodeTargetHandle, }); + await assertTargetAllowsIncomingEdge(ctx, { + targetNodeId: edge.targetNodeId, + edgeIdToIgnore: args.splitEdgeId, + }); + await ctx.db.insert("edges", { canvasId: args.canvasId, sourceNodeId: args.middleNodeId, @@ -733,6 +924,10 @@ export const createWithEdgeToTarget = mutation({ zIndex: args.zIndex, }); + await assertTargetAllowsIncomingEdge(ctx, { + targetNodeId: args.targetNodeId, + }); + await ctx.db.insert("edges", { canvasId: args.canvasId, sourceNodeId: nodeId, @@ -789,7 +984,11 @@ export const resize = mutation({ if (!node) return; await getCanvasOrThrow(ctx, node.canvasId, user.userId); - await ctx.db.patch(nodeId, { width, height }); + const clampedWidth = + isAdjustmentNodeType(node.type) && width < ADJUSTMENT_MIN_WIDTH + ? ADJUSTMENT_MIN_WIDTH + : width; + await ctx.db.patch(nodeId, { width: clampedWidth, height }); await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); }, }); diff --git a/convex/presets.ts b/convex/presets.ts index dbb5af1..e2fe38d 100644 --- a/convex/presets.ts +++ b/convex/presets.ts @@ -2,7 +2,7 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { requireAuth } from "./helpers"; -import { adjustmentPresetNodeTypeValidator } from "./node-type-validator"; +import { adjustmentPresetNodeTypeValidator } from "./node_type_validator"; export const list = query({ args: { diff --git a/convex/schema.ts b/convex/schema.ts index 1b97263..5c2ceb7 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -6,7 +6,7 @@ import { adjustmentPresetNodeTypeValidator, nodeTypeValidator, phase1NodeTypeValidator, -} from "./node-type-validator"; +} from "./node_type_validator"; // ============================================================================ // Node Types diff --git a/convex/storage.ts b/convex/storage.ts index e5d2815..107ea8d 100644 --- a/convex/storage.ts +++ b/convex/storage.ts @@ -87,7 +87,7 @@ async function resolveStorageUrls( } const { storageId, url } = entry; - resolved[storageId] = url; + resolved[storageId] = url ?? undefined; } } diff --git a/hooks/use-pipeline-preview.ts b/hooks/use-pipeline-preview.ts new file mode 100644 index 0000000..174a513 --- /dev/null +++ b/hooks/use-pipeline-preview.ts @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; + +import { hashPipeline, type PipelineStep } from "@/lib/image-pipeline/contracts"; +import { emptyHistogram, type HistogramData } from "@/lib/image-pipeline/histogram"; +import { + renderPreview, + type PreviewRenderResult, +} from "@/lib/image-pipeline/preview-renderer"; + +type UsePipelinePreviewOptions = { + sourceUrl: string | null; + steps: readonly PipelineStep[]; + nodeWidth: number; + previewScale?: number; + maxPreviewWidth?: number; +}; + +function computePreviewWidth(nodeWidth: number, previewScale: number, maxPreviewWidth: number): number { + const dpr = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1; + return Math.max(1, Math.round(Math.min(nodeWidth * dpr * previewScale, maxPreviewWidth))); +} + +export function usePipelinePreview(options: UsePipelinePreviewOptions): { + canvasRef: React.RefObject; + histogram: HistogramData; + isRendering: boolean; + hasSource: boolean; + previewAspectRatio: number; + error: string | null; +} { + const canvasRef = useRef(null); + const [histogram, setHistogram] = useState(() => emptyHistogram()); + const [isRendering, setIsRendering] = useState(false); + const [previewAspectRatio, setPreviewAspectRatio] = useState(1); + const [error, setError] = useState(null); + const runIdRef = useRef(0); + + const previewScale = useMemo(() => { + if (typeof options.previewScale !== "number" || !Number.isFinite(options.previewScale)) { + return 1; + } + return Math.max(0.2, Math.min(1, options.previewScale)); + }, [options.previewScale]); + + const maxPreviewWidth = useMemo(() => { + if (typeof options.maxPreviewWidth !== "number" || !Number.isFinite(options.maxPreviewWidth)) { + return 1024; + } + return Math.max(128, Math.round(options.maxPreviewWidth)); + }, [options.maxPreviewWidth]); + + const previewWidth = useMemo( + () => computePreviewWidth(options.nodeWidth, previewScale, maxPreviewWidth), + [maxPreviewWidth, options.nodeWidth, previewScale], + ); + + const pipelineHash = useMemo(() => { + if (!options.sourceUrl) { + return "no-source"; + } + return hashPipeline(options.sourceUrl, options.steps); + }, [options.sourceUrl, options.steps]); + + useEffect(() => { + const sourceUrl = options.sourceUrl; + if (!sourceUrl) { + const frameId = window.requestAnimationFrame(() => { + setHistogram(emptyHistogram()); + setError(null); + setIsRendering(false); + }); + return () => { + window.cancelAnimationFrame(frameId); + }; + } + + const currentRun = runIdRef.current + 1; + runIdRef.current = currentRun; + + const timer = window.setTimeout(() => { + setIsRendering(true); + setError(null); + void renderPreview({ + sourceUrl, + steps: options.steps, + previewWidth, + }) + .then((result: PreviewRenderResult) => { + if (runIdRef.current !== currentRun) return; + + const canvas = canvasRef.current; + if (!canvas) return; + canvas.width = result.width; + canvas.height = result.height; + const context = canvas.getContext("2d"); + if (!context) { + setError("Preview context unavailable"); + return; + } + context.putImageData(result.imageData, 0, 0); + setHistogram(result.histogram); + setPreviewAspectRatio(result.width / result.height); + }) + .catch((renderError: unknown) => { + if (runIdRef.current !== currentRun) return; + const message = + renderError instanceof Error + ? renderError.message + : "Preview rendering failed"; + setError(message); + }) + .finally(() => { + if (runIdRef.current !== currentRun) return; + setIsRendering(false); + }); + }, 16); + + return () => { + window.clearTimeout(timer); + }; + }, [options.sourceUrl, options.steps, pipelineHash, previewWidth]); + + return { + canvasRef, + histogram, + isRendering, + hasSource: Boolean(options.sourceUrl), + previewAspectRatio, + error, + }; +} diff --git a/lib/canvas-node-catalog.ts b/lib/canvas-node-catalog.ts index 72f2864..2e73c70 100644 --- a/lib/canvas-node-catalog.ts +++ b/lib/canvas-node-catalog.ts @@ -169,40 +169,30 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [ label: "Kurven", category: "image-edit", phase: 2, - implemented: false, - disabledHint: "Folgt in Phase 2", }), entry({ type: "color-adjust", label: "Farbe", category: "image-edit", phase: 2, - implemented: false, - disabledHint: "Folgt in Phase 2", }), entry({ type: "light-adjust", label: "Licht", category: "image-edit", phase: 2, - implemented: false, - disabledHint: "Folgt in Phase 2", }), entry({ type: "detail-adjust", label: "Detail", category: "image-edit", phase: 2, - implemented: false, - disabledHint: "Folgt in Phase 2", }), entry({ type: "render", label: "Render", category: "image-edit", phase: 2, - implemented: false, - disabledHint: "Folgt in Phase 2", }), // Steuerung & Flow entry({ diff --git a/lib/canvas-node-templates.ts b/lib/canvas-node-templates.ts index 0309c61..c8a19f5 100644 --- a/lib/canvas-node-templates.ts +++ b/lib/canvas-node-templates.ts @@ -62,6 +62,45 @@ export const CANVAS_NODE_TEMPLATES = [ height: 180, defaultData: {}, }, + { + type: "curves", + label: "Kurven", + width: 280, + height: 460, + defaultData: {}, + }, + { + type: "color-adjust", + label: "Farbe", + width: 280, + height: 560, + defaultData: {}, + }, + { + type: "light-adjust", + label: "Licht", + width: 280, + height: 620, + defaultData: {}, + }, + { + type: "detail-adjust", + label: "Detail", + width: 280, + height: 620, + defaultData: {}, + }, + { + type: "render", + label: "Render", + width: 300, + height: 420, + defaultData: { + outputResolution: "original", + format: "png", + jpegQuality: 90, + }, + }, ] as const; export type CanvasNodeTemplate = (typeof CANVAS_NODE_TEMPLATES)[number]; diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts index 27d5efb..4c202b4 100644 --- a/lib/canvas-utils.ts +++ b/lib/canvas-utils.ts @@ -6,6 +6,12 @@ import { type Edge as RFEdge, } from "@xyflow/react"; import type { Doc, Id } from "@/convex/_generated/dataModel"; +import { + DEFAULT_COLOR_ADJUST_DATA, + DEFAULT_CURVES_DATA, + DEFAULT_DETAIL_ADJUST_DATA, + DEFAULT_LIGHT_ADJUST_DATA, +} from "@/lib/image-pipeline/adjustment-types"; /** * Convex Node → React Flow Node @@ -105,6 +111,11 @@ const SOURCE_NODE_GLOW_RGB: Record = group: [100, 116, 139], frame: [249, 115, 22], compare: [100, 116, 139], + curves: [16, 185, 129], + "color-adjust": [6, 182, 212], + "light-adjust": [245, 158, 11], + "detail-adjust": [99, 102, 241], + render: [14, 165, 233], }; /** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */ @@ -204,6 +215,11 @@ export const NODE_HANDLE_MAP: Record< compare: { source: "compare-out", target: "left" }, asset: { source: undefined, target: undefined }, video: { source: undefined, target: undefined }, + curves: { source: undefined, target: undefined }, + "color-adjust": { source: undefined, target: undefined }, + "light-adjust": { source: undefined, target: undefined }, + "detail-adjust": { source: undefined, target: undefined }, + render: { source: undefined, target: undefined }, }; /** @@ -228,6 +244,15 @@ export const NODE_DEFAULTS: Record< compare: { width: 500, height: 380, data: {} }, asset: { width: 260, height: 240, data: {} }, video: { width: 320, height: 180, data: {} }, + curves: { width: 280, height: 460, data: DEFAULT_CURVES_DATA }, + "color-adjust": { width: 280, height: 560, data: DEFAULT_COLOR_ADJUST_DATA }, + "light-adjust": { width: 280, height: 620, data: DEFAULT_LIGHT_ADJUST_DATA }, + "detail-adjust": { width: 280, height: 620, data: DEFAULT_DETAIL_ADJUST_DATA }, + render: { + width: 300, + height: 420, + data: { outputResolution: "original", format: "png", jpegQuality: 90 }, + }, }; type MediaNodeKind = "asset" | "image"; diff --git a/lib/image-pipeline/adjustment-types.ts b/lib/image-pipeline/adjustment-types.ts new file mode 100644 index 0000000..c69e502 --- /dev/null +++ b/lib/image-pipeline/adjustment-types.ts @@ -0,0 +1,264 @@ +export type AdjustmentNodeKind = "curves" | "color-adjust" | "light-adjust" | "detail-adjust"; + +export type CurvePoint = { + x: number; + y: number; +}; + +export type CurvesData = { + channelMode: "rgb" | "red" | "green" | "blue"; + points: { + rgb: CurvePoint[]; + red: CurvePoint[]; + green: CurvePoint[]; + blue: CurvePoint[]; + }; + levels: { + blackPoint: number; + whitePoint: number; + gamma: number; + }; + preset: string | null; +}; + +export type ColorAdjustData = { + hsl: { + hue: number; + saturation: number; + luminance: number; + }; + temperature: number; + tint: number; + vibrance: number; + preset: string | null; +}; + +export type LightAdjustData = { + brightness: number; + contrast: number; + exposure: number; + highlights: number; + shadows: number; + whites: number; + blacks: number; + vignette: { + amount: number; + size: number; + roundness: number; + }; + preset: string | null; +}; + +export type DetailAdjustData = { + sharpen: { + amount: number; + radius: number; + threshold: number; + }; + clarity: number; + denoise: { + luminance: number; + color: number; + }; + grain: { + amount: number; + size: number; + }; + preset: string | null; +}; + +export const DEFAULT_CURVES_DATA: CurvesData = { + channelMode: "rgb", + points: { + rgb: [ + { x: 0, y: 0 }, + { x: 255, y: 255 }, + ], + red: [ + { x: 0, y: 0 }, + { x: 255, y: 255 }, + ], + green: [ + { x: 0, y: 0 }, + { x: 255, y: 255 }, + ], + blue: [ + { x: 0, y: 0 }, + { x: 255, y: 255 }, + ], + }, + levels: { + blackPoint: 0, + whitePoint: 255, + gamma: 1, + }, + preset: null, +}; + +export const DEFAULT_COLOR_ADJUST_DATA: ColorAdjustData = { + hsl: { + hue: 0, + saturation: 0, + luminance: 0, + }, + temperature: 0, + tint: 0, + vibrance: 0, + preset: null, +}; + +export const DEFAULT_LIGHT_ADJUST_DATA: LightAdjustData = { + brightness: 0, + contrast: 0, + exposure: 0, + highlights: 0, + shadows: 0, + whites: 0, + blacks: 0, + vignette: { + amount: 0, + size: 0.5, + roundness: 1, + }, + preset: null, +}; + +export const DEFAULT_DETAIL_ADJUST_DATA: DetailAdjustData = { + sharpen: { + amount: 0, + radius: 1, + threshold: 0, + }, + clarity: 0, + denoise: { + luminance: 0, + color: 0, + }, + grain: { + amount: 0, + size: 1, + }, + preset: null, +}; + +export function cloneAdjustmentData(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function safeNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function normalizeCurvePoints(points: unknown): CurvePoint[] { + if (!Array.isArray(points)) { + return cloneAdjustmentData(DEFAULT_CURVES_DATA.points.rgb); + } + + const normalized = points + .map((point) => { + if (!point || typeof point !== "object") return null; + const record = point as Record; + return { + x: clamp(safeNumber(record.x, 0), 0, 255), + y: clamp(safeNumber(record.y, 0), 0, 255), + }; + }) + .filter((point): point is CurvePoint => point !== null) + .sort((a, b) => a.x - b.x); + + if (normalized.length >= 2) return normalized; + return cloneAdjustmentData(DEFAULT_CURVES_DATA.points.rgb); +} + +export function normalizeCurvesData(value: unknown): CurvesData { + const input = (value ?? {}) as Record; + const levels = (input.levels ?? {}) as Record; + const points = (input.points ?? {}) as Record; + const channelMode = + input.channelMode === "red" || + input.channelMode === "green" || + input.channelMode === "blue" || + input.channelMode === "rgb" + ? input.channelMode + : DEFAULT_CURVES_DATA.channelMode; + + return { + channelMode, + points: { + rgb: normalizeCurvePoints(points.rgb), + red: normalizeCurvePoints(points.red), + green: normalizeCurvePoints(points.green), + blue: normalizeCurvePoints(points.blue), + }, + levels: { + blackPoint: clamp(safeNumber(levels.blackPoint, 0), 0, 255), + whitePoint: clamp(safeNumber(levels.whitePoint, 255), 0, 255), + gamma: clamp(safeNumber(levels.gamma, 1), 0.1, 10), + }, + preset: typeof input.preset === "string" ? input.preset : null, + }; +} + +export function normalizeColorAdjustData(value: unknown): ColorAdjustData { + const input = (value ?? {}) as Record; + const hsl = (input.hsl ?? {}) as Record; + return { + hsl: { + hue: clamp(safeNumber(hsl.hue, 0), -180, 180), + saturation: clamp(safeNumber(hsl.saturation, 0), -100, 100), + luminance: clamp(safeNumber(hsl.luminance, 0), -100, 100), + }, + temperature: clamp(safeNumber(input.temperature, 0), -100, 100), + tint: clamp(safeNumber(input.tint, 0), -100, 100), + vibrance: clamp(safeNumber(input.vibrance, 0), -100, 100), + preset: typeof input.preset === "string" ? input.preset : null, + }; +} + +export function normalizeLightAdjustData(value: unknown): LightAdjustData { + const input = (value ?? {}) as Record; + const vignette = (input.vignette ?? {}) as Record; + return { + brightness: clamp(safeNumber(input.brightness, 0), -100, 100), + contrast: clamp(safeNumber(input.contrast, 0), -100, 100), + exposure: clamp(safeNumber(input.exposure, 0), -5, 5), + highlights: clamp(safeNumber(input.highlights, 0), -100, 100), + shadows: clamp(safeNumber(input.shadows, 0), -100, 100), + whites: clamp(safeNumber(input.whites, 0), -100, 100), + blacks: clamp(safeNumber(input.blacks, 0), -100, 100), + vignette: { + amount: clamp(safeNumber(vignette.amount, 0), 0, 1), + size: clamp(safeNumber(vignette.size, 0.5), 0, 1), + roundness: clamp(safeNumber(vignette.roundness, 1), 0, 1), + }, + preset: typeof input.preset === "string" ? input.preset : null, + }; +} + +export function normalizeDetailAdjustData(value: unknown): DetailAdjustData { + const input = (value ?? {}) as Record; + const sharpen = (input.sharpen ?? {}) as Record; + const denoise = (input.denoise ?? {}) as Record; + const grain = (input.grain ?? {}) as Record; + return { + sharpen: { + amount: clamp(safeNumber(sharpen.amount, 0), 0, 500), + radius: clamp(safeNumber(sharpen.radius, 1), 0.5, 5), + threshold: clamp(safeNumber(sharpen.threshold, 0), 0, 255), + }, + clarity: clamp(safeNumber(input.clarity, 0), -100, 100), + denoise: { + luminance: clamp(safeNumber(denoise.luminance, 0), 0, 100), + color: clamp(safeNumber(denoise.color, 0), 0, 100), + }, + grain: { + amount: clamp(safeNumber(grain.amount, 0), 0, 100), + size: clamp(safeNumber(grain.size, 1), 0.5, 3), + }, + preset: typeof input.preset === "string" ? input.preset : null, + }; +} diff --git a/lib/image-pipeline/bridge.ts b/lib/image-pipeline/bridge.ts new file mode 100644 index 0000000..c616ec6 --- /dev/null +++ b/lib/image-pipeline/bridge.ts @@ -0,0 +1,137 @@ +import { applyPipelineSteps } from "@/lib/image-pipeline/render-core"; +import { resolveRenderSize } from "@/lib/image-pipeline/render-size"; +import { + RENDER_FORMAT_TO_MIME, + type RenderFormat, + type RenderFullOptions, + type RenderFullResult, +} from "@/lib/image-pipeline/render-types"; +import { loadSourceBitmap } from "@/lib/image-pipeline/source-loader"; + +type SupportedCanvas = HTMLCanvasElement | OffscreenCanvas; +type SupportedContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + +function normalizeJpegQuality(value: number | undefined): number { + if (value === undefined) { + return 0.92; + } + + if (!Number.isFinite(value)) { + throw new Error("Invalid render options: jpegQuality must be a finite number."); + } + + return Math.max(0, Math.min(1, value)); +} + +function createCanvasContext(width: number, height: number): { + canvas: SupportedCanvas; + context: SupportedContext; +} { + if (typeof document !== "undefined") { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) { + throw new Error("Render bridge could not create a 2D context."); + } + + return { + canvas, + context, + }; + } + + if (typeof OffscreenCanvas !== "undefined") { + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) { + throw new Error("Render bridge could not create an offscreen 2D context."); + } + + return { + canvas, + context, + }; + } + + throw new Error("Canvas rendering is not available in this environment."); +} + +async function canvasToBlob( + canvas: SupportedCanvas, + mimeType: string, + quality: number | undefined, +): Promise { + if (typeof OffscreenCanvas !== "undefined" && canvas instanceof OffscreenCanvas) { + return await canvas.convertToBlob({ type: mimeType, quality }); + } + + return await new Promise((resolve, reject) => { + (canvas as HTMLCanvasElement).toBlob( + (blob) => { + if (!blob) { + reject(new Error("Render bridge could not encode output blob.")); + return; + } + + resolve(blob); + }, + mimeType, + quality, + ); + }); +} + +function resolveMimeType(format: RenderFormat): string { + const mimeType = RENDER_FORMAT_TO_MIME[format]; + if (!mimeType) { + throw new Error(`Unsupported render format '${format}'.`); + } + + return mimeType; +} + +export async function renderFull(options: RenderFullOptions): Promise { + const bitmap = await loadSourceBitmap(options.sourceUrl); + const resolvedSize = resolveRenderSize({ + sourceWidth: bitmap.width, + sourceHeight: bitmap.height, + render: options.render, + limits: options.limits, + }); + + const { canvas, context } = createCanvasContext(resolvedSize.width, resolvedSize.height); + + context.drawImage(bitmap, 0, 0, resolvedSize.width, resolvedSize.height); + + const imageData = context.getImageData(0, 0, resolvedSize.width, resolvedSize.height); + applyPipelineSteps( + imageData.data, + options.steps, + resolvedSize.width, + resolvedSize.height, + ); + context.putImageData(imageData, 0, 0); + + const mimeType = resolveMimeType(options.render.format); + const quality = options.render.format === "jpeg" ? normalizeJpegQuality(options.render.jpegQuality) : null; + const blob = await canvasToBlob(canvas, mimeType, quality ?? undefined); + + return { + blob, + width: resolvedSize.width, + height: resolvedSize.height, + mimeType, + format: options.render.format, + quality, + sizeBytes: blob.size, + sourceWidth: bitmap.width, + sourceHeight: bitmap.height, + wasSizeClamped: resolvedSize.wasClamped, + }; +} + +export const bridge = { + renderFull, +}; diff --git a/lib/image-pipeline/histogram.ts b/lib/image-pipeline/histogram.ts new file mode 100644 index 0000000..30294c4 --- /dev/null +++ b/lib/image-pipeline/histogram.ts @@ -0,0 +1,42 @@ +export type HistogramData = { + rgb: number[]; + red: number[]; + green: number[]; + blue: number[]; + max: number; +}; + +export function emptyHistogram(): HistogramData { + return { + rgb: Array.from({ length: 256 }, () => 0), + red: Array.from({ length: 256 }, () => 0), + green: Array.from({ length: 256 }, () => 0), + blue: Array.from({ length: 256 }, () => 0), + max: 0, + }; +} + +export function computeHistogram(data: Uint8ClampedArray): HistogramData { + const histogram = emptyHistogram(); + + for (let index = 0; index < data.length; index += 4) { + const red = data[index] ?? 0; + const green = data[index + 1] ?? 0; + const blue = data[index + 2] ?? 0; + const luminance = Math.round(red * 0.2126 + green * 0.7152 + blue * 0.0722); + + histogram.red[red] += 1; + histogram.green[green] += 1; + histogram.blue[blue] += 1; + histogram.rgb[luminance] += 1; + } + + histogram.max = Math.max( + ...histogram.rgb, + ...histogram.red, + ...histogram.green, + ...histogram.blue, + ); + + return histogram; +} diff --git a/lib/image-pipeline/presets.ts b/lib/image-pipeline/presets.ts new file mode 100644 index 0000000..8500338 --- /dev/null +++ b/lib/image-pipeline/presets.ts @@ -0,0 +1,144 @@ +import { + cloneAdjustmentData, + DEFAULT_COLOR_ADJUST_DATA, + DEFAULT_CURVES_DATA, + DEFAULT_DETAIL_ADJUST_DATA, + DEFAULT_LIGHT_ADJUST_DATA, + type ColorAdjustData, + type CurvesData, + type DetailAdjustData, + type LightAdjustData, +} from "@/lib/image-pipeline/adjustment-types"; + +export const CURVE_PRESETS: Record = { + contrast: { + ...cloneAdjustmentData(DEFAULT_CURVES_DATA), + points: { + ...cloneAdjustmentData(DEFAULT_CURVES_DATA.points), + rgb: [ + { x: 0, y: 0 }, + { x: 64, y: 48 }, + { x: 192, y: 220 }, + { x: 255, y: 255 }, + ], + }, + preset: "contrast", + }, + brighten: { + ...cloneAdjustmentData(DEFAULT_CURVES_DATA), + levels: { + blackPoint: 0, + whitePoint: 245, + gamma: 0.9, + }, + preset: "brighten", + }, + film: { + ...cloneAdjustmentData(DEFAULT_CURVES_DATA), + points: { + ...cloneAdjustmentData(DEFAULT_CURVES_DATA.points), + rgb: [ + { x: 0, y: 8 }, + { x: 74, y: 68 }, + { x: 180, y: 196 }, + { x: 255, y: 248 }, + ], + }, + preset: "film", + }, +}; + +export const COLOR_PRESETS: Record = { + warm: { + ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), + temperature: 24, + tint: 6, + vibrance: 22, + preset: "warm", + }, + cool: { + ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), + temperature: -22, + tint: -4, + vibrance: 14, + preset: "cool", + }, + vintage: { + ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), + hsl: { hue: -6, saturation: -18, luminance: 4 }, + temperature: 14, + tint: 5, + vibrance: -12, + preset: "vintage", + }, +}; + +export const LIGHT_PRESETS: Record = { + hdr: { + ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), + contrast: 24, + exposure: 0.3, + highlights: -34, + shadows: 38, + whites: 18, + blacks: -16, + preset: "hdr", + }, + lowkey: { + ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), + brightness: -18, + contrast: 28, + exposure: -0.4, + highlights: -20, + shadows: -8, + whites: -10, + blacks: -22, + preset: "lowkey", + }, + highkey: { + ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), + brightness: 18, + contrast: -8, + exposure: 0.5, + highlights: 22, + shadows: 16, + whites: 26, + blacks: 8, + preset: "highkey", + }, +}; + +export const DETAIL_PRESETS: Record = { + web: { + ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), + sharpen: { + amount: 72, + radius: 1, + threshold: 6, + }, + clarity: 10, + preset: "web", + }, + print: { + ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), + sharpen: { + amount: 120, + radius: 1.6, + threshold: 4, + }, + denoise: { + luminance: 8, + color: 10, + }, + preset: "print", + }, + "film-grain": { + ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), + grain: { + amount: 22, + size: 1.4, + }, + clarity: -6, + preset: "film-grain", + }, +}; diff --git a/lib/image-pipeline/preview-renderer.ts b/lib/image-pipeline/preview-renderer.ts new file mode 100644 index 0000000..0775347 --- /dev/null +++ b/lib/image-pipeline/preview-renderer.ts @@ -0,0 +1,48 @@ +import type { PipelineStep } from "@/lib/image-pipeline/contracts"; +import { computeHistogram, type HistogramData } from "@/lib/image-pipeline/histogram"; +import { applyPipelineStep } from "@/lib/image-pipeline/render-core"; +import { loadSourceBitmap } from "@/lib/image-pipeline/source-loader"; + +export type PreviewRenderResult = { + width: number; + height: number; + imageData: ImageData; + histogram: HistogramData; +}; + +export async function renderPreview(options: { + sourceUrl: string; + steps: readonly PipelineStep[]; + previewWidth: number; +}): Promise { + const bitmap = await loadSourceBitmap(options.sourceUrl); + const width = Math.max(1, Math.round(options.previewWidth)); + const height = Math.max(1, Math.round((bitmap.height / bitmap.width) * width)); + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) { + throw new Error("Preview renderer could not create 2D context."); + } + + context.drawImage(bitmap, 0, 0, width, height); + const imageData = context.getImageData(0, 0, width, height); + + for (let index = 0; index < options.steps.length; index += 1) { + applyPipelineStep(imageData.data, options.steps[index]!, width, height); + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } + + const histogram = computeHistogram(imageData.data); + + return { + width, + height, + imageData, + histogram, + }; +} diff --git a/lib/image-pipeline/render-core.ts b/lib/image-pipeline/render-core.ts new file mode 100644 index 0000000..3e1c819 --- /dev/null +++ b/lib/image-pipeline/render-core.ts @@ -0,0 +1,323 @@ +import type { PipelineStep } from "@/lib/image-pipeline/contracts"; +import { + normalizeColorAdjustData, + normalizeCurvesData, + normalizeDetailAdjustData, + normalizeLightAdjustData, + type CurvePoint, +} from "@/lib/image-pipeline/adjustment-types"; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function toByte(value: number): number { + return clamp(Math.round(value), 0, 255); +} + +function buildLut(points: CurvePoint[]): Uint8Array { + const lut = new Uint8Array(256); + const normalized = [...points].sort((a, b) => a.x - b.x); + + for (let input = 0; input < 256; input += 1) { + const first = normalized[0] ?? { x: 0, y: 0 }; + const last = normalized[normalized.length - 1] ?? { x: 255, y: 255 }; + if (input <= first.x) { + lut[input] = toByte(first.y); + continue; + } + if (input >= last.x) { + lut[input] = toByte(last.y); + continue; + } + + for (let index = 1; index < normalized.length; index += 1) { + const left = normalized[index - 1]!; + const right = normalized[index]!; + if (input < left.x || input > right.x) continue; + const span = Math.max(1, right.x - left.x); + const progress = (input - left.x) / span; + lut[input] = toByte(left.y + (right.y - left.y) * progress); + break; + } + } + + return lut; +} + +function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const delta = max - min; + const l = (max + min) / 2; + if (delta === 0) return { h: 0, s: 0, l }; + + const s = delta / (1 - Math.abs(2 * l - 1)); + let h = 0; + if (max === rn) h = ((gn - bn) / delta) % 6; + else if (max === gn) h = (bn - rn) / delta + 2; + else h = (rn - gn) / delta + 4; + h *= 60; + if (h < 0) h += 360; + return { h, s, l }; +} + +function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let rp = 0; + let gp = 0; + let bp = 0; + + if (h < 60) { + rp = c; + gp = x; + } else if (h < 120) { + rp = x; + gp = c; + } else if (h < 180) { + gp = c; + bp = x; + } else if (h < 240) { + gp = x; + bp = c; + } else if (h < 300) { + rp = x; + bp = c; + } else { + rp = c; + bp = x; + } + + return { + r: toByte((rp + m) * 255), + g: toByte((gp + m) * 255), + b: toByte((bp + m) * 255), + }; +} + +function applyCurves(pixels: Uint8ClampedArray, params: unknown): void { + const curves = normalizeCurvesData(params); + const rgbLut = buildLut(curves.points.rgb); + const redLut = buildLut(curves.points.red); + const greenLut = buildLut(curves.points.green); + const blueLut = buildLut(curves.points.blue); + + const whitePoint = Math.max(curves.levels.whitePoint, curves.levels.blackPoint + 1); + const levelRange = whitePoint - curves.levels.blackPoint; + const invGamma = 1 / curves.levels.gamma; + + for (let index = 0; index < pixels.length; index += 4) { + const applyLevels = (value: number) => { + const normalized = clamp((value - curves.levels.blackPoint) / levelRange, 0, 1); + return toByte(Math.pow(normalized, invGamma) * 255); + }; + + let red = applyLevels(pixels[index] ?? 0); + let green = applyLevels(pixels[index + 1] ?? 0); + let blue = applyLevels(pixels[index + 2] ?? 0); + + red = rgbLut[red]; + green = rgbLut[green]; + blue = rgbLut[blue]; + + if (curves.channelMode === "red") { + red = redLut[red]; + } else if (curves.channelMode === "green") { + green = greenLut[green]; + } else if (curves.channelMode === "blue") { + blue = blueLut[blue]; + } else { + red = redLut[red]; + green = greenLut[green]; + blue = blueLut[blue]; + } + + pixels[index] = red; + pixels[index + 1] = green; + pixels[index + 2] = blue; + } +} + +function applyColorAdjust(pixels: Uint8ClampedArray, params: unknown): void { + const color = normalizeColorAdjustData(params); + const saturationFactor = 1 + color.hsl.saturation / 100; + const luminanceShift = color.hsl.luminance / 100; + const hueShift = color.hsl.hue; + + for (let index = 0; index < pixels.length; index += 4) { + const currentRed = pixels[index] ?? 0; + const currentGreen = pixels[index + 1] ?? 0; + const currentBlue = pixels[index + 2] ?? 0; + + const hsl = rgbToHsl(currentRed, currentGreen, currentBlue); + const shiftedHue = (hsl.h + hueShift + 360) % 360; + const shiftedSaturation = clamp(hsl.s * saturationFactor, 0, 1); + const shiftedLuminance = clamp(hsl.l + luminanceShift, 0, 1); + const tempShift = color.temperature * 0.6; + const tintShift = color.tint * 0.4; + const vibranceBoost = color.vibrance / 100; + const saturationDelta = (1 - hsl.s) * vibranceBoost; + + const vivid = hslToRgb( + shiftedHue, + clamp(shiftedSaturation + saturationDelta, 0, 1), + shiftedLuminance, + ); + + pixels[index] = toByte(vivid.r + tempShift); + pixels[index + 1] = toByte(vivid.g + tintShift); + pixels[index + 2] = toByte(vivid.b - tempShift - tintShift * 0.3); + } +} + +function applyLightAdjust( + pixels: Uint8ClampedArray, + params: unknown, + width: number, + height: number, +): void { + const light = normalizeLightAdjustData(params); + const exposureFactor = Math.pow(2, light.exposure / 2); + const contrastFactor = 1 + light.contrast / 100; + const brightnessShift = light.brightness * 1.8; + + const centerX = width / 2; + const centerY = height / 2; + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const index = (y * width + x) * 4; + let red = pixels[index] ?? 0; + let green = pixels[index + 1] ?? 0; + let blue = pixels[index + 2] ?? 0; + + red = red * exposureFactor; + green = green * exposureFactor; + blue = blue * exposureFactor; + + red = (red - 128) * contrastFactor + 128 + brightnessShift; + green = (green - 128) * contrastFactor + 128 + brightnessShift; + blue = (blue - 128) * contrastFactor + 128 + brightnessShift; + + const luma = red * 0.2126 + green * 0.7152 + blue * 0.0722; + const highlightsBoost = (luma / 255) * (light.highlights / 100) * 40; + const shadowsBoost = ((255 - luma) / 255) * (light.shadows / 100) * 40; + const whitesBoost = (luma / 255) * (light.whites / 100) * 35; + const blacksBoost = ((255 - luma) / 255) * (light.blacks / 100) * 35; + + const totalBoost = highlightsBoost + shadowsBoost + whitesBoost + blacksBoost; + red = toByte(red + totalBoost); + green = toByte(green + totalBoost); + blue = toByte(blue + totalBoost); + + if (light.vignette.amount > 0) { + const dx = (x - centerX) / Math.max(1, centerX); + const dy = (y - centerY) / Math.max(1, centerY); + const radialDistance = Math.sqrt(dx * dx + dy * dy); + const softEdge = Math.pow(1 - clamp(radialDistance, 0, 1), 1 + light.vignette.roundness); + const strength = 1 - light.vignette.amount * (1 - softEdge) * (1.5 - light.vignette.size); + red = toByte(red * strength); + green = toByte(green * strength); + blue = toByte(blue * strength); + } + + pixels[index] = red; + pixels[index + 1] = green; + pixels[index + 2] = blue; + } + } +} + +function pseudoNoise(seed: number): number { + const x = Math.sin(seed * 12.9898) * 43758.5453; + return x - Math.floor(x); +} + +function applyDetailAdjust(pixels: Uint8ClampedArray, params: unknown): void { + const detail = normalizeDetailAdjustData(params); + const sharpenBoost = detail.sharpen.amount / 500; + const clarityBoost = detail.clarity / 100; + const denoiseLuma = detail.denoise.luminance / 100; + const denoiseColor = detail.denoise.color / 100; + const grainAmount = detail.grain.amount / 100; + const grainScale = Math.max(0.5, detail.grain.size); + + for (let index = 0; index < pixels.length; index += 4) { + let red = pixels[index] ?? 0; + let green = pixels[index + 1] ?? 0; + let blue = pixels[index + 2] ?? 0; + + const luma = red * 0.2126 + green * 0.7152 + blue * 0.0722; + + red = red + (red - luma) * sharpenBoost * 0.6; + green = green + (green - luma) * sharpenBoost * 0.6; + blue = blue + (blue - luma) * sharpenBoost * 0.6; + + const midtoneFactor = 1 - Math.abs(luma / 255 - 0.5) * 2; + const clarityScale = 1 + clarityBoost * midtoneFactor * 0.7; + red = (red - 128) * clarityScale + 128; + green = (green - 128) * clarityScale + 128; + blue = (blue - 128) * clarityScale + 128; + + if (denoiseLuma > 0 || denoiseColor > 0) { + red = red * (1 - denoiseLuma * 0.2) + luma * denoiseLuma * 0.2; + green = green * (1 - denoiseLuma * 0.2) + luma * denoiseLuma * 0.2; + blue = blue * (1 - denoiseLuma * 0.2) + luma * denoiseLuma * 0.2; + + const average = (red + green + blue) / 3; + red = red * (1 - denoiseColor * 0.2) + average * denoiseColor * 0.2; + green = green * (1 - denoiseColor * 0.2) + average * denoiseColor * 0.2; + blue = blue * (1 - denoiseColor * 0.2) + average * denoiseColor * 0.2; + } + + if (grainAmount > 0) { + const grain = (pseudoNoise((index + 1) / grainScale) - 0.5) * grainAmount * 40; + red += grain; + green += grain; + blue += grain; + } + + pixels[index] = toByte(red); + pixels[index + 1] = toByte(green); + pixels[index + 2] = toByte(blue); + } +} + +export function applyPipelineStep( + pixels: Uint8ClampedArray, + step: PipelineStep, + width: number, + height: number, +): void { + if (step.type === "curves") { + applyCurves(pixels, step.params); + return; + } + if (step.type === "color-adjust") { + applyColorAdjust(pixels, step.params); + return; + } + if (step.type === "light-adjust") { + applyLightAdjust(pixels, step.params, width, height); + return; + } + if (step.type === "detail-adjust") { + applyDetailAdjust(pixels, step.params); + } +} + +export function applyPipelineSteps( + pixels: Uint8ClampedArray, + steps: readonly PipelineStep[], + width: number, + height: number, +): void { + for (let index = 0; index < steps.length; index += 1) { + applyPipelineStep(pixels, steps[index]!, width, height); + } +} diff --git a/lib/image-pipeline/render-size.ts b/lib/image-pipeline/render-size.ts new file mode 100644 index 0000000..c6e6dc2 --- /dev/null +++ b/lib/image-pipeline/render-size.ts @@ -0,0 +1,108 @@ +import type { + RenderOptions, + RenderSizeLimits, + ResolvedRenderSize, +} from "@/lib/image-pipeline/render-types"; + +const DEFAULT_MAX_DIMENSION = 8192; +const DEFAULT_MAX_PIXELS = 33_554_432; + +function sanitizeLimit(name: string, value: number | undefined, fallback: number): number { + if (value === undefined) { + return fallback; + } + + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`Invalid render limit '${name}'. Expected a positive finite number.`); + } + + return Math.max(1, Math.floor(value)); +} + +function sanitizeDimension(name: string, value: number): number { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`Invalid ${name}. Expected a positive finite number.`); + } + + return Math.max(1, Math.round(value)); +} + +function scaleDimensions( + width: number, + height: number, + factor: number, +): { + width: number; + height: number; +} { + return { + width: Math.max(1, Math.floor(width * factor)), + height: Math.max(1, Math.floor(height * factor)), + }; +} + +export function resolveRenderSize(options: { + sourceWidth: number; + sourceHeight: number; + render: RenderOptions; + limits?: RenderSizeLimits; +}): ResolvedRenderSize { + const sourceWidth = sanitizeDimension("sourceWidth", options.sourceWidth); + const sourceHeight = sanitizeDimension("sourceHeight", options.sourceHeight); + + const maxDimension = sanitizeLimit( + "maxDimension", + options.limits?.maxDimension, + DEFAULT_MAX_DIMENSION, + ); + const maxPixels = sanitizeLimit("maxPixels", options.limits?.maxPixels, DEFAULT_MAX_PIXELS); + + let targetWidth = sourceWidth; + let targetHeight = sourceHeight; + + if (options.render.resolution === "2x") { + targetWidth = sourceWidth * 2; + targetHeight = sourceHeight * 2; + } else if (options.render.resolution === "custom") { + if (!options.render.customSize) { + throw new Error("Invalid render options: resolution 'custom' requires customSize."); + } + + targetWidth = sanitizeDimension("customSize.width", options.render.customSize.width); + targetHeight = sanitizeDimension("customSize.height", options.render.customSize.height); + } else if (options.render.resolution !== "original") { + throw new Error(`Unsupported render resolution '${options.render.resolution}'.`); + } + + targetWidth = sanitizeDimension("targetWidth", targetWidth); + targetHeight = sanitizeDimension("targetHeight", targetHeight); + + let scaleFactor = 1; + let wasClamped = false; + + const dimensionScale = Math.min(1, maxDimension / Math.max(targetWidth, targetHeight)); + if (dimensionScale < 1) { + const scaled = scaleDimensions(targetWidth, targetHeight, dimensionScale); + targetWidth = scaled.width; + targetHeight = scaled.height; + scaleFactor *= dimensionScale; + wasClamped = true; + } + + const pixelCount = targetWidth * targetHeight; + if (pixelCount > maxPixels) { + const pixelScale = Math.sqrt(maxPixels / pixelCount); + const scaled = scaleDimensions(targetWidth, targetHeight, pixelScale); + targetWidth = scaled.width; + targetHeight = scaled.height; + scaleFactor *= pixelScale; + wasClamped = true; + } + + return { + width: targetWidth, + height: targetHeight, + scaleFactor, + wasClamped, + }; +} diff --git a/lib/image-pipeline/render-types.ts b/lib/image-pipeline/render-types.ts new file mode 100644 index 0000000..f616930 --- /dev/null +++ b/lib/image-pipeline/render-types.ts @@ -0,0 +1,52 @@ +import type { PipelineStep } from "@/lib/image-pipeline/contracts"; + +export const RENDER_FORMAT_TO_MIME = { + png: "image/png", + jpeg: "image/jpeg", + webp: "image/webp", +} as const; + +export type RenderResolution = "original" | "2x" | "custom"; +export type RenderFormat = keyof typeof RENDER_FORMAT_TO_MIME; + +export type RenderOptions = { + resolution: RenderResolution; + customSize?: { + width: number; + height: number; + }; + format: RenderFormat; + jpegQuality?: number; +}; + +export type RenderSizeLimits = { + maxDimension?: number; + maxPixels?: number; +}; + +export type ResolvedRenderSize = { + width: number; + height: number; + scaleFactor: number; + wasClamped: boolean; +}; + +export type RenderFullOptions = { + sourceUrl: string; + steps: readonly PipelineStep[]; + render: RenderOptions; + limits?: RenderSizeLimits; +}; + +export type RenderFullResult = { + blob: Blob; + width: number; + height: number; + mimeType: string; + format: RenderFormat; + quality: number | null; + sizeBytes: number; + sourceWidth: number; + sourceHeight: number; + wasSizeClamped: boolean; +}; diff --git a/lib/image-pipeline/source-loader.ts b/lib/image-pipeline/source-loader.ts new file mode 100644 index 0000000..4c7fa34 --- /dev/null +++ b/lib/image-pipeline/source-loader.ts @@ -0,0 +1,35 @@ +const imageBitmapCache = new Map>(); + +export async function loadSourceBitmap(sourceUrl: string): Promise { + if (!sourceUrl || sourceUrl.trim().length === 0) { + throw new Error("Render sourceUrl is required."); + } + + if (typeof createImageBitmap !== "function") { + throw new Error("ImageBitmap is not available in this environment."); + } + + const cached = imageBitmapCache.get(sourceUrl); + if (cached) { + return await cached; + } + + const promise = (async () => { + const response = await fetch(sourceUrl); + if (!response.ok) { + throw new Error(`Render source failed: ${response.status}`); + } + + const blob = await response.blob(); + return await createImageBitmap(blob); + })(); + + imageBitmapCache.set(sourceUrl, promise); + + try { + return await promise; + } catch (error) { + imageBitmapCache.delete(sourceUrl); + throw error; + } +} diff --git a/package.json b/package.json index 82ea747..acb188d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@napi-rs/canvas": "^0.1.97", "@polar-sh/better-auth": "^1.8.3", "@polar-sh/sdk": "^0.46.7", + "@radix-ui/react-slider": "^1.3.6", "@sentry/nextjs": "^10.46.0", "@xyflow/react": "^12.10.1", "better-auth": "^1.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fe7fe9..79ee7e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@polar-sh/sdk': specifier: ^0.46.7 version: 0.46.7 + '@radix-ui/react-slider': + specifier: ^1.3.6 + version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@sentry/nextjs': specifier: ^10.46.0 version: 10.46.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.0))(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.105.4) diff --git a/src/components/tool-ui/parameter-slider/README.md b/src/components/tool-ui/parameter-slider/README.md new file mode 100644 index 0000000..04d1be5 --- /dev/null +++ b/src/components/tool-ui/parameter-slider/README.md @@ -0,0 +1,19 @@ +# Parameter Slider + +Implementation for the "parameter-slider" Tool UI surface. + +## Files + +- public exports: components/tool-ui/parameter-slider/index.tsx +- serializable schema + parse helpers: components/tool-ui/parameter-slider/schema.ts + +## Companion assets + +- Docs page: app/docs/parameter-slider/content.mdx +- Preset payload: lib/presets/parameter-slider.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/src/components/tool-ui/parameter-slider/_adapter.tsx b/src/components/tool-ui/parameter-slider/_adapter.tsx new file mode 100644 index 0000000..4b43e86 --- /dev/null +++ b/src/components/tool-ui/parameter-slider/_adapter.tsx @@ -0,0 +1,4 @@ +export { cn } from "@/lib/utils"; +export { Button } from "@/components/ui/button"; +export { Separator } from "@/components/ui/separator"; +export { Slider } from "@/components/ui/slider"; diff --git a/src/components/tool-ui/parameter-slider/index.tsx b/src/components/tool-ui/parameter-slider/index.tsx new file mode 100644 index 0000000..8f2ecf3 --- /dev/null +++ b/src/components/tool-ui/parameter-slider/index.tsx @@ -0,0 +1,7 @@ +export { ParameterSlider } from "./parameter-slider"; +export type { + ParameterSliderProps, + SliderConfig, + SliderValue, + SerializableParameterSlider, +} from "./schema"; diff --git a/src/components/tool-ui/parameter-slider/math.ts b/src/components/tool-ui/parameter-slider/math.ts new file mode 100644 index 0000000..80df94e --- /dev/null +++ b/src/components/tool-ui/parameter-slider/math.ts @@ -0,0 +1,42 @@ +import type { SliderConfig, SliderValue } from "./schema"; + +type SliderPercentInput = { + value: number; + min: number; + max: number; +}; + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, value)); +} + +export function sliderRangeToPercent({ + value, + min, + max, +}: SliderPercentInput): number { + const range = max - min; + if (!Number.isFinite(range) || range <= 0) return 0; + return clampPercent(((value - min) / range) * 100); +} + +export function createSliderValueSnapshot( + sliders: SliderConfig[], +): SliderValue[] { + return sliders.map((slider) => ({ id: slider.id, value: slider.value })); +} + +export function createSliderSignature(sliders: SliderConfig[]): string { + return JSON.stringify( + sliders.map(({ id, min, max, step, value, unit, precision }) => ({ + id, + min, + max, + step: step ?? 1, + value, + unit: unit ?? "", + precision: precision ?? null, + })), + ); +} diff --git a/src/components/tool-ui/parameter-slider/parameter-slider.tsx b/src/components/tool-ui/parameter-slider/parameter-slider.tsx new file mode 100644 index 0000000..4bbaa0d --- /dev/null +++ b/src/components/tool-ui/parameter-slider/parameter-slider.tsx @@ -0,0 +1,821 @@ +"use client"; + +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import * as SliderPrimitive from "@radix-ui/react-slider"; +import type { ParameterSliderProps, SliderConfig, SliderValue } from "./schema"; +import { ActionButtons } from "../shared/action-buttons"; +import { normalizeActionsConfig } from "../shared/actions-config"; +import { useControllableState } from "../shared/use-controllable-state"; +import { useSignatureReset } from "../shared/use-signature-reset"; + +import { cn } from "./_adapter"; +import { + createSliderSignature, + createSliderValueSnapshot, + sliderRangeToPercent, +} from "./math"; + +function formatSignedValue( + value: number, + min: number, + max: number, + precision?: number, + unit?: string, +): string { + const crossesZero = min < 0 && max > 0; + const fixed = + precision !== undefined ? value.toFixed(precision) : String(value); + const numericPart = crossesZero && value >= 0 ? `+${fixed}` : fixed; + return unit ? `${numericPart} ${unit}` : numericPart; +} + +function getAriaValueText( + value: number, + min: number, + max: number, + unit?: string, +): string { + const crossesZero = min < 0 && max > 0; + if (crossesZero) { + if (value > 0) { + return unit ? `plus ${value} ${unit}` : `plus ${value}`; + } else if (value < 0) { + return unit + ? `minus ${Math.abs(value)} ${unit}` + : `minus ${Math.abs(value)}`; + } + } + return unit ? `${value} ${unit}` : String(value); +} + +const TICK_COUNT = 16; +const TEXT_PADDING_X = 4; +const TEXT_PADDING_X_OUTER = 0; // Less inset on outer-facing side (near edges) +const TEXT_PADDING_Y = 2; +const DETECTION_MARGIN_X = 12; +const DETECTION_MARGIN_X_OUTER = 4; // Small margin at edges for steep falloff - segments fully close at terminal positions +const DETECTION_MARGIN_Y = 12; +const TRACK_HEIGHT = 48; +const TEXT_RELEASE_INSET = 8; +const TRACK_EDGE_INSET = 4; // px from track edge - keeps elements visible at extremes +const THUMB_WIDTH = 12; // w-3 +// Text vertical offset: raised slightly from center +// Positive = raised, negative = lowered +const TEXT_VERTICAL_OFFSET = 0.5; + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, value)); +} + +// Convert a percentage (0-100) to an inset position string +// At 0%: 4px from left edge; at 100%: 4px from right edge +function toInsetPosition(percent: number): string { + const safePercent = clampPercent(percent); + return `calc(${TRACK_EDGE_INSET}px + (100% - ${TRACK_EDGE_INSET * 2}px) * ${safePercent / 100})`; +} + +// Radix keeps the thumb in bounds by applying a percent-dependent px offset. +// Matching this for fill clipping prevents handle/fill drift near extremes. +function getRadixThumbInBoundsOffsetPx(percent: number): number { + const safePercent = clampPercent(percent); + const halfWidth = THUMB_WIDTH / 2; + return halfWidth - (safePercent * halfWidth) / 50; +} + +function toRadixThumbPosition(percent: number): string { + const safePercent = clampPercent(percent); + const offsetPx = getRadixThumbInBoundsOffsetPx(safePercent); + return `calc(${safePercent}% + ${offsetPx}px)`; +} + +function signedDistanceToRoundedRect( + px: number, + py: number, + left: number, + right: number, + top: number, + bottom: number, + radiusLeft: number, + radiusRight: number, +): number { + const innerLeft = left + radiusLeft; + const innerRight = right - radiusRight; + const innerTop = top + Math.max(radiusLeft, radiusRight); + const innerBottom = bottom - Math.max(radiusLeft, radiusRight); + + const inLeftCorner = px < innerLeft; + const inRightCorner = px > innerRight; + const inCornerY = py < innerTop || py > innerBottom; + + if ((inLeftCorner || inRightCorner) && inCornerY) { + const radius = inLeftCorner ? radiusLeft : radiusRight; + const cornerX = inLeftCorner ? innerLeft : innerRight; + const cornerY = py < innerTop ? top + radius : bottom - radius; + const distToCornerCenter = Math.hypot(px - cornerX, py - cornerY); + return distToCornerCenter - radius; + } + + const dx = Math.max(left - px, px - right, 0); + const dy = Math.max(top - py, py - bottom, 0); + + if (dx === 0 && dy === 0) { + return -Math.min(px - left, right - px, py - top, bottom - py); + } + + return Math.max(dx, dy); +} + +const OUTER_EDGE_RADIUS_FACTOR = 0.3; // Reduced radius on outer-facing sides for steeper falloff + +function calculateGap( + thumbCenterX: number, + textRect: { left: number; right: number; height: number; centerY: number }, + isLeftAligned: boolean, +): number { + const { left, right, height, centerY } = textRect; + // Asymmetric padding/margin: outer-facing side has less padding, more margin + const paddingLeft = isLeftAligned ? TEXT_PADDING_X_OUTER : TEXT_PADDING_X; + const paddingRight = isLeftAligned ? TEXT_PADDING_X : TEXT_PADDING_X_OUTER; + const marginLeft = isLeftAligned + ? DETECTION_MARGIN_X_OUTER + : DETECTION_MARGIN_X; + const marginRight = isLeftAligned + ? DETECTION_MARGIN_X + : DETECTION_MARGIN_X_OUTER; + const paddingY = TEXT_PADDING_Y; + const marginY = DETECTION_MARGIN_Y; + const thumbCenterY = centerY; + + // Inner boundary (where max gap occurs) + const innerLeft = left - paddingLeft; + const innerRight = right + paddingRight; + const innerTop = centerY - height / 2 - paddingY; + const innerBottom = centerY + height / 2 + paddingY; + const innerHeight = height + paddingY * 2; + const innerRadius = innerHeight / 2; + // Smaller radius on outer-facing side (left for label, right for value) + const innerRadiusLeft = isLeftAligned + ? innerRadius * OUTER_EDGE_RADIUS_FACTOR + : innerRadius; + const innerRadiusRight = isLeftAligned + ? innerRadius + : innerRadius * OUTER_EDGE_RADIUS_FACTOR; + + // Outer boundary (where effect starts) - proportionally larger + const outerLeft = left - paddingLeft - marginLeft; + const outerRight = right + paddingRight + marginRight; + const outerTop = centerY - height / 2 - paddingY - marginY; + const outerBottom = centerY + height / 2 + paddingY + marginY; + const outerHeight = height + paddingY * 2 + marginY * 2; + const outerRadius = outerHeight / 2; + const outerRadiusLeft = isLeftAligned + ? outerRadius * OUTER_EDGE_RADIUS_FACTOR + : outerRadius; + const outerRadiusRight = isLeftAligned + ? outerRadius + : outerRadius * OUTER_EDGE_RADIUS_FACTOR; + + const outerDist = signedDistanceToRoundedRect( + thumbCenterX, + thumbCenterY, + outerLeft, + outerRight, + outerTop, + outerBottom, + outerRadiusLeft, + outerRadiusRight, + ); + + // Outside outer boundary - no gap + if (outerDist > 0) return 0; + + const innerDist = signedDistanceToRoundedRect( + thumbCenterX, + thumbCenterY, + innerLeft, + innerRight, + innerTop, + innerBottom, + innerRadiusLeft, + innerRadiusRight, + ); + + // Inside inner boundary - max gap + const maxGap = height + paddingY * 2; + if (innerDist <= 0) return maxGap; + + // Between boundaries - linear interpolation + // outerDist is negative (inside outer), innerDist is positive (outside inner) + const totalDist = Math.abs(outerDist) + innerDist; + const t = Math.abs(outerDist) / totalDist; + + return maxGap * t; +} + +interface SliderRowProps { + config: SliderConfig; + value: number; + onChange: (value: number) => void; + trackClassName?: string; + fillClassName?: string; + handleClassName?: string; +} + +function SliderRow({ + config, + value, + onChange, + trackClassName, + fillClassName, + handleClassName, +}: SliderRowProps) { + const { id, label, min, max, step = 1, unit, precision, disabled } = config; + // Per-slider theming overrides component-level theming + const resolvedTrackClassName = config.trackClassName ?? trackClassName; + const resolvedFillClassName = config.fillClassName ?? fillClassName; + const resolvedHandleClassName = config.handleClassName ?? handleClassName; + const crossesZero = min < 0 && max > 0; + const [isDragging, setIsDragging] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + const trackRef = useRef(null); + const labelRef = useRef(null); + const valueRef = useRef(null); + + const [dragGap, setDragGap] = useState(0); + const [fullGap, setFullGap] = useState(0); + const [intersectsText, setIntersectsText] = useState(false); + const [layoutVersion, setLayoutVersion] = useState(0); + + useEffect(() => { + if (!isDragging) return; + const handlePointerUp = () => setIsDragging(false); + document.addEventListener("pointerup", handlePointerUp); + return () => document.removeEventListener("pointerup", handlePointerUp); + }, [isDragging]); + + useEffect(() => { + const track = trackRef.current; + const labelEl = labelRef.current; + const valueEl = valueRef.current; + if (!track || !labelEl || !valueEl) return; + + const bumpLayoutVersion = () => setLayoutVersion((v) => v + 1); + + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(() => { + bumpLayoutVersion(); + }); + observer.observe(track); + observer.observe(labelEl); + observer.observe(valueEl); + return () => observer.disconnect(); + } + + window.addEventListener("resize", bumpLayoutVersion); + return () => window.removeEventListener("resize", bumpLayoutVersion); + }, []); + + useLayoutEffect(() => { + const track = trackRef.current; + const labelEl = labelRef.current; + const valueEl = valueRef.current; + + if (!track || !labelEl || !valueEl) return; + + const trackRect = track.getBoundingClientRect(); + const labelRect = labelEl.getBoundingClientRect(); + const valueRect = valueEl.getBoundingClientRect(); + + const trackWidth = trackRect.width; + const valuePercent = sliderRangeToPercent({ value, min, max }); + // Use same inset coordinate system as visual elements + const thumbCenterPx = + (trackWidth * clampPercent(valuePercent)) / 100 + + getRadixThumbInBoundsOffsetPx(valuePercent); + const thumbHalfWidth = THUMB_WIDTH / 2; + + // Text is raised by TEXT_VERTICAL_OFFSET from center + const trackCenterY = TRACK_HEIGHT / 2 - TEXT_VERTICAL_OFFSET; + + const labelGap = calculateGap( + thumbCenterPx, + { + left: labelRect.left - trackRect.left, + right: labelRect.right - trackRect.left, + height: labelRect.height, + centerY: trackCenterY, + }, + true, + ); // label is left-aligned + + const valueGap = calculateGap( + thumbCenterPx, + { + left: valueRect.left - trackRect.left, + right: valueRect.right - trackRect.left, + height: valueRect.height, + centerY: trackCenterY, + }, + false, + ); // value is right-aligned + + setDragGap(Math.max(labelGap, valueGap)); + + // Tight intersection check for release state + // Inset by px-2 (8px) padding to check against actual text, not padded container + const labelLeft = labelRect.left - trackRect.left + TEXT_RELEASE_INSET; + const labelRight = labelRect.right - trackRect.left - TEXT_RELEASE_INSET; + const valueLeft = valueRect.left - trackRect.left + TEXT_RELEASE_INSET; + const valueRight = valueRect.right - trackRect.left - TEXT_RELEASE_INSET; + + const thumbLeft = thumbCenterPx - thumbHalfWidth; + const thumbRight = thumbCenterPx + thumbHalfWidth; + + const hitsLabel = thumbRight > labelLeft && thumbLeft < labelRight; + const hitsValue = thumbRight > valueLeft && thumbLeft < valueRight; + + setIntersectsText(hitsLabel || hitsValue); + + // Calculate full separation gap for release state + // Use the max gap of whichever text element(s) the handle intersects + const labelFullGap = labelRect.height + TEXT_PADDING_Y * 2; + const valueFullGap = valueRect.height + TEXT_PADDING_Y * 2; + const releaseGap = + hitsLabel && hitsValue + ? Math.max(labelFullGap, valueFullGap) + : hitsLabel + ? labelFullGap + : hitsValue + ? valueFullGap + : 0; + setFullGap(releaseGap); + }, [value, min, max, layoutVersion]); + + // While dragging: use distance-based separation, but never collapse below + // the release split when the thumb still intersects text. + const gap = isDragging + ? Math.max(dragGap, intersectsText ? fullGap : 0) + : intersectsText + ? fullGap + : 0; + + const ticks = useMemo(() => { + // Generate equidistant ticks regardless of step value + const majorTickCount = TICK_COUNT; + const result: { percent: number; isCenter: boolean; isSubtick: boolean }[] = + []; + + for (let i = 0; i <= majorTickCount; i++) { + const percent = (i / majorTickCount) * 100; + const isCenter = !crossesZero && percent === 50; + + // Skip the center tick (50%) for crossesZero sliders + if (crossesZero && percent === 50) continue; + + // Add subtick at midpoint before this tick (except for first) + if (i > 0) { + const prevPercent = ((i - 1) / majorTickCount) * 100; + // Don't add subtick if it would be at 50% for crossesZero + const midPercent = (prevPercent + percent) / 2; + if (!(crossesZero && midPercent === 50)) { + result.push({ + percent: midPercent, + isCenter: false, + isSubtick: true, + }); + } + } + + result.push({ percent, isCenter, isSubtick: false }); + } + + return result; + }, [crossesZero]); + + const zeroPercent = crossesZero + ? sliderRangeToPercent({ value: 0, min, max }) + : 0; + const valuePercent = sliderRangeToPercent({ value, min, max }); + + // Fill clip-path uses the same inset coordinate system as the handle. + // This keeps the collapsed stroke aligned with the fill edge near extremes. + const fillClipPath = useMemo(() => { + const toClipFromRightInset = (percent: number) => + `calc(100% - ${toRadixThumbPosition(percent)})`; + const toClipFromLeftInset = (percent: number) => + toRadixThumbPosition(percent); + const TERMINAL_EPSILON = 1e-6; + const snapLeftInset = (percent: number) => { + if (percent <= TERMINAL_EPSILON) return "0"; + if (percent >= 100 - TERMINAL_EPSILON) return "100%"; + return toClipFromLeftInset(percent); + }; + const snapRightInset = (percent: number) => { + if (percent <= TERMINAL_EPSILON) return "100%"; + if (percent >= 100 - TERMINAL_EPSILON) return "0"; + return toClipFromRightInset(percent); + }; + + if (crossesZero) { + // Keep center anchor stable by always clipping the low/high pair, + // independent of sign branch, then snapping at terminal edges. + const lowPercent = Math.min(valuePercent, zeroPercent); + const highPercent = Math.max(valuePercent, zeroPercent); + return `inset(0 ${snapRightInset(highPercent)} 0 ${snapLeftInset(lowPercent)})`; + } + // Non-crossing: fill starts at left edge; snap right inset at terminals. + return `inset(0 ${snapRightInset(valuePercent)} 0 0)`; + }, [crossesZero, zeroPercent, valuePercent]); + + const fillMaskImage = crossesZero + ? "linear-gradient(to right, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.35) 50%, rgba(0,0,0,0.7) 100%)" + : "linear-gradient(to right, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.7) 100%)"; + + // Metallic reflection gradient that follows the handle position + // Visible while dragging OR when resting at edges (0%/100%) + const reflectionStyle = useMemo(() => { + const edgeThreshold = 3; + const nearEdge = + valuePercent <= edgeThreshold || valuePercent >= 100 - edgeThreshold; + + // Narrower spread when stationary at edges (~35% narrower) + const spreadPercent = nearEdge && !isDragging ? 6.5 : 10; + const handlePos = toRadixThumbPosition(valuePercent); + const start = `clamp(0%, calc(${handlePos} - ${spreadPercent}%), 100%)`; + const end = `clamp(0%, calc(${handlePos} + ${spreadPercent}%), 100%)`; + + const gradient = `linear-gradient(to right, + transparent ${start}, + white ${handlePos}, + transparent ${end})`; + + return { + background: gradient, + WebkitMask: + "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)", + WebkitMaskComposite: "xor", + maskComposite: "exclude", + padding: "1px", + }; + }, [valuePercent, isDragging]); + + // Opacity scales with handle size: rest → hover → drag + const reflectionOpacity = useMemo(() => { + const edgeThreshold = 3; + const atEdge = + valuePercent <= edgeThreshold || valuePercent >= 100 - edgeThreshold; + + if (isDragging || atEdge) { + return 1; + } + if (isHovered) { + return 0.6; + } + return 0; + }, [valuePercent, isDragging, isHovered]); + + const handleValueChange = useCallback( + (values: number[]) => { + if (values[0] !== undefined) { + onChange(values[0]); + } + }, + [onChange], + ); + + return ( +
+ span]:transition-[left,transform] [&>span]:duration-45 [&>span]:ease-linear" + : "[&>span]:transition-[left,transform] [&>span]:duration-90 [&>span]:ease-[cubic-bezier(0.22,1,0.36,1)]", + "[&>span]:will-change-[left,transform]", + "motion-reduce:[&>span]:transition-none", + disabled && "pointer-events-none opacity-50", + )} + value={[value]} + onValueChange={handleValueChange} + onPointerDown={() => setIsDragging(true)} + onPointerUp={() => setIsDragging(false)} + onPointerEnter={() => setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + min={min} + max={max} + step={step} + disabled={disabled} + aria-valuetext={getAriaValueText(value, min, max, unit)} + > + +
+ + {ticks.map((tick, i) => { + const isEdge = + !tick.isSubtick && (tick.percent === 0 || tick.percent === 100); + return ( + + ); + })} + + + {/* Metallic reflection overlay - follows handle, brightness scales with interaction */} +
+ + + {(() => { + // Calculate morph state + const isActive = isHovered || isDragging; + + // Indicator stays centered on the real thumb while CSS transitions + // smooth thumb wrapper and fill movement together. + const fillEdgeOffset = 0; + + // Hide rest-state indicator at edges (0% or 100%) - the reflection gradient handles this + const edgeThreshold = 3; + const atEdge = + valuePercent <= edgeThreshold || + valuePercent >= 100 - edgeThreshold; + const restOpacity = atEdge ? 0 : 0.25; + + // Asymmetric segment heights: gap is shifted up to match raised text position + // Top segment is shorter, bottom segment is taller + const topHeight = + isActive && gap > 0 + ? `calc(50% - ${gap / 2 + TEXT_VERTICAL_OFFSET}px)` + : "50%"; + const bottomHeight = + isActive && gap > 0 + ? `calc(50% - ${gap / 2 - TEXT_VERTICAL_OFFSET}px)` + : "50%"; + + return ( + <> + 0 + ? "rounded-full" + : "rounded-t-full" + : "rounded-t-sm", + isDragging ? "w-2" : isActive ? "w-1.5" : "w-px", + resolvedHandleClassName ?? "bg-primary", + )} + style={{ + transform: `translateX(calc(-50% + ${fillEdgeOffset}px))`, + height: topHeight, + opacity: isActive ? 1 : restOpacity, + }} + /> + 0 + ? "rounded-full" + : "rounded-b-full" + : "rounded-b-sm", + isDragging ? "w-2" : isActive ? "w-1.5" : "w-px", + resolvedHandleClassName ?? "bg-primary", + )} + style={{ + transform: `translateX(calc(-50% + ${fillEdgeOffset}px))`, + height: bottomHeight, + opacity: isActive ? 1 : restOpacity, + }} + /> + + ); + })()} + + +
+ + {label} + + + {formatSignedValue(value, min, max, precision, unit)} + +
+ +
+ ); +} + +export function ParameterSlider({ + id, + sliders, + values: controlledValues, + onChange, + actions, + onAction, + onBeforeAction, + className, + trackClassName, + fillClassName, + handleClassName, +}: ParameterSliderProps) { + const slidersSignature = useMemo( + () => createSliderSignature(sliders), + [sliders], + ); + const sliderSnapshot = useMemo( + () => createSliderValueSnapshot(sliders), + [sliders], + ); + const { + value: currentValues, + isControlled, + setValue, + setUncontrolledValue, + } = useControllableState({ + value: controlledValues, + defaultValue: sliderSnapshot, + onChange, + }); + + useSignatureReset(slidersSignature, () => { + if (!isControlled) { + setUncontrolledValue(sliderSnapshot); + } + }); + + const valueMap = useMemo(() => { + const map = new Map(); + for (const v of currentValues) { + map.set(v.id, v.value); + } + return map; + }, [currentValues]); + + const updateValue = useCallback( + (sliderId: string, newValue: number) => { + setValue((prev) => + prev.map((v) => (v.id === sliderId ? { ...v, value: newValue } : v)), + ); + }, + [setValue], + ); + + const handleReset = useCallback(() => { + setValue(sliderSnapshot); + }, [setValue, sliderSnapshot]); + + const handleAction = useCallback( + async (actionId: string) => { + let nextValues = currentValues; + if (actionId === "reset") { + handleReset(); + nextValues = sliderSnapshot; + } + + await onAction?.(actionId, nextValues); + }, + [currentValues, handleReset, onAction, sliderSnapshot], + ); + + const normalizedActions = useMemo(() => { + const normalized = normalizeActionsConfig(actions); + if (normalized) return normalized; + return { + items: [ + { id: "reset", label: "Reset", variant: "ghost" as const }, + { id: "apply", label: "Apply", variant: "default" as const }, + ], + align: "right" as const, + }; + }, [actions]); + + return ( +
+
+ {sliders.map((slider) => ( + updateValue(slider.id, v)} + trackClassName={trackClassName} + fillClassName={fillClassName} + handleClassName={handleClassName} + /> + ))} +
+ +
+ onBeforeAction(actionId, currentValues) + : undefined + } + /> +
+
+ ); +} diff --git a/src/components/tool-ui/parameter-slider/schema.ts b/src/components/tool-ui/parameter-slider/schema.ts new file mode 100644 index 0000000..8667396 --- /dev/null +++ b/src/components/tool-ui/parameter-slider/schema.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; +import { type ActionsProp } from "../shared/actions-config"; +import type { EmbeddedActionsProps } from "../shared/embedded-actions"; +import { defineToolUiContract } from "../shared/contract"; +import { + SerializableActionSchema, + SerializableActionsConfigSchema, + ToolUIIdSchema, + ToolUIRoleSchema, +} from "../shared/schema"; + +export const SliderConfigSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + min: z.number().finite(), + max: z.number().finite(), + step: z.number().finite().positive().optional(), + value: z.number().finite(), + unit: z.string().optional(), + precision: z.number().int().min(0).optional(), + disabled: z.boolean().optional(), + trackClassName: z.string().optional(), + fillClassName: z.string().optional(), + handleClassName: z.string().optional(), + }) + .superRefine((slider, ctx) => { + if (slider.max <= slider.min) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["max"], + message: "max must be greater than min", + }); + } + + if (slider.value < slider.min || slider.value > slider.max) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["value"], + message: "value must be between min and max", + }); + } + }); + +export type SliderConfig = z.infer; + +export const SerializableParameterSliderSchema = z + .object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + sliders: z.array(SliderConfigSchema).min(1), + actions: z + .union([ + z.array(SerializableActionSchema), + SerializableActionsConfigSchema, + ]) + .optional(), + }) + .strict() + .superRefine((payload, ctx) => { + const seenIds = new Map(); + + payload.sliders.forEach((slider, index) => { + const firstSeenAt = seenIds.get(slider.id); + if (firstSeenAt !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["sliders", index, "id"], + message: `duplicate slider id '${slider.id}' (first seen at index ${firstSeenAt})`, + }); + return; + } + seenIds.set(slider.id, index); + }); + }); + +export type SerializableParameterSlider = z.infer< + typeof SerializableParameterSliderSchema +>; + +const SerializableParameterSliderSchemaContract = defineToolUiContract( + "ParameterSlider", + SerializableParameterSliderSchema, +); + +export const parseSerializableParameterSlider: ( + input: unknown, +) => SerializableParameterSlider = + SerializableParameterSliderSchemaContract.parse; + +export const safeParseSerializableParameterSlider: ( + input: unknown, +) => SerializableParameterSlider | null = + SerializableParameterSliderSchemaContract.safeParse; + +export interface SliderValue { + id: string; + value: number; +} + +export interface ParameterSliderProps extends Omit< + SerializableParameterSlider, + "actions" +> { + className?: string; + values?: SliderValue[]; + onChange?: (values: SliderValue[]) => void; + actions?: ActionsProp; + onAction?: EmbeddedActionsProps["onAction"]; + onBeforeAction?: EmbeddedActionsProps["onBeforeAction"]; + trackClassName?: string; + fillClassName?: string; + handleClassName?: string; +} diff --git a/src/components/tool-ui/shared/_adapter.tsx b/src/components/tool-ui/shared/_adapter.tsx new file mode 100644 index 0000000..ef8ffcb --- /dev/null +++ b/src/components/tool-ui/shared/_adapter.tsx @@ -0,0 +1,2 @@ +export { cn } from "@/lib/utils"; +export { Button } from "@/components/ui/button"; diff --git a/src/components/tool-ui/shared/action-buttons.tsx b/src/components/tool-ui/shared/action-buttons.tsx new file mode 100644 index 0000000..73a99af --- /dev/null +++ b/src/components/tool-ui/shared/action-buttons.tsx @@ -0,0 +1,100 @@ +"use client"; + +import type { Action } from "./schema"; +import { cn, Button } from "./_adapter"; +import { useActionButtons } from "./use-action-buttons"; + +export interface ActionButtonsProps { + actions: Action[]; + onAction: (actionId: string) => void | Promise; + onBeforeAction?: (actionId: string) => boolean | Promise; + confirmTimeout?: number; + align?: "left" | "center" | "right"; + className?: string; +} + +export function ActionButtons({ + actions, + onAction, + onBeforeAction, + confirmTimeout = 3000, + align = "right", + className, +}: ActionButtonsProps) { + const { actions: resolvedActions, runAction } = useActionButtons({ + actions, + onAction, + onBeforeAction, + confirmTimeout, + }); + + return ( +
+ {resolvedActions.map((action) => { + const label = action.currentLabel; + const variant = action.variant || "default"; + + return ( + + ); + })} +
+ ); +} diff --git a/src/components/tool-ui/shared/actions-config.ts b/src/components/tool-ui/shared/actions-config.ts new file mode 100644 index 0000000..a1b0aa5 --- /dev/null +++ b/src/components/tool-ui/shared/actions-config.ts @@ -0,0 +1,48 @@ +import type { Action, ActionsConfig } from "./schema"; + +export type ActionsProp = ActionsConfig | Action[]; + +const NEGATORY_ACTION_IDS = new Set([ + "cancel", + "dismiss", + "skip", + "no", + "reset", + "close", + "decline", + "reject", + "back", + "later", + "not-now", + "maybe-later", +]); + +function inferVariant(action: Action): Action { + if (action.variant) return action; + if (NEGATORY_ACTION_IDS.has(action.id)) { + return { ...action, variant: "ghost" }; + } + return action; +} + +export function normalizeActionsConfig( + actions?: ActionsProp, +): ActionsConfig | null { + if (!actions) return null; + + const rawItems = Array.isArray(actions) ? actions : (actions.items ?? []); + + if (rawItems.length === 0) { + return null; + } + + const items = rawItems.map(inferVariant); + + return Array.isArray(actions) + ? { items } + : { + items, + align: actions.align, + confirmTimeout: actions.confirmTimeout, + }; +} diff --git a/src/components/tool-ui/shared/contract.ts b/src/components/tool-ui/shared/contract.ts new file mode 100644 index 0000000..82bc05f --- /dev/null +++ b/src/components/tool-ui/shared/contract.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import { parseWithSchema, safeParseWithSchema } from "./parse"; + +export interface ToolUiContract { + schema: z.ZodType; + parse: (input: unknown) => T; + safeParse: (input: unknown) => T | null; +} + +export function defineToolUiContract( + componentName: string, + schema: z.ZodType, +): ToolUiContract { + return { + schema, + parse: (input: unknown) => parseWithSchema(schema, input, componentName), + safeParse: (input: unknown) => safeParseWithSchema(schema, input), + }; +} diff --git a/src/components/tool-ui/shared/embedded-actions.ts b/src/components/tool-ui/shared/embedded-actions.ts new file mode 100644 index 0000000..f0d318a --- /dev/null +++ b/src/components/tool-ui/shared/embedded-actions.ts @@ -0,0 +1,17 @@ +import type { ActionsProp } from "./actions-config"; + +export type EmbeddedActionHandler = ( + actionId: string, + state: TState, +) => void | Promise; + +export type EmbeddedBeforeActionHandler = ( + actionId: string, + state: TState, +) => boolean | Promise; + +export interface EmbeddedActionsProps { + actions?: ActionsProp; + onAction?: EmbeddedActionHandler; + onBeforeAction?: EmbeddedBeforeActionHandler; +} diff --git a/src/components/tool-ui/shared/parse.ts b/src/components/tool-ui/shared/parse.ts new file mode 100644 index 0000000..7214241 --- /dev/null +++ b/src/components/tool-ui/shared/parse.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +function formatZodPath(path: Array): string { + if (path.length === 0) return "root"; + return path + .map((segment) => + typeof segment === "number" ? `[${segment}]` : String(segment), + ) + .join("."); +} + +/** + * Format Zod errors into a compact `path: message` string. + */ +export function formatZodError(error: z.ZodError): string { + const parts = error.issues.map((issue) => { + const path = formatZodPath(issue.path); + return `${path}: ${issue.message}`; + }); + + return Array.from(new Set(parts)).join("; "); +} + +/** + * Parse unknown input and throw a readable error. + */ +export function parseWithSchema( + schema: z.ZodType, + input: unknown, + name: string, +): T { + const res = schema.safeParse(input); + if (!res.success) { + throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`); + } + return res.data; +} + +/** + * Parse unknown input, returning `null` instead of throwing on failure. + * + * Use this in assistant-ui `render` functions where `args` stream in + * incrementally and may be incomplete until the tool call finishes. + */ +export function safeParseWithSchema( + schema: z.ZodType, + input: unknown, +): T | null { + const res = schema.safeParse(input); + return res.success ? res.data : null; +} diff --git a/src/components/tool-ui/shared/schema.ts b/src/components/tool-ui/shared/schema.ts new file mode 100644 index 0000000..b19f4a3 --- /dev/null +++ b/src/components/tool-ui/shared/schema.ts @@ -0,0 +1,159 @@ +import { z } from "zod"; +import type { ReactNode } from "react"; + +/** + * Tool UI conventions: + * - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`). + * - Schema: `SerializableXSchema` + * - Parser: `parseSerializableX(input: unknown)` (throws on invalid) + * - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid) + * - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions + * - Root attrs: `data-tool-ui-id` + `data-slot` + */ + +/** + * Schema for tool UI identity. + * + * Every tool UI should have a unique identifier that: + * - Is stable across re-renders + * - Is meaningful (not auto-generated) + * - Is unique within the conversation + * + * Format recommendation: `{component-type}-{semantic-identifier}` + * Examples: "data-table-expenses-q3", "option-list-deploy-target" + */ +export const ToolUIIdSchema = z.string().min(1); + +export type ToolUIId = z.infer; + +/** + * Primary role of a Tool UI surface in a chat context. + */ +export const ToolUIRoleSchema = z.enum([ + "information", + "decision", + "control", + "state", + "composite", +]); + +export type ToolUIRole = z.infer; + +export const ToolUIReceiptOutcomeSchema = z.enum([ + "success", + "partial", + "failed", + "cancelled", +]); + +export type ToolUIReceiptOutcome = z.infer; + +/** + * Optional receipt metadata: a durable summary of an outcome. + */ +export const ToolUIReceiptSchema = z.object({ + outcome: ToolUIReceiptOutcomeSchema, + summary: z.string().min(1), + identifiers: z.record(z.string(), z.string()).optional(), + at: z.string().datetime(), +}); + +export type ToolUIReceipt = z.infer; + +/** + * Base schema for Tool UI payloads (id + optional role/receipt). + */ +export const ToolUISurfaceSchema = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + receipt: ToolUIReceiptSchema.optional(), +}); + +export type ToolUISurface = z.infer; + +export const ActionSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + /** + * Canonical narration the assistant can use after this action is taken. + * + * Example: "I exported the table as CSV." / "I opened the link in a new tab." + */ + sentence: z.string().optional(), + confirmLabel: z.string().optional(), + variant: z + .enum(["default", "destructive", "secondary", "ghost", "outline"]) + .optional(), + icon: z.custom().optional(), + loading: z.boolean().optional(), + disabled: z.boolean().optional(), + shortcut: z.string().optional(), +}); + +export type Action = z.infer; +export type LocalAction = Action; +export type DecisionAction = Action; + +export const DecisionResultSchema = z.object({ + kind: z.literal("decision"), + version: z.literal(1), + decisionId: z.string().min(1), + actionId: z.string().min(1), + actionLabel: z.string().min(1), + at: z.string().datetime(), + payload: z.record(z.string(), z.unknown()).optional(), +}); + +export type DecisionResult< + TPayload extends Record = Record, +> = Omit, "payload"> & { + payload?: TPayload; +}; + +export function createDecisionResult< + TPayload extends Record = Record, +>(args: { + decisionId: string; + action: { id: string; label: string }; + payload?: TPayload; +}): DecisionResult { + return { + kind: "decision", + version: 1, + decisionId: args.decisionId, + actionId: args.action.id, + actionLabel: args.action.label, + at: new Date().toISOString(), + payload: args.payload, + }; +} + +export const ActionButtonsPropsSchema = z.object({ + actions: z.array(ActionSchema).min(1), + align: z.enum(["left", "center", "right"]).optional(), + confirmTimeout: z.number().positive().optional(), + className: z.string().optional(), +}); + +export const SerializableActionSchema = ActionSchema.omit({ icon: true }); +export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({ + actions: z.array(SerializableActionSchema), +}).omit({ className: true }); + +export interface ActionsConfig { + items: Action[]; + align?: "left" | "center" | "right"; + confirmTimeout?: number; +} + +export const SerializableActionsConfigSchema = z.object({ + items: z.array(SerializableActionSchema).min(1), + align: z.enum(["left", "center", "right"]).optional(), + confirmTimeout: z.number().positive().optional(), +}); + +export type SerializableActionsConfig = z.infer< + typeof SerializableActionsConfigSchema +>; + +export type SerializableAction = z.infer; diff --git a/src/components/tool-ui/shared/use-action-buttons.tsx b/src/components/tool-ui/shared/use-action-buttons.tsx new file mode 100644 index 0000000..43144f3 --- /dev/null +++ b/src/components/tool-ui/shared/use-action-buttons.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { Action } from "./schema"; + +export type UseActionButtonsOptions = { + actions: Action[]; + onAction: (actionId: string) => void | Promise; + onBeforeAction?: (actionId: string) => boolean | Promise; + confirmTimeout?: number; +}; + +export type UseActionButtonsResult = { + actions: Array< + Action & { + currentLabel: string; + isConfirming: boolean; + isExecuting: boolean; + isDisabled: boolean; + isLoading: boolean; + } + >; + runAction: (actionId: string) => Promise; + confirmingActionId: string | null; + executingActionId: string | null; +}; + +type ActionExecutionLock = { + tryAcquire: () => boolean; + release: () => void; +}; + +export function createActionExecutionLock(): ActionExecutionLock { + let locked = false; + + return { + tryAcquire: () => { + if (locked) return false; + locked = true; + return true; + }, + release: () => { + locked = false; + }, + }; +} + +export function useActionButtons( + options: UseActionButtonsOptions, +): UseActionButtonsResult { + const { actions, onAction, onBeforeAction, confirmTimeout = 3000 } = options; + + const [confirmingActionId, setConfirmingActionId] = useState( + null, + ); + const [executingActionId, setExecutingActionId] = useState( + null, + ); + const executionLockRef = useRef( + createActionExecutionLock(), + ); + + useEffect(() => { + if (!confirmingActionId) return; + const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout); + return () => clearTimeout(id); + }, [confirmingActionId, confirmTimeout]); + + useEffect(() => { + if (!confirmingActionId) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setConfirmingActionId(null); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [confirmingActionId]); + + const runAction = useCallback( + async (actionId: string) => { + const action = actions.find((a) => a.id === actionId); + if (!action) return; + + const isAnyActionExecuting = executingActionId !== null; + if (action.disabled || action.loading || isAnyActionExecuting) { + return; + } + + if (action.confirmLabel && confirmingActionId !== action.id) { + setConfirmingActionId(action.id); + return; + } + + if (!executionLockRef.current.tryAcquire()) { + return; + } + + if (onBeforeAction) { + const shouldProceed = await onBeforeAction(action.id); + if (!shouldProceed) { + setConfirmingActionId(null); + executionLockRef.current.release(); + return; + } + } + + try { + setExecutingActionId(action.id); + await onAction(action.id); + } finally { + executionLockRef.current.release(); + setExecutingActionId(null); + setConfirmingActionId(null); + } + }, + [actions, confirmingActionId, executingActionId, onAction, onBeforeAction], + ); + + const resolvedActions = useMemo( + () => + actions.map((action) => { + const isConfirming = confirmingActionId === action.id; + const isThisActionExecuting = executingActionId === action.id; + const isLoading = action.loading || isThisActionExecuting; + const isDisabled = + action.disabled || + (executingActionId !== null && !isThisActionExecuting); + const currentLabel = + isConfirming && action.confirmLabel + ? action.confirmLabel + : action.label; + + return { + ...action, + currentLabel, + isConfirming, + isExecuting: isThisActionExecuting, + isDisabled, + isLoading, + }; + }), + [actions, confirmingActionId, executingActionId], + ); + + return { + actions: resolvedActions, + runAction, + confirmingActionId, + executingActionId, + }; +} diff --git a/src/components/tool-ui/shared/use-controllable-state.ts b/src/components/tool-ui/shared/use-controllable-state.ts new file mode 100644 index 0000000..8a9afdc --- /dev/null +++ b/src/components/tool-ui/shared/use-controllable-state.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState } from "react"; + +export type UseControllableStateOptions = { + value?: T; + defaultValue: T; + onChange?: (next: T) => void; +}; + +export function useControllableState({ + value, + defaultValue, + onChange, +}: UseControllableStateOptions) { + const [uncontrolled, setUncontrolled] = useState(defaultValue); + const isControlled = value !== undefined; + + const currentValue = useMemo( + () => (isControlled ? (value as T) : uncontrolled), + [isControlled, value, uncontrolled], + ); + const currentValueRef = useRef(currentValue); + currentValueRef.current = currentValue; + + const setValue = useCallback( + (next: T | ((prev: T) => T)) => { + const resolved = + typeof next === "function" + ? (next as (prev: T) => T)(currentValueRef.current) + : next; + + currentValueRef.current = resolved; + if (!isControlled) { + setUncontrolled(resolved); + } + + onChange?.(resolved); + return resolved; + }, + [isControlled, onChange], + ); + + const setUncontrolledValue = useCallback((next: T) => { + setUncontrolled(next); + }, []); + + return { + value: currentValue, + isControlled, + setValue, + setUncontrolledValue, + }; +} diff --git a/src/components/tool-ui/shared/use-signature-reset.ts b/src/components/tool-ui/shared/use-signature-reset.ts new file mode 100644 index 0000000..6939af8 --- /dev/null +++ b/src/components/tool-ui/shared/use-signature-reset.ts @@ -0,0 +1,16 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +export function useSignatureReset( + signature: string, + onSignatureChange: () => void, +) { + const previousSignature = useRef(signature); + + useEffect(() => { + if (previousSignature.current === signature) return; + previousSignature.current = signature; + onSignatureChange(); + }, [signature, onSignatureChange]); +}