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,6 +1,6 @@
|
|||||||
# 🍋 LemonSpace — ADR: Non-destruktiver Adjustment-Stack
|
# 🍋 LemonSpace — ADR: Non-destruktiver Adjustment-Stack
|
||||||
|
|
||||||
**Status:** Accepted
|
**Status:** In Progress (Phase 0)
|
||||||
**Datum:** März 2026
|
**Datum:** März 2026
|
||||||
**Kontext:** PRD v1.4, Kategorie 4 (Bildbearbeitung), Phase 2
|
**Kontext:** PRD v1.4, Kategorie 4 (Bildbearbeitung), Phase 2
|
||||||
|
|
||||||
@@ -8,7 +8,14 @@
|
|||||||
|
|
||||||
## 1. Entscheidung
|
## 1. Entscheidung
|
||||||
|
|
||||||
Adjustment-Nodes arbeiten non-destruktiv über eine **edge-basierte Pipeline**. Die Edge-Kette im Canvas *ist* der Stack — kein separates Datenmodell. Die Bildverarbeitung läuft client-seitig primär über eine **Web-Worker-Pipeline** mit OffscreenCanvas/2D-Rendering; ein WebGL-Pfad existiert ergänzend für kompatible Teilpfade. Keine externen Packages.
|
Adjustment-Nodes arbeiten non-destruktiv über eine **edge-basierte Pipeline**. Die Edge-Kette im Canvas *ist* der Stack — kein separates Datenmodell.
|
||||||
|
|
||||||
|
Ziel-API der Umsetzung:
|
||||||
|
|
||||||
|
- `Worker Preview`: Preview-Rendering als primärer Pfad.
|
||||||
|
- `Worker Full Render`: Voll-Render als separater Worker-Pfad.
|
||||||
|
- `Fallback/Recovery`: Main-Thread-Fallback bleibt Default-Sicherheitsnetz.
|
||||||
|
- `WebGL`: optionaler Off-Path, nicht vorausgesetzt für Phase 0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -104,46 +111,40 @@ Invalidierung erfolgt request-basiert: Neue Requests verdrängen veraltete Ergeb
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementierungsstand (Stand: 31.03.2026)
|
## Implementierungsstand (Stand: Phase 0)
|
||||||
|
|
||||||
### Produktiv umgesetzt
|
### Aktueller Ist-Zustand
|
||||||
|
|
||||||
- WebWorker-Migration ist produktiv aktiv über `lib/image-pipeline/pipeline.worker.ts`, `lib/image-pipeline/pipeline-bridge.ts` und `lib/image-pipeline/index.ts`.
|
- Es gibt derzeit **keine produktiv integrierte Frontend-Runtime** für die Image-Pipeline im Repository.
|
||||||
- Preview- und Full-Render laufen über Worker-Requests aus `hooks/use-pipeline-preview.ts` und `components/canvas/nodes/render-node.tsx`.
|
- Phase 0 liefert den Architektur- und Vertragsabgleich (Node-Type Single Source, Pipeline-Contract als pure TS-Funktionen, serverseitige Guard-Rules für Adjustment-Data).
|
||||||
- Die Worker-Pipeline rendert aktuell über `OffscreenCanvas` + 2D-Kontext (inkl. Kurven-LUT, Canvas-Filter und nachgelagerte Pixel-Adjustments), Histogramm-Berechnung erfolgt im Worker.
|
- Worker-Preview/Worker-Full-Render bleiben Zielarchitektur für die weiteren Phasen.
|
||||||
- Render-Node nutzt `bridge.renderFull(...)` und liefert aktuell einen lokalen Download-Export (kein Convex-Upload in diesem Pfad).
|
|
||||||
- Lifecycle-Cleanup ist angebunden: `disposePipelineBridge()` wird in `components/canvas/canvas.tsx` beim Unmount ausgeführt.
|
|
||||||
|
|
||||||
### Abweichungen zur ursprünglichen ADR-Intention / Zielvision aus dem Guide
|
### Was in Phase 0 bewusst noch nicht enthalten ist
|
||||||
|
|
||||||
- Die ursprünglich beschriebene, primär shader-zentrierte WebGL-Architektur ist nicht 1:1 der produktive Standardpfad.
|
- Keine UI-Integration für Adjustment-Preview oder Render-Node-Workflow.
|
||||||
- Statt einer reinen „WebGL-im-Worker“-Ausführung nutzt die aktuelle Worker-Pipeline einen OffscreenCanvas/2D-Rendering-Pfad mit ergänzenden ImageData-Operationen.
|
- Kein Worker-Bridge-Lifecycle im Canvas.
|
||||||
- Der Guide skizziert konzeptionell eine zentrale Worker-Instanz; die aktuelle Bridge betreibt getrennte Worker-Kanäle für Preview und Full-Render.
|
- Keine produktive WebGL-Pipeline.
|
||||||
- Der im ADR beschriebene Render-Node-Flow mit Convex-Storage-Materialisierung ist in der aktuellen UI nicht der Default-Exportpfad.
|
|
||||||
|
|
||||||
### Fallback- und Recovery-Mechanismen
|
### Fallback- und Recovery-Mechanismen
|
||||||
|
|
||||||
- `usePipelinePreview` versucht Worker-Rendering zuerst und schaltet bei Fehlern auf Main-Thread-Fallback (`canvas-render.ts`) um.
|
- Für die Zielimplementierung bleibt Main-Thread-Fallback mit Recovery der Default.
|
||||||
- Während des Fallback-Betriebs werden Worker-Recovery-Retries zeit- und zählbasiert angestoßen; bei erfolgreicher Probe wird zurück auf Worker gewechselt.
|
- Phase 0 definiert dafür den deterministischen Pipeline-Contract (`collectPipeline`, `getSourceImage`, `hashPipeline`) als Grundlage für Preview und Full-Render.
|
||||||
- Stale Ergebnisse werden über Request-Sequenzierung verworfen; betroffene `ImageBitmap`s werden aktiv freigegeben.
|
|
||||||
- Preview-Metriken erfassen u. a. Fallback-Switches und Recoveries über `lib/image-pipeline/preview-metrics.ts`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Einfluss des WebWorker-Migration-Guides
|
## Einfluss des WebWorker-Migration-Guides (Zielbild)
|
||||||
|
|
||||||
### Übernommene Konzepte
|
### Übernommene Konzepte (architektonisch)
|
||||||
|
|
||||||
- Entkopplung von UI und Bildpipeline über Worker + Bridge (`pipeline.worker.ts` / `pipeline-bridge.ts`).
|
- Entkopplung von UI und Bildpipeline über Worker + Bridge.
|
||||||
- Request-basierte Worker-API mit korrelierbarer Request-ID.
|
- Trennung von Preview und Full-Render API.
|
||||||
- Rückgabe von Preview-Bitmaps und Histogramm-Daten über Worker-Messages.
|
- Deterministische Pipeline-Berechnung und zyklussichere Traversierung.
|
||||||
- Singleton-Verwaltung der Bridge (`lib/image-pipeline/index.ts`) und Cleanup im Canvas-Lifecycle.
|
|
||||||
|
|
||||||
### Bewusst abgewandelte Punkte
|
### Bewusst offen gehaltene Punkte
|
||||||
|
|
||||||
- Reine WebGL-im-Worker-Zielarchitektur wurde zugunsten eines OffscreenCanvas/2D-Pfads umgesetzt.
|
- Ob der Renderpfad über OffscreenCanvas/2D, WebGL oder hybrid ausgeführt wird.
|
||||||
- Getrennte Worker für Preview und Full-Render statt nur eines universellen Workers.
|
- Ob Preview und Full-Render denselben Worker teilen oder separiert laufen.
|
||||||
- Render-Node-Integration ist aktuell auf clientseitigen Export fokussiert, nicht auf serverseitige Persistierung als Standardfluss.
|
- Persistenzstrategie für final gerenderte Artefakte.
|
||||||
|
|
||||||
### Offene Punkte / Follow-ups
|
### Offene Punkte / Follow-ups
|
||||||
|
|
||||||
@@ -155,9 +156,9 @@ Invalidierung erfolgt request-basiert: Neue Requests verdrängen veraltete Ergeb
|
|||||||
|
|
||||||
## 4. WebGL-Wrapper
|
## 4. WebGL-Wrapper
|
||||||
|
|
||||||
> **Hinweis zum Ist-Stand:** Dieser Abschnitt dokumentiert weiterhin die WebGL-Ziel-/Referenzarchitektur der Pipeline. Produktiv läuft die Preview-/Render-Ausführung derzeit primär im Worker über OffscreenCanvas/2D.
|
> **Hinweis zum Ist-Stand:** Dieser Abschnitt dokumentiert die Ziel-/Referenzarchitektur. Die Runtime ist im aktuellen Repository noch nicht integriert.
|
||||||
|
|
||||||
### Dateien
|
### Geplante Dateien
|
||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
@@ -639,7 +640,7 @@ Die Validierung läuft in `canvas.tsx` bei `onConnect` — ungültige Verbindung
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Dateistruktur (Phase 2 — Bildbearbeitung)
|
## 11. Ziel-Dateistruktur (Phase 2 — Bildbearbeitung)
|
||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
@@ -682,7 +683,7 @@ hooks/
|
|||||||
| Adjustment-Node Resize | ✅ | Resizeable (wie alle Nodes via base-node-wrapper), mit `minWidth: 240`. Preview skaliert mit, Slider-Layout bleibt stabil. |
|
| Adjustment-Node Resize | ✅ | Resizeable (wie alle Nodes via base-node-wrapper), mit `minWidth: 240`. Preview skaliert mit, Slider-Layout bleibt stabil. |
|
||||||
| Render-Node: Client- vs. Server-seitig | ✅ | Client-seitig über Worker-Bridge; aktueller Pfad ist Download-Export (`renderFull`) statt serverseitiger Persistierung. |
|
| Render-Node: Client- vs. Server-seitig | ✅ | Client-seitig über Worker-Bridge; aktueller Pfad ist Download-Export (`renderFull`) statt serverseitiger Persistierung. |
|
||||||
| Worker-Fallback/Recovery | ✅ | Bei Worker-Fehlern Fallback auf Main Thread; periodische Recovery-Versuche zurück in den Worker-Pfad. |
|
| Worker-Fallback/Recovery | ✅ | Bei Worker-Fehlern Fallback auf Main Thread; periodische Recovery-Versuche zurück in den Worker-Pfad. |
|
||||||
| Reine WebGL-im-Worker-Architektur | ⏳ | Guide-Zielbild; aktuell produktiv ist ein OffscreenCanvas/2D-Pfad im Worker. |
|
| Reine WebGL-im-Worker-Architektur | ⏳ | Optionaler Off-Path; Entscheidung folgt in späteren Phasen. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -696,7 +697,7 @@ hooks/
|
|||||||
| Preview-Auflösung | Dynamisch: nodeWidth × devicePixelRatio, max 1024px |
|
| Preview-Auflösung | Dynamisch: nodeWidth × devicePixelRatio, max 1024px |
|
||||||
| Mindestbreite Adjustment-Nodes | 240px |
|
| Mindestbreite Adjustment-Nodes | 240px |
|
||||||
| Max. Bild-Auflösung Render | Original-Auflösung |
|
| Max. Bild-Auflösung Render | Original-Auflösung |
|
||||||
| Primärer Renderpfad | Web Worker + OffscreenCanvas/2D |
|
| Primärer Renderpfad | Ziel: Web Worker (technischer Unterpfad wird später festgelegt) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
134
components/ui/drawer.tsx
Normal file
134
components/ui/drawer.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-sm text-popover-foreground data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
23
convex/node-type-validator.ts
Normal file
23
convex/node-type-validator.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { v, type Validator } from "convex/values";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ADJUSTMENT_NODE_TYPES,
|
||||||
|
CANVAS_NODE_TYPES,
|
||||||
|
PHASE1_CANVAS_NODE_TYPES,
|
||||||
|
} from "../lib/canvas-node-types";
|
||||||
|
|
||||||
|
function buildNodeTypeUnion<
|
||||||
|
const TValues extends readonly [string, string, ...string[]],
|
||||||
|
>(values: TValues): Validator<TValues[number], "required", string> {
|
||||||
|
return v.union(
|
||||||
|
...values.map((value) => v.literal(value)) as [
|
||||||
|
Validator<TValues[number], "required", string>,
|
||||||
|
Validator<TValues[number], "required", string>,
|
||||||
|
...Validator<TValues[number], "required", string>[],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const phase1NodeTypeValidator = buildNodeTypeUnion(PHASE1_CANVAS_NODE_TYPES);
|
||||||
|
export const nodeTypeValidator = buildNodeTypeUnion(CANVAS_NODE_TYPES);
|
||||||
|
export const adjustmentNodeTypeValidator = buildNodeTypeUnion(ADJUSTMENT_NODE_TYPES);
|
||||||
@@ -2,6 +2,8 @@ import { query, mutation, QueryCtx, MutationCtx } from "./_generated/server";
|
|||||||
import { v } from "convex/values";
|
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 { nodeTypeValidator } from "./node-type-validator";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Interne Helpers
|
// Interne Helpers
|
||||||
@@ -40,6 +42,35 @@ type NodeCreateMutationName =
|
|||||||
| "nodes.createWithEdgeFromSource"
|
| "nodes.createWithEdgeFromSource"
|
||||||
| "nodes.createWithEdgeToTarget";
|
| "nodes.createWithEdgeToTarget";
|
||||||
|
|
||||||
|
const DISALLOWED_ADJUSTMENT_DATA_KEYS = [
|
||||||
|
"storageId",
|
||||||
|
"url",
|
||||||
|
"blob",
|
||||||
|
"blobUrl",
|
||||||
|
"imageData",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoAdjustmentImagePayload(
|
||||||
|
nodeType: Doc<"nodes">["type"],
|
||||||
|
data: unknown,
|
||||||
|
): void {
|
||||||
|
if (!isAdjustmentNodeType(nodeType) || !isRecord(data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of DISALLOWED_ADJUSTMENT_DATA_KEYS) {
|
||||||
|
if (key in data) {
|
||||||
|
throw new Error(
|
||||||
|
`Adjustment nodes accept parameter data only. '${key}' is not allowed in data.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getIdempotentNodeCreateResult(
|
async function getIdempotentNodeCreateResult(
|
||||||
ctx: MutationCtx,
|
ctx: MutationCtx,
|
||||||
args: {
|
args: {
|
||||||
@@ -159,7 +190,7 @@ export const get = query({
|
|||||||
export const listByType = query({
|
export const listByType = query({
|
||||||
args: {
|
args: {
|
||||||
canvasId: v.id("canvases"),
|
canvasId: v.id("canvases"),
|
||||||
type: v.string(),
|
type: nodeTypeValidator,
|
||||||
},
|
},
|
||||||
handler: async (ctx, { canvasId, type }) => {
|
handler: async (ctx, { canvasId, type }) => {
|
||||||
const user = await requireAuth(ctx);
|
const user = await requireAuth(ctx);
|
||||||
@@ -187,7 +218,7 @@ export const listByType = query({
|
|||||||
export const create = mutation({
|
export const create = mutation({
|
||||||
args: {
|
args: {
|
||||||
canvasId: v.id("canvases"),
|
canvasId: v.id("canvases"),
|
||||||
type: v.string(),
|
type: nodeTypeValidator,
|
||||||
positionX: v.number(),
|
positionX: v.number(),
|
||||||
positionY: v.number(),
|
positionY: v.number(),
|
||||||
width: v.number(),
|
width: v.number(),
|
||||||
@@ -212,6 +243,8 @@ export const create = mutation({
|
|||||||
return existingNodeId;
|
return existingNodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertNoAdjustmentImagePayload(args.type, args.data);
|
||||||
|
|
||||||
const nodeId = await ctx.db.insert("nodes", {
|
const nodeId = await ctx.db.insert("nodes", {
|
||||||
canvasId: args.canvasId,
|
canvasId: args.canvasId,
|
||||||
type: args.type as Doc<"nodes">["type"],
|
type: args.type as Doc<"nodes">["type"],
|
||||||
@@ -246,7 +279,7 @@ export const create = mutation({
|
|||||||
export const createWithEdgeSplit = mutation({
|
export const createWithEdgeSplit = mutation({
|
||||||
args: {
|
args: {
|
||||||
canvasId: v.id("canvases"),
|
canvasId: v.id("canvases"),
|
||||||
type: v.string(),
|
type: nodeTypeValidator,
|
||||||
positionX: v.number(),
|
positionX: v.number(),
|
||||||
positionY: v.number(),
|
positionY: v.number(),
|
||||||
width: v.number(),
|
width: v.number(),
|
||||||
@@ -280,6 +313,8 @@ export const createWithEdgeSplit = mutation({
|
|||||||
throw new Error("Edge not found");
|
throw new Error("Edge not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertNoAdjustmentImagePayload(args.type, args.data);
|
||||||
|
|
||||||
const nodeId = await ctx.db.insert("nodes", {
|
const nodeId = await ctx.db.insert("nodes", {
|
||||||
canvasId: args.canvasId,
|
canvasId: args.canvasId,
|
||||||
type: args.type as Doc<"nodes">["type"],
|
type: args.type as Doc<"nodes">["type"],
|
||||||
@@ -434,7 +469,7 @@ export const splitEdgeAtExistingNode = mutation({
|
|||||||
export const createWithEdgeFromSource = mutation({
|
export const createWithEdgeFromSource = mutation({
|
||||||
args: {
|
args: {
|
||||||
canvasId: v.id("canvases"),
|
canvasId: v.id("canvases"),
|
||||||
type: v.string(),
|
type: nodeTypeValidator,
|
||||||
positionX: v.number(),
|
positionX: v.number(),
|
||||||
positionY: v.number(),
|
positionY: v.number(),
|
||||||
width: v.number(),
|
width: v.number(),
|
||||||
@@ -466,6 +501,8 @@ export const createWithEdgeFromSource = mutation({
|
|||||||
throw new Error("Source node not found");
|
throw new Error("Source node not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertNoAdjustmentImagePayload(args.type, args.data);
|
||||||
|
|
||||||
const nodeId = await ctx.db.insert("nodes", {
|
const nodeId = await ctx.db.insert("nodes", {
|
||||||
canvasId: args.canvasId,
|
canvasId: args.canvasId,
|
||||||
type: args.type as Doc<"nodes">["type"],
|
type: args.type as Doc<"nodes">["type"],
|
||||||
@@ -508,7 +545,7 @@ export const createWithEdgeFromSource = mutation({
|
|||||||
export const createWithEdgeToTarget = mutation({
|
export const createWithEdgeToTarget = mutation({
|
||||||
args: {
|
args: {
|
||||||
canvasId: v.id("canvases"),
|
canvasId: v.id("canvases"),
|
||||||
type: v.string(),
|
type: nodeTypeValidator,
|
||||||
positionX: v.number(),
|
positionX: v.number(),
|
||||||
positionY: v.number(),
|
positionY: v.number(),
|
||||||
width: v.number(),
|
width: v.number(),
|
||||||
@@ -540,6 +577,8 @@ export const createWithEdgeToTarget = mutation({
|
|||||||
throw new Error("Target node not found");
|
throw new Error("Target node not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertNoAdjustmentImagePayload(args.type, args.data);
|
||||||
|
|
||||||
const nodeId = await ctx.db.insert("nodes", {
|
const nodeId = await ctx.db.insert("nodes", {
|
||||||
canvasId: args.canvasId,
|
canvasId: args.canvasId,
|
||||||
type: args.type as Doc<"nodes">["type"],
|
type: args.type as Doc<"nodes">["type"],
|
||||||
@@ -659,6 +698,7 @@ export const updateData = mutation({
|
|||||||
if (!node) throw new Error("Node not found");
|
if (!node) throw new Error("Node not found");
|
||||||
|
|
||||||
await getCanvasOrThrow(ctx, node.canvasId, user.userId);
|
await getCanvasOrThrow(ctx, node.canvasId, user.userId);
|
||||||
|
assertNoAdjustmentImagePayload(node.type, data);
|
||||||
await ctx.db.patch(nodeId, { data });
|
await ctx.db.patch(nodeId, { data });
|
||||||
await ctx.db.patch(node.canvasId, { updatedAt: Date.now() });
|
await ctx.db.patch(node.canvasId, { updatedAt: Date.now() });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,76 +2,23 @@
|
|||||||
import { defineSchema, defineTable } from "convex/server";
|
import { defineSchema, defineTable } from "convex/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
import {
|
||||||
|
nodeTypeValidator,
|
||||||
|
phase1NodeTypeValidator,
|
||||||
|
} from "./node-type-validator";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Node Types
|
// Node Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// Phase 1 Node Types
|
// Phase 1 Node Types
|
||||||
const phase1NodeTypes = v.union(
|
const phase1NodeTypes = phase1NodeTypeValidator;
|
||||||
// Quelle
|
|
||||||
v.literal("image"),
|
|
||||||
v.literal("text"),
|
|
||||||
v.literal("prompt"),
|
|
||||||
// KI-Ausgabe
|
|
||||||
v.literal("ai-image"),
|
|
||||||
// Canvas & Layout
|
|
||||||
v.literal("group"),
|
|
||||||
v.literal("frame"),
|
|
||||||
v.literal("note"),
|
|
||||||
v.literal("compare")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Alle Node Types (Phase 1 + spätere Phasen)
|
// Alle Node Types (Phase 1 + spätere Phasen)
|
||||||
// Phase 2+3 Typen sind hier schon definiert, damit das Schema nicht bei
|
// Phase 2+3 Typen sind hier schon definiert, damit das Schema nicht bei
|
||||||
// jedem Phasenübergang migriert werden muss. Die UI zeigt nur die Typen
|
// jedem Phasenübergang migriert werden muss. Die UI zeigt nur die Typen
|
||||||
// der jeweiligen Phase an.
|
// der jeweiligen Phase an.
|
||||||
const nodeType = v.union(
|
const nodeType = nodeTypeValidator;
|
||||||
// Quelle (Phase 1)
|
|
||||||
v.literal("image"),
|
|
||||||
v.literal("text"),
|
|
||||||
v.literal("prompt"),
|
|
||||||
// Quelle (Phase 2)
|
|
||||||
v.literal("color"),
|
|
||||||
v.literal("video"),
|
|
||||||
v.literal("asset"),
|
|
||||||
// KI-Ausgabe (Phase 1)
|
|
||||||
v.literal("ai-image"),
|
|
||||||
// KI-Ausgabe (Phase 2)
|
|
||||||
v.literal("ai-text"),
|
|
||||||
v.literal("ai-video"),
|
|
||||||
// KI-Ausgabe (Phase 3)
|
|
||||||
v.literal("agent-output"),
|
|
||||||
// Transformation (Phase 2)
|
|
||||||
v.literal("crop"),
|
|
||||||
v.literal("bg-remove"),
|
|
||||||
v.literal("upscale"),
|
|
||||||
// Transformation (Phase 3)
|
|
||||||
v.literal("style-transfer"),
|
|
||||||
v.literal("face-restore"),
|
|
||||||
// Bildbearbeitung (Phase 2)
|
|
||||||
v.literal("curves"),
|
|
||||||
v.literal("color-adjust"),
|
|
||||||
v.literal("light-adjust"),
|
|
||||||
v.literal("detail-adjust"),
|
|
||||||
v.literal("render"),
|
|
||||||
// Steuerung (Phase 2)
|
|
||||||
v.literal("splitter"),
|
|
||||||
v.literal("loop"),
|
|
||||||
v.literal("agent"),
|
|
||||||
// Steuerung (Phase 3)
|
|
||||||
v.literal("mixer"),
|
|
||||||
v.literal("switch"),
|
|
||||||
// Canvas & Layout (Phase 1)
|
|
||||||
v.literal("group"),
|
|
||||||
v.literal("frame"),
|
|
||||||
v.literal("note"),
|
|
||||||
v.literal("compare"),
|
|
||||||
// Canvas & Layout (Phase 2)
|
|
||||||
v.literal("text-overlay"),
|
|
||||||
// Canvas & Layout (Phase 3)
|
|
||||||
v.literal("comment"),
|
|
||||||
v.literal("presentation")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Node Status — direkt am Node sichtbar (UX-Strategie aus dem PRD)
|
// Node Status — direkt am Node sichtbar (UX-Strategie aus dem PRD)
|
||||||
const nodeStatus = v.union(
|
const nodeStatus = v.union(
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Doc } from "@/convex/_generated/dataModel";
|
|
||||||
import { nodeTypes } from "@/components/canvas/node-types";
|
import { nodeTypes } from "@/components/canvas/node-types";
|
||||||
import {
|
import {
|
||||||
CANVAS_NODE_TEMPLATES,
|
CANVAS_NODE_TEMPLATES,
|
||||||
type CanvasNodeTemplate,
|
type CanvasNodeTemplate,
|
||||||
} from "@/lib/canvas-node-templates";
|
} from "@/lib/canvas-node-templates";
|
||||||
|
import type { CanvasNodeType } from "@/lib/canvas-node-types";
|
||||||
|
|
||||||
/** PRD-Kategorien (Reihenfolge für Sidebar / Dropdown). */
|
/** PRD-Kategorien (Reihenfolge für Sidebar / Dropdown). */
|
||||||
export type NodeCategoryId =
|
export type NodeCategoryId =
|
||||||
@@ -30,7 +30,7 @@ export const NODE_CATEGORIES_ORDERED: NodeCategoryId[] = (
|
|||||||
Object.keys(NODE_CATEGORY_META) as NodeCategoryId[]
|
Object.keys(NODE_CATEGORY_META) as NodeCategoryId[]
|
||||||
).sort((a, b) => NODE_CATEGORY_META[a].order - NODE_CATEGORY_META[b].order);
|
).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 = {
|
export type NodeCatalogEntry = {
|
||||||
type: CatalogNodeType;
|
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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"shadcn": "^4.1.0",
|
"shadcn": "^4.1.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -104,6 +104,9 @@ importers:
|
|||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
|
vaul:
|
||||||
|
specifier: ^1.1.2
|
||||||
|
version: 1.1.2(@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)
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
|
|||||||
Reference in New Issue
Block a user