Files
lemonspace_app/lib/canvas-utils.ts

529 lines
15 KiB
TypeScript

import {
getConnectedEdges,
getIncomers,
getOutgoers,
type Node as RFNode,
type Edge as RFEdge,
} from "@xyflow/react";
import type { Doc, Id } from "@/convex/_generated/dataModel";
import {
DEFAULT_COLOR_ADJUST_DATA,
DEFAULT_CURVES_DATA,
DEFAULT_DETAIL_ADJUST_DATA,
DEFAULT_LIGHT_ADJUST_DATA,
} from "@/lib/image-pipeline/adjustment-types";
import { DEFAULT_AGENT_MODEL_ID } from "@/lib/agent-models";
import { DEFAULT_CROP_NODE_DATA } from "@/lib/image-pipeline/crop-node-data";
/**
* Convex Node → React Flow Node
*
* Convex speichert positionX/positionY als separate Felder,
* React Flow erwartet position: { x, y }.
*/
/**
* Reichert Node-Dokumente mit `data.url` an (aus gebündelter Storage-URL-Map).
* Behält eine zuvor gemappte URL bei, solange die URL-Auflösung noch lädt.
*/
export function convexNodeDocWithMergedStorageUrl(
node: Doc<"nodes">,
urlByStorage: Record<string, string | undefined> | undefined,
previousDataByNodeId: Map<string, Record<string, unknown>>,
): Doc<"nodes"> {
const data = node.data as Record<string, unknown> | undefined;
const sid = data?.storageId;
if (typeof sid !== "string") {
return node;
}
if (urlByStorage) {
const fromBatch = urlByStorage[sid];
if (fromBatch !== undefined) {
return {
...node,
data: { ...data, url: fromBatch },
};
}
}
const prev = previousDataByNodeId.get(node._id);
if (
prev?.url !== undefined &&
typeof prev.storageId === "string" &&
prev.storageId === sid
) {
return {
...node,
data: { ...data, url: prev.url },
};
}
return node;
}
export function convexNodeToRF(node: Doc<"nodes">): RFNode {
return {
id: node._id,
type: node.type,
position: { x: node.positionX, y: node.positionY },
data: {
...(node.data as Record<string, unknown>),
// Status direkt in data durchreichen, damit Node-Komponenten darauf zugreifen können
_status: node.status,
_statusMessage: node.statusMessage,
retryCount: node.retryCount,
},
parentId: node.parentId ?? undefined,
zIndex: node.zIndex,
style: {
width: node.width,
height: node.height,
},
};
}
/**
* Convex Edge → React Flow Edge
* Sanitize handles: null/undefined/"null" → undefined (ReactFlow erwartet string | null | undefined, aber nie den String "null")
*/
export function convexEdgeToRF(edge: Doc<"edges">): RFEdge {
const sanitize = (h: string | undefined): string | undefined =>
h === undefined || h === "null" ? undefined : h;
return {
id: edge._id,
source: edge.sourceNodeId,
target: edge.targetNodeId,
sourceHandle: sanitize(edge.sourceHandle),
targetHandle: sanitize(edge.targetHandle),
};
}
/**
* Akzentfarben der Handles je Node-Typ (s. jeweilige Node-Komponente).
* Für einen dezenten Glow entlang der Kante (drop-shadow am Pfad).
*/
type RgbColor = readonly [number, number, number];
const SOURCE_NODE_GLOW_RGB: Record<string, RgbColor> = {
prompt: [139, 92, 246],
"video-prompt": [124, 58, 237],
"ai-image": [139, 92, 246],
"ai-video": [124, 58, 237],
image: [13, 148, 136],
text: [13, 148, 136],
note: [13, 148, 136],
asset: [13, 148, 136],
video: [13, 148, 136],
group: [100, 116, 139],
frame: [249, 115, 22],
compare: [100, 116, 139],
curves: [16, 185, 129],
"color-adjust": [6, 182, 212],
"light-adjust": [245, 158, 11],
"detail-adjust": [99, 102, 241],
crop: [139, 92, 246],
render: [14, 165, 233],
agent: [245, 158, 11],
"agent-output": [245, 158, 11],
mixer: [100, 116, 139],
};
/** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */
const COMPARE_HANDLE_CONNECTION_RGB: Record<string, RgbColor> = {
left: [59, 130, 246],
right: [16, 185, 129],
"compare-out": [100, 116, 139],
};
const MIXER_HANDLE_CONNECTION_RGB: Record<string, RgbColor> = {
base: [14, 165, 233],
overlay: [236, 72, 153],
"mixer-out": [100, 116, 139],
};
const CONNECTION_LINE_FALLBACK_RGB: RgbColor = [13, 148, 136];
export function canvasHandleAccentRgb(args: {
nodeType: string | undefined;
handleId?: string | null;
handleType: "source" | "target";
}): RgbColor {
const nodeType = args.nodeType;
const handleId = args.handleId ?? undefined;
const handleType = args.handleType;
if (nodeType === "compare" && handleId) {
if (handleType === "target" && handleId === "compare-out") {
return SOURCE_NODE_GLOW_RGB.compare;
}
const byHandle = COMPARE_HANDLE_CONNECTION_RGB[handleId];
if (byHandle) {
return byHandle;
}
}
if (nodeType === "mixer" && handleId) {
if (handleType === "target" && handleId === "mixer-out") {
return SOURCE_NODE_GLOW_RGB.mixer;
}
const byHandle = MIXER_HANDLE_CONNECTION_RGB[handleId];
if (byHandle) {
return byHandle;
}
}
if (!nodeType) {
return CONNECTION_LINE_FALLBACK_RGB;
}
return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB;
}
export function canvasHandleAccentColor(args: {
nodeType: string | undefined;
handleId?: string | null;
handleType: "source" | "target";
}): string {
const [r, g, b] = canvasHandleAccentRgb(args);
return `rgb(${r}, ${g}, ${b})`;
}
export function canvasHandleAccentColorWithAlpha(
args: {
nodeType: string | undefined;
handleId?: string | null;
handleType: "source" | "target";
},
alpha: number,
): string {
const [r, g, b] = canvasHandleAccentRgb(args);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/**
* RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect).
*/
export function connectionLineAccentRgb(
nodeType: string | undefined,
handleId: string | null | undefined,
): RgbColor {
return canvasHandleAccentRgb({
nodeType,
handleId,
handleType: "source",
});
}
export type EdgeGlowColorMode = "light" | "dark";
function sourceGlowFilterForNodeType(
type: string | undefined,
colorMode: EdgeGlowColorMode,
): string | undefined {
if (!type) return undefined;
const rgb = SOURCE_NODE_GLOW_RGB[type];
if (!rgb) return undefined;
const [r, g, b] = rgb;
if (colorMode === "dark") {
/* Zwei kleine Schatten statt gestapelter großer Blur — weniger GPU-Last beim Pan/Zoom */
return `drop-shadow(0 0 4px rgba(${r},${g},${b},0.72)) drop-shadow(0 0 9px rgba(${r},${g},${b},0.38))`;
}
return `drop-shadow(0 0 3px rgba(${r},${g},${b},0.42)) drop-shadow(0 0 7px rgba(${r},${g},${b},0.2))`;
}
function sourceEdgeStrokeForNodeType(
type: string | undefined,
colorMode: EdgeGlowColorMode,
): string | undefined {
if (colorMode !== "light" || !type) return undefined;
const rgb = SOURCE_NODE_GLOW_RGB[type];
if (!rgb) return undefined;
const [r, g, b] = rgb;
return `rgba(${r}, ${g}, ${b}, 0.5)`;
}
/** Wie convexEdgeToRF, setzt zusätzlich filter am Pfad nach Quell-Node-Typ. */
export function convexEdgeToRFWithSourceGlow(
edge: Doc<"edges">,
sourceNodeType: string | undefined,
colorMode: EdgeGlowColorMode = "light",
): RFEdge {
const base = convexEdgeToRF(edge);
const filter = sourceGlowFilterForNodeType(sourceNodeType, colorMode);
const stroke = sourceEdgeStrokeForNodeType(sourceNodeType, colorMode);
if (!filter && !stroke) return base;
const style: NonNullable<RFEdge["style"]> = { ...(base.style ?? {}) };
if (filter) style.filter = filter;
if (stroke) style.stroke = stroke;
return {
...base,
style,
};
}
/**
* Handle-IDs pro Node-Typ für Proximity Connect.
* `undefined` = default handle (kein explizites `id`-Attribut auf dem Handle).
* Fehlendes Feld = Node hat keinen Handle dieses Typs.
*/
export const NODE_HANDLE_MAP: Record<
string,
{ source?: string; target?: string }
> = {
image: { source: undefined, target: undefined },
text: { source: undefined, target: undefined },
prompt: { source: "prompt-out", target: "image-in" },
"video-prompt": { source: "video-prompt-out", target: "video-prompt-in" },
"ai-image": { source: "image-out", target: "prompt-in" },
"ai-video": { source: "video-out", target: "video-in" },
group: { source: undefined, target: undefined },
frame: { source: "frame-out", target: "frame-in" },
note: { source: undefined, target: undefined },
compare: { source: "compare-out", target: "left" },
asset: { source: undefined, target: undefined },
video: { source: undefined, target: undefined },
curves: { source: undefined, target: undefined },
"color-adjust": { source: undefined, target: undefined },
"light-adjust": { source: undefined, target: undefined },
"detail-adjust": { source: undefined, target: undefined },
crop: { source: undefined, target: undefined },
render: { source: undefined, target: undefined },
agent: { target: "agent-in" },
mixer: { source: "mixer-out", target: "base" },
"agent-output": { target: "agent-output-in" },
};
/**
* Default-Größen für neue Nodes je nach Typ.
*/
export const NODE_DEFAULTS: Record<
string,
{ width: number; height: number; data: Record<string, unknown> }
> = {
image: { width: 280, height: 200, data: {} },
text: { width: 256, height: 120, data: { content: "" } },
prompt: {
width: 288,
height: 220,
data: { prompt: "", model: "google/gemini-2.5-flash-image", aspectRatio: "1:1" },
},
"video-prompt": {
width: 288,
height: 220,
data: {
prompt: "",
modelId: "wan-2-2-720p",
durationSeconds: 5,
hasAudio: false,
},
},
// 1:1 viewport 320 + chrome 88 ≈ äußere Höhe (siehe lib/image-formats.ts)
"ai-image": { width: 320, height: 408, data: {} },
"ai-video": { width: 360, height: 280, data: {} },
group: { width: 400, height: 300, data: { label: "Gruppe" } },
frame: {
width: 400,
height: 300,
data: { label: "Frame", resolution: "1080x1080" },
},
note: { width: 208, height: 100, data: { content: "" } },
compare: { width: 500, height: 380, data: {} },
asset: { width: 260, height: 240, data: {} },
video: { width: 320, height: 180, data: {} },
curves: { width: 320, height: 660, data: DEFAULT_CURVES_DATA },
"color-adjust": { width: 320, height: 800, data: DEFAULT_COLOR_ADJUST_DATA },
"light-adjust": { width: 320, height: 920, data: DEFAULT_LIGHT_ADJUST_DATA },
"detail-adjust": { width: 320, height: 880, data: DEFAULT_DETAIL_ADJUST_DATA },
crop: { width: 340, height: 620, data: DEFAULT_CROP_NODE_DATA },
render: {
width: 300,
height: 420,
data: { outputResolution: "original", format: "png", jpegQuality: 90 },
},
agent: {
width: 360,
height: 320,
data: {
templateId: "campaign-distributor",
modelId: DEFAULT_AGENT_MODEL_ID,
clarificationQuestions: [],
clarificationAnswers: {},
outputNodeIds: [],
},
},
mixer: {
width: 360,
height: 320,
data: {
blendMode: "normal",
opacity: 100,
offsetX: 0,
offsetY: 0,
},
},
"agent-output": {
width: 360,
height: 260,
data: {
title: "",
channel: "",
outputType: "",
body: "",
},
},
};
type MediaNodeKind = "asset" | "image";
const MEDIA_NODE_CONFIG: Record<
MediaNodeKind,
{
width: number;
chromeHeight: number;
minPreviewHeight: number;
maxPreviewHeight: number;
}
> = {
asset: {
width: 260,
chromeHeight: 88,
minPreviewHeight: 120,
maxPreviewHeight: 300,
},
image: {
width: 280,
chromeHeight: 52,
minPreviewHeight: 120,
maxPreviewHeight: 320,
},
};
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function fallbackAspectRatio(orientation?: string): number {
if (orientation === "horizontal") return 4 / 3;
if (orientation === "vertical") return 3 / 4;
return 1;
}
export function resolveMediaAspectRatio(
intrinsicWidth?: number,
intrinsicHeight?: number,
orientation?: string,
): number {
if (
typeof intrinsicWidth === "number" &&
typeof intrinsicHeight === "number" &&
intrinsicWidth > 0 &&
intrinsicHeight > 0
) {
return intrinsicWidth / intrinsicHeight;
}
return fallbackAspectRatio(orientation);
}
export function computeMediaNodeSize(
kind: MediaNodeKind,
options?: {
intrinsicWidth?: number;
intrinsicHeight?: number;
orientation?: string;
},
): { width: number; height: number; previewHeight: number; aspectRatio: number } {
const config = MEDIA_NODE_CONFIG[kind];
const aspectRatio = resolveMediaAspectRatio(
options?.intrinsicWidth,
options?.intrinsicHeight,
options?.orientation,
);
const previewHeight = clamp(
Math.round(config.width / aspectRatio),
config.minPreviewHeight,
config.maxPreviewHeight,
);
return {
width: config.width,
height: previewHeight + config.chromeHeight,
previewHeight,
aspectRatio,
};
}
function reconnectEdgeKey(edge: RFEdge): string {
return `${edge.source}\0${edge.target}\0${edge.sourceHandle ?? ""}\0${edge.targetHandle ?? ""}`;
}
export type BridgeCreatePayload = {
sourceNodeId: Id<"nodes">;
targetNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
};
/**
* Nach Löschen mittlerer Knoten: Kanten wie im React-Flow-Beispiel
* „Delete Middle Node“ fortschreiben; nur Kanten zurückgeben, die neu
* angelegt werden müssen (nicht bereits vor dem Löschen vorhanden).
*/
export function computeBridgeCreatesForDeletedNodes(
deletedNodes: RFNode[],
allNodes: RFNode[],
allEdges: RFEdge[],
): BridgeCreatePayload[] {
if (deletedNodes.length === 0) return [];
const initialPersisted = allEdges.filter((e) => e.className !== "temp");
const initialKeys = new Set(initialPersisted.map(reconnectEdgeKey));
let remainingNodes = [...allNodes];
let acc = [...initialPersisted];
for (const node of deletedNodes) {
const incomers = getIncomers(node, remainingNodes, acc);
const outgoers = getOutgoers(node, remainingNodes, acc);
const connectedEdges = getConnectedEdges([node], acc);
const remainingEdges = acc.filter((e) => !connectedEdges.includes(e));
const createdEdges: RFEdge[] = [];
for (const inc of incomers) {
for (const out of outgoers) {
const inEdge = connectedEdges.find(
(e) => e.source === inc.id && e.target === node.id,
);
const outEdge = connectedEdges.find(
(e) => e.source === node.id && e.target === out.id,
);
if (!inEdge || !outEdge || inc.id === out.id) continue;
createdEdges.push({
id: `reconnect-${inc.id}-${out.id}-${node.id}-${createdEdges.length}`,
source: inc.id,
target: out.id,
sourceHandle: inEdge.sourceHandle,
targetHandle: outEdge.targetHandle,
});
}
}
acc = [...remainingEdges, ...createdEdges];
remainingNodes = remainingNodes.filter((rn) => rn.id !== node.id);
}
const result: BridgeCreatePayload[] = [];
for (const e of acc) {
if (!initialKeys.has(reconnectEdgeKey(e))) {
result.push({
sourceNodeId: e.source as Id<"nodes">,
targetNodeId: e.target as Id<"nodes">,
sourceHandle: e.sourceHandle ?? undefined,
targetHandle: e.targetHandle ?? undefined,
});
}
}
return result;
}