Enhance canvas functionality with new node types and validation
- Added support for new canvas node types: curves, color-adjust, light-adjust, detail-adjust, and render. - Implemented validation for adjustment nodes to restrict incoming edges to one. - Updated canvas connection validation to improve user feedback on invalid connections. - Enhanced node creation and rendering logic to accommodate new node types and their properties. - Refactored related components and utilities for better maintainability and performance.
This commit is contained in:
@@ -12,6 +12,8 @@
|
|||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
"iconLibrary": "lucide",
|
||||||
"rtl": false,
|
"rtl": false,
|
||||||
|
"menuColor": "inverted",
|
||||||
|
"menuAccent": "subtle",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
@@ -19,7 +21,7 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"menuColor": "inverted",
|
"registries": {
|
||||||
"menuAccent": "subtle",
|
"@tool-ui": "https://www.tool-ui.com/r/{name}.json"
|
||||||
"registries": {}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
import {
|
import {
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Frame,
|
Frame,
|
||||||
|
Focus,
|
||||||
GitCompare,
|
GitCompare,
|
||||||
|
ImageDown,
|
||||||
Image,
|
Image,
|
||||||
Package,
|
Package,
|
||||||
|
Palette,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
|
Sun,
|
||||||
Type,
|
Type,
|
||||||
Video,
|
Video,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -29,6 +33,11 @@ const NODE_ICONS: Record<CanvasNodeTemplate["type"], LucideIcon> = {
|
|||||||
group: FolderOpen,
|
group: FolderOpen,
|
||||||
asset: Package,
|
asset: Package,
|
||||||
video: Video,
|
video: Video,
|
||||||
|
curves: Sparkles,
|
||||||
|
"color-adjust": Palette,
|
||||||
|
"light-adjust": Sun,
|
||||||
|
"detail-adjust": Focus,
|
||||||
|
render: ImageDown,
|
||||||
};
|
};
|
||||||
|
|
||||||
const NODE_SEARCH_KEYWORDS: Partial<
|
const NODE_SEARCH_KEYWORDS: Partial<
|
||||||
@@ -43,6 +52,11 @@ const NODE_SEARCH_KEYWORDS: Partial<
|
|||||||
group: ["group", "gruppe", "folder"],
|
group: ["group", "gruppe", "folder"],
|
||||||
asset: ["asset", "freepik", "stock"],
|
asset: ["asset", "freepik", "stock"],
|
||||||
video: ["video", "pexels", "clip"],
|
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 = {
|
export type CanvasNodeTemplatePickerProps = {
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import { useStore, type Edge as RFEdge } from "@xyflow/react";
|
|||||||
|
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||||
|
import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
||||||
import { isOptimisticEdgeId } from "./canvas-helpers";
|
import { isOptimisticEdgeId } from "./canvas-helpers";
|
||||||
|
|
||||||
type CreateNodeArgs = {
|
type CreateNodeArgs = {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
type: string;
|
type: CanvasNodeType;
|
||||||
positionX: number;
|
positionX: number;
|
||||||
positionY: number;
|
positionY: number;
|
||||||
width: number;
|
width: number;
|
||||||
@@ -28,7 +29,7 @@ type CreateNodeArgs = {
|
|||||||
|
|
||||||
type CreateNodeWithEdgeSplitArgs = {
|
type CreateNodeWithEdgeSplitArgs = {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
type: string;
|
type: CanvasNodeType;
|
||||||
positionX: number;
|
positionX: number;
|
||||||
positionY: number;
|
positionY: number;
|
||||||
width: number;
|
width: number;
|
||||||
@@ -70,7 +71,7 @@ type CreateNodeWithEdgeToTargetMutation = (
|
|||||||
type FlowPoint = { x: number; y: number };
|
type FlowPoint = { x: number; y: number };
|
||||||
|
|
||||||
type CreateNodeWithIntersectionInput = {
|
type CreateNodeWithIntersectionInput = {
|
||||||
type: string;
|
type: CanvasNodeType;
|
||||||
position: FlowPoint;
|
position: FlowPoint;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ type UseCanvasReconnectHandlersParams = {
|
|||||||
targetHandle?: string;
|
targetHandle?: string;
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
|
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
|
||||||
|
validateConnection?: (
|
||||||
|
oldEdge: RFEdge,
|
||||||
|
newConnection: Connection,
|
||||||
|
) => string | null;
|
||||||
|
onInvalidConnection?: (message: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCanvasReconnectHandlers({
|
export function useCanvasReconnectHandlers({
|
||||||
@@ -27,6 +32,8 @@ export function useCanvasReconnectHandlers({
|
|||||||
setEdges,
|
setEdges,
|
||||||
runCreateEdgeMutation,
|
runCreateEdgeMutation,
|
||||||
runRemoveEdgeMutation,
|
runRemoveEdgeMutation,
|
||||||
|
validateConnection,
|
||||||
|
onInvalidConnection,
|
||||||
}: UseCanvasReconnectHandlersParams): {
|
}: UseCanvasReconnectHandlersParams): {
|
||||||
onReconnectStart: () => void;
|
onReconnectStart: () => void;
|
||||||
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
|
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
|
||||||
@@ -45,11 +52,19 @@ export function useCanvasReconnectHandlers({
|
|||||||
|
|
||||||
const onReconnect = useCallback(
|
const onReconnect = useCallback(
|
||||||
(oldEdge: RFEdge, newConnection: Connection) => {
|
(oldEdge: RFEdge, newConnection: Connection) => {
|
||||||
|
const validationError = validateConnection?.(oldEdge, newConnection) ?? null;
|
||||||
|
if (validationError) {
|
||||||
|
edgeReconnectSuccessful.current = true;
|
||||||
|
pendingReconnectRef.current = null;
|
||||||
|
onInvalidConnection?.(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
edgeReconnectSuccessful.current = true;
|
edgeReconnectSuccessful.current = true;
|
||||||
pendingReconnectRef.current = { oldEdge, newConnection };
|
pendingReconnectRef.current = { oldEdge, newConnection };
|
||||||
setEdges((currentEdges) => reconnectEdge(oldEdge, newConnection, currentEdges));
|
setEdges((currentEdges) => reconnectEdge(oldEdge, newConnection, currentEdges));
|
||||||
},
|
},
|
||||||
[edgeReconnectSuccessful, setEdges],
|
[edgeReconnectSuccessful, onInvalidConnection, setEdges, validateConnection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onReconnectEnd = useCallback(
|
const onReconnectEnd = useCallback(
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ import {
|
|||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Doc, Id } from "@/convex/_generated/dataModel";
|
import type { Doc, Id } from "@/convex/_generated/dataModel";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import {
|
||||||
|
isAdjustmentNodeType,
|
||||||
|
isCanvasNodeType,
|
||||||
|
type CanvasNodeType,
|
||||||
|
} from "@/lib/canvas-node-types";
|
||||||
|
|
||||||
import { nodeTypes } from "./node-types";
|
import { nodeTypes } from "./node-types";
|
||||||
import {
|
import {
|
||||||
@@ -157,6 +162,54 @@ function hasStorageId(node: Doc<"nodes">): boolean {
|
|||||||
return typeof data?.storageId === "string" && data.storageId.length > 0;
|
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) {
|
function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||||
const t = useTranslations('toasts');
|
const t = useTranslations('toasts');
|
||||||
const { screenToFlowPosition } = useReactFlow();
|
const { screenToFlowPosition } = useReactFlow();
|
||||||
@@ -899,7 +952,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (op.type === "createNode") {
|
if (op.type === "createNode") {
|
||||||
const realId = await createNodeRaw(op.payload);
|
const realId = await createNodeRaw(
|
||||||
|
op.payload as Parameters<typeof createNodeRaw>[0],
|
||||||
|
);
|
||||||
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
|
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
|
||||||
await syncPendingMoveForClientRequestRef.current(
|
await syncPendingMoveForClientRequestRef.current(
|
||||||
op.payload.clientRequestId,
|
op.payload.clientRequestId,
|
||||||
@@ -907,7 +962,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
);
|
);
|
||||||
setEdgeSyncNonce((value) => value + 1);
|
setEdgeSyncNonce((value) => value + 1);
|
||||||
} else if (op.type === "createNodeWithEdgeFromSource") {
|
} else if (op.type === "createNodeWithEdgeFromSource") {
|
||||||
const realId = await createNodeWithEdgeFromSourceRaw(op.payload);
|
const realId = await createNodeWithEdgeFromSourceRaw(
|
||||||
|
op.payload as Parameters<typeof createNodeWithEdgeFromSourceRaw>[0],
|
||||||
|
);
|
||||||
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
|
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
|
||||||
await syncPendingMoveForClientRequestRef.current(
|
await syncPendingMoveForClientRequestRef.current(
|
||||||
op.payload.clientRequestId,
|
op.payload.clientRequestId,
|
||||||
@@ -915,7 +972,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
);
|
);
|
||||||
setEdgeSyncNonce((value) => value + 1);
|
setEdgeSyncNonce((value) => value + 1);
|
||||||
} else if (op.type === "createNodeWithEdgeToTarget") {
|
} else if (op.type === "createNodeWithEdgeToTarget") {
|
||||||
const realId = await createNodeWithEdgeToTargetRaw(op.payload);
|
const realId = await createNodeWithEdgeToTargetRaw(
|
||||||
|
op.payload as Parameters<typeof createNodeWithEdgeToTargetRaw>[0],
|
||||||
|
);
|
||||||
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
|
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
|
||||||
await syncPendingMoveForClientRequestRef.current(
|
await syncPendingMoveForClientRequestRef.current(
|
||||||
op.payload.clientRequestId,
|
op.payload.clientRequestId,
|
||||||
@@ -923,7 +982,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
);
|
);
|
||||||
setEdgeSyncNonce((value) => value + 1);
|
setEdgeSyncNonce((value) => value + 1);
|
||||||
} else if (op.type === "createNodeWithEdgeSplit") {
|
} else if (op.type === "createNodeWithEdgeSplit") {
|
||||||
const realId = await createNodeWithEdgeSplitRaw(op.payload);
|
const realId = await createNodeWithEdgeSplitRaw(
|
||||||
|
op.payload as Parameters<typeof createNodeWithEdgeSplitRaw>[0],
|
||||||
|
);
|
||||||
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
|
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
|
||||||
await syncPendingMoveForClientRequestRef.current(
|
await syncPendingMoveForClientRequestRef.current(
|
||||||
op.payload.clientRequestId,
|
op.payload.clientRequestId,
|
||||||
@@ -1625,6 +1686,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
setEdges,
|
setEdges,
|
||||||
runCreateEdgeMutation,
|
runCreateEdgeMutation,
|
||||||
runRemoveEdgeMutation,
|
runRemoveEdgeMutation,
|
||||||
|
validateConnection: (oldEdge, nextConnection) =>
|
||||||
|
validateCanvasConnection(nextConnection, nodes, edges, oldEdge.id),
|
||||||
|
onInvalidConnection: (message) => {
|
||||||
|
toast.warning("Verbindung abgelehnt", message);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Convex → Lokaler State Sync ──────────────────────────────
|
// ─── Convex → Lokaler State Sync ──────────────────────────────
|
||||||
@@ -2262,17 +2328,23 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
// ─── Neue Verbindung → Convex Edge ────────────────────────────
|
// ─── Neue Verbindung → Convex Edge ────────────────────────────
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(connection: Connection) => {
|
(connection: Connection) => {
|
||||||
if (connection.source && connection.target) {
|
const validationError = validateCanvasConnection(connection, nodes, edges);
|
||||||
void runCreateEdgeMutation({
|
if (validationError) {
|
||||||
canvasId,
|
toast.warning("Verbindung abgelehnt", validationError);
|
||||||
sourceNodeId: connection.source as Id<"nodes">,
|
return;
|
||||||
targetNodeId: connection.target as Id<"nodes">,
|
|
||||||
sourceHandle: connection.sourceHandle ?? undefined,
|
|
||||||
targetHandle: connection.targetHandle ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<OnConnectEnd>(
|
const onConnectEnd = useCallback<OnConnectEnd>(
|
||||||
@@ -2477,19 +2549,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Support both plain type string (sidebar) and JSON payload (browser panels)
|
// Support both plain type string (sidebar) and JSON payload (browser panels)
|
||||||
let nodeType: string;
|
let nodeType: CanvasNodeType | null = null;
|
||||||
let payloadData: Record<string, unknown> | undefined;
|
let payloadData: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(rawData);
|
const parsed = JSON.parse(rawData);
|
||||||
if (typeof parsed === "object" && parsed.type) {
|
if (
|
||||||
nodeType = parsed.type;
|
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;
|
payloadData = parsed.data;
|
||||||
} else {
|
|
||||||
nodeType = rawData;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} 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({
|
const position = screenToFlowPosition({
|
||||||
@@ -2526,6 +2608,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
screenToFlowPosition,
|
screenToFlowPosition,
|
||||||
|
t,
|
||||||
canvasId,
|
canvasId,
|
||||||
generateUploadUrl,
|
generateUploadUrl,
|
||||||
isSyncOnline,
|
isSyncOnline,
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import NoteNode from "./nodes/note-node";
|
|||||||
import CompareNode from "./nodes/compare-node";
|
import CompareNode from "./nodes/compare-node";
|
||||||
import AssetNode from "./nodes/asset-node";
|
import AssetNode from "./nodes/asset-node";
|
||||||
import VideoNode from "./nodes/video-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.
|
* Node-Type-Map für React Flow.
|
||||||
@@ -27,4 +32,9 @@ export const nodeTypes = {
|
|||||||
compare: CompareNode,
|
compare: CompareNode,
|
||||||
asset: AssetNode,
|
asset: AssetNode,
|
||||||
video: VideoNode,
|
video: VideoNode,
|
||||||
|
curves: CurvesNode,
|
||||||
|
"color-adjust": ColorAdjustNode,
|
||||||
|
"light-adjust": LightAdjustNode,
|
||||||
|
"detail-adjust": DetailAdjustNode,
|
||||||
|
render: RenderNode,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
38
components/canvas/nodes/adjustment-controls.tsx
Normal file
38
components/canvas/nodes/adjustment-controls.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="font-medium text-foreground">{value.toFixed(step < 1 ? 2 : 0)}</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[value]}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
onValueChange={(values) => {
|
||||||
|
onChange(values[0] ?? value);
|
||||||
|
}}
|
||||||
|
className="nodrag nowheel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
components/canvas/nodes/adjustment-preview.tsx
Normal file
220
components/canvas/nodes/adjustment-preview.tsx
Normal file
@@ -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<string, unknown>;
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden rounded-md border border-border bg-muted/30"
|
||||||
|
style={{ aspectRatio: `${Math.max(0.25, previewAspectRatio)}` }}
|
||||||
|
>
|
||||||
|
{!hasSource ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center px-3 text-center text-[11px] text-muted-foreground">
|
||||||
|
Verbinde eine Bild-, Asset- oder KI-Bild-Node fuer Live-Preview.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{hasSource ? (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{isRendering ? (
|
||||||
|
<div className="absolute right-1 top-1 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
Rendering...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="absolute bottom-2 right-2 z-10 w-28 rounded-md border border-border/80 bg-background/85 px-2 py-1.5 backdrop-blur-sm">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 96 44"
|
||||||
|
className="h-11 w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label="Histogramm als RGB-Linienkurven"
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
points={histogramPolylines.rgb}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(248, 250, 252, 0.9)"
|
||||||
|
strokeWidth={1.6}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points={histogramPolylines.red}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(248, 113, 113, 0.9)"
|
||||||
|
strokeWidth={1.2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points={histogramPolylines.green}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(74, 222, 128, 0.85)"
|
||||||
|
strokeWidth={1.2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points={histogramPolylines.blue}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(96, 165, 250, 0.88)"
|
||||||
|
strokeWidth={1.2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error ? <p className="text-[11px] text-destructive">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import { Trash2, Copy } from "lucide-react";
|
import { Trash2, Copy } from "lucide-react";
|
||||||
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
||||||
|
import { isCanvasNodeType } from "@/lib/canvas-node-types";
|
||||||
import { NodeErrorBoundary } from "./node-error-boundary";
|
import { NodeErrorBoundary } from "./node-error-boundary";
|
||||||
|
|
||||||
interface ResizeConfig {
|
interface ResizeConfig {
|
||||||
@@ -38,6 +39,11 @@ const RESIZE_CONFIGS: Record<string, ResizeConfig> = {
|
|||||||
"ai-image": { minWidth: 200, minHeight: 208, keepAspectRatio: false },
|
"ai-image": { minWidth: 200, minHeight: 208, keepAspectRatio: false },
|
||||||
compare: { minWidth: 300, minHeight: 200 },
|
compare: { minWidth: 300, minHeight: 200 },
|
||||||
prompt: { minWidth: 260, minHeight: 220 },
|
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 },
|
text: { minWidth: 220, minHeight: 90 },
|
||||||
note: { minWidth: 200, minHeight: 90 },
|
note: { minWidth: 200, minHeight: 90 },
|
||||||
};
|
};
|
||||||
@@ -58,6 +64,19 @@ const INTERNAL_FIELDS = new Set([
|
|||||||
"retryCount",
|
"retryCount",
|
||||||
"url",
|
"url",
|
||||||
"canvasId",
|
"canvasId",
|
||||||
|
"lastRenderedAt",
|
||||||
|
"lastRenderedHash",
|
||||||
|
"lastRenderWidth",
|
||||||
|
"lastRenderHeight",
|
||||||
|
"lastRenderFormat",
|
||||||
|
"lastRenderMimeType",
|
||||||
|
"lastRenderSizeBytes",
|
||||||
|
"lastRenderQuality",
|
||||||
|
"lastRenderSourceWidth",
|
||||||
|
"lastRenderSourceHeight",
|
||||||
|
"lastRenderWasSizeClamped",
|
||||||
|
"lastRenderError",
|
||||||
|
"lastRenderErrorHash",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function NodeToolbarActions({
|
function NodeToolbarActions({
|
||||||
@@ -128,7 +147,10 @@ function NodeToolbarActions({
|
|||||||
|
|
||||||
// Fire-and-forget: optimistic update makes the duplicate appear instantly
|
// Fire-and-forget: optimistic update makes the duplicate appear instantly
|
||||||
void createNodeWithIntersection({
|
void createNodeWithIntersection({
|
||||||
type: node.type ?? "text",
|
type:
|
||||||
|
typeof node.type === "string" && isCanvasNodeType(node.type)
|
||||||
|
? node.type
|
||||||
|
: "text",
|
||||||
position: {
|
position: {
|
||||||
x: originalPosition.x + 50,
|
x: originalPosition.x + 50,
|
||||||
y: originalPosition.y + 50,
|
y: originalPosition.y + 50,
|
||||||
@@ -213,6 +235,7 @@ export default function BaseNodeWrapper({
|
|||||||
analyzing: "border-yellow-400 animate-pulse",
|
analyzing: "border-yellow-400 animate-pulse",
|
||||||
clarifying: "border-amber-400",
|
clarifying: "border-amber-400",
|
||||||
executing: "border-yellow-400 animate-pulse",
|
executing: "border-yellow-400 animate-pulse",
|
||||||
|
rendering: "border-yellow-400 animate-pulse",
|
||||||
done: "border-green-500",
|
done: "border-green-500",
|
||||||
error: "border-red-500",
|
error: "border-red-500",
|
||||||
};
|
};
|
||||||
|
|||||||
257
components/canvas/nodes/color-adjust-node.tsx
Normal file
257
components/canvas/nodes/color-adjust-node.tsx
Normal file
@@ -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<ColorAdjustNodeData, "color-adjust">;
|
||||||
|
|
||||||
|
type PresetDoc = {
|
||||||
|
_id: Id<"adjustmentPresets">;
|
||||||
|
name: string;
|
||||||
|
params: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ColorAdjustNode({ id, data, selected, width }: NodeProps<ColorAdjustNodeType>) {
|
||||||
|
const { queueNodeDataUpdate } = useCanvasSync();
|
||||||
|
const savePreset = useMutation(api.presets.save);
|
||||||
|
const userPresets = (useAuthQuery(api.presets.list, { nodeType: "color-adjust" }) ?? []) as PresetDoc[];
|
||||||
|
|
||||||
|
const [localData, setLocalData] = useState<ColorAdjustData>(() =>
|
||||||
|
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 (
|
||||||
|
<BaseNodeWrapper
|
||||||
|
nodeType="color-adjust"
|
||||||
|
selected={selected}
|
||||||
|
status={data._status}
|
||||||
|
statusMessage={data._statusMessage}
|
||||||
|
className="min-w-[240px] border-cyan-500/30"
|
||||||
|
>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-cyan-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-cyan-700 dark:text-cyan-400">
|
||||||
|
<Palette className="h-3.5 w-3.5" />
|
||||||
|
Farbe
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={presetSelection} onValueChange={applyPresetValue}>
|
||||||
|
<SelectTrigger className="nodrag h-8 text-xs" size="sm">
|
||||||
|
<SelectValue placeholder="Preset" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="nodrag">
|
||||||
|
<SelectItem value="custom">Custom</SelectItem>
|
||||||
|
{builtinOptions.map(([name]) => (
|
||||||
|
<SelectItem key={name} value={`builtin:${name}`}>
|
||||||
|
Built-in: {name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{userPresets.map((preset) => (
|
||||||
|
<SelectItem key={preset._id} value={`user:${preset._id}`}>
|
||||||
|
User: {preset.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nodrag rounded-md border px-2 py-1 text-[11px]"
|
||||||
|
onClick={() => {
|
||||||
|
void handleSavePreset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdjustmentPreview
|
||||||
|
nodeId={id}
|
||||||
|
nodeWidth={width ?? 240}
|
||||||
|
currentType="color-adjust"
|
||||||
|
currentParams={localData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-md border border-border/80 bg-background/70 p-2">
|
||||||
|
<SliderRow
|
||||||
|
label="Hue"
|
||||||
|
value={localData.hsl.hue}
|
||||||
|
min={-180}
|
||||||
|
max={180}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateData((current) => ({
|
||||||
|
...current,
|
||||||
|
hsl: { ...current.hsl, hue: value },
|
||||||
|
preset: null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SliderRow
|
||||||
|
label="Saturation"
|
||||||
|
value={localData.hsl.saturation}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateData((current) => ({
|
||||||
|
...current,
|
||||||
|
hsl: { ...current.hsl, saturation: value },
|
||||||
|
preset: null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SliderRow
|
||||||
|
label="Luminance"
|
||||||
|
value={localData.hsl.luminance}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateData((current) => ({
|
||||||
|
...current,
|
||||||
|
hsl: { ...current.hsl, luminance: value },
|
||||||
|
preset: null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SliderRow
|
||||||
|
label="Temperature"
|
||||||
|
value={localData.temperature}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateData((current) => ({ ...current, temperature: value, preset: null }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SliderRow
|
||||||
|
label="Tint"
|
||||||
|
value={localData.tint}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateData((current) => ({ ...current, tint: value, preset: null }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SliderRow
|
||||||
|
label="Vibrance"
|
||||||
|
value={localData.vibrance}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateData((current) => ({ ...current, vibrance: value, preset: null }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-cyan-500"
|
||||||
|
/>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
components/canvas/nodes/curves-node.tsx
Normal file
232
components/canvas/nodes/curves-node.tsx
Normal file
@@ -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<CurvesNodeData, "curves">;
|
||||||
|
|
||||||
|
type PresetDoc = {
|
||||||
|
_id: Id<"adjustmentPresets">;
|
||||||
|
name: string;
|
||||||
|
params: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CurvesNode({ id, data, selected, width }: NodeProps<CurvesNodeType>) {
|
||||||
|
const { queueNodeDataUpdate } = useCanvasSync();
|
||||||
|
const savePreset = useMutation(api.presets.save);
|
||||||
|
const userPresets = (useAuthQuery(api.presets.list, { nodeType: "curves" }) ?? []) as PresetDoc[];
|
||||||
|
|
||||||
|
const [localData, setLocalData] = useState<CurvesData>(() =>
|
||||||
|
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 (
|
||||||
|
<BaseNodeWrapper
|
||||||
|
nodeType="curves"
|
||||||
|
selected={selected}
|
||||||
|
status={data._status}
|
||||||
|
statusMessage={data._statusMessage}
|
||||||
|
className="min-w-[240px] border-emerald-500/30"
|
||||||
|
>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-700 dark:text-emerald-400">
|
||||||
|
<TrendingUp className="h-3.5 w-3.5" />
|
||||||
|
Kurven
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={presetSelection} onValueChange={applyPresetValue}>
|
||||||
|
<SelectTrigger className="nodrag h-8 text-xs" size="sm">
|
||||||
|
<SelectValue placeholder="Preset" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="nodrag">
|
||||||
|
<SelectItem value="custom">Custom</SelectItem>
|
||||||
|
{builtinOptions.map(([name]) => (
|
||||||
|
<SelectItem key={name} value={`builtin:${name}`}>
|
||||||
|
Built-in: {name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{userPresets.map((preset) => (
|
||||||
|
<SelectItem key={preset._id} value={`user:${preset._id}`}>
|
||||||
|
User: {preset.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nodrag rounded-md border px-2 py-1 text-[11px]"
|
||||||
|
onClick={() => {
|
||||||
|
void handleSavePreset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdjustmentPreview
|
||||||
|
nodeId={id}
|
||||||
|
nodeWidth={width ?? 240}
|
||||||
|
currentType="curves"
|
||||||
|
currentParams={localData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-md border border-border/80 bg-background/70 p-2">
|
||||||
|
<SliderRow
|
||||||
|
label="Black Point"
|
||||||
|
value={localData.levels.blackPoint}
|
||||||
|
min={0}
|
||||||
|
max={255}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateData((current) => ({
|
||||||
|
...current,
|
||||||
|
levels: { ...current.levels, blackPoint: value },
|
||||||
|
preset: null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SliderRow
|
||||||
|
label="White Point"
|
||||||
|
value={localData.levels.whitePoint}
|
||||||
|
min={0}
|
||||||
|
max={255}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateData((current) => ({
|
||||||
|
...current,
|
||||||
|
levels: { ...current.levels, whitePoint: value },
|
||||||
|
preset: null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SliderRow
|
||||||
|
label="Gamma"
|
||||||
|
value={localData.levels.gamma}
|
||||||
|
min={0.1}
|
||||||
|
max={3}
|
||||||
|
step={0.01}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateData((current) => ({
|
||||||
|
...current,
|
||||||
|
levels: { ...current.levels, gamma: value },
|
||||||
|
preset: null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
||||||
|
/>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
components/canvas/nodes/detail-adjust-node.tsx
Normal file
198
components/canvas/nodes/detail-adjust-node.tsx
Normal file
@@ -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<DetailAdjustNodeData, "detail-adjust">;
|
||||||
|
|
||||||
|
type PresetDoc = {
|
||||||
|
_id: Id<"adjustmentPresets">;
|
||||||
|
name: string;
|
||||||
|
params: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DetailAdjustNode({ id, data, selected, width }: NodeProps<DetailAdjustNodeType>) {
|
||||||
|
const { queueNodeDataUpdate } = useCanvasSync();
|
||||||
|
const savePreset = useMutation(api.presets.save);
|
||||||
|
const userPresets = (useAuthQuery(api.presets.list, { nodeType: "detail-adjust" }) ?? []) as PresetDoc[];
|
||||||
|
|
||||||
|
const [localData, setLocalData] = useState<DetailAdjustData>(() =>
|
||||||
|
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 (
|
||||||
|
<BaseNodeWrapper
|
||||||
|
nodeType="detail-adjust"
|
||||||
|
selected={selected}
|
||||||
|
status={data._status}
|
||||||
|
statusMessage={data._statusMessage}
|
||||||
|
className="min-w-[240px] border-indigo-500/30"
|
||||||
|
>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-indigo-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-indigo-700 dark:text-indigo-300">
|
||||||
|
<Focus className="h-3.5 w-3.5" />
|
||||||
|
Detail
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={presetSelection} onValueChange={applyPresetValue}>
|
||||||
|
<SelectTrigger className="nodrag h-8 text-xs" size="sm">
|
||||||
|
<SelectValue placeholder="Preset" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="nodrag">
|
||||||
|
<SelectItem value="custom">Custom</SelectItem>
|
||||||
|
{builtinOptions.map(([name]) => (
|
||||||
|
<SelectItem key={name} value={`builtin:${name}`}>
|
||||||
|
Built-in: {name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{userPresets.map((preset) => (
|
||||||
|
<SelectItem key={preset._id} value={`user:${preset._id}`}>
|
||||||
|
User: {preset.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nodrag rounded-md border px-2 py-1 text-[11px]"
|
||||||
|
onClick={() => {
|
||||||
|
void handleSavePreset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdjustmentPreview
|
||||||
|
nodeId={id}
|
||||||
|
nodeWidth={width ?? 240}
|
||||||
|
currentType="detail-adjust"
|
||||||
|
currentParams={localData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-md border border-border/80 bg-background/70 p-2">
|
||||||
|
<SliderRow label="Sharpen" value={localData.sharpen.amount} min={0} max={500} onChange={(value) => updateData((current) => ({ ...current, sharpen: { ...current.sharpen, amount: value }, preset: null }))} />
|
||||||
|
<SliderRow label="Radius" value={localData.sharpen.radius} min={0.5} max={5} step={0.01} onChange={(value) => updateData((current) => ({ ...current, sharpen: { ...current.sharpen, radius: value }, preset: null }))} />
|
||||||
|
<SliderRow label="Threshold" value={localData.sharpen.threshold} min={0} max={255} onChange={(value) => updateData((current) => ({ ...current, sharpen: { ...current.sharpen, threshold: value }, preset: null }))} />
|
||||||
|
<SliderRow label="Clarity" value={localData.clarity} min={-100} max={100} onChange={(value) => updateData((current) => ({ ...current, clarity: value, preset: null }))} />
|
||||||
|
<SliderRow label="Denoise Luma" value={localData.denoise.luminance} min={0} max={100} onChange={(value) => updateData((current) => ({ ...current, denoise: { ...current.denoise, luminance: value }, preset: null }))} />
|
||||||
|
<SliderRow label="Denoise Color" value={localData.denoise.color} min={0} max={100} onChange={(value) => updateData((current) => ({ ...current, denoise: { ...current.denoise, color: value }, preset: null }))} />
|
||||||
|
<SliderRow label="Grain" value={localData.grain.amount} min={0} max={100} onChange={(value) => updateData((current) => ({ ...current, grain: { ...current.grain, amount: value }, preset: null }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-indigo-500"
|
||||||
|
/>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
components/canvas/nodes/light-adjust-node.tsx
Normal file
199
components/canvas/nodes/light-adjust-node.tsx
Normal file
@@ -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<LightAdjustNodeData, "light-adjust">;
|
||||||
|
|
||||||
|
type PresetDoc = {
|
||||||
|
_id: Id<"adjustmentPresets">;
|
||||||
|
name: string;
|
||||||
|
params: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LightAdjustNode({ id, data, selected, width }: NodeProps<LightAdjustNodeType>) {
|
||||||
|
const { queueNodeDataUpdate } = useCanvasSync();
|
||||||
|
const savePreset = useMutation(api.presets.save);
|
||||||
|
const userPresets = (useAuthQuery(api.presets.list, { nodeType: "light-adjust" }) ?? []) as PresetDoc[];
|
||||||
|
|
||||||
|
const [localData, setLocalData] = useState<LightAdjustData>(() =>
|
||||||
|
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 (
|
||||||
|
<BaseNodeWrapper
|
||||||
|
nodeType="light-adjust"
|
||||||
|
selected={selected}
|
||||||
|
status={data._status}
|
||||||
|
statusMessage={data._statusMessage}
|
||||||
|
className="min-w-[240px] border-amber-500/30"
|
||||||
|
>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-amber-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||||
|
<Sun className="h-3.5 w-3.5" />
|
||||||
|
Licht
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={presetSelection} onValueChange={applyPresetValue}>
|
||||||
|
<SelectTrigger className="nodrag h-8 text-xs" size="sm">
|
||||||
|
<SelectValue placeholder="Preset" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="nodrag">
|
||||||
|
<SelectItem value="custom">Custom</SelectItem>
|
||||||
|
{builtinOptions.map(([name]) => (
|
||||||
|
<SelectItem key={name} value={`builtin:${name}`}>
|
||||||
|
Built-in: {name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{userPresets.map((preset) => (
|
||||||
|
<SelectItem key={preset._id} value={`user:${preset._id}`}>
|
||||||
|
User: {preset.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nodrag rounded-md border px-2 py-1 text-[11px]"
|
||||||
|
onClick={() => {
|
||||||
|
void handleSavePreset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdjustmentPreview
|
||||||
|
nodeId={id}
|
||||||
|
nodeWidth={width ?? 240}
|
||||||
|
currentType="light-adjust"
|
||||||
|
currentParams={localData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-md border border-border/80 bg-background/70 p-2">
|
||||||
|
<SliderRow label="Brightness" value={localData.brightness} min={-100} max={100} onChange={(value) => updateData((current) => ({ ...current, brightness: value, preset: null }))} />
|
||||||
|
<SliderRow label="Contrast" value={localData.contrast} min={-100} max={100} onChange={(value) => updateData((current) => ({ ...current, contrast: value, preset: null }))} />
|
||||||
|
<SliderRow label="Exposure" value={localData.exposure} min={-5} max={5} step={0.01} onChange={(value) => updateData((current) => ({ ...current, exposure: value, preset: null }))} />
|
||||||
|
<SliderRow label="Highlights" value={localData.highlights} min={-100} max={100} onChange={(value) => updateData((current) => ({ ...current, highlights: value, preset: null }))} />
|
||||||
|
<SliderRow label="Shadows" value={localData.shadows} min={-100} max={100} onChange={(value) => updateData((current) => ({ ...current, shadows: value, preset: null }))} />
|
||||||
|
<SliderRow label="Whites" value={localData.whites} min={-100} max={100} onChange={(value) => updateData((current) => ({ ...current, whites: value, preset: null }))} />
|
||||||
|
<SliderRow label="Blacks" value={localData.blacks} min={-100} max={100} onChange={(value) => updateData((current) => ({ ...current, blacks: value, preset: null }))} />
|
||||||
|
<SliderRow label="Vignette" value={localData.vignette.amount} min={0} max={1} step={0.01} onChange={(value) => updateData((current) => ({ ...current, vignette: { ...current.vignette, amount: value }, preset: null }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-amber-500"
|
||||||
|
/>
|
||||||
|
</BaseNodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
1213
components/canvas/nodes/render-node.tsx
Normal file
1213
components/canvas/nodes/render-node.tsx
Normal file
File diff suppressed because it is too large
Load Diff
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -17,10 +17,12 @@ import type * as export_ from "../export.js";
|
|||||||
import type * as freepik from "../freepik.js";
|
import type * as freepik from "../freepik.js";
|
||||||
import type * as helpers from "../helpers.js";
|
import type * as helpers from "../helpers.js";
|
||||||
import type * as http from "../http.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 nodes from "../nodes.js";
|
||||||
import type * as openrouter from "../openrouter.js";
|
import type * as openrouter from "../openrouter.js";
|
||||||
import type * as pexels from "../pexels.js";
|
import type * as pexels from "../pexels.js";
|
||||||
import type * as polar from "../polar.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 storage from "../storage.js";
|
||||||
import type * as users from "../users.js";
|
import type * as users from "../users.js";
|
||||||
|
|
||||||
@@ -40,10 +42,12 @@ declare const fullApi: ApiFromModules<{
|
|||||||
freepik: typeof freepik;
|
freepik: typeof freepik;
|
||||||
helpers: typeof helpers;
|
helpers: typeof helpers;
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
|
node_type_validator: typeof node_type_validator;
|
||||||
nodes: typeof nodes;
|
nodes: typeof nodes;
|
||||||
openrouter: typeof openrouter;
|
openrouter: typeof openrouter;
|
||||||
pexels: typeof pexels;
|
pexels: typeof pexels;
|
||||||
polar: typeof polar;
|
polar: typeof polar;
|
||||||
|
presets: typeof presets;
|
||||||
storage: typeof storage;
|
storage: typeof storage;
|
||||||
users: typeof users;
|
users: typeof users;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -1,7 +1,36 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation, type MutationCtx } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { requireAuth } from "./helpers";
|
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<void> {
|
||||||
|
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
|
// Queries
|
||||||
@@ -89,6 +118,10 @@ export const create = mutation({
|
|||||||
throw new Error("Cannot connect a node to itself");
|
throw new Error("Cannot connect a node to itself");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await assertTargetAllowsIncomingEdge(ctx, {
|
||||||
|
targetNodeId: args.targetNodeId,
|
||||||
|
});
|
||||||
|
|
||||||
const edgeId = await ctx.db.insert("edges", {
|
const edgeId = await ctx.db.insert("edges", {
|
||||||
canvasId: args.canvasId,
|
canvasId: args.canvasId,
|
||||||
sourceNodeId: args.sourceNodeId,
|
sourceNodeId: args.sourceNodeId,
|
||||||
|
|||||||
203
convex/nodes.ts
203
convex/nodes.ts
@@ -3,7 +3,7 @@ import { v } from "convex/values";
|
|||||||
import { requireAuth } from "./helpers";
|
import { requireAuth } from "./helpers";
|
||||||
import type { Doc, Id } from "./_generated/dataModel";
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
import { isAdjustmentNodeType } from "../lib/canvas-node-types";
|
import { isAdjustmentNodeType } from "../lib/canvas-node-types";
|
||||||
import { nodeTypeValidator } from "./node-type-validator";
|
import { nodeTypeValidator } from "./node_type_validator";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Interne Helpers
|
// Interne Helpers
|
||||||
@@ -60,6 +60,7 @@ const CUSTOM_RENDER_DIMENSION_MAX = 16384;
|
|||||||
const DEFAULT_RENDER_OUTPUT_RESOLUTION = "original" as const;
|
const DEFAULT_RENDER_OUTPUT_RESOLUTION = "original" as const;
|
||||||
const DEFAULT_RENDER_FORMAT = "png" as const;
|
const DEFAULT_RENDER_FORMAT = "png" as const;
|
||||||
const DEFAULT_RENDER_JPEG_QUALITY = 90;
|
const DEFAULT_RENDER_JPEG_QUALITY = 90;
|
||||||
|
const ADJUSTMENT_MIN_WIDTH = 240;
|
||||||
|
|
||||||
type RenderOutputResolution = (typeof RENDER_OUTPUT_RESOLUTIONS)[number];
|
type RenderOutputResolution = (typeof RENDER_OUTPUT_RESOLUTIONS)[number];
|
||||||
type RenderFormat = (typeof RENDER_FORMATS)[number];
|
type RenderFormat = (typeof RENDER_FORMATS)[number];
|
||||||
@@ -143,6 +144,20 @@ function parseRenderJpegQuality(value: unknown): number {
|
|||||||
return value as 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<string, unknown> {
|
function normalizeRenderData(data: unknown): Record<string, unknown> {
|
||||||
if (!isRecord(data)) {
|
if (!isRecord(data)) {
|
||||||
throw new Error("Render node data must be an object.");
|
throw new Error("Render node data must be an object.");
|
||||||
@@ -174,6 +189,151 @@ function normalizeRenderData(data: unknown): Record<string, unknown> {
|
|||||||
normalized.lastRenderedAt = data.lastRenderedAt;
|
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 (data.storageId !== undefined) {
|
||||||
if (typeof data.storageId !== "string" || data.storageId.length === 0) {
|
if (typeof data.storageId !== "string" || data.storageId.length === 0) {
|
||||||
throw new Error("Render data 'storageId' must be a non-empty string when provided.");
|
throw new Error("Render data 'storageId' must be a non-empty string when provided.");
|
||||||
@@ -211,6 +371,32 @@ function normalizeNodeDataForWrite(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertTargetAllowsIncomingEdge(
|
||||||
|
ctx: MutationCtx,
|
||||||
|
args: {
|
||||||
|
targetNodeId: Id<"nodes">;
|
||||||
|
edgeIdToIgnore?: Id<"edges">;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
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(
|
async function getIdempotentNodeCreateResult(
|
||||||
ctx: MutationCtx,
|
ctx: MutationCtx,
|
||||||
args: {
|
args: {
|
||||||
@@ -577,6 +763,11 @@ export const splitEdgeAtExistingNode = mutation({
|
|||||||
targetHandle: args.newNodeTargetHandle,
|
targetHandle: args.newNodeTargetHandle,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await assertTargetAllowsIncomingEdge(ctx, {
|
||||||
|
targetNodeId: edge.targetNodeId,
|
||||||
|
edgeIdToIgnore: args.splitEdgeId,
|
||||||
|
});
|
||||||
|
|
||||||
await ctx.db.insert("edges", {
|
await ctx.db.insert("edges", {
|
||||||
canvasId: args.canvasId,
|
canvasId: args.canvasId,
|
||||||
sourceNodeId: args.middleNodeId,
|
sourceNodeId: args.middleNodeId,
|
||||||
@@ -733,6 +924,10 @@ export const createWithEdgeToTarget = mutation({
|
|||||||
zIndex: args.zIndex,
|
zIndex: args.zIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await assertTargetAllowsIncomingEdge(ctx, {
|
||||||
|
targetNodeId: args.targetNodeId,
|
||||||
|
});
|
||||||
|
|
||||||
await ctx.db.insert("edges", {
|
await ctx.db.insert("edges", {
|
||||||
canvasId: args.canvasId,
|
canvasId: args.canvasId,
|
||||||
sourceNodeId: nodeId,
|
sourceNodeId: nodeId,
|
||||||
@@ -789,7 +984,11 @@ export const resize = mutation({
|
|||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
await getCanvasOrThrow(ctx, node.canvasId, user.userId);
|
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() });
|
await ctx.db.patch(node.canvasId, { updatedAt: Date.now() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { mutation, query } from "./_generated/server";
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
import { requireAuth } from "./helpers";
|
import { requireAuth } from "./helpers";
|
||||||
import { adjustmentPresetNodeTypeValidator } from "./node-type-validator";
|
import { adjustmentPresetNodeTypeValidator } from "./node_type_validator";
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
adjustmentPresetNodeTypeValidator,
|
adjustmentPresetNodeTypeValidator,
|
||||||
nodeTypeValidator,
|
nodeTypeValidator,
|
||||||
phase1NodeTypeValidator,
|
phase1NodeTypeValidator,
|
||||||
} from "./node-type-validator";
|
} from "./node_type_validator";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Node Types
|
// Node Types
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ async function resolveStorageUrls(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { storageId, url } = entry;
|
const { storageId, url } = entry;
|
||||||
resolved[storageId] = url;
|
resolved[storageId] = url ?? undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
133
hooks/use-pipeline-preview.ts
Normal file
133
hooks/use-pipeline-preview.ts
Normal file
@@ -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<HTMLCanvasElement | null>;
|
||||||
|
histogram: HistogramData;
|
||||||
|
isRendering: boolean;
|
||||||
|
hasSource: boolean;
|
||||||
|
previewAspectRatio: number;
|
||||||
|
error: string | null;
|
||||||
|
} {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const [histogram, setHistogram] = useState<HistogramData>(() => emptyHistogram());
|
||||||
|
const [isRendering, setIsRendering] = useState(false);
|
||||||
|
const [previewAspectRatio, setPreviewAspectRatio] = useState(1);
|
||||||
|
const [error, setError] = useState<string | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -169,40 +169,30 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
|
|||||||
label: "Kurven",
|
label: "Kurven",
|
||||||
category: "image-edit",
|
category: "image-edit",
|
||||||
phase: 2,
|
phase: 2,
|
||||||
implemented: false,
|
|
||||||
disabledHint: "Folgt in Phase 2",
|
|
||||||
}),
|
}),
|
||||||
entry({
|
entry({
|
||||||
type: "color-adjust",
|
type: "color-adjust",
|
||||||
label: "Farbe",
|
label: "Farbe",
|
||||||
category: "image-edit",
|
category: "image-edit",
|
||||||
phase: 2,
|
phase: 2,
|
||||||
implemented: false,
|
|
||||||
disabledHint: "Folgt in Phase 2",
|
|
||||||
}),
|
}),
|
||||||
entry({
|
entry({
|
||||||
type: "light-adjust",
|
type: "light-adjust",
|
||||||
label: "Licht",
|
label: "Licht",
|
||||||
category: "image-edit",
|
category: "image-edit",
|
||||||
phase: 2,
|
phase: 2,
|
||||||
implemented: false,
|
|
||||||
disabledHint: "Folgt in Phase 2",
|
|
||||||
}),
|
}),
|
||||||
entry({
|
entry({
|
||||||
type: "detail-adjust",
|
type: "detail-adjust",
|
||||||
label: "Detail",
|
label: "Detail",
|
||||||
category: "image-edit",
|
category: "image-edit",
|
||||||
phase: 2,
|
phase: 2,
|
||||||
implemented: false,
|
|
||||||
disabledHint: "Folgt in Phase 2",
|
|
||||||
}),
|
}),
|
||||||
entry({
|
entry({
|
||||||
type: "render",
|
type: "render",
|
||||||
label: "Render",
|
label: "Render",
|
||||||
category: "image-edit",
|
category: "image-edit",
|
||||||
phase: 2,
|
phase: 2,
|
||||||
implemented: false,
|
|
||||||
disabledHint: "Folgt in Phase 2",
|
|
||||||
}),
|
}),
|
||||||
// Steuerung & Flow
|
// Steuerung & Flow
|
||||||
entry({
|
entry({
|
||||||
|
|||||||
@@ -62,6 +62,45 @@ export const CANVAS_NODE_TEMPLATES = [
|
|||||||
height: 180,
|
height: 180,
|
||||||
defaultData: {},
|
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;
|
] as const;
|
||||||
|
|
||||||
export type CanvasNodeTemplate = (typeof CANVAS_NODE_TEMPLATES)[number];
|
export type CanvasNodeTemplate = (typeof CANVAS_NODE_TEMPLATES)[number];
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import {
|
|||||||
type Edge as RFEdge,
|
type Edge as RFEdge,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import type { Doc, Id } from "@/convex/_generated/dataModel";
|
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
|
* Convex Node → React Flow Node
|
||||||
@@ -105,6 +111,11 @@ const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> =
|
|||||||
group: [100, 116, 139],
|
group: [100, 116, 139],
|
||||||
frame: [249, 115, 22],
|
frame: [249, 115, 22],
|
||||||
compare: [100, 116, 139],
|
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). */
|
/** 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" },
|
compare: { source: "compare-out", target: "left" },
|
||||||
asset: { source: undefined, target: undefined },
|
asset: { source: undefined, target: undefined },
|
||||||
video: { 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: {} },
|
compare: { width: 500, height: 380, data: {} },
|
||||||
asset: { width: 260, height: 240, data: {} },
|
asset: { width: 260, height: 240, data: {} },
|
||||||
video: { width: 320, height: 180, 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";
|
type MediaNodeKind = "asset" | "image";
|
||||||
|
|||||||
264
lib/image-pipeline/adjustment-types.ts
Normal file
264
lib/image-pipeline/adjustment-types.ts
Normal file
@@ -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<T>(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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
const levels = (input.levels ?? {}) as Record<string, unknown>;
|
||||||
|
const points = (input.points ?? {}) as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
const hsl = (input.hsl ?? {}) as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
const vignette = (input.vignette ?? {}) as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
const sharpen = (input.sharpen ?? {}) as Record<string, unknown>;
|
||||||
|
const denoise = (input.denoise ?? {}) as Record<string, unknown>;
|
||||||
|
const grain = (input.grain ?? {}) as Record<string, unknown>;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
137
lib/image-pipeline/bridge.ts
Normal file
137
lib/image-pipeline/bridge.ts
Normal file
@@ -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<Blob> {
|
||||||
|
if (typeof OffscreenCanvas !== "undefined" && canvas instanceof OffscreenCanvas) {
|
||||||
|
return await canvas.convertToBlob({ type: mimeType, quality });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise<Blob>((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<RenderFullResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
42
lib/image-pipeline/histogram.ts
Normal file
42
lib/image-pipeline/histogram.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
144
lib/image-pipeline/presets.ts
Normal file
144
lib/image-pipeline/presets.ts
Normal file
@@ -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<string, CurvesData> = {
|
||||||
|
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<string, ColorAdjustData> = {
|
||||||
|
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<string, LightAdjustData> = {
|
||||||
|
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<string, DetailAdjustData> = {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
};
|
||||||
48
lib/image-pipeline/preview-renderer.ts
Normal file
48
lib/image-pipeline/preview-renderer.ts
Normal file
@@ -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<PreviewRenderResult> {
|
||||||
|
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<void>((resolve) => {
|
||||||
|
requestAnimationFrame(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const histogram = computeHistogram(imageData.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
imageData,
|
||||||
|
histogram,
|
||||||
|
};
|
||||||
|
}
|
||||||
323
lib/image-pipeline/render-core.ts
Normal file
323
lib/image-pipeline/render-core.ts
Normal file
@@ -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<string, unknown>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
lib/image-pipeline/render-size.ts
Normal file
108
lib/image-pipeline/render-size.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
52
lib/image-pipeline/render-types.ts
Normal file
52
lib/image-pipeline/render-types.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
35
lib/image-pipeline/source-loader.ts
Normal file
35
lib/image-pipeline/source-loader.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const imageBitmapCache = new Map<string, Promise<ImageBitmap>>();
|
||||||
|
|
||||||
|
export async function loadSourceBitmap(sourceUrl: string): Promise<ImageBitmap> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"@napi-rs/canvas": "^0.1.97",
|
"@napi-rs/canvas": "^0.1.97",
|
||||||
"@polar-sh/better-auth": "^1.8.3",
|
"@polar-sh/better-auth": "^1.8.3",
|
||||||
"@polar-sh/sdk": "^0.46.7",
|
"@polar-sh/sdk": "^0.46.7",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@sentry/nextjs": "^10.46.0",
|
"@sentry/nextjs": "^10.46.0",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"better-auth": "^1.5.6",
|
"better-auth": "^1.5.6",
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
|||||||
'@polar-sh/sdk':
|
'@polar-sh/sdk':
|
||||||
specifier: ^0.46.7
|
specifier: ^0.46.7
|
||||||
version: 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':
|
'@sentry/nextjs':
|
||||||
specifier: ^10.46.0
|
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)
|
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)
|
||||||
|
|||||||
19
src/components/tool-ui/parameter-slider/README.md
Normal file
19
src/components/tool-ui/parameter-slider/README.md
Normal file
@@ -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
|
||||||
4
src/components/tool-ui/parameter-slider/_adapter.tsx
Normal file
4
src/components/tool-ui/parameter-slider/_adapter.tsx
Normal file
@@ -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";
|
||||||
7
src/components/tool-ui/parameter-slider/index.tsx
Normal file
7
src/components/tool-ui/parameter-slider/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { ParameterSlider } from "./parameter-slider";
|
||||||
|
export type {
|
||||||
|
ParameterSliderProps,
|
||||||
|
SliderConfig,
|
||||||
|
SliderValue,
|
||||||
|
SerializableParameterSlider,
|
||||||
|
} from "./schema";
|
||||||
42
src/components/tool-ui/parameter-slider/math.ts
Normal file
42
src/components/tool-ui/parameter-slider/math.ts
Normal file
@@ -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,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
821
src/components/tool-ui/parameter-slider/parameter-slider.tsx
Normal file
821
src/components/tool-ui/parameter-slider/parameter-slider.tsx
Normal file
@@ -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<HTMLSpanElement>(null);
|
||||||
|
const labelRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const valueRef = useRef<HTMLSpanElement>(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 (
|
||||||
|
<div className="py-2">
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
id={id}
|
||||||
|
className={cn(
|
||||||
|
"group/slider relative flex w-full touch-none items-center select-none",
|
||||||
|
"isolate h-12",
|
||||||
|
isDragging
|
||||||
|
? "[&>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)}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
ref={trackRef}
|
||||||
|
className={cn(
|
||||||
|
"squircle relative h-12 w-full grow overflow-hidden rounded-sm",
|
||||||
|
"ring-border ring-1 ring-inset",
|
||||||
|
"dark:ring-white/10",
|
||||||
|
resolvedTrackClassName ?? "bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 will-change-[clip-path]",
|
||||||
|
isDragging
|
||||||
|
? "transition-[clip-path] duration-45 ease-linear"
|
||||||
|
: "transition-[clip-path] duration-90 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||||
|
"motion-reduce:transition-none",
|
||||||
|
resolvedFillClassName ?? "bg-primary/30 dark:bg-primary/40",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
maskImage: fillMaskImage,
|
||||||
|
WebkitMaskImage: fillMaskImage,
|
||||||
|
clipPath: fillClipPath,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ticks.map((tick, i) => {
|
||||||
|
const isEdge =
|
||||||
|
!tick.isSubtick && (tick.percent === 0 || tick.percent === 100);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute bottom-px w-px",
|
||||||
|
tick.isSubtick ? "h-1.5" : "h-2",
|
||||||
|
isEdge
|
||||||
|
? "bg-transparent"
|
||||||
|
: tick.isSubtick
|
||||||
|
? "bg-foreground/8 dark:bg-white/5"
|
||||||
|
: tick.isCenter
|
||||||
|
? "bg-foreground/30 dark:bg-white/25"
|
||||||
|
: "bg-foreground/15 dark:bg-white/8",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: toInsetPosition(tick.percent),
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
|
||||||
|
{/* Metallic reflection overlay - follows handle, brightness scales with interaction */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"squircle pointer-events-none absolute inset-0 rounded-sm",
|
||||||
|
isDragging
|
||||||
|
? "transition-[opacity,background] duration-45 ease-linear"
|
||||||
|
: "transition-[opacity,background] duration-90 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||||
|
"motion-reduce:transition-none",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
...reflectionStyle,
|
||||||
|
opacity: reflectionOpacity,
|
||||||
|
filter: "blur(1px)",
|
||||||
|
mixBlendMode: "overlay",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
className={cn(
|
||||||
|
"group/thumb z-0 block w-3 shrink-0 cursor-grab rounded-sm",
|
||||||
|
"relative bg-transparent outline-none",
|
||||||
|
"transition-[height,opacity] duration-150 ease-[var(--cubic-ease-in-out)]",
|
||||||
|
"focus-visible:outline-ring focus-visible:outline-2 focus-visible:outline-offset-1",
|
||||||
|
"active:cursor-grabbing",
|
||||||
|
"disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
// Height morphs: rest (track height) → hover → active
|
||||||
|
isDragging ? "h-[56px]" : isHovered ? "h-[54px]" : "h-12",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
// 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 (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute top-0 left-1/2",
|
||||||
|
"transition-all duration-100 ease-[var(--cubic-ease-in-out)]",
|
||||||
|
isActive
|
||||||
|
? gap > 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-0 left-1/2",
|
||||||
|
"transition-all duration-100 ease-[var(--cubic-ease-in-out)]",
|
||||||
|
isActive
|
||||||
|
? gap > 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</SliderPrimitive.Thumb>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-x-3 top-1/2 z-10 flex items-center justify-between"
|
||||||
|
style={{
|
||||||
|
transform: `translateY(calc(-50% - ${TEXT_VERTICAL_OFFSET}px))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
ref={labelRef}
|
||||||
|
className="text-primary -mt-px rounded-full px-2 py-px text-sm font-normal tracking-wide"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
ref={valueRef}
|
||||||
|
className="text-foreground -mt-px -mb-0.5 flex h-6 items-center rounded-full px-2 font-mono text-xs tabular-nums"
|
||||||
|
>
|
||||||
|
{formatSignedValue(value, min, max, precision, unit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SliderValue[]>({
|
||||||
|
value: controlledValues,
|
||||||
|
defaultValue: sliderSnapshot,
|
||||||
|
onChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
useSignatureReset(slidersSignature, () => {
|
||||||
|
if (!isControlled) {
|
||||||
|
setUncontrolledValue(sliderSnapshot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const valueMap = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
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 (
|
||||||
|
<article
|
||||||
|
className={cn(
|
||||||
|
"@container/parameter-slider isolate flex w-full max-w-md min-w-80 flex-col gap-3",
|
||||||
|
"text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-slot="parameter-slider"
|
||||||
|
data-tool-ui-id={id}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-card flex w-full flex-col overflow-hidden rounded-2xl border px-5 py-3 shadow-xs",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sliders.map((slider) => (
|
||||||
|
<SliderRow
|
||||||
|
key={slider.id}
|
||||||
|
config={slider}
|
||||||
|
value={valueMap.get(slider.id) ?? slider.value}
|
||||||
|
onChange={(v) => updateValue(slider.id, v)}
|
||||||
|
trackClassName={trackClassName}
|
||||||
|
fillClassName={fillClassName}
|
||||||
|
handleClassName={handleClassName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="@container/actions">
|
||||||
|
<ActionButtons
|
||||||
|
actions={normalizedActions.items}
|
||||||
|
align={normalizedActions.align}
|
||||||
|
confirmTimeout={normalizedActions.confirmTimeout}
|
||||||
|
onAction={handleAction}
|
||||||
|
onBeforeAction={
|
||||||
|
onBeforeAction
|
||||||
|
? (actionId) => onBeforeAction(actionId, currentValues)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/components/tool-ui/parameter-slider/schema.ts
Normal file
114
src/components/tool-ui/parameter-slider/schema.ts
Normal file
@@ -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<typeof SliderConfigSchema>;
|
||||||
|
|
||||||
|
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<string, number>();
|
||||||
|
|
||||||
|
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<SliderValue[]>["onAction"];
|
||||||
|
onBeforeAction?: EmbeddedActionsProps<SliderValue[]>["onBeforeAction"];
|
||||||
|
trackClassName?: string;
|
||||||
|
fillClassName?: string;
|
||||||
|
handleClassName?: string;
|
||||||
|
}
|
||||||
2
src/components/tool-ui/shared/_adapter.tsx
Normal file
2
src/components/tool-ui/shared/_adapter.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { cn } from "@/lib/utils";
|
||||||
|
export { Button } from "@/components/ui/button";
|
||||||
100
src/components/tool-ui/shared/action-buttons.tsx
Normal file
100
src/components/tool-ui/shared/action-buttons.tsx
Normal file
@@ -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<void>;
|
||||||
|
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-3",
|
||||||
|
"@sm/actions:flex-row @sm/actions:flex-wrap @sm/actions:items-center @sm/actions:gap-2",
|
||||||
|
align === "left" && "@sm/actions:justify-start",
|
||||||
|
align === "center" && "@sm/actions:justify-center",
|
||||||
|
align === "right" && "@sm/actions:justify-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{resolvedActions.map((action) => {
|
||||||
|
const label = action.currentLabel;
|
||||||
|
const variant = action.variant || "default";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={action.id}
|
||||||
|
variant={variant}
|
||||||
|
onClick={() => runAction(action.id)}
|
||||||
|
disabled={action.isDisabled}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-4!",
|
||||||
|
"justify-center",
|
||||||
|
"min-h-11 w-full text-base",
|
||||||
|
"@sm/actions:min-h-0 @sm/actions:w-auto @sm/actions:px-3 @sm/actions:py-2 @sm/actions:text-sm",
|
||||||
|
action.isConfirming &&
|
||||||
|
"ring-destructive ring-2 ring-offset-2 motion-safe:animate-pulse",
|
||||||
|
)}
|
||||||
|
aria-label={
|
||||||
|
action.shortcut ? `${label} (${action.shortcut})` : label
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{action.isLoading && (
|
||||||
|
<svg
|
||||||
|
className="mr-2 h-4 w-4 motion-safe:animate-spin"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{action.icon && !action.isLoading && (
|
||||||
|
<span className="mr-2">{action.icon}</span>
|
||||||
|
)}
|
||||||
|
{label}
|
||||||
|
{action.shortcut && !action.isLoading && (
|
||||||
|
<kbd className="border-border bg-muted ml-2.5 hidden rounded-lg border px-2 py-0.5 font-mono text-xs font-medium sm:inline-block">
|
||||||
|
{action.shortcut}
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/tool-ui/shared/actions-config.ts
Normal file
48
src/components/tool-ui/shared/actions-config.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
19
src/components/tool-ui/shared/contract.ts
Normal file
19
src/components/tool-ui/shared/contract.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { parseWithSchema, safeParseWithSchema } from "./parse";
|
||||||
|
|
||||||
|
export interface ToolUiContract<T> {
|
||||||
|
schema: z.ZodType<T>;
|
||||||
|
parse: (input: unknown) => T;
|
||||||
|
safeParse: (input: unknown) => T | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defineToolUiContract<T>(
|
||||||
|
componentName: string,
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
): ToolUiContract<T> {
|
||||||
|
return {
|
||||||
|
schema,
|
||||||
|
parse: (input: unknown) => parseWithSchema(schema, input, componentName),
|
||||||
|
safeParse: (input: unknown) => safeParseWithSchema(schema, input),
|
||||||
|
};
|
||||||
|
}
|
||||||
17
src/components/tool-ui/shared/embedded-actions.ts
Normal file
17
src/components/tool-ui/shared/embedded-actions.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ActionsProp } from "./actions-config";
|
||||||
|
|
||||||
|
export type EmbeddedActionHandler<TState> = (
|
||||||
|
actionId: string,
|
||||||
|
state: TState,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
|
export type EmbeddedBeforeActionHandler<TState> = (
|
||||||
|
actionId: string,
|
||||||
|
state: TState,
|
||||||
|
) => boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
export interface EmbeddedActionsProps<TState> {
|
||||||
|
actions?: ActionsProp;
|
||||||
|
onAction?: EmbeddedActionHandler<TState>;
|
||||||
|
onBeforeAction?: EmbeddedBeforeActionHandler<TState>;
|
||||||
|
}
|
||||||
51
src/components/tool-ui/shared/parse.ts
Normal file
51
src/components/tool-ui/shared/parse.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
function formatZodPath(path: Array<string | number | symbol>): 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<T>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
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<T>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
input: unknown,
|
||||||
|
): T | null {
|
||||||
|
const res = schema.safeParse(input);
|
||||||
|
return res.success ? res.data : null;
|
||||||
|
}
|
||||||
159
src/components/tool-ui/shared/schema.ts
Normal file
159
src/components/tool-ui/shared/schema.ts
Normal file
@@ -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<typeof ToolUIIdSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<typeof ToolUIRoleSchema>;
|
||||||
|
|
||||||
|
export const ToolUIReceiptOutcomeSchema = z.enum([
|
||||||
|
"success",
|
||||||
|
"partial",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<typeof ToolUIReceiptSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<typeof ToolUISurfaceSchema>;
|
||||||
|
|
||||||
|
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<ReactNode>().optional(),
|
||||||
|
loading: z.boolean().optional(),
|
||||||
|
disabled: z.boolean().optional(),
|
||||||
|
shortcut: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Action = z.infer<typeof ActionSchema>;
|
||||||
|
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<string, unknown> = Record<string, unknown>,
|
||||||
|
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
|
||||||
|
payload?: TPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createDecisionResult<
|
||||||
|
TPayload extends Record<string, unknown> = Record<string, unknown>,
|
||||||
|
>(args: {
|
||||||
|
decisionId: string;
|
||||||
|
action: { id: string; label: string };
|
||||||
|
payload?: TPayload;
|
||||||
|
}): DecisionResult<TPayload> {
|
||||||
|
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<typeof SerializableActionSchema>;
|
||||||
153
src/components/tool-ui/shared/use-action-buttons.tsx
Normal file
153
src/components/tool-ui/shared/use-action-buttons.tsx
Normal file
@@ -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<void>;
|
||||||
|
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
|
||||||
|
confirmTimeout?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseActionButtonsResult = {
|
||||||
|
actions: Array<
|
||||||
|
Action & {
|
||||||
|
currentLabel: string;
|
||||||
|
isConfirming: boolean;
|
||||||
|
isExecuting: boolean;
|
||||||
|
isDisabled: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
runAction: (actionId: string) => Promise<void>;
|
||||||
|
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<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [executingActionId, setExecutingActionId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const executionLockRef = useRef<ActionExecutionLock>(
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
54
src/components/tool-ui/shared/use-controllable-state.ts
Normal file
54
src/components/tool-ui/shared/use-controllable-state.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type UseControllableStateOptions<T> = {
|
||||||
|
value?: T;
|
||||||
|
defaultValue: T;
|
||||||
|
onChange?: (next: T) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useControllableState<T>({
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
}: UseControllableStateOptions<T>) {
|
||||||
|
const [uncontrolled, setUncontrolled] = useState<T>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
16
src/components/tool-ui/shared/use-signature-reset.ts
Normal file
16
src/components/tool-ui/shared/use-signature-reset.ts
Normal file
@@ -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]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user