Enhance canvas components with improved error handling and aspect ratio normalization
- Added error name tracking in NodeErrorBoundary for better debugging. - Introduced aspect ratio normalization in PromptNode to ensure valid values are used. - Updated debounced state management in CanvasInner for improved performance. - Enhanced SelectContent component to support optional portal rendering.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import type { Doc } from "@/convex/_generated/dataModel";
|
||||
import { nodeTypes } from "@/components/canvas/node-types";
|
||||
import {
|
||||
CANVAS_NODE_TEMPLATES,
|
||||
type CanvasNodeTemplate,
|
||||
} from "@/lib/canvas-node-templates";
|
||||
import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
||||
|
||||
/** PRD-Kategorien (Reihenfolge für Sidebar / Dropdown). */
|
||||
export type NodeCategoryId =
|
||||
@@ -30,7 +30,7 @@ export const NODE_CATEGORIES_ORDERED: NodeCategoryId[] = (
|
||||
Object.keys(NODE_CATEGORY_META) as NodeCategoryId[]
|
||||
).sort((a, b) => NODE_CATEGORY_META[a].order - NODE_CATEGORY_META[b].order);
|
||||
|
||||
export type CatalogNodeType = Doc<"nodes">["type"];
|
||||
export type CatalogNodeType = CanvasNodeType;
|
||||
|
||||
export type NodeCatalogEntry = {
|
||||
type: CatalogNodeType;
|
||||
|
||||
68
lib/canvas-node-types.ts
Normal file
68
lib/canvas-node-types.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export const PHASE1_CANVAS_NODE_TYPES = [
|
||||
"image",
|
||||
"text",
|
||||
"prompt",
|
||||
"ai-image",
|
||||
"group",
|
||||
"frame",
|
||||
"note",
|
||||
"compare",
|
||||
] as const;
|
||||
|
||||
export const CANVAS_NODE_TYPES = [
|
||||
"image",
|
||||
"text",
|
||||
"prompt",
|
||||
"color",
|
||||
"video",
|
||||
"asset",
|
||||
"ai-image",
|
||||
"ai-text",
|
||||
"ai-video",
|
||||
"agent-output",
|
||||
"crop",
|
||||
"bg-remove",
|
||||
"upscale",
|
||||
"style-transfer",
|
||||
"face-restore",
|
||||
"curves",
|
||||
"color-adjust",
|
||||
"light-adjust",
|
||||
"detail-adjust",
|
||||
"render",
|
||||
"splitter",
|
||||
"loop",
|
||||
"agent",
|
||||
"mixer",
|
||||
"switch",
|
||||
"group",
|
||||
"frame",
|
||||
"note",
|
||||
"compare",
|
||||
"text-overlay",
|
||||
"comment",
|
||||
"presentation",
|
||||
] as const;
|
||||
|
||||
export const ADJUSTMENT_NODE_TYPES = [
|
||||
"curves",
|
||||
"color-adjust",
|
||||
"light-adjust",
|
||||
"detail-adjust",
|
||||
"render",
|
||||
] as const;
|
||||
|
||||
export type CanvasNodeType = (typeof CANVAS_NODE_TYPES)[number];
|
||||
export type Phase1CanvasNodeType = (typeof PHASE1_CANVAS_NODE_TYPES)[number];
|
||||
export type AdjustmentNodeType = (typeof ADJUSTMENT_NODE_TYPES)[number];
|
||||
|
||||
const CANVAS_NODE_TYPE_SET = new Set<CanvasNodeType>(CANVAS_NODE_TYPES);
|
||||
const ADJUSTMENT_NODE_TYPE_SET = new Set<AdjustmentNodeType>(ADJUSTMENT_NODE_TYPES);
|
||||
|
||||
export function isCanvasNodeType(value: string): value is CanvasNodeType {
|
||||
return CANVAS_NODE_TYPE_SET.has(value as CanvasNodeType);
|
||||
}
|
||||
|
||||
export function isAdjustmentNodeType(value: string): value is AdjustmentNodeType {
|
||||
return ADJUSTMENT_NODE_TYPE_SET.has(value as AdjustmentNodeType);
|
||||
}
|
||||
235
lib/image-pipeline/contracts.ts
Normal file
235
lib/image-pipeline/contracts.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
export type PipelineStep<TNodeType extends string = string, TData = unknown> = {
|
||||
nodeId: string;
|
||||
type: TNodeType;
|
||||
params: TData;
|
||||
};
|
||||
|
||||
type IdLike = string | number;
|
||||
|
||||
export type PipelineNodeLike<
|
||||
TNodeType extends string = string,
|
||||
TData = unknown,
|
||||
TId extends IdLike = string,
|
||||
> = {
|
||||
id: TId;
|
||||
type: TNodeType;
|
||||
data?: TData;
|
||||
};
|
||||
|
||||
export type PipelineEdgeLike<TId extends IdLike = string> = {
|
||||
source: TId;
|
||||
target: TId;
|
||||
};
|
||||
|
||||
type UpstreamTraversalOptions<
|
||||
TNode extends PipelineNodeLike,
|
||||
TEdge extends PipelineEdgeLike,
|
||||
> = {
|
||||
nodeId: TNode["id"];
|
||||
nodes: readonly TNode[];
|
||||
edges: readonly TEdge[];
|
||||
getNodeId?: (node: TNode) => TNode["id"];
|
||||
getNodeType?: (node: TNode) => TNode["type"];
|
||||
getNodeData?: (node: TNode) => TNode["data"];
|
||||
getEdgeSource?: (edge: TEdge) => TNode["id"];
|
||||
getEdgeTarget?: (edge: TEdge) => TNode["id"];
|
||||
};
|
||||
|
||||
type UpstreamWalkResult<TNode extends PipelineNodeLike, TEdge extends PipelineEdgeLike> = {
|
||||
path: TNode[];
|
||||
selectedEdges: TEdge[];
|
||||
};
|
||||
|
||||
function toComparableId(value: IdLike): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function selectIncomingEdge<TNode extends PipelineNodeLike, TEdge extends PipelineEdgeLike>(
|
||||
incomingEdges: readonly TEdge[],
|
||||
getEdgeSource: (edge: TEdge) => TNode["id"],
|
||||
): TEdge | null {
|
||||
if (incomingEdges.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sortedIncoming = [...incomingEdges].sort((left, right) =>
|
||||
toComparableId(getEdgeSource(left)).localeCompare(toComparableId(getEdgeSource(right))),
|
||||
);
|
||||
|
||||
return sortedIncoming[0] ?? null;
|
||||
}
|
||||
|
||||
function walkUpstream<TNode extends PipelineNodeLike, TEdge extends PipelineEdgeLike>(
|
||||
options: UpstreamTraversalOptions<TNode, TEdge>,
|
||||
): UpstreamWalkResult<TNode, TEdge> {
|
||||
const getNodeId = options.getNodeId ?? ((node: TNode) => node.id);
|
||||
const getEdgeSource = options.getEdgeSource ?? ((edge: TEdge) => edge.source as TNode["id"]);
|
||||
const getEdgeTarget = options.getEdgeTarget ?? ((edge: TEdge) => edge.target as TNode["id"]);
|
||||
|
||||
const byId = new Map<string, TNode>();
|
||||
for (const node of options.nodes) {
|
||||
byId.set(toComparableId(getNodeId(node)), node);
|
||||
}
|
||||
|
||||
const incomingByTarget = new Map<string, TEdge[]>();
|
||||
for (const edge of options.edges) {
|
||||
const key = toComparableId(getEdgeTarget(edge));
|
||||
const existing = incomingByTarget.get(key);
|
||||
if (existing) {
|
||||
existing.push(edge);
|
||||
} else {
|
||||
incomingByTarget.set(key, [edge]);
|
||||
}
|
||||
}
|
||||
|
||||
const path: TNode[] = [];
|
||||
const selectedEdges: TEdge[] = [];
|
||||
const visiting = new Set<string>();
|
||||
|
||||
const visit = (currentId: TNode["id"]): void => {
|
||||
const key = toComparableId(currentId);
|
||||
if (visiting.has(key)) {
|
||||
throw new Error(`Cycle detected in pipeline graph at node '${key}'.`);
|
||||
}
|
||||
|
||||
visiting.add(key);
|
||||
|
||||
const incomingEdges = incomingByTarget.get(key) ?? [];
|
||||
const incoming = selectIncomingEdge(incomingEdges, getEdgeSource);
|
||||
if (incoming) {
|
||||
selectedEdges.push(incoming);
|
||||
visit(getEdgeSource(incoming));
|
||||
}
|
||||
|
||||
visiting.delete(key);
|
||||
|
||||
const current = byId.get(key);
|
||||
if (current) {
|
||||
path.push(current);
|
||||
}
|
||||
};
|
||||
|
||||
visit(options.nodeId);
|
||||
|
||||
return {
|
||||
path,
|
||||
selectedEdges,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectPipeline<
|
||||
TNode extends PipelineNodeLike,
|
||||
TEdge extends PipelineEdgeLike,
|
||||
>(
|
||||
options: UpstreamTraversalOptions<TNode, TEdge> & {
|
||||
isPipelineNode: (node: TNode) => boolean;
|
||||
},
|
||||
): PipelineStep<TNode["type"], TNode["data"]>[] {
|
||||
const getNodeId = options.getNodeId ?? ((node: TNode) => node.id);
|
||||
const getNodeType = options.getNodeType ?? ((node: TNode) => node.type);
|
||||
const getNodeData = options.getNodeData ?? ((node: TNode) => node.data);
|
||||
|
||||
const traversal = walkUpstream(options);
|
||||
|
||||
const steps: PipelineStep<TNode["type"], TNode["data"]>[] = [];
|
||||
for (const node of traversal.path) {
|
||||
if (!options.isPipelineNode(node)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
steps.push({
|
||||
nodeId: toComparableId(getNodeId(node)),
|
||||
type: getNodeType(node),
|
||||
params: getNodeData(node),
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
export function getSourceImage<
|
||||
TNode extends PipelineNodeLike,
|
||||
TEdge extends PipelineEdgeLike,
|
||||
TSourceImage,
|
||||
>(
|
||||
options: UpstreamTraversalOptions<TNode, TEdge> & {
|
||||
isSourceNode: (node: TNode) => boolean;
|
||||
getSourceImageFromNode: (node: TNode) => TSourceImage | null | undefined;
|
||||
},
|
||||
): TSourceImage | null {
|
||||
const traversal = walkUpstream(options);
|
||||
|
||||
for (let index = traversal.path.length - 1; index >= 0; index -= 1) {
|
||||
const node = traversal.path[index];
|
||||
if (!options.isSourceNode(node)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceImage = options.getSourceImageFromNode(node);
|
||||
if (sourceImage != null) {
|
||||
return sourceImage;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
const valueType = typeof value;
|
||||
if (valueType === "number" || valueType === "boolean") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (valueType === "string") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
||||
}
|
||||
|
||||
if (valueType === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
const sortedEntries = Object.entries(record).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
|
||||
const serialized = sortedEntries
|
||||
.map(([key, nestedValue]) => `${JSON.stringify(key)}:${stableStringify(nestedValue)}`)
|
||||
.join(",");
|
||||
return `{${serialized}}`;
|
||||
}
|
||||
|
||||
return JSON.stringify(String(value));
|
||||
}
|
||||
|
||||
function fnv1aHash(input: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
hash ^= input.charCodeAt(index);
|
||||
hash +=
|
||||
(hash << 1) +
|
||||
(hash << 4) +
|
||||
(hash << 7) +
|
||||
(hash << 8) +
|
||||
(hash << 24);
|
||||
}
|
||||
|
||||
return (hash >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
export function hashPipeline(
|
||||
sourceImage: unknown,
|
||||
steps: readonly PipelineStep[],
|
||||
): string {
|
||||
return fnv1aHash(
|
||||
stableStringify({
|
||||
sourceImage,
|
||||
steps,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user