diff --git a/.gitignore b/.gitignore index efab411..f240e1a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,8 @@ next-env.d.ts # renders renders/ +# tiles +tiles/ + # .claude .claude/settings.local.json \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3ab1b8a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,83 @@ +# Node Banana - Development Guide + +## Model + +The application uses these models for image generation. These models are very recently released and do exist. +gemini-3-pro-image-preview +gemini-2.5-flash-preview-image-generation + +## Node Connection System + +### Handle Types + +Nodes communicate through typed handles. Each handle has a **data type** that determines what connections are valid. + +| Handle Type | Data Format | Description | +| ----------- | --------------- | ----------------------------------------------------------- | +| `image` | Base64 data URL | Visual content (photos, generated images, annotated images) | +| `text` | String | Text content (user prompts, LLM outputs, transformed text) | + +### Connection Rules + +1. **Type Matching**: Handles can only connect to handles of the same type + + - `image` → `image` (valid) + - `text` → `text` (valid) + - `image` → `text` (invalid) + +2. **Direction**: Connections flow from `source` (output) to `target` (input) + +3. **Multiplicity**: + - Image inputs on generation nodes accept multiple connections (for multi-image context) + - Text inputs accept single connections (last connected wins) + +### Data Flow in `getConnectedInputs` + +When a node executes, it retrieves connected inputs via `getConnectedInputs(nodeId)` in `workflowStore.ts`. This function returns `{ images: string[], text: string | null }`. + +**For `image` handles, extract from:** + +- `imageInput` → `data.image` +- `annotation` → `data.outputImage` +- `nanoBanana` → `data.outputImage` + +**For `text` handles, extract from:** + +- `prompt` → `data.prompt` +- `llmGenerate` → `data.outputText` + +### Adding New Node Types + +When creating a new node type: + +1. **Define the data interface** in `src/types/index.ts` +2. **Add to `NodeType` union** in `src/types/index.ts` +3. **Create default data** in `createDefaultNodeData()` in `workflowStore.ts` +4. **Add dimensions** to `defaultDimensions` in `workflowStore.ts` +5. **Create the component** in `src/components/nodes/` +6. **Export from** `src/components/nodes/index.ts` +7. **Register in nodeTypes** in `WorkflowCanvas.tsx` +8. **Add minimap color** in `WorkflowCanvas.tsx` +9. **Update `getConnectedInputs`** if the node produces output that other nodes consume +10. **Add execution logic** in `executeWorkflow()` if the node requires processing +11. **Update `ConnectionDropMenu.tsx`** to include the node in appropriate source/target lists + +### Handle Naming Convention + +Use descriptive handle IDs that match the data type: + +- `id="image"` for image data +- `id="text"` for text data + +Future handle types might include: + +- `audio` - for audio data +- `video` - for video data +- `json` - for structured data +- `number` - for numeric values + +### Validation + +Connection validation happens in `isValidConnection()` in `WorkflowCanvas.tsx`. Update this function if adding new handle types with specific rules. + +Workflow validation happens in `validateWorkflow()` in `workflowStore.ts`. Add checks for required inputs on new node types. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..384e1a1 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +dev: + npm run dev + +sync-fork: + @echo "Fetching from upstream..." + git fetch upstream + @echo "Merging upstream/main into current branch..." + git merge upstream/main + @echo "Pushing to origin..." + git push origin + @echo "Fork synced successfully!" + +setup-upstream: + @echo "Setting up upstream remote..." + @read -p "Enter upstream repository URL: " url; \ + git remote add upstream $$url + @echo "Upstream remote added. Run 'make sync-fork' to sync." \ No newline at end of file diff --git a/prd-utility-nodes.md b/prd-utility-nodes.md new file mode 100644 index 0000000..ce621b6 --- /dev/null +++ b/prd-utility-nodes.md @@ -0,0 +1,343 @@ +# PRD: Utility Nodes + Image Grid Split Node + +**Version:** 1.0 +**Last Updated:** December 2025 +**Status:** Draft +**Related PRD:** Node-Based Image Annotation & Generation Workflow (v1.0) + +--- + +## 1. Overview + +### 1.1 Product Summary + +Add a new node category called **Utility Nodes** for non-generative operations (pure transforms) inside the workflow graph. The first utility node splits a single image into an **R×C** grid and outputs each tile as a separate image output. + +### 1.2 Problem Statement + +Users frequently need to: + +- split a generated/edited image into consistent tiles (e.g., grids for carousels, texture/detail crops, multi-variant boards), +- feed specific tiles into subsequent nodes (annotation or generation) without exporting/re-importing assets. + +### 1.3 Goals / Success Criteria + +- User can split an image into a grid (e.g., 3×3) and route any tile into downstream nodes in **under 30 seconds**. +- Split node outputs are **stable, deterministic**, and **preserve pixel fidelity**. +- Workflow execution correctly propagates per-tile outputs via edges. + +--- + +## 2. Scope + +### 2.1 In Scope (v1) + +- New **Utility** node group in node picker/action bar. +- **Image Grid Split Node**: + + - accepts image input + rows/columns configuration, + - produces **rows × columns** tile outputs as images (base64 PNG), + - exposes **one output handle per tile**. + +- Workflow engine support for **multi-output nodes** (output keyed by `sourceHandle`). + +### 2.2 Out of Scope (v1) + +- “Batch” execution semantics (one node consuming an array and producing arrays). +- Auto-creating downstream nodes per tile. +- Advanced split features (overlap, padding/gutter, arbitrary crops, smart content-aware splitting). +- Persisting/exporting tiles as a zip. + +--- + +## 3. Tech / Architecture Notes + +### 3.1 Data Type Additions + +Add new conceptual data patterns: + +- `image` (existing): a single base64 image +- `image:tile` (new, still compatible with `image`): same payload shape as image, plus metadata +- **Multi-output**: node produces multiple `image` outputs keyed by output handle id + +### 3.2 Workflow Engine Change (Multi-Output) + +Current execution assumes a node returns a single output. Update to store outputs per node **and per output handle**. + +**Proposed runtime shape:** + +```ts +type NodeOutputMap = Record< + string /* nodeId */, + Record +>; +// Example: outputs[nodeId]["tile-0"] = { imageBase64, meta } +``` + +Edge propagation uses: + +- `edge.source` + `edge.sourceHandle` → find the exact output payload +- assign payload to downstream node input keyed by `edge.targetHandle` + +--- + +## 4. Node Graph Updates + +### 4.1 Node Connection Rules (Additions) + +New node type: + +``` +Image Grid Split Node → [image input] [tile image outputs...] +``` + +Compatibility: + +- Split outputs (`image`) can connect to any node expecting `image input` (Annotation Node, Nano Banana Node, Output Node). +- Split node requires an upstream image (Image Input / Annotation / Nano Banana / Output). + +--- + +## 5. Utility Nodes + +### 5.1 Utility Node Category + +**Definition:** Nodes that transform or restructure data locally (client-side), without calling external AI APIs. + +**General Requirements** + +| ID | Requirement | Priority | +| ----- | ------------------------------------------------------------------------------------ | -------- | +| UT-01 | Utility nodes appear in the same add-node UI (sidebar or action bar) | Must | +| UT-02 | Utility nodes use the same selection, deletion, and edge behaviors as existing nodes | Must | +| UT-03 | Utility nodes execute as part of Run Workflow ordering | Must | +| UT-04 | Utility nodes are deterministic and fast; show status if processing is non-trivial | Should | + +--- + +## 6. Node Spec: Image Grid Split Node + +### 6.1 Purpose + +Split a single input image into **equal** rows and columns and output each tile as an image. + +### 6.2 Inputs / Outputs + +**Inputs** + +- `image` (required) — from an upstream node +- `rows` (required) — integer +- `columns` (required) — integer + +**Outputs** + +- `tile-0 ... tile-(rows*columns - 1)` — each is an `image` output (base64 PNG), plus metadata + +**Tile ordering** + +- Row-major order: + + - index = `row * columns + col` + - `tile-0` = (row 0, col 0), `tile-1` = (row 0, col 1), … + +### 6.3 Requirements + +| ID | Requirement | Priority | +| ----- | ------------------------------------------------------------------ | -------- | +| GS-01 | Accept image input from connected node | Must | +| GS-02 | Rows and Columns are numeric inputs inside the node | Must | +| GS-03 | Validate rows/columns are integers within allowed range | Must | +| GS-04 | Produce rows×columns outputs as individual image handles | Must | +| GS-05 | Display a preview with a grid overlay | Should | +| GS-06 | Display tile thumbnails (collapsible) | Should | +| GS-07 | Preserve pixel fidelity (no resampling unless explicitly required) | Must | +| GS-08 | Deterministic split behavior when dimensions don’t divide evenly | Must | +| GS-09 | Show error state if split cannot be performed | Must | + +### 6.4 Constraints / Validation + +- `rows`: integer, min 1, max **10** (configurable constant) +- `columns`: integer, min 1, max **10** +- Max tiles: `rows * columns <= 64` (default cap; prevents UI/edge explosion) + +### 6.5 Split Algorithm (Deterministic) + +Given image width `W`, height `H`, columns `C`, rows `R`: + +- Base tile width `tw = floor(W / C)` +- Base tile height `th = floor(H / R)` +- Remainders: + + - `rw = W - tw*C` + - `rh = H - th*R` + +**Remainder policy (simple + predictable):** + +- Distribute extra pixels to the **last column** and **last row**: + + - last column width = `tw + rw` + - last row height = `th + rh` + +This guarantees: + +- All pixels are included exactly once +- No overlap, no gaps + +### 6.6 Output Payload + +Each tile output payload: + +```ts +interface ImageTilePayload { + imageBase64: string; // PNG base64 (no data: prefix in internal storage if consistent with current app) + mimeType: "image/png"; + meta: { + sourceNodeId: string; + sourceImageWidth: number; + sourceImageHeight: number; + row: number; + col: number; + index: number; + crop: { x: number; y: number; width: number; height: number }; + }; +} +``` + +--- + +## 7. UI / UX + +### 7.1 Node UI Layout + +- Node header: **“Grid Split”** + utility icon +- Body: + + - Numeric inputs: `Rows`, `Columns` + - Preview area: source image thumbnail + grid overlay + - Tile preview strip (optional collapsible): mini thumbnails labeled `r1c1`, `r1c2`, … + +- Handles: + + - **1 input handle** (left): `image` + - **N output handles** (right): one per tile, labeled by `rXcY` (or `1,2,3…`) + +### 7.2 Output Handle Naming + +- Handle id: `tile-${index}` +- Label: `r${row+1}c${col+1}` (human readable) + +### 7.3 Canvas Clutter Controls (Must-have guardrails) + +- If `rows*columns > 16`, collapse handle labels to short form (e.g., `t0..t15`) with tooltip on hover. +- Enforce max tiles cap (default 64) with inline validation error. + +--- + +## 8. Workflow Execution + +### 8.1 Execution Behavior + +During `Run Workflow`: + +1. Split node waits for its upstream image to resolve. +2. Performs client-side split (Canvas drawImage crop per tile). +3. Emits each tile output mapped to its output handle id. +4. Downstream nodes receive the tile image that corresponds to the connected edge’s `sourceHandle`. + +### 8.2 Status States + +| State | Meaning | +| -------- | ------------------------------------ | +| idle | not executed yet / inputs missing | +| ready | image present and rows/cols valid | +| loading | splitting in progress | +| complete | tiles available | +| error | invalid inputs or processing failure | + +--- + +## 9. Error States + +| Scenario | User Feedback | +| -------------------------------------------- | --------------------------------------------- | +| Missing image input | Node shows “Connect an image input” | +| rows/cols invalid (0, negative, non-integer) | Inline validation + node error | +| Too many tiles (exceeds cap) | Inline validation: “Max tiles is 64” | +| Image decode/canvas failure | Toast + node error: “Could not process image” | +| Memory pressure / very large image | Toast warning + node error if split fails | + +--- + +## 10. State / Types (Implementation Notes) + +### 10.1 Node Data Interface + +```ts +interface GridSplitNodeData { + inputImage: string | null; // base64 + rows: number; // default 2 + columns: number; // default 2 + status: "idle" | "ready" | "loading" | "complete" | "error"; + error: string | null; + + // optional UI convenience + previewImage: string | null; // thumbnail (can reuse inputImage) + tileCount: number; // rows * columns +} +``` + +### 10.2 Workflow Output Storage + +```ts +type HandleId = string; + +interface WorkflowRuntime { + outputs: Record>; +} +``` + +--- + +## 11. Acceptance Criteria + +### 11.1 Node Creation & Editing + +- [ ] User can add **Grid Split** node from Utility section +- [ ] User can set rows/columns (defaults: 2×2) +- [ ] Node validates rows/columns and shows errors inline + +### 11.2 Splitting & Routing + +- [ ] When connected to an image and run, node produces **rows×columns** tile outputs +- [ ] Each tile output can connect to **Annotation**, **Nano Banana**, or **Output** nodes +- [ ] Downstream nodes receive the correct tile image (verified by visual inspection using distinct quadrants) + +### 11.3 Determinism + +- [ ] Given the same image + rows/cols, the tile crop boundaries are stable across runs +- [ ] Non-divisible dimensions follow the documented remainder policy + +### 11.4 UX Guardrails + +- [ ] Tile cap prevents UI blow-ups (default max 64 tiles) +- [ ] Node remains usable on common laptop resolutions without excessive overlap + +--- + +## 12. Design Choice (Where the graph model matters) + +| Option | What it means | Prob. it scales well | Payoff if right | Failure mode | +| -------------------------------------- | --------------------------------------------- | -------------------: | ----------------------------: | --------------------------------------- | +| A) **One output handle per tile** | Split produces N discrete `image` outputs | 0.75 | Highest usability for routing | Canvas clutter at high N | +| B) Output an `image[]` array | One output handle, downstream consumes arrays | 0.55 | Cleaner graph | Requires “array-aware” downstream nodes | +| C) Hybrid: handles up to 9, else array | Smart defaults | 0.65 | Best of both | More complexity in type system | + +If the goal is “route any tile into existing nodes without changing them,” option **A** has the highest payoff because it preserves the existing `image` contract. + +--- + +## 13. Follow-on Utility Nodes (Likely needed soon) + +- **Tile Picker Node**: takes `image[]` (or a split node reference) + index → outputs single image +- **Grid Merge Node**: N tiles → one composite image +- **Batch Generate Node**: multiple images + one prompt → multiple generated outputs (rate-limit aware) diff --git a/scripts/split-grid.sh b/scripts/split-grid.sh new file mode 100755 index 0000000..05747b4 --- /dev/null +++ b/scripts/split-grid.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +# Split an image into a grid of tiles using ffmpeg +# Usage: ./split-grid.sh [output-dir] +# +# Example: ./split-grid.sh contact-sheet.jpg 2 3 ./output + +set -e + +if [ $# -lt 3 ]; then + echo "Usage: $0 [output-dir]" + echo "" + echo "Arguments:" + echo " input-image Path to the input image file" + echo " rows Number of rows in the grid" + echo " cols Number of columns in the grid" + echo " output-dir Output directory (default: ./tiles)" + echo "" + echo "Example:" + echo " $0 contact-sheet.jpg 2 3 ./output" + exit 1 +fi + +INPUT="$1" +ROWS="$2" +COLS="$3" +OUTPUT_DIR="${4:-./tiles}" + +# Check if input file exists +if [ ! -f "$INPUT" ]; then + echo "Error: Input file not found: $INPUT" + exit 1 +fi + +# Check if ffmpeg is available +if ! command -v ffmpeg &> /dev/null; then + echo "Error: ffmpeg is not installed" + exit 1 +fi + +# Create output directory +mkdir -p "$OUTPUT_DIR" + +# Get image dimensions using ffprobe +DIMENSIONS=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 "$INPUT") +WIDTH=$(echo "$DIMENSIONS" | cut -d',' -f1) +HEIGHT=$(echo "$DIMENSIONS" | cut -d',' -f2) + +echo "Input image: ${WIDTH}x${HEIGHT}" +echo "Splitting into ${ROWS} rows x ${COLS} columns = $((ROWS * COLS)) tiles" + +TILE_WIDTH=$((WIDTH / COLS)) +TILE_HEIGHT=$((HEIGHT / ROWS)) + +echo "Tile size: ${TILE_WIDTH}x${TILE_HEIGHT}" + +# Get base name and extension +BASENAME=$(basename "$INPUT") +NAME="${BASENAME%.*}" +EXT="${BASENAME##*.}" + +TILE_NUM=1 +for ((row=0; row - + + ), }, @@ -28,8 +38,37 @@ const IMAGE_TARGET_OPTIONS: MenuOption[] = [ type: "nanoBanana", label: "Generate Image", icon: ( - - + + + + ), + }, + { + type: "gridSplit", + label: "Grid Split", + icon: ( + + ), }, @@ -38,8 +77,18 @@ const IMAGE_TARGET_OPTIONS: MenuOption[] = [ label: "Split Image Grid", isAction: true, icon: ( - - + + ), }, @@ -47,8 +96,18 @@ const IMAGE_TARGET_OPTIONS: MenuOption[] = [ type: "output", label: "Output", icon: ( - - + + ), }, @@ -59,8 +118,18 @@ const TEXT_TARGET_OPTIONS: MenuOption[] = [ type: "nanoBanana", label: "Generate Image", icon: ( - - + + ), }, @@ -68,8 +137,18 @@ const TEXT_TARGET_OPTIONS: MenuOption[] = [ type: "llmGenerate", label: "LLM Generate", icon: ( - - + + ), }, @@ -81,8 +160,18 @@ const IMAGE_SOURCE_OPTIONS: MenuOption[] = [ type: "imageInput", label: "Image Input", icon: ( - - + + ), }, @@ -90,8 +179,18 @@ const IMAGE_SOURCE_OPTIONS: MenuOption[] = [ type: "annotation", label: "Annotate", icon: ( - - + + ), }, @@ -99,8 +198,37 @@ const IMAGE_SOURCE_OPTIONS: MenuOption[] = [ type: "nanoBanana", label: "Generate Image", icon: ( - - + + + + ), + }, + { + type: "gridSplit", + label: "Grid Split", + icon: ( + + ), }, @@ -111,8 +239,18 @@ const TEXT_SOURCE_OPTIONS: MenuOption[] = [ type: "prompt", label: "Prompt", icon: ( - - + + ), }, @@ -120,8 +258,18 @@ const TEXT_SOURCE_OPTIONS: MenuOption[] = [ type: "llmGenerate", label: "LLM Generate", icon: ( - - + + ), }, @@ -131,7 +279,10 @@ interface ConnectionDropMenuProps { position: { x: number; y: number }; handleType: "image" | "text" | null; connectionType: "source" | "target"; // source = dragging from output, target = dragging from input - onSelect: (selection: { type: NodeType | MenuAction; isAction: boolean }) => void; + onSelect: (selection: { + type: NodeType | MenuAction; + isAction: boolean; + }) => void; onClose: () => void; } @@ -151,10 +302,14 @@ export function ConnectionDropMenu({ if (connectionType === "source") { // Dragging from a source handle (output), need nodes with target handles (inputs) - return handleType === "image" ? IMAGE_TARGET_OPTIONS : TEXT_TARGET_OPTIONS; + return handleType === "image" + ? IMAGE_TARGET_OPTIONS + : TEXT_TARGET_OPTIONS; } else { // Dragging from a target handle (input), need nodes with source handles (outputs) - return handleType === "image" ? IMAGE_SOURCE_OPTIONS : TEXT_SOURCE_OPTIONS; + return handleType === "image" + ? IMAGE_SOURCE_OPTIONS + : TEXT_SOURCE_OPTIONS; } }, [handleType, connectionType]); @@ -170,7 +325,9 @@ export function ConnectionDropMenu({ break; case "ArrowUp": e.preventDefault(); - setSelectedIndex((prev) => (prev - 1 + options.length) % options.length); + setSelectedIndex( + (prev) => (prev - 1 + options.length) % options.length + ); break; case "Enter": e.preventDefault(); @@ -231,7 +388,12 @@ export function ConnectionDropMenu({ {options.map((option, index) => (
- ↑↓ navigate + + ↑↓ + {" "} + navigate - select + {" "} + select
diff --git a/src/components/FloatingActionBar.tsx b/src/components/FloatingActionBar.tsx index 5371dcd..3610998 100644 --- a/src/components/FloatingActionBar.tsx +++ b/src/components/FloatingActionBar.tsx @@ -7,7 +7,7 @@ import { useReactFlow } from "@xyflow/react"; // Get the center of the React Flow pane in screen coordinates function getPaneCenter() { - const pane = document.querySelector('.react-flow'); + const pane = document.querySelector(".react-flow"); if (pane) { const rect = pane.getBoundingClientRect(); return { @@ -101,13 +101,19 @@ function GenerateComboButton() { > Generate - + @@ -119,8 +125,18 @@ function GenerateComboButton() { onDragStart={(e) => handleDragStart(e, "nanoBanana")} className="w-full px-3 py-2 text-left text-[11px] font-medium text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100 transition-colors flex items-center gap-2 cursor-grab active:cursor-grabbing" > - - + + Image @@ -130,8 +146,18 @@ function GenerateComboButton() { onDragStart={(e) => handleDragStart(e, "llmGenerate")} className="w-full px-3 py-2 text-left text-[11px] font-medium text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100 transition-colors flex items-center gap-2 cursor-grab active:cursor-grabbing" > - - + + Text (LLM) @@ -141,6 +167,98 @@ function GenerateComboButton() { ); } +function UtilityComboButton() { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + const addNode = useWorkflowStore((state) => state.addNode); + const { screenToFlowPosition } = useReactFlow(); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen]); + + const handleAddNode = (type: NodeType) => { + const center = getPaneCenter(); + const position = screenToFlowPosition({ + x: center.x + Math.random() * 100 - 50, + y: center.y + Math.random() * 100 - 50, + }); + + addNode(type, position); + setIsOpen(false); + }; + + const handleDragStart = (event: React.DragEvent, type: NodeType) => { + event.dataTransfer.setData("application/node-type", type); + event.dataTransfer.effectAllowed = "copy"; + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && ( +
+ +
+ )} +
+ ); +} + export function FloatingActionBar() { const { nodes, @@ -166,7 +284,10 @@ export function FloatingActionBar() { // Close run menu when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (runMenuRef.current && !runMenuRef.current.contains(event.target as Node)) { + if ( + runMenuRef.current && + !runMenuRef.current.contains(event.target as Node) + ) { setRunMenuOpen(false); } }; @@ -213,22 +334,45 @@ export function FloatingActionBar() { +
@@ -293,13 +437,19 @@ export function FloatingActionBar() { title="Run options" > - + )} @@ -314,7 +464,11 @@ export function FloatingActionBar() { }} className="w-full px-3 py-2 text-left text-[11px] font-medium text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100 transition-colors flex items-center gap-2" > - + Run entire workflow @@ -329,8 +483,18 @@ export function FloatingActionBar() { }`} title={!selectedNode ? "Select a single node first" : undefined} > - - + + Run from selected node @@ -344,8 +508,18 @@ export function FloatingActionBar() { }`} title={!selectedNode ? "Select a single node first" : undefined} > - - + + Run selected node only diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 8c8003c..d2eb881 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -1,6 +1,13 @@ "use client"; -import { useCallback, useRef, useState, useEffect, DragEvent, useMemo } from "react"; +import { + useCallback, + useRef, + useState, + useEffect, + DragEvent, + useMemo, +} from "react"; import { ReactFlow, Background, @@ -24,6 +31,7 @@ import { NanoBananaNode, LLMGenerateNode, OutputNode, + GridSplitNode, } from "./nodes"; import { EditableEdge } from "./edges"; import { ConnectionDropMenu, MenuAction } from "./ConnectionDropMenu"; @@ -41,6 +49,7 @@ const nodeTypes: NodeTypes = { nanoBanana: NanoBananaNode, llmGenerate: LLMGenerateNode, output: OutputNode, + gridSplit: GridSplitNode, }; const edgeTypes: EdgeTypes = { @@ -50,12 +59,18 @@ const edgeTypes: EdgeTypes = { // Connection validation rules // - Image handles (green) can only connect to image handles // - Text handles (blue) can only connect to text handles +// - Tile handles from gridSplit can connect to image inputs // - NanoBanana image input accepts multiple connections // - All other inputs accept only one connection const isValidConnection = (connection: Edge | Connection): boolean => { const sourceHandle = connection.sourceHandle; const targetHandle = connection.targetHandle; + // Tile outputs from gridSplit can connect to image inputs + if (sourceHandle?.startsWith("tile-") && targetHandle === "image") { + return true; + } + // Strict type matching: image <-> image, text <-> text if (sourceHandle === "image" && targetHandle !== "image") { return false; @@ -68,7 +83,9 @@ const isValidConnection = (connection: Edge | Connection): boolean => { }; // Define which handles each node type has -const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[] } => { +const getNodeHandles = ( + nodeType: string +): { inputs: string[]; outputs: string[] } => { switch (nodeType) { case "imageInput": return { inputs: [], outputs: ["image"] }; @@ -82,6 +99,8 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[] return { inputs: ["text", "image"], outputs: ["text"] }; case "output": return { inputs: ["image"], outputs: [] }; + case "gridSplit": + return { inputs: ["image"], outputs: ["tile"] }; // Dynamic outputs handled in node default: return { inputs: [], outputs: [] }; } @@ -97,12 +116,27 @@ interface ConnectionDropState { } export function WorkflowCanvas() { - const { nodes, edges, groups, onNodesChange, onEdgesChange, onConnect, addNode, updateNodeData, loadWorkflow, getNodeById, addToGlobalHistory, setNodeGroupId } = - useWorkflowStore(); + const { + nodes, + edges, + groups, + onNodesChange, + onEdgesChange, + onConnect, + addNode, + updateNodeData, + loadWorkflow, + getNodeById, + addToGlobalHistory, + setNodeGroupId, + } = useWorkflowStore(); const { screenToFlowPosition, getViewport } = useReactFlow(); const [isDragOver, setIsDragOver] = useState(false); - const [dropType, setDropType] = useState<"image" | "workflow" | "node" | null>(null); - const [connectionDrop, setConnectionDrop] = useState(null); + const [dropType, setDropType] = useState< + "image" | "workflow" | "node" | null + >(null); + const [connectionDrop, setConnectionDrop] = + useState(null); const [isSplitting, setIsSplitting] = useState(false); const reactFlowWrapper = useRef(null); @@ -111,7 +145,6 @@ export function WorkflowCanvas() { return nodes; }, [nodes]); - // Check if a node was dropped into a group and add it to that group const handleNodeDragStop = useCallback( (_event: React.MouseEvent, node: Node) => { @@ -127,8 +160,12 @@ export function WorkflowCanvas() { let targetGroupId: string | undefined; for (const group of Object.values(groups)) { - const inBoundsX = nodeCenterX >= group.position.x && nodeCenterX <= group.position.x + group.size.width; - const inBoundsY = nodeCenterY >= group.position.y && nodeCenterY <= group.position.y + group.size.height; + const inBoundsX = + nodeCenterX >= group.position.x && + nodeCenterX <= group.position.x + group.size.width; + const inBoundsY = + nodeCenterY >= group.position.y && + nodeCenterY <= group.position.y + group.size.height; if (inBoundsX && inBoundsY) { targetGroupId = group.id; @@ -158,7 +195,11 @@ export function WorkflowCanvas() { // If the source node is selected and there are multiple selected nodes, // connect all selected nodes that have the same source handle type - if (sourceNode?.selected && selectedNodes.length > 1 && connection.sourceHandle) { + if ( + sourceNode?.selected && + selectedNodes.length > 1 && + connection.sourceHandle + ) { selectedNodes.forEach((node) => { // Skip if this is already the connection source if (node.id === connection.source) { @@ -168,7 +209,9 @@ export function WorkflowCanvas() { // Check if this node actually has the same output handle type const nodeHandles = getNodeHandles(node.type || ""); - if (!nodeHandles.outputs.includes(connection.sourceHandle as string)) { + if ( + !nodeHandles.outputs.includes(connection.sourceHandle as string) + ) { // This node doesn't have the same output handle type, skip it return; } @@ -203,7 +246,10 @@ export function WorkflowCanvas() { const { clientX, clientY } = event as MouseEvent; const fromHandleId = connectionState.fromHandle?.id || null; - const fromHandleType = (fromHandleId === "image" || fromHandleId === "text") ? fromHandleId : null; + const fromHandleType = + fromHandleId === "image" || fromHandleId === "text" + ? fromHandleId + : null; const isFromSource = connectionState.fromHandle?.type === "source"; // Check if we dropped on a node by looking for node elements under the cursor @@ -214,10 +260,16 @@ export function WorkflowCanvas() { }); if (nodeElement) { - const nodeWrapper = nodeElement.closest(".react-flow__node") as HTMLElement; + const nodeWrapper = nodeElement.closest( + ".react-flow__node" + ) as HTMLElement; const targetNodeId = nodeWrapper?.dataset.id; - if (targetNodeId && targetNodeId !== connectionState.fromNode.id && fromHandleType) { + if ( + targetNodeId && + targetNodeId !== connectionState.fromNode.id && + fromHandleType + ) { const targetNode = nodes.find((n) => n.id === targetNodeId); if (targetNode) { @@ -291,7 +343,8 @@ export function WorkflowCanvas() { } else if (sourceNode.type === "imageInput") { sourceImage = (sourceNode.data as { image: string | null }).image; } else if (sourceNode.type === "annotation") { - sourceImage = (sourceNode.data as { outputImage: string | null }).outputImage; + sourceImage = (sourceNode.data as { outputImage: string | null }) + .outputImage; } if (!sourceImage) { @@ -299,7 +352,10 @@ export function WorkflowCanvas() { return; } - const sourceNodeData = sourceNode.type === "nanoBanana" ? sourceNode.data as NanoBananaNodeData : null; + const sourceNodeData = + sourceNode.type === "nanoBanana" + ? (sourceNode.data as NanoBananaNodeData) + : null; setIsSplitting(true); try { @@ -323,7 +379,9 @@ export function WorkflowCanvas() { addToGlobalHistory({ image: imageData, timestamp: Date.now() + index, - prompt: `Split ${row + 1}-${col + 1} from ${grid.rows}x${grid.cols} grid`, + prompt: `Split ${row + 1}-${col + 1} from ${grid.rows}x${ + grid.cols + } grid`, aspectRatio: sourceNodeData?.aspectRatio || "1:1", model: sourceNodeData?.model || "nano-banana", }); @@ -351,10 +409,17 @@ export function WorkflowCanvas() { img.src = imageData; }); - console.log(`[SplitGrid] Created ${images.length} nodes from ${grid.rows}x${grid.cols} grid (confidence: ${Math.round(grid.confidence * 100)}%)`); + console.log( + `[SplitGrid] Created ${images.length} nodes from ${grid.rows}x${ + grid.cols + } grid (confidence: ${Math.round(grid.confidence * 100)}%)` + ); } catch (error) { console.error("[SplitGrid] Error:", error); - alert("Failed to split image grid: " + (error instanceof Error ? error.message : "Unknown error")); + alert( + "Failed to split image grid: " + + (error instanceof Error ? error.message : "Unknown error") + ); } finally { setIsSplitting(false); } @@ -363,28 +428,37 @@ export function WorkflowCanvas() { ); // Helper to get image from a node - const getImageFromNode = useCallback((nodeId: string): string | null => { - const node = getNodeById(nodeId); - if (!node) return null; - - switch (node.type) { - case "imageInput": - return (node.data as { image: string | null }).image; - case "annotation": - return (node.data as { outputImage: string | null }).outputImage; - case "nanoBanana": - return (node.data as { outputImage: string | null }).outputImage; - default: - return null; - } - }, [getNodeById]); + const getImageFromNode = useCallback( + (nodeId: string): string | null => { + const node = getNodeById(nodeId); + if (!node) return null; + + switch (node.type) { + case "imageInput": + return (node.data as { image: string | null }).image; + case "annotation": + return (node.data as { outputImage: string | null }).outputImage; + case "nanoBanana": + return (node.data as { outputImage: string | null }).outputImage; + default: + return null; + } + }, + [getNodeById] + ); // Handle node selection from drop menu const handleMenuSelect = useCallback( (selection: { type: NodeType | MenuAction; isAction: boolean }) => { if (!connectionDrop) return; - const { flowPosition, sourceNodeId, sourceHandleId, connectionType, handleType } = connectionDrop; + const { + flowPosition, + sourceNodeId, + sourceHandleId, + connectionType, + handleType, + } = connectionDrop; // Handle actions differently from node creation if (selection.isAction) { @@ -402,7 +476,12 @@ export function WorkflowCanvas() { const newNodeId = addNode(nodeType, flowPosition); // If creating an annotation node from an image source, populate it with the source image - if (nodeType === "annotation" && connectionType === "source" && handleType === "image" && sourceNodeId) { + if ( + nodeType === "annotation" && + connectionType === "source" && + handleType === "image" && + sourceNodeId + ) { const sourceImage = getImageFromNode(sourceNodeId); if (sourceImage) { updateNodeData(newNodeId, { sourceImage, outputImage: sourceImage }); @@ -468,7 +547,12 @@ export function WorkflowCanvas() { }); } else { // Single node connection (original behavior) - if (connectionType === "source" && sourceNodeId && sourceHandleId && targetHandleId) { + if ( + connectionType === "source" && + sourceNodeId && + sourceHandleId && + targetHandleId + ) { // Dragging from source (output), connect to new node's input const connection: Connection = { source: sourceNodeId, @@ -477,7 +561,12 @@ export function WorkflowCanvas() { targetHandle: targetHandleId, }; onConnect(connection); - } else if (connectionType === "target" && sourceNodeId && sourceHandleId && sourceHandleIdForNewNode) { + } else if ( + connectionType === "target" && + sourceNodeId && + sourceHandleId && + sourceHandleIdForNewNode + ) { // Dragging from target (input), connect from new node's output const connection: Connection = { source: newNodeId, @@ -491,7 +580,15 @@ export function WorkflowCanvas() { setConnectionDrop(null); }, - [connectionDrop, addNode, onConnect, nodes, handleSplitGridAction, getImageFromNode, updateNodeData] + [ + connectionDrop, + addNode, + onConnect, + nodes, + handleSplitGridAction, + getImageFromNode, + updateNodeData, + ] ); const handleCloseDropMenu = useCallback(() => { @@ -499,7 +596,8 @@ export function WorkflowCanvas() { }, []); // Get copy/paste functions and clipboard from store - const { copySelectedNodes, pasteNodes, clearClipboard, clipboard } = useWorkflowStore(); + const { copySelectedNodes, pasteNodes, clearClipboard, clipboard } = + useWorkflowStore(); // Keyboard shortcuts for copy/paste and stacking selected nodes useEffect(() => { @@ -554,16 +652,23 @@ export function WorkflowCanvas() { event.preventDefault(); const { centerX, centerY } = getViewportCenter(); // Offset by half the default node dimensions to center it - const defaultDimensions: Record = { + const defaultDimensions: Record< + NodeType, + { width: number; height: number } + > = { imageInput: { width: 300, height: 280 }, annotation: { width: 300, height: 280 }, prompt: { width: 320, height: 220 }, nanoBanana: { width: 300, height: 300 }, llmGenerate: { width: 320, height: 360 }, output: { width: 320, height: 320 }, + gridSplit: { width: 320, height: 400 }, }; const dims = defaultDimensions[nodeType]; - addNode(nodeType, { x: centerX - dims.width / 2, y: centerY - dims.height / 2 }); + addNode(nodeType, { + x: centerX - dims.width / 2, + y: centerY - dims.height / 2, + }); return; } } @@ -580,53 +685,68 @@ export function WorkflowCanvas() { } // Check system clipboard for images first, then text - navigator.clipboard.read().then(async (items) => { - for (const item of items) { - // Check for image - const imageType = item.types.find(type => type.startsWith('image/')); - if (imageType) { - const blob = await item.getType(imageType); - const reader = new FileReader(); - reader.onload = (e) => { - const dataUrl = e.target?.result as string; - const viewport = getViewport(); - const centerX = (-viewport.x + window.innerWidth / 2) / viewport.zoom; - const centerY = (-viewport.y + window.innerHeight / 2) / viewport.zoom; - - const img = new Image(); - img.onload = () => { - // ImageInput node default dimensions: 300x280 - const nodeId = addNode("imageInput", { x: centerX - 150, y: centerY - 140 }); - updateNodeData(nodeId, { - image: dataUrl, - filename: `pasted-${Date.now()}.png`, - dimensions: { width: img.width, height: img.height }, - }); + navigator.clipboard + .read() + .then(async (items) => { + for (const item of items) { + // Check for image + const imageType = item.types.find((type) => + type.startsWith("image/") + ); + if (imageType) { + const blob = await item.getType(imageType); + const reader = new FileReader(); + reader.onload = (e) => { + const dataUrl = e.target?.result as string; + const viewport = getViewport(); + const centerX = + (-viewport.x + window.innerWidth / 2) / viewport.zoom; + const centerY = + (-viewport.y + window.innerHeight / 2) / viewport.zoom; + + const img = new Image(); + img.onload = () => { + // ImageInput node default dimensions: 300x280 + const nodeId = addNode("imageInput", { + x: centerX - 150, + y: centerY - 140, + }); + updateNodeData(nodeId, { + image: dataUrl, + filename: `pasted-${Date.now()}.png`, + dimensions: { width: img.width, height: img.height }, + }); + }; + img.src = dataUrl; }; - img.src = dataUrl; - }; - reader.readAsDataURL(blob); - return; // Exit after handling image - } + reader.readAsDataURL(blob); + return; // Exit after handling image + } - // Check for text - if (item.types.includes('text/plain')) { - const blob = await item.getType('text/plain'); - const text = await blob.text(); - if (text.trim()) { - const viewport = getViewport(); - const centerX = (-viewport.x + window.innerWidth / 2) / viewport.zoom; - const centerY = (-viewport.y + window.innerHeight / 2) / viewport.zoom; - // Prompt node default dimensions: 320x220 - const nodeId = addNode("prompt", { x: centerX - 160, y: centerY - 110 }); - updateNodeData(nodeId, { prompt: text }); - return; // Exit after handling text + // Check for text + if (item.types.includes("text/plain")) { + const blob = await item.getType("text/plain"); + const text = await blob.text(); + if (text.trim()) { + const viewport = getViewport(); + const centerX = + (-viewport.x + window.innerWidth / 2) / viewport.zoom; + const centerY = + (-viewport.y + window.innerHeight / 2) / viewport.zoom; + // Prompt node default dimensions: 320x220 + const nodeId = addNode("prompt", { + x: centerX - 160, + y: centerY - 110, + }); + updateNodeData(nodeId, { prompt: text }); + return; // Exit after handling text + } } } - } - }).catch(() => { - // Clipboard API failed - nothing to paste - }); + }) + .catch(() => { + // Clipboard API failed - nothing to paste + }); return; } @@ -637,7 +757,9 @@ export function WorkflowCanvas() { if (event.key === "v" || event.key === "V") { // Stack vertically - sort by current y position to maintain relative order - const sortedNodes = [...selectedNodes].sort((a, b) => a.position.y - b.position.y); + const sortedNodes = [...selectedNodes].sort( + (a, b) => a.position.y - b.position.y + ); // Use the leftmost x position as the alignment point const alignX = Math.min(...sortedNodes.map((n) => n.position.x)); @@ -645,7 +767,8 @@ export function WorkflowCanvas() { let currentY = sortedNodes[0].position.y; sortedNodes.forEach((node) => { - const nodeHeight = (node.style?.height as number) || (node.measured?.height) || 200; + const nodeHeight = + (node.style?.height as number) || node.measured?.height || 200; onNodesChange([ { @@ -659,7 +782,9 @@ export function WorkflowCanvas() { }); } else if (event.key === "h" || event.key === "H") { // Stack horizontally - sort by current x position to maintain relative order - const sortedNodes = [...selectedNodes].sort((a, b) => a.position.x - b.position.x); + const sortedNodes = [...selectedNodes].sort( + (a, b) => a.position.x - b.position.x + ); // Use the topmost y position as the alignment point const alignY = Math.min(...sortedNodes.map((n) => n.position.y)); @@ -667,7 +792,8 @@ export function WorkflowCanvas() { let currentX = sortedNodes[0].position.x; sortedNodes.forEach((node) => { - const nodeWidth = (node.style?.width as number) || (node.measured?.width) || 220; + const nodeWidth = + (node.style?.width as number) || node.measured?.width || 220; onNodesChange([ { @@ -698,10 +824,14 @@ export function WorkflowCanvas() { // Get max node dimensions for consistent spacing const maxWidth = Math.max( - ...sortedNodes.map((n) => (n.style?.width as number) || (n.measured?.width) || 220) + ...sortedNodes.map( + (n) => (n.style?.width as number) || n.measured?.width || 220 + ) ); const maxHeight = Math.max( - ...sortedNodes.map((n) => (n.style?.height as number) || (n.measured?.height) || 200) + ...sortedNodes.map( + (n) => (n.style?.height as number) || n.measured?.height || 200 + ) ); // Position each node in the grid @@ -725,14 +855,26 @@ export function WorkflowCanvas() { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [nodes, onNodesChange, copySelectedNodes, pasteNodes, clearClipboard, clipboard, getViewport, addNode, updateNodeData]); + }, [ + nodes, + onNodesChange, + copySelectedNodes, + pasteNodes, + clearClipboard, + clipboard, + getViewport, + addNode, + updateNodeData, + ]); const handleDragOver = useCallback((event: DragEvent) => { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; // Check if dragging a node type from the action bar - const hasNodeType = Array.from(event.dataTransfer.types).includes("application/node-type"); + const hasNodeType = Array.from(event.dataTransfer.types).includes( + "application/node-type" + ); if (hasNodeType) { setIsDragOver(true); setDropType("node"); @@ -740,7 +882,9 @@ export function WorkflowCanvas() { } // Check if dragging a history image - const hasHistoryImage = Array.from(event.dataTransfer.types).includes("application/history-image"); + const hasHistoryImage = Array.from(event.dataTransfer.types).includes( + "application/history-image" + ); if (hasHistoryImage) { setIsDragOver(true); setDropType("image"); @@ -778,7 +922,9 @@ export function WorkflowCanvas() { setDropType(null); // Check for node type drop from action bar - const nodeType = event.dataTransfer.getData("application/node-type") as NodeType; + const nodeType = event.dataTransfer.getData( + "application/node-type" + ) as NodeType; if (nodeType) { const position = screenToFlowPosition({ x: event.clientX, @@ -789,7 +935,9 @@ export function WorkflowCanvas() { } // Check for history image drop - const historyImageData = event.dataTransfer.getData("application/history-image"); + const historyImageData = event.dataTransfer.getData( + "application/history-image" + ); if (historyImageData) { try { const { image, prompt } = JSON.parse(historyImageData); @@ -820,13 +968,18 @@ export function WorkflowCanvas() { const allFiles = Array.from(event.dataTransfer.files); // Check for JSON workflow files first - const jsonFiles = allFiles.filter((file) => file.type === "application/json" || file.name.endsWith(".json")); + const jsonFiles = allFiles.filter( + (file) => + file.type === "application/json" || file.name.endsWith(".json") + ); if (jsonFiles.length > 0) { const file = jsonFiles[0]; const reader = new FileReader(); reader.onload = (e) => { try { - const workflow = JSON.parse(e.target?.result as string) as WorkflowFile; + const workflow = JSON.parse( + e.target?.result as string + ) as WorkflowFile; if (workflow.version && workflow.nodes && workflow.edges) { loadWorkflow(workflow); } else { @@ -841,7 +994,9 @@ export function WorkflowCanvas() { } // Handle image files - const imageFiles = allFiles.filter((file) => file.type.startsWith("image/")); + const imageFiles = allFiles.filter((file) => + file.type.startsWith("image/") + ); if (imageFiles.length === 0) return; // Get the drop position in flow coordinates @@ -883,7 +1038,9 @@ export function WorkflowCanvas() { return (
-

Splitting image grid...

+

+ Splitting image grid... +

)} @@ -958,6 +1117,8 @@ export function WorkflowCanvas() { return "#06b6d4"; case "output": return "#ef4444"; + case "gridSplit": + return "#a855f7"; default: return "#94a3b8"; } diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index 550a0a5..2e4066a 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -14,6 +14,8 @@ interface BaseNodeProps { className?: string; minWidth?: number; minHeight?: number; + status?: string; + icon?: ReactNode; } export function BaseNode({ @@ -35,7 +37,9 @@ export function BaseNode({ const handleResize: OnResize = useCallback( (event, params) => { const allNodes = getNodes(); - const selectedNodes = allNodes.filter((node) => node.selected && node.id !== id); + const selectedNodes = allNodes.filter( + (node) => node.selected && node.id !== id + ); if (selectedNodes.length > 0) { // Apply the same dimensions to all other selected nodes by updating their style @@ -71,17 +75,26 @@ export function BaseNode({ />
- {title} + + {title} + +
+
+ {children}
-
{children}
); diff --git a/src/components/nodes/GridSplitNode.tsx b/src/components/nodes/GridSplitNode.tsx new file mode 100644 index 0000000..3153b0f --- /dev/null +++ b/src/components/nodes/GridSplitNode.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { Handle, Position, NodeProps, Node } from "@xyflow/react"; +import { BaseNode } from "./BaseNode"; +import { useWorkflowStore } from "@/store/workflowStore"; +import { GridSplitNodeData } from "@/types"; + +type GridSplitNodeType = Node; + +export function GridSplitNode({ + id, + data, + selected, +}: NodeProps) { + const nodeData = data; + const updateNodeData = useWorkflowStore((state) => state.updateNodeData); + + const handleRowsChange = useCallback( + (e: React.ChangeEvent) => { + const value = parseInt(e.target.value, 10); + if (!isNaN(value) && value >= 1 && value <= 10) { + updateNodeData(id, { rows: value, tileOutputs: {} }); + } + }, + [id, updateNodeData] + ); + + const handleColumnsChange = useCallback( + (e: React.ChangeEvent) => { + const value = parseInt(e.target.value, 10); + if (!isNaN(value) && value >= 1 && value <= 10) { + updateNodeData(id, { columns: value, tileOutputs: {} }); + } + }, + [id, updateNodeData] + ); + + const tileCount = nodeData.rows * nodeData.columns; + const exceedsMax = tileCount > 64; + + // Generate tile handles + const tileHandles = useMemo(() => { + const handles = []; + for (let row = 0; row < nodeData.rows; row++) { + for (let col = 0; col < nodeData.columns; col++) { + const index = row * nodeData.columns + col; + const handleId = `tile-${index}`; + const label = `r${row + 1}c${col + 1}`; + + handles.push({ + handleId, + label, + index, + }); + } + } + return handles; + }, [nodeData.rows, nodeData.columns]); + + const statusColor = { + idle: "text-gray-400", + ready: "text-blue-400", + loading: "text-yellow-400", + complete: "text-green-400", + error: "text-red-400", + }[nodeData.status]; + + return ( + + {/* Input handle */} + + +
+ {/* Controls */} +
+
+ + +
+
+ + +
+
+ + {/* Tile count info */} +
+ + {tileCount} tile{tileCount !== 1 ? "s" : ""} + + {nodeData.status} +
+ + {exceedsMax && ( +
+ Max 64 tiles exceeded +
+ )} + + {/* Preview */} + {nodeData.inputImage && ( +
+ Preview + {/* Grid overlay */} + + {/* Vertical lines */} + {Array.from({ length: nodeData.columns - 1 }).map((_, i) => { + const x = ((i + 1) / nodeData.columns) * 100; + return ( + + ); + })} + {/* Horizontal lines */} + {Array.from({ length: nodeData.rows - 1 }).map((_, i) => { + const y = ((i + 1) / nodeData.rows) * 100; + return ( + + ); + })} + +
+ )} + + {!nodeData.inputImage && ( +
+ Connect an image input +
+ )} + + {nodeData.error && ( +
+ {nodeData.error} +
+ )} +
+ + {/* Output handles - dynamic based on grid size */} + {tileHandles.map((tile, idx) => { + // Distribute handles evenly across the node height + // Start at 15% and end at 85% of node height to avoid edges + const percentage = + tileCount === 1 ? 50 : 15 + (idx / (tileCount - 1)) * 70; + const label = tileCount <= 16 ? tile.label : `t${tile.index}`; + + return ( + + + {label} + + + ); + })} +
+ ); +} diff --git a/src/components/nodes/index.ts b/src/components/nodes/index.ts index 6de333f..08fa08e 100644 --- a/src/components/nodes/index.ts +++ b/src/components/nodes/index.ts @@ -5,3 +5,4 @@ export { NanoBananaNode } from "./NanoBananaNode"; export { LLMGenerateNode } from "./LLMGenerateNode"; export { OutputNode } from "./OutputNode"; export { GroupNode } from "./GroupNode"; +export { GridSplitNode } from "./GridSplitNode"; diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 51a95ea..1f1f250 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -18,6 +18,7 @@ import { NanoBananaNodeData, LLMGenerateNodeData, OutputNodeData, + GridSplitNodeData, WorkflowNodeData, ImageHistoryItem, WorkflowSaveConfig, @@ -31,12 +32,12 @@ export type EdgeStyle = "angular" | "curved"; // Workflow file format export interface WorkflowFile { version: 1; - id?: string; // Optional for backward compatibility with old/shared workflows + id?: string; // Optional for backward compatibility with old/shared workflows name: string; nodes: WorkflowNode[]; edges: WorkflowEdge[]; edgeStyle: EdgeStyle; - groups?: Record; // Optional for backward compatibility + groups?: Record; // Optional for backward compatibility } // Clipboard data structure for copy/paste @@ -96,7 +97,10 @@ interface WorkflowStore { // Helpers getNodeById: (id: string) => WorkflowNode | undefined; - getConnectedInputs: (nodeId: string) => { images: string[]; text: string | null }; + getConnectedInputs: (nodeId: string) => { + images: string[]; + text: string | null; + }; validateWorkflow: () => { valid: boolean; errors: string[] }; // Global Image History @@ -115,7 +119,12 @@ interface WorkflowStore { isSaving: boolean; // Auto-save actions - setWorkflowMetadata: (id: string, name: string, path: string, generationsPath: string | null) => void; + setWorkflowMetadata: ( + id: string, + name: string, + path: string, + generationsPath: string | null + ) => void; setWorkflowName: (name: string) => void; setGenerationsPath: (path: string | null) => void; setAutoSaveEnabled: (enabled: boolean) => void; @@ -170,6 +179,15 @@ const createDefaultNodeData = (type: NodeType): WorkflowNodeData => { return { image: null, } as OutputNodeData; + case "gridSplit": + return { + inputImage: null, + rows: 2, + columns: 2, + status: "idle", + error: null, + tileOutputs: {}, + } as GridSplitNodeData; } }; @@ -188,7 +206,12 @@ export const GROUP_COLORS: Record = { }; const GROUP_COLOR_ORDER: GroupColor[] = [ - "neutral", "blue", "green", "purple", "orange", "red" + "neutral", + "blue", + "green", + "purple", + "orange", + "red", ]; // localStorage helpers for auto-save configs @@ -241,13 +264,17 @@ export const useWorkflowStore = create((set, get) => ({ const id = `${type}-${++nodeIdCounter}`; // Default dimensions based on node type - const defaultDimensions: Record = { + const defaultDimensions: Record< + NodeType, + { width: number; height: number } + > = { imageInput: { width: 300, height: 280 }, annotation: { width: 300, height: 280 }, prompt: { width: 320, height: 220 }, nanoBanana: { width: 300, height: 300 }, llmGenerate: { width: 320, height: 360 }, output: { width: 320, height: 320 }, + gridSplit: { width: 320, height: 400 }, }; const { width, height } = defaultDimensions[type]; @@ -314,7 +341,9 @@ export const useWorkflowStore = create((set, get) => ({ edges: addEdge( { ...connection, - id: `edge-${connection.source}-${connection.target}-${connection.sourceHandle || "default"}-${connection.targetHandle || "default"}`, + id: `edge-${connection.source}-${connection.target}-${ + connection.sourceHandle || "default" + }-${connection.targetHandle || "default"}`, }, state.edges ), @@ -350,12 +379,17 @@ export const useWorkflowStore = create((set, get) => ({ // Copy edges that connect selected nodes to each other const connectedEdges = edges.filter( - (edge) => selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target) + (edge) => + selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target) ); // Deep clone the nodes and edges to avoid reference issues - const clonedNodes = JSON.parse(JSON.stringify(selectedNodes)) as WorkflowNode[]; - const clonedEdges = JSON.parse(JSON.stringify(connectedEdges)) as WorkflowEdge[]; + const clonedNodes = JSON.parse( + JSON.stringify(selectedNodes) + ) as WorkflowNode[]; + const clonedEdges = JSON.parse( + JSON.stringify(connectedEdges) + ) as WorkflowEdge[]; set({ clipboard: { nodes: clonedNodes, edges: clonedEdges } }); }, @@ -389,7 +423,9 @@ export const useWorkflowStore = create((set, get) => ({ // Create new edges with updated source/target IDs const newEdges: WorkflowEdge[] = clipboard.edges.map((edge) => ({ ...edge, - id: `edge-${idMapping.get(edge.source)}-${idMapping.get(edge.target)}-${edge.sourceHandle || "default"}-${edge.targetHandle || "default"}`, + id: `edge-${idMapping.get(edge.source)}-${idMapping.get(edge.target)}-${ + edge.sourceHandle || "default" + }-${edge.targetHandle || "default"}`, source: idMapping.get(edge.source)!, target: idMapping.get(edge.target)!, })); @@ -422,7 +458,10 @@ export const useWorkflowStore = create((set, get) => ({ if (nodesToGroup.length === 0) return ""; // Default dimensions per node type - const defaultNodeDimensions: Record = { + const defaultNodeDimensions: Record< + string, + { width: number; height: number } + > = { imageInput: { width: 300, height: 280 }, annotation: { width: 300, height: 280 }, prompt: { width: 320, height: 220 }, @@ -432,12 +471,22 @@ export const useWorkflowStore = create((set, get) => ({ }; // Calculate bounding box of selected nodes - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; nodesToGroup.forEach((node) => { // Use measured dimensions (actual rendered size) first, then style, then type-specific defaults - const defaults = defaultNodeDimensions[node.type] || { width: 300, height: 280 }; - const width = node.measured?.width || (node.style?.width as number) || defaults.width; - const height = node.measured?.height || (node.style?.height as number) || defaults.height; + const defaults = defaultNodeDimensions[node.type] || { + width: 300, + height: 280, + }; + const width = + node.measured?.width || (node.style?.width as number) || defaults.width; + const height = + node.measured?.height || + (node.style?.height as number) || + defaults.height; minX = Math.min(minX, node.position.x); minY = Math.min(minY, node.position.y); @@ -470,7 +519,7 @@ export const useWorkflowStore = create((set, get) => ({ color, position: { x: minX - padding, - y: minY - padding - headerHeight + y: minY - padding - headerHeight, }, size: { width: maxX - minX + padding * 2, @@ -573,6 +622,7 @@ export const useWorkflowStore = create((set, get) => ({ if (!sourceNode) return; const handleId = edge.targetHandle; + const sourceHandle = edge.sourceHandle; if (handleId === "image" || !handleId) { // Get image from source node - collect all connected images @@ -580,11 +630,25 @@ export const useWorkflowStore = create((set, get) => ({ const sourceImage = (sourceNode.data as ImageInputNodeData).image; if (sourceImage) images.push(sourceImage); } else if (sourceNode.type === "annotation") { - const sourceImage = (sourceNode.data as AnnotationNodeData).outputImage; + const sourceImage = (sourceNode.data as AnnotationNodeData) + .outputImage; if (sourceImage) images.push(sourceImage); } else if (sourceNode.type === "nanoBanana") { - const sourceImage = (sourceNode.data as NanoBananaNodeData).outputImage; + const sourceImage = (sourceNode.data as NanoBananaNodeData) + .outputImage; if (sourceImage) images.push(sourceImage); + } else if (sourceNode.type === "gridSplit") { + // Get specific tile from gridSplit node + const tileOutputs = (sourceNode.data as GridSplitNodeData) + .tileOutputs; + console.log("[getConnectedInputs] GridSplit tile request:", { + sourceHandle, + availableTiles: Object.keys(tileOutputs), + hasTile: !!(sourceHandle && tileOutputs[sourceHandle]), + }); + if (sourceHandle && tileOutputs[sourceHandle]) { + images.push(tileOutputs[sourceHandle]); + } } } @@ -634,7 +698,8 @@ export const useWorkflowStore = create((set, get) => ({ .filter((n) => n.type === "annotation") .forEach((node) => { const imageConnected = edges.some((e) => e.target === node.id); - const hasManualImage = (node.data as AnnotationNodeData).sourceImage !== null; + const hasManualImage = + (node.data as AnnotationNodeData).sourceImage !== null; if (!imageConnected && !hasManualImage) { errors.push(`Annotation node "${node.id}" missing image input`); } @@ -650,11 +715,29 @@ export const useWorkflowStore = create((set, get) => ({ } }); + // Check grid split nodes have image input and valid configuration + nodes + .filter((n) => n.type === "gridSplit") + .forEach((node) => { + const imageConnected = edges.some((e) => e.target === node.id); + if (!imageConnected) { + errors.push(`Grid Split node "${node.id}" missing image input`); + } + + const nodeData = node.data as GridSplitNodeData; + if (nodeData.rows * nodeData.columns > 64) { + errors.push( + `Grid Split node "${node.id}" exceeds max tile count (64)` + ); + } + }); + return { valid: errors.length === 0, errors }; }, executeWorkflow: async (startFromNodeId?: string) => { - const { nodes, edges, updateNodeData, getConnectedInputs, isRunning } = get(); + const { nodes, edges, updateNodeData, getConnectedInputs, isRunning } = + get(); if (isRunning) { return; @@ -677,9 +760,7 @@ export const useWorkflowStore = create((set, get) => ({ visiting.add(nodeId); // Visit all nodes that this node depends on - edges - .filter((e) => e.target === nodeId) - .forEach((e) => visit(e.source)); + edges.filter((e) => e.target === nodeId).forEach((e) => visit(e.source)); visiting.delete(nodeId); visited.add(nodeId); @@ -711,8 +792,14 @@ export const useWorkflowStore = create((set, get) => ({ const incomingEdges = edges.filter((e) => e.target === node.id); const pauseEdge = incomingEdges.find((e) => e.data?.hasPause); if (pauseEdge) { - set({ pausedAtNodeId: node.id, isRunning: false, currentNodeId: null }); - useToast.getState().show("Workflow paused - click Run to continue", "warning"); + set({ + pausedAtNodeId: node.id, + isRunning: false, + currentNodeId: null, + }); + useToast + .getState() + .show("Workflow paused - click Run to continue", "warning"); return; } } @@ -789,7 +876,8 @@ export const useWorkflowStore = create((set, get) => ({ const errorJson = JSON.parse(errorText); errorMessage = errorJson.error || errorMessage; } catch { - if (errorText) errorMessage += ` - ${errorText.substring(0, 200)}`; + if (errorText) + errorMessage += ` - ${errorText.substring(0, 200)}`; } updateNodeData(node.id, { @@ -842,10 +930,18 @@ export const useWorkflowStore = create((set, get) => ({ } } catch (error) { let errorMessage = "Generation failed"; - if (error instanceof DOMException && error.name === 'AbortError') { - errorMessage = "Request timed out. Try reducing image sizes or using a simpler prompt."; - } else if (error instanceof TypeError && error.message.includes('NetworkError')) { - errorMessage = "Network error. Check your connection and try again."; + if ( + error instanceof DOMException && + error.name === "AbortError" + ) { + errorMessage = + "Request timed out. Try reducing image sizes or using a simpler prompt."; + } else if ( + error instanceof TypeError && + error.message.includes("NetworkError") + ) { + errorMessage = + "Network error. Check your connection and try again."; } else if (error instanceof TypeError) { errorMessage = `Network error: ${error.message}`; } else if (error instanceof Error) { @@ -901,7 +997,8 @@ export const useWorkflowStore = create((set, get) => ({ const errorJson = JSON.parse(errorText); errorMessage = errorJson.error || errorMessage; } catch { - if (errorText) errorMessage += ` - ${errorText.substring(0, 200)}`; + if (errorText) + errorMessage += ` - ${errorText.substring(0, 200)}`; } updateNodeData(node.id, { status: "error", @@ -930,7 +1027,10 @@ export const useWorkflowStore = create((set, get) => ({ } catch (error) { updateNodeData(node.id, { status: "error", - error: error instanceof Error ? error.message : "LLM generation failed", + error: + error instanceof Error + ? error.message + : "LLM generation failed", }); set({ isRunning: false, currentNodeId: null }); return; @@ -946,6 +1046,94 @@ export const useWorkflowStore = create((set, get) => ({ } break; } + + case "gridSplit": { + const { images } = getConnectedInputs(node.id); + const image = images[0] || null; + + console.log("[GridSplit] Executing:", { + nodeId: node.id, + hasImage: !!image, + imageLength: image?.length, + }); + + if (!image) { + console.error("[GridSplit] No image input found"); + updateNodeData(node.id, { + status: "error", + error: "Missing image input", + inputImage: null, + tileOutputs: {}, + }); + break; + } + + const nodeData = node.data as GridSplitNodeData; + const { rows, columns } = nodeData; + + console.log("[GridSplit] Configuration:", { + rows, + columns, + totalTiles: rows * columns, + }); + + // Validate tile count + if (rows * columns > 64) { + updateNodeData(node.id, { + status: "error", + error: "Too many tiles (max 64)", + inputImage: image, + tileOutputs: {}, + }); + break; + } + + updateNodeData(node.id, { + inputImage: image, + status: "loading", + error: null, + tileOutputs: {}, + }); + + try { + const { splitImageIntoTiles } = await import( + "@/utils/imageSplitter" + ); + const tiles = await splitImageIntoTiles(image, rows, columns); + + console.log( + "[GridSplit] Split complete, tiles created:", + tiles.length + ); + + // Build tileOutputs map + const tileOutputs: Record = {}; + tiles.forEach((tile) => { + tileOutputs[tile.handleId] = tile.imageBase64; + }); + + console.log( + "[GridSplit] Tile output handles:", + Object.keys(tileOutputs) + ); + + updateNodeData(node.id, { + status: "complete", + tileOutputs, + }); + } catch (error) { + console.error("[GridSplit] Split failed:", error); + updateNodeData(node.id, { + status: "error", + error: + error instanceof Error + ? error.message + : "Failed to split image", + tileOutputs: {}, + }); + } + break; + } } } @@ -979,7 +1167,8 @@ export const useWorkflowStore = create((set, get) => ({ // Always get fresh connected inputs first, fall back to stored inputs only if not connected const inputs = getConnectedInputs(nodeId); - let images = inputs.images.length > 0 ? inputs.images : nodeData.inputImages; + let images = + inputs.images.length > 0 ? inputs.images : nodeData.inputImages; let text = inputs.text ?? nodeData.inputPrompt; if (!images || images.length === 0 || !text) { @@ -1120,6 +1309,91 @@ export const useWorkflowStore = create((set, get) => ({ error: result.error || "LLM generation failed", }); } + } else if (node.type === "gridSplit") { + const nodeData = node.data as GridSplitNodeData; + + // Get fresh connected input + const inputs = getConnectedInputs(nodeId); + const image = inputs.images[0] || null; + + console.log("[GridSplit Regenerate] Executing:", { + nodeId, + hasImage: !!image, + }); + + if (!image) { + console.error("[GridSplit Regenerate] No image input found"); + updateNodeData(nodeId, { + status: "error", + error: "Missing image input", + tileOutputs: {}, + }); + set({ isRunning: false, currentNodeId: null }); + return; + } + + const { rows, columns } = nodeData; + + if (rows * columns > 64) { + updateNodeData(nodeId, { + status: "error", + error: "Too many tiles (max 64)", + tileOutputs: {}, + }); + set({ isRunning: false, currentNodeId: null }); + return; + } + + updateNodeData(nodeId, { + inputImage: image, + status: "loading", + error: null, + tileOutputs: {}, + }); + + try { + const { splitImageIntoTiles } = await import("@/utils/imageSplitter"); + const tiles = await splitImageIntoTiles(image, rows, columns); + + console.log( + "[GridSplit Regenerate] Split complete, tiles created:", + tiles.length + ); + + const tileOutputs: Record = {}; + tiles.forEach((tile) => { + tileOutputs[tile.handleId] = tile.imageBase64; + }); + + updateNodeData(nodeId, { + status: "complete", + tileOutputs, + }); + } catch (error) { + console.error("[GridSplit Regenerate] Split failed:", error); + updateNodeData(nodeId, { + status: "error", + error: + error instanceof Error ? error.message : "Failed to split image", + tileOutputs: {}, + }); + } + } + + // After regenerating, update immediate downstream output nodes + const { edges, nodes: allNodes } = get(); + const downstreamEdges = edges.filter((e) => e.source === nodeId); + + for (const edge of downstreamEdges) { + const downstreamNode = allNodes.find((n) => n.id === edge.target); + if (downstreamNode?.type === "output") { + // Update output nodes with connected inputs + const { images } = getConnectedInputs(downstreamNode.id); + const image = images[0] || null; + if (image) { + updateNodeData(downstreamNode.id, { image }); + } + } } set({ isRunning: false, currentNodeId: null }); @@ -1232,7 +1506,12 @@ export const useWorkflowStore = create((set, get) => ({ }, // Auto-save actions - setWorkflowMetadata: (id: string, name: string, path: string, generationsPath: string | null) => { + setWorkflowMetadata: ( + id: string, + name: string, + path: string, + generationsPath: string | null + ) => { set({ workflowId: id, workflowName: name, @@ -1330,7 +1609,9 @@ export const useWorkflowStore = create((set, get) => ({ useToast .getState() .show( - `Auto-save failed: ${error instanceof Error ? error.message : "Unknown error"}`, + `Auto-save failed: ${ + error instanceof Error ? error.message : "Unknown error" + }`, "error" ); return false; diff --git a/src/types/index.ts b/src/types/index.ts index faaea67..bdac878 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,10 +7,21 @@ export type NodeType = | "prompt" | "nanoBanana" | "llmGenerate" - | "output"; + | "output" + | "gridSplit"; // Aspect Ratios (supported by both Nano Banana and Nano Banana Pro) -export type AspectRatio = "1:1" | "2:3" | "3:2" | "3:4" | "4:3" | "4:5" | "5:4" | "9:16" | "16:9" | "21:9"; +export type AspectRatio = + | "1:1" + | "2:3" + | "3:2" + | "3:4" + | "4:3" + | "4:5" + | "5:4" + | "9:16" + | "16:9" + | "21:9"; // Resolution Options (only supported by Nano Banana Pro) export type Resolution = "1K" | "2K" | "4K"; @@ -109,9 +120,9 @@ export interface PromptNodeData extends BaseNodeData { // Image History Item (for tracking generated images) export interface ImageHistoryItem { id: string; - image: string; // Base64 data URL - timestamp: number; // For display & sorting - prompt: string; // The prompt used + image: string; // Base64 data URL + timestamp: number; // For display & sorting + prompt: string; // The prompt used aspectRatio: AspectRatio; model: ModelType; } @@ -146,6 +157,16 @@ export interface OutputNodeData extends BaseNodeData { image: string | null; } +// Grid Split Node Data +export interface GridSplitNodeData extends BaseNodeData { + inputImage: string | null; + rows: number; + columns: number; + status: NodeStatus; + error: string | null; + tileOutputs: Record; // handleId -> base64 image +} + // Union of all node data types export type WorkflowNodeData = | ImageInputNodeData @@ -153,7 +174,8 @@ export type WorkflowNodeData = | PromptNodeData | NanoBananaNodeData | LLMGenerateNodeData - | OutputNodeData; + | OutputNodeData + | GridSplitNodeData; // Workflow Node with typed data (extended with optional groupId) export type WorkflowNode = Node & { @@ -203,7 +225,13 @@ export interface LLMGenerateResponse { } // Tool Types for annotation -export type ToolType = "select" | "rectangle" | "circle" | "arrow" | "freehand" | "text"; +export type ToolType = + | "select" + | "rectangle" + | "circle" + | "arrow" + | "freehand" + | "text"; // Tool Options export interface ToolOptions { diff --git a/src/utils/imageSplitter.ts b/src/utils/imageSplitter.ts new file mode 100644 index 0000000..7f6e985 --- /dev/null +++ b/src/utils/imageSplitter.ts @@ -0,0 +1,108 @@ +/** + * Split an image into a grid of tiles using Canvas API + * Returns tiles in row-major order (left to right, top to bottom) + */ + +export interface TileMetadata { + row: number; + col: number; + index: number; + crop: { x: number; y: number; width: number; height: number }; + sourceWidth: number; + sourceHeight: number; +} + +export interface TileOutput { + handleId: string; + imageBase64: string; + metadata: TileMetadata; +} + +export async function splitImageIntoTiles( + imageBase64: string, + rows: number, + columns: number +): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + + img.onload = () => { + try { + const { width: W, height: H } = img; + + // Calculate base tile dimensions + const tw = Math.floor(W / columns); + const th = Math.floor(H / rows); + + // Calculate remainders + const rw = W - tw * columns; + const rh = H - th * rows; + + const tiles: TileOutput[] = []; + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < columns; col++) { + const index = row * columns + col; + + // Calculate tile position and dimensions + const x = col * tw; + const y = row * th; + + // Last column/row gets extra pixels from remainder + const tileWidth = col === columns - 1 ? tw + rw : tw; + const tileHeight = row === rows - 1 ? th + rh : th; + + // Create canvas for this tile + const canvas = document.createElement("canvas"); + canvas.width = tileWidth; + canvas.height = tileHeight; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + throw new Error("Failed to get 2D context"); + } + + // Draw the tile portion of the source image + ctx.drawImage( + img, + x, + y, + tileWidth, + tileHeight, // source rect + 0, + 0, + tileWidth, + tileHeight // dest rect + ); + + // Convert to base64 PNG + const tileBase64 = canvas.toDataURL("image/png"); + + tiles.push({ + handleId: `tile-${index}`, + imageBase64: tileBase64, + metadata: { + row, + col, + index, + crop: { x, y, width: tileWidth, height: tileHeight }, + sourceWidth: W, + sourceHeight: H, + }, + }); + } + } + + resolve(tiles); + } catch (error) { + reject(error); + } + }; + + img.onerror = () => { + reject(new Error("Failed to load image")); + }; + + img.src = imageBase64; + }); +}